mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-14 08:19:51 +02:00
send, process, store, display virtual api key ai information in usage and sessions
This commit is contained in:
@@ -12,6 +12,7 @@ import { useTranslations } from "next-intl";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
@@ -50,6 +51,7 @@ export default function AiSessionLogsPage() {
|
||||
capability?: string;
|
||||
resourceId?: string;
|
||||
actor?: string;
|
||||
virtualApiKeyId?: string;
|
||||
model?: string;
|
||||
isStream?: string;
|
||||
}>({
|
||||
@@ -57,6 +59,7 @@ export default function AiSessionLogsPage() {
|
||||
capability: searchParams.get("capability") || undefined,
|
||||
resourceId: searchParams.get("resourceId") || undefined,
|
||||
actor: searchParams.get("actor") || undefined,
|
||||
virtualApiKeyId: searchParams.get("virtualApiKeyId") || undefined,
|
||||
model: searchParams.get("model") || undefined,
|
||||
isStream: searchParams.get("isStream") || undefined
|
||||
});
|
||||
@@ -135,6 +138,7 @@ export default function AiSessionLogsPage() {
|
||||
providers: [],
|
||||
resources: [],
|
||||
users: [],
|
||||
virtualApiKeys: [],
|
||||
models: []
|
||||
};
|
||||
|
||||
@@ -358,7 +362,9 @@ export default function AiSessionLogsPage() {
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.resourceNiceId) {
|
||||
return <span className="text-xs text-muted-foreground">-</span>;
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
@@ -453,13 +459,67 @@ export default function AiSessionLogsPage() {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "virtualApiKeyId",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ColumnFilterButton
|
||||
options={filterAttributes.virtualApiKeys.map(
|
||||
(key) => ({
|
||||
value: key.id,
|
||||
label:
|
||||
key.name ??
|
||||
(key.lastChars
|
||||
? formatVirtualApiKeyPreview(
|
||||
key.id,
|
||||
key.lastChars
|
||||
)
|
||||
: key.id)
|
||||
})
|
||||
)}
|
||||
selectedValue={filters.virtualApiKeyId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("virtualApiKeyId", value)
|
||||
}
|
||||
label={t("virtualApiKey")}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.virtualApiKeyId) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate">
|
||||
{row.original.virtualApiKeyName ??
|
||||
t("aiUsageUnnamedVirtualApiKey")}
|
||||
</span>
|
||||
{row.original.virtualApiKeyLastChars && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{formatVirtualApiKeyPreview(
|
||||
row.original.virtualApiKeyId,
|
||||
row.original.virtualApiKeyLastChars
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const renderExpandedRow = (row: any) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 text-xs">
|
||||
<div>
|
||||
<strong>{t("aiSessionId")}</strong>
|
||||
<p className="text-muted-foreground mt-1 break-all">
|
||||
@@ -490,6 +550,30 @@ export default function AiSessionLogsPage() {
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("virtualApiKey")}</strong>
|
||||
<p className="text-muted-foreground mt-1 break-all">
|
||||
{row.virtualApiKeyId ? (
|
||||
<>
|
||||
{row.virtualApiKeyName ??
|
||||
t("aiUsageUnnamedVirtualApiKey")}
|
||||
{row.virtualApiKeyLastChars && (
|
||||
<>
|
||||
{" "}
|
||||
(
|
||||
{formatVirtualApiKeyPreview(
|
||||
row.virtualApiKeyId,
|
||||
row.virtualApiKeyLastChars
|
||||
)}
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
t("noVirtualApiKey")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{row.usage && (
|
||||
<div>
|
||||
@@ -599,15 +683,25 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
|
||||
];
|
||||
const actors = ["alice@example.com", "bob@example.com", null];
|
||||
const models = ["gpt-4o", "claude-sonnet-5", "gemini-2.5-pro"];
|
||||
const virtualApiKeysSample = [
|
||||
{ id: "vak00001", name: "CI pipeline", lastChars: "ab12" },
|
||||
{ id: "vak00002", name: null, lastChars: "cd34" },
|
||||
null
|
||||
];
|
||||
|
||||
const now = Date.now();
|
||||
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const provider = providers[Math.floor(Math.random() * providers.length)];
|
||||
const provider =
|
||||
providers[Math.floor(Math.random() * providers.length)];
|
||||
const resource =
|
||||
resourcesSample[Math.floor(Math.random() * resourcesSample.length)];
|
||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||
const virtualApiKey =
|
||||
virtualApiKeysSample[
|
||||
Math.floor(Math.random() * virtualApiKeysSample.length)
|
||||
];
|
||||
|
||||
return {
|
||||
id: i,
|
||||
@@ -625,6 +719,9 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
|
||||
resourceType: "public",
|
||||
userId: actor ? `user-${i}` : null,
|
||||
userEmail: actor,
|
||||
virtualApiKeyId: virtualApiKey?.id ?? null,
|
||||
virtualApiKeyName: virtualApiKey?.name ?? null,
|
||||
virtualApiKeyLastChars: virtualApiKey?.lastChars ?? null,
|
||||
requestedModel: models[Math.floor(Math.random() * models.length)],
|
||||
isStream: Math.random() > 0.5,
|
||||
requestBody: null,
|
||||
|
||||
@@ -29,6 +29,8 @@ import { ProvidersTab } from "./ai-usage-analytics/ProvidersTab";
|
||||
import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab";
|
||||
import { RolesTab } from "./ai-usage-analytics/RolesTab";
|
||||
import { UsersTab } from "./ai-usage-analytics/UsersTab";
|
||||
import { VirtualApiKeysTab } from "./ai-usage-analytics/VirtualApiKeysTab";
|
||||
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
|
||||
|
||||
export type AiUsageAnalyticsDataProps = {
|
||||
orgId: string;
|
||||
@@ -140,13 +142,20 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
|
||||
value: u.id,
|
||||
label: u.email ?? u.id
|
||||
}));
|
||||
const virtualApiKeyOptions = (filterOptions?.virtualApiKeys ?? []).map(
|
||||
(k) => ({
|
||||
value: k.id,
|
||||
label: k.name ?? formatVirtualApiKeyPreview(k.id, k.lastChars)
|
||||
})
|
||||
);
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ title: t("aiUsageTabOverview"), href: "#" },
|
||||
{ title: t("aiUsageTabProviders"), href: "#" },
|
||||
{ title: t("aiUsageTabResources"), href: "#" },
|
||||
{ title: t("aiUsageRolesTab"), href: "#" },
|
||||
{ title: t("aiUsageUsersTab"), href: "#" }
|
||||
{ title: t("aiUsageUsersTab"), href: "#" },
|
||||
{ title: t("aiUsageVirtualApiKeysTab"), href: "#" }
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -180,7 +189,9 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
|
||||
value={filters.providerId?.toString()}
|
||||
options={providerOptions}
|
||||
placeholder={t("aiUsageFilterAllProviders")}
|
||||
onValueChange={(v) => setFilter("providerId", v)}
|
||||
onValueChange={(v) =>
|
||||
setFilter("providerId", v)
|
||||
}
|
||||
/>
|
||||
<FilterSelect
|
||||
id="model"
|
||||
@@ -196,7 +207,9 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
|
||||
value={filters.resourceId?.toString()}
|
||||
options={resourceOptions}
|
||||
placeholder={t("aiUsageFilterAllResources")}
|
||||
onValueChange={(v) => setFilter("resourceId", v)}
|
||||
onValueChange={(v) =>
|
||||
setFilter("resourceId", v)
|
||||
}
|
||||
/>
|
||||
<FilterSelect
|
||||
id="roleId"
|
||||
@@ -214,6 +227,18 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
|
||||
placeholder={t("aiUsageFilterAllUsers")}
|
||||
onValueChange={(v) => setFilter("userId", v)}
|
||||
/>
|
||||
<FilterSelect
|
||||
id="virtualApiKeyId"
|
||||
label={t("aiUsageFilterVirtualApiKey")}
|
||||
value={filters.virtualApiKeyId}
|
||||
options={virtualApiKeyOptions}
|
||||
placeholder={t(
|
||||
"aiUsageFilterAllVirtualApiKeys"
|
||||
)}
|
||||
onValueChange={(v) =>
|
||||
setFilter("virtualApiKeyId", v)
|
||||
}
|
||||
/>
|
||||
|
||||
{!isEmptySearchParams && (
|
||||
<Button
|
||||
@@ -259,6 +284,7 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
|
||||
<ResourcesTab orgId={props.orgId} filters={filters} />
|
||||
<RolesTab orgId={props.orgId} filters={filters} />
|
||||
<UsersTab orgId={props.orgId} filters={filters} />
|
||||
<VirtualApiKeysTab orgId={props.orgId} filters={filters} />
|
||||
</HorizontalTabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiUsageAnalyticsQueries } from "@app/lib/queries";
|
||||
import type { AiUsageAnalyticsFilters } from "@app/lib/queries";
|
||||
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import { ToggleableTrendChart } from "./ToggleableTrendChart";
|
||||
import { TopEntitiesList, type TopEntity } from "./TopEntitiesList";
|
||||
import { buildSeriesFromData, formatCost } from "./shared";
|
||||
|
||||
type VirtualApiKeysTabProps = {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
};
|
||||
|
||||
const UNKNOWN_VIRTUAL_API_KEY_KEY = "unknown";
|
||||
|
||||
export function VirtualApiKeysTab(props: VirtualApiKeysTabProps) {
|
||||
const t = useTranslations();
|
||||
const { data, isLoading } = useQuery(
|
||||
aiUsageAnalyticsQueries.virtualApiKeys({
|
||||
orgId: props.orgId,
|
||||
filters: props.filters
|
||||
})
|
||||
);
|
||||
|
||||
const labelByKey = new Map<string, string>();
|
||||
for (const k of data?.topVirtualApiKeys ?? []) {
|
||||
if (k.virtualApiKeyId) {
|
||||
labelByKey.set(k.virtualApiKeyId, k.name ?? k.virtualApiKeyId);
|
||||
}
|
||||
}
|
||||
const virtualApiKeyLabelFor = (key: string) =>
|
||||
key === UNKNOWN_VIRTUAL_API_KEY_KEY
|
||||
? t("aiUsageUnknownVirtualApiKey")
|
||||
: (labelByKey.get(key) ?? key);
|
||||
|
||||
const virtualApiKeyCostSeries = buildSeriesFromData(
|
||||
data?.virtualApiKeyCostPerDay ?? [],
|
||||
virtualApiKeyLabelFor,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
const virtualApiKeyTokensSeries = buildSeriesFromData(
|
||||
data?.virtualApiKeyTokensPerDay ?? [],
|
||||
virtualApiKeyLabelFor,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
|
||||
const topVirtualApiKeys: TopEntity[] = (data?.topVirtualApiKeys ?? []).map(
|
||||
(k) => ({
|
||||
key: k.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY,
|
||||
label: k.virtualApiKeyId
|
||||
? (k.name ?? t("aiUsageUnnamedVirtualApiKey"))
|
||||
: t("aiUsageUnknownVirtualApiKey"),
|
||||
sublabel:
|
||||
k.virtualApiKeyId && k.lastChars
|
||||
? formatVirtualApiKeyPreview(k.virtualApiKeyId, k.lastChars)
|
||||
: undefined,
|
||||
requests: k.requests,
|
||||
totalTokens: k.totalTokens,
|
||||
costUsd: k.costUsd
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">
|
||||
{t("aiUsageTopVirtualApiKeys")}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopEntitiesList
|
||||
entities={topVirtualApiKeys}
|
||||
isLoading={isLoading}
|
||||
nameColumnLabel={t("aiUsageFilterVirtualApiKey")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageVirtualApiKeyCost")}
|
||||
data={data?.virtualApiKeyCostPerDay ?? []}
|
||||
series={virtualApiKeyCostSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageVirtualApiKeyTokenUsage")}
|
||||
data={data?.virtualApiKeyTokensPerDay ?? []}
|
||||
series={virtualApiKeyTokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+60
-6
@@ -12,7 +12,8 @@ import type {
|
||||
QueryAiUsageOverviewResponse,
|
||||
QueryAiUsageProvidersResponse,
|
||||
QueryAiUsageResourcesResponse,
|
||||
QueryAiUsageUsersRolesResponse
|
||||
QueryAiUsageUsersRolesResponse,
|
||||
QueryAiUsageVirtualApiKeysResponse
|
||||
} from "@server/routers/auditLogs";
|
||||
import type {
|
||||
QueryAccessAuditLogResponse,
|
||||
@@ -954,7 +955,8 @@ export const aiUsageAnalyticsFiltersSchema = z.object({
|
||||
model: z.string().optional().catch(undefined),
|
||||
resourceId: z.coerce.number().optional().catch(undefined),
|
||||
roleId: z.coerce.number().optional().catch(undefined),
|
||||
userId: z.string().optional().catch(undefined)
|
||||
userId: z.string().optional().catch(undefined),
|
||||
virtualApiKeyId: z.string().optional().catch(undefined)
|
||||
});
|
||||
|
||||
export type AiUsageAnalyticsFilters = z.output<
|
||||
@@ -1086,6 +1088,7 @@ export const aiSessionLogsFiltersSchema = z.object({
|
||||
capability: z.string().optional().catch(undefined),
|
||||
resourceId: z.string().optional().catch(undefined),
|
||||
actor: z.string().optional().catch(undefined),
|
||||
virtualApiKeyId: z.string().optional().catch(undefined),
|
||||
model: z.string().optional().catch(undefined),
|
||||
isStream: z.string().optional().catch(undefined)
|
||||
});
|
||||
@@ -1284,7 +1287,12 @@ export const aiUsageAnalyticsQueries = {
|
||||
filters: Pick<AiUsageAnalyticsFilters, "timeStart" | "timeEnd">;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["AI_USAGE_ANALYTICS", orgId, "FILTERS", filters] as const,
|
||||
queryKey: [
|
||||
"AI_USAGE_ANALYTICS",
|
||||
orgId,
|
||||
"FILTERS",
|
||||
filters
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryAiUsageFilterOptionsResponse>
|
||||
@@ -1304,7 +1312,12 @@ export const aiUsageAnalyticsQueries = {
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["AI_USAGE_ANALYTICS", orgId, "OVERVIEW", filters] as const,
|
||||
queryKey: [
|
||||
"AI_USAGE_ANALYTICS",
|
||||
orgId,
|
||||
"OVERVIEW",
|
||||
filters
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryAiUsageOverviewResponse>
|
||||
@@ -1330,7 +1343,12 @@ export const aiUsageAnalyticsQueries = {
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["AI_USAGE_ANALYTICS", orgId, "PROVIDERS", filters] as const,
|
||||
queryKey: [
|
||||
"AI_USAGE_ANALYTICS",
|
||||
orgId,
|
||||
"PROVIDERS",
|
||||
filters
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryAiUsageProvidersResponse>
|
||||
@@ -1356,7 +1374,12 @@ export const aiUsageAnalyticsQueries = {
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["AI_USAGE_ANALYTICS", orgId, "RESOURCES", filters] as const,
|
||||
queryKey: [
|
||||
"AI_USAGE_ANALYTICS",
|
||||
orgId,
|
||||
"RESOURCES",
|
||||
filters
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryAiUsageResourcesResponse>
|
||||
@@ -1403,6 +1426,37 @@ export const aiUsageAnalyticsQueries = {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
|
||||
virtualApiKeys: ({
|
||||
orgId,
|
||||
filters
|
||||
}: {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
"AI_USAGE_ANALYTICS",
|
||||
orgId,
|
||||
"VIRTUAL_API_KEYS",
|
||||
filters
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryAiUsageVirtualApiKeysResponse>
|
||||
>(`/org/${orgId}/logs/ai/usage/virtual-api-keys`, {
|
||||
params: filters,
|
||||
signal
|
||||
});
|
||||
return res.data.data;
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
if (query.state.data) {
|
||||
return durationToMs(30, "seconds");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user