Checkpoint: Préparation recette : ajout du journal classification_errors, exposition de la cause technique des fallbacks IA, enregistrement non bloquant depuis le moteur RSS, écran d’administration Erreurs IA, route protégée et migration 0013. Validation locale : 28 tests, TypeScript et build de production réussis.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"timestamp": 1786998105509,
|
||||
"version": "de5930f0"
|
||||
"timestamp": 1787039351851,
|
||||
"version": "85944a51"
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const AAPDashboard = lazy(() => import("./pages/AAPDashboard"));
|
||||
const Settings = lazy(() => import("./pages/Settings"));
|
||||
const UsersAdmin = lazy(() => import("./pages/UsersAdmin"));
|
||||
const ImportLogs = lazy(() => import("./pages/ImportLogs"));
|
||||
const ClassificationErrors = lazy(() => import("./pages/ClassificationErrors"));
|
||||
const BoiteAIdees = lazy(() => import("@/pages/BoiteAIdees"));
|
||||
const RssFeeds = lazy(() => import("@/pages/RssFeeds"));
|
||||
const AzureCallback = lazy(() => import("@/pages/AzureCallback"));
|
||||
@@ -104,6 +105,16 @@ function LogsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ClassificationErrorsPage() {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<DashboardWrapper>
|
||||
<ClassificationErrors />
|
||||
</DashboardWrapper>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function BoiteAIdeesPage() {
|
||||
return (
|
||||
<AuthGuard>
|
||||
@@ -139,6 +150,7 @@ function Router() {
|
||||
<Route path="/admin/settings" component={SettingsPage} />
|
||||
<Route path="/admin/users" component={UsersPage} />
|
||||
<Route path="/admin/logs" component={LogsPage} />
|
||||
<Route path="/admin/classification-errors" component={ClassificationErrorsPage} />
|
||||
<Route path="/boite-a-idees" component={BoiteAIdeesPage} />
|
||||
<Route path="/admin/rss" component={RssFeedsPage} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
X,
|
||||
Lightbulb,
|
||||
Rss,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -64,6 +65,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
defaultOpen: false,
|
||||
items: [
|
||||
{ label: "Logs d'import", href: "/admin/logs", icon: <Activity size={16} />, adminOnly: true },
|
||||
{ label: "Erreurs IA", href: "/admin/classification-errors", icon: <AlertTriangle size={16} />, adminOnly: true },
|
||||
{ label: "Utilisateurs", href: "/admin/users", icon: <Users size={16} />, adminOnly: true },
|
||||
{ label: "Flux RSS", href: "/admin/rss", icon: <Rss size={16} />, adminOnly: true },
|
||||
{ label: "Paramètres", href: "/admin/settings", icon: <Settings size={16} />, adminOnly: true },
|
||||
|
||||
145
client/src/pages/ClassificationErrors.tsx
Normal file
145
client/src/pages/ClassificationErrors.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight, ExternalLink, Loader2, RefreshCw, Rss } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ClassificationError {
|
||||
id: number;
|
||||
feedName: string;
|
||||
feedType: "veille" | "aap";
|
||||
articleTitle: string;
|
||||
articleUrl: string | null;
|
||||
errorMessage: string;
|
||||
occurredAt: Date;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const typeConfig = {
|
||||
veille: { label: "Veille", className: "bg-blue-100 text-blue-800 border-blue-200" },
|
||||
aap: { label: "AAP", className: "bg-violet-100 text-violet-800 border-violet-200" },
|
||||
};
|
||||
|
||||
function formatDate(value: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
/** Rapport des erreurs LLM ayant déclenché le fallback de classification RSS. */
|
||||
export default function ClassificationErrors() {
|
||||
const [page, setPage] = useState(1);
|
||||
const errorsQuery = trpc.rss.classificationErrors.useQuery({ page, pageSize: PAGE_SIZE });
|
||||
const errors = (errorsQuery.data?.errors ?? []) as ClassificationError[];
|
||||
const total = errorsQuery.data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-up">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle size={22} className="text-amber-600" />
|
||||
<h1 className="text-2xl font-bold text-foreground">Erreurs de classification</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fallbacks IA détectés pendant la lecture des flux RSS. Les articles concernés ont été importés avec les règles de repli.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={() => errorsQuery.refetch()} disabled={errorsQuery.isFetching}>
|
||||
<RefreshCw size={15} className={cn(errorsQuery.isFetching && "animate-spin")} />
|
||||
Actualiser
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Card className="border-amber-200 bg-amber-50/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-amber-700 mb-1">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="text-xs font-medium">Fallbacks enregistrés</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-amber-800">{total}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-primary mb-1">
|
||||
<Rss size={16} />
|
||||
<span className="text-xs font-medium text-muted-foreground">Comportement de sécurité</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">Import maintenu via règles de repli</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{errorsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 size={28} className="animate-spin text-primary" /></div>
|
||||
) : errorsQuery.isError ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center gap-2">
|
||||
<AlertTriangle size={40} className="text-destructive/60" />
|
||||
<p className="font-medium text-foreground">Le rapport ne peut pas être chargé</p>
|
||||
<p className="text-sm text-muted-foreground">{errorsQuery.error.message}</p>
|
||||
</div>
|
||||
) : errors.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<AlertTriangle size={40} className="text-emerald-500/60 mb-3" />
|
||||
<p className="font-medium text-foreground">Aucune erreur de classification</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Les prochains fallbacks IA apparaîtront ici.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground whitespace-nowrap">Date</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-24">Flux</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Article</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Cause technique</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{errors.map((error) => {
|
||||
const type = typeConfig[error.feedType];
|
||||
return (
|
||||
<tr key={error.id} className="hover:bg-muted/20 transition-colors align-top">
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">{formatDate(error.occurredAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<Badge variant="outline" className={cn("text-xs", type.className)}>{type.label}</Badge>
|
||||
<p className="text-xs text-muted-foreground max-w-40 truncate" title={error.feedName}>{error.feedName}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-sm">
|
||||
{error.articleUrl ? (
|
||||
<a href={error.articleUrl} target="_blank" rel="noreferrer" className="inline-flex items-start gap-1 font-medium text-primary hover:underline">
|
||||
<span>{error.articleTitle}</span><ExternalLink size={13} className="mt-0.5 shrink-0" />
|
||||
</a>
|
||||
) : <span className="font-medium text-foreground">{error.articleTitle}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-destructive max-w-md break-words">{error.errorMessage}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page === 1}><ChevronLeft size={14} /></Button>
|
||||
<span className="text-sm text-muted-foreground px-2">Page {page} / {totalPages}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((value) => Math.min(totalPages, value + 1))} disabled={page === totalPages}><ChevronRight size={14} /></Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user