diff --git a/apps/dashboard/app/(app)/apis/_components/api-list-card.tsx b/apps/dashboard/app/(app)/apis/_components/api-list-card.tsx new file mode 100644 index 0000000000..4f87fb5c45 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/api-list-card.tsx @@ -0,0 +1,61 @@ +"use client"; +import { StatsCard } from "@/components/stats-card"; +import { StatsTimeseriesBarChart } from "@/components/stats-card/components/chart/stats-chart"; +import { MetricStats } from "@/components/stats-card/components/metric-stats"; +import type { ApiOverview } from "@/lib/trpc/routers/api/query-overview/schemas"; +import { Key, ProgressBar } from "@unkey/icons"; +import { useFetchVerificationTimeseries } from "./hooks/use-query-timeseries"; + +type Props = { + api: ApiOverview; +}; + +export const ApiListCard = ({ api }: Props) => { + const { timeseries, isLoading, isError } = useFetchVerificationTimeseries(api.keyspaceId); + + const passed = timeseries?.reduce((acc, crr) => acc + crr.success, 0) ?? 0; + const blocked = timeseries?.reduce((acc, crr) => acc + crr.error, 0) ?? 0; + + const keyCount = api.keys.reduce((acc, crr) => acc + crr.count, 0); + return ( + + } + stats={ + <> + +
+ +
+ {keyCount > 0 ? `${keyCount} ${keyCount === 1 ? "Key" : "Keys"}` : "No data"} +
+
+ + } + icon={} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/apis/_components/api-list-client.tsx b/apps/dashboard/app/(app)/apis/_components/api-list-client.tsx new file mode 100644 index 0000000000..9a67498b3a --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/api-list-client.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { EmptyComponentSpacer } from "@/components/empty-component-spacer"; +import type { + ApiOverview, + ApisOverviewResponse, +} from "@/lib/trpc/routers/api/query-overview/schemas"; +import { BookBookmark } from "@unkey/icons"; +import { Button, Empty } from "@unkey/ui"; +import { useState } from "react"; +import { ApiListGrid } from "./api-list-grid"; +import { ApiListControlCloud } from "./control-cloud"; +import { ApiListControls } from "./controls"; +import { CreateApiButton } from "./create-api-button"; + +export const ApiListClient = ({ + initialData, + unpaid, +}: { + initialData: ApisOverviewResponse; + unpaid: boolean; +}) => { + const [isSearching, setIsSearching] = useState(false); + const [apiList, setApiList] = useState(initialData.apiList); + + if (unpaid) { + return ( + + + Upgrade your plan + + Team workspaces is a paid feature. Please switch to a paid plan to continue using it. + + + + + + + + + ); + } + + return ( +
+ + + {initialData.apiList.length > 0 ? ( + + ) : ( + + + + No APIs found + + You haven't created any APIs yet. Create one to get started. + + + + + + + + + + )} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/_components/api-list-grid.tsx b/apps/dashboard/app/(app)/apis/_components/api-list-grid.tsx new file mode 100644 index 0000000000..84037ef3ab --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/api-list-grid.tsx @@ -0,0 +1,68 @@ +import { EmptyComponentSpacer } from "@/components/empty-component-spacer"; +import type { + ApiOverview, + ApisOverviewResponse, +} from "@/lib/trpc/routers/api/query-overview/schemas"; +import { ChevronDown } from "@unkey/icons"; +import { Button, Empty } from "@unkey/ui"; +import type { Dispatch, SetStateAction } from "react"; +import { ApiListCard } from "./api-list-card"; +import { useFetchApiOverview } from "./hooks/use-fetch-api-overview"; + +export const ApiListGrid = ({ + initialData, + setApiList, + apiList, + isSearching, +}: { + initialData: ApisOverviewResponse; + apiList: ApiOverview[]; + setApiList: Dispatch>; + isSearching?: boolean; +}) => { + const { total, loadMore, isLoading, hasMore } = useFetchApiOverview(initialData, setApiList); + + if (apiList.length === 0) { + return ( + + + + No APIs found + + No APIs match your search criteria. Try a different search term. + + + + ); + } + + return ( + <> +
+ {apiList.map((api) => ( + + ))} +
+
+
+ Showing {apiList.length} of {total} APIs +
+ {!isSearching && hasMore && ( + + )} +
+ + ); +}; diff --git a/apps/dashboard/app/(app)/apis/_components/constants.ts b/apps/dashboard/app/(app)/apis/_components/constants.ts new file mode 100644 index 0000000000..f65d0c18fb --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_OVERVIEW_FETCH_LIMIT = 9; diff --git a/apps/dashboard/app/(app)/apis/_components/control-cloud/index.tsx b/apps/dashboard/app/(app)/apis/_components/control-cloud/index.tsx new file mode 100644 index 0000000000..9a2110d5b4 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/control-cloud/index.tsx @@ -0,0 +1,29 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import { ControlCloud } from "@/components/logs/control-cloud"; +import { useFilters } from "../hooks/use-filters"; + +const formatFieldName = (field: string): string => { + switch (field) { + case "startTime": + return "Start time"; + case "endTime": + return "End time"; + case "since": + return ""; + default: + return field.charAt(0).toUpperCase() + field.slice(1); + } +}; + +export const ApiListControlCloud = () => { + const { filters, updateFilters, removeFilter } = useFilters(); + return ( + + ); +}; diff --git a/apps/dashboard/app/(app)/apis/_components/controls/components/logs-datetime/index.tsx b/apps/dashboard/app/(app)/apis/_components/controls/components/logs-datetime/index.tsx new file mode 100644 index 0000000000..1d8f2d78a7 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/controls/components/logs-datetime/index.tsx @@ -0,0 +1,88 @@ +import { DatetimePopover } from "@/components/logs/datetime/datetime-popover"; +import { cn } from "@/lib/utils"; +import { Calendar } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { useEffect, useState } from "react"; +import { useFilters } from "../../../hooks/use-filters"; + +export const LogsDateTime = () => { + const [title, setTitle] = useState(null); + const { filters, updateFilters } = useFilters(); + + useEffect(() => { + if (!title) { + setTitle("Last 12 hours"); + } + }, [title]); + + const timeValues = filters + .filter((f) => ["startTime", "endTime", "since"].includes(f.field)) + .reduce( + (acc, f) => ({ + // biome-ignore lint/performance/noAccumulatingSpread: it's safe to spread + ...acc, + [f.field]: f.value, + }), + {}, + ); + + return ( + { + const activeFilters = filters.filter( + (f) => !["endTime", "startTime", "since"].includes(f.field), + ); + if (since !== undefined) { + updateFilters([ + ...activeFilters, + { + field: "since", + value: since, + id: crypto.randomUUID(), + operator: "is", + }, + ]); + return; + } + if (since === undefined && startTime) { + activeFilters.push({ + field: "startTime", + value: startTime, + id: crypto.randomUUID(), + operator: "is", + }); + if (endTime) { + activeFilters.push({ + field: "endTime", + value: endTime, + id: crypto.randomUUID(), + operator: "is", + }); + } + } + updateFilters(activeFilters); + }} + initialTitle={title ?? ""} + onSuggestionChange={setTitle} + > +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/_components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/apis/_components/controls/components/logs-refresh.tsx new file mode 100644 index 0000000000..c6bd9101ed --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/controls/components/logs-refresh.tsx @@ -0,0 +1,18 @@ +import { RefreshButton } from "@/components/logs/refresh-button"; +import { trpc } from "@/lib/trpc/client"; +import { useRouter } from "next/navigation"; +import { useFilters } from "../../hooks/use-filters"; + +export const LogsRefresh = () => { + const { filters } = useFilters(); + const { api } = trpc.useUtils(); + const { refresh } = useRouter(); + const hasRelativeFilter = filters.find((f) => f.field === "since"); + + const handleRefresh = () => { + api.logs.queryVerificationTimeseries.invalidate(); + refresh(); + }; + + return ; +}; diff --git a/apps/dashboard/app/(app)/apis/_components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/apis/_components/controls/components/logs-search/index.tsx new file mode 100644 index 0000000000..439a6a4705 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/controls/components/logs-search/index.tsx @@ -0,0 +1,62 @@ +import { LogsLLMSearch } from "@/components/logs/llm-search"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import type { ApiOverview } from "@/lib/trpc/routers/api/query-overview/schemas"; +import { useRef } from "react"; +type Props = { + apiList: ApiOverview[]; + onApiListChange: (apiList: ApiOverview[]) => void; + onSearch: (value: boolean) => void; +}; + +export const LogsSearch = ({ onSearch, onApiListChange, apiList }: Props) => { + const originalApiList = useRef([]); + const isSearchingRef = useRef(false); + const searchApiOverview = trpc.api.overview.search.useMutation({ + onSuccess(data) { + // Store original list before first search + if (!isSearchingRef.current) { + originalApiList.current = [...apiList]; + isSearchingRef.current = true; + } + onSearch(true); + onApiListChange(data); + }, + onError(error) { + toast.error(error.message, { + duration: 8000, + important: true, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + className: "font-medium", + }); + }, + }); + + const handleClear = () => { + // Reset to original state when search is cleared + if (isSearchingRef.current && originalApiList.current.length > 0) { + onApiListChange(originalApiList.current); + isSearchingRef.current = false; + onSearch(false); + } + }; + + return ( + + searchApiOverview.mutateAsync({ + query, + }) + } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/apis/_components/controls/index.tsx b/apps/dashboard/app/(app)/apis/_components/controls/index.tsx new file mode 100644 index 0000000000..89737b5f02 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/controls/index.tsx @@ -0,0 +1,30 @@ +import type { ApiOverview } from "@/lib/trpc/routers/api/query-overview/schemas"; +import { LogsDateTime } from "./components/logs-datetime"; +import { LogsRefresh } from "./components/logs-refresh"; +import { LogsSearch } from "./components/logs-search"; + +type Props = { + apiList: ApiOverview[]; + onApiListChange: (apiList: ApiOverview[]) => void; + onSearch: (value: boolean) => void; +}; + +export function ApiListControls(props: Props) { + return ( +
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ ); +} diff --git a/apps/dashboard/app/(app)/apis/_components/create-api-button.tsx b/apps/dashboard/app/(app)/apis/_components/create-api-button.tsx new file mode 100644 index 0000000000..74c8c42ea5 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/create-api-button.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { revalidate } from "@/app/actions"; +import { Loading } from "@/components/dashboard/loading"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Plus } from "@unkey/icons"; +import { Button, FormInput } from "@unkey/ui"; +import { useRouter } from "next/navigation"; +import type React from "react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const formSchema = z.object({ + name: z.string().trim().min(3, "Name must be at least 3 characters long").max(50), +}); + +type Props = { + defaultOpen?: boolean; +}; + +export const CreateApiButton = ({ + defaultOpen, + ...rest +}: React.ButtonHTMLAttributes & Props) => { + const [open, setOpen] = useState(defaultOpen ?? false); + const router = useRouter(); + + const { + register, + handleSubmit, + formState: { errors, isValid, isSubmitting }, + } = useForm>({ + resolver: zodResolver(formSchema), + }); + + const create = trpc.api.create.useMutation({ + async onSuccess(res) { + toast.success("Your API has been created"); + await revalidate("/apis"); + router.push(`/apis/${res.id}`); + }, + onError(err) { + console.error(err); + toast.error(err.message); + }, + }); + + async function onSubmit(values: z.infer) { + create.mutate(values); + } + + return ( + <> + setOpen(o)}> + + + + { + // Prevent auto-focus behavior + e.preventDefault(); + }} + > + + + Create New API + + +
+
+ +
+ + +
+ +
+ You'll be redirected to your new API dashboard after creation +
+
+
+
+
+
+ + ); +}; diff --git a/apps/dashboard/app/(app)/apis/_components/filters.schema.ts b/apps/dashboard/app/(app)/apis/_components/filters.schema.ts new file mode 100644 index 0000000000..0090efe305 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/filters.schema.ts @@ -0,0 +1,48 @@ +import type { + FilterValue, + NumberConfig, + StringConfig, +} from "@/components/logs/validation/filter.types"; +import { z } from "zod"; + +// Configuration +export const apiListFilterFieldConfig: FilterFieldConfigs = { + startTime: { + type: "number", + operators: ["is"], + }, + endTime: { + type: "number", + operators: ["is"], + }, + since: { + type: "string", + operators: ["is"], + }, +}; + +// Schemas +export const apiListFilterOperatorEnum = z.enum(["is", "contains"]); +export const apiListFilterFieldEnum = z.enum(["startTime", "endTime", "since"]); + +// Types +export type ApiListFilterOperator = z.infer; +export type ApiListFilterField = z.infer; + +export type FilterFieldConfigs = { + startTime: NumberConfig; + endTime: NumberConfig; + since: StringConfig; +}; + +export type ApiListFilterUrlValue = Pick< + FilterValue, + "value" | "operator" +>; +export type ApiListFilterValue = FilterValue; + +export type ApiListQuerySearchParams = { + startTime?: number | null; + endTime?: number | null; + since?: string | null; +}; diff --git a/apps/dashboard/app/(app)/apis/_components/hooks/query-timeseries.schema.ts b/apps/dashboard/app/(app)/apis/_components/hooks/query-timeseries.schema.ts new file mode 100644 index 0000000000..8e084a5405 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/hooks/query-timeseries.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const verificationQueryTimeseriesPayload = z.object({ + startTime: z.number().int(), + endTime: z.number().int(), + since: z.string(), + keyspaceId: z.string(), +}); + +export type VerificationQueryTimeseriesPayload = z.infer; diff --git a/apps/dashboard/app/(app)/apis/_components/hooks/use-fetch-api-overview.ts b/apps/dashboard/app/(app)/apis/_components/hooks/use-fetch-api-overview.ts new file mode 100644 index 0000000000..c51ce8b677 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/hooks/use-fetch-api-overview.ts @@ -0,0 +1,61 @@ +"use client"; +import { trpc } from "@/lib/trpc/client"; +import type { + ApiOverview, + ApisOverviewResponse, +} from "@/lib/trpc/routers/api/query-overview/schemas"; +import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; +import { DEFAULT_OVERVIEW_FETCH_LIMIT } from "../constants"; + +export const useFetchApiOverview = ( + initialData: ApisOverviewResponse, + setApiList: Dispatch>, +) => { + const [hasMore, setHasMore] = useState(initialData.hasMore); + const [cursor, setCursor] = useState(initialData.nextCursor); + const [total, setTotal] = useState(initialData.total); + const [isFetchingMore, setIsFetchingMore] = useState(false); + + const { data, isFetching, refetch } = trpc.api.overview.queryApisOverview.useQuery( + { limit: DEFAULT_OVERVIEW_FETCH_LIMIT, cursor }, + { + enabled: false, + }, + ); + + useEffect(() => { + if (!data) { + return; + } + + const apisOrderedByKeyCount = sortApisByKeyCount(data.apiList); + setApiList((prev) => [...prev, ...apisOrderedByKeyCount]); + setHasMore(data.hasMore); + setCursor(data.nextCursor); + + if (data.total !== total) { + setTotal(data.total); + } + setIsFetchingMore(false); + }, [total, data, setApiList]); + + const loadMore = () => { + if (!hasMore || isFetchingMore || !cursor) { + return; + } + + setIsFetchingMore(true); + refetch(); + }; + + const isLoading = isFetchingMore || isFetching; + return { isLoading, total, loadMore, hasMore }; +}; + +export function sortApisByKeyCount(apiOverview: ApiOverview[]) { + return apiOverview.toSorted( + (a, b) => + b.keys.reduce((acc, crr) => acc + crr.count, 0) - + a.keys.reduce((acc, crr) => acc + crr.count, 0), + ); +} diff --git a/apps/dashboard/app/(app)/apis/_components/hooks/use-filters.ts b/apps/dashboard/app/(app)/apis/_components/hooks/use-filters.ts new file mode 100644 index 0000000000..46dcfe2389 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/hooks/use-filters.ts @@ -0,0 +1,74 @@ +import { parseAsRelativeTime } from "@/components/logs/validation/utils/nuqs-parsers"; +import { parseAsInteger, useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; +import type { + ApiListFilterField, + ApiListFilterValue, + ApiListQuerySearchParams, +} from "../filters.schema"; + +export const queryParamsPayload = { + startTime: parseAsInteger, + endTime: parseAsInteger, + since: parseAsRelativeTime, +} as const; + +export const useFilters = () => { + const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload); + const filters = useMemo(() => { + const activeFilters: ApiListFilterValue[] = []; + + ["startTime", "endTime", "since"].forEach((field) => { + const value = searchParams[field as keyof ApiListQuerySearchParams]; + if (value !== null && value !== undefined) { + activeFilters.push({ + id: crypto.randomUUID(), + field: field as ApiListFilterField, + operator: "is", + value: value as string | number, + }); + } + }); + + return activeFilters; + }, [searchParams]); + + const updateFilters = useCallback( + (newFilters: ApiListFilterValue[]) => { + const newParams: Partial = { + startTime: null, + endTime: null, + since: null, + }; + + newFilters.forEach((filter) => { + switch (filter.field) { + case "startTime": + case "endTime": + newParams[filter.field] = filter.value as number; + break; + case "since": + newParams.since = filter.value as string; + break; + } + }); + + setSearchParams(newParams); + }, + [setSearchParams], + ); + + const removeFilter = useCallback( + (id: string) => { + const newFilters = filters.filter((f) => f.id !== id); + updateFilters(newFilters); + }, + [filters, updateFilters], + ); + + return { + filters, + removeFilter, + updateFilters, + }; +}; diff --git a/apps/dashboard/app/(app)/apis/_components/hooks/use-query-timeseries.ts b/apps/dashboard/app/(app)/apis/_components/hooks/use-query-timeseries.ts new file mode 100644 index 0000000000..9c66220d3f --- /dev/null +++ b/apps/dashboard/app/(app)/apis/_components/hooks/use-query-timeseries.ts @@ -0,0 +1,76 @@ +import { formatTimestampForChart } from "@/components/logs/chart/utils/format-timestamp"; +import { TIMESERIES_DATA_WINDOW } from "@/components/logs/constants"; +import { trpc } from "@/lib/trpc/client"; +import { useEffect, useMemo, useState } from "react"; +import type { VerificationQueryTimeseriesPayload } from "./query-timeseries.schema"; +import { useFilters } from "./use-filters"; + +export const useFetchVerificationTimeseries = (keyspaceId: string | null) => { + const [enabled, setEnabled] = useState(false); + const { filters } = useFilters(); + const dateNow = useMemo(() => Date.now(), []); + + const queryParams = useMemo(() => { + const params: VerificationQueryTimeseriesPayload = { + keyspaceId: keyspaceId ?? "", + startTime: dateNow - TIMESERIES_DATA_WINDOW * 24, + endTime: dateNow, + since: "", + }; + + filters.forEach((filter) => { + switch (filter.field) { + 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, keyspaceId]); + + useEffect(() => { + // Implement a 2-second delay before enabling queries to prevent excessive ClickHouse load + // during component mounting cycles. This throttling is critical when users are actively searching/filtering, to avoid + // overwhelming the database with redundant or intermediate query states. + setTimeout(() => setEnabled(true), 2000); + }, []); + + const { data, isLoading, isError } = trpc.api.logs.queryVerificationTimeseries.useQuery( + queryParams, + { + refetchInterval: queryParams.endTime ? false : 10_000, + enabled, + }, + ); + + const timeseries = data?.timeseries.map((ts) => ({ + displayX: formatTimestampForChart(ts.x, data.granularity), + originalTimestamp: ts.x, + valid: ts.y.valid, + total: ts.y.total, + success: ts.y.valid, + error: ts.y.total - ts.y.valid, + })); + + return { + timeseries, + isLoading, + isError, + granularity: data?.granularity, + }; +}; diff --git a/apps/dashboard/app/(app)/apis/actions.ts b/apps/dashboard/app/(app)/apis/actions.ts new file mode 100644 index 0000000000..aab5b4452f --- /dev/null +++ b/apps/dashboard/app/(app)/apis/actions.ts @@ -0,0 +1,66 @@ +"use server"; +import { and, db, eq, isNull, schema, sql } from "@/lib/db"; +import type { ApisOverviewResponse } from "@/lib/trpc/routers/api/query-overview/schemas"; + +export type ApiOverviewOptions = { + workspaceId: string; + limit: number; + cursor?: { id: string } | undefined; +}; + +export async function fetchApiOverview({ + workspaceId, + limit, + cursor, +}: ApiOverviewOptions): Promise { + const totalResult = await db + .select({ count: sql`count(*)` }) + .from(schema.apis) + .where(and(eq(schema.apis.workspaceId, workspaceId), isNull(schema.apis.deletedAtM))); + const total = Number(totalResult[0]?.count || 0); + + const query = db.query.apis.findMany({ + where: (table, { and, eq, isNull, gt }) => { + const conditions = [eq(table.workspaceId, workspaceId), isNull(table.deletedAtM)]; + if (cursor) { + conditions.push(gt(table.id, cursor.id)); + } + return and(...conditions); + }, + with: { + keyAuth: { + columns: { + sizeApprox: true, + }, + }, + }, + orderBy: (table, { asc }) => [asc(table.id)], + limit: limit + 1, // Fetch one extra to determine if there are more + }); + + const apis = await query; + const hasMore = apis.length > limit; + const apiItems = hasMore ? apis.slice(0, limit) : apis; + const nextCursor = + hasMore && apiItems.length > 0 ? { id: apiItems[apiItems.length - 1].id } : undefined; + + const apiList = await apiItemsWithApproxKeyCounts(apiItems); + + return { + apiList, + hasMore, + nextCursor, + total, + }; +} + +export async function apiItemsWithApproxKeyCounts(apiItems: Array) { + return apiItems.map((api) => { + return { + id: api.id, + name: api.name, + keyspaceId: api.keyAuthId, + keys: [{ count: api.keyAuth?.sizeApprox || 0 }], + }; + }); +} diff --git a/apps/dashboard/app/(app)/apis/client.tsx b/apps/dashboard/app/(app)/apis/client.tsx deleted file mode 100644 index 58a505f27d..0000000000 --- a/apps/dashboard/app/(app)/apis/client.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { PostHogIdentify } from "@/providers/PostHogProvider"; -import { useUser } from "@clerk/nextjs"; -import { Empty } from "@unkey/ui"; -import { Button } from "@unkey/ui"; -import { BookOpen, Search } from "lucide-react"; -import Link from "next/link"; -import { useEffect, useState } from "react"; -import { CreateApiButton } from "./create-api-button"; -type ApiWithKeys = { - id: string; - name: string; - keys: { - count: number; - }[]; -}[]; - -export function ApiList({ apis }: { apis: ApiWithKeys }) { - const { user, isLoaded } = useUser(); - - const [localData, setLocalData] = useState(apis); - - if (isLoaded && user) { - PostHogIdentify({ user }); - } - - useEffect(() => { - if (apis.length) { - setLocalData(apis); - } - }, [apis]); - - return ( -
-
-
- - { - const filtered = apis.filter((a) => - a.name.toLowerCase().includes(e.target.value.toLowerCase()), - ); - setLocalData(filtered); - }} - /> -
-
- {apis.length ? ( -
    - {localData.map((api) => ( - - - -
    - {api.name} -
    - {api.id} -
    - -
    -
    -
    API Keys
    -
    -
    - {api.keys.at(0)?.count ?? 0} -
    -
    -
    -
    -
    -
    - - ))} -
- ) : ( - - - No APIs found - - You haven't created any APIs yet. Create one to get started. - - - - - - - - - )} -
- ); -} diff --git a/apps/dashboard/app/(app)/apis/create-api-button.tsx b/apps/dashboard/app/(app)/apis/create-api-button.tsx deleted file mode 100644 index 207f2c5674..0000000000 --- a/apps/dashboard/app/(app)/apis/create-api-button.tsx +++ /dev/null @@ -1,109 +0,0 @@ -"use client"; -import { revalidate } from "@/app/actions"; -import { Loading } from "@/components/dashboard/loading"; -import { Dialog, DialogContent, DialogFooter, DialogTrigger } from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/toaster"; -import { trpc } from "@/lib/trpc/client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Button } from "@unkey/ui"; -import { Plus } from "lucide-react"; -import { useRouter } from "next/navigation"; -import type React from "react"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -const formSchema = z.object({ - name: z.string().trim().min(3, "Name must be at least 3 characters long").max(50), -}); - -type Props = { - defaultOpen?: boolean; -}; - -export const CreateApiButton = ({ - defaultOpen, - ...rest -}: React.ButtonHTMLAttributes & Props) => { - const form = useForm>({ - resolver: zodResolver(formSchema), - }); - - const [open, setOpen] = useState(defaultOpen ?? false); - - const create = trpc.api.create.useMutation({ - async onSuccess(res) { - toast.success("Your API has been created"); - await revalidate("/apis"); - router.push(`/apis/${res.id}`); - }, - onError(err) { - console.error(err); - toast.error(err.message); - }, - }); - async function onSubmit(values: z.infer) { - create.mutate(values); - } - const router = useRouter(); - - return ( - <> - setOpen(o)}> - - - - -
- - ( - - Name - - - - - This is just a human readable name for you and not visible to anyone else - - - - )} - /> - - - - - - -
-
- - ); -}; diff --git a/apps/dashboard/app/(app)/apis/navigation.tsx b/apps/dashboard/app/(app)/apis/navigation.tsx index 7e370d267f..ff5f9eb6ac 100644 --- a/apps/dashboard/app/(app)/apis/navigation.tsx +++ b/apps/dashboard/app/(app)/apis/navigation.tsx @@ -2,7 +2,7 @@ import { Navbar } from "@/components/navigation/navbar"; import { Nodes } from "@unkey/icons"; -import { CreateApiButton } from "./create-api-button"; +import { CreateApiButton } from "./_components/create-api-button"; type NavigationProps = { isNewApi: boolean; diff --git a/apps/dashboard/app/(app)/apis/page.tsx b/apps/dashboard/app/(app)/apis/page.tsx index 87dc416d47..161221a3f4 100644 --- a/apps/dashboard/app/(app)/apis/page.tsx +++ b/apps/dashboard/app/(app)/apis/page.tsx @@ -1,9 +1,9 @@ -import { PageContent } from "@/components/page-content"; import { getTenantId } from "@/lib/auth"; -import { and, db, eq, isNull, schema, sql } from "@/lib/db"; -import Link from "next/link"; +import { db } from "@/lib/db"; import { redirect } from "next/navigation"; -import { ApiList } from "./client"; +import { ApiListClient } from "./_components/api-list-client"; +import { DEFAULT_OVERVIEW_FETCH_LIMIT } from "./_components/constants"; +import { fetchApiOverview } from "./actions"; import { Navigation } from "./navigation"; export const dynamic = "force-dynamic"; @@ -15,56 +15,26 @@ type Props = { export default async function ApisOverviewPage(props: Props) { const tenantId = getTenantId(); + const workspace = await db.query.workspaces.findFirst({ where: (table, { and, eq, isNull }) => and(eq(table.tenantId, tenantId), isNull(table.deletedAtM)), - with: { - apis: { - where: (table, { isNull }) => isNull(table.deletedAtM), - }, - }, }); if (!workspace) { return redirect("/new"); } - const apis = await Promise.all( - workspace.apis.map(async (api) => ({ - id: api.id, - name: api.name, - keys: await db - .select({ count: sql`count(*)` }) - .from(schema.keys) - .where(and(eq(schema.keys.keyAuthId, api.keyAuthId!), isNull(schema.keys.deletedAtM))), - })), - ); - + const initialData = await fetchApiOverview({ + workspaceId: workspace.id, + limit: DEFAULT_OVERVIEW_FETCH_LIMIT, + }); const unpaid = workspace.tenantId.startsWith("org_") && workspace.plan === "free"; return (
- - - {unpaid ? ( -
-

- Upgrade your plan -

-

- Team workspaces is a paid feature. Please switch to a paid plan to continue using it. -

- - Subscribe - -
- ) : ( - - )} -
+ +
); } diff --git a/apps/dashboard/app/(app)/audit/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/audit/components/controls/components/logs-search/index.tsx index 3b1df78be0..504f86cd60 100644 --- a/apps/dashboard/app/(app)/audit/components/controls/components/logs-search/index.tsx +++ b/apps/dashboard/app/(app)/audit/components/controls/components/logs-search/index.tsx @@ -45,6 +45,7 @@ export const LogsSearch = () => { return ( queryLLMForStructuredOutput.mutateAsync({ query, diff --git a/apps/dashboard/app/(app)/audit/components/table/hooks/use-logs-query.ts b/apps/dashboard/app/(app)/audit/components/table/hooks/use-logs-query.ts index cd9a55fea6..3be959b987 100644 --- a/apps/dashboard/app/(app)/audit/components/table/hooks/use-logs-query.ts +++ b/apps/dashboard/app/(app)/audit/components/table/hooks/use-logs-query.ts @@ -21,7 +21,7 @@ export function useAuditLogsQuery({ limit = 50 }: UseLogsQueryParams) { const queryParams = useMemo(() => { const params: AuditQueryLogsPayload = { limit, - startTime: dateNow - HISTORICAL_DATA_WINDOW * 2 * 7, + startTime: dateNow - HISTORICAL_DATA_WINDOW, endTime: dateNow, events: { filters: [] }, users: { filters: [] }, diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-search/index.tsx index e4b18bb9d6..523aa0eea5 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-search/index.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-search/index.tsx @@ -45,6 +45,7 @@ export const LogsSearch = () => { return ( queryLLMForStructuredOutput.mutateAsync({ query, diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-search/index.tsx index 85e5b1716d..b562471c46 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-search/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-search/index.tsx @@ -45,6 +45,7 @@ export const LogsSearch = () => { return ( queryLLMForStructuredOutput.mutateAsync({ query, 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 index 581ccc2092..5ca0864afb 100644 --- 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 @@ -45,6 +45,7 @@ export const LogsSearch = () => { return ( queryLLMForStructuredOutput.mutateAsync({ query, diff --git a/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-search/index.tsx index 7b78f91ea4..2a73b38bcb 100644 --- a/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-search/index.tsx +++ b/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-search/index.tsx @@ -1,6 +1,7 @@ import { LogsLLMSearch } from "@/components/logs/llm-search"; import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; +import { useRef } from "react"; type LogsSearchProps = { setNamespaces: (namespaces: { id: string; name: string }[]) => void; @@ -8,8 +9,13 @@ type LogsSearchProps = { }; export const LogsSearch = ({ setNamespaces, initialNamespaces }: LogsSearchProps) => { + const isSearchingRef = useRef(false); + const searchNamespace = trpc.ratelimit.namespace.search.useMutation({ onSuccess(data) { + if (!isSearchingRef.current) { + isSearchingRef.current = true; + } setNamespaces(data); }, onError(error) { @@ -26,7 +32,11 @@ export const LogsSearch = ({ setNamespaces, initialNamespaces }: LogsSearchProps }); const handleClear = () => { - setNamespaces(initialNamespaces); + // Only reset if we have performed a search + if (isSearchingRef.current) { + setNamespaces(initialNamespaces); + isSearchingRef.current = false; + } }; return ( @@ -34,7 +44,9 @@ export const LogsSearch = ({ setNamespaces, initialNamespaces }: LogsSearchProps hideExplainer onClear={handleClear} placeholder="Search namespaces" + loadingText="Searching namespaces..." isLoading={searchNamespace.isLoading} + searchMode="allowTypeDuringSearch" onSearch={(query) => searchNamespace.mutateAsync({ query, diff --git a/apps/dashboard/app/(app)/ratelimits/_components/namespace-card.tsx b/apps/dashboard/app/(app)/ratelimits/_components/namespace-card.tsx new file mode 100644 index 0000000000..34ac248225 --- /dev/null +++ b/apps/dashboard/app/(app)/ratelimits/_components/namespace-card.tsx @@ -0,0 +1,72 @@ +"use client"; +import { StatsCard } from "@/components/stats-card"; +import { StatsTimeseriesBarChart } from "@/components/stats-card/components/chart/stats-chart"; +import { MetricStats } from "@/components/stats-card/components/metric-stats"; +import { Clock, ProgressBar } from "@unkey/icons"; +import ms from "ms"; +import { useFetchRatelimitOverviewTimeseries } from "../[namespaceId]/_overview/components/charts/bar-chart/hooks/use-fetch-timeseries"; + +type Props = { + namespace: { + id: string; + name: string; + }; +}; + +export const NamespaceCard = ({ namespace }: Props) => { + const { timeseries, isLoading, isError } = useFetchRatelimitOverviewTimeseries(namespace.id); + + const passed = timeseries?.reduce((acc, crr) => acc + crr.success, 0) ?? 0; + const blocked = timeseries?.reduce((acc, crr) => acc + crr.error, 0) ?? 0; + + const lastRatelimit = timeseries + ? timeseries + .filter((entry) => entry.total > 0) + .sort((a, b) => b.originalTimestamp - a.originalTimestamp)[0] + : null; + + return ( + + } + stats={ + <> + +
+ +
+ {lastRatelimit + ? `${ms(Date.now() - lastRatelimit.originalTimestamp, { + long: true, + })} ago` + : "No data"} +
+
+ + } + icon={} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/index.tsx b/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/index.tsx deleted file mode 100644 index aa7c25f8e1..0000000000 --- a/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/index.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; -import { Clock, ProgressBar } from "@unkey/icons"; -import ms from "ms"; -import Link from "next/link"; -import { useFetchRatelimitOverviewTimeseries } from "../../[namespaceId]/_overview/components/charts/bar-chart/hooks/use-fetch-timeseries"; -import { LogsTimeseriesBarChart } from "./chart/bar-chart"; - -type Props = { - namespace: { - id: string; - name: string; - }; -}; - -export const NamespaceCard = ({ namespace }: Props) => { - const { timeseries } = useFetchRatelimitOverviewTimeseries(namespace.id); - - const passed = timeseries?.reduce((acc, crr) => acc + crr.success, 0) ?? 0; - const blocked = timeseries?.reduce((acc, crr) => acc + crr.error, 0) ?? 0; - - const lastRatelimit = timeseries - ? timeseries - .filter((entry) => entry.total > 0) - .sort((a, b) => b.originalTimestamp - a.originalTimestamp)[0] - : null; - - return ( -
-
- -
- - -
-
- -
{namespace.name}
-
-
-
-
-
-
-
{passed}
-
PASSED
-
-
-
-
-
-
{blocked}
-
BLOCKED
-
-
-
-
- -
- {lastRatelimit - ? `${ms(Date.now() - lastRatelimit.originalTimestamp, { - long: true, - })} ago` - : "No data"} -
-
-
-
- -
- ); -}; diff --git a/apps/dashboard/app/(app)/ratelimits/_components/ratelimit-client.tsx b/apps/dashboard/app/(app)/ratelimits/_components/ratelimit-client.tsx index 43fb4cc99c..f9fa1598c4 100644 --- a/apps/dashboard/app/(app)/ratelimits/_components/ratelimit-client.tsx +++ b/apps/dashboard/app/(app)/ratelimits/_components/ratelimit-client.tsx @@ -1,5 +1,6 @@ "use client"; import { CopyButton } from "@/components/dashboard/copy-button"; +import { EmptyComponentSpacer } from "@/components/empty-component-spacer"; import { Button, Empty } from "@unkey/ui"; import { BookOpen } from "lucide-react"; import { type PropsWithChildren, useState } from "react"; @@ -17,47 +18,6 @@ const EXAMPLE_SNIPPET = `curl -XPOST 'https://api.unkey.dev/v1/ratelimits.limit' "duration": 10000 }'`; -const EmptyNamespaces = () => ( - - - No Namespaces found - - You haven't created any Namespaces yet. Create one by performing a limit request as shown - below. - - -
-
-
-          {EXAMPLE_SNIPPET}
-        
- -
-
- - - - - - -
-); - -const NamespaceGrid = ({ - namespaces, -}: { - namespaces: { id: string; name: string }[]; -}) => ( -
- {namespaces.map((namespace) => ( - - ))} -
-); - export const RatelimitClient = ({ ratelimitNamespaces, }: PropsWithChildren<{ @@ -76,9 +36,42 @@ export const RatelimitClient = ({ /> -
- {namespaces.length > 0 ? : } -
+ {namespaces.length > 0 ? ( +
+ {namespaces.map((namespace) => ( + + ))} +
+ ) : ( + + + + No Namespaces found + + You haven't created any Namespaces yet. Create one by performing a limit request as + shown below. + + +
+
+
+                  {EXAMPLE_SNIPPET}
+                
+ +
+
+ + + + + + +
+
+ )}
); }; diff --git a/apps/dashboard/components/empty-component-spacer.tsx b/apps/dashboard/components/empty-component-spacer.tsx new file mode 100644 index 0000000000..a50b9e9d5a --- /dev/null +++ b/apps/dashboard/components/empty-component-spacer.tsx @@ -0,0 +1,9 @@ +import type { PropsWithChildren } from "react"; + +export const EmptyComponentSpacer = ({ children }: PropsWithChildren) => { + return ( +
+
{children}
+
+ ); +}; diff --git a/apps/dashboard/components/logs/chart/utils/format-timestamp.ts b/apps/dashboard/components/logs/chart/utils/format-timestamp.ts index 7379cd0636..108a4489ae 100644 --- a/apps/dashboard/components/logs/chart/utils/format-timestamp.ts +++ b/apps/dashboard/components/logs/chart/utils/format-timestamp.ts @@ -1,4 +1,4 @@ -import type { TimeseriesGranularity } from "@/lib/trpc/routers/utils/granularity"; +import type { CompoundTimeseriesGranularity } from "@/lib/trpc/routers/utils/granularity"; import { addMinutes, format, fromUnixTime } from "date-fns"; export const formatTimestampLabel = (timestamp: string | number | Date) => { @@ -8,7 +8,7 @@ export const formatTimestampLabel = (timestamp: string | number | Date) => { export const formatTimestampForChart = ( value: string | number, - granularity: TimeseriesGranularity, + granularity: CompoundTimeseriesGranularity, ) => { const date = new Date(value); const offset = new Date().getTimezoneOffset() * -1; @@ -28,6 +28,15 @@ export const formatTimestampForChart = ( return format(localDate, "MMM d, HH:mm"); case "perDay": return format(localDate, "MMM d"); + + case "per12Hours": + return format(localDate, "MMM d, HH:mm"); + case "per3Days": + return format(localDate, "MMM d"); + case "perWeek": + return format(localDate, "MMM d"); + case "perMonth": + return format(localDate, "MMM yyyy"); default: return format(localDate, "Pp"); } diff --git a/apps/dashboard/components/logs/llm-search/components/search-actions.tsx b/apps/dashboard/components/logs/llm-search/components/search-actions.tsx new file mode 100644 index 0000000000..d754a32b2e --- /dev/null +++ b/apps/dashboard/components/logs/llm-search/components/search-actions.tsx @@ -0,0 +1,50 @@ +import { XMark } from "@unkey/icons"; +import { SearchExampleTooltip } from "./search-example-tooltip"; + +type SearchActionsProps = { + searchText: string; + hideClear: boolean; + hideExplainer: boolean; + isProcessing: boolean; + searchMode: "allowTypeDuringSearch" | "debounced" | "manual"; + onClear: () => void; + onSelectExample: (query: string) => void; +}; + +/** + * SearchActions component renders the right-side actions (clear button or examples tooltip) + */ +export const SearchActions: React.FC = ({ + searchText, + hideClear, + hideExplainer, + isProcessing, + searchMode, + onClear, + onSelectExample, +}) => { + // Don't render anything if processing (unless in allowTypeDuringSearch mode) + if (!(!isProcessing || searchMode === "allowTypeDuringSearch")) { + return null; + } + + // Render clear button when there's text + if (searchText.length > 0 && !hideClear) { + return ( + + ); + } + + if (searchText.length === 0 && !hideExplainer) { + return ; + } + + return null; +}; diff --git a/apps/dashboard/components/logs/llm-search/components/search-example-tooltip.tsx b/apps/dashboard/components/logs/llm-search/components/search-example-tooltip.tsx new file mode 100644 index 0000000000..8fbdd68d37 --- /dev/null +++ b/apps/dashboard/components/logs/llm-search/components/search-example-tooltip.tsx @@ -0,0 +1,52 @@ +import { CaretRightOutline, CircleInfoSparkle } from "@unkey/icons"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "components/ui/tooltip"; + +type SearchExampleTooltipProps = { + onSelectExample: (query: string) => void; +}; + +export const SearchExampleTooltip: React.FC = ({ onSelectExample }) => { + const examples = [ + { id: "failed-requests", text: "Show failed requests today" }, + { id: "auth-errors", text: "auth errors in the last 3h" }, + { id: "api-calls", text: "API calls from a path that includes /api/v1/oz" }, + ]; + + return ( + + + +
+ +
+
+ +
+
+ Try queries like: + (click to use) +
+
    + {examples.map((example) => ( +
  • + + +
  • + ))} +
+
+
+
+
+ ); +}; diff --git a/apps/dashboard/components/logs/llm-search/components/search-icon.tsx b/apps/dashboard/components/logs/llm-search/components/search-icon.tsx new file mode 100644 index 0000000000..3bd93ec221 --- /dev/null +++ b/apps/dashboard/components/logs/llm-search/components/search-icon.tsx @@ -0,0 +1,13 @@ +import { Magnifier, Refresh3 } from "@unkey/icons"; + +type SearchIconProps = { + isProcessing: boolean; +}; + +export const SearchIcon = ({ isProcessing }: SearchIconProps) => { + if (isProcessing) { + return ; + } + + return ; +}; diff --git a/apps/dashboard/components/logs/llm-search/components/search-input.tsx b/apps/dashboard/components/logs/llm-search/components/search-input.tsx new file mode 100644 index 0000000000..8ff15afb01 --- /dev/null +++ b/apps/dashboard/components/logs/llm-search/components/search-input.tsx @@ -0,0 +1,50 @@ +type SearchInputProps = { + value: string; + placeholder: string; + isProcessing: boolean; + isLoading: boolean; + loadingText: string; + clearingText: string; + searchMode: "allowTypeDuringSearch" | "debounced" | "manual"; + onChange: (e: React.ChangeEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + inputRef: React.RefObject; +}; + +export const SearchInput = ({ + value, + placeholder, + isProcessing, + isLoading, + loadingText, + clearingText, + searchMode, + onChange, + onKeyDown, + inputRef, +}: SearchInputProps) => { + // Show loading state unless we're in allowTypeDuringSearch mode + if (isProcessing && searchMode !== "allowTypeDuringSearch") { + return ( +
+ {isLoading ? loadingText : clearingText} +
+ ); + } + + return ( + + ); +}; diff --git a/apps/dashboard/components/logs/llm-search/hooks/use-search-strategy.test.tsx b/apps/dashboard/components/logs/llm-search/hooks/use-search-strategy.test.tsx new file mode 100644 index 0000000000..07501d8098 --- /dev/null +++ b/apps/dashboard/components/logs/llm-search/hooks/use-search-strategy.test.tsx @@ -0,0 +1,195 @@ +import { act, renderHook } from "@testing-library/react-hooks"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useSearchStrategy } from "./use-search-strategy"; + +describe("useSearchStrategy", () => { + // Mock timers for debounce/throttle testing + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const onSearchMock = vi.fn(); + + it("should execute search immediately with executeSearch", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + act(() => { + result.current.executeSearch("test query"); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + expect(onSearchMock).toHaveBeenCalledWith("test query"); + }); + + it("should not execute search with empty query", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + act(() => { + result.current.executeSearch(" "); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + }); + + it("should debounce search calls with debouncedSearch", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + act(() => { + result.current.debouncedSearch("test query"); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(499); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + expect(onSearchMock).toHaveBeenCalledWith("test query"); + }); + + it("should cancel previous debounce if debouncedSearch is called again", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + act(() => { + result.current.debouncedSearch("first query"); + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + act(() => { + result.current.debouncedSearch("second query"); + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + expect(onSearchMock).toHaveBeenCalledWith("second query"); + expect(onSearchMock).not.toHaveBeenCalledWith("first query"); + }); + + it("should use debounce for initial query with throttledSearch", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + act(() => { + result.current.throttledSearch("initial query"); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + expect(onSearchMock).toHaveBeenCalledWith("initial query"); + }); + + it("should throttle subsequent searches", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + // First search - should be debounced + act(() => { + result.current.throttledSearch("initial query"); + vi.advanceTimersByTime(500); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + + // Reset mock to track subsequent calls + onSearchMock.mockReset(); + + // Second search immediately after - should be throttled + act(() => { + result.current.throttledSearch("second query"); + }); + + // Should not execute immediately due to throttling + expect(onSearchMock).not.toHaveBeenCalled(); + + // Advance time to just before throttle interval ends + act(() => { + vi.advanceTimersByTime(999); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + + // Complete the throttle interval + act(() => { + vi.advanceTimersByTime(1); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + expect(onSearchMock).toHaveBeenCalledWith("second query"); + }); + + it("should clean up timers with clearDebounceTimer", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + act(() => { + result.current.debouncedSearch("test query"); + }); + + act(() => { + result.current.clearDebounceTimer(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(onSearchMock).not.toHaveBeenCalled(); + }); + + it("should reset search state with resetSearchState", () => { + const { result } = renderHook(() => useSearchStrategy(onSearchMock, 500)); + + // First search to set initial state + act(() => { + result.current.throttledSearch("initial query"); + vi.advanceTimersByTime(500); + }); + + onSearchMock.mockReset(); + + // Reset search state + act(() => { + result.current.resetSearchState(); + }); + + // Next search should be debounced again, not throttled + act(() => { + result.current.throttledSearch("new query after reset"); + }); + + // Should not execute immediately (debounced, not throttled) + expect(onSearchMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onSearchMock).toHaveBeenCalledTimes(1); + expect(onSearchMock).toHaveBeenCalledWith("new query after reset"); + }); +}); diff --git a/apps/dashboard/components/logs/llm-search/hooks/use-search-strategy.ts b/apps/dashboard/components/logs/llm-search/hooks/use-search-strategy.ts new file mode 100644 index 0000000000..109d2623ec --- /dev/null +++ b/apps/dashboard/components/logs/llm-search/hooks/use-search-strategy.ts @@ -0,0 +1,102 @@ +import { useCallback, useRef } from "react"; + +/** + * Custom hook that provides different search strategies + * @param onSearch Function to execute the search + * @param debounceTime Delay for debounce in ms + */ +export const useSearchStrategy = (onSearch: (query: string) => void, debounceTime = 500) => { + const debounceTimerRef = useRef(null); + const lastSearchTimeRef = useRef(0); + const THROTTLE_INTERVAL = 1000; + + /** + * Clears the debounce timer + */ + const clearDebounceTimer = useCallback(() => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }, []); + + /** + * Executes the search with the given query + */ + const executeSearch = useCallback( + (query: string) => { + if (query.trim()) { + try { + lastSearchTimeRef.current = Date.now(); + onSearch(query.trim()); + } catch (error) { + console.error("Search failed:", error); + } + } + }, + [onSearch], + ); + + /** + * Debounced search - waits for user to stop typing before executing search + */ + const debouncedSearch = useCallback( + (search: string) => { + clearDebounceTimer(); + + debounceTimerRef.current = setTimeout(() => { + executeSearch(search); + }, debounceTime); + }, + [clearDebounceTimer, executeSearch, debounceTime], + ); + + /** + * Throttled search with initial debounce - debounce first query, throttle subsequent searches + */ + // biome-ignore lint/correctness/useExhaustiveDependencies: + const throttledSearch = useCallback( + (search: string) => { + const now = Date.now(); + const timeElapsed = now - lastSearchTimeRef.current; + const query = search.trim(); + + // If this is the first search, use debounced search + if (lastSearchTimeRef.current === 0 && query) { + debouncedSearch(search); + return; + } + + // For subsequent searches, use throttling + if (timeElapsed >= THROTTLE_INTERVAL) { + // Enough time has passed, execute immediately + executeSearch(search); + } else if (query) { + // Not enough time has passed, schedule for later + clearDebounceTimer(); + + // Schedule execution after remaining throttle time + const remainingTime = THROTTLE_INTERVAL - timeElapsed; + debounceTimerRef.current = setTimeout(() => { + throttledSearch(search); + }, remainingTime); + } + }, + [clearDebounceTimer, debouncedSearch, executeSearch], + ); + + /** + * Resets search state for new search sequences + */ + const resetSearchState = useCallback(() => { + lastSearchTimeRef.current = 0; + }, []); + + return { + debouncedSearch, + throttledSearch, + executeSearch, + clearDebounceTimer, + resetSearchState, + }; +}; diff --git a/apps/dashboard/components/logs/llm-search/index.tsx b/apps/dashboard/components/logs/llm-search/index.tsx index 1093e80d76..d2d2e7d1aa 100644 --- a/apps/dashboard/components/logs/llm-search/index.tsx +++ b/apps/dashboard/components/logs/llm-search/index.tsx @@ -1,8 +1,12 @@ import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { cn } from "@/lib/utils"; -import { CaretRightOutline, CircleInfoSparkle, Magnifier, Refresh3, XMark } from "@unkey/icons"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "components/ui/tooltip"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { SearchActions } from "./components/search-actions"; +import { SearchIcon } from "./components/search-icon"; +import { SearchInput } from "./components/search-input"; +import { useSearchStrategy } from "./hooks/use-search-strategy"; + +type SearchMode = "allowTypeDuringSearch" | "debounced" | "manual"; type Props = { onSearch: (query: string) => void; @@ -11,6 +15,10 @@ type Props = { isLoading: boolean; hideExplainer?: boolean; hideClear?: boolean; + loadingText?: string; + clearingText?: string; + searchMode?: SearchMode; + debounceTime?: number; }; export const LogsLLMSearch = ({ @@ -20,151 +28,140 @@ export const LogsLLMSearch = ({ hideExplainer = false, hideClear = false, placeholder = "Search and filter with AI…", + loadingText = "AI consults the Palantír...", + clearingText = "Clearing search...", + searchMode = "manual", + debounceTime = 500, }: Props) => { const [searchText, setSearchText] = useState(""); + const [isClearingState, setIsClearingState] = useState(false); + const inputRef = useRef(null); + const isClearing = isClearingState; + const isProcessing = isLoading || isClearing; + + const { debouncedSearch, throttledSearch, executeSearch, clearDebounceTimer, resetSearchState } = + useSearchStrategy(onSearch, debounceTime); useKeyboardShortcut("s", () => { inputRef.current?.click(); inputRef.current?.focus(); }); - const handleSearch = async (search: string) => { - const query = search.trim(); - if (query) { - try { - onSearch(query); - } catch (error) { - console.error("Search failed:", error); - } + const handleClear = () => { + clearDebounceTimer(); + setIsClearingState(true); + + setTimeout(() => { + onClear?.(); + setSearchText(""); + }, 0); + + setIsClearingState(false); + resetSearchState(); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + const wasFilled = searchText !== ""; + + setSearchText(value); + + // Handle clearing + if (wasFilled && value === "") { + handleClear(); + return; + } + + // Skip if empty + if (value === "") { + return; + } + + // Apply appropriate search strategy based on mode + switch (searchMode) { + case "allowTypeDuringSearch": + throttledSearch(value); + break; + case "debounced": + debouncedSearch(value); + break; + case "manual": + // Do nothing - search triggered on Enter key or preset click + break; } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); - (document.activeElement as HTMLElement)?.blur(); + setSearchText(""); + handleClear(); + inputRef.current?.blur(); } + if (e.key === "Enter") { e.preventDefault(); - handleSearch(searchText); + if (searchText !== "") { + executeSearch(searchText); + } else { + handleClear(); + } } }; const handlePresetQuery = (query: string) => { setSearchText(query); - handleSearch(query); + executeSearch(query); }; + // Clean up timers on unmount + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + return clearDebounceTimer(); + }, []); + return ( -
+
0 ? "bg-gray-4" : "", - isLoading ? "bg-gray-4" : "", + isProcessing ? "bg-gray-4" : "", )} >
- {isLoading ? ( - - ) : ( - - )} +
- {isLoading ? ( -
- AI consults the Palantír... -
- ) : ( - setSearchText(e.target.value)} - placeholder={placeholder} - 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} - /> - )} +
- {!isLoading && ( - <> - {searchText.length > 0 && !hideClear && ( - - )} - {searchText.length === 0 && !hideExplainer && ( - - - -
- -
-
- -
-
- Try queries like: - (click to use) -
-
    -
  • - - -
  • -
  • - - -
  • -
  • - - -
  • -
-
-
-
-
- )} - - )} +
); diff --git a/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/components/logs-chart-error.tsx b/apps/dashboard/components/stats-card/components/chart/components/logs-chart-error.tsx similarity index 100% rename from apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/components/logs-chart-error.tsx rename to apps/dashboard/components/stats-card/components/chart/components/logs-chart-error.tsx diff --git a/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/components/logs-chart-loading.tsx b/apps/dashboard/components/stats-card/components/chart/components/logs-chart-loading.tsx similarity index 100% rename from apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/components/logs-chart-loading.tsx rename to apps/dashboard/components/stats-card/components/chart/components/logs-chart-loading.tsx diff --git a/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/bar-chart.tsx b/apps/dashboard/components/stats-card/components/chart/stats-chart.tsx similarity index 89% rename from apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/bar-chart.tsx rename to apps/dashboard/components/stats-card/components/chart/stats-chart.tsx index b47c8ecdab..24b14cf43d 100644 --- a/apps/dashboard/app/(app)/ratelimits/_components/namespace-card/chart/bar-chart.tsx +++ b/apps/dashboard/components/stats-card/components/chart/stats-chart.tsx @@ -1,4 +1,3 @@ -// GenericTimeseriesChart.tsx "use client"; import { formatTimestampTooltip } from "@/components/logs/chart/utils/format-timestamp"; @@ -13,25 +12,28 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, YAxis } from "rechar import { LogsChartError } from "./components/logs-chart-error"; import { LogsChartLoading } from "./components/logs-chart-loading"; -type TimeseriesData = { +// Generic base type that all timeseries data must include +export type BaseTimeseriesData = { originalTimestamp: number; total: number; - [key: string]: any; + [key: string]: any; // Allow for any additional properties }; -type LogsTimeseriesBarChartProps = { - data?: TimeseriesData[]; +export type TimeseriesChartProps = { + data?: T[]; config: ChartConfig; isLoading?: boolean; isError?: boolean; + tooltipExtraContent?: (payload: any) => React.ReactNode; }; -export function LogsTimeseriesBarChart({ +export function StatsTimeseriesBarChart({ data, config, isError, isLoading, -}: LogsTimeseriesBarChartProps) { + tooltipExtraContent, +}: TimeseriesChartProps) { if (isError) { return ; } @@ -90,6 +92,7 @@ export function LogsTimeseriesBarChart({
+ {tooltipExtraContent?.(payload)} } className="rounded-lg shadow-lg border border-gray-4" diff --git a/apps/dashboard/components/stats-card/components/metric-stats.tsx b/apps/dashboard/components/stats-card/components/metric-stats.tsx new file mode 100644 index 0000000000..36ce44ad66 --- /dev/null +++ b/apps/dashboard/components/stats-card/components/metric-stats.tsx @@ -0,0 +1,28 @@ +export const MetricStats = ({ + successCount, + errorCount, + successLabel = "VALID", + errorLabel = "INVALID", +}: { + successCount: number; + errorCount: number; + successLabel?: string; + errorLabel?: string; +}) => ( +
+
+
+
+
{successCount}
+
{successLabel}
+
+
+
+
+
+
{errorCount}
+
{errorLabel}
+
+
+
+); diff --git a/apps/dashboard/components/stats-card/index.tsx b/apps/dashboard/components/stats-card/index.tsx new file mode 100644 index 0000000000..1a70375cc0 --- /dev/null +++ b/apps/dashboard/components/stats-card/index.tsx @@ -0,0 +1,69 @@ +"use client"; +import { ProgressBar } from "@unkey/icons"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@unkey/ui"; +import Link from "next/link"; +import type { ReactNode } from "react"; + +export type StatsCardProps = { + name: string; + secondaryId?: string; + linkPath: string; + chart: ReactNode; + stats: ReactNode; + rightContent?: ReactNode; + icon?: ReactNode; +}; + +export const StatsCard = ({ + name, + secondaryId, + linkPath, + chart, + stats, + rightContent, + icon = , +}: StatsCardProps) => { + return ( +
+
{chart}
+ +
+
+
+
+ {icon} + + +
+ {name} +
+
+ + {name} + +
+
+ {secondaryId && ( + + +
+ {secondaryId} +
+
+ + {secondaryId} + +
+ )} +
+ {rightContent &&
{rightContent}
} +
+ +
+ {stats} +
+
+ +
+ ); +}; diff --git a/apps/dashboard/lib/trpc/routers/api/overview-api-search.ts b/apps/dashboard/lib/trpc/routers/api/overview-api-search.ts new file mode 100644 index 0000000000..e7e8c7354e --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/api/overview-api-search.ts @@ -0,0 +1,34 @@ +import { apiItemsWithApproxKeyCounts } from "@/app/(app)/apis/actions"; +import { db, sql } from "@/lib/db"; +import { rateLimitedProcedure, ratelimit } from "@/lib/trpc/ratelimitProcedure"; +import { z } from "zod"; + +export const overviewApiSearch = rateLimitedProcedure(ratelimit.read) + .input( + z.object({ + query: z.string().trim().max(100), + }), + ) + .mutation(async ({ ctx, input }) => { + const apis = await db.query.apis.findMany({ + where: (table, { isNull, and, eq, or }) => + and( + eq(table.workspaceId, ctx.workspace.id), + or( + sql`${table.name} LIKE ${`%${input.query}%`}`, + sql`${table.id} LIKE ${`%${input.query}%`}`, + ), + isNull(table.deletedAtM), + ), + with: { + keyAuth: { + columns: { + sizeApprox: true, + }, + }, + }, + }); + + const apiList = await apiItemsWithApproxKeyCounts(apis); + return apiList; + }); diff --git a/apps/dashboard/lib/trpc/routers/api/query-overview/index.ts b/apps/dashboard/lib/trpc/routers/api/query-overview/index.ts new file mode 100644 index 0000000000..8760263013 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/api/query-overview/index.ts @@ -0,0 +1,25 @@ +import { fetchApiOverview } from "@/app/(app)/apis/actions"; +import { rateLimitedProcedure, ratelimit } from "@/lib/trpc/ratelimitProcedure"; +import { TRPCError } from "@trpc/server"; +import { apisOverviewResponse, queryApisOverviewPayload } from "./schemas"; + +export const queryApisOverview = rateLimitedProcedure(ratelimit.read) + .input(queryApisOverviewPayload) + .output(apisOverviewResponse) + .query(async ({ ctx, input }) => { + try { + const result = await fetchApiOverview({ + workspaceId: ctx.workspace.id, + limit: input.limit, + cursor: input.cursor, + }); + + return result; + } catch (error) { + console.error("Something went wrong when fetching api overview list", JSON.stringify(error)); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch API overview", + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/api/query-overview/schemas.ts b/apps/dashboard/lib/trpc/routers/api/query-overview/schemas.ts new file mode 100644 index 0000000000..3906a5fa49 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/api/query-overview/schemas.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +export const apiOverview = z.object({ + id: z.string(), + name: z.string(), + keyspaceId: z.string().nullable(), + keys: z.array( + z.object({ + count: z.number(), + }), + ), +}); +export type ApiOverview = z.infer; + +const Cursor = z.object({ + id: z.string(), +}); + +export const apisOverviewResponse = z.object({ + apiList: z.array(apiOverview), + hasMore: z.boolean(), + nextCursor: Cursor.optional(), + total: z.number(), +}); + +export type ApisOverviewResponse = z.infer; + +export const queryApisOverviewPayload = z.object({ + limit: z.number().min(1).max(18).default(9), + cursor: Cursor.optional(), +}); diff --git a/apps/dashboard/lib/trpc/routers/api/query-timeseries/index.ts b/apps/dashboard/lib/trpc/routers/api/query-timeseries/index.ts new file mode 100644 index 0000000000..5f53a9af44 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/api/query-timeseries/index.ts @@ -0,0 +1,27 @@ +import { verificationQueryTimeseriesPayload } from "@/app/(app)/apis/_components/hooks/query-timeseries.schema"; +import { clickhouse } from "@/lib/clickhouse"; +import { rateLimitedProcedure, ratelimit } from "@/lib/trpc/ratelimitProcedure"; +import { TRPCError } from "@trpc/server"; +import { transformVerificationFilters } from "./utils"; + +export const queryVerificationTimeseries = rateLimitedProcedure(ratelimit.read) + .input(verificationQueryTimeseriesPayload) + .query(async ({ ctx, input }) => { + const { params: transformedInputs, granularity } = transformVerificationFilters(input); + + const result = await clickhouse.verifications.timeseries[granularity]({ + ...transformedInputs, + workspaceId: ctx.workspace.id, + keyspaceId: input.keyspaceId, + }); + + if (result.err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to retrieve ratelimit timeseries analytics due to an error. If this issue persists, please contact support@unkey.dev with the time this occurred.", + }); + } + + return { timeseries: result.val, granularity }; + }); diff --git a/apps/dashboard/lib/trpc/routers/api/query-timeseries/utils.ts b/apps/dashboard/lib/trpc/routers/api/query-timeseries/utils.ts new file mode 100644 index 0000000000..66c0f13ba9 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/api/query-timeseries/utils.ts @@ -0,0 +1,32 @@ +import type { VerificationQueryTimeseriesPayload } from "@/app/(app)/apis/_components/hooks/query-timeseries.schema"; +import { getTimestampFromRelative } from "@/lib/utils"; +import type { VerificationTimeseriesParams } from "@unkey/clickhouse/src/verifications"; +import { + type TimeseriesConfig, + type VerificationTimeseriesGranularity, + getTimeseriesGranularity, +} from "../../utils/granularity"; + +export function transformVerificationFilters(params: VerificationQueryTimeseriesPayload): { + params: Omit; + granularity: VerificationTimeseriesGranularity; +} { + let timeConfig: TimeseriesConfig<"forVerifications">; + + if (params.since !== "") { + const startTime = getTimestampFromRelative(params.since); + const endTime = Date.now(); + + timeConfig = getTimeseriesGranularity("forVerifications", startTime, endTime); + } else { + timeConfig = getTimeseriesGranularity("forVerifications", params.startTime, params.endTime); + } + + return { + params: { + startTime: timeConfig.startTime, + endTime: timeConfig.endTime, + }, + granularity: timeConfig.granularity, + }; +} diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 2f235a9234..84cb003549 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -1,6 +1,9 @@ import { t } from "../trpc"; import { createApi } from "./api/create"; import { deleteApi } from "./api/delete"; +import { overviewApiSearch } from "./api/overview-api-search"; +import { queryApisOverview } from "./api/query-overview"; +import { queryVerificationTimeseries } from "./api/query-timeseries"; import { setDefaultApiBytes } from "./api/setDefaultBytes"; import { setDefaultApiPrefix } from "./api/setDefaultPrefix"; import { updateAPIDeleteProtection } from "./api/updateDeleteProtection"; @@ -83,6 +86,13 @@ export const router = t.router({ setDefaultBytes: setDefaultApiBytes, updateIpWhitelist: updateApiIpWhitelist, updateDeleteProtection: updateAPIDeleteProtection, + logs: t.router({ + queryVerificationTimeseries, + }), + overview: t.router({ + queryApisOverview, + search: overviewApiSearch, + }), }), workspace: t.router({ create: createWorkspace, diff --git a/apps/dashboard/lib/trpc/routers/logs/query-logs/index.ts b/apps/dashboard/lib/trpc/routers/logs/query-logs/index.ts index 33bdeea0af..c29f7785dd 100644 --- a/apps/dashboard/lib/trpc/routers/logs/query-logs/index.ts +++ b/apps/dashboard/lib/trpc/routers/logs/query-logs/index.ts @@ -20,7 +20,7 @@ const LogsResponse = z.object({ type LogsResponse = z.infer; -export const queryLogs = rateLimitedProcedure(ratelimit.update) +export const queryLogs = rateLimitedProcedure(ratelimit.read) .input(queryLogsPayload) .output(LogsResponse) .query(async ({ ctx, input }) => { diff --git a/apps/dashboard/lib/trpc/routers/logs/query-timeseries/utils.test.ts b/apps/dashboard/lib/trpc/routers/logs/query-timeseries/utils.test.ts deleted file mode 100644 index 473a451669..0000000000 --- a/apps/dashboard/lib/trpc/routers/logs/query-timeseries/utils.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { HOUR_IN_MS, WEEK_IN_MS } from "../../utils/constants"; -import { getTimeseriesGranularity } from "../../utils/granularity"; -import { transformFilters } from "./utils"; - -describe("getTimeseriesGranularity", () => { - const NOW = 1706024400000; // 2024-01-23T12:00:00.000Z - - // Original tests to ensure backward compatibility - it("should return perMinute granularity for missing start and end times", () => { - const result = getTimeseriesGranularity(null, null); - expect(result.granularity).toBe("perMinute"); - expect(result.endTime - result.startTime).toBe(HOUR_IN_MS); - }); - - it("should return perMinute granularity for timerange <= 1 hour", () => { - const endTime = NOW; - const startTime = endTime - HOUR_IN_MS / 2; - const result = getTimeseriesGranularity(startTime, endTime); - expect(result).toEqual({ - granularity: "perMinute", - startTime, - endTime, - }); - }); - - it("should return perHour granularity for timerange > 1 hour and <= 1 week", () => { - const endTime = NOW; - const startTime = endTime - WEEK_IN_MS / 2; - const result = getTimeseriesGranularity(startTime, endTime); - expect(result).toEqual({ - granularity: "perHour", - startTime, - endTime, - }); - }); - - it("should return perDay granularity for timerange > 1 week", () => { - const endTime = NOW; - const startTime = endTime - WEEK_IN_MS * 2; - const result = getTimeseriesGranularity(startTime, endTime); - expect(result).toEqual({ - granularity: "perDay", - startTime, - endTime, - }); - }); - - it("should use current time as endTime when only startTime is provided", () => { - const startTime = NOW - HOUR_IN_MS; - const result = getTimeseriesGranularity(startTime, null); - expect(result.endTime).toBeGreaterThan(startTime); - expect(result.startTime).toBe(startTime); - }); - - // New tests for additional granularities - it("should return per5Minutes granularity for timerange > 10 minutes", () => { - const endTime = NOW; - const startTime = endTime - HOUR_IN_MS / 4; // 15 minutes - const result = getTimeseriesGranularity(startTime, endTime); - expect(result).toEqual({ - granularity: "per5Minutes", - startTime, - endTime, - }); - }); - - it("should return per15Minutes granularity for timerange > 30 minutes", () => { - const endTime = NOW; - const startTime = endTime - HOUR_IN_MS / 1.5; // 40 minutes - const result = getTimeseriesGranularity(startTime, endTime); - expect(result).toEqual({ - granularity: "per15Minutes", - startTime, - endTime, - }); - }); - - it("should return per30Minutes granularity for timerange > 45 minutes", () => { - const endTime = NOW; - const startTime = endTime - HOUR_IN_MS * 0.8; // 48 minutes - const result = getTimeseriesGranularity(startTime, endTime); - expect(result).toEqual({ - granularity: "per30Minutes", - startTime, - endTime, - }); - }); - - it("should return per2Hours granularity for timerange > 3 hours", () => { - const endTime = NOW; - const startTime = endTime - HOUR_IN_MS * 4; - const result = getTimeseriesGranularity(startTime, endTime); - expect(result).toEqual({ - granularity: "per2Hours", - startTime, - endTime, - }); - }); - - it("should return per4Hours granularity for timerange > 6 hours", () => { - const endTime = NOW; - const startTime = endTime - HOUR_IN_MS * 7; - const result = getTimeseriesGranularity(startTime, endTime); - expect(result).toEqual({ - granularity: "per4Hours", - startTime, - endTime, - }); - }); - - it("should return per6Hours granularity for timerange > 8 hours", () => { - const endTime = NOW; - const startTime = endTime - HOUR_IN_MS * 9; - const result = getTimeseriesGranularity(startTime, endTime); - expect(result).toEqual({ - granularity: "per6Hours", - startTime, - endTime, - }); - }); - - it("should return per8Hours granularity for timerange > 12 hours", () => { - const endTime = NOW; - const startTime = endTime - HOUR_IN_MS * 13; - const result = getTimeseriesGranularity(startTime, endTime); - expect(result).toEqual({ - granularity: "per8Hours", - startTime, - endTime, - }); - }); - - it("should return per12Hours granularity for timerange > 16 hours", () => { - const endTime = NOW; - const startTime = endTime - HOUR_IN_MS * 18; - const result = getTimeseriesGranularity(startTime, endTime); - expect(result).toEqual({ - granularity: "per12Hours", - startTime, - endTime, - }); - }); -}); - -describe("transformFilters", () => { - const basePayload = { - startTime: 1706024400000, - endTime: 1706028000000, - since: "", - path: null, - host: null, - method: null, - status: null, - }; - - it("should transform empty filters correctly", () => { - const result = transformFilters(basePayload); - expect(result).toEqual({ - params: { - startTime: basePayload.startTime, - endTime: basePayload.endTime, - hosts: [], - methods: [], - paths: [], - statusCodes: [], - }, - granularity: "perMinute", - }); - }); - - it("should transform filters with values correctly", () => { - const payload = { - ...basePayload, - host: { - filters: [{ operator: "is" as const, value: "example.com" }], - }, - method: { - filters: [{ operator: "is" as const, value: "GET" }], - }, - path: { - filters: [{ operator: "startsWith" as const, value: "/api" }], - }, - status: { - filters: [{ operator: "is" as const, value: 200 }], - }, - }; - const result = transformFilters(payload); - expect(result).toEqual({ - params: { - startTime: payload.startTime, - endTime: payload.endTime, - hosts: ["example.com"], - methods: ["GET"], - paths: [{ operator: "startsWith", value: "/api" }], - statusCodes: [200], - }, - granularity: "perMinute", - }); - }); - - it('should handle relative time with "since" parameter', () => { - const payload = { - ...basePayload, - since: "24h", - }; - const result = transformFilters(payload); - expect(result.params.endTime).toBeGreaterThan(result.params.startTime); - expect(result.params.endTime - result.params.startTime).toBeCloseTo(24 * 60 * 60 * 1000, -2); - expect(result.granularity).toBe("perHour"); - }); - - // Additional tests for transformFilters with new granularities - it('should return per6Hours granularity for "7d" since parameter', () => { - const payload = { - ...basePayload, - since: "7d", - }; - const result = transformFilters(payload); - expect(result.granularity).toBe("per6Hours"); - expect(result.params.endTime - result.params.startTime).toBeCloseTo( - 7 * 24 * 60 * 60 * 1000, - -2, - ); - }); - - it('should return per12Hours granularity for "14d" since parameter', () => { - const payload = { - ...basePayload, - since: "14d", - }; - const result = transformFilters(payload); - expect(result.granularity).toBe("per12Hours"); - expect(result.params.endTime - result.params.startTime).toBeCloseTo( - 14 * 24 * 60 * 60 * 1000, - -2, - ); - }); - - it('should return perDay granularity for "30d" since parameter', () => { - const payload = { - ...basePayload, - since: "30d", - }; - const result = transformFilters(payload); - expect(result.granularity).toBe("perDay"); - expect(result.params.endTime - result.params.startTime).toBeCloseTo( - 30 * 24 * 60 * 60 * 1000, - -2, - ); - }); -}); diff --git a/apps/dashboard/lib/trpc/routers/logs/query-timeseries/utils.ts b/apps/dashboard/lib/trpc/routers/logs/query-timeseries/utils.ts index 6e2ef62573..dab121e671 100644 --- a/apps/dashboard/lib/trpc/routers/logs/query-timeseries/utils.ts +++ b/apps/dashboard/lib/trpc/routers/logs/query-timeseries/utils.ts @@ -3,22 +3,22 @@ import { getTimestampFromRelative } from "@/lib/utils"; import type { LogsTimeseriesParams } from "@unkey/clickhouse/src/logs"; import type { z } from "zod"; import { + type RegularTimeseriesGranularity, type TimeseriesConfig, - type TimeseriesGranularity, getTimeseriesGranularity, } from "../../utils/granularity"; export function transformFilters(params: z.infer): { params: Omit; - granularity: TimeseriesGranularity; + granularity: RegularTimeseriesGranularity; } { - let timeConfig: TimeseriesConfig; + let timeConfig: TimeseriesConfig<"forRegular">; if (params.since !== "") { const startTime = getTimestampFromRelative(params.since); const endTime = Date.now(); - timeConfig = getTimeseriesGranularity(startTime, endTime); + timeConfig = getTimeseriesGranularity("forRegular", startTime, endTime); } else { - timeConfig = getTimeseriesGranularity(params.startTime, params.endTime); + timeConfig = getTimeseriesGranularity("forRegular", params.startTime, params.endTime); } return { diff --git a/apps/dashboard/lib/trpc/routers/ratelimit/namespace-search.ts b/apps/dashboard/lib/trpc/routers/ratelimit/namespace-search.ts index 6670f13e60..c460a8f507 100644 --- a/apps/dashboard/lib/trpc/routers/ratelimit/namespace-search.ts +++ b/apps/dashboard/lib/trpc/routers/ratelimit/namespace-search.ts @@ -1,4 +1,4 @@ -import { db } from "@/lib/db"; +import { db, sql } from "@/lib/db"; import { rateLimitedProcedure, ratelimit } from "@/lib/trpc/ratelimitProcedure"; import { z } from "zod"; @@ -6,10 +6,10 @@ export const searchNamespace = rateLimitedProcedure(ratelimit.update) .input(z.object({ query: z.string() })) .mutation(async ({ ctx, input }) => { return await db.query.ratelimitNamespaces.findMany({ - where: (table, { isNull, and, like, eq }) => + where: (table, { isNull, and, eq }) => and( eq(table.workspaceId, ctx.workspace.id), - like(table.name, `%${input.query}%`), + sql`${table.name} LIKE ${`%${input.query}%`}`, isNull(table.deletedAtM), ), columns: { diff --git a/apps/dashboard/lib/trpc/routers/ratelimit/query-latency-timeseries/index.ts b/apps/dashboard/lib/trpc/routers/ratelimit/query-latency-timeseries/index.ts index 4b20a6ac86..525d9a38fa 100644 --- a/apps/dashboard/lib/trpc/routers/ratelimit/query-latency-timeseries/index.ts +++ b/apps/dashboard/lib/trpc/routers/ratelimit/query-latency-timeseries/index.ts @@ -6,7 +6,7 @@ import { TRPCError } from "@trpc/server"; import { transformRatelimitFilters } from "./utils"; //TODO: Refactor this endpoint once we move to AWS -export const queryRatelimitLatencyTimeseries = rateLimitedProcedure(ratelimit.update) +export const queryRatelimitLatencyTimeseries = rateLimitedProcedure(ratelimit.read) .input(ratelimitOverviewQueryTimeseriesPayload) .query(async ({ ctx, input }) => { const ratelimitNamespaces = await db.query.ratelimitNamespaces diff --git a/apps/dashboard/lib/trpc/routers/ratelimit/query-latency-timeseries/utils.ts b/apps/dashboard/lib/trpc/routers/ratelimit/query-latency-timeseries/utils.ts index 8b361c44eb..c5799a9ecc 100644 --- a/apps/dashboard/lib/trpc/routers/ratelimit/query-latency-timeseries/utils.ts +++ b/apps/dashboard/lib/trpc/routers/ratelimit/query-latency-timeseries/utils.ts @@ -2,23 +2,23 @@ import type { RatelimitQueryTimeseriesPayload } from "@/app/(app)/ratelimits/[na import { getTimestampFromRelative } from "@/lib/utils"; import type { RatelimitLogsTimeseriesParams } from "@unkey/clickhouse/src/ratelimits"; import { + type RegularTimeseriesGranularity, type TimeseriesConfig, - type TimeseriesGranularity, getTimeseriesGranularity, } from "../../utils/granularity"; export function transformRatelimitFilters(params: RatelimitQueryTimeseriesPayload): { params: Omit; - granularity: TimeseriesGranularity; + granularity: RegularTimeseriesGranularity; } { - let timeConfig: TimeseriesConfig; + let timeConfig: TimeseriesConfig<"forRegular">; if (params.since !== "") { const startTime = getTimestampFromRelative(params.since); const endTime = Date.now(); - timeConfig = getTimeseriesGranularity(startTime, endTime); + timeConfig = getTimeseriesGranularity("forRegular", startTime, endTime); } else { - timeConfig = getTimeseriesGranularity(params.startTime, params.endTime); + timeConfig = getTimeseriesGranularity("forRegular", params.startTime, params.endTime); } return { diff --git a/apps/dashboard/lib/trpc/routers/ratelimit/query-logs/index.ts b/apps/dashboard/lib/trpc/routers/ratelimit/query-logs/index.ts index 50cba61ca1..be8e9d7dd0 100644 --- a/apps/dashboard/lib/trpc/routers/ratelimit/query-logs/index.ts +++ b/apps/dashboard/lib/trpc/routers/ratelimit/query-logs/index.ts @@ -20,7 +20,7 @@ const RatelimitLogsResponse = z.object({ type RatelimitLogsResponse = z.infer; -export const queryRatelimitLogs = rateLimitedProcedure(ratelimit.update) +export const queryRatelimitLogs = rateLimitedProcedure(ratelimit.read) .input(ratelimitQueryLogsPayload) .output(RatelimitLogsResponse) .query(async ({ ctx, input }) => { diff --git a/apps/dashboard/lib/trpc/routers/ratelimit/query-overview-logs/index.ts b/apps/dashboard/lib/trpc/routers/ratelimit/query-overview-logs/index.ts index a91d7a63fa..b0b95c5225 100644 --- a/apps/dashboard/lib/trpc/routers/ratelimit/query-overview-logs/index.ts +++ b/apps/dashboard/lib/trpc/routers/ratelimit/query-overview-logs/index.ts @@ -20,7 +20,7 @@ const RatelimitOverviewLogsResponse = z.object({ type RatelimitOverviewLogsResponse = z.infer; -export const queryRatelimitOverviewLogs = rateLimitedProcedure(ratelimit.update) +export const queryRatelimitOverviewLogs = rateLimitedProcedure(ratelimit.read) .input(ratelimitQueryOverviewLogsPayload) .output(RatelimitOverviewLogsResponse) .query(async ({ ctx, input }) => { diff --git a/apps/dashboard/lib/trpc/routers/ratelimit/query-timeseries/index.ts b/apps/dashboard/lib/trpc/routers/ratelimit/query-timeseries/index.ts index 482150e542..928cd44e29 100644 --- a/apps/dashboard/lib/trpc/routers/ratelimit/query-timeseries/index.ts +++ b/apps/dashboard/lib/trpc/routers/ratelimit/query-timeseries/index.ts @@ -5,7 +5,7 @@ import { rateLimitedProcedure, ratelimit } from "@/lib/trpc/ratelimitProcedure"; import { TRPCError } from "@trpc/server"; import { transformRatelimitFilters } from "./utils"; -export const queryRatelimitTimeseries = rateLimitedProcedure(ratelimit.update) +export const queryRatelimitTimeseries = rateLimitedProcedure(ratelimit.read) .input(ratelimitQueryTimeseriesPayload) .query(async ({ ctx, input }) => { const ratelimitNamespaces = await db.query.ratelimitNamespaces diff --git a/apps/dashboard/lib/trpc/routers/ratelimit/query-timeseries/utils.ts b/apps/dashboard/lib/trpc/routers/ratelimit/query-timeseries/utils.ts index 8b361c44eb..c5799a9ecc 100644 --- a/apps/dashboard/lib/trpc/routers/ratelimit/query-timeseries/utils.ts +++ b/apps/dashboard/lib/trpc/routers/ratelimit/query-timeseries/utils.ts @@ -2,23 +2,23 @@ import type { RatelimitQueryTimeseriesPayload } from "@/app/(app)/ratelimits/[na import { getTimestampFromRelative } from "@/lib/utils"; import type { RatelimitLogsTimeseriesParams } from "@unkey/clickhouse/src/ratelimits"; import { + type RegularTimeseriesGranularity, type TimeseriesConfig, - type TimeseriesGranularity, getTimeseriesGranularity, } from "../../utils/granularity"; export function transformRatelimitFilters(params: RatelimitQueryTimeseriesPayload): { params: Omit; - granularity: TimeseriesGranularity; + granularity: RegularTimeseriesGranularity; } { - let timeConfig: TimeseriesConfig; + let timeConfig: TimeseriesConfig<"forRegular">; if (params.since !== "") { const startTime = getTimestampFromRelative(params.since); const endTime = Date.now(); - timeConfig = getTimeseriesGranularity(startTime, endTime); + timeConfig = getTimeseriesGranularity("forRegular", startTime, endTime); } else { - timeConfig = getTimeseriesGranularity(params.startTime, params.endTime); + timeConfig = getTimeseriesGranularity("forRegular", params.startTime, params.endTime); } return { diff --git a/apps/dashboard/lib/trpc/routers/utils/constants.ts b/apps/dashboard/lib/trpc/routers/utils/constants.ts index eb15afceac..4659427fe7 100644 --- a/apps/dashboard/lib/trpc/routers/utils/constants.ts +++ b/apps/dashboard/lib/trpc/routers/utils/constants.ts @@ -2,3 +2,4 @@ export const HOUR_IN_MS = 60 * 60 * 1000; export const DAY_IN_MS = 24 * HOUR_IN_MS; export const WEEK_IN_MS = 8 * DAY_IN_MS; export const MONTH_IN_MS = 31 * 24 * 60 * 60 * 1000; // 30 days in milliseconds +export const QUARTER_IN_MS = MONTH_IN_MS * 3; diff --git a/apps/dashboard/lib/trpc/routers/utils/granularity.test.ts b/apps/dashboard/lib/trpc/routers/utils/granularity.test.ts new file mode 100644 index 0000000000..e4d382a92d --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/utils/granularity.test.ts @@ -0,0 +1,306 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DAY_IN_MS, HOUR_IN_MS } from "./constants"; +import { + type TimeseriesConfig, + type VerificationTimeseriesGranularity, + getTimeseriesGranularity, +} from "./granularity"; + +describe("getTimeseriesGranularity", () => { + const originalDateNow = Date.now; + const FIXED_NOW = 1640995200000; // 2022-01-01T00:00:00.000Z + + beforeEach(() => { + vi.spyOn(Date, "now").mockImplementation(() => FIXED_NOW); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Date.now = originalDateNow; + }); + + const getTime = (offset: number) => FIXED_NOW - offset; + + describe("Default parameters (null startTime and endTime)", () => { + it("should return correct defaults for forRegular context", () => { + const result = getTimeseriesGranularity("forRegular", null, null); + + expect(result).toEqual({ + granularity: "perMinute", + startTime: FIXED_NOW - HOUR_IN_MS, + endTime: FIXED_NOW, + context: "forRegular", + }); + }); + + it("should return correct defaults for forVerifications context", () => { + const result = getTimeseriesGranularity("forVerifications", null, null); + + expect(result).toEqual({ + granularity: "perHour", + startTime: FIXED_NOW - DAY_IN_MS, + endTime: FIXED_NOW, + context: "forVerifications", + }); + }); + }); + + describe("With endTime only (null startTime)", () => { + it("should set startTime based on context for forRegular", () => { + const endTime = FIXED_NOW; + const result = getTimeseriesGranularity("forRegular", null, endTime); + + expect(result.startTime).toBe(endTime - HOUR_IN_MS); + expect(result.endTime).toBe(endTime); + }); + + it("should set startTime based on context for forVerifications", () => { + const endTime = FIXED_NOW; + const result = getTimeseriesGranularity("forVerifications", null, endTime); + + expect(result.startTime).toBe(endTime - DAY_IN_MS); + expect(result.endTime).toBe(endTime); + }); + }); + + describe("With startTime only (null endTime)", () => { + it("should use current time as endTime for forRegular", () => { + const startTime = FIXED_NOW - HOUR_IN_MS * 2; + const result = getTimeseriesGranularity("forRegular", startTime, null); + + expect(result.startTime).toBe(startTime); + expect(result.endTime).toBe(FIXED_NOW); + }); + + it("should use current time as endTime for forVerifications", () => { + const startTime = FIXED_NOW - DAY_IN_MS * 2; + const result = getTimeseriesGranularity("forVerifications", startTime, null); + + expect(result.startTime).toBe(startTime); + expect(result.endTime).toBe(FIXED_NOW); + }); + }); + + describe("Test granularity selection for Regular context", () => { + const testCases = [ + { + name: "should use perMinute for timeRange < 2 hours", + startTime: getTime(HOUR_IN_MS * 1.5), + expectedGranularity: "perMinute", + }, + { + name: "should use per5Minutes for timeRange >= 2 hours & < 4 hours", + startTime: getTime(HOUR_IN_MS * 3), + expectedGranularity: "per5Minutes", + }, + { + name: "should use per15Minutes for timeRange >= 4 hours & < 6 hours", + startTime: getTime(HOUR_IN_MS * 5), + expectedGranularity: "per15Minutes", + }, + { + name: "should use per30Minutes for timeRange >= 6 hours & < 8 hours", + startTime: getTime(HOUR_IN_MS * 7), + expectedGranularity: "per30Minutes", + }, + { + name: "should use per30Minutes for timeRange >= 8 hours & < 12 hours", + startTime: getTime(HOUR_IN_MS * 10), + expectedGranularity: "per30Minutes", + }, + { + name: "should use perHour for timeRange >= 12 hours & < 16 hours", + startTime: getTime(HOUR_IN_MS * 14), + expectedGranularity: "perHour", + }, + { + name: "should use per2Hours for timeRange >= 16 hours & < 24 hours", + startTime: getTime(HOUR_IN_MS * 20), + expectedGranularity: "per2Hours", + }, + { + name: "should use per4Hours for timeRange >= 24 hours & < 3 days", + startTime: getTime(DAY_IN_MS * 2), + expectedGranularity: "per4Hours", + }, + { + name: "should use per6Hours for timeRange >= 3 days & < 7 days", + startTime: getTime(DAY_IN_MS * 5), + expectedGranularity: "per6Hours", + }, + { + name: "should use perDay for timeRange >= 7 days", + startTime: getTime(DAY_IN_MS * 10), + expectedGranularity: "perDay", + }, + ]; + + testCases.forEach((testCase) => { + it(testCase.name, () => { + const result = getTimeseriesGranularity("forRegular", testCase.startTime, FIXED_NOW); + expect(result.granularity).toBe(testCase.expectedGranularity); + }); + }); + + it("should handle edge case at exactly 2 hours boundary", () => { + const result = getTimeseriesGranularity("forRegular", FIXED_NOW - HOUR_IN_MS * 2, FIXED_NOW); + expect(result.granularity).toBe("per5Minutes"); + }); + + it("should handle edge case at exactly 7 days boundary", () => { + const result = getTimeseriesGranularity("forRegular", FIXED_NOW - DAY_IN_MS * 7, FIXED_NOW); + expect(result.granularity).toBe("perDay"); + }); + }); + + describe("Test granularity selection for Verifications context", () => { + const testCases = [ + { + name: "should use perHour for timeRange < 7 days", + startTime: getTime(DAY_IN_MS * 6), + expectedGranularity: "perHour", + }, + { + name: "should use per12Hours for timeRange >= 7 days & < 14 days", + startTime: getTime(DAY_IN_MS * 10), + expectedGranularity: "per12Hours", + }, + { + name: "should use perDay for timeRange >= 14 days & < 30 days", + startTime: getTime(DAY_IN_MS * 20), + expectedGranularity: "perDay", + }, + { + name: "should use per3Days for timeRange >= 30 days & < 60 days", + startTime: getTime(DAY_IN_MS * 45), + expectedGranularity: "per3Days", + }, + { + name: "should use perWeek for timeRange >= 60 days & < 90 days", + startTime: getTime(DAY_IN_MS * 75), + expectedGranularity: "perWeek", + }, + { + name: "should use perMonth for timeRange >= 90 days", + startTime: getTime(DAY_IN_MS * 100), + expectedGranularity: "perMonth", + }, + ]; + + testCases.forEach((testCase) => { + it(testCase.name, () => { + const result = getTimeseriesGranularity("forVerifications", testCase.startTime, FIXED_NOW); + expect(result.granularity).toBe(testCase.expectedGranularity); + }); + }); + + it("should handle edge case at exactly 7 days boundary", () => { + const result = getTimeseriesGranularity( + "forVerifications", + FIXED_NOW - DAY_IN_MS * 7, + FIXED_NOW, + ); + expect(result.granularity).toBe("per12Hours"); + }); + + it("should handle edge case at exactly 30 days boundary", () => { + const result = getTimeseriesGranularity( + "forVerifications", + FIXED_NOW - DAY_IN_MS * 30, + FIXED_NOW, + ); + expect(result.granularity).toBe("per3Days"); + }); + }); + + describe("Type compatibility tests", () => { + it("should properly type the return for forRegular context", () => { + const result: TimeseriesConfig<"forRegular"> = getTimeseriesGranularity( + "forRegular", + null, + null, + ); + expect(result.context).toBe("forRegular"); + + const validGranularities = [ + "perMinute", + "per5Minutes", + "per15Minutes", + "per30Minutes", + "perHour", + "per2Hours", + "per4Hours", + "per6Hours", + ]; + + expect(validGranularities.includes(result.granularity)).toBeTruthy(); + }); + + it("should properly type the return for forVerifications context", () => { + const result: TimeseriesConfig<"forVerifications"> = getTimeseriesGranularity( + "forVerifications", + null, + null, + ); + expect(result.context).toBe("forVerifications"); + + const validGranularities: VerificationTimeseriesGranularity[] = [ + "perDay", + "per12Hours", + "per3Days", + "perWeek", + "perMonth", + "perHour", + ]; + + expect(validGranularities.includes(result.granularity)).toBeTruthy(); + }); + }); + + describe("Use cases", () => { + it("should handle a 1-hour dashboard view correctly", () => { + const oneHourAgo = FIXED_NOW - HOUR_IN_MS; + const result = getTimeseriesGranularity("forRegular", oneHourAgo, FIXED_NOW); + + expect(result.granularity).toBe("perMinute"); + expect(result.startTime).toBe(oneHourAgo); + expect(result.endTime).toBe(FIXED_NOW); + }); + + it("should handle a 24-hour dashboard view correctly", () => { + const oneDayAgo = FIXED_NOW - DAY_IN_MS; + const result = getTimeseriesGranularity("forRegular", oneDayAgo, FIXED_NOW); + + expect(result.granularity).toBe("per4Hours"); + expect(result.startTime).toBe(oneDayAgo); + expect(result.endTime).toBe(FIXED_NOW); + }); + + it("should handle a 1-week dashboard view correctly", () => { + const oneWeekAgo = FIXED_NOW - DAY_IN_MS * 7; + const result = getTimeseriesGranularity("forRegular", oneWeekAgo, FIXED_NOW); + + expect(result.granularity).toBe("perDay"); + expect(result.startTime).toBe(oneWeekAgo); + expect(result.endTime).toBe(FIXED_NOW); + }); + + it("should handle a 30-day verification dashboard view correctly", () => { + const thirtyDaysAgo = FIXED_NOW - DAY_IN_MS * 30; + const result = getTimeseriesGranularity("forVerifications", thirtyDaysAgo, FIXED_NOW); + + expect(result.granularity).toBe("per3Days"); + expect(result.startTime).toBe(thirtyDaysAgo); + expect(result.endTime).toBe(FIXED_NOW); + }); + + it("should handle a quarterly verification dashboard view correctly", () => { + const threeMonthsAgo = FIXED_NOW - DAY_IN_MS * 90; + const result = getTimeseriesGranularity("forVerifications", threeMonthsAgo, FIXED_NOW); + + expect(result.granularity).toBe("perMonth"); + expect(result.startTime).toBe(threeMonthsAgo); + expect(result.endTime).toBe(FIXED_NOW); + }); + }); +}); diff --git a/apps/dashboard/lib/trpc/routers/utils/granularity.ts b/apps/dashboard/lib/trpc/routers/utils/granularity.ts index 497e9126c2..5e7d8c6e39 100644 --- a/apps/dashboard/lib/trpc/routers/utils/granularity.ts +++ b/apps/dashboard/lib/trpc/routers/utils/granularity.ts @@ -1,6 +1,14 @@ import { DAY_IN_MS, HOUR_IN_MS } from "./constants"; -export type TimeseriesGranularity = +export type VerificationTimeseriesGranularity = + | "perDay" + | "per12Hours" + | "per3Days" + | "perWeek" + | "perMonth" + | "perHour"; + +export type RegularTimeseriesGranularity = | "perMinute" | "per5Minutes" | "per15Minutes" @@ -8,60 +16,113 @@ export type TimeseriesGranularity = | "perHour" | "per2Hours" | "per4Hours" - | "per6Hours" - | "perDay"; + | "per6Hours"; + +export type TimeseriesContext = "forVerifications" | "forRegular"; + +export type TimeseriesGranularityMap = { + forVerifications: VerificationTimeseriesGranularity; + forRegular: RegularTimeseriesGranularity; +}; + +export type CompoundTimeseriesGranularity = + | VerificationTimeseriesGranularity + | RegularTimeseriesGranularity; + +const DEFAULT_GRANULARITY: Record = { + forVerifications: "perHour", + forRegular: "perMinute", +}; -export type TimeseriesConfig = { - granularity: TimeseriesGranularity; +export type TimeseriesConfig = { + granularity: TimeseriesGranularityMap[TContext]; startTime: number; endTime: number; + context: TContext; }; -export const getTimeseriesGranularity = ( +/** + * Returns an appropriate timeseries configuration based on the time range and context + * @param context The context for which to get a granularity ("forVerifications", "forRegular") + * @param startTime Optional start time in milliseconds + * @param endTime Optional end time in milliseconds + * @returns TimeseriesConfig with the appropriate granularity for the given context and time range + */ +export const getTimeseriesGranularity = ( + context: TContext, startTime?: number | null, endTime?: number | null, -): TimeseriesConfig => { +): TimeseriesConfig => { const now = Date.now(); - // If both of them are missing fallback to perMinute and fetch lastHour to show latest + const WEEK_IN_MS = DAY_IN_MS * 7; + const MONTH_IN_MS = DAY_IN_MS * 30; + const QUARTER_IN_MS = MONTH_IN_MS * 3; + + // If both are missing, fallback to an appropriate default for the context if (!startTime && !endTime) { + const defaultGranularity = DEFAULT_GRANULARITY[context]; + const defaultDuration = context === "forVerifications" ? DAY_IN_MS : HOUR_IN_MS; + return { - granularity: "perMinute", - startTime: now - HOUR_IN_MS, + granularity: defaultGranularity as TimeseriesGranularityMap[TContext], + startTime: now - defaultDuration, endTime: now, + context, }; } + // Set default end time if missing const effectiveEndTime = endTime ?? now; - // Set default start time if missing (last hour) - const effectiveStartTime = startTime ?? effectiveEndTime - HOUR_IN_MS; + // Set default start time if missing (defaults vary by context) + const defaultDuration = context === "forVerifications" ? DAY_IN_MS : HOUR_IN_MS; + const effectiveStartTime = startTime ?? effectiveEndTime - defaultDuration; + const timeRange = effectiveEndTime - effectiveStartTime; - let granularity: TimeseriesGranularity; - if (timeRange > DAY_IN_MS * 7) { - granularity = "perDay"; - } else if (timeRange > DAY_IN_MS * 3) { - granularity = "per6Hours"; - } else if (timeRange > HOUR_IN_MS * 24) { - granularity = "per4Hours"; - } else if (timeRange > HOUR_IN_MS * 16) { - granularity = "per2Hours"; - } else if (timeRange > HOUR_IN_MS * 12) { - granularity = "perHour"; - } else if (timeRange > HOUR_IN_MS * 8) { - granularity = "per30Minutes"; - } else if (timeRange > HOUR_IN_MS * 6) { - granularity = "per30Minutes"; - } else if (timeRange > HOUR_IN_MS * 4) { - granularity = "per15Minutes"; - } else if (timeRange > HOUR_IN_MS * 2) { - granularity = "per5Minutes"; + let granularity: CompoundTimeseriesGranularity; + + if (context === "forVerifications") { + if (timeRange >= QUARTER_IN_MS) { + granularity = "perMonth"; + } else if (timeRange >= MONTH_IN_MS * 2) { + granularity = "perWeek"; + } else if (timeRange >= MONTH_IN_MS) { + granularity = "per3Days"; + } else if (timeRange >= WEEK_IN_MS * 2) { + granularity = "perDay"; + } else if (timeRange >= WEEK_IN_MS) { + granularity = "per12Hours"; + } else { + granularity = "perHour"; + } } else { - granularity = "perMinute"; + if (timeRange >= DAY_IN_MS * 7) { + granularity = "perDay"; + } else if (timeRange >= DAY_IN_MS * 3) { + granularity = "per6Hours"; + } else if (timeRange >= HOUR_IN_MS * 24) { + granularity = "per4Hours"; + } else if (timeRange >= HOUR_IN_MS * 16) { + granularity = "per2Hours"; + } else if (timeRange >= HOUR_IN_MS * 12) { + granularity = "perHour"; + } else if (timeRange >= HOUR_IN_MS * 8) { + granularity = "per30Minutes"; + } else if (timeRange >= HOUR_IN_MS * 6) { + granularity = "per30Minutes"; + } else if (timeRange >= HOUR_IN_MS * 4) { + granularity = "per15Minutes"; + } else if (timeRange >= HOUR_IN_MS * 2) { + granularity = "per5Minutes"; + } else { + granularity = "perMinute"; + } } return { - granularity, + granularity: granularity as TimeseriesGranularityMap[TContext], startTime: effectiveStartTime, endTime: effectiveEndTime, + context, }; }; diff --git a/internal/clickhouse/src/index.ts b/internal/clickhouse/src/index.ts index 6a7400a6bc..f1829680fe 100644 --- a/internal/clickhouse/src/index.ts +++ b/internal/clickhouse/src/index.ts @@ -43,9 +43,18 @@ import { insertApiRequest } from "./requests"; import { getActiveWorkspacesPerMonth } from "./success"; import { insertSDKTelemetry } from "./telemetry"; import { + getDailyVerificationTimeseries, + getFourHourlyVerificationTimeseries, + getHourlyVerificationTimeseries, + getMonthlyVerificationTimeseries, + getSixHourlyVerificationTimeseries, + getThreeDayVerificationTimeseries, + getTwelveHourlyVerificationTimeseries, + getTwoHourlyVerificationTimeseries, getVerificationsPerDay, getVerificationsPerHour, getVerificationsPerMonth, + getWeeklyVerificationTimeseries, insertVerification, } from "./verifications"; @@ -91,6 +100,17 @@ export class ClickHouse { perDay: getVerificationsPerDay(this.querier), perMonth: getVerificationsPerMonth(this.querier), latest: getLatestVerifications(this.querier), + timeseries: { + perHour: getHourlyVerificationTimeseries(this.querier), + per2Hours: getTwoHourlyVerificationTimeseries(this.querier), + per4Hours: getFourHourlyVerificationTimeseries(this.querier), + per6Hours: getSixHourlyVerificationTimeseries(this.querier), + per12Hours: getTwelveHourlyVerificationTimeseries(this.querier), + perDay: getDailyVerificationTimeseries(this.querier), + per3Days: getThreeDayVerificationTimeseries(this.querier), + perWeek: getWeeklyVerificationTimeseries(this.querier), + perMonth: getMonthlyVerificationTimeseries(this.querier), + }, }; } public get activeKeys() { diff --git a/internal/clickhouse/src/verifications.ts b/internal/clickhouse/src/verifications.ts index f92f297d81..68f54e6333 100644 --- a/internal/clickhouse/src/verifications.ts +++ b/internal/clickhouse/src/verifications.ts @@ -136,3 +136,173 @@ export function getVerificationsPerMonth(ch: Querier) { return ch.query({ query, params, schema })(args); }; } + +export const verificationTimeseriesParams = z.object({ + workspaceId: z.string(), + keyspaceId: z.string(), + keyId: z.string().optional(), + startTime: z.number().int(), + endTime: z.number().int(), +}); + +export const verificationTimeseriesDataPoint = z.object({ + x: z.number().int(), + y: z.object({ + total: z.number().int().default(0), + valid: z.number().int().default(0), + }), +}); + +export type VerificationTimeseriesDataPoint = z.infer; +export type VerificationTimeseriesParams = z.infer; + +type TimeInterval = { + table: string; + step: string; + stepSize: number; +}; + +const INTERVALS: Record = { + hour: { + table: "verifications.key_verifications_per_hour_v3", + step: "HOUR", + stepSize: 1, + }, + twoHours: { + table: "verifications.key_verifications_per_hour_v3", + step: "HOURS", + stepSize: 2, + }, + fourHours: { + table: "verifications.key_verifications_per_hour_v3", + step: "HOURS", + stepSize: 4, + }, + sixHours: { + table: "verifications.key_verifications_per_hour_v3", + step: "HOURS", + stepSize: 6, + }, + twelveHours: { + table: "verifications.key_verifications_per_hour_v3", + step: "HOURS", + stepSize: 12, + }, + day: { + table: "verifications.key_verifications_per_day_v3", + step: "DAY", + stepSize: 1, + }, + threeDays: { + table: "verifications.key_verifications_per_day_v3", + step: "DAYS", + stepSize: 3, + }, + week: { + table: "verifications.key_verifications_per_day_v3", + step: "DAYS", + stepSize: 7, + }, + twoWeeks: { + table: "verifications.key_verifications_per_day_v3", + step: "DAYS", + stepSize: 14, + }, + // Monthly-based intervals + month: { + table: "verifications.key_verifications_per_month_v3", + step: "MONTH", + stepSize: 1, + }, + quarter: { + table: "verifications.key_verifications_per_month_v3", + step: "MONTHS", + stepSize: 3, + }, +} as const; + +function createVerificationTimeseriesQuery(interval: TimeInterval, whereClause: string) { + const intervalUnit = { + HOUR: "hour", + HOURS: "hour", + DAY: "day", + DAYS: "day", + MONTH: "month", + MONTHS: "month", + }[interval.step]; + + // For millisecond step calculation + const msPerUnit = { + HOUR: 3600_000, + HOURS: 3600_000, + DAY: 86400_000, + DAYS: 86400_000, + MONTH: 2592000_000, + MONTHS: 2592000_000, + }[interval.step]; + + // Calculate step in milliseconds + const stepMs = msPerUnit! * interval.stepSize; + + return ` + SELECT + toUnixTimestamp64Milli(CAST(toStartOfInterval(time, INTERVAL ${interval.stepSize} ${intervalUnit}) AS DateTime64(3))) as x, + map( + 'total', SUM(count), + 'valid', SUM(IF(outcome = 'VALID', count, 0)) + ) as y + FROM ${interval.table} + ${whereClause} + GROUP BY x + ORDER BY x ASC + WITH FILL + FROM toUnixTimestamp64Milli(CAST(toStartOfInterval(toDateTime(fromUnixTimestamp64Milli({startTime: Int64})), INTERVAL ${interval.stepSize} ${intervalUnit}) AS DateTime64(3))) + TO toUnixTimestamp64Milli(CAST(toStartOfInterval(toDateTime(fromUnixTimestamp64Milli({endTime: Int64})), INTERVAL ${interval.stepSize} ${intervalUnit}) AS DateTime64(3))) + STEP ${stepMs}`; +} + +function getVerificationTimeseriesWhereClause(): string { + const conditions = [ + "workspace_id = {workspaceId: String}", + "key_space_id = {keyspaceId: String}", + "time >= fromUnixTimestamp64Milli({startTime: Int64})", + "time <= fromUnixTimestamp64Milli({endTime: Int64})", + ]; + + return conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; +} + +function createVerificationTimeseriesQuerier(interval: TimeInterval) { + return (ch: Querier) => async (args: VerificationTimeseriesParams) => { + const whereClause = getVerificationTimeseriesWhereClause(); + const query = createVerificationTimeseriesQuery(interval, whereClause); + + return ch.query({ + query, + params: verificationTimeseriesParams, + schema: verificationTimeseriesDataPoint, + })(args); + }; +} + +export const getHourlyVerificationTimeseries = createVerificationTimeseriesQuerier(INTERVALS.hour); +export const getTwoHourlyVerificationTimeseries = createVerificationTimeseriesQuerier( + INTERVALS.twoHours, +); +export const getFourHourlyVerificationTimeseries = createVerificationTimeseriesQuerier( + INTERVALS.fourHours, +); +export const getSixHourlyVerificationTimeseries = createVerificationTimeseriesQuerier( + INTERVALS.sixHours, +); +export const getTwelveHourlyVerificationTimeseries = createVerificationTimeseriesQuerier( + INTERVALS.twelveHours, +); +export const getDailyVerificationTimeseries = createVerificationTimeseriesQuerier(INTERVALS.day); +export const getThreeDayVerificationTimeseries = createVerificationTimeseriesQuerier( + INTERVALS.threeDays, +); +export const getWeeklyVerificationTimeseries = createVerificationTimeseriesQuerier(INTERVALS.week); +export const getMonthlyVerificationTimeseries = createVerificationTimeseriesQuerier( + INTERVALS.month, +); diff --git a/internal/icons/src/icons/chevron-down.tsx b/internal/icons/src/icons/chevron-down.tsx new file mode 100644 index 0000000000..a20ee7192d --- /dev/null +++ b/internal/icons/src/icons/chevron-down.tsx @@ -0,0 +1,34 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import { type IconProps, sizeMap } from "../props"; + +export const ChevronDown: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize } = sizeMap[size]; + + return ( + + + + + + ); +}; diff --git a/internal/icons/src/icons/chevron-up.tsx b/internal/icons/src/icons/chevron-up.tsx new file mode 100644 index 0000000000..39625cb786 --- /dev/null +++ b/internal/icons/src/icons/chevron-up.tsx @@ -0,0 +1,34 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import { type IconProps, sizeMap } from "../props"; + +export const ChevronUp: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize } = sizeMap[size]; + + return ( + + + + + + ); +}; diff --git a/internal/icons/src/icons/key.tsx b/internal/icons/src/icons/key.tsx new file mode 100644 index 0000000000..2167fc0019 --- /dev/null +++ b/internal/icons/src/icons/key.tsx @@ -0,0 +1,39 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import { type IconProps, sizeMap } from "../props"; + +export const Key: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + + return ( + + + + + + + ); +}; diff --git a/internal/icons/src/index.ts b/internal/icons/src/index.ts index f33770294d..9885869c5f 100644 --- a/internal/icons/src/index.ts +++ b/internal/icons/src/index.ts @@ -47,3 +47,6 @@ export * from "./icons/progress-bar"; export * from "./icons/caret-up"; export * from "./icons/caret-down"; export * from "./icons/caret-expand-y"; +export * from "./icons/key"; +export * from "./icons/chevron-up"; +export * from "./icons/chevron-down";