From 1926e730649d1213ae4df80f8e9d241423be4209 Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Sun, 16 Aug 2026 15:16:30 +0400 Subject: [PATCH] feat(dashboard): Segment Mask visual world and merged Fleet Control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fleet page: drop the aggregate totals row and bar skeletons — cards carry identity, liveness, and runtime summary; hardware detail stays one click down. Stream descriptors (origin/label) now ride FrameEvents into the fleet view, so the orchestrator renders as a full-width amber-framed module pinned above the grid. Cards are real links; roster rows are keyboard-operable. Fleet Control: /view/reconciler folds into /view/demo-control as one control bench — ghost-segment ready/desired counter, generation digit, unified node rows (reconciler stage + pid + state + kill), activity feeds demoted to a collapsed tail. The standalone reconciler page is retired; its API stays live to feed the merge. Visual world (both themes, nav toggle, persisted, prefers-color-scheme default): dark = Bloomberg night housing (black ground, navy panels, amber data register); light = Atom One Light. 2px corners, monospace data, outline chips for states, cyan as the only interactive voice, ghost-eight segments for counters, blink reserved for unresolved states, depressing controls, reduced-motion collapse. --- crates/dashboard/src/control_plane.rs | 127 ++++--- crates/dashboard/src/control_plane_page.html | 209 ++++++----- crates/dashboard/src/demo_control.rs | 12 +- crates/dashboard/src/demo_control_page.html | 333 ++++++++++++++---- crates/dashboard/src/lib.rs | 22 +- crates/dashboard/src/live_explorer.rs | 2 + crates/dashboard/src/live_explorer_page.html | 117 +++++- crates/dashboard/src/server.rs | 28 +- xtask/src/provisioning_demo/feed.rs | 30 +- xtask/src/provisioning_demo/mod.rs | 17 +- xtask/src/provisioning_demo/provider.rs | 13 +- .../provisioning_demo/reconciler_page.html | 151 -------- xtask/src/provisioning_demo/view.rs | 16 +- 13 files changed, 660 insertions(+), 417 deletions(-) delete mode 100644 xtask/src/provisioning_demo/reconciler_page.html diff --git a/crates/dashboard/src/control_plane.rs b/crates/dashboard/src/control_plane.rs index 7ace22c..e50269f 100644 --- a/crates/dashboard/src/control_plane.rs +++ b/crates/dashboard/src/control_plane.rs @@ -51,6 +51,8 @@ struct FusedNode { last_seen: Instant, hardware: NodeHardwareState, actors: RuntimeState, + origin: Option, + label: Option, } impl DashboardView for ControlPlaneView { @@ -81,8 +83,17 @@ impl DashboardView for ControlPlaneView { last_seen: now, hardware: NodeHardwareState::new(now), actors: RuntimeState::new(now), + origin: event.stream.origin.clone(), + label: event.stream.label.clone(), }); node.last_seen = now; + // Later events may carry descriptor metadata the first lacked. + if let Some(origin) = &event.stream.origin { + node.origin = Some(origin.clone()); + } + if let Some(label) = &event.stream.label { + node.label = Some(label.clone()); + } node.hardware.update(&event.channel, &event.payload, now); node.actors.update(&event.channel, &event.payload, now); prune(&mut state.streams, &event.stream, now); @@ -110,7 +121,7 @@ impl DashboardView for ControlPlaneView { // Stale pool: most recently seen first, bounded by the physical cap. stale.sort_by_key(|right| std::cmp::Reverse(right.last_seen_ms_ago)); stale.truncate(STALE_POOL_CAP); - let totals = fused_totals(&live, stale.len()); + let totals = fused_totals(live.len(), stale.len()); let snapshot = FusedSnapshot { totals, live, stale }; serde_json::to_value(snapshot).unwrap_or_else(|_| { json!({ @@ -128,7 +139,14 @@ impl DashboardView for ControlPlaneView { let state = self.state.read(); let node = state.streams.get(&stream)?; let actor = node.actors.actors.get(&actor_key)?; - Some(actor_detail(actor, &node.stream, &stream, now)) + Some(actor_detail( + actor, + &node.stream, + &stream, + now, + node.origin.clone(), + node.label.clone(), + )) } fn html(&self) -> Option<&'static str> { @@ -174,21 +192,8 @@ struct FusedSnapshot { #[derive(Default, Serialize)] struct FusedTotals { - nodes: u32, live_nodes: u32, stale_nodes: u32, - actors: u32, - msg_per_sec: f64, - mailbox_depth: u32, - poisoned: u32, - gpu_count: u32, - cpu_avg_percent: Option, - gpu_max_percent: Option, - gpu_memory_used_mib: u64, - gpu_memory_total_mib: u64, - net_rx_bps: f64, - net_tx_bps: f64, - errors: u32, } #[derive(Serialize)] @@ -214,6 +219,10 @@ struct StreamKeySnapshot { key: String, node: String, life: u64, + #[serde(skip_serializing_if = "Option::is_none")] + origin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + label: Option, } #[derive(Default, Serialize)] @@ -308,6 +317,8 @@ fn node_card(node: &FusedNode, now: Instant) -> NodeCard { key: stream_key(&node.stream), node: node.stream.node.clone(), life: node.stream.life, + origin: node.origin.clone(), + label: node.label.clone(), }, live: now.duration_since(node.last_seen) <= LIVE_TTL, last_seen_ms_ago: duration_ms(now.duration_since(node.last_seen)), @@ -365,12 +376,16 @@ fn actor_detail( stream: &StreamEvent, stream_key_value: &str, now: Instant, + origin: Option, + label: Option, ) -> Value { let detail = ActorDetail { stream: StreamKeySnapshot { key: stream_key_value.to_owned(), node: stream.node.clone(), life: stream.life, + origin, + label, }, address: actor.address.clone(), name: actor.name.clone(), @@ -415,61 +430,11 @@ fn actor_detail( serde_json::to_value(detail).unwrap_or_else(|_| json!({})) } -fn fused_totals(live: &[NodeCard], stale_len: usize) -> FusedTotals { - let mut totals = FusedTotals { - nodes: saturating_u32(live.len() + stale_len), - live_nodes: saturating_u32(live.len()), +fn fused_totals(live_len: usize, stale_len: usize) -> FusedTotals { + FusedTotals { + live_nodes: saturating_u32(live_len), stale_nodes: saturating_u32(stale_len), - ..FusedTotals::default() - }; - let mut cpu_total = 0.0; - let mut cpu_count = 0_u32; - for node in live { - let summary = &node.actor_summary; - totals.actors = totals.actors.saturating_add(summary.actors); - totals.msg_per_sec += summary.msg_per_sec; - totals.mailbox_depth = totals.mailbox_depth.saturating_add(summary.mailbox_depth); - totals.poisoned = totals.poisoned.saturating_add(summary.poisoned); - totals.errors = totals.errors.saturating_add(saturating_u32(node.errors.len())); - - if let Some(cpu_percent) = node - .cpu - .as_ref() - .and_then(|cpu| cpu.host.as_ref()) - .and_then(|host| host.total_percent) - { - cpu_total += cpu_percent; - cpu_count = cpu_count.saturating_add(1); - } - if let Some(gpu) = &node.gpu { - totals.gpu_count = totals.gpu_count.saturating_add(saturating_u32(gpu.gpus.len())); - for device in &gpu.gpus { - if let Some(percent) = device.utilization_gpu_percent { - totals.gpu_max_percent = Some( - totals - .gpu_max_percent - .map_or(percent, |current| current.max(percent)), - ); - } - totals.gpu_memory_used_mib = totals - .gpu_memory_used_mib - .saturating_add(device.memory_used_mib.unwrap_or_default()); - totals.gpu_memory_total_mib = totals - .gpu_memory_total_mib - .saturating_add(device.memory_total_mib.unwrap_or_default()); - } - } - if let Some(net) = &node.net { - for interface in &net.interfaces { - totals.net_rx_bps += interface.rx_bps.unwrap_or_default(); - totals.net_tx_bps += interface.tx_bps.unwrap_or_default(); - } - } } - if cpu_count > 0 { - totals.cpu_avg_percent = Some(cpu_total / f64::from(cpu_count)); - } - totals } /// Minimal `application/x-www-form-urlencoded` reader with percent-decoding @@ -523,6 +488,8 @@ mod tests { stream: crate::StreamEvent { node: stream.node.as_str().to_string(), life: stream.life.0, + origin: None, + label: None, }, channel: channel.to_string(), position, @@ -531,6 +498,30 @@ mod tests { view.ingest(stream, &frame, &event); } + #[test] + fn stream_origin_and_label_surface_on_cards() { + let view = ControlPlaneView::default(); + let stream = StreamId::new(NodeId::new("supervisor"), Lifetime(1)); + let frame = Frame::new(ChannelId(1), Position(0), actors_payload(0, json!([]))); + let event = FrameEvent { + stream: crate::StreamEvent { + node: "supervisor".to_owned(), + life: 1, + origin: Some("orchestrator".to_owned()), + label: Some("provisioning supervisor".to_owned()), + }, + channel: "runtime.actors".to_owned(), + position: 0, + payload: frame.payload.clone(), + }; + view.ingest(&stream, &frame, &event); + + let snapshot = view.snapshot_json(); + let card = &snapshot["live"][0]["stream"]; + assert_eq!(card["origin"], json!("orchestrator")); + assert_eq!(card["label"], json!("provisioning supervisor")); + } + fn actors_payload(worker: u32, actors: serde_json::Value) -> Vec { serde_json::json!({ "worker_id": worker, "actors": actors }) .to_string() diff --git a/crates/dashboard/src/control_plane_page.html b/crates/dashboard/src/control_plane_page.html index 45e9df9..dcd65ff 100644 --- a/crates/dashboard/src/control_plane_page.html +++ b/crates/dashboard/src/control_plane_page.html @@ -5,65 +5,120 @@ swactor fleet +

Fleet

- loading… + loading…
@@ -141,7 +196,7 @@ function render() { const live = data.live || []; const stale = data.stale || []; const totals = data.totals || {}; - status.textContent = `${fmt(totals.live_nodes)} live · ${fmt(totals.stale_nodes)} stale · ${fmt(totals.actors)} actors`; + status.textContent = `${fmt(totals.live_nodes)} live · ${fmt(totals.stale_nodes)} stale`; const node = live.concat(stale).find(n => n.stream.key === selectedStream) || live.find(n => n.stream.key === selectedStream); @@ -154,23 +209,13 @@ function render() { function renderFleet(page, live, stale, totals) { stopDetailPolling(); + const orchestrators = live.filter(n => (n.stream.origin || '') === 'orchestrator'); + const workers = live.filter(n => (n.stream.origin || '') !== 'orchestrator'); const parts = []; - parts.push(`
- ${totalCard('Live nodes', fmt(totals.live_nodes))} - ${totalCard('Actors', fmt(totals.actors))} - ${totalCard('Msg/s', fmtRate(totals.msg_per_sec))} - ${totalCard('Mailbox', fmt(totals.mailbox_depth))} - ${totalCard('Poisoned', fmt(totals.poisoned), totals.poisoned > 0 ? 'err' : '')} - ${totalCard('CPU avg', totals.cpu_avg_percent == null ? '—' : fmt(totals.cpu_avg_percent, 1) + '%')} - ${totalCard('GPU max', totals.gpu_max_percent == null ? '—' : fmt(totals.gpu_max_percent) + '%')} - ${totalCard('GPU mem', totals.gpu_memory_total_mib ? fmt(totals.gpu_memory_used_mib) + '/' + fmt(totals.gpu_memory_total_mib) + ' MiB' : '—')} - ${totalCard('Net', fmtRate(totals.net_rx_bps) + '↓ ' + fmtRate(totals.net_tx_bps) + '↑')} -
`); - if (!live.length && !stale.length) { parts.push('
No telemetry streams yet. A live swactor runtime or host publisher will populate this page.
'); } else { - parts.push('
' + live.map(nodeCard).join('') + '
'); + parts.push('
' + orchestrators.concat(workers).map(nodeCard).join('') + '
'); if (stale.length) { parts.push(`
Stale nodes (${stale.length}) — silent beyond the liveness window @@ -182,33 +227,24 @@ function renderFleet(page, live, stale, totals) { bindCards(); } -function totalCard(label, value, cls = '') { - return `
${esc(label)}
${esc(value)}
`; -} - function nodeCard(node) { const summary = node.actor_summary || {}; - const cpu = node.cpu && node.cpu.host ? node.cpu.host.total_percent : null; - const gpu = node.gpu && node.gpu.gpus && node.gpu.gpus.length - ? node.gpu.gpus.reduce((max, d) => Math.max(max, d.utilization_gpu_percent || 0), 0) - : null; - const gpuMem = node.gpu && node.gpu.gpus && node.gpu.gpus.length - ? node.gpu.gpus.reduce((acc, d) => ({ - used: acc.used + (d.memory_used_mib || 0), - total: acc.total + (d.memory_total_mib || 0) }), { used: 0, total: 0 }) - : null; const proc = node.process ? `${esc(node.process.state)} ` : ''; - return `
-

${esc(node.stream.node)} life ${fmt(node.stream.life)}

-
${proc}seen ${ago(node.last_seen_ms_ago)} ago · ${fmt(summary.actors)} actors · ${fmtRate(summary.msg_per_sec)} msg/s${summary.num_workers != null ? ' · ' + fmt(summary.num_workers) + ' workers' : ''}${summary.uptime_ms != null ? ' · up ' + ago(summary.uptime_ms) : ''}
-
-
CPU${bar(cpu)}${cpu == null ? '—' : fmt(cpu, 0) + '%'}
-
GPU${bar(gpu)}${gpu == null ? '—' : fmt(gpu) + '%'}
- ${gpuMem && gpuMem.total ? `
GPU mem${bar(gpuMem.used, gpuMem.total)}${fmt(gpuMem.used)}/${fmt(gpuMem.total)}
` : ''} -
Mailbox${bar(summary.mailbox_depth, Math.max(64, summary.mailbox_depth))}${fmt(summary.mailbox_depth)}
-
+ const origin = node.stream.origin || ''; + const isOrchestrator = origin === 'orchestrator'; + const role = isOrchestrator ? 'orchestrator' : ''; + const stats = [ + summary.actors ? fmt(summary.actors) + ' actors' : null, + summary.msg_per_sec ? fmtRate(summary.msg_per_sec) + ' msg/s' : null, + summary.num_workers != null ? fmt(summary.num_workers) + ' workers' : null, + summary.uptime_ms != null ? 'up ' + ago(summary.uptime_ms) : null, + ].filter(Boolean).join(' · '); + const label = node.stream.label ? ` · ${esc(node.stream.label)}` : ''; + return ` +

${esc(node.stream.node)}${role} life ${fmt(node.stream.life)}

+
${proc}seen ${ago(node.last_seen_ms_ago)} ago · ${stats || 'no runtime stats'}${label}
${summary.poisoned ? `
${fmt(summary.poisoned)} poisoned actor(s)
` : ''} -
`; + `; } function renderNode(page, node, live, stale) { @@ -219,12 +255,12 @@ function renderNode(page, node, live, stale) {
-

${esc(node.stream.node)} life ${fmt(node.stream.life)} · ${node.live ? 'live' : 'stale ' + ago(node.last_seen_ms_ago) + ' ago'}

+

${esc(node.stream.node)}${node.stream.label ? ` ${esc(node.stream.label)}` : ''} life ${fmt(node.stream.life)} · ${node.live ? 'live' : 'stale ' + ago(node.last_seen_ms_ago) + ' ago'}

${machine}

Actors (${fmt(summary.actors)})

- +
@@ -307,7 +343,7 @@ function renderRoster(node) { } wrap.innerHTML = notice + ` - ${capped.map(a => ` + ${capped.map(a => ` @@ -319,11 +355,13 @@ function renderRoster(node) { `).join('')}
ActorTypeStateMailboxMsg/sProcessedWorkerLast message
${esc(shortAddr(a.address))}${a.name ? `
${esc(a.name)}` : ''}
${typeShort(a.actor_type)} ${a.poisoned ? 'poisoned' : 'running'}
`; wrap.querySelectorAll('tr[data-addr]').forEach(tr => { - tr.addEventListener('click', () => { + const select = () => { selectedActor = tr.getAttribute('data-addr'); pushUrl(); render(); - }); + }; + tr.addEventListener('click', select); + tr.addEventListener('keydown', e => { if (e.key === 'Enter') select(); }); }); } @@ -377,7 +415,7 @@ function renderDossier(d) { slot.innerHTML = `

${typeShort(d.actor_type)} ${esc(shortAddr(d.address))}

- +
node${esc(d.stream.node)} @@ -414,7 +452,8 @@ function renderDossier(d) { function bindCards() { document.querySelectorAll('.node-card').forEach(card => { - card.addEventListener('click', () => { + card.addEventListener('click', e => { + e.preventDefault(); selectedStream = card.getAttribute('data-stream'); selectedActor = null; pushUrl(); diff --git a/crates/dashboard/src/demo_control.rs b/crates/dashboard/src/demo_control.rs index deaee00..528a1a6 100644 --- a/crates/dashboard/src/demo_control.rs +++ b/crates/dashboard/src/demo_control.rs @@ -1,9 +1,11 @@ -//! Demo-only fleet control view (`demo-control` feature). +//! Demo-only Fleet Control view (`demo-control` feature). //! -//! A k8s-style control panel beside the fleet view: one row per provisioned -//! process (streamed on `proc..lifecycle`), each with a kill action, -//! plus a provision action that asks the reconciler for more nodes. Inert in -//! regular builds — this module compiles only under `demo-control`. +//! The merged control surface: one row per provisioned node fusing the +//! process table (`proc..lifecycle`, `node.status`) with the +//! provisioning reconciler's stage snapshot (fetched client-side from +//! `/api/view/reconciler` when that view is registered), plus provision and +//! kill actions. Inert in regular builds — this module compiles only under +//! `demo-control`. use std::collections::BTreeMap; use std::time::Instant; diff --git a/crates/dashboard/src/demo_control_page.html b/crates/dashboard/src/demo_control_page.html index 18f0852..f163492 100644 --- a/crates/dashboard/src/demo_control_page.html +++ b/crates/dashboard/src/demo_control_page.html @@ -8,57 +8,179 @@ :root { color-scheme: dark; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #0f172a; - color: #e2e8f0; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --bg: #000000; --panel: #001220; --panel-hover: #001c38; --inset: #00060c; + --border: #14406a; --divider: #0d2c4a; + --text: #ffffff; --muted: #9db2c4; + --amber: #ff9900; --ghost: rgba(255, 153, 0, .16); + --ok: #00d400; --bad: #ff4141; --cyan: #5cd5ff; --magenta: #cc78bc; + --primary-fill: #ff9900; --primary-ink: #000000; + --danger-fill: #7a1010; --danger-border: #ff4141; --danger-ink: #ffd7d7; + --selected: rgba(255, 153, 0, .10); --selected-edge: #ff9900; + --card-hover: #00263f; --row-hover: #002a47; + --r: 2px; --t: 120ms; + background: var(--bg); color: var(--text); + } + :root[data-theme="light"] { + color-scheme: light; + --bg: #fafafa; --panel: #ffffff; --panel-hover: #f0f0f0; --inset: #f5f5f5; + --border: #dcdcE0; --divider: #eaeaea; + --text: #383a42; --muted: #696c77; + --amber: #986801; --ghost: rgba(152, 104, 1, .18); + --ok: #50a14f; --bad: #e45649; --cyan: #0184bc; --magenta: #a626a4; + --primary-fill: #0184bc; --primary-ink: #ffffff; + --danger-fill: #cc3a2f; --danger-border: #cc3a2f; --danger-ink: #ffffff; + --selected: rgba(64, 120, 242, .08); --selected-edge: #4078f2; + --card-hover: #f0f4fb; --row-hover: #eef1f8; + } + @media (prefers-color-scheme: light) { + :root:not([data-theme]) { + color-scheme: light; + --bg: #fafafa; --panel: #ffffff; --panel-hover: #f0f0f0; --inset: #f5f5f5; + --border: #dcdcE0; --divider: #eaeaea; + --text: #383a42; --muted: #696c77; + --amber: #986801; --ghost: rgba(152, 104, 1, .18); + --ok: #50a14f; --bad: #e45649; --cyan: #0184bc; --magenta: #a626a4; + --primary-fill: #0184bc; --primary-ink: #ffffff; + --danger-fill: #cc3a2f; --danger-border: #cc3a2f; --danger-ink: #ffffff; + --selected: rgba(64, 120, 242, .08); --selected-edge: #4078f2; + --card-hover: #f0f4fb; --row-hover: #eef1f8; + } } * { box-sizing: border-box; } body { margin: 0; padding: 20px; } - .nav { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; padding: 8px; background: #111827; border: 1px solid #334155; border-radius: 12px; } - .nav a { padding: 7px 10px; color: #cbd5e1; border: 1px solid transparent; border-radius: 8px; text-decoration: none; } - .nav a:hover { color: #f8fafc; background: #1e293b; } - header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; margin-bottom: 18px; } - h1 { font-size: 24px; margin: 0; } - .muted { color: #94a3b8; } - .panel { background: #1e293b; border: 1px solid #334155; border-radius: 14px; padding: 16px; margin-bottom: 18px; } - table { width: 100%; border-collapse: collapse; } - th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #334155; } - th { color: #94a3b8; font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; } - .pill { display: inline-flex; padding: 3px 8px; border-radius: 999px; border: 1px solid #334155; font-size: 12px; } - .running { color: #34d399; } - .exited, .failed { color: #f87171; } - button { font: inherit; border-radius: 8px; border: 1px solid #475569; background: #334155; color: #e2e8f0; padding: 6px 12px; cursor: pointer; } - button.danger { background: #7f1d1d; border-color: #b91c1c; color: #fee2e2; } - button:disabled { opacity: 0.5; cursor: not-allowed; } - #status { margin-left: 12px; font-size: 13px; } + .muted { color: var(--muted); } + header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px 24px; margin-bottom: 16px; } + h1 { font-size: 20px; margin: 0; } + .counter { font-variant-numeric: tabular-nums; display: flex; align-items: baseline; gap: 6px; } + .counter .muted { font-size: 13px; } + .counter .meta { font-size: 12px; color: var(--muted); font-family: var(--mono); } + .counter #converged.ok { color: var(--ok); } + .counter #converged.warn { color: var(--amber); } + .seg { position: relative; display: inline-flex; justify-content: flex-end; font: 700 26px/1 var(--mono); color: var(--amber); font-variant-numeric: tabular-nums; letter-spacing: .05em; } + .seg::before { content: attr(data-ghost); position: absolute; right: 0; top: 0; color: var(--ghost); } + .seg .seg-v { position: relative; } + .seg-sm { font-size: 15px; } + .blink { animation: seg-blink 1.1s steps(2, start) infinite; } + @keyframes seg-blink { to { visibility: hidden; } } + .controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } + .controls input { + width: 3.5em; background: var(--inset); color: var(--text); + border: 1px solid var(--border); border-radius: var(--r); padding: 6px 8px; font: 13px var(--mono); + } + .controls input:hover { border-color: var(--cyan); } + button { font: 600 13px var(--mono); border-radius: var(--r); border: 1px solid var(--border); background: transparent; color: var(--text); padding: 6px 12px; cursor: pointer; transition: background var(--t), border-color var(--t); } + button:hover:not(:disabled) { background: var(--panel-hover); border-color: var(--cyan); } + button:active:not(:disabled) { transform: translateY(1px); } + button.primary { background: var(--primary-fill); border-color: var(--primary-fill); color: var(--primary-ink); } + button.primary:hover:not(:disabled) { background: var(--primary-fill); border-color: var(--cyan); filter: brightness(1.1); } + button.danger { background: var(--danger-fill); border-color: var(--danger-border); color: var(--danger-ink); } + button.danger:hover:not(:disabled) { background: var(--danger-fill); border-color: var(--bad); filter: brightness(1.15); } + button:disabled { opacity: 0.45; cursor: not-allowed; } + :is(a, button, input, summary):focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; } + #status { font-size: 12px; font-family: var(--mono); } + #status.failed { color: var(--bad); } + .panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r); padding: 16px; margin-bottom: 16px; overflow-x: auto; } + table { width: 100%; border-collapse: collapse; min-width: 620px; font-family: var(--mono); font-size: 12px; } + th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--divider); font-variant-numeric: tabular-nums; } + tr:last-child td { border-bottom: none; } + th { color: var(--amber); font-weight: 600; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; } + .node-name { font-weight: 700; } + .node-sub { font-size: 11px; color: var(--muted); } + .pill { display: inline-flex; padding: 3px 6px; border: 1px solid var(--muted); border-radius: var(--r); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text); } + .pill.running { color: var(--ok); border-color: var(--ok); } + .pill.exited, .pill.failed { color: var(--bad); border-color: var(--bad); } + .badge { display: inline-block; padding: 3px 6px; border-radius: var(--r); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; border: 1px solid; font-family: var(--mono); } + .badge .sub { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.85; } + .b-ready { color: var(--ok); border-color: var(--ok); } + .b-progress { color: var(--cyan); border-color: var(--cyan); } + .b-failed { color: var(--bad); border-color: var(--bad); } + .b-deleting { color: var(--amber); border-color: var(--amber); } + .b-destroyed { color: var(--muted); border-color: var(--muted); } + .row-error { color: var(--bad); font-size: 11px; font-family: var(--mono); } + details.activity summary { cursor: pointer; color: var(--muted); padding: 6px 0; font-family: var(--mono); font-size: 12px; } + details.activity[open] summary { margin-bottom: 10px; color: var(--amber); } + .feeds { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + .feed { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r); padding: 10px 12px; } + .feed h2 { margin: 0 0 8px; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--amber); } + .feed ul { list-style: none; margin: 0; padding: 0; font-family: var(--mono); font-size: 12px; max-height: 360px; overflow-y: auto; } + .feed li { padding: 3px 0; border-bottom: 1px solid var(--divider); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .feed li time { color: var(--muted); margin-right: 8px; } + .k-command { color: var(--cyan); } + .k-result { color: var(--ok); } + .k-transition { color: var(--amber); } + .k-control { color: var(--magenta); } + .k-observation { color: var(--muted); } + .k-error { color: var(--bad); font-weight: 700; } + @media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; animation: none !important; } } +
-

Fleet Control

- - - - - +

Fleet Control

+ +
+
+ + + + +
-
- +
nodepidstateseen
nodestagestatepidseen
- +
+
+ Activity — commands out · events in · transitions +
+
+

Commands out

+
    +
    +
    +

    Events in · transitions · control

    +
      +
      +
      +
      "#, r#""#, - r#""#, + r#""#, ), links.join("") ); diff --git a/xtask/src/provisioning_demo/feed.rs b/xtask/src/provisioning_demo/feed.rs index 3e60f33..6d0f655 100644 --- a/xtask/src/provisioning_demo/feed.rs +++ b/xtask/src/provisioning_demo/feed.rs @@ -40,6 +40,10 @@ use crate::provisioning_demo::provider::{ pub struct SupervisorTelemetry { pub endpoint: TelemetryEndpoint, pub producer: TelemetryProducer, + /// Descriptor metadata mirrored onto every published frame so the + /// dashboard can classify the stream without a catalog. + pub origin: &'static str, + pub label: &'static str, names: BTreeMap, } @@ -62,10 +66,11 @@ impl SupervisorTelemetry { Self { endpoint, producer, + origin: "orchestrator", + label: "provisioning supervisor", names: BTreeMap::new(), } } - pub fn register(&mut self, name: &str) -> telemetry::ChannelId { let id = self.endpoint.register_channel( name, @@ -608,7 +613,14 @@ impl SupervisorActor { .get(&frame.channel) .cloned() .unwrap_or_else(|| format!("channel#{}", frame.channel.0)); - publish_frame(&self.dashboard, &supervisor_stream, &channel, &frame); + publish_frame( + &self.dashboard, + &supervisor_stream, + &channel, + &frame, + self.telemetry.origin, + self.telemetry.label, + ); } let attempts: Vec = self.nodes.keys().copied().collect(); for attempt in attempts { @@ -626,22 +638,34 @@ impl SupervisorActor { }) .map(|descriptor| descriptor.name.clone()) .unwrap_or_else(|| format!("channel#{}", frame.channel.0)); - publish_frame(&self.dashboard, &stream, &channel, &frame); + publish_frame( + &self.dashboard, + &stream, + &channel, + &frame, + streams.telemetry.origin, + &streams.telemetry.label, + ); } } } } +#[allow(clippy::too_many_arguments)] fn publish_frame( dashboard: &dashboard::DashboardHandle, stream: &telemetry::frame::StreamId, channel: &str, frame: &telemetry::frame::Frame, + origin: &str, + label: &str, ) { dashboard.publish(dashboard::FrameEvent { stream: dashboard::StreamEvent { node: stream.node.as_str().to_string(), life: stream.life.0, + origin: Some(origin.to_owned()), + label: Some(label.to_owned()), }, channel: channel.to_owned(), position: frame.position.0, diff --git a/xtask/src/provisioning_demo/mod.rs b/xtask/src/provisioning_demo/mod.rs index 47efe88..ff2b281 100644 --- a/xtask/src/provisioning_demo/mod.rs +++ b/xtask/src/provisioning_demo/mod.rs @@ -7,10 +7,10 @@ //! re-exec of this binary as a real swactor runtime that joins the //! supervisor's iroh endpoint. //! -//! Humans watch `/view/reconciler` (k8s-style current-vs-desired, node -//! stages, command/result feeds) and the fleet cards (per-node PID/state), -//! kill nodes from the Fleet Control view or a shell, and watch the -//! reconciler replace them for real. +//! Humans watch the Fleet Control view (reconciler current-vs-desired, node +//! stages, command/result feeds, kill/provision controls) and the fleet cards +//! (per-node PID/state), kill nodes from Fleet Control or a shell, and watch +//! the reconciler replace them for real. pub mod control; pub mod feed; @@ -224,7 +224,7 @@ fn run_supervisor(args: &[String]) -> Result<(), String> { let cluster_driver = ClusterDriver::new(shape, demo_retry_policy()).map_err(|e| format!("driver: {e}"))?; - let plugin = DemoProvider::new(manager.clone(), keys_dir); + let plugin = DemoProvider::new(manager.clone(), keys_dir.clone()); let spawner = EngineSpawner::new(engine.handle()); let executor = IdempotentEffectExecutor::new( DemoBackend { @@ -294,9 +294,8 @@ fn run_supervisor(args: &[String]) -> Result<(), String> { .expect("supervisor address slot set once"); println!("provisioning-reconciler-demo: dashboard on http://localhost:{port}"); - println!(" /view/reconciler — reconciler state machine, current vs desired"); println!(" /view/fleet — per-node cards (pid, lifecycle)"); - println!(" /view/demo-control — kill / provision controls"); + println!(" /view/demo-control — Fleet Control: stages, feeds, kill / provision"); println!(" Ctrl-C to tear down."); // Block until Ctrl-C (synchronous signal flag — the wait must not depend @@ -329,8 +328,8 @@ fn run_supervisor(args: &[String]) -> Result<(), String> { std::thread::sleep(TICK); } // Best-effort drain window closed: children still alive (if any) are - // killed by the process-tree teardown of the launching console, so exit - // deterministically rather than risking a wedged teardown path. + // killed by the kernel parent-death signal armed in the node role. + let _ = std::fs::remove_dir_all(&keys_dir); std::process::exit(0); } diff --git a/xtask/src/provisioning_demo/provider.rs b/xtask/src/provisioning_demo/provider.rs index 1598dd1..c29eb7f 100644 --- a/xtask/src/provisioning_demo/provider.rs +++ b/xtask/src/provisioning_demo/provider.rs @@ -230,10 +230,14 @@ impl ProvisionPlugin for DemoProvider { } /// Per-node telemetry: one endpoint/producer per provisioned node so each -/// lands on its own dashboard stream (one fleet card per node). +/// lands on its own dashboard stream (one fleet card per node). Carries the +/// stream descriptor's origin/label so the dashboard can classify the stream +/// (the frame path itself has no catalog). pub struct NodeTelemetry { pub endpoint: TelemetryEndpoint, pub producer: TelemetryProducer, + pub origin: &'static str, + pub label: String, } impl NodeTelemetry { @@ -252,7 +256,12 @@ impl NodeTelemetry { 16, ); let producer = endpoint.producer(); - Self { endpoint, producer } + Self { + endpoint, + producer, + origin: "remote_node", + label: format!("demo node {logical_node}"), + } } } diff --git a/xtask/src/provisioning_demo/reconciler_page.html b/xtask/src/provisioning_demo/reconciler_page.html deleted file mode 100644 index 19d518e..0000000 --- a/xtask/src/provisioning_demo/reconciler_page.html +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - Provisioning Reconciler - - - - -
      -

      Provisioning Reconciler

      -
      0 / 0 Ready
      -
      gen 0 · — · snapshot — old
      -
      -
      -
      -
      -

      Commands out

      -
        -
        -
        -

        Events in · transitions · control

        -
          -
          -
          - - - diff --git a/xtask/src/provisioning_demo/view.rs b/xtask/src/provisioning_demo/view.rs index 872ea59..3c437a0 100644 --- a/xtask/src/provisioning_demo/view.rs +++ b/xtask/src/provisioning_demo/view.rs @@ -1,9 +1,9 @@ -//! Reconciler dashboard view — k8s workload semantics. +//! Reconciler dashboard view — API only. //! -//! Header: current-ready vs desired (Deployment-style `2 / 3`). -//! Node cards: stage badge, bootstrap sub-stage, attempt, PID — pod status. -//! Feeds: commands out / results + transitions in, `kubectl describe` events -//! style, one line each with timestamps. +//! The snapshot/feed JSON at `/api/view/reconciler` feeds the merged Fleet +//! Control page (`/view/demo-control`), which fuses it with the process +//! table. No standalone page: `html()` is `None` and the view stays out of +//! the navbar while its API remains registered. use std::collections::VecDeque; use std::time::{SystemTime, UNIX_EPOCH}; @@ -95,6 +95,10 @@ impl DashboardView for ReconcilerDashboardView { &[EVENTS_CHANNEL, SNAPSHOT_CHANNEL] } + fn show_in_nav(&self) -> bool { + false + } + fn ingest(&self, _stream: &StreamId, _frame: &Frame, event: &FrameEvent) { let Ok(payload) = serde_json::from_slice::(&event.payload) else { return; @@ -164,7 +168,7 @@ impl DashboardView for ReconcilerDashboardView { } fn html(&self) -> Option<&'static str> { - Some(include_str!("reconciler_page.html")) + None } }