SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
14.1 KB · 326 lines tsx
Raw Blame History
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&nbsp;: <b className="tabular-nums text-fg">{fmtInt(result.scanned)}</b></span>146            <span className="text-muted">Ingérés&nbsp;: <b className="tabular-nums text-fg">{fmtInt(result.ingested)}</b></span>147            <span className="text-muted">Ignorés (inchangés)&nbsp;: <b className="tabular-nums text-fg">{fmtInt(result.skipped)}</b></span>148            <span className="text-muted">Fragments créés&nbsp;: <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