feat(operations-ui): BURST toggle + deadline dialog for the halacha drain (#133)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 7s

Implements the chair-approved Claude Design mockup (02b-operations-burst): a single
gold " הפעל BURST" button on the legal-halacha-drain row that opens a deadline
dialog (datetime-local, default the upcoming Saturday 18:00, quick chips for Sat
18:00 / +5h / midnight). While a burst is active the row shows a gold pill with the
deadline + an "עצור BURST" stop button. Manual only.

- operations.ts: burst_until on OpsService + useDrainBurst hook (POST .../burst).
- page.tsx: BurstControl component, gated to legal-halacha-drain.
- types.ts: regenerated (npm run api:types) — the new burst route.

Active-state is derived from burst_until presence (the supervisor NULLs it at the
deadline, snapshot refetches every 5s) — no impure Date.now() in render.

Invariants: G1/G2 — single DB-backed control (drain_controls.burst_until), shared
with the host supervisor; no parallel path. UI passed the Claude Design gate.
This commit is contained in:
2026-06-12 11:46:41 +00:00
parent 75a1b23972
commit 9154a1d817
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 { Button } from "@/components/ui/button";
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 { ScrollArea } from "@/components/ui/scroll-area";
import {
@@ -16,11 +18,13 @@ import {
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
useOperations,
useServiceAction,
useDrainToggle,
useDrainBurst,
useAgentRuns,
useRunLog,
useCancelRun,
@@ -116,6 +120,138 @@ const SERVICE_LABELS: Record<string, string> = {
"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) ─────────────────
function ServiceControls({
s,
@@ -143,6 +279,7 @@ function ServiceControls({
>
הרץ עכשיו
</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">
<Switch
checked={!s.disabled}