Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/dashboard/src/schedules.js
atlas a4a3f81d5c dashboard: SCH3DUL3S is a read-only list with a removal banner
The tab lists the rows left in the hive's store so the operator can
recreate them with `swarmctl schedule add`: no create row, edit form,
fire/pause/cancel buttons or schedules_changed subscription. The next
column shows the stored next fire time as an absolute UTC timestamp (the
value for --at) instead of a countdown. A warning banner says the page is
slated for removal and points at swarmctl. The approvals view drops the
schedule_prompt card and the approval_added subscription.
2026-10-04 21:23:30 +02:00

232 lines
7.6 KiB
JavaScript

// 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;
}