Storage stays UTC (DB TIMESTAMPTZ, API ISO-UTC) — only the display layer is localized, and now deterministically: every timestamp renders pinned to Asia/Jerusalem via a single Intl-based formatter, so SSR (UTC container) and the browser agree on any runtime. No layout/visible-format change — only the tz. - New single date formatter web-ui/src/lib/format-date.ts (G2): formatDate / formatDateShort / formatDateLong / formatDateTime / formatDateTimeFull / formatTime / formatIsoDate + Israel helpers getIsraelYear / israelDayKey / israelMidnightMs / israelParts + formatRelative (long/short/tight wording). - Routed all ad-hoc toLocaleDateString/toLocaleTimeString/toLocaleString + hand-rolled new Date(...).get*() / toISOString().slice(0,10) timestamp call sites (30 files) through it. Number toLocaleString left untouched. - Spec: added INV-UI9 to docs/spec/X6 (UTC storage, Asia/Jerusalem display). Display-only; no layout/design/IA change → Claude Design gate N/A. Invariants: G2 (single date formatter, no parallel ad-hoc formatting), INV-UI9. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
251 lines
9.8 KiB
TypeScript
251 lines
9.8 KiB
TypeScript
"use client";
|
||
|
||
/*
|
||
* System-health monitoring section (INV-IA4): DB health, table counts, the
|
||
* halacha review backlog (read-only pointer to /approvals), active background
|
||
* tasks, and failed/stuck documents. Extracted from the former /diagnostics
|
||
* page so /operations is the single monitoring surface — /diagnostics now
|
||
* redirects here (one intent = one surface).
|
||
*/
|
||
|
||
import Link from "next/link";
|
||
import { AlertTriangle, CheckCircle2, Clock, Database } from "lucide-react";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { useDiagnostics, type DiagDoc } from "@/lib/api/system";
|
||
import { formatDateShort, formatRelative } from "@/lib/format-date";
|
||
|
||
const TABLE_LABELS: Record<string, string> = {
|
||
cases: "תיקים",
|
||
documents: "מסמכים",
|
||
document_chunks: "chunks",
|
||
style_corpus: "קורפוס סגנון",
|
||
style_patterns: "דפוסי סגנון",
|
||
};
|
||
|
||
function formatRelativeTime(iso: string | null) {
|
||
if (!iso) return "—";
|
||
const min = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
|
||
// Past 30 days, fall back to an absolute Israel-time date.
|
||
if (min >= 30 * 24 * 60) return formatDateShort(iso);
|
||
return formatRelative(iso, { units: "long" });
|
||
}
|
||
|
||
function DocRow({ doc, tone }: { doc: DiagDoc; tone: "danger" | "warn" }) {
|
||
const cls =
|
||
tone === "danger" ? "bg-danger-bg/60 border-danger/30" : "bg-warn-bg/60 border-warn/30";
|
||
return (
|
||
<li className={`rounded border px-3 py-2 flex items-center gap-3 text-sm ${cls}`}>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-ink font-medium truncate" title={doc.title}>
|
||
{doc.title || "(ללא כותרת)"}
|
||
</div>
|
||
<div className="text-[0.72rem] text-ink-muted flex gap-3 mt-0.5">
|
||
{doc.case_number && (
|
||
<Link href={`/cases/${doc.case_number}`} className="hover:text-gold-deep">
|
||
ערר {doc.case_number}
|
||
</Link>
|
||
)}
|
||
<span>{formatRelativeTime(doc.created_at)}</span>
|
||
</div>
|
||
</div>
|
||
<Badge variant="outline" className="text-[0.7rem]">{doc.status}</Badge>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
export function SystemHealthSection() {
|
||
const { data, isPending, error } = useDiagnostics();
|
||
|
||
if (error) {
|
||
return (
|
||
<Card className="bg-danger-bg border-danger/40">
|
||
<CardContent className="px-6 py-6 text-center text-danger">
|
||
{error.message}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* DB status + table counts */}
|
||
<div className="grid gap-4 md:grid-cols-[240px_1fr]">
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-5 py-4 flex flex-col items-start gap-2">
|
||
<div className="flex items-center gap-2 text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||
<Database className="w-3.5 h-3.5" />
|
||
מצב DB
|
||
</div>
|
||
{isPending ? (
|
||
<Skeleton className="h-6 w-24" />
|
||
) : data?.db_ok ? (
|
||
<div className="flex items-center gap-2 text-success font-semibold">
|
||
<CheckCircle2 className="w-5 h-5" />
|
||
<span>מחובר</span>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2 text-danger font-semibold">
|
||
<AlertTriangle className="w-5 h-5" />
|
||
<span>מנותק</span>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-5 py-4">
|
||
<div className="text-ink-muted text-[0.72rem] uppercase tracking-wider mb-3">
|
||
ספירת טבלאות
|
||
</div>
|
||
<dl className="grid grid-cols-2 md:grid-cols-5 gap-y-2 gap-x-4">
|
||
{Object.keys(TABLE_LABELS).map((key) => (
|
||
<div key={key} className="space-y-0.5">
|
||
<dt className="text-[0.72rem] text-ink-muted">{TABLE_LABELS[key]}</dt>
|
||
<dd className="font-display font-bold text-navy text-xl tabular-nums">
|
||
{isPending ? "—" : (data?.tables[key] ?? "—")}
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Halacha review backlog (ADM-1, INV-IA5): render the human-gate counter
|
||
the backend returns; the action lives at /approvals (INV-IA1). */}
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-6 py-5">
|
||
<h3 className="text-navy text-base mb-3 flex items-center gap-2">
|
||
<Clock className="w-4 h-4" />
|
||
תור אישור הלכות
|
||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||
{isPending ? "—" : (data?.halacha_backlog?.pending_review ?? 0)}
|
||
</Badge>
|
||
<Link
|
||
href="/approvals"
|
||
className="ms-auto text-[0.75rem] text-gold-deep hover:underline"
|
||
>
|
||
לאישור ←
|
||
</Link>
|
||
</h3>
|
||
{isPending ? (
|
||
<Skeleton className="h-12 w-full" />
|
||
) : (
|
||
<dl className="grid grid-cols-2 md:grid-cols-4 gap-y-2 gap-x-4">
|
||
<div className="space-y-0.5">
|
||
<dt className="text-[0.72rem] text-ink-muted">ממתינים (נקי)</dt>
|
||
<dd className="font-display font-bold text-navy text-xl tabular-nums">
|
||
{data?.halacha_backlog?.pending_clean ?? 0}
|
||
</dd>
|
||
</div>
|
||
<div className="space-y-0.5">
|
||
<dt className="text-[0.72rem] text-ink-muted">דורש תיקון</dt>
|
||
<dd className="font-display font-bold text-warn text-xl tabular-nums">
|
||
{data?.halacha_backlog?.pending_flagged ?? 0}
|
||
</dd>
|
||
</div>
|
||
<div className="space-y-0.5">
|
||
<dt className="text-[0.72rem] text-ink-muted">אושרו</dt>
|
||
<dd className="font-display font-bold text-success text-xl tabular-nums">
|
||
{data?.halacha_backlog?.approved ?? 0}
|
||
</dd>
|
||
</div>
|
||
<div className="space-y-0.5">
|
||
<dt className="text-[0.72rem] text-ink-muted">הוותיק ביותר</dt>
|
||
<dd className="text-sm text-ink-soft">
|
||
{formatRelativeTime(data?.halacha_backlog?.oldest_pending_at ?? null)}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Active tasks */}
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-6 py-5">
|
||
<h3 className="text-navy text-base mb-3 flex items-center gap-2">
|
||
<Clock className="w-4 h-4" />
|
||
משימות רקע פעילות
|
||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||
{data?.active_tasks.length ?? 0}
|
||
</Badge>
|
||
</h3>
|
||
{isPending ? (
|
||
<Skeleton className="h-16 w-full" />
|
||
) : data?.active_tasks.length === 0 ? (
|
||
<p className="text-ink-muted text-sm">אין משימות פעילות</p>
|
||
) : (
|
||
<ul className="space-y-2">
|
||
{data?.active_tasks.map((t) => (
|
||
<li
|
||
key={t.task_id}
|
||
className="flex items-center gap-3 text-sm rounded bg-rule-soft/60 border border-rule px-3 py-2"
|
||
>
|
||
<span className="flex-1 text-ink truncate">
|
||
{t.filename || t.task_id}
|
||
</span>
|
||
<Badge variant="outline" className="text-[0.7rem]">
|
||
{t.step || t.status}
|
||
</Badge>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Failed + stuck docs */}
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-6 py-5">
|
||
<h3 className="text-danger text-base mb-3 flex items-center gap-2">
|
||
<AlertTriangle className="w-4 h-4" />
|
||
מסמכים שנכשלו
|
||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||
{data?.failed_documents.length ?? 0}
|
||
</Badge>
|
||
</h3>
|
||
{isPending ? (
|
||
<Skeleton className="h-20 w-full" />
|
||
) : data?.failed_documents.length === 0 ? (
|
||
<p className="text-ink-muted text-sm">אין כשלונות</p>
|
||
) : (
|
||
<ul className="space-y-2">
|
||
{data?.failed_documents.map((d) => (
|
||
<DocRow key={d.id} doc={d} tone="danger" />
|
||
))}
|
||
</ul>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-6 py-5">
|
||
<h3 className="text-warn text-base mb-3 flex items-center gap-2">
|
||
<Clock className="w-4 h-4" />
|
||
תקועים (> 10 דק׳)
|
||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||
{data?.stuck_documents.length ?? 0}
|
||
</Badge>
|
||
</h3>
|
||
{isPending ? (
|
||
<Skeleton className="h-20 w-full" />
|
||
) : data?.stuck_documents.length === 0 ? (
|
||
<p className="text-ink-muted text-sm">אין מסמכים תקועים</p>
|
||
) : (
|
||
<ul className="space-y-2">
|
||
{data?.stuck_documents.map((d) => (
|
||
<DocRow key={d.id} doc={d} tone="warn" />
|
||
))}
|
||
</ul>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|