TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Cours & contenu : documents ingérés regroupés par cours puis espace,3// relance de l'ingestion (normale ou --force) et historique des exécutions.4import { useMemo, useState } from "react";5import { ChevronDown, ChevronRight, FileText, RefreshCw } from "lucide-react";6import { PageHeader } from "@/components/app-shell";7import { Badge, Button, Card, EmptyState, Skeleton, Spinner, cn } from "@/components/ui";8import { ErrorBanner, SectionTitle, fmtDate, fmtInt, postJson, useFetchJson } from "./shared";910type Doc = {11 id: number;12 course_code: string;13 space: string;14 filename: string;15 doc_type: string;16 title: string;17 week: number | null;18 status: string;19 error: string | null;20 visible_to_students: number;21 ingested_at: string | null;22 chunk_count: number;23};2425type Run = {26 id: number;27 started_at: string;28 finished_at: string | null;29 triggered_by: string;30 files_scanned: number;31 files_ingested: number;32 files_skipped: number;33 chunks_created: number;34 status: string;35 report: string | null;36};3738type IngestResult = {39 ok: boolean;40 runId: number;41 scanned: number;42 ingested: number;43 skipped: number;44 chunks: number;45 errors: { path: string; error: string }[];46 report: string;47};4849const DOC_TYPE_LABEL: Record<string, string> = {50 slides: "Diapositives",51 plan: "Plan de cours",52 exam: "Examen",53 glossary: "Glossaire",54 "aide-memoire": "Aide-mémoire",55 markdown: "Document",56};5758function SpaceBadge({ space }: { space: string }) {59 if (space === "instructor-private") return <Badge tone="red">Privé prof</Badge>;60 if (space.startsWith("official-")) return <Badge tone="brand">Officiel</Badge>;61 return <Badge>{space}</Badge>;62}6364function statusTone(status: string): "green" | "red" | "amber" {65 const s = status.toLowerCase();66 if (s === "ok" || s.includes("success") || s.includes("succès") || s.includes("completed")) return "green";67 if (s.includes("error") || s.includes("fail") || s.includes("erreur") || s.includes("échec")) return "red";68 return "amber";69}7071export function AdminCourses() {72 const { data, error, loading, reload } = useFetchJson<{ runs: Run[]; documents: Doc[] }>("/api/admin/ingest");73 const [running, setRunning] = useState<null | "normal" | "force">(null);74 const [runError, setRunError] = useState<string | null>(null);75 const [result, setResult] = useState<IngestResult | null>(null);76 const [expandedRun, setExpandedRun] = useState<number | null>(null);77 const [showResultReport, setShowResultReport] = useState(false);7879 const grouped = useMemo(() => {80 const byCourse = new Map<string, Map<string, Doc[]>>();81 for (const d of data?.documents ?? []) {82 if (!byCourse.has(d.course_code)) byCourse.set(d.course_code, new Map());83 const spaces = byCourse.get(d.course_code)!;84 if (!spaces.has(d.space)) spaces.set(d.space, []);85 spaces.get(d.space)!.push(d);86 }87 return byCourse;88 }, [data]);8990 async function launch(force: boolean) {91 setRunning(force ? "force" : "normal");92 setRunError(null);93 setResult(null);94 setShowResultReport(false);95 try {96 const r = await postJson<IngestResult>(`/api/admin/ingest${force ? "?force=1" : ""}`);97 setResult(r);98 await reload();99 } catch (e) {100 setRunError(e instanceof Error ? e.message : "L'ingestion a échoué.");101 } finally {102 setRunning(null);103 }104 }105106 const actions = (107 <div className="flex flex-wrap gap-2">108 <Button size="sm" onClick={() => launch(false)} disabled={running !== null}>109 {running === "normal" ? <Spinner /> : <RefreshCw size={15} />}110 Relancer l'ingestion111 </Button>112 <Button size="sm" variant="secondary" onClick={() => launch(true)} disabled={running !== null}>113 {running === "force" ? <Spinner /> : <RefreshCw size={15} />}114 Réindexation complète (--force)115 </Button>116 </div>117 );118119 return (120 <div className="animate-fade-up">121 <PageHeader122 title="Cours & contenu"123 subtitle="Documents indexés pour la recherche et relance de l'ingestion"124 actions={actions}125 />126127 {running && (128 <Card className="mb-4 flex items-center gap-3 border-brand-300 bg-brand-50 p-4 dark:border-brand-800 dark:bg-brand-900/30">129 <Spinner className="text-brand-600 dark:text-brand-300" />130 <p className="text-sm text-brand-800 dark:text-brand-200">131 {running === "force" ? "Réindexation complète en cours" : "Ingestion en cours"} — cela peut prendre de 1 à 3 minutes.132 Merci de patienter, ne quittez pas cette page.133 </p>134 </Card>135 )}136137 {runError && <div className="mb-4"><ErrorBanner message={runError} /></div>}138139 {result && (140 <Card className="mb-4 p-4">141 <div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-[13px]">142 <Badge tone={result.errors.length > 0 ? "amber" : "green"}>143 {result.errors.length > 0 ? "Terminée avec avertissements" : "Ingestion terminée"}144 </Badge>145 <span className="text-muted">Fichiers examinés : <b className="tabular-nums text-fg">{fmtInt(result.scanned)}</b></span>146 <span className="text-muted">Ingérés : <b className="tabular-nums text-fg">{fmtInt(result.ingested)}</b></span>147 <span className="text-muted">Ignorés (inchangés) : <b className="tabular-nums text-fg">{fmtInt(result.skipped)}</b></span>148 <span className="text-muted">Fragments créés : <b className="tabular-nums text-fg">{fmtInt(result.chunks)}</b></span>149 <button150 type="button"151 onClick={() => setShowResultReport((v) => !v)}152 className="text-[13px] font-medium text-brand-600 hover:underline dark:text-brand-300"153 >154 {showResultReport ? "Masquer le rapport" : "Voir le rapport"}155 </button>156 </div>157 {result.errors.length > 0 && (158 <ul className="mt-2 list-disc space-y-0.5 pl-5 text-[12.5px] text-red-700 dark:text-red-400">159 {result.errors.map((e, i) => (160 <li key={i}><span className="font-mono">{e.path}</span> — {e.error}</li>161 ))}162 </ul>163 )}164 {showResultReport && (165 <pre className="mt-3 max-h-72 overflow-auto rounded-lg border border-app bg-surface-1 p-3 text-[12px] leading-relaxed text-fg dark:bg-brand-950">166 {result.report || "(rapport vide)"}167 </pre>168 )}169 </Card>170 )}171172 {loading ? (173 <div className="space-y-3">174 <Skeleton className="h-8 w-48" />175 <Skeleton className="h-40" />176 <Skeleton className="h-40" />177 </div>178 ) : error ? (179 <ErrorBanner message={error} onRetry={reload} />180 ) : grouped.size === 0 ? (181 <Card>182 <EmptyState183 icon={<FileText />}184 title="Aucun document indexé"185 description="Lancez l'ingestion pour indexer le matériel des cours (diapositives, plans, glossaires…)."186 action={<Button size="sm" onClick={() => launch(false)} disabled={running !== null}>Relancer l'ingestion</Button>}187 />188 </Card>189 ) : (190 <div className="space-y-6">191 {[...grouped.entries()].map(([course, spaces]) => (192 <section key={course}>193 <h2 className="mb-2 text-[15px] font-semibold text-fg">{course}</h2>194 <div className="space-y-4">195 {[...spaces.entries()].map(([space, docs]) => (196 <Card key={space} className="overflow-hidden">197 <div className="flex items-center gap-2.5 border-b border-app px-4 py-2.5">198 <SpaceBadge space={space} />199 <span className="text-[12px] text-muted">200 {fmtInt(docs.length)} document{docs.length > 1 ? "s" : ""} ·{" "}201 {fmtInt(docs.reduce((s, d) => s + d.chunk_count, 0))} fragments202 </span>203 </div>204 <div className="overflow-x-auto">205 <table className="w-full text-[13px]">206 <thead>207 <tr className="border-b border-app text-left text-[12px] text-muted">208 <th className="px-4 py-2 font-medium">Fichier</th>209 <th className="px-3 py-2 font-medium">Type</th>210 <th className="px-3 py-2 text-right font-medium">Semaine</th>211 <th className="px-3 py-2 text-right font-medium">Fragments</th>212 <th className="px-3 py-2 font-medium">Statut</th>213 <th className="px-4 py-2 font-medium">Ingéré le</th>214 </tr>215 </thead>216 <tbody>217 {docs.map((d) => (218 <tr key={d.id} className="border-b border-app last:border-0">219 <td className="px-4 py-2">220 <p className="font-medium text-fg">{d.title || d.filename}</p>221 <p className="font-mono text-[11.5px] text-muted">{d.filename}</p>222 </td>223 <td className="px-3 py-2 text-muted">{DOC_TYPE_LABEL[d.doc_type] ?? d.doc_type}</td>224 <td className="px-3 py-2 text-right tabular-nums text-muted">{d.week ?? "—"}</td>225 <td className="px-3 py-2 text-right tabular-nums text-fg">{fmtInt(d.chunk_count)}</td>226 <td className="px-3 py-2">227 <Badge tone={statusTone(d.status)}>{d.status === "ok" ? "OK" : d.status}</Badge>228 {d.error && <p className="mt-0.5 max-w-[220px] text-[11.5px] text-red-600 dark:text-red-400">{d.error}</p>}229 </td>230 <td className="whitespace-nowrap px-4 py-2 tabular-nums text-muted">{fmtDate(d.ingested_at)}</td>231 </tr>232 ))}233 </tbody>234 </table>235 </div>236 </Card>237 ))}238 </div>239 </section>240 ))}241 </div>242 )}243244 {!loading && !error && (245 <section className="mt-8">246 <SectionTitle sub="10 dernières exécutions de l'ingestion">Historique des exécutions</SectionTitle>247 {(data?.runs.length ?? 0) === 0 ? (248 <Card className="p-5">249 <p className="text-sm text-muted">Aucune exécution enregistrée.</p>250 </Card>251 ) : (252 <Card className="overflow-hidden">253 <div className="overflow-x-auto">254 <table className="w-full text-[13px]">255 <thead>256 <tr className="border-b border-app text-left text-[12px] text-muted">257 <th className="px-4 py-2 font-medium">#</th>258 <th className="px-3 py-2 font-medium">Démarrée</th>259 <th className="px-3 py-2 font-medium">Terminée</th>260 <th className="px-3 py-2 font-medium">Déclenchée par</th>261 <th className="px-3 py-2 text-right font-medium">Examinés</th>262 <th className="px-3 py-2 text-right font-medium">Ingérés</th>263 <th className="px-3 py-2 text-right font-medium">Ignorés</th>264 <th className="px-3 py-2 text-right font-medium">Fragments</th>265 <th className="px-3 py-2 font-medium">Statut</th>266 <th className="px-4 py-2 font-medium">Rapport</th>267 </tr>268 </thead>269 <tbody>270 {(data?.runs ?? []).map((r) => (271 <RunRow272 key={r.id}273 run={r}274 expanded={expandedRun === r.id}275 onToggle={() => setExpandedRun(expandedRun === r.id ? null : r.id)}276 />277 ))}278 </tbody>279 </table>280 </div>281 </Card>282 )}283 </section>284 )}285 </div>286 );287}288289function RunRow({ run, expanded, onToggle }: { run: Run; expanded: boolean; onToggle: () => void }) {290 return (291 <>292 <tr className={cn("border-b border-app", !expanded && "last:border-0")}>293 <td className="px-4 py-2 tabular-nums text-muted">{run.id}</td>294 <td className="whitespace-nowrap px-3 py-2 tabular-nums text-fg">{fmtDate(run.started_at)}</td>295 <td className="whitespace-nowrap px-3 py-2 tabular-nums text-muted">{run.finished_at ? fmtDate(run.finished_at) : "—"}</td>296 <td className="px-3 py-2 font-mono text-[12px] text-muted">{run.triggered_by}</td>297 <td className="px-3 py-2 text-right tabular-nums text-muted">{fmtInt(run.files_scanned)}</td>298 <td className="px-3 py-2 text-right tabular-nums text-fg">{fmtInt(run.files_ingested)}</td>299 <td className="px-3 py-2 text-right tabular-nums text-muted">{fmtInt(run.files_skipped)}</td>300 <td className="px-3 py-2 text-right tabular-nums text-fg">{fmtInt(run.chunks_created)}</td>301 <td className="px-3 py-2"><Badge tone={statusTone(run.status)}>{run.status}</Badge></td>302 <td className="px-4 py-2">303 <button304 type="button"305 onClick={onToggle}306 aria-expanded={expanded}307 className="inline-flex items-center gap-1 text-[12.5px] font-medium text-brand-600 hover:underline dark:text-brand-300"308 >309 {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}310 {expanded ? "Replier" : "Déplier"}311 </button>312 </td>313 </tr>314 {expanded && (315 <tr className="border-b border-app last:border-0">316 <td colSpan={10} className="bg-surface-1 px-4 py-3 dark:bg-brand-950/60">317 <pre className="max-h-80 overflow-auto whitespace-pre-wrap text-[12px] leading-relaxed text-fg">318 {run.report || "(aucun rapport)"}319 </pre>320 </td>321 </tr>322 )}323 </>324 );325}326