feat(ops): /operations — מוני-תור אחידים, "מה רץ עכשיו", וניהול-תהליכים
הדף הציג את התורים באופן לא-אחיד (by_status גולמי), בלי הבחנה בין "ממתין"
(בקלוג: status=pending) ל"בתור" (התור הפעיל: requested_at IS NOT NULL), בלי
הצגת הפריט שרץ כרגע, ובלי שום שליטה בתהליכים.
מה נוסף:
1. כרטיסי-תור אחידים — בתור / ממתין(בקלוג) / בעיבוד / הושלם / נכשל + "רץ עכשיו"
(citation/case_number של הפריט בעיבוד) לכל drain (אחזור-פסיקה, מטא-דאטה,
הלכות, יומונים). שערי-אנוש (אישור-הלכות, פסיקה-חסרה) נשארים מוני-סטטוס.
2. פאנל ניהול-תהליכים בסגנון "שירותי Windows":
- דמון (court-fetch-service/xvfb/chat/reaper): הפעל-מחדש / עצור / הפעל.
- cron drain: "הרץ עכשיו" (pm2 restart) + מתג הפעל/כבה תזמון.
3. כל תגי-הסטטוס מתורגמים לעברית.
מנגנון:
- הפעל/כבה תזמון = דגל ב-DB (טבלה drain_controls). pm2 cron_restart מחיה תהליך
שעוצר ב-stop, לכן ה"כיבוי" האמין הוא דגל שכל drain בודק ב-startup (no-op מיידי
כשכבוי). הקונטיינר כותב/קורא ישירות מ-DB.
- הרץ-עכשיו + restart/stop/start = proxy ל-pm2 דרך endpoint חדש בגשר-המארח
(court_fetch_service /pm2/control), מאובטח Bearer + whitelist ל-legal-* בלבד.
- יומונים: drain_digests הועבר מ-crontab ל-pm2 (legal-digest-drain.config.cjs)
כדי שיופיע ויהיה שליט כמו כל drain. drain_halacha_queue.py הובא לבקרת-גרסאות.
Invariants: מקיים G2 (הרחבת /operations + הגשר הקיים, לא מסלול מקביל) ו-G1
(drain_controls = מקור-אמת יחיד לכיבוי, נורמליזציה במקור ולא תיקון-בקריאה).
אין בליעת שגיאות שקטה (הגשר מחזיר {ok,error}; המוטציות מציגות toast).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,46 +4,81 @@ 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 { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
useOperations,
|
||||
useServiceAction,
|
||||
useDrainToggle,
|
||||
type OpsService,
|
||||
type OperationsSnapshot,
|
||||
type PipelineStats,
|
||||
} from "@/lib/api/operations";
|
||||
|
||||
function mb(bytes: number): string {
|
||||
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
|
||||
}
|
||||
|
||||
function uptime(ms: number): string {
|
||||
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)}י׳`;
|
||||
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",
|
||||
approved: "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",
|
||||
open: "secondary",
|
||||
rejected: "destructive",
|
||||
};
|
||||
|
||||
function StatusBadge({ value, count }: { value: string; count?: number }) {
|
||||
return (
|
||||
<Badge variant={STATUS_VARIANT[value] ?? "outline"} className="font-normal">
|
||||
{value}
|
||||
{he(value)}
|
||||
{count !== undefined ? <span className="ms-1 font-semibold">{count}</span> : null}
|
||||
</Badge>
|
||||
);
|
||||
@@ -52,56 +87,151 @@ function StatusBadge({ value, count }: { value: string; count?: number }) {
|
||||
const SERVICE_LABELS: Record<string, string> = {
|
||||
"legal-court-fetch-service": "שירות אחזור פסיקה (דפדפן נט המשפט)",
|
||||
"legal-court-fetch-xvfb": "צג וירטואלי (Xvfb) לדפדפן",
|
||||
"legal-court-fetch-drain": "תזמון: ניקוז תור אחזור (שעתי)",
|
||||
"legal-court-fetch-drain": "תזמון: ניקוז תור אחזור פסיקה (שעתי)",
|
||||
"legal-metadata-drain": "תזמון: חילוץ מטא-דאטה (Gemini, ×15 דק׳)",
|
||||
"legal-halacha-drain": "תזמון: חילוץ הלכות (Claude, ×שעתיים)",
|
||||
"legal-digest-drain": "תזמון: העשרת יומונים (Sonnet, ×שעתיים)",
|
||||
"legal-reaper": "מנקה תהליכים-יתומים (נגד דליפות זיכרון)",
|
||||
"legal-chat-service": "שירות צ׳אט אימון (גשר ל-claude CLI)",
|
||||
};
|
||||
|
||||
function ServicesSection({ data }: { data: OperationsSnapshot }) {
|
||||
// ── 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>
|
||||
<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">
|
||||
<h2 className="text-navy text-lg mb-1">שירותי רקע (pm2)</h2>
|
||||
<h2 className="text-navy text-lg mb-1">ניהול תהליכי-רקע (pm2)</h2>
|
||||
<p className="text-ink-muted text-xs mb-4">
|
||||
דמונים ומשימות-תזמון על שרת המארח. "cron" = רץ לפי לוח-זמנים (מציג
|
||||
"stopped" בין הרצות — תקין).
|
||||
כמו "שירותים" ב-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>
|
||||
) : (
|
||||
<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}
|
||||
<div className="grid gap-2">
|
||||
{data.services.map((s: OpsService) => {
|
||||
const isCron = !!s.cron;
|
||||
return (
|
||||
<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 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.7rem] text-ink-muted font-mono" dir="ltr">
|
||||
{s.cron}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[0.8rem] text-navy truncate mt-0.5">
|
||||
{SERVICE_LABELS[s.name] ?? s.name}
|
||||
</div>
|
||||
<div className="text-[0.66rem] text-ink-muted flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono" dir="ltr">
|
||||
{s.name}
|
||||
</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}
|
||||
<span>{mb(s.memory_bytes)}</span>
|
||||
<span>↻{s.restarts}</span>
|
||||
<span>{isCron ? `ריצה אחרונה ${ago(s.uptime_ms)}` : ago(s.uptime_ms)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ServiceControls
|
||||
s={s}
|
||||
busy={busy}
|
||||
onAction={(a) => action.mutate({ name: s.name, action: a })}
|
||||
onToggle={(disabled) => toggle.mutate({ name: s.name, disabled })}
|
||||
/>
|
||||
</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>
|
||||
@@ -109,6 +239,68 @@ function ServicesSection({ data }: { data: OperationsSnapshot }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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>;
|
||||
@@ -134,7 +326,7 @@ function PipelineCard({
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4 space-y-2">
|
||||
<CardContent className="px-5 py-4 space-y-2.5">
|
||||
<div>
|
||||
<h3 className="text-navy text-sm font-semibold mb-0.5">{title}</h3>
|
||||
<p className="text-ink-muted text-[0.72rem]">{desc}</p>
|
||||
@@ -160,7 +352,7 @@ export default function OperationsPage() {
|
||||
<h1 className="text-navy mb-0">תפעול — מה רץ ברקע</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
|
||||
כל מה שמוריד ומנתח אוטומטית: שירותי-המארח, משימות-התזמון, ותורי-העבודה.
|
||||
מתרענן כל 5 שניות.
|
||||
ניתן להפעיל-מחדש / לעצור / להריץ-עכשיו כל תהליך. מתרענן כל 5 שניות.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -177,39 +369,44 @@ export default function OperationsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ServicesSection data={data} />
|
||||
<ServicesPanel 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} />
|
||||
<UniformStats p={data.pipelines.court_fetch} />
|
||||
</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>
|
||||
<UniformStats p={data.pipelines.metadata_extraction} />
|
||||
</PipelineCard>
|
||||
|
||||
<PipelineCard
|
||||
title="חילוץ הלכות"
|
||||
desc="Claude — הלכות מכל פסיקה (→ אישור יו״ר)"
|
||||
>
|
||||
<StatusRow by={data.pipelines.halacha_extraction.by_status} />
|
||||
<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.halacha_extraction.queued}
|
||||
{data.pipelines.digests.linked}/{data.pipelines.digests.total} מקושרים
|
||||
לפסיקה
|
||||
</p>
|
||||
</PipelineCard>
|
||||
|
||||
<PipelineCard
|
||||
title="אישור הלכות (שער יו״ר)"
|
||||
desc="הלכות שחולצו, ממתינות להכרעת דפנה ב-/approvals"
|
||||
desc="הלכות שחולצו, ממתינות להכרעת דפנה ב-/approvals — שער-אנושי, לא תהליך"
|
||||
>
|
||||
<StatusRow by={data.pipelines.halacha_review.by_status} />
|
||||
</PipelineCard>
|
||||
@@ -220,16 +417,6 @@ export default function OperationsPage() {
|
||||
>
|
||||
<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">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type OpsService = {
|
||||
@@ -10,6 +11,7 @@ export type OpsService = {
|
||||
memory_bytes: number;
|
||||
cron: string;
|
||||
autorestart: boolean;
|
||||
disabled?: boolean; // cron drain switched off via the dashboard
|
||||
};
|
||||
|
||||
export type CourtFetchJob = {
|
||||
@@ -28,16 +30,27 @@ export type IngestedRow = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
/** The uniform per-pipeline shape every background drain reports. */
|
||||
export type PipelineStats = {
|
||||
pending: number; // backlog: rows not yet processed (status default)
|
||||
processing: number; // being worked right now
|
||||
done: number; // completed
|
||||
failed: number; // terminal failures (court_fetch folds in 'manual')
|
||||
queued: number; // explicitly enqueued for the next drain run
|
||||
running_now: string[]; // human labels of the items currently processing
|
||||
by_status: Record<string, number>; // raw counts, for the curious
|
||||
};
|
||||
|
||||
export type OperationsSnapshot = {
|
||||
services: OpsService[];
|
||||
services_error: string | null;
|
||||
pipelines: {
|
||||
court_fetch: { by_status: Record<string, number>; recent: CourtFetchJob[] };
|
||||
metadata_extraction: { by_status: Record<string, number>; queued: number };
|
||||
halacha_extraction: { by_status: Record<string, number>; queued: number };
|
||||
court_fetch: PipelineStats & { recent: CourtFetchJob[] };
|
||||
metadata_extraction: PipelineStats;
|
||||
halacha_extraction: PipelineStats;
|
||||
digests: PipelineStats & { total: number; linked: number };
|
||||
halacha_review: { by_status: Record<string, number> };
|
||||
missing_precedents: { by_status: Record<string, number> };
|
||||
digests: { total: number; linked: number };
|
||||
ingested_recent: IngestedRow[];
|
||||
};
|
||||
};
|
||||
@@ -51,3 +64,42 @@ export function useOperations() {
|
||||
staleTime: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
export type ServiceAction = "restart" | "stop" | "start" | "run-now";
|
||||
|
||||
/** Control a background service (daemon restart/stop/start, or run a drain now). */
|
||||
export function useServiceAction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ name, action }: { name: string; action: ServiceAction }) =>
|
||||
apiRequest(`/api/operations/services/${name}/${action}`, { method: "POST" }),
|
||||
onSuccess: (_d, { action }) => {
|
||||
const labels: Record<ServiceAction, string> = {
|
||||
"run-now": "הופעל עכשיו",
|
||||
restart: "הופעל מחדש",
|
||||
stop: "נעצר",
|
||||
start: "הופעל",
|
||||
};
|
||||
toast.success(labels[action]);
|
||||
qc.invalidateQueries({ queryKey: ["operations"] });
|
||||
},
|
||||
onError: (e) => toast.error(`הפעולה נכשלה: ${String(e)}`),
|
||||
});
|
||||
}
|
||||
|
||||
/** Switch a cron drain on/off (its "startup type"). */
|
||||
export function useDrainToggle() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ name, disabled }: { name: string; disabled: boolean }) =>
|
||||
apiRequest(`/api/operations/drains/${name}/disabled`, {
|
||||
method: "POST",
|
||||
body: { disabled },
|
||||
}),
|
||||
onSuccess: (_d, { disabled }) => {
|
||||
toast.success(disabled ? "התזמון כובה" : "התזמון הופעל");
|
||||
qc.invalidateQueries({ queryKey: ["operations"] });
|
||||
},
|
||||
onError: (e) => toast.error(`העדכון נכשל: ${String(e)}`),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user