// Dashboard SCH3DUL3S tab — read-only list of the scheduled prompts left // in the hive's store, so the operator can recreate them with // `swarmctl schedule`. Nothing on the hive fires or edits them; the list // is fetched from `/api/schedules` on tab activation and state loads. // // Owns its own module state (the schedules list); reads the shared agent // roster from state.js for the per-agent columns. 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 { paintAtomic, epochSec, fmtAgo, fmtDuration } from "./util.js"; import { containersState } from "./state.js"; // Local cache lets `refreshTabCounts` show the active count without // re-fetching every second. let schedulesState = []; 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; } // Table layout: one row per schedule, attribute columns + one ✓/✕ // column per agent (vertical header so a row of agents takes ~28px // each instead of full word width). function renderSchedulesList() { const liveRoot = $("schedules-section"); if (!liveRoot) return; paintAtomic(liveRoot, (root) => { if (!schedulesState.length) { root.append(el("p", { class: "empty" }, "no schedules left")); return; } 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)); table.append(tbody); const wrap = el("div", { class: "schedules-table-wrap" }); wrap.append(table); root.append(wrap); }); } // The set of agent columns in the schedules table: live containers // (sorted), then any extra names that appear as a schedule target but // aren't in the live container list. function schedulesTableAgentSet() { const seen = new Set(); const out = []; const push = (n) => { if (!seen.has(n)) { seen.add(n); out.push(n); } }; 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"), ); // Every 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 = !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)), ), ); } 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 — the stored next fire time, absolute: it is the value // to pass as `swarmctl schedule add --at`. Cancelled / paused rows say // so, with the time in the title. const nextIso = new Date(s.next_fire_at_unix).toISOString(); 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() + "\nnext fire " + nextIso; nextCell.append(el("span", { class: "sched-paused-label" }, "⏸ paused")); } else { nextCell.textContent = nextIso; } 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: ✓ active target, muted ✕ cancelled target, // empty when the agent isn't a target. Last fire + result on hover. 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) { td.title = (t.cancelled_at_unix ? "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( t.cancelled_at_unix ? el("span", { class: "schedules-table-check-cancelled" }, "✕") : el("span", {}, "✓"), ); } tr.append(td); } return tr; }