SPB Git
19.5 KB · 169 lines tsx
1"use client";2import { useMemo, useState } from "react";3import useSWR, { mutate } from "swr";4import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, LineChart, Line } from "recharts";5import { Download, Pencil, Check, X, AlertTriangle, BarChart3, Info } from "lucide-react";6import { api, fetcher, fmtUsd, fmtTokens, fmtMs } from "@/lib/client/fetcher";7import { useModels, shortName } from "@/lib/client/use-models";8import type { ProjectDTO } from "@/lib/client/types";9import { cn, Progress, Button, Empty, toast, Badge } from "../ui";1011interface 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 }12interface Daily { day: string; provider: string; cost: number; requests: number; input_tokens: number; output_tokens: number }13interface 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 }14interface 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 } }1516const 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;17type Preset = typeof PRESETS[number]["id"];18const KIND_LABEL: Record<string, string> = { 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" };19const PROVIDER_COLOR: Record<string, string> = { openai: "var(--accent)", anthropic: "var(--accent-2)" };20const PROVIDER_LABEL: Record<string, string> = { openai: "OpenAI", anthropic: "Anthropic" };2122function rangeFor(p: Preset, custom: { from: string; to: string }) {23  const now = new Date(); const end = new Date(now.getTime() + 60_000);24  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() };25  if (p === "month") return { from: new Date(now.getFullYear(), now.getMonth(), 1).toISOString(), to: end.toISOString() };26  const days = p === "7d" ? 7 : p === "90d" ? 90 : 30;27  return { from: new Date(now.getTime() - days * 86_400_000).toISOString(), to: end.toISOString() };28}2930const tooltipStyle = { background: "var(--bg-elev)", border: "1px solid var(--border)", borderRadius: 10, fontSize: 12, color: "var(--text)" };3132export function UsageDashboard() {33  const [preset, setPreset] = useState<Preset>("30d"); const [custom, setCustom] = useState({ from: "", to: "" });34  const [provider, setProvider] = useState(""); const [model, setModel] = useState(""); const [project, setProject] = useState("");35  const range = useMemo(() => rangeFor(preset, custom), [preset, custom]);36  const qs = new URLSearchParams({ from: range.from, to: range.to, ...(provider ? { provider } : {}), ...(model ? { model } : {}), ...(project ? { project } : {}) }).toString();37  const { data, isLoading, error } = useSWR<UsageData>(`/api/usage?${qs}`, fetcher, { keepPreviousData: true });38  const { data: projData } = useSWR<{ projects: ProjectDTO[] }>("/api/projects", fetcher);39  const { byId, chat } = useModels(true);4041  const daily = useMemo(() => {42    if (!data) return [];43    const m = new Map<string, Record<string, number | string>>();44    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); }45    return [...m.values()].sort((a, b) => String(a.day).localeCompare(String(b.day)));46  }, [data]);47  const monthly = useMemo(() => (data?.monthly ?? []).slice().reverse().map(m => ({ month: String(m.month).slice(0, 7), cost: m.cost })), [data]);48  const t = data?.totals;49  const errRate = t && t.requests ? (100 * t.errors / t.requests) : 0;5051  function exportCsv() {52    if (!data) return;53    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])];54    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);55  }5657  return (58    <div className="mx-auto w-full max-w-6xl px-4 py-5 md:px-6 space-y-5">59      <div className="flex flex-wrap items-start justify-between gap-3">60        <div><h1 className="text-xl font-semibold tracking-tight">Coûts & usage</h1><p className="mt-0.5 text-[13px] text-muted">Estimé d'après les prix vérifiés — la facturation réelle du fournisseur peut différer.</p></div>61        <Button variant="outline" size="sm" onClick={exportCsv} disabled={!data?.byModel.length}><Download className="h-3.5 w-3.5" /> CSV</Button>62      </div>6364      {/* filters */}65      <div className="flex flex-wrap items-center gap-2">66        <div className="inline-flex rounded-lg border border-border bg-surface p-0.5">{PRESETS.map(p => <button key={p.id} onClick={() => setPreset(p.id)} className={cn("rounded-md px-2.5 py-1 text-[12px] font-medium", preset === p.id ? "bg-surface-2 text-fg" : "text-muted hover:text-fg")}>{p.label}</button>)}</div>67        {preset === "custom" && <><input type="date" value={custom.from} onChange={e => setCustom(c => ({ ...c, from: e.target.value }))} className="input !w-auto !py-1 text-[12px]" /><span className="text-muted text-xs">→</span><input type="date" value={custom.to} onChange={e => setCustom(c => ({ ...c, to: e.target.value }))} className="input !w-auto !py-1 text-[12px]" /></>}68        <select value={provider} onChange={e => { setProvider(e.target.value); setModel(""); }} className="input !w-auto !py-1 text-[12px]"><option value="">Tous les fournisseurs</option><option value="openai">OpenAI</option><option value="anthropic">Anthropic</option></select>69        <select value={model} onChange={e => setModel(e.target.value)} className="input !w-auto max-w-56 !py-1 text-[12px]"><option value="">Tous les modèles</option>{chat.filter(m => !provider || m.provider === provider).map(m => <option key={m.id} value={m.id}>{m.displayName}</option>)}</select>70        <select value={project} onChange={e => setProject(e.target.value)} className="input !w-auto max-w-48 !py-1 text-[12px]"><option value="">Tous les projets</option>{(projData?.projects ?? []).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}</select>71      </div>7273      {error && <div className="flex items-center gap-2 rounded-xl border border-danger/30 bg-danger/10 p-3 text-sm text-danger"><AlertTriangle className="h-4 w-4" /> {(error as Error).message}</div>}7475      {/* KPIs */}76      <div className="grid grid-cols-2 gap-3 md:grid-cols-4">77        <Tile label="Dépense estimée" value={fmtUsd(t?.cost, 3)} sub={<span className="inline-flex items-center gap-1">Confirmé : {t?.cost_confirmed ? fmtUsd(t.cost_confirmed) : "—"}<span title="Importe un export de facturation du fournisseur pour remplir cette valeur."><Info className="h-3 w-3" /></span></span>} loading={isLoading && !data} />78        <Tile label="Requêtes" value={t ? t.requests.toLocaleString("fr-CA") : "—"} sub={t ? `${t.errors} erreur${t.errors > 1 ? "s" : ""} · ${errRate.toFixed(1)} %` : undefined} tone={errRate > 5 ? "warn" : undefined} loading={isLoading && !data} />79        <Tile label="Jetons entrée → sortie" value={t ? `${fmtTokens(Number(t.input_tokens))} → ${fmtTokens(Number(t.output_tokens))}` : "—"} sub={t ? `cache ${fmtTokens(Number(t.cached_tokens))} · raisonnement ${fmtTokens(Number(t.reasoning_tokens))}` : undefined} loading={isLoading && !data} />80        <Tile label="Latence moyenne" value={fmtMs(t?.avg_latency)} sub={t ? `1er jeton ${fmtMs(t.avg_ttft)}` : undefined} loading={isLoading && !data} />81      </div>8283      {/* budgets */}84      {data && <Budgets spending={data.spending} />}8586      {/* daily chart */}87      <section className="card p-4">88        <div className="mb-3 flex items-center justify-between"><h2 className="text-[14px] font-medium">Dépense quotidienne par fournisseur</h2><span className="text-[11px] text-muted">{daily.length} jour{daily.length > 1 ? "s" : ""}</span></div>89        {daily.length ? (90          <div className="h-60">91            <ResponsiveContainer width="100%" height="100%">92              <BarChart data={daily} barCategoryGap="30%" margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>93                <CartesianGrid vertical={false} stroke="var(--border)" />94                <XAxis dataKey="day" tickFormatter={d => String(d).slice(5)} tick={{ fontSize: 11, fill: "var(--muted)" }} axisLine={false} tickLine={false} />95                <YAxis tickFormatter={v => `${Number(v).toFixed(2)} $`} tick={{ fontSize: 11, fill: "var(--muted)" }} axisLine={false} tickLine={false} width={60} />96                <Tooltip contentStyle={tooltipStyle} cursor={{ fill: "var(--surface-2)" }} formatter={(v, name) => [fmtUsd(Number(v)), PROVIDER_LABEL[String(name)] ?? String(name)]} labelFormatter={l => `Jour ${l}`} />97                <Legend formatter={v => <span className="text-[12px] text-fg-2">{PROVIDER_LABEL[v] ?? v}</span>} iconType="circle" iconSize={8} />98                <Bar dataKey="openai" stackId="c" fill={PROVIDER_COLOR.openai} stroke="var(--surface)" strokeWidth={2} maxBarSize={24} />99                <Bar dataKey="anthropic" stackId="c" fill={PROVIDER_COLOR.anthropic} stroke="var(--surface)" strokeWidth={2} maxBarSize={24} radius={[4, 4, 0, 0]} />100              </BarChart>101            </ResponsiveContainer>102          </div>103        ) : <Empty icon={<BarChart3 className="h-8 w-8" />} title="Aucune dépense sur la période" hint="Les requêtes effectuées depuis l'application apparaîtront ici." />}104      </section>105106      <div className="grid gap-4 lg:grid-cols-3">107        {/* monthly */}108        <section className="card p-4 lg:col-span-2">109          <h2 className="mb-3 text-[14px] font-medium">Dépense mensuelle (12 mois)</h2>110          {monthly.length ? <div className="h-48"><ResponsiveContainer width="100%" height="100%"><LineChart data={monthly} margin={{ top: 8, right: 12, left: 0, bottom: 0 }}><CartesianGrid vertical={false} stroke="var(--border)" /><XAxis dataKey="month" tick={{ fontSize: 11, fill: "var(--muted)" }} axisLine={false} tickLine={false} /><YAxis tickFormatter={v => `${Number(v).toFixed(0)} $`} tick={{ fontSize: 11, fill: "var(--muted)" }} axisLine={false} tickLine={false} width={48} /><Tooltip contentStyle={tooltipStyle} formatter={v => [fmtUsd(Number(v)), "Dépense"]} /><Line type="monotone" dataKey="cost" stroke="var(--accent)" strokeWidth={2} dot={{ r: 4, fill: "var(--accent)", stroke: "var(--surface)", strokeWidth: 2 }} activeDot={{ r: 6 }} /></LineChart></ResponsiveContainer></div> : <div className="py-10 text-center text-sm text-muted">Pas encore de données mensuelles.</div>}111        </section>112        {/* by kind */}113        <section className="card p-4">114          <h2 className="mb-3 text-[14px] font-medium">Par type d'usage</h2>115          {data?.byKind.length ? <HBars rows={data.byKind.map(k => ({ label: KIND_LABEL[k.kind] ?? k.kind, value: k.cost, sub: `${k.requests} req.` }))} /> : <div className="py-10 text-center text-sm text-muted">—</div>}116        </section>117      </div>118119      <div className="grid gap-4 lg:grid-cols-3">120        <section className="card p-4 lg:col-span-2 overflow-hidden">121          <h2 className="mb-3 text-[14px] font-medium">Par modèle</h2>122          {data?.byModel.length ? <ModelTable rows={data.byModel} name={id => shortName(byId(id), id)} /> : <div className="py-10 text-center text-sm text-muted">Aucune requête sur la période.</div>}123        </section>124        <section className="card p-4">125          <h2 className="mb-3 text-[14px] font-medium">Par projet</h2>126          {data?.byProject.length ? <HBars rows={data.byProject.map(p => ({ label: p.project, value: p.cost, sub: `${p.requests} req.` }))} /> : <div className="py-10 text-center text-sm text-muted">—</div>}127        </section>128      </div>129    </div>130  );131}132133function Tile({ label, value, sub, tone, loading }: { label: string; value: string; sub?: React.ReactNode; tone?: "warn"; loading?: boolean }) {134  return <div className="card p-4"><div className="text-[12px] text-muted">{label}</div>{loading ? <div className="skeleton mt-1.5 h-7 w-24 rounded-md" /> : <div className={cn("mt-1 text-xl font-semibold tabular-nums tracking-tight", tone === "warn" && "text-warn")}>{value}</div>}{sub && <div className="mt-1 text-[11px] text-muted">{sub}</div>}</div>;135}136137function HBars({ rows }: { rows: { label: string; value: number; sub?: string }[] }) {138  const max = Math.max(...rows.map(r => r.value), 0.000001);139  return <ul className="space-y-2.5">{rows.slice(0, 10).map(r => <li key={r.label}><div className="mb-1 flex items-center justify-between text-[12px]"><span className="truncate text-fg-2">{r.label}</span><span className="ml-2 shrink-0 tabular-nums text-fg">{fmtUsd(r.value, 3)}{r.sub ? <span className="ml-1.5 text-muted">· {r.sub}</span> : null}</span></div><div className="h-2 w-full rounded-full bg-surface-2"><div className="h-full rounded-full bg-accent" style={{ width: `${Math.max(2, 100 * r.value / max)}%` }} /></div></li>)}</ul>;140}141142type SortKey = keyof Pick<ByModel, "cost" | "requests" | "input_tokens" | "output_tokens" | "avg_latency" | "errors">;143function ModelTable({ rows, name }: { rows: ByModel[]; name: (id: string) => string }) {144  const [sort, setSort] = useState<SortKey>("cost"); const [dir, setDir] = useState<1 | -1>(-1);145  const sorted = useMemo(() => [...rows].sort((a, b) => ((Number(a[sort] ?? 0) - Number(b[sort] ?? 0)) * dir)), [rows, sort, dir]);146  const H = ({ k, children, right = true }: { k: SortKey; children: React.ReactNode; right?: boolean }): React.ReactNode => <th className={cn("cursor-pointer select-none px-2 py-1.5 font-medium text-muted hover:text-fg", right && "text-right")} onClick={() => { if (sort === k) setDir(d => (d === 1 ? -1 : 1)); else { setSort(k); setDir(-1); } }}>{children}{sort === k ? (dir === -1 ? " ↓" : " ↑") : ""}</th>;147  return (148    <div className="overflow-x-auto -mx-4 px-4">149      <table className="w-full text-[12px]">150        <thead><tr className="border-b border-border text-left"><th className="px-2 py-1.5 font-medium text-muted">Modèle</th>{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." })}</tr></thead>151        <tbody>{sorted.map(r => <tr key={r.model_id} className="border-b border-border/60 hover:bg-surface-2/50"><td className="px-2 py-1.5"><span className="inline-flex items-center gap-2"><span className="h-2 w-2 shrink-0 rounded-full" style={{ background: PROVIDER_COLOR[r.provider] ?? "var(--muted)" }} /><span className="truncate">{name(r.model_id)}</span></span></td><td className="px-2 py-1.5 text-right tabular-nums">{r.requests}</td><td className="px-2 py-1.5 text-right tabular-nums">{fmtUsd(r.cost, 4)}</td><td className="px-2 py-1.5 text-right tabular-nums">{fmtTokens(Number(r.input_tokens))}{Number(r.cached_tokens) > 0 && <span className="text-muted"> ({fmtTokens(Number(r.cached_tokens))} cache)</span>}</td><td className="px-2 py-1.5 text-right tabular-nums">{fmtTokens(Number(r.output_tokens))}</td><td className="px-2 py-1.5 text-right tabular-nums">{fmtMs(r.avg_latency)}{r.avg_ttft != null && <span className="text-muted"> / {fmtMs(r.avg_ttft)}</span>}</td><td className={cn("px-2 py-1.5 text-right tabular-nums", r.errors > 0 && "text-warn")}>{r.errors}</td></tr>)}</tbody>152      </table>153    </div>154  );155}156157function Budgets({ spending }: { spending: UsageData["spending"] }) {158  const [edit, setEdit] = useState(false); const [l, setL] = useState(spending.limits); const [busy, setBusy] = useState(false);159  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); } }160  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 <div><div className="mb-1 flex items-center justify-between text-[12px]"><span className="text-fg-2">{label}</span><span className="tabular-nums">{fmtUsd(v, 3)} <span className="text-muted">/ {lim > 0 ? fmtUsd(lim) : "∞"}</span>{pct >= 100 && <Badge tone="danger" className="ml-2">bloqué</Badge>}</span></div><Progress value={lim > 0 ? Math.min(v, lim) : 0} max={lim > 0 ? lim : 1} tone={tone} /></div>; };161  return (162    <section className="card p-4">163      <div className="mb-3 flex items-center justify-between"><h2 className="text-[14px] font-medium">Limites de dépenses locales</h2>{!edit ? <button onClick={() => { setL(spending.limits); setEdit(true); }} className="inline-flex items-center gap-1 text-[12px] text-fg-2 hover:text-fg"><Pencil className="h-3.5 w-3.5" /> Modifier</button> : <span className="flex gap-1"><Button size="sm" variant="primary" loading={busy} onClick={save}><Check className="h-3.5 w-3.5" /> Enregistrer</Button><Button size="sm" variant="ghost" onClick={() => setEdit(false)}><X className="h-3.5 w-3.5" /></Button></span>}</div>164      {edit ? <div className="grid gap-3 sm:grid-cols-3 text-[12px]"><label>Limite quotidienne ($)<input type="number" min={0} step={1} value={l.dailyLimitUsd} onChange={e => setL(x => ({ ...x, dailyLimitUsd: Number(e.target.value) }))} className="input mt-1" /></label><label>Limite mensuelle ($)<input type="number" min={0} step={5} value={l.monthlyLimitUsd} onChange={e => setL(x => ({ ...x, monthlyLimitUsd: Number(e.target.value) }))} className="input mt-1" /></label><label>Alerte à (%)<input type="number" min={1} max={100} value={l.alertAtPercent} onChange={e => setL(x => ({ ...x, alertAtPercent: Number(e.target.value) }))} className="input mt-1" /></label><p className="sm:col-span-3 text-muted">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.</p></div>165        : <div className="grid gap-4 sm:grid-cols-2">{gauge("Aujourd'hui", spending.today, spending.limits.dailyLimitUsd)}{gauge("Ce mois", spending.month, spending.limits.monthlyLimitUsd)}</div>}166    </section>167  );168}169
Files · main
Menu

Theme