"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 { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from "@/components/ui/dialog"; import { useOperations, useServiceAction, useDrainToggle, useDrainBurst, useAgentRuns, useRunLog, useCancelRun, useResetAgentSession, type OpsService, type OperationsSnapshot, type PipelineStats, type AgentRun, type SubscriptionUsage, type UsageWindow, } from "@/lib/api/operations"; 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 "—"; const d = new Date(iso); return `איפוס ${d.toLocaleTimeString("he-IL", { hour: "2-digit", minute: "2-digit" })}`; } 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 (
{label} {active ? ( {Math.round(pct)}% ) : ( )}
{active ? usageResetLabel(w?.resets_at ?? null) : "לא פעיל כרגע"}
); } function SubscriptionUsagePanel({ usage }: { usage: SubscriptionUsage }) { if (!usage) return null; // undocumented endpoint unreachable — show nothing return (
המנוי המשותף שמפעיל את כל הסוכנים והדריינרים. הפס נצבע בענבר מעל 75% ובאדום מעל 90%. דריינר-ההלכות משתהה אוטומטית כשחלון מגיע ל-100% וממשיך לבד כשהוא מתאפס.
); } // 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

{children}

; } 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 = { 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 = { 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 ( {he(value)} {count !== undefined ? {count} : null} ); } const SERVICE_LABELS: Record = { "legal-court-fetch-service": "שירות אחזור פסיקה (דפדפן נט המשפט)", "legal-court-fetch-xvfb": "צג וירטואלי (Xvfb) לדפדפן", "legal-court-fetch-drain": "תזמון: ניקוז תור אחזור פסיקה (שעתי)", "legal-metadata-drain": "תזמון: חילוץ מטא-דאטה (Gemini, ×15 דק׳)", "legal-halacha-drain": "תזמון: חילוץ הלכות (Claude, חלון-לילה 23:00–05: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 d = new Date(iso); return `${HE_DAYS[d.getDay()]} ${pad2(d.getDate())}/${pad2(d.getMonth() + 1)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; } 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 ( ⚡ BURST · עד {fmtDeadline(s.burst_until!)} ); } return ( <> ⚡ הפעלת BURST — חילוץ הלכות רצוף הדריינר ירוץ ברצף מעכשיו (מתעלם מחלון-הלילה) ויעבד את תור-ההלכות עד המועד שתבחר, תוך ניצול מכסת-Claude הפנויה. נעצר אוטומטית במועד — או ידנית בכפתור "עצור BURST".
setUntil(e.target.value)} />

ℹ️ הפעלה ידנית בלבד — המערכת לא תפעיל BURST לבד. נכנס לתוקף תוך ≤15 דק׳ (מחזור המתזמר).

); } 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 (
{s.name === "legal-halacha-drain" && }
); } // Daemon: restart / stop / start. const online = s.status === "online"; return (
{online ? ( ) : ( )}
); } function ServicesPanel({ data }: { data: OperationsSnapshot }) { const action = useServiceAction(); const toggle = useDrainToggle(); const busy = action.isPending || toggle.isPending; return (

ניהול תהליכי-רקע (pm2) — כמו "שירותים" ב-Windows. דמון = שירות רץ-תמיד (הפעל-מחדש/עצור/הפעל). תזמון (cron) = רץ לפי לוח-זמנים ("הרץ עכשיו" להרצה מיידית, ומתג הפעלה/כיבוי של התזמון).

{data.services_error ? (

{data.services_error}

) : data.services.length === 0 ? (

אין שירותים.

) : ( /* mockup 02: services as a table — שירות · סטטוס · זמן-ריצה · זיכרון · controls */
{data.services.map((s: OpsService) => { const isCron = !!s.cron; return ( ); })}
שירות סטטוס זמן-ריצה זיכרון / ↻
{SERVICE_LABELS[s.name] ?? s.name}
{s.name}
{isCron ? ( {s.disabled ? "כבוי" : "פעיל (מתוזמן)"} ) : ( )} {s.cron ? ( {s.cron} ) : null}
{isCron ? `אחרונה ${ago(s.uptime_ms)}` : ago(s.uptime_ms)} {mb(s.memory_bytes)} · ↻{s.restarts}
action.mutate({ name: s.name, action: a })} onToggle={(disabled) => toggle.mutate({ name: s.name, disabled })} />
)}
); } // ── 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 (
{value} {label}
); } function UniformStats({ p }: { p: PipelineStats }) { return (
{p.failed > 0 ? : null}
{p.running_now.length > 0 ? (
רץ עכשיו: {p.running_now.join(" · ")}
) : (
אין פריט בעיבוד כרגע
)}
); } function StatusRow({ by }: { by: Record }) { const entries = Object.entries(by).filter(([, n]) => n > 0); if (entries.length === 0) return ריק; return (
{entries .sort((a, b) => b[1] - a[1]) .map(([k, n]) => ( ))}
); } 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 (

{title}

{desc}

{href && ( {hrefLabel ?? "לטיפול ←"} )}
{children}
); } // ── Live agents — who's working now + output + controls ──────────────────── // The platform's own liveness signal → a Hebrew label + tone. const SILENCE_HE: Record = { 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 ( !o && onClose()}> פלט הסוכן — {run?.agent_name} {run?.company_label} · ריצה {run?.run_id?.slice(0, 8)} · מתעדכן חי {isLoading ? ( ) : error ? (

שגיאה בטעינת הלוג: {String(error)}

) : (
              {text || "אין פלט עדיין."}
            
)}
); } function LiveAgentsPanel() { const { data, isLoading } = useAgentRuns(); const cancel = useCancelRun(); const reset = useResetAgentSession(); const [logRun, setLogRun] = useState(null); const busy = cancel.isPending || reset.isPending; return ( {/* mockup 02: status pills row (running / queued) + company hint */} {data ? (
{/* 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. */} רצים {data.running}{data.errors.length > 0 ? "+" : ""} בתור {data.queued}{data.errors.length > 0 ? "+" : ""} {data.errors.length > 0 ? ( ⚠ חלקי ) : null} חברות: CMP · CMPA
) : null}

מי מבין סוכני-הוועדה עובד כרגע ומה הפלט שלו — כולל עבודה שלא קשורה לתיק (כמו ריקון תור הלכות ע״י ה-CEO). עצירה היא מבוקרת דרך הפלטפורמה (לא kill).

{isLoading || !data ? ( ) : data.runs.length === 0 ? (

אין סוכן פעיל כרגע.

) : (
{data.errors.length > 0 ? (

לא ניתן לטעון חלק מהחברות: {data.errors.join(" · ")}

) : null} {data.runs.map((r) => { const sil = SILENCE_HE[r.silence_level]; const startMs = r.started_at ? Date.parse(r.started_at) : 0; return (
{r.status === "running" ? "רץ" : "בתור"} {r.agent_name} {sil ? ● {sil.label} : null}
{r.company_label} {r.status === "running" && startMs ? החל {ago(startMs)} : null} {r.invocation_source ? ( {r.invocation_source} ) : null} {r.continuation_attempt > 0 ? ניסיון #{r.continuation_attempt + 1} : null}
); })}
)}
setLogRun(null)} />
); } export default function OperationsPage() { const { data, isLoading, error } = useOperations(); return (

תפעול — מה רץ ברקע

כל מה שמוריד ומנתח אוטומטית: שירותי-המארח, משימות-התזמון, ותורי-העבודה. ניתן להפעיל-מחדש / לעצור / להריץ-עכשיו כל תהליך. מתרענן כל 5 שניות.

{error ? ( שגיאה בטעינת מצב התפעול: {String(error)} ) : isLoading || !data ? (
) : ( <> מכסת claude.ai סוכנים פעילים שירותים צינורות-עבודה {/* Automatic pipelines — uniform stat cards */}

{data.pipelines.digests.linked}/{data.pipelines.digests.total} מקושרים לפסיקה

{/* mockup 02: human-gate pointer cards — gold-wash, "לתיבת-האישורים ←" */}
אחזורים אחרונים

אחזורים אחרונים (תור)

{data.pipelines.court_fetch.recent.map((j) => (
{j.citation_raw?.slice(0, 48) || j.case_number_norm} {j.error ? (
{j.error.slice(0, 80)}
) : null}
{(j.tier || "").slice(0, 7)}
))}

נקלטו לאחרונה (מבתי-משפט)

{data.pipelines.ingested_recent.map((r) => (
{r.case_number} {r.created_at?.slice(0, 16).replace("T", " ")}
))} {data.pipelines.ingested_recent.length === 0 ? (

עדיין אין.

) : null}
{/* INV-IA4: the former /diagnostics surface, folded in here — one monitoring intent, one surface. /diagnostics now redirects here. */}
בריאות-מערכת

מצב ה-DB, תורי-אישור, ומסמכים שנכשלו/תקועים. (אוחד מ״אבחון״.)

)}
); }