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:
parent
c5f991e8f1
commit
1926e73064
13 changed files with 660 additions and 417 deletions
|
|
@ -51,6 +51,8 @@ struct FusedNode {
|
|||
last_seen: Instant,
|
||||
hardware: NodeHardwareState,
|
||||
actors: RuntimeState,
|
||||
origin: Option<String>,
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
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<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)]
|
||||
|
|
@ -214,6 +219,10 @@ struct StreamKeySnapshot {
|
|||
key: String,
|
||||
node: String,
|
||||
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)]
|
||||
|
|
@ -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<String>,
|
||||
label: Option<String>,
|
||||
) -> 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<u8> {
|
||||
serde_json::json!({ "worker_id": worker, "actors": actors })
|
||||
.to_string()
|
||||
|
|
|
|||
|
|
@ -5,65 +5,120 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>swactor fleet</title>
|
||||
<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; }
|
||||
body { margin: 0; padding: 18px; }
|
||||
header { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
h1 { margin: 0; font-size: 24px; }
|
||||
h2 { margin: 22px 0 10px; font-size: 17px; }
|
||||
.muted { color: #94a3b8; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||
.totals { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 16px; }
|
||||
.totals .card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 10px 12px; }
|
||||
.totals .label { color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: .07em; }
|
||||
.totals .value { font-size: 20px; margin-top: 4px; font-variant-numeric: tabular-nums; }
|
||||
body { margin: 0; padding: 20px; }
|
||||
header { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
h1 { margin: 0; font-size: 20px; }
|
||||
h2 { margin: 20px 0 10px; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--amber); }
|
||||
.muted { color: var(--muted); }
|
||||
.mono { font-family: var(--mono); font-size: 12px; }
|
||||
#status { font-family: var(--mono); font-size: 13px; color: var(--amber); }
|
||||
a.node-card { color: inherit; text-decoration: none; display: block; }
|
||||
.node-card.orchestrator { grid-column: 1 / -1; border-color: var(--amber); background: var(--card-hover); }
|
||||
.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; }
|
||||
.node-card { background: #1e293b; border: 1px solid #334155; border-radius: 14px; padding: 14px; cursor: pointer; transition: border-color .12s; }
|
||||
.node-card:hover { border-color: #60a5fa; }
|
||||
.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: var(--amber); background: var(--card-hover); }
|
||||
.node-card[data-live="false"] { opacity: .55; }
|
||||
.node-card h3 { margin: 0 0 2px; font-size: 15px; word-break: break-all; }
|
||||
.node-card .meta { color: #94a3b8; font-size: 12px; margin-bottom: 8px; }
|
||||
.node-card h3 { margin: 0 0 2px; font: 700 13px/1.4 var(--mono); word-break: break-all; }
|
||||
.node-card .meta { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
|
||||
.bars { display: grid; gap: 5px; }
|
||||
.bar-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.bar-row .name { width: 92px; color: #94a3b8; }
|
||||
.bar-track { flex: 1; height: 8px; background: #111827; border-radius: 99px; overflow: hidden; }
|
||||
.bar-fill { height: 100%; border-radius: 99px; }
|
||||
.bar-fill.ok { background: #34d399; } .bar-fill.warn { background: #fbbf24; } .bar-fill.bad { background: #f87171; }
|
||||
.bar-row { display: flex; align-items: center; gap: 8px; font-family: var(--mono); font-size: 12px; }
|
||||
.bar-row .name { width: 92px; color: var(--muted); }
|
||||
.bar-track { flex: 1; height: 8px; background: var(--inset); border-radius: var(--r); overflow: hidden; }
|
||||
.bar-fill { height: 100%; }
|
||||
.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; }
|
||||
.pill { display: inline-flex; padding: 2px 8px; border-radius: 999px; border: 1px solid #334155; font-size: 11px; }
|
||||
.pill.live { color: #34d399; border-color: #065f46; }
|
||||
.pill.stale { color: #f87171; border-color: #7f1d1d; }
|
||||
.pill.poisoned { color: #f87171; border-weight: 600; }
|
||||
.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: var(--ok); border-color: var(--ok); }
|
||||
.pill.stale { color: var(--bad); border-color: var(--bad); }
|
||||
.pill.poisoned { color: var(--bad); border-color: var(--bad); font-weight: 700; }
|
||||
details.stale { margin-top: 18px; }
|
||||
details.stale summary { cursor: pointer; color: #94a3b8; padding: 8px 0; }
|
||||
.focus-back { display: inline-block; margin-bottom: 12px; color: #93c5fd; text-decoration: none; }
|
||||
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: var(--cyan); text-decoration: none; font-family: var(--mono); font-size: 12px; }
|
||||
.focus-back:hover { color: var(--amber); }
|
||||
.node-view { display: grid; gap: 14px; }
|
||||
.panel { background: #1e293b; border: 1px solid #334155; border-radius: 14px; padding: 14px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid #1f2937; font-variant-numeric: tabular-nums; }
|
||||
th { color: #93c5fd; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
tbody tr[data-addr] { cursor: pointer; }
|
||||
tbody tr[data-addr]:hover { background: #17255466; }
|
||||
tbody tr[data-selected="true"] { background: #17255488; }
|
||||
input[type=search] { background: #111827; color: #e2e8f0; border: 1px solid #334155; border-radius: 8px; padding: 7px 10px; width: 240px; max-width: 100%; }
|
||||
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r); padding: 14px; }
|
||||
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 var(--divider); font-variant-numeric: tabular-nums; }
|
||||
th { color: var(--amber); font-weight: 600; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
tbody tr[data-addr] { cursor: pointer; transition: background var(--t); }
|
||||
tbody tr[data-addr]:hover { background: var(--row-hover); }
|
||||
tbody tr[data-selected="true"] { background: var(--selected); box-shadow: inset 2px 0 0 var(--selected-edge); }
|
||||
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; }
|
||||
@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; }
|
||||
.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; }
|
||||
.receipts td { font-size: 12px; }
|
||||
.empty { padding: 24px; border: 1px dashed #475569; border-radius: 12px; color: #94a3b8; }
|
||||
.notice { font-size: 12px; color: #94a3b8; margin: 6px 0; }
|
||||
canvas { width: 100%; height: 64px; background: #111827; border: 1px solid #334155; border-radius: 10px; }
|
||||
.err { color: #f87171; }
|
||||
.receipts td { font-size: 11px; }
|
||||
.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: var(--muted); margin: 6px 0; }
|
||||
canvas { width: 100%; height: 64px; background: var(--inset); border: 1px solid var(--border); border-radius: var(--r); }
|
||||
.err { color: var(--bad); }
|
||||
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; animation: none !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<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-->
|
||||
<header>
|
||||
<h1>Fleet</h1>
|
||||
<span class="muted" id="status">loading…</span>
|
||||
<span class="muted" id="status" role="status" aria-live="polite">loading…</span>
|
||||
</header>
|
||||
|
||||
<div id="page">
|
||||
|
|
@ -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(`<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) {
|
||||
parts.push('<div class="empty">No telemetry streams yet. A live swactor runtime or host publisher will populate this page.</div>');
|
||||
} 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) {
|
||||
parts.push(`<details class="stale">
|
||||
<summary>Stale nodes (${stale.length}) — silent beyond the liveness window</summary>
|
||||
|
|
@ -182,33 +227,24 @@ function renderFleet(page, live, stale, totals) {
|
|||
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) {
|
||||
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> ` : '';
|
||||
return `<article class="node-card" data-stream="${esc(node.stream.key)}" data-live="${node.live ? 'true' : 'false'}">
|
||||
<h3>${esc(node.stream.node)} <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 · ${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>
|
||||
<div class="bars">
|
||||
<div class="bar-row"><span class="name">CPU</span>${bar(cpu)}<span class="num">${cpu == null ? '—' : fmt(cpu, 0) + '%'}</span></div>
|
||||
<div class="bar-row"><span class="name">GPU</span>${bar(gpu)}<span class="num">${gpu == null ? '—' : fmt(gpu) + '%'}</span></div>
|
||||
${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>` : ''}
|
||||
<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>
|
||||
</div>
|
||||
const origin = node.stream.origin || '';
|
||||
const isOrchestrator = origin === 'orchestrator';
|
||||
const role = isOrchestrator ? '<span class="role">orchestrator</span>' : '';
|
||||
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 `<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>` : ''}
|
||||
</article>`;
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function renderNode(page, node, live, stale) {
|
||||
|
|
@ -219,12 +255,12 @@ function renderNode(page, node, live, stale) {
|
|||
<div class="layout ${selectedActor ? 'with-dossier' : ''}">
|
||||
<div class="node-view">
|
||||
<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}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -307,7 +343,7 @@ function renderRoster(node) {
|
|||
}
|
||||
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>
|
||||
<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><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>
|
||||
|
|
@ -319,11 +355,13 @@ function renderRoster(node) {
|
|||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
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 = `<section class="panel dossier">
|
||||
<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>
|
||||
<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 class="kv" style="margin-top:10px">
|
||||
<span class="k">node</span><span>${esc(d.stream.node)}</span>
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.<node>.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.<node>.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;
|
||||
|
|
|
|||
|
|
@ -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; } }
|
||||
</style>
|
||||
</head>
|
||||
<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-->
|
||||
<header>
|
||||
<h1>Fleet Control</h1>
|
||||
<div>
|
||||
<label class="muted" for="provision-count">nodes</label>
|
||||
<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;">
|
||||
<button id="provision">+ provision</button>
|
||||
<button id="remove">− remove</button>
|
||||
<span id="status" class="muted"></span>
|
||||
<h1>Fleet Control</h1>
|
||||
<div class="counter" id="counter" hidden>
|
||||
<span class="seg" id="ready-seg" data-ghost="8"><span class="seg-v" id="ready">0</span></span>
|
||||
<span class="meta">/</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>
|
||||
</header>
|
||||
<section class="panel">
|
||||
<table>
|
||||
<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>
|
||||
<tbody id="rows"></tbody>
|
||||
</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>
|
||||
<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>
|
||||
'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 empty = document.getElementById('empty');
|
||||
const status = document.getElementById('status');
|
||||
const counter = document.getElementById('counter');
|
||||
|
||||
function setStatus(text, isError) {
|
||||
status.textContent = text;
|
||||
|
|
@ -67,30 +189,88 @@
|
|||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, (ch) => ({
|
||||
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
function fmtSeen(msAgo) {
|
||||
if (msAgo == null || msAgo === Number.MAX_SAFE_INTEGER) return '—';
|
||||
if (msAgo < 1000) return 'just now';
|
||||
if (msAgo < 60_000) return Math.round(msAgo / 1000) + 's ago';
|
||||
return Math.round(msAgo / 60_000) + 'm ago';
|
||||
}
|
||||
|
||||
function render(snapshot) {
|
||||
const nodes = (snapshot && snapshot.nodes) || [];
|
||||
empty.hidden = nodes.length > 0;
|
||||
rows.innerHTML = nodes.map((entry) => {
|
||||
const stateClass = entry.state === 'running' ? 'running' : entry.state;
|
||||
return `<tr>
|
||||
<td>${escapeHtml(entry.node)}</td>
|
||||
<td>${entry.pid == null ? '—' : escapeHtml(entry.pid)}</td>
|
||||
<td><span class="pill ${stateClass}">${escapeHtml(entry.state)}</span></td>
|
||||
<td class="muted">${escapeHtml(fmtSeen(entry.seen_ms_ago))}</td>
|
||||
<td><button class="danger" data-node="${escapeHtml(entry.node)}" ${entry.state !== 'running' ? 'disabled' : ''}>kill</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
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 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>
|
||||
<td><span class="node-name">${escapeHtml(node)}</span>${attempt}</td>
|
||||
<td>${stage}</td>
|
||||
<td>${state}</td>
|
||||
<td>${pid == null ? '—' : escapeHtml(pid)}</td>
|
||||
<td class="muted">${escapeHtml(seen)}</td>
|
||||
<td><button class="danger" data-node="${escapeHtml(node)}" ${killable ? '' : 'disabled'}>kill</button></td>
|
||||
</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]')) {
|
||||
button.addEventListener('click', () => {
|
||||
button.disabled = true;
|
||||
|
|
@ -103,38 +283,73 @@
|
|||
}).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() {
|
||||
return Math.max(1, Math.min(8, Number(document.getElementById('provision-count').value) || 1));
|
||||
}
|
||||
|
||||
document.getElementById('provision').addEventListener('click', () => {
|
||||
const count = nodeCount();
|
||||
fetch('/control/provision', {
|
||||
function post(path, body, action) {
|
||||
fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Provision: { count } })
|
||||
body: JSON.stringify(body)
|
||||
}).then((response) => {
|
||||
setStatus(response.ok ? 'provision issued' : 'provision failed: HTTP ' + response.status, !response.ok);
|
||||
}).catch((error) => setStatus('provision failed: ' + error, true));
|
||||
setStatus(response.ok ? action + ' issued' : action + ' failed: HTTP ' + response.status, !response.ok);
|
||||
}).catch((error) => setStatus(action + ' failed: ' + error, true));
|
||||
}
|
||||
|
||||
document.getElementById('provision').addEventListener('click', () => {
|
||||
post('/control/provision', { Provision: { count: nodeCount() } }, 'provision');
|
||||
});
|
||||
|
||||
document.getElementById('remove').addEventListener('click', () => {
|
||||
const count = nodeCount();
|
||||
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));
|
||||
post('/control/remove', { Remove: { count: nodeCount() } }, 'remove');
|
||||
});
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const response = await fetch(API_URL);
|
||||
if (response.ok) render(await response.json());
|
||||
const [control, reconciler] = await Promise.allSettled([
|
||||
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 */ }
|
||||
}
|
||||
poll();
|
||||
|
|
|
|||
|
|
@ -54,15 +54,29 @@ pub struct FrameEvent {
|
|||
pub struct StreamEvent {
|
||||
pub node: String,
|
||||
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 {
|
||||
pub fn new(stream: &StreamId, frame: &Frame) -> Self {
|
||||
Self {
|
||||
stream: StreamEvent {
|
||||
node: stream.node.as_str().to_string(),
|
||||
life: stream.life.0,
|
||||
},
|
||||
stream: StreamEvent::new(stream),
|
||||
channel: frame.channel.to_string(),
|
||||
position: frame.position.0,
|
||||
payload: frame.payload.clone(),
|
||||
|
|
|
|||
|
|
@ -130,6 +130,8 @@ mod tests {
|
|||
stream: crate::StreamEvent {
|
||||
node: stream.node.as_str().to_string(),
|
||||
life: stream.life.0,
|
||||
origin: None,
|
||||
label: None,
|
||||
},
|
||||
channel: channel.to_owned(),
|
||||
position,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,65 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Telemetry live explorer</title>
|
||||
<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%}}
|
||||
.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{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)}
|
||||
: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>
|
||||
</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>
|
||||
<!--swactor:nav-->
|
||||
<header>
|
||||
|
|
@ -16,15 +71,14 @@
|
|||
<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>
|
||||
<span class="pill warn" id="status">starting</span>
|
||||
<span class="pill warn" id="status" role="status" aria-live="polite">starting</span>
|
||||
</header>
|
||||
|
||||
<section class="top">
|
||||
<button data-level="streams">Streams</button>
|
||||
<button data-level="channels">Channels</button>
|
||||
<button data-level="frames">Frames</button>
|
||||
<button id="clearScope">all streams</button>
|
||||
<input id="filter" placeholder="filter visible rows" spellcheck="false">
|
||||
<button data-level="streams" aria-pressed="true">Streams</button>
|
||||
<button data-level="channels" aria-pressed="false">Channels</button>
|
||||
<button data-level="frames" aria-pressed="false">Frames</button>
|
||||
<button id="clearScope" aria-label="Reset scope to all streams">all streams</button>
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -32,10 +86,10 @@
|
|||
</section>
|
||||
|
||||
<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">streams</div><div class="value" id="metricStreams">0</div></div>
|
||||
<div class="card"><div class="label">channels</div><div class="value" id="metricChannels">0</div></div>
|
||||
<div class="card"><div class="label">json</div><div class="value" id="metricJson">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><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><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><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>
|
||||
</section>
|
||||
|
||||
|
|
@ -146,6 +200,7 @@
|
|||
function createStream(stream) {
|
||||
const row = document.createElement('tr');
|
||||
row.dataset.stream = stream.key;
|
||||
row.tabIndex = 0;
|
||||
row.innerHTML = '<td></td><td></td><td></td>';
|
||||
stream.cells = { key: row.children[0], frames: row.children[1], channels: row.children[2] };
|
||||
stream.row = row;
|
||||
|
|
@ -157,6 +212,7 @@
|
|||
const row = document.createElement('tr');
|
||||
row.dataset.stream = channel.stream;
|
||||
row.dataset.channel = channel.channel;
|
||||
row.tabIndex = 0;
|
||||
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.row = row;
|
||||
|
|
@ -285,13 +341,22 @@
|
|||
const status = state.error ? 'error' : state.connected ? 'live' : 'connecting';
|
||||
nodes.status.textContent = status;
|
||||
nodes.status.className = `pill ${status === 'live' ? 'ok' : status === 'error' ? 'bad' : 'warn'}`;
|
||||
nodes.metricFrames.textContent = `${state.totalFrames} (${frameLimit()}/channel)`;
|
||||
nodes.metricStreams.textContent = state.streams.size;
|
||||
nodes.metricChannels.textContent = state.channels.size;
|
||||
nodes.metricJson.textContent = state.jsonFrames;
|
||||
setSeg('metricFramesSeg', state.totalFrames);
|
||||
setSeg('metricStreamsSeg', state.streams.size);
|
||||
setSeg('metricChannelsSeg', state.channels.size);
|
||||
setSeg('metricJsonSeg', state.jsonFrames);
|
||||
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) {
|
||||
if (!stream) return;
|
||||
stream.cells.frames.textContent = stream.frames;
|
||||
|
|
@ -332,6 +397,7 @@
|
|||
function createFrameRow(frame) {
|
||||
const row = document.createElement('tr');
|
||||
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.dataset.active = String(state.selectedFrame === frame.id);
|
||||
return row;
|
||||
|
|
@ -386,7 +452,7 @@
|
|||
|
||||
function selectLevel(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.channelsPanel.hidden = level !== 'channels';
|
||||
nodes.framesPanel.hidden = level !== 'frames';
|
||||
|
|
@ -535,6 +601,21 @@
|
|||
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 => {
|
||||
const row = event.target.closest('tr[data-frame]');
|
||||
if (row) selectFrame(row.dataset.frame);
|
||||
|
|
|
|||
|
|
@ -139,15 +139,29 @@ fn inject_nav(html: &str, active_path: &str, state: &AppState) -> String {
|
|||
}
|
||||
let nav = format!(
|
||||
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#".sw-nav{{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 16px;padding:8px;"#,
|
||||
r#"background:#111827;border:1px solid #334155;border-radius:12px;}}"#,
|
||||
r#".sw-nav a{{padding:7px 10px;color:#cbd5e1;border:1px solid transparent;"#,
|
||||
r#"border-radius:8px;text-decoration:none;font-size:14px;}}"#,
|
||||
r#".sw-nav a:hover{{color:#f8fafc;background:#1e293b;}}"#,
|
||||
r#".sw-nav a[data-active="true"]{{color:#eff6ff;background:#172554;border-color:#60a5fa;}}"#,
|
||||
r#".sw-nav{{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0 0 16px;padding:8px;"#,
|
||||
r#"background:#001220;border:1px solid #14406a;border-radius:2px;}}"#,
|
||||
r#".sw-nav a{{padding:6px 10px;color:#c7d3de;border:1px solid transparent;border-bottom:2px solid transparent;"#,
|
||||
r#"border-radius:2px;text-decoration:none;font:600 13px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;"#,
|
||||
r#"transition:color .12s,background .12s,border-color .12s;}}"#,
|
||||
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#"<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("")
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<telemetry::ChannelId, String>,
|
||||
}
|
||||
|
||||
|
|
@ -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<u64> = 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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[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>
|
||||
|
|
@ -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::<Value>(&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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue