diff --git a/apps/dashboard/app/(app)/logs/utils.ts b/apps/dashboard/app/(app)/logs/utils.ts index e78b1a82d5..ec144758a9 100644 --- a/apps/dashboard/app/(app)/logs/utils.ts +++ b/apps/dashboard/app/(app)/logs/utils.ts @@ -1,4 +1,5 @@ import type { Log } from "@unkey/clickhouse/src/logs"; +import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; export type ResponseBody = { keyId: string; @@ -17,7 +18,7 @@ export type ResponseBody = { }; export const extractResponseField = ( - log: Log, + log: Log | RatelimitLog, fieldName: K, ): ResponseBody[K] | null => { if (!log?.response_body) { @@ -34,7 +35,7 @@ export const extractResponseField = ( } }; -export const getRequestHeader = (log: Log, headerName: string): string | null => { +export const getRequestHeader = (log: Log | RatelimitLog, headerName: string): string | null => { if (!headerName.trim()) { console.error("Invalid header name provided"); return null; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/components/logs-chart-error.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/components/logs-chart-error.tsx new file mode 100644 index 0000000000..b394b2df85 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/components/logs-chart-error.tsx @@ -0,0 +1,23 @@ +import { ResponsiveContainer } from "recharts"; + +export const LogsChartError = () => { + return ( +
+
+ {Array(5) + .fill(0) + .map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: it's okay +
+ --:-- +
+ ))} +
+ +
+ Could not retrieve logs +
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/components/logs-chart-loading.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/components/logs-chart-loading.tsx new file mode 100644 index 0000000000..a5ad4d70a5 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/components/logs-chart-loading.tsx @@ -0,0 +1,28 @@ +import { Bar, BarChart, ResponsiveContainer, YAxis } from "recharts"; +import { calculateTimePoints } from "../utils/calculate-timepoints"; +import { formatTimestampLabel } from "../utils/format-timestamp"; + +export const LogsChartLoading = () => { + const mockData = Array.from({ length: 100 }).map(() => ({ + success: Math.random() * 0.5 + 0.5, // Random values between 0.5 and 1 + })); + + return ( +
+
+ {calculateTimePoints(Date.now(), Date.now()).map((time, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: it's safe to use index here +
+ {formatTimestampLabel(time)} +
+ ))} +
+ + + dataMax * 2]} hide /> + + + +
+ ); +}; 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 new file mode 100644 index 0000000000..d7ca38ed0a --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/hooks/use-fetch-timeseries.ts @@ -0,0 +1,95 @@ +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(), []); + + const queryParams = useMemo(() => { + const params: RatelimitQueryTimeseriesPayload = { + 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: formatTimestamp(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]/logs/components/charts/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/index.tsx new file mode 100644 index 0000000000..9949cb0fd7 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/index.tsx @@ -0,0 +1,142 @@ +"use client"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Grid } from "@unkey/icons"; +import { useEffect, useRef } from "react"; +import { Bar, BarChart, ResponsiveContainer, YAxis } from "recharts"; +import { useRatelimitLogsContext } from "../../context/logs"; +import { LogsChartError } from "./components/logs-chart-error"; +import { LogsChartLoading } from "./components/logs-chart-loading"; +import { useFetchRatelimitTimeseries } from "./hooks/use-fetch-timeseries"; +import { calculateTimePoints } from "./utils/calculate-timepoints"; +import { formatTimestampLabel, formatTimestampTooltip } from "./utils/format-timestamp"; + +const chartConfig = { + success: { + label: "Passed", + subLabel: "Passed", + color: "hsl(var(--accent-4))", + }, + error: { + label: "Blocked", + subLabel: "Blocked", + color: "hsl(var(--warning-9))", + }, +} satisfies ChartConfig; + +export function RatelimitLogsChart({ + onMount, +}: { + onMount: (distanceToTop: number) => void; +}) { + const chartRef = useRef(null); + + const { namespaceId } = useRatelimitLogsContext(); + const { timeseries, isLoading, isError } = useFetchRatelimitTimeseries(namespaceId); + // biome-ignore lint/correctness/useExhaustiveDependencies: We need this to re-trigger distanceToTop calculation + useEffect(() => { + const distanceToTop = chartRef.current?.getBoundingClientRect().top ?? 0; + onMount(distanceToTop); + }, [onMount, isLoading, isError]); + + if (isError) { + return ; + } + + if (isLoading) { + return ; + } + + return ( +
+
+ {timeseries + ? calculateTimePoints( + timeseries[0].originalTimestamp ?? Date.now(), + timeseries.at(-1)?.originalTimestamp ?? Date.now(), + ).map((time, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: use of index is acceptable here. +
+ {formatTimestampLabel(time)} +
+ )) + : null} +
+ + + + dataMax * 1.5]} 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)} + +
+ ) : ( + "" + ); + }} + /> + ); + }} + /> + + + + + + + ); +} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/query-timeseries.schema.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/query-timeseries.schema.ts new file mode 100644 index 0000000000..c7c2c612b3 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/query-timeseries.schema.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { filterOperatorEnum } from "../../filters.schema"; + +export const ratelimitQueryTimeseriesPayload = 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: filterOperatorEnum, + value: z.string(), + }), + ), + }) + .nullable(), +}); + +export type RatelimitQueryTimeseriesPayload = z.infer; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/utils/calculate-timepoints.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/utils/calculate-timepoints.ts new file mode 100644 index 0000000000..bd5de03495 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/utils/calculate-timepoints.ts @@ -0,0 +1,9 @@ +export const calculateTimePoints = (startTime: number, endTime: number) => { + const points = 6; + const timeRange = endTime - startTime; + const step = Math.floor(timeRange / (points - 1)); + + return Array.from({ length: points }, (_, i) => new Date(startTime + step * i)).filter( + (date) => date.getTime() <= endTime, + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/utils/format-timestamp.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/utils/format-timestamp.ts new file mode 100644 index 0000000000..ea649d2307 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/charts/utils/format-timestamp.ts @@ -0,0 +1,13 @@ +import { addMinutes, format } from "date-fns"; + +export const formatTimestampTooltip = (value: string | number) => { + const date = new Date(value); + const offset = new Date().getTimezoneOffset() * -1; + const localDate = addMinutes(date, offset); + return format(localDate, "MMM dd HH:mm aa"); +}; + +export const formatTimestampLabel = (timestamp: string | number | Date) => { + const date = new Date(timestamp); + return format(date, "MMM dd, h:mma").toUpperCase(); +}; 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 new file mode 100644 index 0000000000..2b8c966eb5 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/control-cloud/index.tsx @@ -0,0 +1,247 @@ +import { KeyboardButton } from "@/components/keyboard-button"; +import { TimestampInfo } from "@/components/timestamp-info"; +import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +import { cn } from "@/lib/utils"; +import { XMark } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { format } from "date-fns"; +import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; +import type { FilterValue } from "../../filters.type"; +import { useFilters } from "../../hooks/use-filters"; +import { HISTORICAL_DATA_WINDOW } from "../table/hooks/use-logs-query"; + +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); + } +}; + +const formatValue = (value: string | number, field: string): string => { + if (typeof value === "number" && (field === "startTime" || field === "endTime")) { + return format(value, "MMM d, yyyy HH:mm:ss"); + } + return String(value); +}; + +const formatOperator = (operator: string, field: string): string => { + if (field === "since" && operator === "is") { + return "Last"; + } + return operator; +}; + +type ControlPillProps = { + filter: FilterValue; + onRemove: (id: string) => void; + isFocused?: boolean; + onFocus?: () => void; + index: number; +}; + +const ControlPill = ({ filter, onRemove, isFocused, onFocus, index }: ControlPillProps) => { + const { field, operator, value, metadata } = filter; + const pillRef = useRef(null); + + useEffect(() => { + if (isFocused && pillRef.current) { + const button = pillRef.current.querySelector("button"); + button?.focus(); + } + }, [isFocused]); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Backspace" || e.key === "Delete") { + e.preventDefault(); + onRemove(filter.id); + } + }; + + return ( +
+ {formatFieldName(field) === "" ? null : ( +
+ {formatFieldName(field)} +
+ )} +
+ {formatOperator(operator, field)} +
+
+ {metadata?.colorClass && ( +
+ )} + {field === "endTime" || field === "startTime" ? ( + + ) : ( + {formatValue(value, field)} + )} +
+
+ +
+
+ ); +}; + +export const RatelimitLogsControlCloud = () => { + const { filters, removeFilter, updateFilters } = useFilters(); + const [focusedIndex, setFocusedIndex] = useState(null); + + useKeyboardShortcut({ key: "d", meta: true }, () => { + const timestamp = Date.now(); + updateFilters([ + { + field: "endTime", + value: timestamp, + id: crypto.randomUUID(), + operator: "is", + }, + { + field: "startTime", + value: timestamp - HISTORICAL_DATA_WINDOW, + id: crypto.randomUUID(), + operator: "is", + }, + ]); + }); + + useKeyboardShortcut({ key: "c", meta: true }, () => { + setFocusedIndex(0); + }); + + const handleRemoveFilter = useCallback( + (id: string) => { + removeFilter(id); + // Adjust focus after removal + if (focusedIndex !== null) { + if (focusedIndex >= filters.length - 1) { + setFocusedIndex(Math.max(filters.length - 2, 0)); + } + } + }, + [removeFilter, filters.length, focusedIndex], + ); + + const handleKeyDown = (e: KeyboardEvent) => { + if (filters.length === 0) { + return; + } + + const findNextInDirection = (direction: "up" | "down") => { + if (focusedIndex === null) { + return 0; + } + + // Get all buttons + const buttons = document.querySelectorAll("[data-pill-index] button"); + const currentButton = buttons[focusedIndex] as HTMLElement; + if (!currentButton) { + return focusedIndex; + } + + const currentRect = currentButton.getBoundingClientRect(); + let closestDistance = Number.POSITIVE_INFINITY; + let closestIndex = focusedIndex; + + buttons.forEach((button, index) => { + const rect = button.getBoundingClientRect(); + + // Check if item is in the row above/below + const isAbove = direction === "up" && rect.bottom < currentRect.top; + const isBelow = direction === "down" && rect.top > currentRect.bottom; + + if (isAbove || isBelow) { + // Calculate horizontal distance + const horizontalDistance = Math.abs(rect.left - currentRect.left); + if (horizontalDistance < closestDistance) { + closestDistance = horizontalDistance; + closestIndex = index; + } + } + }); + + return closestIndex; + }; + + switch (e.key) { + case "ArrowRight": + case "l": + e.preventDefault(); + setFocusedIndex((prev) => (prev === null ? 0 : (prev + 1) % filters.length)); + break; + case "ArrowLeft": + case "h": + e.preventDefault(); + setFocusedIndex((prev) => + prev === null ? filters.length - 1 : (prev - 1 + filters.length) % filters.length, + ); + break; + case "ArrowDown": + case "j": + e.preventDefault(); + setFocusedIndex(findNextInDirection("down")); + break; + case "ArrowUp": + case "k": + e.preventDefault(); + setFocusedIndex(findNextInDirection("up")); + break; + } + }; + + if (filters.length === 0) { + return null; + } + + return ( +
+ {filters.map((filter, index) => ( + setFocusedIndex(index)} + index={index} + /> + ))} +
+ Clear filters + +
+ Focus filters + +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-datetime/components/datetime-popover.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-datetime/components/datetime-popover.tsx new file mode 100644 index 0000000000..9b34b59090 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-datetime/components/datetime-popover.tsx @@ -0,0 +1,203 @@ +import { useKeyboardShortcut } from "@/app/(app)/logs/hooks/use-keyboard-shortcut"; +import { KeyboardButton } from "@/components/keyboard-button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { processTimeFilters } from "@/lib/utils"; +import { Button, DateTime, type Range, type TimeUnit } from "@unkey/ui"; +import { type PropsWithChildren, useEffect, useState } from "react"; +import type { OptionsType } from "../logs-datetime.type"; +import { DateTimeSuggestions } from "./suggestions"; + +const CUSTOM_OPTION_ID = 10; +const options: OptionsType = [ + { + id: 1, + value: "5m", + display: "Last 5 minutes", + checked: false, + }, + { + id: 2, + value: "15m", + display: "Last 15 minutes", + checked: false, + }, + { + id: 3, + value: "30m", + display: "Last 30 minutes", + checked: false, + }, + { + id: 4, + value: "1h", + display: "Last 1 hour", + checked: false, + }, + { + id: 5, + value: "3h", + display: "Last 3 hours", + checked: false, + }, + { + id: 6, + value: "6h", + display: "Last 6 hours", + checked: false, + }, + { + id: 7, + value: "12h", + display: "Last 12 hours", + checked: false, + }, + { + id: 8, + value: "24h", + display: "Last 24 hours", + checked: false, + }, + { + id: 9, + value: "48h", + display: "Last 2 days", + checked: false, + }, + { + id: 10, + value: undefined, + display: "Custom", + checked: false, + }, +]; + +interface DatetimePopoverProps extends PropsWithChildren { + initialTitle: string; + initialTimeValues: { startTime?: number; endTime?: number; since?: string }; + onSuggestionChange: (title: string) => void; + onDateTimeChange: (startTime?: number, endTime?: number, since?: string) => void; +} + +type TimeRangeType = { + startTime?: number; + endTime?: number; +}; + +export const DatetimePopover = ({ + children, + initialTitle, + initialTimeValues, + onSuggestionChange, + onDateTimeChange, +}: DatetimePopoverProps) => { + const [open, setOpen] = useState(false); + useKeyboardShortcut("t", () => setOpen((prev) => !prev)); + + const { startTime, since, endTime } = initialTimeValues; + const [time, setTime] = useState({ startTime, endTime }); + const [suggestions, setSuggestions] = useState(() => { + const matchingSuggestion = since + ? options.find((s) => s.value === since) + : startTime + ? options.find((s) => s.id === CUSTOM_OPTION_ID) + : null; + + return options.map((s) => ({ + ...s, + checked: s.id === matchingSuggestion?.id, + })); + }); + + useEffect(() => { + const newTitle = since + ? options.find((s) => s.value === since)?.display ?? initialTitle + : startTime + ? "Custom" + : initialTitle; + + onSuggestionChange(newTitle); + }, [since, startTime, initialTitle, onSuggestionChange]); + + const handleSuggestionChange = (id: number) => { + if (id === CUSTOM_OPTION_ID) { + return; + } + + const newSuggestions = suggestions.map((s) => ({ + ...s, + checked: s.id === id && !s.checked, + })); + setSuggestions(newSuggestions); + + const selectedValue = newSuggestions.find((s) => s.checked)?.value; + onDateTimeChange(undefined, undefined, selectedValue); + }; + + const handleDateTimeChange = (newRange?: Range, newStart?: TimeUnit, newEnd?: TimeUnit) => { + setSuggestions( + suggestions.map((s) => ({ + ...s, + checked: s.id === CUSTOM_OPTION_ID, + })), + ); + + setTime({ + startTime: processTimeFilters(newRange?.from, newStart)?.getTime(), + endTime: processTimeFilters(newRange?.to, newEnd)?.getTime(), + }); + }; + + const handleApplyFilter = () => { + onDateTimeChange(time.startTime, time.endTime, undefined); + setOpen(false); + }; + + return ( + + +
{children}
+
+ +
+ + +
+ + + + + + + +
+
+ ); +}; + +const PopoverHeader = () => { + return ( +
+ Filter by time range + +
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-datetime/components/suggestions.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-datetime/components/suggestions.tsx new file mode 100644 index 0000000000..8109f18b2a --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-datetime/components/suggestions.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Check } from "lucide-react"; +import type { KeyboardEvent, PropsWithChildren } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { SuggestionOption } from "../logs-datetime.type"; + +type SuggestionsProps = PropsWithChildren<{ + className?: string; + options: Array; + onChange: (id: number) => void; +}>; + +export const DateTimeSuggestions = ({ className, options, onChange }: SuggestionsProps) => { + const [focusedIndex, setFocusedIndex] = useState( + () => options.findIndex((option) => option.checked) ?? 0, + ); + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + + // Add effect to update focus when checked item changes + useEffect(() => { + const newCheckedIndex = options.findIndex((option) => option.checked); + if (newCheckedIndex !== -1) { + setFocusedIndex(newCheckedIndex); + // Optional: also update focus on the DOM element + itemRefs.current[newCheckedIndex]?.focus(); + } + }, [options]); + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case "ArrowDown": + case "j": + e.preventDefault(); + setFocusedIndex((prev) => { + const newIndex = (prev + 1) % options.length; + itemRefs.current[newIndex]?.focus(); + return newIndex; + }); + break; + case "ArrowUp": + case "k": + e.preventDefault(); + setFocusedIndex((prev) => { + const newIndex = (prev - 1 + options.length) % options.length; + itemRefs.current[newIndex]?.focus(); + return newIndex; + }); + break; + case "Enter": + case " ": + e.preventDefault(); + onChange(options[focusedIndex].id); + break; + } + }; + + return ( +
+ {options.map(({ id, display, checked }, index) => ( +
+ +
+ ))} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-datetime/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-datetime/index.tsx new file mode 100644 index 0000000000..4a112d5772 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-datetime/index.tsx @@ -0,0 +1,81 @@ +import { cn } from "@/lib/utils"; +import { Calendar } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { useState } from "react"; +import { useFilters } from "../../../../hooks/use-filters"; +import { DatetimePopover } from "./components/datetime-popover"; + +export const LogsDateTime = () => { + const [title, setTitle] = useState("Last 12 hours"); + const { filters, updateFilters } = useFilters(); + const timeValues = filters + .filter((f) => ["startTime", "endTime", "since"].includes(f.field)) + .reduce( + (acc, f) => ({ + // biome-ignore lint/performance/noAccumulatingSpread: 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={(newTitle) => { + setTitle(newTitle); + }} + > +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-datetime/logs-datetime.type.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-datetime/logs-datetime.type.ts new file mode 100644 index 0000000000..b39bc0836d --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-datetime/logs-datetime.type.ts @@ -0,0 +1,18 @@ +import type { PropsWithChildren } from "react"; + +export type SuggestionOption = { + id: number; + value: string | undefined; + display: string; + checked: boolean; +}; + +export type OptionsType = SuggestionOption[]; + +export interface DatetimePopoverProps extends PropsWithChildren { + initialTitle: string; + initialSelected: boolean; + initialTimeValues: { startTime?: number; endTime?: number; since?: string }; + onSuggestionChange: (title: string, selected: boolean) => void; + onDateTimeChange: (startTime?: number, endTime?: number, since?: string) => void; +} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx new file mode 100644 index 0000000000..21e75e0b9a --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filter-checkbox.tsx @@ -0,0 +1,121 @@ +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils"; +import { Button } from "@unkey/ui"; +import { useCallback } from "react"; +import type { FilterValue } from "../../../../../filters.type"; +import { useFilters } from "../../../../../hooks/use-filters"; +import { useCheckboxState } from "./hooks/use-checkbox-state"; + +export type BaseCheckboxOption = { + id: number; + checked: boolean; + [key: string]: any; +}; + +interface BaseCheckboxFilterProps { + options: TCheckbox[]; + filterField: "identifiers" | "status"; + checkPath: string; + className?: string; + showScroll?: boolean; + scrollContainerRef?: React.RefObject; + renderBottomGradient?: () => React.ReactNode; + renderOptionContent?: (option: TCheckbox) => React.ReactNode; + createFilterValue: (option: TCheckbox) => Pick; +} + +export const FilterCheckbox = ({ + options, + filterField, + checkPath, + className, + showScroll = false, + renderOptionContent, + createFilterValue, + scrollContainerRef, + renderBottomGradient, +}: BaseCheckboxFilterProps) => { + const { filters, updateFilters } = useFilters(); + const { checkboxes, handleCheckboxChange, handleSelectAll, handleKeyDown } = useCheckboxState({ + options, + filters, + filterField, + checkPath, + }); + + const handleApplyFilter = useCallback(() => { + const selectedValues = checkboxes.filter((c) => c.checked).map((c) => createFilterValue(c)); + + const otherFilters = filters.filter((f) => f.field !== filterField); + const newFilters: FilterValue[] = selectedValues.map((filterValue) => ({ + id: crypto.randomUUID(), + field: filterField, + operator: "is", + ...filterValue, + })); + + updateFilters([...otherFilters, ...newFilters]); + }, [checkboxes, filterField, filters, updateFilters, createFilterValue]); + + return ( +
+
+
+ +
+ {checkboxes.map((checkbox, index) => ( + + ))} +
+ + {renderBottomGradient?.()} + + {renderBottomGradient &&
} + +
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filters-popover.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filters-popover.tsx new file mode 100644 index 0000000000..1e4eed217e --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/filters-popover.tsx @@ -0,0 +1,237 @@ +import { KeyboardButton } from "@/components/keyboard-button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +import { CaretRight } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { type KeyboardEvent, type PropsWithChildren, useEffect, useRef, useState } from "react"; +import { useFilters } from "../../../../../hooks/use-filters"; +import { IdentifiersFilter } from "./identifiers-filter"; +import { StatusFilter } from "./status-filter"; + +type FilterItemConfig = { + id: string; + label: string; + shortcut?: string; + component: React.ReactNode; +}; + +const FILTER_ITEMS: FilterItemConfig[] = [ + { + id: "status", + label: "Status", + shortcut: "e", + component: , + }, + { + id: "identifiers", + label: "Identifier", + shortcut: "p", + component: , + }, +]; + +export const FiltersPopover = ({ children }: PropsWithChildren) => { + const [open, setOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(null); + const [activeFilter, setActiveFilter] = useState(null); + + // Clean up activeFilter to prevent unnecessary render when opening and closing + // biome-ignore lint/correctness/useExhaustiveDependencies(a): without clean up doesn't work properly + useEffect(() => { + return () => { + setActiveFilter(null); + }; + }, [open]); + + useKeyboardShortcut("f", () => { + setOpen((prev) => !prev); + }); + + const handleKeyDown = (e: KeyboardEvent) => { + if (!open) { + return; + } + + // Don't handle navigation in main popover if we have an active filter + if (activeFilter) { + if (e.key === "ArrowLeft" || e.key === "h") { + e.preventDefault(); + setActiveFilter(null); + } + return; + } + + switch (e.key) { + case "ArrowDown": + case "j": + e.preventDefault(); + setFocusedIndex((prev) => (prev === null ? 0 : (prev + 1) % FILTER_ITEMS.length)); + break; + case "ArrowUp": + case "k": + e.preventDefault(); + setFocusedIndex((prev) => + prev === null + ? FILTER_ITEMS.length - 1 + : (prev - 1 + FILTER_ITEMS.length) % FILTER_ITEMS.length, + ); + break; + case "Enter": + case "l": + case "ArrowRight": + e.preventDefault(); + if (focusedIndex !== null) { + const selectedFilter = FILTER_ITEMS[focusedIndex]; + if (selectedFilter) { + setActiveFilter(selectedFilter.id); + } + } + break; + case "h": + case "ArrowLeft": + // Don't handle left arrow in main popover - let it bubble to FilterItem + break; + } + }; + + return ( + + {children} + +
+ + {FILTER_ITEMS.map((item, index) => ( + + ))} +
+
+
+ ); +}; + +const PopoverHeader = () => { + return ( +
+ Filters... + +
+ ); +}; + +type FilterItemProps = FilterItemConfig & { + isFocused?: boolean; + isActive?: boolean; +}; + +export const FilterItem = ({ + label, + shortcut, + id, + component, + isFocused, + isActive, +}: FilterItemProps) => { + const { filters } = useFilters(); + const [open, setOpen] = useState(false); + const itemRef = useRef(null); + const contentRef = useRef(null); + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.key === "ArrowLeft" || e.key === "h") && open) { + e.preventDefault(); + setOpen(false); + itemRef.current?.focus(); + } + }; + + useKeyboardShortcut( + { key: shortcut || "", meta: true }, + () => { + setOpen(true); + }, + { preventDefault: true }, + ); + + // Focus the element when isFocused changes + useEffect(() => { + if (isFocused && itemRef.current) { + itemRef.current.focus(); + } + }, [isFocused]); + + // Handle focus transfer to sub-popover when active + useEffect(() => { + if (isActive && !open) { + setOpen(true); + } + if (isActive && open && contentRef.current) { + // Focus the first focusable element in the sub-popover + const focusableElements = contentRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusableElements.length > 0) { + (focusableElements[0] as HTMLElement).focus(); + } else { + contentRef.current.focus(); + } + } + }, [isActive, open]); + + return ( + + +
+
+ {shortcut && ( + + )} + {label} +
+
+ {filters.filter((filter) => filter.field === id).length > 0 && ( +
+ {filters.filter((filter) => filter.field === id).length} +
+ )} + +
+
+
+ + {component} + +
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts new file mode 100644 index 0000000000..a1821098da --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts @@ -0,0 +1,100 @@ +import type { FilterValue } from "@/app/(app)/ratelimits/[namespaceId]/logs/filters.type"; +import { useEffect, useState } from "react"; + +type UseCheckboxStateProps = { + options: Array<{ id: number } & TItem>; + filters: FilterValue[]; + filterField: string; + checkPath: keyof TItem; // Specify which field to get from checkbox item + shouldSyncWithOptions?: boolean; +}; + +export const useCheckboxState = >({ + options, + filters, + filterField, + checkPath, + shouldSyncWithOptions = false, +}: UseCheckboxStateProps) => { + const [checkboxes, setCheckboxes] = useState(() => { + const activeFilters = filters + .filter((f) => f.field === filterField) + .map((f) => String(f.value)); + + return options.map((checkbox) => ({ + ...checkbox, + checked: activeFilters.includes(String(checkbox[checkPath])), + })); + }); + + useEffect(() => { + if (shouldSyncWithOptions && options.length > 0) { + setCheckboxes(options); + } + }, [options, shouldSyncWithOptions]); + + const handleCheckboxChange = (index: number): void => { + setCheckboxes((prevCheckboxes) => { + const newCheckboxes = [...prevCheckboxes]; + newCheckboxes[index] = { + ...newCheckboxes[index], + checked: !newCheckboxes[index].checked, + }; + return newCheckboxes; + }); + }; + + const handleSelectAll = (): void => { + setCheckboxes((prevCheckboxes) => { + const allChecked = prevCheckboxes.every((checkbox) => checkbox.checked); + return prevCheckboxes.map((checkbox) => ({ + ...checkbox, + checked: !allChecked, + })); + }); + }; + + const handleToggle = (index?: number) => { + if (typeof index === "number") { + handleCheckboxChange(index); + } else { + handleSelectAll(); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent, index?: number) => { + // Handle checkbox toggle + if (event.key === " " || event.key === "Enter" || event.key === "h" || event.key === "l") { + event.preventDefault(); + handleToggle(index); + } + + // Handle navigation + if ( + event.key === "ArrowDown" || + event.key === "ArrowUp" || + event.key === "j" || + event.key === "k" + ) { + event.preventDefault(); + const elements = document.querySelectorAll('label[role="checkbox"]'); + const currentIndex = Array.from(elements).findIndex((el) => el === event.currentTarget); + + let nextIndex: number; + if (event.key === "ArrowDown" || event.key === "j") { + nextIndex = currentIndex < elements.length - 1 ? currentIndex + 1 : 0; + } else { + nextIndex = currentIndex > 0 ? currentIndex - 1 : elements.length - 1; + } + + (elements[nextIndex] as HTMLElement).focus(); + } + }; + + return { + checkboxes, + handleCheckboxChange, + handleSelectAll, + handleKeyDown, + }; +}; 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 new file mode 100644 index 0000000000..61fc9925e9 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/identifiers-filter.tsx @@ -0,0 +1,177 @@ +import { Checkbox } from "@/components/ui/checkbox"; +import { trpc } from "@/lib/trpc/client"; +import { Button } from "@unkey/ui"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRatelimitLogsContext } from "../../../../../context/logs"; +import type { FilterValue } from "../../../../../filters.type"; +import { useFilters } from "../../../../../hooks/use-filters"; +import { useCheckboxState } from "./hooks/use-checkbox-state"; + +export const IdentifiersFilter = () => { + const { namespaceId } = useRatelimitLogsContext(); + const { + data: identifiers, + isLoading, + isError, + refetch, + isFetching, + } = trpc.ratelimit.logs.queryDistinctIdentifiers.useQuery( + { namespaceId }, + { + select(identifiers) { + return identifiers + ? identifiers.map((identifier, index) => ({ + id: index + 1, + path: identifier, + checked: false, + })) + : []; + }, + }, + ); + const { filters, updateFilters } = useFilters(); + const [isAtBottom, setIsAtBottom] = useState(false); + const [needsScroll, setNeedsScroll] = useState(false); + const scrollContainerRef = useRef(null); + + const { checkboxes, handleCheckboxChange, handleSelectAll, handleKeyDown } = useCheckboxState({ + options: identifiers ?? [], + filters, + filterField: "identifiers", + checkPath: "path", + shouldSyncWithOptions: true, + }); + + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (scrollContainer) { + const checkScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + const isBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 1; + setIsAtBottom(isBottom); + setNeedsScroll(scrollHeight > clientHeight); + }; + + scrollContainer.addEventListener("scroll", checkScroll); + // Check initial state + checkScroll(); + + // Add resize observer to recheck on resize + const resizeObserver = new ResizeObserver(checkScroll); + resizeObserver.observe(scrollContainer); + + // Also check after a small delay to ensure content is rendered + setTimeout(checkScroll, 100); + + return () => { + scrollContainer.removeEventListener("scroll", checkScroll); + resizeObserver.disconnect(); + }; + } + }, []); + + const handleApplyFilter = useCallback(() => { + const selectedPaths = checkboxes.filter((c) => c.checked).map((c) => c.path); + + // Keep all non-paths filters and add new path filters + const otherFilters = filters.filter((f) => f.field !== "identifiers"); + const identifiersFilters: FilterValue[] = selectedPaths.map((path) => ({ + id: crypto.randomUUID(), + field: "identifiers", + operator: "is", + value: path, + })); + + updateFilters([...otherFilters, ...identifiersFilters]); + }, [checkboxes, filters, updateFilters]); + + if (isError) { + return ( +
+
+
+ Could not load identifiers +
+
+
+
+ +
+
+ ); + } + + if (isLoading) { + return ( +
+
+
+ Loading paths... +
+
+ ); + } + + return ( +
+ +
+
+ {checkboxes.map((checkbox, index) => ( + + ))} +
+ {needsScroll && !isAtBottom && ( +
+
+
+ )} +
+
+
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/status-filter.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/status-filter.tsx new file mode 100644 index 0000000000..0dd7d50e2a --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/components/status-filter.tsx @@ -0,0 +1,45 @@ +import { FilterCheckbox } from "./filter-checkbox"; + +type StatusOption = { + id: number; + status: "passed" | "blocked"; + label: string; + color: string; + checked: boolean; +}; + +const options: StatusOption[] = [ + { + id: 1, + status: "passed", + label: "Passed", + color: "bg-success-9", + checked: false, + }, + { + id: 2, + status: "blocked", + label: "Blocked", + color: "bg-warning-9", + checked: false, + }, +]; + +export const StatusFilter = () => { + return ( + ( + <> +
+ {checkbox.label} + + )} + createFilterValue={(option) => ({ + value: option.status, + })} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/index.tsx new file mode 100644 index 0000000000..3416d73848 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-filters/index.tsx @@ -0,0 +1,33 @@ +import { BarsFilter } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useFilters } from "../../../../hooks/use-filters"; +import { FiltersPopover } from "./components/filters-popover"; + +export const LogsFilters = () => { + const { filters } = useFilters(); + return ( + +
+ +
+
+ ); +}; 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 new file mode 100644 index 0000000000..de4b6c8ca5 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-live-switch.tsx @@ -0,0 +1,60 @@ +import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +import { CircleCarretRight } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useRatelimitLogsContext } from "../../../context/logs"; +import { useFilters } from "../../../hooks/use-filters"; +import { HISTORICAL_DATA_WINDOW } from "../../table/hooks/use-logs-query"; + +export const LogsLiveSwitch = () => { + const { isLive, toggleLive } = useRatelimitLogsContext(); + const { filters, updateFilters } = useFilters(); + + useKeyboardShortcut("l", () => { + handleSwitch(); + }); + + const handleSwitch = () => { + toggleLive(); + // To able to refetch historic data again we have to update the endTime + if (isLive) { + const timestamp = Date.now(); + const activeFilters = filters.filter((f) => !["endTime", "startTime"].includes(f.field)); + updateFilters([ + ...activeFilters, + { + field: "endTime", + value: timestamp, + id: crypto.randomUUID(), + operator: "is", + }, + { + field: "startTime", + value: timestamp - HISTORICAL_DATA_WINDOW, + id: crypto.randomUUID(), + operator: "is", + }, + ]); + } + }; + return ( + + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-refresh.tsx new file mode 100644 index 0000000000..f1261f1bcc --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-refresh.tsx @@ -0,0 +1,64 @@ +import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +import { trpc } from "@/lib/trpc/client"; +import { Refresh3 } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useState } from "react"; +import { useRatelimitLogsContext } from "../../../context/logs"; +import { useFilters } from "../../../hooks/use-filters"; + +const REFRESH_TIMEOUT_MS = 1000; + +export const LogsRefresh = () => { + const { isLive, toggleLive } = useRatelimitLogsContext(); + const { filters } = useFilters(); + const { ratelimit } = trpc.useUtils(); + const [isLoading, setIsLoading] = useState(false); + const [refreshTimeout, setRefreshTimeout] = useState(null); + + const hasRelativeFilter = filters.find((f) => f.field === "since"); + useKeyboardShortcut("r", () => { + hasRelativeFilter && handleSwitch(); + }); + + const handleSwitch = () => { + if (isLoading) { + return; + } + + const isLiveBefore = Boolean(isLive); + setIsLoading(true); + toggleLive(false); + ratelimit.logs.query.invalidate(); + ratelimit.logs.queryRatelimitTimeseries.invalidate(); + + if (refreshTimeout) { + clearTimeout(refreshTimeout); + } + const timeout = setTimeout(() => { + setIsLoading(false); + if (isLiveBefore) { + toggleLive(true); + } + }, REFRESH_TIMEOUT_MS); + setRefreshTimeout(timeout); + }; + + return ( + + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-search/index.tsx new file mode 100644 index 0000000000..7ff85b4620 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-search/index.tsx @@ -0,0 +1,172 @@ +import { toast } from "@/components/ui/toaster"; +import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +import { trpc } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import { CaretRightOutline, CircleInfoSparkle, Magnifier, Refresh3 } from "@unkey/icons"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "components/ui/tooltip"; +import { useRef, useState } from "react"; +import { transformStructuredOutputToFilters } from "../../../../filters.schema"; +import { useFilters } from "../../../../hooks/use-filters"; + +export const LogsSearch = () => { + const { filters, updateFilters } = useFilters(); + const queryLLMForStructuredOutput = trpc.ratelimit.logs.ratelimitLlmSearch.useMutation({ + onSuccess(data) { + if (data) { + const transformedFilters = transformStructuredOutputToFilters(data, filters); + updateFilters(transformedFilters); + } else { + toast.error("Try to be more descriptive about your query", { + duration: 8000, + important: true, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + }); + } + }, + onError(error) { + toast.error(error.message, { + duration: 8000, + important: true, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + }); + }, + }); + + const [searchText, setSearchText] = useState(""); + const inputRef = useRef(null); + + useKeyboardShortcut("s", () => { + inputRef.current?.click(); + inputRef.current?.focus(); + }); + + const handleSearch = async (search: string) => { + const query = search.trim(); + if (query) { + try { + await queryLLMForStructuredOutput.mutateAsync({ + query, + timestamp: Date.now(), + }); + } catch (error) { + console.error("Search failed:", error); + } + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + (document.activeElement as HTMLElement)?.blur(); + } + if (e.key === "Enter") { + e.preventDefault(); + handleSearch(searchText); + } + }; + + const handlePresetQuery = (query: string) => { + setSearchText(query); + handleSearch(query); + }; + + const isLoading = queryLLMForStructuredOutput.isLoading; + + return ( +
+
0 ? "bg-gray-4" : "", + isLoading ? "bg-gray-4" : "", + )} + > +
+
+ {isLoading ? ( + + ) : ( + + )} +
+ +
+ {isLoading ? ( +
+ AI consults the Palantír... +
+ ) : ( + setSearchText(e.target.value)} + placeholder="Search and filter with AI…" + className="text-accent-12 font-medium text-[13px] bg-transparent border-none outline-none focus:ring-0 focus:outline-none placeholder:text-accent-12 selection:bg-gray-6 w-full" + disabled={isLoading} + /> + )} +
+
+ + + +
+ +
+
+ +
+
+ Try queries like: + (click to use) +
+
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+
+
+
+
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/index.tsx new file mode 100644 index 0000000000..5fd9bf8238 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/index.tsx @@ -0,0 +1,34 @@ +import { LogsDateTime } from "./components/logs-datetime"; +import { LogsFilters } from "./components/logs-filters"; +import { LogsLiveSwitch } from "./components/logs-live-switch"; +import { LogsRefresh } from "./components/logs-refresh"; +import { LogsSearch } from "./components/logs-search"; + +export function RatelimitLogsControls() { + return ( +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/logs-client.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/logs-client.tsx new file mode 100644 index 0000000000..0c6f62b77c --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/logs-client.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { RatelimitLogsProvider } from "../context/logs"; + +import { RatelimitLogsChart } from "./charts"; +import { RatelimitLogsControlCloud } from "./control-cloud"; +import { RatelimitLogsControls } from "./controls"; +import { RatelimitLogDetails } from "./table/log-details"; +import { RatelimitLogsTable } from "./table/logs-table"; + +export const LogsClient = ({ namespaceId }: { namespaceId: string }) => { + const [tableDistanceToTop, setTableDistanceToTop] = useState(0); + + const handleDistanceToTop = useCallback((distanceToTop: number) => { + setTableDistanceToTop(distanceToTop); + }, []); + + return ( + + + + + + + + ); +}; 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 new file mode 100644 index 0000000000..7eaefb0b95 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.test.ts @@ -0,0 +1,161 @@ +import { trpc } from "@/lib/trpc/client"; +import { act, renderHook } from "@testing-library/react"; +import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useRatelimitLogsQuery } from "./use-logs-query"; + +let mockFilters: any[] = []; +const mockDate = 1706024400000; + +vi.mock("@/lib/trpc/client", () => { + const useInfiniteQuery = vi.fn().mockReturnValue({ + data: null, + hasNextPage: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + isLoading: false, + }); + + const fetch = vi.fn(); + + return { + trpc: { + useUtils: () => ({ + ratelimit: { + logs: { + query: { + fetch, + }, + }, + }, + }), + ratelimit: { + logs: { + query: { + useInfiniteQuery, + }, + }, + }, + }, + }; +}); + +vi.mock("../../../hooks/use-filters", () => ({ + useFilters: () => ({ + filters: mockFilters, + }), +})); + +describe("useRatelimitLogsQuery filter processing", () => { + beforeEach(() => { + mockFilters = []; + vi.setSystemTime(mockDate); + }); + + it("handles valid status filter", () => { + mockFilters = [{ field: "status", operator: "is", value: "rejected" }]; + const { result } = renderHook(() => useRatelimitLogsQuery()); + expect(result.current.isPolling).toBe(false); + }); + + it("handles multiple valid filters", () => { + mockFilters = [ + { field: "status", operator: "is", value: "succeeded" }, + { field: "identifiers", operator: "is", value: "test-id" }, + { field: "requestIds", operator: "is", value: "req-123" }, + ]; + const { result } = renderHook(() => useRatelimitLogsQuery()); + expect(result.current.isPolling).toBe(false); + }); + + it("handles invalid filter types", () => { + const consoleMock = vi.spyOn(console, "error"); + mockFilters = [ + { field: "identifiers", operator: "is", value: 123 }, + { field: "requestIds", operator: "is", value: true }, + { field: "status", operator: "is", value: {} }, + ]; + renderHook(() => useRatelimitLogsQuery()); + expect(consoleMock).toHaveBeenCalledTimes(3); + }); + + it("handles time-based filters", () => { + mockFilters = [ + { field: "startTime", operator: "is", value: mockDate - 3600000 }, + { field: "since", operator: "is", value: "1h" }, + ]; + const { result } = renderHook(() => useRatelimitLogsQuery()); + expect(result.current.isPolling).toBe(false); + }); +}); + +describe("useRatelimitLogsQuery realtime logs", () => { + let useInfiniteQuery: ReturnType; + let fetch: ReturnType; + + beforeEach(() => { + vi.setSystemTime(mockDate); + mockFilters = []; + //@ts-expect-error hacky way to mock trpc + useInfiniteQuery = vi.mocked(trpc.ratelimit.logs.query.useInfiniteQuery); + //@ts-expect-error hacky way to mock trpc + fetch = vi.mocked(trpc.useUtils().ratelimit.logs.query.fetch); + }); + + it("resets realtime logs when polling stops", async () => { + const mockLogs: Partial[] = [ + { + request_id: "1", + time: Date.now(), + status: 1, + identifier: "test-1", + }, + { + request_id: "2", + time: Date.now(), + status: 0, + identifier: "test-2", + }, + ]; + + useInfiniteQuery.mockReturnValue({ + data: { + pages: [{ ratelimitLogs: mockLogs, nextCursor: null }], + }, + hasNextPage: false, + fetchNextPage: vi.fn(), + isFetchingNextPage: false, + isLoading: false, + }); + + fetch.mockResolvedValue({ + ratelimitLogs: [ + { + request_id: "3", + time: Date.now(), + status: "succeeded", + identifier: "test-3", + }, + ], + }); + + const { result, rerender } = renderHook( + ({ startPolling, pollIntervalMs }) => useRatelimitLogsQuery({ startPolling, pollIntervalMs }), + { initialProps: { startPolling: true, pollIntervalMs: 1000 } }, + ); + + expect(result.current.historicalLogs).toHaveLength(2); + + // Wait for polling interval + await act(async () => { + await new Promise((resolve) => setTimeout(resolve)); + }); + + act(() => { + rerender({ startPolling: false, pollIntervalMs: 1000 }); + }); + + expect(result.current.realtimeLogs).toHaveLength(0); + expect(result.current.historicalLogs).toHaveLength(2); + }); +}); diff --git a/apps/dashboard/app/(app)/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 new file mode 100644 index 0000000000..0ef74f0235 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/hooks/use-logs-query.ts @@ -0,0 +1,204 @@ +import { trpc } from "@/lib/trpc/client"; +import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useFilters } from "../../../hooks/use-filters"; +import type { RatelimitQueryLogsPayload } from "../query-logs.schema"; + +// Duration in milliseconds for historical data fetch window (12 hours) +export const HISTORICAL_DATA_WINDOW = 12 * 60 * 60 * 1000; +type UseLogsQueryParams = { + limit?: number; + pollIntervalMs?: number; + startPolling?: boolean; + namespaceId?: string; +}; + +export function useRatelimitLogsQuery({ + namespaceId, + limit = 50, + pollIntervalMs = 5000, + startPolling = false, +}: UseLogsQueryParams = {}) { + const [historicalLogsMap, setHistoricalLogsMap] = useState(() => new Map()); + const [realtimeLogsMap, setRealtimeLogsMap] = useState(() => new Map()); + + const { filters } = useFilters(); + const queryClient = trpc.useUtils(); + + const realtimeLogs = useMemo(() => { + return Array.from(realtimeLogsMap.values()); + }, [realtimeLogsMap]); + + const historicalLogs = useMemo(() => Array.from(historicalLogsMap.values()), [historicalLogsMap]); + + //Required for preventing double trpc call during initial render + const dateNow = useMemo(() => Date.now(), []); + const queryParams = useMemo(() => { + const params: RatelimitQueryLogsPayload = { + limit, + startTime: dateNow - HISTORICAL_DATA_WINDOW, + endTime: dateNow, + requestIds: { filters: [] }, + identifiers: { filters: [] }, + status: { filters: [] }, + //@ts-expect-error will be fixed later + namespaceId, + since: "", + }; + + 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 "requestIds": { + if (typeof filter.value !== "string") { + console.error("Request ID filter value type has to be 'string'"); + return; + } + params.requestIds?.filters.push({ + operator: "is", + 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]); + + // Main query for historical data + const { + data: initialData, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + isLoading: isLoadingInitial, + } = trpc.ratelimit.logs.query.useInfiniteQuery(queryParams, { + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialCursor: { requestId: null, time: null }, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + // 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; + const result = await queryClient.ratelimit.logs.query.fetch({ + ...queryParams, + startTime: latestTime ?? Date.now() - pollIntervalMs, + endTime: Date.now(), + }); + + if (result.ratelimitLogs.length === 0) { + return; + } + + setRealtimeLogsMap((prevMap) => { + const newMap = new Map(prevMap); + let added = 0; + + for (const log of result.ratelimitLogs) { + // Skip if exists in either map + if (newMap.has(log.request_id) || historicalLogsMap.has(log.request_id)) { + continue; + } + + newMap.set(log.request_id, log); + added++; + + if (newMap.size > Math.min(limit, 100)) { + const oldestKey = Array.from(newMap.keys()).shift()!; + newMap.delete(oldestKey); + } + } + + // 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]); + + // Set up polling effect + useEffect(() => { + if (startPolling) { + const interval = setInterval(pollForNewLogs, pollIntervalMs); + return () => clearInterval(interval); + } + }, [startPolling, pollForNewLogs, pollIntervalMs]); + + // Update historical logs effect + useEffect(() => { + if (initialData) { + const newMap = new Map(); + initialData.pages.forEach((page) => { + page.ratelimitLogs.forEach((log) => { + newMap.set(log.request_id, log); + }); + }); + setHistoricalLogsMap(newMap); + } + }, [initialData]); + + // Reset realtime logs effect + useEffect(() => { + if (!startPolling) { + setRealtimeLogsMap(new Map()); + } + }, [startPolling]); + + return { + realtimeLogs, + historicalLogs, + isLoading: isLoadingInitial, + hasMore: hasNextPage, + loadMore: fetchNextPage, + isLoadingMore: isFetchingNextPage, + isPolling: startPolling, + }; +} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-footer.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-footer.tsx new file mode 100644 index 0000000000..0f4be9149d --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-footer.tsx @@ -0,0 +1,102 @@ +"use client"; +import { RED_STATES, YELLOW_STATES } from "@/app/(app)/logs/constants"; +import { extractResponseField, getRequestHeader } from "@/app/(app)/logs/utils"; +import { TimestampInfo } from "@/components/timestamp-info"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; +import { RequestResponseDetails } from "./request-response-details"; + +type Props = { + log: RatelimitLog; +}; + +const DEFAULT_OUTCOME = "VALID"; +export const LogFooter = ({ log }: Props) => { + return ( + ( + + ), + content: log.time, + tooltipContent: "Copy Time", + tooltipSuccessMessage: "Time copied to clipboard", + skipTooltip: true, + }, + { + label: "Host", + description: (content) => {content}, + content: log.host, + tooltipContent: "Copy Host", + tooltipSuccessMessage: "Host copied to clipboard", + }, + { + label: "Request Path", + description: (content) => {content}, + content: log.path, + tooltipContent: "Copy Request Path", + tooltipSuccessMessage: "Request path copied to clipboard", + }, + { + label: "Request ID", + description: (content) => {content}, + content: log.request_id, + tooltipContent: "Copy Request ID", + tooltipSuccessMessage: "Request ID copied to clipboard", + }, + { + label: "Request User Agent", + description: (content) => {content}, + content: getRequestHeader(log, "user-agent") ?? "", + tooltipContent: "Copy Request User Agent", + tooltipSuccessMessage: "Request user agent copied to clipboard", + }, + { + label: "Outcome", + description: (content) => { + let contentCopy = content; + if (contentCopy == null) { + contentCopy = DEFAULT_OUTCOME; + } + return ( + + {contentCopy} + + ); + }, + content: extractResponseField(log, "code"), + tooltipContent: "Copy Outcome", + tooltipSuccessMessage: "Outcome copied to clipboard", + }, + { + label: "Permissions", + description: (content) => ( + + {content.map((permission) => ( + + {permission} + + ))} + + ), + content: extractResponseField(log, "permissions"), + tooltipContent: "Copy Permissions", + tooltipSuccessMessage: "Permissions copied to clipboard", + }, + ]} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-header.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-header.tsx new file mode 100644 index 0000000000..d728d6a7ed --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-header.tsx @@ -0,0 +1,43 @@ +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; +import { XMark } from "@unkey/icons"; +import { Button } from "@unkey/ui"; + +type Props = { + log: RatelimitLog; + onClose: () => void; +}; + +export const LogHeader = ({ onClose, log }: Props) => { + return ( +
+
+ + {log.method} + +

{log.path}

+
+ +
+
+ = 200 && log.response_status < 300, + "bg-warning-3 text-warning-11 hover:bg-warning-4": + log.response_status >= 400 && log.response_status < 500, + "bg-error-3 text-error-11 hover:bg-error-4": log.response_status >= 500, + })} + > + {log.response_status} + + | + +
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-meta.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-meta.tsx new file mode 100644 index 0000000000..645c91d0a4 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-meta.tsx @@ -0,0 +1,37 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "@/components/ui/toaster"; +import { Button } from "@unkey/ui"; +import { Copy } from "lucide-react"; + +export const LogMetaSection = ({ content }: { content: string }) => { + const handleClick = () => { + navigator.clipboard + .writeText(content) + .then(() => { + toast.success("Meta copied to clipboard"); + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + toast.error("Failed to copy to clipboard"); + }); + }; + + return ( +
+
Meta
+ + +
{content ?? ""} 
+ +
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-section.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-section.tsx new file mode 100644 index 0000000000..a86487e144 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/log-section.tsx @@ -0,0 +1,71 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "@/components/ui/toaster"; +import { Button } from "@unkey/ui"; +import { Copy } from "lucide-react"; + +export const LogSection = ({ + details, + title, +}: { + details: string | string[]; + title: string; +}) => { + const handleClick = () => { + navigator.clipboard + .writeText(getFormattedContent(details)) + .then(() => { + toast.success(`${title} copied to clipboard`); + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + toast.error("Failed to copy to clipboard"); + }); + }; + + return ( +
+
+ {title} +
+ + +
+            {Array.isArray(details)
+              ? details.map((header) => {
+                  const [key, ...valueParts] = header.split(":");
+                  const value = valueParts.join(":").trim();
+                  return (
+                    
+ {key}: + {value} +
+ ); + }) + : details} +
+ +
+
+
+ ); +}; + +const getFormattedContent = (details: string | string[]) => { + if (Array.isArray(details)) { + return details + .map((header) => { + const [key, ...valueParts] = header.split(":"); + const value = valueParts.join(":").trim(); + return `${key}: ${value}`; + }) + .join("\n"); + } + return details; +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/request-response-details.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/request-response-details.tsx new file mode 100644 index 0000000000..15869e7d80 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/components/request-response-details.tsx @@ -0,0 +1,105 @@ +import { toast } from "@/components/ui/toaster"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; + +type Field = { + label: string; + description: (content: NonNullable) => ReactNode; + content: T | null; + tooltipContent?: ReactNode; + tooltipSuccessMessage?: string; + className?: string; + skipTooltip?: boolean; +}; + +type Props = { + fields: { [K in keyof T]: Field }; + className?: string; +}; + +const isNonEmpty = (content: unknown): boolean => { + if (content === undefined || content === null) { + return false; + } + + if (Array.isArray(content)) { + return content.some((item) => item !== null && item !== undefined); + } + + if (typeof content === "object" && content !== null) { + return Object.values(content).some((value) => value !== null && value !== undefined); + } + + if (typeof content === "string") { + return content.trim().length > 0; + } + + return Boolean(content); +}; + +export const RequestResponseDetails = ({ fields, className }: Props) => { + const handleClick = (field: Field) => { + try { + const text = + typeof field.content === "object" ? JSON.stringify(field.content) : String(field.content); + navigator.clipboard + .writeText(text) + .then(() => { + if (field.tooltipSuccessMessage) { + toast.success(field.tooltipSuccessMessage); + } + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + toast.error("Failed to copy to clipboard"); + }); + } catch (error) { + console.error("Error preparing content for clipboard:", error); + toast.error("Failed to prepare content for clipboard"); + } + }; + + const renderField = (field: Field, index: number) => { + const baseContent = ( + // biome-ignore lint/a11y/useKeyWithClickEvents: no need +
handleClick(field) : undefined} + > + {field.label} + + {field.description(field.content as NonNullable)} + +
+ ); + + if (field.skipTooltip) { + return baseContent; + } + + return ( + + + {baseContent} + {field.tooltipContent} + + + ); + }; + + return ( +
+ {fields.map( + (field, index) => + isNonEmpty(field.content) && ( +
{renderField(field, index)}
+ ), + )} +
+ ); +}; 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 new file mode 100644 index 0000000000..b6177d0b22 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/index.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { extractResponseField, safeParseJson } from "@/app/(app)/logs/utils"; +import { useMemo } from "react"; +import { DEFAULT_DRAGGABLE_WIDTH } from "../../../constants"; +import { useRatelimitLogsContext } from "../../../context/logs"; +import { LogFooter } from "./components/log-footer"; +import { LogHeader } from "./components/log-header"; +import { LogMetaSection } from "./components/log-meta"; +import { LogSection } from "./components/log-section"; +import ResizablePanel from "./resizable-panel"; + +const PANEL_MAX_WIDTH = 600; +const PANEL_MIN_WIDTH = 400; + +const createPanelStyle = (distanceToTop: number) => ({ + top: `${distanceToTop}px`, + width: `${DEFAULT_DRAGGABLE_WIDTH}px`, + height: `calc(100vh - ${distanceToTop}px)`, + paddingBottom: "1rem", +}); + +type Props = { + distanceToTop: number; +}; + +export const RatelimitLogDetails = ({ distanceToTop }: Props) => { + const { setSelectedLog, selectedLog: log } = useRatelimitLogsContext(); + const panelStyle = useMemo(() => createPanelStyle(distanceToTop), [distanceToTop]); + + if (!log) { + return null; + } + + const handleClose = () => { + setSelectedLog(null); + }; + + return ( + + + + + + + +
+ + + + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/resizable-panel.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/resizable-panel.tsx new file mode 100644 index 0000000000..a79d1063a1 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/log-details/resizable-panel.tsx @@ -0,0 +1,81 @@ +import type React from "react"; +import { type PropsWithChildren, useCallback, useEffect, useRef, useState } from "react"; +import { useOnClickOutside } from "usehooks-ts"; +import { MAX_DRAGGABLE_WIDTH, MIN_DRAGGABLE_WIDTH } from "../../../constants"; + +const ResizablePanel = ({ + children, + onResize, + onClose, + className, + style, + minW = MIN_DRAGGABLE_WIDTH, + maxW = MAX_DRAGGABLE_WIDTH, +}: PropsWithChildren<{ + onResize?: (newWidth: number) => void; + onClose: () => void; + className: string; + style: Record; + minW?: number; + maxW?: number; +}>) => { + const [isDragging, setIsDragging] = useState(false); + const [width, setWidth] = useState(String(style?.width)); + const panelRef = useRef(null); + + useOnClickOutside(panelRef, onClose); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging || !panelRef.current) { + return; + } + + const containerRect = panelRef.current.getBoundingClientRect(); + const newWidth = Math.min(Math.max(containerRect.right - e.clientX, minW), maxW); + setWidth(`${newWidth}px`); + onResize?.(newWidth); + }, + [isDragging, minW, maxW, onResize], + ); + + useEffect(() => { + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + } else { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + } + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, handleMouseMove, handleMouseUp]); + + return ( +
+
+ {children} +
+ ); +}; + +export default ResizablePanel; 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 new file mode 100644 index 0000000000..bddbef00af --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/components/table-action-popover.tsx @@ -0,0 +1,196 @@ +"use client"; + +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 { type KeyboardEvent, type PropsWithChildren, useEffect, useRef, useState } from "react"; +import { useRatelimitLogsContext } from "../../../../context/logs"; +import { useFilters } from "../../../../hooks/use-filters"; + +type Props = { + identifier: string; +}; + +export const TableActionPopover = ({ children, identifier }: PropsWithChildren) => { + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + const [focusIndex, setFocusIndex] = useState(0); + const menuItems = useRef([]); + const { filters, updateFilters } = useFilters(); + const { namespaceId } = useRatelimitLogsContext(); + + useEffect(() => { + if (open) { + setFocusIndex(0); + menuItems.current[0]?.focus(); + } + }, [open]); + + 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, + }); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + 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} + > + + {/* biome-ignore lint/a11y/useKeyWithClickEvents: it's okay */} +
{ + if (el) { + menuItems.current[0] = el; + } + }} + role="menuitem" + tabIndex={focusIndex === 0 ? 0 : -1} + className="flex w-full items-center px-2 py-1.5 justify-between 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 + {copied ? : } +
+ e.stopPropagation()} + > +
{ + if (el) { + menuItems.current[1] = el; + } + }} + role="menuitem" + tabIndex={focusIndex === 1 ? 0 : -1} + className="flex w-full items-center px-2 py-1.5 justify-between rounded-lg group cursor-pointer + hover:bg-gray-3 data-[state=open]:bg-gray-3 focus:outline-none focus:bg-gray-3" + > + Override + +
+ + {/* biome-ignore lint/a11y/useKeyWithClickEvents: it's okay */} +
{ + if (el) { + menuItems.current[2] = el; + } + }} + role="menuitem" + tabIndex={focusIndex === 2 ? 0 : -1} + className="flex w-full items-center px-2 py-1.5 justify-between rounded-lg group cursor-pointer + hover:bg-gray-3 data-[state=open]:bg-gray-3 focus:outline-none focus:bg-gray-3" + onClick={handleFilterClick} + > + Filter for identifier + +
+
+
+
+ ); +}; + +const PopoverHeader = () => { + return ( +
+ Actions +
+ ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/index.tsx new file mode 100644 index 0000000000..21d0db6560 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-actions/index.tsx @@ -0,0 +1,14 @@ +import { Dots } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { TableActionPopover } from "./components/table-action-popover"; + +export const LogsTableAction = ({ identifier }: { identifier: string }) => { + return ( + + + + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-table.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-table.tsx new file mode 100644 index 0000000000..5fc314ea0d --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/logs-table.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { safeParseJson } from "@/app/(app)/logs/utils"; +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 { RatelimitLog } from "@unkey/clickhouse/src/ratelimits"; +import { BookBookmark } from "@unkey/icons"; +import { Button, Empty } from "@unkey/ui"; +import { useMemo } from "react"; +import { DEFAULT_STATUS_FLAG } from "../../constants"; +import { useRatelimitLogsContext } from "../../context/logs"; +import { useRatelimitLogsQuery } from "./hooks/use-logs-query"; +import { LogsTableAction } from "./logs-actions"; + +type StatusStyle = { + base: string; + hover: string; + selected: string; + badge: { + default: string; + selected: string; + }; + focusRing: string; +}; + +const STATUS_STYLES = { + success: { + base: "text-accent-9", + hover: "hover:text-accent-11 dark:hover:text-accent-12 hover:bg-accent-3", + selected: "text-accent-11 bg-accent-3 dark:text-accent-12", + 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", + }, + warning: { + base: "text-warning-11 bg-warning-2", + hover: "hover:bg-warning-3", + selected: "bg-warning-3", + badge: { + default: "bg-warning-4 text-warning-11 group-hover:bg-warning-5", + selected: "bg-warning-5 text-warning-11 hover:bg-warning-5", + }, + focusRing: "focus:ring-warning-7", + }, +}; + +const getStatusStyle = (rejected: number): StatusStyle => { + if (rejected === DEFAULT_STATUS_FLAG) { + return STATUS_STYLES.warning; + } + return STATUS_STYLES.success; +}; + +const getSelectedClassName = (log: RatelimitLog, isSelected: boolean) => { + if (!isSelected) { + return ""; + } + const style = getStatusStyle(log.status); + return style.selected; +}; + +export const RatelimitLogsTable = () => { + const { setSelectedLog, selectedLog, isLive, namespaceId } = useRatelimitLogsContext(); + const { realtimeLogs, historicalLogs, isLoading, isLoadingMore, loadMore } = + useRatelimitLogsQuery({ + namespaceId, + startPolling: isLive, + pollIntervalMs: 2000, + }); + + const getRowClassName = (log: RatelimitLog) => { + const style = getStatusStyle(log.status); + const isSelected = selectedLog?.request_id === log.request_id; + + return cn( + style.base, + style.hover, + "group rounded-md", + "focus:outline-none focus:ring-1 focus:ring-opacity-40", + style.focusRing, + isSelected && style.selected, + isLive && + !realtimeLogs.some((realtime) => realtime.request_id === log.request_id) && [ + "opacity-50", + "hover:opacity-100", + ], + selectedLog && { + "opacity-50 z-0": !isSelected, + "opacity-100 z-10": isSelected, + }, + ); + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: it's okay + const columns: Column[] = useMemo( + () => [ + { + key: "time", + header: "Time", + headerClassName: "pl-2", + width: "10%", + render: (log) => ( +
+ +
+ ), + }, + { + key: "identifier", + header: "Identifier", + width: "15%", + render: (log) =>
{log.identifier}
, + }, + { + key: "rejected", + header: "Status", + width: "10%", + render: (log) => { + const style = getStatusStyle(log.status); + const isSelected = selectedLog?.request_id === log.request_id; + return ( + + {log.status === 0 ? "Blocked" : "Passed"} + + ); + }, + }, + { + key: "limit", + header: "Limit", + width: "auto", + render: (log) => { + return
{safeParseJson(log.response_body).limit}
; + }, + }, + { + key: "duration", + header: "Duration", + width: "auto", + render: (log) => { + return ( +
{msToSeconds(safeParseJson(log.request_body).duration)}
+ ); + }, + }, + { + key: "reset", + header: "Resets At", + width: "auto", + render: (log) => { + return ( +
+ +
+ ); + }, + }, + { + key: "region", + header: "Region", + width: "auto", + render: (log) =>
{log.colo}
, + }, + { + key: "actions", + header: "", + width: "auto", + render: (log) => ( +
+ +
+ ), + }, + ], + [selectedLog?.request_id], + ); + + return ( + log.request_id} + rowClassName={getRowClassName} + selectedClassName={getSelectedClassName} + emptyState={ +
+ + + 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. + + + + + + + +
+ } + /> + ); +}; + +function msToSeconds(ms: number) { + const seconds = Math.round(ms / 1000); + return `${seconds}s`; +} diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/query-logs.schema.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/query-logs.schema.ts new file mode 100644 index 0000000000..e861b7bfba --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/table/query-logs.schema.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; +import { filterOperatorEnum } from "../../filters.schema"; + +export const ratelimitQueryLogsPayload = z.object({ + limit: z.number().int(), + startTime: z.number().int(), + endTime: z.number().int(), + namespaceId: z.string(), + since: z.string(), + identifiers: z + .object({ + filters: z.array( + z.object({ + operator: filterOperatorEnum, + value: z.string(), + }), + ), + }) + .nullable(), + requestIds: z + .object({ + filters: z.array( + z.object({ + operator: z.literal("is"), + value: z.string(), + }), + ), + }) + .nullable(), + status: z + .object({ + filters: z.array( + z.object({ + operator: z.literal("is"), + value: z.enum(["blocked", "passed"]), + }), + ), + }) + .nullable(), + cursor: z + .object({ + requestId: z.string().nullable(), + time: z.number().nullable(), + }) + .optional() + .nullable(), +}); + +export type RatelimitQueryLogsPayload = z.infer; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/constants.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/constants.ts new file mode 100644 index 0000000000..8be78bf31a --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/constants.ts @@ -0,0 +1,5 @@ +export const DEFAULT_STATUS_FLAG = 0; + +export const DEFAULT_DRAGGABLE_WIDTH = 500; +export const MAX_DRAGGABLE_WIDTH = 800; +export const MIN_DRAGGABLE_WIDTH = 300; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/context/logs.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/context/logs.tsx new file mode 100644 index 0000000000..5dfb0e6432 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/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 RatelimitLogsProvider = ({ + 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 useRatelimitLogsContext = () => { + 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]/logs/filter.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filter.tsx deleted file mode 100644 index 4d6494d393..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filter.tsx +++ /dev/null @@ -1,275 +0,0 @@ -"use client"; -import { ArrayInput } from "@/components/array-input"; -import { cn } from "@/lib/utils"; -import { Button, buttonVariants } from "@unkey/ui"; -import { - CalendarIcon, - CalendarRange, - CheckCheck, - ChevronDown, - RefreshCw, - User, - X, -} from "lucide-react"; -import { - parseAsArrayOf, - parseAsBoolean, - parseAsString, - parseAsTimestamp, - useQueryState, -} from "nuqs"; -import type React from "react"; -import { useState, useTransition } from "react"; - -import { DateTimePicker } from "@/components/date-time-picker"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { format } from "date-fns"; -import { useRouter } from "next/navigation"; - -export const Filters: React.FC = () => { - const router = useRouter(); - const [isPending, startTransition] = useTransition(); - - const [identifier, setIdentifier] = useQueryState( - "identifier", - parseAsArrayOf(parseAsString).withDefault([]).withOptions({ - history: "push", - shallow: false, // otherwise server components won't notice the change - clearOnDefault: true, - }), - ); - const [success, setSuccess] = useQueryState( - "success", - parseAsBoolean.withOptions({ - history: "push", - shallow: false, // otherwise server components won't notice the change - clearOnDefault: true, - }), - ); - - const [after, setAfter] = useQueryState( - "after", - parseAsTimestamp.withOptions({ - history: "push", - shallow: false, - clearOnDefault: true, - }), - ); - - const [before, setBefore] = useQueryState( - "before", - parseAsTimestamp.withOptions({ - history: "push", - shallow: false, - clearOnDefault: true, - }), - ); - - const [identifierVisible, setIdentifierVisible] = useState(false); - const [successVisible, setSuccessVisible] = useState(false); - const [timeRangeVisible, setTimeRangeVisible] = useState(false); - - return ( -
-
- - - - - - - Filters are case sensitive - - - setIdentifierVisible(true)}> - - Identifier - - setTimeRangeVisible(true)}> - - Time - - setSuccess(true)}> - - Success - - - - {identifierVisible ? ( - - ) : null} - -
-
- {identifierVisible || identifier.length > 0 ? ( - { - setIdentifier(v); - startTransition(() => {}); - }} - removeFilter={() => setIdentifierVisible(false)} - /> - ) : null} - - {successVisible || typeof success === "boolean" ? ( -
- - -
- ) : null} - {timeRangeVisible || after !== null || before !== null ? ( -
- - startTransition(() => { - setAfter(date); - }) - } - timeInputLabel="Select Time" - calendarProps={{ - disabled: { after: new Date() }, - showOutsideDays: true, - }} - timeInputProps={{ - className: "w-[100px]", - }} - > -
- From: - - {after ? format(after, "PPp") : format(new Date(), "PPp")} - - -
-
-
- - startTransition(() => { - setBefore(date); - }) - } - timeInputLabel="Select Time" - calendarProps={{ - disabled: { before: after ?? new Date() }, - showOutsideDays: true, - }} - timeInputProps={{ - className: "w-[130px]", - }} - > -
- Until: - - {before ? format(before, "PPp") : format(new Date(), "PPp")} - - -
-
-
- - -
- ) : null} -
-
- ); -}; - -const FilterRow: React.FC<{ - title: string; - selected: string[]; - setSelected: (v: string[]) => void; - removeFilter: () => void; -}> = ({ title, selected, setSelected, removeFilter }) => { - return ( -
- - -
- ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filters.schema.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filters.schema.ts new file mode 100644 index 0000000000..8eef09fae6 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filters.schema.ts @@ -0,0 +1,144 @@ +import { z } from "zod"; +import type { + FieldConfig, + FilterField, + FilterFieldConfigs, + FilterValue, + NumberConfig, + StringConfig, +} from "./filters.type"; + +export const filterOperatorEnum = z.enum(["is", "contains"]); + +export const filterFieldEnum = z.enum([ + "startTime", + "endTime", + "since", + "identifiers", + "requestIds", + "status", +]); + +export const filterOutputSchema = z.object({ + filters: z.array( + z + .object({ + field: filterFieldEnum, + filters: z.array( + z.object({ + operator: filterOperatorEnum, + value: z.union([z.string(), z.number()]), + }), + ), + }) + .refine( + (data) => { + const config = filterFieldConfig[data.field]; + return data.filters.every((filter) => { + const isOperatorValid = config.operators.includes(filter.operator as any); + if (!isOperatorValid) { + return false; + } + return validateFieldValue(data.field, filter.value); + }); + }, + { + message: "Invalid field/operator/value combination", + }, + ), + ), +}); + +// Required for transforming OpenAI structured outputs into our own Filter types +export const transformStructuredOutputToFilters = ( + data: z.infer, + existingFilters: FilterValue[] = [], +): FilterValue[] => { + const uniqueFilters = [...existingFilters]; + const seenFilters = new Set(existingFilters.map((f) => `${f.field}-${f.operator}-${f.value}`)); + + for (const filterGroup of data.filters) { + filterGroup.filters.forEach((filter) => { + const baseFilter = { + field: filterGroup.field, + operator: filter.operator, + value: filter.value, + }; + + const filterKey = `${baseFilter.field}-${baseFilter.operator}-${baseFilter.value}`; + + if (seenFilters.has(filterKey)) { + return; + } + + uniqueFilters.push({ + id: crypto.randomUUID(), + ...baseFilter, + }); + + seenFilters.add(filterKey); + }); + } + + return uniqueFilters; +}; + +// Type guard for config types +function isNumberConfig(config: FieldConfig): config is NumberConfig { + return config.type === "number"; +} + +function isStringConfig(config: FieldConfig): config is StringConfig { + return config.type === "string"; +} + +export function validateFieldValue(field: FilterField, value: string | number): boolean { + const config = filterFieldConfig[field]; + + if (isStringConfig(config) && typeof value === "string") { + if (config.validValues) { + return config.validValues.includes(value); + } + return config.validate ? config.validate(value) : true; + } + + if (isNumberConfig(config) && typeof value === "number") { + return config.validate ? config.validate(value) : true; + } + + return true; +} + +export const filterFieldConfig: FilterFieldConfigs = { + startTime: { + type: "number", + operators: ["is"], + }, + endTime: { + type: "number", + operators: ["is"], + }, + since: { + type: "string", + operators: ["is"], + }, + identifiers: { + type: "string", + operators: ["is", "contains"], + }, + requestIds: { + type: "string", + operators: ["is"], + }, + status: { + type: "string", + operators: ["is"], + validValues: ["blocked", "passed"], + getColorClass: (value) => { + if (value === "blocked") { + return "bg-warning-9"; + } + return "bg-success-9"; + }, + } as const, +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filters.type.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filters.type.ts new file mode 100644 index 0000000000..2c68f14167 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/filters.type.ts @@ -0,0 +1,55 @@ +import type { z } from "zod"; +import type { filterFieldEnum, filterOperatorEnum } from "./filters.schema"; + +export type FilterOperator = z.infer; +export type FilterField = z.infer; + +export type FieldConfig = StringConfig | NumberConfig; + +export interface BaseFieldConfig { + type: T extends string ? "string" : "number"; + operators: FilterOperator[]; +} + +export interface NumberConfig extends BaseFieldConfig { + type: "number"; + validate?: (value: number) => boolean; +} + +export interface StringConfig extends BaseFieldConfig { + type: "string"; + validValues?: readonly string[]; + validate?: (value: string) => boolean; + getColorClass?: (value: string) => string; +} + +export type FilterFieldConfigs = { + startTime: NumberConfig; + endTime: NumberConfig; + since: StringConfig; + identifiers: StringConfig; + requestIds: StringConfig; + status: StringConfig; +}; + +export type QuerySearchParams = { + startTime?: number | null; + endTime?: number | null; + since?: string | null; + identifiers: FilterUrlValue[] | null; + requestIds: FilterUrlValue[] | null; + status: FilterUrlValue[] | null; +}; + +export type FilterUrlValue = Pick; + +export type FilterValue = { + id: string; + field: FilterField; + operator: FilterOperator; + value: string | number; + metadata?: { + colorClass?: string; + icon?: React.ReactNode; + }; +}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.test.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.test.ts new file mode 100644 index 0000000000..1ab1fda313 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.test.ts @@ -0,0 +1,295 @@ +import { act, renderHook } from "@testing-library/react"; +import { useQueryStates } from "nuqs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { parseAsFilterValueArray, parseAsRelativeTime, useFilters } from "./use-filters"; + +vi.mock("nuqs", () => { + const mockSetSearchParams = vi.fn(); + + return { + useQueryStates: vi.fn(() => [ + { + requestIds: null, + identifiers: null, + startTime: null, + endTime: null, + status: null, + since: null, + }, + mockSetSearchParams, + ]), + parseAsInteger: { + parse: (str: string | null) => (str ? Number.parseInt(str) : null), + serialize: (value: number | null) => value?.toString() ?? "", + }, + }; +}); + +vi.stubGlobal("crypto", { + randomUUID: vi.fn(() => "test-uuid"), +}); + +const mockUseQueryStates = vi.mocked(useQueryStates); +const mockSetSearchParams = vi.fn(); + +describe("parseAsFilterValueArray", () => { + it("should return empty array for null input", () => { + //@ts-expect-error ts yells for no reason + expect(parseAsFilterValueArray.parse(null)).toEqual([]); + }); + + it("should return empty array for empty string", () => { + expect(parseAsFilterValueArray.parse("")).toEqual([]); + }); + + it("should parse single filter correctly", () => { + const result = parseAsFilterValueArray.parse("is:200"); + expect(result).toEqual([ + { + operator: "is", + value: "200", + }, + ]); + }); + + it("should parse multiple filters correctly", () => { + const result = parseAsFilterValueArray.parse("is:200,contains:error"); + expect(result).toEqual([ + { operator: "is", value: "200" }, + { operator: "contains", value: "error" }, + ]); + }); + + it("should return empty array for invalid operator", () => { + expect(parseAsFilterValueArray.parse("invalid:200")).toEqual([]); + }); + + it("should serialize empty array to empty string", () => { + //@ts-expect-error ts yells for no reason + expect(parseAsFilterValueArray.serialize([])).toBe(""); + }); + + it("should serialize array of filters correctly", () => { + const filters = [ + { operator: "is", value: "200" }, + { operator: "contains", value: "error" }, + ]; + //@ts-expect-error ts yells for no reason + expect(parseAsFilterValueArray.serialize(filters)).toBe("is:200,contains:error"); + }); +}); + +describe("parseAsRelativeTime", () => { + it("should return null for null input", () => { + //@ts-expect-error ts yells for no reason + expect(parseAsRelativeTime.parse(null)).toBeNull(); + }); + + it("should return null for empty string", () => { + expect(parseAsRelativeTime.parse("")).toBeNull(); + }); + + it("should parse valid single unit formats", () => { + expect(parseAsRelativeTime.parse("1h")).toBe("1h"); + expect(parseAsRelativeTime.parse("24h")).toBe("24h"); + expect(parseAsRelativeTime.parse("7d")).toBe("7d"); + expect(parseAsRelativeTime.parse("30m")).toBe("30m"); + }); + + it("should parse valid multiple unit formats", () => { + expect(parseAsRelativeTime.parse("1h30m")).toBe("1h30m"); + expect(parseAsRelativeTime.parse("2d5h")).toBe("2d5h"); + expect(parseAsRelativeTime.parse("1d6h30m")).toBe("1d6h30m"); + }); + + it("should return null for invalid formats", () => { + expect(parseAsRelativeTime.parse("1x")).toBeNull(); + expect(parseAsRelativeTime.parse("h")).toBeNull(); + expect(parseAsRelativeTime.parse("24")).toBeNull(); + expect(parseAsRelativeTime.parse("-1h")).toBeNull(); + expect(parseAsRelativeTime.parse("1h2")).toBeNull(); + expect(parseAsRelativeTime.parse("1h 2d")).toBeNull(); + }); + + it("should serialize null to empty string", () => { + //@ts-expect-error ts yells for no reason + expect(parseAsRelativeTime.serialize(null)).toBe(""); + }); + + it("should serialize valid time strings correctly", () => { + //@ts-expect-error ts yells for no reason + expect(parseAsRelativeTime.serialize("1h")).toBe("1h"); + //@ts-expect-error ts yells for no reason + expect(parseAsRelativeTime.serialize("2d5h30m")).toBe("2d5h30m"); + }); +}); + +describe("useFilters hook", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseQueryStates.mockImplementation(() => [ + { + requestIds: null, + identifiers: null, + startTime: null, + endTime: null, + status: null, + since: null, + }, + mockSetSearchParams, + ]); + }); + + it("should initialize with empty filters", () => { + const { result } = renderHook(() => useFilters()); + expect(result.current.filters).toEqual([]); + }); + + it("should initialize with existing filters", () => { + mockUseQueryStates.mockImplementation(() => [ + { + requestIds: [{ operator: "is", value: "123" }], + identifiers: null, + startTime: null, + endTime: null, + status: null, + since: null, + }, + mockSetSearchParams, + ]); + + const { result } = renderHook(() => useFilters()); + expect(result.current.filters).toEqual([ + { + id: "test-uuid", + field: "requestIds", + operator: "is", + value: "123", + }, + ]); + }); + + it("should handle multiple filter types", () => { + mockUseQueryStates.mockImplementation(() => [ + { + requestIds: [{ operator: "is", value: "123" }], + identifiers: [{ operator: "contains", value: "api" }], + startTime: 1609459200000, + endTime: null, + status: null, + since: "24h", + }, + mockSetSearchParams, + ]); + + const { result } = renderHook(() => useFilters()); + expect(result.current.filters).toEqual([ + { + id: "test-uuid", + field: "requestIds", + operator: "is", + value: "123", + }, + { + id: "test-uuid", + field: "identifiers", + operator: "contains", + value: "api", + }, + { + id: "test-uuid", + field: "startTime", + operator: "is", + value: 1609459200000, + }, + { + id: "test-uuid", + field: "since", + operator: "is", + value: "24h", + }, + ]); + }); + + it("should remove filter correctly", () => { + mockUseQueryStates.mockImplementation(() => [ + { + requestIds: [{ operator: "is", value: "123" }], + identifiers: null, + startTime: null, + endTime: null, + status: null, + since: null, + }, + mockSetSearchParams, + ]); + + const { result } = renderHook(() => useFilters()); + + act(() => { + result.current.removeFilter("test-uuid"); + }); + + expect(mockSetSearchParams).toHaveBeenCalledWith({ + requestIds: null, + identifiers: null, + startTime: null, + endTime: null, + status: null, + since: null, + }); + }); + + it("should handle clearing all filters", () => { + mockUseQueryStates.mockImplementation(() => [ + { + requestIds: [{ operator: "is", value: "123" }], + identifiers: [{ operator: "contains", value: "api" }], + startTime: 1609459200000, + endTime: null, + status: null, + since: "24h", + }, + mockSetSearchParams, + ]); + + const { result } = renderHook(() => useFilters()); + + act(() => { + result.current.updateFilters([]); + }); + + expect(mockSetSearchParams).toHaveBeenCalledWith({ + requestIds: null, + identifiers: null, + startTime: null, + endTime: null, + status: null, + since: null, + }); + }); + + it("should update filters correctly", () => { + const { result } = renderHook(() => useFilters()); + + act(() => { + result.current.updateFilters([ + { + id: "test-uuid-1", + field: "identifiers", + operator: "contains", + value: "api", + }, + ]); + }); + + expect(mockSetSearchParams).toHaveBeenCalledWith({ + requestIds: null, + identifiers: [{ operator: "contains", value: "api" }], + startTime: null, + endTime: null, + status: null, + since: null, + }); + }); +}); diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.ts b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.ts new file mode 100644 index 0000000000..a1d8c8bcb9 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/hooks/use-filters.ts @@ -0,0 +1,195 @@ +import { type Parser, parseAsInteger, useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; +import { filterFieldConfig } from "../filters.schema"; +import type { + FilterField, + FilterOperator, + FilterUrlValue, + FilterValue, + QuerySearchParams, +} from "../filters.type"; + +export const parseAsRelativeTime: Parser = { + parse: (str: string | null) => { + if (!str) { + return null; + } + + try { + // Validate the format matches one or more of: number + (h|d|m) + const isValid = /^(\d+[hdm])+$/.test(str); + if (!isValid) { + return null; + } + return str; + } catch { + return null; + } + }, + serialize: (value: string | null) => { + if (!value) { + return ""; + } + return value; + }, +}; + +export const parseAsFilterValueArray: Parser = { + parse: (str: string | null) => { + if (!str) { + return []; + } + try { + // Format: operator:value,operator:value (e.g., "is:200,is:404") + return str.split(",").map((item) => { + const [operator, val] = item.split(/:(.+)/); + if (!["is", "contains"].includes(operator)) { + throw new Error("Invalid operator"); + } + return { + operator: operator as FilterOperator, + value: val, + }; + }); + } catch { + return []; + } + }, + serialize: (value: FilterUrlValue[]) => { + if (!value?.length) { + return ""; + } + return value.map((v) => `${v.operator}:${v.value}`).join(","); + }, +}; + +export const queryParamsPayload = { + requestIds: parseAsFilterValueArray, + identifiers: parseAsFilterValueArray, + startTime: parseAsInteger, + endTime: parseAsInteger, + status: parseAsFilterValueArray, + since: parseAsRelativeTime, +} as const; + +export const useFilters = () => { + const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload); + + const filters = useMemo(() => { + const activeFilters: FilterValue[] = []; + + searchParams.requestIds?.forEach((requestIdFilter) => { + activeFilters.push({ + id: crypto.randomUUID(), + field: "requestIds", + operator: requestIdFilter.operator, + value: requestIdFilter.value, + }); + }); + + 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: filterFieldConfig.status.getColorClass?.(statusFilter.value as string), + }, + }); + }); + + ["startTime", "endTime", "since"].forEach((field) => { + const value = searchParams[field as keyof QuerySearchParams]; + if (value !== null && value !== undefined) { + activeFilters.push({ + id: crypto.randomUUID(), + field: field as FilterField, + operator: "is", + value: value as string | number, + }); + } + }); + + return activeFilters; + }, [searchParams]); + + const updateFilters = useCallback( + (newFilters: FilterValue[]) => { + const newParams: Partial = { + requestIds: null, + startTime: null, + endTime: null, + since: null, + identifiers: null, + status: null, + }; + + // Group filters by field + const requestIdFilters: FilterUrlValue[] = []; + const statusFilters: FilterUrlValue[] = []; + const identifierFilters: FilterUrlValue[] = []; + + newFilters.forEach((filter) => { + switch (filter.field) { + case "requestIds": + requestIdFilters.push({ + value: filter.value as string, + operator: filter.operator, + }); + break; + 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.requestIds = requestIdFilters.length > 0 ? requestIdFilters : 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]/logs/menu.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/menu.tsx deleted file mode 100644 index e4944f28f6..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/menu.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; -import { Copy, Filter, MoreHorizontal, UserRoundCog } from "lucide-react"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { toast } from "@/components/ui/toaster"; -import { Button } from "@unkey/ui"; -import Link from "next/link"; -import { parseAsArrayOf, parseAsString, useQueryState } from "nuqs"; - -type Props = { - namespace: { id: string }; - identifier: string; -}; - -export const Menu: React.FC = ({ namespace, identifier }) => { - const [_, setIdentifier] = useQueryState( - "identifier", - parseAsArrayOf(parseAsString).withDefault([]).withOptions({ - history: "push", - shallow: false, // otherwise server components won't notice the change - clearOnDefault: true, - }), - ); - - return ( - - - - - - { - navigator.clipboard.writeText(identifier); - toast.success("Copied to clipboard", { - description: identifier, - }); - }} - > - - Copy identifier - - - - - Override - - - { - setIdentifier([identifier]); - }} - > - - Filter for identifier - - - - ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/page.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/page.tsx index 16cca80152..877f695b13 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/page.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/page.tsx @@ -3,58 +3,22 @@ import { db } from "@/lib/db"; import { notFound } from "next/navigation"; import { CopyButton } from "@/components/dashboard/copy-button"; -import { Loading } from "@/components/dashboard/loading"; import { Navbar as SubMenu } from "@/components/dashboard/navbar"; import { Navbar } from "@/components/navbar"; -import { PageContent } from "@/components/page-content"; -import { TimestampInfo } from "@/components/timestamp-info"; import { Badge } from "@/components/ui/badge"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { clickhouse } from "@/lib/clickhouse"; import { Gauge } from "@unkey/icons"; -import { Empty } from "@unkey/ui"; -import { Check, X } from "lucide-react"; -import { parseAsArrayOf, parseAsBoolean, parseAsIsoDateTime, parseAsString } from "nuqs/server"; -import { Suspense } from "react"; import { navigation } from "../constants"; -import { Filters } from "./filter"; -import { Menu } from "./menu"; +import { LogsClient } from "./components/logs-client"; -export const dynamic = "force-dynamic"; -export const runtime = "edge"; - -type Props = { - params: { - namespaceId: string; - }; - searchParams: { - after?: string; - before?: string; - identifier?: string | string[]; - ipAddress?: string | string[]; - country?: string | string[]; - success?: string; - }; -}; - -/** - * Parse searchParam string arrays - */ -const stringParser = parseAsArrayOf(parseAsString).withDefault([]); - -export default async function AuditPage(props: Props) { +export default async function RatelimitLogsPage({ + params: { namespaceId }, +}: { + params: { namespaceId: 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)), + where: (table, { eq, and, isNull }) => and(eq(table.id, namespaceId), isNull(table.deletedAt)), with: { workspace: true, }, @@ -63,24 +27,25 @@ export default async function AuditPage(props: Props) { return notFound(); } - const selected = { - identifier: stringParser.parseServerSide(props.searchParams.identifier), - ipAddress: stringParser.parseServerSide(props.searchParams.ipAddress), - country: stringParser.parseServerSide(props.searchParams.country), - success: parseAsBoolean.parseServerSide(props.searchParams.success), - after: parseAsIsoDateTime.parseServerSide(props.searchParams.after), - before: parseAsIsoDateTime.parseServerSide(props.searchParams.before), - }; + return ; +} +const LogsContainerPage = ({ + namespaceId, + namespaceName, +}: { + namespaceId: string; + namespaceName: string; +}) => { return (
}> Ratelimits - - {namespace.name} + + {namespaceName.length > 0 ? namespaceName : ""} - + Logs @@ -90,129 +55,13 @@ export default async function AuditPage(props: Props) { variant="secondary" className="flex justify-between w-full gap-2 font-mono font-medium ph-no-capture" > - {props.params.namespaceId} - + {namespaceId} + - - - -
- - - -
- } - > - - -
- -
- ); -} - -const AuditLogTable: React.FC<{ - workspaceId: string; - namespaceId: string; - selected: { - identifier: string[]; - ipAddress: string[]; - country: string[]; - success: boolean | null; - before: Date | null; - after: Date | null; - }; -}> = async ({ workspaceId, namespaceId, selected }) => { - const isFiltered = - selected.identifier.length > 0 || - selected.ipAddress.length > 0 || - selected.country.length > 0 || - selected.before || - selected.after || - typeof selected.success === "boolean"; - - const query = { - workspaceId: workspaceId, - namespaceId: namespaceId, - start: selected.before?.getTime() ?? undefined, - end: selected.after?.getTime() ?? undefined, - identifier: selected.identifier.length > 0 ? selected.identifier : undefined, - country: selected.country.length > 0 ? selected.country : undefined, - ipAddress: selected.ipAddress.length > 0 ? selected.ipAddress : undefined, - - passed: selected.success ?? undefined, - }; - const logs = await clickhouse.ratelimits.logs(query).then((res) => res.val!); - - if (logs.length === 0) { - return ( - - - No logs found - {isFiltered ? ( -
- - No events matched these filters, try changing them. - -
- ) : ( - - Create, update or delete something and come back again. - - )} -
- ); - } - - return ( -
- - - - Time - Identifier - Passed - - - - - {logs.map((l) => ( - - -
- -
-
- - - - {l.identifier} - - - - - {l.passed ? ( - - ) : ( - - )} - - - - - - - ))} - -
+ +
); }; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/page.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/page.tsx index 153c8ceefd..7ac1e9f12c 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/page.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/page.tsx @@ -45,6 +45,7 @@ export default async function RatelimitNamespacePage(props: { }, }, }); + if (!namespace || namespace.workspace.tenantId !== tenantId) { return redirect("/ratelimits"); } @@ -61,13 +62,25 @@ export default async function RatelimitNamespacePage(props: { 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, - start, - end, - identifier: selectedIdentifier.length > 0 ? selectedIdentifier : undefined, + 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(*)` }) @@ -75,13 +88,12 @@ export default async function RatelimitNamespacePage(props: { .where(eq(schema.ratelimitOverrides.namespaceId, namespace.id)) .execute() .then((res) => res?.at(0)?.count ?? 0), - getRatelimitsPerInterval(query).then((res) => res.val!), - getRatelimitsPerInterval({ - workspaceId: namespace.workspaceId, - namespaceId: namespace.id, - start: billingCycleStart, - end: billingCycleEnd, - }).then((res) => res.val!), + clickhouse.ratelimits.timeseries[timeseriesMethod](query), + clickhouse.ratelimits.timeseries[timeseriesMethod]({ + ...query, + startTime: billingCycleStart, + endTime: billingCycleEnd, + }), clickhouse.ratelimits .latest({ workspaceId: namespace.workspaceId, @@ -91,28 +103,27 @@ export default async function RatelimitNamespacePage(props: { .then((res) => res.val?.at(0)?.time), ]); - const passedOverTime: { x: string; y: number }[] = []; - const ratelimitedOverTime: { x: string; y: number }[] = []; - - for (const d of ratelimitEvents.sort((a, b) => a.time - b.time)) { - const x = new Date(d.time).toISOString(); - passedOverTime.push({ x, y: d.passed }); - ratelimitedOverTime.push({ x, y: d.total - d.passed }); - } - - const dataOverTime = ratelimitEvents.flatMap((d) => [ + const dataOverTime = (ratelimitEvents.val ?? []).flatMap((event) => [ { - x: new Date(d.time).toISOString(), - y: d.total - d.passed, + x: new Date(event.x).toISOString(), + y: event.y.total - event.y.passed, category: "Ratelimited", }, { - x: new Date(d.time).toISOString(), - y: d.passed, + 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 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 ' \\ @@ -122,6 +133,7 @@ export default async function RatelimitNamespacePage(props: { "limit": 10, "duration": 10000 }'`; + return (
@@ -132,7 +144,7 @@ export default async function RatelimitNamespacePage(props: { isIdentifier active > - {namespace.name} + {namespace.name.length > 0 ? namespace.name : ""} @@ -157,7 +169,7 @@ export default async function RatelimitNamespacePage(props: { month: "long", })}`} value={formatNumber( - ratelimitsInBillingCycle.reduce((sum, day) => sum + day.passed, 0), + (ratelimitsInBillingCycle.val ?? []).reduce((sum, day) => sum + day.y.passed, 0), )} />
- sum + day.passed, 0))} - /> - sum + (day.total - day.passed), 0), - )} - /> - sum + day.total, 0))} - /> - sum + day.passed, 0) / - ratelimitEvents.reduce((sum, day) => sum + day.total, 0)) * - 100, - )}%`} - /> + + + +
@@ -227,7 +221,7 @@ export default async function RatelimitNamespacePage(props: { No usage Ratelimit something or change the range - + {snippet} @@ -251,7 +245,7 @@ function prepareInterval(interval: Interval) { end, intervalMs, granularity: 1000 * 60, - getRatelimitsPerInterval: clickhouse.ratelimits.perMinute, + getRatelimitsPerInterval: "perMinute", }; } case "24h": { @@ -262,7 +256,7 @@ function prepareInterval(interval: Interval) { end, intervalMs, granularity: 1000 * 60 * 60, - getRatelimitsPerInterval: clickhouse.ratelimits.perHour, + getRatelimitsPerInterval: "perHour", }; } case "7d": { @@ -274,7 +268,7 @@ function prepareInterval(interval: Interval) { end, intervalMs, granularity: 1000 * 60 * 60 * 24, - getRatelimitsPerInterval: clickhouse.ratelimits.perDay, + getRatelimitsPerInterval: "perDay", }; } case "30d": { @@ -286,7 +280,7 @@ function prepareInterval(interval: Interval) { end, intervalMs, granularity: 1000 * 60 * 60 * 24, - getRatelimitsPerInterval: clickhouse.ratelimits.perDay, + getRatelimitsPerInterval: "perDay", }; } case "90d": { @@ -298,7 +292,7 @@ function prepareInterval(interval: Interval) { end, intervalMs, granularity: 1000 * 60 * 60 * 24, - getRatelimitsPerInterval: clickhouse.ratelimits.perDay, + getRatelimitsPerInterval: "perDay", }; } } diff --git a/apps/dashboard/app/(app)/ratelimits/card.tsx b/apps/dashboard/app/(app)/ratelimits/card.tsx index 6c5c745b8c..0f71c5526f 100644 --- a/apps/dashboard/app/(app)/ratelimits/card.tsx +++ b/apps/dashboard/app/(app)/ratelimits/card.tsx @@ -1,6 +1,7 @@ import { clickhouse } from "@/lib/clickhouse"; import ms from "ms"; import { Sparkline } from "./sparkline"; + type Props = { workspace: { id: string; @@ -11,20 +12,21 @@ type Props = { }; }; -export const RatelimitCard: React.FC = async ({ workspace, namespace }) => { +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 + clickhouse.ratelimits.timeseries .perMinute({ + identifiers: [], workspaceId: workspace.id, namespaceId: namespace.id, - start: end - intervalMs, - end, + startTime: end - intervalMs, + endTime: end, }) - .then((res) => res.val!), + .then((res) => res.val ?? []), clickhouse.ratelimits .latest({ workspaceId: workspace.id, @@ -34,37 +36,38 @@ export const RatelimitCard: React.FC = async ({ workspace, namespace }) = .then((res) => res.val?.at(0)?.time), ]); - const totalRequests = history.reduce((sum, d) => sum + d.total, 0); - const totalSeconds = Math.floor( - ((history.at(-1)?.time ?? 0) - (history.at(0)?.time ?? 0)) / 1000, - ); + 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.flatMap((d) => ({ - time: d.time, + const data = history.map((d) => ({ + time: d.x, values: { - passed: d.passed, - total: d.total, + passed: d.y.passed, + total: d.y.total, }, })); + return ( -
-
+
+
-

{namespace.name}

+

{namespace.name}

~{rps.toFixed(2)} requests/s
-
{lastUsed ? ( <> Last request{" "} -