mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-14 16:30:15 +02:00
basic ai analytics created
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { AiUsageAnalyticsData } from "@app/components/AiUsageAnalyticsData";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AI Usage Analytics"
|
||||
};
|
||||
|
||||
export interface AiUsageAnalyticsPageProps {
|
||||
params: Promise<{ orgId: string }>;
|
||||
}
|
||||
|
||||
export default async function AiUsageAnalyticsPage(
|
||||
props: AiUsageAnalyticsPageProps
|
||||
) {
|
||||
const orgId = (await props.params).orgId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="AI Usage Analytics"
|
||||
description="Analyze AI gateway cost, token usage, and activity across providers, resources, roles, and users"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<AiUsageAnalyticsData orgId={orgId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Building2,
|
||||
Cable,
|
||||
ChartLine,
|
||||
Coins,
|
||||
Combine,
|
||||
CreditCard,
|
||||
Fingerprint,
|
||||
@@ -233,6 +234,11 @@ export const orgNavSections = (
|
||||
href: "/{orgId}/settings/logs/ai",
|
||||
icon: <Bot className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarLogsAiUsage",
|
||||
href: "/{orgId}/settings/logs/ai-usage",
|
||||
icon: <Coins className="size-4 flex-none" />
|
||||
},
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
@@ -532,6 +538,11 @@ export const commandBarNavSections = (
|
||||
href: "/{orgId}/settings/logs/ai",
|
||||
icon: <Bot className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandLogsAiUsage",
|
||||
href: "/{orgId}/settings/logs/ai-usage",
|
||||
icon: <Coins className="size-4 flex-none" />
|
||||
},
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
aiUsageAnalyticsFiltersSchema,
|
||||
aiUsageAnalyticsQueries,
|
||||
type AiUsageAnalyticsFilters
|
||||
} from "@app/lib/queries";
|
||||
import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RefreshCw, XIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { DateRangePicker, type DateTimeValue } from "./DateTimePicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardHeader } from "./ui/card";
|
||||
import { Label } from "./ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "./ui/select";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { HorizontalTabs, type TabItem } from "./HorizontalTabs";
|
||||
import { OverviewTab } from "./ai-usage-analytics/OverviewTab";
|
||||
import { ProvidersTab } from "./ai-usage-analytics/ProvidersTab";
|
||||
import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab";
|
||||
import { UsersRolesTab } from "./ai-usage-analytics/UsersRolesTab";
|
||||
|
||||
export type AiUsageAnalyticsDataProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
const AI_USAGE_ANALYTICS_QUERY_PREFIX = ["AI_USAGE_ANALYTICS"];
|
||||
|
||||
export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const path = usePathname();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const filters = aiUsageAnalyticsFiltersSchema.parse(
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const isEmptySearchParams = Object.values(filters).every(
|
||||
(v) => v === undefined
|
||||
);
|
||||
|
||||
const dateRange = {
|
||||
startDate: filters.timeStart
|
||||
? new Date(filters.timeStart)
|
||||
: getSevenDaysAgo(),
|
||||
endDate: filters.timeEnd ? new Date(filters.timeEnd) : new Date()
|
||||
};
|
||||
|
||||
const { data: filterOptions } = useQuery(
|
||||
aiUsageAnalyticsQueries.filterOptions({
|
||||
orgId: props.orgId,
|
||||
filters: {
|
||||
timeStart: filters.timeStart,
|
||||
timeEnd: filters.timeEnd
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const isFetching =
|
||||
useIsFetching({
|
||||
queryKey: [...AI_USAGE_ANALYTICS_QUERY_PREFIX, props.orgId]
|
||||
}) > 0;
|
||||
|
||||
function setFilter(key: keyof AiUsageAnalyticsFilters, value?: string) {
|
||||
const newSearch = new URLSearchParams(searchParams);
|
||||
newSearch.delete(key);
|
||||
if (value !== undefined) {
|
||||
newSearch.set(key, value);
|
||||
}
|
||||
router.replace(`${path}?${newSearch.toString()}`);
|
||||
}
|
||||
|
||||
function handleTimeRangeUpdate(start: DateTimeValue, end: DateTimeValue) {
|
||||
const newSearch = new URLSearchParams(searchParams);
|
||||
const timeRegex =
|
||||
/^(?<hours>\d{1,2})\:(?<minutes>\d{1,2})(\:(?<seconds>\d{1,2}))?$/;
|
||||
|
||||
if (start.date) {
|
||||
const startDate = new Date(start.date);
|
||||
if (start.time) {
|
||||
const time = timeRegex.exec(start.time);
|
||||
const groups = time?.groups ?? {};
|
||||
startDate.setHours(Number(groups.hours));
|
||||
startDate.setMinutes(Number(groups.minutes));
|
||||
if (groups.seconds) {
|
||||
startDate.setSeconds(Number(groups.seconds));
|
||||
}
|
||||
}
|
||||
newSearch.set("timeStart", startDate.toISOString());
|
||||
}
|
||||
if (end.date) {
|
||||
const endDate = new Date(end.date);
|
||||
if (end.time) {
|
||||
const time = timeRegex.exec(end.time);
|
||||
const groups = time?.groups ?? {};
|
||||
endDate.setHours(Number(groups.hours));
|
||||
endDate.setMinutes(Number(groups.minutes));
|
||||
if (groups.seconds) {
|
||||
endDate.setSeconds(Number(groups.seconds));
|
||||
}
|
||||
}
|
||||
newSearch.set("timeEnd", endDate.toISOString());
|
||||
}
|
||||
router.replace(`${path}?${newSearch.toString()}`);
|
||||
}
|
||||
|
||||
function getDateTime(date: Date) {
|
||||
return `${date.getHours()}:${date.getMinutes()}`;
|
||||
}
|
||||
|
||||
const providerOptions = (filterOptions?.providers ?? []).map((p) => ({
|
||||
value: String(p.id),
|
||||
label: p.name ?? `Provider #${p.id}`
|
||||
}));
|
||||
const modelOptions = (filterOptions?.models ?? []).map((m) => ({
|
||||
value: m,
|
||||
label: m
|
||||
}));
|
||||
const resourceOptions = (filterOptions?.resources ?? []).map((r) => ({
|
||||
value: String(r.id),
|
||||
label: r.name ?? `Resource #${r.id}`
|
||||
}));
|
||||
const roleOptions = (filterOptions?.roles ?? []).map((r) => ({
|
||||
value: String(r.id),
|
||||
label: r.name ?? `Role #${r.id}`
|
||||
}));
|
||||
const userOptions = (filterOptions?.users ?? []).map((u) => ({
|
||||
value: u.id,
|
||||
label: u.email ?? u.id
|
||||
}));
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ title: "Overview", href: "#" },
|
||||
{ title: "Provider usage", href: "#" },
|
||||
{ title: "Resources", href: "#" },
|
||||
{ title: "Users & roles", href: "#" }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4">
|
||||
<div className="flex flex-col lg:flex-row items-start lg:items-end w-full gap-2">
|
||||
<DateRangePicker
|
||||
startValue={{
|
||||
date: dateRange.startDate,
|
||||
time: dateRange.startDate
|
||||
? getDateTime(dateRange.startDate)
|
||||
: undefined
|
||||
}}
|
||||
endValue={{
|
||||
date: dateRange.endDate,
|
||||
time: dateRange.endDate
|
||||
? getDateTime(dateRange.endDate)
|
||||
: undefined
|
||||
}}
|
||||
onRangeChange={handleTimeRangeUpdate}
|
||||
className="flex-wrap gap-2"
|
||||
/>
|
||||
|
||||
<Separator className="w-px h-6 self-end relative bottom-1.5 hidden lg:block" />
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<FilterSelect
|
||||
id="providerId"
|
||||
label="Provider"
|
||||
value={filters.providerId?.toString()}
|
||||
options={providerOptions}
|
||||
placeholder="All providers"
|
||||
onValueChange={(v) => setFilter("providerId", v)}
|
||||
/>
|
||||
<FilterSelect
|
||||
id="model"
|
||||
label="Model"
|
||||
value={filters.model}
|
||||
options={modelOptions}
|
||||
placeholder="All models"
|
||||
onValueChange={(v) => setFilter("model", v)}
|
||||
/>
|
||||
<FilterSelect
|
||||
id="resourceId"
|
||||
label="Resource"
|
||||
value={filters.resourceId?.toString()}
|
||||
options={resourceOptions}
|
||||
placeholder="All resources"
|
||||
onValueChange={(v) => setFilter("resourceId", v)}
|
||||
/>
|
||||
<FilterSelect
|
||||
id="roleId"
|
||||
label="Role"
|
||||
value={filters.roleId?.toString()}
|
||||
options={roleOptions}
|
||||
placeholder="All roles"
|
||||
onValueChange={(v) => setFilter("roleId", v)}
|
||||
/>
|
||||
<FilterSelect
|
||||
id="userId"
|
||||
label="User"
|
||||
value={filters.userId}
|
||||
options={userOptions}
|
||||
placeholder="All users"
|
||||
onValueChange={(v) => setFilter("userId", v)}
|
||||
/>
|
||||
|
||||
{!isEmptySearchParams && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.replace(path)}
|
||||
className="gap-2"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
Reset filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
...AI_USAGE_ANALYTICS_QUERY_PREFIX,
|
||||
props.orgId
|
||||
]
|
||||
})
|
||||
}
|
||||
disabled={isFetching}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"size-4",
|
||||
isFetching && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<HorizontalTabs items={tabs} clientSide>
|
||||
<OverviewTab orgId={props.orgId} filters={filters} />
|
||||
<ProvidersTab orgId={props.orgId} filters={filters} />
|
||||
<ResourcesTab orgId={props.orgId} filters={filters} />
|
||||
<UsersRolesTab orgId={props.orgId} filters={filters} />
|
||||
</HorizontalTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FilterSelectProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
options: { value: string; label: string }[];
|
||||
placeholder: string;
|
||||
onValueChange: (value?: string) => void;
|
||||
};
|
||||
|
||||
function FilterSelect(props: FilterSelectProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 w-44">
|
||||
<Label htmlFor={props.id}>{props.label}</Label>
|
||||
<Select
|
||||
onValueChange={(newValue) =>
|
||||
props.onValueChange(
|
||||
newValue === "all" ? undefined : newValue
|
||||
)
|
||||
}
|
||||
value={props.value ?? "all"}
|
||||
>
|
||||
<SelectTrigger id={props.id} className="w-full">
|
||||
<SelectValue placeholder={props.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-full">
|
||||
<SelectItem value="all">{props.placeholder}</SelectItem>
|
||||
{props.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiUsageAnalyticsQueries } from "@app/lib/queries";
|
||||
import type { AiUsageAnalyticsFilters } from "@app/lib/queries";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { ToggleableTrendChart } from "./ToggleableTrendChart";
|
||||
import { TopEntitiesList, type TopEntity } from "./TopEntitiesList";
|
||||
import {
|
||||
SERIES_COLORS,
|
||||
buildSeriesFromData,
|
||||
compactNumberFormatter,
|
||||
formatCost
|
||||
} from "./shared";
|
||||
|
||||
type OverviewTabProps = {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
};
|
||||
|
||||
const TOKEN_TYPE_LABELS: Record<string, string> = {
|
||||
promptTokens: "Prompt",
|
||||
cacheReadTokens: "Cache read",
|
||||
cacheWriteTokens: "Cache write",
|
||||
completionTokens: "Completion",
|
||||
reasoningTokens: "Reasoning"
|
||||
};
|
||||
|
||||
export function OverviewTab(props: OverviewTabProps) {
|
||||
const { data, isLoading } = useQuery(
|
||||
aiUsageAnalyticsQueries.overview({
|
||||
orgId: props.orgId,
|
||||
filters: props.filters
|
||||
})
|
||||
);
|
||||
|
||||
const requestsSeries = [
|
||||
{ key: "requests", label: "Requests", color: SERIES_COLORS[0] }
|
||||
];
|
||||
const tokensSeries = Object.keys(TOKEN_TYPE_LABELS).map((key, i) => ({
|
||||
key,
|
||||
label: TOKEN_TYPE_LABELS[key],
|
||||
color: SERIES_COLORS[i % SERIES_COLORS.length]
|
||||
}));
|
||||
const costSeries = [
|
||||
{ key: "cost", label: "Cost", color: SERIES_COLORS[0] }
|
||||
];
|
||||
|
||||
const modelCostSeries = buildSeriesFromData(
|
||||
data?.modelCostPerDay ?? [],
|
||||
(key) => key
|
||||
);
|
||||
const modelTokensSeries = buildSeriesFromData(
|
||||
data?.modelTokensPerDay ?? [],
|
||||
(key) => key
|
||||
);
|
||||
|
||||
const topModels: TopEntity[] = (data?.topModels ?? []).map((m) => ({
|
||||
key: m.model,
|
||||
label: m.model,
|
||||
requests: m.requests,
|
||||
totalTokens: m.totalTokens,
|
||||
costUsd: m.costUsd
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Total requests</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{data
|
||||
? compactNumberFormatter.format(
|
||||
data.totalRequests
|
||||
)
|
||||
: "--"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Total tokens</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{data
|
||||
? compactNumberFormatter.format(
|
||||
data.totalTokens
|
||||
)
|
||||
: "--"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Total cost</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{data ? formatCost(data.totalCost) : "--"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Estimated</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{data
|
||||
? `${Math.round(data.estimatedPercent)}%`
|
||||
: "--"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Request volume</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ToggleableTrendChart
|
||||
data={data?.requestsPerDay ?? []}
|
||||
series={requestsSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Token usage</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ToggleableTrendChart
|
||||
data={data?.tokensPerDay ?? []}
|
||||
series={tokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Cost</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ToggleableTrendChart
|
||||
data={data?.costPerDay ?? []}
|
||||
series={costSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Model cost</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ToggleableTrendChart
|
||||
data={data?.modelCostPerDay ?? []}
|
||||
series={modelCostSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Model tokens</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ToggleableTrendChart
|
||||
data={data?.modelTokensPerDay ?? []}
|
||||
series={modelTokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Top models</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopEntitiesList
|
||||
entities={topModels}
|
||||
isLoading={isLoading}
|
||||
nameColumnLabel="Model"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiUsageAnalyticsQueries } from "@app/lib/queries";
|
||||
import type { AiUsageAnalyticsFilters } from "@app/lib/queries";
|
||||
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 ProvidersTabProps = {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
};
|
||||
|
||||
export function ProvidersTab(props: ProvidersTabProps) {
|
||||
const { data, isLoading } = useQuery(
|
||||
aiUsageAnalyticsQueries.providers({
|
||||
orgId: props.orgId,
|
||||
filters: props.filters
|
||||
})
|
||||
);
|
||||
|
||||
const nameByKey = new Map<string, string>();
|
||||
for (const p of data?.topProviders ?? []) {
|
||||
nameByKey.set(String(p.providerId), p.name ?? `Provider #${p.providerId}`);
|
||||
}
|
||||
const labelFor = (key: string) => nameByKey.get(key) ?? `Provider #${key}`;
|
||||
|
||||
const costSeries = buildSeriesFromData(
|
||||
data?.providerCostPerDay ?? [],
|
||||
labelFor
|
||||
);
|
||||
const tokensSeries = buildSeriesFromData(
|
||||
data?.providerTokensPerDay ?? [],
|
||||
labelFor
|
||||
);
|
||||
|
||||
const topProviders: TopEntity[] = (data?.topProviders ?? []).map((p) => ({
|
||||
key: String(p.providerId),
|
||||
label: p.name ?? `Provider #${p.providerId}`,
|
||||
requests: p.requests,
|
||||
totalTokens: p.totalTokens,
|
||||
costUsd: p.costUsd
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Top providers</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopEntitiesList
|
||||
entities={topProviders}
|
||||
isLoading={isLoading}
|
||||
nameColumnLabel="Provider"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Provider cost</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ToggleableTrendChart
|
||||
data={data?.providerCostPerDay ?? []}
|
||||
series={costSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Provider token usage</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ToggleableTrendChart
|
||||
data={data?.providerTokensPerDay ?? []}
|
||||
series={tokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiUsageAnalyticsQueries } from "@app/lib/queries";
|
||||
import type { AiUsageAnalyticsFilters } from "@app/lib/queries";
|
||||
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 ResourcesTabProps = {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
};
|
||||
|
||||
function resourceTypeLabel(type: "public" | "site" | null) {
|
||||
if (type === "public") return "Resource";
|
||||
if (type === "site") return "Site resource";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function ResourcesTab(props: ResourcesTabProps) {
|
||||
const { data, isLoading } = useQuery(
|
||||
aiUsageAnalyticsQueries.resources({
|
||||
orgId: props.orgId,
|
||||
filters: props.filters
|
||||
})
|
||||
);
|
||||
|
||||
const nameByKey = new Map<string, string>();
|
||||
for (const r of data?.topResources ?? []) {
|
||||
nameByKey.set(r.key, r.name ?? r.key);
|
||||
}
|
||||
const labelFor = (key: string) =>
|
||||
key === "none" ? "No resource" : (nameByKey.get(key) ?? key);
|
||||
|
||||
const costSeries = buildSeriesFromData(
|
||||
data?.resourceCostPerDay ?? [],
|
||||
labelFor
|
||||
);
|
||||
const tokensSeries = buildSeriesFromData(
|
||||
data?.resourceTokensPerDay ?? [],
|
||||
labelFor
|
||||
);
|
||||
|
||||
const topResources: TopEntity[] = (data?.topResources ?? []).map((r) => ({
|
||||
key: r.key,
|
||||
label: r.name ?? (r.key === "none" ? "No resource" : r.key),
|
||||
sublabel: resourceTypeLabel(r.type),
|
||||
requests: r.requests,
|
||||
totalTokens: r.totalTokens,
|
||||
costUsd: r.costUsd
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Top resources</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopEntitiesList
|
||||
entities={topResources}
|
||||
isLoading={isLoading}
|
||||
nameColumnLabel="Resource"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Resource cost</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ToggleableTrendChart
|
||||
data={data?.resourceCostPerDay ?? []}
|
||||
series={costSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Resource token usage</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ToggleableTrendChart
|
||||
data={data?.resourceTokensPerDay ?? []}
|
||||
series={tokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { BarChart3, LineChart as LineChartIcon, LoaderIcon } from "lucide-react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
XAxis,
|
||||
YAxis
|
||||
} from "recharts";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig
|
||||
} from "@app/components/ui/chart";
|
||||
|
||||
export type TrendSeries = {
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export interface TrendChartRow {
|
||||
day: string;
|
||||
[seriesKey: string]: number | string;
|
||||
}
|
||||
|
||||
type ToggleableTrendChartProps = {
|
||||
data: TrendChartRow[];
|
||||
series: TrendSeries[];
|
||||
isLoading?: boolean;
|
||||
valueFormatter?: (value: number) => string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const compactFormatter = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
notation: "compact",
|
||||
compactDisplay: "short"
|
||||
});
|
||||
|
||||
export function ToggleableTrendChart(props: ToggleableTrendChartProps) {
|
||||
const [chartType, setChartType] = useState<"bar" | "line">("bar");
|
||||
|
||||
const valueFormatter = props.valueFormatter ?? compactFormatter.format;
|
||||
|
||||
const chartConfig = props.series.reduce((acc, s) => {
|
||||
acc[s.key] = { label: s.label, color: s.color };
|
||||
return acc;
|
||||
}, {} as ChartConfig);
|
||||
|
||||
const hasData = props.data.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex flex-col gap-2", props.className)}>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={chartType === "bar" ? "secondary" : "ghost"}
|
||||
onClick={() => setChartType("bar")}
|
||||
className="gap-1.5 px-2"
|
||||
>
|
||||
<BarChart3 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={chartType === "line" ? "secondary" : "ghost"}
|
||||
onClick={() => setChartType("line")}
|
||||
className="gap-1.5 px-2"
|
||||
>
|
||||
<LineChartIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hasData ? (
|
||||
<div className="flex h-64 w-full items-center justify-center text-muted-foreground gap-2">
|
||||
{props.isLoading ? (
|
||||
<>
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
"No data"
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="min-h-50 w-full h-64"
|
||||
>
|
||||
{chartType === "bar" ? (
|
||||
<BarChart accessibilityLayer data={props.data}>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="dot"
|
||||
labelFormatter={(_value, payload) =>
|
||||
formatDay(payload?.[0]?.payload?.day)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={valueFormatter}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={formatDay}
|
||||
/>
|
||||
{props.series.map((s) => (
|
||||
<Bar
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
stackId="stack"
|
||||
fill={`var(--color-${s.key})`}
|
||||
radius={2}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart accessibilityLayer data={props.data}>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="line"
|
||||
labelFormatter={(_value, payload) =>
|
||||
formatDay(payload?.[0]?.payload?.day)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={valueFormatter}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={formatDay}
|
||||
/>
|
||||
{props.series.map((s) => (
|
||||
<Line
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
stroke={`var(--color-${s.key})`}
|
||||
strokeWidth={2}
|
||||
fill="transparent"
|
||||
isAnimationActive={false}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDay(value: unknown) {
|
||||
if (typeof value !== "string") return "";
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) return value;
|
||||
return date.toLocaleDateString(undefined, { dateStyle: "medium" });
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import { compactNumberFormatter, formatCost } from "./shared";
|
||||
|
||||
export type TopEntity = {
|
||||
key: string;
|
||||
label: string;
|
||||
sublabel?: string | null;
|
||||
requests: number;
|
||||
totalTokens: number;
|
||||
costUsd: number | null;
|
||||
};
|
||||
|
||||
type TopEntitiesListProps = {
|
||||
entities: TopEntity[];
|
||||
isLoading: boolean;
|
||||
nameColumnLabel: string;
|
||||
emptyLabel?: string;
|
||||
};
|
||||
|
||||
export function TopEntitiesList(props: TopEntitiesListProps) {
|
||||
const totalCost = props.entities.reduce(
|
||||
(sum, e) => sum + (e.costUsd ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2">
|
||||
{props.entities.length > 0 && (
|
||||
<div className="grid grid-cols-12 text-sm text-muted-foreground font-semibold h-4">
|
||||
<div className="col-span-5">{props.nameColumnLabel}</div>
|
||||
<div className="col-span-2 text-end">Requests</div>
|
||||
<div className="col-span-2 text-end">Tokens</div>
|
||||
<div className="col-span-2 text-end">Cost</div>
|
||||
<div className="col-span-1 text-end">%</div>
|
||||
</div>
|
||||
)}
|
||||
<ol className="w-full overflow-auto gap-1 flex flex-col max-h-100">
|
||||
{props.entities.length === 0 && (
|
||||
<div className="flex items-center justify-center size-full text-muted-foreground gap-2 py-8">
|
||||
{props.isLoading ? (
|
||||
<>
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
(props.emptyLabel ?? "No data")
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.entities.map((entity) => {
|
||||
const percent =
|
||||
totalCost > 0 ? (entity.costUsd ?? 0) / totalCost : 0;
|
||||
return (
|
||||
<li
|
||||
key={entity.key}
|
||||
className="w-full grid grid-cols-12 rounded-xs hover:bg-muted relative items-center text-sm py-1"
|
||||
>
|
||||
<div
|
||||
className="absolute bg-[#f36117]/40 top-0 bottom-0 left-0 rounded-xs"
|
||||
style={{ width: `${percent * 100}%` }}
|
||||
/>
|
||||
<div className="col-span-5 px-2 relative z-1 flex flex-col min-w-0">
|
||||
<span className="truncate">{entity.label}</span>
|
||||
{entity.sublabel && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{entity.sublabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 text-end relative z-1">
|
||||
{compactNumberFormatter.format(entity.requests)}
|
||||
</div>
|
||||
<div className="col-span-2 text-end relative z-1">
|
||||
{compactNumberFormatter.format(
|
||||
entity.totalTokens
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 text-end relative z-1">
|
||||
{formatCost(entity.costUsd)}
|
||||
</div>
|
||||
<div className="col-span-1 text-end relative z-1">
|
||||
{Math.round(percent * 100)}%
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiUsageAnalyticsQueries } from "@app/lib/queries";
|
||||
import type { AiUsageAnalyticsFilters } from "@app/lib/queries";
|
||||
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 UsersRolesTabProps = {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
};
|
||||
|
||||
const UNKNOWN_USER_KEY = "unknown";
|
||||
|
||||
export function UsersRolesTab(props: UsersRolesTabProps) {
|
||||
const { data, isLoading } = useQuery(
|
||||
aiUsageAnalyticsQueries.usersRoles({
|
||||
orgId: props.orgId,
|
||||
filters: props.filters
|
||||
})
|
||||
);
|
||||
|
||||
const roleNameByKey = new Map<string, string>();
|
||||
for (const r of data?.topRoles ?? []) {
|
||||
roleNameByKey.set(String(r.roleId), r.name ?? `Role #${r.roleId}`);
|
||||
}
|
||||
const roleLabelFor = (key: string) =>
|
||||
roleNameByKey.get(key) ?? `Role #${key}`;
|
||||
|
||||
const userEmailByKey = new Map<string, string>();
|
||||
for (const u of data?.topUsers ?? []) {
|
||||
if (u.userId) {
|
||||
userEmailByKey.set(u.userId, u.email ?? u.userId);
|
||||
}
|
||||
}
|
||||
const userLabelFor = (key: string) =>
|
||||
key === UNKNOWN_USER_KEY
|
||||
? "Unknown user"
|
||||
: (userEmailByKey.get(key) ?? key);
|
||||
|
||||
const roleCostSeries = buildSeriesFromData(
|
||||
data?.roleCostPerDay ?? [],
|
||||
roleLabelFor
|
||||
);
|
||||
const roleTokensSeries = buildSeriesFromData(
|
||||
data?.roleTokensPerDay ?? [],
|
||||
roleLabelFor
|
||||
);
|
||||
const userCostSeries = buildSeriesFromData(
|
||||
data?.userCostPerDay ?? [],
|
||||
userLabelFor
|
||||
);
|
||||
const userTokensSeries = buildSeriesFromData(
|
||||
data?.userTokensPerDay ?? [],
|
||||
userLabelFor
|
||||
);
|
||||
|
||||
const topRoles: TopEntity[] = (data?.topRoles ?? []).map((r) => ({
|
||||
key: String(r.roleId),
|
||||
label: r.name ?? `Role #${r.roleId}`,
|
||||
requests: r.requests,
|
||||
totalTokens: r.totalTokens,
|
||||
costUsd: r.costUsd
|
||||
}));
|
||||
|
||||
const topUsers: TopEntity[] = (data?.topUsers ?? []).map((u) => ({
|
||||
key: u.userId ?? UNKNOWN_USER_KEY,
|
||||
label: u.email ?? u.userId ?? "Unknown user",
|
||||
requests: u.requests,
|
||||
totalTokens: u.totalTokens,
|
||||
costUsd: u.costUsd
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-5">
|
||||
<h3 className="font-semibold text-muted-foreground">Roles</h3>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Top roles</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopEntitiesList
|
||||
entities={topRoles}
|
||||
isLoading={isLoading}
|
||||
nameColumnLabel="Role"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Role cost</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ToggleableTrendChart
|
||||
data={data?.roleCostPerDay ?? []}
|
||||
series={roleCostSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Role token usage</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ToggleableTrendChart
|
||||
data={data?.roleTokensPerDay ?? []}
|
||||
series={roleTokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<h3 className="font-semibold text-muted-foreground">Users</h3>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">Top users</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopEntitiesList
|
||||
entities={topUsers}
|
||||
isLoading={isLoading}
|
||||
nameColumnLabel="User"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">User cost</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ToggleableTrendChart
|
||||
data={data?.userCostPerDay ?? []}
|
||||
series={userCostSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">User token usage</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ToggleableTrendChart
|
||||
data={data?.userTokensPerDay ?? []}
|
||||
series={userTokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { TrendSeries } from "./ToggleableTrendChart";
|
||||
|
||||
// Matches the theme's 5 categorical chart colors (--chart-1..--chart-5 in
|
||||
// src/app/globals.css) - the same ceiling RequestChart already respects.
|
||||
export const SERIES_COLORS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)"
|
||||
];
|
||||
export const OTHER_COLOR = "var(--muted-foreground)";
|
||||
export const OTHER_KEY = "other";
|
||||
|
||||
// The server already collapsed each day's row down to the top-N dimension
|
||||
// keys (already ranked) plus an optional "other" bucket - so the full set of
|
||||
// series can be derived straight from the data's own keys, no separate
|
||||
// top-list needed. Assigns one categorical color per key, "other" last.
|
||||
export function buildSeriesFromData(
|
||||
data: Array<Record<string, number | string>>,
|
||||
labelFor: (key: string) => string
|
||||
): TrendSeries[] {
|
||||
const keys = new Set<string>();
|
||||
for (const row of data) {
|
||||
for (const key of Object.keys(row)) {
|
||||
if (key !== "day" && key !== OTHER_KEY) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const series: TrendSeries[] = [...keys].map((key, i) => ({
|
||||
key,
|
||||
label: labelFor(key),
|
||||
color: SERIES_COLORS[i % SERIES_COLORS.length]
|
||||
}));
|
||||
|
||||
const hasOther = data.some((row) => OTHER_KEY in row);
|
||||
if (hasOther) {
|
||||
series.push({ key: OTHER_KEY, label: "Other", color: OTHER_COLOR });
|
||||
}
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
export const currencyFormatter = new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
|
||||
export const compactNumberFormatter = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
notation: "compact",
|
||||
compactDisplay: "short"
|
||||
});
|
||||
|
||||
export const exactNumberFormatter = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
|
||||
export function formatCost(value: number | null | undefined) {
|
||||
return currencyFormatter.format(value ?? 0);
|
||||
}
|
||||
+165
-1
@@ -6,7 +6,14 @@ import {
|
||||
type BatchedStatusHistoryResponse
|
||||
} from "@server/lib/statusHistory";
|
||||
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
||||
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
||||
import type {
|
||||
QueryRequestAnalyticsResponse,
|
||||
QueryAiUsageFilterOptionsResponse,
|
||||
QueryAiUsageOverviewResponse,
|
||||
QueryAiUsageProvidersResponse,
|
||||
QueryAiUsageResourcesResponse,
|
||||
QueryAiUsageUsersRolesResponse
|
||||
} from "@server/routers/auditLogs";
|
||||
import type {
|
||||
QueryAccessAuditLogResponse,
|
||||
QueryActionAuditLogResponse,
|
||||
@@ -928,6 +935,32 @@ export const logAnalyticsFiltersSchema = z.object({
|
||||
|
||||
export type LogAnalyticsFilters = z.output<typeof logAnalyticsFiltersSchema>;
|
||||
|
||||
export const aiUsageAnalyticsFiltersSchema = z.object({
|
||||
timeStart: z
|
||||
.string()
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
error: "timeStart must be a valid ISO date string"
|
||||
})
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
timeEnd: z
|
||||
.string()
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
providerId: z.coerce.number().optional().catch(undefined),
|
||||
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)
|
||||
});
|
||||
|
||||
export type AiUsageAnalyticsFilters = z.output<
|
||||
typeof aiUsageAnalyticsFiltersSchema
|
||||
>;
|
||||
|
||||
export const httpLogsFiltersSchema = z.object({
|
||||
timeStart: z
|
||||
.string()
|
||||
@@ -1242,6 +1275,137 @@ export const logQueries = {
|
||||
})
|
||||
};
|
||||
|
||||
export const aiUsageAnalyticsQueries = {
|
||||
filterOptions: ({
|
||||
orgId,
|
||||
filters
|
||||
}: {
|
||||
orgId: string;
|
||||
filters: Pick<AiUsageAnalyticsFilters, "timeStart" | "timeEnd">;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["AI_USAGE_ANALYTICS", orgId, "FILTERS", filters] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryAiUsageFilterOptionsResponse>
|
||||
>(`/org/${orgId}/logs/ai/usage/filters`, {
|
||||
params: filters,
|
||||
signal
|
||||
});
|
||||
return res.data.data;
|
||||
}
|
||||
}),
|
||||
|
||||
overview: ({
|
||||
orgId,
|
||||
filters
|
||||
}: {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["AI_USAGE_ANALYTICS", orgId, "OVERVIEW", filters] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryAiUsageOverviewResponse>
|
||||
>(`/org/${orgId}/logs/ai/usage/overview`, {
|
||||
params: filters,
|
||||
signal
|
||||
});
|
||||
return res.data.data;
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
if (query.state.data) {
|
||||
return durationToMs(30, "seconds");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
|
||||
providers: ({
|
||||
orgId,
|
||||
filters
|
||||
}: {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["AI_USAGE_ANALYTICS", orgId, "PROVIDERS", filters] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryAiUsageProvidersResponse>
|
||||
>(`/org/${orgId}/logs/ai/usage/providers`, {
|
||||
params: filters,
|
||||
signal
|
||||
});
|
||||
return res.data.data;
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
if (query.state.data) {
|
||||
return durationToMs(30, "seconds");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
|
||||
resources: ({
|
||||
orgId,
|
||||
filters
|
||||
}: {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["AI_USAGE_ANALYTICS", orgId, "RESOURCES", filters] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryAiUsageResourcesResponse>
|
||||
>(`/org/${orgId}/logs/ai/usage/resources`, {
|
||||
params: filters,
|
||||
signal
|
||||
});
|
||||
return res.data.data;
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
if (query.state.data) {
|
||||
return durationToMs(30, "seconds");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
|
||||
usersRoles: ({
|
||||
orgId,
|
||||
filters
|
||||
}: {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
"AI_USAGE_ANALYTICS",
|
||||
orgId,
|
||||
"USERS_ROLES",
|
||||
filters
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<QueryAiUsageUsersRolesResponse>
|
||||
>(`/org/${orgId}/logs/ai/usage/users-roles`, {
|
||||
params: filters,
|
||||
signal
|
||||
});
|
||||
return res.data.data;
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
if (query.state.data) {
|
||||
return durationToMs(30, "seconds");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
export const aiProviderQueries = {
|
||||
providerTargets: ({ providerId }: { providerId: number }) =>
|
||||
queryOptions({
|
||||
|
||||
Reference in New Issue
Block a user