Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
atlas 2026-10-04 21:23:18 +02:00
commit a4a3f81d5c
8 changed files with 68 additions and 1500 deletions

View file

@ -685,7 +685,7 @@ async function init() {
await refreshState();
// kinds= matches SSE_HANDLERS below verbatim — narrows this from all
// 17 wire kinds down to the 3 this page acts on. This page was one
// 13 wire kinds down to the 3 this page acts on. This page was one
// of 4 unfiltered `/api/dashboard/stream` subscribers before this
// (subscription discipline, part 1 of the dashboard-event-stream-
// split issue).

View file

@ -12,7 +12,7 @@
import { $, form, appendLinkified } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { epochSec, fmtAgo, fmtDuration } from "./util.js";
import { epochSec, fmtAgo } from "./util.js";
// Registered by the dashboard entry at boot; defaults to a no-op so the
// module is safe to call before wiring.
@ -117,7 +117,7 @@ export function operatorInboxAppendFromEvent(ev) {
// ─── approvals — the operator config-change approval queue ────────
const APPROVAL_TAB_KEY = "hyperhive:approvals:tab";
// Derived approval state — cold-loaded from /api/state, then mutated
// live by `approval_added` / `approval_resolved` dashboard events.
// live by `approval_resolved` dashboard events.
// `pending` is the open queue (newest-first); `history` is the last
// 30 resolved rows.
const APPROVAL_HISTORY_LIMIT = 30;
@ -129,26 +129,6 @@ export function syncApprovalsFromSnapshot(s) {
approvalsState.pending = (s.approvals || []).slice();
approvalsState.history = (s.approval_history || []).slice();
}
export function applyApprovalAdded(ev) {
// Upsert by id so a snapshot that already included the row (cold
// load + event lands at the same tick) doesn't double it.
const existing = approvalsState.pending.findIndex((a) => a.id === ev.id);
const row = {
id: ev.id,
agent: ev.agent,
kind: ev.approval_kind,
description: ev.description || null,
// The ApprovalAdded event carries no requested_at; a live-added
// approval was queued just now, so client-now is accurate — and
// consistent with how fmtAgo compares everything to client-now.
// A later /api/state cold-load swaps in the server value.
requested_at:
ev.requested_at != null ? ev.requested_at : Math.floor(Date.now() / 1000),
};
if (existing >= 0) approvalsState.pending[existing] = row;
else approvalsState.pending.push(row);
renderApprovals();
}
export function applyApprovalResolved(ev) {
// Drop from pending; prepend to history (newest-first), cap at 30.
approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id);
@ -175,7 +155,7 @@ export function applyApprovalResolved(ev) {
export function renderApprovals() {
const root = $("approvals-section");
// #approvals-section only lives on /dashboard.html (Y3R C4LL tab);
// no-op elsewhere — `approval_added` / `approval_resolved` SSE
// no-op elsewhere — `approval_resolved` SSE
// events route through here on every page that loads the bundle.
if (!root) return;
root.replaceChildren();
@ -223,17 +203,16 @@ export function renderApprovals() {
const ul = el("ul", { class: "approvals" });
for (const a of pending) {
const isUpdateMeta = a.kind === "update_meta_inputs";
const isSchedule = a.kind === "schedule_prompt";
const li = el("li", { class: "approval-card" });
// ── identity header ──────────────────────────────────────────
const head = el(
"div",
{ class: "approval-head" },
el("span", { class: "glyph" }, isUpdateMeta ? "↻" : "⏱"),
el("span", { class: "glyph" }, "↻"),
el("span", { class: "id" }, "#" + a.id),
el("span", { class: "agent" }, a.agent),
el("span", { class: "kind" }, isUpdateMeta ? "meta-update" : "schedule"),
el("span", { class: "kind" }, isUpdateMeta ? "meta-update" : a.kind),
);
// When the approval was requested — relative time, right-aligned.
// Goes amber once it's been pending an hour so a stale request is
@ -276,34 +255,6 @@ export function renderApprovals() {
: "bump all flake inputs",
),
);
} else if (isSchedule) {
let payload;
try {
payload = JSON.parse(a.commit_ref || "{}");
} catch (_) {
payload = {};
}
const targets = (payload.targets || []).join(", ");
const firstFire = payload.first_fire_at_unix
? new Date(payload.first_fire_at_unix * 1000).toLocaleString()
: "?";
const cadence = payload.interval_seconds
? " · ↻ every " + fmtDuration(payload.interval_seconds)
: " · one-shot";
body.append(
el(
"div",
{ class: "meta" },
"→ " + targets + " · first: " + firstFire + cadence,
),
);
if (payload.body) {
const excerpt =
payload.body.length > 80
? payload.body.slice(0, 80) + "…"
: payload.body;
body.append(el("div", { class: "approval-description" }, excerpt));
}
} else {
body.append(
el(
@ -381,7 +332,7 @@ function renderApprovalHistory(root, history) {
el(
"span",
{ class: "kind" },
a.kind === "update_meta_inputs" ? "meta-update" : "schedule",
a.kind === "update_meta_inputs" ? "meta-update" : a.kind,
),
" ",
);

View file

@ -269,26 +269,6 @@ code {
color: var(--amber);
border-color: var(--amber);
}
.btn-fire-now {
color: var(--purple);
border-color: var(--purple);
}
/* Post-fire flash: report renders directly on the button for ~1.5s
so the operator sees ok/failed/missing/consumed counts inline
without a modal. Green when at least one ok; muted otherwise. */
.btn-fire-now-flashed {
color: var(--green);
border-color: var(--green);
text-shadow: 0 0 6px currentColor;
box-shadow: 0 0 8px -2px currentColor;
}
/* Inline edit button on each schedule row. Yellow reads as a
parallel destructive-adjacent action (edit changes state, but
isn't deletion). */
.btn-edit-schedule {
color: var(--yellow);
border-color: var(--yellow);
}
/* ─── file-preview / diff panel (common.js Panel) ───────────────────
Side-panel content rendered by openFilePanel / buildTabbedPreview

View file

@ -532,7 +532,7 @@ async function init() {
await refreshState();
// kinds= matches SSE_HANDLERS below verbatim — narrows this from all
// 17 wire kinds down to the 4 this page acts on. This page was one
// 13 wire kinds down to the 4 this page acts on. This page was one
// of 4 unfiltered `/api/dashboard/stream` subscribers before this
// (subscription discipline, part 1 of the dashboard-event-stream-
// split issue).

View file

@ -878,131 +878,7 @@ footer .banner-thin {
}
/* ─── scheduled prompts tab ────────────────────────────────────────
Creation form at the top, list of queued schedule cards below. */
.schedule-edit-form {
display: flex;
flex-direction: column;
gap: 0.6em;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.8em 1em;
margin-bottom: 0.5em;
}
.schedule-edit-form-wrapper {
margin-top: 0.5em;
padding-left: 0.5em;
border-left: 2px solid var(--yellow);
}
.schedule-edit-targets-note {
color: var(--muted);
font-style: italic;
font-size: 0.85em;
margin: 0.2em 0 0 0;
}
.schedule-field {
display: flex;
flex-direction: column;
gap: 0.2em;
}
.schedule-field-label {
color: var(--muted);
font-size: 0.85em;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.schedule-field input,
.schedule-field textarea {
font-family: inherit;
font-size: inherit;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.35em 0.5em;
}
.schedule-field textarea {
resize: vertical;
min-height: 4em;
}
.schedule-targets {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.schedule-target-chip {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.2em 0.6em;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.85em;
cursor: pointer;
background: color-mix(in srgb, var(--bg-elev) 40%, transparent);
}
.schedule-target-chip:hover {
border-color: var(--purple-dim);
}
.schedule-target-chip:has(input:checked) {
border-color: var(--purple);
color: var(--purple);
background: color-mix(in srgb, var(--purple) 8%, transparent);
}
.schedule-target-chip input {
margin: 0;
}
.schedule-actions {
display: flex;
gap: 0.5em;
justify-content: flex-end;
}
.schedule-interval-presets {
display: flex;
flex-wrap: wrap;
gap: 0.3em;
margin-top: 0.1em;
}
.btn-interval-preset {
color: var(--cyan);
border-color: var(--cyan);
font-size: 0.75em;
padding: 0.15em 0.55em;
letter-spacing: 0.05em;
}
.btn-interval-oneshot {
color: var(--muted);
border-color: var(--muted);
}
.schedule-interval-parts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35em;
margin-top: 0.4em;
}
.schedule-interval-num {
width: 4em;
text-align: right;
font-variant-numeric: tabular-nums;
}
.schedule-interval-unit {
color: var(--muted);
font-size: 0.85em;
margin-right: 0.2em;
}
.schedule-interval-preview {
margin-top: 0.4em;
color: var(--cyan);
font-size: 0.9em;
font-variant-numeric: tabular-nums;
}
.schedule-interval-preview-oneshot {
color: var(--muted);
font-style: italic;
}
Read-only table of the schedules left in the hive's store. */
.schedules-table-wrap {
overflow-x: auto;
@ -1031,12 +907,10 @@ footer .banner-thin {
width: 3em;
text-align: center;
}
/* next / every columns: sized to typical content (short durations).
Previous 8em / 7em wasted horizontal space. */
.schedules-table-next-col {
width: 5.5em;
white-space: nowrap;
}
/* every column: sized to typical content (short durations). */
.schedules-table-every-col {
width: 5em;
white-space: nowrap;
@ -1044,9 +918,6 @@ footer .banner-thin {
.schedules-table-body-th {
min-width: 12em;
}
.schedules-table-actions-th {
width: 7em;
}
/* Agent-name columns: 90° vertical text (writing-mode) so names are fully
readable without truncation. Previously used a -45° CSS transform which
clipped names mid-glyph. writing-mode + rotate(180deg) is in-flow and
@ -1078,8 +949,7 @@ footer .banner-thin {
padding: 0 2px;
}
/* Column for an agent no longer in the live roster — its past schedules
linger but the agent is gone. Greyed + struck through so the operator
can still see (and cancel) the stale entries rather than be surprised. */
linger but the agent is gone. Greyed + struck through. */
.schedules-table-agent-th--gone > div {
color: var(--muted);
text-decoration: line-through;
@ -1095,12 +965,6 @@ footer .banner-thin {
color: var(--yellow);
font-size: 0.9em;
}
.btn-pause-schedule {
color: var(--teal);
}
.btn-resume-schedule {
color: var(--green);
}
.schedules-table-body-cell {
max-width: 30em;
overflow: hidden;
@ -1112,135 +976,13 @@ footer .banner-thin {
.schedules-table-check {
width: 28px;
text-align: center;
padding: 0.3em 0 !important;
}
.schedules-table-check-btn {
background: transparent;
border: 0;
color: var(--green);
cursor: pointer;
padding: 0.1em 0.3em;
font-family: inherit;
font-size: 1em;
}
.schedules-table-check-btn:hover:not(:disabled) {
color: var(--red);
}
.schedules-table-check-btn:disabled {
cursor: default;
padding: 0.3em 0 !important;
}
.schedules-table-check-cancelled {
color: var(--muted);
font-size: 0.9em;
}
.schedules-table-actions {
text-align: right;
white-space: nowrap;
}
.schedules-table-actions .btn-inline-small {
margin-left: 0.3em;
}
.schedules-table-edit-row > td {
/* Off-palette dim-purple surface tint: not an exact theme var, left as a
literal pending a dedicated named var so a theme swap can recolour it. */
background: rgba(38, 32, 60, 0.35);
padding: 0.5em 0.6em;
}
.schedules-table-create-row td {
/* Off-palette dim-purple surface tint — see the edit-row note above. */
background: rgba(38, 32, 60, 0.25);
border-top: 2px solid var(--border);
vertical-align: top;
padding: 0.4em 0.5em;
}
.schedules-table-create-cell {
padding: 0.35em 0.4em;
}
.schedules-table-inline-input,
.schedules-table-inline-textarea,
.schedules-table-inline-num {
background: var(--bg);
border: 1px solid var(--border);
color: var(--fg);
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.85em;
padding: 0.2em 0.4em;
border-radius: 2px;
}
.schedules-table-inline-input:focus,
.schedules-table-inline-textarea:focus,
.schedules-table-inline-num:focus {
border-color: var(--purple);
outline: none;
}
.schedules-table-inline-textarea {
display: block;
width: 100%;
min-height: 1.6em;
resize: vertical;
}
.schedules-table-create-body:focus-within .schedules-table-inline-textarea {
min-height: 4em;
}
.schedules-table-inline-desc {
display: block;
width: 100%;
margin-top: 0.25em;
}
.schedules-table-inline-datetime {
width: 100%;
font-size: 0.78em;
}
.schedules-table-inline-interval {
display: flex;
align-items: center;
gap: 0.1em;
white-space: nowrap;
}
.schedules-table-inline-num {
width: 2.4em;
text-align: right;
font-variant-numeric: tabular-nums;
-moz-appearance: textfield;
appearance: textfield;
}
.schedules-table-inline-num::-webkit-outer-spin-button,
.schedules-table-inline-num::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.schedules-table-inline-unit {
color: var(--muted);
font-size: 0.7em;
margin-right: 0.15em;
}
.schedules-table-inline-check {
width: 16px;
height: 16px;
cursor: pointer;
margin: 0;
}
.schedules-table-inline-check-lbl {
display: inline-flex;
cursor: pointer;
padding: 0.4em 0.6em;
}
.schedules-table-create-submit {
font-size: 1em;
padding: 0.15em 0.55em;
color: var(--green);
border-color: var(--green);
}
.schedules-table-create-reset {
font-size: 0.85em;
padding: 0.15em 0.45em;
color: var(--muted);
border-color: var(--border);
}
.btn-inline-small {
font-size: 0.7em;
padding: 0.1em 0.45em;
}
/* Selection bar — sticky-bottom strip for bulk actions. */
.selection-bar {

View file

@ -68,8 +68,7 @@
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
</a>
<!-- SCH3DUL3S: scheduled-prompts surface. List of queued
schedules + an operator-direct creation form. Count pill
<!-- SCH3DUL3S: read-only scheduled-prompts list. Count pill
mirrors the active (non-cancelled) schedule count; hidden
when zero. -->
<a
@ -285,15 +284,10 @@
</div>
</section>
<!-- SCH3DUL3S: scheduled prompts. Creation + edit are folded
into the same table — empty bottom row is the create form
(fill cells, click +), inline-edit-row expands on the `✎`
toggle for existing schedules. Schedules list driven by
GET /api/schedules; POST /api/schedules to create, PATCH
/api/schedules/{id} to edit, POST /api/schedules/{id}/cancel
for per-target / whole-row cancel. Live updates via
`schedules_changed` SSE; tab activation re-fetches as a
safety net for disconnect windows.
<!-- SCH3DUL3S: read-only list of the scheduled prompts left in the
hive's store, driven by GET /api/schedules (re-fetched on tab
activation). Nothing on the hive fires them; the banner points
at `swarmctl schedule`, where schedules live now.
See docs/web-ui/dashboard.md::SCH3DUL3S tab. -->
<section
class="tab-pane"
@ -307,11 +301,15 @@
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="server-warn server-warn-warn" role="note">
⚠ this page is slated for removal. the hive no longer fires these
schedules — they live at swarm level now: recreate each one with
<code>swarmctl schedule add</code> (see
<code>docs/swarm/schedules.md</code>).
</p>
<p class="meta">
all schedules currently in the table. fill the bottom row to queue a
new schedule (operator-direct, no approval gate; recurring when an
interval is set). cancel a single target with the row button or the
whole schedule with <code>✕ cancel all</code>.
read-only: every schedule still in the hive's table, kept so it can
be copied over.
</p>
<div id="schedules-section">
<p class="meta">loading…</p>

File diff suppressed because it is too large Load diff

View file

@ -17,7 +17,7 @@ import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
import { createTabStrip } from "@hive/shared/tabs.js";
import { containersState, syncContainersFromSnapshot } from "./state.js";
import { fmtAgo, fmtDuration } from "./util.js";
import { fmtAgo } from "./util.js";
import {
applyCapabilitiesChanged,
applyToolGroupsChanged,
@ -25,18 +25,13 @@ import {
fetchAndRenderToolGroups,
initPermissions,
} from "./permissions.js";
import {
applySchedulesChanged,
refreshSchedules,
activeScheduleCount,
} from "./schedules.js";
import { refreshSchedules, activeScheduleCount } from "./schedules.js";
import {
initCall,
refreshOperatorInbox,
operatorInboxAppendFromEvent,
operatorInboxCount,
syncApprovalsFromSnapshot,
applyApprovalAdded,
applyApprovalResolved,
renderApprovals,
activeApprovalCount,
@ -95,11 +90,9 @@ window.marked = marked;
// opts out via `data-no-refresh` (its mutation arrives via an SSE event).
bindAsyncForms(() => refreshState());
// One ticker feeds two live displays that would otherwise only
// update on the next SSE-triggered re-render: approval request-age
// chips (`.approval-ts`, flips `.stale` at the 1h mark) and schedule
// next-fire countdowns (`.sched-due`, stamped with `data-due-at` by
// the renderers so this loop can refresh them without a full re-render).
// Refreshes approval request-age chips (`.approval-ts`, flips `.stale`
// at the 1h mark), which would otherwise only update on the next
// SSE-triggered re-render.
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
document
@ -111,13 +104,6 @@ window.marked = marked;
node.textContent = "requested " + fmtAgo(requestedAt);
node.classList.toggle("stale", ageSec >= 3600);
});
document.querySelectorAll(".sched-due[data-due-at]").forEach((node) => {
const dueAt = Number(node.getAttribute("data-due-at"));
if (!Number.isFinite(dueAt)) return;
const dueIn = dueAt - now;
node.textContent =
dueIn <= 0 ? "overdue " + fmtAgo(dueAt) : fmtDuration(dueIn);
});
}, 1000);
// ─── state polling ──────────────────────────────────────────────────────
@ -284,7 +270,6 @@ window.marked = marked;
// and filter client-side — the dashboard ignores broker traffic
// and the inbox ignores mutation events.
const MUTATION_HANDLERS = {
approval_added: applyApprovalAdded,
approval_resolved: applyApprovalResolved,
transient_set: applyTransientSet,
transient_cleared: applyTransientCleared,
@ -295,7 +280,6 @@ window.marked = marked;
// /builds.html gives it for its JobqGraph mount handle's .refresh()
// (its own separate subscription).
rebuild_queue_changed: applyRebuildQueueChanged,
schedules_changed: applySchedulesChanged,
capabilities_changed: applyCapabilitiesChanged,
tool_groups_changed: applyToolGroupsChanged,
};
@ -309,15 +293,15 @@ window.marked = marked;
//
// kinds= matches MUTATION_HANDLERS below verbatim, plus `sent`
// (checked separately, just above, for the operator inbox) —
// narrows this from all 15 wire kinds down to the 11 this page
// narrows this from all 13 wire kinds down to the 9 this page
// actually acts on. This page was one of 4 unfiltered
// `/api/dashboard/stream` subscribers before this (subscription
// discipline, part 1 of the dashboard-event-stream-split issue).
const es = openStream(
"/api/dashboard/stream?kinds=sent,approval_added,approval_resolved," +
"/api/dashboard/stream?kinds=sent,approval_resolved," +
"transient_set,transient_cleared," +
"container_state_changed,container_removed,rebuild_queue_changed," +
"schedules_changed,capabilities_changed,tool_groups_changed",
"capabilities_changed,tool_groups_changed",
);
es.onmessage = (e) => {
let ev;
@ -436,8 +420,7 @@ window.marked = marked;
const rawTitle = document.title.replace(/^\(\d+\) /, "");
document.title = callCount > 0 ? `(${callCount}) ${rawTitle}` : rawTitle;
// SCH3DUL3S — count of schedules with at least one still-active
// target (whole-schedule cancellation or all-targets-cancelled
// means "not waiting on the worker"; those don't pull attention).
// target: the ones left to recreate with `swarmctl schedule`.
setTabCount("schedules", activeScheduleCount());
}
// Poll the state stores on a 1s tick to keep the pill counts in