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.
232 lines
7.6 KiB
JavaScript
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;
|
|
}
|