Merge pull request 'feat(operations-ui): BURST toggle + deadline dialog for the halacha drain' (#238) from worktree-halacha-burst-ops into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m7s
G12 Leak-Guard / leak-guard (push) Successful in 6s

This commit was merged in pull request #238.
This commit is contained in:
2026-06-12 11:47:08 +00:00
3 changed files with 223 additions and 0 deletions

View File

@@ -8,6 +8,8 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; 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 { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
@@ -16,11 +18,13 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import {
useOperations, useOperations,
useServiceAction, useServiceAction,
useDrainToggle, useDrainToggle,
useDrainBurst,
useAgentRuns, useAgentRuns,
useRunLog, useRunLog,
useCancelRun, useCancelRun,
@@ -116,6 +120,138 @@ const SERVICE_LABELS: Record<string, string> = {
"legal-chat-service": "שירות צ׳אט אימון (גשר ל-claude CLI)", "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:0005: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 הפנויה. נעצר אוטומטית במועד או ידנית בכפתור &quot;עצור BURST&quot;.
</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) ───────────────── // ── Process-management panel (the "Windows services" view) ─────────────────
function ServiceControls({ function ServiceControls({
s, s,
@@ -143,6 +279,7 @@ function ServiceControls({
> >
הרץ עכשיו הרץ עכשיו
</Button> </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"> <label className="flex items-center gap-1.5 text-[0.7rem] text-ink-muted cursor-pointer">
<Switch <Switch
checked={!s.disabled} checked={!s.disabled}

View File

@@ -12,6 +12,7 @@ export type OpsService = {
cron: string; cron: string;
autorestart: boolean; autorestart: boolean;
disabled?: boolean; // cron drain switched off via the dashboard disabled?: boolean; // cron drain switched off via the dashboard
burst_until?: string | null; // manual "run continuously now" window (ISO) — see useDrainBurst
}; };
export type CourtFetchJob = { export type CourtFetchJob = {
@@ -104,6 +105,28 @@ export function useDrainToggle() {
}); });
} }
/**
* Start/stop a drain's MANUAL burst window ("run continuously now until X").
* `until` is a tz-naive local datetime string (e.g. "2026-06-13T18:00") — the
* backend interprets it as Israel time. Omit it to let the server default to the
* upcoming Saturday 18:00. The host supervisor enforces it within ≤15 min.
*/
export function useDrainBurst() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ name, action, until }: { name: string; action: "on" | "off"; until?: string }) =>
apiRequest(`/api/operations/drains/${name}/burst`, {
method: "POST",
body: action === "on" ? { action, ...(until ? { until } : {}) } : { action },
}),
onSuccess: (_d, { action }) => {
toast.success(action === "on" ? "BURST הופעל" : "BURST כובה");
qc.invalidateQueries({ queryKey: ["operations"] });
},
onError: (e) => toast.error(`הפעולה נכשלה: ${String(e)}`),
});
}
// ── Live agents — which agent is working now + its output + controls ─────── // ── Live agents — which agent is working now + its output + controls ───────
export type AgentRun = { export type AgentRun = {

View File

@@ -2958,6 +2958,32 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/operations/drains/{name}/burst": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Operations Drain Burst
* @description Start/stop a drain's MANUAL burst window (chair-controlled, from /operations).
*
* ``action='on'`` → ``burst_until`` = body ``until`` (ISO) or the upcoming
* Saturday 18:00 Israel time. ``action='off'`` → NULL. The host supervisor
* (legal-halacha-supervisor) reads this from the DB and lifts/restores the
* drain's window accordingly (takes effect within one supervisor tick, ≤15 min).
* Never set automatically — manual only.
*/
post: operations["operations_drain_burst_api_operations_drains__name__burst_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/operations/agents": { "/api/operations/agents": {
parameters: { parameters: {
query?: never; query?: never;
@@ -9147,6 +9173,43 @@ export interface operations {
}; };
}; };
}; };
operations_drain_burst_api_operations_drains__name__burst_post: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
[key: string]: unknown;
};
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
operations_agents_api_operations_agents_get: { operations_agents_api_operations_agents_get: {
parameters: { parameters: {
query?: never; query?: never;