Files
legal-ai/web-ui/src/app/operations/page.tsx
Chaim 6e69c1dc38
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 9s
feat(ia): IA גל-2 — איחוד-משטחים: ערוץ-למידה אחד · /operations⊇/diagnostics · MET-2/3 (#131, X17)
גל-2 מבקלוג #127 — איחוד-משטחים לפי משטח-היעד של X17. מקיים INV-IA1/IA3/IA4 +
דלתות-הספ (X6 INV-UI7/8, 07-learning §0.4, 00-constitution G2). שומר G10/INV-LRN1
(לא הוסר שום שער-אנושי — רק שער/דגל כפול).

א) תיבת-אישור אחת (INV-IA1): כרטיסי "אישור הלכות"+"פסיקה חסרה" ב-/operations
   מצביעים ל-/approvals (לתיבת-האישורים ←) — /operations מנטר, /approvals מחליט.

ב) ערוץ-למידה אחד (INV-IA3): הוסר applied_to_skill end-to-end —
   - UI: כפתור "סמן כ'אומץ'" + badge "אומץ" ב-lessons-tab; badge ב-curator-portrait.
   - API: LessonPatch, _lesson_to_json, patch call, curator recent_findings (→review_status).
   - db.py: list/add/update_decision_lesson לא בוחרים/כותבים applied_to_skill;
     הפרמטר הוסר. העמודה+אינדקס נשמרים (back-compat, ללא migration), מסומנים DEPRECATED.
   - types: DecisionLesson/LessonPatch/CuratorFinding.
   review_status='approved' = הסטטוס היחיד "זורם-לכותב" (INV-LRN1, #126).

ג) MET-2/3 lost-update (INV-IA3): _append_methodology_override רץ עכשיו בטרנזקציה
   אחת עם SELECT ... FOR UPDATE — אין read-modify-write מתפצל מול עורך-המתודולוגיה
   או promote מקביל. /methodology = העורך-הקנוני; promote מבטל את ה-cache (גל-1 MET-1).

ד) /operations⊇/diagnostics (INV-IA4): גוף /diagnostics חולץ ל-<SystemHealthSection/>
   ומורנדר ב-/operations תחת "בריאות-מערכת". /diagnostics → redirect ל-/operations.
   /diagnostics הוסר מהניווט. משטח-ניטור יחיד.

ה) דלתות-ספ (≥3 מקורות ב-X17, אושר ע"י חיים /goal):
   - X6: INV-UI7 (aggregate=SSoT, mutation מבטל queryKey) + INV-UI8 (render-or-remove, חלקיות).
   - 07-learning §0.4: שער-אחד + טרנזקציה-אחת + applied_to_skill מוסר.
   - 00-constitution G2: תאום-המתודולוגיה כהפרה-ידועה-ממותנת.
   - X17 דלתות-ספ סומנו  קודדו.

בדיקות: py_compile app.py + db.py ✓ · tsc --noEmit ✓ · eslint ✓ (לבד מ-learning-panel:109
קיים-מראש). next build נכשל ב-worktree רק בגלל symlink (Turbopack) — Docker/CI תקין.
api:types יתרענן בדפלוי (curator/lessons אינם response-modeled; הטיפוסים יד-כתובים עודכנו).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:04:57 +00:00

728 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { SystemHealthSection } from "@/components/operations/system-health-section";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
useOperations,
useServiceAction,
useDrainToggle,
useAgentRuns,
useRunLog,
useCancelRun,
useResetAgentSession,
type OpsService,
type OperationsSnapshot,
type PipelineStats,
type AgentRun,
} from "@/lib/api/operations";
function mb(bytes: number): string {
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
}
function ago(ms: number): string {
if (!ms) return "—";
const secs = Math.floor((Date.now() - ms) / 1000);
if (secs < 60) return `לפני ${secs}ש׳`;
if (secs < 3600) return `לפני ${Math.floor(secs / 60)}ד׳`;
if (secs < 86400) return `לפני ${Math.floor(secs / 3600)}ש׳`;
return `לפני ${Math.floor(secs / 86400)}י׳`;
}
// Hebrew labels for the raw status strings the backend reports.
const STATUS_HE: Record<string, string> = {
online: "פעיל",
stopped: "עצור",
errored: "שגיאה",
launching: "עולה",
pending: "ממתין",
processing: "בעיבוד",
running: "רץ",
done: "הושלם",
completed: "הושלם",
failed: "נכשל",
manual: "ידני",
approved: "אושר",
pending_review: "ממתין לאישור",
rejected: "נדחה",
published: "פורסם",
deferred: "נדחה זמנית",
open: "פתוח",
closed: "סגור",
unknown: "לא ידוע",
};
function he(status: string): string {
return STATUS_HE[status] ?? status;
}
const STATUS_VARIANT: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
online: "default",
done: "default",
completed: "default",
approved: "default",
published: "default",
stopped: "secondary",
pending: "secondary",
pending_review: "secondary",
open: "secondary",
running: "outline",
processing: "outline",
launching: "outline",
failed: "destructive",
errored: "destructive",
manual: "destructive",
rejected: "destructive",
};
function StatusBadge({ value, count }: { value: string; count?: number }) {
return (
<Badge variant={STATUS_VARIANT[value] ?? "outline"} className="font-normal">
{he(value)}
{count !== undefined ? <span className="ms-1 font-semibold">{count}</span> : null}
</Badge>
);
}
const SERVICE_LABELS: Record<string, string> = {
"legal-court-fetch-service": "שירות אחזור פסיקה (דפדפן נט המשפט)",
"legal-court-fetch-xvfb": "צג וירטואלי (Xvfb) לדפדפן",
"legal-court-fetch-drain": "תזמון: ניקוז תור אחזור פסיקה (שעתי)",
"legal-metadata-drain": "תזמון: חילוץ מטא-דאטה (Gemini, ×15 דק׳)",
"legal-halacha-drain": "תזמון: חילוץ הלכות (Claude, ×שעתיים)",
"legal-digest-drain": "תזמון: העשרת יומונים (Sonnet, ×שעתיים)",
"legal-reaper": "מנקה תהליכים-יתומים (נגד דליפות זיכרון)",
"legal-chat-service": "שירות צ׳אט אימון (גשר ל-claude CLI)",
};
// ── Process-management panel (the "Windows services" view) ─────────────────
function ServiceControls({
s,
busy,
onAction,
onToggle,
}: {
s: OpsService;
busy: boolean;
onAction: (action: "restart" | "stop" | "start" | "run-now") => void;
onToggle: (disabled: boolean) => void;
}) {
const isCron = !!s.cron;
if (isCron) {
// Cron drain: "run now" (pm2 restart) + an enable/disable switch (DB flag).
return (
<div className="flex items-center gap-2 shrink-0">
<Button
size="xs"
variant="outline"
disabled={busy || s.disabled}
onClick={() => onAction("run-now")}
title={s.disabled ? "הפעל את התזמון כדי להריץ" : "הרץ את ה-drain פעם אחת מיד"}
>
הרץ עכשיו
</Button>
<label className="flex items-center gap-1.5 text-[0.7rem] text-ink-muted cursor-pointer">
<Switch
checked={!s.disabled}
disabled={busy}
onCheckedChange={(on) => {
if (on || confirm(`לכבות את התזמון "${SERVICE_LABELS[s.name] ?? s.name}"?`)) {
onToggle(!on);
}
}}
/>
{s.disabled ? "כבוי" : "פעיל"}
</label>
</div>
);
}
// Daemon: restart / stop / start.
const online = s.status === "online";
return (
<div className="flex items-center gap-1.5 shrink-0">
<Button size="xs" variant="outline" disabled={busy} onClick={() => onAction("restart")}>
הפעל מחדש
</Button>
{online ? (
<Button
size="xs"
variant="ghost"
disabled={busy}
className="text-destructive"
onClick={() => {
if (confirm(`לעצור את "${SERVICE_LABELS[s.name] ?? s.name}"?`)) onAction("stop");
}}
>
עצור
</Button>
) : (
<Button size="xs" variant="ghost" disabled={busy} onClick={() => onAction("start")}>
הפעל
</Button>
)}
</div>
);
}
function ServicesPanel({ data }: { data: OperationsSnapshot }) {
const action = useServiceAction();
const toggle = useDrainToggle();
const busy = action.isPending || toggle.isPending;
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-1">ניהול תהליכי-רקע (pm2)</h2>
<p className="text-ink-muted text-xs mb-4">
כמו &quot;שירותים&quot; ב-Windows. דמון = שירות רץ-תמיד (הפעל-מחדש/עצור/הפעל).
תזמון (cron) = רץ לפי לוח-זמנים (&quot;הרץ עכשיו&quot; להרצה מיידית, ומתג
הפעלה/כיבוי של התזמון).
</p>
{data.services_error ? (
<p className="text-sm text-destructive">{data.services_error}</p>
) : data.services.length === 0 ? (
<p className="text-sm text-ink-muted">אין שירותים.</p>
) : (
<div className="grid gap-2">
{data.services.map((s: OpsService) => {
const isCron = !!s.cron;
return (
<div
key={s.name}
className="flex items-center justify-between gap-3 rounded-md border border-rule-soft bg-rule-soft/30 px-3 py-2"
>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{isCron ? (
<Badge
variant={s.disabled ? "destructive" : "default"}
className="font-normal"
>
{s.disabled ? "כבוי" : "פעיל (מתוזמן)"}
</Badge>
) : (
<StatusBadge value={s.status} />
)}
{s.cron ? (
<span className="text-[0.7rem] text-ink-muted font-mono" dir="ltr">
{s.cron}
</span>
) : null}
</div>
<div className="text-[0.8rem] text-navy truncate mt-0.5">
{SERVICE_LABELS[s.name] ?? s.name}
</div>
<div className="text-[0.66rem] text-ink-muted flex items-center gap-2 flex-wrap">
<span className="font-mono" dir="ltr">
{s.name}
</span>
<span>{mb(s.memory_bytes)}</span>
<span>{s.restarts}</span>
<span>{isCron ? `ריצה אחרונה ${ago(s.uptime_ms)}` : ago(s.uptime_ms)}</span>
</div>
</div>
<ServiceControls
s={s}
busy={busy}
onAction={(a) => action.mutate({ name: s.name, action: a })}
onToggle={(disabled) => toggle.mutate({ name: s.name, disabled })}
/>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
// ── Uniform queue stats ────────────────────────────────────────────────────
function StatTile({
label,
value,
tone,
title,
}: {
label: string;
value: number;
tone: "navy" | "muted" | "amber" | "green" | "red";
title?: string;
}) {
const toneClass = {
navy: "text-navy",
muted: "text-ink-muted",
amber: "text-gold-deep",
green: "text-emerald-600",
red: "text-destructive",
}[tone];
return (
<div
title={title}
className="flex flex-col items-center rounded-md border border-rule-soft px-2.5 py-1.5 min-w-[68px]"
>
<span className={`text-base font-semibold leading-none ${toneClass}`}>{value}</span>
<span className="text-[0.66rem] text-ink-muted mt-1 text-center">{label}</span>
</div>
);
}
function UniformStats({ p }: { p: PipelineStats }) {
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<StatTile
label="בתור"
value={p.queued}
tone="navy"
title="ממתינים שנדרשו במפורש לעיבוד — אלה שה-drain הבא יטפל בהם"
/>
<StatTile
label="ממתין (בקלוג)"
value={p.pending}
tone="muted"
title="כל הפריטים שטרם עובדו (ברירת-מחדל) — לאו דווקא בתור הפעיל"
/>
<StatTile label="בעיבוד" value={p.processing} tone="amber" />
<StatTile label="הושלם" value={p.done} tone="green" />
{p.failed > 0 ? <StatTile label="נכשל" value={p.failed} tone="red" /> : null}
</div>
{p.running_now.length > 0 ? (
<div className="text-[0.74rem] text-navy">
<span className="text-ink-muted">רץ עכשיו: </span>
{p.running_now.join(" · ")}
</div>
) : (
<div className="text-[0.72rem] text-ink-muted">אין פריט בעיבוד כרגע</div>
)}
</div>
);
}
function StatusRow({ by }: { by: Record<string, number> }) {
const entries = Object.entries(by).filter(([, n]) => n > 0);
if (entries.length === 0) return <span className="text-ink-muted text-sm">ריק</span>;
return (
<div className="flex flex-wrap gap-1.5">
{entries
.sort((a, b) => b[1] - a[1])
.map(([k, n]) => (
<StatusBadge key={k} value={k} count={n} />
))}
</div>
);
}
function PipelineCard({
title,
desc,
children,
href,
hrefLabel,
}: {
title: string;
desc: string;
children: React.ReactNode;
// INV-IA1: a human-gate card is a *pointer* — the action lives at `href`
// (/approvals), never duplicated here. /operations only monitors.
href?: string;
hrefLabel?: string;
}) {
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="text-navy text-sm font-semibold mb-0.5">{title}</h3>
<p className="text-ink-muted text-[0.72rem]">{desc}</p>
</div>
{href && (
<Link
href={href}
className="shrink-0 text-[0.72rem] text-gold-deep hover:underline whitespace-nowrap"
>
{hrefLabel ?? "לטיפול ←"}
</Link>
)}
</div>
{children}
</CardContent>
</Card>
);
}
// ── Live agents — who's working now + output + controls ────────────────────
// The platform's own liveness signal → a Hebrew label + tone.
const SILENCE_HE: Record<string, { label: string; tone: string }> = {
ok: { label: "פעיל", tone: "text-emerald-600" },
suspicion: { label: "שקט חשוד", tone: "text-gold-deep" },
critical: { label: "תקוע?", tone: "text-destructive" },
};
/** Best-effort: turn the captured NDJSON stream into readable lines (tail). */
function parseRunLog(content: string, maxLines = 400): string {
if (!content) return "";
const out: string[] = [];
for (const raw of content.split("\n")) {
const line = raw.trim();
if (!line) continue;
let chunk = line;
try {
const wrap = JSON.parse(line);
chunk = typeof wrap.chunk === "string" ? wrap.chunk : line;
} catch {
// not a wrapper line — keep raw
}
// The chunk is often a claude stream-json event; extract the human bits.
for (const part of chunk.split("\n")) {
const p = part.trim();
if (!p) continue;
try {
const ev = JSON.parse(p);
if (ev?.type === "assistant" && ev?.message?.content) {
const txt = (ev.message.content as Array<{ type: string; text?: string }>)
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text)
.join("");
if (txt) out.push(txt);
} else if (ev?.type === "result" && typeof ev.result === "string") {
out.push(`${ev.result}`);
} else if (ev?.type === "system" && ev?.subtype) {
out.push(`· [${ev.subtype}]`);
} else {
out.push(p);
}
} catch {
out.push(p);
}
}
}
return out.slice(-maxLines).join("\n");
}
function RunLogDialog({ run, onClose }: { run: AgentRun | null; onClose: () => void }) {
const { data, isLoading, error } = useRunLog(run?.run_id ?? null);
const text = data ? parseRunLog(data.content) : "";
return (
<Dialog open={!!run} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>פלט הסוכן {run?.agent_name}</DialogTitle>
<DialogDescription>
{run?.company_label} · ריצה <span dir="ltr" className="font-mono">{run?.run_id?.slice(0, 8)}</span> · מתעדכן חי
</DialogDescription>
</DialogHeader>
{isLoading ? (
<Skeleton className="h-72 w-full" />
) : error ? (
<p className="text-sm text-destructive">שגיאה בטעינת הלוג: {String(error)}</p>
) : (
<ScrollArea className="h-[60vh] rounded-md border border-rule-soft bg-rule-soft/20 p-3">
<pre dir="ltr" className="text-[0.72rem] leading-relaxed whitespace-pre-wrap break-words text-navy text-start">
{text || "אין פלט עדיין."}
</pre>
</ScrollArea>
)}
</DialogContent>
</Dialog>
);
}
function LiveAgentsPanel() {
const { data, isLoading } = useAgentRuns();
const cancel = useCancelRun();
const reset = useResetAgentSession();
const [logRun, setLogRun] = useState<AgentRun | null>(null);
const busy = cancel.isPending || reset.isPending;
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<div className="flex items-center justify-between gap-3 mb-1 flex-wrap">
<h2 className="text-navy text-lg mb-0">סוכנים פעילים</h2>
{data ? (
<div className="flex items-center gap-2 text-[0.72rem]">
{/* ADM-6 (INV-IA5): counts sum only the companies that loaded.
When a company errored, mark the totals as a floor ("+") so
the operator isn't shown a shrunken depth as if complete. */}
<Badge variant="default" className="font-normal">
רצים {data.running}{data.errors.length > 0 ? "+" : ""}
</Badge>
<Badge variant="secondary" className="font-normal">
בתור {data.queued}{data.errors.length > 0 ? "+" : ""}
</Badge>
{data.errors.length > 0 ? (
<span
className="text-warn"
title={`ספירה חלקית — חברות שלא נטענו: ${data.errors.join(" · ")}`}
>
חלקי
</span>
) : null}
</div>
) : null}
</div>
<p className="text-ink-muted text-xs mb-4">
מי מבין סוכני-הוועדה עובד כרגע ומה הפלט שלו כולל עבודה שלא קשורה לתיק (כמו
ריקון תור הלכות ע״י ה-CEO). עצירה היא מבוקרת דרך הפלטפורמה (לא kill).
</p>
{isLoading || !data ? (
<Skeleton className="h-24 w-full" />
) : data.runs.length === 0 ? (
<p className="text-sm text-ink-muted">אין סוכן פעיל כרגע.</p>
) : (
<div className="grid gap-2">
{data.errors.length > 0 ? (
<p className="text-[0.72rem] text-destructive">
לא ניתן לטעון חלק מהחברות: {data.errors.join(" · ")}
</p>
) : null}
{data.runs.map((r) => {
const sil = SILENCE_HE[r.silence_level];
const startMs = r.started_at ? Date.parse(r.started_at) : 0;
return (
<div
key={r.run_id}
className="flex items-center justify-between gap-3 rounded-md border border-rule-soft bg-rule-soft/30 px-3 py-2"
>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Badge
variant={r.status === "running" ? "default" : "secondary"}
className="font-normal"
>
{r.status === "running" ? "רץ" : "בתור"}
</Badge>
<span className="text-[0.85rem] text-navy font-semibold">{r.agent_name}</span>
{sil ? <span className={`text-[0.68rem] ${sil.tone}`}> {sil.label}</span> : null}
</div>
<div className="text-[0.66rem] text-ink-muted flex items-center gap-2 flex-wrap mt-0.5">
<span>{r.company_label}</span>
{r.status === "running" && startMs ? <span>החל {ago(startMs)}</span> : null}
{r.invocation_source ? (
<span dir="ltr" className="font-mono">{r.invocation_source}</span>
) : null}
{r.continuation_attempt > 0 ? <span>ניסיון #{r.continuation_attempt + 1}</span> : null}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Button size="xs" variant="outline" onClick={() => setLogRun(r)}>
פלט
</Button>
<Button
size="xs"
variant="ghost"
className="text-destructive"
disabled={busy || r.status !== "running"}
onClick={() => {
if (confirm(`לעצור את הריצה של "${r.agent_name}"? (עצירה מבוקרת)`)) {
cancel.mutate(r.run_id);
}
}}
>
עצור
</Button>
<Button
size="xs"
variant="ghost"
disabled={busy}
title="איפוס session — ה-wakeup הבא יתחיל נקי"
onClick={() => {
if (confirm(`לאפס את ה-session של "${r.agent_name}"?`)) {
reset.mutate(r.agent_id);
}
}}
>
אפס
</Button>
</div>
</div>
);
})}
</div>
)}
</CardContent>
<RunLogDialog run={logRun} onClose={() => setLogRun(null)} />
</Card>
);
}
export default function OperationsPage() {
const { data, isLoading, error } = useOperations();
return (
<AppShell>
<section className="space-y-6">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">תפעול</span>
</nav>
<h1 className="text-navy mb-0">תפעול מה רץ ברקע</h1>
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
כל מה שמוריד ומנתח אוטומטית: שירותי-המארח, משימות-התזמון, ותורי-העבודה.
ניתן להפעיל-מחדש / לעצור / להריץ-עכשיו כל תהליך. מתרענן כל 5 שניות.
</p>
</header>
{error ? (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-5 text-sm text-destructive">
שגיאה בטעינת מצב התפעול: {String(error)}
</CardContent>
</Card>
) : isLoading || !data ? (
<div className="space-y-4">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-40 w-full" />
</div>
) : (
<>
<LiveAgentsPanel />
<ServicesPanel data={data} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<PipelineCard
title="אחזור פסיקה (X13)"
desc="הורדה אוטומטית מנט-המשפט / פורטל-העליון → קורפוס"
>
<UniformStats p={data.pipelines.court_fetch} />
</PipelineCard>
<PipelineCard
title="חילוץ מטא-דאטה"
desc="Gemini Flash — שם/תקציר/תגיות לכל פסיקה"
>
<UniformStats p={data.pipelines.metadata_extraction} />
</PipelineCard>
<PipelineCard
title="חילוץ הלכות"
desc="Claude — הלכות מכל פסיקה (→ אישור יו״ר)"
>
<UniformStats p={data.pipelines.halacha_extraction} />
</PipelineCard>
<PipelineCard
title="העשרת יומונים (רדאר)"
desc="Sonnet — תקציר/תגיות/קישור-לפסיקה לכל יומון"
>
<UniformStats p={data.pipelines.digests} />
<p className="text-[0.72rem] text-ink-muted">
{data.pipelines.digests.linked}/{data.pipelines.digests.total} מקושרים
לפסיקה
</p>
</PipelineCard>
<PipelineCard
title="אישור הלכות (שער יו״ר)"
desc="הלכות שחולצו, ממתינות להכרעת דפנה — שער-אנושי, לא תהליך"
href="/approvals"
hrefLabel="לתיבת-האישורים ←"
>
<StatusRow by={data.pipelines.halacha_review.by_status} />
</PipelineCard>
<PipelineCard
title="פסיקה חסרה"
desc="פערים בקורפוס — נסגרים אוטומטית כשנקלטים"
href="/approvals"
hrefLabel="לתיבת-האישורים ←"
>
<StatusRow by={data.pipelines.missing_precedents.by_status} />
</PipelineCard>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4">
<h3 className="text-navy text-sm font-semibold mb-2">
אחזורים אחרונים (תור)
</h3>
<div className="space-y-1.5">
{data.pipelines.court_fetch.recent.map((j) => (
<div
key={j.case_number_norm}
className="flex items-start justify-between gap-2 text-[0.78rem] border-b border-rule-soft pb-1.5 last:border-0"
>
<div className="min-w-0">
<StatusBadge value={j.status} />
<span className="text-navy ms-2">
{j.citation_raw?.slice(0, 48) || j.case_number_norm}
</span>
{j.error ? (
<div className="text-ink-muted text-[0.68rem] truncate">
{j.error.slice(0, 80)}
</div>
) : null}
</div>
<span className="text-ink-muted text-[0.66rem] shrink-0" dir="ltr">
{(j.tier || "").slice(0, 7)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4">
<h3 className="text-navy text-sm font-semibold mb-2">
נקלטו לאחרונה (מבתי-משפט)
</h3>
<div className="space-y-1.5">
{data.pipelines.ingested_recent.map((r) => (
<div
key={r.case_number}
className="text-[0.78rem] text-navy border-b border-rule-soft pb-1.5 last:border-0 truncate"
>
{r.case_number}
<span className="text-ink-muted text-[0.66rem] ms-2" dir="ltr">
{r.created_at?.slice(0, 16).replace("T", " ")}
</span>
</div>
))}
{data.pipelines.ingested_recent.length === 0 ? (
<p className="text-ink-muted text-sm">עדיין אין.</p>
) : null}
</div>
</CardContent>
</Card>
</div>
{/* INV-IA4: the former /diagnostics surface, folded in here — one
monitoring intent, one surface. /diagnostics now redirects here. */}
<div className="space-y-3 pt-2">
<h2 className="text-navy text-lg mb-0">בריאות-מערכת</h2>
<p className="text-ink-muted text-sm">
מצב ה-DB, תורי-אישור, ומסמכים שנכשלו/תקועים. (אוחד מ״אבחון״.)
</p>
<SystemHealthSection />
</div>
</>
)}
</section>
</AppShell>
);
}