feat(ops): /operations dashboard — everything running in the background

A single live page for all the background work that downloads/analyses, so the
chair can see what's running instead of guessing.

- court_fetch_service: GET /pm2 (unauthenticated, host-only) → trimmed pm2 jlist
  for the legal-* services (status, restarts, mem, cron schedule).
- FastAPI GET /api/operations: aggregates the DB-backed pipelines (court_fetch
  jobs, metadata + halacha extraction queues, halacha review gate,
  missing_precedents, digests, recent court ingests) and proxies the host /pm2
  over the docker bridge (graceful if the host service is down).
- web-ui /operations page (+ src/lib/api/operations.ts hook, nav entry under
  admin): services grid (with Hebrew labels + schedules) + pipeline cards +
  recent-fetch / recent-ingest lists. Auto-refreshes every 5s.

tsc --noEmit clean; pm2 status carries nothing sensitive and the bind
(10.0.1.1) is host/container-only, so /pm2 needs no secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 07:28:41 +00:00
parent 64b9bd9d99
commit 34d80a39e5
5 changed files with 488 additions and 0 deletions

View File

@@ -0,0 +1,296 @@
"use client";
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 { Skeleton } from "@/components/ui/skeleton";
import {
useOperations,
type OpsService,
type OperationsSnapshot,
} from "@/lib/api/operations";
function mb(bytes: number): string {
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
}
function uptime(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)}י׳`;
}
const STATUS_VARIANT: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
online: "default",
done: "default",
approved: "default",
completed: "default",
stopped: "secondary",
pending: "secondary",
pending_review: "secondary",
running: "outline",
processing: "outline",
failed: "destructive",
errored: "destructive",
manual: "destructive",
open: "secondary",
};
function StatusBadge({ value, count }: { value: string; count?: number }) {
return (
<Badge variant={STATUS_VARIANT[value] ?? "outline"} className="font-normal">
{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-reaper": "מנקה תהליכים-יתומים (נגד דליפות זיכרון)",
"legal-chat-service": "שירות צ׳אט אימון (גשר ל-claude CLI)",
};
function ServicesSection({ data }: { data: OperationsSnapshot }) {
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">
דמונים ומשימות-תזמון על שרת המארח. &quot;cron&quot; = רץ לפי לוח-זמנים (מציג
&quot;stopped&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>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{data.services.map((s: OpsService) => (
<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">
<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.78rem] text-navy truncate mt-0.5">
{SERVICE_LABELS[s.name] ?? s.name}
</div>
<div className="text-[0.68rem] text-ink-muted font-mono" dir="ltr">
{s.name}
</div>
</div>
<div className="text-end text-[0.7rem] text-ink-muted shrink-0">
<div>{mb(s.memory_bytes)}</div>
<div>{s.restarts}</div>
{!s.cron ? <div>{uptime(s.uptime_ms)}</div> : null}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
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">
<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>
);
}
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>
) : (
<>
<ServicesSection data={data} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<PipelineCard
title="אחזור פסיקה (X13)"
desc="הורדה אוטומטית מנט-המשפט / פורטל-העליון → קורפוס"
>
<StatusRow by={data.pipelines.court_fetch.by_status} />
</PipelineCard>
<PipelineCard
title="חילוץ מטא-דאטה"
desc="Gemini Flash — שם/תקציר/תגיות לכל פסיקה"
>
<StatusRow by={data.pipelines.metadata_extraction.by_status} />
<p className="text-[0.72rem] text-ink-muted">
בתור לחילוץ: {data.pipelines.metadata_extraction.queued}
</p>
</PipelineCard>
<PipelineCard
title="חילוץ הלכות"
desc="Claude — הלכות מכל פסיקה (→ אישור יו״ר)"
>
<StatusRow by={data.pipelines.halacha_extraction.by_status} />
<p className="text-[0.72rem] text-ink-muted">
בתור לחילוץ: {data.pipelines.halacha_extraction.queued}
</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>
<PipelineCard
title="יומונים (רדאר)"
desc="מצביעים על פסקי-דין → מזניקים אחזור אוטומטי"
>
<p className="text-sm text-navy">
{data.pipelines.digests.linked}/{data.pipelines.digests.total} מקושרים
לפסיקה
</p>
</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>
);
}