All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Surfaces the 5-hour / weekly / weekly-Opus utilization the Claude Code status bar shows — the authoritative number, not a token estimate. Design approved via the Claude Design gate (card 02c-operations-usage.html). Three layers: - court-fetch-service (host bridge): new GET /usage reads the OAuth token from ~/.claude/.credentials.json and proxies /api/oauth/usage with the required claude-code User-Agent. Read-only, no auth (like /pm2). Host-only — the token never enters the container. - web/app.py: _ops_subscription_usage() proxies the bridge /usage; the /api/operations snapshot gains a `subscription_usage` field (null when the undocumented endpoint is unreachable). - web-ui: SubscriptionUsagePanel renders three meters (label · % · bar · reset) at the top of /operations; bar turns amber >75% / red >90%; hidden when usage is null. Types added to operations.ts (hand-maintained snapshot type). Also fixes a pre-existing react/no-unescaped-entities lint error in learning-panel.tsx (escaped a `"` in Hebrew text — renders identically). tsc --noEmit passes; lint error count 0. (Full next build is blocked only by the manual-worktree node_modules symlink — the Docker build has real node_modules.) Invariants: G2 (usage surfaced through the existing host bridge + /api/operations snapshot — no parallel control path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
977 lines
38 KiB
TypeScript
977 lines
38 KiB
TypeScript
"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 (
|
||
<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="שבועי · Opus" w={usage.seven_day_opus} />
|
||
</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: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 (
|
||
<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:00–05: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 הפנויה. נעצר אוטומטית במועד — או ידנית בכפתור "עצור BURST".
|
||
</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-rule rounded-full px-2.5 py-0.5 hover:border-gold"
|
||
onClick={() => setUntil(toLocalInput(nextSaturday18()))}>
|
||
שבת 18:00
|
||
</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) — כמו "שירותים" ב-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>
|
||
) : (
|
||
/* 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>
|
||
) : (
|
||
<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">
|
||
{/* 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>
|
||
);
|
||
}
|
||
|
||
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>
|
||
<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>
|
||
);
|
||
}
|