docs/web-ui.md duplicated the web-ui/ directory name at the top level -- the only such collision in docs/ (every other subsystem has just a directory, no sibling <dir>.md file). That's exactly why it rendered outside the directory structure in the docs site nav (mara's report, hyperhive#4054): the site build walks docs/ generically with no special-casing, so a loose top-level file next to a same-named directory shows up as its own flat top-level entry instead of nesting under that directory's section. web-ui.md's own first paragraph already said as much -- 'This doc has been split for readability... start at web-ui/README.md instead.' It was a leftover pointer from before the split, not a page carrying unique content on its own merit. Folded its two sections web-ui/README.md didn't already have (the swarm-ui design-guide link, and the task-oriented 'reading paths' quick-lookup list) into web-ui/README.md's existing 'More depth' section, then deleted the stray file and repointed every real reference at it: 3 in-tree doc cross-links, 3 doc prose mentions (retargeted to the more specific dashboard.md/shape.md sub-page each one was actually about), and ~28 frontend source comments (dashboard/agent/shared packages) that cited it as 'docs/web-ui.md::<heading>' for implementation context -- retargeted each to whichever of dashboard.md/shape.md/agent.md actually carries that heading now, verified against each file's real heading list rather than guessed. Verified via scripts/check-doc-refs.sh (the same lint CI runs): 0 dead pointers, both before write (confirming the tree was clean beforehand) and after (confirming nothing broke).
1318 lines
44 KiB
JavaScript
1318 lines
44 KiB
JavaScript
// Dashboard SCH3DUL3S tab — scheduled-prompts.
|
||
//
|
||
// The operator's scheduled prompts: the list, the inline create row,
|
||
// the per-row edit form, and the fire-now / cancel actions. Live updates
|
||
// arrive via the `schedules_changed` dashboard event (wired into the
|
||
// entry's mutation dispatch table); the tab-activation re-fetch is the
|
||
// cold-load / reconnect recovery path.
|
||
//
|
||
// Owns its own module state (the schedules list + edit-in-progress
|
||
// tracking); reads the shared agent roster from state.js for the
|
||
// target-chip pickers. Render + format helpers come from util.js.
|
||
//
|
||
// Note: per-agent reminders live in the in-container store, not here —
|
||
// they surface via get_loose_ends / the todos pill on the agent page.
|
||
|
||
import { $ } from "./common.js";
|
||
import { el } from "@hive/shared/dom.js";
|
||
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
|
||
import { paintAtomic, epochSec, fmtAgo, fmtDuration } from "./util.js";
|
||
import { containersState } from "./state.js";
|
||
import { asyncBtn } from "@hive/shared/forms.js";
|
||
// ─── scheduled prompts ─────────────────────────────────────────────────
|
||
// Backend exposes `/api/schedules` (snapshot), `/api/schedules`
|
||
// (POST, operator-direct submit), `/api/schedules/{id}/cancel`
|
||
// (whole or per-target), `/api/schedules/{id}` (PATCH edit),
|
||
// `/api/schedules/{id}/fire-now` (POST). Mutations now emit a
|
||
// `schedules_changed` SSE event so the list updates live;
|
||
// `applySchedulesChanged` handles it. Tab-activation re-fetch kept as
|
||
// a safety net for approval-path inserts and disconnect windows.
|
||
// Local cache lets `refreshTabCounts` show the active count without
|
||
// re-fetching every second.
|
||
let schedulesState = [];
|
||
// Schedule ids whose inline edit form is currently open. The set
|
||
// survives across refreshSchedules() calls so a state
|
||
// poll doesn't yank the form out from under the operator. Per-id
|
||
// mid-edit carry sits in `scheduleEditCarry` so unsaved typing
|
||
// also rides the refresh.
|
||
const editingSchedules = new Set();
|
||
const scheduleEditCarry = new Map();
|
||
export async function refreshSchedules() {
|
||
const listRoot = $("schedules-section");
|
||
if (!listRoot) return;
|
||
try {
|
||
const resp = await fetch("/api/schedules");
|
||
if (!resp.ok) {
|
||
paintAtomic(listRoot, (root) => {
|
||
root.append(
|
||
el(
|
||
"p",
|
||
{ class: "empty" },
|
||
"schedules unavailable: http " + resp.status,
|
||
),
|
||
);
|
||
});
|
||
return;
|
||
}
|
||
schedulesState = await resp.json();
|
||
} catch (err) {
|
||
paintAtomic(listRoot, (root) => {
|
||
root.append(
|
||
el("p", { class: "empty" }, "schedules fetch failed: " + err),
|
||
);
|
||
});
|
||
return;
|
||
}
|
||
renderSchedulesList();
|
||
}
|
||
// Active = at least one target still alive (no `cancelled_at_unix`)
|
||
// AND the whole schedule isn't cancelled. Drives the tab pill count.
|
||
export function activeScheduleCount() {
|
||
let n = 0;
|
||
for (const s of schedulesState) {
|
||
if (s.cancelled_at_unix) continue;
|
||
if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++;
|
||
}
|
||
return n;
|
||
}
|
||
// Label + caption wrapper shared by every field on the schedule
|
||
// forms — `<label class="schedule-field"><span
|
||
// class="schedule-field-label">…</span>…children…</label>`. Variadic
|
||
// children land inside the label after the caption span, which is
|
||
// what every caller wants (the input + any preset/parts/preview
|
||
// sub-rows live next to the caption).
|
||
function scheduleField(labelText, ...children) {
|
||
return el(
|
||
"label",
|
||
{ class: "schedule-field" },
|
||
el("span", { class: "schedule-field-label" }, labelText),
|
||
...children,
|
||
);
|
||
}
|
||
|
||
// Interval composer — shared between the new-schedule form and
|
||
// the edit-schedule form. Builds the preset chip row +
|
||
// d/h/m/s sub-fields + live preview, and returns helpers to read
|
||
// and write the value.
|
||
//
|
||
// The composer carries no opinion on what "0 / blank" means at the
|
||
// semantic layer: it just reports the integer total. Callers decide
|
||
// whether to treat 0 as `null` (one-shot) for their own submit path.
|
||
function buildIntervalComposer({
|
||
label = "interval (blank / all-zero = one-shot)",
|
||
namePrefix = "interval_",
|
||
initialSeconds = 0,
|
||
} = {}) {
|
||
const wrapper = scheduleField(label);
|
||
const presets = [
|
||
["1m", 60],
|
||
["5m", 300],
|
||
["15m", 900],
|
||
["30m", 1800],
|
||
["1h", 3600],
|
||
["6h", 21600],
|
||
["12h", 43200],
|
||
["1d", 86400],
|
||
["7d", 604800],
|
||
];
|
||
const presetsRow = el("div", { class: "schedule-interval-presets" });
|
||
for (const [presetLabel, secs] of presets) {
|
||
presetsRow.append(
|
||
el(
|
||
"button",
|
||
{
|
||
type: "button",
|
||
class: "btn btn-interval-preset",
|
||
"data-secs": String(secs),
|
||
},
|
||
presetLabel,
|
||
),
|
||
);
|
||
}
|
||
presetsRow.append(
|
||
el(
|
||
"button",
|
||
{
|
||
type: "button",
|
||
class: "btn btn-interval-preset btn-interval-oneshot",
|
||
"data-secs": "0",
|
||
},
|
||
"one-shot",
|
||
),
|
||
);
|
||
wrapper.append(presetsRow);
|
||
|
||
const partsRow = el("div", { class: "schedule-interval-parts" });
|
||
const mkPart = (suffix, unit) => {
|
||
const inp = el("input", {
|
||
type: "number",
|
||
name: namePrefix + suffix,
|
||
min: "0",
|
||
step: "1",
|
||
placeholder: "0",
|
||
class: "schedule-interval-num",
|
||
});
|
||
partsRow.append(inp, el("span", { class: "schedule-interval-unit" }, unit));
|
||
return inp;
|
||
};
|
||
const dInp = mkPart("d", "d");
|
||
const hInp = mkPart("h", "h");
|
||
const mInp = mkPart("m", "m");
|
||
const sInp = mkPart("s", "s");
|
||
wrapper.append(partsRow);
|
||
|
||
const preview = el("div", {
|
||
class: "schedule-interval-preview",
|
||
"aria-live": "polite",
|
||
});
|
||
wrapper.append(preview);
|
||
|
||
function getSeconds() {
|
||
const n = (v) => {
|
||
const x = parseInt(v, 10);
|
||
return Number.isFinite(x) && x > 0 ? x : 0;
|
||
};
|
||
return (
|
||
n(dInp.value) * 86400 +
|
||
n(hInp.value) * 3600 +
|
||
n(mInp.value) * 60 +
|
||
n(sInp.value)
|
||
);
|
||
}
|
||
function fillFromSeconds(total) {
|
||
const t = Math.max(0, Math.floor(total));
|
||
const d = Math.floor(t / 86400);
|
||
const h = Math.floor((t % 86400) / 3600);
|
||
const m = Math.floor((t % 3600) / 60);
|
||
const s = t % 60;
|
||
dInp.value = d > 0 ? String(d) : "";
|
||
hInp.value = h > 0 ? String(h) : "";
|
||
mInp.value = m > 0 ? String(m) : "";
|
||
sInp.value = s > 0 ? String(s) : "";
|
||
}
|
||
function setParts({ d, h, m, s }) {
|
||
if (d !== undefined) dInp.value = d;
|
||
if (h !== undefined) hInp.value = h;
|
||
if (m !== undefined) mInp.value = m;
|
||
if (s !== undefined) sInp.value = s;
|
||
}
|
||
function updatePreview() {
|
||
const total = getSeconds();
|
||
preview.textContent =
|
||
total > 0
|
||
? "↻ every " + fmtDuration(total)
|
||
: "one-shot (fires once at first-fire time)";
|
||
preview.classList.toggle("schedule-interval-preview-oneshot", total === 0);
|
||
}
|
||
for (const inp of [dInp, hInp, mInp, sInp]) {
|
||
inp.addEventListener("input", updatePreview);
|
||
}
|
||
for (const b of presetsRow.querySelectorAll("button[data-secs]")) {
|
||
b.addEventListener("click", () => {
|
||
fillFromSeconds(parseInt(b.getAttribute("data-secs"), 10) || 0);
|
||
updatePreview();
|
||
});
|
||
}
|
||
if (initialSeconds > 0) fillFromSeconds(initialSeconds);
|
||
updatePreview();
|
||
|
||
return { wrapper, getSeconds, fillFromSeconds, setParts, updatePreview };
|
||
}
|
||
|
||
// Parse the d/h/m/s sub-fields of a `buildIntervalComposer` instance
|
||
// out of submitted FormData. Returns total seconds, or NaN if any
|
||
// field carries a non-integer / negative value — the caller alerts
|
||
// and bails. Total `0` means "one-shot" at the call site (wire is
|
||
// `interval_seconds: null`). `namePrefix` matches the prefix passed
|
||
// to `buildIntervalComposer` (e.g. `interval_` or `edit_interval_`).
|
||
function intervalSecondsFromFormData(fd, namePrefix) {
|
||
const part = (suffix, mult) => {
|
||
const raw = String(fd.get(namePrefix + suffix) || "").trim();
|
||
if (!raw) return 0;
|
||
const n = parseInt(raw, 10);
|
||
if (!Number.isFinite(n) || n < 0) return NaN;
|
||
return n * mult;
|
||
};
|
||
return part("d", 86400) + part("h", 3600) + part("m", 60) + part("s", 1);
|
||
}
|
||
|
||
// Targets multi-select chip box — shared between the new-schedule
|
||
// form and the edit-schedule form. Same DOM shape, same candidate
|
||
// list (containers + operator + root); only the chip element id
|
||
// prefix and checkbox field name vary. Used to be inlined twice in
|
||
// near-identical 18-line blocks; consolidated here so a future
|
||
// change (new chip kind, candidate-list source swap, etc.) lives in
|
||
// one place. Returns the wrapping `<label class="schedule-field">`
|
||
// ready to append to the form.
|
||
function buildTargetChips({ idPrefix, fieldName, checked, extraNames = [] }) {
|
||
const candidates = ["operator"];
|
||
const containerNames = Array.from(containersState.values())
|
||
.map((c) => c.name)
|
||
.filter((n) => n !== "operator")
|
||
.sort();
|
||
for (const n of containerNames) candidates.push(n);
|
||
// `extraNames` lets the edit form keep showing an already-active
|
||
// target that's vanished from the live container list (operator's
|
||
// typo, container destroyed mid-schedule, etc.) so it's still
|
||
// explicitly uncheckable. New-schedule callers pass `[]`.
|
||
for (const n of extraNames) if (!candidates.includes(n)) candidates.push(n);
|
||
|
||
const box = el("div", { class: "schedule-targets" });
|
||
for (const name of candidates) {
|
||
const id_ = idPrefix + name;
|
||
const cb = el("input", {
|
||
type: "checkbox",
|
||
name: fieldName,
|
||
value: name,
|
||
id: id_,
|
||
});
|
||
if (checked.has(name)) cb.checked = true;
|
||
box.append(
|
||
el(
|
||
"label",
|
||
{ class: "schedule-target-chip", for: id_ },
|
||
cb,
|
||
el("span", {}, name),
|
||
),
|
||
);
|
||
}
|
||
return scheduleField("targets", box);
|
||
}
|
||
|
||
// Inline-create carry-state — survives paintAtomic re-renders.
|
||
// The schedules table refreshes on tab activate + after every mutation,
|
||
// and each refresh rebuilds the DOM via `paintAtomic`. The bottom
|
||
// create row's inputs would lose mid-typing values without this
|
||
// carry. `readNewScheduleCarryFromDOM` is called BEFORE every
|
||
// re-render so the carry sees the latest user input; the row
|
||
// builders then pre-fill from `newScheduleCarry`.
|
||
const newScheduleCarry = {
|
||
targets: new Set(),
|
||
body: "",
|
||
description: "",
|
||
first_fire: "",
|
||
interval_d: "",
|
||
interval_h: "",
|
||
interval_m: "",
|
||
interval_s: "",
|
||
};
|
||
function readNewScheduleCarryFromDOM(tr) {
|
||
if (!tr) return;
|
||
const get = (sel) => tr.querySelector(sel);
|
||
const checked = Array.from(
|
||
tr.querySelectorAll('input[name="new_targets"]:checked'),
|
||
).map((i) => i.value);
|
||
newScheduleCarry.targets = new Set(checked);
|
||
const setIfDefined = (key, sel) => {
|
||
const el_ = get(sel);
|
||
if (el_) newScheduleCarry[key] = el_.value || "";
|
||
};
|
||
setIfDefined("body", 'textarea[name="new_body"]');
|
||
setIfDefined("description", 'input[name="new_description"]');
|
||
setIfDefined("first_fire", 'input[name="new_first_fire"]');
|
||
setIfDefined("interval_d", 'input[name="new_interval_d"]');
|
||
setIfDefined("interval_h", 'input[name="new_interval_h"]');
|
||
setIfDefined("interval_m", 'input[name="new_interval_m"]');
|
||
setIfDefined("interval_s", 'input[name="new_interval_s"]');
|
||
}
|
||
function resetNewScheduleCarry() {
|
||
newScheduleCarry.targets = new Set();
|
||
newScheduleCarry.body = "";
|
||
newScheduleCarry.description = "";
|
||
newScheduleCarry.first_fire = "";
|
||
newScheduleCarry.interval_d = "";
|
||
newScheduleCarry.interval_h = "";
|
||
newScheduleCarry.interval_m = "";
|
||
newScheduleCarry.interval_s = "";
|
||
}
|
||
function isoForDatetimeLocal(date) {
|
||
// `<input type="datetime-local">` expects `YYYY-MM-DDTHH:MM`
|
||
// in the user's local timezone (with no trailing Z). Build it
|
||
// by hand rather than slicing toISOString which is always UTC.
|
||
const pad = (n) => String(n).padStart(2, "0");
|
||
return (
|
||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||
);
|
||
}
|
||
// Schedules render as a single table — one row per schedule,
|
||
// attribute columns + one ✓/✕ column per agent (tilted 45° header
|
||
// so a row of agents takes ~28px each instead of full word width),
|
||
// actions column on the right. Edit form expands into a colspan'd
|
||
// row underneath when its row's `✎` is toggled on.
|
||
function renderSchedulesList() {
|
||
const liveRoot = $("schedules-section");
|
||
if (!liveRoot) return;
|
||
// Capture any mid-typed inline-create state BEFORE paintAtomic
|
||
// blows the row away so a refresh doesn't yank the operator's
|
||
// half-filled form. The carry is replayed by
|
||
// `renderInlineCreateRow` below.
|
||
readNewScheduleCarryFromDOM(
|
||
liveRoot.querySelector(".schedules-table-create-row"),
|
||
);
|
||
paintAtomic(liveRoot, (root) => {
|
||
const agents = schedulesTableAgentSet();
|
||
const table = el("table", { class: "schedules-table" });
|
||
table.append(renderSchedulesTableHead(agents));
|
||
const tbody = el("tbody", {});
|
||
const sorted = schedulesState.slice().sort((a, b) => {
|
||
// Cancelled → bucket 2, paused → bucket 1, active → bucket 0.
|
||
const aOrd = a.cancelled_at_unix ? 2 : a.paused_at_unix ? 1 : 0;
|
||
const bOrd = b.cancelled_at_unix ? 2 : b.paused_at_unix ? 1 : 0;
|
||
if (aOrd !== bOrd) return aOrd - bOrd;
|
||
return epochSec(a.next_fire_at_unix) - epochSec(b.next_fire_at_unix);
|
||
});
|
||
for (const s of sorted) {
|
||
tbody.append(renderScheduleRow(s, agents));
|
||
if (editingSchedules.has(s.id) && !s.cancelled_at_unix) {
|
||
tbody.append(renderScheduleEditRow(s, agents));
|
||
}
|
||
}
|
||
// Always-visible inline create row at the bottom of the table
|
||
// — fill cells + click + to POST. See
|
||
// docs/web-ui/dashboard.md::SCH3DUL3S tab for the layout rationale.
|
||
tbody.append(renderInlineCreateRow(agents));
|
||
table.append(tbody);
|
||
const wrap = el("div", { class: "schedules-table-wrap" });
|
||
wrap.append(table);
|
||
root.append(wrap);
|
||
});
|
||
}
|
||
// Inline create row. Each column carries an input matching its
|
||
// display semantics (datetime-local for next-fire, mini d/h/m/s
|
||
// number inputs for every, textarea for body, checkbox per agent
|
||
// column for targets). Submitting POSTs `/api/schedules` and clears
|
||
// the carry on success; the next `refreshSchedules` redraws the
|
||
// table with the new schedule above.
|
||
function renderInlineCreateRow(agents) {
|
||
const tr = el("tr", { class: "schedules-table-create-row" });
|
||
|
||
tr.append(el("td", { class: "meta schedules-table-id" }, "new"));
|
||
tr.append(el("td", { class: "meta" }, "—"));
|
||
|
||
const nextInput = el("input", {
|
||
type: "datetime-local",
|
||
name: "new_first_fire",
|
||
class: "schedules-table-inline-input schedules-table-inline-datetime",
|
||
required: "required",
|
||
title: "first fire time (defaults to 5 minutes from now)",
|
||
});
|
||
// Default: 5 minutes from now so a stale-clock or quick-submit
|
||
// accident doesn't fire immediately on `now()`.
|
||
nextInput.value =
|
||
newScheduleCarry.first_fire ||
|
||
isoForDatetimeLocal(new Date(Date.now() + 5 * 60 * 1000));
|
||
tr.append(el("td", { class: "schedules-table-create-cell" }, nextInput));
|
||
|
||
const intervalRow = el("div", {
|
||
class: "schedules-table-inline-interval",
|
||
title:
|
||
"recurring every D days H hours M minutes S seconds (all blank / zero = one-shot)",
|
||
});
|
||
const mkUnit = (suffix, unit) => {
|
||
const inp = el("input", {
|
||
type: "number",
|
||
name: "new_interval_" + suffix,
|
||
min: "0",
|
||
step: "1",
|
||
placeholder: "0",
|
||
class: "schedules-table-inline-num",
|
||
"aria-label": "interval " + suffix,
|
||
});
|
||
inp.value = newScheduleCarry["interval_" + suffix] || "";
|
||
intervalRow.append(
|
||
inp,
|
||
el("span", { class: "schedules-table-inline-unit" }, unit),
|
||
);
|
||
};
|
||
mkUnit("d", "d");
|
||
mkUnit("h", "h");
|
||
mkUnit("m", "m");
|
||
mkUnit("s", "s");
|
||
tr.append(el("td", { class: "schedules-table-create-cell" }, intervalRow));
|
||
|
||
tr.append(el("td", { class: "meta" }, "operator"));
|
||
|
||
const bodyTa = el("textarea", {
|
||
name: "new_body",
|
||
rows: "1",
|
||
required: "required",
|
||
placeholder: "prompt body (required, multi-line ok)",
|
||
class: "schedules-table-inline-textarea",
|
||
});
|
||
bodyTa.value = newScheduleCarry.body;
|
||
const descInput = el("input", {
|
||
type: "text",
|
||
name: "new_description",
|
||
placeholder: "description (optional)",
|
||
class: "schedules-table-inline-input schedules-table-inline-desc",
|
||
});
|
||
descInput.value = newScheduleCarry.description;
|
||
tr.append(
|
||
el(
|
||
"td",
|
||
{ class: "schedules-table-create-cell schedules-table-create-body" },
|
||
bodyTa,
|
||
descInput,
|
||
),
|
||
);
|
||
|
||
// Per-agent target checkboxes — one cell per agent column. Wrap
|
||
// each checkbox in a label so the whole cell area is clickable.
|
||
for (const a of agents) {
|
||
const td = el("td", {
|
||
class: "schedules-table-check schedules-table-create-cell",
|
||
});
|
||
const id_ = "new-target-" + a;
|
||
const cb = el("input", {
|
||
type: "checkbox",
|
||
name: "new_targets",
|
||
value: a,
|
||
id: id_,
|
||
class: "schedules-table-inline-check",
|
||
});
|
||
if (newScheduleCarry.targets.has(a)) cb.checked = true;
|
||
const lbl = el(
|
||
"label",
|
||
{
|
||
for: id_,
|
||
class: "schedules-table-inline-check-lbl",
|
||
title: "tick to target " + a,
|
||
},
|
||
cb,
|
||
);
|
||
td.append(lbl);
|
||
tr.append(td);
|
||
}
|
||
|
||
// Actions cell — submit button + reset.
|
||
const actionsCell = el("td", { class: "schedules-table-actions" });
|
||
const submitBtn = el(
|
||
"button",
|
||
{
|
||
type: "button",
|
||
class: "btn btn-spawn btn-inline-small schedules-table-create-submit",
|
||
title: "queue this new schedule",
|
||
},
|
||
"+",
|
||
);
|
||
submitBtn.addEventListener("click", () =>
|
||
submitNewScheduleInline(tr, submitBtn),
|
||
);
|
||
const resetBtn = el(
|
||
"button",
|
||
{
|
||
type: "button",
|
||
class: "btn btn-inline-small schedules-table-create-reset",
|
||
title: "clear all fields",
|
||
},
|
||
"⌫",
|
||
);
|
||
resetBtn.addEventListener("click", () => {
|
||
resetNewScheduleCarry();
|
||
renderSchedulesList();
|
||
});
|
||
actionsCell.append(submitBtn, resetBtn);
|
||
tr.append(actionsCell);
|
||
|
||
return tr;
|
||
}
|
||
async function submitNewScheduleInline(tr, submitBtn) {
|
||
const targets = Array.from(
|
||
tr.querySelectorAll('input[name="new_targets"]:checked'),
|
||
).map((i) => i.value);
|
||
const body = String(
|
||
tr.querySelector('textarea[name="new_body"]')?.value || "",
|
||
).trim();
|
||
const description = String(
|
||
tr.querySelector('input[name="new_description"]')?.value || "",
|
||
).trim();
|
||
const firstFireStr = String(
|
||
tr.querySelector('input[name="new_first_fire"]')?.value || "",
|
||
);
|
||
|
||
if (!targets.length) {
|
||
themedToast(
|
||
"schedule must have at least one target — tick at least one agent column",
|
||
{ type: "error" },
|
||
);
|
||
return;
|
||
}
|
||
if (!body) {
|
||
themedToast("prompt body must be non-empty", { type: "error" });
|
||
return;
|
||
}
|
||
if (!firstFireStr) {
|
||
themedToast("first fire timestamp is required", { type: "error" });
|
||
return;
|
||
}
|
||
const firstFireDate = new Date(firstFireStr);
|
||
if (Number.isNaN(firstFireDate.getTime())) {
|
||
themedToast("first fire is not a valid datetime", { type: "error" });
|
||
return;
|
||
}
|
||
const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000);
|
||
|
||
// Parse the d/h/m/s parts inline — we don't use FormData since
|
||
// the inline row isn't wrapped in a <form>. Mirrors
|
||
// `intervalSecondsFromFormData` semantics: blank/0 = one-shot,
|
||
// anything non-integer or negative → NaN → alert.
|
||
const part = (suffix, mult) => {
|
||
const raw = String(
|
||
tr.querySelector(`input[name="new_interval_${suffix}"]`)?.value || "",
|
||
).trim();
|
||
if (!raw) return 0;
|
||
const n = parseInt(raw, 10);
|
||
if (!Number.isFinite(n) || n < 0) return NaN;
|
||
return n * mult;
|
||
};
|
||
const intervalTotal =
|
||
part("d", 86400) + part("h", 3600) + part("m", 60) + part("s", 1);
|
||
if (Number.isNaN(intervalTotal)) {
|
||
themedToast(
|
||
"interval fields must be non-negative integers (or blank for one-shot)",
|
||
{ type: "error" },
|
||
);
|
||
return;
|
||
}
|
||
const interval_seconds = intervalTotal > 0 ? intervalTotal : null;
|
||
|
||
const payload = { targets, body, first_fire_at_unix };
|
||
if (interval_seconds != null) payload.interval_seconds = interval_seconds;
|
||
if (description) payload.description = description;
|
||
|
||
asyncBtn(submitBtn, async () => {
|
||
try {
|
||
const resp = await fetch("/api/schedules", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => "");
|
||
themedToast(
|
||
"schedule submit failed: http " +
|
||
resp.status +
|
||
(text ? "\n\n" + text : ""),
|
||
{ type: "error" },
|
||
);
|
||
return;
|
||
}
|
||
// Reset carry so the next render shows an empty row.
|
||
resetNewScheduleCarry();
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
themedToast("schedule submit failed: " + err, { type: "error" });
|
||
}
|
||
});
|
||
}
|
||
// The set of agent columns in the schedules table: operator + root
|
||
// (manager) first, then live containers (sorted), then any extra names
|
||
// that appear as a schedule target but aren't in the live container list
|
||
// (operator typo, container destroyed mid-schedule, etc.) — same
|
||
// membership rule as `buildTargetChips` so the table and the new-/
|
||
// edit-form chip boxes agree on what's addressable.
|
||
function schedulesTableAgentSet() {
|
||
const seen = new Set();
|
||
const out = [];
|
||
const push = (n) => {
|
||
if (!seen.has(n)) {
|
||
seen.add(n);
|
||
out.push(n);
|
||
}
|
||
};
|
||
push("operator");
|
||
const containerNames = Array.from(containersState.values())
|
||
.map((c) => c.name)
|
||
.filter((n) => n !== "operator")
|
||
.sort();
|
||
for (const n of containerNames) push(n);
|
||
for (const s of schedulesState) {
|
||
for (const t of s.targets || []) push(t.target);
|
||
}
|
||
return out;
|
||
}
|
||
function renderSchedulesTableHead(agents) {
|
||
const thead = el("thead", {});
|
||
const headerRow = el("tr", {});
|
||
headerRow.append(
|
||
el("th", { class: "schedules-table-id" }, "#"),
|
||
el("th", {}, "src"),
|
||
el("th", { class: "schedules-table-next-col" }, "next"),
|
||
el("th", { class: "schedules-table-every-col" }, "every"),
|
||
el("th", {}, "owner"),
|
||
el("th", { class: "schedules-table-body-th" }, "body"),
|
||
);
|
||
// 'operator' is always a valid target; every other column maps to a
|
||
// container, so flag any column whose agent is no longer in the live
|
||
// roster — its past schedules linger in the table but the agent is gone.
|
||
const liveNames = new Set(
|
||
Array.from(containersState.values()).map((c) => c.name),
|
||
);
|
||
for (const a of agents) {
|
||
const gone = a !== "operator" && !liveNames.has(a);
|
||
headerRow.append(
|
||
el(
|
||
"th",
|
||
{
|
||
class:
|
||
"schedules-table-agent-th" +
|
||
(gone ? " schedules-table-agent-th--gone" : ""),
|
||
title: gone ? a + " — no longer exists (gone)" : a,
|
||
},
|
||
el("div", {}, el("span", {}, a)),
|
||
),
|
||
);
|
||
}
|
||
headerRow.append(el("th", { class: "schedules-table-actions-th" }, ""));
|
||
thead.append(headerRow);
|
||
return thead;
|
||
}
|
||
function renderScheduleRow(s, agents) {
|
||
const cancelled = !!s.cancelled_at_unix;
|
||
const paused = !cancelled && !!s.paused_at_unix;
|
||
const tr = el("tr", {
|
||
class:
|
||
"schedules-table-row" +
|
||
(cancelled ? " schedules-table-row-cancelled" : "") +
|
||
(paused ? " schedules-table-row-paused" : ""),
|
||
});
|
||
|
||
tr.append(el("td", { class: "meta schedules-table-id" }, "#" + s.id));
|
||
|
||
const srcKind =
|
||
s.source && s.source.kind === "approval" ? "approval" : "manual";
|
||
tr.append(
|
||
el(
|
||
"td",
|
||
{},
|
||
el(
|
||
"span",
|
||
{ class: "rqe-source rqe-source-" + srcKind },
|
||
srcKind === "approval" ? "approval" : "operator",
|
||
),
|
||
),
|
||
);
|
||
|
||
// "next" cell — relative due-in for active schedules, "cancelled"
|
||
// for cancelled ones, "paused" for paused ones. Both carry the
|
||
// absolute ISO in the title.
|
||
const nextCell = el("td", { class: "meta schedules-table-next-col" });
|
||
if (cancelled) {
|
||
nextCell.title = "cancelled " + new Date(s.cancelled_at_unix).toISOString();
|
||
nextCell.textContent = "cancelled";
|
||
} else if (paused) {
|
||
nextCell.title =
|
||
"paused since " +
|
||
new Date(s.paused_at_unix).toISOString() +
|
||
"\nwould fire at " +
|
||
new Date(s.next_fire_at_unix).toISOString();
|
||
nextCell.append(el("span", { class: "sched-paused-label" }, "⏸ paused"));
|
||
} else {
|
||
const dueIn = epochSec(s.next_fire_at_unix) - Math.floor(Date.now() / 1000);
|
||
nextCell.title = new Date(s.next_fire_at_unix).toISOString();
|
||
nextCell.textContent =
|
||
dueIn <= 0
|
||
? "overdue " + fmtAgo(s.next_fire_at_unix)
|
||
: fmtDuration(dueIn);
|
||
nextCell.classList.add("sched-due");
|
||
nextCell.dataset.dueAt = String(epochSec(s.next_fire_at_unix));
|
||
}
|
||
tr.append(nextCell);
|
||
|
||
tr.append(
|
||
el(
|
||
"td",
|
||
{ class: "meta schedules-table-every-col" },
|
||
s.interval_seconds ? "↻ " + fmtDuration(s.interval_seconds) : "one-shot",
|
||
),
|
||
);
|
||
|
||
tr.append(el("td", { class: "meta" }, s.owner));
|
||
|
||
// Body cell — truncates with ellipsis; full body + description on hover.
|
||
const bodyCell = el("td", { class: "schedules-table-body-cell" });
|
||
const bodyText = s.body || "";
|
||
bodyCell.title = (s.description ? s.description + "\n\n" : "") + bodyText;
|
||
bodyCell.textContent = bodyText;
|
||
tr.append(bodyCell);
|
||
|
||
// Per-agent target cells. Three states:
|
||
// - active target → ✓ button that cancels just that target on
|
||
// click (same affordance as the per-row ✕ on the old layout's
|
||
// targets table)
|
||
// - cancelled target → muted ✕ glyph (no button — backend
|
||
// re-add flows through the edit form's targets multi-select)
|
||
// - not a target → empty cell
|
||
const targetByName = new Map();
|
||
for (const t of s.targets || []) targetByName.set(t.target, t);
|
||
for (const a of agents) {
|
||
const t = targetByName.get(a);
|
||
const td = el("td", { class: "schedules-table-check" });
|
||
if (!t) {
|
||
// empty — no button, no glyph
|
||
} else if (t.cancelled_at_unix) {
|
||
td.title =
|
||
"cancelled — " +
|
||
(t.last_fired_at_unix
|
||
? "last fired " + fmtAgo(t.last_fired_at_unix) + " ago"
|
||
: "never fired") +
|
||
(t.last_result ? " · " + t.last_result : "");
|
||
td.append(el("span", { class: "schedules-table-check-cancelled" }, "✕"));
|
||
} else {
|
||
const lastFireDesc = t.last_fired_at_unix
|
||
? "last fired " + fmtAgo(t.last_fired_at_unix) + " ago"
|
||
: "never fired";
|
||
const lastResultDesc = t.last_result ? " · " + t.last_result : "";
|
||
const checkBtn = el(
|
||
"button",
|
||
{
|
||
type: "button",
|
||
class: "schedules-table-check-btn",
|
||
},
|
||
"✓",
|
||
);
|
||
checkBtn.title =
|
||
lastFireDesc +
|
||
lastResultDesc +
|
||
(cancelled ? "" : "\nclick to cancel this target");
|
||
if (cancelled) {
|
||
checkBtn.disabled = true;
|
||
} else {
|
||
checkBtn.addEventListener("click", () =>
|
||
cancelScheduleTargets(s.id, [a]),
|
||
);
|
||
}
|
||
td.append(checkBtn);
|
||
}
|
||
tr.append(td);
|
||
}
|
||
|
||
// Actions cell — fire / pause-toggle / edit / cancel-all.
|
||
// Glyph-only to fit a compact column; colour classes carry the
|
||
// visual cue: mauve = fire, teal = pause/resume, yellow = edit,
|
||
// red = cancel.
|
||
const actionsCell = el("td", { class: "schedules-table-actions" });
|
||
if (!cancelled) {
|
||
const activeTargets = (s.targets || []).filter((t) => !t.cancelled_at_unix);
|
||
const isOneShot = !s.interval_seconds;
|
||
const fireBtn = el(
|
||
"button",
|
||
{
|
||
type: "button",
|
||
class: "btn btn-fire-now btn-inline-small",
|
||
},
|
||
"↯",
|
||
);
|
||
fireBtn.title = isOneShot
|
||
? "fire once — one-shot, consumed after the manual fire"
|
||
: "fire once now — recurring; choose whether to reset the next-fire timer";
|
||
if (!activeTargets.length || paused) {
|
||
fireBtn.disabled = true;
|
||
fireBtn.title = paused
|
||
? "resume the schedule first to use fire-now"
|
||
: "every target is cancelled — nothing to fire";
|
||
}
|
||
fireBtn.addEventListener("click", () =>
|
||
fireScheduleNow(
|
||
s.id,
|
||
isOneShot,
|
||
activeTargets.map((t) => t.target),
|
||
fireBtn,
|
||
),
|
||
);
|
||
actionsCell.append(fireBtn);
|
||
|
||
// Pause / resume toggle — only meaningful for recurring schedules.
|
||
// One-shots can still be paused (to delay a one-time fire), so we
|
||
// show the button for both cases.
|
||
const pauseBtn = el(
|
||
"button",
|
||
{
|
||
type: "button",
|
||
class:
|
||
"btn btn-pause-schedule btn-inline-small" +
|
||
(paused ? " btn-resume-schedule" : ""),
|
||
},
|
||
paused ? "▶" : "⏸",
|
||
);
|
||
pauseBtn.title = paused ? "resume schedule" : "pause schedule";
|
||
pauseBtn.addEventListener("click", () =>
|
||
paused ? resumeSchedule(s.id) : pauseSchedule(s.id),
|
||
);
|
||
actionsCell.append(pauseBtn);
|
||
|
||
const editingThis = editingSchedules.has(s.id);
|
||
const editBtn = el(
|
||
"button",
|
||
{
|
||
type: "button",
|
||
class: "btn btn-edit-schedule btn-inline-small",
|
||
},
|
||
editingThis ? "✎×" : "✎",
|
||
);
|
||
editBtn.title = editingThis
|
||
? "close edit"
|
||
: "edit body / description / interval / next-fire / targets";
|
||
editBtn.addEventListener("click", () => {
|
||
if (editingThis) {
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
} else {
|
||
editingSchedules.add(s.id);
|
||
}
|
||
renderSchedulesList();
|
||
});
|
||
actionsCell.append(editBtn);
|
||
|
||
const cancelBtn = el(
|
||
"button",
|
||
{
|
||
type: "button",
|
||
class: "btn btn-deny btn-inline-small",
|
||
},
|
||
"✕",
|
||
);
|
||
cancelBtn.title = "cancel the whole schedule";
|
||
cancelBtn.addEventListener("click", () => cancelScheduleAll(s.id));
|
||
actionsCell.append(cancelBtn);
|
||
}
|
||
tr.append(actionsCell);
|
||
|
||
return tr;
|
||
}
|
||
function renderScheduleEditRow(s, agents) {
|
||
// colspan = 6 attribute cols + N agent cols + 1 actions col
|
||
const colCount = 7 + agents.length;
|
||
const tr = el("tr", { class: "schedules-table-edit-row" });
|
||
const td = el("td", { colspan: String(colCount) });
|
||
td.append(renderScheduleEditForm(s));
|
||
tr.append(td);
|
||
return tr;
|
||
}
|
||
|
||
// Inline edit form. Renders inside the schedule row when the
|
||
// row's `✎ edit` button is toggled on. Pre-filled with current
|
||
// values; submit PATCHes /api/schedules/{id}. Targets stay
|
||
// immutable (per damocles's backend; the workaround for retargeting
|
||
// is cancel + new schedule). Mid-edit field values survive a
|
||
// state-poll refresh via `scheduleEditCarry`.
|
||
function renderScheduleEditForm(s) {
|
||
const wrapper = el("div", { class: "schedule-edit-form-wrapper" });
|
||
const form_ = el("form", { class: "schedule-edit-form" });
|
||
form_.addEventListener("submit", (e) => {
|
||
e.preventDefault();
|
||
submitEditSchedule(s, form_);
|
||
});
|
||
|
||
const carry = scheduleEditCarry.get(s.id) || {};
|
||
|
||
const bodyInput = el("textarea", {
|
||
name: "body",
|
||
rows: "4",
|
||
required: "required",
|
||
});
|
||
bodyInput.value = carry.body !== undefined ? carry.body : s.body;
|
||
form_.append(scheduleField("body", bodyInput));
|
||
|
||
const descInput = el("input", { type: "text", name: "description" });
|
||
descInput.value =
|
||
carry.description !== undefined ? carry.description : s.description || "";
|
||
form_.append(scheduleField("description (blank to clear)", descInput));
|
||
|
||
const firstFireInput = el("input", {
|
||
type: "datetime-local",
|
||
name: "next_fire",
|
||
required: "required",
|
||
});
|
||
firstFireInput.value =
|
||
carry.next_fire !== undefined
|
||
? carry.next_fire
|
||
: isoForDatetimeLocal(new Date(s.next_fire_at_unix));
|
||
form_.append(scheduleField("next fire", firstFireInput));
|
||
|
||
const intervalCx = buildIntervalComposer({
|
||
label: "interval (blank / all-zero = flip to one-shot)",
|
||
namePrefix: "edit_interval_",
|
||
initialSeconds: 0,
|
||
});
|
||
form_.append(intervalCx.wrapper);
|
||
if (
|
||
carry.interval_d !== undefined ||
|
||
carry.interval_h !== undefined ||
|
||
carry.interval_m !== undefined ||
|
||
carry.interval_s !== undefined
|
||
) {
|
||
intervalCx.setParts({
|
||
d: carry.interval_d || "",
|
||
h: carry.interval_h || "",
|
||
m: carry.interval_m || "",
|
||
s: carry.interval_s || "",
|
||
});
|
||
} else if (s.interval_seconds) {
|
||
intervalCx.fillFromSeconds(s.interval_seconds);
|
||
}
|
||
intervalCx.updatePreview();
|
||
|
||
// Persist carry on every input + checkbox change so a refresh
|
||
// repaint preserves it.
|
||
const saveCarry = () => {
|
||
const fd = new FormData(form_);
|
||
scheduleEditCarry.set(s.id, {
|
||
body: String(fd.get("body") || ""),
|
||
description: String(fd.get("description") || ""),
|
||
next_fire: String(fd.get("next_fire") || ""),
|
||
interval_d: String(fd.get("edit_interval_d") || ""),
|
||
interval_h: String(fd.get("edit_interval_h") || ""),
|
||
interval_m: String(fd.get("edit_interval_m") || ""),
|
||
interval_s: String(fd.get("edit_interval_s") || ""),
|
||
targets: fd.getAll("edit_targets").map(String),
|
||
});
|
||
};
|
||
form_.addEventListener("input", saveCarry);
|
||
form_.addEventListener("change", saveCarry);
|
||
|
||
// Targets multi-select. Shares the chip-box pattern with the
|
||
// new-schedule form via `buildTargetChips`;
|
||
// cancelled tombstones aren't listed (re-adding them flows
|
||
// through `targets_add`, which the backend replace-on-conflict
|
||
// drops the tombstone for). Submit diffs against the original
|
||
// active set to populate `targets_add` / `targets_remove` on the
|
||
// PATCH body.
|
||
const originalActiveTargets = new Set(
|
||
(s.targets || []).filter((t) => !t.cancelled_at_unix).map((t) => t.target),
|
||
);
|
||
form_.append(
|
||
buildTargetChips({
|
||
idPrefix: "se-" + s.id + "-",
|
||
fieldName: "edit_targets",
|
||
checked: carry.targets ? new Set(carry.targets) : originalActiveTargets,
|
||
extraNames: [...originalActiveTargets],
|
||
}),
|
||
);
|
||
form_.append(
|
||
el(
|
||
"p",
|
||
{ class: "meta schedule-edit-targets-note" },
|
||
"re-adding a previously cancelled target drops its history and starts fresh; " +
|
||
"unchecking an active target cancels it.",
|
||
),
|
||
);
|
||
|
||
const actions = el("div", { class: "schedule-actions" });
|
||
const submit = el(
|
||
"button",
|
||
{ type: "submit", class: "btn btn-spawn" },
|
||
"✓ save changes",
|
||
);
|
||
const cancelEdit = el("button", { type: "button", class: "btn" }, "cancel");
|
||
cancelEdit.addEventListener("click", () => {
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
renderSchedulesList();
|
||
});
|
||
actions.append(submit, cancelEdit);
|
||
form_.append(actions);
|
||
|
||
wrapper.append(form_);
|
||
return wrapper;
|
||
}
|
||
|
||
async function submitEditSchedule(originalSchedule, form_) {
|
||
const s = originalSchedule;
|
||
const fd = new FormData(form_);
|
||
const newBody = String(fd.get("body") || "").trim();
|
||
const newDescription = String(fd.get("description") || "").trim();
|
||
const newNextFireStr = String(fd.get("next_fire") || "");
|
||
|
||
if (!newBody) {
|
||
themedToast("body must be non-empty", { type: "error" });
|
||
return;
|
||
}
|
||
if (!newNextFireStr) {
|
||
themedToast("next-fire timestamp is required", { type: "error" });
|
||
return;
|
||
}
|
||
const newNextFireDate = new Date(newNextFireStr);
|
||
if (Number.isNaN(newNextFireDate.getTime())) {
|
||
themedToast("next-fire is not a valid datetime", { type: "error" });
|
||
return;
|
||
}
|
||
const newNextFireUnix = Math.floor(newNextFireDate.getTime() / 1000);
|
||
|
||
// Compute interval total from the d/h/m/s fields (composer uses
|
||
// `edit_interval_*` names in this form).
|
||
const intervalTotal = intervalSecondsFromFormData(fd, "edit_interval_");
|
||
if (Number.isNaN(intervalTotal)) {
|
||
themedToast("interval fields must be non-negative integers", {
|
||
type: "error",
|
||
});
|
||
return;
|
||
}
|
||
const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null;
|
||
|
||
// Compute target diff against the schedule's currently-active set
|
||
// (cancelled tombstones don't count). The backend treats
|
||
// `targets_add` as replace-on-conflict (re-adding a tombstoned
|
||
// target drops its history), so we can just blanket "send the
|
||
// checked list as add, send the unchecked-but-was-active list as
|
||
// remove."
|
||
const newTargets = new Set(fd.getAll("edit_targets").map(String));
|
||
const originalActive = new Set(
|
||
(s.targets || []).filter((t) => !t.cancelled_at_unix).map((t) => t.target),
|
||
);
|
||
if (!newTargets.size) {
|
||
themedToast(
|
||
"schedule must have at least one target — uncheck submit, or use ✕ cancel all instead",
|
||
{ type: "error" },
|
||
);
|
||
return;
|
||
}
|
||
const targetsAdd = [...newTargets].filter((t) => !originalActive.has(t));
|
||
const targetsRemove = [...originalActive].filter((t) => !newTargets.has(t));
|
||
|
||
// Build the PATCH body. Only include keys for fields that
|
||
// actually changed; explicit `null` clears description / flips
|
||
// recurring→one-shot. `targets_add` / `targets_remove` only
|
||
// populated when non-empty so the wire body stays minimal.
|
||
const patch = {};
|
||
if (newBody !== s.body) patch.body = newBody;
|
||
if (newDescription !== (s.description || "")) {
|
||
patch.description = newDescription || null;
|
||
}
|
||
if (newNextFireUnix !== epochSec(s.next_fire_at_unix)) {
|
||
patch.next_fire_at_unix = newNextFireUnix;
|
||
}
|
||
if (newIntervalSeconds !== (s.interval_seconds || null)) {
|
||
patch.interval_seconds = newIntervalSeconds;
|
||
}
|
||
if (targetsAdd.length) patch.targets_add = targetsAdd;
|
||
if (targetsRemove.length) patch.targets_remove = targetsRemove;
|
||
if (!Object.keys(patch).length) {
|
||
// No-op submit. Treat as "close edit form".
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
renderSchedulesList();
|
||
return;
|
||
}
|
||
|
||
const submitBtn = form_.querySelector('button[type="submit"]');
|
||
const doEdit = async () => {
|
||
try {
|
||
const resp = await fetch("/api/schedules/" + encodeURIComponent(s.id), {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(patch),
|
||
});
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => "");
|
||
themedToast(
|
||
"edit failed: http " + resp.status + (text ? "\n\n" + text : ""),
|
||
{ type: "error" },
|
||
);
|
||
return;
|
||
}
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
themedToast("edit failed: " + err, { type: "error" });
|
||
}
|
||
};
|
||
if (submitBtn) asyncBtn(submitBtn, doEdit);
|
||
else await doEdit();
|
||
}
|
||
async function fireScheduleNow(id, isOneShot, targets, btn) {
|
||
const targetList = targets.length
|
||
? targets.join(", ")
|
||
: "(no active targets)";
|
||
// Recurring schedules get a "reset timer" checkbox (default on): firing
|
||
// now also re-arms the next regular fire to now + interval. One-shot is
|
||
// consumed regardless, so the flag is moot there (no checkbox).
|
||
const checkboxes = isOneShot
|
||
? []
|
||
: [
|
||
{
|
||
name: "reset_timer",
|
||
label: "reset timer — re-arm next fire to now + interval",
|
||
checked: true,
|
||
},
|
||
];
|
||
const prompt = isOneShot
|
||
? `fire schedule #${id} now to ${targetList}?\n\n` +
|
||
"this is a ONE-SHOT — firing now consumes the schedule. " +
|
||
"the scheduled fire time will no longer trigger."
|
||
: `fire schedule #${id} now to ${targetList}?\n\n` +
|
||
"this is RECURRING — sends an extra pulse out-of-band. " +
|
||
'leave "reset timer" on to re-arm the next regular fire to ' +
|
||
"now + interval; uncheck it to keep the existing cadence.";
|
||
const confirmRes = await themedConfirm({
|
||
message: prompt,
|
||
danger: true,
|
||
checkboxes,
|
||
});
|
||
if (!confirmRes) return;
|
||
const resetTimer = !!confirmRes.reset_timer;
|
||
// Capture child nodes so we can restore on error, then replace
|
||
// with DOM-built content (textContent + element children rather
|
||
// than innerHTML — the format string only carries server-side
|
||
// ints/bool today but textContent is the safer pattern if a
|
||
// stringy field ever lands).
|
||
const originalChildren = btn ? Array.from(btn.childNodes) : [];
|
||
const restoreBtn = () => {
|
||
if (!btn) return;
|
||
btn.disabled = false;
|
||
while (btn.firstChild) btn.removeChild(btn.firstChild);
|
||
for (const n of originalChildren) btn.appendChild(n);
|
||
};
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
while (btn.firstChild) btn.removeChild(btn.firstChild);
|
||
btn.append(el("span", { class: "spinner" }, "◐"), " firing…");
|
||
}
|
||
try {
|
||
const resp = await fetch(
|
||
"/api/schedules/" + encodeURIComponent(id) + "/fire-now",
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ reset_timer: resetTimer }),
|
||
},
|
||
);
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => "");
|
||
themedToast(
|
||
"fire-now failed: http " + resp.status + (text ? "\n\n" + text : ""),
|
||
{ type: "error" },
|
||
);
|
||
restoreBtn();
|
||
return;
|
||
}
|
||
// Backend returns FireNowReport { ok, failed, missing, one_shot_consumed,
|
||
// timer_reset }. Flash the per-target outcome on the button itself so the
|
||
// operator sees the result immediately, then refresh to pick up the
|
||
// authoritative per-target `last_result` annotations.
|
||
let report = null;
|
||
try {
|
||
report = await resp.json();
|
||
} catch {
|
||
/* shape drift / empty body — ignore */
|
||
}
|
||
if (btn && report) {
|
||
const bits = [];
|
||
if (report.ok) bits.push(report.ok + " ok");
|
||
if (report.failed) bits.push(report.failed + " failed");
|
||
if (report.missing) bits.push(report.missing + " missing");
|
||
const suffix = report.one_shot_consumed
|
||
? " — consumed"
|
||
: report.timer_reset
|
||
? " — timer reset"
|
||
: "";
|
||
while (btn.firstChild) btn.removeChild(btn.firstChild);
|
||
btn.textContent =
|
||
"↯ fired: " + (bits.join(", ") || "no targets") + suffix;
|
||
btn.classList.add("btn-fire-now-flashed");
|
||
}
|
||
// Hold the flash briefly so the operator can read it before the
|
||
// refresh wipes the row in place.
|
||
setTimeout(refreshSchedules, 1500);
|
||
} catch (err) {
|
||
themedToast("fire-now failed: " + err, { type: "error" });
|
||
restoreBtn();
|
||
}
|
||
}
|
||
async function cancelScheduleAll(id) {
|
||
if (
|
||
!(await themedConfirm({
|
||
message: `cancel schedule #${id}? this stops all future fires for every target.`,
|
||
danger: true,
|
||
}))
|
||
)
|
||
return;
|
||
await postScheduleCancel(id, null);
|
||
}
|
||
async function cancelScheduleTargets(id, targets) {
|
||
if (
|
||
!(await themedConfirm({
|
||
message: `cancel schedule #${id} for ${targets.join(", ")}? other targets keep firing.`,
|
||
danger: true,
|
||
}))
|
||
)
|
||
return;
|
||
await postScheduleCancel(id, targets);
|
||
}
|
||
async function postScheduleCancel(id, targets) {
|
||
try {
|
||
const opts = {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
// Always send a valid JSON body — cancel-all is `{}` (an empty body
|
||
// with a json content-type would 400 a strict extractor). Per-target
|
||
// cancel carries the list.
|
||
body: JSON.stringify(targets ? { targets } : {}),
|
||
};
|
||
const resp = await fetch(
|
||
"/api/schedules/" + encodeURIComponent(id) + "/cancel",
|
||
opts,
|
||
);
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => "");
|
||
themedToast(
|
||
"cancel failed: http " + resp.status + (text ? "\n\n" + text : ""),
|
||
{ type: "error" },
|
||
);
|
||
return;
|
||
}
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
themedToast("cancel failed: " + err, { type: "error" });
|
||
}
|
||
}
|
||
|
||
async function pauseSchedule(id) {
|
||
try {
|
||
const resp = await fetch(
|
||
"/api/schedules/" + encodeURIComponent(id) + "/pause",
|
||
{
|
||
method: "POST",
|
||
},
|
||
);
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => "");
|
||
themedToast(
|
||
"pause failed: http " + resp.status + (text ? "\n\n" + text : ""),
|
||
{ type: "error" },
|
||
);
|
||
return;
|
||
}
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
themedToast("pause failed: " + err, { type: "error" });
|
||
}
|
||
}
|
||
async function resumeSchedule(id) {
|
||
try {
|
||
const resp = await fetch(
|
||
"/api/schedules/" + encodeURIComponent(id) + "/resume",
|
||
{
|
||
method: "POST",
|
||
},
|
||
);
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => "");
|
||
themedToast(
|
||
"resume failed: http " + resp.status + (text ? "\n\n" + text : ""),
|
||
{ type: "error" },
|
||
);
|
||
return;
|
||
}
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
themedToast("resume failed: " + err, { type: "error" });
|
||
}
|
||
}
|
||
|
||
export function applySchedulesChanged(ev) {
|
||
schedulesState = (ev.schedules || []).slice();
|
||
renderSchedulesList();
|
||
}
|