basic ai analytics created

This commit is contained in:
Owen
2026-08-11 17:52:27 -04:00
parent 0016b8fce7
commit b08e875b37
21 changed files with 2820 additions and 1 deletions
@@ -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);
}