diff --git a/apps/dashboard/app/(app)/apis/[apiId]/actions.ts b/apps/dashboard/app/(app)/apis/[apiId]/actions.ts new file mode 100644 index 0000000000..d1ecd90355 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/actions.ts @@ -0,0 +1,66 @@ +import { getOrgId } from "@/lib/auth"; +import { and, db, eq, isNull } from "@/lib/db"; +import { apis } from "@unkey/db/src/schema"; +import { notFound } from "next/navigation"; + +export type ApiLayoutData = { + currentApi: { + id: string; + name: string; + workspaceId: string; + keyAuthId: string | null; + }; + workspaceApis: { + id: string; + name: string; + }[]; +}; + +export const fetchApiAndWorkspaceDataFromDb = async (apiId: string): Promise => { + const orgId = await getOrgId(); + if (!apiId || !orgId) { + console.error("fetchApiLayoutDataFromDb: apiId or orgId is missing"); + notFound(); + } + + const currentApi = await db.query.apis.findFirst({ + where: (table, { and, eq, isNull }) => and(eq(table.id, apiId), isNull(table.deletedAtM)), + with: { + workspace: { + columns: { + id: true, + orgId: true, + }, + }, + }, + columns: { + id: true, + name: true, + workspaceId: true, + keyAuthId: true, + }, + }); + + if (!currentApi || currentApi.workspace.orgId !== orgId) { + console.warn(`DB Validation failed: API ${apiId} not found or org mismatch for org ${orgId}`); + notFound(); + } + + const workspaceId = currentApi.workspaceId; + + const workspaceApis = await db + .select({ id: apis.id, name: apis.name }) + .from(apis) + .where(and(eq(apis.workspaceId, workspaceId), isNull(apis.deletedAtM))) + .orderBy(apis.name); + + return { + currentApi: { + id: currentApi.id, + name: currentApi.name, + workspaceId: currentApi.workspaceId, + keyAuthId: currentApi.keyAuthId, + }, + workspaceApis, + }; +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/api-id-navbar.tsx b/apps/dashboard/app/(app)/apis/[apiId]/api-id-navbar.tsx index c6fed0b1c4..129eb502f6 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/api-id-navbar.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/api-id-navbar.tsx @@ -11,6 +11,7 @@ export const ApisNavbar = ({ api, apis, activePage, + keyId, }: { api: { id: string; @@ -25,6 +26,7 @@ export const ApisNavbar = ({ href: string; text: string; }; + keyId?: string; }) => { return ( <> @@ -61,6 +63,15 @@ export const ApisNavbar = ({ label: "Settings", href: `/apis/${api.id}/settings`, }, + ...(keyId + ? [ + { + id: "settings", + label: `${keyId.substring(0, 8)}...${keyId.substring(keyId.length - 4)}`, + href: `/apis/${api.id}/keys/${api.keyAuthId}/${keyId}`, + }, + ] + : []), ]} shortcutKey="M" > diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx index cc24930e64..0fdf0d9568 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx @@ -16,8 +16,9 @@ import { Minus } from "lucide-react"; import ms from "ms"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { fetchApiAndWorkspaceDataFromDb } from "../../../actions"; +import { ApisNavbar } from "../../../api-id-navbar"; import { RBACButtons } from "./_components/rbac-buttons"; -import { Navigation } from "./navigation"; import { PermissionList } from "./permission-list"; import { VerificationTable } from "./verification-table"; @@ -72,19 +73,12 @@ export default async function APIKeyDetailPage(props: { return notFound(); } - const api = await db.query.apis.findFirst({ - where: (table, { eq, and, isNull }) => - and(eq(table.keyAuthId, key.keyAuthId), isNull(table.deletedAtM)), - }); - if (!api) { - return notFound(); - } - + const { currentApi, workspaceApis } = await fetchApiAndWorkspaceDataFromDb(props.params.apiId); const interval = props.searchParams.interval ?? "7d"; const { getVerificationsPerInterval, start, end, granularity } = prepareInterval(interval); const query = { - workspaceId: api.workspaceId, + workspaceId: currentApi.workspaceId, keySpaceId: key.keyAuthId, keyId: key.id, start, @@ -96,17 +90,18 @@ export default async function APIKeyDetailPage(props: { workspaceId: key.workspaceId, keySpaceId: key.keyAuthId, keyId: key.id, + limit: 50, }), clickhouse.verifications .latest({ workspaceId: key.workspaceId, keySpaceId: key.keyAuthId, keyId: key.id, + limit: 1, }) .then((res) => res.val?.at(0)?.time ?? 0), ]); - // Sort all verifications by time first const sortedVerifications = verifications.val!.sort((a, b) => a.time - b.time); const successOverTime: { x: string; y: number }[] = []; @@ -117,10 +112,8 @@ export default async function APIKeyDetailPage(props: { const expiredOverTime: { x: string; y: number }[] = []; const forbiddenOverTime: { x: string; y: number }[] = []; - // Get all unique timestamps const uniqueDates = [...new Set(sortedVerifications.map((d) => d.time))].sort((a, b) => a - b); - // Ensure each array has entries for all timestamps with zero counts for (const timestamp of uniqueDates) { const x = new Date(timestamp).toISOString(); successOverTime.push({ x, y: 0 }); @@ -235,7 +228,15 @@ export default async function APIKeyDetailPage(props: { return (
- +
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/control-cloud/index.tsx new file mode 100644 index 0000000000..baaf8b3717 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/control-cloud/index.tsx @@ -0,0 +1,29 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import { ControlCloud } from "@/components/logs/control-cloud"; +import { useFilters } from "../../hooks/use-filters"; + +const formatFieldName = (field: string): string => { + switch (field) { + case "names": + return "Name"; + case "identities": + return "Identity"; + case "keyIds": + return "Key ID"; + default: + return field.charAt(0).toUpperCase() + field.slice(1); + } +}; + +export const KeysListControlCloud = () => { + const { filters, updateFilters, removeFilter } = useFilters(); + return ( + + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/controls/components/logs-filters/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/controls/components/logs-filters/index.tsx new file mode 100644 index 0000000000..f495aa0aa4 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/controls/components/logs-filters/index.tsx @@ -0,0 +1,128 @@ +import { FiltersPopover } from "@/components/logs/checkbox/filters-popover"; + +import { FilterOperatorInput } from "@/components/logs/filter-operator-input"; +import { BarsFilter } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { keysListFilterFieldConfig } from "../../../../filters.schema"; +import { useFilters } from "../../../../hooks/use-filters"; + +export const LogsFilters = () => { + const { filters, updateFilters } = useFilters(); + + const options = keysListFilterFieldConfig.names.operators.map((op) => ({ + id: op, + label: op, + })); + const activeNameFilter = filters.find((f) => f.field === "names"); + const activeIdentityFilter = filters.find((f) => f.field === "identities"); + const activeKeyIdsFilter = filters.find((f) => f.field === "keyIds"); + const keyIdOptions = keysListFilterFieldConfig.names.operators.map((op) => ({ + id: op, + label: op, + })); + return ( + { + const activeFiltersWithoutNames = filters.filter((f) => f.field !== "names"); + updateFilters([ + ...activeFiltersWithoutNames, + { + field: "names", + id: crypto.randomUUID(), + operator: id, + value: text, + }, + ]); + }} + /> + ), + }, + { + id: "identities", + label: "Identity", + shortcut: "i", + component: ( + { + const activeFiltersWithoutNames = filters.filter((f) => f.field !== "identities"); + updateFilters([ + ...activeFiltersWithoutNames, + { + field: "identities", + id: crypto.randomUUID(), + operator: id, + value: text, + }, + ]); + }} + /> + ), + }, + { + id: "keyids", + label: "Key ID", + shortcut: "k", + component: ( + { + const activeFiltersWithoutKeyIds = filters.filter((f) => f.field !== "keyIds"); + updateFilters([ + ...activeFiltersWithoutKeyIds, + { + field: "keyIds", + id: crypto.randomUUID(), + operator: id, + value: text, + }, + ]); + }} + /> + ), + }, + ]} + activeFilters={filters} + > +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/controls/components/logs-search/index.tsx new file mode 100644 index 0000000000..9a18da6f29 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/controls/components/logs-search/index.tsx @@ -0,0 +1,63 @@ +import { LogsLLMSearch } from "@/components/logs/llm-search"; +import { transformStructuredOutputToFilters } from "@/components/logs/validation/utils/transform-structured-output-filter-format"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { useFilters } from "../../../../hooks/use-filters"; + +export const LogsSearch = ({ keyspaceId }: { keyspaceId: string }) => { + const { filters, updateFilters } = useFilters(); + const queryLLMForStructuredOutput = trpc.api.keys.listLlmSearch.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, + important: true, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + }, + ); + return; + } + const transformedFilters = transformStructuredOutputToFilters(data, filters); + updateFilters(transformedFilters as any); + }, + 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, + important: true, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + className: "font-medium", + }); + }, + }); + + return ( + + queryLLMForStructuredOutput.mutateAsync({ + keyspaceId, + query, + }) + } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/controls/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/controls/index.tsx new file mode 100644 index 0000000000..a8c37cdf16 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/controls/index.tsx @@ -0,0 +1,19 @@ +import { LogsFilters } from "./components/logs-filters"; +import { LogsSearch } from "./components/logs-search"; + +export function KeysListControls({ keyspaceId }: { keyspaceId: string }) { + return ( +
+
+
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/bar-chart/components/outcome-explainer.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/bar-chart/components/outcome-explainer.tsx new file mode 100644 index 0000000000..6ca2e2488a --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/bar-chart/components/outcome-explainer.tsx @@ -0,0 +1,152 @@ +import { formatNumber } from "@/lib/fmt"; + +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@unkey/ui"; +import { useMemo } from "react"; +import type { ProcessedTimeseriesDataPoint } from "../use-fetch-timeseries"; + +type OutcomeExplainerProps = { + children: React.ReactNode; + timeseries: ProcessedTimeseriesDataPoint[]; +}; + +type ErrorType = { + type: string; + value: string; + color: string; +}; + +export function OutcomeExplainer({ children, timeseries }: OutcomeExplainerProps): JSX.Element { + // Aggregate all timeseries data for the tooltip + const aggregatedData = useMemo(() => { + if (!timeseries || timeseries.length === 0) { + return { + valid: 0, + rate_limited: 0, + insufficient_permissions: 0, + forbidden: 0, + disabled: 0, + expired: 0, + usage_exceeded: 0, + total: 0, + }; + } + + return timeseries.reduce( + (acc, dataPoint) => { + acc.valid += dataPoint.valid || 0; + acc.rate_limited += dataPoint.rate_limited || 0; + acc.insufficient_permissions += dataPoint.insufficient_permissions || 0; + acc.forbidden += dataPoint.forbidden || 0; + acc.disabled += dataPoint.disabled || 0; + acc.expired += dataPoint.expired || 0; + acc.usage_exceeded += dataPoint.usage_exceeded || 0; + acc.total += dataPoint.total || 0; + return acc; + }, + { + valid: 0, + rate_limited: 0, + insufficient_permissions: 0, + forbidden: 0, + disabled: 0, + expired: 0, + usage_exceeded: 0, + total: 0, + }, + ); + }, [timeseries]); + + const errorTypes = useMemo(() => { + const potentialErrors = [ + { + type: "Insufficient Permissions", + rawValue: aggregatedData.insufficient_permissions, + color: "bg-error-9", + }, + { + type: "Rate Limited", + rawValue: aggregatedData.rate_limited, + color: "bg-error-9", + }, + { + type: "Forbidden", + rawValue: aggregatedData.forbidden, + color: "bg-error-9", + }, + { + type: "Disabled", + rawValue: aggregatedData.disabled, + color: "bg-error-9", + }, + { + type: "Expired", + rawValue: aggregatedData.expired, + color: "bg-error-9", + }, + { + type: "Usage Exceeded", + rawValue: aggregatedData.usage_exceeded, + color: "bg-error-9", + }, + ]; + + const filteredErrors = potentialErrors.filter((error) => error.rawValue > 0); + + return filteredErrors.map((error) => ({ + type: error.type, + value: formatNumber(error.rawValue), + color: error.color, + })) as ErrorType[]; + }, [aggregatedData]); + + return ( + + + +
{children}
+
+ +
API Key Activity
+
Last 36 hours
+ + {/* Valid count */} +
+
+
+
Valid
+
+
+ {formatNumber(aggregatedData.valid)} +
+
+ +
+ + {/* Error types */} +
+ {errorTypes.map((error, index) => ( +
+ key={index} + className="flex justify-between w-full items-center" + > +
+
+
{error.type}
+
+
{error.value}
+
+ ))} + + {errorTypes.length === 0 && aggregatedData.valid === 0 && ( +
No verification activity
+ )} +
+ + + + ); +} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/bar-chart/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/bar-chart/index.tsx new file mode 100644 index 0000000000..1756112cb2 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/bar-chart/index.tsx @@ -0,0 +1,184 @@ +import { cn } from "@/lib/utils"; +import { useMemo } from "react"; +import { OutcomeExplainer } from "./components/outcome-explainer"; +import { useFetchVerificationTimeseries } from "./use-fetch-timeseries"; + +type BarData = { + id: string | number; + topHeight: number; + bottomHeight: number; + totalHeight: number; +}; + +type VerificationBarChartProps = { + keyAuthId: string; + keyId: string; + maxBars?: number; + selected: boolean; +}; + +const MAX_HEIGHT_BUFFER_FACTOR = 1.3; +const MAX_BAR_HEIGHT = 28; + +export const VerificationBarChart = ({ + keyAuthId, + keyId, + selected, + maxBars = 30, +}: VerificationBarChartProps) => { + const { timeseries, isLoading, isError } = useFetchVerificationTimeseries(keyAuthId, keyId); + + const isEmpty = useMemo( + () => timeseries.reduce((acc, crr) => acc + crr.total, 0) === 0, + [timeseries], + ); + + const bars = useMemo((): BarData[] => { + if (isLoading || isError || timeseries.length === 0) { + // Return empty data if loading, error, or no data + return Array(maxBars).fill({ + id: 0, + topHeight: 0, + bottomHeight: 0, + totalHeight: 0, + }); + } + // Get the most recent data points (or all if less than maxBars) + const recentData = timeseries.slice(-maxBars); + // Calculate the maximum total value to normalize heights + const maxTotal = + Math.max(...recentData.map((item) => item.total), 1) * MAX_HEIGHT_BUFFER_FACTOR; + // Generate bars from the data + return recentData.map((item, index): BarData => { + // Scale to fit within max height of 28px + const totalHeight = Math.min( + Math.round((item.total / maxTotal) * MAX_BAR_HEIGHT), + MAX_BAR_HEIGHT, + ); + // Calculate heights proportionally + const topHeight = item.error + ? Math.max(Math.round((item.error / item.total) * totalHeight), 1) + : 0; + const bottomHeight = Math.max(totalHeight - topHeight, 0); + return { + id: index, + totalHeight, + topHeight, + bottomHeight, + }; + }); + }, [timeseries, isLoading, isError, maxBars]); + + // Pad with empty bars if we have fewer than maxBars data points + const displayBars = useMemo((): BarData[] => { + const result = [...bars]; + while (result.length < maxBars) { + result.unshift({ + id: `empty-${result.length}`, + topHeight: 0, + bottomHeight: 0, + totalHeight: 0, + }); + } + return result; + }, [bars, maxBars]); + + // Loading state - animated pulse effect for bars with grid layout + if (isLoading) { + return ( +
+ {Array(maxBars) + .fill(0) + .map((_, index) => ( +
+ index + }`} + className="flex flex-col" + > +
+
+
+ ))} +
+ ); + } + + // Error state with grid layout + if (isError) { + return ( +
+
+ Error loading data +
+
+ ); + } + + // Empty state with grid layout + if (isEmpty) { + return ( +
+
+ No data available +
+
+ ); + } + + // Data display with grid layout + return ( + +
+ {displayBars.map((bar) => ( +
+
+
+
+ ))} +
+ + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/bar-chart/query-timeseries.schema.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/bar-chart/query-timeseries.schema.ts new file mode 100644 index 0000000000..51b95ba2df --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/bar-chart/query-timeseries.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const keysListQueryTimeseriesPayload = z.object({ + startTime: z.number().int(), + endTime: z.number().int(), + keyId: z.string(), + keyAuthId: z.string(), +}); + +export type KeysListQueryTimeseriesPayload = z.infer; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/bar-chart/use-fetch-timeseries.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/bar-chart/use-fetch-timeseries.ts new file mode 100644 index 0000000000..ab71bd7e37 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/bar-chart/use-fetch-timeseries.ts @@ -0,0 +1,126 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import { trpc } from "@/lib/trpc/client"; +import type { VerificationTimeseriesDataPoint } from "@unkey/clickhouse/src/verifications"; +import { useEffect, useMemo, useRef, useState } from "react"; + +export type ProcessedTimeseriesDataPoint = { + valid: number; + total: number; + success: number; + error: number; + rate_limited?: number; + insufficient_permissions?: number; + forbidden?: number; + disabled?: number; + expired?: number; + usage_exceeded?: number; +}; + +type CacheEntry = { + data: { timeseries: VerificationTimeseriesDataPoint[] }; + timestamp: number; +}; + +const timeseriesCache = new Map(); + +export const useFetchVerificationTimeseries = (keyAuthId: string, keyId: string) => { + // Use a ref for the initial timestamp to keep it stable + const initialTimeRef = useRef(Date.now()); + const cacheKey = `${keyAuthId}-${keyId}`; + + // Check if we have cached data + const cachedData = timeseriesCache.get(cacheKey); + + // State to force updates when cache changes + const [_, setCacheVersion] = useState(0); + + // Determine if we should run the query + const shouldFetch = !cachedData || Date.now() - cachedData.timestamp > 60000; + + // Set up query parameters - stable between renders + const queryParams = useMemo( + () => ({ + startTime: initialTimeRef.current - HISTORICAL_DATA_WINDOW * 3, + endTime: initialTimeRef.current, + keyAuthId, + keyId, + }), + [keyAuthId, keyId], + ); + + // Use TRPC's useQuery with critical settings + const { + data, + isLoading: trpcIsLoading, + isError, + } = trpc.api.keys.usageTimeseries.useQuery(queryParams, { + // CRITICAL: Only enable the query if we should fetch + enabled: shouldFetch, + // Prevent automatic refetching + refetchOnMount: false, + refetchOnWindowFocus: false, + staleTime: Number.POSITIVE_INFINITY, + refetchInterval: shouldFetch && queryParams.endTime >= Date.now() - 60_000 ? 10_000 : false, + }); + + // Process the timeseries data - using cached or fresh data + const effectiveData = data || (cachedData ? cachedData.data : undefined); + + // Process the timeseries from the effective data + const timeseries = useMemo(() => { + if (!effectiveData?.timeseries) { + return [] as ProcessedTimeseriesDataPoint[]; + } + + return effectiveData.timeseries.map((ts): ProcessedTimeseriesDataPoint => { + const result: ProcessedTimeseriesDataPoint = { + valid: ts.y.valid, + total: ts.y.total, + success: ts.y.valid, + error: ts.y.total - ts.y.valid, + }; + + // Add optional fields if they exist + if (ts.y.rate_limited_count !== undefined) { + result.rate_limited = ts.y.rate_limited_count; + } + if (ts.y.insufficient_permissions_count !== undefined) { + result.insufficient_permissions = ts.y.insufficient_permissions_count; + } + if (ts.y.forbidden_count !== undefined) { + result.forbidden = ts.y.forbidden_count; + } + if (ts.y.disabled_count !== undefined) { + result.disabled = ts.y.disabled_count; + } + if (ts.y.expired_count !== undefined) { + result.expired = ts.y.expired_count; + } + if (ts.y.usage_exceeded_count !== undefined) { + result.usage_exceeded = ts.y.usage_exceeded_count; + } + + return result; + }); + }, [effectiveData]); + + // Update cache when we get new data + useEffect(() => { + if (data) { + timeseriesCache.set(cacheKey, { + data, + timestamp: Date.now(), + }); + // Force a re-render to use cached data + setCacheVersion((prev) => prev + 1); + } + }, [data, cacheKey]); + + const isLoading = trpcIsLoading && !cachedData; + + return { + timeseries, + isLoading, + isError, + }; +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/hidden-value.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/hidden-value.tsx new file mode 100644 index 0000000000..69bb2722fa --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/hidden-value.tsx @@ -0,0 +1,47 @@ +import { toast } from "@/components/ui/toaster"; +import { cn } from "@/lib/utils"; +import { CircleLock } from "@unkey/icons"; + +export const HiddenValueCell = ({ + value, + title = "Value", + selected, +}: { + value: string; + title: string; + selected: boolean; +}) => { + // Show only first 4 characters, then dots + const displayValue = value.padEnd(16, "•"); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard + .writeText(value) + .then(() => { + toast.success(`${title} copied to clipboard`); + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + toast.error("Failed to copy to clipboard"); + }); + }; + + return ( + <> + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
handleClick(e)} + > +
+ +
+
{displayValue}
+
+ + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/last-used.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/last-used.tsx new file mode 100644 index 0000000000..143b610319 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/last-used.tsx @@ -0,0 +1,57 @@ +import { TimestampInfo } from "@/components/timestamp-info"; +import { Badge } from "@/components/ui/badge"; +import { trpc } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import { ChartActivity2 } from "@unkey/icons"; +import { STATUS_STYLES } from "../utils/get-row-class"; + +export const LastUsedCell = ({ + keyAuthId, + keyId, + isSelected, +}: { + keyAuthId: string; + keyId: string; + isSelected: boolean; +}) => { + const { data, isLoading, isError } = trpc.api.keys.latestVerification.useQuery({ + keyAuthId, + keyId, + }); + + return ( + +
+ +
+
+ {isLoading ? ( +
+
+
+
+
+ ) : isError ? ( + "Failed to load" + ) : data?.lastVerificationTime ? ( + + ) : ( + "Never used" + )} +
+ + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/skeletons.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/skeletons.tsx new file mode 100644 index 0000000000..f75647a11f --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/skeletons.tsx @@ -0,0 +1,69 @@ +import { cn } from "@unkey/ui/src/lib/utils"; + +export const KeyColumnSkeleton = () => ( +
+
+
+
+
+
+
+
+
+); + +export const ValueColumnSkeleton = () => ( +
+
+
+
+); + +export const UsageColumnSkeleton = ({ maxBars = 30 }: { maxBars?: number }) => ( +
+ {Array(maxBars) + .fill(0) + .map((_, index) => ( +
+ index + }`} + className="flex flex-col justify-end" + > +
+
+ ))} +
+); + +export const LastUsedColumnSkeleton = () => ( +
+
+
+
+
+); + +export const StatusColumnSkeleton = () => ( +
+
+
+
+); diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/components/status-badge.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/components/status-badge.tsx new file mode 100644 index 0000000000..7d07cee05f --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/components/status-badge.tsx @@ -0,0 +1,38 @@ +import { cn } from "@/lib/utils"; + +type StatusBadgeProps = { + // Accept simplified primary status info + primary: { + label: string; + color: string; + icon: React.ReactNode; + }; + count: number; +}; + +export const StatusBadge = ({ primary, count }: StatusBadgeProps) => { + return ( +
+
0 ? "rounded-l-md" : "rounded-md", + )} + > + {primary.icon && {primary.icon}} + {primary.label} +
+ {count > 0 && ( +
+ +{count} +
+ )} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/constants.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/constants.tsx new file mode 100644 index 0000000000..863aa2a44b --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/constants.tsx @@ -0,0 +1,79 @@ +import { + Ban, + CircleCaretRight, + CircleCheck, + CircleHalfDottedClock, + ShieldKey, + TriangleWarning2, +} from "@unkey/icons"; + +export type StatusType = + | "disabled" + | "low-credits" + | "expires-soon" + | "rate-limited" + | "validation-issues" + | "operational"; + +export interface StatusInfo { + type: StatusType; + label: string; + color: string; + icon: React.ReactNode; + tooltip: string; + priority: number; // Lower number = higher priority +} + +export const STATUS_DEFINITIONS: Record = { + "low-credits": { + type: "low-credits", + label: "Low Credits", + color: "bg-errorA-3 text-errorA-11", + icon: , + tooltip: "This key has a low credit balance. Top it off to prevent disruptions.", + priority: 1, + }, + "rate-limited": { + type: "rate-limited", + label: "Ratelimited", + color: "bg-errorA-3 text-errorA-11", + icon: , + tooltip: + "This key is getting ratelimited frequently. Check the configured ratelimits and reach out to your user about their usage.", + priority: 2, + }, + "expires-soon": { + type: "expires-soon", + label: "Expires soon", + color: "bg-orangeA-3 text-orangeA-11", + icon: , + tooltip: + "This key will expire in less than 24 hours. Rotate the key or extend its deadline to prevent disruptions.", + priority: 2, + }, + "validation-issues": { + type: "validation-issues", + label: "Potential issues", + color: "bg-warningA-3 text-warningA-11", + icon: , + tooltip: "This key has a high error rate. Please check its logs to debug potential issues.", + priority: 3, + }, + //TODO: Add a way to enable this through tooltip + disabled: { + type: "disabled", + label: "Disabled", + color: "bg-grayA-3 text-grayA-11", + icon: , + tooltip: "This key is currently disabled and cannot be used for verification.", + priority: 0, + }, + operational: { + type: "operational", + label: "Operational", + color: "bg-successA-3 text-successA-11", + icon: , + tooltip: "This key is operating normally.", + priority: 99, // Lowest priority + }, +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/index.tsx new file mode 100644 index 0000000000..4e0e2d83f3 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/index.tsx @@ -0,0 +1,143 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@unkey/ui"; +import { useState } from "react"; +import { StatusBadge } from "./components/status-badge"; +import { useKeyStatus } from "./use-key-status"; + +type StatusDisplayProps = { + keyData: KeyDetails; + keyAuthId: string; +}; + +export const StatusDisplay = ({ keyAuthId, keyData }: StatusDisplayProps) => { + const { primary, count, isLoading, statuses, isError } = useKeyStatus(keyAuthId, keyData); + const utils = trpc.useUtils(); + const [isOpen, setIsOpen] = useState(false); + + const enableKeyMutation = trpc.api.keys.enableKey.useMutation({ + onSuccess: async () => { + toast.success("Key enabled successfully!"); + await utils.api.keys.list.invalidate({ keyAuthId }); + }, + onError: (error) => { + toast.error("Failed to enable key", { + description: error.message || "An unknown error occurred.", + }); + }, + }); + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + if (isError) { + return ( +
+ Failed to load +
+ ); + } + + return ( + + + setIsOpen(!isOpen)} className="cursor-pointer"> + + + + {statuses && statuses.length > 1 && ( +
+
+
+
Key status overview
+
+ This key has{" "} + {statuses.length} active + flags{" "} +
+
+
+
+ )} + + {statuses?.map((status, i) => ( +
+
+
+ +
+ +
+ {status.type === "disabled" ? ( +
+ + This key has been manually disabled and cannot be used for any requests. + + {" "} +
+ ) : ( + status.tooltip + )} +
+
+
+ ))} +
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/query-timeseries.schema.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/query-timeseries.schema.ts new file mode 100644 index 0000000000..142868060e --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/query-timeseries.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const keyOutcomesQueryPayload = z.object({ + startTime: z.number().int(), + endTime: z.number().int(), + keyId: z.string(), + keyAuthId: z.string(), +}); + +export type KeyOutcomesQueryPayload = z.infer; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/use-key-status.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/use-key-status.ts new file mode 100644 index 0000000000..c24d0ec0c6 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/status-cell/use-key-status.ts @@ -0,0 +1,173 @@ +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { useMemo } from "react"; +import { + type ProcessedTimeseriesDataPoint, + useFetchVerificationTimeseries, +} from "../bar-chart/use-fetch-timeseries"; +import { STATUS_DEFINITIONS, type StatusInfo } from "./constants"; + +const RATE_LIMIT_THRESHOLD_PERCENT = 0.1; // 10% +const VALIDATION_ISSUE_THRESHOLD_PERCENT = 0.1; // 10% +const LOW_CREDITS_THRESHOLD_ABSOLUTE = 100; +const LOW_CREDITS_THRESHOLD_REFILL_PERCENT = 0.1; // 10% +const EXPIRY_THRESHOLD_HOURS = 24; + +type AggregatedData = { + total: number; + error: number; + rate_limited: number; +}; + +const aggregateTimeseries = (timeseries: ProcessedTimeseriesDataPoint[]): AggregatedData => { + return timeseries.reduce( + (acc, point) => { + acc.total += point.total; + acc.error += point.error; + acc.rate_limited += point.rate_limited ?? 0; + return acc; + }, + { total: 0, error: 0, rate_limited: 0 }, + ); +}; + +type UseKeyStatusResult = { + primary: { + label: string; + color: string; + icon: React.ReactNode; + }; + count: number; + statuses: StatusInfo[]; + isLoading: boolean; + isError: boolean; +}; + +const LOADING_PRIMARY = { + label: "Loading", + color: "bg-grayA-3", + icon: null, +}; + +export const useKeyStatus = (keyAuthId: string, keyData: KeyDetails): UseKeyStatusResult => { + const { timeseries, isError, isLoading } = useFetchVerificationTimeseries(keyAuthId, keyData.id); + + const statusResult = useMemo(() => { + // Handle case where keyData might not be loaded yet + if (!keyData) { + return { + primary: LOADING_PRIMARY, + count: 0, + statuses: [], + }; + } + + if (isLoading && timeseries.length === 0) { + return { + primary: LOADING_PRIMARY, + count: 0, + statuses: [], + }; + } + + if (isError) { + const fallbackStatus = keyData.enabled + ? STATUS_DEFINITIONS.operational + : STATUS_DEFINITIONS.disabled; + return { + primary: { + label: fallbackStatus.label, + color: fallbackStatus.color, + icon: fallbackStatus.icon, + }, + count: 0, + statuses: [fallbackStatus], + }; + } + + if (!keyData.enabled) { + const disabledStatus = STATUS_DEFINITIONS.disabled; + return { + primary: { + label: disabledStatus.label, + color: disabledStatus.color, + icon: disabledStatus.icon, + }, + count: 0, + statuses: [disabledStatus], + }; + } + + const applicableStatuses: StatusInfo[] = []; + const aggregatedData = aggregateTimeseries(timeseries); + const totalVerifications = aggregatedData.total; + + if ( + totalVerifications > 0 && + aggregatedData.rate_limited / totalVerifications > RATE_LIMIT_THRESHOLD_PERCENT + ) { + applicableStatuses.push(STATUS_DEFINITIONS["rate-limited"]); + } + + if ( + totalVerifications > 0 && + aggregatedData.error / totalVerifications > VALIDATION_ISSUE_THRESHOLD_PERCENT + ) { + applicableStatuses.push(STATUS_DEFINITIONS["validation-issues"]); + } + + const remaining = keyData.key.remaining; + const refillAmount = keyData.key.refillAmount; + const isLowOnCredits = + (remaining != null && remaining < LOW_CREDITS_THRESHOLD_ABSOLUTE) || + (refillAmount && + remaining != null && + refillAmount > 0 && + remaining < refillAmount * LOW_CREDITS_THRESHOLD_REFILL_PERCENT); + + if (isLowOnCredits) { + applicableStatuses.push(STATUS_DEFINITIONS["low-credits"]); + } + + // Check Expiry + if (keyData.expires) { + const hoursToExpiry = (keyData.expires * 1000 - Date.now()) / (1000 * 60 * 60); + // Ensure current time used is consistent if needed, Date.now() is fine here + if (hoursToExpiry > 0 && hoursToExpiry <= EXPIRY_THRESHOLD_HOURS) { + applicableStatuses.push(STATUS_DEFINITIONS["expires-soon"]); + } + } + + // Handle Operational state (if no issues found) + if (applicableStatuses.length === 0) { + const operationalStatus = STATUS_DEFINITIONS.operational; + return { + primary: { + label: operationalStatus.label, + color: operationalStatus.color, + icon: operationalStatus.icon, + }, + count: 0, + statuses: [operationalStatus], // Return array with the single operational status + }; + } + + applicableStatuses.sort((a, b) => a.priority - b.priority); // Sort by priority + + const primaryStatus = applicableStatuses[0]; // Highest priority is the first element + return { + primary: { + label: primaryStatus.label, + color: primaryStatus.color, + icon: primaryStatus.icon, + }, + count: applicableStatuses.length - 1, // Count of *other* statuses besides primary + statuses: applicableStatuses, // Return the full sorted array of applicable statuses + }; + }, [keyData, timeseries, isLoading, isError]); + + return { + ...statusResult, + isLoading: isLoading || !keyData, + isError, + }; +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/hooks/use-keys-list-query.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/hooks/use-keys-list-query.ts new file mode 100644 index 0000000000..964507d8ed --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/hooks/use-keys-list-query.ts @@ -0,0 +1,84 @@ +import { trpc } from "@/lib/trpc/client"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { useEffect, useMemo, useState } from "react"; +import { keysListFilterFieldConfig, keysListFilterFieldNames } from "../../../filters.schema"; +import { useFilters } from "../../../hooks/use-filters"; +import type { KeysQueryListPayload } from "../query-logs.schema"; + +type UseKeysListQueryParams = { + keyAuthId: string; +}; + +export function useKeysListQuery({ keyAuthId }: UseKeysListQueryParams) { + const [totalCount, setTotalCount] = useState(0); + const [keysMap, setKeysMap] = useState(() => new Map()); + + const { filters } = useFilters(); + + const keysList = useMemo(() => Array.from(keysMap.values()), [keysMap]); + + const queryParams = useMemo(() => { + const params: KeysQueryListPayload = { + ...Object.fromEntries(keysListFilterFieldNames.map((field) => [field, []])), + keyAuthId, + }; + + filters.forEach((filter) => { + if (!keysListFilterFieldNames.includes(filter.field) || !params[filter.field]) { + return; + } + + const fieldConfig = keysListFilterFieldConfig[filter.field]; + const validOperators = fieldConfig.operators; + if (!validOperators.includes(filter.operator)) { + throw new Error("Invalid operator"); + } + + if (typeof filter.value === "string") { + params[filter.field]?.push({ + operator: filter.operator, + value: filter.value, + }); + } + }); + + return params; + }, [filters, keyAuthId]); + + const { + data: keysData, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + isLoading: isLoadingInitial, + } = trpc.api.keys.list.useInfiniteQuery(queryParams, { + getNextPageParam: (lastPage) => lastPage.nextCursor, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if (keysData) { + const newMap = new Map(); + keysData.pages.forEach((page) => { + page.keys.forEach((key) => { + newMap.set(key.id, key); + }); + }); + if (keysData.pages.length > 0) { + setTotalCount(keysData.pages[0].totalCount); + } + setKeysMap(newMap); + } + }, [keysData]); + + return { + keys: keysList, + isLoading: isLoadingInitial, + hasMore: hasNextPage, + loadMore: fetchNextPage, + isLoadingMore: isFetchingNextPage, + totalCount, + }; +} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx new file mode 100644 index 0000000000..c16ca34915 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx @@ -0,0 +1,266 @@ +"use client"; +import { VirtualTable } from "@/components/virtual-table/index"; +import type { Column } from "@/components/virtual-table/types"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { BookBookmark, Focus, Key } from "@unkey/icons"; +import { + AnimatedLoadingSpinner, + Button, + Empty, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import Link from "next/link"; +import { useCallback, useMemo, useState } from "react"; +import React from "react"; +import { VerificationBarChart } from "./components/bar-chart"; +import { HiddenValueCell } from "./components/hidden-value"; +import { LastUsedCell } from "./components/last-used"; +import { + KeyColumnSkeleton, + LastUsedColumnSkeleton, + StatusColumnSkeleton, + UsageColumnSkeleton, + ValueColumnSkeleton, +} from "./components/skeletons"; +import { StatusDisplay } from "./components/status-cell"; +import { useKeysListQuery } from "./hooks/use-keys-list-query"; +import { getRowClassName } from "./utils/get-row-class"; + +export const KeysList = ({ + keyspaceId, + apiId, +}: { + keyspaceId: string; + apiId: string; +}) => { + const { keys, isLoading, isLoadingMore, loadMore, totalCount, hasMore } = useKeysListQuery({ + keyAuthId: keyspaceId, + }); + const [selectedKey, setSelectedKey] = useState(null); + const [navigatingKeyId, setNavigatingKeyId] = useState(null); + + const handleLinkClick = useCallback((keyId: string) => { + setNavigatingKeyId(keyId); + setSelectedKey(null); + }, []); + + const columns: Column[] = useMemo( + () => [ + { + key: "key", + header: "Key", + width: "10%", + headerClassName: "pl-[18px]", + render: (key) => { + const identity = key.identity?.external_id ?? key.owner_id; + const isNavigating = key.id === navigatingKeyId; + + const iconContainer = isNavigating ? ( +
+ +
+ ) : ( +
+ {identity ? ( + + ) : ( + + )} +
+ ); + + return ( +
+
+ {identity ? ( + + + + {React.cloneElement(iconContainer, { + className: cn(iconContainer.props.className, "cursor-pointer"), + })} + + + This key is associated with the identity:{" "} + {key.identity_id ? ( + + {identity} + + ) : ( + {identity} + )} + + + + ) : ( + iconContainer + )} + +
+ { + handleLinkClick(key.id); + }} + > +
+ {key.id.substring(0, 8)}... + {key.id.substring(key.id.length - 4)} +
+ + {key.name && {key.name}} +
+
+
+ ); + }, + }, + { + key: "value", + header: "Value", + width: "15%", + render: (key) => ( + + ), + }, + { + key: "usage", + header: "Usage in last 36H", + width: "15%", + render: (key) => ( + + ), + }, + { + key: "last_used", + header: "Last Used", + width: "15%", + render: (key) => { + return ( + + ); + }, + }, + { + key: "status", + header: "Status", + width: "15%", + render: (key) => { + return ; + }, + }, + ], + [keyspaceId, selectedKey?.id, apiId, navigatingKeyId, handleLinkClick], + ); + + return ( +
+ log.id} + rowClassName={(log) => getRowClassName(log, selectedKey as KeyDetails)} + loadMoreFooterProps={{ + hide: isLoading, + buttonText: "Load more keys", + hasMore, + countInfoText: ( +
+ Showing {keys.length} + of + {totalCount} + keys +
+ ), + }} + emptyState={ +
+ + + No API Keys Found + + There are no API keys associated with this service yet. Create your first API key to + get started. + + + + + + + +
+ } + config={{ + rowHeight: 52, + layoutMode: "grid", + rowBorders: true, + containerPadding: "px-0", + }} + renderSkeletonRow={({ columns, rowHeight }) => + columns.map((column, idx) => ( + + {column.key === "key" && } + {column.key === "value" && } + {column.key === "usage" && } + {column.key === "last_used" && } + {column.key === "status" && } + {!["key", "value", "usage", "last_used", "status"].includes(column.key) && ( +
+ )} + + )) + } + /> +
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/query-logs.schema.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/query-logs.schema.ts new file mode 100644 index 0000000000..201c68e7a0 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/query-logs.schema.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { keysListFilterOperatorEnum } from "../../filters.schema"; + +const filterItemSchema = z.object({ + operator: keysListFilterOperatorEnum, + value: z.string(), +}); +const baseFilterArraySchema = z.array(filterItemSchema).nullish(); + +const baseKeysSchema = z.object({ + keyAuthId: z.string(), + names: baseFilterArraySchema, + identities: baseFilterArraySchema, + keyIds: baseFilterArraySchema, +}); + +export const keysQueryListPayload = baseKeysSchema.extend({ + cursor: z.string().nullish(), +}); + +export type KeysQueryListPayload = z.infer; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/utils/get-row-class.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/utils/get-row-class.ts new file mode 100644 index 0000000000..7574372bb8 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/utils/get-row-class.ts @@ -0,0 +1,38 @@ +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { cn } from "@/lib/utils"; + +export type StatusStyle = { + base: string; + hover: string; + selected: string; + badge: { + default: string; + selected: string; + }; + focusRing: string; +}; + +export const STATUS_STYLES = { + base: "text-grayA-9", + hover: "hover:text-accent-11 dark:hover:text-accent-12 hover:bg-grayA-2", + selected: "text-accent-12 bg-grayA-2 hover:text-accent-12", + badge: { + default: "bg-grayA-3 text-grayA-11 group-hover:bg-grayA-5 border-transparent", + selected: "bg-grayA-5 text-grayA-12 hover:bg-grayA-5 border-grayA-3", + }, + focusRing: "focus:ring-accent-7", +}; + +export const getRowClassName = (log: KeyDetails, selectedLog: KeyDetails) => { + const style = STATUS_STYLES; + const isSelected = log.id === selectedLog?.id; + + return cn( + style.base, + style.hover, + "group rounded", + "focus:outline-none focus:ring-1 focus:ring-opacity-40", + style.focusRing, + isSelected && style.selected, + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/filters.schema.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/filters.schema.ts new file mode 100644 index 0000000000..dfa1a5eeac --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/filters.schema.ts @@ -0,0 +1,67 @@ +// src/features/keys/filters.schema.ts + +import type { FilterValue, StringConfig } from "@/components/logs/validation/filter.types"; +import { parseAsFilterValueArray } from "@/components/logs/validation/utils/nuqs-parsers"; +import { createFilterOutputSchema } from "@/components/logs/validation/utils/structured-output-schema-generator"; +import { z } from "zod"; + +const commonStringOperators = ["is", "contains", "startsWith", "endsWith"] as const; + +export const keysListFilterOperatorEnum = z.enum(commonStringOperators); +export type KeysListFilterOperator = z.infer; + +export type FilterFieldConfigs = { + keyIds: StringConfig; + names: StringConfig; + identities: StringConfig; +}; + +export const keysListFilterFieldConfig: FilterFieldConfigs = { + keyIds: { + type: "string", + operators: [...commonStringOperators], + }, + names: { + type: "string", + operators: [...commonStringOperators], + }, + identities: { + type: "string", + operators: [...commonStringOperators], + }, +}; + +const allFilterFieldNames = Object.keys(keysListFilterFieldConfig) as (keyof FilterFieldConfigs)[]; + +if (allFilterFieldNames.length === 0) { + throw new Error("keysListFilterFieldConfig must contain at least one field definition."); +} + +const [firstFieldName, ...restFieldNames] = allFilterFieldNames; + +export const keysListFilterFieldEnum = z.enum([firstFieldName, ...restFieldNames]); + +export const keysListFilterFieldNames = allFilterFieldNames; + +export type KeysListFilterField = z.infer; + +export const filterOutputSchema = createFilterOutputSchema( + keysListFilterFieldEnum, + keysListFilterOperatorEnum, + keysListFilterFieldConfig, +); + +export type AllOperatorsUrlValue = { + value: string; + operator: KeysListFilterOperator; +}; + +export type KeysListFilterValue = FilterValue; + +export type KeysQuerySearchParams = { + [K in KeysListFilterField]?: AllOperatorsUrlValue[] | null; +}; + +export const parseAsAllOperatorsFilterArray = parseAsFilterValueArray([ + ...commonStringOperators, +]); diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/hooks/use-filters.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/hooks/use-filters.ts new file mode 100644 index 0000000000..8e0b2b4207 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/hooks/use-filters.ts @@ -0,0 +1,104 @@ +// src/features/keys/hooks/use-filters.ts (or your path) + +import { useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; +import { + type AllOperatorsUrlValue, + type KeysListFilterField, + type KeysListFilterValue, + type KeysQuerySearchParams, + keysListFilterFieldConfig, + keysListFilterFieldNames, + parseAsAllOperatorsFilterArray, +} from "../filters.schema"; + +export const queryParamsPayload = Object.fromEntries( + keysListFilterFieldNames.map((field) => [field, parseAsAllOperatorsFilterArray]), +) as { [K in KeysListFilterField]: typeof parseAsAllOperatorsFilterArray }; + +export const useFilters = () => { + const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload, { + history: "push", + }); + + const filters = useMemo(() => { + const activeFilters: KeysListFilterValue[] = []; + + for (const field of keysListFilterFieldNames) { + const value = searchParams[field]; + + if (!Array.isArray(value)) { + continue; + } + + for (const filterItem of value) { + if (filterItem && typeof filterItem.value === "string" && filterItem.operator) { + const baseFilter: KeysListFilterValue = { + id: crypto.randomUUID(), + field: field, + operator: filterItem.operator, + value: filterItem.value, + }; + activeFilters.push(baseFilter); + } + } + } + + return activeFilters; + }, [searchParams]); + + const updateFilters = useCallback( + (newFilters: KeysListFilterValue[]) => { + const newParams: Partial = Object.fromEntries( + keysListFilterFieldNames.map((field) => [field, null]), + ); + + const filtersByField = new Map(); + keysListFilterFieldNames.forEach((field) => filtersByField.set(field, [])); + + newFilters.forEach((filter) => { + if (!keysListFilterFieldNames.includes(filter.field)) { + return; + } + + const fieldConfig = keysListFilterFieldConfig[filter.field]; + const validOperators = fieldConfig.operators; + + if (!validOperators.includes(filter.operator)) { + throw new Error("Invalid operator"); + } + + if (typeof filter.value === "string") { + const fieldFilters = filtersByField.get(filter.field); + fieldFilters?.push({ + value: filter.value, + operator: filter.operator, + }); + } + }); + + filtersByField.forEach((fieldFilters, field) => { + if (fieldFilters.length > 0) { + newParams[field] = fieldFilters; + } + }); + + 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)/apis/[apiId]/keys/[keyAuthId]/_components/keys-client.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/keys-client.tsx new file mode 100644 index 0000000000..81100d924b --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/keys-client.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { KeysListControlCloud } from "./components/control-cloud"; +import { KeysListControls } from "./components/controls"; +import { KeysList } from "./components/table/keys-list"; + +export const KeysClient = ({ + keyspaceId, + apiId, +}: { + keyspaceId: string; + apiId: string; +}) => { + return ( +
+ + + +
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/keys.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/keys.tsx deleted file mode 100644 index a953c087b9..0000000000 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/keys.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { CreateKeyButton } from "@/components/dashboard/create-key-button"; -import BackButton from "@/components/ui/back-button"; -import { Badge } from "@/components/ui/badge"; -import { db } from "@/lib/db"; -import { formatNumber } from "@/lib/fmt"; -import { Empty } from "@unkey/ui"; -import { Button } from "@unkey/ui"; -import { ChevronRight, User, VenetianMask } from "lucide-react"; -import Link from "next/link"; - -type Props = { - keyAuthId: string; - apiId: string; -}; - -export const dynamic = "force-dynamic"; - -export const Keys: React.FC = async ({ keyAuthId, apiId }) => { - const keys = await db.query.keys.findMany({ - where: (table, { and, eq, isNull }) => - and(eq(table.keyAuthId, keyAuthId), isNull(table.deletedAtM)), - limit: 100, - with: { - identity: { - columns: { - externalId: true, - }, - }, - roles: { - with: { - role: { - with: { - permissions: true, - }, - }, - }, - }, - permissions: true, - }, - }); - - const nullExternalId = "UNKEY_NULL_OWNER_ID"; - type KeysByOwnerId = { - [externalId: string]: { - id: string; - keyAuthId: string; - name: string | null; - start: string | null; - roles: number; - permissions: number; - enabled: boolean; - environment: string | null; - }[]; - }; - const keysByExternalId = keys.reduce((acc, curr) => { - const externalId = curr.identity?.externalId ?? curr.ownerId ?? nullExternalId; - if (!acc[externalId]) { - acc[externalId] = []; - } - const permissions = new Set(curr.permissions.map((p) => p.permissionId)); - for (const role of curr.roles) { - for (const permission of role.role.permissions) { - permissions.add(permission.permissionId); - } - } - acc[externalId].push({ - id: curr.id, - keyAuthId: curr.keyAuthId, - name: curr.name, - start: curr.start, - roles: curr.roles.length, - enabled: curr.enabled, - permissions: permissions.size, - environment: curr.environment, - }); - return acc; - }, {} as KeysByOwnerId); - - return ( -
- {keys.length === 0 ? ( - - - No keys found - Create your first key - - - Go Back - - - {/* Create New Role} /> */} - - ) : ( - Object.entries(keysByExternalId).map(([externalId, ks]) => ( -
-
- {externalId === nullExternalId ? ( -
- - Without OwnerID - - You can associate keys with the a userId or other identifier from your own - system. - -
- ) : ( -
- - {externalId} -
- )} -
-
    - {ks.map((k) => ( - -
    - {k.name} -
    {k.id}
    -
    - -
    - {k.environment ? env: {k.environment} : null} -
    - -
    - - {formatNumber(k.permissions)} Permission - {k.permissions !== 1 ? "s" : ""} - - - - {formatNumber(k.roles)} Role - {k.roles !== 1 ? "s" : ""} - - - {!k.enabled && Disabled} -
    - -
    - -
    - - ))} -
-
- )) - )} -
- ); -}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/navigation.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/navigation.tsx deleted file mode 100644 index b4df5379f7..0000000000 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/navigation.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import { CopyButton } from "@/components/dashboard/copy-button"; -import { CreateKeyButton } from "@/components/dashboard/create-key-button"; -import { Navbar } from "@/components/navigation/navbar"; -import { Badge } from "@/components/ui/badge"; -import { Nodes } from "@unkey/icons"; - -type KeyAuthProps = { - id: string; - api: { - id: string; - name: string; - keyAuthId: string | null; - }; -}; - -interface NavigationProps { - apiId: string; - keyAuth: KeyAuthProps; -} - -export function Navigation({ apiId, keyAuth }: NavigationProps) { - return ( - - }> - APIs - - {keyAuth.api.name} - - - Keys - - - - - {keyAuth.api.id} - - - - - - ); -} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/page.tsx index 83ae9d71be..4b63001e3b 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/page.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/page.tsx @@ -1,12 +1,6 @@ -import { getOrgId } from "@/lib/auth"; -import { db } from "@/lib/db"; -import { notFound } from "next/navigation"; - -import { Navbar as SubMenu } from "@/components/dashboard/navbar"; -import { PageContent } from "@/components/page-content"; -import { navigation } from "../../constants"; -import { Keys } from "./keys"; -import { Navigation } from "./navigation"; +import { fetchApiAndWorkspaceDataFromDb } from "../../actions"; +import { ApisNavbar } from "../../api-id-navbar"; +import { KeysClient } from "./_components/keys-client"; export const dynamic = "force-dynamic"; @@ -16,31 +10,22 @@ export default async function APIKeysPage(props: { keyAuthId: string; }; }) { - const orgId = await getOrgId(); + const apiId = props.params.apiId; + const keyspaceId = props.params.keyAuthId; - const keyAuth = await db.query.keyAuth.findFirst({ - where: (table, { eq, and, isNull }) => - and(eq(table.id, props.params.keyAuthId), isNull(table.deletedAtM)), - with: { - workspace: true, - api: true, - }, - }); - if (!keyAuth || keyAuth.workspace.orgId !== orgId) { - return notFound(); - } + const { currentApi, workspaceApis } = await fetchApiAndWorkspaceDataFromDb(apiId); return (
- - - - - -
- -
-
+ +
); } diff --git a/apps/dashboard/app/(app)/apis/[apiId]/navigation.tsx b/apps/dashboard/app/(app)/apis/[apiId]/navigation.tsx deleted file mode 100644 index 8a106d1b1a..0000000000 --- a/apps/dashboard/app/(app)/apis/[apiId]/navigation.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import { CopyButton } from "@/components/dashboard/copy-button"; -import { CreateKeyButton } from "@/components/dashboard/create-key-button"; -import { Navbar } from "@/components/navigation/navbar"; -import { Badge } from "@/components/ui/badge"; -import type { Api } from "@unkey/db"; -import { Nodes } from "@unkey/icons"; - -export function Navigation({ api }: { api: Api }) { - return ( - - }> - APIs - - {api.name} - - - - - {api.id} - - - - - - ); -} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/page.tsx index d08b24457d..4c3a74d15d 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/page.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/page.tsx @@ -1,39 +1,12 @@ -import { getOrgId } from "@/lib/auth"; -import { and, db, eq, isNull } from "@/lib/db"; -import { apis } from "@unkey/db/src/schema"; -import { redirect } from "next/navigation"; import { LogsClient } from "./_overview/logs-client"; +import { fetchApiAndWorkspaceDataFromDb } from "./actions"; import { ApisNavbar } from "./api-id-navbar"; export const dynamic = "force-dynamic"; export default async function ApiPage(props: { params: { apiId: string } }) { - const orgId = await getOrgId(); const apiId = props.params.apiId; - const currentApi = await db.query.apis.findFirst({ - where: (table, { and, eq, isNull }) => and(eq(table.id, apiId), isNull(table.deletedAtM)), - with: { - workspace: { - columns: { - id: true, - orgId: true, - }, - }, - }, - }); - - if (!currentApi || currentApi.workspace.orgId !== orgId || !currentApi?.keyAuthId) { - return redirect("/new"); - } - - const workspaceApis = await db - .select({ - id: apis.id, - name: apis.name, - }) - .from(apis) - .where(and(eq(apis.workspaceId, currentApi.workspaceId), isNull(apis.deletedAtM))) - .orderBy(apis.name); + const { currentApi, workspaceApis } = await fetchApiAndWorkspaceDataFromDb(apiId); return (
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/settings/navigation.tsx b/apps/dashboard/app/(app)/apis/[apiId]/settings/navigation.tsx deleted file mode 100644 index aeedeed9ca..0000000000 --- a/apps/dashboard/app/(app)/apis/[apiId]/settings/navigation.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { CopyButton } from "@/components/dashboard/copy-button"; -import { CreateKeyButton } from "@/components/dashboard/create-key-button"; -import { Navbar } from "@/components/navigation/navbar"; -import { Badge } from "@/components/ui/badge"; -import type { Api } from "@unkey/db"; -import { Nodes } from "@unkey/icons"; - -export function Navigation({ api }: { api: Api }) { - return ( - - }> - APIs - - {api.name} - - - Settings - - - - - {api.id} - - - - - - ); -} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/settings/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/settings/page.tsx index 4e498d620c..66b33a845e 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/settings/page.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/settings/page.tsx @@ -1,17 +1,15 @@ import { CopyButton } from "@/components/dashboard/copy-button"; -import { Navbar as SubMenu } from "@/components/dashboard/navbar"; import { PageContent } from "@/components/page-content"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Code } from "@/components/ui/code"; import { getOrgId } from "@/lib/auth"; import { db, eq, schema } from "@/lib/db"; import { notFound, redirect } from "next/navigation"; -import { navigation } from "../constants"; +import { ApisNavbar } from "../api-id-navbar"; import { DefaultBytes } from "./default-bytes"; import { DefaultPrefix } from "./default-prefix"; import { DeleteApi } from "./delete-api"; import { DeleteProtection } from "./delete-protection"; -import { Navigation } from "./navigation"; import { UpdateApiName } from "./update-api-name"; import { UpdateIpWhitelist } from "./update-ip-whitelist"; @@ -53,11 +51,15 @@ export default async function SettingsPage(props: Props) { return (
- - + - -
diff --git a/apps/dashboard/app/(app)/apis/_components/hooks/use-query-timeseries.ts b/apps/dashboard/app/(app)/apis/_components/hooks/use-query-timeseries.ts index 58b4b0aca8..39b8190199 100644 --- a/apps/dashboard/app/(app)/apis/_components/hooks/use-query-timeseries.ts +++ b/apps/dashboard/app/(app)/apis/_components/hooks/use-query-timeseries.ts @@ -53,6 +53,11 @@ export const useFetchVerificationTimeseries = (keyspaceId: string | null) => { const { data, isLoading, isError } = trpc.api.overview.timeseries.useQuery(queryParams, { refetchInterval: queryParams.endTime ? false : 10_000, enabled, + trpc: { + context: { + skipBatch: true, + }, + }, }); const timeseries = (data?.timeseries ?? []).map((ts) => ({ diff --git a/apps/dashboard/app/(app)/identities/page.tsx b/apps/dashboard/app/(app)/identities/page.tsx index cc68c77e99..f4da605fbc 100644 --- a/apps/dashboard/app/(app)/identities/page.tsx +++ b/apps/dashboard/app/(app)/identities/page.tsx @@ -62,9 +62,11 @@ export default async function Page(props: Props) { ); } -const Results: React.FC<{ search: string; limit: number }> = async (props) => { - const orgId = await getOrgId(); +const Results: React.FC<{ search?: string; limit?: number }> = async (props) => { + const search = props.search || ""; + const limit = props.limit || 10; + const orgId = await getOrgId(); const getData = cache( async () => db.query.workspaces.findFirst({ @@ -73,11 +75,9 @@ const Results: React.FC<{ search: string; limit: number }> = async (props) => { with: { identities: { where: (table, { or, like }) => - or(like(table.externalId, `%${props.search}%`), like(table.id, `%${props.search}%`)), - - limit: props.limit, + or(like(table.externalId, `%${search}%`), like(table.id, `%${search}%`)), + limit: limit, orderBy: (table, { asc }) => asc(table.id), - with: { ratelimits: { columns: { @@ -93,7 +93,7 @@ const Results: React.FC<{ search: string; limit: number }> = async (props) => { }, }, }), - [`${orgId}-${props.search}-${props.limit}`], + [`${orgId}-${search}-${limit}`], ); const workspace = await getData(); diff --git a/apps/dashboard/app/(app)/layout.tsx b/apps/dashboard/app/(app)/layout.tsx index b5b3a82f61..91d36a224d 100644 --- a/apps/dashboard/app/(app)/layout.tsx +++ b/apps/dashboard/app/(app)/layout.tsx @@ -29,7 +29,7 @@ export default async function Layout({ children }: LayoutProps) { } return ( -
+
{/* Desktop Sidebar */} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/hooks/use-fetch-timeseries.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/hooks/use-fetch-timeseries.ts index 15dd708a84..d834c0054d 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/hooks/use-fetch-timeseries.ts +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/hooks/use-fetch-timeseries.ts @@ -58,6 +58,11 @@ export const useFetchRatelimitOverviewTimeseries = (namespaceId: string) => { queryParams, { refetchInterval: queryParams.endTime ? false : 10_000, + trpc: { + context: { + skipBatch: true, + }, + }, }, ); diff --git a/apps/dashboard/components/navigation/navbar.tsx b/apps/dashboard/components/navigation/navbar.tsx index abb5ee0953..1900434edb 100644 --- a/apps/dashboard/components/navigation/navbar.tsx +++ b/apps/dashboard/components/navigation/navbar.tsx @@ -44,7 +44,7 @@ export const Navbar = React.forwardRef(