feat(dashboard): Segment Mask visual world and merged Fleet Control

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.
This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-08-16 15:16:30 +04:00
parent c5f991e8f1
commit 1926e73064
13 changed files with 660 additions and 417 deletions

View file

@ -51,6 +51,8 @@ struct FusedNode {
last_seen: Instant, last_seen: Instant,
hardware: NodeHardwareState, hardware: NodeHardwareState,
actors: RuntimeState, actors: RuntimeState,
origin: Option<String>,
label: Option<String>,
} }
impl DashboardView for ControlPlaneView { impl DashboardView for ControlPlaneView {
@ -81,8 +83,17 @@ impl DashboardView for ControlPlaneView {
last_seen: now, last_seen: now,
hardware: NodeHardwareState::new(now), hardware: NodeHardwareState::new(now),
actors: RuntimeState::new(now), actors: RuntimeState::new(now),
origin: event.stream.origin.clone(),
label: event.stream.label.clone(),
}); });
node.last_seen = now; 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.hardware.update(&event.channel, &event.payload, now);
node.actors.update(&event.channel, &event.payload, now); node.actors.update(&event.channel, &event.payload, now);
prune(&mut state.streams, &event.stream, 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 pool: most recently seen first, bounded by the physical cap.
stale.sort_by_key(|right| std::cmp::Reverse(right.last_seen_ms_ago)); stale.sort_by_key(|right| std::cmp::Reverse(right.last_seen_ms_ago));
stale.truncate(STALE_POOL_CAP); 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 }; let snapshot = FusedSnapshot { totals, live, stale };
serde_json::to_value(snapshot).unwrap_or_else(|_| { serde_json::to_value(snapshot).unwrap_or_else(|_| {
json!({ json!({
@ -128,7 +139,14 @@ impl DashboardView for ControlPlaneView {
let state = self.state.read(); let state = self.state.read();
let node = state.streams.get(&stream)?; let node = state.streams.get(&stream)?;
let actor = node.actors.actors.get(&actor_key)?; 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> { fn html(&self) -> Option<&'static str> {
@ -174,21 +192,8 @@ struct FusedSnapshot {
#[derive(Default, Serialize)] #[derive(Default, Serialize)]
struct FusedTotals { struct FusedTotals {
nodes: u32,
live_nodes: u32, live_nodes: u32,
stale_nodes: u32, stale_nodes: u32,
actors: u32,
msg_per_sec: f64,
mailbox_depth: u32,
poisoned: u32,
gpu_count: u32,
cpu_avg_percent: Option<f64>,
gpu_max_percent: Option<u64>,
gpu_memory_used_mib: u64,
gpu_memory_total_mib: u64,
net_rx_bps: f64,
net_tx_bps: f64,
errors: u32,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -214,6 +219,10 @@ struct StreamKeySnapshot {
key: String, key: String,
node: String, node: String,
life: u64, life: u64,
#[serde(skip_serializing_if = "Option::is_none")]
origin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>,
} }
#[derive(Default, Serialize)] #[derive(Default, Serialize)]
@ -308,6 +317,8 @@ fn node_card(node: &FusedNode, now: Instant) -> NodeCard {
key: stream_key(&node.stream), key: stream_key(&node.stream),
node: node.stream.node.clone(), node: node.stream.node.clone(),
life: node.stream.life, life: node.stream.life,
origin: node.origin.clone(),
label: node.label.clone(),
}, },
live: now.duration_since(node.last_seen) <= LIVE_TTL, live: now.duration_since(node.last_seen) <= LIVE_TTL,
last_seen_ms_ago: duration_ms(now.duration_since(node.last_seen)), last_seen_ms_ago: duration_ms(now.duration_since(node.last_seen)),
@ -365,12 +376,16 @@ fn actor_detail(
stream: &StreamEvent, stream: &StreamEvent,
stream_key_value: &str, stream_key_value: &str,
now: Instant, now: Instant,
origin: Option<String>,
label: Option<String>,
) -> Value { ) -> Value {
let detail = ActorDetail { let detail = ActorDetail {
stream: StreamKeySnapshot { stream: StreamKeySnapshot {
key: stream_key_value.to_owned(), key: stream_key_value.to_owned(),
node: stream.node.clone(), node: stream.node.clone(),
life: stream.life, life: stream.life,
origin,
label,
}, },
address: actor.address.clone(), address: actor.address.clone(),
name: actor.name.clone(), name: actor.name.clone(),
@ -415,61 +430,11 @@ fn actor_detail(
serde_json::to_value(detail).unwrap_or_else(|_| json!({})) serde_json::to_value(detail).unwrap_or_else(|_| json!({}))
} }
fn fused_totals(live: &[NodeCard], stale_len: usize) -> FusedTotals { fn fused_totals(live_len: usize, stale_len: usize) -> FusedTotals {
let mut totals = FusedTotals { FusedTotals {
nodes: saturating_u32(live.len() + stale_len), live_nodes: saturating_u32(live_len),
live_nodes: saturating_u32(live.len()),
stale_nodes: saturating_u32(stale_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 /// Minimal `application/x-www-form-urlencoded` reader with percent-decoding
@ -523,6 +488,8 @@ mod tests {
stream: crate::StreamEvent { stream: crate::StreamEvent {
node: stream.node.as_str().to_string(), node: stream.node.as_str().to_string(),
life: stream.life.0, life: stream.life.0,
origin: None,
label: None,
}, },
channel: channel.to_string(), channel: channel.to_string(),
position, position,
@ -531,6 +498,30 @@ mod tests {
view.ingest(stream, &frame, &event); 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<u8> { fn actors_payload(worker: u32, actors: serde_json::Value) -> Vec<u8> {
serde_json::json!({ "worker_id": worker, "actors": actors }) serde_json::json!({ "worker_id": worker, "actors": actors })
.to_string() .to_string()

View file

@ -5,65 +5,120 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>swactor fleet</title> <title>swactor fleet</title>
<style> <style>
:root { color-scheme: dark; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f172a; color: #e2e8f0; } :root {
color-scheme: dark;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* dark: Bloomberg night housing */
--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;
--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;
/* light: Atom One Light bench card */
--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;
--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;
--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; } * { box-sizing: border-box; }
body { margin: 0; padding: 18px; } body { margin: 0; padding: 20px; }
header { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; margin-bottom: 14px; } header { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; margin-bottom: 16px; }
h1 { margin: 0; font-size: 24px; } h1 { margin: 0; font-size: 20px; }
h2 { margin: 22px 0 10px; font-size: 17px; } h2 { margin: 20px 0 10px; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--amber); }
.muted { color: #94a3b8; } .muted { color: var(--muted); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } .mono { font-family: var(--mono); font-size: 12px; }
.totals { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 16px; } #status { font-family: var(--mono); font-size: 13px; color: var(--amber); }
.totals .card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 10px 12px; } a.node-card { color: inherit; text-decoration: none; display: block; }
.totals .label { color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: .07em; } .node-card.orchestrator { grid-column: 1 / -1; border-color: var(--amber); background: var(--card-hover); }
.totals .value { font-size: 20px; margin-top: 4px; font-variant-numeric: tabular-nums; } .node-card .role { display: inline-flex; margin-left: 8px; padding: 2px 6px; border: 1px solid var(--amber); border-radius: var(--r); color: var(--amber); font: 600 10px/1.4 var(--mono); text-transform: uppercase; letter-spacing: .07em; vertical-align: 2px; }
:is(a, button, input, summary):focus-visible, tr[data-addr]:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
.node-card { background: #1e293b; border: 1px solid #334155; border-radius: 14px; padding: 14px; cursor: pointer; transition: border-color .12s; } .node-card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r); padding: 14px; cursor: pointer; transition: border-color var(--t), background var(--t); }
.node-card:hover { border-color: #60a5fa; } .node-card:hover { border-color: var(--amber); background: var(--card-hover); }
.node-card[data-live="false"] { opacity: .55; } .node-card[data-live="false"] { opacity: .55; }
.node-card h3 { margin: 0 0 2px; font-size: 15px; word-break: break-all; } .node-card h3 { margin: 0 0 2px; font: 700 13px/1.4 var(--mono); word-break: break-all; }
.node-card .meta { color: #94a3b8; font-size: 12px; margin-bottom: 8px; } .node-card .meta { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
.bars { display: grid; gap: 5px; } .bars { display: grid; gap: 5px; }
.bar-row { display: flex; align-items: center; gap: 8px; font-size: 12px; } .bar-row { display: flex; align-items: center; gap: 8px; font-family: var(--mono); font-size: 12px; }
.bar-row .name { width: 92px; color: #94a3b8; } .bar-row .name { width: 92px; color: var(--muted); }
.bar-track { flex: 1; height: 8px; background: #111827; border-radius: 99px; overflow: hidden; } .bar-track { flex: 1; height: 8px; background: var(--inset); border-radius: var(--r); overflow: hidden; }
.bar-fill { height: 100%; border-radius: 99px; } .bar-fill { height: 100%; }
.bar-fill.ok { background: #34d399; } .bar-fill.warn { background: #fbbf24; } .bar-fill.bad { background: #f87171; } .bar-fill.ok { background: var(--ok); } .bar-fill.warn { background: var(--amber); } .bar-fill.bad { background: var(--bad); }
.bar-row .num { width: 64px; text-align: right; font-variant-numeric: tabular-nums; } .bar-row .num { width: 64px; text-align: right; font-variant-numeric: tabular-nums; }
.pill { display: inline-flex; padding: 2px 8px; border-radius: 999px; border: 1px solid #334155; font-size: 11px; } .pill { display: inline-flex; padding: 3px 6px; border: 1px solid var(--muted); border-radius: var(--r); font: 600 10px/1.4 var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text); }
.pill.live { color: #34d399; border-color: #065f46; } .pill.live { color: var(--ok); border-color: var(--ok); }
.pill.stale { color: #f87171; border-color: #7f1d1d; } .pill.stale { color: var(--bad); border-color: var(--bad); }
.pill.poisoned { color: #f87171; border-weight: 600; } .pill.poisoned { color: var(--bad); border-color: var(--bad); font-weight: 700; }
details.stale { margin-top: 18px; } details.stale { margin-top: 18px; }
details.stale summary { cursor: pointer; color: #94a3b8; padding: 8px 0; } details.stale summary { cursor: pointer; color: var(--muted); padding: 8px 0; font-family: var(--mono); font-size: 12px; }
.focus-back { display: inline-block; margin-bottom: 12px; color: #93c5fd; text-decoration: none; } .focus-back { display: inline-block; margin-bottom: 12px; color: var(--cyan); text-decoration: none; font-family: var(--mono); font-size: 12px; }
.focus-back:hover { color: var(--amber); }
.node-view { display: grid; gap: 14px; } .node-view { display: grid; gap: 14px; }
.panel { background: #1e293b; border: 1px solid #334155; border-radius: 14px; padding: 14px; } .panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r); padding: 14px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; } table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid #1f2937; font-variant-numeric: tabular-nums; } th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--divider); font-variant-numeric: tabular-nums; }
th { color: #93c5fd; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; } th { color: var(--amber); font-weight: 600; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
tbody tr[data-addr] { cursor: pointer; } tbody tr[data-addr] { cursor: pointer; transition: background var(--t); }
tbody tr[data-addr]:hover { background: #17255466; } tbody tr[data-addr]:hover { background: var(--row-hover); }
tbody tr[data-selected="true"] { background: #17255488; } tbody tr[data-selected="true"] { background: var(--selected); box-shadow: inset 2px 0 0 var(--selected-edge); }
input[type=search] { background: #111827; color: #e2e8f0; border: 1px solid #334155; border-radius: 8px; padding: 7px 10px; width: 240px; max-width: 100%; } input[type=search] { background: var(--inset); color: var(--text); border: 1px solid var(--border); border-radius: var(--r); padding: 7px 10px; width: 240px; max-width: 100%; font: 12px var(--mono); }
input[type=search]:hover { border-color: var(--cyan); }
.layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; } .layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; }
@media (min-width: 1100px) { .layout.with-dossier { grid-template-columns: minmax(0, 1fr) 420px; } } @media (min-width: 1100px) { .layout.with-dossier { grid-template-columns: minmax(0, 1fr) 420px; } }
.dossier { position: sticky; top: 12px; align-self: start; max-height: calc(100vh - 24px); overflow: auto; } .dossier { position: sticky; top: 12px; align-self: start; max-height: calc(100vh - 24px); overflow: auto; }
.kv { display: grid; grid-template-columns: 120px 1fr; gap: 4px 10px; font-size: 13px; } .kv { display: grid; grid-template-columns: 120px 1fr; gap: 4px 10px; font-size: 13px; }
.kv .k { color: #94a3b8; } .kv .k { color: var(--muted); font-family: var(--mono); font-size: 12px; }
.diet .bar-row .name { width: 48%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .diet .bar-row .name { width: 48%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.receipts td { font-size: 12px; } .receipts td { font-size: 11px; }
.empty { padding: 24px; border: 1px dashed #475569; border-radius: 12px; color: #94a3b8; } .empty { padding: 24px; border: 1px dashed var(--border); border-radius: var(--r); color: var(--muted); font-family: var(--mono); font-size: 12px; }
.notice { font-size: 12px; color: #94a3b8; margin: 6px 0; } .notice { font-size: 12px; color: var(--muted); margin: 6px 0; }
canvas { width: 100%; height: 64px; background: #111827; border: 1px solid #334155; border-radius: 10px; } canvas { width: 100%; height: 64px; background: var(--inset); border: 1px solid var(--border); border-radius: var(--r); }
.err { color: #f87171; } .err { color: var(--bad); }
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; animation: none !important; } }
</style> </style>
</head> </head>
<body> <body>
<!--
swactor dashboard — direction contract (impeccable seed 8b5f2278)
THESIS: a numeric instrument bank for a live actor fleet; Bloomberg night housing (dark) / Atom One Light bench card (light); absence drawn as designed negative space, never blank.
OWN-WORLD: black-on-navy or paper-on-white; amber owns the data register, green/red are law, cyan is the only interactive voice; 2px corners, monospace data, machined caps labels, controls that depress.
FIRST VIEWPORT: the fleet as an instrument rack — orchestrator module pinned with an amber edge, node cards as module slots with state tags.
SIGNATURE: state as instrument grammar — lit tag for live, struck dim for dead, no skeleton bars for silent channels.
RISK: terminal cosplay if decoration creeps past data; held by the palette law.
-->
<!--swactor:nav--> <!--swactor:nav-->
<header> <header>
<h1>Fleet</h1> <h1>Fleet</h1>
<span class="muted" id="status">loading…</span> <span class="muted" id="status" role="status" aria-live="polite">loading…</span>
</header> </header>
<div id="page"> <div id="page">
@ -141,7 +196,7 @@ function render() {
const live = data.live || []; const live = data.live || [];
const stale = data.stale || []; const stale = data.stale || [];
const totals = data.totals || {}; 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) const node = live.concat(stale).find(n => n.stream.key === selectedStream)
|| live.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) { function renderFleet(page, live, stale, totals) {
stopDetailPolling(); stopDetailPolling();
const orchestrators = live.filter(n => (n.stream.origin || '') === 'orchestrator');
const workers = live.filter(n => (n.stream.origin || '') !== 'orchestrator');
const parts = []; const parts = [];
parts.push(`<div class="totals">
${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) + '↑')}
</div>`);
if (!live.length && !stale.length) { if (!live.length && !stale.length) {
parts.push('<div class="empty">No telemetry streams yet. A live swactor runtime or host publisher will populate this page.</div>'); parts.push('<div class="empty">No telemetry streams yet. A live swactor runtime or host publisher will populate this page.</div>');
} else { } else {
parts.push('<div class="grid">' + live.map(nodeCard).join('') + '</div>'); parts.push('<div class="grid">' + orchestrators.concat(workers).map(nodeCard).join('') + '</div>');
if (stale.length) { if (stale.length) {
parts.push(`<details class="stale"> parts.push(`<details class="stale">
<summary>Stale nodes (${stale.length}) — silent beyond the liveness window</summary> <summary>Stale nodes (${stale.length}) — silent beyond the liveness window</summary>
@ -182,33 +227,24 @@ function renderFleet(page, live, stale, totals) {
bindCards(); bindCards();
} }
function totalCard(label, value, cls = '') {
return `<div class="card"><div class="label">${esc(label)}</div><div class="value ${cls}">${esc(value)}</div></div>`;
}
function nodeCard(node) { function nodeCard(node) {
const summary = node.actor_summary || {}; 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 ? `<span class="pill">${esc(node.process.state)}</span> ` : ''; const proc = node.process ? `<span class="pill">${esc(node.process.state)}</span> ` : '';
return `<article class="node-card" data-stream="${esc(node.stream.key)}" data-live="${node.live ? 'true' : 'false'}"> const origin = node.stream.origin || '';
<h3>${esc(node.stream.node)} <span class="muted" style="font-size:12px">life ${fmt(node.stream.life)}</span></h3> const isOrchestrator = origin === 'orchestrator';
<div class="meta">${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) : ''}</div> const role = isOrchestrator ? '<span class="role">orchestrator</span>' : '';
<div class="bars"> const stats = [
<div class="bar-row"><span class="name">CPU</span>${bar(cpu)}<span class="num">${cpu == null ? '—' : fmt(cpu, 0) + '%'}</span></div> summary.actors ? fmt(summary.actors) + ' actors' : null,
<div class="bar-row"><span class="name">GPU</span>${bar(gpu)}<span class="num">${gpu == null ? '—' : fmt(gpu) + '%'}</span></div> summary.msg_per_sec ? fmtRate(summary.msg_per_sec) + ' msg/s' : null,
${gpuMem && gpuMem.total ? `<div class="bar-row"><span class="name">GPU mem</span>${bar(gpuMem.used, gpuMem.total)}<span class="num">${fmt(gpuMem.used)}/${fmt(gpuMem.total)}</span></div>` : ''} summary.num_workers != null ? fmt(summary.num_workers) + ' workers' : null,
<div class="bar-row"><span class="name">Mailbox</span>${bar(summary.mailbox_depth, Math.max(64, summary.mailbox_depth))}<span class="num">${fmt(summary.mailbox_depth)}</span></div> summary.uptime_ms != null ? 'up ' + ago(summary.uptime_ms) : null,
</div> ].filter(Boolean).join(' · ');
const label = node.stream.label ? ` · ${esc(node.stream.label)}` : '';
return `<a class="node-card${isOrchestrator ? ' orchestrator' : ''}" data-stream="${esc(node.stream.key)}" data-live="${node.live ? 'true' : 'false'}" href="?stream=${encodeURIComponent(node.stream.key)}">
<h3>${esc(node.stream.node)}${role} <span class="muted" style="font-size:12px">life ${fmt(node.stream.life)}</span></h3>
<div class="meta">${proc}seen ${ago(node.last_seen_ms_ago)} ago · ${stats || 'no runtime stats'}${label}</div>
${summary.poisoned ? `<div class="notice err">${fmt(summary.poisoned)} poisoned actor(s)</div>` : ''} ${summary.poisoned ? `<div class="notice err">${fmt(summary.poisoned)} poisoned actor(s)</div>` : ''}
</article>`; </a>`;
} }
function renderNode(page, node, live, stale) { function renderNode(page, node, live, stale) {
@ -219,12 +255,12 @@ function renderNode(page, node, live, stale) {
<div class="layout ${selectedActor ? 'with-dossier' : ''}"> <div class="layout ${selectedActor ? 'with-dossier' : ''}">
<div class="node-view"> <div class="node-view">
<section class="panel"> <section class="panel">
<h2 style="margin:0 0 8px">${esc(node.stream.node)} <span class="muted" style="font-size:13px">life ${fmt(node.stream.life)} · ${node.live ? 'live' : 'stale ' + ago(node.last_seen_ms_ago) + ' ago'}</span></h2> <h2 style="margin:0 0 8px">${esc(node.stream.node)}${node.stream.label ? ` <span class="muted" style="font-size:13px">${esc(node.stream.label)}</span>` : ''} <span class="muted" style="font-size:13px">life ${fmt(node.stream.life)} · ${node.live ? 'live' : 'stale ' + ago(node.last_seen_ms_ago) + ' ago'}</span></h2>
${machine} ${machine}
</section> </section>
<section class="panel"> <section class="panel">
<h2 style="margin:0 0 8px">Actors (${fmt(summary.actors)})</h2> <h2 style="margin:0 0 8px">Actors (${fmt(summary.actors)})</h2>
<input type="search" id="roster-filter" placeholder="filter by type, address, worker…" value="${esc(rosterFilter)}"> <input type="search" id="roster-filter" aria-label="Filter actors" placeholder="filter by type, address, worker…" value="${esc(rosterFilter)}">
<div id="roster-wrap" style="margin-top:10px"></div> <div id="roster-wrap" style="margin-top:10px"></div>
</section> </section>
</div> </div>
@ -307,7 +343,7 @@ function renderRoster(node) {
} }
wrap.innerHTML = notice + `<table> wrap.innerHTML = notice + `<table>
<thead><tr><th>Actor</th><th>Type</th><th>State</th><th>Mailbox</th><th>Msg/s</th><th>Processed</th><th>Worker</th><th>Last message</th></tr></thead> <thead><tr><th>Actor</th><th>Type</th><th>State</th><th>Mailbox</th><th>Msg/s</th><th>Processed</th><th>Worker</th><th>Last message</th></tr></thead>
<tbody>${capped.map(a => `<tr data-addr="${esc(a.address)}" ${a.address === selectedActor ? 'data-selected="true"' : ''}> <tbody>${capped.map(a => `<tr data-addr="${esc(a.address)}" tabindex="0" ${a.address === selectedActor ? 'data-selected="true"' : ''}>
<td class="mono">${esc(shortAddr(a.address))}${a.name ? `<br><span class="muted">${esc(a.name)}</span>` : ''}</td> <td class="mono">${esc(shortAddr(a.address))}${a.name ? `<br><span class="muted">${esc(a.name)}</span>` : ''}</td>
<td><span class="muted" title="${esc(a.actor_type || '')}">${typeShort(a.actor_type)}</span></td> <td><span class="muted" title="${esc(a.actor_type || '')}">${typeShort(a.actor_type)}</span></td>
<td>${a.poisoned ? '<span class="pill poisoned">poisoned</span>' : '<span class="muted">running</span>'}</td> <td>${a.poisoned ? '<span class="pill poisoned">poisoned</span>' : '<span class="muted">running</span>'}</td>
@ -319,11 +355,13 @@ function renderRoster(node) {
</tr>`).join('')}</tbody> </tr>`).join('')}</tbody>
</table>`; </table>`;
wrap.querySelectorAll('tr[data-addr]').forEach(tr => { wrap.querySelectorAll('tr[data-addr]').forEach(tr => {
tr.addEventListener('click', () => { const select = () => {
selectedActor = tr.getAttribute('data-addr'); selectedActor = tr.getAttribute('data-addr');
pushUrl(); pushUrl();
render(); render();
}); };
tr.addEventListener('click', select);
tr.addEventListener('keydown', e => { if (e.key === 'Enter') select(); });
}); });
} }
@ -377,7 +415,7 @@ function renderDossier(d) {
slot.innerHTML = `<section class="panel dossier"> slot.innerHTML = `<section class="panel dossier">
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:8px"> <div style="display:flex;justify-content:space-between;align-items:baseline;gap:8px">
<h2 style="margin:0">${typeShort(d.actor_type)} <span class="muted mono" style="font-size:12px">${esc(shortAddr(d.address))}</span></h2> <h2 style="margin:0">${typeShort(d.actor_type)} <span class="muted mono" style="font-size:12px">${esc(shortAddr(d.address))}</span></h2>
<button id="dossier-close" style="background:none;border:none;color:#94a3b8;cursor:pointer;font-size:16px">×</button> <button id="dossier-close" aria-label="Close dossier" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:16px">×</button>
</div> </div>
<div class="kv" style="margin-top:10px"> <div class="kv" style="margin-top:10px">
<span class="k">node</span><span>${esc(d.stream.node)}</span> <span class="k">node</span><span>${esc(d.stream.node)}</span>
@ -414,7 +452,8 @@ function renderDossier(d) {
function bindCards() { function bindCards() {
document.querySelectorAll('.node-card').forEach(card => { document.querySelectorAll('.node-card').forEach(card => {
card.addEventListener('click', () => { card.addEventListener('click', e => {
e.preventDefault();
selectedStream = card.getAttribute('data-stream'); selectedStream = card.getAttribute('data-stream');
selectedActor = null; selectedActor = null;
pushUrl(); pushUrl();

View file

@ -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 //! The merged control surface: one row per provisioned node fusing the
//! process (streamed on `proc.<node>.lifecycle`), each with a kill action, //! process table (`proc.<node>.lifecycle`, `node.status`) with the
//! plus a provision action that asks the reconciler for more nodes. Inert in //! provisioning reconciler's stage snapshot (fetched client-side from
//! regular builds — this module compiles only under `demo-control`. //! `/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::collections::BTreeMap;
use std::time::Instant; use std::time::Instant;

View file

@ -8,57 +8,179 @@
:root { :root {
color-scheme: dark; color-scheme: dark;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f172a; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #e2e8f0; --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; } * { box-sizing: border-box; }
body { margin: 0; padding: 20px; } 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; } .muted { color: var(--muted); }
.nav a { padding: 7px 10px; color: #cbd5e1; border: 1px solid transparent; border-radius: 8px; text-decoration: none; } header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px 24px; margin-bottom: 16px; }
.nav a:hover { color: #f8fafc; background: #1e293b; } h1 { font-size: 20px; margin: 0; }
header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; margin-bottom: 18px; } .counter { font-variant-numeric: tabular-nums; display: flex; align-items: baseline; gap: 6px; }
h1 { font-size: 24px; margin: 0; } .counter .muted { font-size: 13px; }
.muted { color: #94a3b8; } .counter .meta { font-size: 12px; color: var(--muted); font-family: var(--mono); }
.panel { background: #1e293b; border: 1px solid #334155; border-radius: 14px; padding: 16px; margin-bottom: 18px; } .counter #converged.ok { color: var(--ok); }
table { width: 100%; border-collapse: collapse; } .counter #converged.warn { color: var(--amber); }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #334155; } .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; }
th { color: #94a3b8; font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; } .seg::before { content: attr(data-ghost); position: absolute; right: 0; top: 0; color: var(--ghost); }
.pill { display: inline-flex; padding: 3px 8px; border-radius: 999px; border: 1px solid #334155; font-size: 12px; } .seg .seg-v { position: relative; }
.running { color: #34d399; } .seg-sm { font-size: 15px; }
.exited, .failed { color: #f87171; } .blink { animation: seg-blink 1.1s steps(2, start) infinite; }
button { font: inherit; border-radius: 8px; border: 1px solid #475569; background: #334155; color: #e2e8f0; padding: 6px 12px; cursor: pointer; } @keyframes seg-blink { to { visibility: hidden; } }
button.danger { background: #7f1d1d; border-color: #b91c1c; color: #fee2e2; } .controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
button:disabled { opacity: 0.5; cursor: not-allowed; } .controls input {
#status { margin-left: 12px; font-size: 13px; } 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; } }
</style> </style>
</head> </head>
<body> <body>
<!--
swactor dashboard — direction contract (impeccable seed 8b5f2278)
THESIS: a numeric instrument bank for a live actor fleet; Bloomberg night housing (dark) / Atom One Light bench card (light); absence drawn as ghost segments, never blank.
OWN-WORLD: black-on-navy or paper-on-white; amber owns the data register, green/red are law, cyan is the only interactive voice; 2px corners, monospace data, machined caps labels, controls that depress.
FIRST VIEWPORT: the control bench — ghost-segment ready/desired counter, generation digit, node rows as channel strips with stage tags and kill switches.
SIGNATURE: counters tick by instant segment swap; converging blinks like an unset clock; ghost eights hold every unlit place.
RISK: terminal cosplay if decoration creeps past data; held by the palette law.
-->
<!--swactor:nav--> <!--swactor:nav-->
<header> <header>
<h1>Fleet Control</h1>
<div> <div>
<label class="muted" for="provision-count">nodes</label> <h1>Fleet Control</h1>
<input id="provision-count" type="number" min="1" max="8" value="1" style="width: 3.5em; background:#0f172a; color:#e2e8f0; border:1px solid #334155; border-radius:6px; padding:5px 8px;"> <div class="counter" id="counter" hidden>
<button id="provision">+ provision</button> <span class="seg" id="ready-seg" data-ghost="8"><span class="seg-v" id="ready">0</span></span>
<button id="remove">− remove</button> <span class="meta">/</span>
<span id="status" class="muted"></span> <span class="seg" id="desired-seg" data-ghost="8"><span class="seg-v" id="desired">0</span></span>
<span class="muted">ready</span>
<span class="meta">· <span id="converged">—</span> · gen <span class="seg seg-sm" id="gen-seg" data-ghost="8"><span class="seg-v" id="gen">0</span></span></span>
</div>
</div>
<div class="controls">
<label class="muted" for="provision-count">nodes</label>
<input id="provision-count" type="number" min="1" max="8" value="1">
<button id="provision" class="primary">+ provision</button>
<button id="remove">− remove</button>
<span id="status" class="muted" role="status" aria-live="polite"></span>
</div> </div>
</header>
<section class="panel"> <section class="panel">
<table> <table>
<thead> <thead>
<tr><th>node</th><th>pid</th><th>state</th><th>seen</th><th></th></tr> <tr><th>node</th><th>stage</th><th>state</th><th>pid</th><th>seen</th><th></th></tr>
</thead> </thead>
<tbody id="rows"></tbody> <tbody id="rows"></tbody>
</table> </table>
<p class="muted" id="empty" hidden>No provisioned processes observed yet.</p> <p class="muted" id="empty" hidden>No provisioned nodes observed yet.</p>
</section> </section>
<details class="activity">
<summary>Activity — commands out · events in · transitions</summary>
<div class="feeds">
<div class="feed">
<h2>Commands out</h2>
<ul id="commands"></ul>
</div>
<div class="feed">
<h2>Events in · transitions · control</h2>
<ul id="events"></ul>
</div>
</div>
</details>
<script> <script>
'use strict'; 'use strict';
const API_URL = '/api/view/demo-control'; const CONTROL_URL = '/api/view/demo-control';
const RECONCILER_URL = '/api/view/reconciler';
const STAGE_CLASS = {
New: 'b-destroyed', LeaseRequested: 'b-progress', LeaseCreated: 'b-progress',
EndpointKnown: 'b-progress', BootstrapRunning: 'b-progress',
SwactorJoined: 'b-progress', HandedOff: 'b-ready', Dormant: 'b-ready',
Failed: 'b-failed', Destroyed: 'b-destroyed'
};
const INTENT_DELETING = new Set(['Deleting']);
const rows = document.getElementById('rows'); const rows = document.getElementById('rows');
const empty = document.getElementById('empty'); const empty = document.getElementById('empty');
const status = document.getElementById('status'); const status = document.getElementById('status');
const counter = document.getElementById('counter');
function setStatus(text, isError) { function setStatus(text, isError) {
status.textContent = text; status.textContent = text;
@ -67,30 +189,88 @@
} }
function escapeHtml(value) { function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (ch) => ({ return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[ch])); }[ch]));
} }
function fmtSeen(msAgo) { function fmtSeen(msAgo) {
if (msAgo == null || msAgo === Number.MAX_SAFE_INTEGER) return '—';
if (msAgo < 1000) return 'just now'; if (msAgo < 1000) return 'just now';
if (msAgo < 60_000) return Math.round(msAgo / 1000) + 's ago'; if (msAgo < 60_000) return Math.round(msAgo / 1000) + 's ago';
return Math.round(msAgo / 60_000) + 'm ago'; return Math.round(msAgo / 60_000) + 'm ago';
} }
function render(snapshot) { function fmtTime(ms) {
const nodes = (snapshot && snapshot.nodes) || []; if (!ms) return '--:--:--';
empty.hidden = nodes.length > 0; const date = new Date(ms);
rows.innerHTML = nodes.map((entry) => { return [date.getHours(), date.getMinutes(), date.getSeconds()]
const stateClass = entry.state === 'running' ? 'running' : entry.state; .map((part) => String(part).padStart(2, '0')).join(':');
}
function stageBadge(node) {
const stage = node.stage || 'New';
const deleting = INTENT_DELETING.has(node.intent);
const cls = deleting ? 'b-deleting' : (STAGE_CLASS[stage] || 'b-progress');
const sub = node.bootstrap ? `<span class="sub"> · ${escapeHtml(node.bootstrap)}</span>` : '';
return `<span class="badge ${cls}">${escapeHtml(stage)}${sub}</span>`;
}
// One row per logical node: reconciler state (stage, attempt, ready) fused
// with the observed process (state, pid, seen). Reconciler order leads;
// processes the reconciler no longer tracks still render.
function renderRow(entry) {
const reconciler = entry.reconciler;
const process = entry.process;
const node = reconciler ? reconciler.id : process.node;
const stage = reconciler
? stageBadge(reconciler)
: '<span class="muted">—</span>';
const state = process
? `<span class="pill ${escapeHtml(process.state)}">${escapeHtml(process.state)}</span>`
: '<span class="muted">—</span>';
const pid = process && process.pid != null ? process.pid : (reconciler && reconciler.pid != null ? reconciler.pid : null);
const seen = process ? fmtSeen(process.seen_ms_ago) : '—';
const attempt = reconciler ? `<div class="node-sub">attempt ${escapeHtml(reconciler.attempt)}${reconciler.ready ? ' · ready' : ''}</div>` : '';
const failure = reconciler && reconciler.failure
? `<div class="row-error">${escapeHtml(reconciler.failure)}</div>`
: '';
const killable = process && process.state === 'running';
return `<tr> return `<tr>
<td>${escapeHtml(entry.node)}</td> <td><span class="node-name">${escapeHtml(node)}</span>${attempt}</td>
<td>${entry.pid == null ? '—' : escapeHtml(entry.pid)}</td> <td>${stage}</td>
<td><span class="pill ${stateClass}">${escapeHtml(entry.state)}</span></td> <td>${state}</td>
<td class="muted">${escapeHtml(fmtSeen(entry.seen_ms_ago))}</td> <td>${pid == null ? '—' : escapeHtml(pid)}</td>
<td><button class="danger" data-node="${escapeHtml(entry.node)}" ${entry.state !== 'running' ? 'disabled' : ''}>kill</button></td> <td class="muted">${escapeHtml(seen)}</td>
</tr>`; <td><button class="danger" data-node="${escapeHtml(node)}" ${killable ? '' : 'disabled'}>kill</button></td>
}).join(''); </tr>${failure ? `<tr><td colspan="6" style="padding-top:0">${failure}</td></tr>` : ''}`;
}
function fuse(processes, reconcilerNodes) {
const byNode = new Map();
const order = [];
for (const node of reconcilerNodes || []) {
byNode.set(node.id, { reconciler: node, process: null });
order.push(node.id);
}
for (const process of processes || []) {
const entry = byNode.get(process.node);
if (entry) {
entry.process = process;
} else {
byNode.set(process.node, { reconciler: null, process });
order.push(process.node);
}
}
return order.map((id) => byNode.get(id));
}
function render(processSnapshot, reconcilerSnapshot) {
const processes = (processSnapshot && processSnapshot.nodes) || [];
const reconcilerNodes = (reconcilerSnapshot && reconcilerSnapshot.nodes) || [];
const fused = fuse(processes, reconcilerNodes);
empty.hidden = fused.length > 0;
rows.innerHTML = fused.map(renderRow).join('');
for (const button of rows.querySelectorAll('button[data-node]')) { for (const button of rows.querySelectorAll('button[data-node]')) {
button.addEventListener('click', () => { button.addEventListener('click', () => {
button.disabled = true; button.disabled = true;
@ -103,38 +283,73 @@
}).catch((error) => setStatus('kill failed: ' + error, true)); }).catch((error) => setStatus('kill failed: ' + error, true));
}); });
} }
if (reconcilerSnapshot) {
counter.hidden = false;
setSeg('ready-seg', reconcilerSnapshot.ready);
setSeg('desired-seg', reconcilerSnapshot.desired);
const converged = document.getElementById('converged');
converged.textContent = reconcilerSnapshot.converged ? 'converged' : 'converging…';
converged.className = reconcilerSnapshot.converged ? 'ok' : 'warn blink';
setSeg('gen-seg', reconcilerSnapshot.generation);
renderFeeds(reconcilerSnapshot.feed || []);
}
}
// Segment display: value over ghost eights; every unlit place stays drawn.
function setSeg(id, value) {
const text = String(value == null ? '—' : value);
const seg = document.getElementById(id);
seg.dataset.ghost = text === '—' ? '—' : '8'.repeat(text.length);
seg.querySelector('.seg-v').textContent = text;
}
function feedLine(line, filter) {
if (filter && line.kind !== filter) return '';
return `<li class="k-${escapeHtml(line.kind)}"><time>${fmtTime(line.at_ms)}</time>` +
`${line.node ? '[' + escapeHtml(line.node) + '] ' : ''}${escapeHtml(line.detail)}</li>`;
}
function renderFeeds(feed) {
const recent = feed.slice().reverse();
document.getElementById('commands').innerHTML =
recent.map((line) => feedLine(line, 'command')).join('') || '<li class="muted" >none</li>';
const eventKinds = new Set(['command']);
document.getElementById('events').innerHTML =
recent.filter((line) => !eventKinds.has(line.kind)).map((line) => feedLine(line)).join('')
|| '<li >none</li>';
} }
function nodeCount() { function nodeCount() {
return Math.max(1, Math.min(8, Number(document.getElementById('provision-count').value) || 1)); return Math.max(1, Math.min(8, Number(document.getElementById('provision-count').value) || 1));
} }
document.getElementById('provision').addEventListener('click', () => { function post(path, body, action) {
const count = nodeCount(); fetch(path, {
fetch('/control/provision', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ Provision: { count } }) body: JSON.stringify(body)
}).then((response) => { }).then((response) => {
setStatus(response.ok ? 'provision issued' : 'provision failed: HTTP ' + response.status, !response.ok); setStatus(response.ok ? action + ' issued' : action + ' failed: HTTP ' + response.status, !response.ok);
}).catch((error) => setStatus('provision failed: ' + error, true)); }).catch((error) => setStatus(action + ' failed: ' + error, true));
}
document.getElementById('provision').addEventListener('click', () => {
post('/control/provision', { Provision: { count: nodeCount() } }, 'provision');
}); });
document.getElementById('remove').addEventListener('click', () => { document.getElementById('remove').addEventListener('click', () => {
const count = nodeCount(); post('/control/remove', { Remove: { count: nodeCount() } }, 'remove');
fetch('/control/remove', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ Remove: { count } })
}).then((response) => {
setStatus(response.ok ? 'remove issued' : 'remove failed: HTTP ' + response.status, !response.ok);
}).catch((error) => setStatus('remove failed: ' + error, true));
}); });
async function poll() { async function poll() {
try { try {
const response = await fetch(API_URL); const [control, reconciler] = await Promise.allSettled([
if (response.ok) render(await response.json()); fetch(CONTROL_URL).then((response) => response.ok ? response.json() : null),
fetch(RECONCILER_URL).then((response) => response.ok ? response.json() : null)
]);
if (control.status === 'fulfilled' && control.value) {
render(control.value, reconciler.status === 'fulfilled' ? reconciler.value : null);
}
} catch { /* transient */ } } catch { /* transient */ }
} }
poll(); poll();

View file

@ -54,15 +54,29 @@ pub struct FrameEvent {
pub struct StreamEvent { pub struct StreamEvent {
pub node: String, pub node: String,
pub life: u64, pub life: u64,
/// Stream-descriptor metadata when the publisher knows it (catalog
/// truth). `None` on raw ingest paths that never saw a descriptor.
#[serde(skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
impl StreamEvent {
fn new(stream: &StreamId) -> Self {
Self {
node: stream.node.as_str().to_string(),
life: stream.life.0,
origin: None,
label: None,
}
}
} }
impl FrameEvent { impl FrameEvent {
pub fn new(stream: &StreamId, frame: &Frame) -> Self { pub fn new(stream: &StreamId, frame: &Frame) -> Self {
Self { Self {
stream: StreamEvent { stream: StreamEvent::new(stream),
node: stream.node.as_str().to_string(),
life: stream.life.0,
},
channel: frame.channel.to_string(), channel: frame.channel.to_string(),
position: frame.position.0, position: frame.position.0,
payload: frame.payload.clone(), payload: frame.payload.clone(),

View file

@ -130,6 +130,8 @@ mod tests {
stream: crate::StreamEvent { stream: crate::StreamEvent {
node: stream.node.as_str().to_string(), node: stream.node.as_str().to_string(),
life: stream.life.0, life: stream.life.0,
origin: None,
label: None,
}, },
channel: channel.to_owned(), channel: channel.to_owned(),
position, position,

View file

@ -5,10 +5,65 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Telemetry live explorer</title> <title>Telemetry live explorer</title>
<style> <style>
:root{color-scheme:dark;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#0f172a;color:#e2e8f0}body{margin:0;padding:18px}header{display:flex;justify-content:space-between;align-items:flex-end;gap:16px;margin-bottom:14px}h1{margin:0;font-size:26px}.muted{color:#94a3b8}.top{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px}.pill,button,input,select{background:#111827;color:#e2e8f0;border:1px solid #334155;border-radius:9px;padding:8px 10px}.pill{background:#1e293b;border-radius:999px;font-size:12px}button{cursor:pointer}button[data-active=true]{outline:2px solid #60a5fa;background:#172554}input{min-width:280px}.metrics{display:grid;grid-template-columns:repeat(5,minmax(110px,1fr));gap:10px;margin-bottom:12px}.grid{display:grid;grid-template-columns:1fr 420px;gap:12px}.card{background:#1e293b;border:1px solid #334155;border-radius:14px;padding:14px}.label{font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.08em}.value{font-size:23px;font-variant-numeric:tabular-nums}.table-wrap{max-height:740px;overflow:auto}table{width:100%;border-collapse:collapse;background:#111827;border:1px solid #334155;border-radius:12px;overflow:hidden}th,td{padding:8px 9px;border-bottom:1px solid #1f2937;text-align:left;vertical-align:top;font-variant-numeric:tabular-nums}th{background:#1e293b;color:#93c5fd;position:sticky;top:0}tbody tr{cursor:pointer}tr[data-active=true]{outline:2px solid #60a5fa;background:#172554}.tag{font-size:12px;border-radius:999px;padding:3px 8px;background:#334155}.json{color:#86efac}.text{color:#fbbf24}.binary{color:#c4b5fd}.ok{color:#34d399}.warn{color:#fbbf24}.bad{color:#f87171}.kv{display:grid;grid-template-columns:115px 1fr;gap:8px;border-bottom:1px solid #334155;padding:7px 0}pre{white-space:pre-wrap;word-break:break-word;background:#020617;border:1px solid #334155;border-radius:12px;padding:12px;max-height:560px;overflow:auto}.empty{border:1px dashed #475569;border-radius:12px;padding:22px;color:#94a3b8}section[hidden]{display:none}@media(max-width:1100px){.grid{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}input{min-width:0;width:100%}} :root{color-scheme:dark;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--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;--selected:rgba(255,153,0,.10);--selected-edge:#ff9900;--row-hover:#002a47;--r:2px;--t:120ms;background:var(--bg);color:var(--text)}
.nav{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;background:#1e293b;border:1px solid #334155;border-radius:14px;padding:8px}.nav a{color:#e2e8f0;text-decoration:none;border:1px solid transparent;border-radius:9px;padding:7px 10px}.nav a[data-active="true"]{background:#172554;border-color:#60a5fa} :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;--selected:rgba(64,120,242,.08);--selected-edge:#4078f2;--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;--selected:rgba(64,120,242,.08);--selected-edge:#4078f2;--row-hover:#eef1f8}}
*{box-sizing:border-box}
body{margin:0;padding:20px}
header{display:flex;justify-content:space-between;align-items:flex-end;gap:16px;margin-bottom:16px}
h1{margin:0;font-size:20px}
h2{margin:0 0 8px;font-size:16px}
.muted{color:var(--muted)}
.top{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
button,input,select{font:600 13px var(--mono);color:var(--text);background:var(--inset);border:1px solid var(--border);border-radius:var(--r);padding:6px 10px}
button{cursor:pointer;transition:background var(--t),border-color var(--t)}
button:hover{background:var(--panel-hover);border-color:var(--cyan)}
button:active{transform:translateY(1px)}
button[aria-pressed="true"]{color:var(--amber);background:var(--selected);border-color:var(--amber)}
input:hover,select:hover{border-color:var(--cyan)}
input{min-width:280px;font-weight:400}
:is(a,button,input,select,summary):focus-visible,tr[data-stream]:focus-visible,tr[data-channel]:focus-visible,tr[data-frame]:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
.pill{display:inline-flex;padding:3px 8px;background:transparent;border:1px solid var(--muted);border-radius:var(--r);font-size:11px;font-family:var(--mono);text-transform:uppercase;letter-spacing:.06em}
.pill.ok{color:var(--ok);border-color:var(--ok)}
.pill.warn{color:var(--amber);border-color:var(--amber)}
.pill.bad{color:var(--bad);border-color:var(--bad)}
.metrics{display:grid;grid-template-columns:repeat(5,minmax(110px,1fr));gap:12px;margin-bottom:12px}
.grid{display:grid;grid-template-columns:1fr 420px;gap:12px}
.card{background:var(--panel);border:1px solid var(--border);border-radius:var(--r);padding:14px}
.label{font:600 10px var(--mono);color:var(--amber);text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px}
.seg{position:relative;display:inline-flex;justify-content:flex-end;font:700 20px/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}
.value{font:700 20px/1 var(--mono);color:var(--amber);font-variant-numeric:tabular-nums}
.table-wrap{max-height:740px;overflow:auto}
table{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:12px}
th,td{padding:8px 9px;border-bottom:1px solid var(--divider);text-align:left;vertical-align:top;font-variant-numeric:tabular-nums}
th{background:var(--panel);color:var(--amber);font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.08em;position:sticky;top:0}
tbody tr[data-stream],tbody tr[data-channel],tbody tr[data-frame]{cursor:pointer;transition:background var(--t)}
tbody tr[data-stream]:hover,tbody tr[data-channel]:hover,tbody tr[data-frame]:hover{background:var(--row-hover)}
tr[data-active="true"]{background:var(--selected);box-shadow:inset 2px 0 0 var(--selected-edge)}
.tag{font-size:10px;border-radius:var(--r);padding:3px 6px;border:1px solid var(--muted);text-transform:uppercase;letter-spacing:.05em}
.tag.json{color:var(--ok);border-color:var(--ok)}
.tag.text{color:var(--amber);border-color:var(--amber)}
.tag.binary{color:var(--magenta);border-color:var(--magenta)}
.json{color:var(--ok)}.text{color:var(--amber)}.binary{color:var(--magenta)}
.ok{color:var(--ok)}.warn{color:var(--amber)}.bad{color:var(--bad)}
.kv{display:grid;grid-template-columns:115px 1fr;gap:8px;border-bottom:1px solid var(--divider);padding:7px 0;font-size:12px;font-family:var(--mono)}
pre{white-space:pre-wrap;word-break:break-word;font-family:var(--mono);background:var(--inset);border:1px solid var(--border);border-radius:var(--r);padding:12px;max-height:560px;overflow:auto;font-size:12px}
.empty{border:1px dashed var(--border);border-radius:var(--r);padding:22px;color:var(--muted);font-family:var(--mono);font-size:12px}
section[hidden]{display:none}
@media(max-width:1100px){.grid{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}input{min-width:0;width:100%}}
@media(prefers-reduced-motion:reduce){*{transition-duration:0.01ms!important;animation:none!important}}
</style> </style>
</head> </head>
<!--
swactor dashboard — direction contract (impeccable seed 8b5f2278)
THESIS: a numeric instrument bank for a live actor fleet; Bloomberg night housing (dark) / Atom One Light bench card (light); absence drawn as ghost segments, never blank.
OWN-WORLD: black-on-navy or paper-on-white; amber owns the data register, green/red are law, cyan is the only interactive voice; 2px corners, monospace data, machined caps labels, controls that depress.
FIRST VIEWPORT: the telemetry bench — ghost-segment counters over the frame table, channel kinds as colored tags, payload inspection in an inset well.
SIGNATURE: counters tick by instant segment swap; ghost eights hold every unlit place.
RISK: terminal cosplay if decoration creeps past data; held by the palette law.
-->
<body> <body>
<!--swactor:nav--> <!--swactor:nav-->
<header> <header>
@ -16,15 +71,14 @@
<h1>Telemetry live explorer</h1> <h1>Telemetry live explorer</h1>
<div class="muted">Auto-connected. Retained channel history is restored from /api/view/telemetry/live, then live frames continue from /events.</div> <div class="muted">Auto-connected. Retained channel history is restored from /api/view/telemetry/live, then live frames continue from /events.</div>
</div> </div>
<span class="pill warn" id="status">starting</span> <span class="pill warn" id="status" role="status" aria-live="polite">starting</span>
</header> </header>
<section class="top"> <section class="top">
<button data-level="streams">Streams</button> <button data-level="streams" aria-pressed="true">Streams</button>
<button data-level="channels">Channels</button> <button data-level="channels" aria-pressed="false">Channels</button>
<button data-level="frames">Frames</button> <button data-level="frames" aria-pressed="false">Frames</button>
<button id="clearScope">all streams</button> <button id="clearScope" aria-label="Reset scope to all streams">all streams</button>
<input id="filter" placeholder="filter visible rows" spellcheck="false"> <input id="filter" aria-label="Filter visible rows" placeholder="filter visible rows" spellcheck="false">
<select id="kind"><option value="">all payloads</option><option value="json">json</option><option value="text">text</option><option value="binary">binary</option></select> <select id="kind"><option value="">all payloads</option><option value="json">json</option><option value="text">text</option><option value="binary">binary</option></select>
<label class="muted" for="limit">keep/channel</label> <label class="muted" for="limit">keep/channel</label>
<select id="limit"><option>100</option><option selected>500</option><option>1000</option><option>2000</option></select> <select id="limit"><option>100</option><option selected>500</option><option>1000</option><option>2000</option></select>
@ -32,10 +86,10 @@
</section> </section>
<section class="metrics"> <section class="metrics">
<div class="card"><div class="label">frames</div><div class="value" id="metricFrames">0</div></div> <div class="card"><div class="label">frames</div><span class="seg" id="metricFramesSeg" data-ghost="8"><span class="seg-v" id="metricFrames">0</span></span></div>
<div class="card"><div class="label">streams</div><div class="value" id="metricStreams">0</div></div> <div class="card"><div class="label">streams</div><span class="seg" id="metricStreamsSeg" data-ghost="8"><span class="seg-v" id="metricStreams">0</span></span></div>
<div class="card"><div class="label">channels</div><div class="value" id="metricChannels">0</div></div> <div class="card"><div class="label">channels</div><span class="seg" id="metricChannelsSeg" data-ghost="8"><span class="seg-v" id="metricChannels">0</span></span></div>
<div class="card"><div class="label">json</div><div class="value" id="metricJson">0</div></div> <div class="card"><div class="label">json</div><span class="seg" id="metricJsonSeg" data-ghost="8"><span class="seg-v" id="metricJson">0</span></span></div>
<div class="card"><div class="label">scope</div><div class="value" id="metricScope">all</div></div> <div class="card"><div class="label">scope</div><div class="value" id="metricScope">all</div></div>
</section> </section>
@ -146,6 +200,7 @@
function createStream(stream) { function createStream(stream) {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.dataset.stream = stream.key; row.dataset.stream = stream.key;
row.tabIndex = 0;
row.innerHTML = '<td></td><td></td><td></td>'; row.innerHTML = '<td></td><td></td><td></td>';
stream.cells = { key: row.children[0], frames: row.children[1], channels: row.children[2] }; stream.cells = { key: row.children[0], frames: row.children[1], channels: row.children[2] };
stream.row = row; stream.row = row;
@ -157,6 +212,7 @@
const row = document.createElement('tr'); const row = document.createElement('tr');
row.dataset.stream = channel.stream; row.dataset.stream = channel.stream;
row.dataset.channel = channel.channel; row.dataset.channel = channel.channel;
row.tabIndex = 0;
row.innerHTML = '<td></td><td></td><td></td><td></td><td></td><td></td><td></td>'; row.innerHTML = '<td></td><td></td><td></td><td></td><td></td><td></td><td></td>';
channel.cells = { stream: row.children[0], channel: row.children[1], frames: row.children[2], json: row.children[3], text: row.children[4], binary: row.children[5], latest: row.children[6] }; channel.cells = { stream: row.children[0], channel: row.children[1], frames: row.children[2], json: row.children[3], text: row.children[4], binary: row.children[5], latest: row.children[6] };
channel.row = row; channel.row = row;
@ -285,13 +341,22 @@
const status = state.error ? 'error' : state.connected ? 'live' : 'connecting'; const status = state.error ? 'error' : state.connected ? 'live' : 'connecting';
nodes.status.textContent = status; nodes.status.textContent = status;
nodes.status.className = `pill ${status === 'live' ? 'ok' : status === 'error' ? 'bad' : 'warn'}`; nodes.status.className = `pill ${status === 'live' ? 'ok' : status === 'error' ? 'bad' : 'warn'}`;
nodes.metricFrames.textContent = `${state.totalFrames} (${frameLimit()}/channel)`; setSeg('metricFramesSeg', state.totalFrames);
nodes.metricStreams.textContent = state.streams.size; setSeg('metricStreamsSeg', state.streams.size);
nodes.metricChannels.textContent = state.channels.size; setSeg('metricChannelsSeg', state.channels.size);
nodes.metricJson.textContent = state.jsonFrames; setSeg('metricJsonSeg', state.jsonFrames);
nodes.metricScope.textContent = selectedScopeText(); nodes.metricScope.textContent = selectedScopeText();
} }
// Segment display: value over ghost eights; every unlit place stays drawn.
function setSeg(id, value) {
const seg = document.getElementById(id);
if (!seg) return;
const text = String(value == null ? '—' : value);
seg.dataset.ghost = text === '—' ? '—' : '8'.repeat(Math.max(1, text.length));
seg.querySelector('.seg-v').textContent = text;
}
function updateStreamRow(stream) { function updateStreamRow(stream) {
if (!stream) return; if (!stream) return;
stream.cells.frames.textContent = stream.frames; stream.cells.frames.textContent = stream.frames;
@ -332,6 +397,7 @@
function createFrameRow(frame) { function createFrameRow(frame) {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.dataset.frame = frame.id; row.dataset.frame = frame.id;
row.tabIndex = 0;
row.innerHTML = `<td>${frame.position}</td><td><span class="tag ${frame.kind}">${frame.kind}</span></td><td>${html(frame.preview)}</td><td>${frame.bytes}</td>`; row.innerHTML = `<td>${frame.position}</td><td><span class="tag ${frame.kind}">${frame.kind}</span></td><td>${html(frame.preview)}</td><td>${frame.bytes}</td>`;
row.dataset.active = String(state.selectedFrame === frame.id); row.dataset.active = String(state.selectedFrame === frame.id);
return row; return row;
@ -386,7 +452,7 @@
function selectLevel(level) { function selectLevel(level) {
state.level = level; state.level = level;
for (const button of document.querySelectorAll('[data-level]')) button.dataset.active = String(button.dataset.level === level); for (const button of document.querySelectorAll('[data-level]')) button.setAttribute('aria-pressed', String(button.dataset.level === level));
nodes.streamsPanel.hidden = level !== 'streams'; nodes.streamsPanel.hidden = level !== 'streams';
nodes.channelsPanel.hidden = level !== 'channels'; nodes.channelsPanel.hidden = level !== 'channels';
nodes.framesPanel.hidden = level !== 'frames'; nodes.framesPanel.hidden = level !== 'frames';
@ -535,6 +601,21 @@
if (row) selectChannel(row.dataset.stream, row.dataset.channel); if (row) selectChannel(row.dataset.stream, row.dataset.channel);
}); });
nodes.streamsBody.addEventListener('keydown', event => {
const row = event.target.closest('tr[data-stream]');
if (row && event.key === 'Enter') selectStream(row.dataset.stream);
});
nodes.channelsBody.addEventListener('keydown', event => {
const row = event.target.closest('tr[data-channel]');
if (row && event.key === 'Enter') selectChannel(row.dataset.stream, row.dataset.channel);
});
nodes.framesBody.addEventListener('keydown', event => {
const row = event.target.closest('tr[data-frame]');
if (row && event.key === 'Enter') selectFrame(row.dataset.frame);
});
nodes.framesBody.addEventListener('click', event => { nodes.framesBody.addEventListener('click', event => {
const row = event.target.closest('tr[data-frame]'); const row = event.target.closest('tr[data-frame]');
if (row) selectFrame(row.dataset.frame); if (row) selectFrame(row.dataset.frame);

View file

@ -139,15 +139,29 @@ fn inject_nav(html: &str, active_path: &str, state: &AppState) -> String {
} }
let nav = format!( let nav = format!(
concat!( concat!(
r#"<script>try{{var t=localStorage.getItem('swactor-theme');document.documentElement.dataset.theme=t||(matchMedia('(prefers-color-scheme: light)').matches?'light':'dark')}}catch(e){{}}</script>"#,
r#"<style>"#, r#"<style>"#,
r#".sw-nav{{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 16px;padding:8px;"#, r#".sw-nav{{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0 0 16px;padding:8px;"#,
r#"background:#111827;border:1px solid #334155;border-radius:12px;}}"#, r#"background:#001220;border:1px solid #14406a;border-radius:2px;}}"#,
r#".sw-nav a{{padding:7px 10px;color:#cbd5e1;border:1px solid transparent;"#, r#".sw-nav a{{padding:6px 10px;color:#c7d3de;border:1px solid transparent;border-bottom:2px solid transparent;"#,
r#"border-radius:8px;text-decoration:none;font-size:14px;}}"#, r#"border-radius:2px;text-decoration:none;font:600 13px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;"#,
r#".sw-nav a:hover{{color:#f8fafc;background:#1e293b;}}"#, r#"transition:color .12s,background .12s,border-color .12s;}}"#,
r#".sw-nav a[data-active="true"]{{color:#eff6ff;background:#172554;border-color:#60a5fa;}}"#, r#".sw-nav a:hover{{color:#ffffff;background:#001c38;}}"#,
r#".sw-nav a:focus-visible{{outline:2px solid #5cd5ff;outline-offset:2px;}}"#,
r#".sw-nav a[data-active="true"]{{color:#ff9900;border-bottom-color:#ff9900;}}"#,
r#".sw-nav .sw-theme{{margin-left:auto;cursor:pointer;background:transparent;border:1px solid #14406a;"#,
r#"color:#c7d3de;border-radius:2px;font:600 13px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;padding:6px 10px;}}"#,
r#".sw-nav .sw-theme:hover{{color:#ffffff;background:#001c38;border-color:#5cd5ff;}}"#,
r#":root[data-theme="light"] .sw-nav{{background:#ffffff;border-color:#dcdce0;}}"#,
r#":root[data-theme="light"] .sw-nav a{{color:#696c77;}}"#,
r#":root[data-theme="light"] .sw-nav a:hover{{color:#383a42;background:#f0f0f0;}}"#,
r#":root[data-theme="light"] .sw-nav a[data-active="true"]{{color:#986801;border-bottom-color:#986801;}}"#,
r#":root[data-theme="light"] .sw-nav .sw-theme{{border-color:#dcdce0;color:#696c77;}}"#,
r#":root[data-theme="light"] .sw-nav .sw-theme:hover{{color:#383a42;background:#f0f0f0;border-color:#0184bc;}}"#,
r#"</style>"#, r#"</style>"#,
r#"<nav class="sw-nav" aria-label="Dashboard views">{}</nav>"#, r#"<nav class="sw-nav" aria-label="Dashboard views">{}"#,
r#"<button class="sw-theme" aria-label="Toggle light or dark theme" title="Toggle light or dark theme""#,
r#" onclick="var d=document.documentElement;d.dataset.theme=d.dataset.theme==='light'?'dark':'light';try{{localStorage.setItem('swactor-theme',d.dataset.theme)}}catch(e){{}}">◐</button></nav>"#,
), ),
links.join("") links.join("")
); );

View file

@ -40,6 +40,10 @@ use crate::provisioning_demo::provider::{
pub struct SupervisorTelemetry { pub struct SupervisorTelemetry {
pub endpoint: TelemetryEndpoint, pub endpoint: TelemetryEndpoint,
pub producer: TelemetryProducer, 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<telemetry::ChannelId, String>, names: BTreeMap<telemetry::ChannelId, String>,
} }
@ -62,10 +66,11 @@ impl SupervisorTelemetry {
Self { Self {
endpoint, endpoint,
producer, producer,
origin: "orchestrator",
label: "provisioning supervisor",
names: BTreeMap::new(), names: BTreeMap::new(),
} }
} }
pub fn register(&mut self, name: &str) -> telemetry::ChannelId { pub fn register(&mut self, name: &str) -> telemetry::ChannelId {
let id = self.endpoint.register_channel( let id = self.endpoint.register_channel(
name, name,
@ -608,7 +613,14 @@ impl SupervisorActor {
.get(&frame.channel) .get(&frame.channel)
.cloned() .cloned()
.unwrap_or_else(|| format!("channel#{}", frame.channel.0)); .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<u64> = self.nodes.keys().copied().collect(); let attempts: Vec<u64> = self.nodes.keys().copied().collect();
for attempt in attempts { for attempt in attempts {
@ -626,22 +638,34 @@ impl SupervisorActor {
}) })
.map(|descriptor| descriptor.name.clone()) .map(|descriptor| descriptor.name.clone())
.unwrap_or_else(|| format!("channel#{}", frame.channel.0)); .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( fn publish_frame(
dashboard: &dashboard::DashboardHandle, dashboard: &dashboard::DashboardHandle,
stream: &telemetry::frame::StreamId, stream: &telemetry::frame::StreamId,
channel: &str, channel: &str,
frame: &telemetry::frame::Frame, frame: &telemetry::frame::Frame,
origin: &str,
label: &str,
) { ) {
dashboard.publish(dashboard::FrameEvent { dashboard.publish(dashboard::FrameEvent {
stream: dashboard::StreamEvent { stream: dashboard::StreamEvent {
node: stream.node.as_str().to_string(), node: stream.node.as_str().to_string(),
life: stream.life.0, life: stream.life.0,
origin: Some(origin.to_owned()),
label: Some(label.to_owned()),
}, },
channel: channel.to_owned(), channel: channel.to_owned(),
position: frame.position.0, position: frame.position.0,

View file

@ -7,10 +7,10 @@
//! re-exec of this binary as a real swactor runtime that joins the //! re-exec of this binary as a real swactor runtime that joins the
//! supervisor's iroh endpoint. //! supervisor's iroh endpoint.
//! //!
//! Humans watch `/view/reconciler` (k8s-style current-vs-desired, node //! Humans watch the Fleet Control view (reconciler current-vs-desired, node
//! stages, command/result feeds) and the fleet cards (per-node PID/state), //! stages, command/result feeds, kill/provision controls) and the fleet cards
//! kill nodes from the Fleet Control view or a shell, and watch the //! (per-node PID/state), kill nodes from Fleet Control or a shell, and watch
//! reconciler replace them for real. //! the reconciler replace them for real.
pub mod control; pub mod control;
pub mod feed; pub mod feed;
@ -224,7 +224,7 @@ fn run_supervisor(args: &[String]) -> Result<(), String> {
let cluster_driver = let cluster_driver =
ClusterDriver::new(shape, demo_retry_policy()).map_err(|e| format!("driver: {e}"))?; 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 spawner = EngineSpawner::new(engine.handle());
let executor = IdempotentEffectExecutor::new( let executor = IdempotentEffectExecutor::new(
DemoBackend { DemoBackend {
@ -294,9 +294,8 @@ fn run_supervisor(args: &[String]) -> Result<(), String> {
.expect("supervisor address slot set once"); .expect("supervisor address slot set once");
println!("provisioning-reconciler-demo: dashboard on http://localhost:{port}"); 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/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."); println!(" Ctrl-C to tear down.");
// Block until Ctrl-C (synchronous signal flag — the wait must not depend // 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); std::thread::sleep(TICK);
} }
// Best-effort drain window closed: children still alive (if any) are // Best-effort drain window closed: children still alive (if any) are
// killed by the process-tree teardown of the launching console, so exit // killed by the kernel parent-death signal armed in the node role.
// deterministically rather than risking a wedged teardown path. let _ = std::fs::remove_dir_all(&keys_dir);
std::process::exit(0); std::process::exit(0);
} }

View file

@ -230,10 +230,14 @@ impl ProvisionPlugin for DemoProvider {
} }
/// Per-node telemetry: one endpoint/producer per provisioned node so each /// 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 struct NodeTelemetry {
pub endpoint: TelemetryEndpoint, pub endpoint: TelemetryEndpoint,
pub producer: TelemetryProducer, pub producer: TelemetryProducer,
pub origin: &'static str,
pub label: String,
} }
impl NodeTelemetry { impl NodeTelemetry {
@ -252,7 +256,12 @@ impl NodeTelemetry {
16, 16,
); );
let producer = endpoint.producer(); let producer = endpoint.producer();
Self { endpoint, producer } Self {
endpoint,
producer,
origin: "remote_node",
label: format!("demo node {logical_node}"),
}
} }
} }

View file

@ -1,151 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Provisioning Reconciler</title>
<style>
:root {
color-scheme: dark;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f172a;
color: #e2e8f0;
}
* { 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: baseline; gap: 18px; flex-wrap: wrap; margin-bottom: 16px; }
h1 { font-size: 24px; margin: 0; }
.counter { font-size: 22px; font-weight: 700; }
.counter .ready { color: #34d399; }
.muted { color: #94a3b8; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 18px; }
.node-card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 12px 14px; }
.node-card h3 { margin: 0 0 6px; font-size: 15px; overflow-wrap: anywhere; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; border: 1px solid; }
.b-ready { color: #34d399; border-color: #065f46; background: #064e3b33; }
.b-progress { color: #60a5fa; border-color: #1e40af; background: #1e3a8a33; }
.b-failed { color: #f87171; border-color: #7f1d1d; background: #7f1d1d33; }
.b-deleting { color: #fbbf24; border-color: #78350f; background: #78350f33; }
.b-destroyed { color: #94a3b8; border-color: #334155; background: #1e293b66; }
.meta { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 8px; font-size: 12px; }
.feeds { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 900px) { .feeds { grid-template-columns: 1fr; } }
.feed { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 10px 12px; }
.feed h2 { margin: 0 0 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; }
.feed ul { list-style: none; margin: 0; padding: 0; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; max-height: 420px; overflow-y: auto; }
.feed li { padding: 3px 0; border-bottom: 1px solid #1e293b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.feed li time { color: #64748b; margin-right: 8px; }
.k-command { color: #93c5fd; }
.k-result { color: #86efac; }
.k-transition { color: #fbbf24; }
.k-control { color: #f0abfc; }
.k-observation { color: #fca5a5; }
.k-error { color: #f87171; font-weight: 700; }
</style>
</head>
<body>
<!--swactor:nav-->
<header>
<h1>Provisioning Reconciler</h1>
<div class="counter"><span class="ready" id="ready">0</span> / <span id="desired">0</span> <span class="muted">Ready</span></div>
<div class="muted">gen <span id="gen">0</span> · <span id="converged">—</span> · snapshot <span id="age">—</span> old</div>
</header>
<section class="grid" id="nodes"></section>
<section class="feeds">
<div class="feed">
<h2>Commands out</h2>
<ul id="commands"></ul>
</div>
<div class="feed">
<h2>Events in · transitions · control</h2>
<ul id="events"></ul>
</div>
</section>
<script>
'use strict';
const API_URL = '/api/view/reconciler';
const STAGE_CLASS = {
New: 'b-destroyed', LeaseRequested: 'b-progress', LeaseCreated: 'b-progress',
EndpointKnown: 'b-progress', BootstrapRunning: 'b-progress',
SwactorJoined: 'b-progress', HandedOff: 'b-ready', Dormant: 'b-ready',
Failed: 'b-failed', Destroyed: 'b-destroyed'
};
const INTENT_DELETING = new Set(['Deleting']);
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[ch]));
}
function fmtTime(ms) {
if (!ms) return '--:--:--';
const date = new Date(ms);
return [date.getHours(), date.getMinutes(), date.getSeconds()]
.map((part) => String(part).padStart(2, '0')).join(':');
}
function fmtAge(ms) {
if (ms < 1500) return ms + 'ms';
if (ms < 60_000) return Math.round(ms / 1000) + 's';
return Math.round(ms / 60_000) + 'm';
}
function renderNode(node) {
const stage = node.stage || 'New';
const deleting = INTENT_DELETING.has(node.intent);
const badgeClass = deleting ? 'b-deleting' : (STAGE_CLASS[stage] || 'b-progress');
const sub = node.bootstrap ? ` · ${escapeHtml(node.bootstrap)}` : '';
const failure = node.failure ? `<div class="k-error">${escapeHtml(node.failure)}</div>` : '';
return `<article class="node-card">
<h3>${escapeHtml(node.id)}</h3>
<span class="badge ${badgeClass}">${escapeHtml(stage)}${sub}</span>
<div class="meta">
<span class="muted">attempt ${escapeHtml(node.attempt)}</span>
${node.pid != null ? `<span class="muted">pid ${escapeHtml(node.pid)}</span>` : ''}
${node.ready ? '<span class="b-ready badge">ready</span>' : ''}
</div>
${failure}
</article>`;
}
function renderFeedLine(line, filter) {
if (filter && line.kind !== filter) return '';
return `<li class="k-${escapeHtml(line.kind)}"><time>${fmtTime(line.at_ms)}</time>` +
`${line.node ? '[' + escapeHtml(line.node) + '] ' : ''}${escapeHtml(line.detail)}</li>`;
}
function render(snapshot) {
if (!snapshot) return;
document.getElementById('ready').textContent = snapshot.ready;
document.getElementById('desired').textContent = snapshot.desired;
document.getElementById('gen').textContent = snapshot.generation;
document.getElementById('converged').textContent = snapshot.converged ? 'converged' : 'converging…';
document.getElementById('age').textContent = fmtAge(snapshot.age_ms);
document.getElementById('nodes').innerHTML =
(snapshot.nodes || []).map(renderNode).join('') ||
'<p class="muted">No nodes yet.</p>';
const feed = (snapshot.feed || []).slice().reverse();
document.getElementById('commands').innerHTML =
feed.map((line) => renderFeedLine(line, 'command')).join('') || '<li class="muted">none</li>';
const eventKinds = new Set(['command']);
document.getElementById('events').innerHTML =
feed.filter((line) => !eventKinds.has(line.kind))
.map((line) => renderFeedLine(line))
.join('') || '<li class="muted">none</li>';
}
async function poll() {
try {
const response = await fetch(API_URL);
if (response.ok) render(await response.json());
} catch { /* transient */ }
}
poll();
setInterval(poll, 1000);
</script>
</body>
</html>

View file

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