feat(ai): add token-usage dashboard (AI Agents → Usage)

Surfaces the ai_usage_log data added in the previous commit:
- GET /api/ai/usage — admin-only aggregation (totals, per-mode,
  per-model, zero-filled daily series) over a 7/30/90-day window,
  mirroring the ai_usage_log SELECT policy
- AiUsageCard — stat tiles + daily token bar chart + by-model list,
  rendered under a new admin-only "Usage" tab on the AI Agents page

Counts only; no message content is stored or shown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-07-08 14:31:36 +03:00
parent b0194dfc27
commit c736e7a43f
4 changed files with 408 additions and 5 deletions

View File

@ -35,9 +35,12 @@ logged**.
short **internal note** summarizing the exchange for whoever picks it
up. Assigning fires the existing assignment notification. Pick the
target under **AI Agents → Setup → Hand off to**.
- **Token-usage logging.** Every draft and auto-reply records its
provider token counts to the new `ai_usage_log` table (admin-readable),
for cost visibility on your BYO key.
- **Token-usage logging + dashboard.** Every draft and auto-reply records
its provider token counts to the new `ai_usage_log` table
(admin-readable). A new **AI Agents → Usage** tab (admin-only) charts
daily token spend on your BYO key with per-mode and per-model
breakdowns, backed by `GET /api/ai/usage`. Counts only — no message
content is stored or shown.
### Changed

View File

@ -1,14 +1,19 @@
'use client';
import { useEffect, useState } from 'react';
import { Bot, Sparkles, Settings2 } from 'lucide-react';
import { Bot, Sparkles, Settings2, BarChart3 } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { AiPlayground } from '@/components/agents/ai-playground';
import { AiUsageCard } from '@/components/agents/ai-usage';
import { AiConfig } from '@/components/settings/ai-config';
import { useAuth } from '@/hooks/use-auth';
import { canEditSettings } from '@/lib/auth/roles';
type Tab = 'playground' | 'setup';
type Tab = 'playground' | 'setup' | 'usage';
export default function AgentsPage() {
const { accountRole } = useAuth();
const canViewUsage = accountRole ? canEditSettings(accountRole) : false;
const [tab, setTab] = useState<Tab>('playground');
const [decided, setDecided] = useState(false);
@ -57,6 +62,11 @@ export default function AgentsPage() {
<TabsTrigger value="setup">
<Settings2 className="mr-1.5 h-4 w-4" /> Setup
</TabsTrigger>
{canViewUsage && (
<TabsTrigger value="usage">
<BarChart3 className="mr-1.5 h-4 w-4" /> Usage
</TabsTrigger>
)}
</TabsList>
<TabsContent value="playground" className="mt-4">
@ -66,6 +76,12 @@ export default function AgentsPage() {
<TabsContent value="setup" className="mt-4">
<AiConfig />
</TabsContent>
{canViewUsage && (
<TabsContent value="usage" className="mt-4">
<AiUsageCard />
</TabsContent>
)}
</Tabs>
)}
</div>

View File

@ -0,0 +1,134 @@
import { NextResponse } from 'next/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
// Rows are aggregated in-process over a bounded window. An active
// account writes a handful of rows per conversation, so 30 days sits
// comfortably under this cap; we surface `truncated` when it doesn't so
// the UI can say "showing a partial window" rather than under-reporting
// silently.
const MAX_ROWS = 10_000
const DEFAULT_WINDOW_DAYS = 30
interface UsageRow {
created_at: string
mode: 'auto_reply' | 'draft'
provider: string
model: string
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
/** UTC yyyy-mm-dd — stable regardless of server timezone. */
function dayKey(d: Date): string {
return d.toISOString().slice(0, 10)
}
/**
* GET /api/ai/usage?days=30 (admin+)
*
* Token-spend summary for the account's BYO key over the last `days`
* (1–90, default 30): totals, per-mode + per-model breakdowns, and a
* zero-filled daily series for charting. Admin-only, mirroring the
* `ai_usage_log` SELECT policy — spend is billing-class.
*/
export async function GET(request: Request) {
try {
const { supabase, accountId } = await requireRole('admin')
const url = new URL(request.url)
const rawDays = Number(url.searchParams.get('days'))
const days = Number.isFinite(rawDays)
? Math.min(90, Math.max(1, Math.floor(rawDays)))
: DEFAULT_WINDOW_DAYS
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
const { data, error } = await supabase
.from('ai_usage_log')
.select(
'created_at, mode, provider, model, prompt_tokens, completion_tokens, total_tokens',
)
.eq('account_id', accountId)
.gte('created_at', since.toISOString())
.order('created_at', { ascending: false })
.limit(MAX_ROWS + 1)
if (error) {
console.error('[ai/usage GET] fetch error:', error)
return NextResponse.json(
{ error: 'Failed to load usage' },
{ status: 500 },
)
}
const all = (data ?? []) as UsageRow[]
const truncated = all.length > MAX_ROWS
const rows = truncated ? all.slice(0, MAX_ROWS) : all
// Totals.
let promptTokens = 0
let completionTokens = 0
let totalTokens = 0
// Per-mode + per-model tallies.
const byMode = {
auto_reply: { calls: 0, tokens: 0 },
draft: { calls: 0, tokens: 0 },
}
const modelMap = new Map<
string,
{ model: string; provider: string; calls: number; tokens: number }
>()
// Zero-filled daily buckets so the chart shows quiet days as gaps,
// not missing points. Keyed by UTC date, oldest → newest.
const daily = new Map<string, { date: string; tokens: number; calls: number }>()
for (let i = days - 1; i >= 0; i--) {
const key = dayKey(new Date(Date.now() - i * 24 * 60 * 60 * 1000))
daily.set(key, { date: key, tokens: 0, calls: 0 })
}
for (const r of rows) {
promptTokens += r.prompt_tokens
completionTokens += r.completion_tokens
totalTokens += r.total_tokens
const mode = r.mode === 'draft' ? 'draft' : 'auto_reply'
byMode[mode].calls += 1
byMode[mode].tokens += r.total_tokens
const mk = `${r.provider}:${r.model}`
const m =
modelMap.get(mk) ??
{ model: r.model, provider: r.provider, calls: 0, tokens: 0 }
m.calls += 1
m.tokens += r.total_tokens
modelMap.set(mk, m)
const bucket = daily.get(dayKey(new Date(r.created_at)))
if (bucket) {
bucket.tokens += r.total_tokens
bucket.calls += 1
}
}
const byModel = [...modelMap.values()].sort((a, b) => b.tokens - a.tokens)
return NextResponse.json({
window_days: days,
truncated,
totals: {
calls: rows.length,
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: totalTokens,
},
by_mode: byMode,
by_model: byModel,
daily: [...daily.values()],
})
} catch (err) {
return toErrorResponse(err)
}
}

View File

@ -0,0 +1,250 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { BarChart3, Bot, PencilLine } from 'lucide-react';
import { useAuth } from '@/hooks/use-auth';
import { canEditSettings } from '@/lib/auth/roles';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/dashboard/skeleton';
import { BarChart } from '@/components/tremor/bar-chart';
interface UsageResponse {
window_days: number;
truncated: boolean;
totals: {
calls: number;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
by_mode: {
auto_reply: { calls: number; tokens: number };
draft: { calls: number; tokens: number };
};
by_model: {
model: string;
provider: string;
calls: number;
tokens: number;
}[];
daily: { date: string; tokens: number; calls: number }[];
}
const WINDOWS = [7, 30, 90] as const;
/** Compact token count — 1234 → "1.2k", 1_200_000 → "1.2M". */
function fmt(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
/** "2026-07-08" → "Jul 8" for the chart axis. */
function shortDay(iso: string): string {
const [, m, d] = iso.split('-');
const month = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
][Number(m) - 1];
return `${month} ${Number(d)}`;
}
/**
* Token-spend dashboard for the account's BYO key. Admin-only (spend is
* billing-class), mirroring the `ai_usage_log` SELECT policy and the
* `GET /api/ai/usage` route. Renders nothing for non-admins.
*/
export function AiUsageCard() {
const { accountId, accountRole, profileLoading } = useAuth();
const canView = accountRole ? canEditSettings(accountRole) : false;
const [days, setDays] = useState<number>(30);
const [loading, setLoading] = useState(true);
const [data, setData] = useState<UsageResponse | null>(null);
const loadedRef = useRef<string | null>(null);
const fetchUsage = useCallback(async (windowDays: number) => {
setLoading(true);
try {
const res = await fetch(`/api/ai/usage?days=${windowDays}`, {
cache: 'no-store',
});
const json = await res.json().catch(() => null);
if (!res.ok) {
toast.error(json?.error ?? 'Failed to load usage');
setData(null);
return;
}
setData(json as UsageResponse);
} catch {
toast.error('Failed to load usage');
setData(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!canView || !accountId) return;
// Refetch on account switch or window change.
const key = `${accountId}:${days}`;
if (loadedRef.current === key) return;
loadedRef.current = key;
void fetchUsage(days);
}, [canView, accountId, days, fetchUsage]);
if (profileLoading || !canView) return null;
const chartData =
data?.daily.map((d) => ({ day: shortDay(d.date), Tokens: d.tokens })) ?? [];
const hasSpend = (data?.totals.total_tokens ?? 0) > 0;
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4 text-primary" /> Token usage
</CardTitle>
<CardDescription>
Tokens spent on your provider key by drafts and the auto-reply
bot. Counts only — no message content is stored here.
</CardDescription>
</div>
<Select
value={String(days)}
onValueChange={(v) => setDays(Number(v))}
>
<SelectTrigger className="w-32 flex-shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOWS.map((w) => (
<SelectItem key={w} value={String(w)}>
Last {w} days
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="space-y-5">
{loading || !data ? (
<Skeleton className="h-[220px] w-full" />
) : !hasSpend ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-sm text-muted-foreground">
<BarChart3 className="h-8 w-8 opacity-40" />
<p>No AI usage in the last {data.window_days} days yet.</p>
<p className="text-xs">
This fills in as the assistant drafts and auto-replies.
</p>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Total tokens" value={fmt(data.totals.total_tokens)} />
<Stat label="LLM calls" value={String(data.totals.calls)} />
<Stat
label="Auto-reply"
value={fmt(data.by_mode.auto_reply.tokens)}
icon={Bot}
/>
<Stat
label="Drafts"
value={fmt(data.by_mode.draft.tokens)}
icon={PencilLine}
/>
</div>
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">
Tokens per day
</p>
<BarChart
data={chartData}
index="day"
categories={['Tokens']}
colors={['violet']}
valueFormatter={(v) => fmt(v)}
showLegend={false}
yAxisWidth={48}
className="h-[200px]"
/>
</div>
{data.by_model.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">
By model
</p>
<ul className="divide-y divide-border rounded-md border border-border">
{data.by_model.map((m) => (
<li
key={`${m.provider}:${m.model}`}
className="flex items-center justify-between px-3 py-2 text-sm"
>
<span className="min-w-0 truncate">
<span className="text-foreground">{m.model}</span>{' '}
<span className="text-xs text-muted-foreground">
({m.provider})
</span>
</span>
<span className="flex-shrink-0 tabular-nums text-muted-foreground">
{fmt(m.tokens)} tok · {m.calls}{' '}
{m.calls === 1 ? 'call' : 'calls'}
</span>
</li>
))}
</ul>
</div>
)}
{data.truncated && (
<p className="text-xs text-muted-foreground">
Showing a partial window — usage is high enough that only the
most recent records are summarized here.
</p>
)}
</>
)}
</CardContent>
</Card>
);
}
function Stat({
label,
value,
icon: Icon,
}: {
label: string;
value: string;
icon?: typeof Bot;
}) {
return (
<div className="rounded-md border border-border p-3">
<p className="flex items-center gap-1 text-xs text-muted-foreground">
{Icon && <Icon className="h-3 w-3" />}
{label}
</p>
<p className="mt-1 text-lg font-semibold tabular-nums text-foreground">
{value}
</p>
</div>
);
}