Files
legal-ai/web-ui/src/app/operations/page.tsx
Chaim 34d80a39e5 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>
2026-06-08 07:28:41 +00:00

297 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 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>
);
}