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 (
+ <>
+
+ >
+ );
+};
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 (
-
-
- {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 (
- <>
-
- >
- );
-};
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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{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 (
+
+ );
+};
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";