Files
legal-ai/web-ui/src/app/operations/page.tsx
Chaim cf7c918bed
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 30s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(operations): פאנל בריאות-הסוכנים + הסלמות אחרונות ב-/operations (#218+#222 UI)
הטמעת מוקאפ 02e (מאושר Claude-Design X17) — נראות שרת-הצד שנבנתה כ-backend:
- backend: get_recent_escalations() ב-paperclip_client — משחזר הסלמות מהערות
  author_type='system' (severity+reason מנותחים מהגוף, identifier מ-join); Port
  pc_get_recent_escalations (read-only); endpoint GET /api/operations/agents/escalations.
- frontend: useAgentHealth + useRecentEscalations (טיפוסים מקומיים כמו שאר
  operations.ts) + AgentHealthPanel ב-/operations תחת "בריאות-הסוכנים":
  4 stat-tiles (זומבי/תקוע/עובד/רגוע), שורות-בעיה worst-first, וקארד
  הסלמות-אוטומטיות-אחרונות (severity+תיק+סיבה+מתי).

דיוק מול המוקאפ: issue מוסלם עובר לבעלות-חיים→יוצא מרשימת-הבריאות, לכן
שורות-הבריאות = זומבים טרם-הסלמה וקארד-ההסלמות = מטופלים (משלימים).

Invariants: G12 (fetch מהמעטפת, המגע מהשער; UI צורך endpoint דומייני), G2
(מקור-בריאות/הסלמות יחיד). שער-עיצוב: מוקאפ 02e אושר לפני קוד (feedback_claude_design_gate).

בדיקות: 13 pytest (2 חדשות ל-get_recent_escalations: parse severity/reason,
fallback). frontend: tsc --noEmit + eslint נקיים (build מלא ב-CI/deploy —
symlink node_modules שובר next build ב-worktree). api:types לא נדרש (operations.ts
טיפוסים ידניים); ה-endpoint נפרס אטומית עם ה-UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 04:44:50 +00:00

1135 lines
45 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { AgentAdaptersPanel } from "@/components/operations/agent-adapters-panel";
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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
useOperations,
useServiceAction,
useDrainToggle,
useDrainBurst,
useAgentRuns,
useRunLog,
useCancelRun,
useResetAgentSession,
useAgentHealth,
useRecentEscalations,
type OpsService,
type OperationsSnapshot,
type PipelineStats,
type AgentRun,
type SubscriptionUsage,
type UsageWindow,
type AgentHealthState,
} from "@/lib/api/operations";
import { formatTime, israelParts } from "@/lib/format-date";
function mb(bytes: number): string {
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
}
// claude.ai subscription usage — the fuel gauge for every agent/drain.
// Bar turns amber > 75% and red > 90%; the halacha drain auto-pauses at 100%
// and resumes on its own when a window resets.
function usageResetLabel(iso: string | null): string {
if (!iso) return "—";
return `איפוס ${formatTime(iso)}`;
}
function UsageMeter({ label, w }: { label: string; w?: UsageWindow | null }) {
const util = w?.utilization;
const active = typeof util === "number";
const pct = active ? Math.min(100, Math.max(0, util as number)) : 0;
const fill = pct >= 90 ? "bg-danger" : pct >= 75 ? "bg-warn" : "bg-gold";
return (
<div>
<div className="flex items-baseline justify-between text-[13px] text-ink-soft mb-1.5">
<span>{label}</span>
{active ? (
<b className="text-navy tabular-nums text-[15px] font-bold">{Math.round(pct)}%</b>
) : (
<b className="text-ink-muted text-sm font-semibold"></b>
)}
</div>
<div className="h-[7px] overflow-hidden rounded-full bg-rule-soft">
<span className={`block h-full rounded-full ${fill}`} style={{ width: `${pct}%` }} />
</div>
<div className="mt-1.5 text-[11.5px] text-ink-muted">
{active ? usageResetLabel(w?.resets_at ?? null) : "לא פעיל כרגע"}
</div>
</div>
);
}
function SubscriptionUsagePanel({ usage }: { usage: SubscriptionUsage }) {
if (!usage) return null; // undocumented endpoint unreachable — show nothing
return (
<Card className="border-rule">
<CardContent className="px-5 py-4">
<div className="grid gap-6 sm:grid-cols-3">
<UsageMeter label="חלון 5-שעות" w={usage.five_hour} />
<UsageMeter label="שבועי · כל המודלים" w={usage.seven_day} />
<UsageMeter label="שבועי · Sonnet" w={usage.seven_day_sonnet} />
</div>
<div className="mt-3 border-t border-rule-soft pt-2.5 text-[11.5px] text-ink-muted">
המנוי המשותף שמפעיל את כל הסוכנים והדריינרים. הפס נצבע בענבר מעל 75%
ובאדום מעל 90%. דריינר-ההלכות משתהה אוטומטית כשחלון מגיע ל-100% וממשיך
לבד כשהוא מתאפס.
</div>
</CardContent>
</Card>
);
}
// mockup 02: every region opens with a navy section heading (h2, 18px) — the
// page reads as a sequence of titled sections rather than a stack of cards.
function SectionHeader({ children }: { children: React.ReactNode }) {
return <h2 className="text-navy text-lg font-semibold mb-3 mt-2">{children}</h2>;
}
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, חלון-לילה 23:0005:00)",
"legal-halacha-supervisor": "ניהול בריאות חילוץ-ההלכות + בקרת BURST (×15 דק׳)",
"legal-digest-drain": "תזמון: העשרת יומונים (Sonnet, ×שעתיים)",
"legal-reaper": "מנקה תהליכים-יתומים (נגד דליפות זיכרון)",
"legal-chat-service": "שירות צ׳אט אימון (גשר ל-claude CLI)",
};
// ── BURST control (halacha drain) — manual "run continuously now until X" ──
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
/** Format a Date as a datetime-local input value ("YYYY-MM-DDTHH:MM"), local tz. */
function toLocalInput(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
/** The upcoming Saturday at 18:00, local time. */
function nextSaturday18(): Date {
const d = new Date();
d.setHours(18, 0, 0, 0);
d.setDate(d.getDate() + ((6 - d.getDay() + 7) % 7)); // Sat = getDay() 6
if (d.getTime() <= Date.now()) d.setDate(d.getDate() + 7);
return d;
}
const HE_DAYS = ["א׳", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳"];
function fmtDeadline(iso: string): string {
const p = israelParts(iso);
if (!p) return "—";
return `${HE_DAYS[p.weekday]} ${pad2(p.day)}/${pad2(p.month)} ${pad2(p.hour)}:${pad2(p.minute)}`;
}
function BurstControl({ s }: { s: OpsService }) {
const burst = useDrainBurst();
const [open, setOpen] = useState(false);
const [until, setUntil] = useState(() => toLocalInput(nextSaturday18()));
// The backend (supervisor) is the authority on expiry — it NULLs burst_until at
// the deadline (snapshot refetches every 5s), so presence ⇒ active. Avoids an
// impure Date.now() during render.
const active = !!s.burst_until;
if (active) {
return (
<span className="flex items-center gap-1.5 shrink-0">
<Badge variant="outline" className="border-gold/40 bg-gold-wash text-gold-deep text-[0.7rem] font-semibold whitespace-nowrap">
BURST · עד {fmtDeadline(s.burst_until!)}
</Badge>
<Button
size="xs"
variant="ghost"
className="text-destructive"
disabled={burst.isPending}
onClick={() => {
if (confirm("לעצור את ה-BURST? החילוץ יחזור לחלון-הלילה (23:0005:00) והדריינר ייעצר.")) {
burst.mutate({ name: s.name, action: "off" });
}
}}
>
עצור BURST
</Button>
</span>
);
}
return (
<>
<Button
size="xs"
variant="outline"
className="border-gold text-gold-deep bg-gold-wash hover:bg-gold-wash/70"
disabled={busyBurst(burst.isPending, s.disabled)}
title={s.disabled ? "הפעל את התזמון תחילה" : "הרץ את חילוץ-ההלכות ברצף עכשיו עד מועד נבחר"}
onClick={() => setOpen(true)}
>
הפעל BURST
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"> הפעלת BURST חילוץ הלכות רצוף</DialogTitle>
<DialogDescription className="text-start leading-relaxed">
הדריינר ירוץ <b>ברצף מעכשיו</b> (מתעלם מחלון-הלילה) ויעבד את תור-ההלכות עד המועד שתבחר,
תוך ניצול מכסת-Claude הפנויה. נעצר אוטומטית במועד או ידנית בכפתור &quot;עצור BURST&quot;.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="burst-until">רוץ עד</Label>
<Input
id="burst-until"
type="datetime-local"
value={until}
dir="ltr"
className="text-end"
onChange={(e) => setUntil(e.target.value)}
/>
<div className="flex flex-wrap gap-1.5 pt-1">
<button type="button" className="text-[0.72rem] text-gold-deep bg-gold-wash border border-gold rounded-full px-2.5 py-0.5 font-semibold hover:border-gold-deep"
onClick={() => setUntil(toLocalInput(nextSaturday18()))}>
שבת 18:00 <span className="font-normal text-ink-muted">(ברירת-מחדל)</span>
</button>
<button type="button" className="text-[0.72rem] text-gold-deep bg-gold-wash border border-rule rounded-full px-2.5 py-0.5 hover:border-gold"
onClick={() => setUntil(toLocalInput(new Date(Date.now() + 5 * 3600 * 1000)))}>
עוד 5 שעות
</button>
<button type="button" className="text-[0.72rem] text-gold-deep bg-gold-wash border border-rule rounded-full px-2.5 py-0.5 hover:border-gold"
onClick={() => {
const m = new Date();
m.setHours(24, 0, 0, 0);
setUntil(toLocalInput(m));
}}>
חצות
</button>
</div>
<p className="text-[0.72rem] text-ink-muted bg-info/10 rounded-md px-2.5 py-2 leading-snug">
הפעלה ידנית בלבד המערכת לא תפעיל BURST לבד. נכנס לתוקף תוך 15 דק׳ (מחזור המתזמר).
</p>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
ביטול
</Button>
<Button
size="sm"
className="bg-gold text-white hover:bg-gold-deep"
disabled={burst.isPending || !until}
onClick={() => {
burst.mutate({ name: s.name, action: "on", until });
setOpen(false);
}}
>
הפעל BURST
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function busyBurst(pending: boolean, disabled?: boolean): boolean {
return pending || !!disabled;
}
// ── 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>
{s.name === "legal-halacha-drain" && <BurstControl s={s} />}
<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">
<p className="text-ink-muted text-xs mb-4">
ניהול תהליכי-רקע (pm2) כמו &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>
) : (
/* mockup 02: services as a table — שירות · סטטוס · זמן-ריצה · זיכרון · controls */
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-rule-soft text-ink-muted">
<th className="text-start font-medium text-xs py-2 pe-3">שירות</th>
<th className="text-start font-medium text-xs py-2 px-3">סטטוס</th>
<th className="text-start font-medium text-xs py-2 px-3 whitespace-nowrap">
זמן-ריצה
</th>
<th className="text-start font-medium text-xs py-2 px-3 whitespace-nowrap">
זיכרון /
</th>
<th className="py-2 ps-3" />
</tr>
</thead>
<tbody>
{data.services.map((s: OpsService) => {
const isCron = !!s.cron;
return (
<tr
key={s.name}
className="border-b border-rule-soft last:border-0 align-top"
>
<td className="py-2.5 pe-3">
<div className="text-navy font-semibold text-[0.82rem]">
{SERVICE_LABELS[s.name] ?? s.name}
</div>
<div className="text-[0.66rem] text-ink-muted font-mono" dir="ltr">
{s.name}
</div>
</td>
<td className="py-2.5 px-3">
<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.66rem] text-ink-muted font-mono"
dir="ltr"
>
{s.cron}
</span>
) : null}
</div>
</td>
<td className="py-2.5 px-3 text-[0.78rem] text-ink-soft whitespace-nowrap tabular-nums">
{isCron ? `אחרונה ${ago(s.uptime_ms)}` : ago(s.uptime_ms)}
</td>
<td className="py-2.5 px-3 text-[0.78rem] text-ink-soft whitespace-nowrap tabular-nums">
{mb(s.memory_bytes)} · {s.restarts}
</td>
<td className="py-2.5 ps-3">
<div className="flex justify-end">
<ServiceControls
s={s}
busy={busy}
onAction={(a) => action.mutate({ name: s.name, action: a })}
onToggle={(disabled) => toggle.mutate({ name: s.name, disabled })}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</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,
gate = false,
}: {
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;
// mockup 02: gate cards get a gold-wash treatment + gold border so the
// human-gates read as distinct from the automatic pipelines.
gate?: boolean;
}) {
return (
<Card
className={
gate
? "bg-gold-wash border-gold/40 shadow-sm"
: "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 font-semibold 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>
) : (
<div className="h-[60vh] w-full overflow-y-auto overflow-x-hidden rounded-md border border-rule-soft bg-rule-soft/20 p-3">
<pre dir="ltr" className="w-full min-w-0 max-w-full overflow-x-hidden text-[0.72rem] leading-relaxed whitespace-pre-wrap break-all text-navy text-start">
{text || "אין פלט עדיין."}
</pre>
</div>
)}
</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">
{/* mockup 02: status pills row (running / queued) + company hint */}
{data ? (
<div className="flex items-center gap-2 text-[0.72rem] mb-3 flex-wrap">
{/* 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}
<span className="text-ink-muted ms-auto">חברות: CMP · CMPA</span>
</div>
) : null}
<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>
);
}
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
const HEALTH_LABEL: Record<AgentHealthState, string> = {
zombie: "זומבי",
stalled: "תקוע",
working: "עובד",
idle: "רגוע",
};
function AgentHealthPanel() {
const { data, isLoading } = useAgentHealth();
const { data: esc } = useRecentEscalations();
const counts = data?.counts;
// Only zombies/stalled are "problems" — the backend already sorts worst-first.
// An escalated issue is chair-owned, so it leaves this list; the escalations
// card below is the handled-loop history.
const problems = (data?.items ?? []).filter(
(i) => i.health === "zombie" || i.health === "stalled",
);
const healthyWorking = counts?.working ?? 0;
const healthyIdle = counts?.idle ?? 0;
return (
<div className="space-y-4">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
{isLoading || !data ? (
<Skeleton className="h-24 w-full" />
) : (
<>
<div className="flex flex-wrap gap-2 mb-4">
<StatTile
label="זומבי"
value={counts?.zombie ?? 0}
tone="red"
title="לולאת-recovery — משויך+פעיל-כביכול אך אין ריצה חיה. מוסלם אוטומטית."
/>
<StatTile
label="תקוע"
value={counts?.stalled ?? 0}
tone="amber"
title="ננער חוזר בלי התקדמות"
/>
<StatTile label="עובד" value={counts?.working ?? 0} tone="green" />
<StatTile label="רגוע" value={counts?.idle ?? 0} tone="muted" />
</div>
{problems.length === 0 ? (
<p className="text-sm text-emerald-600">
אין סוכן תקוע כרגע כל הסוכנים תקינים.
</p>
) : (
<div className="grid gap-2">
{problems.map((p) => (
<div
key={p.issue_id}
className={
p.health === "zombie"
? "flex items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2"
: "flex items-center justify-between gap-3 rounded-md border border-gold/40 bg-gold-wash px-3 py-2"
}
>
<div className="flex items-center gap-2 flex-wrap min-w-0">
<Badge
variant={p.health === "zombie" ? "destructive" : "outline"}
className={
p.health === "stalled"
? "border-warn/50 text-warn font-normal"
: "font-normal"
}
>
{HEALTH_LABEL[p.health]}
</Badge>
<span dir="ltr" className="font-mono text-[0.82rem] text-navy font-semibold">
{p.identifier}
</span>
<span className="text-[0.82rem] text-navy font-semibold">
{p.agent_name}
</span>
<span className="text-[0.7rem] text-ink-muted">
{p.recovery_wakeups > 0
? `${p.recovery_wakeups} יקיצות-שחזור`
: `${p.wakeups} יקיצות`}
</span>
</div>
<span className="text-[0.7rem] text-ink-muted shrink-0">
{p.health === "zombie" ? "יוסלם אוטומטית" : "מנוטר"}
</span>
</div>
))}
</div>
)}
<p className="mt-3 text-[0.78rem] text-ink-muted">
<b className="text-navy">{healthyWorking + healthyIdle} סוכנים תקינים</b> {" "}
{healthyWorking} עובדים, {healthyIdle} רגועים. מוצגות רק בעיות.
</p>
</>
)}
</CardContent>
</Card>
{/* handled-loop history — the watchdog's escalations, gold-wash like a gate */}
<Card className="bg-gold-wash border-gold/40 shadow-sm">
<CardContent className="px-5 py-4">
<h3 className="text-navy text-sm font-semibold mb-0.5">הסלמות אוטומטיות אחרונות</h3>
<p className="text-ink-muted text-[0.72rem] mb-3">
כל זומבי מתמשך (לולאת-recovery 2 יקיצות-שחזור) משויך אליך אוטומטית עם הערת-מערכת
בלי להעיר סוכן מחדש.
</p>
{!esc?.items?.length ? (
<p className="text-sm text-ink-muted">אין הסלמות אחרונות.</p>
) : (
<div className="grid gap-1">
{esc.items.map((e) => (
<div
key={`${e.issue_id}-${e.created_at ?? ""}`}
className="flex items-start gap-2.5 border-t border-gold/15 first:border-0 pt-2 first:pt-0"
>
<Badge
variant={e.severity === "medium" ? "outline" : "destructive"}
className={
e.severity === "medium"
? "border-gold/50 text-gold-deep font-normal shrink-0"
: "font-normal shrink-0"
}
>
{e.severity}
</Badge>
<div className="min-w-0 flex-1">
<span dir="ltr" className="font-mono text-[0.8rem] text-navy font-semibold">
{e.identifier}
</span>
<div className="text-[0.78rem] text-ink-soft">{e.reason}</div>
</div>
<span className="text-[0.68rem] text-ink-muted shrink-0">
{e.created_at ? ago(Date.parse(e.created_at)) : ""}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
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>
) : (
<>
<SectionHeader>מכסת claude.ai</SectionHeader>
<SubscriptionUsagePanel usage={data.subscription_usage} />
<SectionHeader>סוכנים פעילים</SectionHeader>
<LiveAgentsPanel />
<SectionHeader>בריאות-הסוכנים</SectionHeader>
<AgentHealthPanel />
<SectionHeader>מתאמי-סוכנים</SectionHeader>
<AgentAdaptersPanel />
<SectionHeader>שירותים</SectionHeader>
<ServicesPanel data={data} />
<SectionHeader>צינורות-עבודה</SectionHeader>
{/* Automatic pipelines — uniform stat cards */}
<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>
</div>
{/* mockup 02: human-gate pointer cards — gold-wash, "לתיבת-האישורים ←" */}
<div className="grid gap-4 md:grid-cols-2 mt-4">
<PipelineCard
gate
title="אישור הלכות (שער יו״ר)"
desc="שער-אנושי, לא תהליך — הפעולה ב-/approvals"
href="/approvals"
hrefLabel="לתיבת-האישורים ←"
>
<StatusRow by={data.pipelines.halacha_review.by_status} />
</PipelineCard>
<PipelineCard
gate
title="פסיקה חסרה"
desc="פערים בקורפוס — נסגרים אוטומטית כשנקלטים"
href="/approvals"
hrefLabel="לתיבת-האישורים ←"
>
<StatusRow by={data.pipelines.missing_precedents.by_status} />
</PipelineCard>
</div>
<SectionHeader>אחזורים אחרונים</SectionHeader>
<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="pt-2">
<SectionHeader>בריאות-מערכת</SectionHeader>
<p className="text-ink-muted text-sm mb-3">
מצב ה-DB, תורי-אישור, ומסמכים שנכשלו/תקועים. (אוחד מ״אבחון״.)
</p>
<SystemHealthSection />
</div>
</>
)}
</section>
</AppShell>
);
}