dashboard: transient_set / transient_cleared mutation events + client derived state
This commit is contained in:
parent
1879b2f485
commit
7956e1c627
3 changed files with 103 additions and 7 deletions
|
|
@ -202,6 +202,43 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Derived transient state — cold-loaded from /api/state.transients,
|
||||||
|
// then mutated live by `transient_set` / `transient_cleared`. Keyed
|
||||||
|
// by agent name so add/remove are O(1). `since_unix` is wall-clock so
|
||||||
|
// the elapsed-seconds badge ticks without polling.
|
||||||
|
const transientsState = new Map();
|
||||||
|
function syncTransientsFromSnapshot(s) {
|
||||||
|
transientsState.clear();
|
||||||
|
for (const t of s.transients || []) {
|
||||||
|
// Snapshot ships `secs` (server-computed); reconstruct an
|
||||||
|
// approximate since_unix so the live ticker keeps progressing
|
||||||
|
// without surprising jumps when the next snapshot lands.
|
||||||
|
const nowUnix = Math.floor(Date.now() / 1000);
|
||||||
|
transientsState.set(t.name, {
|
||||||
|
kind: t.kind,
|
||||||
|
since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function applyTransientSet(ev) {
|
||||||
|
transientsState.set(ev.name, {
|
||||||
|
kind: ev.transient_kind,
|
||||||
|
since_unix: ev.since_unix,
|
||||||
|
});
|
||||||
|
renderContainersFromState();
|
||||||
|
}
|
||||||
|
function applyTransientCleared(ev) {
|
||||||
|
if (transientsState.delete(ev.name)) renderContainersFromState();
|
||||||
|
}
|
||||||
|
// Re-render using the last cached snapshot (containers come from
|
||||||
|
// /api/state, transients overlay from the derived map). The snapshot
|
||||||
|
// is stashed on window.__hyperhive_state by refreshState; on cold
|
||||||
|
// load before the first snapshot we just skip.
|
||||||
|
function renderContainersFromState() {
|
||||||
|
const s = window.__hyperhive_state;
|
||||||
|
if (s) renderContainers(s);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── state rendering ────────────────────────────────────────────────────
|
// ─── state rendering ────────────────────────────────────────────────────
|
||||||
function renderContainers(s) {
|
function renderContainers(s) {
|
||||||
const root = $('containers-section');
|
const root = $('containers-section');
|
||||||
|
|
@ -226,20 +263,22 @@
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s.transients.length) {
|
if (transientsState.size) {
|
||||||
const ul = el('ul');
|
const ul = el('ul');
|
||||||
for (const t of s.transients) {
|
const nowUnix = Math.floor(Date.now() / 1000);
|
||||||
|
for (const [name, t] of transientsState) {
|
||||||
|
const secs = Math.max(0, nowUnix - t.since_unix);
|
||||||
ul.append(el('li', {},
|
ul.append(el('li', {},
|
||||||
el('span', { class: 'glyph spinner' }, '◐'), ' ',
|
el('span', { class: 'glyph spinner' }, '◐'), ' ',
|
||||||
el('span', { class: 'agent' }, t.name), ' ',
|
el('span', { class: 'agent' }, name), ' ',
|
||||||
el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
|
el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
|
||||||
el('span', { class: 'meta' }, `nixos-container create + start (${t.secs}s)`),
|
el('span', { class: 'meta' }, `nixos-container create + start (${secs}s)`),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
root.append(ul);
|
root.append(ul);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!s.containers.length && !s.transients.length) {
|
if (!s.containers.length && !transientsState.size) {
|
||||||
root.append(el('p', { class: 'empty' }, 'no managed containers'));
|
root.append(el('p', { class: 'empty' }, 'no managed containers'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1037,6 +1076,10 @@
|
||||||
// names from here instead of refetching on every keystroke).
|
// names from here instead of refetching on every keystroke).
|
||||||
window.__hyperhive_state = s;
|
window.__hyperhive_state = s;
|
||||||
const openDetails = snapshotOpenDetails();
|
const openDetails = snapshotOpenDetails();
|
||||||
|
// Sync transients first so renderContainers below sees the
|
||||||
|
// current derived map (it reads from `transientsState`, not
|
||||||
|
// from `s.transients`).
|
||||||
|
syncTransientsFromSnapshot(s);
|
||||||
renderContainers(s);
|
renderContainers(s);
|
||||||
renderTombstones(s);
|
renderTombstones(s);
|
||||||
// Sync the derived approvals + questions stores from the
|
// Sync the derived approvals + questions stores from the
|
||||||
|
|
@ -1058,7 +1101,7 @@
|
||||||
// refresh on operator-bound messages; this catches the rest
|
// refresh on operator-bound messages; this catches the rest
|
||||||
// (approvals, tombstones, questions).
|
// (approvals, tombstones, questions).
|
||||||
const anyPending = s.containers.some((c) => c.pending);
|
const anyPending = s.containers.some((c) => c.pending);
|
||||||
const next = (s.transients.length || anyPending) ? 2000 : 5000;
|
const next = (transientsState.size || anyPending) ? 2000 : 5000;
|
||||||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||||||
if (next) pollTimer = setTimeout(refreshState, next);
|
if (next) pollTimer = setTimeout(refreshState, next);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -1114,6 +1157,8 @@
|
||||||
approval_resolved: (ev) => { applyApprovalResolved(ev); },
|
approval_resolved: (ev) => { applyApprovalResolved(ev); },
|
||||||
question_added: (ev) => { applyQuestionAdded(ev); },
|
question_added: (ev) => { applyQuestionAdded(ev); },
|
||||||
question_resolved: (ev) => { applyQuestionResolved(ev); },
|
question_resolved: (ev) => { applyQuestionResolved(ev); },
|
||||||
|
transient_set: (ev) => { applyTransientSet(ev); },
|
||||||
|
transient_cleared: (ev) => { applyTransientCleared(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
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,21 @@ pub enum TransientKind {
|
||||||
Destroying,
|
Destroying,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TransientKind {
|
||||||
|
/// Wire/UI label. Matches the strings the dashboard already
|
||||||
|
/// renders in the transient spinner.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TransientKind::Spawning => "spawning",
|
||||||
|
TransientKind::Starting => "starting",
|
||||||
|
TransientKind::Stopping => "stopping",
|
||||||
|
TransientKind::Restarting => "restarting",
|
||||||
|
TransientKind::Rebuilding => "rebuilding",
|
||||||
|
TransientKind::Destroying => "destroying",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Coordinator {
|
impl Coordinator {
|
||||||
pub fn open(
|
pub fn open(
|
||||||
db_path: &Path,
|
db_path: &Path,
|
||||||
|
|
@ -318,10 +333,30 @@ impl Coordinator {
|
||||||
since: std::time::Instant::now(),
|
since: std::time::Instant::now(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// Live-update dashboards. `since_unix` is wall-clock so the
|
||||||
|
// browser can tick "Ns spawning…" without polling. The
|
||||||
|
// intra-process map keeps using `Instant` for monotonicity.
|
||||||
|
let since_unix = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
self.emit_dashboard_event(DashboardEvent::TransientSet {
|
||||||
|
seq: self.next_seq(),
|
||||||
|
name: name.to_owned(),
|
||||||
|
transient_kind: kind.as_str(),
|
||||||
|
since_unix,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_transient(&self, name: &str) {
|
pub fn clear_transient(&self, name: &str) {
|
||||||
self.transient.lock().unwrap().remove(name);
|
let removed = self.transient.lock().unwrap().remove(name).is_some();
|
||||||
|
if removed {
|
||||||
|
self.emit_dashboard_event(DashboardEvent::TransientCleared {
|
||||||
|
seq: self.next_seq(),
|
||||||
|
name: name.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set a transient state and return a guard that clears it on drop.
|
/// Set a transient state and return a guard that clears it on drop.
|
||||||
|
|
|
||||||
|
|
@ -105,4 +105,20 @@ pub enum DashboardEvent {
|
||||||
answered_at: i64,
|
answered_at: i64,
|
||||||
cancelled: bool,
|
cancelled: bool,
|
||||||
},
|
},
|
||||||
|
/// A lifecycle action started for an agent (spawn / start / stop
|
||||||
|
/// / restart / rebuild / destroy). Clients render a spinner next
|
||||||
|
/// to the row; the client computes "seconds in this state"
|
||||||
|
/// locally from `since_unix` so a slow rebuild's elapsed time
|
||||||
|
/// ticks without polling.
|
||||||
|
TransientSet {
|
||||||
|
seq: u64,
|
||||||
|
name: String,
|
||||||
|
/// Lifecycle kind: `"spawning"` / `"starting"` / `"stopping"` /
|
||||||
|
/// `"restarting"` / `"rebuilding"` / `"destroying"`.
|
||||||
|
transient_kind: &'static str,
|
||||||
|
since_unix: i64,
|
||||||
|
},
|
||||||
|
/// The matching lifecycle action resolved (success or failure).
|
||||||
|
/// Clients drop the spinner row.
|
||||||
|
TransientCleared { seq: u64, name: String },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue