From c736e7a43f6c7b7cba4bbd94fec8072d1f34cda0 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Wed, 8 Jul 2026 14:31:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(ai):=20add=20token-usage=20dashboard=20(AI?= =?UTF-8?q?=20Agents=20=E2=86=92=20Usage)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 9 +- src/app/(dashboard)/agents/page.tsx | 20 ++- src/app/api/ai/usage/route.ts | 134 +++++++++++++++ src/components/agents/ai-usage.tsx | 250 ++++++++++++++++++++++++++++ 4 files changed, 408 insertions(+), 5 deletions(-) create mode 100644 src/app/api/ai/usage/route.ts create mode 100644 src/components/agents/ai-usage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e8ad8f..e7e9a91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/app/(dashboard)/agents/page.tsx b/src/app/(dashboard)/agents/page.tsx index 151ec4d..c377484 100644 --- a/src/app/(dashboard)/agents/page.tsx +++ b/src/app/(dashboard)/agents/page.tsx @@ -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('playground'); const [decided, setDecided] = useState(false); @@ -57,6 +62,11 @@ export default function AgentsPage() { Setup + {canViewUsage && ( + + Usage + + )} @@ -66,6 +76,12 @@ export default function AgentsPage() { + + {canViewUsage && ( + + + + )} )} diff --git a/src/app/api/ai/usage/route.ts b/src/app/api/ai/usage/route.ts new file mode 100644 index 0000000..983e95b --- /dev/null +++ b/src/app/api/ai/usage/route.ts @@ -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() + 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) + } +} diff --git a/src/components/agents/ai-usage.tsx b/src/components/agents/ai-usage.tsx new file mode 100644 index 0000000..085cc65 --- /dev/null +++ b/src/components/agents/ai-usage.tsx @@ -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(30); + const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + const loadedRef = useRef(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 ( + + +
+
+ + Token usage + + + Tokens spent on your provider key by drafts and the auto-reply + bot. Counts only — no message content is stored here. + +
+ +
+
+ + {loading || !data ? ( + + ) : !hasSpend ? ( +
+ +

No AI usage in the last {data.window_days} days yet.

+

+ This fills in as the assistant drafts and auto-replies. +

+
+ ) : ( + <> +
+ + + + +
+ +
+

+ Tokens per day +

+ fmt(v)} + showLegend={false} + yAxisWidth={48} + className="h-[200px]" + /> +
+ + {data.by_model.length > 0 && ( +
+

+ By model +

+
    + {data.by_model.map((m) => ( +
  • + + {m.model}{' '} + + ({m.provider}) + + + + {fmt(m.tokens)} tok · {m.calls}{' '} + {m.calls === 1 ? 'call' : 'calls'} + +
  • + ))} +
+
+ )} + + {data.truncated && ( +

+ Showing a partial window — usage is high enough that only the + most recent records are summarized here. +

+ )} + + )} +
+
+ ); +} + +function Stat({ + label, + value, + icon: Icon, +}: { + label: string; + value: string; + icon?: typeof Bot; +}) { + return ( +
+

+ {Icon && } + {label} +

+

+ {value} +

+
+ ); +}