dashboard: show meta-update progress in the META INPUTS panel
post_meta_update returns 200 immediately and runs the nix flake update + agent-rebuild ripple in a background task, so the META INPUTS panel looked idle for the whole multi-minute window (#259). Track in-flight runs with a Coordinator atomic counter, exposed via an RAII MetaUpdateGuard held across run_meta_update. Surface it as the meta_update_running snapshot field plus a MetaUpdateRunning SSE event (flipped only when the count crosses 0, so concurrent runs flip the flag once). The panel shows a pulsing in-progress banner and disables the update button while a run is active.
This commit is contained in:
parent
20d2b48fe5
commit
2f1b846baf
6 changed files with 119 additions and 3 deletions
|
|
@ -111,7 +111,11 @@ the previous process's socket release resolves itself.
|
||||||
`agent-<n>` rows). Checking inputs + submitting bumps the lock
|
`agent-<n>` rows). Checking inputs + submitting bumps the lock
|
||||||
in `/meta/` and rebuilds the selected agents in sequence; each
|
in `/meta/` and rebuilds the selected agents in sequence; each
|
||||||
outcome reaches the manager as a `rebuilt` system event.
|
outcome reaches the manager as a `rebuilt` system event.
|
||||||
`POST /meta-update`.
|
`POST /meta-update`. The lock bump + rebuild ripple runs in the
|
||||||
|
background; while it does, the panel shows a pulsing "⏳
|
||||||
|
meta-update running" banner and the update button is disabled
|
||||||
|
(snapshot field `meta_update_running`, live event
|
||||||
|
`meta_update_running`).
|
||||||
5. **M1ND H4S QU3STI0NS** — pending operator-targeted `ask`
|
5. **M1ND H4S QU3STI0NS** — pending operator-targeted `ask`
|
||||||
questions, i.e. rows with `target IS NULL` (peer-to-peer
|
questions, i.e. rows with `target IS NULL` (peer-to-peer
|
||||||
questions live in the same table but never surface here)
|
questions live in the same table but never surface here)
|
||||||
|
|
|
||||||
|
|
@ -454,11 +454,18 @@
|
||||||
// avoids ordering races between a same-tick destroy + purge.
|
// avoids ordering races between a same-tick destroy + purge.
|
||||||
let tombstonesState = [];
|
let tombstonesState = [];
|
||||||
let metaInputsState = [];
|
let metaInputsState = [];
|
||||||
|
// True while a dashboard-triggered meta-update (flake lock bump +
|
||||||
|
// agent rebuild ripple) runs in the background. Cold-loaded from
|
||||||
|
// `s.meta_update_running`, then flipped live by the
|
||||||
|
// `meta_update_running` event. Drives the META INPUTS panel's
|
||||||
|
// disabled "updating…" state (issue #259).
|
||||||
|
let metaUpdateRunning = false;
|
||||||
function syncTombstonesFromSnapshot(s) {
|
function syncTombstonesFromSnapshot(s) {
|
||||||
tombstonesState = (s.tombstones || []).slice();
|
tombstonesState = (s.tombstones || []).slice();
|
||||||
}
|
}
|
||||||
function syncMetaInputsFromSnapshot(s) {
|
function syncMetaInputsFromSnapshot(s) {
|
||||||
metaInputsState = (s.meta_inputs || []).slice();
|
metaInputsState = (s.meta_inputs || []).slice();
|
||||||
|
metaUpdateRunning = !!s.meta_update_running;
|
||||||
}
|
}
|
||||||
function applyTombstonesChanged(ev) {
|
function applyTombstonesChanged(ev) {
|
||||||
tombstonesState = (ev.tombstones || []).slice();
|
tombstonesState = (ev.tombstones || []).slice();
|
||||||
|
|
@ -468,6 +475,10 @@
|
||||||
metaInputsState = (ev.inputs || []).slice();
|
metaInputsState = (ev.inputs || []).slice();
|
||||||
renderMetaInputsFromState();
|
renderMetaInputsFromState();
|
||||||
}
|
}
|
||||||
|
function applyMetaUpdateRunning(ev) {
|
||||||
|
metaUpdateRunning = !!ev.running;
|
||||||
|
renderMetaInputsFromState();
|
||||||
|
}
|
||||||
function renderTombstonesFromState() {
|
function renderTombstonesFromState() {
|
||||||
renderTombstones({ tombstones: tombstonesState });
|
renderTombstones({ tombstones: tombstonesState });
|
||||||
}
|
}
|
||||||
|
|
@ -1482,6 +1493,11 @@
|
||||||
root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet'));
|
root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (metaUpdateRunning) {
|
||||||
|
root.append(el('p', { class: 'meta-update-running' },
|
||||||
|
'⏳ meta-update running — flake lock bump + affected agents rebuilding. '
|
||||||
|
+ 'watch the agent cards for per-rebuild progress.'));
|
||||||
|
}
|
||||||
const form = el('form', {
|
const form = el('form', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
action: '/meta-update',
|
action: '/meta-update',
|
||||||
|
|
@ -1528,11 +1544,13 @@
|
||||||
type: 'submit',
|
type: 'submit',
|
||||||
class: 'btn btn-meta-update',
|
class: 'btn btn-meta-update',
|
||||||
disabled: '',
|
disabled: '',
|
||||||
}, '◆ UPD4TE & R3BU1LD');
|
}, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD');
|
||||||
form.append(btn);
|
form.append(btn);
|
||||||
function refreshDisabled() {
|
function refreshDisabled() {
|
||||||
const any = form.querySelectorAll('input[data-meta-input]:checked').length > 0;
|
const any = form.querySelectorAll('input[data-meta-input]:checked').length > 0;
|
||||||
if (any) btn.removeAttribute('disabled');
|
// Stay disabled while an update is already in flight — no
|
||||||
|
// stacking a second run on top of the rebuild ripple.
|
||||||
|
if (any && !metaUpdateRunning) btn.removeAttribute('disabled');
|
||||||
else btn.setAttribute('disabled', '');
|
else btn.setAttribute('disabled', '');
|
||||||
}
|
}
|
||||||
form.addEventListener('change', refreshDisabled);
|
form.addEventListener('change', refreshDisabled);
|
||||||
|
|
@ -1857,6 +1875,7 @@
|
||||||
container_removed: (ev) => { applyContainerRemoved(ev); },
|
container_removed: (ev) => { applyContainerRemoved(ev); },
|
||||||
tombstones_changed: (ev) => { applyTombstonesChanged(ev); },
|
tombstones_changed: (ev) => { applyTombstonesChanged(ev); },
|
||||||
meta_inputs_changed: (ev) => { applyMetaInputsChanged(ev); },
|
meta_inputs_changed: (ev) => { applyMetaInputsChanged(ev); },
|
||||||
|
meta_update_running: (ev) => { applyMetaUpdateRunning(ev); },
|
||||||
},
|
},
|
||||||
// Both history backfill and live frames flow through here, so the
|
// Both history backfill and live frames flow through here, so the
|
||||||
// inbox section ends up populated correctly on first paint and
|
// inbox section ends up populated correctly on first paint and
|
||||||
|
|
|
||||||
|
|
@ -446,6 +446,17 @@ code {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
/* In-progress banner for the META INPUTS panel: shown while a
|
||||||
|
dashboard-triggered meta-update runs in the background (issue #259). */
|
||||||
|
.meta-update-running {
|
||||||
|
margin: 0 0 0.7em;
|
||||||
|
padding: 0.4em 0.7em;
|
||||||
|
border: 1px solid var(--purple);
|
||||||
|
background: rgba(203, 166, 247, 0.12);
|
||||||
|
color: var(--purple);
|
||||||
|
font-size: 0.85em;
|
||||||
|
animation: badge-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
.history-note {
|
.history-note {
|
||||||
margin-left: 1.8em;
|
margin-left: 1.8em;
|
||||||
margin-top: 0.2em;
|
margin-top: 0.2em;
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,14 @@ pub struct Coordinator {
|
||||||
/// snapshot.
|
/// snapshot.
|
||||||
dashboard_events: broadcast::Sender<DashboardEvent>,
|
dashboard_events: broadcast::Sender<DashboardEvent>,
|
||||||
event_seq: AtomicU64,
|
event_seq: AtomicU64,
|
||||||
|
/// Count of dashboard-triggered `meta-update` runs currently in
|
||||||
|
/// flight. `post_meta_update` returns 200 immediately and does the
|
||||||
|
/// multi-minute `nix flake update` + agent-rebuild ripple in a
|
||||||
|
/// background task, so without this the META INPUTS panel showed no
|
||||||
|
/// sign anything was happening (issue #259). Held via
|
||||||
|
/// `MetaUpdateGuard`; a count > 0 surfaces on `/api/state` as
|
||||||
|
/// `meta_update_running` and via the `MetaUpdateRunning` event.
|
||||||
|
meta_updates_active: AtomicU64,
|
||||||
/// Last container snapshot seen by `rescan_containers_and_emit`,
|
/// Last container snapshot seen by `rescan_containers_and_emit`,
|
||||||
/// keyed by `ContainerView.name`. The rescan diffs a fresh
|
/// keyed by `ContainerView.name`. The rescan diffs a fresh
|
||||||
/// `container_view::build_all` against this map and emits one
|
/// `container_view::build_all` against this map and emits one
|
||||||
|
|
@ -109,6 +117,33 @@ impl Drop for TransientGuard {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RAII guard for the `meta-update` in-progress flag, held for the
|
||||||
|
/// duration of a `run_meta_update` background task. Created by
|
||||||
|
/// `Coordinator::meta_update_guard`. Drop decrements the active-run
|
||||||
|
/// count; the count crossing back to 0 emits
|
||||||
|
/// `MetaUpdateRunning { running: false }`, so a concurrent pair of
|
||||||
|
/// updates only flips the dashboard flag once.
|
||||||
|
pub struct MetaUpdateGuard {
|
||||||
|
coord: Arc<Coordinator>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MetaUpdateGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self
|
||||||
|
.coord
|
||||||
|
.meta_updates_active
|
||||||
|
.fetch_sub(1, Ordering::SeqCst)
|
||||||
|
== 1
|
||||||
|
{
|
||||||
|
self.coord
|
||||||
|
.emit_dashboard_event(DashboardEvent::MetaUpdateRunning {
|
||||||
|
seq: self.coord.next_seq(),
|
||||||
|
running: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum TransientKind {
|
pub enum TransientKind {
|
||||||
/// `lifecycle::spawn` is running (nixos-container create + update + start).
|
/// `lifecycle::spawn` is running (nixos-container create + update + start).
|
||||||
|
|
@ -165,6 +200,7 @@ impl Coordinator {
|
||||||
transient: Mutex::new(HashMap::new()),
|
transient: Mutex::new(HashMap::new()),
|
||||||
dashboard_events,
|
dashboard_events,
|
||||||
event_seq: AtomicU64::new(0),
|
event_seq: AtomicU64::new(0),
|
||||||
|
meta_updates_active: AtomicU64::new(0),
|
||||||
last_containers: tokio::sync::Mutex::new(HashMap::new()),
|
last_containers: tokio::sync::Mutex::new(HashMap::new()),
|
||||||
shutdown_tx,
|
shutdown_tx,
|
||||||
})
|
})
|
||||||
|
|
@ -218,6 +254,31 @@ impl Coordinator {
|
||||||
let _ = self.dashboard_events.send(event);
|
let _ = self.dashboard_events.send(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark a `meta-update` as in flight and return an RAII guard that
|
||||||
|
/// clears it on drop (including drop-via-panic). The first
|
||||||
|
/// concurrent run emits `MetaUpdateRunning { running: true }`; the
|
||||||
|
/// last one to finish emits `running: false`. The dashboard's META
|
||||||
|
/// INPUTS panel reads the flag to show a disabled "updating…"
|
||||||
|
/// state while the lock bump + rebuild ripple runs (issue #259).
|
||||||
|
pub fn meta_update_guard(self: &Arc<Self>) -> MetaUpdateGuard {
|
||||||
|
if self.meta_updates_active.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||||
|
self.emit_dashboard_event(DashboardEvent::MetaUpdateRunning {
|
||||||
|
seq: self.next_seq(),
|
||||||
|
running: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
MetaUpdateGuard {
|
||||||
|
coord: Arc::clone(self),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True while at least one dashboard-triggered `meta-update` is
|
||||||
|
/// running. Surfaced on `/api/state` as `meta_update_running` so a
|
||||||
|
/// client that cold-loads mid-update sees the in-progress state.
|
||||||
|
pub fn meta_update_in_progress(&self) -> bool {
|
||||||
|
self.meta_updates_active.load(Ordering::SeqCst) > 0
|
||||||
|
}
|
||||||
|
|
||||||
/// Emit `ApprovalAdded` immediately after the row is inserted in
|
/// Emit `ApprovalAdded` immediately after the row is inserted in
|
||||||
/// sqlite. Caller passes the diff text it already computed (or
|
/// sqlite. Caller passes the diff text it already computed (or
|
||||||
/// `None` for spawn approvals which carry no diff).
|
/// `None` for spawn approvals which carry no diff).
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,12 @@ struct StateSnapshot {
|
||||||
/// Inputs in `meta/flake.lock` the operator can selectively
|
/// Inputs in `meta/flake.lock` the operator can selectively
|
||||||
/// `nix flake update`. Hyperhive first, then `agent-<n>` rows.
|
/// `nix flake update`. Hyperhive first, then `agent-<n>` rows.
|
||||||
meta_inputs: Vec<MetaInputView>,
|
meta_inputs: Vec<MetaInputView>,
|
||||||
|
/// True while a dashboard-triggered `meta-update` (flake lock bump +
|
||||||
|
/// agent rebuild ripple) is running in the background. Lets a
|
||||||
|
/// client that cold-loads mid-update render the META INPUTS panel's
|
||||||
|
/// disabled "updating…" state; live transitions arrive via the
|
||||||
|
/// `MetaUpdateRunning` event (issue #259).
|
||||||
|
meta_update_running: bool,
|
||||||
/// Whether the hive-forge container is up. When true the dashboard
|
/// Whether the hive-forge container is up. When true the dashboard
|
||||||
/// links each container's config + each approval's commit into the
|
/// links each container's config + each approval's commit into the
|
||||||
/// forge's `agent-configs` repos.
|
/// forge's `agent-configs` repos.
|
||||||
|
|
@ -395,6 +401,7 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
||||||
approvals,
|
approvals,
|
||||||
approval_history,
|
approval_history,
|
||||||
meta_inputs: read_meta_inputs(),
|
meta_inputs: read_meta_inputs(),
|
||||||
|
meta_update_running: state.coord.meta_update_in_progress(),
|
||||||
questions,
|
questions,
|
||||||
question_history,
|
question_history,
|
||||||
tombstones,
|
tombstones,
|
||||||
|
|
@ -1495,6 +1502,10 @@ async fn post_meta_update(
|
||||||
/// operator and manager get the same feedback they'd see from an
|
/// operator and manager get the same feedback they'd see from an
|
||||||
/// auto-update / manual dashboard rebuild.
|
/// auto-update / manual dashboard rebuild.
|
||||||
async fn run_meta_update(coord: &Arc<crate::coordinator::Coordinator>, inputs: &[String]) {
|
async fn run_meta_update(coord: &Arc<crate::coordinator::Coordinator>, inputs: &[String]) {
|
||||||
|
// Held for the whole run (incl. the early `return` on lock failure):
|
||||||
|
// emits `MetaUpdateRunning { running: true }` now and `false` on
|
||||||
|
// drop so the META INPUTS panel shows progress (issue #259).
|
||||||
|
let _progress = coord.meta_update_guard();
|
||||||
tracing::info!(?inputs, "meta-update: starting");
|
tracing::info!(?inputs, "meta-update: starting");
|
||||||
if let Err(e) = crate::meta::lock_update(inputs).await {
|
if let Err(e) = crate::meta::lock_update(inputs).await {
|
||||||
tracing::warn!(error = ?e, "meta-update: lock_update failed");
|
tracing::warn!(error = ?e, "meta-update: lock_update failed");
|
||||||
|
|
|
||||||
|
|
@ -194,4 +194,14 @@ pub enum DashboardEvent {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
inputs: Vec<MetaInputView>,
|
inputs: Vec<MetaInputView>,
|
||||||
},
|
},
|
||||||
|
/// A dashboard-triggered `meta-update` started (`running: true`) or
|
||||||
|
/// finished (`running: false`). `post_meta_update` returns 200
|
||||||
|
/// immediately and runs the `nix flake update` + agent-rebuild
|
||||||
|
/// ripple in a background task — this event lets the META INPUTS
|
||||||
|
/// panel show a disabled "updating…" state for that whole window
|
||||||
|
/// instead of looking idle (issue #259). Emitted by
|
||||||
|
/// `Coordinator::meta_update_guard` / `MetaUpdateGuard::drop` only
|
||||||
|
/// when the active-run count crosses 0, so concurrent updates flip
|
||||||
|
/// the flag exactly once.
|
||||||
|
MetaUpdateRunning { seq: u64, running: bool },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue