diff --git a/apps/dashboard/app/(app)/desktop-sidebar.tsx b/apps/dashboard/app/(app)/desktop-sidebar.tsx index 0ad1f61a46..290c851f04 100644 --- a/apps/dashboard/app/(app)/desktop-sidebar.tsx +++ b/apps/dashboard/app/(app)/desktop-sidebar.tsx @@ -3,8 +3,8 @@ import { createWorkspaceNavigation, resourcesNavigation } from "@/app/(app)/work import { Feedback } from "@/components/dashboard/feedback-component"; import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useDelayLoader } from "@/hooks/useDelayLoader"; import type { Workspace } from "@/lib/db"; -import { useDelayLoader } from "@/lib/hooks/useDelayLoader"; import { cn } from "@/lib/utils"; import { Loader2, type LucideIcon } from "lucide-react"; import Link from "next/link"; 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 8394cc1401..604aa4e78b 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,31 +1,11 @@ +import { formatTimestampForChart } from "@/components/logs/chart/utils/format-timestamp"; +import { TIMESERIES_DATA_WINDOW } from "@/components/logs/constants"; import { trpc } from "@/lib/trpc/client"; -import type { TimeseriesGranularity } from "@/lib/trpc/routers/logs/query-timeseries/utils"; -import { addMinutes, format } from "date-fns"; import { useMemo } from "react"; import type { z } from "zod"; import { useFilters } from "../../../hooks/use-filters"; import type { queryTimeseriesPayload } from "../query-timeseries.schema"; -// Duration in milliseconds for historical data fetch window (1 hours) -const TIMESERIES_DATA_WINDOW = 60 * 60 * 1000; - -const formatTimestamp = (value: string | number, granularity: TimeseriesGranularity) => { - const date = new Date(value); - const offset = new Date().getTimezoneOffset() * -1; - const localDate = addMinutes(date, offset); - - switch (granularity) { - case "perMinute": - return format(localDate, "HH:mm:ss"); - case "perHour": - return format(localDate, "MMM d, HH:mm"); - case "perDay": - return format(localDate, "MMM d"); - default: - return format(localDate, "Pp"); - } -}; - export const useFetchTimeseries = () => { const { filters } = useFilters(); @@ -115,7 +95,7 @@ export const useFetchTimeseries = () => { }); const timeseries = data?.timeseries.map((ts) => ({ - displayX: formatTimestamp(ts.x, data.granularity), + displayX: formatTimestampForChart(ts.x, data.granularity), originalTimestamp: ts.x, ...ts.y, })); diff --git a/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx index 1d7c0f1aef..1a1efcf329 100644 --- a/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/logs/components/control-cloud/index.tsx @@ -1,6 +1,6 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; import { ControlCloud } from "@/components/logs/control-cloud"; import { format } from "date-fns"; -import { HISTORICAL_DATA_WINDOW } from "../../constants"; import { useFilters } from "../../hooks/use-filters"; const formatFieldName = (field: string): string => { 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 c7bb0b81ac..c7128a460a 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,78 +1,36 @@ -import { InputSearch } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { cn } from "@unkey/ui/src/lib/utils"; -import { useState } from "react"; -import { useFilters } from "../../../../../hooks/use-filters"; +import { logsFilterFieldConfig } from "@/app/(app)/logs/filters.schema"; +import { useFilters } from "@/app/(app)/logs/hooks/use-filters"; +import { FilterOperatorInput } from "@/components/logs/filter-operator-input"; export const PathsFilter = () => { const { filters, updateFilters } = useFilters(); - const activeFilter = filters.find((f) => f.field === "paths"); - const [searchText, setSearchText] = useState(activeFilter?.value.toString() ?? ""); - const [isFocused, setIsFocused] = useState(false); - const handleSearch = () => { - const activeFilters = filters.filter((f) => f.field !== "paths"); - if (searchText.trim()) { - updateFilters([ - ...activeFilters, - { - field: "paths", - value: searchText, - id: crypto.randomUUID(), - operator: "contains", - }, - ]); - } else { - updateFilters(activeFilters); - } - }; + const pathOperators = logsFilterFieldConfig.paths.operators; + const options = pathOperators.map((op) => ({ + id: op, + label: op, + })); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (isFocused) { - e.stopPropagation(); - if (e.key === "Enter") { - handleSearch(); - } - } - }; - - const handleFocus = () => { - setIsFocused(true); - }; - - const handleBlur = () => { - setIsFocused(false); - }; + const activePathFilter = filters.find((f) => f.field === "paths"); return ( -
-
-
- - setSearchText(e.target.value)} - onKeyDown={handleKeyDown} - onFocus={handleFocus} - onBlur={handleBlur} - type="text" - placeholder="Search for path..." - className="w-full text-[13px] font-medium text-accent-12 bg-transparent border-none outline-none focus:ring-0 focus:outline-none placeholder:text-accent-12" - /> -
-
- -
+ { + 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)/logs/components/controls/components/logs-live-switch.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-live-switch.tsx index 1d8827ccdd..8c495d4eb7 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-live-switch.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-live-switch.tsx @@ -1,5 +1,5 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; import { LiveSwitchButton } from "@/components/logs/live-switch-button"; -import { HISTORICAL_DATA_WINDOW } from "../../../constants"; import { useLogsContext } from "../../../context/logs"; import { useFilters } from "../../../hooks/use-filters"; 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 079f2b59f0..972d64428a 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,8 +1,8 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; import { trpc } from "@/lib/trpc/client"; import type { Log } from "@unkey/clickhouse/src/logs"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { z } from "zod"; -import { HISTORICAL_DATA_WINDOW } from "../../../constants"; import { useFilters } from "../../../hooks/use-filters"; import type { queryLogsPayload } from "../query-logs.schema"; @@ -13,6 +13,8 @@ type UseLogsQueryParams = { startPolling?: boolean; }; +const REALTIME_DATA_LIMIT = 100; + export function useLogsQuery({ limit = 50, pollIntervalMs = 5000, @@ -25,7 +27,7 @@ export function useLogsQuery({ const queryClient = trpc.useUtils(); const realtimeLogs = useMemo(() => { - return Array.from(realtimeLogsMap.values()); + return sortLogs(Array.from(realtimeLogsMap.values())); }, [realtimeLogsMap]); const historicalLogs = useMemo(() => Array.from(historicalLogsMap.values()), [historicalLogsMap]); @@ -142,7 +144,6 @@ export function useLogsQuery({ }); // Query for new logs (polling) - // biome-ignore lint/correctness/useExhaustiveDependencies: biome wants to everything as dep const pollForNewLogs = useCallback(async () => { try { const latestTime = realtimeLogs[0]?.time ?? historicalLogs[0]?.time; @@ -169,19 +170,30 @@ export function useLogsQuery({ newMap.set(log.request_id, log); added++; - if (newMap.size > Math.min(limit, 100)) { - const oldestKey = Array.from(newMap.keys()).shift()!; - newMap.delete(oldestKey); + // 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]); } } - // If nothing was added, return old map to prevent re-render return added > 0 ? newMap : prevMap; }); } catch (error) { console.error("Error polling for new logs:", error); } - }, [queryParams, queryClient, limit, pollIntervalMs, historicalLogsMap]); + }, [ + queryParams, + queryClient, + limit, + pollIntervalMs, + historicalLogsMap, + realtimeLogs, + historicalLogs, + ]); // Set up polling effect useEffect(() => { @@ -221,3 +233,7 @@ export function useLogsQuery({ isPolling: startPolling, }; } + +const sortLogs = (logs: Log[]) => { + return logs.toSorted((a, b) => b.time - a.time); +}; 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 index 167dcf20af..7c2a533a76 100644 --- 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 @@ -21,7 +21,7 @@ export const LogMetaSection = ({ content }: { content: string }) => {
Meta
-
{content ?? ""}
+
{content}
+ + } diff --git a/apps/dashboard/app/(app)/logs/constants.ts b/apps/dashboard/app/(app)/logs/constants.ts index 5e12113788..be02968cef 100644 --- a/apps/dashboard/app/(app)/logs/constants.ts +++ b/apps/dashboard/app/(app)/logs/constants.ts @@ -7,5 +7,3 @@ 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; - -export const HISTORICAL_DATA_WINDOW = 12 * 60 * 60 * 1000; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/form-field.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/form-field.tsx new file mode 100644 index 0000000000..6aa2eddbc1 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/form-field.tsx @@ -0,0 +1,30 @@ +import { Label } from "@/components/ui/label"; +import { CircleInfo } from "@unkey/icons"; +import type { ReactNode } from "react"; +import { InputTooltip } from "../_overview/components/table/components/logs-actions/components/input-tooltip"; + +type FormFieldProps = { + label: string; + tooltip?: string; + error?: string; + children: ReactNode; +}; + +export const FormField = ({ label, tooltip, error, children }: FormFieldProps) => ( + // biome-ignore lint/a11y/useKeyWithClickEvents: no need for button +
e.stopPropagation()}> + + {children} + {error && {error}} +
+); diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/namespace-delete-dialog.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/namespace-delete-dialog.tsx new file mode 100644 index 0000000000..0cb52a2edd --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/namespace-delete-dialog.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { revalidateTag } from "@/app/actions"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/toaster"; +import { tags } from "@/lib/cache"; +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@unkey/ui"; +import { validation } from "@unkey/validation"; +import { useRouter } from "next/navigation"; +import type { PropsWithChildren } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { FormField } from "./form-field"; + +const intent = "delete namespace"; + +const formSchema = z.object({ + // biome-ignore lint/suspicious/noSelfCompare: + name: z.string().refine((v) => v === v, "Please confirm the namespace name"), + intent: z.string().refine((v) => v === intent, "Please confirm your intent"), + namespaceId: validation.unkeyId, +}); + +type FormValues = z.infer; +type Props = PropsWithChildren<{ + isModalOpen: boolean; + onOpenChange: (value: boolean) => void; + namespace: { + id: string; + workspaceId: string; + name: string; + }; +}>; + +export const DeleteNamespaceDialog = ({ isModalOpen, onOpenChange, namespace }: Props) => { + const router = useRouter(); + const { + register, + handleSubmit, + watch, + formState: { errors, isSubmitting }, + } = useForm({ + mode: "onChange", + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + }, + }); + + const isValid = watch("intent") === intent && watch("name") === namespace.name; + + const deleteNamespace = trpc.ratelimit.namespace.delete.useMutation({ + onSuccess() { + toast.success("Namespace Deleted", { + description: "Your namespace and all its overridden identifiers have been deleted.", + }); + revalidateTag(tags.namespace(namespace.id)); + router.push("/ratelimits"); + onOpenChange(false); + }, + onError(err) { + toast.error("Failed to delete namespace", { + description: err.message, + }); + }, + }); + + const onSubmit = async () => { + await deleteNamespace.mutateAsync({ namespaceId: namespace.id }); + }; + + return ( + + { + e.preventDefault(); + }} + > + + + Delete Namespace + + + +
+
+ + Warning + + This namespace will be deleted, along with all of its identifiers and data. This + action cannot be undone. + + + + + + + + + + + + +
+ + +
+
+ + +
+
This action cannot be undone
+
+
+
+
+
+ ); +}; + +export const DeleteNamespace = ({ + namespace, +}: { + namespace: Props["namespace"]; +}) => { + return ( + + + Delete + + This namespace will be deleted, along with all of its identifiers and data. This action + cannot be undone. + + + + + {}} /> + + + + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/namespace-update-dialog.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/namespace-update-dialog.tsx new file mode 100644 index 0000000000..6b0ae5da88 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_components/namespace-update-dialog.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { revalidateTag } from "@/app/actions"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "@/components/ui/toaster"; +import { tags } from "@/lib/cache"; +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { CircleInfo } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { validation } from "@unkey/validation"; +import { useRouter } from "next/navigation"; +import type { PropsWithChildren, ReactNode } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { InputTooltip } from "../_overview/components/table/components/logs-actions/components/input-tooltip"; + +const formSchema = z.object({ + name: validation.name, + namespaceId: validation.unkeyId, +}); + +type FormValues = z.infer; + +type FormFieldProps = { + label: string; + tooltip?: string; + error?: string; + children: ReactNode; +}; + +const FormField = ({ label, tooltip, error, children }: FormFieldProps) => ( + // biome-ignore lint/a11y/useKeyWithClickEvents: +
e.stopPropagation()}> + + {children} + {error && {error}} +
+); + +type Props = PropsWithChildren<{ + isModalOpen: boolean; + onOpenChange: (value: boolean) => void; + namespace: { + id: string; + name: string; + workspaceId: string; + }; +}>; + +export const NamespaceUpdateNameDialog = ({ isModalOpen, onOpenChange, namespace }: Props) => { + const router = useRouter(); + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + mode: "onChange", + resolver: zodResolver(formSchema), + defaultValues: { + name: namespace.name, + namespaceId: namespace.id, + }, + }); + + const updateName = trpc.ratelimit.namespace.update.name.useMutation({ + onSuccess() { + toast.success("Your namespace name has been renamed!"); + revalidateTag(tags.namespace(namespace.id)); + router.refresh(); + onOpenChange(false); + }, + onError(err) { + toast.error("Failed to update namespace name", { + description: err.message, + }); + }, + }); + + const onSubmit = async (values: FormValues) => { + if (values.name === namespace.name || !values.name) { + return toast.error("Please provide a different name before saving."); + } + await updateName.mutateAsync({ + name: values.name, + namespaceId: namespace.id, + }); + }; + + return ( + + { + e.preventDefault(); + }} + > + + + Update Namespace Name + + +
+
+ + + + +
+ +
+ +
Name changes are applied immediately
+
+
+
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/components/logs-chart-error.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/components/logs-chart-error.tsx new file mode 100644 index 0000000000..ad93083d2b --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/components/logs-chart-error.tsx @@ -0,0 +1,49 @@ +export const LogsChartError = () => { + return ( +
+ {/* Header section matching the main chart */} +
+
+
REQUESTS
+
--
+
+ +
+
+
+
+
PASSED
+
+
--
+
+
+
+
+
BLOCKED
+
+
--
+
+
+
+ + {/* Chart area */} +
+
+ Could not retrieve logs +
+
+ + {/* Time labels footer */} +
+ {Array(5) + .fill(0) + .map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
+ --:-- +
+ ))} +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/components/logs-chart-loading.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/components/logs-chart-loading.tsx new file mode 100644 index 0000000000..57d142911e --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/components/logs-chart-loading.tsx @@ -0,0 +1,98 @@ +import { calculateTimePoints } from "@/components/logs/chart/utils/calculate-timepoints"; +import { formatTimestampLabel } from "@/components/logs/chart/utils/format-timestamp"; +import { useEffect, useState } from "react"; +import { Bar, BarChart, ResponsiveContainer, YAxis } from "recharts"; + +export const LogsChartLoading = () => { + const [mockData, setMockData] = useState(generateInitialData()); + + function generateInitialData() { + return Array.from({ length: 100 }).map(() => ({ + success: Math.random() * 0.5 + 0.5, + error: Math.random() * 0.3, + originalTimestamp: Date.now(), + })); + } + + useEffect(() => { + const interval = setInterval(() => { + setMockData((prevData) => + prevData.map((item) => ({ + ...item, + success: Math.random() * 0.5 + 0.5, + error: Math.random() * 0.3, + })), + ); + }, 600); // Update every 200ms for smooth animation + + return () => clearInterval(interval); + }, []); + + return ( +
+ {/* Header section */} +
+
+
REQUESTS
+
+   +
+
+ +
+
+
+
+
PASSED
+
+
+   +
+
+
+
+
+
BLOCKED
+
+
+   +
+
+
+
+ + {/* Chart area */} +
+ + + dataMax * 2]} hide /> + + + + +
+ + {/* Time labels footer */} +
+ {calculateTimePoints(Date.now(), Date.now()).map((time, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
+ {formatTimestampLabel(time)} +
+ ))} +
+
+ ); +}; 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 new file mode 100644 index 0000000000..55c9d2bb0c --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/hooks/use-fetch-timeseries.ts @@ -0,0 +1,73 @@ +import { formatTimestampForChart } from "@/components/logs/chart/utils/format-timestamp"; +import { TIMESERIES_DATA_WINDOW } from "@/components/logs/constants"; +import { trpc } from "@/lib/trpc/client"; +import { useMemo } from "react"; +import { useFilters } from "../../../../hooks/use-filters"; +import type { RatelimitOverviewQueryTimeseriesPayload } from "../query-timeseries.schema"; + +export const useFetchRatelimitOverviewTimeseries = (namespaceId: string) => { + const { filters } = useFilters(); + const dateNow = useMemo(() => Date.now(), []); + + const queryParams = useMemo(() => { + const params: RatelimitOverviewQueryTimeseriesPayload = { + namespaceId, + startTime: dateNow - TIMESERIES_DATA_WINDOW, + endTime: dateNow, + identifiers: { filters: [] }, + since: "", + }; + + filters.forEach((filter) => { + switch (filter.field) { + case "identifiers": { + if (typeof filter.value !== "string") { + console.error("Identifier filter value type has to be 'string'"); + return; + } + params.identifiers?.filters.push({ + operator: filter.operator as "is" | "contains", + value: filter.value, + }); + break; + } + case "startTime": + case "endTime": { + if (typeof filter.value !== "number") { + console.error(`${filter.field} filter value type has to be 'number'`); + return; + } + params[filter.field] = filter.value; + break; + } + case "since": { + if (typeof filter.value !== "string") { + console.error("Since filter value type has to be 'string'"); + return; + } + params.since = filter.value; + break; + } + } + }); + + return params; + }, [filters, dateNow, namespaceId]); + + const { data, isLoading, isError } = trpc.ratelimit.logs.queryRatelimitTimeseries.useQuery( + queryParams, + { + refetchInterval: queryParams.endTime ? false : 10_000, + }, + ); + + const timeseries = data?.timeseries.map((ts) => ({ + displayX: formatTimestampForChart(ts.x, data.granularity), + originalTimestamp: ts.x, + success: ts.y.passed, + error: ts.y.total - ts.y.passed, + total: ts.y.total, + })); + + return { timeseries, isLoading, isError }; +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/index.tsx new file mode 100644 index 0000000000..0e2a7e4764 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/index.tsx @@ -0,0 +1,248 @@ +// GenericTimeseriesChart.tsx +"use client"; + +import { calculateTimePoints } from "@/components/logs/chart/utils/calculate-timepoints"; +import { + formatTimestampLabel, + formatTimestampTooltip, +} from "@/components/logs/chart/utils/format-timestamp"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Grid } from "@unkey/icons"; +import { useState } from "react"; +import { Bar, BarChart, CartesianGrid, ReferenceArea, ResponsiveContainer, YAxis } from "recharts"; +import { compactFormatter } from "../../../utils"; +import { LogsChartError } from "./components/logs-chart-error"; +import { LogsChartLoading } from "./components/logs-chart-loading"; + +type Selection = { + start: string | number; + end: string | number; + startTimestamp?: number; + endTimestamp?: number; +}; + +type TimeseriesData = { + originalTimestamp: number; + total: number; + [key: string]: any; +}; + +type LogsTimeseriesBarChartProps = { + data?: TimeseriesData[]; + config: ChartConfig; + onSelectionChange?: (selection: { start: number; end: number }) => void; + isLoading?: boolean; + isError?: boolean; + enableSelection?: boolean; +}; + +export function LogsTimeseriesBarChart({ + data, + config, + onSelectionChange, + isLoading, + isError, + enableSelection = false, +}: LogsTimeseriesBarChartProps) { + const [selection, setSelection] = useState({ start: "", end: "" }); + + const handleMouseDown = (e: any) => { + if (!enableSelection) { + return; + } + const timestamp = e.activePayload?.[0]?.payload?.originalTimestamp; + setSelection({ + start: e.activeLabel, + end: e.activeLabel, + startTimestamp: timestamp, + endTimestamp: timestamp, + }); + }; + + const handleMouseMove = (e: any) => { + if (!enableSelection) { + return; + } + if (selection.start) { + const timestamp = e.activePayload?.[0]?.payload?.originalTimestamp; + setSelection((prev) => ({ + ...prev, + end: e.activeLabel, + startTimestamp: timestamp, + })); + } + }; + + const handleMouseUp = () => { + if (!enableSelection) { + return; + } + if (selection.start && selection.end && onSelectionChange) { + if (!selection.startTimestamp || !selection.endTimestamp) { + return; + } + const [start, end] = [selection.startTimestamp, selection.endTimestamp].sort((a, b) => a - b); + onSelectionChange({ start, end }); + } + setSelection({ + start: "", + end: "", + startTimestamp: undefined, + endTimestamp: undefined, + }); + }; + + if (isError) { + return ; + } + if (isLoading) { + return ; + } + + return ( +
+
+
+
REQUESTS
+
+ {compactFormatter.format( + (data ?? []).reduce((acc, crr) => acc + crr.success + crr.error, 0), + )} +
+
+ +
+
+
+
+
PASSED
+
+
+ {compactFormatter.format((data ?? []).reduce((acc, crr) => acc + crr.success, 0))} +
+
+
+
+
+
BLOCKED
+
+
+ {compactFormatter.format((data ?? []).reduce((acc, crr) => acc + crr.error, 0))} +
+
+
+
+
+ + + + dataMax * 1]} hide /> + + { + if (!active || !payload?.length || payload?.[0]?.payload.total === 0) { + return null; + } + return ( + +
+ +
+
+ + All + + Total +
+
+ + {payload[0]?.payload?.total} + +
+
+
+
+ } + className="rounded-lg shadow-lg border border-gray-4" + labelFormatter={(_, tooltipPayload) => { + const originalTimestamp = tooltipPayload[0]?.payload?.originalTimestamp; + return originalTimestamp ? ( +
+ + {formatTimestampTooltip(originalTimestamp)} + +
+ ) : ( + "" + ); + }} + /> + ); + }} + /> + {Object.keys(config).map((key) => ( + + ))} + {enableSelection && selection.start && selection.end && ( + + )} + + + +
+ +
+ {data + ? calculateTimePoints( + data[0]?.originalTimestamp ?? Date.now(), + data.at(-1)?.originalTimestamp ?? Date.now(), + ).map((time, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
+ {formatTimestampLabel(time)} +
+ )) + : null} +
+
+ ); +} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/query-timeseries.schema.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/query-timeseries.schema.ts new file mode 100644 index 0000000000..f22c77023a --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/bar-chart/query-timeseries.schema.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { ratelimitOverviewFilterOperatorEnum } from "../../../filters.schema"; + +export const ratelimitOverviewQueryTimeseriesPayload = z.object({ + startTime: z.number().int(), + endTime: z.number().int(), + since: z.string(), + namespaceId: z.string(), + identifiers: z + .object({ + filters: z.array( + z.object({ + operator: ratelimitOverviewFilterOperatorEnum, + value: z.string(), + }), + ), + }) + .nullable(), +}); + +export type RatelimitOverviewQueryTimeseriesPayload = z.infer< + typeof ratelimitOverviewQueryTimeseriesPayload +>; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/index.tsx new file mode 100644 index 0000000000..f37b9e2fd5 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/index.tsx @@ -0,0 +1,84 @@ +import { convertDateToLocal } from "@/components/logs/chart/utils/convert-date-to-local"; +import { useFilters } from "../../hooks/use-filters"; +import { LogsTimeseriesBarChart } from "./bar-chart"; +import { useFetchRatelimitOverviewTimeseries } from "./bar-chart/hooks/use-fetch-timeseries"; + +export const RatelimitOverviewLogsCharts = ({ + namespaceId, +}: { + namespaceId: string; +}) => { + const { filters, updateFilters } = useFilters(); + const { isError, isLoading, timeseries } = useFetchRatelimitOverviewTimeseries(namespaceId); + // const { latencyIsError, latencyIsLoading, latencyTimeseries } = + // useFetchRatelimitOverviewLatencyTimeseries(namespaceId); + + const handleSelectionChange = ({ + start, + end, + }: { + start: number; + end: number; + }) => { + const activeFilters = filters.filter( + (f) => !["startTime", "endTime", "since"].includes(f.field), + ); + + updateFilters([ + ...activeFilters, + { + field: "startTime", + value: convertDateToLocal(start), + id: crypto.randomUUID(), + operator: "is", + }, + { + field: "endTime", + value: convertDateToLocal(end), + id: crypto.randomUUID(), + operator: "is", + }, + ]); + }; + + return ( +
+
+ +
+ {/*
*/} + {/* */} + {/*
*/} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/line-chart/components/logs-chart-error.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/line-chart/components/logs-chart-error.tsx new file mode 100644 index 0000000000..2b4626365f --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/line-chart/components/logs-chart-error.tsx @@ -0,0 +1,49 @@ +export const LogsTimeseriesAreaChartError = () => { + return ( +
+ {/* Header section */} +
+
+
DURATION
+
--
+
+ +
+
+
+
+
AVG
+
+
--
+
+
+
+
+
P99
+
+
--
+
+
+
+ + {/* Chart area with error message */} +
+
+ Could not retrieve logs +
+
+ + {/* Time labels footer */} +
+ {Array(5) + .fill(0) + .map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
+ --:-- +
+ ))} +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/line-chart/components/logs-chart-loading.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/line-chart/components/logs-chart-loading.tsx new file mode 100644 index 0000000000..fc5cc91b0e --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/line-chart/components/logs-chart-loading.tsx @@ -0,0 +1,127 @@ +import { calculateTimePoints } from "@/components/logs/chart/utils/calculate-timepoints"; +import { formatTimestampLabel } from "@/components/logs/chart/utils/format-timestamp"; +import { useEffect, useState } from "react"; +import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts"; + +type MockDataPoint = { + avgLatency: number; + p99Latency: number; + originalTimestamp: number; +}; + +function generateInitialData(): MockDataPoint[] { + return Array.from({ length: 100 }).map((_, index) => { + const base = Math.sin(index * 0.1) * 0.5 + 1; + return { + avgLatency: base * 100, + p99Latency: base * 150, + originalTimestamp: Date.now() - (100 - index) * 60000, + }; + }); +} + +export const LogsTimeseriesAreaChartLoading = () => { + const [mockData, setMockData] = useState(generateInitialData()); + + useEffect(() => { + const interval = setInterval(() => { + setMockData((prevData) => + prevData.map((item) => ({ + ...item, + avgLatency: item.avgLatency * (0.95 + Math.random() * 0.1), + p99Latency: item.p99Latency * (0.95 + Math.random() * 0.1), + })), + ); + }, 1000); + + return () => clearInterval(interval); + }, []); + + const currentTime = Date.now(); + const timePoints = calculateTimePoints(currentTime - 100 * 60000, currentTime); + + return ( +
+ {/* Header section */} +
+
+
DURATION
+
+   +
+
+ +
+
+
+
+
AVG
+
+
+   +
+
+
+
+
+
P99
+
+
+   +
+
+
+
+ + {/* Chart area */} +
+ + + + + + + + + + + + + + + dataMax * 1.1]} hide /> + + + + +
+ + {/* Time labels footer */} +
+ {timePoints.map((time, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
+ {formatTimestampLabel(time)} +
+ ))} +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/line-chart/hooks/use-fetch-timeseries.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/line-chart/hooks/use-fetch-timeseries.ts new file mode 100644 index 0000000000..fb35bccf24 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/line-chart/hooks/use-fetch-timeseries.ts @@ -0,0 +1,74 @@ +import { formatTimestampForChart } from "@/components/logs/chart/utils/format-timestamp"; +import { TIMESERIES_DATA_WINDOW } from "@/components/logs/constants"; +import { trpc } from "@/lib/trpc/client"; +import { useMemo } from "react"; +import { useFilters } from "../../../../hooks/use-filters"; +import type { RatelimitOverviewQueryTimeseriesPayload } from "../../bar-chart/query-timeseries.schema"; + +export const useFetchRatelimitOverviewLatencyTimeseries = (namespaceId: string) => { + const { filters } = useFilters(); + const dateNow = useMemo(() => Date.now(), []); + + const queryParams = useMemo(() => { + const params: RatelimitOverviewQueryTimeseriesPayload = { + namespaceId, + startTime: dateNow - TIMESERIES_DATA_WINDOW, + endTime: dateNow, + identifiers: { filters: [] }, + since: "", + }; + + filters.forEach((filter) => { + switch (filter.field) { + case "identifiers": { + if (typeof filter.value !== "string") { + console.error("Identifier filter value type has to be 'string'"); + return; + } + params.identifiers?.filters.push({ + operator: filter.operator as "is" | "contains", + value: filter.value, + }); + break; + } + case "startTime": + case "endTime": { + if (typeof filter.value !== "number") { + console.error(`${filter.field} filter value type has to be 'number'`); + return; + } + params[filter.field] = filter.value; + break; + } + case "since": { + if (typeof filter.value !== "string") { + console.error("Since filter value type has to be 'string'"); + return; + } + params.since = filter.value; + break; + } + } + }); + + return params; + }, [filters, dateNow, namespaceId]); + + const { data, isLoading, isError } = + trpc.ratelimit.overview.logs.queryRatelimitLatencyTimeseries.useQuery(queryParams, { + refetchInterval: queryParams.endTime ? false : 10_000, + }); + + const timeseries = data?.timeseries.map((ts) => ({ + displayX: formatTimestampForChart(ts.x, data.granularity), + originalTimestamp: ts.x, + avgLatency: ts.y.avg_latency, + p99Latency: ts.y.p99_latency, + })); + + return { + latencyTimeseries: timeseries, + latencyIsLoading: isLoading, + latencyIsError: isError, + }; +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/line-chart/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/line-chart/index.tsx new file mode 100644 index 0000000000..2b654aeb31 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/charts/line-chart/index.tsx @@ -0,0 +1,289 @@ +import { calculateTimePoints } from "@/components/logs/chart/utils/calculate-timepoints"; +import { formatTimestampLabel } from "@/components/logs/chart/utils/format-timestamp"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { useEffect, useState } from "react"; +import { + Area, + AreaChart, + CartesianGrid, + ReferenceArea, + ResponsiveContainer, + YAxis, +} from "recharts"; +import { LogsTimeseriesAreaChartError } from "./components/logs-chart-error"; +import { LogsTimeseriesAreaChartLoading } from "./components/logs-chart-loading"; + +const latencyFormatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 2, +}); + +type Selection = { + start: string | number; + end: string | number; + startTimestamp?: number; + endTimestamp?: number; +}; + +type TimeseriesData = { + originalTimestamp: number; + avgLatency: number; + p99Latency: number; + [key: string]: any; +}; + +interface LogsTimeseriesAreaChartProps { + data?: TimeseriesData[]; + config: ChartConfig; + onSelectionChange?: (selection: { start: number; end: number }) => void; + isLoading?: boolean; + isError?: boolean; + enableSelection?: boolean; +} + +const formatTimestampTooltip = (timestamp: number): string => { + return new Date(timestamp).toLocaleString(); +}; + +export const LogsTimeseriesAreaChart: React.FC = ({ + data = [], + config, + onSelectionChange, + isLoading, + isError, + enableSelection = false, +}) => { + const [selection, setSelection] = useState({ start: "", end: "" }); + const [isDarkMode, setIsDarkMode] = useState(false); + + useEffect(() => { + const darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + setIsDarkMode(darkModeMediaQuery.matches); + + const handleThemeChange = (e: MediaQueryListEvent) => setIsDarkMode(e.matches); + darkModeMediaQuery.addEventListener("change", handleThemeChange); + + return () => darkModeMediaQuery.removeEventListener("change", handleThemeChange); + }, []); + + const getThemeColor = (lightColor: string, darkColor: string) => { + return isDarkMode ? darkColor : lightColor; + }; + + const chartConfig = { + avgLatency: { + color: getThemeColor("hsl(var(--accent-11))", "hsl(var(--accent-11))"), + label: config.avgLatency.label, + }, + p99Latency: { + color: getThemeColor("hsl(var(--warning-10))", "hsl(var(--warning-11))"), + label: config.p99Latency.label, + }, + }; + + const handleMouseDown = (e: any) => { + if (!enableSelection) { + return; + } + const timestamp = e?.activePayload?.[0]?.payload?.originalTimestamp; + setSelection({ + start: e.activeLabel, + end: e.activeLabel, + startTimestamp: timestamp, + endTimestamp: timestamp, + }); + }; + + const handleMouseMove = (e: any) => { + if (!enableSelection || !selection.start) { + return; + } + const timestamp = e?.activePayload?.[0]?.payload?.originalTimestamp; + setSelection((prev) => ({ + ...prev, + end: e.activeLabel, + endTimestamp: timestamp, + })); + }; + + const handleMouseUp = () => { + if (!enableSelection) { + return; + } + if (selection.start && selection.end && onSelectionChange) { + if (!selection.startTimestamp || !selection.endTimestamp) { + return; + } + const [start, end] = [selection.startTimestamp, selection.endTimestamp].sort((a, b) => a - b); + onSelectionChange({ start, end }); + } + setSelection({ + start: "", + end: "", + startTimestamp: undefined, + endTimestamp: undefined, + }); + }; + + if (isError) { + return ; + } + if (isLoading) { + return ; + } + + // Calculate metrics + const minLatency = data.length > 0 ? Math.min(...data.map((d) => d.avgLatency)) : 0; + const maxLatency = data.length > 0 ? Math.max(...data.map((d) => d.p99Latency)) : 0; + const avgLatency = + data.length > 0 ? data.reduce((acc, curr) => acc + curr.avgLatency, 0) / data.length : 0; + + return ( +
+
+
+
DURATION
+
+ {minLatency} - {maxLatency}ms +
+
+ +
+
+
+
+
AVG
+
+
+ {latencyFormatter.format(avgLatency)}ms +
+
+
+
+
+
P99
+
+
+ {latencyFormatter.format(maxLatency)}ms +
+
+
+
+ +
+ + + + + + + + + + + + + + + dataMax * 1.1]} hide /> + + { + if (!active || !payload?.length) { + return null; + } + return ( + { + const originalTimestamp = tooltipPayload[0]?.payload?.originalTimestamp; + return originalTimestamp ? ( +
+ + {formatTimestampTooltip(originalTimestamp)} + +
+ ) : ( + "" + ); + }} + /> + ); + }} + /> + + + {enableSelection && selection.start && selection.end && ( + + )} +
+
+
+
+ +
+ {data + ? calculateTimePoints( + data[0]?.originalTimestamp ?? Date.now(), + data.at(-1)?.originalTimestamp ?? Date.now(), + ).map((time, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: +
+ {formatTimestampLabel(time)} +
+ )) + : null} +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/control-cloud/index.tsx new file mode 100644 index 0000000000..408a8452d8 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/control-cloud/index.tsx @@ -0,0 +1,35 @@ +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 "startTime": + return "Start time"; + case "endTime": + return "End time"; + case "status": + return "Status"; + case "requestId": + return "Request ID"; + case "identifiers": + return "Identifier"; + case "since": + return ""; + default: + return field.charAt(0).toUpperCase() + field.slice(1); + } +}; + +export const RatelimitOverviewLogsControlCloud = () => { + const { filters, updateFilters, removeFilter } = useFilters(); + return ( + + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-datetime/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-datetime/index.tsx new file mode 100644 index 0000000000..4007c17f2d --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-datetime/index.tsx @@ -0,0 +1,88 @@ +import { DatetimePopover } from "@/components/logs/datetime/datetime-popover"; +import { cn } from "@/lib/utils"; +import { Calendar } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { useEffect, useState } from "react"; +import { useFilters } from "../../../../hooks/use-filters"; + +export const LogsDateTime = () => { + const [title, setTitle] = useState(null); + const { filters, updateFilters } = useFilters(); + + useEffect(() => { + if (!title) { + setTitle("Last 12 hours"); + } + }, [title]); + + const timeValues = filters + .filter((f) => ["startTime", "endTime", "since"].includes(f.field)) + .reduce( + (acc, f) => ({ + // biome-ignore lint/performance/noAccumulatingSpread: it's safe to spread + ...acc, + [f.field]: f.value, + }), + {}, + ); + + return ( + { + const activeFilters = filters.filter( + (f) => !["endTime", "startTime", "since"].includes(f.field), + ); + if (since !== undefined) { + updateFilters([ + ...activeFilters, + { + field: "since", + value: since, + id: crypto.randomUUID(), + operator: "is", + }, + ]); + return; + } + if (since === undefined && startTime) { + activeFilters.push({ + field: "startTime", + value: startTime, + id: crypto.randomUUID(), + operator: "is", + }); + if (endTime) { + activeFilters.push({ + field: "endTime", + value: endTime, + id: crypto.randomUUID(), + operator: "is", + }); + } + } + updateFilters(activeFilters); + }} + initialTitle={title ?? ""} + onSuggestionChange={setTitle} + > +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-filters/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-filters/index.tsx new file mode 100644 index 0000000000..958d0c3493 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-filters/index.tsx @@ -0,0 +1,75 @@ +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 { ratelimitOverviewFilterFieldConfig } from "../../../../filters.schema"; +import { useFilters } from "../../../../hooks/use-filters"; + +export const LogsFilters = () => { + const { filters, updateFilters } = useFilters(); + + const identifierOperators = ratelimitOverviewFilterFieldConfig.identifiers.operators; + const options = identifierOperators.map((op) => ({ + id: op, + label: op, + })); + + const activeIdentifierFilter = filters.find((f) => f.field === "identifiers"); + return ( + { + const activeFiltersWithoutIdentifiers = filters.filter( + (f) => f.field !== "identifiers", + ); + updateFilters([ + ...activeFiltersWithoutIdentifiers, + { + field: "identifiers", + id: crypto.randomUUID(), + operator: id, + value: text, + }, + ]); + }} + /> + ), + }, + ]} + activeFilters={filters} + > +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-refresh.tsx new file mode 100644 index 0000000000..25e6f6dcdc --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-refresh.tsx @@ -0,0 +1,17 @@ +import { RefreshButton } from "@/components/logs/refresh-button"; +import { trpc } from "@/lib/trpc/client"; +import { useFilters } from "../../../hooks/use-filters"; + +export const LogsRefresh = () => { + const { filters } = useFilters(); + const { ratelimit } = trpc.useUtils(); + const hasRelativeFilter = filters.find((f) => f.field === "since"); + + const handleRefresh = () => { + ratelimit.overview.logs.query.invalidate(); + ratelimit.overview.logs.queryRatelimitLatencyTimeseries.invalidate(); + ratelimit.logs.queryRatelimitTimeseries.invalidate(); + }; + + return ; +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-search/index.tsx new file mode 100644 index 0000000000..85e5b1716d --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-search/index.tsx @@ -0,0 +1,56 @@ +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 = () => { + const { filters, updateFilters } = useFilters(); + const queryLLMForStructuredOutput = trpc.ratelimit.logs.ratelimitLlmSearch.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({ + query, + timestamp: Date.now(), + }) + } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/index.tsx new file mode 100644 index 0000000000..15f27d87df --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/index.tsx @@ -0,0 +1,28 @@ +import { LogsDateTime } from "./components/logs-datetime"; +import { LogsFilters } from "./components/logs-filters"; +import { LogsRefresh } from "./components/logs-refresh"; +import { LogsSearch } from "./components/logs-search"; + +export function RatelimitOverviewLogsControls() { + return ( +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+
+ ); +} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/inline-filter.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/inline-filter.tsx new file mode 100644 index 0000000000..1e3334447c --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/inline-filter.tsx @@ -0,0 +1,53 @@ +import { BarsFilter } from "@unkey/icons"; +import type { RatelimitOverviewFilterValue } from "../../../filters.schema"; +import { useFilters } from "../../../hooks/use-filters"; +import { RatelimitOverviewTooltip } from "./ratelimit-overview-tooltip"; + +type FilterPair = { + status?: "blocked" | "passed"; + identifiers?: string; +}; + +export const InlineFilter = ({ + filterPair, + content, +}: { + filterPair: FilterPair; + content: string; +}) => { + const { filters, updateFilters } = useFilters(); + const fields = Object.entries(filterPair) + .filter(([_, value]) => value !== undefined) + .map(([key]) => key as keyof FilterPair); + + const activeFilters = filters.filter((f) => + ["startTime", "endTime", "since"].includes(f.field as keyof FilterPair), + ); + + return ( + {content}}> + + + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/identifier-dialog.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/identifier-dialog.tsx new file mode 100644 index 0000000000..0723efb749 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/identifier-dialog.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { CircleInfo } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import type { PropsWithChildren, ReactNode } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { z } from "zod"; +import type { OverrideDetails } from "../../../logs-table"; +import { InputTooltip } from "./input-tooltip"; + +const overrideValidationSchema = z.object({ + identifier: z + .string() + .trim() + .min(2, "Name is required and should be at least 2 characters") + .max(250), + limit: z.coerce + .number() + .int() + .min(1, "Limit must be at least 1") + .max(10_000, "Limit cannot exceed 10,000"), + duration: z.coerce + .number() + .int() + .min(1_000, "Duration must be at least 1 second (1000ms)") + .max(24 * 60 * 60 * 1000, "Duration cannot exceed 24 hours"), + async: z.enum(["unset", "sync", "async"]), +}); + +type FormValues = z.infer; + +type FormFieldProps = { + label: string; + tooltip?: string; + error?: string; + children: ReactNode; +}; + +const FormField = ({ label, tooltip, error, children }: FormFieldProps) => ( + // biome-ignore lint/a11y/useKeyWithClickEvents: no need for button +
e.stopPropagation()}> + + {children} + {error && {error}} +
+); + +type Props = PropsWithChildren<{ + isModalOpen: boolean; + onOpenChange: (value: boolean) => void; + identifier: string; + isLoading?: boolean; + namespaceId: string; + overrideDetails?: OverrideDetails | null; +}>; + +export const IdentifierDialog = ({ + isModalOpen, + onOpenChange, + namespaceId, + identifier, + overrideDetails, + isLoading = false, +}: Props) => { + const { ratelimit } = trpc.useUtils(); + const { + register, + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(overrideValidationSchema), + defaultValues: { + identifier, + limit: overrideDetails?.limit ?? 10, + duration: overrideDetails?.duration ?? 60_000, + async: + overrideDetails?.async === undefined ? "unset" : overrideDetails.async ? "async" : "sync", + }, + }); + + const update = trpc.ratelimit.override.update.useMutation({ + onSuccess() { + toast.success("Limits have been updated", { + description: "Changes may take up to 60s to propagate globally", + }); + onOpenChange(false); + ratelimit.overview.logs.query.invalidate(); + }, + onError(err) { + toast.error("Failed to update override", { + description: err.message, + }); + }, + }); + + const create = trpc.ratelimit.override.create.useMutation({ + onSuccess() { + toast.success("Override has been created", { + description: "Changes may take up to 60s to propagate globally", + }); + onOpenChange(false); + ratelimit.overview.logs.query.invalidate(); + }, + onError(err) { + toast.error("Failed to create override", { + description: err.message, + }); + }, + }); + + const onSubmitForm = async (values: FormValues) => { + try { + const asyncValue = { + unset: undefined, + sync: false, + async: true, + }[values.async]; + + if (overrideDetails?.overrideId) { + await update.mutateAsync({ + id: overrideDetails.overrideId, + limit: values.limit, + duration: values.duration, + async: Boolean(overrideDetails.async), + }); + } else { + await create.mutateAsync({ + namespaceId, + identifier: values.identifier, + limit: values.limit, + duration: values.duration, + async: asyncValue, + }); + } + } catch (error) { + console.error("Form submission error:", error); + } + }; + + return ( + + { + // Prevent auto-focus behavior + e.preventDefault(); + }} + > + + + Override Identifier + + +
+
+ + + + + + + + + +
+ + + MS + +
+
+ + + ( + + )} + /> + +
+ + +
+ +
+ Changes are propagated globally within 60 seconds +
+
+
+
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/input-tooltip.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/input-tooltip.tsx new file mode 100644 index 0000000000..ddebd79234 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/input-tooltip.tsx @@ -0,0 +1,15 @@ +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@unkey/ui"; +import type { PropsWithChildren } from "react"; + +export const InputTooltip = ({ desc, children }: PropsWithChildren<{ desc: string }>) => { + return ( + + + {children} + +

{desc}

+
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/table-action-popover.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/table-action-popover.tsx new file mode 100644 index 0000000000..fe58004bf1 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/components/table-action-popover.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { toast } from "@/components/ui/toaster"; +import { Clone, Layers3, PenWriting3 } from "@unkey/icons"; +import Link from "next/link"; +import { type KeyboardEvent, type PropsWithChildren, useEffect, useRef, useState } from "react"; +import { useFilters } from "../../../../../hooks/use-filters"; +import type { OverrideDetails } from "../../../logs-table"; +import { IdentifierDialog } from "./identifier-dialog"; + +type Props = { + identifier: string; + namespaceId: string; + overrideDetails?: OverrideDetails | null; +}; + +export const TableActionPopover = ({ + children, + identifier, + namespaceId, + overrideDetails, +}: PropsWithChildren) => { + const [isModalOpen, setIsModalOpen] = useState(false); + + const [open, setOpen] = useState(false); + const [focusIndex, setFocusIndex] = useState(0); + const menuItems = useRef([]); + const { filters, updateFilters } = useFilters(); + + const timeFilters = filters.filter((f) => ["startTime", "endTime", "since"].includes(f.field)); + + const getTimeParams = () => { + const params = new URLSearchParams({ + identifier: `contains:${identifier}`, + }); + + // Only add time parameters if they exist + const timeMap = { + startTime: timeFilters.find((f) => f.field === "startTime")?.value, + endTime: timeFilters.find((f) => f.field === "endTime")?.value, + since: timeFilters.find((f) => f.field === "since")?.value, + }; + + Object.entries(timeMap).forEach(([key, value]) => { + if (value) { + params.append(key, value.toString()); + } + }); + + return params.toString(); + }; + + useEffect(() => { + if (open) { + setFocusIndex(0); + menuItems.current[0]?.focus(); + } + }, [open]); + + const handleEditClick = (e: React.MouseEvent | KeyboardEvent) => { + e.stopPropagation(); + setOpen(false); + setIsModalOpen(true); + }; + + const handleFilterClick = (e: React.MouseEvent | KeyboardEvent) => { + e.stopPropagation(); + const newFilter = { + id: crypto.randomUUID(), + field: "identifiers" as const, + operator: "is" as const, + value: identifier, + }; + const existingFilters = filters.filter( + (f) => !(f.field === "identifiers" && f.value === identifier), + ); + updateFilters([...existingFilters, newFilter]); + setOpen(false); + }; + + const handleCopy = (e: React.MouseEvent | KeyboardEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(identifier); + toast.success("Copied to clipboard", { + description: identifier, + }); + setOpen(false); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + e.stopPropagation(); + + const activeElement = document.activeElement; + const currentIndex = menuItems.current.findIndex((item) => item === activeElement); + + switch (e.key) { + case "Tab": + e.preventDefault(); + if (!e.shiftKey) { + setFocusIndex((currentIndex + 1) % 3); + menuItems.current[(currentIndex + 1) % 3]?.focus(); + } else { + setFocusIndex((currentIndex - 1 + 3) % 3); + menuItems.current[(currentIndex - 1 + 3) % 3]?.focus(); + } + break; + + case "j": + case "ArrowDown": + e.preventDefault(); + setFocusIndex((currentIndex + 1) % 3); + menuItems.current[(currentIndex + 1) % 3]?.focus(); + break; + + case "k": + case "ArrowUp": + e.preventDefault(); + setFocusIndex((currentIndex - 1 + 3) % 3); + menuItems.current[(currentIndex - 1 + 3) % 3]?.focus(); + break; + case "Escape": + e.preventDefault(); + setOpen(false); + break; + case "Enter": + case "ArrowRight": + case "l": + case " ": + e.preventDefault(); + if (activeElement === menuItems.current[0]) { + handleCopy(e); + } else if (activeElement === menuItems.current[2]) { + handleFilterClick(e); + } + break; + } + }; + + return ( + <> + + e.stopPropagation()} asChild> +
{children}
+
+ { + e.preventDefault(); + menuItems.current[0]?.focus(); + }} + onCloseAutoFocus={(e) => e.preventDefault()} + onEscapeKeyDown={(e) => { + e.preventDefault(); + setOpen(false); + }} + onInteractOutside={(e) => { + e.preventDefault(); + setOpen(false); + }} + > +
e.stopPropagation()} + onKeyDown={handleKeyDown} + > + e.stopPropagation()} + > +
{ + if (el) { + menuItems.current[0] = el; + } + }} + role="menuitem" + tabIndex={focusIndex === 0 ? 0 : -1} + className="flex w-full items-center px-2 py-1.5 gap-3 rounded-lg group cursor-pointer + hover:bg-gray-3 data-[state=open]:bg-gray-3 focus:outline-none focus:bg-gray-3" + > + + Go to logs +
+ + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
{ + if (el) { + menuItems.current[1] = el; + } + }} + role="menuitem" + tabIndex={focusIndex === 1 ? 0 : -1} + className="flex w-full items-center px-2 py-1.5 gap-3 rounded-lg group cursor-pointer + hover:bg-gray-3 data-[state=open]:bg-gray-3 focus:outline-none focus:bg-gray-3" + onClick={handleCopy} + > + + Copy identifier +
+ + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
{ + if (el) { + menuItems.current[2] = el; + } + }} + role="menuitem" + tabIndex={focusIndex === 2 ? 0 : -1} + className="flex w-full items-center px-2 py-1.5 gap-3 rounded-lg group cursor-pointer + hover:bg-orange-3 data-[state=open]:bg-orange-3 focus:outline-none focus:bg-orange-3 text-orange-11" + onClick={handleEditClick} + > + + Override Identifier +
+
+
+
+ + + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/index.tsx new file mode 100644 index 0000000000..69532bdbfa --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/logs-actions/index.tsx @@ -0,0 +1,32 @@ +import { Dots } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import type { OverrideDetails } from "../../logs-table"; +import { TableActionPopover } from "./components/table-action-popover"; + +export const LogsTableAction = ({ + identifier, + namespaceId, + overrideDetails, +}: { + identifier: string; + namespaceId: string; + overrideDetails?: OverrideDetails | null; +}) => { + return ( + + + + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/override-indicator.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/override-indicator.tsx new file mode 100644 index 0000000000..4447abd742 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/override-indicator.tsx @@ -0,0 +1,111 @@ +import { cn } from "@/lib/utils"; +import type { RatelimitOverviewLog } from "@unkey/clickhouse/src/ratelimits"; +import { ArrowDotAntiClockwise, Focus, TriangleWarning2 } from "@unkey/icons"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@unkey/ui"; +import ms from "ms"; +import { calculateBlockedPercentage } from "../utils/calculate-blocked-percentage"; +import { getStatusStyle } from "../utils/get-row-class"; +import { RatelimitOverviewTooltip } from "./ratelimit-overview-tooltip"; + +type IdentifierColumnProps = { + log: RatelimitOverviewLog; +}; + +export const IdentifierColumn = ({ log }: IdentifierColumnProps) => { + const style = getStatusStyle(log); + const hasMoreBlocked = calculateBlockedPercentage(log); + const totalRequests = log.blocked_count + log.passed_count; + const blockRate = totalRequests > 0 ? (log.blocked_count / totalRequests) * 100 : 0; + + return ( +
+ + More than {Math.round(blockRate)}% of requests have been +
+ blocked in this timeframe +

+ } + > +
+ +
+
+
+
+ {log.override ? ( + + ) : ( + + )} +
+
{log.identifier}
+ {log.override && } +
+
+ ); +}; + +interface OverrideIndicatorProps { + log: RatelimitOverviewLog; + style: ReturnType; +} + +const OverrideIndicator = ({ log, style }: OverrideIndicatorProps) => ( + + + +
+
+
+
+ + +
+
+ +
+
+
+ Custom override in effect +
+
+ {log.override && ( +
+ Limit set to{" "} + + {Intl.NumberFormat().format(log.override.limit)}{" "} + + requests per {ms(log.override.duration)} +
+ )} +
+
+ + + +); diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/ratelimit-overview-tooltip.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/ratelimit-overview-tooltip.tsx new file mode 100644 index 0000000000..afcdab3e3b --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/ratelimit-overview-tooltip.tsx @@ -0,0 +1,23 @@ +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@unkey/ui"; +import type { PropsWithChildren } from "react"; + +export const RatelimitOverviewTooltip = ({ + content, + children, +}: PropsWithChildren<{ + content: React.ReactNode; +}>) => { + return ( + + + {children} + + {content} + + + + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/hooks/use-logs-query.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/hooks/use-logs-query.ts new file mode 100644 index 0000000000..c3dc2ab72d --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/hooks/use-logs-query.ts @@ -0,0 +1,123 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import { trpc } from "@/lib/trpc/client"; +import type { RatelimitOverviewLog } from "@unkey/clickhouse/src/ratelimits"; +import { useEffect, useMemo, useState } from "react"; +import { useFilters } from "../../../hooks/use-filters"; +import type { RatelimitQueryOverviewLogsPayload } from "../query-logs.schema"; +import { useSort } from "./use-sort"; + +type UseLogsQueryParams = { + limit?: number; + namespaceId: string; +}; + +export function useRatelimitOverviewLogsQuery({ namespaceId, limit = 50 }: UseLogsQueryParams) { + const [historicalLogsMap, setHistoricalLogsMap] = useState( + () => new Map(), + ); + + const { filters } = useFilters(); + + const historicalLogs = useMemo(() => Array.from(historicalLogsMap.values()), [historicalLogsMap]); + + const { sorts } = useSort(); + + //Required for preventing double trpc call during initial render + const dateNow = useMemo(() => Date.now(), []); + const queryParams = useMemo(() => { + const params: RatelimitQueryOverviewLogsPayload = { + limit, + startTime: dateNow - HISTORICAL_DATA_WINDOW, + endTime: dateNow, + identifiers: { filters: [] }, + status: { filters: [] }, + namespaceId, + since: "", + sorts: sorts.length > 0 ? sorts : null, + }; + + filters.forEach((filter) => { + switch (filter.field) { + case "identifiers": { + if (typeof filter.value !== "string") { + console.error("Identifiers filter value type has to be 'string'"); + return; + } + params.identifiers?.filters.push({ + operator: filter.operator, + value: filter.value, + }); + break; + } + + case "status": { + if (typeof filter.value !== "string") { + console.error("Status filter value type has to be 'string'"); + return; + } + params.status?.filters.push({ + operator: "is", + value: filter.value as "blocked" | "passed", + }); + break; + } + case "startTime": + case "endTime": { + if (typeof filter.value !== "number") { + console.error(`${filter.field} filter value type has to be 'string'`); + return; + } + params[filter.field] = filter.value; + break; + } + + case "since": { + if (typeof filter.value !== "string") { + console.error("Since filter value type has to be 'string'"); + return; + } + params.since = filter.value; + break; + } + } + }); + + return params; + }, [filters, limit, dateNow, namespaceId, sorts]); + + // Main query for historical data + const { + data: initialData, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + isLoading: isLoadingInitial, + } = trpc.ratelimit.overview.logs.query.useInfiniteQuery(queryParams, { + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialCursor: { requestId: null, time: null }, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + // Update historical logs effect + useEffect(() => { + if (initialData) { + const newMap = new Map(); + initialData.pages.forEach((page) => { + page.ratelimitOverviewLogs.forEach((log) => { + newMap.set(log.identifier, log); + }); + }); + setHistoricalLogsMap(newMap); + } + }, [initialData]); + + return { + historicalLogs, + isLoading: isLoadingInitial, + hasMore: hasNextPage, + loadMore: fetchNextPage, + isLoadingMore: isFetchingNextPage, + }; +} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/hooks/use-sort.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/hooks/use-sort.tsx new file mode 100644 index 0000000000..24f5cedd2c --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/hooks/use-sort.tsx @@ -0,0 +1,55 @@ +import { + type SortDirection, + type SortUrlValue, + parseAsSortArray, +} from "@/components/logs/validation/utils/nuqs-parsers"; +import { useQueryState } from "nuqs"; +import { useCallback } from "react"; +import type { z } from "zod"; +import type { sortFields } from "../query-logs.schema"; + +type SortField = z.infer; + +export function useSort() { + const [sortParams, setSortParams] = useQueryState("sorts", parseAsSortArray()); + + const getSortDirection = useCallback( + (columnKey: SortField): SortDirection | undefined => { + return sortParams?.find((sort) => sort.column === columnKey)?.direction; + }, + [sortParams], + ); + + const toggleSort = useCallback( + (columnKey: SortField, multiSort = false) => { + const currentSort = sortParams?.find((sort) => sort.column === columnKey); + const otherSorts = sortParams?.filter((sort) => sort.column !== columnKey) ?? []; + + let newSorts: SortUrlValue[]; + + if (!currentSort) { + // Add new sort + newSorts = multiSort + ? [...(sortParams ?? []), { column: columnKey, direction: "asc" }] + : [{ column: columnKey, direction: "asc" }]; + } else if (currentSort.direction === "asc") { + // Toggle to desc + newSorts = multiSort + ? [...otherSorts, { column: columnKey, direction: "desc" }] + : [{ column: columnKey, direction: "desc" }]; + } else { + // Remove sort + newSorts = multiSort ? otherSorts : []; + } + + setSortParams(newSorts.length > 0 ? newSorts : null); + }, + [sortParams, setSortParams], + ); + + return { + sorts: sortParams ?? [], + getSortDirection, + toggleSort, + } as const; +} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/logs-table.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/logs-table.tsx new file mode 100644 index 0000000000..1eef5f83f5 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/logs-table.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { TimestampInfo } from "@/components/timestamp-info"; +import { Badge } from "@/components/ui/badge"; +import { VirtualTable } from "@/components/virtual-table/index"; +import type { Column } from "@/components/virtual-table/types"; +import { cn } from "@/lib/utils"; +import type { RatelimitOverviewLog } from "@unkey/clickhouse/src/ratelimits"; +import { Ban, BookBookmark } from "@unkey/icons"; +import { Button, Empty } from "@unkey/ui"; +import { compactFormatter } from "../../utils"; +import { InlineFilter } from "./components/inline-filter"; +import { LogsTableAction } from "./components/logs-actions"; +import { IdentifierColumn } from "./components/override-indicator"; +import { useRatelimitOverviewLogsQuery } from "./hooks/use-logs-query"; +import { useSort } from "./hooks/use-sort"; +import { STATUS_STYLES, getRowClassName, getStatusStyle } from "./utils/get-row-class"; + +// const MAX_LATENCY = 10; +export type OverrideDetails = { + overrideId?: string; + limit: number; + duration: number; + async?: boolean | null; +}; + +export const RatelimitOverviewLogsTable = ({ + namespaceId, +}: { + namespaceId: string; +}) => { + const { getSortDirection, toggleSort } = useSort(); + const { historicalLogs, isLoading, isLoadingMore, loadMore } = useRatelimitOverviewLogsQuery({ + namespaceId, + }); + + const columns = (namespaceId: string): Column[] => { + return [ + { + key: "identifier", + header: "Identifier", + width: "7.5%", + headerClassName: "pl-11", + render: (log) => { + return ( +
+ + +
+ ); + }, + }, + { + key: "passed", + header: "Passed", + width: "7.5%", + render: (log) => { + return ( +
+ + {compactFormatter.format(log.passed_count)} + + +
+ ); + }, + }, + { + key: "blocked", + header: "Blocked", + width: "7.5%", + render: (log) => { + const style = getStatusStyle(log); + return ( +
+ + + {compactFormatter.format(log.blocked_count)} + + +
+ ); + }, + }, + // { + // key: "avgLatency", + // header: "Avg. Latency", + // width: "7.5%", + // sort: { + // direction: getSortDirection("avg_latency"), + // sortable: true, + // onSort() { + // toggleSort("avg_latency", true); + // }, + // }, + // render: (log) => ( + //
MAX_LATENCY + // ? "text-orange-11 font-medium dark:text-warning-11" + // : "text-accent-9", + // )} + // > + // {Math.round(log.avg_latency)}ms + //
+ // ), + // }, + // { + // key: "p99", + // header: "P99 Latency", + // width: "7.5%", + // sort: { + // direction: getSortDirection("p99_latency"), + // sortable: true, + // onSort() { + // toggleSort("p99_latency", true); + // }, + // }, + // render: (log) => ( + //
MAX_LATENCY + // ? "text-orange-11 font-medium dark:text-warning-11" + // : "text-accent-9", + // )} + // > + // {Math.round(log.p99_latency)}ms + //
+ // ), + // }, + { + key: "lastRequest", + header: "Last Request", + width: "7.5%", + sort: { + direction: getSortDirection("time"), + sortable: true, + onSort() { + toggleSort("time", true); + }, + }, + render: (log) => ( +
+ +
+ +
+
+ ), + }, + ]; + }; + + return ( + log.identifier} + rowClassName={getRowClassName} + emptyState={ +
+ + + Logs + + No rate limit data to show. Once requests are made, you'll see a summary of passed and + blocked requests for each rate limit identifier. + + + + + + + +
+ } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/query-logs.schema.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/query-logs.schema.ts new file mode 100644 index 0000000000..b55d74191b --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/query-logs.schema.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; +import { ratelimitOverviewFilterOperatorEnum } from "../../filters.schema"; + +export const sortFields = z.enum(["time", "avg_latency", "p99_latency"]); +export const ratelimitQueryOverviewLogsPayload = z.object({ + limit: z.number().int(), + startTime: z.number().int(), + endTime: z.number().int(), + namespaceId: z.string(), + status: z + .object({ + filters: z.array( + z.object({ + operator: z.literal("is"), + value: z.enum(["blocked", "passed"]), + }), + ), + }) + .nullable(), + since: z.string(), + identifiers: z + .object({ + filters: z.array( + z.object({ + operator: ratelimitOverviewFilterOperatorEnum, + value: z.string(), + }), + ), + }) + .nullable(), + cursor: z + .object({ + requestId: z.string().nullable(), + time: z.number().nullable(), + }) + .optional() + .nullable(), + sorts: z + .array( + z.object({ + column: sortFields, + direction: z.enum(["asc", "desc"]), + }), + ) + .optional() + .nullable(), +}); + +export type RatelimitQueryOverviewLogsPayload = z.infer; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/utils/calculate-blocked-percentage.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/utils/calculate-blocked-percentage.ts new file mode 100644 index 0000000000..2266dc6a25 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/utils/calculate-blocked-percentage.ts @@ -0,0 +1,9 @@ +import type { RatelimitOverviewLog } from "@unkey/clickhouse/src/ratelimits"; + +export const calculateBlockedPercentage = (log: RatelimitOverviewLog) => { + const totalRequests = log.blocked_count + log.passed_count; + const blockRate = totalRequests > 0 ? (log.blocked_count / totalRequests) * 100 : 0; + const hasMoreBlocked = blockRate > 60; + + return hasMoreBlocked; +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/utils/format-duration.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/utils/format-duration.ts new file mode 100644 index 0000000000..6c99e968c5 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/utils/format-duration.ts @@ -0,0 +1,20 @@ +export const formatDuration = (ms: number) => { + const seconds = ms / 1000; + + if (seconds < 60) { + return { value: seconds, unit: "second" }; + } + + const minutes = seconds / 60; + if (minutes < 60) { + return { value: minutes, unit: "minute" }; + } + + const hours = minutes / 60; + if (hours < 24) { + return { value: hours, unit: "hour" }; + } + + const days = hours / 24; + return { value: days, unit: "day" }; +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/utils/get-row-class.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/utils/get-row-class.ts new file mode 100644 index 0000000000..0c6963efe3 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/table/utils/get-row-class.ts @@ -0,0 +1,55 @@ +import { cn } from "@/lib/utils"; +import type { RatelimitOverviewLog } from "@unkey/clickhouse/src/ratelimits"; +import { calculateBlockedPercentage } from "./calculate-blocked-percentage"; + +type StatusStyle = { + base: string; + hover: string; + selected: string; + badge: { + default: string; + selected: string; + }; + focusRing: string; +}; + +export const STATUS_STYLES = { + success: { + base: "text-accent-9", + hover: "hover:bg-accent-3", + selected: "text-accent-11 bg-accent-3", + badge: { + default: "bg-accent-4 text-accent-11 group-hover:bg-accent-5", + selected: "bg-accent-5 text-accent-12 hover:bg-hover-5", + }, + focusRing: "focus:ring-accent-7", + }, + blocked: { + base: "text-orange-11", + hover: "hover:bg-orange-3", + selected: "bg-orange-3", + badge: { + default: "bg-orange-4 text-orange-11 group-hover:bg-orange-5", + selected: "bg-orange-5 text-orange-11 hover:bg-orange-5", + }, + focusRing: "focus:ring-orange-7", + }, +}; + +export const getStatusStyle = (log: RatelimitOverviewLog): StatusStyle => { + return calculateBlockedPercentage(log) ? STATUS_STYLES.blocked : STATUS_STYLES.success; +}; + +export const getRowClassName = (log: RatelimitOverviewLog) => { + const hasMoreBlocked = calculateBlockedPercentage(log); + const style = getStatusStyle(log); + + return cn( + style.base, + style.hover, + hasMoreBlocked ? "bg-orange-2" : "", + "group rounded-md", + "focus:outline-none focus:ring-1 focus:ring-opacity-40", + style.focusRing, + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/context/logs.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/context/logs.tsx new file mode 100644 index 0000000000..b5d9fd932e --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/context/logs.tsx @@ -0,0 +1,48 @@ +"use client"; + +import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; +import { type PropsWithChildren, createContext, useContext, useState } from "react"; + +type LogsContextType = { + isLive: boolean; + toggleLive: (value?: boolean) => void; + selectedLog: RatelimitLog | null; + setSelectedLog: (log: RatelimitLog | null) => void; + namespaceId: string; +}; + +const LogsContext = createContext(null); + +export const RatelimitOverviewLogsProvider = ({ + children, + namespaceId, +}: PropsWithChildren<{ namespaceId: string }>) => { + const [selectedLog, setSelectedLog] = useState(null); + const [isLive, setIsLive] = useState(false); + + const toggleLive = (value?: boolean) => { + setIsLive((prev) => (typeof value !== "undefined" ? value : !prev)); + }; + + return ( + + {children} + + ); +}; + +export const useRatelimitOverviewLogsContext = () => { + const context = useContext(LogsContext); + if (!context) { + throw new Error("useLogsContext must be used within a LogsProvider"); + } + return context; +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/filters.schema.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/filters.schema.ts new file mode 100644 index 0000000000..88fa80872f --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/filters.schema.ts @@ -0,0 +1,77 @@ +import type { + FilterValue, + NumberConfig, + StringConfig, +} from "@/components/logs/validation/filter.types"; +import { createFilterOutputSchema } from "@/components/logs/validation/utils/structured-output-schema-generator"; +import { z } from "zod"; + +// Configuration +export const ratelimitOverviewFilterFieldConfig: FilterFieldConfigs = { + startTime: { + type: "number", + operators: ["is"], + }, + endTime: { + type: "number", + operators: ["is"], + }, + since: { + type: "string", + operators: ["is"], + }, + identifiers: { + type: "string", + operators: ["is", "contains"], + }, + status: { + type: "string", + operators: ["is"], + validValues: ["blocked", "passed"], + getColorClass: (value) => (value === "blocked" ? "bg-warning-9" : "bg-success-9"), + } as const, +}; + +// Schemas +export const ratelimitOverviewFilterOperatorEnum = z.enum(["is", "contains"]); +export const ratelimitOverviewFilterFieldEnum = z.enum([ + "startTime", + "endTime", + "since", + "identifiers", + "status", +]); +export const filterOutputSchema = createFilterOutputSchema( + ratelimitOverviewFilterFieldEnum, + ratelimitOverviewFilterOperatorEnum, + ratelimitOverviewFilterFieldConfig, +); + +// Types +export type RatelimitOverviewFilterOperator = z.infer; +export type RatelimitOverviewFilterField = z.infer; + +export type FilterFieldConfigs = { + startTime: NumberConfig; + endTime: NumberConfig; + since: StringConfig; + identifiers: StringConfig; + status: StringConfig; +}; + +export type RatelimitOverviewFilterUrlValue = Pick< + FilterValue, + "value" | "operator" +>; +export type RatelimitOverviewFilterValue = FilterValue< + RatelimitOverviewFilterField, + RatelimitOverviewFilterOperator +>; + +export type RatelimitQuerySearchParams = { + startTime?: number | null; + endTime?: number | null; + since?: string | null; + identifiers: RatelimitOverviewFilterUrlValue[] | null; + status: RatelimitOverviewFilterUrlValue[] | null; +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/hooks/use-filters.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/hooks/use-filters.ts new file mode 100644 index 0000000000..66618e4d92 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/hooks/use-filters.ts @@ -0,0 +1,130 @@ +import { + parseAsFilterValueArray, + parseAsRelativeTime, +} from "@/components/logs/validation/utils/nuqs-parsers"; +import { parseAsInteger, useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; +import { + type RatelimitOverviewFilterField, + type RatelimitOverviewFilterOperator, + type RatelimitOverviewFilterUrlValue, + type RatelimitOverviewFilterValue, + type RatelimitQuerySearchParams, + ratelimitOverviewFilterFieldConfig, +} from "../filters.schema"; + +const parseAsFilterValArray = parseAsFilterValueArray([ + "is", + "contains", +]); +export const queryParamsPayload = { + identifiers: parseAsFilterValArray, + startTime: parseAsInteger, + endTime: parseAsInteger, + since: parseAsRelativeTime, + status: parseAsFilterValArray, +} as const; + +export const useFilters = () => { + const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload); + const filters = useMemo(() => { + const activeFilters: RatelimitOverviewFilterValue[] = []; + + searchParams.identifiers?.forEach((pathFilter) => { + activeFilters.push({ + id: crypto.randomUUID(), + field: "identifiers", + operator: pathFilter.operator, + value: pathFilter.value, + }); + }); + + searchParams.status?.forEach((statusFilter) => { + activeFilters.push({ + id: crypto.randomUUID(), + field: "status", + operator: statusFilter.operator, + value: statusFilter.value, + metadata: { + colorClass: ratelimitOverviewFilterFieldConfig.status.getColorClass?.( + statusFilter.value as string, + ), + }, + }); + }); + + ["startTime", "endTime", "since"].forEach((field) => { + const value = searchParams[field as keyof RatelimitQuerySearchParams]; + if (value !== null && value !== undefined) { + activeFilters.push({ + id: crypto.randomUUID(), + field: field as RatelimitOverviewFilterField, + operator: "is", + value: value as string | number, + }); + } + }); + + return activeFilters; + }, [searchParams]); + + const updateFilters = useCallback( + (newFilters: RatelimitOverviewFilterValue[]) => { + const newParams: Partial = { + startTime: null, + endTime: null, + since: null, + identifiers: null, + }; + + const identifierFilters: RatelimitOverviewFilterUrlValue[] = []; + const statusFilters: RatelimitOverviewFilterUrlValue[] = []; + + newFilters.forEach((filter) => { + switch (filter.field) { + case "identifiers": + identifierFilters.push({ + value: filter.value, + operator: filter.operator, + }); + break; + + case "status": + statusFilters.push({ + value: filter.value, + operator: filter.operator, + }); + break; + + case "startTime": + case "endTime": + newParams[filter.field] = filter.value as number; + break; + case "since": + newParams.since = filter.value as string; + break; + } + }); + + // Set arrays to null when empty, otherwise use the filtered values + newParams.identifiers = identifierFilters.length > 0 ? identifierFilters : null; + newParams.status = statusFilters.length > 0 ? statusFilters : 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)/ratelimits/[namespaceId]/_overview/logs-client.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/logs-client.tsx new file mode 100644 index 0000000000..e11075a796 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/logs-client.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { RatelimitOverviewLogsCharts } from "./components/charts"; +import { RatelimitOverviewLogsControlCloud } from "./components/control-cloud"; +import { RatelimitOverviewLogsControls } from "./components/controls"; +import { RatelimitOverviewLogsTable } from "./components/table/logs-table"; + +export const LogsClient = ({ namespaceId }: { namespaceId: string }) => { + return ( +
+ + + + +
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/utils.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/utils.ts new file mode 100644 index 0000000000..37b0b9c7e6 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/utils.ts @@ -0,0 +1,4 @@ +export const compactFormatter = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/constants.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/constants.ts deleted file mode 100644 index 361fdb74a1..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/constants.ts +++ /dev/null @@ -1,23 +0,0 @@ -export const navigation = (namespaceId: string) => [ - { - label: "Overview", - href: `/ratelimits/${namespaceId}`, - segment: "overview", - }, - - { - label: "Settings", - href: `/ratelimits/${namespaceId}/settings`, - segment: "settings", - }, - { - label: "Logs", - href: `/ratelimits/${namespaceId}/logs`, - segment: "logs", - }, - { - label: "Overrides", - href: `/ratelimits/${namespaceId}/overrides`, - segment: "overrides", - }, -]; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/filters.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/filters.tsx deleted file mode 100644 index 2e1f6a1198..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/filters.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; - -import { ArrayInput } from "@/components/array-input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { cn } from "@/lib/utils"; -import { Button } from "@unkey/ui"; -import { RefreshCw } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { parseAsArrayOf, parseAsString, parseAsStringEnum, useQueryState } from "nuqs"; -import type React from "react"; -import { useTransition } from "react"; - -export const intervals = { - "60m": "Last 60 minutes", - "24h": "Last 24 hours", - "7d": "Last 7 days", - "30d": "Last 30 days", - "90d": "Last 3 months", -} as const; - -export type Interval = keyof typeof intervals; - -export const Filters: React.FC<{ identifier?: boolean; interval?: boolean }> = (props) => { - const router = useRouter(); - const [isPending, startTransition] = useTransition(); - - const [interval, setInterval] = useQueryState( - "interval", - parseAsStringEnum(["60m", "24h", "7d", "30d", "90d"]).withDefault("7d").withOptions({ - history: "push", - shallow: false, // otherwise server components won't notice the change - }), - ); - const [identifier, setIdentifier] = useQueryState( - "identifier", - parseAsArrayOf(parseAsString).withDefault([]).withOptions({ - history: "push", - shallow: false, // otherwise server components won't notice the change - }), - ); - - return ( -
- {props.identifier ? ( -
- { - setIdentifier(v); - startTransition(() => {}); - }} - /> -
- ) : null}{" "} - {props.interval ? ( -
- -
- ) : null} -
- -
-
- ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/loading.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/loading.tsx deleted file mode 100644 index 94d63ddeeb..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/loading.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Loading } from "@/components/dashboard/loading"; - -export default function () { - // You can add any UI inside Loading, including a Skeleton. - return ( -
- -
- ); -} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/hooks/use-fetch-timeseries.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/hooks/use-fetch-timeseries.ts index d7ca38ed0a..6303e7671d 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/hooks/use-fetch-timeseries.ts +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/hooks/use-fetch-timeseries.ts @@ -1,32 +1,10 @@ +import { formatTimestampForChart } from "@/components/logs/chart/utils/format-timestamp"; +import { TIMESERIES_DATA_WINDOW } from "@/components/logs/constants"; import { trpc } from "@/lib/trpc/client"; -import type { TimeseriesGranularity } from "@/lib/trpc/routers/ratelimit/query-timeseries/utils"; -import { addMinutes, format } from "date-fns"; import { useMemo } from "react"; import { useFilters } from "../../../hooks/use-filters"; import type { RatelimitQueryTimeseriesPayload } from "../query-timeseries.schema"; -// Duration in milliseconds for historical data fetch window (1 hours) -const TIMESERIES_DATA_WINDOW = 60 * 60 * 1000; - -const formatTimestamp = (value: string | number, granularity: TimeseriesGranularity) => { - const date = new Date(value); - const offset = new Date().getTimezoneOffset() * -1; - const localDate = addMinutes(date, offset); - - switch (granularity) { - case "perMinute": - return format(localDate, "HH:mm:ss"); - case "perHour": - return format(localDate, "MMM d, HH:mm"); - case "perDay": - return format(localDate, "MMM d"); - case "perMonth": - return format(localDate, "MMM yyyy"); - default: - return format(localDate, "Pp"); - } -}; - export const useFetchRatelimitTimeseries = (namespaceId: string) => { const { filters } = useFilters(); const dateNow = useMemo(() => Date.now(), []); @@ -84,7 +62,7 @@ export const useFetchRatelimitTimeseries = (namespaceId: string) => { ); const timeseries = data?.timeseries.map((ts) => ({ - displayX: formatTimestamp(ts.x, data.granularity), + displayX: formatTimestampForChart(ts.x, data.granularity), originalTimestamp: ts.x, success: ts.y.passed, error: ts.y.total - ts.y.passed, diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/control-cloud/index.tsx index 3f297564f8..a36c80f255 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/control-cloud/index.tsx @@ -1,5 +1,5 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; import { ControlCloud } from "@/components/logs/control-cloud"; -import { HISTORICAL_DATA_WINDOW } from "../../constants"; import { useFilters } from "../../hooks/use-filters"; const formatFieldName = (field: string): string => { diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/identifiers-filter.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/identifiers-filter.tsx index 2987d57bfd..2655da3d9e 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/identifiers-filter.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/identifiers-filter.tsx @@ -1,78 +1,36 @@ -import { InputSearch } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { cn } from "@unkey/ui/src/lib/utils"; -import { useState } from "react"; +import { FilterOperatorInput } from "@/components/logs/filter-operator-input"; +import { ratelimitFilterFieldConfig } from "../../../../../filters.schema"; import { useFilters } from "../../../../../hooks/use-filters"; export const IdentifiersFilter = () => { const { filters, updateFilters } = useFilters(); - const activeFilter = filters.find((f) => f.field === "identifiers"); - const [searchText, setSearchText] = useState(activeFilter?.value.toString() ?? ""); - const [isFocused, setIsFocused] = useState(false); - const handleSearch = () => { - const activeFilters = filters.filter((f) => f.field !== "identifiers"); - if (searchText.trim()) { - updateFilters([ - ...activeFilters, - { - field: "identifiers", - value: searchText, - id: crypto.randomUUID(), - operator: "contains", - }, - ]); - } else { - updateFilters(activeFilters); - } - }; + const identifierOperators = ratelimitFilterFieldConfig.identifiers.operators; + const options = identifierOperators.map((op) => ({ + id: op, + label: op, + })); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (isFocused) { - e.stopPropagation(); - if (e.key === "Enter") { - handleSearch(); - } - } - }; - - const handleFocus = () => { - setIsFocused(true); - }; - - const handleBlur = () => { - setIsFocused(false); - }; + const activeIdentifierFilter = filters.find((f) => f.field === "identifiers"); return ( -
-
-
- - setSearchText(e.target.value)} - onKeyDown={handleKeyDown} - onFocus={handleFocus} - onBlur={handleBlur} - type="text" - placeholder="Search for identifier..." - className="w-full text-[13px] font-medium text-accent-12 bg-transparent border-none outline-none focus:ring-0 focus:outline-none placeholder:text-accent-12" - /> -
-
- -
+ { + const activeFiltersWithoutIdentifiers = filters.filter((f) => f.field !== "identifiers"); + updateFilters([ + ...activeFiltersWithoutIdentifiers, + { + field: "identifiers", + id: crypto.randomUUID(), + operator: id, + value: text, + }, + ]); + }} + /> ); }; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-live-switch.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-live-switch.tsx index e738fbf289..9f60f79d75 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-live-switch.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-live-switch.tsx @@ -1,5 +1,5 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; import { LiveSwitchButton } from "@/components/logs/live-switch-button"; -import { HISTORICAL_DATA_WINDOW } from "../../../constants"; import { useRatelimitLogsContext } from "../../../context/logs"; import { useFilters } from "../../../hooks/use-filters"; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.test.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.test.ts index 7eaefb0b95..ff6709cd2a 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.test.ts +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.test.ts @@ -54,7 +54,7 @@ describe("useRatelimitLogsQuery filter processing", () => { it("handles valid status filter", () => { mockFilters = [{ field: "status", operator: "is", value: "rejected" }]; - const { result } = renderHook(() => useRatelimitLogsQuery()); + const { result } = renderHook(() => useRatelimitLogsQuery({ namespaceId: "test-namespace" })); expect(result.current.isPolling).toBe(false); }); @@ -64,7 +64,7 @@ describe("useRatelimitLogsQuery filter processing", () => { { field: "identifiers", operator: "is", value: "test-id" }, { field: "requestIds", operator: "is", value: "req-123" }, ]; - const { result } = renderHook(() => useRatelimitLogsQuery()); + const { result } = renderHook(() => useRatelimitLogsQuery({ namespaceId: "test-namespace" })); expect(result.current.isPolling).toBe(false); }); @@ -75,7 +75,7 @@ describe("useRatelimitLogsQuery filter processing", () => { { field: "requestIds", operator: "is", value: true }, { field: "status", operator: "is", value: {} }, ]; - renderHook(() => useRatelimitLogsQuery()); + renderHook(() => useRatelimitLogsQuery({ namespaceId: "test-namspace" })); expect(consoleMock).toHaveBeenCalledTimes(3); }); @@ -84,7 +84,7 @@ describe("useRatelimitLogsQuery filter processing", () => { { field: "startTime", operator: "is", value: mockDate - 3600000 }, { field: "since", operator: "is", value: "1h" }, ]; - const { result } = renderHook(() => useRatelimitLogsQuery()); + const { result } = renderHook(() => useRatelimitLogsQuery({ namespaceId: "test-namespace" })); expect(result.current.isPolling).toBe(false); }); }); @@ -140,7 +140,12 @@ describe("useRatelimitLogsQuery realtime logs", () => { }); const { result, rerender } = renderHook( - ({ startPolling, pollIntervalMs }) => useRatelimitLogsQuery({ startPolling, pollIntervalMs }), + ({ startPolling, pollIntervalMs }) => + useRatelimitLogsQuery({ + startPolling, + pollIntervalMs, + namespaceId: "test-namespace", + }), { initialProps: { startPolling: true, pollIntervalMs: 1000 } }, ); diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.ts index b65854838c..f80bbcf4d3 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.ts +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.ts @@ -1,24 +1,24 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; import { trpc } from "@/lib/trpc/client"; import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { HISTORICAL_DATA_WINDOW } from "../../../constants"; import { useFilters } from "../../../hooks/use-filters"; import type { RatelimitQueryLogsPayload } from "../query-logs.schema"; -// Duration in milliseconds for historical data fetch window (12 hours) type UseLogsQueryParams = { limit?: number; pollIntervalMs?: number; startPolling?: boolean; - namespaceId?: string; + namespaceId: string; }; +const REALTIME_DATA_LIMIT = 100; export function useRatelimitLogsQuery({ namespaceId, limit = 50, pollIntervalMs = 5000, startPolling = false, -}: UseLogsQueryParams = {}) { +}: UseLogsQueryParams) { const [historicalLogsMap, setHistoricalLogsMap] = useState(() => new Map()); const [realtimeLogsMap, setRealtimeLogsMap] = useState(() => new Map()); @@ -26,7 +26,7 @@ export function useRatelimitLogsQuery({ const queryClient = trpc.useUtils(); const realtimeLogs = useMemo(() => { - return Array.from(realtimeLogsMap.values()); + return sortLogs(Array.from(realtimeLogsMap.values())); }, [realtimeLogsMap]); const historicalLogs = useMemo(() => Array.from(historicalLogsMap.values()), [historicalLogsMap]); @@ -41,7 +41,6 @@ export function useRatelimitLogsQuery({ requestIds: { filters: [] }, identifiers: { filters: [] }, status: { filters: [] }, - //@ts-expect-error will be fixed later namespaceId, since: "", }; @@ -123,7 +122,6 @@ export function useRatelimitLogsQuery({ }); // Query for new logs (polling) - // biome-ignore lint/correctness/useExhaustiveDependencies: biome wants to everything as dep const pollForNewLogs = useCallback(async () => { try { const latestTime = realtimeLogs[0]?.time ?? historicalLogs[0]?.time; @@ -150,9 +148,15 @@ export function useRatelimitLogsQuery({ newMap.set(log.request_id, log); added++; - if (newMap.size > Math.min(limit, 100)) { - const oldestKey = Array.from(newMap.keys()).shift()!; - newMap.delete(oldestKey); + // Remove oldest entries when exceeding the size limit to prevent memory issues + // We use min(limit, REALTIME_DATA_LIMIT) to ensure a reasonable upper bound + if (newMap.size > Math.min(limit, REALTIME_DATA_LIMIT)) { + // Find and remove the entry with the oldest timestamp + 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]); } } @@ -162,7 +166,15 @@ export function useRatelimitLogsQuery({ } catch (error) { console.error("Error polling for new logs:", error); } - }, [queryParams, queryClient, limit, pollIntervalMs, historicalLogsMap]); + }, [ + queryParams, + queryClient, + limit, + pollIntervalMs, + historicalLogsMap, + realtimeLogs, + historicalLogs, + ]); // Set up polling effect useEffect(() => { @@ -202,3 +214,7 @@ export function useRatelimitLogsQuery({ isPolling: startPolling, }; } + +const sortLogs = (logs: RatelimitLog[]) => { + return logs.toSorted((a, b) => b.time - a.time); +}; 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 index a86487e144..39fee40773 100644 --- 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 @@ -36,8 +36,8 @@ export const LogSection = ({ const value = valueParts.join(":").trim(); return (
- {key}: - {value} + {key}: + {value}
); }) 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 b6177d0b22..406deae409 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 @@ -46,19 +46,39 @@ export const RatelimitLogDetails = ({ distanceToTop }: Props) => { > - "} + 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/components/table/logs-actions/components/table-action-popover.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/components/table-action-popover.tsx index bddbef00af..36e7347801 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/components/table-action-popover.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/components/table-action-popover.tsx @@ -4,6 +4,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { toast } from "@/components/ui/toaster"; import { Clipboard, ClipboardCheck, InputSearch, PenWriting3 } from "@unkey/icons"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { type KeyboardEvent, type PropsWithChildren, useEffect, useRef, useState } from "react"; import { useRatelimitLogsContext } from "../../../../context/logs"; import { useFilters } from "../../../../hooks/use-filters"; @@ -13,6 +14,7 @@ type Props = { }; export const TableActionPopover = ({ children, identifier }: PropsWithChildren) => { + const { push } = useRouter(); const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); const [focusIndex, setFocusIndex] = useState(0); @@ -93,6 +95,8 @@ export const TableActionPopover = ({ children, identifier }: PropsWithChildren

{ header: "Limit", width: "auto", render: (log) => { - return

{safeParseJson(log.response_body).limit}
; + return ( +
{safeParseJson(log.response_body)?.limit ?? ""}
+ ); }, }, { @@ -154,8 +156,11 @@ export const RatelimitLogsTable = () => { header: "Duration", width: "auto", render: (log) => { + const parsedDuration = safeParseJson(log.request_body)?.duration; return ( -
{msToSeconds(safeParseJson(log.request_body).duration)}
+
+ {parsedDuration ? msToSeconds(parsedDuration) : ""} +
); }, }, @@ -164,16 +169,19 @@ export const RatelimitLogsTable = () => { header: "Resets At", width: "auto", render: (log) => { - return ( + const parsedReset = safeParseJson(log.response_body)?.reset; + return parsedReset ? (
"} className={cn( "font-mono group-hover:underline decoration-dotted", selectedLog && selectedLog.request_id !== log.request_id && "pointer-events-none", )} />
+ ) : ( + "" ); }, }, @@ -216,9 +224,8 @@ export const RatelimitLogsTable = () => { Logs - Keep track of all activity within your workspace. Logs automatically record key - actions like key creation, permission updates, and configuration changes, giving you a - clear history of resource requests. + No ratelimit logs yet. Once API requests start coming in, you'll see a detailed view + of your rate limits, including passed and blocked requests, across your API endpoints. and(eq(table.id, namespaceId), isNull(table.deletedAt)), - with: { - workspace: true, - }, - }); - if (!namespace || namespace.workspace.tenantId !== tenantId) { - return notFound(); - } - - return ; -} + const { namespace, ratelimitNamespaces } = await getWorkspaceDetails(namespaceId); -const LogsContainerPage = ({ - namespaceId, - namespaceName, -}: { - namespaceId: string; - namespaceName: string; -}) => { return (
- - }> - Ratelimits - - {namespaceName.length > 0 ? namespaceName : ""} - - - Logs - - - - - {namespaceId} - - - - - +
); -}; +} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/namespace-navbar.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/namespace-navbar.tsx new file mode 100644 index 0000000000..1f24e34de3 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/namespace-navbar.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { CopyButton } from "@/components/dashboard/copy-button"; +import { Navbar } from "@/components/navbar"; +import { QuickNavPopover } from "@/components/navbar-popover"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "@/components/ui/toaster"; +import { Dots, Gauge } from "@unkey/icons"; +import { useState } from "react"; +import { DeleteNamespaceDialog } from "./_components/namespace-delete-dialog"; +import { NamespaceUpdateNameDialog } from "./_components/namespace-update-dialog"; + +export const NamespaceNavbar = ({ + namespace, + ratelimitNamespaces, + activePage, +}: { + namespace: { + id: string; + name: string; + workspaceId: string; + }; + ratelimitNamespaces: { + id: string; + name: string; + }[]; + activePage: { + href: string; + text: string; + }; +}) => { + const [isNamespaceNameUpdateModalOpen, setIsNamespaceNameUpdateModalOpen] = useState(false); + + const [isNamespaceNameDeleteModalOpen, setIsNamespaceNameDeleteModalOpen] = useState(false); + return ( + <> + + }> + Ratelimits + +
+
+ ({ + id: ns.id, + label: ns.name, + href: `/ratelimits/${ns.id}`, + }))} + shortcutKey="R" + > +
{namespace.name}
+
+ + { + navigator.clipboard.writeText(namespace.id); + toast.success("Copied to clipboard", { + description: namespace.id, + }); + }, + }, + { + id: "delete", + hideRightIcon: true, + itemClassName: "hover:bg-error-3", + label:
Delete namespace
, + onClick() { + setIsNamespaceNameDeleteModalOpen(true); + }, + }, + ]} + > + +
+
+
+
+ + +
{activePage.text}
+
+
+
+ + + {namespace.id} + + + +
+ + + + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/namespace.actions.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/namespace.actions.ts new file mode 100644 index 0000000000..900beff2a5 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/namespace.actions.ts @@ -0,0 +1,93 @@ +"use server"; + +import { getTenantId } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { notFound, redirect } from "next/navigation"; + +export const getWorkspaceDetails = async (namespaceId: string, fallbackUrl = "/ratelimits") => { + const tenantId = getTenantId(); + + const workspace = await db.query.workspaces.findFirst({ + where: (table, { and, eq, isNull }) => + and(eq(table.tenantId, tenantId), isNull(table.deletedAt)), + columns: { + name: true, + tenantId: true, + }, + with: { + ratelimitNamespaces: { + where: (table, { isNull }) => isNull(table.deletedAt), + columns: { + id: true, + workspaceId: true, + name: true, + }, + }, + }, + }); + + if (!workspace || workspace.tenantId !== tenantId) { + // Will take users to onboarding + return redirect("/new"); + } + + const namespace = workspace?.ratelimitNamespaces.find((r) => r.id === namespaceId); + + if (!namespace) { + return fallbackUrl ? redirect(fallbackUrl) : notFound(); + } + + return { namespace, ratelimitNamespaces: workspace?.ratelimitNamespaces }; +}; + +export const getWorkspaceDetailsWithOverrides = async ( + namespaceId: string, + fallbackUrl = "/ratelimits", +) => { + const tenantId = getTenantId(); + + const workspace = await db.query.workspaces.findFirst({ + where: (table, { and, eq, isNull }) => + and(eq(table.tenantId, tenantId), isNull(table.deletedAt)), + columns: { + name: true, + tenantId: true, + features: true, + }, + with: { + ratelimitNamespaces: { + where: (table, { isNull }) => isNull(table.deletedAt), + columns: { + id: true, + workspaceId: true, + name: true, + }, + with: { + overrides: { + columns: { + id: true, + identifier: true, + limit: true, + duration: true, + async: true, + }, + where: (table, { isNull }) => isNull(table.deletedAt), + }, + }, + }, + }, + }); + + if (!workspace || workspace.tenantId !== tenantId) { + // Will take users to onboarding + return redirect("/new"); + } + + const namespace = workspace?.ratelimitNamespaces.find((r) => r.id === namespaceId); + + if (!namespace) { + return fallbackUrl ? redirect(fallbackUrl) : notFound(); + } + + return { namespace, workspace }; +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/page.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/page.tsx index 78f35d5486..991924fa57 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/page.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/page.tsx @@ -1,114 +1,59 @@ -import { CopyButton } from "@/components/dashboard/copy-button"; -import { Navbar as SubMenu } from "@/components/dashboard/navbar"; import { PageHeader } from "@/components/dashboard/page-header"; -import { Navbar } from "@/components/navbar"; -import { PageContent } from "@/components/page-content"; import { Badge } from "@/components/ui/badge"; -import { getTenantId } from "@/lib/auth"; -import { db } from "@/lib/db"; -import { Gauge } from "@unkey/icons"; import { Empty } from "@unkey/ui"; -import { notFound } from "next/navigation"; -import { navigation } from "../constants"; import { CreateNewOverride } from "./create-new-override"; import { Overrides } from "./table"; export const dynamic = "force-dynamic"; export const runtime = "edge"; -type Props = { - params: { - namespaceId: string; - }; -}; - -export default async function OverridePage(props: Props) { - const tenantId = getTenantId(); +import { PageContent } from "@/components/page-content"; +import { NamespaceNavbar } from "../namespace-navbar"; +import { getWorkspaceDetailsWithOverrides } from "../namespace.actions"; - const namespace = await db.query.ratelimitNamespaces.findFirst({ - where: (table, { eq, and, isNull }) => - and(eq(table.id, props.params.namespaceId), isNull(table.deletedAt)), - with: { - overrides: { - columns: { - id: true, - identifier: true, - limit: true, - duration: true, - async: true, - }, - where: (table, { isNull }) => isNull(table.deletedAt), - }, - workspace: { - columns: { - id: true, - tenantId: true, - features: true, - }, - }, - }, - }); - if (!namespace || namespace.workspace.tenantId !== tenantId) { - return notFound(); - } +export default async function OverridePage({ + params: { namespaceId }, +}: { + params: { namespaceId: string }; +}) { + const { namespace, workspace } = await getWorkspaceDetailsWithOverrides(namespaceId); return (
- - }> - Ratelimits - - {namespace.name} - - - Overrides - - - - - {namespace.id} - - - - + - + + {Intl.NumberFormat().format(namespace.overrides?.length)} /{" "} + {Intl.NumberFormat().format(workspace.features.ratelimitOverrides ?? 5)} used{" "} + , + ]} + /> -
- - {Intl.NumberFormat().format(namespace.overrides.length)} /{" "} - {Intl.NumberFormat().format(namespace.workspace.features.ratelimitOverrides ?? 5)}{" "} - used{" "} - , - ]} + + {namespace.overrides?.length === 0 ? ( + + + No custom ratelimits found + Create your first override below + + ) : ( + - - - {namespace.overrides.length === 0 ? ( - - - No custom ratelimits found - Create your first override below - - ) : ( - - )} -
+ )}
); diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/table.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/table.tsx index f0b5b8e868..f14eedba86 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/table.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/table.tsx @@ -27,7 +27,7 @@ type Props = { export const Overrides: React.FC = async ({ workspaceId, namespaceId, ratelimits }) => { return ( - +
Identifier diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/page.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/page.tsx index 3f437b1d44..01dec6d9b0 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/page.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/page.tsx @@ -1,304 +1,31 @@ -import { StackedColumnChart } from "@/components/dashboard/charts"; -import { CopyButton } from "@/components/dashboard/copy-button"; -import { Navbar as SubMenu } from "@/components/dashboard/navbar"; -import { Navbar } from "@/components/navbar"; -import { PageContent } from "@/components/page-content"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Code } from "@/components/ui/code"; -import { Metric } from "@/components/ui/metric"; -import { Separator } from "@/components/ui/separator"; -import { getTenantId } from "@/lib/auth"; -import { clickhouse } from "@/lib/clickhouse"; -import { and, db, eq, isNull, schema, sql } from "@/lib/db"; -import { formatNumber } from "@/lib/fmt"; -import { Gauge } from "@unkey/icons"; -import { Empty } from "@unkey/ui"; -import ms from "ms"; -import { redirect } from "next/navigation"; -import { parseAsArrayOf, parseAsString, parseAsStringEnum } from "nuqs/server"; -import { navigation } from "./constants"; -import { Filters, type Interval } from "./filters"; +import { LogsClient } from "./_overview/logs-client"; +import { NamespaceNavbar } from "./namespace-navbar"; +import { getWorkspaceDetails } from "./namespace.actions"; export const dynamic = "force-dynamic"; export const runtime = "edge"; -const intervalParser = parseAsStringEnum(["60m", "24h", "7d", "30d", "90d"]).withDefault("7d"); - export default async function RatelimitNamespacePage(props: { params: { namespaceId: string }; searchParams: { - interval?: Interval; identifier?: string; }; }) { - const tenantId = getTenantId(); - - const namespace = await db.query.ratelimitNamespaces.findFirst({ - where: (table, { eq, and, isNull }) => - and(eq(table.id, props.params.namespaceId), isNull(table.deletedAt)), - with: { - workspace: { - columns: { - tenantId: true, - }, - }, - }, - }); - - if (!namespace || namespace.workspace.tenantId !== tenantId) { - return redirect("/ratelimits"); - } - - const interval = intervalParser.withDefault("7d").parseServerSide(props.searchParams.interval); - const selectedIdentifier = parseAsArrayOf(parseAsString) - .withDefault([]) - .parseServerSide(props.searchParams.identifier); - - const t = new Date(); - t.setUTCDate(1); - t.setUTCHours(0, 0, 0, 0); - const billingCycleStart = t.getTime(); - const billingCycleEnd = t.setUTCMonth(t.getUTCMonth() + 1) - 1; - - const { getRatelimitsPerInterval, start, end, granularity } = prepareInterval(interval); - const timeseriesMethod = getRatelimitsPerInterval.replace("clickhouse.ratelimits.", "") as - | "perMinute" - | "perHour" - | "perDay" - | "perMonth"; - const query = { - workspaceId: namespace.workspaceId, - namespaceId: namespace.id, - startTime: start, - endTime: end, - identifiers: - selectedIdentifier.length > 0 - ? selectedIdentifier.map((id) => ({ - operator: "is" as const, - value: id, - })) - : null, - }; - - const [customLimits, ratelimitEvents, ratelimitsInBillingCycle, lastUsed] = await Promise.all([ - db - .select({ count: sql`count(*)` }) - .from(schema.ratelimitOverrides) - .where( - and( - eq(schema.ratelimitOverrides.namespaceId, namespace.id), - isNull(schema.ratelimitOverrides.deletedAt), - ), - ) - .execute() - .then((res) => res?.at(0)?.count ?? 0), - clickhouse.ratelimits.timeseries[timeseriesMethod](query), - clickhouse.ratelimits.timeseries[timeseriesMethod]({ - ...query, - startTime: billingCycleStart, - endTime: billingCycleEnd, - }), - clickhouse.ratelimits - .latest({ - workspaceId: namespace.workspaceId, - namespaceId: namespace.id, - limit: 1, - }) - .then((res) => res.val?.at(0)?.time), - ]); - - const dataOverTime = (ratelimitEvents.val ?? []).flatMap((event) => [ - { - x: new Date(event.x).toISOString(), - y: event.y.total - event.y.passed, - category: "Ratelimited", - }, - { - x: new Date(event.x).toISOString(), - y: event.y.passed, - category: "Passed", - }, - ]); - - const totalPassed = (ratelimitEvents.val ?? []).reduce((sum, event) => sum + event.y.passed, 0); - const totalRatelimited = (ratelimitEvents.val ?? []).reduce( - (sum, event) => sum + (event.y.total - event.y.passed), - 0, + const { namespace, ratelimitNamespaces } = await getWorkspaceDetails( + props.params.namespaceId, + "/ratelimits", ); - const totalRequests = totalPassed + totalRatelimited; - const successRate = totalRequests > 0 ? (totalPassed / totalRequests) * 100 : 0; - - const snippet = `curl -XPOST 'https://api.unkey.dev/v1/ratelimits.limit' \\ - -H 'Content-Type: application/json' \\ - -H 'Authorization: Bearer ' \\ - -d '{ - "namespace": "${namespace.name}", - "identifier": "", - "limit": 10, - "duration": 10000 - }'`; - return (
- - }> - Ratelimits - - {namespace.name.length > 0 ? namespace.name : ""} - - - - - {props.params.namespaceId} - - - - - - -
- - - - sum + day.y.passed, 0), - )} - /> - - - - - -
-
-

- Requests -

-
- - -
- - {dataOverTime.some((d) => d.y > 0) ? ( - - -
- - - - -
-
- - = 1000 * 60 * 60 * 24 * 30 - ? "month" - : granularity >= 1000 * 60 * 60 * 24 - ? "day" - : granularity >= 1000 * 60 * 60 - ? "hour" - : "minute" - } - /> - -
- ) : ( - - - No usage - Ratelimit something or change the range - - {snippet} - - - - )} -
-
+ +
); } - -function prepareInterval(interval: Interval) { - const now = new Date(); - - switch (interval) { - case "60m": { - const end = now.setUTCMinutes(now.getUTCMinutes() + 1, 0, 0); - const intervalMs = 1000 * 60 * 60; - return { - start: end - intervalMs, - end, - intervalMs, - granularity: 1000 * 60, - getRatelimitsPerInterval: "perMinute", - }; - } - case "24h": { - const end = now.setUTCHours(now.getUTCHours() + 1, 0, 0, 0); - const intervalMs = 1000 * 60 * 60 * 24; - return { - start: end - intervalMs, - end, - intervalMs, - granularity: 1000 * 60 * 60, - getRatelimitsPerInterval: "perHour", - }; - } - case "7d": { - now.setUTCDate(now.getUTCDate() + 1); - const end = now.setUTCHours(0, 0, 0, 0); - const intervalMs = 1000 * 60 * 60 * 24 * 7; - return { - start: end - intervalMs, - end, - intervalMs, - granularity: 1000 * 60 * 60 * 24, - getRatelimitsPerInterval: "perDay", - }; - } - case "30d": { - now.setUTCDate(now.getUTCDate() + 1); - const end = now.setUTCHours(0, 0, 0, 0); - const intervalMs = 1000 * 60 * 60 * 24 * 30; - return { - start: end - intervalMs, - end, - intervalMs, - granularity: 1000 * 60 * 60 * 24, - getRatelimitsPerInterval: "perDay", - }; - } - case "90d": { - now.setUTCDate(now.getUTCDate() + 1); - const end = now.setUTCHours(0, 0, 0, 0); - const intervalMs = 1000 * 60 * 60 * 24 * 90; - return { - start: end - intervalMs, - end, - intervalMs, - granularity: 1000 * 60 * 60 * 24, - getRatelimitsPerInterval: "perDay", - }; - } - } -} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/settings/page.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/settings/page.tsx index 031660f178..9cff68cff8 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/settings/page.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/settings/page.tsx @@ -1,15 +1,9 @@ import { CopyButton } from "@/components/dashboard/copy-button"; -import { Navbar as SubMenu } from "@/components/dashboard/navbar"; -import { Navbar } from "@/components/navbar"; import { PageContent } from "@/components/page-content"; -import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Code } from "@/components/ui/code"; -import { getTenantId } from "@/lib/auth"; -import { db } from "@/lib/db"; -import { Gauge } from "@unkey/icons"; -import { notFound, redirect } from "next/navigation"; -import { navigation } from "../constants"; +import { NamespaceNavbar } from "../namespace-navbar"; +import { getWorkspaceDetails } from "../namespace.actions"; import { DeleteNamespace } from "./delete-namespace"; import { UpdateNamespaceName } from "./update-namespace-name"; @@ -22,57 +16,20 @@ type Props = { }; export default async function SettingsPage(props: Props) { - const tenantId = getTenantId(); - - const workspace = await db.query.workspaces.findFirst({ - where: (table, { and, eq, isNull }) => - and(eq(table.tenantId, tenantId), isNull(table.deletedAt)), - with: { - ratelimitNamespaces: { - where: (table, { eq }) => eq(table.id, props.params.namespaceId), - }, - }, - }); - - if (!workspace || workspace.tenantId !== tenantId) { - return redirect("/new"); - } - - const namespace = workspace.ratelimitNamespaces.find( - (namespace) => namespace.id === props.params.namespaceId, - ); - - if (!namespace) { - return notFound(); - } + const { namespace, ratelimitNamespaces } = await getWorkspaceDetails(props.params.namespaceId); return (
- - }> - Ratelimits - - {namespace.name} - - - Settings{" "} - - - - - {props.params.namespaceId} - - - - + - - -
+
diff --git a/apps/dashboard/app/(app)/ratelimits/_components/control-cloud/index.tsx b/apps/dashboard/app/(app)/ratelimits/_components/control-cloud/index.tsx new file mode 100644 index 0000000000..62f44fda70 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/_components/control-cloud/index.tsx @@ -0,0 +1,31 @@ +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 "startTime": + return "Start time"; + case "endTime": + return "End time"; + case "since": + return ""; + case "ns": + return "Namespace"; + default: + return field.charAt(0).toUpperCase() + field.slice(1); + } +}; + +export const RatelimitListControlCloud = () => { + const { filters, updateFilters, removeFilter } = useFilters(); + return ( + + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-datetime/index.tsx b/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-datetime/index.tsx new file mode 100644 index 0000000000..1d8f2d78a7 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-datetime/index.tsx @@ -0,0 +1,88 @@ +import { DatetimePopover } from "@/components/logs/datetime/datetime-popover"; +import { cn } from "@/lib/utils"; +import { Calendar } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { useEffect, useState } from "react"; +import { useFilters } from "../../../hooks/use-filters"; + +export const LogsDateTime = () => { + const [title, setTitle] = useState(null); + const { filters, updateFilters } = useFilters(); + + useEffect(() => { + if (!title) { + setTitle("Last 12 hours"); + } + }, [title]); + + const timeValues = filters + .filter((f) => ["startTime", "endTime", "since"].includes(f.field)) + .reduce( + (acc, f) => ({ + // biome-ignore lint/performance/noAccumulatingSpread: it's safe to spread + ...acc, + [f.field]: f.value, + }), + {}, + ); + + return ( + { + const activeFilters = filters.filter( + (f) => !["endTime", "startTime", "since"].includes(f.field), + ); + if (since !== undefined) { + updateFilters([ + ...activeFilters, + { + field: "since", + value: since, + id: crypto.randomUUID(), + operator: "is", + }, + ]); + return; + } + if (since === undefined && startTime) { + activeFilters.push({ + field: "startTime", + value: startTime, + id: crypto.randomUUID(), + operator: "is", + }); + if (endTime) { + activeFilters.push({ + field: "endTime", + value: endTime, + id: crypto.randomUUID(), + operator: "is", + }); + } + } + updateFilters(activeFilters); + }} + initialTitle={title ?? ""} + onSuggestionChange={setTitle} + > +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-refresh.tsx new file mode 100644 index 0000000000..aa6b4ca3ac --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-refresh.tsx @@ -0,0 +1,18 @@ +import { RefreshButton } from "@/components/logs/refresh-button"; +import { trpc } from "@/lib/trpc/client"; +import { useRouter } from "next/navigation"; +import { useFilters } from "../../hooks/use-filters"; + +export const LogsRefresh = () => { + const { filters } = useFilters(); + const { ratelimit } = trpc.useUtils(); + const { refresh } = useRouter(); + const hasRelativeFilter = filters.find((f) => f.field === "since"); + + const handleRefresh = () => { + ratelimit.logs.queryRatelimitTimeseries.invalidate(); + refresh(); + }; + + return ; +}; diff --git a/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-search/index.tsx new file mode 100644 index 0000000000..7b78f91ea4 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-search/index.tsx @@ -0,0 +1,45 @@ +import { LogsLLMSearch } from "@/components/logs/llm-search"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; + +type LogsSearchProps = { + setNamespaces: (namespaces: { id: string; name: string }[]) => void; + initialNamespaces: { id: string; name: string }[]; +}; + +export const LogsSearch = ({ setNamespaces, initialNamespaces }: LogsSearchProps) => { + const searchNamespace = trpc.ratelimit.namespace.search.useMutation({ + onSuccess(data) { + setNamespaces(data); + }, + onError(error) { + toast.error(error.message, { + duration: 8000, + important: true, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + className: "font-medium", + }); + }, + }); + + const handleClear = () => { + setNamespaces(initialNamespaces); + }; + + return ( + + searchNamespace.mutateAsync({ + query, + }) + } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/_components/controls/index.tsx b/apps/dashboard/app/(app)/ratelimits/_components/controls/index.tsx new file mode 100644 index 0000000000..217075cdcf --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/_components/controls/index.tsx @@ -0,0 +1,31 @@ +import { LogsDateTime } from "./components/logs-datetime"; +import { LogsRefresh } from "./components/logs-refresh"; +import { LogsSearch } from "./components/logs-search"; + +type RatelimitListControlsProps = { + setNamespaces: (namespaces: { id: string; name: string }[]) => void; + initialNamespaces: { id: string; name: string }[]; +}; + +export function RatelimitListControls({ + setNamespaces, + initialNamespaces, +}: RatelimitListControlsProps) { + return ( +
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ ); +} diff --git a/apps/dashboard/app/(app)/ratelimits/create-namespace-button.tsx b/apps/dashboard/app/(app)/ratelimits/_components/create-namespace-button.tsx similarity index 100% rename from apps/dashboard/app/(app)/ratelimits/create-namespace-button.tsx rename to apps/dashboard/app/(app)/ratelimits/_components/create-namespace-button.tsx diff --git a/apps/dashboard/app/(app)/ratelimits/_components/filters.schema.ts b/apps/dashboard/app/(app)/ratelimits/_components/filters.schema.ts new file mode 100644 index 0000000000..886693d5ca --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/_components/filters.schema.ts @@ -0,0 +1,51 @@ +import type { + FilterValue, + NumberConfig, + StringConfig, +} from "@/components/logs/validation/filter.types"; +import { z } from "zod"; + +// Configuration +export const ratelimitListFilterFieldConfig: FilterFieldConfigs = { + startTime: { + type: "number", + operators: ["is"], + }, + endTime: { + type: "number", + operators: ["is"], + }, + since: { + type: "string", + operators: ["is"], + }, +}; + +// Schemas +export const ratelimitListFilterOperatorEnum = z.enum(["is", "contains"]); +export const ratelimitListFilterFieldEnum = z.enum(["startTime", "endTime", "since"]); + +// Types +export type RatelimitListFilterOperator = z.infer; +export type RatelimitListFilterField = z.infer; + +export type FilterFieldConfigs = { + startTime: NumberConfig; + endTime: NumberConfig; + since: StringConfig; +}; + +export type RatelimitListFilterUrlValue = Pick< + FilterValue, + "value" | "operator" +>; +export type RatelimitListFilterValue = FilterValue< + RatelimitListFilterField, + RatelimitListFilterOperator +>; + +export type RatelimitQuerySearchParams = { + startTime?: number | null; + endTime?: number | null; + since?: string | null; +}; diff --git a/apps/dashboard/app/(app)/ratelimits/_components/hooks/use-filters.ts b/apps/dashboard/app/(app)/ratelimits/_components/hooks/use-filters.ts new file mode 100644 index 0000000000..55e7b3e683 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/_components/hooks/use-filters.ts @@ -0,0 +1,84 @@ +import { + parseAsFilterValueArray, + parseAsRelativeTime, +} from "@/components/logs/validation/utils/nuqs-parsers"; +import { parseAsInteger, useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; +import type { + RatelimitListFilterField, + RatelimitListFilterOperator, + RatelimitListFilterValue, + RatelimitQuerySearchParams, +} from "../filters.schema"; + +const parseAsFilterValArray = parseAsFilterValueArray([ + "is", + "contains", +]); +export const queryParamsPayload = { + identifiers: parseAsFilterValArray, + startTime: parseAsInteger, + endTime: parseAsInteger, + since: parseAsRelativeTime, + status: parseAsFilterValArray, +} as const; + +export const useFilters = () => { + const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload); + const filters = useMemo(() => { + const activeFilters: RatelimitListFilterValue[] = []; + + ["startTime", "endTime", "since"].forEach((field) => { + const value = searchParams[field as keyof RatelimitQuerySearchParams]; + if (value !== null && value !== undefined) { + activeFilters.push({ + id: crypto.randomUUID(), + field: field as RatelimitListFilterField, + operator: "is", + value: value as string | number, + }); + } + }); + + return activeFilters; + }, [searchParams]); + + const updateFilters = useCallback( + (newFilters: RatelimitListFilterValue[]) => { + const newParams: Partial = { + startTime: null, + endTime: null, + since: null, + }; + + newFilters.forEach((filter) => { + switch (filter.field) { + case "startTime": + case "endTime": + newParams[filter.field] = filter.value as number; + break; + case "since": + newParams.since = filter.value as string; + break; + } + }); + + 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)/ratelimits/_components/namespace-card/chart/bar-chart.tsx b/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/bar-chart.tsx new file mode 100644 index 0000000000..b47c8ecdab --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/bar-chart.tsx @@ -0,0 +1,119 @@ +// GenericTimeseriesChart.tsx +"use client"; + +import { formatTimestampTooltip } from "@/components/logs/chart/utils/format-timestamp"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Grid } from "@unkey/icons"; +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, YAxis } from "recharts"; +import { LogsChartError } from "./components/logs-chart-error"; +import { LogsChartLoading } from "./components/logs-chart-loading"; + +type TimeseriesData = { + originalTimestamp: number; + total: number; + [key: string]: any; +}; + +type LogsTimeseriesBarChartProps = { + data?: TimeseriesData[]; + config: ChartConfig; + isLoading?: boolean; + isError?: boolean; +}; + +export function LogsTimeseriesBarChart({ + data, + config, + isError, + isLoading, +}: LogsTimeseriesBarChartProps) { + if (isError) { + return ; + } + if (isLoading) { + return ; + } + + return ( + + + + dataMax * 1.3]} hide /> + + { + if (!active || !payload?.length || payload?.[0]?.payload.total === 0) { + return null; + } + return ( + +
+ +
+
+ + All + + Total +
+
+ + {payload[0]?.payload?.total} + +
+
+
+
+ } + className="rounded-lg shadow-lg border border-gray-4" + labelFormatter={(_, tooltipPayload) => { + const originalTimestamp = tooltipPayload[0]?.payload?.originalTimestamp; + return originalTimestamp ? ( +
+ + {formatTimestampTooltip(originalTimestamp)} + +
+ ) : ( + "" + ); + }} + /> + ); + }} + /> + {Object.keys(config).map((key) => ( + + ))} + + + + ); +} diff --git a/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/components/logs-chart-error.tsx b/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/components/logs-chart-error.tsx new file mode 100644 index 0000000000..70f5ce6562 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/components/logs-chart-error.tsx @@ -0,0 +1,11 @@ +export const LogsChartError = () => { + return ( +
+
+
+ Could not retrieve logs +
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/components/logs-chart-loading.tsx b/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/components/logs-chart-loading.tsx new file mode 100644 index 0000000000..10402da006 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/components/logs-chart-loading.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; +import { Bar, BarChart, ResponsiveContainer, YAxis } from "recharts"; + +export const LogsChartLoading = () => { + const [mockData, setMockData] = useState(generateInitialData()); + + function generateInitialData() { + return Array.from({ length: 100 }).map(() => ({ + success: Math.random() * 0.5 + 0.5, + error: Math.random() * 0.3, + originalTimestamp: Date.now(), + })); + } + + useEffect(() => { + const interval = setInterval(() => { + setMockData((prevData) => + prevData.map((item) => ({ + ...item, + success: Math.random() * 0.5 + 0.5, + error: Math.random() * 0.3, + })), + ); + }, 600); // Update every 200ms for smooth animation + + return () => clearInterval(interval); + }, []); + + return ( +
+
+ + + dataMax * 2]} hide /> + + + + +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/index.tsx b/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/index.tsx new file mode 100644 index 0000000000..aa7c25f8e1 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/index.tsx @@ -0,0 +1,83 @@ +"use client"; +import { Clock, ProgressBar } from "@unkey/icons"; +import ms from "ms"; +import Link from "next/link"; +import { useFetchRatelimitOverviewTimeseries } from "../../[namespaceId]/_overview/components/charts/bar-chart/hooks/use-fetch-timeseries"; +import { LogsTimeseriesBarChart } from "./chart/bar-chart"; + +type Props = { + namespace: { + id: string; + name: string; + }; +}; + +export const NamespaceCard = ({ namespace }: Props) => { + const { timeseries } = useFetchRatelimitOverviewTimeseries(namespace.id); + + const passed = timeseries?.reduce((acc, crr) => acc + crr.success, 0) ?? 0; + const blocked = timeseries?.reduce((acc, crr) => acc + crr.error, 0) ?? 0; + + const lastRatelimit = timeseries + ? timeseries + .filter((entry) => entry.total > 0) + .sort((a, b) => b.originalTimestamp - a.originalTimestamp)[0] + : null; + + return ( +
+
+ +
+ + +
+
+ +
{namespace.name}
+
+
+
+
+
+
+
{passed}
+
PASSED
+
+
+
+
+
+
{blocked}
+
BLOCKED
+
+
+
+
+ +
+ {lastRatelimit + ? `${ms(Date.now() - lastRatelimit.originalTimestamp, { + long: true, + })} ago` + : "No data"} +
+
+
+
+ +
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/_components/ratelimit-client.tsx b/apps/dashboard/app/(app)/ratelimits/_components/ratelimit-client.tsx new file mode 100644 index 0000000000..43fb4cc99c --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/_components/ratelimit-client.tsx @@ -0,0 +1,84 @@ +"use client"; +import { CopyButton } from "@/components/dashboard/copy-button"; +import { Button, Empty } from "@unkey/ui"; +import { BookOpen } from "lucide-react"; +import { type PropsWithChildren, useState } from "react"; +import { RatelimitListControlCloud } from "./control-cloud"; +import { RatelimitListControls } from "./controls"; +import { NamespaceCard } from "./namespace-card"; + +const EXAMPLE_SNIPPET = `curl -XPOST 'https://api.unkey.dev/v1/ratelimits.limit' \\ + -H 'Content-Type: application/json' \\ + -H 'Authorization: Bearer ' \\ + -d '{ + "namespace": "demo_namespace", + "identifier": "user_123", + "limit": 10, + "duration": 10000 + }'`; + +const EmptyNamespaces = () => ( + + + No Namespaces found + + You haven't created any Namespaces yet. Create one by performing a limit request as shown + below. + + +
+
+
+          {EXAMPLE_SNIPPET}
+        
+ +
+
+ + + + + + +
+); + +const NamespaceGrid = ({ + namespaces, +}: { + namespaces: { id: string; name: string }[]; +}) => ( +
+ {namespaces.map((namespace) => ( + + ))} +
+); + +export const RatelimitClient = ({ + ratelimitNamespaces, +}: PropsWithChildren<{ + ratelimitNamespaces: { + id: string; + name: string; + }[]; +}>) => { + const [namespaces, setNamespaces] = useState(ratelimitNamespaces); + + return ( +
+ + + +
+ {namespaces.length > 0 ? : } +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/card.tsx b/apps/dashboard/app/(app)/ratelimits/card.tsx deleted file mode 100644 index 0f71c5526f..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/card.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { clickhouse } from "@/lib/clickhouse"; -import ms from "ms"; -import { Sparkline } from "./sparkline"; - -type Props = { - workspace: { - id: string; - }; - namespace: { - id: string; - name: string; - }; -}; - -export const RatelimitCard = async ({ workspace, namespace }: Props) => { - const now = new Date(); - const end = now.setUTCMinutes(now.getUTCMinutes() + 1, 0, 0); - const intervalMs = 1000 * 60 * 60; - - const [history, lastUsed] = await Promise.all([ - clickhouse.ratelimits.timeseries - .perMinute({ - identifiers: [], - workspaceId: workspace.id, - namespaceId: namespace.id, - startTime: end - intervalMs, - endTime: end, - }) - .then((res) => res.val ?? []), - clickhouse.ratelimits - .latest({ - workspaceId: workspace.id, - namespaceId: namespace.id, - limit: 1, - }) - .then((res) => res.val?.at(0)?.time), - ]); - - const totalRequests = history.reduce((sum, d) => sum + d.y.total, 0); - const totalSeconds = Math.floor(((history.at(-1)?.x ?? 0) - (history.at(0)?.x ?? 0)) / 1000); - const rps = totalSeconds === 0 ? 0 : totalRequests / totalSeconds; - - const data = history.map((d) => ({ - time: d.x, - values: { - passed: d.y.passed, - total: d.y.total, - }, - })); - - return ( -
-
- -
-
-
-

{namespace.name}

- - ~{rps.toFixed(2)} requests/s - -
-
- {lastUsed ? ( - <> - Last request{" "} - - - ) : ( - "Never used" - )} -
-
-
- ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/page.tsx b/apps/dashboard/app/(app)/ratelimits/page.tsx index e648bdd8fb..af12457c49 100644 --- a/apps/dashboard/app/(app)/ratelimits/page.tsx +++ b/apps/dashboard/app/(app)/ratelimits/page.tsx @@ -1,19 +1,10 @@ -import { CopyButton } from "@/components/dashboard/copy-button"; -import { Empty } from "@unkey/ui"; - import { Navbar } from "@/components/navbar"; -import { PageContent } from "@/components/page-content"; -import { Code } from "@/components/ui/code"; import { getTenantId } from "@/lib/auth"; import { db } from "@/lib/db"; import { Gauge } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { BookOpen } from "lucide-react"; -import Link from "next/link"; import { redirect } from "next/navigation"; -import { Suspense } from "react"; -import { RatelimitCard } from "./card"; -import { CreateNamespaceButton } from "./create-namespace-button"; +import { CreateNamespaceButton } from "./_components/create-namespace-button"; +import { RatelimitClient } from "./_components/ratelimit-client"; export const dynamic = "force-dynamic"; export const runtime = "edge"; @@ -38,16 +29,6 @@ export default async function RatelimitOverviewPage() { return redirect("/new"); } - const snippet = `curl -XPOST 'https://api.unkey.dev/v1/ratelimits.limit' \\ - -H 'Content-Type: application/json' \\ - -H 'Authorization: Bearer ' \\ - -d '{ - "namespace": "demo_namespace", - "identifier": "user_123", - "limit": 10, - "duration": 10000 - }'`; - return (
@@ -58,40 +39,7 @@ export default async function RatelimitOverviewPage() { - - {workspace.ratelimitNamespaces.length > 0 ? ( -
    - {workspace.ratelimitNamespaces.map((namespace) => ( - - - - - - ))} -
- ) : ( - - - No Namespaces found - - You haven't created any Namespaces yet. Create one by performing a limit request - as shown below. - - - {snippet} - - - - - - - - - )} -
+
); } diff --git a/apps/dashboard/app/(app)/ratelimits/sparkline.tsx b/apps/dashboard/app/(app)/ratelimits/sparkline.tsx deleted file mode 100644 index 98356a86bb..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/sparkline.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; -import { AreaChart } from "@/lib/charts/area-chart"; -import { SparkLine as Chart } from "@/lib/charts/sparkline"; -import { nFormatter } from "@/lib/format"; -type Props = { - data: { - time: number; - values: { - passed: number; - total: number; - }; - }[]; -}; - -export const Sparkline: React.FC = ({ data }) => { - const data2 = data.map((d) => ({ - date: new Date(d.time), - values: d.values, - })); - return ( -
- d.values.total, color: "text-warn" }, - { id: "passed", valueAccessor: (d) => d.values.passed, color: "text-primary" }, - ]} - tooltipContent={(d) => ( - <> -

- - {nFormatter(d.values.passed, { full: true })} - {" "} - /{" "} - {nFormatter(d.values.total, { full: true })}{" "} - Passed -

-

{new Date(d.date).toLocaleTimeString()}

- - )} - > - -
-
- ); -}; diff --git a/apps/dashboard/components/logs/chart/utils/format-timestamp.ts b/apps/dashboard/components/logs/chart/utils/format-timestamp.ts index ea649d2307..31ead9967f 100644 --- a/apps/dashboard/components/logs/chart/utils/format-timestamp.ts +++ b/apps/dashboard/components/logs/chart/utils/format-timestamp.ts @@ -1,3 +1,4 @@ +import type { TimeseriesGranularity } from "@/lib/trpc/routers/utils/granularity"; import { addMinutes, format } from "date-fns"; export const formatTimestampTooltip = (value: string | number) => { @@ -11,3 +12,30 @@ export const formatTimestampLabel = (timestamp: string | number | Date) => { const date = new Date(timestamp); return format(date, "MMM dd, h:mma").toUpperCase(); }; + +export const formatTimestampForChart = ( + value: string | number, + granularity: TimeseriesGranularity, +) => { + const date = new Date(value); + const offset = new Date().getTimezoneOffset() * -1; + const localDate = addMinutes(date, offset); + + switch (granularity) { + case "perMinute": + return format(localDate, "HH:mm:ss"); + case "per5Minutes": + case "per15Minutes": + case "per30Minutes": + return format(localDate, "HH:mm"); + case "perHour": + case "per2Hours": + case "per4Hours": + case "per6Hours": + return format(localDate, "MMM d, HH:mm"); + case "perDay": + return format(localDate, "MMM d"); + default: + return format(localDate, "Pp"); + } +}; diff --git a/apps/dashboard/components/logs/constants.ts b/apps/dashboard/components/logs/constants.ts new file mode 100644 index 0000000000..37ff88b5b9 --- /dev/null +++ b/apps/dashboard/components/logs/constants.ts @@ -0,0 +1,3 @@ +// Those two setting is being used by every log table and chart. So be carefuly when you are making changes. Consult to core team. +export const HISTORICAL_DATA_WINDOW = 12 * 60 * 60 * 1000; +export const TIMESERIES_DATA_WINDOW = 60 * 60 * 1000; diff --git a/apps/dashboard/components/logs/filter-operator-input/index.tsx b/apps/dashboard/components/logs/filter-operator-input/index.tsx new file mode 100644 index 0000000000..a1dfef5c92 --- /dev/null +++ b/apps/dashboard/components/logs/filter-operator-input/index.tsx @@ -0,0 +1,97 @@ +import { Textarea } from "@/components/ui/textarea"; +import { Check } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useState } from "react"; + +type FilterOption = { + id: T; + label: string; +}; + +type FilterOperatorInputProps = { + options: readonly FilterOption[]; + defaultOption?: T; + defaultText?: string; + label: string; + onApply: (selectedId: T, text: string) => void; +}; + +export const FilterOperatorInput = ({ + options, + defaultOption = options[0].id, + defaultText = "", + onApply, + label, +}: FilterOperatorInputProps) => { + const [selectedOption, setSelectedOption] = useState(defaultOption); + const [text, setText] = useState(defaultText); + + const handleApply = () => { + if (text.trim()) { + onApply(selectedOption, text); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleApply(); + } + }; + + return ( +
+
+ {options.map((option) => ( +
+ +
+ ))} +
+
+
+

+ {label}{" "} + + {options.find((opt) => opt.id === selectedOption)?.label} + {" "} + ... +

+