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
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:
@@ -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:00–05: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 הפנויה. נעצר אוטומטית במועד — או ידנית בכפתור "עצור BURST".
|
||||
</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}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type OpsService = {
|
||||
cron: string;
|
||||
autorestart: boolean;
|
||||
disabled?: boolean; // cron drain switched off via the dashboard
|
||||
burst_until?: string | null; // manual "run continuously now" window (ISO) — see useDrainBurst
|
||||
};
|
||||
|
||||
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 ───────
|
||||
|
||||
export type AgentRun = {
|
||||
|
||||
@@ -2958,6 +2958,32 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Reference in New Issue
Block a user