All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
פאנל ב-/operations שמראה אילו סוכני Paperclip עובדים כעת (רצים+בתור), הפלט החי
שלהם, ושליטה מבוקרת: עצירת ריצה, איפוס session. סוגר את הנקודה-העיוורת שבה drain
מונע-סוכן (למשל ריקון תור הלכות ע"י ה-CEO heartbeat) עוקף את בקרת /operations
שמכירה רק שירותי pm2, והפלט הגולמי נגיש רק ב-Paperclip UI.
מקור-נתונים: Paperclip heartbeat-runs API (אומת חי):
GET /api/companies/{cid}/live-runs — רצים+בתור (agentName/status/issue/outputSilence)
GET /api/heartbeat-runs/{id}/log — NDJSON של פלט הסוכן
GET /api/heartbeat-runs/{id}/events — timeline
POST /api/heartbeat-runs/{id}/cancel — עצירה מבוקרת (לא kill — מכבד watchdog+checkpoint)
POST /api/agents/{id}/runtime-state/reset-session
ארכיטקטורה (G12/INV-PORT1): כל המגע החדש עם Paperclip דרך השער בלבד —
web/paperclip_client.py (shell) → re-export ב-web/agent_platform_port.py →
web/app.py צורך מהשער. leak_guard.py עובר (seam שלם). אסור kill ישיר על
process_pid (עוקף את השער).
Backend:
- paperclip_client: list_live_runs / get_run_log / get_run_events / cancel_run / reset_agent_session
- agent_platform_port: re-export pc_list_live_runs / pc_get_run_log / pc_get_run_events / pc_cancel_run / pc_reset_agent_session
- app.py: GET /api/operations/agents (אגרגציה CMP+CMPA, עמיד לכשל-חברה),
GET .../runs/{id}/log, GET .../runs/{id}/events, POST .../runs/{id}/cancel,
POST .../agents/{id}/reset-session
Frontend: פאנל "סוכנים פעילים" ב-/operations (polling 4s) + dialog ללוג חי
(פרסור NDJSON→טקסט קריא) + כפתורי עצור/אפס. הוספת hooks ל-operations.ts.
בטיחות: cancel על דריינר הלכות בטוח — חילוץ checkpointed per-chunk + resumable
+ self-heal לשורות processing.
Invariants: מקיים G12/INV-PORT1 (שער-הפלטפורמה). נוגע X6 (UI↔API).
api:types יורץ אחרי deploy (openapi.json חי).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
682 lines
26 KiB
TypeScript
682 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import Link from "next/link";
|
||
import { AppShell } from "@/components/app-shell";
|
||
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">
|
||
כמו "שירותים" ב-Windows. דמון = שירות רץ-תמיד (הפעל-מחדש/עצור/הפעל).
|
||
תזמון (cron) = רץ לפי לוח-זמנים ("הרץ עכשיו" להרצה מיידית, ומתג
|
||
הפעלה/כיבוי של התזמון).
|
||
</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,
|
||
}: {
|
||
title: string;
|
||
desc: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-5 py-4 space-y-2.5">
|
||
<div>
|
||
<h3 className="text-navy text-sm font-semibold mb-0.5">{title}</h3>
|
||
<p className="text-ink-muted text-[0.72rem]">{desc}</p>
|
||
</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]">
|
||
<Badge variant="default" className="font-normal">רצים {data.running}</Badge>
|
||
<Badge variant="secondary" className="font-normal">בתור {data.queued}</Badge>
|
||
</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="הלכות שחולצו, ממתינות להכרעת דפנה ב-/approvals — שער-אנושי, לא תהליך"
|
||
>
|
||
<StatusRow by={data.pipelines.halacha_review.by_status} />
|
||
</PipelineCard>
|
||
|
||
<PipelineCard
|
||
title="פסיקה חסרה"
|
||
desc="פערים בקורפוס — נסגרים אוטומטית כשנקלטים"
|
||
>
|
||
<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>
|
||
</>
|
||
)}
|
||
</section>
|
||
</AppShell>
|
||
);
|
||
}
|