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..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 @@ -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..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 @@ -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)/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/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/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/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/index.tsx b/apps/dashboard/app/(app)/logs/components/table/log-details/index.tsx index 0905951937..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 @@ -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/components/table/query-logs.schema.ts b/apps/dashboard/app/(app)/logs/components/table/query-logs.schema.ts deleted file mode 100644 index feadf1eed1..0000000000 --- a/apps/dashboard/app/(app)/logs/components/table/query-logs.schema.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { z } from "zod"; -import { logsFilterOperatorEnum } from "../../filters.schema"; - -export const queryLogsPayload = z.object({ - limit: z.number().int(), - startTime: z.number().int(), - endTime: z.number().int(), - since: z.string(), - path: z - .object({ - filters: z.array( - z.object({ - operator: logsFilterOperatorEnum, - value: z.string(), - }), - ), - }) - .nullable(), - host: z - .object({ - filters: z.array( - z.object({ - operator: z.literal("is"), - value: z.string(), - }), - ), - }) - .nullable(), - method: z - .object({ - filters: z.array( - z.object({ - operator: z.literal("is"), - value: z.string(), - }), - ), - }) - .nullable(), - requestId: z - .object({ - filters: z.array( - z.object({ - operator: z.literal("is"), - value: z.string(), - }), - ), - }) - .nullable(), - status: z - .object({ - filters: z.array( - z.object({ - operator: z.literal("is"), - value: z.number(), - }), - ), - }) - .nullable(), - cursor: z.number().nullable().optional().nullable(), -}); diff --git a/apps/dashboard/app/(app)/logs/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)/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)/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)/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]/deployments/components/promotion-dialog.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx index 5ba3994961..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 @@ -27,14 +27,14 @@ const DeploymentSection = ({ title, deployment, isLive, showSignal }: Deployment type PromotionDialogProps = { isOpen: boolean; - onOpenChange: (open: boolean) => void; + onClose: () => void; targetDeployment: Deployment; liveDeployment: Deployment; }; export const PromotionDialog = ({ isOpen, - onOpenChange, + onClose, targetDeployment, liveDeployment, }: PromotionDialogProps) => { @@ -66,7 +66,7 @@ export const PromotionDialog = ({ console.error("Refetch error:", error); } - onOpenChange(false); + onClose(); }, onError: (error) => { toast.error("Promotion failed", { @@ -88,7 +88,7 @@ export const PromotionDialog = ({ return (
{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 e3ddefd156..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 @@ -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 { 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,26 +28,28 @@ 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"])), ); + const rollback = trpc.deploy.deployment.rollback.useMutation({ onSuccess: () => { utils.invalidate(); @@ -65,7 +68,7 @@ export const RollbackDialog = ({ console.error("Refetch error:", error); } - onOpenChange(false); + onClose(); }, onError: (error) => { toast.error("Rollback failed", { @@ -87,7 +90,7 @@ export const RollbackDialog = ({ return (
{domains.data.map((domain) => ( -
-
- -
{domain.domain}
-
+
+
+

Domain

+ +
+
+
+ +
{domain.domain}
+
+
))} 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 50e41d16c4..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,8 +1,11 @@ "use client"; -import { TableActionPopover } from "@/components/logs/table-action.popover"; +import { useProjectLayout } from "@/app/(app)/projects/[projectId]/layout-provider"; +import { type MenuItem, TableActionPopover } from "@/components/logs/table-action.popover"; import type { Deployment, Environment } from "@/lib/collections"; -import { ArrowDottedRotateAnticlockwise, ChevronUp } from "@unkey/icons"; -import { useState } from "react"; +import { eq, useLiveQuery } from "@tanstack/react-db"; +import { ArrowDottedRotateAnticlockwise, ChevronUp, Layers3 } from "@unkey/icons"; +import { useRouter } from "next/navigation"; +import { useMemo } from "react"; import { PromotionDialog } from "../../../promotion-dialog"; import { RollbackDialog } from "../../../rollback-dialog"; @@ -17,68 +20,78 @@ export const DeploymentListTableActions = ({ selectedDeployment, environment, }: DeploymentListTableActionsProps) => { - const [isRollbackModalOpen, setIsRollbackModalOpen] = useState(false); - const [isPromotionModalOpen, setIsPromotionModalOpen] = useState(false); + const { collections } = useProjectLayout(); + const { data } = useLiveQuery((q) => + q + .from({ domain: collections.domains }) + .where(({ domain }) => eq(domain.deploymentId, selectedDeployment.id)) + .select(({ domain }) => ({ host: domain.domain })), + ); - const canRollback = - liveDeployment && - environment?.slug === "production" && - selectedDeployment.status === "ready" && - selectedDeployment.id !== liveDeployment.id; + const router = useRouter(); + // biome-ignore lint/correctness/useExhaustiveDependencies: its okay + const menuItems = useMemo((): MenuItem[] => { + const canRollbackAndRollback = + liveDeployment && + environment?.slug === "production" && + selectedDeployment.status === "ready" && + selectedDeployment.id !== liveDeployment.id; - // TODO - // 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 = - liveDeployment && - environment?.slug === "production" && - selectedDeployment.status === "ready" && - selectedDeployment.id !== liveDeployment.id; + return [ + { + id: "rollback", + label: "Rollback", + icon: , + disabled: !canRollbackAndRollback, + ActionComponent: + liveDeployment && canRollbackAndRollback + ? (props) => ( + + ) + : undefined, + }, + { + id: "Promote", + label: "Promote", + icon: , + disabled: !canRollbackAndRollback, + ActionComponent: + liveDeployment && canRollbackAndRollback + ? (props) => ( + + ) + : undefined, + }, - return ( - <> - , - disabled: !canRollback, - onClick: () => { - if (canRollback) { - setIsRollbackModalOpen(true); - } - }, - }, - { - id: "Promote", - label: "Promote", - icon: , - disabled: !canPromote, - onClick: () => { - if (canPromote) { - setIsPromotionModalOpen(true); - } - }, - }, - ]} - /> - {liveDeployment && selectedDeployment && ( - - )} - {liveDeployment && selectedDeployment && ( - - )} - - ); + { + 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/components/env-status-badge.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/env-status-badge.tsx index 77d8697b80..0a8288c598 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/app/(app)/projects/[projectId]/deployments/components/table/deployments-list.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/deployments-list.tsx index ca4f71f44e..f4534cad6a 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 @@ -49,9 +49,12 @@ export const DeploymentsList = () => { const { liveDeployment, deployments, project } = useDeployments(); + const selectedDeploymentId = selectedDeployment?.deployment.id; + const columns: Column<{ deployment: Deployment; environment?: Environment; + // biome-ignore lint/correctness/useExhaustiveDependencies: its okay }>[] = useMemo(() => { return [ { @@ -302,16 +305,18 @@ export const DeploymentsList = () => { environment?: Environment; }) => { return ( - +
+ +
); }, }, ]; - }, [selectedDeployment?.deployment.id, isCompactView, liveDeployment, project]); + }, [selectedDeploymentId, isCompactView, liveDeployment, project]); return ( { + const { filters } = useGatewayLogsFilters(); + const { queryTime: timestamp } = useQueryTime(); + + const queryParams = useMemo(() => { + const params: TimeseriesRequestSchema = { + startTime: timestamp - HISTORICAL_DATA_WINDOW, + endTime: timestamp, + host: { filters: [], exclude: EXCLUDED_HOSTS }, + 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]/gateway-logs/components/charts/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/charts/index.tsx new file mode 100644 index 0000000000..5706eefd03 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-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 { useGatewayLogsFilters } from "../../hooks/use-gateway-logs-filters"; +import { useGatewayLogsTimeseries } from "./hooks/use-gateway-logs-timeseries"; + +export function GatewayLogsChart({ + onMount, +}: { + onMount: (distanceToTop: number) => void; +}) { + const { filters, updateFilters } = useGatewayLogsFilters(); + const { timeseries, isLoading, isError, granularity } = useGatewayLogsTimeseries(); + + 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]/gateway-logs/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/control-cloud/index.tsx new file mode 100644 index 0000000000..a742d19510 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/control-cloud/index.tsx @@ -0,0 +1,57 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import { ControlCloud } from "@unkey/ui"; +import { format } from "date-fns"; +import { useGatewayLogsFilters } from "../../hooks/use-gateway-logs-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 "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 GatewayLogsControlCloud = () => { + const { filters, updateFilters, removeFilter } = useGatewayLogsFilters(); + return ( + + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-datetime/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-datetime/index.tsx new file mode 100644 index 0000000000..4fc8b0e888 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-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 { useGatewayLogsFilters } from "../../../../hooks/use-gateway-logs-filters"; + +export const GatewayLogsDateTime = () => { + const [title, setTitle] = useState(null); + const { filters, updateFilters } = useGatewayLogsFilters(); + + 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]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-methods-filter.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-methods-filter.tsx new file mode 100644 index 0000000000..f6b3265bfa --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-methods-filter.tsx @@ -0,0 +1,35 @@ +import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; +import { useGatewayLogsFilters } from "../../../../../hooks/use-gateway-logs-filters"; + +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 GatewayMethodsFilter = () => { + const { filters, updateFilters } = useGatewayLogsFilters(); + return ( + ( +
{checkbox.method}
+ )} + createFilterValue={(option) => ({ + value: option.method, + })} + filters={filters} + updateFilters={updateFilters} + /> + ); +}; 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 new file mode 100644 index 0000000000..a2103f95d4 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-paths-filter.tsx @@ -0,0 +1,36 @@ +import { FilterOperatorInput } from "@/components/logs/filter-operator-input"; +import { logsFilterFieldConfig as gatewayLogsFilterFieldConfig } from "@/lib/schemas/logs.filter.schema"; +import { useGatewayLogsFilters } from "../../../../../hooks/use-gateway-logs-filters"; + +export const GatewayPathsFilter = () => { + const { filters, updateFilters } = useGatewayLogsFilters(); + + const pathOperators = gatewayLogsFilterFieldConfig.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]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-status-filter.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-status-filter.tsx new file mode 100644 index 0000000000..3c1086af9f --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/components/gateway-logs-status-filter.tsx @@ -0,0 +1,70 @@ +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; + 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 GatewayStatusFilter = () => { + const { filters, updateFilters } = useGatewayLogsFilters(); + 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]/gateway-logs/components/controls/components/gateway-logs-filters/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/index.tsx new file mode 100644 index 0000000000..ec96065f00 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-filters/index.tsx @@ -0,0 +1,61 @@ +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 { useGatewayLogsFilters } from "../../../../hooks/use-gateway-logs-filters"; +import { GatewayMethodsFilter } from "./components/gateway-logs-methods-filter"; +import { GatewayPathsFilter } from "./components/gateway-logs-paths-filter"; +import { GatewayStatusFilter } from "./components/gateway-logs-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 GatewayLogsFilters = () => { + const { filters } = useGatewayLogsFilters(); + return ( + +
+ +
+
+ ); +}; 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 new file mode 100644 index 0000000000..8d0e61b9cc --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-live-switch.tsx @@ -0,0 +1,16 @@ +import { LiveSwitchButton } from "@/components/logs/live-switch-button"; +import { useQueryTime } from "@/providers/query-time-provider"; +import { useGatewayLogsContext } from "../../../context/gateway-logs-provider"; + +export const GatewayLogsLiveSwitch = () => { + const { toggleLive, isLive } = useGatewayLogsContext(); + const { refreshQueryTime } = useQueryTime(); + + const handleSwitch = () => { + toggleLive(); + if (isLive) { + refreshQueryTime(); + } + }; + return ; +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-refresh.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-refresh.tsx new file mode 100644 index 0000000000..b601e0455e --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-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 { useGatewayLogsContext } from "../../../context/gateway-logs-provider"; + +export const GatewayLogsRefresh = () => { + const { toggleLive, isLive } = useGatewayLogsContext(); + 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]/gateway-logs/components/controls/components/gateway-logs-search/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-search/index.tsx new file mode 100644 index 0000000000..262cef49c5 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/controls/components/gateway-logs-search/index.tsx @@ -0,0 +1,58 @@ +import { trpc } from "@/lib/trpc/client"; +import { LLMSearch, toast, transformStructuredOutputToFilters } from "@unkey/ui"; +import { useGatewayLogsFilters } from "../../../../hooks/use-gateway-logs-filters"; + +export const GatewayLogsSearch = () => { + const { filters, updateFilters } = useGatewayLogsFilters(); + 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]/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]/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 new file mode 100644 index 0000000000..e75c2ed9ef --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-log-details/index.tsx @@ -0,0 +1,31 @@ +"use client"; +import { LogDetails } from "@/components/logs/details/log-details"; +import { useGatewayLogsContext } from "../../../context/gateway-logs-provider"; + +const ANIMATION_DELAY = 350; + +type Props = { + distanceToTop: number; +}; + +export const GatewayLogDetails = ({ distanceToTop }: Props) => { + const { setSelectedLog, selectedLog: log } = useGatewayLogsContext(); + + const handleClose = () => { + setSelectedLog(null); + }; + + if (!log) { + return null; + } + + return ( + + + + + + + + ); +}; 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 new file mode 100644 index 0000000000..3cc3cd0cfd --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/gateway-logs-table.tsx @@ -0,0 +1,185 @@ +"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 { 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"; + +export const GatewayLogsTable = () => { + const { setSelectedLog, selectedLog, isLive } = useGatewayLogsContext(); + const { realtimeLogs, historicalLogs, isLoading, isLoadingMore, loadMore, hasMore, total } = + useGatewayLogsQuery({ + startPolling: isLive, + pollIntervalMs: 2000, + }); + + 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 }) => ( + = 400 && status < 500 && WARNING_ICON_STYLES.warning, + status >= 500 && WARNING_ICON_STYLES.error, + )} + /> +); + +const columns: Column[] = [ + { + key: "time", + header: "Time", + width: "180px", + headerClassName: "pl-8", + render: (log) => ( +
+ +
+ +
+
+ ), + }, + { + key: "response_status", + header: "Status", + width: "120px", + render: (log) => { + const style = getStatusStyle(log.response_status); + return ( + + {log.response_status}{" "} + {extractResponseField(log, "code") ? `| ${extractResponseField(log, "code")}` : ""} + + ); + }, + }, + { + key: "host", + header: "Hostname", + width: "200px", + render: (log) => ( +
+ {log.host} +
+ ), + }, + { + key: "method", + header: "Method", + width: "80px", + render: (log) => ( + + {log.method} + + ), + }, + { + key: "path", + header: "Path", + width: "250px", + render: (log) => ( +
+ {log.path} +
+ ), + }, + { + key: "response_body", + header: "Response Body", + width: "300px", + render: (log) => ( +
+ {log.response_body} +
+ ), + }, + { + key: "request_body", + header: "Request Body", + width: "1fr", + render: (log) => ( +
+ {log.request_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 new file mode 100644 index 0000000000..336ca822b6 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/components/table/hooks/use-gateway-logs-query.ts @@ -0,0 +1,248 @@ +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 { EXCLUDED_HOSTS } from "../../../constants"; +import { useGatewayLogsFilters } from "../../../hooks/use-gateway-logs-filters"; + +// Constants +const REALTIME_DATA_LIMIT = 100; + +// Types +type UseGatewayLogsQueryParams = { + limit?: number; + pollIntervalMs?: number; + startPolling?: boolean; +}; + +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 useGatewayLogsQuery({ + limit = 50, + pollIntervalMs = 5000, + startPolling = false, +}: UseGatewayLogsQueryParams = {}) { + const [historicalLogsMap, setHistoricalLogsMap] = useState(() => new Map()); + const [realtimeLogsMap, setRealtimeLogsMap] = useState(() => new Map()); + const [totalCount, setTotalCount] = useState(0); + + const { filters } = useGatewayLogsFilters(); + 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]); + + // "memo" required for preventing double trpc call during initial render + const queryParams = useMemo(() => { + const params: LogsRequestSchema = { + limit, + startTime: timestamp - HISTORICAL_DATA_WINDOW, + endTime: timestamp, + host: { filters: [], exclude: EXCLUDED_HOSTS }, + requestId: { 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": + 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; + } + + 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, 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]/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/app/(app)/projects/[projectId]/gateway-logs/constants.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/constants.ts new file mode 100644 index 0000000000..5cc1866351 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/constants.ts @@ -0,0 +1,8 @@ +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; + +// 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/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/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..7d340b3e10 --- /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 { + 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"; + +// 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]/gateway-logs/types.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/types.ts new file mode 100644 index 0000000000..be5879b57c --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/types.ts @@ -0,0 +1 @@ +export type ResponseStatus = 200 | 400 | 500; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/utils.ts b/apps/dashboard/app/(app)/projects/[projectId]/gateway-logs/utils.ts new file mode 100644 index 0000000000..ec144758a9 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/gateway-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]/logs/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/page.tsx deleted file mode 100644 index 60558a57b4..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/logs/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -"use client"; - -export default function ProjectLogs() { - return
Overview
; -} 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..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 @@ -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,10 +65,10 @@ export const ProjectSubNavigation = ({ path: `/projects/${projectId}/deployments`, }, { - id: "logs", - label: "Logs", + id: "gateway-logs", + label: "Gateway Logs", icon: Layers3, - path: `/projects/${projectId}/logs`, + path: `/projects/${projectId}/gateway-logs`, }, ]; 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-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..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 @@ -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; 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({ { return ( { void; }; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-meta.tsx b/apps/dashboard/components/logs/details/log-details/components/log-meta.tsx similarity index 83% rename from apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-meta.tsx rename to apps/dashboard/components/logs/details/log-details/components/log-meta.tsx index b7a60b3bef..3361a1e0a3 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-meta.tsx +++ b/apps/dashboard/components/logs/details/log-details/components/log-meta.tsx @@ -2,8 +2,8 @@ import { Card, CardContent, CopyButton } from "@unkey/ui"; export const LogMetaSection = ({ content }: { content: string }) => { return ( -
-
Meta
+
+
Meta
{content ?? ""} 
diff --git a/apps/dashboard/app/(app)/logs/components/table/log-details/components/log-section.tsx b/apps/dashboard/components/logs/details/log-details/components/log-section.tsx similarity index 95% rename from apps/dashboard/app/(app)/logs/components/table/log-details/components/log-section.tsx rename to apps/dashboard/components/logs/details/log-details/components/log-section.tsx index 276934956d..71a89eb7a8 100644 --- a/apps/dashboard/app/(app)/logs/components/table/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/log-details/index.tsx b/apps/dashboard/components/logs/details/log-details/index.tsx new file mode 100644 index 0000000000..309b69bf7f --- /dev/null +++ b/apps/dashboard/components/logs/details/log-details/index.tsx @@ -0,0 +1,331 @@ +"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 { 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"; +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 StandardLogTypes = Log | RatelimitLog; +export type SupportedLogTypes = StandardLogTypes | KeysOverviewLog; + +type LogDetailsContextValue = { + animated: boolean; + isOpen: boolean; + log: SupportedLogTypes; +}; + +const LogDetailsContext = createContext({ + animated: false, + isOpen: true, + log: {} as SupportedLogTypes, +}); + +const useLogDetailsContext = () => useContext(LogDetailsContext); + +// Helper functions for standard logs +const createLogSections = (log: Log | RatelimitLog) => [ + { + 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) => { + // 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 +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 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 (only works for standard logs) +const Sections = ({ + startDelay = 150, + staggerDelay = 50, +}: { + startDelay?: number; + 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 ( + <> + {sections.map((section, index) => ( +
+ +
+ ))} + + ); +}; + +// 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(); + + return ( +
+ ); +}; + +// Meta section +const Meta = ({ delay = 400 }: { delay?: number }) => { + const { log } = useLogDetailsContext(); + const content = createMetaContent(log); + + return ( +
+ +
+ ); +}; + +// 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; + translateX?: "translate-x-6" | "translate-x-8"; + onClose?: () => void; + children?: ReactNode; +}) => { + const { log } = useLogDetailsContext(); + + return ( +
+ {children || + (onClose && + (isStandardLog(log) ? ( + + ) : null))} +
+ ); +}; + +// 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)} +
+ ); +}; + +// Compound components +LogDetails.Section = Section; +LogDetails.Sections = Sections; +LogDetails.CustomSections = CustomSections; +LogDetails.Spacer = Spacer; +LogDetails.Meta = Meta; +LogDetails.Header = Header; +LogDetails.Footer = Footer; +LogDetails.useContext = useLogDetailsContext; +LogDetails.createMetaContent = createMetaContent; diff --git a/apps/dashboard/components/logs/details/request-response-details.tsx b/apps/dashboard/components/logs/details/request-response-details.tsx index 0f81882c74..b96186ab3e 100644 --- a/apps/dashboard/components/logs/details/request-response-details.tsx +++ b/apps/dashboard/components/logs/details/request-response-details.tsx @@ -64,13 +64,13 @@ export const RequestResponseDetails = ({ fields, className // biome-ignore lint/a11y/useKeyWithClickEvents: no need
handleClick(field)} > - {field.label} + {field.label} {field.description(field.content as NonNullable)} 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/overview-charts/overview-bar-chart.tsx b/apps/dashboard/components/logs/overview-charts/overview-bar-chart.tsx index 3ff7862a6d..84779e7b44 100644 --- a/apps/dashboard/components/logs/overview-charts/overview-bar-chart.tsx +++ b/apps/dashboard/components/logs/overview-charts/overview-bar-chart.tsx @@ -171,6 +171,7 @@ export function OverviewBarChart({ - //@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/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 new file mode 100644 index 0000000000..4f3b432cf2 --- /dev/null +++ b/apps/dashboard/lib/schemas/logs.schema.ts @@ -0,0 +1,126 @@ +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({ + 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/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"; 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.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/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/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 aa24ec0788..347a5b8dd1 100644 --- a/apps/dashboard/lib/trpc/routers/utils/granularity.ts +++ b/apps/dashboard/lib/trpc/routers/utils/granularity.ts @@ -107,23 +107,23 @@ export const getTimeseriesGranularity = ( } } else { if (timeRange >= DAY_IN_MS * 7) { - granularity = "perDay"; + granularity = "per2Hours"; } else if (timeRange >= DAY_IN_MS * 3) { - granularity = "per6Hours"; + granularity = "per30Minutes"; } else if (timeRange >= HOUR_IN_MS * 24) { - granularity = "per4Hours"; + granularity = "per15Minutes"; } else if (timeRange >= HOUR_IN_MS * 16) { - granularity = "per2Hours"; + granularity = "per15Minutes"; } 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) { granularity = "per5Minutes"; } else if (timeRange >= HOUR_IN_MS * 2) { - granularity = "per5Minutes"; + granularity = "perMinute"; } else { granularity = "perMinute"; } 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(), diff --git a/internal/clickhouse/src/logs.ts b/internal/clickhouse/src/logs.ts index 0ad25bb81e..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(), }); @@ -210,47 +219,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, }, @@ -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