The journald viewer opens in the side panel, but the <pre> only takes
the height of its content — so a short log fetch leaves the bottom 80%
of the panel empty, and a long one pushes the controls past the
viewport. Wrap the body in a column-flex container that fills the
side-panel-body, keep controls fixed at the top, and let the <pre>
flex-grow into the rest of the panel with its own overflow:
.journal-body { display: flex; flex-direction: column; height: 100%; }
.journal-controls { flex: 0 0 auto; }
.journal-output { flex: 1 1 0; min-height: 0; overflow: auto; }
`min-height: 0` is the canonical "let me actually flex-shrink for
overflow" escape hatch on flex children. Also moved the auto-scroll
target from side-panel-body onto the <pre> itself — the panel-body no
longer overflows now that journal-body fills it; the <pre> is the new
scroll surface.
Pure CSS + one JS line; no DOM shape change.
Replaces the per-schedule card layout with one table:
| # | src | next | every | owner | body | …agents… | actions |
|---|-----|------|-------|-------|------|----------|---------|
| 5 | op | 5m | 10m | mara | "…" | ✓ ✓ . ✓ | ↯ ✎ ✕ |
Each schedule is one <tr> in the tbody. Agent columns are dynamic —
operator + manager + live containers + any "extra" name that appears
as a target on some schedule but isn't a current container (same
membership rule buildTargetChips uses, so the table + new/edit forms
agree on what's addressable). Per-agent cells:
- active target → <button>✓</button> that cancels just that target
on click (replaces the per-row ✕ from the old targets sub-table)
- cancelled target → muted ✕ glyph (no button; re-add flows through
the edit form's targets multi-select)
- not a target → empty cell
Agent column headers tilt -45° via CSS so each column reads as 28px
of horizontal real estate instead of the full word width. Standard
rotated-header pattern: 95px-tall <th> with position:relative, inner
<div> positioned absolute at bottom-left, transform rotates about
left-bottom.
Body cell truncates with ellipsis + full-text title. Description used
to be a separate visible block on the card layout; the table folds
it into the body cell's title to keep row height tight. If mara wants
description visible in-table it's a small follow-up — easier to
iterate on a rejection.
Edit form expands inline into a colspan'd row underneath the schedule
row it edits (instead of inside the card). Wrapper drops the form's
background so it reads as a row extension.
No backend changes; everything renders from existing schedulesState
+ containersState.
The output-layout block was written before:
- the dashboard's app.js → tabs.js rename (#495)
- the flow.html / flow.js page (#406 / #485)
- the SharedWorker stream-worker.js (#448)
- the build.mjs split-into-static/ subdir convention
Brings the comment in line with what `find dist -type f` actually
prints for both packages. Pure documentation refresh; install phase
and build hashes untouched.
Every field on the new- and edit-schedule forms (plus buildIntervalComposer
and buildTargetChips) builds the same wrapper shape:
el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, '…'))
then appends an input. Eight identical-modulo-text sites. Extracting one
small helper:
function scheduleField(labelText, ...children) {
return el('label', { class: 'schedule-field' },
el('span', { class: 'schedule-field-label' }, labelText),
...children);
}
drops the per-site cost to a single `form_.append(scheduleField('caption',
input));` line. -10 LOC net; no behaviour change.
argus pointed out the inline catch comment claimed dead ports get cleaned up on
next subscribe, but nothing actually prunes the allPorts Set on subscribe — the
honest answer is the one already at the bottom of onconnect: dead entries are
left in the Set, the bound cost is acceptable, and postMessage's throw is the
ambient signal we use. Point at that comment instead of repeating a wrong
description.
Firefox kills "idle" SharedWorkers under memory pressure with no native
signal to the client. The page's port silently becomes a no-op and
events stop flowing — observable symptom: mara's "dashboard never
refreshes; F5 fixes it" (because F5 creates a fresh page that creates
a fresh worker).
The worker now pings every connected port every 30s. The client tracks
last-activity-from-worker on every message arrival (incl. pings, since
those carry no URL — bumped before the URL filter in the route handler).
A visibility-gated watchdog polls every 15s; if the page is visible AND
has active subs AND hasn't heard from the worker in >90s, it presumes
the worker dead, logs a console warning, and re-subscribes on a fresh
port. Three pings missed before we act, so a normal tab-throttle blip
doesn't false-positive.
The fresh-port re-subscribe re-uses the bfcache-restore code path
(same shape: drop stale listeners, getSharedPort → new SharedWorker,
re-attach each route + repost subscribe). Recovery is per-tab — when
one tab's watchdog fires and brings up a new worker, other tabs that
share the named worker pick it up on their own watchdog cycle.
Falls back gracefully on environments without SharedWorker (the
existing direct-EventSource path is untouched) and is invisible on the
healthy path — pings are 30s apart, no UI surface.
mara: \"look through the code for dedups, structural improvements
and so on\". Two near-identical blocks across the new-schedule and
edit-schedule forms folded into shared helpers.
## `buildTargetChips({ idPrefix, fieldName, checked, extraNames })`
Was inlined twice in 18-line blocks that built the same
`<label class="schedule-field">` + `<div class="schedule-targets">`
+ candidate-list logic (containers + operator + manager). Now one
function, two callers; `extraNames` lets the edit form keep
showing already-active targets that have vanished from the live
container list so the operator can still uncheck them
intentionally.
## `intervalSecondsFromFormData(fd, namePrefix)`
Both submit handlers had the same ~13-line d/h/m/s → total-seconds
parser (with `NaN` propagation on bad input). Pulled into one
helper next to `buildIntervalComposer`; call sites become 3 lines:
const intervalTotal = intervalSecondsFromFormData(fd, 'interval_');
if (Number.isNaN(intervalTotal)) { alert(...); return; }
const interval_seconds = intervalTotal > 0 ? intervalTotal : null;
Net -13 lines, but the bigger win is shape — when (not if) a new
schedule field surfaces, there's one chip-render path + one
interval-parser to thread it through instead of two.
Zero behaviour change. Built clean.
argus 🟡 on #504 — `font-variant-numeric: tabular-nums` has no
effect on `.rqe-step` since step strings are text-only
("nixos-container update" / "meta prepare_deploy" / etc.),
no digit columns to align. Harmless but cleaner without it.
Frontend half of #437 — paired with damocles's #501 backend which
adds `step: Option<String>` to `QueueEntry` and annotates 9
worker phases across ApplyCommit / Spawn / MetaUpdate / Rebuild
pipelines.
When `entry.step` is present, render a cyan `↳ <step>` sub-line
below the main row in `renderQueueEntry`. Wraps to its own flex
line via `flex-basis: 100%`, indented 1.8em to align under the
state glyph + kind. Terminal transitions clear `step` on the
backend, so the sub-line just disappears on Done / Failed rows —
no client-side staleness handling needed.
CSS: `.rqe-step` keys off cyan to visually group with the
running-spinner color cue (vs the muted `.rqe-when` /
`.rqe-reason` chips). `tabular-nums` keeps the indented arrow
column stable as step strings change length mid-build.
docs/web-ui.md updated to describe the new sub-line behaviour
in the R3BU1LD QU3U3 section.
Frontend half of #408 — wires flow.js's terminal subscribe URL to
the kinds-filter damocles shipped in #499. Only the four kinds
flow.js actually handles get sent on the wire:
- sent / delivered → broker terminal renderer
- container_state_changed / container_removed → local autocomplete
cache (flowContainers Map used by the @-mention composer)
Everything else (approval_*, question_*, transient_*, tombstones_*,
meta_*, rebuild_queue_*) is now dropped server-side before the
JSON-serialise, instead of being deserialised + dispatched to
flow.js's no-op `_default: () => {}` per frame.
Tabs.js keeps the unfiltered `/dashboard/stream` subscribe since
it routes every mutation kind into its derived stores.
## SharedWorker note
`openStream` keys subscriptions by full URL (#448, #453). The
filtered URL is therefore its own upstream connection to
hive-c0re — when both /index.html and /flow.html are open at the
same time the worker holds two upstreams (one filtered, one full)
instead of today's single shared upstream. The trade is small
per-frame wire-byte + serialise savings vs +1 backend connection
in the both-open case. The flow-page-only case is a pure win.
Acceptable per #408's intent ("split flow messages from main
endpoint").
Round-trip note: if the backend's `kind_tag` strings ever drift
from what's listed here, the affected kind silently never matches
and flow loses that event class. damocles's #499 added a serde
round-trip test against `kind_tag` to guard the backend side;
the frontend mirrors the spelling exactly from the PR body.