From f2f097e4268594318765987e4ca5348c5d71bb72 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 18 Sep 2025 13:27:59 +0300 Subject: [PATCH 01/27] feat: add init lgos --- apps/dashboard/app/(app)/logs/page.tsx | 17 +- .../charts/hooks/use-fetch-timeseries.ts | 105 +++++++ .../logs/components/charts/index.tsx | 75 +++++ .../charts/query-timeseries.schema.ts | 48 +++ .../logs/components/control-cloud/index.tsx | 59 ++++ .../components/logs-datetime/index.tsx | 90 ++++++ .../components/methods-filter.tsx | 35 +++ .../logs-filters/components/paths-filter.tsx | 36 +++ .../logs-filters/components/status-filter.tsx | 70 +++++ .../components/logs-filters/index.tsx | 61 ++++ .../controls/components/logs-live-switch.tsx | 34 +++ .../components/logs-queries/index.tsx | 33 ++ .../controls/components/logs-queries/utils.ts | 107 +++++++ .../controls/components/logs-refresh.tsx | 20 ++ .../controls/components/logs-search/index.tsx | 58 ++++ .../logs/components/controls/index.tsx | 33 ++ .../table/hooks/use-logs-query.test.ts | 154 ++++++++++ .../components/table/hooks/use-logs-query.ts | 245 +++++++++++++++ .../log-details/components/log-footer.tsx | 106 +++++++ .../log-details/components/log-header.tsx | 42 +++ .../table/log-details/components/log-meta.tsx | 22 ++ .../log-details/components/log-section.tsx | 56 ++++ .../components/table/log-details/index.tsx | 77 +++++ .../logs/components/table/logs-table.tsx | 286 ++++++++++++++++++ .../components/table/query-logs.schema.ts | 60 ++++ .../projects/[projectId]/logs/constants.ts | 7 + .../[projectId]/logs/context/logs.tsx | 97 ++++++ .../[projectId]/logs/filters.schema.ts | 114 +++++++ .../[projectId]/logs/hooks/use-filters.ts | 191 ++++++++++++ .../(app)/projects/[projectId]/logs/page.tsx | 25 +- .../(app)/projects/[projectId]/logs/types.ts | 1 + .../(app)/projects/[projectId]/logs/utils.ts | 72 +++++ .../navigations/project-sub-navigation.tsx | 6 +- 33 files changed, 2422 insertions(+), 20 deletions(-) create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/hooks/use-fetch-timeseries.ts create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/index.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/query-timeseries.schema.ts create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/control-cloud/index.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-datetime/index.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/methods-filter.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/paths-filter.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/status-filter.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/index.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-live-switch.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/index.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/utils.ts create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-refresh.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-search/index.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/index.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.test.ts create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.ts create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-footer.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-header.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-meta.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-section.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/index.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/logs-table.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/query-logs.schema.ts create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/constants.ts create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/context/logs.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/filters.schema.ts create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/hooks/use-filters.ts create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/types.ts create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/utils.ts diff --git a/apps/dashboard/app/(app)/logs/page.tsx b/apps/dashboard/app/(app)/logs/page.tsx index e7b62239b7..ddb1511017 100644 --- a/apps/dashboard/app/(app)/logs/page.tsx +++ b/apps/dashboard/app/(app)/logs/page.tsx @@ -1,23 +1,10 @@ -import { getAuth } from "@/lib/auth"; -import { db } from "@/lib/db"; +"use client"; import { Layers3 } from "@unkey/icons"; -import { notFound } from "next/navigation"; import { LogsClient } from "./components/logs-client"; import { Navigation } from "@/components/navigation/navigation"; -export const dynamic = "force-dynamic"; - -export default async function Page() { - const { orgId } = await getAuth(); - - const workspace = await db.query.workspaces.findFirst({ - where: (table, { and, eq, isNull }) => and(eq(table.orgId, orgId), isNull(table.deletedAtM)), - }); - - if (!workspace) { - return notFound(); - } +export default function Page() { return (
} /> diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/hooks/use-fetch-timeseries.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/hooks/use-fetch-timeseries.ts new file mode 100644 index 0000000000..80ad4fda31 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/hooks/use-fetch-timeseries.ts @@ -0,0 +1,105 @@ +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"; + +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 { data, isLoading, isError } = trpc.logs.queryTimeseries.useQuery(queryParams, { + refetchInterval: queryParams.endTime ? false : 10_000, + }); + + const timeseries = data?.timeseries.map((ts) => ({ + displayX: formatTimestampForChart(ts.x, data.granularity), + originalTimestamp: ts.x, + ...ts.y, + })); + + return { timeseries, isLoading, isError, granularity: data?.granularity }; +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/index.tsx new file mode 100644 index 0000000000..410d196bc7 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/index.tsx @@ -0,0 +1,75 @@ +"use client"; +import { LogsTimeseriesBarChart } from "@/components/logs/chart"; +import { getTimeBufferForGranularity } from "@/lib/trpc/routers/utils/granularity"; +import { useFilters } from "../../hooks/use-filters"; +import { useFetchTimeseries } from "./hooks/use-fetch-timeseries"; + +export function LogsChart({ + onMount, +}: { + onMount: (distanceToTop: number) => void; +}) { + const { filters, updateFilters } = useFilters(); + const { timeseries, isLoading, isError, granularity } = useFetchTimeseries(); + + const handleSelectionChange = ({ + start, + end, + }: { + start: number; + end: number; + }) => { + const activeFilters = filters.filter( + (f) => !["startTime", "endTime", "since"].includes(f.field), + ); + + let adjustedEnd = end; + if (start === end && granularity) { + adjustedEnd = end + getTimeBufferForGranularity(granularity); + } + + updateFilters([ + ...activeFilters, + { + field: "startTime", + value: start, + id: crypto.randomUUID(), + operator: "is", + }, + { + field: "endTime", + value: adjustedEnd, + id: crypto.randomUUID(), + operator: "is", + }, + ]); + }; + + return ( + + ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/query-timeseries.schema.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/query-timeseries.schema.ts new file mode 100644 index 0000000000..2a833ea81a --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/query-timeseries.schema.ts @@ -0,0 +1,48 @@ +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)/projects/[projectId]/logs/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/control-cloud/index.tsx new file mode 100644 index 0000000000..e936451c92 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/control-cloud/index.tsx @@ -0,0 +1,59 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import { ControlCloud } from "@unkey/ui"; +import { format } from "date-fns"; +import { useFilters } from "../../hooks/use-filters"; + +const formatFieldName = (field: string): string => { + switch (field) { + case "startTime": + return "Start time"; + case "endTime": + return "End time"; + case "status": + return "Status"; + case "paths": + return "Path"; + case "methods": + return "Method"; + case "requestId": + return "Request ID"; + case "since": + return ""; + default: + return field.charAt(0).toUpperCase() + field.slice(1); + } +}; + +const formatValue = (value: string | number, field: string): string => { + if (typeof value === "string" && /^\d+$/.test(value)) { + const statusFamily = Math.floor(Number.parseInt(value) / 100); + switch (statusFamily) { + case 5: + return "5xx (Error)"; + case 4: + return "4xx (Warning)"; + case 2: + return "2xx (Success)"; + default: + return `${statusFamily}xx`; + } + } + if (typeof value === "number" && (field === "startTime" || field === "endTime")) { + return format(value, "MMM d, yyyy HH:mm:ss"); + } + return String(value); +}; + +export const LogsControlCloud = () => { + const { filters, updateFilters, removeFilter } = useFilters(); + return ( + + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-datetime/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-datetime/index.tsx new file mode 100644 index 0000000000..ca034d3507 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-datetime/index.tsx @@ -0,0 +1,90 @@ +import { DatetimePopover } from "@/components/logs/datetime/datetime-popover"; +import { cn } from "@/lib/utils"; +import { Calendar } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { useEffect, useState } from "react"; +import { useFilters } from "../../../../hooks/use-filters"; + +export const LogsDateTime = () => { + const [title, setTitle] = useState(null); + const { filters, updateFilters } = useFilters(); + + useEffect(() => { + if (!title) { + setTitle("Last 12 hours"); + } + }, [title]); + + const timeValues = filters + .filter((f) => ["startTime", "endTime", "since"].includes(f.field)) + .reduce( + (acc, f) => ({ + // biome-ignore lint/performance/noAccumulatingSpread: it's safe to spread + ...acc, + [f.field]: f.value, + }), + {}, + ); + + return ( + { + const activeFilters = filters.filter( + (f) => !["endTime", "startTime", "since"].includes(f.field), + ); + if (since !== undefined) { + updateFilters([ + ...activeFilters, + { + field: "since", + value: since, + id: crypto.randomUUID(), + operator: "is", + }, + ]); + return; + } + if (since === undefined && startTime) { + activeFilters.push({ + field: "startTime", + value: startTime, + id: crypto.randomUUID(), + operator: "is", + }); + if (endTime) { + activeFilters.push({ + field: "endTime", + value: endTime, + id: crypto.randomUUID(), + operator: "is", + }); + } + } + updateFilters(activeFilters); + }} + initialTitle={title ?? ""} + onSuggestionChange={setTitle} + > +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/methods-filter.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/methods-filter.tsx new file mode 100644 index 0000000000..2887f8f528 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/methods-filter.tsx @@ -0,0 +1,35 @@ +import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; +import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; + +type MethodOption = { + id: number; + method: string; + checked: boolean; +}; + +const options: MethodOption[] = [ + { id: 1, method: "GET", checked: false }, + { id: 2, method: "POST", checked: false }, + { id: 3, method: "PUT", checked: false }, + { id: 4, method: "DELETE", checked: false }, + { id: 5, method: "PATCH", checked: false }, +] as const; + +export const MethodsFilter = () => { + const { filters, updateFilters } = useFilters(); + return ( + ( +
{checkbox.method}
+ )} + createFilterValue={(option) => ({ + value: option.method, + })} + filters={filters} + updateFilters={updateFilters} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/paths-filter.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/paths-filter.tsx new file mode 100644 index 0000000000..c7128a460a --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/paths-filter.tsx @@ -0,0 +1,36 @@ +import { logsFilterFieldConfig } from "@/app/(app)/logs/filters.schema"; +import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; +import { FilterOperatorInput } from "@/components/logs/filter-operator-input"; + +export const PathsFilter = () => { + const { filters, updateFilters } = useFilters(); + + const pathOperators = logsFilterFieldConfig.paths.operators; + const options = pathOperators.map((op) => ({ + id: op, + label: op, + })); + + const activePathFilter = filters.find((f) => f.field === "paths"); + + return ( + { + const activeFiltersWithoutPaths = filters.filter((f) => f.field !== "paths"); + updateFilters([ + ...activeFiltersWithoutPaths, + { + field: "paths", + id: crypto.randomUUID(), + operator: id, + value: text, + }, + ]); + }} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/status-filter.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/status-filter.tsx new file mode 100644 index 0000000000..fc574f3f7b --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/status-filter.tsx @@ -0,0 +1,70 @@ +import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; +import type { ResponseStatus } from "@/app/(app)/logs/types"; +import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; + +type StatusOption = { + id: number; + status: ResponseStatus; + display: string; + label: string; + color: string; + checked: boolean; +}; + +const options: StatusOption[] = [ + { + id: 1, + status: 200, + display: "2xx", + label: "Success", + color: "bg-success-9", + checked: false, + }, + { + id: 2, + status: 400, + display: "4xx", + label: "Warning", + color: "bg-warning-8", + checked: false, + }, + { + id: 3, + status: 500, + display: "5xx", + label: "Error", + color: "bg-error-9", + checked: false, + }, +]; + +export const StatusFilter = () => { + const { filters, updateFilters } = useFilters(); + return ( + ( + <> +
+ {checkbox.display} + {checkbox.label} + + )} + createFilterValue={(option) => ({ + value: option.status, + metadata: { + colorClass: + option.status >= 500 + ? "bg-error-9" + : option.status >= 400 + ? "bg-warning-8" + : "bg-success-9", + }, + })} + filters={filters} + updateFilters={updateFilters} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/index.tsx new file mode 100644 index 0000000000..38eed0a0b2 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/index.tsx @@ -0,0 +1,61 @@ +import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; +import { type FilterItemConfig, FiltersPopover } from "@/components/logs/checkbox/filters-popover"; +import { BarsFilter } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { MethodsFilter } from "./components/methods-filter"; +import { PathsFilter } from "./components/paths-filter"; +import { StatusFilter } from "./components/status-filter"; + +const FILTER_ITEMS: FilterItemConfig[] = [ + { + id: "status", + label: "Status", + shortcut: "E", + shortcutLabel: "E", + component: , + }, + { + id: "methods", + label: "Method", + shortcut: "M", + shortcutLabel: "M", + component: , + }, + { + id: "paths", + label: "Path", + shortcut: "P", + shortcutLabel: "P", + component: , + }, +]; + +export const LogsFilters = () => { + const { filters } = useFilters(); + return ( + +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-live-switch.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-live-switch.tsx new file mode 100644 index 0000000000..8c495d4eb7 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-live-switch.tsx @@ -0,0 +1,34 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import { LiveSwitchButton } from "@/components/logs/live-switch-button"; +import { useLogsContext } from "../../../context/logs"; +import { useFilters } from "../../../hooks/use-filters"; + +export const LogsLiveSwitch = () => { + const { isLive, toggleLive } = useLogsContext(); + const { filters, updateFilters } = useFilters(); + + const handleSwitch = () => { + toggleLive(); + // To able to refetch historic data again we have to update the endTime + if (isLive) { + const timestamp = Date.now(); + const activeFilters = filters.filter((f) => !["endTime", "startTime"].includes(f.field)); + updateFilters([ + ...activeFilters, + { + field: "endTime", + value: timestamp, + id: crypto.randomUUID(), + operator: "is", + }, + { + field: "startTime", + value: timestamp - HISTORICAL_DATA_WINDOW, + id: crypto.randomUUID(), + operator: "is", + }, + ]); + } + }; + return ; +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/index.tsx new file mode 100644 index 0000000000..69daf07819 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/index.tsx @@ -0,0 +1,33 @@ +import { QueriesPopover } from "@/components/logs/queries/queries-popover"; +import { cn } from "@/lib/utils"; +import { ChartBarAxisY } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { useFilters } from "../../../../hooks/use-filters"; +import { formatFilterValues, getFilterFieldIcon } from "./utils"; +export const LogsQueries = () => { + const { filters, updateFilters } = useFilters(); + + return ( + +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/utils.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/utils.ts new file mode 100644 index 0000000000..c008182006 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/utils.ts @@ -0,0 +1,107 @@ +import type { QuerySearchParams } from "@/app/(app)/logs/filters.schema"; +import { iconsPerField } from "@/components/logs/queries/utils"; +import { ChartActivity2 } from "@unkey/icons"; +import { format } from "date-fns"; +import React from "react"; + +export function formatFilterValues( + filters: QuerySearchParams, +): Record { + const transform = (field: string, value: string): { color: string | null; value: string } => { + switch (field) { + case "status": + return { + value: + value === "200" ? "2xx" : value === "400" ? "4xx" : value === "500" ? "5xx" : value, + color: value.startsWith("2") + ? "bg-success-9" + : value.startsWith("4") + ? "bg-warning-9" + : value.startsWith("5") + ? "bg-error-9" + : null, + }; + case "methods": + return { value: value.toUpperCase(), color: null }; + case "startTime": + case "endTime": + return { value: format(Number(value), "MMM d HH:mm:ss"), color: null }; + case "since": + return { value: value, color: null }; + case "host": + case "requestId": + case "paths": + return { value: value, color: null }; + default: + return { value: value, color: null }; + } + }; + + const transformed: Record< + string, + { operator: string; values: { value: string; color: string | null }[] } + > = {}; + + // Handle special cases for different field types const transformed: Record = {}; + if (filters.startTime && filters.endTime) { + transformed.time = { + operator: "between", + values: [ + transform("startTime", filters.startTime.toString()), + transform("endTime", filters.endTime.toString()), + ], + }; + } else if (filters.startTime) { + transformed.time = { + operator: "starts from", + values: [transform("startTime", filters.startTime.toString())], + }; + } else if (filters.since) { + transformed.time = { + operator: "since", + values: [{ value: filters.since, color: null }], + }; + } + + Object.entries(filters).forEach(([field, value]) => { + if (field === "startTime" || field === "endTime" || field === "since" || field === "time") { + return []; + } + + if (value === null) { + return; + } + + if (Array.isArray(value)) { + transformed[field] = { + operator: value[0]?.operator || "is", + values: value.map((v) => transform(field, v.value.toString())), + }; + } else { + transformed[field] = { + operator: "is", + values: [transform(field, value.toString())], + }; + } + }); + + return transformed; +} + +export function getFilterFieldIcon(field: string): JSX.Element { + const Icon = iconsPerField[field] || ChartActivity2; + return React.createElement(Icon, { size: "md-regular", className: "justify-center" }); +} + +export const FieldsToTruncate = [ + "users", + "workspaceId", + "keyId", + "apiId", + "requestId", + "responseId", +] as const; + +export function shouldTruncateRow(field: string): boolean { + return FieldsToTruncate.includes(field as (typeof FieldsToTruncate)[number]); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-refresh.tsx new file mode 100644 index 0000000000..e21cb8be16 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-refresh.tsx @@ -0,0 +1,20 @@ +import { trpc } from "@/lib/trpc/client"; +import { useQueryTime } from "@/providers/query-time-provider"; +import { RefreshButton } from "@unkey/ui"; +import { useLogsContext } from "../../../context/logs"; + +export const LogsRefresh = () => { + const { toggleLive, isLive } = useLogsContext(); + const { refreshQueryTime } = useQueryTime(); + const { logs } = trpc.useUtils(); + + const handleRefresh = () => { + refreshQueryTime(); + logs.queryLogs.invalidate(); + logs.queryTimeseries.invalidate(); + }; + + return ( + + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-search/index.tsx new file mode 100644 index 0000000000..58a0e5f7d0 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-search/index.tsx @@ -0,0 +1,58 @@ +import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; +import { trpc } from "@/lib/trpc/client"; +import { LLMSearch, toast, transformStructuredOutputToFilters } from "@unkey/ui"; + +export const LogsSearch = () => { + const { filters, updateFilters } = useFilters(); + const queryLLMForStructuredOutput = trpc.logs.llmSearch.useMutation({ + onSuccess(data) { + if (data?.filters.length === 0 || !data) { + toast.error( + "Please provide more specific search criteria. Your query requires additional details for accurate results.", + { + duration: 8000, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + }, + ); + return; + } + const transformedFilters = transformStructuredOutputToFilters(data, filters); + updateFilters(transformedFilters); + }, + onError(error) { + const errorMessage = `Unable to process your search request${ + error.message ? `' ${error.message} '` : "." + } Please try again or refine your search criteria.`; + + toast.error(errorMessage, { + duration: 8000, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + className: "font-medium", + }); + }, + }); + + return ( + + queryLLMForStructuredOutput.mutateAsync({ + query, + timestamp: Date.now(), + }) + } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/index.tsx new file mode 100644 index 0000000000..99d34144d0 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/index.tsx @@ -0,0 +1,33 @@ +import { + ControlsContainer, + ControlsLeft, + ControlsRight, +} from "@/components/logs/controls-container"; +import { Separator } from "@unkey/ui"; +import { LogsDateTime } from "./components/logs-datetime"; +import { LogsFilters } from "./components/logs-filters"; +import { LogsLiveSwitch } from "./components/logs-live-switch"; +import { LogsQueries } from "./components/logs-queries"; +import { LogsRefresh } from "./components/logs-refresh"; +import { LogsSearch } from "./components/logs-search"; + +export function LogsControls() { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.test.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.test.ts new file mode 100644 index 0000000000..1750654b2a --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.test.ts @@ -0,0 +1,154 @@ +import { trpc } from "@/lib/trpc/client"; +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useLogsQuery } from "./use-logs-query"; + +let mockFilters: any[] = []; +const mockDate = 1706024400000; + +vi.mock("@/providers/query-time-provider", () => ({ + QueryTimeProvider: ({ children }: { children: React.ReactNode }) => children, + useQueryTime: () => ({ + queryTime: new Date(mockDate), + refreshQueryTime: vi.fn(), + }), +})); + +vi.mock("@/lib/trpc/client", () => { + const useInfiniteQuery = vi.fn().mockReturnValue({ + data: null, + hasNextPage: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + isLoading: false, + }); + + const fetch = vi.fn(); + + return { + trpc: { + useUtils: () => ({ + logs: { + queryLogs: { + fetch, + }, + }, + }), + logs: { + queryLogs: { + useInfiniteQuery, + }, + }, + }, + }; +}); + +vi.mock("../../../hooks/use-filters", () => ({ + useFilters: () => ({ + filters: mockFilters, + }), +})); + +describe("useLogsQuery filter processing", () => { + beforeEach(() => { + mockFilters = []; + vi.setSystemTime(mockDate); + }); + + it("handles valid status filter", () => { + mockFilters = [{ field: "status", operator: "is", value: "404" }]; + const { result } = renderHook(() => useLogsQuery()); + expect(result.current.isPolling).toBe(false); + }); + + it("handles multiple valid filters", () => { + mockFilters = [ + { field: "status", operator: "is", value: "404" }, + { field: "methods", operator: "is", value: "GET" }, + { field: "paths", operator: "startsWith", value: "/api" }, + ]; + const { result } = renderHook(() => useLogsQuery()); + expect(result.current.isPolling).toBe(false); + }); + + it("handles invalid filter types", () => { + const consoleMock = vi.spyOn(console, "error"); + mockFilters = [ + { field: "methods", operator: "is", value: 123 }, + { field: "paths", operator: "startsWith", value: true }, + { field: "host", operator: "is", value: {} }, + ]; + renderHook(() => useLogsQuery()); + expect(consoleMock).toHaveBeenCalledTimes(6); + }); + + it("handles time-based filters", () => { + mockFilters = [ + { field: "startTime", operator: "is", value: mockDate - 3600000 }, + { field: "since", operator: "is", value: "1h" }, + ]; + const { result } = renderHook(() => useLogsQuery()); + expect(result.current.isPolling).toBe(false); + }); +}); + +describe("useLogsQuery realtime logs", () => { + let useInfiniteQuery: ReturnType; + let fetch: ReturnType; + + beforeEach(() => { + vi.setSystemTime(mockDate); + mockFilters = []; + //@ts-expect-error hacky way to mock trpc + useInfiniteQuery = vi.mocked(trpc.logs.queryLogs.useInfiniteQuery); + //@ts-expect-error hacky way to mock trpc + fetch = vi.mocked(trpc.useUtils().logs.queryLogs.fetch); + }); + + it("resets realtime logs when polling stops", async () => { + const mockLogs = [ + { request_id: "1", time: Date.now(), method: "GET", path: "/api/test" }, + { request_id: "2", time: Date.now(), method: "POST", path: "/api/users" }, + ]; + + useInfiniteQuery.mockReturnValue({ + data: { + pages: [{ logs: mockLogs, nextCursor: null }], + }, + hasNextPage: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + isLoading: false, + }); + + fetch.mockResolvedValue({ + logs: [ + { + request_id: "3", + time: Date.now(), + method: "PUT", + path: "/api/update", + }, + ], + }); + + const { result, rerender } = renderHook( + ({ startPolling, pollIntervalMs }) => useLogsQuery({ startPolling, pollIntervalMs }), + { initialProps: { startPolling: true, pollIntervalMs: 1000 } }, + ); + + expect(result.current.historicalLogs).toHaveLength(2); + + // Wait for polling interval + await act(async () => { + await new Promise((resolve) => setTimeout(resolve)); + }); + + act(() => { + rerender({ startPolling: false, pollIntervalMs: 1000 }); + }); + + expect(result.current.realtimeLogs).toHaveLength(0); + expect(result.current.historicalLogs).toHaveLength(2); + }); +}); diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.ts new file mode 100644 index 0000000000..5bad52e4cc --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.ts @@ -0,0 +1,245 @@ +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"; + +// Duration in milliseconds for historical data fetch window (12 hours) +type UseLogsQueryParams = { + limit?: number; + pollIntervalMs?: number; + startPolling?: boolean; +}; + +const REALTIME_DATA_LIMIT = 100; + +export function useLogsQuery({ + limit = 50, + pollIntervalMs = 5000, + startPolling = false, +}: UseLogsQueryParams = {}) { + const [historicalLogsMap, setHistoricalLogsMap] = useState(() => new Map()); + const [realtimeLogsMap, setRealtimeLogsMap] = useState(() => new Map()); + const [totalCount, setTotalCount] = useState(0); + + const { filters } = useFilters(); + const queryClient = trpc.useUtils(); + const { queryTime: timestamp } = useQueryTime(); + + const realtimeLogs = useMemo(() => { + return sortLogs(Array.from(realtimeLogsMap.values())); + }, [realtimeLogsMap]); + + 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]); + + // Main query for historical data + const { + data: initialData, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + isLoading: isLoadingInitial, + } = trpc.logs.queryLogs.useInfiniteQuery(queryParams, { + getNextPageParam: (lastPage) => lastPage.nextCursor, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + // Query for new logs (polling) + const pollForNewLogs = useCallback(async () => { + try { + const latestTime = realtimeLogs[0]?.time ?? historicalLogs[0]?.time; + const result = await queryClient.logs.queryLogs.fetch({ + ...queryParams, + startTime: latestTime ?? Date.now() - pollIntervalMs, + endTime: Date.now(), + }); + + if (result.logs.length === 0) { + return; + } + + setRealtimeLogsMap((prevMap) => { + const newMap = new Map(prevMap); + let added = 0; + + for (const log of result.logs) { + // Skip if exists in either map + if (newMap.has(log.request_id) || historicalLogsMap.has(log.request_id)) { + continue; + } + + newMap.set(log.request_id, log); + added++; + + // Remove oldest entries when exceeding the size limit `100` + if (newMap.size > Math.min(limit, REALTIME_DATA_LIMIT)) { + const entries = Array.from(newMap.entries()); + const oldestEntry = entries.reduce((oldest, current) => { + return oldest[1].time < current[1].time ? oldest : current; + }); + newMap.delete(oldestEntry[0]); + } + } + + return added > 0 ? newMap : prevMap; + }); + } catch (error) { + console.error("Error polling for new logs:", error); + } + }, [ + queryParams, + queryClient, + limit, + pollIntervalMs, + historicalLogsMap, + realtimeLogs, + historicalLogs, + ]); + + // Set up polling effect + useEffect(() => { + if (startPolling) { + const interval = setInterval(pollForNewLogs, pollIntervalMs); + return () => clearInterval(interval); + } + }, [startPolling, pollForNewLogs, pollIntervalMs]); + + // Update historical logs effect + useEffect(() => { + if (initialData) { + const newMap = new Map(); + initialData.pages.forEach((page) => { + page.logs.forEach((log) => { + newMap.set(log.request_id, log); + }); + }); + setHistoricalLogsMap(newMap); + + if (initialData.pages.length > 0) { + setTotalCount(initialData.pages[0].total); + } + } + }, [initialData]); + + // Reset realtime logs effect + useEffect(() => { + if (!startPolling) { + setRealtimeLogsMap(new Map()); + } + }, [startPolling]); + + return { + realtimeLogs, + historicalLogs, + isLoading: isLoadingInitial, + hasMore: hasNextPage, + loadMore: fetchNextPage, + isLoadingMore: isFetchingNextPage, + isPolling: startPolling, + total: totalCount, + }; +} + +const sortLogs = (logs: Log[]) => { + return logs.toSorted((a, b) => b.time - a.time); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-footer.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-footer.tsx new file mode 100644 index 0000000000..a6aea86212 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-footer.tsx @@ -0,0 +1,106 @@ +"use client"; +import { RED_STATES, YELLOW_STATES } from "@/app/(app)/logs/constants"; +import { extractResponseField, getRequestHeader } from "@/app/(app)/logs/utils"; +import { RequestResponseDetails } from "@/components/logs/details/request-response-details"; +import { cn } from "@/lib/utils"; +import type { Log } from "@unkey/clickhouse/src/logs"; +import { Badge, TimestampInfo } from "@unkey/ui"; + +type Props = { + log: Log; +}; + +const DEFAULT_OUTCOME = "VALID"; +export const LogFooter = ({ log }: Props) => { + return ( + ( + + ), + content: log.time, + tooltipContent: "Copy Time", + tooltipSuccessMessage: "Time copied to clipboard", + skipTooltip: true, + }, + { + label: "Host", + description: (content) => {content}, + content: log.host, + tooltipContent: "Copy Host", + tooltipSuccessMessage: "Host copied to clipboard", + }, + { + label: "Request Path", + description: (content) => {content}, + content: log.path, + tooltipContent: "Copy Request Path", + tooltipSuccessMessage: "Request path copied to clipboard", + }, + { + label: "Request ID", + description: (content) => {content}, + content: log.request_id, + tooltipContent: "Copy Request ID", + tooltipSuccessMessage: "Request ID copied to clipboard", + }, + { + label: "Request User Agent", + description: (content) => {content}, + content: getRequestHeader(log, "user-agent") ?? "", + tooltipContent: "Copy Request User Agent", + tooltipSuccessMessage: "Request user agent copied to clipboard", + }, + { + label: "Outcome", + description: (content) => { + let contentCopy = content; + if (contentCopy == null) { + contentCopy = DEFAULT_OUTCOME; + } + return ( + + {content} + + ); + }, + content: extractResponseField(log, "code"), + tooltipContent: "Copy Outcome", + tooltipSuccessMessage: "Outcome copied to clipboard", + }, + { + label: "Permissions", + description: (content) => ( +
+ {content.map((permission, index) => ( + + {permission} + + ))} +
+ ), + content: extractResponseField(log, "permissions"), + tooltipContent: "Copy Permissions", + tooltipSuccessMessage: "Permissions copied to clipboard", + }, + ]} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-header.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-header.tsx new file mode 100644 index 0000000000..7c50cffa6d --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-header.tsx @@ -0,0 +1,42 @@ +import { cn } from "@/lib/utils"; +import type { Log } from "@unkey/clickhouse/src/logs"; +import { XMark } from "@unkey/icons"; +import { Badge, Button } from "@unkey/ui"; + +type Props = { + log: Log; + onClose: () => void; +}; + +export const LogHeader = ({ onClose, log }: Props) => { + return ( +
+
+ + {log.method} + +

{log.path}

+
+ +
+
+ = 200 && log.response_status < 300, + "bg-warning-3 text-warning-11 hover:bg-warning-4": + log.response_status >= 400 && log.response_status < 500, + "bg-error-3 text-error-11 hover:bg-error-4": log.response_status >= 500, + })} + > + {log.response_status} + + | + +
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-meta.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-meta.tsx new file mode 100644 index 0000000000..b7a60b3bef --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-meta.tsx @@ -0,0 +1,22 @@ +import { Card, CardContent, CopyButton } from "@unkey/ui"; + +export const LogMetaSection = ({ content }: { content: string }) => { + return ( +
+
Meta
+ + +
{content ?? ""} 
+ +
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-section.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-section.tsx new file mode 100644 index 0000000000..276934956d --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-section.tsx @@ -0,0 +1,56 @@ +import { Card, CardContent, CopyButton } from "@unkey/ui"; + +export const LogSection = ({ + details, + title, +}: { + details: string | string[]; + title: string; +}) => { + return ( +
+
+ {title} +
+ + +
+            {Array.isArray(details)
+              ? details.map((header) => {
+                  const [key, ...valueParts] = header.split(":");
+                  const value = valueParts.join(":").trim();
+                  return (
+                    
+ {key}: + {value} +
+ ); + }) + : details} +
+ +
+
+
+ ); +}; + +const getFormattedContent = (details: string | string[]) => { + if (Array.isArray(details)) { + return details + .map((header) => { + const [key, ...valueParts] = header.split(":"); + const value = valueParts.join(":").trim(); + return `${key}: ${value}`; + }) + .join("\n"); + } + return details; +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/index.tsx new file mode 100644 index 0000000000..0905951937 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/index.tsx @@ -0,0 +1,77 @@ +"use client"; +import { ResizablePanel } from "@/components/logs/details/resizable-panel"; +import { useMemo } from "react"; +import { DEFAULT_DRAGGABLE_WIDTH } from "../../../constants"; +import { useLogsContext } from "../../../context/logs"; +import { extractResponseField, safeParseJson } from "../../../utils"; +import { LogFooter } from "./components/log-footer"; +import { LogHeader } from "./components/log-header"; +import { LogMetaSection } from "./components/log-meta"; +import { LogSection } from "./components/log-section"; + +const createPanelStyle = (distanceToTop: number) => ({ + top: `${distanceToTop}px`, + width: `${DEFAULT_DRAGGABLE_WIDTH}px`, + height: `calc(100vh - ${distanceToTop}px)`, + paddingBottom: "1rem", +}); + +type Props = { + distanceToTop: number; +}; + +export const LogDetails = ({ distanceToTop }: Props) => { + const { setSelectedLog, selectedLog: log } = useLogsContext(); + const panelStyle = useMemo(() => createPanelStyle(distanceToTop), [distanceToTop]); + + if (!log) { + return null; + } + + const handleClose = () => { + setSelectedLog(null); + }; + + return ( + + + "} + title="Request Header" + /> + " + : JSON.stringify(safeParseJson(log.request_body), null, 2) + } + title="Request Body" + /> + "} + title="Response Header" + /> + " + : JSON.stringify(safeParseJson(log.response_body), null, 2) + } + title="Response Body" + /> +
+ + " + : JSON.stringify(extractResponseField(log, "meta"), null, 2) + } + /> + + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/logs-table.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/logs-table.tsx new file mode 100644 index 0000000000..3c89367479 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/logs-table.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { VirtualTable } from "@/components/virtual-table/index"; +import type { Column } from "@/components/virtual-table/types"; +import { cn } from "@/lib/utils"; +import type { Log } from "@unkey/clickhouse/src/logs"; +import { BookBookmark, TriangleWarning2 } from "@unkey/icons"; +import { Badge, Button, Empty, TimestampInfo } from "@unkey/ui"; +import { useMemo } from "react"; +import { isDisplayProperty, useLogsContext } from "../../context/logs"; +import { extractResponseField } from "../../utils"; +import { useLogsQuery } from "./hooks/use-logs-query"; + +type StatusStyle = { + base: string; + hover: string; + selected: string; + badge: { + default: string; + selected: string; + }; + focusRing: string; +}; + +const STATUS_STYLES = { + success: { + base: "text-grayA-9", + hover: "hover:text-accent-11 dark:hover:text-accent-12 hover:bg-grayA-3", + selected: "text-accent-12 bg-grayA-3 hover:text-accent-12", + badge: { + default: "bg-grayA-3 text-grayA-11 group-hover:bg-grayA-5", + selected: "bg-grayA-5 text-grayA-12 hover:bg-grayA-5", + }, + focusRing: "focus:ring-accent-7", + }, + warning: { + base: "text-warning-11 bg-warning-2", + hover: "hover:bg-warning-3", + selected: "bg-warning-3", + badge: { + default: "bg-warning-4 text-warning-11 group-hover:bg-warning-5", + selected: "bg-warning-5 text-warning-11 hover:bg-warning-5", + }, + focusRing: "focus:ring-warning-7", + }, + error: { + base: "text-error-11 bg-error-2", + hover: "hover:bg-error-3", + selected: "bg-error-3", + badge: { + default: "bg-error-4 text-error-11 group-hover:bg-error-5", + selected: "bg-error-5 text-error-11 hover:bg-error-5", + }, + focusRing: "focus:ring-error-7", + }, +}; + +const getStatusStyle = (status: number): StatusStyle => { + if (status >= 500) { + return STATUS_STYLES.error; + } + if (status >= 400) { + return STATUS_STYLES.warning; + } + return STATUS_STYLES.success; +}; + +const WARNING_ICON_STYLES = { + base: "size-3", + warning: "text-warning-11", + error: "text-error-11", +}; + +const getSelectedClassName = (log: Log, isSelected: boolean) => { + if (!isSelected) { + return ""; + } + const style = getStatusStyle(log.response_status); + return style.selected; +}; + +const WarningIcon = ({ status }: { status: number }) => ( + = 400 && status < 500 && WARNING_ICON_STYLES.warning, + status >= 500 && WARNING_ICON_STYLES.error, + )} + /> +); + +const additionalColumns: Column[] = [ + "response_body", + "request_body", + "request_headers", + "response_headers", + "request_id", + "workspace_id", + "host", +].map((key) => ({ + key, + header: key + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "), + width: "auto", + render: (log: Log) => ( +
{log[key as keyof Log]}
+ ), +})); + +export const LogsTable = () => { + const { displayProperties, setSelectedLog, selectedLog, isLive } = useLogsContext(); + const { realtimeLogs, historicalLogs, isLoading, isLoadingMore, loadMore, hasMore, total } = + useLogsQuery({ + startPolling: isLive, + pollIntervalMs: 2000, + }); + + const getRowClassName = (log: Log) => { + const style = getStatusStyle(log.response_status); + const isSelected = selectedLog?.request_id === log.request_id; + + return cn( + style.base, + style.hover, + "group rounded-md", + "focus:outline-none focus:ring-1 focus:ring-opacity-40", + style.focusRing, + isSelected && style.selected, + isLive && + !realtimeLogs.some((realtime) => realtime.request_id === log.request_id) && [ + "opacity-50", + "hover:opacity-100", + ], + selectedLog && { + "opacity-50 z-0": !isSelected, + "opacity-100 z-10": isSelected, + }, + ); + }; + // biome-ignore lint/correctness/useExhaustiveDependencies: it's okay + const basicColumns: Column[] = useMemo( + () => [ + { + key: "time", + header: "Time", + width: "5%", + render: (log) => ( + + ), + }, + { + key: "response_status", + header: "Status", + width: "7.5%", + render: (log) => { + const style = getStatusStyle(log.response_status); + const isSelected = selectedLog?.request_id === log.request_id; + return ( + + {log.response_status}{" "} + {extractResponseField(log, "code") ? `| ${extractResponseField(log, "code")}` : ""} + + ); + }, + }, + { + key: "method", + header: "Method", + width: "7.5%", + render: (log) => { + const isSelected = selectedLog?.request_id === log.request_id; + return ( + + {log.method} + + ); + }, + }, + { + key: "path", + header: "Path", + width: "15%", + render: (log) =>
{log.path}
, + }, + ], + [selectedLog?.request_id], + ); + + const visibleColumns = useMemo(() => { + const filtered = [...basicColumns, ...additionalColumns].filter( + (column) => isDisplayProperty(column.key) && displayProperties.has(column.key), + ); + + // If we have visible columns + if (filtered.length > 0) { + const originalRender = filtered[0].render; + filtered[0] = { + ...filtered[0], + headerClassName: "pl-8", + render: (log: Log) => ( +
+ +
{originalRender(log)}
+
+ ), + }; + } + + return filtered; + }, [basicColumns, displayProperties]); + + return ( + log.request_id} + rowClassName={getRowClassName} + selectedClassName={getSelectedClassName} + loadMoreFooterProps={{ + hide: isLoading, + buttonText: "Load more logs", + hasMore, + countInfoText: ( +
+ Showing {historicalLogs.length} + of + {total} + requests +
+ ), + }} + emptyState={ +
+ + + Logs + + Keep track of all activity within your workspace. We collect all API requests, giving + you a clear history to find problems or debug issues. + + + + + + + +
+ } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/query-logs.schema.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/query-logs.schema.ts new file mode 100644 index 0000000000..feadf1eed1 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/query-logs.schema.ts @@ -0,0 +1,60 @@ +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)/projects/[projectId]/logs/constants.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/constants.ts new file mode 100644 index 0000000000..585c7c28b8 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/constants.ts @@ -0,0 +1,7 @@ +export const DEFAULT_DRAGGABLE_WIDTH = 500; + +export const YELLOW_STATES = ["RATE_LIMITED", "EXPIRED", "USAGE_EXCEEDED"]; +export const RED_STATES = ["DISABLED", "FORBIDDEN", "INSUFFICIENT_PERMISSIONS"]; + +export const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const; +export const STATUSES = [200, 400, 500] as const; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/context/logs.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/context/logs.tsx new file mode 100644 index 0000000000..59344cabeb --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/context/logs.tsx @@ -0,0 +1,97 @@ +"use client"; + +import type { Log } from "@unkey/clickhouse/src/logs"; +import { type PropsWithChildren, createContext, useContext, useState } from "react"; + +type DisplayProperty = + | "time" + | "response_status" + | "method" + | "path" + | "response_body" + | "request_id" + | "host" + | "request_headers" + | "request_body" + | "response_headers"; + +type LogsContextType = { + isLive: boolean; + toggleLive: (value?: boolean) => void; + selectedLog: Log | null; + setSelectedLog: (log: Log | null) => void; + displayProperties: Set; + toggleDisplayProperty: (property: DisplayProperty) => void; +}; + +const DEFAULT_DISPLAY_PROPERTIES: DisplayProperty[] = [ + "time", + "response_status", + "method", + "path", + "response_body", +]; + +const LogsContext = createContext(null); + +export const LogsProvider = ({ children }: PropsWithChildren) => { + const [selectedLog, setSelectedLog] = useState(null); + const [isLive, setIsLive] = useState(false); + const [displayProperties, setDisplayProperties] = useState>( + new Set(DEFAULT_DISPLAY_PROPERTIES), + ); + + const toggleDisplayProperty = (property: DisplayProperty) => { + setDisplayProperties((prev) => { + const next = new Set(prev); + if (next.has(property)) { + next.delete(property); + } else { + next.add(property); + } + return next; + }); + }; + + const toggleLive = (value?: boolean) => { + setIsLive((prev) => (typeof value !== "undefined" ? value : !prev)); + }; + + return ( + + {children} + + ); +}; + +export const useLogsContext = () => { + const context = useContext(LogsContext); + if (!context) { + throw new Error("useLogsContext must be used within a LogsProvider"); + } + return context; +}; + +export const isDisplayProperty = (value: string): value is DisplayProperty => { + return [ + "time", + "response_status", + "method", + "path", + "response_body", + "request_id", + "host", + "request_headers", + "request_body", + "response_headers", + ].includes(value); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/filters.schema.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/filters.schema.ts new file mode 100644 index 0000000000..2d766fded9 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/filters.schema.ts @@ -0,0 +1,114 @@ +import { METHODS } from "./constants"; + +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 = { + status: { + type: "number", + operators: ["is"], + getColorClass: (value) => { + if (value >= 500) { + return "bg-error-9"; + } + if (value >= 400) { + return "bg-warning-8"; + } + return "bg-success-9"; + }, + validate: (value) => value >= 200 && value <= 599, + }, + methods: { + type: "string", + operators: ["is"], + validValues: METHODS, + }, + paths: { + type: "string", + operators: ["is", "contains", "startsWith", "endsWith"], + }, + host: { + type: "string", + operators: ["is"], + }, + requestId: { + type: "string", + operators: ["is"], + }, + startTime: { + type: "number", + operators: ["is"], + }, + endTime: { + type: "number", + operators: ["is"], + }, + since: { + type: "string", + operators: ["is"], + }, +} as const; + +export interface StatusConfig extends NumberConfig { + type: "number"; + operators: ["is"]; + validate: (value: number) => boolean; +} + +// Schemas +export const logsFilterOperatorEnum = z.enum(["is", "contains", "startsWith", "endsWith"]); + +export const logsFilterFieldEnum = z.enum([ + "host", + "requestId", + "methods", + "paths", + "status", + "startTime", + "endTime", + "since", +]); + +export const filterOutputSchema = createFilterOutputSchema( + logsFilterFieldEnum, + logsFilterOperatorEnum, + logsFilterFieldConfig, +); + +// Types +export type LogsFilterOperator = z.infer; +export type LogsFilterField = z.infer; + +export type FilterFieldConfigs = { + status: StatusConfig; + methods: StringConfig; + paths: StringConfig; + host: StringConfig; + requestId: StringConfig; + startTime: NumberConfig; + 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)/projects/[projectId]/logs/hooks/use-filters.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/hooks/use-filters.ts new file mode 100644 index 0000000000..38874d1238 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/hooks/use-filters.ts @@ -0,0 +1,191 @@ +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"; + +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, + }; +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/page.tsx index 60558a57b4..521564bc1c 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/page.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/page.tsx @@ -1,5 +1,26 @@ "use client"; +import { useCallback, useState } from "react"; +import { LogsChart } from "./components/charts"; +import { LogsControlCloud } from "./components/control-cloud"; +import { LogsControls } from "./components/controls"; +import { LogDetails } from "./components/table/log-details"; +import { LogsTable } from "./components/table/logs-table"; +import { LogsProvider } from "./context/logs"; -export default function ProjectLogs() { - return
Overview
; +export default function Page() { + const [tableDistanceToTop, setTableDistanceToTop] = useState(0); + + const handleDistanceToTop = useCallback((distanceToTop: number) => { + setTableDistanceToTop(distanceToTop); + }, []); + + return ( + + + + + + + + ); } diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/types.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/types.ts new file mode 100644 index 0000000000..be5879b57c --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/types.ts @@ -0,0 +1 @@ +export type ResponseStatus = 200 | 400 | 500; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/utils.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/utils.ts new file mode 100644 index 0000000000..ec144758a9 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/utils.ts @@ -0,0 +1,72 @@ +import type { Log } from "@unkey/clickhouse/src/logs"; +import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; + +export type ResponseBody = { + keyId: string; + valid: boolean; + meta: Record; + enabled: boolean; + permissions: string[]; + code: + | "VALID" + | "RATE_LIMITED" + | "EXPIRED" + | "USAGE_EXCEEDED" + | "DISABLED" + | "FORBIDDEN" + | "INSUFFICIENT_PERMISSIONS"; +}; + +export const extractResponseField = ( + log: Log | RatelimitLog, + fieldName: K, +): ResponseBody[K] | null => { + if (!log?.response_body) { + console.error("Invalid log or missing response_body"); + return null; + } + + try { + const parsedBody = JSON.parse(log.response_body) as ResponseBody; + + return parsedBody[fieldName]; + } catch { + return null; + } +}; + +export const getRequestHeader = (log: Log | RatelimitLog, headerName: string): string | null => { + if (!headerName.trim()) { + console.error("Invalid header name provided"); + return null; + } + + if (!Array.isArray(log.request_headers)) { + console.error("request_headers is not an array"); + return null; + } + + const lowerHeaderName = headerName.toLowerCase(); + const header = log.request_headers.find((h) => h.toLowerCase().startsWith(`${lowerHeaderName}:`)); + + if (!header) { + console.warn(`Header "${headerName}" not found in request headers`); + return null; + } + + const [, value] = header.split(":", 2); + return value ? value.trim() : null; +}; + +export const safeParseJson = (jsonString?: string | null) => { + if (!jsonString) { + return null; + } + + try { + return JSON.parse(jsonString); + } catch { + console.error("Cannot parse JSON:", jsonString); + return "Invalid JSON format"; + } +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-sub-navigation.tsx b/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-sub-navigation.tsx index a3cd400f0e..1ec6da17fd 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-sub-navigation.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-sub-navigation.tsx @@ -45,7 +45,7 @@ export const ProjectSubNavigation = ({ const tabIndex = segments.findIndex((segment) => segment === projectId) + 1; const currentTab = segments[tabIndex]; - const validTabs = ["overview", "deployments", "logs", "settings"]; + const validTabs = ["overview", "deployments", "gateway-logs", "settings"]; return validTabs.includes(currentTab) ? currentTab : "overview"; }; @@ -65,8 +65,8 @@ export const ProjectSubNavigation = ({ path: `/projects/${projectId}/deployments`, }, { - id: "logs", - label: "Logs", + id: "gateway-logs", + label: "Gateway Logs", icon: Layers3, path: `/projects/${projectId}/logs`, }, From b429b4ca3c8f4a487f70f129967cab279c726fed Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 18 Sep 2025 14:08:37 +0300 Subject: [PATCH 02/27] feat: fix prefixes and filters --- .../deployments/hooks/use-filters.ts | 52 +++-- .../hooks/use-gateway-logs-timeseries.ts | 123 +++++++++++ .../components/charts/index.tsx | 10 +- .../charts/query-timeseries.schema.ts | 4 +- .../components/control-cloud/index.tsx | 6 +- .../gateway-logs-datetime}/index.tsx | 6 +- .../gateway-logs-methods-filter.tsx} | 6 +- .../components/gateway-logs-paths-filter.tsx} | 10 +- .../gateway-logs-status-filter.tsx} | 8 +- .../gateway-logs-filters}/index.tsx | 18 +- .../components/gateway-logs-live-switch.tsx} | 10 +- .../components/gateway-logs-refresh.tsx} | 6 +- .../components/gateway-logs-search}/index.tsx | 6 +- .../components/controls/index.tsx | 26 +++ .../components/gateway-log-footer.tsx} | 0 .../components/gateway-log-header.tsx} | 0 .../components/gateway-log-meta.tsx} | 0 .../components/gateway-log-section.tsx} | 0 .../table/gateway-log-details}/index.tsx | 14 +- .../components/table/gateway-logs-table.tsx} | 196 ++++++++---------- .../table/hooks/use-gateway-logs-query.ts} | 149 ++++++------- .../table/query-gateway-logs.schema.ts} | 4 +- .../{logs => gateway-logs}/constants.ts | 0 .../context/gateway-logs-provider.tsx | 49 +++++ .../gateway-logs-filters.schema.ts | 124 +++++++++++ .../hooks/use-gateway-logs-filters.ts | 135 ++++++++++++ .../[projectId]/gateway-logs/page.tsx | 36 ++++ .../{logs => gateway-logs}/types.ts | 0 .../{logs => gateway-logs}/utils.ts | 0 .../charts/hooks/use-fetch-timeseries.ts | 105 ---------- .../components/logs-queries/index.tsx | 33 --- .../controls/components/logs-queries/utils.ts | 107 ---------- .../logs/components/controls/index.tsx | 33 --- .../table/hooks/use-logs-query.test.ts | 154 -------------- .../[projectId]/logs/context/logs.tsx | 97 --------- .../[projectId]/logs/filters.schema.ts | 114 ---------- .../[projectId]/logs/hooks/use-filters.ts | 191 ----------------- .../(app)/projects/[projectId]/logs/page.tsx | 26 --- 38 files changed, 733 insertions(+), 1125 deletions(-) create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/hooks/use-gateway-logs-timeseries.ts rename apps/dashboard/app/(app)/projects/[projectId]/{logs => gateway-logs}/components/charts/index.tsx (81%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs => gateway-logs}/components/charts/query-timeseries.schema.ts (86%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs => gateway-logs}/components/control-cloud/index.tsx (87%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/controls/components/logs-datetime => gateway-logs/components/controls/components/gateway-logs-datetime}/index.tsx (92%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/controls/components/logs-filters/components/methods-filter.tsx => gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-methods-filter.tsx} (80%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/controls/components/logs-filters/components/paths-filter.tsx => gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-paths-filter.tsx} (68%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/controls/components/logs-filters/components/status-filter.tsx => gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-status-filter.tsx} (84%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/controls/components/logs-filters => gateway-logs/components/controls/components/gateway-logs-filters}/index.tsx (72%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/controls/components/logs-live-switch.tsx => gateway-logs/components/controls/components/gateway-logs-live-switch.tsx} (72%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/controls/components/logs-refresh.tsx => gateway-logs/components/controls/components/gateway-logs-refresh.tsx} (72%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/controls/components/logs-search => gateway-logs/components/controls/components/gateway-logs-search}/index.tsx (89%) create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/index.tsx rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/table/log-details/components/log-footer.tsx => gateway-logs/components/table/gateway-log-details/components/gateway-log-footer.tsx} (100%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/table/log-details/components/log-header.tsx => gateway-logs/components/table/gateway-log-details/components/gateway-log-header.tsx} (100%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/table/log-details/components/log-meta.tsx => gateway-logs/components/table/gateway-log-details/components/gateway-log-meta.tsx} (100%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/table/log-details/components/log-section.tsx => gateway-logs/components/table/gateway-log-details/components/gateway-log-section.tsx} (100%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/table/log-details => gateway-logs/components/table/gateway-log-details}/index.tsx (81%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/table/logs-table.tsx => gateway-logs/components/table/gateway-logs-table.tsx} (59%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/table/hooks/use-logs-query.ts => gateway-logs/components/table/hooks/use-gateway-logs-query.ts} (63%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs/components/table/query-logs.schema.ts => gateway-logs/components/table/query-gateway-logs.schema.ts} (89%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs => gateway-logs}/constants.ts (100%) create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/context/gateway-logs-provider.tsx create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/gateway-logs-filters.schema.ts create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/hooks/use-gateway-logs-filters.ts create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/page.tsx rename apps/dashboard/app/(app)/projects/[projectId]/{logs => gateway-logs}/types.ts (100%) rename apps/dashboard/app/(app)/projects/[projectId]/{logs => gateway-logs}/utils.ts (100%) delete mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/hooks/use-fetch-timeseries.ts delete mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/index.tsx delete mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/utils.ts delete mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/index.tsx delete mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.test.ts delete mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/context/logs.tsx delete mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/filters.schema.ts delete mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/hooks/use-filters.ts delete mode 100644 apps/dashboard/app/(app)/projects/[projectId]/logs/page.tsx diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/hooks/use-filters.ts b/apps/dashboard/app/(app)/projects/[projectId]/deployments/hooks/use-filters.ts index eeb5774760..d04dc8f469 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/hooks/use-filters.ts +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/hooks/use-filters.ts @@ -5,38 +5,44 @@ import { import { parseAsInteger, useQueryStates } from "nuqs"; import { useCallback, useMemo } from "react"; import { - type DeploymentListFilterField, - type DeploymentListFilterOperator, - type DeploymentListFilterUrlValue, - type DeploymentListFilterValue, - type DeploymentListQuerySearchParams, - deploymentListFilterFieldConfig, -} from "../filters.schema"; + type LogsFilterField, + type LogsFilterOperator, + type LogsFilterUrlValue, + type LogsFilterValue, + type LogsQuerySearchParams, + logsFilterFieldConfig, +} from "../gateway-logs-filters.schema"; -const parseAsFilterValArray = parseAsFilterValueArray([ +// Constants +const parseAsFilterValArray = parseAsFilterValueArray([ "is", "contains", + "startsWith", + "endsWith", ]); +const arrayFields = ["status", "methods", "paths", "host", "requestId"] as const; +const timeFields = ["startTime", "endTime", "since"] as const; + +// Query params configuration export const queryParamsPayload = { status: parseAsFilterValArray, - environment: parseAsFilterValArray, - branch: parseAsFilterValArray, + methods: parseAsFilterValArray, + paths: parseAsFilterValArray, + host: parseAsFilterValArray, + requestId: parseAsFilterValArray, startTime: parseAsInteger, endTime: parseAsInteger, since: parseAsRelativeTime, } as const; -const arrayFields = ["status", "environment", "branch"] as const; -const timeFields = ["startTime", "endTime", "since"] as const; - export const useFilters = () => { const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload, { history: "push", }); const filters = useMemo(() => { - const activeFilters: DeploymentListFilterValue[] = []; + const activeFilters: LogsFilterValue[] = []; // Handle array filters arrayFields.forEach((field) => { @@ -46,10 +52,10 @@ export const useFilters = () => { field, operator: item.operator, value: item.value, - metadata: deploymentListFilterFieldConfig[field].getColorClass + metadata: logsFilterFieldConfig[field].getColorClass ? { - colorClass: deploymentListFilterFieldConfig[field].getColorClass( - item.value as string, + colorClass: logsFilterFieldConfig[field].getColorClass( + field === "status" ? Number(item.value) : item.value, ), } : undefined, @@ -58,12 +64,12 @@ export const useFilters = () => { }); // Handle time filters - ["startTime", "endTime", "since"].forEach((field) => { - const value = searchParams[field as keyof DeploymentListQuerySearchParams]; + timeFields.forEach((field) => { + const value = searchParams[field]; if (value !== null && value !== undefined) { activeFilters.push({ id: crypto.randomUUID(), - field: field as DeploymentListFilterField, + field: field as LogsFilterField, operator: "is", value: value as string | number, }); @@ -74,8 +80,8 @@ export const useFilters = () => { }, [searchParams]); const updateFilters = useCallback( - (newFilters: DeploymentListFilterValue[]) => { - const newParams: Partial = Object.fromEntries([ + (newFilters: LogsFilterValue[]) => { + const newParams: Partial = Object.fromEntries([ ...arrayFields.map((field) => [field, null]), ...timeFields.map((field) => [field, null]), ]); @@ -85,7 +91,7 @@ export const useFilters = () => { acc[field] = []; return acc; }, - {} as Record<(typeof arrayFields)[number], DeploymentListFilterUrlValue[]>, + {} as Record<(typeof arrayFields)[number], LogsFilterUrlValue[]>, ); newFilters.forEach((filter) => { diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/hooks/use-gateway-logs-timeseries.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/hooks/use-gateway-logs-timeseries.ts new file mode 100644 index 0000000000..1153813a58 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/hooks/use-gateway-logs-timeseries.ts @@ -0,0 +1,123 @@ +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 { useGatewayLogsFilters } from "../../../hooks/use-gateway-logs-filters"; +import type { queryTimeseriesPayload } from "../query-timeseries.schema"; + +// Constants +const FILTER_FIELD_MAPPING = { + status: "status", + methods: "method", + paths: "path", + host: "host", +} as const; + +const TIME_FIELDS = ["startTime", "endTime", "since"] as const; + +export const useGatewayLogsTimeseries = () => { + const { filters } = useGatewayLogsFilters(); + 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) => { + const paramKey = FILTER_FIELD_MAPPING[filter.field as keyof typeof FILTER_FIELD_MAPPING]; + + if (paramKey && params[paramKey as keyof typeof params]) { + switch (filter.field) { + case "status": { + const statusValue = Number.parseInt(filter.value as string); + if (Number.isNaN(statusValue)) { + console.error("Status filter value must be a valid number"); + return; + } + params.status?.filters.push({ + operator: "is", + value: statusValue, + }); + break; + } + + case "methods": + case "host": { + if (typeof filter.value !== "string") { + console.error(`${filter.field} filter value must be a string`); + return; + } + const targetParam = params[paramKey as keyof typeof params] as { + filters: Array<{ operator: string; value: string }>; + }; + targetParam.filters.push({ + operator: "is", + value: filter.value, + }); + break; + } + + case "paths": { + if (typeof filter.value !== "string") { + console.error("Path filter value must be a string"); + return; + } + params.path?.filters.push({ + operator: filter.operator, + value: filter.value, + }); + break; + } + } + } else if (TIME_FIELDS.includes(filter.field as (typeof TIME_FIELDS)[number])) { + switch (filter.field) { + case "startTime": + case "endTime": { + if (typeof filter.value !== "number") { + console.error(`${filter.field} filter value must be a number`); + return; + } + params[filter.field] = filter.value; + break; + } + case "since": { + if (typeof filter.value !== "string") { + console.error("Since filter value must be a string"); + return; + } + params.since = filter.value; + break; + } + } + } + }); + + return params; + }, [filters, timestamp]); + + const { data, isLoading, isError } = trpc.logs.queryTimeseries.useQuery(queryParams, { + refetchInterval: queryParams.endTime ? false : 10_000, + }); + + const timeseries = data?.timeseries.map((ts) => ({ + displayX: formatTimestampForChart(ts.x, data.granularity), + originalTimestamp: ts.x, + ...ts.y, + })); + + return { + timeseries, + isLoading, + isError, + granularity: data?.granularity, + }; +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/index.tsx similarity index 81% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/index.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/index.tsx index 410d196bc7..5706eefd03 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/index.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/index.tsx @@ -1,16 +1,16 @@ "use client"; import { LogsTimeseriesBarChart } from "@/components/logs/chart"; import { getTimeBufferForGranularity } from "@/lib/trpc/routers/utils/granularity"; -import { useFilters } from "../../hooks/use-filters"; -import { useFetchTimeseries } from "./hooks/use-fetch-timeseries"; +import { useGatewayLogsFilters } from "../../hooks/use-gateway-logs-filters"; +import { useGatewayLogsTimeseries } from "./hooks/use-gateway-logs-timeseries"; -export function LogsChart({ +export function GatewayLogsChart({ onMount, }: { onMount: (distanceToTop: number) => void; }) { - const { filters, updateFilters } = useFilters(); - const { timeseries, isLoading, isError, granularity } = useFetchTimeseries(); + const { filters, updateFilters } = useGatewayLogsFilters(); + const { timeseries, isLoading, isError, granularity } = useGatewayLogsTimeseries(); const handleSelectionChange = ({ start, diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/query-timeseries.schema.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/query-timeseries.schema.ts similarity index 86% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/query-timeseries.schema.ts rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/query-timeseries.schema.ts index 2a833ea81a..d62b8bcc99 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/query-timeseries.schema.ts +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/query-timeseries.schema.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { logsFilterOperatorEnum } from "../../filters.schema"; +import { gatewayLogsFilterOperatorEnum } from "../../gateway-logs-filters.schema"; export const queryTimeseriesPayload = z.object({ startTime: z.number().int(), @@ -9,7 +9,7 @@ export const queryTimeseriesPayload = z.object({ .object({ filters: z.array( z.object({ - operator: logsFilterOperatorEnum, + operator: gatewayLogsFilterOperatorEnum, value: z.string(), }), ), diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/control-cloud/index.tsx similarity index 87% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/control-cloud/index.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/control-cloud/index.tsx index e936451c92..b4223e5989 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/control-cloud/index.tsx @@ -1,7 +1,7 @@ import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; import { ControlCloud } from "@unkey/ui"; import { format } from "date-fns"; -import { useFilters } from "../../hooks/use-filters"; +import { useGatewayLogsFilters } from "../../hooks/use-gateway-logs-filters"; const formatFieldName = (field: string): string => { switch (field) { @@ -44,8 +44,8 @@ const formatValue = (value: string | number, field: string): string => { return String(value); }; -export const LogsControlCloud = () => { - const { filters, updateFilters, removeFilter } = useFilters(); +export const GatewayLogsControlCloud = () => { + const { filters, updateFilters, removeFilter } = useGatewayLogsFilters(); return ( { +export const GatewayLogsDateTime = () => { const [title, setTitle] = useState(null); - const { filters, updateFilters } = useFilters(); + const { filters, updateFilters } = useGatewayLogsFilters(); useEffect(() => { if (!title) { diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/methods-filter.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-methods-filter.tsx similarity index 80% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/methods-filter.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-methods-filter.tsx index 2887f8f528..f6b3265bfa 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/methods-filter.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-methods-filter.tsx @@ -1,5 +1,5 @@ -import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; +import { useGatewayLogsFilters } from "../../../../../hooks/use-gateway-logs-filters"; type MethodOption = { id: number; @@ -15,8 +15,8 @@ const options: MethodOption[] = [ { id: 5, method: "PATCH", checked: false }, ] as const; -export const MethodsFilter = () => { - const { filters, updateFilters } = useFilters(); +export const GatewayMethodsFilter = () => { + const { filters, updateFilters } = useGatewayLogsFilters(); return ( { - const { filters, updateFilters } = useFilters(); +export const GatewayPathsFilter = () => { + const { filters, updateFilters } = useGatewayLogsFilters(); - const pathOperators = logsFilterFieldConfig.paths.operators; + const pathOperators = gatewayLogsFilterFieldConfig.paths.operators; const options = pathOperators.map((op) => ({ id: op, label: op, diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/status-filter.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-status-filter.tsx similarity index 84% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/status-filter.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-status-filter.tsx index fc574f3f7b..3c1086af9f 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-filters/components/status-filter.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-status-filter.tsx @@ -1,6 +1,6 @@ -import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; -import type { ResponseStatus } from "@/app/(app)/logs/types"; import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; +import { useGatewayLogsFilters } from "../../../../../hooks/use-gateway-logs-filters"; +import type { ResponseStatus } from "../../../../../types"; type StatusOption = { id: number; @@ -38,8 +38,8 @@ const options: StatusOption[] = [ }, ]; -export const StatusFilter = () => { - const { filters, updateFilters } = useFilters(); +export const GatewayStatusFilter = () => { + const { filters, updateFilters } = useGatewayLogsFilters(); return ( , + component: , }, { id: "methods", label: "Method", shortcut: "M", shortcutLabel: "M", - component: , + component: , }, { id: "paths", label: "Path", shortcut: "P", shortcutLabel: "P", - component: , + component: , }, ]; -export const LogsFilters = () => { - const { filters } = useFilters(); +export const GatewayLogsFilters = () => { + const { filters } = useGatewayLogsFilters(); return (
diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-live-switch.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-live-switch.tsx similarity index 72% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-live-switch.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-live-switch.tsx index 8c495d4eb7..321b3987f7 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-live-switch.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-live-switch.tsx @@ -1,11 +1,11 @@ import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; import { LiveSwitchButton } from "@/components/logs/live-switch-button"; -import { useLogsContext } from "../../../context/logs"; -import { useFilters } from "../../../hooks/use-filters"; +import { useGatewayLogsContext } from "../../../context/gateway-logs-provider"; +import { useGatewayLogsFilters } from "../../../hooks/use-gateway-logs-filters"; -export const LogsLiveSwitch = () => { - const { isLive, toggleLive } = useLogsContext(); - const { filters, updateFilters } = useFilters(); +export const GatewayLogsLiveSwitch = () => { + const { toggleLive, isLive } = useGatewayLogsContext(); + const { filters, updateFilters } = useGatewayLogsFilters(); const handleSwitch = () => { toggleLive(); diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-refresh.tsx similarity index 72% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-refresh.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-refresh.tsx index e21cb8be16..b601e0455e 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-refresh.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-refresh.tsx @@ -1,10 +1,10 @@ import { trpc } from "@/lib/trpc/client"; import { useQueryTime } from "@/providers/query-time-provider"; import { RefreshButton } from "@unkey/ui"; -import { useLogsContext } from "../../../context/logs"; +import { useGatewayLogsContext } from "../../../context/gateway-logs-provider"; -export const LogsRefresh = () => { - const { toggleLive, isLive } = useLogsContext(); +export const GatewayLogsRefresh = () => { + const { toggleLive, isLive } = useGatewayLogsContext(); const { refreshQueryTime } = useQueryTime(); const { logs } = trpc.useUtils(); diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-search/index.tsx similarity index 89% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-search/index.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-search/index.tsx index 58a0e5f7d0..262cef49c5 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-search/index.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-search/index.tsx @@ -1,9 +1,9 @@ -import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; import { trpc } from "@/lib/trpc/client"; import { LLMSearch, toast, transformStructuredOutputToFilters } from "@unkey/ui"; +import { useGatewayLogsFilters } from "../../../../hooks/use-gateway-logs-filters"; -export const LogsSearch = () => { - const { filters, updateFilters } = useFilters(); +export const GatewayLogsSearch = () => { + const { filters, updateFilters } = useGatewayLogsFilters(); const queryLLMForStructuredOutput = trpc.logs.llmSearch.useMutation({ onSuccess(data) { if (data?.filters.length === 0 || !data) { diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/index.tsx new file mode 100644 index 0000000000..e8ae8a80b8 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/index.tsx @@ -0,0 +1,26 @@ +import { + ControlsContainer, + ControlsLeft, + ControlsRight, +} from "@/components/logs/controls-container"; +import { GatewayLogsDateTime } from "./components/gateway-logs-datetime"; +import { GatewayLogsFilters } from "./components/gateway-logs-filters"; +import { GatewayLogsLiveSwitch } from "./components/gateway-logs-live-switch"; +import { GatewayLogsRefresh } from "./components/gateway-logs-refresh"; +import { GatewayLogsSearch } from "./components/gateway-logs-search"; + +export function GatewayLogsControls() { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-footer.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-footer.tsx similarity index 100% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-footer.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-footer.tsx diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-header.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-header.tsx similarity index 100% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-header.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-header.tsx diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-meta.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-meta.tsx similarity index 100% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-meta.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-meta.tsx diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-section.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-section.tsx similarity index 100% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/components/log-section.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-section.tsx diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx similarity index 81% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/index.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx index 0905951937..4272edb1a3 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/log-details/index.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx @@ -2,12 +2,12 @@ import { ResizablePanel } from "@/components/logs/details/resizable-panel"; import { useMemo } from "react"; import { DEFAULT_DRAGGABLE_WIDTH } from "../../../constants"; -import { useLogsContext } from "../../../context/logs"; +import { useGatewayLogsContext } from "../../../context/gateway-logs-provider"; import { extractResponseField, safeParseJson } from "../../../utils"; -import { LogFooter } from "./components/log-footer"; -import { LogHeader } from "./components/log-header"; -import { LogMetaSection } from "./components/log-meta"; -import { LogSection } from "./components/log-section"; +import { LogFooter } from "./components/gateway-log-footer"; +import { LogHeader } from "./components/gateway-log-header"; +import { LogMetaSection } from "./components/gateway-log-meta"; +import { LogSection } from "./components/gateway-log-section"; const createPanelStyle = (distanceToTop: number) => ({ top: `${distanceToTop}px`, @@ -20,8 +20,8 @@ type Props = { distanceToTop: number; }; -export const LogDetails = ({ distanceToTop }: Props) => { - const { setSelectedLog, selectedLog: log } = useLogsContext(); +export const GatewayLogDetails = ({ distanceToTop }: Props) => { + const { setSelectedLog, selectedLog: log } = useGatewayLogsContext(); const panelStyle = useMemo(() => createPanelStyle(distanceToTop), [distanceToTop]); if (!log) { diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/logs-table.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx similarity index 59% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/logs-table.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx index 3c89367479..2a4c5de8fb 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/logs-table.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx @@ -6,10 +6,9 @@ import { cn } from "@/lib/utils"; import type { Log } from "@unkey/clickhouse/src/logs"; import { BookBookmark, TriangleWarning2 } from "@unkey/icons"; import { Badge, Button, Empty, TimestampInfo } from "@unkey/ui"; -import { useMemo } from "react"; -import { isDisplayProperty, useLogsContext } from "../../context/logs"; +import { useGatewayLogsContext } from "../../context/gateway-logs-provider"; import { extractResponseField } from "../../utils"; -import { useLogsQuery } from "./hooks/use-logs-query"; +import { useGatewayLogsQuery } from "./hooks/use-gateway-logs-query"; type StatusStyle = { base: string; @@ -91,30 +90,86 @@ const WarningIcon = ({ status }: { status: number }) => ( /> ); -const additionalColumns: Column[] = [ - "response_body", - "request_body", - "request_headers", - "response_headers", - "request_id", - "workspace_id", - "host", -].map((key) => ({ - key, - header: key - .split("_") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "), - width: "auto", - render: (log: Log) => ( -
{log[key as keyof Log]}
- ), -})); +const columns: Column[] = [ + { + key: "time", + header: "Time", + width: "5%", + headerClassName: "pl-8", + render: (log) => ( +
+ +
+ +
+
+ ), + }, + { + key: "response_status", + header: "Status", + width: "7.5%", + render: (log) => { + const style = getStatusStyle(log.response_status); + return ( + + {log.response_status}{" "} + {extractResponseField(log, "code") ? `| ${extractResponseField(log, "code")}` : ""} + + ); + }, + }, + { + key: "method", + header: "Method", + width: "7.5%", + render: (log) => ( + + {log.method} + + ), + }, + { + key: "path", + header: "Path", + width: "15%", + render: (log) =>
{log.path}
, + }, + { + key: "response_body", + header: "Response Body", + width: "auto", + render: (log) => ( +
{log.response_body}
+ ), + }, + { + key: "request_body", + header: "Request Body", + width: "auto", + render: (log) => ( +
{log.request_body}
+ ), + }, +]; -export const LogsTable = () => { - const { displayProperties, setSelectedLog, selectedLog, isLive } = useLogsContext(); +export const GatewayLogsTable = () => { + const { setSelectedLog, selectedLog, isLive } = useGatewayLogsContext(); const { realtimeLogs, historicalLogs, isLoading, isLoadingMore, loadMore, hasMore, total } = - useLogsQuery({ + useGatewayLogsQuery({ startPolling: isLive, pollIntervalMs: 2000, }); @@ -141,95 +196,6 @@ export const LogsTable = () => { }, ); }; - // biome-ignore lint/correctness/useExhaustiveDependencies: it's okay - const basicColumns: Column[] = useMemo( - () => [ - { - key: "time", - header: "Time", - width: "5%", - render: (log) => ( - - ), - }, - { - key: "response_status", - header: "Status", - width: "7.5%", - render: (log) => { - const style = getStatusStyle(log.response_status); - const isSelected = selectedLog?.request_id === log.request_id; - return ( - - {log.response_status}{" "} - {extractResponseField(log, "code") ? `| ${extractResponseField(log, "code")}` : ""} - - ); - }, - }, - { - key: "method", - header: "Method", - width: "7.5%", - render: (log) => { - const isSelected = selectedLog?.request_id === log.request_id; - return ( - - {log.method} - - ); - }, - }, - { - key: "path", - header: "Path", - width: "15%", - render: (log) =>
{log.path}
, - }, - ], - [selectedLog?.request_id], - ); - - const visibleColumns = useMemo(() => { - const filtered = [...basicColumns, ...additionalColumns].filter( - (column) => isDisplayProperty(column.key) && displayProperties.has(column.key), - ); - - // If we have visible columns - if (filtered.length > 0) { - const originalRender = filtered[0].render; - filtered[0] = { - ...filtered[0], - headerClassName: "pl-8", - render: (log: Log) => ( -
- -
{originalRender(log)}
-
- ), - }; - } - - return filtered; - }, [basicColumns, displayProperties]); return ( { isLoading={isLoading} isFetchingNextPage={isLoadingMore} onLoadMore={loadMore} - columns={visibleColumns} + columns={columns} onRowClick={setSelectedLog} selectedItem={selectedLog} keyExtractor={(log) => log.request_id} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/hooks/use-gateway-logs-query.ts similarity index 63% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.ts rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/hooks/use-gateway-logs-query.ts index 5bad52e4cc..d4ef12f9c9 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.ts +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/hooks/use-gateway-logs-query.ts @@ -4,28 +4,39 @@ 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 { useGatewayLogsFilters } from "../../../hooks/use-gateway-logs-filters"; import type { queryLogsPayload } from "../query-logs.schema"; -// Duration in milliseconds for historical data fetch window (12 hours) -type UseLogsQueryParams = { +// Constants +const REALTIME_DATA_LIMIT = 100; + +// Types +type UseGatewayLogsQueryParams = { limit?: number; pollIntervalMs?: number; startPolling?: boolean; }; -const REALTIME_DATA_LIMIT = 100; +const FILTER_FIELD_MAPPING = { + status: "status", + methods: "method", + paths: "path", + host: "host", + requestId: "requestId", +} as const; + +const TIME_FIELDS = ["startTime", "endTime", "since"] as const; -export function useLogsQuery({ +export function useGatewayLogsQuery({ limit = 50, pollIntervalMs = 5000, startPolling = false, -}: UseLogsQueryParams = {}) { +}: UseGatewayLogsQueryParams = {}) { const [historicalLogsMap, setHistoricalLogsMap] = useState(() => new Map()); const [realtimeLogsMap, setRealtimeLogsMap] = useState(() => new Map()); const [totalCount, setTotalCount] = useState(0); - const { filters } = useFilters(); + const { filters } = useGatewayLogsFilters(); const queryClient = trpc.useUtils(); const { queryTime: timestamp } = useQueryTime(); @@ -35,7 +46,7 @@ export function useLogsQuery({ const historicalLogs = useMemo(() => Array.from(historicalLogsMap.values()), [historicalLogsMap]); - //Required for preventing double trpc call during initial render + // "memo" required for preventing double trpc call during initial render const queryParams = useMemo(() => { const params: z.infer = { limit, @@ -50,79 +61,71 @@ export function useLogsQuery({ }; 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; + const paramKey = FILTER_FIELD_MAPPING[filter.field as keyof typeof FILTER_FIELD_MAPPING]; + + if (paramKey && params[paramKey as keyof typeof params]) { + switch (filter.field) { + case "status": { + const statusValue = Number.parseInt(filter.value as string); + if (Number.isNaN(statusValue)) { + console.error("Status filter value must be a valid number"); + return; + } + params.status?.filters.push({ + operator: "is", + value: statusValue, + }); + break; } - 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; + case "methods": + case "host": + case "requestId": { + if (typeof filter.value !== "string") { + console.error(`${filter.field} filter value must be a string`); + return; + } + const targetParam = params[paramKey as keyof typeof params] as { + filters: { operator: string; value: string }[]; + }; + targetParam.filters.push({ + operator: "is", + value: filter.value, + }); + break; } - 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; + case "paths": { + if (typeof filter.value !== "string") { + console.error("Path filter value must be a string"); + return; + } + params.path?.filters.push({ + operator: filter.operator, + value: filter.value, + }); + break; } - 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; + } else if (TIME_FIELDS.includes(filter.field as (typeof TIME_FIELDS)[number])) { + switch (filter.field) { + case "startTime": + case "endTime": { + if (typeof filter.value !== "number") { + console.error(`${filter.field} filter value must be a number`); + return; + } + params[filter.field] = filter.value; + break; } - params[filter.field] = filter.value; - break; - } - case "since": { - if (typeof filter.value !== "string") { - console.error("Since filter value type has to be 'string'"); - return; + case "since": { + if (typeof filter.value !== "string") { + console.error("Since filter value must be a string"); + return; + } + params.since = filter.value; + break; } - params.since = filter.value; - break; } } }); diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/query-logs.schema.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/query-gateway-logs.schema.ts similarity index 89% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/query-logs.schema.ts rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/query-gateway-logs.schema.ts index feadf1eed1..d29332e6ae 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/query-logs.schema.ts +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/query-gateway-logs.schema.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { logsFilterOperatorEnum } from "../../filters.schema"; +import { gatewayLogsFilterOperatorEnum } from "../../gateway-logs-filters.schema"; export const queryLogsPayload = z.object({ limit: z.number().int(), @@ -10,7 +10,7 @@ export const queryLogsPayload = z.object({ .object({ filters: z.array( z.object({ - operator: logsFilterOperatorEnum, + operator: gatewayLogsFilterOperatorEnum, value: z.string(), }), ), diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/constants.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/constants.ts similarity index 100% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/constants.ts rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/constants.ts diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/context/gateway-logs-provider.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/context/gateway-logs-provider.tsx new file mode 100644 index 0000000000..55b37b8e74 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/context/gateway-logs-provider.tsx @@ -0,0 +1,49 @@ +"use client"; +import type { Log } from "@unkey/clickhouse/src/logs"; +import { type PropsWithChildren, createContext, useContext, useState } from "react"; +import { useProjectLayout } from "../../layout-provider"; + +type GatewayLogsContextType = { + isLive: boolean; + toggleLive: (value?: boolean) => void; + selectedLog: Log | null; + setSelectedLog: (log: Log | null) => void; +}; + +const GatewayLogsContext = createContext(null); + +export const GatewayLogsProvider = ({ children }: PropsWithChildren) => { + const { setIsDetailsOpen } = useProjectLayout(); + const [selectedLog, setSelectedLog] = useState(null); + const [isLive, setIsLive] = useState(false); + + const toggleLive = (value?: boolean) => { + setIsLive((prev) => (typeof value !== "undefined" ? value : !prev)); + }; + + return ( + { + if (log) { + setIsDetailsOpen(false); + } + setSelectedLog(log); + }, + }} + > + {children} + + ); +}; + +export const useGatewayLogsContext = () => { + const context = useContext(GatewayLogsContext); + if (!context) { + throw new Error("useGatewayLogsContext must be used within a GatewayLogsProvider"); + } + return context; +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/gateway-logs-filters.schema.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/gateway-logs-filters.schema.ts new file mode 100644 index 0000000000..d741293dc4 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/gateway-logs-filters.schema.ts @@ -0,0 +1,124 @@ +import type { + FilterValue, + NumberConfig, + StringConfig, +} from "@/components/logs/validation/filter.types"; +import { parseAsFilterValueArray } from "@/components/logs/validation/utils/nuqs-parsers"; +import { createFilterOutputSchema } from "@/components/logs/validation/utils/structured-output-schema-generator"; +import { z } from "zod"; +import { METHODS } from "./constants"; + +// Constants +const ALL_OPERATORS = ["is", "contains", "startsWith", "endsWith"] as const; + +// Types +export type GatewayLogsFilterOperator = (typeof ALL_OPERATORS)[number]; + +type StatusConfig = NumberConfig & { + type: "number"; + operators: ["is"]; + getColorClass: (value: number) => string; + validate: (value: number) => boolean; +}; + +type FilterFieldConfigs = { + status: StatusConfig; + methods: StringConfig; + paths: StringConfig; + host: StringConfig; + requestId: StringConfig; + startTime: NumberConfig; + endTime: NumberConfig; + since: StringConfig; +}; + +// Configuration +export const gatewayLogsFilterFieldConfig: FilterFieldConfigs = { + status: { + type: "number", + operators: ["is"], + getColorClass: (value) => { + if (value >= 500) { + return "bg-error-9"; + } + if (value >= 400) { + return "bg-warning-8"; + } + return "bg-success-9"; + }, + validate: (value) => value >= 200 && value <= 599, + }, + methods: { + type: "string", + operators: ["is"], + validValues: METHODS, + }, + paths: { + type: "string", + operators: ["is", "contains", "startsWith", "endsWith"], + }, + host: { + type: "string", + operators: ["is"], + }, + requestId: { + type: "string", + operators: ["is"], + }, + startTime: { + type: "number", + operators: ["is"], + }, + endTime: { + type: "number", + operators: ["is"], + }, + since: { + type: "string", + operators: ["is"], + }, +} as const; + +// Schemas +export const gatewayLogsFilterOperatorEnum = z.enum(ALL_OPERATORS); +export const gatewayLogsFilterFieldEnum = z.enum([ + "status", + "methods", + "paths", + "host", + "requestId", + "startTime", + "endTime", + "since", +]); + +export const gatewayLogsFilterOutputSchema = createFilterOutputSchema( + gatewayLogsFilterFieldEnum, + gatewayLogsFilterOperatorEnum, + gatewayLogsFilterFieldConfig, +); + +// Derived types +export type GatewayLogsFilterField = z.infer; + +export type GatewayLogsFilterUrlValue = { + value: string; + operator: GatewayLogsFilterOperator; +}; + +export type GatewayLogsFilterValue = FilterValue; + +export type GatewayLogsQuerySearchParams = { + status: GatewayLogsFilterUrlValue[] | null; + methods: GatewayLogsFilterUrlValue[] | null; + paths: GatewayLogsFilterUrlValue[] | null; + host: GatewayLogsFilterUrlValue[] | null; + requestId: GatewayLogsFilterUrlValue[] | null; + startTime: number | null; + endTime: number | null; + since: string | null; +}; + +export const parseAsAllOperatorsFilterArray = parseAsFilterValueArray([ + ...ALL_OPERATORS, +]); diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/hooks/use-gateway-logs-filters.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/hooks/use-gateway-logs-filters.ts new file mode 100644 index 0000000000..5caa9f4d3c --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/hooks/use-gateway-logs-filters.ts @@ -0,0 +1,135 @@ +import { + parseAsFilterValueArray, + parseAsRelativeTime, +} from "@/components/logs/validation/utils/nuqs-parsers"; +import { parseAsInteger, useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; +import { + type GatewayLogsFilterField, + type GatewayLogsFilterOperator, + type GatewayLogsFilterUrlValue, + type GatewayLogsFilterValue, + type GatewayLogsQuerySearchParams, + gatewayLogsFilterFieldConfig, +} from "../gateway-logs-filters.schema"; + +// Constants +const parseAsFilterValArray = parseAsFilterValueArray([ + "is", + "contains", + "startsWith", + "endsWith", +]); + +const arrayFields = ["status", "methods", "paths", "host", "requestId"] as const; +const timeFields = ["startTime", "endTime", "since"] as const; + +// Query params configuration +export const queryParamsPayload = { + status: parseAsFilterValArray, + methods: parseAsFilterValArray, + paths: parseAsFilterValArray, + host: parseAsFilterValArray, + requestId: parseAsFilterValArray, + startTime: parseAsInteger, + endTime: parseAsInteger, + since: parseAsRelativeTime, +} as const; + +export const useGatewayLogsFilters = () => { + const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload, { + history: "push", + }); + + const filters = useMemo(() => { + const activeFilters: GatewayLogsFilterValue[] = []; + + // Handle array filters + arrayFields.forEach((field) => { + searchParams[field]?.forEach((item) => { + activeFilters.push({ + id: crypto.randomUUID(), + field, + operator: item.operator, + value: item.value, + metadata: gatewayLogsFilterFieldConfig[field].getColorClass + ? { + colorClass: gatewayLogsFilterFieldConfig[field].getColorClass( + //TODO: Handle this later + //@ts-expect-error will fix it + field === "status" ? Number(item.value) : item.value, + ), + } + : undefined, + }); + }); + }); + + // Handle time filters + timeFields.forEach((field) => { + const value = searchParams[field]; + if (value !== null && value !== undefined) { + activeFilters.push({ + id: crypto.randomUUID(), + field: field as GatewayLogsFilterField, + operator: "is", + value: value as string | number, + }); + } + }); + + return activeFilters; + }, [searchParams]); + + const updateFilters = useCallback( + (newFilters: GatewayLogsFilterValue[]) => { + const newParams: Partial = Object.fromEntries([ + ...arrayFields.map((field) => [field, null]), + ...timeFields.map((field) => [field, null]), + ]); + + const filterGroups = arrayFields.reduce( + (acc, field) => { + acc[field] = []; + return acc; + }, + {} as Record<(typeof arrayFields)[number], GatewayLogsFilterUrlValue[]>, + ); + + newFilters.forEach((filter) => { + if (arrayFields.includes(filter.field as (typeof arrayFields)[number])) { + filterGroups[filter.field as (typeof arrayFields)[number]].push({ + value: filter.value as string, + operator: filter.operator, + }); + } else if (filter.field === "startTime" || filter.field === "endTime") { + newParams[filter.field] = filter.value as number; + } else if (filter.field === "since") { + newParams.since = filter.value as string; + } + }); + + // Set array filters + arrayFields.forEach((field) => { + newParams[field] = filterGroups[field].length > 0 ? filterGroups[field] : 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, + }; +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/page.tsx new file mode 100644 index 0000000000..7dec3cd3db --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/page.tsx @@ -0,0 +1,36 @@ +"use client"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useCallback, useState } from "react"; +import { useProjectLayout } from "../layout-provider"; +import { GatewayLogsChart } from "./components/charts"; +import { GatewayLogsControlCloud } from "./components/control-cloud"; +import { GatewayLogsControls } from "./components/controls"; +import { GatewayLogDetails } from "./components/table/gateway-log-details"; +import { GatewayLogsTable } from "./components/table/gateway-logs-table"; +import { GatewayLogsProvider } from "./context/gateway-logs-provider"; + +export default function Page() { + const { isDetailsOpen } = useProjectLayout(); + const [tableDistanceToTop, setTableDistanceToTop] = useState(0); + + const handleDistanceToTop = useCallback((distanceToTop: number) => { + setTableDistanceToTop(distanceToTop); + }, []); + + return ( +
+ + + + + + + +
+ ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/types.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/types.ts similarity index 100% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/types.ts rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/types.ts diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/utils.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/utils.ts similarity index 100% rename from apps/dashboard/app/(app)/projects/[projectId]/logs/utils.ts rename to apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/utils.ts diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/hooks/use-fetch-timeseries.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/hooks/use-fetch-timeseries.ts deleted file mode 100644 index 80ad4fda31..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/charts/hooks/use-fetch-timeseries.ts +++ /dev/null @@ -1,105 +0,0 @@ -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"; - -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 { data, isLoading, isError } = trpc.logs.queryTimeseries.useQuery(queryParams, { - refetchInterval: queryParams.endTime ? false : 10_000, - }); - - const timeseries = data?.timeseries.map((ts) => ({ - displayX: formatTimestampForChart(ts.x, data.granularity), - originalTimestamp: ts.x, - ...ts.y, - })); - - return { timeseries, isLoading, isError, granularity: data?.granularity }; -}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/index.tsx deleted file mode 100644 index 69daf07819..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { QueriesPopover } from "@/components/logs/queries/queries-popover"; -import { cn } from "@/lib/utils"; -import { ChartBarAxisY } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { useFilters } from "../../../../hooks/use-filters"; -import { formatFilterValues, getFilterFieldIcon } from "./utils"; -export const LogsQueries = () => { - const { filters, updateFilters } = useFilters(); - - return ( - -
- -
-
- ); -}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/utils.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/utils.ts deleted file mode 100644 index c008182006..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/components/logs-queries/utils.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { QuerySearchParams } from "@/app/(app)/logs/filters.schema"; -import { iconsPerField } from "@/components/logs/queries/utils"; -import { ChartActivity2 } from "@unkey/icons"; -import { format } from "date-fns"; -import React from "react"; - -export function formatFilterValues( - filters: QuerySearchParams, -): Record { - const transform = (field: string, value: string): { color: string | null; value: string } => { - switch (field) { - case "status": - return { - value: - value === "200" ? "2xx" : value === "400" ? "4xx" : value === "500" ? "5xx" : value, - color: value.startsWith("2") - ? "bg-success-9" - : value.startsWith("4") - ? "bg-warning-9" - : value.startsWith("5") - ? "bg-error-9" - : null, - }; - case "methods": - return { value: value.toUpperCase(), color: null }; - case "startTime": - case "endTime": - return { value: format(Number(value), "MMM d HH:mm:ss"), color: null }; - case "since": - return { value: value, color: null }; - case "host": - case "requestId": - case "paths": - return { value: value, color: null }; - default: - return { value: value, color: null }; - } - }; - - const transformed: Record< - string, - { operator: string; values: { value: string; color: string | null }[] } - > = {}; - - // Handle special cases for different field types const transformed: Record = {}; - if (filters.startTime && filters.endTime) { - transformed.time = { - operator: "between", - values: [ - transform("startTime", filters.startTime.toString()), - transform("endTime", filters.endTime.toString()), - ], - }; - } else if (filters.startTime) { - transformed.time = { - operator: "starts from", - values: [transform("startTime", filters.startTime.toString())], - }; - } else if (filters.since) { - transformed.time = { - operator: "since", - values: [{ value: filters.since, color: null }], - }; - } - - Object.entries(filters).forEach(([field, value]) => { - if (field === "startTime" || field === "endTime" || field === "since" || field === "time") { - return []; - } - - if (value === null) { - return; - } - - if (Array.isArray(value)) { - transformed[field] = { - operator: value[0]?.operator || "is", - values: value.map((v) => transform(field, v.value.toString())), - }; - } else { - transformed[field] = { - operator: "is", - values: [transform(field, value.toString())], - }; - } - }); - - return transformed; -} - -export function getFilterFieldIcon(field: string): JSX.Element { - const Icon = iconsPerField[field] || ChartActivity2; - return React.createElement(Icon, { size: "md-regular", className: "justify-center" }); -} - -export const FieldsToTruncate = [ - "users", - "workspaceId", - "keyId", - "apiId", - "requestId", - "responseId", -] as const; - -export function shouldTruncateRow(field: string): boolean { - return FieldsToTruncate.includes(field as (typeof FieldsToTruncate)[number]); -} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/index.tsx deleted file mode 100644 index 99d34144d0..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/controls/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { - ControlsContainer, - ControlsLeft, - ControlsRight, -} from "@/components/logs/controls-container"; -import { Separator } from "@unkey/ui"; -import { LogsDateTime } from "./components/logs-datetime"; -import { LogsFilters } from "./components/logs-filters"; -import { LogsLiveSwitch } from "./components/logs-live-switch"; -import { LogsQueries } from "./components/logs-queries"; -import { LogsRefresh } from "./components/logs-refresh"; -import { LogsSearch } from "./components/logs-search"; - -export function LogsControls() { - return ( - - - - - - - - - - - - - - ); -} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.test.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.test.ts deleted file mode 100644 index 1750654b2a..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/components/table/hooks/use-logs-query.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { trpc } from "@/lib/trpc/client"; -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useLogsQuery } from "./use-logs-query"; - -let mockFilters: any[] = []; -const mockDate = 1706024400000; - -vi.mock("@/providers/query-time-provider", () => ({ - QueryTimeProvider: ({ children }: { children: React.ReactNode }) => children, - useQueryTime: () => ({ - queryTime: new Date(mockDate), - refreshQueryTime: vi.fn(), - }), -})); - -vi.mock("@/lib/trpc/client", () => { - const useInfiniteQuery = vi.fn().mockReturnValue({ - data: null, - hasNextPage: false, - fetchNextPage: vi.fn(), - isFetchingNextPage: false, - isLoading: false, - }); - - const fetch = vi.fn(); - - return { - trpc: { - useUtils: () => ({ - logs: { - queryLogs: { - fetch, - }, - }, - }), - logs: { - queryLogs: { - useInfiniteQuery, - }, - }, - }, - }; -}); - -vi.mock("../../../hooks/use-filters", () => ({ - useFilters: () => ({ - filters: mockFilters, - }), -})); - -describe("useLogsQuery filter processing", () => { - beforeEach(() => { - mockFilters = []; - vi.setSystemTime(mockDate); - }); - - it("handles valid status filter", () => { - mockFilters = [{ field: "status", operator: "is", value: "404" }]; - const { result } = renderHook(() => useLogsQuery()); - expect(result.current.isPolling).toBe(false); - }); - - it("handles multiple valid filters", () => { - mockFilters = [ - { field: "status", operator: "is", value: "404" }, - { field: "methods", operator: "is", value: "GET" }, - { field: "paths", operator: "startsWith", value: "/api" }, - ]; - const { result } = renderHook(() => useLogsQuery()); - expect(result.current.isPolling).toBe(false); - }); - - it("handles invalid filter types", () => { - const consoleMock = vi.spyOn(console, "error"); - mockFilters = [ - { field: "methods", operator: "is", value: 123 }, - { field: "paths", operator: "startsWith", value: true }, - { field: "host", operator: "is", value: {} }, - ]; - renderHook(() => useLogsQuery()); - expect(consoleMock).toHaveBeenCalledTimes(6); - }); - - it("handles time-based filters", () => { - mockFilters = [ - { field: "startTime", operator: "is", value: mockDate - 3600000 }, - { field: "since", operator: "is", value: "1h" }, - ]; - const { result } = renderHook(() => useLogsQuery()); - expect(result.current.isPolling).toBe(false); - }); -}); - -describe("useLogsQuery realtime logs", () => { - let useInfiniteQuery: ReturnType; - let fetch: ReturnType; - - beforeEach(() => { - vi.setSystemTime(mockDate); - mockFilters = []; - //@ts-expect-error hacky way to mock trpc - useInfiniteQuery = vi.mocked(trpc.logs.queryLogs.useInfiniteQuery); - //@ts-expect-error hacky way to mock trpc - fetch = vi.mocked(trpc.useUtils().logs.queryLogs.fetch); - }); - - it("resets realtime logs when polling stops", async () => { - const mockLogs = [ - { request_id: "1", time: Date.now(), method: "GET", path: "/api/test" }, - { request_id: "2", time: Date.now(), method: "POST", path: "/api/users" }, - ]; - - useInfiniteQuery.mockReturnValue({ - data: { - pages: [{ logs: mockLogs, nextCursor: null }], - }, - hasNextPage: false, - fetchNextPage: vi.fn(), - isFetchingNextPage: false, - isLoading: false, - }); - - fetch.mockResolvedValue({ - logs: [ - { - request_id: "3", - time: Date.now(), - method: "PUT", - path: "/api/update", - }, - ], - }); - - const { result, rerender } = renderHook( - ({ startPolling, pollIntervalMs }) => useLogsQuery({ startPolling, pollIntervalMs }), - { initialProps: { startPolling: true, pollIntervalMs: 1000 } }, - ); - - expect(result.current.historicalLogs).toHaveLength(2); - - // Wait for polling interval - await act(async () => { - await new Promise((resolve) => setTimeout(resolve)); - }); - - act(() => { - rerender({ startPolling: false, pollIntervalMs: 1000 }); - }); - - expect(result.current.realtimeLogs).toHaveLength(0); - expect(result.current.historicalLogs).toHaveLength(2); - }); -}); diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/context/logs.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/context/logs.tsx deleted file mode 100644 index 59344cabeb..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/context/logs.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import type { Log } from "@unkey/clickhouse/src/logs"; -import { type PropsWithChildren, createContext, useContext, useState } from "react"; - -type DisplayProperty = - | "time" - | "response_status" - | "method" - | "path" - | "response_body" - | "request_id" - | "host" - | "request_headers" - | "request_body" - | "response_headers"; - -type LogsContextType = { - isLive: boolean; - toggleLive: (value?: boolean) => void; - selectedLog: Log | null; - setSelectedLog: (log: Log | null) => void; - displayProperties: Set; - toggleDisplayProperty: (property: DisplayProperty) => void; -}; - -const DEFAULT_DISPLAY_PROPERTIES: DisplayProperty[] = [ - "time", - "response_status", - "method", - "path", - "response_body", -]; - -const LogsContext = createContext(null); - -export const LogsProvider = ({ children }: PropsWithChildren) => { - const [selectedLog, setSelectedLog] = useState(null); - const [isLive, setIsLive] = useState(false); - const [displayProperties, setDisplayProperties] = useState>( - new Set(DEFAULT_DISPLAY_PROPERTIES), - ); - - const toggleDisplayProperty = (property: DisplayProperty) => { - setDisplayProperties((prev) => { - const next = new Set(prev); - if (next.has(property)) { - next.delete(property); - } else { - next.add(property); - } - return next; - }); - }; - - const toggleLive = (value?: boolean) => { - setIsLive((prev) => (typeof value !== "undefined" ? value : !prev)); - }; - - return ( - - {children} - - ); -}; - -export const useLogsContext = () => { - const context = useContext(LogsContext); - if (!context) { - throw new Error("useLogsContext must be used within a LogsProvider"); - } - return context; -}; - -export const isDisplayProperty = (value: string): value is DisplayProperty => { - return [ - "time", - "response_status", - "method", - "path", - "response_body", - "request_id", - "host", - "request_headers", - "request_body", - "response_headers", - ].includes(value); -}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/filters.schema.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/filters.schema.ts deleted file mode 100644 index 2d766fded9..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/filters.schema.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { METHODS } from "./constants"; - -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 = { - status: { - type: "number", - operators: ["is"], - getColorClass: (value) => { - if (value >= 500) { - return "bg-error-9"; - } - if (value >= 400) { - return "bg-warning-8"; - } - return "bg-success-9"; - }, - validate: (value) => value >= 200 && value <= 599, - }, - methods: { - type: "string", - operators: ["is"], - validValues: METHODS, - }, - paths: { - type: "string", - operators: ["is", "contains", "startsWith", "endsWith"], - }, - host: { - type: "string", - operators: ["is"], - }, - requestId: { - type: "string", - operators: ["is"], - }, - startTime: { - type: "number", - operators: ["is"], - }, - endTime: { - type: "number", - operators: ["is"], - }, - since: { - type: "string", - operators: ["is"], - }, -} as const; - -export interface StatusConfig extends NumberConfig { - type: "number"; - operators: ["is"]; - validate: (value: number) => boolean; -} - -// Schemas -export const logsFilterOperatorEnum = z.enum(["is", "contains", "startsWith", "endsWith"]); - -export const logsFilterFieldEnum = z.enum([ - "host", - "requestId", - "methods", - "paths", - "status", - "startTime", - "endTime", - "since", -]); - -export const filterOutputSchema = createFilterOutputSchema( - logsFilterFieldEnum, - logsFilterOperatorEnum, - logsFilterFieldConfig, -); - -// Types -export type LogsFilterOperator = z.infer; -export type LogsFilterField = z.infer; - -export type FilterFieldConfigs = { - status: StatusConfig; - methods: StringConfig; - paths: StringConfig; - host: StringConfig; - requestId: StringConfig; - startTime: NumberConfig; - 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)/projects/[projectId]/logs/hooks/use-filters.ts b/apps/dashboard/app/(app)/projects/[projectId]/logs/hooks/use-filters.ts deleted file mode 100644 index 38874d1238..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/hooks/use-filters.ts +++ /dev/null @@ -1,191 +0,0 @@ -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"; - -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, - }; -}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/page.tsx deleted file mode 100644 index 521564bc1c..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; -import { useCallback, useState } from "react"; -import { LogsChart } from "./components/charts"; -import { LogsControlCloud } from "./components/control-cloud"; -import { LogsControls } from "./components/controls"; -import { LogDetails } from "./components/table/log-details"; -import { LogsTable } from "./components/table/logs-table"; -import { LogsProvider } from "./context/logs"; - -export default function Page() { - const [tableDistanceToTop, setTableDistanceToTop] = useState(0); - - const handleDistanceToTop = useCallback((distanceToTop: number) => { - setTableDistanceToTop(distanceToTop); - }, []); - - return ( - - - - - - - - ); -} From ebccfd3b7fbae26aea232cf318674e11867570f4 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 18 Sep 2025 15:16:35 +0300 Subject: [PATCH 03/27] fix: add animated logdetails --- .../table/gateway-log-details/index.tsx | 73 +---- .../log-details/components/log-footer.tsx} | 12 +- .../log-details/components/log-header.tsx} | 4 +- .../log-details/components/log-meta.tsx} | 0 .../log-details/components/log-section.tsx} | 0 .../logs/details/log-details/index.tsx | 260 ++++++++++++++++++ 6 files changed, 282 insertions(+), 67 deletions(-) rename apps/dashboard/{app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-footer.tsx => components/logs/details/log-details/components/log-footer.tsx} (90%) rename apps/dashboard/{app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-header.tsx => components/logs/details/log-details/components/log-header.tsx} (95%) rename apps/dashboard/{app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-meta.tsx => components/logs/details/log-details/components/log-meta.tsx} (100%) rename apps/dashboard/{app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-section.tsx => components/logs/details/log-details/components/log-section.tsx} (100%) create mode 100644 apps/dashboard/components/logs/details/log-details/index.tsx diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx index 4272edb1a3..68db12e1a7 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx @@ -1,77 +1,30 @@ "use client"; -import { ResizablePanel } from "@/components/logs/details/resizable-panel"; -import { useMemo } from "react"; -import { DEFAULT_DRAGGABLE_WIDTH } from "../../../constants"; +import { LogDetails } from "@/components/logs/details/log-details"; import { useGatewayLogsContext } from "../../../context/gateway-logs-provider"; -import { extractResponseField, safeParseJson } from "../../../utils"; -import { LogFooter } from "./components/gateway-log-footer"; -import { LogHeader } from "./components/gateway-log-header"; -import { LogMetaSection } from "./components/gateway-log-meta"; -import { LogSection } from "./components/gateway-log-section"; - -const createPanelStyle = (distanceToTop: number) => ({ - top: `${distanceToTop}px`, - width: `${DEFAULT_DRAGGABLE_WIDTH}px`, - height: `calc(100vh - ${distanceToTop}px)`, - paddingBottom: "1rem", -}); type Props = { distanceToTop: number; }; +const ANIMATION_DELAY = 350; export const GatewayLogDetails = ({ distanceToTop }: Props) => { const { setSelectedLog, selectedLog: log } = useGatewayLogsContext(); - const panelStyle = useMemo(() => createPanelStyle(distanceToTop), [distanceToTop]); - - if (!log) { - return null; - } const handleClose = () => { setSelectedLog(null); }; + if (!log) { + return null; + } + return ( - - - "} - title="Request Header" - /> - " - : JSON.stringify(safeParseJson(log.request_body), null, 2) - } - title="Request Body" - /> - "} - title="Response Header" - /> - " - : JSON.stringify(safeParseJson(log.response_body), null, 2) - } - title="Response Body" - /> -
- - " - : JSON.stringify(extractResponseField(log, "meta"), null, 2) - } - /> - + + + + + + + ); }; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-footer.tsx b/apps/dashboard/components/logs/details/log-details/components/log-footer.tsx similarity index 90% rename from apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-footer.tsx rename to apps/dashboard/components/logs/details/log-details/components/log-footer.tsx index a6aea86212..d152782bce 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-footer.tsx +++ b/apps/dashboard/components/logs/details/log-details/components/log-footer.tsx @@ -1,16 +1,18 @@ "use client"; -import { RED_STATES, YELLOW_STATES } from "@/app/(app)/logs/constants"; import { extractResponseField, getRequestHeader } from "@/app/(app)/logs/utils"; import { RequestResponseDetails } from "@/components/logs/details/request-response-details"; import { cn } from "@/lib/utils"; -import type { Log } from "@unkey/clickhouse/src/logs"; import { Badge, TimestampInfo } from "@unkey/ui"; +import type { SupportedLogTypes } from ".."; type Props = { - log: Log; + log: SupportedLogTypes; }; +export const YELLOW_STATES = ["RATE_LIMITED", "EXPIRED", "USAGE_EXCEEDED"]; +export const RED_STATES = ["DISABLED", "FORBIDDEN", "INSUFFICIENT_PERMISSIONS"]; const DEFAULT_OUTCOME = "VALID"; + export const LogFooter = ({ log }: Props) => { return ( { void; }; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-meta.tsx b/apps/dashboard/components/logs/details/log-details/components/log-meta.tsx similarity index 100% rename from apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-meta.tsx rename to apps/dashboard/components/logs/details/log-details/components/log-meta.tsx diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-section.tsx b/apps/dashboard/components/logs/details/log-details/components/log-section.tsx similarity index 100% rename from apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/components/gateway-log-section.tsx rename to apps/dashboard/components/logs/details/log-details/components/log-section.tsx diff --git a/apps/dashboard/components/logs/details/log-details/index.tsx b/apps/dashboard/components/logs/details/log-details/index.tsx new file mode 100644 index 0000000000..9ad7db3ea9 --- /dev/null +++ b/apps/dashboard/components/logs/details/log-details/index.tsx @@ -0,0 +1,260 @@ +"use client"; +import { extractResponseField, safeParseJson } from "@/app/(app)/logs/utils"; +import { ResizablePanel } from "@/components/logs/details/resizable-panel"; +import { cn } from "@/lib/utils"; +import type { Log } from "@unkey/clickhouse/src/logs"; +import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; +import { type ReactNode, createContext, useContext, useEffect, useMemo, useState } from "react"; +import { LogFooter } from "./components/log-footer"; +import { LogHeader } from "./components/log-header"; +import { LogMetaSection } from "./components/log-meta"; +import { LogSection } from "./components/log-section"; + +export const DEFAULT_DRAGGABLE_WIDTH = 500; +const EMPTY_TEXT = ""; + +const createPanelStyle = (distanceToTop: number) => ({ + top: `${distanceToTop}px`, + height: `calc(100vh - ${distanceToTop}px)`, + paddingBottom: "1rem", +}); + +export type SupportedLogTypes = Log | RatelimitLog; + +const LogDetailsContext = createContext<{ + animated: boolean; + isOpen: boolean; + log: SupportedLogTypes; +}>({ animated: false, isOpen: true, log: {} as SupportedLogTypes }); + +const useLogDetailsContext = () => useContext(LogDetailsContext); + +// Helper functions +const createLogSections = (log: SupportedLogTypes) => [ + { + title: "Request Header", + content: log.request_headers.length ? log.request_headers : EMPTY_TEXT, + }, + { + title: "Request Body", + content: + JSON.stringify(safeParseJson(log.request_body), null, 2) === "null" + ? EMPTY_TEXT + : JSON.stringify(safeParseJson(log.request_body), null, 2), + }, + { + title: "Response Header", + content: log.response_headers.length ? log.response_headers : EMPTY_TEXT, + }, + { + title: "Response Body", + content: + JSON.stringify(safeParseJson(log.response_body), null, 2) === "null" + ? EMPTY_TEXT + : JSON.stringify(safeParseJson(log.response_body), null, 2), + }, +]; + +const createMetaContent = (log: SupportedLogTypes) => { + const meta = extractResponseField(log, "meta"); + return JSON.stringify(meta, null, 2) === "null" ? EMPTY_TEXT : JSON.stringify(meta, null, 2); +}; + +// Main LogDetails component +type LogDetailsProps = { + distanceToTop: number; + log: SupportedLogTypes | null; + onClose: () => void; + animated?: boolean; + children: ReactNode; +}; + +export const LogDetails = ({ + distanceToTop, + log, + onClose, + animated = false, + children, +}: LogDetailsProps) => { + const [isOpen, setIsOpen] = useState(false); + + const panelStyle = useMemo(() => createPanelStyle(distanceToTop), [distanceToTop]); + + useEffect(() => { + if (!animated) { + return; + } + + if (log) { + const timer = setTimeout(() => setIsOpen(true), 50); + return () => clearTimeout(timer); + } + setIsOpen(false); + }, [log, animated]); + + useEffect(() => { + if (!animated) { + setIsOpen(Boolean(log)); + } + }, [log, animated]); + + if (!log) { + return null; + } + + const handleClose = () => { + if (animated) { + setIsOpen(false); + setTimeout(onClose, 300); + } else { + onClose(); + } + }; + + const baseClasses = "bg-gray-1 dark:bg-black font-mono drop-shadow-2xl z-20"; + const animationClasses = animated + ? cn( + "transition-all duration-300 ease-out", + isOpen ? "translate-x-0 opacity-100" : "translate-x-full opacity-0", + ) + : ""; + const staticClasses = animated ? "" : "absolute right-0 overflow-y-auto p-4"; + + return ( + +
+ + {children} + +
+
+ ); +}; + +// Section wrapper with animation +type SectionProps = { + children: ReactNode; + delay?: number; + translateX?: "translate-x-6" | "translate-x-8"; +}; + +const Section = ({ children, delay = 0, translateX = "translate-x-8" }: SectionProps) => { + const { animated, isOpen } = useLogDetailsContext(); + + if (!animated) { + return <>{children}; + } + + return ( +
+ {children} +
+ ); +}; + +// Standard log sections +const Sections = ({ + startDelay = 150, + staggerDelay = 50, +}: { + startDelay?: number; + staggerDelay?: number; +}) => { + const { log } = useLogDetailsContext(); + const sections = createLogSections(log); + + return ( + <> + {sections.map((section, index) => ( +
+ +
+ ))} + + ); +}; + +// Spacer with animation +const Spacer = ({ delay = 0 }: { delay?: number }) => { + const { animated, isOpen } = useLogDetailsContext(); + + return ( +
+ ); +}; + +// Meta section +const Meta = ({ delay = 400 }: { delay?: number }) => { + const { log } = useLogDetailsContext(); + const content = createMetaContent(log); + + return ( +
+ +
+ ); +}; + +// Header compound component +const Header = ({ + delay = 100, + onClose, +}: { + delay?: number; + onClose: () => void; +}) => { + const { log } = useLogDetailsContext(); + + return ( +
+ +
+ ); +}; + +// Footer compound component +const Footer = ({ delay = 375 }: { delay?: number }) => { + const { log } = useLogDetailsContext(); + + return ( +
+ +
+ ); +}; + +// Compound components +LogDetails.Section = Section; +LogDetails.Sections = Sections; +LogDetails.Spacer = Spacer; +LogDetails.Meta = Meta; +LogDetails.Header = Header; +LogDetails.Footer = Footer; +LogDetails.useContext = useLogDetailsContext; +LogDetails.createMetaContent = createMetaContent; From e201e77fc22d0bbe162b04e42df5878fd6ba1cc9 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 18 Sep 2025 15:24:04 +0300 Subject: [PATCH 04/27] refactor: get rid of duplicated components --- .../log-details/components/log-footer.tsx | 106 ------------------ .../log-details/components/log-header.tsx | 42 ------- .../table/log-details/components/log-meta.tsx | 22 ---- .../log-details/components/log-section.tsx | 56 --------- .../components/table/log-details/index.tsx | 65 ++--------- apps/dashboard/app/(app)/logs/constants.ts | 2 - .../table/gateway-log-details/index.tsx | 3 +- .../log-details/components/log-footer.tsx | 101 ----------------- .../log-details/components/log-header.tsx | 42 ------- .../table/log-details/components/log-meta.tsx | 22 ---- .../log-details/components/log-section.tsx | 56 --------- .../components/table/log-details/index.tsx | 65 ++--------- .../[namespaceId]/logs/constants.ts | 2 - 13 files changed, 21 insertions(+), 563 deletions(-) delete mode 100644 apps/dashboard/app/(app)/logs/components/table/log-details/components/log-footer.tsx delete mode 100644 apps/dashboard/app/(app)/logs/components/table/log-details/components/log-header.tsx delete mode 100644 apps/dashboard/app/(app)/logs/components/table/log-details/components/log-meta.tsx delete mode 100644 apps/dashboard/app/(app)/logs/components/table/log-details/components/log-section.tsx delete mode 100644 apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-footer.tsx delete mode 100644 apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-header.tsx delete mode 100644 apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-meta.tsx delete mode 100644 apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-section.tsx diff --git a/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-footer.tsx b/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-footer.tsx deleted file mode 100644 index a6aea86212..0000000000 --- a/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-footer.tsx +++ /dev/null @@ -1,106 +0,0 @@ -"use client"; -import { RED_STATES, YELLOW_STATES } from "@/app/(app)/logs/constants"; -import { extractResponseField, getRequestHeader } from "@/app/(app)/logs/utils"; -import { RequestResponseDetails } from "@/components/logs/details/request-response-details"; -import { cn } from "@/lib/utils"; -import type { Log } from "@unkey/clickhouse/src/logs"; -import { Badge, TimestampInfo } from "@unkey/ui"; - -type Props = { - log: Log; -}; - -const DEFAULT_OUTCOME = "VALID"; -export const LogFooter = ({ log }: Props) => { - return ( - ( - - ), - content: log.time, - tooltipContent: "Copy Time", - tooltipSuccessMessage: "Time copied to clipboard", - skipTooltip: true, - }, - { - label: "Host", - description: (content) => {content}, - content: log.host, - tooltipContent: "Copy Host", - tooltipSuccessMessage: "Host copied to clipboard", - }, - { - label: "Request Path", - description: (content) => {content}, - content: log.path, - tooltipContent: "Copy Request Path", - tooltipSuccessMessage: "Request path copied to clipboard", - }, - { - label: "Request ID", - description: (content) => {content}, - content: log.request_id, - tooltipContent: "Copy Request ID", - tooltipSuccessMessage: "Request ID copied to clipboard", - }, - { - label: "Request User Agent", - description: (content) => {content}, - content: getRequestHeader(log, "user-agent") ?? "", - tooltipContent: "Copy Request User Agent", - tooltipSuccessMessage: "Request user agent copied to clipboard", - }, - { - label: "Outcome", - description: (content) => { - let contentCopy = content; - if (contentCopy == null) { - contentCopy = DEFAULT_OUTCOME; - } - return ( - - {content} - - ); - }, - content: extractResponseField(log, "code"), - tooltipContent: "Copy Outcome", - tooltipSuccessMessage: "Outcome copied to clipboard", - }, - { - label: "Permissions", - description: (content) => ( -
- {content.map((permission, index) => ( - - {permission} - - ))} -
- ), - content: extractResponseField(log, "permissions"), - tooltipContent: "Copy Permissions", - tooltipSuccessMessage: "Permissions copied to clipboard", - }, - ]} - /> - ); -}; diff --git a/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-header.tsx b/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-header.tsx deleted file mode 100644 index 7c50cffa6d..0000000000 --- a/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-header.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { cn } from "@/lib/utils"; -import type { Log } from "@unkey/clickhouse/src/logs"; -import { XMark } from "@unkey/icons"; -import { Badge, Button } from "@unkey/ui"; - -type Props = { - log: Log; - onClose: () => void; -}; - -export const LogHeader = ({ onClose, log }: Props) => { - return ( -
-
- - {log.method} - -

{log.path}

-
- -
-
- = 200 && log.response_status < 300, - "bg-warning-3 text-warning-11 hover:bg-warning-4": - log.response_status >= 400 && log.response_status < 500, - "bg-error-3 text-error-11 hover:bg-error-4": log.response_status >= 500, - })} - > - {log.response_status} - - | - -
-
-
- ); -}; diff --git a/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-meta.tsx b/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-meta.tsx deleted file mode 100644 index b7a60b3bef..0000000000 --- a/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-meta.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Card, CardContent, CopyButton } from "@unkey/ui"; - -export const LogMetaSection = ({ content }: { content: string }) => { - return ( -
-
Meta
- - -
{content ?? ""} 
- -
-
-
- ); -}; diff --git a/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-section.tsx b/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-section.tsx deleted file mode 100644 index 276934956d..0000000000 --- a/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-section.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Card, CardContent, CopyButton } from "@unkey/ui"; - -export const LogSection = ({ - details, - title, -}: { - details: string | string[]; - title: string; -}) => { - return ( -
-
- {title} -
- - -
-            {Array.isArray(details)
-              ? details.map((header) => {
-                  const [key, ...valueParts] = header.split(":");
-                  const value = valueParts.join(":").trim();
-                  return (
-                    
- {key}: - {value} -
- ); - }) - : details} -
- -
-
-
- ); -}; - -const getFormattedContent = (details: string | string[]) => { - if (Array.isArray(details)) { - return details - .map((header) => { - const [key, ...valueParts] = header.split(":"); - const value = valueParts.join(":").trim(); - return `${key}: ${value}`; - }) - .join("\n"); - } - return details; -}; diff --git a/apps/dashboard/app/(app)/logs/components/table/log-details/index.tsx b/apps/dashboard/app/(app)/logs/components/table/log-details/index.tsx index 0905951937..c4e314051f 100644 --- a/apps/dashboard/app/(app)/logs/components/table/log-details/index.tsx +++ b/apps/dashboard/app/(app)/logs/components/table/log-details/index.tsx @@ -1,20 +1,9 @@ "use client"; -import { ResizablePanel } from "@/components/logs/details/resizable-panel"; -import { useMemo } from "react"; -import { DEFAULT_DRAGGABLE_WIDTH } from "../../../constants"; import { useLogsContext } from "../../../context/logs"; -import { extractResponseField, safeParseJson } from "../../../utils"; -import { LogFooter } from "./components/log-footer"; -import { LogHeader } from "./components/log-header"; -import { LogMetaSection } from "./components/log-meta"; -import { LogSection } from "./components/log-section"; -const createPanelStyle = (distanceToTop: number) => ({ - top: `${distanceToTop}px`, - width: `${DEFAULT_DRAGGABLE_WIDTH}px`, - height: `calc(100vh - ${distanceToTop}px)`, - paddingBottom: "1rem", -}); +import { LogDetails as SharedLogDetails } from "@/components/logs/details/log-details"; + +const ANIMATION_DELAY = 350; type Props = { distanceToTop: number; @@ -22,7 +11,6 @@ type Props = { export const LogDetails = ({ distanceToTop }: Props) => { const { setSelectedLog, selectedLog: log } = useLogsContext(); - const panelStyle = useMemo(() => createPanelStyle(distanceToTop), [distanceToTop]); if (!log) { return null; @@ -33,45 +21,12 @@ export const LogDetails = ({ distanceToTop }: Props) => { }; return ( - - - "} - title="Request Header" - /> - " - : JSON.stringify(safeParseJson(log.request_body), null, 2) - } - title="Request Body" - /> - "} - title="Response Header" - /> - " - : JSON.stringify(safeParseJson(log.response_body), null, 2) - } - title="Response Body" - /> -
- - " - : JSON.stringify(extractResponseField(log, "meta"), null, 2) - } - /> - + + + + + + + ); }; diff --git a/apps/dashboard/app/(app)/logs/constants.ts b/apps/dashboard/app/(app)/logs/constants.ts index 585c7c28b8..cfc7820c30 100644 --- a/apps/dashboard/app/(app)/logs/constants.ts +++ b/apps/dashboard/app/(app)/logs/constants.ts @@ -1,5 +1,3 @@ -export const DEFAULT_DRAGGABLE_WIDTH = 500; - export const YELLOW_STATES = ["RATE_LIMITED", "EXPIRED", "USAGE_EXCEEDED"]; export const RED_STATES = ["DISABLED", "FORBIDDEN", "INSUFFICIENT_PERMISSIONS"]; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx index 68db12e1a7..269d76f76c 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx @@ -2,11 +2,12 @@ import { LogDetails } from "@/components/logs/details/log-details"; import { useGatewayLogsContext } from "../../../context/gateway-logs-provider"; +const ANIMATION_DELAY = 350; + type Props = { distanceToTop: number; }; -const ANIMATION_DELAY = 350; export const GatewayLogDetails = ({ distanceToTop }: Props) => { const { setSelectedLog, selectedLog: log } = useGatewayLogsContext(); diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-footer.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-footer.tsx deleted file mode 100644 index 6ddec7deb3..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-footer.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; -import { RED_STATES, YELLOW_STATES } from "@/app/(app)/logs/constants"; -import { extractResponseField, getRequestHeader } from "@/app/(app)/logs/utils"; -import { RequestResponseDetails } from "@/components/logs/details/request-response-details"; -import { cn } from "@/lib/utils"; -import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; -import { Badge, TimestampInfo } from "@unkey/ui"; - -type Props = { - log: RatelimitLog; -}; - -const DEFAULT_OUTCOME = "VALID"; -export const LogFooter = ({ log }: Props) => { - return ( - ( - - ), - content: log.time, - tooltipContent: "Copy Time", - tooltipSuccessMessage: "Time copied to clipboard", - skipTooltip: true, - }, - { - label: "Host", - description: (content) => {content}, - content: log.host, - tooltipContent: "Copy Host", - tooltipSuccessMessage: "Host copied to clipboard", - }, - { - label: "Request Path", - description: (content) => {content}, - content: log.path, - tooltipContent: "Copy Request Path", - tooltipSuccessMessage: "Request path copied to clipboard", - }, - { - label: "Request ID", - description: (content) => {content}, - content: log.request_id, - tooltipContent: "Copy Request ID", - tooltipSuccessMessage: "Request ID copied to clipboard", - }, - { - label: "Request User Agent", - description: (content) => {content}, - content: getRequestHeader(log, "user-agent") ?? "", - tooltipContent: "Copy Request User Agent", - tooltipSuccessMessage: "Request user agent copied to clipboard", - }, - { - label: "Outcome", - description: (content) => { - let contentCopy = content; - if (contentCopy == null) { - contentCopy = DEFAULT_OUTCOME; - } - return ( - - {contentCopy} - - ); - }, - content: extractResponseField(log, "code"), - tooltipContent: "Copy Outcome", - tooltipSuccessMessage: "Outcome copied to clipboard", - }, - { - label: "Permissions", - description: (content) => ( - - {content.map((permission) => ( - - {permission} - - ))} - - ), - content: extractResponseField(log, "permissions"), - tooltipContent: "Copy Permissions", - tooltipSuccessMessage: "Permissions copied to clipboard", - }, - ]} - /> - ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-header.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-header.tsx deleted file mode 100644 index 53618ac803..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-header.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { cn } from "@/lib/utils"; -import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; -import { XMark } from "@unkey/icons"; -import { Badge, Button } from "@unkey/ui"; - -type Props = { - log: RatelimitLog; - onClose: () => void; -}; - -export const LogHeader = ({ onClose, log }: Props) => { - return ( -
-
- - {log.method} - -

{log.path}

-
- -
-
- = 200 && log.response_status < 300, - "bg-warning-3 text-warning-11 hover:bg-warning-4": - log.response_status >= 400 && log.response_status < 500, - "bg-error-3 text-error-11 hover:bg-error-4": log.response_status >= 500, - })} - > - {log.response_status} - - | - -
-
-
- ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-meta.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-meta.tsx deleted file mode 100644 index b7a60b3bef..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-meta.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Card, CardContent, CopyButton } from "@unkey/ui"; - -export const LogMetaSection = ({ content }: { content: string }) => { - return ( -
-
Meta
- - -
{content ?? ""} 
- -
-
-
- ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-section.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-section.tsx deleted file mode 100644 index 276934956d..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-section.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Card, CardContent, CopyButton } from "@unkey/ui"; - -export const LogSection = ({ - details, - title, -}: { - details: string | string[]; - title: string; -}) => { - return ( -
-
- {title} -
- - -
-            {Array.isArray(details)
-              ? details.map((header) => {
-                  const [key, ...valueParts] = header.split(":");
-                  const value = valueParts.join(":").trim();
-                  return (
-                    
- {key}: - {value} -
- ); - }) - : details} -
- -
-
-
- ); -}; - -const getFormattedContent = (details: string | string[]) => { - if (Array.isArray(details)) { - return details - .map((header) => { - const [key, ...valueParts] = header.split(":"); - const value = valueParts.join(":").trim(); - return `${key}: ${value}`; - }) - .join("\n"); - } - return details; -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/index.tsx index e944a13b26..83709c7199 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/index.tsx @@ -1,21 +1,9 @@ "use client"; -import { extractResponseField, safeParseJson } from "@/app/(app)/logs/utils"; -import { ResizablePanel } from "@/components/logs/details/resizable-panel"; -import { useMemo } from "react"; -import { DEFAULT_DRAGGABLE_WIDTH } from "../../../constants"; +import { LogDetails } from "@/components/logs/details/log-details"; import { useRatelimitLogsContext } from "../../../context/logs"; -import { LogFooter } from "./components/log-footer"; -import { LogHeader } from "./components/log-header"; -import { LogMetaSection } from "./components/log-meta"; -import { LogSection } from "./components/log-section"; -const createPanelStyle = (distanceToTop: number) => ({ - top: `${distanceToTop}px`, - width: `${DEFAULT_DRAGGABLE_WIDTH}px`, - height: `calc(100vh - ${distanceToTop}px)`, - paddingBottom: "1rem", -}); +const ANIMATION_DELAY = 350; type Props = { distanceToTop: number; @@ -23,7 +11,6 @@ type Props = { export const RatelimitLogDetails = ({ distanceToTop }: Props) => { const { setSelectedLog, selectedLog: log } = useRatelimitLogsContext(); - const panelStyle = useMemo(() => createPanelStyle(distanceToTop), [distanceToTop]); if (!log) { return null; @@ -34,46 +21,12 @@ export const RatelimitLogDetails = ({ distanceToTop }: Props) => { }; return ( - - - - "} - title="Request Header" - /> - " - : JSON.stringify(safeParseJson(log.request_body), null, 2) - } - title="Request Body" - /> - "} - title="Response Header" - /> - " - : JSON.stringify(safeParseJson(log.response_body), null, 2) - } - title="Response Body" - /> -
- - " - : JSON.stringify(extractResponseField(log, "meta"), null, 2) - } - /> - + + + + + + + ); }; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/constants.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/constants.ts index c8dc40a2ce..9a45ee13bf 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/constants.ts +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/constants.ts @@ -1,3 +1 @@ export const DEFAULT_STATUS_FLAG = 0; - -export const DEFAULT_DRAGGABLE_WIDTH = 500; From 2e94f1f40777b1f1efded7990b3d83702de38638 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 18 Sep 2025 15:45:49 +0300 Subject: [PATCH 05/27] fix: colors --- .../deployments/hooks/use-filters.ts | 52 ++++++++----------- .../navigations/project-sub-navigation.tsx | 2 +- .../logs/details/log-details/index.tsx | 2 +- 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/hooks/use-filters.ts b/apps/dashboard/app/(app)/projects/[projectId]/deployments/hooks/use-filters.ts index d04dc8f469..eeb5774760 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/hooks/use-filters.ts +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/hooks/use-filters.ts @@ -5,44 +5,38 @@ import { import { parseAsInteger, useQueryStates } from "nuqs"; import { useCallback, useMemo } from "react"; import { - type LogsFilterField, - type LogsFilterOperator, - type LogsFilterUrlValue, - type LogsFilterValue, - type LogsQuerySearchParams, - logsFilterFieldConfig, -} from "../gateway-logs-filters.schema"; + type DeploymentListFilterField, + type DeploymentListFilterOperator, + type DeploymentListFilterUrlValue, + type DeploymentListFilterValue, + type DeploymentListQuerySearchParams, + deploymentListFilterFieldConfig, +} from "../filters.schema"; -// Constants -const parseAsFilterValArray = parseAsFilterValueArray([ +const parseAsFilterValArray = parseAsFilterValueArray([ "is", "contains", - "startsWith", - "endsWith", ]); -const arrayFields = ["status", "methods", "paths", "host", "requestId"] as const; -const timeFields = ["startTime", "endTime", "since"] as const; - -// Query params configuration export const queryParamsPayload = { status: parseAsFilterValArray, - methods: parseAsFilterValArray, - paths: parseAsFilterValArray, - host: parseAsFilterValArray, - requestId: parseAsFilterValArray, + environment: parseAsFilterValArray, + branch: parseAsFilterValArray, startTime: parseAsInteger, endTime: parseAsInteger, since: parseAsRelativeTime, } as const; +const arrayFields = ["status", "environment", "branch"] as const; +const timeFields = ["startTime", "endTime", "since"] as const; + export const useFilters = () => { const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload, { history: "push", }); const filters = useMemo(() => { - const activeFilters: LogsFilterValue[] = []; + const activeFilters: DeploymentListFilterValue[] = []; // Handle array filters arrayFields.forEach((field) => { @@ -52,10 +46,10 @@ export const useFilters = () => { field, operator: item.operator, value: item.value, - metadata: logsFilterFieldConfig[field].getColorClass + metadata: deploymentListFilterFieldConfig[field].getColorClass ? { - colorClass: logsFilterFieldConfig[field].getColorClass( - field === "status" ? Number(item.value) : item.value, + colorClass: deploymentListFilterFieldConfig[field].getColorClass( + item.value as string, ), } : undefined, @@ -64,12 +58,12 @@ export const useFilters = () => { }); // Handle time filters - timeFields.forEach((field) => { - const value = searchParams[field]; + ["startTime", "endTime", "since"].forEach((field) => { + const value = searchParams[field as keyof DeploymentListQuerySearchParams]; if (value !== null && value !== undefined) { activeFilters.push({ id: crypto.randomUUID(), - field: field as LogsFilterField, + field: field as DeploymentListFilterField, operator: "is", value: value as string | number, }); @@ -80,8 +74,8 @@ export const useFilters = () => { }, [searchParams]); const updateFilters = useCallback( - (newFilters: LogsFilterValue[]) => { - const newParams: Partial = Object.fromEntries([ + (newFilters: DeploymentListFilterValue[]) => { + const newParams: Partial = Object.fromEntries([ ...arrayFields.map((field) => [field, null]), ...timeFields.map((field) => [field, null]), ]); @@ -91,7 +85,7 @@ export const useFilters = () => { acc[field] = []; return acc; }, - {} as Record<(typeof arrayFields)[number], LogsFilterUrlValue[]>, + {} as Record<(typeof arrayFields)[number], DeploymentListFilterUrlValue[]>, ); newFilters.forEach((filter) => { diff --git a/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-sub-navigation.tsx b/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-sub-navigation.tsx index 1ec6da17fd..6ce33b33e9 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-sub-navigation.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-sub-navigation.tsx @@ -68,7 +68,7 @@ export const ProjectSubNavigation = ({ id: "gateway-logs", label: "Gateway Logs", icon: Layers3, - path: `/projects/${projectId}/logs`, + path: `/projects/${projectId}/gateway-logs`, }, ]; diff --git a/apps/dashboard/components/logs/details/log-details/index.tsx b/apps/dashboard/components/logs/details/log-details/index.tsx index 9ad7db3ea9..74819e3740 100644 --- a/apps/dashboard/components/logs/details/log-details/index.tsx +++ b/apps/dashboard/components/logs/details/log-details/index.tsx @@ -111,7 +111,7 @@ export const LogDetails = ({ } }; - const baseClasses = "bg-gray-1 dark:bg-black font-mono drop-shadow-2xl z-20"; + const baseClasses = "bg-gray-1 font-mono drop-shadow-2xl z-20"; const animationClasses = animated ? cn( "transition-all duration-300 ease-out", From d5584b89d747ca4090e134e760847ac5aa03a02b Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 18 Sep 2025 16:04:53 +0300 Subject: [PATCH 06/27] refactor: get rid of unsued --- .../[projectId]/gateway-logs/components/control-cloud/index.tsx | 2 -- .../app/(app)/projects/[projectId]/gateway-logs/constants.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/control-cloud/index.tsx index b4223e5989..a742d19510 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/control-cloud/index.tsx @@ -15,8 +15,6 @@ const formatFieldName = (field: string): string => { return "Path"; case "methods": return "Method"; - case "requestId": - return "Request ID"; case "since": return ""; default: diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/constants.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/constants.ts index 585c7c28b8..cfc7820c30 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/constants.ts +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/constants.ts @@ -1,5 +1,3 @@ -export const DEFAULT_DRAGGABLE_WIDTH = 500; - export const YELLOW_STATES = ["RATE_LIMITED", "EXPIRED", "USAGE_EXCEEDED"]; export const RED_STATES = ["DISABLED", "FORBIDDEN", "INSUFFICIENT_PERMISSIONS"]; From 3ed66b004ea8e31be0a51d5d12a7fdd040b8b34a Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 18 Sep 2025 16:46:14 +0300 Subject: [PATCH 07/27] refactor: use common component --- .../table/components/log-details/index.tsx | 138 +++++++++--------- .../table/components/log-details/index.tsx | 57 ++------ .../table/hooks/use-gateway-logs-query.ts | 2 +- .../log-details/components/log-footer.tsx | 4 +- .../log-details/components/log-header.tsx | 4 +- .../log-details/components/log-meta.tsx | 2 +- .../logs/details/log-details/index.tsx | 101 +++++++++++-- 7 files changed, 166 insertions(+), 142 deletions(-) diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/log-details/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/log-details/index.tsx index 08d663cc6c..f4934e4d5b 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/log-details/index.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/log-details/index.tsx @@ -1,83 +1,57 @@ "use client"; -import { DEFAULT_DRAGGABLE_WIDTH } from "@/app/(app)/logs/constants"; -import { ResizablePanel } from "@/components/logs/details/resizable-panel"; +import { LogDetails } from "@/components/logs/details/log-details"; import type { KeysOverviewLog } from "@unkey/clickhouse/src/keys/keys"; -import { TimestampInfo } from "@unkey/ui"; +import { TimestampInfo, toast } from "@unkey/ui"; import Link from "next/link"; -import { useMemo } from "react"; +import { useEffect, useState } from "react"; import { LogHeader } from "./components/log-header"; import { OutcomeDistributionSection } from "./components/log-outcome-distribution-section"; import { LogSection } from "./components/log-section"; import { PermissionsSection, RolesSection } from "./components/roles-permissions"; -type StyleObject = { - top: string; - width: string; - height: string; - paddingBottom: string; -}; - -const createPanelStyle = (distanceToTop: number): StyleObject => ({ - top: `${distanceToTop}px`, - width: `${DEFAULT_DRAGGABLE_WIDTH}px`, - height: `calc(100vh - ${distanceToTop}px)`, - paddingBottom: "1rem", -}); +const ANIMATION_DELAY = 350; -type KeysOverviewLogDetailsProps = { +type Props = { distanceToTop: number; log: KeysOverviewLog | null; apiId: string; setSelectedLog: (data: KeysOverviewLog | null) => void; }; -export const KeysOverviewLogDetails = ({ - distanceToTop, - log, - setSelectedLog, - apiId, -}: KeysOverviewLogDetailsProps) => { - const panelStyle = useMemo(() => createPanelStyle(distanceToTop), [distanceToTop]); +export const KeysOverviewLogDetails = ({ distanceToTop, log, setSelectedLog, apiId }: Props) => { + const [errorShown, setErrorShown] = useState(false); - if (!log) { - return null; - } + useEffect(() => { + if (!errorShown && log) { + if (!log.key_details) { + toast.error("Key Details Unavailable", { + description: + "Could not retrieve key information for this log. The key may have been deleted or is still processing.", + }); + setErrorShown(true); + } + } + if (!log) { + setErrorShown(false); + } + }, [log, errorShown]); - const handleClose = (): void => { + const handleClose = () => { setSelectedLog(null); }; - // Only process if key_details exists + if (!log) { + return null; + } + if (!log.key_details) { - return ( - - -
No key details available
-
- ); + return null; } - // Process key details data const metaData = formatMeta(log.key_details.meta); - const identifiers = { - "Key ID": ( - -
{log.key_id}
- - ), - Name: log.key_details.name || "N/A", - }; const usage = { - Created: metaData?.createdAt ? metaData.createdAt : "N/A", + Created: metaData?.createdAt || "N/A", "Last Used": log.time ? ( ) : ( @@ -93,32 +67,50 @@ export const KeysOverviewLogDetails = ({ : "Unlimited", }; - const tags = - log.tags && log.tags.length > 0 ? { Tags: log.tags.join(", ") } : { "No tags": null }; + const identifiers = { + "Key ID": ( + +
{log.key_id}
+ + ), + Name: log.key_details.name || "N/A", + }; const identity = log.key_details.identity ? { "External ID": log.key_details.identity.external_id || "N/A" } : { "No identity connected": null }; - const metaString = metaData ? JSON.stringify(metaData, null, 2) : { "No meta available": "" }; + const tags = + log.tags && log.tags.length > 0 ? { Tags: log.tags.join(", ") } : { "No tags": null }; + + const sections = [ + , + log.outcome_counts && ( + + ), + , + , + , + , + , + , + ].filter(Boolean); return ( - - - - {log.outcome_counts && } - - - - - - - - + + + + + + {sections} + + + + ); }; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/components/log-details/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/components/log-details/index.tsx index a1edaa1651..7100105ee8 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/components/log-details/index.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/components/log-details/index.tsx @@ -1,23 +1,12 @@ "use client"; -import { ResizablePanel } from "@/components/logs/details/resizable-panel"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; -import { LogFooter } from "@/app/(app)/logs/components/table/log-details/components/log-footer"; -import { LogHeader } from "@/app/(app)/logs/components/table/log-details/components/log-header"; -import { LogSection } from "@/app/(app)/logs/components/table/log-details/components/log-section"; -import { DEFAULT_DRAGGABLE_WIDTH } from "@/app/(app)/logs/constants"; -import { safeParseJson } from "@/app/(app)/logs/utils"; +import { LogDetails } from "@/components/logs/details/log-details"; import type { KeyDetailsLog } from "@unkey/clickhouse/src/verifications"; import { toast } from "@unkey/ui"; import { useFetchRequestDetails } from "./components/hooks/use-logs-query"; -const createPanelStyle = (distanceToTop: number) => ({ - top: `${distanceToTop}px`, - width: `${DEFAULT_DRAGGABLE_WIDTH}px`, - height: `calc(100vh - ${distanceToTop}px)`, - paddingBottom: "1rem", -}); - +const ANIMATION_DELAY = 350; type Props = { distanceToTop: number; selectedLog: KeyDetailsLog | null; @@ -25,7 +14,6 @@ type Props = { }; export const KeyDetailsDrawer = ({ distanceToTop, onLogSelect, selectedLog }: Props) => { - const panelStyle = useMemo(() => createPanelStyle(distanceToTop), [distanceToTop]); const { log, error } = useFetchRequestDetails({ requestId: selectedLog?.request_id, }); @@ -69,38 +57,11 @@ export const KeyDetailsDrawer = ({ distanceToTop, onLogSelect, selectedLog }: Pr } return ( - - - "} - title="Request Header" - /> - " - : JSON.stringify(safeParseJson(log.request_body), null, 2) - } - title="Request Body" - /> - "} - title="Response Header" - /> - " - : JSON.stringify(safeParseJson(log.response_body), null, 2) - } - title="Response Body" - /> -
- - + + + + + + ); }; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/hooks/use-gateway-logs-query.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/hooks/use-gateway-logs-query.ts index d4ef12f9c9..d7869b5333 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/hooks/use-gateway-logs-query.ts +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/hooks/use-gateway-logs-query.ts @@ -5,7 +5,7 @@ import type { Log } from "@unkey/clickhouse/src/logs"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { z } from "zod"; import { useGatewayLogsFilters } from "../../../hooks/use-gateway-logs-filters"; -import type { queryLogsPayload } from "../query-logs.schema"; +import type { queryLogsPayload } from "../query-gateway-logs.schema"; // Constants const REALTIME_DATA_LIMIT = 100; diff --git a/apps/dashboard/components/logs/details/log-details/components/log-footer.tsx b/apps/dashboard/components/logs/details/log-details/components/log-footer.tsx index d152782bce..5659765f29 100644 --- a/apps/dashboard/components/logs/details/log-details/components/log-footer.tsx +++ b/apps/dashboard/components/logs/details/log-details/components/log-footer.tsx @@ -3,10 +3,10 @@ import { extractResponseField, getRequestHeader } from "@/app/(app)/logs/utils"; import { RequestResponseDetails } from "@/components/logs/details/request-response-details"; import { cn } from "@/lib/utils"; import { Badge, TimestampInfo } from "@unkey/ui"; -import type { SupportedLogTypes } from ".."; +import type { StandardLogTypes } from ".."; type Props = { - log: SupportedLogTypes; + log: StandardLogTypes; }; export const YELLOW_STATES = ["RATE_LIMITED", "EXPIRED", "USAGE_EXCEEDED"]; diff --git a/apps/dashboard/components/logs/details/log-details/components/log-header.tsx b/apps/dashboard/components/logs/details/log-details/components/log-header.tsx index 03104f494f..8aa4785804 100644 --- a/apps/dashboard/components/logs/details/log-details/components/log-header.tsx +++ b/apps/dashboard/components/logs/details/log-details/components/log-header.tsx @@ -1,10 +1,10 @@ import { cn } from "@/lib/utils"; import { XMark } from "@unkey/icons"; import { Badge, Button } from "@unkey/ui"; -import type { SupportedLogTypes } from ".."; +import type { StandardLogTypes } from ".."; type Props = { - log: SupportedLogTypes; + log: StandardLogTypes; onClose: () => void; }; diff --git a/apps/dashboard/components/logs/details/log-details/components/log-meta.tsx b/apps/dashboard/components/logs/details/log-details/components/log-meta.tsx index b7a60b3bef..3f9b54dd72 100644 --- a/apps/dashboard/components/logs/details/log-details/components/log-meta.tsx +++ b/apps/dashboard/components/logs/details/log-details/components/log-meta.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CopyButton } from "@unkey/ui"; export const LogMetaSection = ({ content }: { content: string }) => { return ( -
+
Meta
diff --git a/apps/dashboard/components/logs/details/log-details/index.tsx b/apps/dashboard/components/logs/details/log-details/index.tsx index 74819e3740..309b69bf7f 100644 --- a/apps/dashboard/components/logs/details/log-details/index.tsx +++ b/apps/dashboard/components/logs/details/log-details/index.tsx @@ -2,6 +2,7 @@ import { extractResponseField, safeParseJson } from "@/app/(app)/logs/utils"; import { ResizablePanel } from "@/components/logs/details/resizable-panel"; import { cn } from "@/lib/utils"; +import type { KeysOverviewLog } from "@unkey/clickhouse/src/keys/keys"; import type { Log } from "@unkey/clickhouse/src/logs"; import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; import { type ReactNode, createContext, useContext, useEffect, useMemo, useState } from "react"; @@ -19,18 +20,25 @@ const createPanelStyle = (distanceToTop: number) => ({ paddingBottom: "1rem", }); -export type SupportedLogTypes = Log | RatelimitLog; +export type StandardLogTypes = Log | RatelimitLog; +export type SupportedLogTypes = StandardLogTypes | KeysOverviewLog; -const LogDetailsContext = createContext<{ +type LogDetailsContextValue = { animated: boolean; isOpen: boolean; log: SupportedLogTypes; -}>({ animated: false, isOpen: true, log: {} as SupportedLogTypes }); +}; + +const LogDetailsContext = createContext({ + animated: false, + isOpen: true, + log: {} as SupportedLogTypes, +}); const useLogDetailsContext = () => useContext(LogDetailsContext); -// Helper functions -const createLogSections = (log: SupportedLogTypes) => [ +// Helper functions for standard logs +const createLogSections = (log: Log | RatelimitLog) => [ { title: "Request Header", content: log.request_headers.length ? log.request_headers : EMPTY_TEXT, @@ -56,8 +64,28 @@ const createLogSections = (log: SupportedLogTypes) => [ ]; const createMetaContent = (log: SupportedLogTypes) => { - const meta = extractResponseField(log, "meta"); - return JSON.stringify(meta, null, 2) === "null" ? EMPTY_TEXT : JSON.stringify(meta, null, 2); + // Handle KeysOverviewLog meta differently + if ("key_details" in log && (log.key_details as { meta: string })?.meta) { + try { + const parsedMeta = JSON.parse((log.key_details as { meta: string })?.meta); + return JSON.stringify(parsedMeta, null, 2); + } catch { + return EMPTY_TEXT; + } + } + + // Standard log meta handling + if ("request_body" in log || "response_body" in log) { + const meta = extractResponseField(log as Log | RatelimitLog, "meta"); + return JSON.stringify(meta, null, 2) === "null" ? EMPTY_TEXT : JSON.stringify(meta, null, 2); + } + + return EMPTY_TEXT; +}; + +// Type guards +const isStandardLog = (log: SupportedLogTypes): log is Log | RatelimitLog => { + return "request_headers" in log && "response_headers" in log; }; // Main LogDetails component @@ -168,7 +196,7 @@ const Section = ({ children, delay = 0, translateX = "translate-x-8" }: SectionP ); }; -// Standard log sections +// Standard log sections (only works for standard logs) const Sections = ({ startDelay = 150, staggerDelay = 50, @@ -177,6 +205,12 @@ const Sections = ({ staggerDelay?: number; }) => { const { log } = useLogDetailsContext(); + + if (!isStandardLog(log)) { + console.warn("LogDetails.Sections can only be used with standard logs (Log | RatelimitLog)"); + return null; + } + const sections = createLogSections(log); return ( @@ -190,6 +224,28 @@ const Sections = ({ ); }; +// Custom sections wrapper for flexible content +type CustomSectionsProps = { + children: ReactNode; + startDelay?: number; + staggerDelay?: number; +}; + +const CustomSections = ({ children, startDelay = 150, staggerDelay = 50 }: CustomSectionsProps) => { + const childArray = Array.isArray(children) ? children : [children]; + + return ( + <> + {childArray.map((child, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: its fine +
+ {child} +
+ ))} + + ); +}; + // Spacer with animation const Spacer = ({ delay = 0 }: { delay?: number }) => { const { animated, isOpen } = useLogDetailsContext(); @@ -221,30 +277,44 @@ const Meta = ({ delay = 400 }: { delay?: number }) => { ); }; -// Header compound component +// Generic Header wrapper - allows any header component or falls back to default const Header = ({ delay = 100, + translateX = "translate-x-6" as const, onClose, + children, }: { delay?: number; - onClose: () => void; + translateX?: "translate-x-6" | "translate-x-8"; + onClose?: () => void; + children?: ReactNode; }) => { const { log } = useLogDetailsContext(); return ( -
- +
+ {children || + (onClose && + (isStandardLog(log) ? ( + + ) : null))}
); }; -// Footer compound component -const Footer = ({ delay = 375 }: { delay?: number }) => { +// Generic Footer wrapper - allows any footer component or falls back to default +const Footer = ({ + delay = 375, + children, +}: { + delay?: number; + children?: ReactNode; +}) => { const { log } = useLogDetailsContext(); return (
- + {children || (isStandardLog(log) ? : null)}
); }; @@ -252,6 +322,7 @@ const Footer = ({ delay = 375 }: { delay?: number }) => { // Compound components LogDetails.Section = Section; LogDetails.Sections = Sections; +LogDetails.CustomSections = CustomSections; LogDetails.Spacer = Spacer; LogDetails.Meta = Meta; LogDetails.Header = Header; From 580f9e652cc86453c4a4fd8910f6a26b9dc8bf98 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 18 Sep 2025 17:46:54 +0300 Subject: [PATCH 08/27] fix: live switch --- .../components/gateway-logs-live-switch.tsx | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-live-switch.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-live-switch.tsx index 321b3987f7..8d0e61b9cc 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-live-switch.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-live-switch.tsx @@ -1,33 +1,15 @@ -import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; import { LiveSwitchButton } from "@/components/logs/live-switch-button"; +import { useQueryTime } from "@/providers/query-time-provider"; import { useGatewayLogsContext } from "../../../context/gateway-logs-provider"; -import { useGatewayLogsFilters } from "../../../hooks/use-gateway-logs-filters"; export const GatewayLogsLiveSwitch = () => { const { toggleLive, isLive } = useGatewayLogsContext(); - const { filters, updateFilters } = useGatewayLogsFilters(); + const { refreshQueryTime } = useQueryTime(); const handleSwitch = () => { toggleLive(); - // To able to refetch historic data again we have to update the endTime if (isLive) { - const timestamp = Date.now(); - const activeFilters = filters.filter((f) => !["endTime", "startTime"].includes(f.field)); - updateFilters([ - ...activeFilters, - { - field: "endTime", - value: timestamp, - id: crypto.randomUUID(), - operator: "is", - }, - { - field: "startTime", - value: timestamp - HISTORICAL_DATA_WINDOW, - id: crypto.randomUUID(), - operator: "is", - }, - ]); + refreshQueryTime(); } }; return ; From 6afe3513faf2a190093a2c004a56c305dc01f78d Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 22 Sep 2025 12:42:08 +0300 Subject: [PATCH 09/27] fix: table read source --- internal/clickhouse/src/logs.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/clickhouse/src/logs.ts b/internal/clickhouse/src/logs.ts index 0ad25bb81e..82e861a2e5 100644 --- a/internal/clickhouse/src/logs.ts +++ b/internal/clickhouse/src/logs.ts @@ -210,47 +210,47 @@ type TimeInterval = { const INTERVALS: Record = { minute: { - table: "metrics.api_requests_per_minute_v1", + table: "default.api_requests_per_minute_v2", step: "MINUTE", stepSize: 1, }, fiveMinutes: { - table: "metrics.api_requests_per_minute_v1", + table: "default.api_requests_per_minute_v2", step: "MINUTES", stepSize: 5, }, fifteenMinutes: { - table: "metrics.api_requests_per_minute_v1", + table: "default.api_requests_per_minute_v2", step: "MINUTES", stepSize: 15, }, thirtyMinutes: { - table: "metrics.api_requests_per_minute_v1", + table: "default.api_requests_per_minute_v2", step: "MINUTES", stepSize: 30, }, hour: { - table: "metrics.api_requests_per_hour_v1", + table: "default.api_requests_per_hour_v2", step: "HOUR", stepSize: 1, }, twoHours: { - table: "metrics.api_requests_per_hour_v1", + table: "default.api_requests_per_hour_v2", step: "HOURS", stepSize: 2, }, fourHours: { - table: "metrics.api_requests_per_hour_v1", + table: "default.api_requests_per_hour_v2", step: "HOURS", stepSize: 4, }, sixHours: { - table: "metrics.api_requests_per_hour_v1", + table: "default.api_requests_per_hour_v2", step: "HOURS", stepSize: 6, }, day: { - table: "metrics.api_requests_per_day_v1", + table: "default.api_requests_per_day_v2", step: "DAY", stepSize: 1, }, From fa06c31508237beeaf56f0bddcfd7f74d6b0a28c Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 22 Sep 2025 13:20:56 +0300 Subject: [PATCH 10/27] refactor: use common schema for logs --- .../charts/hooks/use-fetch-timeseries.ts | 7 +- .../charts/query-timeseries.schema.ts | 48 ------- .../components/table/hooks/use-logs-query.ts | 7 +- .../components/table/query-logs.schema.ts | 60 --------- .../hooks/use-gateway-logs-timeseries.ts | 8 +- .../charts/query-timeseries.schema.ts | 48 ------- .../table/hooks/use-gateway-logs-query.ts | 8 +- .../table/query-gateway-logs.schema.ts | 60 --------- .../[projectId]/gateway-logs/constants.ts | 3 + apps/dashboard/lib/schemas/logs.schema.ts | 126 ++++++++++++++++++ .../lib/trpc/routers/logs/query-logs/index.ts | 23 ++-- .../lib/trpc/routers/logs/query-logs/utils.ts | 17 ++- .../routers/logs/query-timeseries/index.ts | 4 +- .../routers/logs/query-timeseries/utils.ts | 8 +- internal/clickhouse/src/logs.ts | 15 +++ 15 files changed, 182 insertions(+), 260 deletions(-) delete mode 100644 apps/dashboard/app/(app)/logs/components/charts/query-timeseries.schema.ts delete mode 100644 apps/dashboard/app/(app)/logs/components/table/query-logs.schema.ts delete mode 100644 apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/query-timeseries.schema.ts delete mode 100644 apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/query-gateway-logs.schema.ts create mode 100644 apps/dashboard/lib/schemas/logs.schema.ts 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..07cac89d5e 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,18 +1,17 @@ import { formatTimestampForChart } from "@/components/logs/chart/utils/format-timestamp"; import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import type { TimeseriesRequestSchema } from "@/lib/schemas/logs.schema"; 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"; export const useFetchTimeseries = () => { const { filters } = useFilters(); const { queryTime: timestamp } = useQueryTime(); const queryParams = useMemo(() => { - const params: z.infer = { + const params: TimeseriesRequestSchema = { startTime: timestamp - HISTORICAL_DATA_WINDOW, endTime: timestamp, host: { filters: [] }, @@ -61,7 +60,7 @@ export const useFetchTimeseries = () => { console.error("Host filter value type has to be 'string'"); return; } - params.host?.filters.push({ + params.host?.filters?.push({ operator: "is", value: filter.value, }); 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..f2c9a7d0a6 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,10 @@ import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import type { LogsRequestSchema } from "@/lib/schemas/logs.schema"; 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"; // Duration in milliseconds for historical data fetch window (12 hours) type UseLogsQueryParams = { @@ -37,7 +36,7 @@ export function useLogsQuery({ //Required for preventing double trpc call during initial render const queryParams = useMemo(() => { - const params: z.infer = { + const params: LogsRequestSchema = { limit, startTime: timestamp - HISTORICAL_DATA_WINDOW, endTime: timestamp, @@ -88,7 +87,7 @@ export function useLogsQuery({ console.error("Host filter value type has to be 'string'"); return; } - params.host?.filters.push({ + params.host?.filters?.push({ operator: "is", value: filter.value, }); 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)/projects/[projectId]/gateway-logs/components/charts/hooks/use-gateway-logs-timeseries.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/hooks/use-gateway-logs-timeseries.ts index 1153813a58..2f056b0b11 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/hooks/use-gateway-logs-timeseries.ts +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/hooks/use-gateway-logs-timeseries.ts @@ -1,11 +1,11 @@ import { formatTimestampForChart } from "@/components/logs/chart/utils/format-timestamp"; import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import type { TimeseriesRequestSchema } from "@/lib/schemas/logs.schema"; import { trpc } from "@/lib/trpc/client"; import { useQueryTime } from "@/providers/query-time-provider"; import { useMemo } from "react"; -import type { z } from "zod"; +import { EXCLUDED_HOSTS } from "../../../constants"; import { useGatewayLogsFilters } from "../../../hooks/use-gateway-logs-filters"; -import type { queryTimeseriesPayload } from "../query-timeseries.schema"; // Constants const FILTER_FIELD_MAPPING = { @@ -22,10 +22,10 @@ export const useGatewayLogsTimeseries = () => { const { queryTime: timestamp } = useQueryTime(); const queryParams = useMemo(() => { - const params: z.infer = { + const params: TimeseriesRequestSchema = { startTime: timestamp - HISTORICAL_DATA_WINDOW, endTime: timestamp, - host: { filters: [] }, + host: { filters: [], exclude: EXCLUDED_HOSTS }, method: { filters: [] }, path: { filters: [] }, status: { filters: [] }, diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/query-timeseries.schema.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/query-timeseries.schema.ts deleted file mode 100644 index d62b8bcc99..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/query-timeseries.schema.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from "zod"; -import { gatewayLogsFilterOperatorEnum } from "../../gateway-logs-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: gatewayLogsFilterOperatorEnum, - 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)/projects/[projectId]/gateway-logs/components/table/hooks/use-gateway-logs-query.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/hooks/use-gateway-logs-query.ts index d7869b5333..336ca822b6 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/hooks/use-gateway-logs-query.ts +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/hooks/use-gateway-logs-query.ts @@ -1,11 +1,11 @@ import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import type { LogsRequestSchema } from "@/lib/schemas/logs.schema"; 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 { EXCLUDED_HOSTS } from "../../../constants"; import { useGatewayLogsFilters } from "../../../hooks/use-gateway-logs-filters"; -import type { queryLogsPayload } from "../query-gateway-logs.schema"; // Constants const REALTIME_DATA_LIMIT = 100; @@ -48,11 +48,11 @@ export function useGatewayLogsQuery({ // "memo" required for preventing double trpc call during initial render const queryParams = useMemo(() => { - const params: z.infer = { + const params: LogsRequestSchema = { limit, startTime: timestamp - HISTORICAL_DATA_WINDOW, endTime: timestamp, - host: { filters: [] }, + host: { filters: [], exclude: EXCLUDED_HOSTS }, requestId: { filters: [] }, method: { filters: [] }, path: { filters: [] }, diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/query-gateway-logs.schema.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/query-gateway-logs.schema.ts deleted file mode 100644 index d29332e6ae..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/query-gateway-logs.schema.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { z } from "zod"; -import { gatewayLogsFilterOperatorEnum } from "../../gateway-logs-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: gatewayLogsFilterOperatorEnum, - 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)/projects/[projectId]/gateway-logs/constants.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/constants.ts index cfc7820c30..5cc1866351 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/constants.ts +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/constants.ts @@ -3,3 +3,6 @@ export const RED_STATES = ["DISABLED", "FORBIDDEN", "INSUFFICIENT_PERMISSIONS"]; export const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const; export const STATUSES = [200, 400, 500] as const; + +// If we don't exclude those host names, gateway logs will behave just like regular logs +export const EXCLUDED_HOSTS = ["api.unkey.com", "api.unkey.dev"]; diff --git a/apps/dashboard/lib/schemas/logs.schema.ts b/apps/dashboard/lib/schemas/logs.schema.ts new file mode 100644 index 0000000000..7051dcd9b8 --- /dev/null +++ b/apps/dashboard/lib/schemas/logs.schema.ts @@ -0,0 +1,126 @@ +import { logsFilterOperatorEnum } from "@/app/(app)/logs/filters.schema"; +import { log } from "@unkey/clickhouse/src/logs"; +import { z } from "zod"; + +export type LogsRequestSchema = z.infer; +export const logsRequestSchema = 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(), + }), + ) + .optional(), + exclude: z.array(z.string()).optional(), + }) + .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(), +}); + +export const logsResponseSchema = z.object({ + logs: z.array(log), + hasMore: z.boolean(), + total: z.number(), + nextCursor: z.number().int().optional(), +}); + +export type LogsResponseSchema = z.infer; + +// ### Timeseries + +export type TimeseriesRequestSchema = z.infer; +export const timeseriesRequestSchema = 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(), + }), + ) + .optional(), + exclude: z.array(z.string()).optional(), + }) + .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/lib/trpc/routers/logs/query-logs/index.ts b/apps/dashboard/lib/trpc/routers/logs/query-logs/index.ts index aacac64412..00aaa2ab61 100644 --- a/apps/dashboard/lib/trpc/routers/logs/query-logs/index.ts +++ b/apps/dashboard/lib/trpc/routers/logs/query-logs/index.ts @@ -1,27 +1,20 @@ -import { queryLogsPayload } from "@/app/(app)/logs/components/table/query-logs.schema"; import { clickhouse } from "@/lib/clickhouse"; import { db } from "@/lib/db"; +import { + type LogsResponseSchema, + logsRequestSchema, + logsResponseSchema, +} from "@/lib/schemas/logs.schema"; import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; import { TRPCError } from "@trpc/server"; -import { log } from "@unkey/clickhouse/src/logs"; -import { z } from "zod"; import { transformFilters } from "./utils"; -const LogsResponse = z.object({ - logs: z.array(log), - hasMore: z.boolean(), - total: z.number(), - nextCursor: z.number().int().optional(), -}); - -type LogsResponse = z.infer; - export const queryLogs = t.procedure .use(requireUser) .use(requireWorkspace) .use(withRatelimit(ratelimit.read)) - .input(queryLogsPayload) - .output(LogsResponse) + .input(logsRequestSchema) + .output(logsResponseSchema) .query(async ({ ctx, input }) => { // Get workspace const workspace = await db.query.workspaces @@ -63,7 +56,7 @@ export const queryLogs = t.procedure const logs = logsResult.val; // Prepare the response with pagination info - const response: LogsResponse = { + const response: LogsResponseSchema = { logs, hasMore: logs.length === input.limit, total: countResult.val[0].total_count, 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..ec51748583 100644 --- a/apps/dashboard/lib/trpc/routers/logs/query-logs/utils.ts +++ b/apps/dashboard/lib/trpc/routers/logs/query-logs/utils.ts @@ -1,10 +1,9 @@ -import type { queryLogsPayload } from "@/app/(app)/logs/components/table/query-logs.schema"; +import type { LogsRequestSchema } from "@/lib/schemas/logs.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: LogsRequestSchema, ): Omit { // Transform path filters to include operators const paths = @@ -14,10 +13,13 @@ 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 statusCodes = params.status?.filters.map((f) => f.value) || []; + const requestIds = params.requestId?.filters?.map((f) => f.value) || []; + const methods = params.method?.filters?.map((f) => f.value) || []; + const statusCodes = params.status?.filters?.map((f) => f.value) || []; + + // Hosts with include/exclude pattern + const hosts = params.host?.filters?.map((f) => f.value) || []; + const excludeHosts = params.host?.exclude || []; let startTime = params.startTime; let endTime = params.endTime; @@ -35,6 +37,7 @@ export function transformFilters( endTime, requestIds, hosts, + excludeHosts, methods, paths, statusCodes, 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..5e911a93f8 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 { timeseriesRequestSchema } from "@/lib/schemas/logs.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(timeseriesRequestSchema) .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..5ac1ee3d7b 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 { TimeseriesRequestSchema } from "@/lib/schemas/logs.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: TimeseriesRequestSchema): { params: Omit; granularity: RegularTimeseriesGranularity; } { @@ -25,7 +24,8 @@ export function transformFilters(params: z.infer) params: { startTime: timeConfig.startTime, endTime: timeConfig.endTime, - hosts: params.host?.filters.map((f) => f.value) || [], + hosts: params.host?.filters?.map((f) => f.value) || [], + excludeHosts: params.host?.exclude || [], methods: params.method?.filters.map((f) => f.value) || [], paths: params.path?.filters.map((f) => ({ diff --git a/internal/clickhouse/src/logs.ts b/internal/clickhouse/src/logs.ts index 82e861a2e5..9f12ece0ec 100644 --- a/internal/clickhouse/src/logs.ts +++ b/internal/clickhouse/src/logs.ts @@ -15,6 +15,7 @@ export const getLogsClickhousePayload = z.object({ ) .nullable(), hosts: z.array(z.string()).nullable(), + excludeHosts: z.array(z.string()).nullable(), methods: z.array(z.string()).nullable(), requestIds: z.array(z.string()).nullable(), statusCodes: z.array(z.number().int()).nullable(), @@ -93,6 +94,13 @@ export function getLogs(ch: Querier) { ELSE TRUE END ) + AND ( + CASE + WHEN length({excludeHosts: Array(String)}) > 0 THEN + host NOT IN {excludeHosts: Array(String)} + ELSE TRUE + END + ) ---------- Apply method filter AND ( @@ -185,6 +193,7 @@ export const logsTimeseriesParams = z.object({ ) .nullable(), hosts: z.array(z.string()).nullable(), + excludeHosts: z.array(z.string()).nullable(), methods: z.array(z.string()).nullable(), statusCodes: z.array(z.number().int()).nullable(), }); @@ -313,6 +322,12 @@ function getLogsTimeseriesWhereClause( WHEN length({hosts: Array(String)}) > 0 THEN host IN {hosts: Array(String)} ELSE TRUE + END) + AND + (CASE + WHEN length({excludeHosts: Array(String)}) > 0 THEN + host NOT IN {excludeHosts: Array(String)} + ELSE TRUE END)`, // Method filter `(CASE From 38b4875b47d6fd5d1cb40a4655a5be8ea167c6b6 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 22 Sep 2025 13:35:11 +0300 Subject: [PATCH 11/27] refactor: use same filters --- .../logs-filters/components/paths-filter.tsx | 2 +- .../controls/components/logs-queries/utils.ts | 7 +- .../app/(app)/logs/hooks/use-filters.ts | 6 +- .../components/gateway-logs-paths-filter.tsx | 2 +- .../gateway-logs-filters.schema.ts | 124 ------------------ .../hooks/use-gateway-logs-filters.ts | 16 +-- .../logs/hooks/use-bookmarked-filters.test.ts | 2 +- .../logs/hooks/use-bookmarked-filters.ts | 2 +- .../logs/queries/queries-context.tsx | 7 +- .../components/logs/queries/utils.ts | 7 +- .../schemas/logs.filter.schema.ts} | 4 +- apps/dashboard/lib/schemas/logs.schema.ts | 2 +- .../lib/trpc/routers/logs/llm-search/utils.ts | 2 +- 13 files changed, 33 insertions(+), 150 deletions(-) delete mode 100644 apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/gateway-logs-filters.schema.ts rename apps/dashboard/{app/(app)/logs/filters.schema.ts => lib/schemas/logs.filter.schema.ts} (97%) diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/paths-filter.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/paths-filter.tsx index c7128a460a..aaca6342a5 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/paths-filter.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/components/paths-filter.tsx @@ -1,6 +1,6 @@ -import { logsFilterFieldConfig } from "@/app/(app)/logs/filters.schema"; import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; import { FilterOperatorInput } from "@/components/logs/filter-operator-input"; +import { logsFilterFieldConfig } from "@/lib/schemas/logs.filter.schema"; export const PathsFilter = () => { const { filters, updateFilters } = useFilters(); diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-queries/utils.ts b/apps/dashboard/app/(app)/logs/components/controls/components/logs-queries/utils.ts index c008182006..58df4fe40a 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-queries/utils.ts +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-queries/utils.ts @@ -1,5 +1,5 @@ -import type { QuerySearchParams } from "@/app/(app)/logs/filters.schema"; import { iconsPerField } from "@/components/logs/queries/utils"; +import type { QuerySearchParams } from "@/lib/schemas/logs.filter.schema"; import { ChartActivity2 } from "@unkey/icons"; import { format } from "date-fns"; import React from "react"; @@ -90,7 +90,10 @@ export function formatFilterValues( export function getFilterFieldIcon(field: string): JSX.Element { const Icon = iconsPerField[field] || ChartActivity2; - return React.createElement(Icon, { size: "md-regular", className: "justify-center" }); + return React.createElement(Icon, { + size: "md-regular", + className: "justify-center", + }); } export const FieldsToTruncate = [ diff --git a/apps/dashboard/app/(app)/logs/hooks/use-filters.ts b/apps/dashboard/app/(app)/logs/hooks/use-filters.ts index 38874d1238..8135103b2c 100644 --- a/apps/dashboard/app/(app)/logs/hooks/use-filters.ts +++ b/apps/dashboard/app/(app)/logs/hooks/use-filters.ts @@ -2,8 +2,6 @@ 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, @@ -11,7 +9,9 @@ import { type LogsFilterValue, type QuerySearchParams, logsFilterFieldConfig, -} from "../filters.schema"; +} from "@/lib/schemas/logs.filter.schema"; +import { parseAsInteger, useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; const parseAsFilterValArray = parseAsFilterValueArray([ "is", diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-paths-filter.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-paths-filter.tsx index 3c956ed0e6..a2103f95d4 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-paths-filter.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-paths-filter.tsx @@ -1,5 +1,5 @@ import { FilterOperatorInput } from "@/components/logs/filter-operator-input"; -import { gatewayLogsFilterFieldConfig } from "../../../../../gateway-logs-filters.schema"; +import { logsFilterFieldConfig as gatewayLogsFilterFieldConfig } from "@/lib/schemas/logs.filter.schema"; import { useGatewayLogsFilters } from "../../../../../hooks/use-gateway-logs-filters"; export const GatewayPathsFilter = () => { diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/gateway-logs-filters.schema.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/gateway-logs-filters.schema.ts deleted file mode 100644 index d741293dc4..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/gateway-logs-filters.schema.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { - FilterValue, - NumberConfig, - StringConfig, -} from "@/components/logs/validation/filter.types"; -import { parseAsFilterValueArray } from "@/components/logs/validation/utils/nuqs-parsers"; -import { createFilterOutputSchema } from "@/components/logs/validation/utils/structured-output-schema-generator"; -import { z } from "zod"; -import { METHODS } from "./constants"; - -// Constants -const ALL_OPERATORS = ["is", "contains", "startsWith", "endsWith"] as const; - -// Types -export type GatewayLogsFilterOperator = (typeof ALL_OPERATORS)[number]; - -type StatusConfig = NumberConfig & { - type: "number"; - operators: ["is"]; - getColorClass: (value: number) => string; - validate: (value: number) => boolean; -}; - -type FilterFieldConfigs = { - status: StatusConfig; - methods: StringConfig; - paths: StringConfig; - host: StringConfig; - requestId: StringConfig; - startTime: NumberConfig; - endTime: NumberConfig; - since: StringConfig; -}; - -// Configuration -export const gatewayLogsFilterFieldConfig: FilterFieldConfigs = { - status: { - type: "number", - operators: ["is"], - getColorClass: (value) => { - if (value >= 500) { - return "bg-error-9"; - } - if (value >= 400) { - return "bg-warning-8"; - } - return "bg-success-9"; - }, - validate: (value) => value >= 200 && value <= 599, - }, - methods: { - type: "string", - operators: ["is"], - validValues: METHODS, - }, - paths: { - type: "string", - operators: ["is", "contains", "startsWith", "endsWith"], - }, - host: { - type: "string", - operators: ["is"], - }, - requestId: { - type: "string", - operators: ["is"], - }, - startTime: { - type: "number", - operators: ["is"], - }, - endTime: { - type: "number", - operators: ["is"], - }, - since: { - type: "string", - operators: ["is"], - }, -} as const; - -// Schemas -export const gatewayLogsFilterOperatorEnum = z.enum(ALL_OPERATORS); -export const gatewayLogsFilterFieldEnum = z.enum([ - "status", - "methods", - "paths", - "host", - "requestId", - "startTime", - "endTime", - "since", -]); - -export const gatewayLogsFilterOutputSchema = createFilterOutputSchema( - gatewayLogsFilterFieldEnum, - gatewayLogsFilterOperatorEnum, - gatewayLogsFilterFieldConfig, -); - -// Derived types -export type GatewayLogsFilterField = z.infer; - -export type GatewayLogsFilterUrlValue = { - value: string; - operator: GatewayLogsFilterOperator; -}; - -export type GatewayLogsFilterValue = FilterValue; - -export type GatewayLogsQuerySearchParams = { - status: GatewayLogsFilterUrlValue[] | null; - methods: GatewayLogsFilterUrlValue[] | null; - paths: GatewayLogsFilterUrlValue[] | null; - host: GatewayLogsFilterUrlValue[] | null; - requestId: GatewayLogsFilterUrlValue[] | null; - startTime: number | null; - endTime: number | null; - since: string | null; -}; - -export const parseAsAllOperatorsFilterArray = parseAsFilterValueArray([ - ...ALL_OPERATORS, -]); diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/hooks/use-gateway-logs-filters.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/hooks/use-gateway-logs-filters.ts index 5caa9f4d3c..7d340b3e10 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/hooks/use-gateway-logs-filters.ts +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/hooks/use-gateway-logs-filters.ts @@ -2,16 +2,16 @@ import { parseAsFilterValueArray, parseAsRelativeTime, } from "@/components/logs/validation/utils/nuqs-parsers"; +import { + type LogsFilterField as GatewayLogsFilterField, + type LogsFilterOperator as GatewayLogsFilterOperator, + type LogsFilterUrlValue as GatewayLogsFilterUrlValue, + type LogsFilterValue as GatewayLogsFilterValue, + type QuerySearchParams as GatewayLogsQuerySearchParams, + logsFilterFieldConfig as gatewayLogsFilterFieldConfig, +} from "@/lib/schemas/logs.filter.schema"; import { parseAsInteger, useQueryStates } from "nuqs"; import { useCallback, useMemo } from "react"; -import { - type GatewayLogsFilterField, - type GatewayLogsFilterOperator, - type GatewayLogsFilterUrlValue, - type GatewayLogsFilterValue, - type GatewayLogsQuerySearchParams, - gatewayLogsFilterFieldConfig, -} from "../gateway-logs-filters.schema"; // Constants const parseAsFilterValArray = parseAsFilterValueArray([ diff --git a/apps/dashboard/components/logs/hooks/use-bookmarked-filters.test.ts b/apps/dashboard/components/logs/hooks/use-bookmarked-filters.test.ts index a86b43329f..92e76a5f1f 100644 --- a/apps/dashboard/components/logs/hooks/use-bookmarked-filters.test.ts +++ b/apps/dashboard/components/logs/hooks/use-bookmarked-filters.test.ts @@ -1,4 +1,4 @@ -import type { QuerySearchParams } from "@/app/(app)/logs/filters.schema"; +import type { QuerySearchParams } from "@/lib/schemas/logs.filter.schema"; import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { FilterValue } from "../validation/filter.types"; diff --git a/apps/dashboard/components/logs/hooks/use-bookmarked-filters.ts b/apps/dashboard/components/logs/hooks/use-bookmarked-filters.ts index f16792e30d..50ea1036f5 100644 --- a/apps/dashboard/components/logs/hooks/use-bookmarked-filters.ts +++ b/apps/dashboard/components/logs/hooks/use-bookmarked-filters.ts @@ -1,4 +1,4 @@ -import { type QuerySearchParams, logsFilterFieldConfig } from "@/app/(app)/logs/filters.schema"; +import { type QuerySearchParams, logsFilterFieldConfig } from "@/lib/schemas/logs.filter.schema"; import { isBrowser } from "@/lib/utils"; import { useCallback, useEffect, useState } from "react"; import type { FilterValue } from "../validation/filter.types"; diff --git a/apps/dashboard/components/logs/queries/queries-context.tsx b/apps/dashboard/components/logs/queries/queries-context.tsx index 601efd746d..d5472a998a 100644 --- a/apps/dashboard/components/logs/queries/queries-context.tsx +++ b/apps/dashboard/components/logs/queries/queries-context.tsx @@ -1,5 +1,4 @@ import type { QuerySearchParams as AuditSearchParams } from "@/app/(app)/audit/filters.schema"; -import type { QuerySearchParams } from "@/app/(app)/logs/filters.schema"; import type { RatelimitQuerySearchParams } from "@/app/(app)/ratelimits/[namespaceId]/logs/filters.schema"; import { type ReactNode, createContext, useContext } from "react"; import { type SavedFiltersGroup, useBookmarkedFilters } from "../hooks/use-bookmarked-filters"; @@ -91,6 +90,7 @@ export function useQueries() { return context; } +import type { QuerySearchParams } from "@/lib/schemas/logs.filter.schema"; import { ChartActivity2 } from "@unkey/icons"; import React from "react"; import { iconsPerField } from "./utils"; @@ -175,7 +175,10 @@ export const defaultFormatValues = ( export const defaultGetIcon = (field: string): React.ReactNode => { const Icon = iconsPerField[field] || ChartActivity2; - return React.createElement(Icon, { size: "md-regular", className: "justify-center" }); + return React.createElement(Icon, { + size: "md-regular", + className: "justify-center", + }); }; export const defaultFieldsToTruncate = [ diff --git a/apps/dashboard/components/logs/queries/utils.ts b/apps/dashboard/components/logs/queries/utils.ts index b5282d9b98..a5d59ad4a0 100644 --- a/apps/dashboard/components/logs/queries/utils.ts +++ b/apps/dashboard/components/logs/queries/utils.ts @@ -11,7 +11,6 @@ import { import React from "react"; import { auditLogsFilterFieldEnum } from "@/app/(app)/audit/filters.schema"; -import { logsFilterFieldEnum } from "@/app/(app)/logs/filters.schema"; import { ratelimitFilterFieldEnum } from "@/app/(app)/ratelimits/[namespaceId]/logs/filters.schema"; import { Bucket, @@ -28,9 +27,13 @@ import { } from "@unkey/icons"; import type { AuditLogsFilterField } from "@/app/(app)/audit/filters.schema"; -import type { LogsFilterField, QuerySearchParams } from "@/app/(app)/logs/filters.schema"; import type { RatelimitFilterField } from "@/app/(app)/ratelimits/[namespaceId]/logs/filters.schema"; import { namespaceListFilterFieldEnum } from "@/app/(app)/ratelimits/_components/namespace-list-filters.schema"; +import { + type LogsFilterField, + type QuerySearchParams, + logsFilterFieldEnum, +} from "@/lib/schemas/logs.filter.schema"; import type { IconProps } from "@unkey/icons/src/props"; import type { FC } from "react"; diff --git a/apps/dashboard/app/(app)/logs/filters.schema.ts b/apps/dashboard/lib/schemas/logs.filter.schema.ts similarity index 97% rename from apps/dashboard/app/(app)/logs/filters.schema.ts rename to apps/dashboard/lib/schemas/logs.filter.schema.ts index 2d766fded9..c34c2364d3 100644 --- a/apps/dashboard/app/(app)/logs/filters.schema.ts +++ b/apps/dashboard/lib/schemas/logs.filter.schema.ts @@ -1,5 +1,3 @@ -import { METHODS } from "./constants"; - import type { FilterValue, NumberConfig, @@ -27,7 +25,7 @@ export const logsFilterFieldConfig: FilterFieldConfigs = { methods: { type: "string", operators: ["is"], - validValues: METHODS, + validValues: ["GET", "POST", "PUT", "DELETE", "PATCH"] as const, }, paths: { type: "string", diff --git a/apps/dashboard/lib/schemas/logs.schema.ts b/apps/dashboard/lib/schemas/logs.schema.ts index 7051dcd9b8..4f3b432cf2 100644 --- a/apps/dashboard/lib/schemas/logs.schema.ts +++ b/apps/dashboard/lib/schemas/logs.schema.ts @@ -1,6 +1,6 @@ -import { logsFilterOperatorEnum } from "@/app/(app)/logs/filters.schema"; import { log } from "@unkey/clickhouse/src/logs"; import { z } from "zod"; +import { logsFilterOperatorEnum } from "./logs.filter.schema"; export type LogsRequestSchema = z.infer; export const logsRequestSchema = z.object({ diff --git a/apps/dashboard/lib/trpc/routers/logs/llm-search/utils.ts b/apps/dashboard/lib/trpc/routers/logs/llm-search/utils.ts index b415567135..26dad15e82 100644 --- a/apps/dashboard/lib/trpc/routers/logs/llm-search/utils.ts +++ b/apps/dashboard/lib/trpc/routers/logs/llm-search/utils.ts @@ -1,5 +1,5 @@ import { METHODS } from "@/app/(app)/logs/constants"; -import { filterOutputSchema, logsFilterFieldConfig } from "@/app/(app)/logs/filters.schema"; +import { filterOutputSchema, logsFilterFieldConfig } from "@/lib/schemas/logs.filter.schema"; import { TRPCError } from "@trpc/server"; import type OpenAI from "openai"; import { zodResponseFormat } from "openai/helpers/zod.mjs"; From c3c5b95dca524d39497683a2eef1ec5a08a2e899 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 22 Sep 2025 13:41:33 +0300 Subject: [PATCH 12/27] refactor: make logs denser --- apps/dashboard/lib/trpc/routers/utils/granularity.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/lib/trpc/routers/utils/granularity.ts b/apps/dashboard/lib/trpc/routers/utils/granularity.ts index aa24ec0788..6a2ad9c4ec 100644 --- a/apps/dashboard/lib/trpc/routers/utils/granularity.ts +++ b/apps/dashboard/lib/trpc/routers/utils/granularity.ts @@ -109,11 +109,11 @@ export const getTimeseriesGranularity = ( if (timeRange >= DAY_IN_MS * 7) { granularity = "perDay"; } else if (timeRange >= DAY_IN_MS * 3) { - granularity = "per6Hours"; + granularity = "perHour"; } else if (timeRange >= HOUR_IN_MS * 24) { - granularity = "per4Hours"; + granularity = "per30Minutes"; } else if (timeRange >= HOUR_IN_MS * 16) { - granularity = "per2Hours"; + granularity = "per30Minutes"; } else if (timeRange >= HOUR_IN_MS * 12) { granularity = "per30Minutes"; } else if (timeRange >= HOUR_IN_MS * 8) { @@ -123,7 +123,7 @@ export const getTimeseriesGranularity = ( } else if (timeRange >= HOUR_IN_MS * 4) { granularity = "per5Minutes"; } else if (timeRange >= HOUR_IN_MS * 2) { - granularity = "per5Minutes"; + granularity = "perMinute"; } else { granularity = "perMinute"; } From 275d209cf87f5910d0d289f58b02e4861b947af1 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 22 Sep 2025 13:42:24 +0300 Subject: [PATCH 13/27] fix: make it denser --- apps/dashboard/lib/trpc/routers/utils/granularity.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/lib/trpc/routers/utils/granularity.ts b/apps/dashboard/lib/trpc/routers/utils/granularity.ts index 6a2ad9c4ec..c729003f84 100644 --- a/apps/dashboard/lib/trpc/routers/utils/granularity.ts +++ b/apps/dashboard/lib/trpc/routers/utils/granularity.ts @@ -115,9 +115,9 @@ export const getTimeseriesGranularity = ( } else if (timeRange >= HOUR_IN_MS * 16) { granularity = "per30Minutes"; } else if (timeRange >= HOUR_IN_MS * 12) { - granularity = "per30Minutes"; + granularity = "per15Minutes"; } else if (timeRange >= HOUR_IN_MS * 8) { - granularity = "per30Minutes"; + granularity = "per15Minutes"; } else if (timeRange >= HOUR_IN_MS * 6) { granularity = "per5Minutes"; } else if (timeRange >= HOUR_IN_MS * 4) { From a4c9765488559c196b20abb1ad9d7181ac768a63 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 22 Sep 2025 13:44:11 +0300 Subject: [PATCH 14/27] fix: add missing hostname to table --- .../gateway-logs/components/table/gateway-logs-table.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx index 2a4c5de8fb..b172569c17 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx @@ -127,6 +127,12 @@ const columns: Column[] = [ ); }, }, + { + key: "host", + header: "Hostname", + width: "15%", + render: (log) =>
{log.host}
, + }, { key: "method", header: "Method", From c1c29d40b018f826b2b79a8030c2288d41d3ecaa Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 22 Sep 2025 14:01:55 +0300 Subject: [PATCH 15/27] refactor: table columns and text contrasts --- .../components/table/gateway-logs-table.tsx | 251 +++++++----------- .../components/table/utils/get-row-class.ts | 128 +++++++++ .../log-details/components/log-meta.tsx | 2 +- .../log-details/components/log-section.tsx | 2 +- .../logs/details/request-response-details.tsx | 2 +- 5 files changed, 220 insertions(+), 165 deletions(-) create mode 100644 apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/utils/get-row-class.ts diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx index b172569c17..3ef43cdef1 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx @@ -9,73 +9,73 @@ import { Badge, Button, Empty, TimestampInfo } from "@unkey/ui"; import { useGatewayLogsContext } from "../../context/gateway-logs-provider"; import { extractResponseField } from "../../utils"; import { useGatewayLogsQuery } from "./hooks/use-gateway-logs-query"; +import { + WARNING_ICON_STYLES, + getRowClassName, + getSelectedClassName, + getStatusStyle, +} from "./utils/get-row-class"; -type StatusStyle = { - base: string; - hover: string; - selected: string; - badge: { - default: string; - selected: string; - }; - focusRing: string; -}; - -const STATUS_STYLES = { - success: { - base: "text-grayA-9", - hover: "hover:text-accent-11 dark:hover:text-accent-12 hover:bg-grayA-3", - selected: "text-accent-12 bg-grayA-3 hover:text-accent-12", - badge: { - default: "bg-grayA-3 text-grayA-11 group-hover:bg-grayA-5", - selected: "bg-grayA-5 text-grayA-12 hover:bg-grayA-5", - }, - focusRing: "focus:ring-accent-7", - }, - warning: { - base: "text-warning-11 bg-warning-2", - hover: "hover:bg-warning-3", - selected: "bg-warning-3", - badge: { - default: "bg-warning-4 text-warning-11 group-hover:bg-warning-5", - selected: "bg-warning-5 text-warning-11 hover:bg-warning-5", - }, - focusRing: "focus:ring-warning-7", - }, - error: { - base: "text-error-11 bg-error-2", - hover: "hover:bg-error-3", - selected: "bg-error-3", - badge: { - default: "bg-error-4 text-error-11 group-hover:bg-error-5", - selected: "bg-error-5 text-error-11 hover:bg-error-5", - }, - focusRing: "focus:ring-error-7", - }, -}; - -const getStatusStyle = (status: number): StatusStyle => { - if (status >= 500) { - return STATUS_STYLES.error; - } - if (status >= 400) { - return STATUS_STYLES.warning; - } - return STATUS_STYLES.success; -}; - -const WARNING_ICON_STYLES = { - base: "size-3", - warning: "text-warning-11", - error: "text-error-11", -}; +export const GatewayLogsTable = () => { + const { setSelectedLog, selectedLog, isLive } = useGatewayLogsContext(); + const { realtimeLogs, historicalLogs, isLoading, isLoadingMore, loadMore, hasMore, total } = + useGatewayLogsQuery({ + startPolling: isLive, + pollIntervalMs: 2000, + }); -const getSelectedClassName = (log: Log, isSelected: boolean) => { - if (!isSelected) { - return ""; - } - const style = getStatusStyle(log.response_status); - return style.selected; + return ( + log.request_id} + rowClassName={(log) => getRowClassName({ log, selectedLog, isLive, realtimeLogs })} + selectedClassName={getSelectedClassName} + loadMoreFooterProps={{ + hide: isLoading, + buttonText: "Load more logs", + hasMore, + countInfoText: ( +
+ Showing {historicalLogs.length} + of + {total} + requests +
+ ), + }} + emptyState={ +
+ + + Logs + + Keep track of all activity within your workspace. We collect all API requests, giving + you a clear history to find problems or debug issues. + + + + + + + +
+ } + /> + ); }; const WarningIcon = ({ status }: { status: number }) => ( @@ -94,7 +94,7 @@ const columns: Column[] = [ { key: "time", header: "Time", - width: "5%", + width: "180px", headerClassName: "pl-8", render: (log) => (
@@ -111,7 +111,7 @@ const columns: Column[] = [ { key: "response_status", header: "Status", - width: "7.5%", + width: "120px", render: (log) => { const style = getStatusStyle(log.response_status); return ( @@ -130,18 +130,22 @@ const columns: Column[] = [ { key: "host", header: "Hostname", - width: "15%", - render: (log) =>
{log.host}
, + width: "200px", + render: (log) => ( +
+ {log.host} +
+ ), }, { key: "method", header: "Method", - width: "7.5%", + width: "80px", render: (log) => ( {log.method} @@ -151,108 +155,31 @@ const columns: Column[] = [ { key: "path", header: "Path", - width: "15%", - render: (log) =>
{log.path}
, + width: "250px", + render: (log) => ( +
+ {log.path} +
+ ), }, { key: "response_body", header: "Response Body", - width: "auto", + width: "300px", render: (log) => ( -
{log.response_body}
+
+ {log.response_body} +
), }, { key: "request_body", header: "Request Body", - width: "auto", + width: "1fr", render: (log) => ( -
{log.request_body}
+
+ {log.request_body} +
), }, ]; - -export const GatewayLogsTable = () => { - const { setSelectedLog, selectedLog, isLive } = useGatewayLogsContext(); - const { realtimeLogs, historicalLogs, isLoading, isLoadingMore, loadMore, hasMore, total } = - useGatewayLogsQuery({ - startPolling: isLive, - pollIntervalMs: 2000, - }); - - const getRowClassName = (log: Log) => { - const style = getStatusStyle(log.response_status); - const isSelected = selectedLog?.request_id === log.request_id; - - return cn( - style.base, - style.hover, - "group rounded-md", - "focus:outline-none focus:ring-1 focus:ring-opacity-40", - style.focusRing, - isSelected && style.selected, - isLive && - !realtimeLogs.some((realtime) => realtime.request_id === log.request_id) && [ - "opacity-50", - "hover:opacity-100", - ], - selectedLog && { - "opacity-50 z-0": !isSelected, - "opacity-100 z-10": isSelected, - }, - ); - }; - - return ( - log.request_id} - rowClassName={getRowClassName} - selectedClassName={getSelectedClassName} - loadMoreFooterProps={{ - hide: isLoading, - buttonText: "Load more logs", - hasMore, - countInfoText: ( -
- Showing {historicalLogs.length} - of - {total} - requests -
- ), - }} - emptyState={ -
- - - Logs - - Keep track of all activity within your workspace. We collect all API requests, giving - you a clear history to find problems or debug issues. - - - - - - - -
- } - /> - ); -}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/utils/get-row-class.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/utils/get-row-class.ts new file mode 100644 index 0000000000..286c2af1aa --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/utils/get-row-class.ts @@ -0,0 +1,128 @@ +import type { Log } from "@unkey/clickhouse/src/logs"; +import { cn } from "@unkey/ui/src/lib/utils"; + +type StatusStyle = { + base: string; + hover: string; + selected: string; + badge: { + default: string; + selected: string; + }; + focusRing: string; +}; + +const STATUS_STYLES = { + success: { + base: "text-grayA-9", + hover: "hover:text-accent-11 dark:hover:text-accent-12 hover:bg-grayA-3", + selected: "text-accent-12 bg-grayA-3 hover:text-accent-12", + badge: { + default: "bg-grayA-3 text-grayA-11 group-hover:bg-grayA-5", + selected: "bg-grayA-5 text-grayA-12 hover:bg-grayA-5", + }, + focusRing: "focus:ring-accent-7", + }, + warning: { + base: "text-warning-11 bg-warning-2", + hover: "hover:bg-warning-3", + selected: "bg-warning-3", + badge: { + default: "bg-warning-4 text-warning-11 group-hover:bg-warning-5", + selected: "bg-warning-5 text-warning-11 hover:bg-warning-5", + }, + focusRing: "focus:ring-warning-7", + }, + error: { + base: "text-error-11 bg-error-2", + hover: "hover:bg-error-3", + selected: "bg-error-3", + badge: { + default: "bg-error-4 text-error-11 group-hover:bg-error-5", + selected: "bg-error-5 text-error-11 hover:bg-error-5", + }, + focusRing: "focus:ring-error-7", + }, +}; + +export const getStatusStyle = (status: number): StatusStyle => { + if (status >= 500) { + return STATUS_STYLES.error; + } + if (status >= 400) { + return STATUS_STYLES.warning; + } + return STATUS_STYLES.success; +}; + +export const WARNING_ICON_STYLES = { + base: "size-3", + warning: "text-warning-11", + error: "text-error-11", +}; + +export const getSelectedClassName = (log: Log, isSelected: boolean) => { + if (!isSelected) { + return ""; + } + const style = getStatusStyle(log.response_status); + return style.selected; +}; + +type GetRowClassNameParams = { + log: Log; + selectedLog?: Log | null; + isLive?: boolean; + realtimeLogs?: Log[]; +}; + +export const getRowClassName = ({ + log, + selectedLog, + isLive = false, + realtimeLogs = [], +}: GetRowClassNameParams): string => { + // Early validation + if (!log?.request_id) { + throw new Error("Log must have a valid request_id"); + } + + if ( + !Number.isInteger(log.response_status) || + log.response_status < 100 || + log.response_status > 599 + ) { + throw new Error( + `Invalid response_status: ${log.response_status}. Must be a valid HTTP status code.`, + ); + } + + const style = getStatusStyle(log.response_status); + const isSelected = Boolean(selectedLog?.request_id === log.request_id); + + const isInRealtime = realtimeLogs.some((realtime) => realtime?.request_id === log.request_id); + + const baseClasses = [ + style.base, + style.hover, + "group rounded-md", + "focus:outline-none focus:ring-1 focus:ring-opacity-40", + style.focusRing, + ]; + + const conditionalClasses = [ + // Selected state + isSelected && style.selected, + + // Live mode opacity for non-realtime items + isLive && !isInRealtime && ["opacity-50", "hover:opacity-100"], + + // Selection-based z-index and opacity + selectedLog && { + "opacity-50 z-0": !isSelected, + "opacity-100 z-10": isSelected, + }, + ].filter(Boolean); + + return cn(...baseClasses, ...conditionalClasses); +}; diff --git a/apps/dashboard/components/logs/details/log-details/components/log-meta.tsx b/apps/dashboard/components/logs/details/log-details/components/log-meta.tsx index 3f9b54dd72..3361a1e0a3 100644 --- a/apps/dashboard/components/logs/details/log-details/components/log-meta.tsx +++ b/apps/dashboard/components/logs/details/log-details/components/log-meta.tsx @@ -3,7 +3,7 @@ import { Card, CardContent, CopyButton } from "@unkey/ui"; export const LogMetaSection = ({ content }: { content: string }) => { return (
-
Meta
+
Meta
{content ?? ""} 
diff --git a/apps/dashboard/components/logs/details/log-details/components/log-section.tsx b/apps/dashboard/components/logs/details/log-details/components/log-section.tsx index 276934956d..71a89eb7a8 100644 --- a/apps/dashboard/components/logs/details/log-details/components/log-section.tsx +++ b/apps/dashboard/components/logs/details/log-details/components/log-section.tsx @@ -10,7 +10,7 @@ export const LogSection = ({ return (
- {title} + {title}
diff --git a/apps/dashboard/components/logs/details/request-response-details.tsx b/apps/dashboard/components/logs/details/request-response-details.tsx index 0f81882c74..c26270e53c 100644 --- a/apps/dashboard/components/logs/details/request-response-details.tsx +++ b/apps/dashboard/components/logs/details/request-response-details.tsx @@ -70,7 +70,7 @@ export const RequestResponseDetails = ({ fields, className )} onClick={field.skipTooltip ? undefined : () => handleClick(field)} > - {field.label} + {field.label} {field.description(field.content as NonNullable)} From b06fb52781a43488ab9ec31a4ffbaabc1194f126 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 22 Sep 2025 15:11:39 +0300 Subject: [PATCH 16/27] fix: memo issue and add gateway to logs to deployments --- .../components/rollback-dialog.tsx | 19 ++-- ...nt-list-table-action.popover.constants.tsx | 107 ++++++++++-------- .../components/table/deployments-list.tsx | 19 ++-- 3 files changed, 84 insertions(+), 61 deletions(-) diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx index c6507e31d7..4dfda1c762 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx @@ -1,12 +1,13 @@ "use client"; -import { type Deployment, collection, collectionManager } from "@/lib/collections"; +import { type Deployment, collection } from "@/lib/collections"; import { shortenId } from "@/lib/shorten-id"; import { trpc } from "@/lib/trpc/client"; import { eq, inArray, useLiveQuery } from "@tanstack/react-db"; import { CircleInfo, CodeBranch, CodeCommit, Link4 } from "@unkey/icons"; import { Badge, Button, DialogContainer, toast } from "@unkey/ui"; import { StatusIndicator } from "../../details/active-deployment-card/status-indicator"; +import { useProjectLayout } from "../../layout-provider"; type DeploymentSectionProps = { title: string; @@ -27,27 +28,29 @@ const DeploymentSection = ({ title, deployment, isLive, showSignal }: Deployment type RollbackDialogProps = { isOpen: boolean; - onOpenChange: (open: boolean) => void; + onClose: () => void; targetDeployment: Deployment; liveDeployment: Deployment; }; export const RollbackDialog = ({ isOpen, - onOpenChange, + onClose, targetDeployment, liveDeployment, }: RollbackDialogProps) => { const utils = trpc.useUtils(); - const domainCollection = collectionManager.getProjectCollections( - liveDeployment.projectId, - ).domains; + + const { + collections: { domains: domainCollection }, + } = useProjectLayout(); const domains = useLiveQuery((q) => q .from({ domain: domainCollection }) .where(({ domain }) => inArray(domain.sticky, ["environment", "live"])) .where(({ domain }) => eq(domain.deploymentId, liveDeployment.id)), ); + const rollback = trpc.deploy.deployment.rollback.useMutation({ onSuccess: () => { utils.invalidate(); @@ -62,7 +65,7 @@ export const RollbackDialog = ({ console.error("Refetch error:", error); } - onOpenChange(false); + onClose(); }, onError: (error) => { toast.error("Rollback failed", { @@ -84,7 +87,7 @@ export const RollbackDialog = ({ return ( { - const [isRollbackModalOpen, setIsRollbackModalOpen] = useState(false); - const menuItems = getDeploymentListTableActionItems( - selectedDeployment, - liveDeployment, - environment, - setIsRollbackModalOpen, + const { collections } = useProjectLayout(); + const { data } = useLiveQuery((q) => + q + .from({ domain: collections.domains }) + .where(({ domain }) => eq(domain.deploymentId, selectedDeployment.id)) + .select(({ domain }) => ({ host: domain.domain })), ); - return ( - <> - - {liveDeployment && selectedDeployment && ( - - )} - - ); -}; + const router = useRouter(); + // biome-ignore lint/correctness/useExhaustiveDependencies: its okay + const menuItems = useMemo((): MenuItem[] => { + // Rollback is only enabled when: + // Selected deployment is not the current live deployment + // Selected deployment status is "ready" + // Environment is production, when testing locally if you don't use `--prod=env` flag rollback won't work. + const isCurrentlyLive = liveDeployment?.id === selectedDeployment.id; + const isDeploymentReady = selectedDeployment.status === "ready"; + const isProductionEnv = environment?.slug === "production"; -const getDeploymentListTableActionItems = ( - selectedDeployment: Deployment, - liveDeployment: Deployment | undefined, - environment: Environment | undefined, - setIsRollbackModalOpen: (open: boolean) => void, -): MenuItem[] => { - // Rollback is only enabled for production deployments that are ready and not currently active - const canRollback = - liveDeployment && - environment?.slug === "production" && - selectedDeployment.status === "ready" && - selectedDeployment.id !== liveDeployment.id; + const canRollback = !isCurrentlyLive && isDeploymentReady && isProductionEnv; - return [ - { - id: "rollback", - label: "Rollback", - icon: , - disabled: !canRollback, - onClick: () => { - if (canRollback) { - setIsRollbackModalOpen(true); - } + return [ + { + id: "rollback", + label: "Rollback", + icon: , + disabled: !canRollback, + ActionComponent: + liveDeployment && canRollback + ? (props) => ( + + ) + : undefined, }, - }, - ]; + { + id: "gateway-logs", + label: "Go to Gateway Logs...", + icon: , + onClick: () => { + //INFO: This will produce a long query, but once we start using `contains` instead of `is` this will be a shorter query. + router.push( + `/projects/${selectedDeployment.projectId}/gateway-logs?host=${data + .map((item) => `is:${item.host}`) + .join(",")}`, + ); + }, + }, + ]; + }, [ + selectedDeployment.id, + selectedDeployment.status, + liveDeployment?.id, + environment?.slug, + data, + ]); + + return ; }; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/deployments-list.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/deployments-list.tsx index baeb499a5d..ba412999e7 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/deployments-list.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/deployments-list.tsx @@ -45,12 +45,15 @@ export const DeploymentsList = () => { environment?: Environment; } | null>(null); const isCompactView = useIsMobile({ breakpoint: COMPACT_BREAKPOINT }); - const { liveDeployment, deployments } = useDeployments(); + const selectedDeploymentId = selectedDeployment?.deployment.id; + const liveDeploymentId = liveDeployment?.id; + const columns: Column<{ deployment: Deployment; environment?: Environment; + // biome-ignore lint/correctness/useExhaustiveDependencies: its okay }>[] = useMemo(() => { return [ { @@ -288,16 +291,18 @@ export const DeploymentsList = () => { environment?: Environment; }) => { return ( - +
+ +
); }, }, ]; - }, [selectedDeployment?.deployment.id, isCompactView, liveDeployment]); + }, [selectedDeploymentId, isCompactView, liveDeploymentId]); return ( Date: Mon, 22 Sep 2025 15:18:19 +0300 Subject: [PATCH 17/27] fix: color inconsistency --- .../components/table/components/env-status-badge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/env-status-badge.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/env-status-badge.tsx index 769283e81d..e4bda4c400 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/env-status-badge.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/env-status-badge.tsx @@ -11,7 +11,7 @@ const statusBadgeVariants = cva( enabled: "text-successA-11 bg-successA-3", disabled: "text-warningA-11 bg-warningA-3", live: "text-feature-11 bg-feature-4", - rolledBack: "text-warningA-11 bg-warningA-2", + rolledBack: "text-warningA-11 bg-warningA-3", }, }, defaultVariants: { From 63f0c1e3a658b7528093209a83257d110cf7664e Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 23 Sep 2025 13:23:52 +0300 Subject: [PATCH 18/27] fix: test and add tooltip --- .../table/components/env-status-badge.tsx | 27 +++++++++---- .../dashboard/components/logs/chart/index.tsx | 1 + .../overview-charts/overview-bar-chart.tsx | 8 +++- .../routers/logs/query-logs/utils.test.ts | 26 ++++++++++++ .../trpc/routers/utils/granularity.test.ts | 40 +++++++++---------- .../lib/trpc/routers/utils/granularity.ts | 8 ++-- 6 files changed, 77 insertions(+), 33 deletions(-) diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/env-status-badge.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/env-status-badge.tsx index e4bda4c400..f966811325 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/env-status-badge.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/env-status-badge.tsx @@ -1,4 +1,5 @@ import { cn } from "@/lib/utils"; +import { InfoTooltip } from "@unkey/ui"; import { cva } from "class-variance-authority"; import type { VariantProps } from "class-variance-authority"; import type { HTMLAttributes, ReactNode } from "react"; @@ -20,23 +21,35 @@ const statusBadgeVariants = cva( }, ); -interface EnvStatusBadgeProps extends HTMLAttributes { +const tooltipContent = { + enabled: "This environment is enabled and ready to receive deployments.", + disabled: "This environment is disabled and cannot receive deployments.", + live: "This environment is currently receiving live traffic.", + rolledBack: "This environment was previously live but has been rolled back.", +} as const; + +type EnvStatusBadgeProps = HTMLAttributes & { variant?: VariantProps["variant"]; icon?: ReactNode; text: string; -} +}; export const EnvStatusBadge = ({ - variant, + variant = "live", icon, text, className, ...props }: EnvStatusBadgeProps) => { return ( -
- {icon && {icon}} - {text} -
+ ]} + variant="inverted" + > +
+ {icon && {icon}} + {text} +
+
); }; diff --git a/apps/dashboard/components/logs/chart/index.tsx b/apps/dashboard/components/logs/chart/index.tsx index 389e32ffb9..44c588c998 100644 --- a/apps/dashboard/components/logs/chart/index.tsx +++ b/apps/dashboard/components/logs/chart/index.tsx @@ -139,6 +139,7 @@ export function LogsTimeseriesBarChart({ - //@ts-expect-error safe to ignore for now - createTimeIntervalFormatter(data, "HH:mm")(tooltipPayload) + createTimeIntervalFormatter( + data, + "HH:mm", + //@ts-expect-error safe to ignore for now + )(tooltipPayload) } /> ); 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 index 6f4223ee3e..881a54e719 100644 --- a/apps/dashboard/lib/trpc/routers/logs/query-logs/utils.test.ts +++ b/apps/dashboard/lib/trpc/routers/logs/query-logs/utils.test.ts @@ -25,6 +25,7 @@ describe("transformFilters", () => { hosts: [], methods: [], paths: [], + excludeHosts: [], statusCodes: [], requestIds: [], cursorTime: null, @@ -58,6 +59,7 @@ describe("transformFilters", () => { startTime: payload.startTime, endTime: payload.endTime, limit: 50, + excludeHosts: [], hosts: ["example.com"], methods: ["GET"], paths: [{ operator: "startsWith", value: "/api" }], @@ -89,4 +91,28 @@ describe("transformFilters", () => { expect(result.cursorTime).toBe(1706024400000); }); + + it("should handle excluded hosts", () => { + const payload = { + ...basePayload, + host: { + filters: [{ operator: "is" as const, value: "example.com" }], + exclude: ["blocked.com", "spam.com"], + }, + }; + + const result = transformFilters(payload); + expect(result).toEqual({ + startTime: payload.startTime, + endTime: payload.endTime, + limit: 50, + hosts: ["example.com"], + excludeHosts: ["blocked.com", "spam.com"], + methods: [], + paths: [], + statusCodes: [], + requestIds: [], + cursorTime: null, + }); + }); }); diff --git a/apps/dashboard/lib/trpc/routers/utils/granularity.test.ts b/apps/dashboard/lib/trpc/routers/utils/granularity.test.ts index 012a6dae23..482a5f2cd3 100644 --- a/apps/dashboard/lib/trpc/routers/utils/granularity.test.ts +++ b/apps/dashboard/lib/trpc/routers/utils/granularity.test.ts @@ -89,49 +89,49 @@ describe("getTimeseriesGranularity", () => { expectedGranularity: "perMinute", }, { - name: "should use per5Minutes for timeRange >= 2 hours & < 4 hours", + name: "should use perMinute for timeRange >= 2 hours & < 4 hours", startTime: getTime(HOUR_IN_MS * 3), - expectedGranularity: "per5Minutes", + expectedGranularity: "perMinute", }, { - name: "should use per15Minutes for timeRange >= 4 hours & < 6 hours", + name: "should use per5Minutes for timeRange >= 4 hours & < 6 hours", startTime: getTime(HOUR_IN_MS * 5), expectedGranularity: "per5Minutes", }, { - name: "should use per30Minutes for timeRange >= 6 hours & < 8 hours", + name: "should use per5Minutes for timeRange >= 6 hours & < 8 hours", startTime: getTime(HOUR_IN_MS * 7), expectedGranularity: "per5Minutes", }, { - name: "should use per30Minutes for timeRange >= 8 hours & < 12 hours", + name: "should use per15Minutes for timeRange >= 8 hours & < 12 hours", startTime: getTime(HOUR_IN_MS * 10), - expectedGranularity: "per30Minutes", + expectedGranularity: "per15Minutes", }, { - name: "should use perHour for timeRange >= 12 hours & < 16 hours", + name: "should use per15Minutes for timeRange >= 12 hours & < 16 hours", startTime: getTime(HOUR_IN_MS * 14), - expectedGranularity: "per30Minutes", + expectedGranularity: "per15Minutes", }, { - name: "should use per2Hours for timeRange >= 16 hours & < 24 hours", + name: "should use per15Minutes for timeRange >= 16 hours & < 24 hours", startTime: getTime(HOUR_IN_MS * 20), - expectedGranularity: "per2Hours", + expectedGranularity: "per15Minutes", }, { - name: "should use per4Hours for timeRange >= 24 hours & < 3 days", + name: "should use per15Minutes for timeRange >= 24 hours & < 3 days", startTime: getTime(DAY_IN_MS * 2), - expectedGranularity: "per4Hours", + expectedGranularity: "per15Minutes", }, { - name: "should use per6Hours for timeRange >= 3 days & < 7 days", + name: "should use per30Minutes for timeRange >= 3 days & < 7 days", startTime: getTime(DAY_IN_MS * 5), - expectedGranularity: "per6Hours", + expectedGranularity: "per30Minutes", }, { - name: "should use perDay for timeRange >= 7 days", + name: "should use per2Hours for timeRange >= 7 days", startTime: getTime(DAY_IN_MS * 10), - expectedGranularity: "perDay", + expectedGranularity: "per2Hours", }, ]; @@ -144,12 +144,12 @@ describe("getTimeseriesGranularity", () => { it("should handle edge case at exactly 2 hours boundary", () => { const result = getTimeseriesGranularity("forRegular", FIXED_NOW - HOUR_IN_MS * 2, FIXED_NOW); - expect(result.granularity).toBe("per5Minutes"); + expect(result.granularity).toBe("perMinute"); }); it("should handle edge case at exactly 7 days boundary", () => { const result = getTimeseriesGranularity("forRegular", FIXED_NOW - DAY_IN_MS * 7, FIXED_NOW); - expect(result.granularity).toBe("perDay"); + expect(result.granularity).toBe("per2Hours"); }); }); @@ -271,7 +271,7 @@ describe("getTimeseriesGranularity", () => { const oneDayAgo = FIXED_NOW - DAY_IN_MS; const result = getTimeseriesGranularity("forRegular", oneDayAgo, FIXED_NOW); - expect(result.granularity).toBe("per4Hours"); + expect(result.granularity).toBe("per15Minutes"); expect(result.startTime).toBe(oneDayAgo); expect(result.endTime).toBe(FIXED_NOW); }); @@ -280,7 +280,7 @@ describe("getTimeseriesGranularity", () => { const oneWeekAgo = FIXED_NOW - DAY_IN_MS * 7; const result = getTimeseriesGranularity("forRegular", oneWeekAgo, FIXED_NOW); - expect(result.granularity).toBe("perDay"); + expect(result.granularity).toBe("per2Hours"); expect(result.startTime).toBe(oneWeekAgo); expect(result.endTime).toBe(FIXED_NOW); }); diff --git a/apps/dashboard/lib/trpc/routers/utils/granularity.ts b/apps/dashboard/lib/trpc/routers/utils/granularity.ts index c729003f84..347a5b8dd1 100644 --- a/apps/dashboard/lib/trpc/routers/utils/granularity.ts +++ b/apps/dashboard/lib/trpc/routers/utils/granularity.ts @@ -107,13 +107,13 @@ export const getTimeseriesGranularity = ( } } else { if (timeRange >= DAY_IN_MS * 7) { - granularity = "perDay"; + granularity = "per2Hours"; } else if (timeRange >= DAY_IN_MS * 3) { - granularity = "perHour"; - } else if (timeRange >= HOUR_IN_MS * 24) { granularity = "per30Minutes"; + } else if (timeRange >= HOUR_IN_MS * 24) { + granularity = "per15Minutes"; } else if (timeRange >= HOUR_IN_MS * 16) { - granularity = "per30Minutes"; + granularity = "per15Minutes"; } else if (timeRange >= HOUR_IN_MS * 12) { granularity = "per15Minutes"; } else if (timeRange >= HOUR_IN_MS * 8) { From 525669ee432a604e362738fadd359523c06ffa93 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 23 Sep 2025 13:31:00 +0300 Subject: [PATCH 19/27] fix: border-color --- .../components/logs/details/request-response-details.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/components/logs/details/request-response-details.tsx b/apps/dashboard/components/logs/details/request-response-details.tsx index c26270e53c..b96186ab3e 100644 --- a/apps/dashboard/components/logs/details/request-response-details.tsx +++ b/apps/dashboard/components/logs/details/request-response-details.tsx @@ -64,7 +64,7 @@ export const RequestResponseDetails = ({ fields, className // biome-ignore lint/a11y/useKeyWithClickEvents: no need
Date: Tue, 23 Sep 2025 16:15:26 +0300 Subject: [PATCH 20/27] fix: missing params --- internal/clickhouse/src/logs-timeseries.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/clickhouse/src/logs-timeseries.test.ts b/internal/clickhouse/src/logs-timeseries.test.ts index 15647aad3b..5056d6e2fb 100644 --- a/internal/clickhouse/src/logs-timeseries.test.ts +++ b/internal/clickhouse/src/logs-timeseries.test.ts @@ -70,6 +70,7 @@ describe.each([10, 100, 1_000, 10_000, 100_000])("with %i requests", (n) => { statusCodes: [], paths: [], hosts: [], + excludeHosts: [], methods: [], startTime: new Date(Date.now() - 24 * 60 * 60 * 1000).getTime(), // 24 hours ago endTime: Date.now(), @@ -81,6 +82,7 @@ describe.each([10, 100, 1_000, 10_000, 100_000])("with %i requests", (n) => { const hourly = await ch.api.timeseries.perHour({ workspaceId, statusCodes: [], + excludeHosts: [], paths: [], hosts: [], methods: [], @@ -96,6 +98,7 @@ describe.each([10, 100, 1_000, 10_000, 100_000])("with %i requests", (n) => { statusCodes: [], paths: [], hosts: [], + excludeHosts: [], methods: [], startTime: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).getTime(), // 30 days ago endTime: Date.now(), From 1f9c975ee51818343821a224c61b4037cc0b7a26 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 23 Sep 2025 16:20:44 +0300 Subject: [PATCH 21/27] fix: truncate long req and resp body --- .../gateway-logs/components/table/gateway-logs-table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx index 3ef43cdef1..3cc3cd0cfd 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx @@ -167,7 +167,7 @@ const columns: Column[] = [ header: "Response Body", width: "300px", render: (log) => ( -
+
{log.response_body}
), @@ -177,7 +177,7 @@ const columns: Column[] = [ header: "Request Body", width: "1fr", render: (log) => ( -
+
{log.request_body}
), From 941eedc5b5470a6b278c3aec8aa5a32e97a66b11 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 23 Sep 2025 17:24:27 +0300 Subject: [PATCH 22/27] fix: comments --- .../app/(app)/logs/components/table/log-details/index.tsx | 2 +- apps/dashboard/app/(app)/logs/utils.ts | 2 +- .../gateway-logs/components/table/gateway-log-details/index.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/app/(app)/logs/components/table/log-details/index.tsx b/apps/dashboard/app/(app)/logs/components/table/log-details/index.tsx index c4e314051f..40439b57e6 100644 --- a/apps/dashboard/app/(app)/logs/components/table/log-details/index.tsx +++ b/apps/dashboard/app/(app)/logs/components/table/log-details/index.tsx @@ -21,7 +21,7 @@ export const LogDetails = ({ distanceToTop }: Props) => { }; return ( - + diff --git a/apps/dashboard/app/(app)/logs/utils.ts b/apps/dashboard/app/(app)/logs/utils.ts index ec144758a9..ffec1e9825 100644 --- a/apps/dashboard/app/(app)/logs/utils.ts +++ b/apps/dashboard/app/(app)/logs/utils.ts @@ -67,6 +67,6 @@ export const safeParseJson = (jsonString?: string | null) => { return JSON.parse(jsonString); } catch { console.error("Cannot parse JSON:", jsonString); - return "Invalid JSON format"; + return jsonString; } }; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx index 269d76f76c..e75c2ed9ef 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx @@ -20,7 +20,7 @@ export const GatewayLogDetails = ({ distanceToTop }: Props) => { } return ( - + From 96408a4e0a3a9481eb740263f7c0db2ca131b74f Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 24 Sep 2025 17:21:31 +0300 Subject: [PATCH 23/27] fix: props --- .../components/promotion-dialog.tsx | 54 ++++++++++++++----- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx index 5ba3994961..e8d87c26d0 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx @@ -1,6 +1,10 @@ "use client"; -import { type Deployment, collection, collectionManager } from "@/lib/collections"; +import { + type Deployment, + collection, + collectionManager, +} from "@/lib/collections"; import { shortenId } from "@/lib/shorten-id"; import { trpc } from "@/lib/trpc/client"; import { eq, inArray, useLiveQuery } from "@tanstack/react-db"; @@ -15,38 +19,47 @@ type DeploymentSectionProps = { showSignal?: boolean; }; -const DeploymentSection = ({ title, deployment, isLive, showSignal }: DeploymentSectionProps) => ( +const DeploymentSection = ({ + title, + deployment, + isLive, + showSignal, +}: DeploymentSectionProps) => (

{title}

- +
); type PromotionDialogProps = { isOpen: boolean; - onOpenChange: (open: boolean) => void; + onClose: () => void; targetDeployment: Deployment; liveDeployment: Deployment; }; export const PromotionDialog = ({ isOpen, - onOpenChange, + onClose, targetDeployment, liveDeployment, }: PromotionDialogProps) => { const utils = trpc.useUtils(); const domainCollection = collectionManager.getProjectCollections( - liveDeployment.projectId, + liveDeployment.projectId ).domains; const domains = useLiveQuery((q) => q .from({ domain: domainCollection }) .where(({ domain }) => inArray(domain.sticky, ["environment", "live"])) - .where(({ domain }) => eq(domain.deploymentId, liveDeployment.id)), + .where(({ domain }) => eq(domain.deploymentId, liveDeployment.id)) ); const promote = trpc.deploy.deployment.promote.useMutation({ onSuccess: () => { @@ -66,7 +79,7 @@ export const PromotionDialog = ({ console.error("Refetch error:", error); } - onOpenChange(false); + onClose(); }, onError: (error) => { toast.error("Promotion failed", { @@ -88,7 +101,7 @@ export const PromotionDialog = ({ return (
-
{domain.domain}
+
+ {domain.domain} +
))}
- +
); @@ -140,7 +159,11 @@ type DeploymentCardProps = { showSignal?: boolean; }; -const DeploymentCard = ({ deployment, isLive, showSignal }: DeploymentCardProps) => ( +const DeploymentCard = ({ + deployment, + isLive, + showSignal, +}: DeploymentCardProps) => (
@@ -152,13 +175,16 @@ const DeploymentCard = ({ deployment, isLive, showSignal }: DeploymentCardProps) {isLive ? "Live" : deployment.status}
- {deployment.gitCommitMessage || `${isLive ? "Current active" : "Target"} deployment`} + {deployment.gitCommitMessage || + `${isLive ? "Current active" : "Target"} deployment`}
From 718c6821ec96851dae4cd02548e5dfa24330a1a0 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 24 Sep 2025 17:28:19 +0300 Subject: [PATCH 24/27] fix: conflict --- .../components/promotion-dialog.tsx | 46 ++++--------------- ...nt-list-table-action.popover.constants.tsx | 42 ++++++----------- 2 files changed, 23 insertions(+), 65 deletions(-) diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx index e8d87c26d0..c0a72084c6 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx @@ -1,10 +1,6 @@ "use client"; -import { - type Deployment, - collection, - collectionManager, -} from "@/lib/collections"; +import { type Deployment, collection, collectionManager } from "@/lib/collections"; import { shortenId } from "@/lib/shorten-id"; import { trpc } from "@/lib/trpc/client"; import { eq, inArray, useLiveQuery } from "@tanstack/react-db"; @@ -19,22 +15,13 @@ type DeploymentSectionProps = { showSignal?: boolean; }; -const DeploymentSection = ({ - title, - deployment, - isLive, - showSignal, -}: DeploymentSectionProps) => ( +const DeploymentSection = ({ title, deployment, isLive, showSignal }: DeploymentSectionProps) => (

{title}

- +
); @@ -53,13 +40,13 @@ export const PromotionDialog = ({ }: PromotionDialogProps) => { const utils = trpc.useUtils(); const domainCollection = collectionManager.getProjectCollections( - liveDeployment.projectId + liveDeployment.projectId, ).domains; const domains = useLiveQuery((q) => q .from({ domain: domainCollection }) .where(({ domain }) => inArray(domain.sticky, ["environment", "live"])) - .where(({ domain }) => eq(domain.deploymentId, liveDeployment.id)) + .where(({ domain }) => eq(domain.deploymentId, liveDeployment.id)), ); const promote = trpc.deploy.deployment.promote.useMutation({ onSuccess: () => { @@ -135,19 +122,13 @@ export const PromotionDialog = ({ >
-
- {domain.domain} -
+
{domain.domain}
))}
- +
); @@ -159,11 +140,7 @@ type DeploymentCardProps = { showSignal?: boolean; }; -const DeploymentCard = ({ - deployment, - isLive, - showSignal, -}: DeploymentCardProps) => ( +const DeploymentCard = ({ deployment, isLive, showSignal }: DeploymentCardProps) => (
@@ -175,16 +152,13 @@ const DeploymentCard = ({ {isLive ? "Live" : deployment.status}
- {deployment.gitCommitMessage || - `${isLive ? "Current active" : "Target"} deployment`} + {deployment.gitCommitMessage || `${isLive ? "Current active" : "Target"} deployment`}
diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx index b6fd224066..eb575ed767 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx @@ -1,16 +1,9 @@ "use client"; import { useProjectLayout } from "@/app/(app)/projects/[projectId]/layout-provider"; -import { - type MenuItem, - TableActionPopover, -} from "@/components/logs/table-action.popover"; +import { type MenuItem, TableActionPopover } from "@/components/logs/table-action.popover"; import type { Deployment, Environment } from "@/lib/collections"; import { eq, useLiveQuery } from "@tanstack/react-db"; -import { - ArrowDottedRotateAnticlockwise, - ChevronUp, - Layers3, -} from "@unkey/icons"; +import { ArrowDottedRotateAnticlockwise, ChevronUp, Layers3 } from "@unkey/icons"; import { useRouter } from "next/navigation"; import { useMemo } from "react"; import { PromotionDialog } from "../../../promotion-dialog"; @@ -32,35 +25,26 @@ export const DeploymentListTableActions = ({ q .from({ domain: collections.domains }) .where(({ domain }) => eq(domain.deploymentId, selectedDeployment.id)) - .select(({ domain }) => ({ host: domain.domain })) + .select(({ domain }) => ({ host: domain.domain })), ); const router = useRouter(); // biome-ignore lint/correctness/useExhaustiveDependencies: its okay const menuItems = useMemo((): MenuItem[] => { - // Rollback is only enabled when: - // Selected deployment is not the current live deployment - // Selected deployment status is "ready" - // Environment is production, when testing locally if you don't use `--prod=env` flag rollback won't work. - const isCurrentlyLive = liveDeployment?.id === selectedDeployment.id; - const isDeploymentReady = selectedDeployment.status === "ready"; - const isProductionEnv = environment?.slug === "production"; - - const canRollback = - !isCurrentlyLive && isDeploymentReady && isProductionEnv; - - // This logic is slightly flawed as it does not allow you to promote a deployment that - // is currently live due to a rollback. - const canPromote = isProductionEnv && isDeploymentReady && isCurrentlyLive; + const canRollbackAndRollback = + liveDeployment && + environment?.slug === "production" && + selectedDeployment.status === "ready" && + selectedDeployment.id !== liveDeployment.id; return [ { id: "rollback", label: "Rollback", icon: , - disabled: !canRollback, + disabled: !canRollbackAndRollback, ActionComponent: - liveDeployment && canRollback + liveDeployment && canRollbackAndRollback ? (props) => ( , - disabled: !canPromote, + disabled: !canRollbackAndRollback, ActionComponent: - liveDeployment && canPromote + liveDeployment && canRollbackAndRollback ? (props) => ( `is:${item.host}`) - .join(",")}` + .join(",")}`, ); }, }, From 7ef9430db91fce7bc9d325d982034ed9d671a7d2 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 24 Sep 2025 17:31:06 +0300 Subject: [PATCH 25/27] fix: small ui issue --- .../[projectId]/deployments/components/promotion-dialog.tsx | 2 +- .../[projectId]/deployments/components/rollback-dialog.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx index c0a72084c6..61762cb45b 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx @@ -118,7 +118,7 @@ export const PromotionDialog = ({ {domains.data.map((domain) => (
diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx index 968c86e784..d83f51f859 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx @@ -117,7 +117,7 @@ export const RollbackDialog = ({ {domains.data.map((domain) => (
From 989197ba562ccdc7f7a66c023166474e3985bea1 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 24 Sep 2025 17:34:02 +0300 Subject: [PATCH 26/27] fix: make rollback and promote consistent --- .../components/promotion-dialog.tsx | 19 +++++++++++-------- .../components/rollback-dialog.tsx | 19 +++++++++++-------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx index 61762cb45b..f2a3820abd 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx @@ -116,14 +116,17 @@ export const PromotionDialog = ({ />
{domains.data.map((domain) => ( -
-
- -
{domain.domain}
-
+
+
+

Domain

+ +
+
+
+ +
{domain.domain}
+
+
))} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx index d83f51f859..47dec684fd 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx @@ -115,14 +115,17 @@ export const RollbackDialog = ({ />
{domains.data.map((domain) => ( -
-
- -
{domain.domain}
-
+
+
+

Domain

+ +
+
+
+ +
{domain.domain}
+
+
))} From 453049a5e33442ea1e213949f40ac72fe6addc7f Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 25 Sep 2025 12:28:25 +0300 Subject: [PATCH 27/27] fix: remove all animated props --- .../_overview/components/table/components/log-details/index.tsx | 2 +- .../[keyId]/components/table/components/log-details/index.tsx | 2 +- .../[namespaceId]/logs/components/table/log-details/index.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/log-details/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/log-details/index.tsx index f4934e4d5b..c013a7ec0a 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/log-details/index.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/log-details/index.tsx @@ -101,7 +101,7 @@ export const KeysOverviewLogDetails = ({ distanceToTop, log, setSelectedLog, api ].filter(Boolean); return ( - + diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/components/log-details/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/components/log-details/index.tsx index 7100105ee8..5cb4d9dd7a 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/components/log-details/index.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/components/log-details/index.tsx @@ -57,7 +57,7 @@ export const KeyDetailsDrawer = ({ distanceToTop, onLogSelect, selectedLog }: Pr } return ( - + diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/index.tsx index 83709c7199..d0a82ec79e 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/index.tsx @@ -21,7 +21,7 @@ export const RatelimitLogDetails = ({ distanceToTop }: Props) => { }; return ( - +