Skip to content

Commit 996c9a9

Browse files
feat: key details (#3242)
* chore: remove key details * refactor: add v2 for easier dev * chore: cleanup old key detail related stuff * feat: add initial key details * fix: style and color fixes * feat: add chart of key details * fix: use our new controls component * chore: run fmt * feat: Add key details drawer * feat: add proper details error checking * feat: add minute granularity * chore: fmt * fix: add settings to key details * feat: add live query * feat: add loading animation overview * fix: ring colors * feat: add permisssions * fix: permissions * feat: add credits * feat: add ai saerch * fix: broken test * fix: coderabit issues * fix: remove unused param * fix: analytics return type * fix: test case * fix: revalidation issue * fix: separator for remaining credit * fix: coderabbit issues * fix: import path
1 parent 6180034 commit 996c9a9

File tree

77 files changed

+3425
-2429
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+3425
-2429
lines changed

apps/api/src/pkg/analytics.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@ import { z } from "zod";
44
export class Analytics {
55
private readonly clickhouse: ClickHouse;
66

7-
constructor(opts: {
8-
clickhouseUrl: string;
9-
clickhouseInsertUrl?: string;
10-
}) {
7+
constructor(opts: { clickhouseUrl: string; clickhouseInsertUrl?: string }) {
118
if (opts.clickhouseInsertUrl) {
129
this.clickhouse = new ClickHouse({
1310
insertUrl: opts.clickhouseInsertUrl,
@@ -44,7 +41,7 @@ export class Analytics {
4441
}
4542

4643
public get getVerificationsDaily() {
47-
return this.clickhouse.verifications.perDay;
44+
return this.clickhouse.verifications.timeseries.perDay;
4845
}
4946

5047
/**

apps/api/src/routes/v1_keys_getVerifications.ts

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import type { App } from "@/pkg/hono/app";
22
import { createRoute, z } from "@hono/zod-openapi";
33

44
import { rootKeyAuth } from "@/pkg/auth/root_key";
5+
import type { CacheNamespaces } from "@/pkg/cache/namespaces";
56
import { UnkeyApiError, openApiErrorResponses } from "@/pkg/errors";
7+
import type { VerificationTimeseriesDataPoint } from "@unkey/clickhouse/src/verifications";
68
import { buildUnkeyQuery, type unkeyPermissionValidation } from "@unkey/rbac";
79

810
const route = createRoute({
@@ -222,23 +224,33 @@ export const registerV1KeysGetVerifications = (app: App) =>
222224
const verificationsFromAllKeys = await Promise.all(
223225
ids.map(({ keyId, keySpaceId }) => {
224226
return cache.verificationsByKeyId.swr(`${keyId}:${start}-${end}`, async () => {
225-
const res = await analytics.getVerificationsDaily({
226-
workspaceId: authorizedWorkspaceId,
227-
keySpaceId: keySpaceId,
228-
keyId: keyId,
229-
start: start ? start : now - 24 * 60 * 60 * 1000,
230-
end: end ? end : now,
231-
});
232-
if (res.err) {
233-
throw new Error(res.err.message);
234-
}
235-
return res.val;
227+
const res = await analytics
228+
.getVerificationsDaily({
229+
workspaceId: authorizedWorkspaceId,
230+
keyspaceId: keySpaceId,
231+
keyId: keyId,
232+
startTime: start ? start : now - 24 * 60 * 60 * 1000,
233+
endTime: end ? end : now,
234+
identities: null,
235+
keyIds: null,
236+
outcomes: null,
237+
names: null,
238+
})
239+
.catch((err) => {
240+
throw new Error(err.message);
241+
});
242+
243+
return transformData(res);
236244
});
237245
}),
238246
);
239247

240248
const verifications: {
241-
[time: number]: { success: number; rateLimited: number; usageExceeded: number };
249+
[time: number]: {
250+
success: number;
251+
rateLimited: number;
252+
usageExceeded: number;
253+
};
242254
} = {};
243255
for (const dataPoint of verificationsFromAllKeys) {
244256
if (dataPoint.err) {
@@ -247,7 +259,11 @@ export const registerV1KeysGetVerifications = (app: App) =>
247259
}
248260
for (const d of dataPoint.val!) {
249261
if (!verifications[d.time]) {
250-
verifications[d.time] = { success: 0, rateLimited: 0, usageExceeded: 0 };
262+
verifications[d.time] = {
263+
success: 0,
264+
rateLimited: 0,
265+
usageExceeded: 0,
266+
};
251267
}
252268
switch (d.outcome) {
253269
case "VALID":
@@ -283,3 +299,38 @@ export const registerV1KeysGetVerifications = (app: App) =>
283299
),
284300
});
285301
});
302+
303+
function transformData(
304+
data: VerificationTimeseriesDataPoint[] | undefined,
305+
): CacheNamespaces["verificationsByKeyId"] {
306+
if (!data || !data.length) {
307+
return [];
308+
}
309+
310+
const verificationsByKeyId = data.flatMap((item) => {
311+
const time = item.x;
312+
const outcomes: Array<{ outcome: string; count: number }> = [
313+
{ outcome: "valid", count: item.y.valid_count },
314+
{ outcome: "rate_limited", count: item.y.rate_limited_count },
315+
{
316+
outcome: "insufficient_permissions",
317+
count: item.y.insufficient_permissions_count,
318+
},
319+
{ outcome: "forbidden", count: item.y.forbidden_count },
320+
{ outcome: "disabled", count: item.y.disabled_count },
321+
{ outcome: "expired", count: item.y.expired_count },
322+
{ outcome: "usage_exceeded", count: item.y.usage_exceeded_count },
323+
];
324+
325+
// Only include outcomes with non-zero counts
326+
return outcomes
327+
.filter((outcome) => outcome.count > 0)
328+
.map((outcome) => ({
329+
time,
330+
count: outcome.count,
331+
outcome: outcome.outcome,
332+
}));
333+
});
334+
335+
return verificationsByKeyId;
336+
}

apps/dashboard/app/(app)/apis/[apiId]/_overview/components/charts/bar-chart/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export function createOutcomeChartConfig(includedOutcomes?: string[]) {
3939

4040
// Convert to the format used in our timeseries data (snake_case)
4141
const key = outcome.toLowerCase();
42-
const colorClass = OUTCOME_BACKGROUND_COLORS[outcome] || "bg-accent-4";
42+
const colorClass = OUTCOME_BACKGROUND_COLORS[outcome];
4343

4444
config[key] = {
4545
label: formatOutcomeName(outcome),
Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,24 @@
1+
import {
2+
ControlsContainer,
3+
ControlsLeft,
4+
ControlsRight,
5+
} from "@/components/logs/controls-container";
16
import { LogsDateTime } from "./components/logs-datetime";
27
import { LogsFilters } from "./components/logs-filters";
38
import { LogsRefresh } from "./components/logs-refresh";
49
import { LogsSearch } from "./components/logs-search";
510

611
export function KeysOverviewLogsControls({ apiId }: { apiId: string }) {
712
return (
8-
<div className="flex flex-col border-b border-gray-4 w-full">
9-
<div className="px-3 py-1 w-full justify-between flex items-center">
10-
<div className="flex gap-2 w-full">
11-
<div className="flex flex-1 gap-2 items-center">
12-
<LogsSearch apiId={apiId} />
13-
</div>
14-
<div className="flex gap-2 md:w-full max-md:justify-end">
15-
<div className="flex gap-2 items-center">
16-
<LogsFilters />
17-
</div>
18-
<div className="flex gap-2 items-center">
19-
<LogsDateTime />
20-
</div>
21-
</div>
22-
</div>
23-
24-
<div className="flex gap-2 max-md:hidden">
25-
<LogsRefresh />
26-
</div>
27-
</div>
28-
</div>
13+
<ControlsContainer>
14+
<ControlsLeft>
15+
<LogsSearch apiId={apiId} />
16+
<LogsFilters />
17+
<LogsDateTime />
18+
</ControlsLeft>
19+
<ControlsRight>
20+
<LogsRefresh />
21+
</ControlsRight>
22+
</ControlsContainer>
2923
);
3024
}

apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/components/override-indicator.tsx

Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,18 @@
11
"use client";
2+
import { RatelimitOverviewTooltip } from "@/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/ratelimit-overview-tooltip";
23
import { cn } from "@/lib/utils";
34
import type { KeysOverviewLog } from "@unkey/clickhouse/src/keys/keys";
45
import { TriangleWarning2 } from "@unkey/icons";
5-
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@unkey/ui";
6+
import { AnimatedLoadingSpinner } from "@unkey/ui";
67
import Link from "next/link";
8+
import { useRouter } from "next/navigation";
9+
import { useCallback, useState } from "react";
710
import { getErrorPercentage, getErrorSeverity } from "../utils/calculate-blocked-percentage";
811

9-
export const KeyTooltip = ({
10-
children,
11-
content,
12-
}: {
13-
children: React.ReactNode;
14-
content: React.ReactNode;
15-
}) => {
16-
return (
17-
<TooltipProvider>
18-
<Tooltip>
19-
<TooltipTrigger asChild>{children}</TooltipTrigger>
20-
<TooltipContent
21-
className="bg-gray-12 text-gray-1 px-3 py-2 border border-accent-6 shadow-md font-medium text-xs"
22-
side="right"
23-
>
24-
{content}
25-
</TooltipContent>
26-
</Tooltip>
27-
</TooltipProvider>
28-
);
29-
};
30-
3112
type KeyIdentifierColumnProps = {
3213
log: KeysOverviewLog;
3314
apiId: string;
15+
onNavigate?: () => void;
3416
};
3517

3618
// Get warning icon based on error severity
@@ -61,26 +43,48 @@ const getWarningMessage = (severity: string, errorRate: number) => {
6143
}
6244
};
6345

64-
export const KeyIdentifierColumn = ({ log, apiId }: KeyIdentifierColumnProps) => {
46+
export const KeyIdentifierColumn = ({ log, apiId, onNavigate }: KeyIdentifierColumnProps) => {
47+
const router = useRouter();
6548
const errorPercentage = getErrorPercentage(log);
6649
const severity = getErrorSeverity(log);
6750
const hasErrors = severity !== "none";
6851

52+
const [isNavigating, setIsNavigating] = useState(false);
53+
54+
const handleLinkClick = useCallback(
55+
(e: React.MouseEvent) => {
56+
e.preventDefault();
57+
setIsNavigating(true);
58+
59+
onNavigate?.();
60+
61+
router.push(`/apis/${apiId}/keys/${log.key_details?.key_auth_id}/${log.key_id}`);
62+
},
63+
[apiId, log.key_id, log.key_details?.key_auth_id, onNavigate, router.push],
64+
);
65+
6966
return (
7067
<div className="flex gap-6 items-center pl-2">
71-
<KeyTooltip
72-
content={<p className="text-sm">{getWarningMessage(severity, errorPercentage)}</p>}
68+
<RatelimitOverviewTooltip
69+
content={<p className="text-xs">{getWarningMessage(severity, errorPercentage)}</p>}
7370
>
74-
<div className={cn("transition-opacity", hasErrors ? "opacity-100" : "opacity-0")}>
75-
{getWarningIcon(severity)}
76-
</div>
77-
</KeyTooltip>
71+
{isNavigating ? (
72+
<div className="size-[12px] items-center justify-center flex">
73+
<AnimatedLoadingSpinner />
74+
</div>
75+
) : (
76+
<div className={cn("transition-opacity", hasErrors ? "opacity-100" : "opacity-0")}>
77+
{getWarningIcon(severity)}
78+
</div>
79+
)}
80+
</RatelimitOverviewTooltip>
7881
<Link
7982
title={`View details for ${log.key_id}`}
8083
className="font-mono group-hover:underline decoration-dotted"
8184
href={`/apis/${apiId}/keys/${log.key_details?.key_auth_id}/${log.key_id}`}
85+
onClick={handleLinkClick}
8286
>
83-
<div className="font-mono font-medium truncate">
87+
<div className="font-mono font-medium truncate flex items-center">
8488
{log.key_id.substring(0, 8)}...
8589
{log.key_id.substring(log.key_id.length - 4)}
8690
</div>

apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/logs-table.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ export const KeysOverviewLogsTable = ({ apiId, setSelectedLog, log: selectedLog
3636
header: "ID",
3737
width: "15%",
3838
headerClassName: "pl-11",
39-
render: (log) => <KeyIdentifierColumn log={log} apiId={apiId} />,
39+
render: (log) => (
40+
<KeyIdentifierColumn log={log} apiId={apiId} onNavigate={() => setSelectedLog(null)} />
41+
),
4042
},
4143
{
4244
key: "name",

apps/dashboard/app/(app)/apis/[apiId]/actions.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { getAuth } from "@/lib/auth";
22
import { and, db, eq, isNull } from "@/lib/db";
3+
import { getAllKeys } from "@/lib/trpc/routers/api/keys/query-api-keys/get-all-keys";
4+
import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema";
35
import { apis } from "@unkey/db/src/schema";
46
import { notFound } from "next/navigation";
57

@@ -64,3 +66,26 @@ export const fetchApiAndWorkspaceDataFromDb = async (apiId: string): Promise<Api
6466
workspaceApis,
6567
};
6668
};
69+
70+
export async function getKeyDetails(
71+
keyId: string,
72+
keyspaceId: string,
73+
workspaceId: string,
74+
): Promise<KeyDetails | null> {
75+
const result = await getAllKeys({
76+
keyspaceId,
77+
workspaceId,
78+
filters: {
79+
keyIds: [{ operator: "is", value: keyId }],
80+
},
81+
limit: 1,
82+
});
83+
84+
// If no keys found, return null
85+
if (result.keys.length === 0) {
86+
return null;
87+
}
88+
89+
// Return the first (and only) key
90+
return result.keys[0];
91+
}

0 commit comments

Comments
 (0)