diff --git a/frontend/packages/dashboard/src/builds.js b/frontend/packages/dashboard/src/builds.js index 98e2dcac..56cc5595 100644 --- a/frontend/packages/dashboard/src/builds.js +++ b/frontend/packages/dashboard/src/builds.js @@ -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). diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js index 977bdfab..01f82015 100644 --- a/frontend/packages/dashboard/src/call.js +++ b/frontend/packages/dashboard/src/call.js @@ -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, ), " ", ); diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css index a1e272e4..feb188c1 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -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 diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js index fca57613..9aa8b7a1 100644 --- a/frontend/packages/dashboard/src/core.js +++ b/frontend/packages/dashboard/src/core.js @@ -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). diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 587dd21c..6fd695a5 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -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 { diff --git a/frontend/packages/dashboard/src/dashboard.html b/frontend/packages/dashboard/src/dashboard.html index c7e128ea..062015fc 100644 --- a/frontend/packages/dashboard/src/dashboard.html +++ b/frontend/packages/dashboard/src/dashboard.html @@ -68,8 +68,7 @@ ◆ P3RM1SS10NS ◆ - -
══════════════════════════════════════════════════════════════ +

+ ⚠ this page is slated for removal. the hive no longer fires these + schedules — they live at swarm level now: recreate each one with + swarmctl schedule add (see + docs/swarm/schedules.md). +

- 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 ✕ cancel all. + read-only: every schedule still in the hive's table, kept so it can + be copied over.

loading…

diff --git a/frontend/packages/dashboard/src/schedules.js b/frontend/packages/dashboard/src/schedules.js index 611133a1..375da1bf 100644 --- a/frontend/packages/dashboard/src/schedules.js +++ b/frontend/packages/dashboard/src/schedules.js @@ -1,42 +1,23 @@ -// Dashboard SCH3DUL3S tab — scheduled-prompts. +// 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. // -// 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. +// 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 { 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; @@ -75,283 +56,17 @@ export function activeScheduleCount() { } return n; } -// Label + caption wrapper shared by every field on the schedule -// forms — ``. 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 + 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 `