diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/components/log-details/components/hooks/use-logs-query.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/components/log-details/components/hooks/use-logs-query.ts index 496dfd87af..fdb35dd48f 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/components/log-details/components/hooks/use-logs-query.ts +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/components/log-details/components/hooks/use-logs-query.ts @@ -13,8 +13,8 @@ export function useFetchRequestDetails({ requestId }: useFetchRequestDetails) { startTime: 0, endTime: timestamp, host: { filters: [] }, - method: { filters: [] }, - path: { filters: [] }, + methods: { filters: [] }, + paths: { filters: [] }, status: { filters: [] }, requestId: requestId ? { diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/logs-table.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/logs-table.tsx index 26706fae17..93d372f1fa 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/logs-table.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/logs-table.tsx @@ -216,8 +216,8 @@ export const KeyDetailsLogsTable = ({ keyspaceId, keyId, selectedLog, onLogSelec startTime: 0, endTime: timestamp, host: { filters: [] }, - method: { filters: [] }, - path: { filters: [] }, + methods: { filters: [] }, + paths: { filters: [] }, status: { filters: [] }, requestId: { filters: [ diff --git a/apps/dashboard/app/(app)/logs/components/charts/hooks/use-fetch-timeseries.ts b/apps/dashboard/app/(app)/logs/components/charts/hooks/use-fetch-timeseries.ts index 80ad4fda31..fe6914cc5a 100644 --- a/apps/dashboard/app/(app)/logs/components/charts/hooks/use-fetch-timeseries.ts +++ b/apps/dashboard/app/(app)/logs/components/charts/hooks/use-fetch-timeseries.ts @@ -1,95 +1,11 @@ import { formatTimestampForChart } from "@/components/logs/chart/utils/format-timestamp"; -import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; import { trpc } from "@/lib/trpc/client"; import { useQueryTime } from "@/providers/query-time-provider"; -import { useMemo } from "react"; -import type { z } from "zod"; -import { useFilters } from "../../../hooks/use-filters"; -import type { queryTimeseriesPayload } from "../query-timeseries.schema"; +import { buildQueryParams } from "../../../filters.query-params"; export const useFetchTimeseries = () => { - const { filters } = useFilters(); - const { queryTime: timestamp } = useQueryTime(); - const queryParams = useMemo(() => { - const params: z.infer = { - startTime: timestamp - HISTORICAL_DATA_WINDOW, - endTime: timestamp, - host: { filters: [] }, - method: { filters: [] }, - path: { filters: [] }, - status: { filters: [] }, - since: "", - }; - - filters.forEach((filter) => { - switch (filter.field) { - case "status": { - params.status?.filters.push({ - operator: "is", - value: Number.parseInt(filter.value as string), - }); - break; - } - - case "methods": { - if (typeof filter.value !== "string") { - console.error("Method filter value type has to be 'string'"); - return; - } - params.method?.filters.push({ - operator: "is", - value: filter.value, - }); - break; - } - - case "paths": { - if (typeof filter.value !== "string") { - console.error("Path filter value type has to be 'string'"); - return; - } - params.path?.filters.push({ - operator: filter.operator, - value: filter.value, - }); - break; - } - - case "host": { - if (typeof filter.value !== "string") { - console.error("Host filter value type has to be 'string'"); - return; - } - params.host?.filters.push({ - operator: "is", - value: filter.value, - }); - break; - } - - case "startTime": - case "endTime": { - if (typeof filter.value !== "number") { - console.error(`${filter.field} filter value type has to be 'number'`); - return; - } - params[filter.field] = filter.value; - break; - } - case "since": { - if (typeof filter.value !== "string") { - console.error("Since filter value type has to be 'string'"); - return; - } - params.since = filter.value; - break; - } - } - }); - - return params; - }, [filters, timestamp]); + const queryParams = buildQueryParams({ timestamp }); const { data, isLoading, isError } = trpc.logs.queryTimeseries.useQuery(queryParams, { refetchInterval: queryParams.endTime ? false : 10_000, diff --git a/apps/dashboard/app/(app)/logs/components/charts/query-timeseries.schema.ts b/apps/dashboard/app/(app)/logs/components/charts/query-timeseries.schema.ts deleted file mode 100644 index 2a833ea81a..0000000000 --- a/apps/dashboard/app/(app)/logs/components/charts/query-timeseries.schema.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from "zod"; -import { logsFilterOperatorEnum } from "../../filters.schema"; - -export const queryTimeseriesPayload = z.object({ - startTime: z.number().int(), - endTime: z.number().int(), - since: z.string(), - path: z - .object({ - filters: z.array( - z.object({ - operator: logsFilterOperatorEnum, - value: z.string(), - }), - ), - }) - .nullable(), - host: z - .object({ - filters: z.array( - z.object({ - operator: z.literal("is"), - value: z.string(), - }), - ), - }) - .nullable(), - method: z - .object({ - filters: z.array( - z.object({ - operator: z.literal("is"), - value: z.string(), - }), - ), - }) - .nullable(), - status: z - .object({ - filters: z.array( - z.object({ - operator: z.literal("is"), - value: z.number(), - }), - ), - }) - .nullable(), -}); diff --git a/apps/dashboard/app/(app)/logs/components/table/hooks/use-logs-query.ts b/apps/dashboard/app/(app)/logs/components/table/hooks/use-logs-query.ts index 5bad52e4cc..afac667b3c 100644 --- a/apps/dashboard/app/(app)/logs/components/table/hooks/use-logs-query.ts +++ b/apps/dashboard/app/(app)/logs/components/table/hooks/use-logs-query.ts @@ -1,11 +1,8 @@ -import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; import { trpc } from "@/lib/trpc/client"; import { useQueryTime } from "@/providers/query-time-provider"; import type { Log } from "@unkey/clickhouse/src/logs"; import { useCallback, useEffect, useMemo, useState } from "react"; -import type { z } from "zod"; -import { useFilters } from "../../../hooks/use-filters"; -import type { queryLogsPayload } from "../query-logs.schema"; +import { buildQueryParams } from "../../../filters.query-params"; // Duration in milliseconds for historical data fetch window (12 hours) type UseLogsQueryParams = { @@ -25,7 +22,6 @@ export function useLogsQuery({ const [realtimeLogsMap, setRealtimeLogsMap] = useState(() => new Map()); const [totalCount, setTotalCount] = useState(0); - const { filters } = useFilters(); const queryClient = trpc.useUtils(); const { queryTime: timestamp } = useQueryTime(); @@ -35,100 +31,7 @@ export function useLogsQuery({ const historicalLogs = useMemo(() => Array.from(historicalLogsMap.values()), [historicalLogsMap]); - //Required for preventing double trpc call during initial render - const queryParams = useMemo(() => { - const params: z.infer = { - limit, - startTime: timestamp - HISTORICAL_DATA_WINDOW, - endTime: timestamp, - host: { filters: [] }, - requestId: { filters: [] }, - method: { filters: [] }, - path: { filters: [] }, - status: { filters: [] }, - since: "", - }; - - filters.forEach((filter) => { - switch (filter.field) { - case "status": { - params.status?.filters.push({ - operator: "is", - value: Number.parseInt(filter.value as string), - }); - break; - } - - case "methods": { - if (typeof filter.value !== "string") { - console.error("Method filter value type has to be 'string'"); - return; - } - params.method?.filters.push({ - operator: "is", - value: filter.value, - }); - break; - } - - case "paths": { - if (typeof filter.value !== "string") { - console.error("Path filter value type has to be 'string'"); - return; - } - params.path?.filters.push({ - operator: filter.operator, - value: filter.value, - }); - break; - } - - case "host": { - if (typeof filter.value !== "string") { - console.error("Host filter value type has to be 'string'"); - return; - } - params.host?.filters.push({ - operator: "is", - value: filter.value, - }); - break; - } - - case "requestId": { - if (typeof filter.value !== "string") { - console.error("Request ID filter value type has to be 'string'"); - return; - } - params.requestId?.filters.push({ - operator: "is", - value: filter.value, - }); - break; - } - - case "startTime": - case "endTime": { - if (typeof filter.value !== "number") { - console.error(`${filter.field} filter value type has to be 'string'`); - return; - } - params[filter.field] = filter.value; - break; - } - case "since": { - if (typeof filter.value !== "string") { - console.error("Since filter value type has to be 'string'"); - return; - } - params.since = filter.value; - break; - } - } - }); - - return params; - }, [filters, limit, timestamp]); + const queryParams = buildQueryParams({ timestamp, limit }); // Main query for historical data const { diff --git a/apps/dashboard/app/(app)/logs/components/table/query-logs.schema.ts b/apps/dashboard/app/(app)/logs/components/table/query-logs.schema.ts deleted file mode 100644 index feadf1eed1..0000000000 --- a/apps/dashboard/app/(app)/logs/components/table/query-logs.schema.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { z } from "zod"; -import { logsFilterOperatorEnum } from "../../filters.schema"; - -export const queryLogsPayload = z.object({ - limit: z.number().int(), - startTime: z.number().int(), - endTime: z.number().int(), - since: z.string(), - path: z - .object({ - filters: z.array( - z.object({ - operator: logsFilterOperatorEnum, - value: z.string(), - }), - ), - }) - .nullable(), - host: z - .object({ - filters: z.array( - z.object({ - operator: z.literal("is"), - value: z.string(), - }), - ), - }) - .nullable(), - method: z - .object({ - filters: z.array( - z.object({ - operator: z.literal("is"), - value: z.string(), - }), - ), - }) - .nullable(), - requestId: z - .object({ - filters: z.array( - z.object({ - operator: z.literal("is"), - value: z.string(), - }), - ), - }) - .nullable(), - status: z - .object({ - filters: z.array( - z.object({ - operator: z.literal("is"), - value: z.number(), - }), - ), - }) - .nullable(), - cursor: z.number().nullable().optional().nullable(), -}); diff --git a/apps/dashboard/app/(app)/logs/filters.query-params.ts b/apps/dashboard/app/(app)/logs/filters.query-params.ts new file mode 100644 index 0000000000..b6e524a9e4 --- /dev/null +++ b/apps/dashboard/app/(app)/logs/filters.query-params.ts @@ -0,0 +1,78 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import type { QueryLogsPayload } from "./filters.schema"; +import { useFilters } from "./hooks/use-filters"; + +type BuildQueryParamsOptions = { + timestamp: number; + limit?: number; +}; + +export function buildQueryParams({ timestamp, limit }: BuildQueryParamsOptions): QueryLogsPayload { + const { filters } = useFilters(); + const params: QueryLogsPayload = { + startTime: timestamp - HISTORICAL_DATA_WINDOW, + endTime: timestamp, + host: { filters: [] }, + requestId: { filters: [] }, + methods: { filters: [] }, + paths: { filters: [] }, + status: { filters: [] }, + since: "", + // Timeseries queries will ignore this prop + limit: limit ?? 0, + }; + + for (const filter of filters) { + switch (filter.field) { + case "status": { + const statusValue = Number.parseInt(filter.value as string); + if (Number.isNaN(statusValue)) { + throw new Error(`Invalid status filter value: ${filter.value}`); + } + params.status?.filters.push({ + operator: "is", + value: statusValue, + }); + break; + } + + case "methods": + case "paths": + case "host": + case "requestId": { + if (typeof filter.value !== "string") { + throw new Error(`${filter.field} filter value must be a string`); + } + params[filter.field]?.filters.push({ + operator: filter.operator, + value: filter.value, + }); + break; + } + + case "startTime": + case "endTime": { + if (typeof filter.value !== "number") { + throw new Error(`${filter.field} filter value must be a number`); + } + params[filter.field] = filter.value; + break; + } + + case "since": { + if (typeof filter.value !== "string") { + throw new Error("Since filter value must be a string"); + } + params.since = filter.value; + break; + } + + default: { + const _exhaustive: unknown = filter.field; + throw new Error(`Unknown filter field: ${_exhaustive}`); + } + } + } + + return params; +} diff --git a/apps/dashboard/app/(app)/logs/filters.schema.ts b/apps/dashboard/app/(app)/logs/filters.schema.ts index 2d766fded9..fcef1d559b 100644 --- a/apps/dashboard/app/(app)/logs/filters.schema.ts +++ b/apps/dashboard/app/(app)/logs/filters.schema.ts @@ -1,92 +1,76 @@ -import { METHODS } from "./constants"; +import type { NumberConfig, StringConfig } from "@/components/logs/validation/filter.types"; +import { COMMON_STRING_OPERATORS, createFilterSchema } from "@/lib/filters/filter-builder"; +import type { z } from "zod"; -import type { - FilterValue, - NumberConfig, - StringConfig, -} from "@/components/logs/validation/filter.types"; -import { createFilterOutputSchema } from "@/components/logs/validation/utils/structured-output-schema-generator"; -import { z } from "zod"; - -// Configuration -export const logsFilterFieldConfig: FilterFieldConfigs = { +const logsFilterConfigs = { status: { - type: "number", + type: "number" as const, operators: ["is"], - getColorClass: (value) => { - if (value >= 500) { + getColorClass: (value: unknown) => { + const numValue = value as number; + if (numValue >= 500) { return "bg-error-9"; } - if (value >= 400) { + if (numValue >= 400) { return "bg-warning-8"; } return "bg-success-9"; }, - validate: (value) => value >= 200 && value <= 599, }, methods: { - type: "string", - operators: ["is"], - validValues: METHODS, + type: "string" as const, + operators: COMMON_STRING_OPERATORS, }, paths: { - type: "string", - operators: ["is", "contains", "startsWith", "endsWith"], + type: "string" as const, + operators: COMMON_STRING_OPERATORS, }, host: { - type: "string", - operators: ["is"], + type: "string" as const, + operators: COMMON_STRING_OPERATORS, }, requestId: { - type: "string", - operators: ["is"], + type: "string" as const, + operators: COMMON_STRING_OPERATORS, }, startTime: { - type: "number", + type: "number" as const, operators: ["is"], + isTimeField: true as const, }, endTime: { - type: "number", + type: "number" as const, operators: ["is"], + isTimeField: true as const, }, since: { - type: "string", - operators: ["is"], + type: "string" as const, + operators: COMMON_STRING_OPERATORS, + isRelativeTimeField: true as const, }, } as const; -export interface StatusConfig extends NumberConfig { - type: "number"; - operators: ["is"]; - validate: (value: number) => boolean; -} +export const logsSchema = createFilterSchema("logs", logsFilterConfigs, { + pagination: true, +}); -// Schemas -export const logsFilterOperatorEnum = z.enum(["is", "contains", "startsWith", "endsWith"]); +export const logsFilterFieldConfig = logsFilterConfigs; +export const logsFilterOperatorEnum = logsSchema.operatorEnum; +export const logsFilterFieldEnum = logsSchema.fieldEnum; +export const filterOutputSchema = logsSchema.filterOutputSchema; +export const queryLogsPayload = logsSchema.apiQuerySchema; +export const queryParamsPayload = logsSchema.queryParamsPayload; -export const logsFilterFieldEnum = z.enum([ - "host", - "requestId", - "methods", - "paths", - "status", - "startTime", - "endTime", - "since", -]); +export type LogsFilterOperator = typeof logsSchema.types.Operator; +export type LogsFilterField = keyof typeof logsFilterConfigs; +export type LogsFilterValue = typeof logsSchema.types.FilterValue; +export type LogsFilterUrlValue = typeof logsSchema.types.AllOperatorsUrlValue; +export type QuerySearchParams = typeof logsSchema.types.QuerySearchParams; -export const filterOutputSchema = createFilterOutputSchema( - logsFilterFieldEnum, - logsFilterOperatorEnum, - logsFilterFieldConfig, -); - -// Types -export type LogsFilterOperator = z.infer; -export type LogsFilterField = z.infer; +export type QueryLogsPayload = z.infer; export type FilterFieldConfigs = { - status: StatusConfig; + status: NumberConfig; methods: StringConfig; paths: StringConfig; host: StringConfig; @@ -95,20 +79,3 @@ export type FilterFieldConfigs = { endTime: NumberConfig; since: StringConfig; }; - -export type LogsFilterUrlValue = Pick< - FilterValue, - "value" | "operator" ->; -export type LogsFilterValue = FilterValue; - -export type QuerySearchParams = { - methods: LogsFilterUrlValue[] | null; - paths: LogsFilterUrlValue[] | null; - status: LogsFilterUrlValue[] | null; - startTime?: number | null; - endTime?: number | null; - since?: string | null; - host: LogsFilterUrlValue[] | null; - requestId: LogsFilterUrlValue[] | null; -}; diff --git a/apps/dashboard/app/(app)/logs/hooks/use-filters.ts b/apps/dashboard/app/(app)/logs/hooks/use-filters.ts index 38874d1238..0432381534 100644 --- a/apps/dashboard/app/(app)/logs/hooks/use-filters.ts +++ b/apps/dashboard/app/(app)/logs/hooks/use-filters.ts @@ -1,191 +1,4 @@ -import { - parseAsFilterValueArray, - parseAsRelativeTime, -} from "@/components/logs/validation/utils/nuqs-parsers"; -import { parseAsInteger, useQueryStates } from "nuqs"; -import { useCallback, useMemo } from "react"; -import { - type LogsFilterField, - type LogsFilterOperator, - type LogsFilterUrlValue, - type LogsFilterValue, - type QuerySearchParams, - logsFilterFieldConfig, -} from "../filters.schema"; +import { createUseFilters } from "@/lib/filters/filter-hook"; +import { logsSchema } from "../filters.schema"; -const parseAsFilterValArray = parseAsFilterValueArray([ - "is", - "contains", - "startsWith", - "endsWith", -]); -export const queryParamsPayload = { - requestId: parseAsFilterValArray, - host: parseAsFilterValArray, - methods: parseAsFilterValArray, - paths: parseAsFilterValArray, - status: parseAsFilterValArray, - startTime: parseAsInteger, - endTime: parseAsInteger, - since: parseAsRelativeTime, -} as const; - -export const useFilters = () => { - const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload, { - history: "push", - }); - - const filters = useMemo(() => { - const activeFilters: LogsFilterValue[] = []; - - searchParams.status?.forEach((status) => { - activeFilters.push({ - id: crypto.randomUUID(), - field: "status", - operator: status.operator, - value: status.value, - metadata: { - colorClass: logsFilterFieldConfig.status.getColorClass?.(status.value as number), - }, - }); - }); - - searchParams.methods?.forEach((method) => { - activeFilters.push({ - id: crypto.randomUUID(), - field: "methods", - operator: method.operator, - value: method.value, - }); - }); - - searchParams.paths?.forEach((pathFilter) => { - activeFilters.push({ - id: crypto.randomUUID(), - field: "paths", - operator: pathFilter.operator, - value: pathFilter.value, - }); - }); - - searchParams.host?.forEach((hostFilter) => { - activeFilters.push({ - id: crypto.randomUUID(), - field: "host", - operator: hostFilter.operator, - value: hostFilter.value, - }); - }); - - searchParams.requestId?.forEach((requestIdFilter) => { - activeFilters.push({ - id: crypto.randomUUID(), - field: "requestId", - operator: requestIdFilter.operator, - value: requestIdFilter.value, - }); - }); - - ["startTime", "endTime", "since"].forEach((field) => { - const value = searchParams[field as keyof QuerySearchParams]; - if (value !== null && value !== undefined) { - activeFilters.push({ - id: crypto.randomUUID(), - field: field as LogsFilterField, - operator: "is", - value: value as string | number, - }); - } - }); - - return activeFilters; - }, [searchParams]); - - const updateFilters = useCallback( - (newFilters: LogsFilterValue[]) => { - const newParams: Partial = { - paths: null, - host: null, - requestId: null, - startTime: null, - endTime: null, - methods: null, - status: null, - since: null, - }; - - // Group filters by field - const responseStatusFilters: LogsFilterUrlValue[] = []; - const methodFilters: LogsFilterUrlValue[] = []; - const pathFilters: LogsFilterUrlValue[] = []; - const hostFilters: LogsFilterUrlValue[] = []; - const requestIdFilters: LogsFilterUrlValue[] = []; - - newFilters.forEach((filter) => { - switch (filter.field) { - case "status": - responseStatusFilters.push({ - value: filter.value, - operator: filter.operator, - }); - break; - case "methods": - methodFilters.push({ - value: filter.value, - operator: filter.operator, - }); - break; - case "paths": - pathFilters.push({ - value: filter.value, - operator: filter.operator, - }); - break; - case "host": - hostFilters.push({ - value: filter.value as string, - operator: filter.operator, - }); - break; - case "requestId": - requestIdFilters.push({ - value: filter.value as string, - operator: filter.operator, - }); - break; - case "startTime": - case "endTime": - newParams[filter.field] = filter.value as number; - break; - case "since": - newParams.since = filter.value as string; - break; - } - }); - - // Set arrays to null when empty, otherwise use the filtered values - newParams.status = responseStatusFilters.length > 0 ? responseStatusFilters : null; - newParams.methods = methodFilters.length > 0 ? methodFilters : null; - newParams.paths = pathFilters.length > 0 ? pathFilters : null; - newParams.host = hostFilters.length > 0 ? hostFilters : null; - newParams.requestId = requestIdFilters.length > 0 ? requestIdFilters : null; - - setSearchParams(newParams); - }, - [setSearchParams], - ); - - const removeFilter = useCallback( - (id: string) => { - const newFilters = filters.filter((f) => f.id !== id); - updateFilters(newFilters); - }, - [filters, updateFilters], - ); - - return { - filters, - removeFilter, - updateFilters, - }; -}; +export const useFilters = createUseFilters(logsSchema); diff --git a/apps/dashboard/lib/filters/filter-builder.ts b/apps/dashboard/lib/filters/filter-builder.ts new file mode 100644 index 0000000000..c9781d68f2 --- /dev/null +++ b/apps/dashboard/lib/filters/filter-builder.ts @@ -0,0 +1,293 @@ +import type { FilterValue } from "@/components/logs/validation/filter.types"; +import { + parseAsFilterValueArray, + parseAsRelativeTime, +} from "@/components/logs/validation/utils/nuqs-parsers"; +import { createFilterOutputSchema } from "@/components/logs/validation/utils/structured-output-schema-generator"; +import { parseAsInteger } from "nuqs"; +import { z } from "zod"; + +// ============================================================================ +// COMMON OPERATOR DEFINITIONS +// ============================================================================ + +export const COMMON_STRING_OPERATORS = ["is", "contains", "startsWith", "endsWith"] as const; + +/** + * Base configuration for any field type + */ +export interface BaseFieldConfig { + type: "string" | "number"; + operators: TOperators; +} + +interface TimeField { + isTimeField: true; +} + +/** + * Special relative time field marker - uses parseAsRelativeTime parser + */ +interface RelativeTimeField { + isRelativeTimeField: true; +} + +/** + * Complete field configuration type + */ +type FieldConfig = + | BaseFieldConfig + | (BaseFieldConfig & TimeField) + | (BaseFieldConfig & RelativeTimeField); + +// ============================================================================ +// EXTRACTED TYPE HELPERS +// ============================================================================ + +/** + * Extract all operators from field configurations + */ +type ExtractAllOperators>> = { + [K in keyof TConfigs]: TConfigs[K]["operators"][number]; +}[keyof TConfigs]; + +/** + * Check if field is a special time field + */ +type IsTimeField = T extends { isTimeField: true } ? true : false; + +/** + * Check if field is a relative time field + */ +type IsRelativeTimeField = T extends { isRelativeTimeField: true } ? true : false; + +/** + * Get operators for a specific field + */ +type GetFieldOperators< + TConfigs extends Record>, + TField extends keyof TConfigs, +> = TConfigs[TField]["operators"][number]; + +/** + * URL filter value for a specific field + */ +type FilterUrlValue< + TConfigs extends Record>, + TField extends keyof TConfigs, +> = { + operator: GetFieldOperators; + value: TConfigs[TField]["type"] extends "number" ? number : string; +}; + +/** + * URL value type for a field (handles special fields) + */ +type FieldUrlValueType< + TConfigs extends Record>, + TField extends keyof TConfigs, +> = IsTimeField extends true + ? TConfigs[TField]["type"] extends "number" + ? number | null + : string | null + : IsRelativeTimeField extends true + ? string | null + : FilterUrlValue[] | null; + +/** + * Query search params type + */ +type QuerySearchParamsType>> = { + [K in keyof TConfigs]: FieldUrlValueType; +}; + +/** + * Base schema shape for field configurations + */ +type BaseSchemaShape>> = { + [K in keyof TConfigs]: IsTimeField extends true + ? TConfigs[K]["type"] extends "number" + ? z.ZodNumber + : z.ZodString + : IsRelativeTimeField extends true + ? z.ZodString + : z.ZodNullable< + z.ZodObject<{ + filters: z.ZodArray< + z.ZodObject<{ + operator: TConfigs[K]["operators"]["length"] extends 1 + ? z.ZodLiteral + : //@ts-expect-error safe to ignore + z.ZodEnum; + value: TConfigs[K]["type"] extends "number" ? z.ZodNumber : z.ZodString; + }> + >; + }> + >; +}; + +/** + * Pagination schema shape + */ +type PaginationSchemaShape = { + limit: z.ZodNumber; + cursor: z.ZodOptional>; +}; + +/** + * Compile-time API Schema Shape type - conditionally includes pagination + */ +type BuildApiSchemaShape< + TConfigs extends Record>, + TPagination extends boolean = false, +> = TPagination extends true + ? PaginationSchemaShape & BaseSchemaShape + : BaseSchemaShape; + +/** + * Options for createFilterSchema + */ +type CreateFilterSchemaOptions = { + pagination?: boolean; +}; + +/** + * Creates a complete filter schema from field configurations + * This is the factory function that generates everything + */ +export function createFilterSchema< + TPrefix extends string, + TConfigs extends Record>, + TOptions extends CreateFilterSchemaOptions = CreateFilterSchemaOptions, +>(prefix: TPrefix, fieldConfigs: TConfigs, options: TOptions = {} as TOptions) { + type AllOperators = ExtractAllOperators; + type FilterValueForField = FilterUrlValue; + + const fieldNames = Object.keys(fieldConfigs) as (keyof TConfigs)[]; + + if (fieldNames.length === 0) { + throw new Error(`${prefix}FilterFieldConfig must contain at least one field definition.`); + } + + // Extract all unique operators + const allOperators = Array.from( + new Set(Object.values(fieldConfigs).flatMap((config) => config.operators)), + ) as AllOperators[]; + + const operatorEnum = z.enum(allOperators as [AllOperators, ...AllOperators[]]); + + const [firstFieldName, ...restFieldNames] = fieldNames; + + //@ts-expect-error safe to ignore + const fieldEnum = z.enum([firstFieldName, ...restFieldNames] as [ + keyof TConfigs, + ...(keyof TConfigs)[], + ]); + + const queryParamsPayload = Object.fromEntries( + fieldNames.map((fieldName) => { + const config = fieldConfigs[fieldName]; + + if ("isTimeField" in config && config.isTimeField) { + return [fieldName, parseAsInteger]; + } + + if ("isRelativeTimeField" in config && config.isRelativeTimeField) { + return [fieldName, parseAsRelativeTime]; + } + + // Regular fields use parseAsFilterValueArray + //@ts-expect-error safe to ignore + return [fieldName, parseAsFilterValueArray(config.operators)]; + }), + ); + + function createApiQuerySchema() { + const schemaDefinition = {} as Record; + + // Add pagination fields only if requested + if (options.pagination) { + schemaDefinition.limit = z.number().int(); + schemaDefinition.cursor = z.number().nullable().optional(); + } + + // Process each field with exact type matching + fieldNames.forEach((fieldName) => { + const config = fieldConfigs[fieldName]; + + if ("isTimeField" in config && config.isTimeField) { + const fieldSchema = config.type === "number" ? z.number().int() : z.string(); + schemaDefinition[fieldName as string] = fieldSchema; + return; + } + + if ("isRelativeTimeField" in config && config.isRelativeTimeField) { + schemaDefinition[fieldName as string] = z.string(); + return; + } + + // Regular filter fields + const operatorSchema = + config.operators.length === 1 + ? z.literal(config.operators[0]) + : z.enum(config.operators as [string, ...string[]]); + + const valueSchema = config.type === "number" ? z.number() : z.string(); + + schemaDefinition[fieldName as string] = z + .object({ + filters: z.array( + z.object({ + operator: operatorSchema, + value: valueSchema, + }), + ), + }) + .nullable(); + }); + + return z.object(schemaDefinition); + } + + const filterOutputSchema = createFilterOutputSchema(fieldEnum, operatorEnum, fieldConfigs); + + const apiQuerySchema = createApiQuerySchema(); + + //@ts-expect-error safe to ignore + const parseAsAllOperatorsFilterArray = parseAsFilterValueArray(allOperators); + + type OperatorType = z.infer; + type FieldType = z.infer; + + //@ts-expect-error safe to ignore + type FilterValueType = FilterValue; + type AllOperatorsUrlValueType = FilterValueForField; + + return { + // Configuration + fieldConfigs, + fieldNames, + + // Enums + operatorEnum, + fieldEnum, + + // Schemas + filterOutputSchema, + apiQuerySchema: apiQuerySchema as TOptions["pagination"] extends true + ? z.ZodObject> + : z.ZodObject>, + + // Parsers + queryParamsPayload, + parseAsAllOperatorsFilterArray, + + types: {} as { + Operator: OperatorType; + Field: FieldType; + FilterValue: FilterValueType; + QuerySearchParams: QuerySearchParamsType; + AllOperatorsUrlValue: AllOperatorsUrlValueType; + }, + }; +} diff --git a/apps/dashboard/lib/filters/filter-hook.ts b/apps/dashboard/lib/filters/filter-hook.ts new file mode 100644 index 0000000000..5979817814 --- /dev/null +++ b/apps/dashboard/lib/filters/filter-hook.ts @@ -0,0 +1,196 @@ +import { type UseQueryStatesKeysMap, useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; + +type FilterConfig = { + type: "string" | "number"; + operators: readonly string[]; + isTimeField?: boolean; + isRelativeTimeField?: boolean; + getColorClass?: (value: unknown) => string; +}; + +type FilterConfigs = Record; + +type FilterValue = { + id: string; + field: TField; + operator: TOperator; + value: string | number; + metadata?: { + colorClass?: string; + }; +}; + +type UrlFilterValue = { + operator: string; + value: string | number; +}; + +export function createUseFilters< + TFilterSchema extends { + fieldConfigs: FilterConfigs; + queryParamsPayload: UseQueryStatesKeysMap; + types: { + Operator: string; + Field: string; + FilterValue: FilterValue; + QuerySearchParams: Record; + }; + }, +>(filterSchema: TFilterSchema) { + return function useFilters() { + const [searchParams, setSearchParams] = useQueryStates(filterSchema.queryParamsPayload, { + history: "push", + }); + + const filters = useMemo(() => { + const activeFilters: TFilterSchema["types"]["FilterValue"][] = []; + + Object.entries(searchParams).forEach(([fieldName, fieldValue]) => { + const field = fieldName as TFilterSchema["types"]["Field"]; + const config = filterSchema.fieldConfigs[field]; + + if (!config || fieldValue === null || fieldValue === undefined) { + return; + } + + // Handle time fields (direct values) + if (config.isTimeField) { + activeFilters.push({ + id: crypto.randomUUID(), + field, + operator: "is" as TFilterSchema["types"]["Operator"], + value: fieldValue as number, + } as TFilterSchema["types"]["FilterValue"]); + return; + } + + // Handle relative time fields (direct values) + if (config.isRelativeTimeField) { + activeFilters.push({ + id: crypto.randomUUID(), + field, + operator: "is" as TFilterSchema["types"]["Operator"], + value: fieldValue as string, + } as TFilterSchema["types"]["FilterValue"]); + return; + } + + // Handle regular filter arrays + if (Array.isArray(fieldValue)) { + fieldValue.forEach((filterItem: UrlFilterValue) => { + activeFilters.push({ + id: crypto.randomUUID(), + field, + operator: filterItem.operator as TFilterSchema["types"]["Operator"], + value: filterItem.value, + metadata: config.getColorClass + ? { + colorClass: config.getColorClass(filterItem.value), + } + : undefined, + } as TFilterSchema["types"]["FilterValue"]); + }); + } + }); + + return activeFilters; + }, [searchParams, filterSchema]); + + const updateFilters = useCallback( + (newFilters: TFilterSchema["types"]["FilterValue"][]) => { + const newParams = {} as Record; + + // Initialize all fields to null + Object.keys(filterSchema.fieldConfigs).forEach((field) => { + newParams[field as TFilterSchema["types"]["Field"]] = null; + }); + + // Group filters by field + const filterGroups: Record< + TFilterSchema["types"]["Field"], + TFilterSchema["types"]["FilterValue"][] + > = {} as Record; + + newFilters.forEach((filter) => { + const field = filter.field; + //@ts-expect-error safe to ignore + if (!filterGroups[field]) { + //@ts-expect-error safe to ignore + filterGroups[field] = []; + } + //@ts-expect-error safe to ignore + filterGroups[field].push(filter); + }); + + // Convert filter groups to URL params + ( + Object.entries(filterGroups) as [ + TFilterSchema["types"]["Field"], + TFilterSchema["types"]["FilterValue"][], + ][] + ).forEach(([field, filters]) => { + const config = filterSchema.fieldConfigs[field]; + + if (config.isTimeField) { + const timeFilter = filters[0]; + if (timeFilter) { + newParams[field] = timeFilter.value as number; + } + return; + } + + if (config.isRelativeTimeField) { + const relativeTimeFilter = filters[0]; + if (relativeTimeFilter) { + newParams[field] = relativeTimeFilter.value as string; + } + return; + } + + const filterArray = filters.map((filter) => ({ + operator: filter.operator, + value: filter.value, + })); + + newParams[field] = filterArray.length > 0 ? filterArray : null; + }); + + setSearchParams(newParams as Partial); + }, + [setSearchParams, filterSchema], + ); + + const removeFilter = useCallback( + (id: string) => { + const newFilters = filters.filter((f) => f.id !== id); + updateFilters(newFilters); + }, + [filters, updateFilters], + ); + + const addFilter = useCallback( + (filter: Omit) => { + const newFilter: TFilterSchema["types"]["FilterValue"] = { + ...filter, + id: crypto.randomUUID(), + } as TFilterSchema["types"]["FilterValue"]; + updateFilters([...filters, newFilter]); + }, + [filters, updateFilters], + ); + + const clearAllFilters = useCallback(() => { + updateFilters([]); + }, [updateFilters]); + + return { + filters, + searchParams, + removeFilter, + addFilter, + updateFilters, + clearAllFilters, + }; + }; +} diff --git a/apps/dashboard/lib/trpc/routers/logs/query-logs/index.ts b/apps/dashboard/lib/trpc/routers/logs/query-logs/index.ts index aacac64412..9a2de514a6 100644 --- a/apps/dashboard/lib/trpc/routers/logs/query-logs/index.ts +++ b/apps/dashboard/lib/trpc/routers/logs/query-logs/index.ts @@ -1,4 +1,4 @@ -import { queryLogsPayload } from "@/app/(app)/logs/components/table/query-logs.schema"; +import { queryLogsPayload } from "@/app/(app)/logs/filters.schema"; import { clickhouse } from "@/lib/clickhouse"; import { db } from "@/lib/db"; import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; diff --git a/apps/dashboard/lib/trpc/routers/logs/query-logs/utils.test.ts b/apps/dashboard/lib/trpc/routers/logs/query-logs/utils.test.ts deleted file mode 100644 index 6f4223ee3e..0000000000 --- a/apps/dashboard/lib/trpc/routers/logs/query-logs/utils.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { transformFilters } from "./utils"; - -describe("transformFilters", () => { - const basePayload = { - startTime: 1706024400000, - endTime: 1706028000000, - since: "", - limit: 50, - path: null, - host: null, - method: null, - status: null, - requestId: null, - cursor: null, - }; - - it("should transform empty filters", () => { - const result = transformFilters(basePayload); - - expect(result).toEqual({ - startTime: basePayload.startTime, - endTime: basePayload.endTime, - limit: 50, - hosts: [], - methods: [], - paths: [], - statusCodes: [], - requestIds: [], - cursorTime: null, - }); - }); - - it("should transform all filters with values", () => { - const payload = { - ...basePayload, - host: { - filters: [{ operator: "is" as const, value: "example.com" }], - }, - method: { - filters: [{ operator: "is" as const, value: "GET" }], - }, - path: { - filters: [{ operator: "startsWith" as const, value: "/api" }], - }, - status: { - filters: [{ operator: "is" as const, value: 200 }], - }, - requestId: { - filters: [{ operator: "is" as const, value: "req123" }], - }, - cursor: 1706024400000, - }; - - const result = transformFilters(payload); - - expect(result).toEqual({ - startTime: payload.startTime, - endTime: payload.endTime, - limit: 50, - hosts: ["example.com"], - methods: ["GET"], - paths: [{ operator: "startsWith", value: "/api" }], - statusCodes: [200], - requestIds: ["req123"], - cursorTime: 1706024400000, - }); - }); - - it("should handle relative time with since parameter", () => { - const payload = { - ...basePayload, - since: "24h", - }; - - const result = transformFilters(payload); - - expect(result.endTime).toBeGreaterThan(result.startTime); - expect(result.endTime - result.startTime).toBeCloseTo(24 * 60 * 60 * 1000, -2); - }); - - it("should handle cursor values", () => { - const payload = { - ...basePayload, - cursor: 1706024400000, - }; - - const result = transformFilters(payload); - - expect(result.cursorTime).toBe(1706024400000); - }); -}); diff --git a/apps/dashboard/lib/trpc/routers/logs/query-logs/utils.ts b/apps/dashboard/lib/trpc/routers/logs/query-logs/utils.ts index 1ca631ccb1..eb82e2f6f5 100644 --- a/apps/dashboard/lib/trpc/routers/logs/query-logs/utils.ts +++ b/apps/dashboard/lib/trpc/routers/logs/query-logs/utils.ts @@ -1,14 +1,13 @@ -import type { queryLogsPayload } from "@/app/(app)/logs/components/table/query-logs.schema"; +import type { QueryLogsPayload } from "@/app/(app)/logs/filters.schema"; import { getTimestampFromRelative } from "@/lib/utils"; import type { GetLogsClickhousePayload } from "@unkey/clickhouse/src/logs"; -import type { z } from "zod"; export function transformFilters( - params: z.infer, + params: QueryLogsPayload, ): Omit { // Transform path filters to include operators const paths = - params.path?.filters.map((f) => ({ + params.paths?.filters.map((f) => ({ operator: f.operator, value: f.value, })) || []; @@ -16,7 +15,7 @@ export function transformFilters( // Extract other filters as before const requestIds = params.requestId?.filters.map((f) => f.value) || []; const hosts = params.host?.filters.map((f) => f.value) || []; - const methods = params.method?.filters.map((f) => f.value) || []; + const methods = params.methods?.filters.map((f) => f.value) || []; const statusCodes = params.status?.filters.map((f) => f.value) || []; let startTime = params.startTime; diff --git a/apps/dashboard/lib/trpc/routers/logs/query-timeseries/index.ts b/apps/dashboard/lib/trpc/routers/logs/query-timeseries/index.ts index 1669752481..c3b4d40a5f 100644 --- a/apps/dashboard/lib/trpc/routers/logs/query-timeseries/index.ts +++ b/apps/dashboard/lib/trpc/routers/logs/query-timeseries/index.ts @@ -1,7 +1,7 @@ -import { queryTimeseriesPayload } from "@/app/(app)/logs/components/charts/query-timeseries.schema"; import { clickhouse } from "@/lib/clickhouse"; import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { queryLogsPayload } from "@/app/(app)/logs/filters.schema"; import { TRPCError } from "@trpc/server"; import { transformFilters } from "./utils"; @@ -9,7 +9,7 @@ export const queryTimeseries = t.procedure .use(requireUser) .use(requireWorkspace) .use(withRatelimit(ratelimit.read)) - .input(queryTimeseriesPayload) + .input(queryLogsPayload.omit({ limit: true, cursor: true })) .query(async ({ ctx, input }) => { const { params: transformedInputs, granularity } = transformFilters(input); const result = await clickhouse.api.timeseries[granularity]({ diff --git a/apps/dashboard/lib/trpc/routers/logs/query-timeseries/utils.ts b/apps/dashboard/lib/trpc/routers/logs/query-timeseries/utils.ts index dab121e671..8f238d9483 100644 --- a/apps/dashboard/lib/trpc/routers/logs/query-timeseries/utils.ts +++ b/apps/dashboard/lib/trpc/routers/logs/query-timeseries/utils.ts @@ -1,14 +1,13 @@ -import type { queryTimeseriesPayload } from "@/app/(app)/logs/components/charts/query-timeseries.schema"; +import type { QueryLogsPayload } from "@/app/(app)/logs/filters.schema"; import { getTimestampFromRelative } from "@/lib/utils"; import type { LogsTimeseriesParams } from "@unkey/clickhouse/src/logs"; -import type { z } from "zod"; import { type RegularTimeseriesGranularity, type TimeseriesConfig, getTimeseriesGranularity, } from "../../utils/granularity"; -export function transformFilters(params: z.infer): { +export function transformFilters(params: Omit): { params: Omit; granularity: RegularTimeseriesGranularity; } { @@ -26,9 +25,9 @@ export function transformFilters(params: z.infer) startTime: timeConfig.startTime, endTime: timeConfig.endTime, hosts: params.host?.filters.map((f) => f.value) || [], - methods: params.method?.filters.map((f) => f.value) || [], + methods: params.methods?.filters.map((f) => f.value) || [], paths: - params.path?.filters.map((f) => ({ + params.paths?.filters.map((f) => ({ operator: f.operator, value: f.value, })) || [],