"use client"; import { useMemo, useState } from "react"; import useSWR, { mutate } from "swr"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, LineChart, Line } from "recharts"; import { Download, Pencil, Check, X, AlertTriangle, BarChart3, Info } from "lucide-react"; import { api, fetcher, fmtUsd, fmtTokens, fmtMs } from "@/lib/client/fetcher"; import { useModels, shortName } from "@/lib/client/use-models"; import type { ProjectDTO } from "@/lib/client/types"; import { cn, Progress, Button, Empty, toast, Badge } from "../ui"; interface Totals { requests: number; cost: number; cost_confirmed: number; input_tokens: number; output_tokens: number; cached_tokens: number; reasoning_tokens: number; avg_latency: number | null; avg_ttft: number | null; errors: number } interface Daily { day: string; provider: string; cost: number; requests: number; input_tokens: number; output_tokens: number } interface ByModel { model_id: string; provider: string; requests: number; cost: number; input_tokens: number; output_tokens: number; cached_tokens: number; avg_latency: number | null; avg_ttft: number | null; errors: number } interface UsageData { totals: Totals; daily: Daily[]; byModel: ByModel[]; byKind: { kind: string; requests: number; cost: number }[]; byProject: { project: string; project_id: string | null; requests: number; cost: number }[]; monthly: { month: string; cost: number }[]; spending: { today: number; month: number; limits: { dailyLimitUsd: number; monthlyLimitUsd: number; alertAtPercent: number } }; range: { from: string; to: string } } const PRESETS = [{ id: "7d", label: "7 j" }, { id: "30d", label: "30 j" }, { id: "month", label: "Ce mois" }, { id: "90d", label: "90 j" }, { id: "custom", label: "Personnalisé" }] as const; type Preset = typeof PRESETS[number]["id"]; const KIND_LABEL: Record = { chat: "Conversation", compare: "Comparaison", research: "Recherche", title: "Titres", memory: "Mémoire", embedding: "Embeddings", image: "Images", tts: "Synthèse vocale", stt: "Transcription", judge: "Évaluation IA", tool: "Outils" }; const PROVIDER_COLOR: Record = { openai: "var(--accent)", anthropic: "var(--accent-2)" }; const PROVIDER_LABEL: Record = { openai: "OpenAI", anthropic: "Anthropic" }; function rangeFor(p: Preset, custom: { from: string; to: string }) { const now = new Date(); const end = new Date(now.getTime() + 60_000); if (p === "custom") return { from: custom.from ? new Date(custom.from).toISOString() : new Date(now.getTime() - 30 * 86_400_000).toISOString(), to: custom.to ? new Date(custom.to + "T23:59:59").toISOString() : end.toISOString() }; if (p === "month") return { from: new Date(now.getFullYear(), now.getMonth(), 1).toISOString(), to: end.toISOString() }; const days = p === "7d" ? 7 : p === "90d" ? 90 : 30; return { from: new Date(now.getTime() - days * 86_400_000).toISOString(), to: end.toISOString() }; } const tooltipStyle = { background: "var(--bg-elev)", border: "1px solid var(--border)", borderRadius: 10, fontSize: 12, color: "var(--text)" }; export function UsageDashboard() { const [preset, setPreset] = useState("30d"); const [custom, setCustom] = useState({ from: "", to: "" }); const [provider, setProvider] = useState(""); const [model, setModel] = useState(""); const [project, setProject] = useState(""); const range = useMemo(() => rangeFor(preset, custom), [preset, custom]); const qs = new URLSearchParams({ from: range.from, to: range.to, ...(provider ? { provider } : {}), ...(model ? { model } : {}), ...(project ? { project } : {}) }).toString(); const { data, isLoading, error } = useSWR(`/api/usage?${qs}`, fetcher, { keepPreviousData: true }); const { data: projData } = useSWR<{ projects: ProjectDTO[] }>("/api/projects", fetcher); const { byId, chat } = useModels(true); const daily = useMemo(() => { if (!data) return []; const m = new Map>(); for (const d of data.daily) { const k = String(d.day).slice(0, 10); const row = m.get(k) ?? { day: k, openai: 0, anthropic: 0, requests: 0 }; row[d.provider] = Number(row[d.provider] ?? 0) + d.cost; row.requests = Number(row.requests) + d.requests; m.set(k, row); } return [...m.values()].sort((a, b) => String(a.day).localeCompare(String(b.day))); }, [data]); const monthly = useMemo(() => (data?.monthly ?? []).slice().reverse().map(m => ({ month: String(m.month).slice(0, 7), cost: m.cost })), [data]); const t = data?.totals; const errRate = t && t.requests ? (100 * t.errors / t.requests) : 0; function exportCsv() { if (!data) return; const rows = [["model_id", "provider", "requests", "cost_usd_estimated", "input_tokens", "output_tokens", "cached_tokens", "avg_latency_ms", "avg_ttft_ms", "errors"], ...data.byModel.map(r => [r.model_id, r.provider, r.requests, r.cost.toFixed(6), r.input_tokens, r.output_tokens, r.cached_tokens, r.avg_latency ?? "", r.avg_ttft ?? "", r.errors])]; const blob = new Blob([rows.map(r => r.join(",")).join("\n")], { type: "text/csv;charset=utf-8" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `usage-${range.from.slice(0, 10)}_${range.to.slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(a.href); } return (

Coûts & usage

Estimé d'après les prix vérifiés — la facturation réelle du fournisseur peut différer.

{/* filters */}
{PRESETS.map(p => )}
{preset === "custom" && <> setCustom(c => ({ ...c, from: e.target.value }))} className="input !w-auto !py-1 text-[12px]" />→ setCustom(c => ({ ...c, to: e.target.value }))} className="input !w-auto !py-1 text-[12px]" />}
{error &&
{(error as Error).message}
} {/* KPIs */}
Confirmé : {t?.cost_confirmed ? fmtUsd(t.cost_confirmed) : "—"}} loading={isLoading && !data} /> 1 ? "s" : ""} · ${errRate.toFixed(1)} %` : undefined} tone={errRate > 5 ? "warn" : undefined} loading={isLoading && !data} />
{/* budgets */} {data && } {/* daily chart */}

Dépense quotidienne par fournisseur

{daily.length} jour{daily.length > 1 ? "s" : ""}
{daily.length ? (
String(d).slice(5)} tick={{ fontSize: 11, fill: "var(--muted)" }} axisLine={false} tickLine={false} /> `${Number(v).toFixed(2)} $`} tick={{ fontSize: 11, fill: "var(--muted)" }} axisLine={false} tickLine={false} width={60} /> [fmtUsd(Number(v)), PROVIDER_LABEL[String(name)] ?? String(name)]} labelFormatter={l => `Jour ${l}`} /> {PROVIDER_LABEL[v] ?? v}} iconType="circle" iconSize={8} />
) : } title="Aucune dépense sur la période" hint="Les requêtes effectuées depuis l'application apparaîtront ici." />}
{/* monthly */}

Dépense mensuelle (12 mois)

{monthly.length ?
`${Number(v).toFixed(0)} $`} tick={{ fontSize: 11, fill: "var(--muted)" }} axisLine={false} tickLine={false} width={48} /> [fmtUsd(Number(v)), "Dépense"]} />
:
Pas encore de données mensuelles.
}
{/* by kind */}

Par type d'usage

{data?.byKind.length ? ({ label: KIND_LABEL[k.kind] ?? k.kind, value: k.cost, sub: `${k.requests} req.` }))} /> :
—
}

Par modèle

{data?.byModel.length ? shortName(byId(id), id)} /> :
Aucune requête sur la période.
}

Par projet

{data?.byProject.length ? ({ label: p.project, value: p.cost, sub: `${p.requests} req.` }))} /> :
—
}
); } function Tile({ label, value, sub, tone, loading }: { label: string; value: string; sub?: React.ReactNode; tone?: "warn"; loading?: boolean }) { return
{label}
{loading ?
:
{value}
}{sub &&
{sub}
}
; } function HBars({ rows }: { rows: { label: string; value: number; sub?: string }[] }) { const max = Math.max(...rows.map(r => r.value), 0.000001); return
    {rows.slice(0, 10).map(r =>
  • {r.label}{fmtUsd(r.value, 3)}{r.sub ? · {r.sub} : null}
  • )}
; } type SortKey = keyof Pick; function ModelTable({ rows, name }: { rows: ByModel[]; name: (id: string) => string }) { const [sort, setSort] = useState("cost"); const [dir, setDir] = useState<1 | -1>(-1); const sorted = useMemo(() => [...rows].sort((a, b) => ((Number(a[sort] ?? 0) - Number(b[sort] ?? 0)) * dir)), [rows, sort, dir]); const H = ({ k, children, right = true }: { k: SortKey; children: React.ReactNode; right?: boolean }): React.ReactNode => { if (sort === k) setDir(d => (d === 1 ? -1 : 1)); else { setSort(k); setDir(-1); } }}>{children}{sort === k ? (dir === -1 ? " ↓" : " ↑") : ""}; return (
{H({ k: "requests", children: "Req." })}{H({ k: "cost", children: "Coût est." })}{H({ k: "input_tokens", children: "Entrée" })}{H({ k: "output_tokens", children: "Sortie" })}{H({ k: "avg_latency", children: "Latence" })}{H({ k: "errors", children: "Err." })}{sorted.map(r => )}
Modèle
{name(r.model_id)}{r.requests}{fmtUsd(r.cost, 4)}{fmtTokens(Number(r.input_tokens))}{Number(r.cached_tokens) > 0 && ({fmtTokens(Number(r.cached_tokens))} cache)}{fmtTokens(Number(r.output_tokens))}{fmtMs(r.avg_latency)}{r.avg_ttft != null && / {fmtMs(r.avg_ttft)}} 0 && "text-warn")}>{r.errors}
); } function Budgets({ spending }: { spending: UsageData["spending"] }) { const [edit, setEdit] = useState(false); const [l, setL] = useState(spending.limits); const [busy, setBusy] = useState(false); async function save() { setBusy(true); try { await api("/api/settings", { method: "PATCH", json: { key: "spending", value: { dailyLimitUsd: Number(l.dailyLimitUsd), monthlyLimitUsd: Number(l.monthlyLimitUsd), alertAtPercent: Number(l.alertAtPercent) } } }); toast("Limites enregistrées", "ok"); setEdit(false); void mutate((k: string) => typeof k === "string" && k.startsWith("/api/usage")); } catch (e) { toast((e as Error).message, "error"); } finally { setBusy(false); } } const gauge = (label: string, v: number, lim: number) => { const pct = lim > 0 ? 100 * v / lim : 0; const tone = pct >= 100 ? "danger" : pct >= spending.limits.alertAtPercent ? "warn" : "accent"; return
{label}{fmtUsd(v, 3)} / {lim > 0 ? fmtUsd(lim) : "∞"}{pct >= 100 && bloqué}
0 ? Math.min(v, lim) : 0} max={lim > 0 ? lim : 1} tone={tone} />
; }; return (

Limites de dépenses locales

{!edit ? : }
{edit ?

Une limite à 0 désactive le blocage. Une fois la limite atteinte, l'application refuse les nouvelles requêtes ; la facturation du fournisseur peut inclure d'autres usages.

:
{gauge("Aujourd'hui", spending.today, spending.limits.dailyLimitUsd)}{gauge("Ce mois", spending.month, spending.limits.monthlyLimitUsd)}
}
); }