diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/query-timeseries.schema.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/query-timeseries.schema.ts index c7c2c612b3..2c4b26c0dc 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/query-timeseries.schema.ts +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/query-timeseries.schema.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { filterOperatorEnum } from "../../filters.schema"; +import { ratelimitFilterOperatorEnum } from "../../filters.schema"; export const ratelimitQueryTimeseriesPayload = z.object({ startTime: z.number().int(), @@ -10,7 +10,7 @@ export const ratelimitQueryTimeseriesPayload = z.object({ .object({ filters: z.array( z.object({ - operator: filterOperatorEnum, + operator: ratelimitFilterOperatorEnum, value: z.string(), }), ), diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/control-cloud/index.tsx index 2b8c966eb5..2d1df0acda 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/control-cloud/index.tsx @@ -6,7 +6,7 @@ import { XMark } from "@unkey/icons"; import { Button } from "@unkey/ui"; import { format } from "date-fns"; import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; -import type { FilterValue } from "../../filters.type"; +import type { RatelimitFilterValue } from "../../filters.schema"; import { useFilters } from "../../hooks/use-filters"; import { HISTORICAL_DATA_WINDOW } from "../table/hooks/use-logs-query"; @@ -44,7 +44,7 @@ const formatOperator = (operator: string, field: string): string => { }; type ControlPillProps = { - filter: FilterValue; + filter: RatelimitFilterValue; onRemove: (id: string) => void; isFocused?: boolean; onFocus?: () => void; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx index 21e75e0b9a..b95f720d5a 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx @@ -2,7 +2,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { Button } from "@unkey/ui"; import { useCallback } from "react"; -import type { FilterValue } from "../../../../../filters.type"; +import type { RatelimitFilterValue } from "../../../../../filters.schema"; import { useFilters } from "../../../../../hooks/use-filters"; import { useCheckboxState } from "./hooks/use-checkbox-state"; @@ -21,7 +21,7 @@ interface BaseCheckboxFilterProps { scrollContainerRef?: React.RefObject; renderBottomGradient?: () => React.ReactNode; renderOptionContent?: (option: TCheckbox) => React.ReactNode; - createFilterValue: (option: TCheckbox) => Pick; + createFilterValue: (option: TCheckbox) => Pick; } export const FilterCheckbox = ({ @@ -47,7 +47,7 @@ export const FilterCheckbox = ({ const selectedValues = checkboxes.filter((c) => c.checked).map((c) => createFilterValue(c)); const otherFilters = filters.filter((f) => f.field !== filterField); - const newFilters: FilterValue[] = selectedValues.map((filterValue) => ({ + const newFilters: RatelimitFilterValue[] = selectedValues.map((filterValue) => ({ id: crypto.randomUUID(), field: filterField, operator: "is", diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts index a1821098da..f85fb11110 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts @@ -1,9 +1,9 @@ -import type { FilterValue } from "@/app/(app)/ratelimits/[namespaceId]/logs/filters.type"; +import type { RatelimitFilterValue } from "@/app/(app)/ratelimits/[namespaceId]/logs/filters.schema"; import { useEffect, useState } from "react"; type UseCheckboxStateProps = { options: Array<{ id: number } & TItem>; - filters: FilterValue[]; + filters: RatelimitFilterValue[]; filterField: string; checkPath: keyof TItem; // Specify which field to get from checkbox item shouldSyncWithOptions?: boolean; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/identifiers-filter.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/identifiers-filter.tsx index 61fc9925e9..69bcba0cbe 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/identifiers-filter.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/identifiers-filter.tsx @@ -3,7 +3,7 @@ import { trpc } from "@/lib/trpc/client"; import { Button } from "@unkey/ui"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRatelimitLogsContext } from "../../../../../context/logs"; -import type { FilterValue } from "../../../../../filters.type"; +import type { RatelimitFilterValue } from "../../../../../filters.schema"; import { useFilters } from "../../../../../hooks/use-filters"; import { useCheckboxState } from "./hooks/use-checkbox-state"; @@ -75,7 +75,7 @@ export const IdentifiersFilter = () => { // Keep all non-paths filters and add new path filters const otherFilters = filters.filter((f) => f.field !== "identifiers"); - const identifiersFilters: FilterValue[] = selectedPaths.map((path) => ({ + const identifiersFilters: RatelimitFilterValue[] = selectedPaths.map((path) => ({ id: crypto.randomUUID(), field: "identifiers", operator: "is", diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-search/index.tsx index bb8c0120b0..f398df9132 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-search/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-search/index.tsx @@ -1,3 +1,4 @@ +import { transformStructuredOutputToFilters } from "@/components/logs/validation/utils/transform-structured-output-filter-format"; import { toast } from "@/components/ui/toaster"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { trpc } from "@/lib/trpc/client"; @@ -5,7 +6,6 @@ import { cn } from "@/lib/utils"; import { CaretRightOutline, CircleInfoSparkle, Magnifier, Refresh3 } from "@unkey/icons"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "components/ui/tooltip"; import { useRef, useState } from "react"; -import { transformStructuredOutputToFilters } from "../../../../filters.schema"; import { useFilters } from "../../../../hooks/use-filters"; export const LogsSearch = () => { diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/query-logs.schema.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/query-logs.schema.ts index e861b7bfba..dbeef0ae95 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/query-logs.schema.ts +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/query-logs.schema.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { filterOperatorEnum } from "../../filters.schema"; +import { ratelimitFilterOperatorEnum } from "../../filters.schema"; export const ratelimitQueryLogsPayload = z.object({ limit: z.number().int(), @@ -11,7 +11,7 @@ export const ratelimitQueryLogsPayload = z.object({ .object({ filters: z.array( z.object({ - operator: filterOperatorEnum, + operator: ratelimitFilterOperatorEnum, value: z.string(), }), ), diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filters.schema.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filters.schema.ts index 8eef09fae6..85b1ccaf88 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filters.schema.ts +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filters.schema.ts @@ -1,115 +1,13 @@ -import { z } from "zod"; import type { - FieldConfig, - FilterField, - FilterFieldConfigs, FilterValue, NumberConfig, StringConfig, -} from "./filters.type"; - -export const filterOperatorEnum = z.enum(["is", "contains"]); - -export const filterFieldEnum = z.enum([ - "startTime", - "endTime", - "since", - "identifiers", - "requestIds", - "status", -]); - -export const filterOutputSchema = z.object({ - filters: z.array( - z - .object({ - field: filterFieldEnum, - filters: z.array( - z.object({ - operator: filterOperatorEnum, - value: z.union([z.string(), z.number()]), - }), - ), - }) - .refine( - (data) => { - const config = filterFieldConfig[data.field]; - return data.filters.every((filter) => { - const isOperatorValid = config.operators.includes(filter.operator as any); - if (!isOperatorValid) { - return false; - } - return validateFieldValue(data.field, filter.value); - }); - }, - { - message: "Invalid field/operator/value combination", - }, - ), - ), -}); - -// Required for transforming OpenAI structured outputs into our own Filter types -export const transformStructuredOutputToFilters = ( - data: z.infer, - existingFilters: FilterValue[] = [], -): FilterValue[] => { - const uniqueFilters = [...existingFilters]; - const seenFilters = new Set(existingFilters.map((f) => `${f.field}-${f.operator}-${f.value}`)); - - for (const filterGroup of data.filters) { - filterGroup.filters.forEach((filter) => { - const baseFilter = { - field: filterGroup.field, - operator: filter.operator, - value: filter.value, - }; - - const filterKey = `${baseFilter.field}-${baseFilter.operator}-${baseFilter.value}`; - - if (seenFilters.has(filterKey)) { - return; - } - - uniqueFilters.push({ - id: crypto.randomUUID(), - ...baseFilter, - }); - - seenFilters.add(filterKey); - }); - } - - return uniqueFilters; -}; - -// Type guard for config types -function isNumberConfig(config: FieldConfig): config is NumberConfig { - return config.type === "number"; -} - -function isStringConfig(config: FieldConfig): config is StringConfig { - return config.type === "string"; -} - -export function validateFieldValue(field: FilterField, value: string | number): boolean { - const config = filterFieldConfig[field]; - - if (isStringConfig(config) && typeof value === "string") { - if (config.validValues) { - return config.validValues.includes(value); - } - return config.validate ? config.validate(value) : true; - } - - if (isNumberConfig(config) && typeof value === "number") { - return config.validate ? config.validate(value) : true; - } - - return true; -} +} from "@/components/logs/validation/filter.types"; +import { createFilterOutputSchema } from "@/components/logs/validation/utils/structured-output-schema-generator"; +import { z } from "zod"; -export const filterFieldConfig: FilterFieldConfigs = { +// Configuration +export const ratelimitFilterFieldConfig: FilterFieldConfigs = { startTime: { type: "number", operators: ["is"], @@ -134,11 +32,50 @@ export const filterFieldConfig: FilterFieldConfigs = { type: "string", operators: ["is"], validValues: ["blocked", "passed"], - getColorClass: (value) => { - if (value === "blocked") { - return "bg-warning-9"; - } - return "bg-success-9"; - }, + getColorClass: (value) => (value === "blocked" ? "bg-warning-9" : "bg-success-9"), } as const, }; + +// Schemas +export const ratelimitFilterOperatorEnum = z.enum(["is", "contains"]); +export const ratelimitFilterFieldEnum = z.enum([ + "startTime", + "endTime", + "since", + "identifiers", + "requestIds", + "status", +]); +export const filterOutputSchema = createFilterOutputSchema( + ratelimitFilterFieldEnum, + ratelimitFilterOperatorEnum, + ratelimitFilterFieldConfig, +); + +// Types +export type RatelimitFilterOperator = z.infer; +export type RatelimitFilterField = z.infer; + +export type FilterFieldConfigs = { + startTime: NumberConfig; + endTime: NumberConfig; + since: StringConfig; + identifiers: StringConfig; + requestIds: StringConfig; + status: StringConfig; +}; + +export type RatelimitFilterUrlValue = Pick< + FilterValue, + "value" | "operator" +>; +export type RatelimitFilterValue = FilterValue; + +export type RatelimitQuerySearchParams = { + startTime?: number | null; + endTime?: number | null; + since?: string | null; + identifiers: RatelimitFilterUrlValue[] | null; + requestIds: RatelimitFilterUrlValue[] | null; + status: RatelimitFilterUrlValue[] | null; +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filters.type.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filters.type.ts deleted file mode 100644 index 2c68f14167..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filters.type.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { z } from "zod"; -import type { filterFieldEnum, filterOperatorEnum } from "./filters.schema"; - -export type FilterOperator = z.infer; -export type FilterField = z.infer; - -export type FieldConfig = StringConfig | NumberConfig; - -export interface BaseFieldConfig { - type: T extends string ? "string" : "number"; - operators: FilterOperator[]; -} - -export interface NumberConfig extends BaseFieldConfig { - type: "number"; - validate?: (value: number) => boolean; -} - -export interface StringConfig extends BaseFieldConfig { - type: "string"; - validValues?: readonly string[]; - validate?: (value: string) => boolean; - getColorClass?: (value: string) => string; -} - -export type FilterFieldConfigs = { - startTime: NumberConfig; - endTime: NumberConfig; - since: StringConfig; - identifiers: StringConfig; - requestIds: StringConfig; - status: StringConfig; -}; - -export type QuerySearchParams = { - startTime?: number | null; - endTime?: number | null; - since?: string | null; - identifiers: FilterUrlValue[] | null; - requestIds: FilterUrlValue[] | null; - status: FilterUrlValue[] | null; -}; - -export type FilterUrlValue = Pick; - -export type FilterValue = { - id: string; - field: FilterField; - operator: FilterOperator; - value: string | number; - metadata?: { - colorClass?: string; - icon?: React.ReactNode; - }; -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.test.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.test.ts index 1ab1fda313..b79b0f7559 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.test.ts +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.test.ts @@ -1,8 +1,13 @@ +import { + parseAsFilterValueArray, + parseAsRelativeTime, +} from "@/components/logs/validation/utils/nuqs-parsers"; import { act, renderHook } from "@testing-library/react"; import { useQueryStates } from "nuqs"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { parseAsFilterValueArray, parseAsRelativeTime, useFilters } from "./use-filters"; +import { useFilters } from "./use-filters"; +const parseAsFilterValArray = parseAsFilterValueArray(["is", "contains"]); vi.mock("nuqs", () => { const mockSetSearchParams = vi.fn(); @@ -35,15 +40,15 @@ const mockSetSearchParams = vi.fn(); describe("parseAsFilterValueArray", () => { it("should return empty array for null input", () => { //@ts-expect-error ts yells for no reason - expect(parseAsFilterValueArray.parse(null)).toEqual([]); + expect(parseAsFilterValArray.parse(null)).toEqual([]); }); it("should return empty array for empty string", () => { - expect(parseAsFilterValueArray.parse("")).toEqual([]); + expect(parseAsFilterValArray.parse("")).toEqual([]); }); it("should parse single filter correctly", () => { - const result = parseAsFilterValueArray.parse("is:200"); + const result = parseAsFilterValArray.parse("is:200"); expect(result).toEqual([ { operator: "is", @@ -53,7 +58,7 @@ describe("parseAsFilterValueArray", () => { }); it("should parse multiple filters correctly", () => { - const result = parseAsFilterValueArray.parse("is:200,contains:error"); + const result = parseAsFilterValArray.parse("is:200,contains:error"); expect(result).toEqual([ { operator: "is", value: "200" }, { operator: "contains", value: "error" }, @@ -61,12 +66,12 @@ describe("parseAsFilterValueArray", () => { }); it("should return empty array for invalid operator", () => { - expect(parseAsFilterValueArray.parse("invalid:200")).toEqual([]); + expect(parseAsFilterValArray.parse("invalid:200")).toEqual([]); }); it("should serialize empty array to empty string", () => { //@ts-expect-error ts yells for no reason - expect(parseAsFilterValueArray.serialize([])).toBe(""); + expect(parseAsFilterValArray.serialize([])).toBe(""); }); it("should serialize array of filters correctly", () => { @@ -75,7 +80,7 @@ describe("parseAsFilterValueArray", () => { { operator: "contains", value: "error" }, ]; //@ts-expect-error ts yells for no reason - expect(parseAsFilterValueArray.serialize(filters)).toBe("is:200,contains:error"); + expect(parseAsFilterValArray.serialize(filters)).toBe("is:200,contains:error"); }); }); diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.ts index 42720e7f38..18e3e7238a 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.ts +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.ts @@ -1,80 +1,32 @@ -import { getTimestampFromRelative } from "@/lib/utils"; -import { type Parser, parseAsInteger, useQueryStates } from "nuqs"; +import { + parseAsFilterValueArray, + parseAsRelativeTime, +} from "@/components/logs/validation/utils/nuqs-parsers"; +import { parseAsInteger, useQueryStates } from "nuqs"; import { useCallback, useMemo } from "react"; -import { filterFieldConfig } from "../filters.schema"; -import type { - FilterField, - FilterOperator, - FilterUrlValue, - FilterValue, - QuerySearchParams, -} from "../filters.type"; - -export const parseAsRelativeTime: Parser = { - parse: (str: string | null) => { - if (!str) { - return null; - } - - try { - // If that function doesn't throw it means we are safe - getTimestampFromRelative(str); - return str; - } catch { - return null; - } - }, - serialize: (value: string | null) => { - if (!value) { - return ""; - } - return value; - }, -}; - -export const parseAsFilterValueArray: Parser = { - parse: (str: string | null) => { - if (!str) { - return []; - } - try { - // Format: operator:value,operator:value (e.g., "is:200,is:404") - return str.split(",").map((item) => { - const [operator, val] = item.split(/:(.+)/); - if (!["is", "contains"].includes(operator)) { - throw new Error("Invalid operator"); - } - return { - operator: operator as FilterOperator, - value: val, - }; - }); - } catch { - return []; - } - }, - serialize: (value: FilterUrlValue[]) => { - if (!value?.length) { - return ""; - } - return value.map((v) => `${v.operator}:${v.value}`).join(","); - }, -}; - +import { + type RatelimitFilterField, + type RatelimitFilterOperator, + type RatelimitFilterUrlValue, + type RatelimitFilterValue, + type RatelimitQuerySearchParams, + ratelimitFilterFieldConfig, +} from "../filters.schema"; + +const parseAsFilterValArray = parseAsFilterValueArray(["is", "contains"]); export const queryParamsPayload = { - requestIds: parseAsFilterValueArray, - identifiers: parseAsFilterValueArray, + requestIds: parseAsFilterValArray, + identifiers: parseAsFilterValArray, startTime: parseAsInteger, endTime: parseAsInteger, - status: parseAsFilterValueArray, + status: parseAsFilterValArray, since: parseAsRelativeTime, } as const; export const useFilters = () => { const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload); - const filters = useMemo(() => { - const activeFilters: FilterValue[] = []; + const activeFilters: RatelimitFilterValue[] = []; searchParams.requestIds?.forEach((requestIdFilter) => { activeFilters.push({ @@ -101,17 +53,19 @@ export const useFilters = () => { operator: statusFilter.operator, value: statusFilter.value, metadata: { - colorClass: filterFieldConfig.status.getColorClass?.(statusFilter.value as string), + colorClass: ratelimitFilterFieldConfig.status.getColorClass?.( + statusFilter.value as string, + ), }, }); }); ["startTime", "endTime", "since"].forEach((field) => { - const value = searchParams[field as keyof QuerySearchParams]; + const value = searchParams[field as keyof RatelimitQuerySearchParams]; if (value !== null && value !== undefined) { activeFilters.push({ id: crypto.randomUUID(), - field: field as FilterField, + field: field as RatelimitFilterField, operator: "is", value: value as string | number, }); @@ -122,8 +76,8 @@ export const useFilters = () => { }, [searchParams]); const updateFilters = useCallback( - (newFilters: FilterValue[]) => { - const newParams: Partial = { + (newFilters: RatelimitFilterValue[]) => { + const newParams: Partial = { requestIds: null, startTime: null, endTime: null, @@ -133,9 +87,9 @@ export const useFilters = () => { }; // Group filters by field - const requestIdFilters: FilterUrlValue[] = []; - const statusFilters: FilterUrlValue[] = []; - const identifierFilters: FilterUrlValue[] = []; + const requestIdFilters: RatelimitFilterUrlValue[] = []; + const statusFilters: RatelimitFilterUrlValue[] = []; + const identifierFilters: RatelimitFilterUrlValue[] = []; newFilters.forEach((filter) => { switch (filter.field) { diff --git a/apps/dashboard/components/logs/validation/filter.types.ts b/apps/dashboard/components/logs/validation/filter.types.ts new file mode 100644 index 0000000000..c9c3e1938d --- /dev/null +++ b/apps/dashboard/components/logs/validation/filter.types.ts @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; +import { z } from "zod"; + +// Our default filterOperators that we use in query params e.g. "path=is:/oz/refactors" +export const filterOperatorEnum = z.enum(["is", "contains", "startsWith", "endsWith"]); + +export type FilterOperator = z.infer; + +export type BaseFieldConfig = { + type: T extends string ? "string" : "number"; + operators: TOperator[]; +}; + +export type NumberConfig = BaseFieldConfig & { + type: "number"; + validate?: (value: number) => boolean; +}; + +export type StringConfig = BaseFieldConfig & { + type: "string"; + validValues?: readonly string[]; + validate?: (value: string) => boolean; + getColorClass?: (value: string) => string; +}; + +export type FieldConfig = + | StringConfig + | NumberConfig; + +export type FilterUrlValue = { + value: string | number; + operator: TOperator; +}; + +export type FilterValue< + TField extends string = string, + TOperator extends FilterOperator = FilterOperator, + TValue extends string | number = string | number, +> = { + id: string; + field: TField; + operator: TOperator; + value: TValue; + metadata?: { + colorClass?: string; + icon?: ReactNode; + }; +}; diff --git a/apps/dashboard/components/logs/validation/utils/nuqs-parsers.ts b/apps/dashboard/components/logs/validation/utils/nuqs-parsers.ts new file mode 100644 index 0000000000..d11a383b0a --- /dev/null +++ b/apps/dashboard/components/logs/validation/utils/nuqs-parsers.ts @@ -0,0 +1,57 @@ +import { getTimestampFromRelative } from "@/lib/utils"; +import type { Parser } from "nuqs"; +import type { FilterOperator, FilterUrlValue } from "../filter.types"; + +export const parseAsRelativeTime: Parser = { + parse: (str: string | null) => { + if (!str) { + return null; + } + + try { + // If that function doesn't throw it means we are safe + getTimestampFromRelative(str); + return str; + } catch { + return null; + } + }, + serialize: (value: string | null) => { + if (!value) { + return ""; + } + return value; + }, +}; + +export const VALID_OPERATORS: FilterOperator[] = ["is", "contains"]; + +export const parseAsFilterValueArray = ( + operators: TOperator[] = VALID_OPERATORS as TOperator[], +): Parser[]> => ({ + parse: (str: string | null) => { + if (!str) { + return []; + } + try { + return str.split(",").map((item) => { + const [operator, val] = item.split(/:(.+)/); + if (!operators.includes(operator as TOperator)) { + throw new Error("Invalid operator"); + } + return { + operator: operator as TOperator, + value: val, + }; + }); + } catch { + return []; + } + }, + serialize: (value: FilterUrlValue[]) => { + if (!value?.length) { + return ""; + } + return value.map((v) => `${v.operator}:${v.value}`).join(","); + }, +}); diff --git a/apps/dashboard/components/logs/validation/utils/structured-output-schema-generator.ts b/apps/dashboard/components/logs/validation/utils/structured-output-schema-generator.ts new file mode 100644 index 0000000000..4519810018 --- /dev/null +++ b/apps/dashboard/components/logs/validation/utils/structured-output-schema-generator.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; +import type { FieldConfig } from "../filter.types"; +import { isNumberConfig, isStringConfig } from "./type-guards"; + +export function createFilterOutputSchema< + TFieldEnum extends z.ZodEnum<[string, ...string[]]>, + TOperatorEnum extends z.ZodEnum<[string, ...string[]]>, + TConfig extends Record, FieldConfig>>, +>(fieldEnum: TFieldEnum, operatorEnum: TOperatorEnum, filterFieldConfig: TConfig) { + return z.object({ + filters: z.array( + z + .object({ + field: fieldEnum, + filters: z.array( + z.object({ + operator: operatorEnum, + value: z.union([z.string(), z.number()]), + }), + ), + }) + .refine( + (data) => { + const config = filterFieldConfig[data.field as keyof TConfig]; + return data.filters.every((filter) => { + const isOperatorValid = config.operators.includes( + filter.operator as z.infer, + ); + return ( + isOperatorValid && + validateFieldValue(data.field as keyof TConfig, filter.value, filterFieldConfig) + ); + }); + }, + { message: "Invalid field/operator/value combination" }, + ), + ), + }); +} + +function validateFieldValue>( + field: keyof TConfig, + value: string | number, + filterFieldConfig: TConfig, +): boolean { + const config = filterFieldConfig[field]; + + if (isStringConfig(config) && typeof value === "string") { + if (config.validValues) { + return config.validValues.includes(value); + } + return config.validate ? config.validate(value) : true; + } + + if (isNumberConfig(config) && typeof value === "number") { + return config.validate ? config.validate(value) : true; + } + + return true; +} diff --git a/apps/dashboard/components/logs/validation/utils/transform-structured-output-filter-format.ts b/apps/dashboard/components/logs/validation/utils/transform-structured-output-filter-format.ts new file mode 100644 index 0000000000..7ad3ae6d3f --- /dev/null +++ b/apps/dashboard/components/logs/validation/utils/transform-structured-output-filter-format.ts @@ -0,0 +1,61 @@ +/** + * Transforms LLM-generated structured filter output into internal query filters that match + * the FilterFieldConfigs schema. Merges new filters with existing ones while maintaining + * uniqueness based on field-operator-value combinations. + * + * @example + * const llmOutput = { + * filters: [{ + * field: "status", + * filters: [ + * { operator: "is", value: "blocked" } + * ] + * }] + * }; + * const result = transformStructuredOutputToFilters(llmOutput); + * // Returns: [{ id: "uuid", field: "status", operator: "is", value: "blocked" }] + */ +export const transformStructuredOutputToFilters = < + TField extends string, + TOperator extends string, + TValue extends string | number, +>( + data: { + filters: Array<{ + field: TField; + filters: Array<{ operator: TOperator; value: TValue }>; + }>; + }, + existingFilters: Array<{ + id: string; + field: TField; + operator: TOperator; + value: TValue; + }> = [], +): Array<{ id: string; field: TField; operator: TOperator; value: TValue }> => { + const uniqueFilters = [...existingFilters]; + const seenFilters = new Set(existingFilters.map((f) => `${f.field}-${f.operator}-${f.value}`)); + + for (const filterGroup of data.filters) { + filterGroup.filters.forEach((filter) => { + const baseFilter = { + field: filterGroup.field, + operator: filter.operator, + value: filter.value, + }; + + const filterKey = `${baseFilter.field}-${baseFilter.operator}-${baseFilter.value}`; + if (seenFilters.has(filterKey)) { + return; + } + + uniqueFilters.push({ + id: crypto.randomUUID(), + ...baseFilter, + }); + seenFilters.add(filterKey); + }); + } + + return uniqueFilters; +}; diff --git a/apps/dashboard/components/logs/validation/utils/type-guards.ts b/apps/dashboard/components/logs/validation/utils/type-guards.ts new file mode 100644 index 0000000000..f7747e8054 --- /dev/null +++ b/apps/dashboard/components/logs/validation/utils/type-guards.ts @@ -0,0 +1,9 @@ +import type { FieldConfig, NumberConfig, StringConfig } from "../filter.types"; + +// Type guards +export function isNumberConfig(config: FieldConfig): config is NumberConfig { + return config.type === "number"; +} +export function isStringConfig(config: FieldConfig): config is StringConfig { + return config.type === "string"; +} diff --git a/apps/dashboard/lib/trpc/routers/ratelimit/llm-search/utils.ts b/apps/dashboard/lib/trpc/routers/ratelimit/llm-search/utils.ts index 18293b2736..861e40db92 100644 --- a/apps/dashboard/lib/trpc/routers/ratelimit/llm-search/utils.ts +++ b/apps/dashboard/lib/trpc/routers/ratelimit/llm-search/utils.ts @@ -1,11 +1,28 @@ import { - filterFieldConfig, filterOutputSchema, + ratelimitFilterFieldConfig, } from "@/app/(app)/ratelimits/[namespaceId]/logs/filters.schema"; import { TRPCError } from "@trpc/server"; import type OpenAI from "openai"; import { zodResponseFormat } from "openai/helpers/zod.mjs"; +/** + * Creates a Zod schema for validating LLM-generated structured filter output. + * Used with OpenAI's parse completion to enforce type safety and validation rules + * defined in FilterFieldConfigs. + * + * + * @example + * const schema = createFilterOutputSchema( + * z.enum(["status", "identifiers"]), + * z.enum(["is", "contains"]), + * ratelimitFilterFieldConfig + * ); + * + * const llmResponse = await openai.beta.chat.completions.parse({ + * response_format: zodResponseFormat(schema, "searchQuery") + * }); + */ export async function getStructuredSearchFromLLM( openai: OpenAI | null, userSearchMsg: string, @@ -76,7 +93,7 @@ export async function getStructuredSearchFromLLM( } } export const getSystemPrompt = (usersReferenceMS: number) => { - const operatorsByField = Object.entries(filterFieldConfig) + const operatorsByField = Object.entries(ratelimitFilterFieldConfig) .map(([field, config]) => { const operators = config.operators.join(", "); let constraints = "";