"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 (
<>
>
);
}
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 (
);
}
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 (
);
}
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).