TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Vue générale : indicateurs clés, coût par jour (14 jours), modèles les plus3// utilisés (30 jours), état de la dernière ingestion.4import { Activity } from "lucide-react";5import { PageHeader } from "@/components/app-shell";6import { Badge, Card, EmptyState, ProgressBar, Skeleton } from "@/components/ui";7import {8 BarChart, ErrorBanner, SectionTitle, StatTile,9 fmtDate, fmtInt, fmtUSD, useFetchJson, type BarDatum,10} from "./shared";1112type Overview = {13 users: number;14 students: number;15 activeWeek: number;16 conversations: number;17 messages: number;18 documents: number;19 chunks: number;20 flagsOpen: number;21 apiErrors7d: number;22 costMonth: number;23 costToday: number;24 budgets: { dailyPerUserUSD: number; monthlyPerUserUSD: number; monthlyGlobalUSD: number; dailyRequestsPerUser: number };25 lastIngestion: { id: number; started_at: string; finished_at: string | null; status: string; chunks_created: number; files_ingested: number } | null;26 costByDay: { day: string; cost: number; calls: number }[];27 topModels: { model: string; calls: number; cost: number; tin: number | null; tout: number | null }[];28 invalidCitations7d: number;29};3031function ingestionTone(status: string): "green" | "red" | "amber" {32 const s = status.toLowerCase();33 if (["ok", "success", "succès", "completed", "done"].some((k) => s.includes(k))) return "green";34 if (["error", "fail", "échec", "erreur"].some((k) => s.includes(k))) return "red";35 return "amber";36}3738export function AdminOverview() {39 const { data, error, loading, reload } = useFetchJson<Overview>("/api/admin/overview");4041 if (loading) {42 return (43 <div className="animate-fade-up">44 <PageHeader title="Vue générale" subtitle="État de la plateforme en un coup d'œil" />45 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">46 {Array.from({ length: 10 }).map((_, i) => (47 <Skeleton key={i} className="h-24" />48 ))}49 </div>50 <div className="mt-4 grid gap-4 lg:grid-cols-2">51 <Skeleton className="h-64" />52 <Skeleton className="h-64" />53 </div>54 </div>55 );56 }5758 if (error || !data) {59 return (60 <div>61 <PageHeader title="Vue générale" />62 <ErrorBanner message={error ?? "Données indisponibles."} onRetry={reload} />63 </div>64 );65 }6667 // Série des 14 derniers jours, jours sans usage inclus (valeur 0).68 const days: BarDatum[] = [];69 for (let i = 13; i >= 0; i--) {70 const d = new Date();71 d.setDate(d.getDate() - i);72 const key = d.toISOString().slice(0, 10);73 const row = data.costByDay.find((r) => r.day === key);74 days.push({ label: key.slice(5), value: row?.cost ?? 0, hint: `${fmtInt(row?.calls ?? 0)} appels` });75 }7677 const budgetRatio = data.budgets.monthlyGlobalUSD > 0 ? data.costMonth / data.budgets.monthlyGlobalUSD : 0;78 const ingest = data.lastIngestion;7980 return (81 <div className="animate-fade-up">82 <PageHeader title="Vue générale" subtitle="État de la plateforme en un coup d'œil" />8384 <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">85 <StatTile label="Utilisateurs" value={fmtInt(data.users)} sub={`${fmtInt(data.students)} étudiant·e·s`} />86 <StatTile label="Actifs (7 jours)" value={fmtInt(data.activeWeek)} />87 <StatTile label="Conversations" value={fmtInt(data.conversations)} />88 <StatTile label="Messages" value={fmtInt(data.messages)} />89 <StatTile label="Documents" value={fmtInt(data.documents)} sub={`${fmtInt(data.chunks)} fragments`} />90 <StatTile label="Coût du jour" value={fmtUSD(data.costToday)} />91 <StatTile92 label="Signalements ouverts"93 value={fmtInt(data.flagsOpen)}94 tone={data.flagsOpen > 0 ? "amber" : "default"}95 />96 <StatTile97 label="Erreurs API (7 j)"98 value={fmtInt(data.apiErrors7d)}99 tone={data.apiErrors7d > 0 ? "red" : "default"}100 />101 <StatTile102 label="Citations invalides (7 j)"103 value={fmtInt(data.invalidCitations7d)}104 tone={data.invalidCitations7d > 0 ? "amber" : "default"}105 />106 <Card className="p-4">107 <p className="text-[12px] font-medium text-muted">Coût du mois</p>108 <p className="mt-1 text-xl font-bold tabular-nums leading-tight text-fg">{fmtUSD(data.costMonth)}</p>109 <ProgressBar value={budgetRatio} tone={budgetRatio >= 0.9 ? "gold" : "brand"} className="mt-2" />110 <p className="mt-1 text-[12px] tabular-nums text-muted">111 {Math.round(budgetRatio * 100)} % du budget global ({fmtUSD(data.budgets.monthlyGlobalUSD, 0)}/mois)112 </p>113 </Card>114 </div>115116 <div className="mt-5 grid gap-4 lg:grid-cols-2">117 <Card className="p-5">118 <SectionTitle sub="Dépense quotidienne, tous modèles confondus">Coût par jour — 14 derniers jours</SectionTitle>119 <BarChart data={days} formatValue={(v) => fmtUSD(v, 4)} emptyLabel="Aucune dépense sur les 14 derniers jours." />120 </Card>121122 <Card className="p-5">123 <SectionTitle sub="Sur les 30 derniers jours">Modèles les plus utilisés</SectionTitle>124 {data.topModels.length === 0 ? (125 <EmptyState icon={<Activity />} title="Aucun appel de modèle" description="Les statistiques d'usage apparaîtront après les premières conversations." />126 ) : (127 <div className="overflow-x-auto">128 <table className="w-full text-[13px]">129 <thead>130 <tr className="border-b border-app text-left text-[12px] text-muted">131 <th className="py-2 pr-3 font-medium">Modèle</th>132 <th className="py-2 pr-3 text-right font-medium">Appels</th>133 <th className="py-2 pr-3 text-right font-medium">Coût</th>134 <th className="py-2 text-right font-medium">Jetons (entrée / sortie)</th>135 </tr>136 </thead>137 <tbody>138 {data.topModels.map((m) => (139 <tr key={m.model} className="border-b border-app last:border-0">140 <td className="max-w-[220px] truncate py-2 pr-3 font-mono text-[12px] text-fg" title={m.model}>{m.model}</td>141 <td className="py-2 pr-3 text-right tabular-nums text-fg">{fmtInt(m.calls)}</td>142 <td className="py-2 pr-3 text-right tabular-nums text-fg">{fmtUSD(m.cost, 4)}</td>143 <td className="py-2 text-right tabular-nums text-muted">144 {fmtInt(m.tin ?? 0)} / {fmtInt(m.tout ?? 0)}145 </td>146 </tr>147 ))}148 </tbody>149 </table>150 </div>151 )}152 </Card>153 </div>154155 <Card className="mt-4 p-5">156 <SectionTitle>Dernière ingestion du contenu</SectionTitle>157 {!ingest ? (158 <p className="text-sm text-muted">Aucune ingestion n'a encore été exécutée. Lancez-la depuis l'onglet « Cours & contenu ».</p>159 ) : (160 <div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-[13px]">161 <Badge tone={ingestionTone(ingest.status)}>{ingest.status}</Badge>162 <span className="text-muted">163 Démarrée : <span className="tabular-nums text-fg">{fmtDate(ingest.started_at)}</span>164 </span>165 <span className="text-muted">166 Terminée : <span className="tabular-nums text-fg">{ingest.finished_at ? fmtDate(ingest.finished_at) : "en cours…"}</span>167 </span>168 <span className="text-muted">169 Fichiers ingérés : <span className="tabular-nums text-fg">{fmtInt(ingest.files_ingested)}</span>170 </span>171 <span className="text-muted">172 Fragments créés : <span className="tabular-nums text-fg">{fmtInt(ingest.chunks_created)}</span>173 </span>174 </div>175 )}176 </Card>177 </div>178 );179}180