mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 00:04:54 +00:00
fipstop: TUI overhaul with render-snapshot harness and navigation model
Reworks the fipstop TUI across its rendering, the control read surface it draws from, and its interaction model, on a machine-verified base. Test infrastructure: - Add a ratatui TestBackend snapshot harness (testkit + snapshots modules) that renders any ui::draw_* into an in-memory Buffer from canned show_* JSON and asserts the text grid plus per-cell style. Layout, columns, alignment, labels, grouping, and colour are now checkable under cargo test; every render below ships a snapshot. Control read surface (each new field emitted byte-identically on the live and off-loop builders, published once from the tick, with schema fixtures regenerated and the parity asserts holding): - show_status: effective persistence (persistent || nsec.is_some()); root and is_root; and a per-configured-transport-type peer-count map in which idle-but-configured types stay visible at zero. - show_peers: per-peer effective_depth (depth + link_cost, the value evaluate_parent ranks on), null when unmeasured or coordless so fipstop never recomputes it. - show_tree: root_npub, resolved once daemon-side (self when root, an attested peer npub, or an identity-cache hit). - show_bloom: the last-actually-sent uptree filter fill ratio and subtree estimate, null for a root or before the first announce. - show_mmp: session-layer srtt, loss, and etx trend labels. Rendering: - Display a 6-byte non-UTF-8 TransportAddr as a colon-separated MAC at the type layer, so daemon logs, fipsctl, and JSON consumers all benefit; non-6-byte payloads stay bare hex. - Right-justify the Bloom Peer Filters numerics into aligned fixed-width columns, render the Routing panes through a kv_lines helper that shares one value column across a key-value group, and right-justify the Graphs by-peer summary columns. - Truncate an over-long peer name (the npub shown when no friendly name exists) in the Tree, Bloom, and MMP peer lists so it no longer runs into the next column. - Group the Peers table by role (parent, then STP children, then other) and render it as a full grouped view with styled group labels and blank separators; the selection stays a peer index and the cursor only ever lands on a peer row. Apply the same role grouping to the Tree and Bloom peer lists, joining each peer's role from the peers view by node address. - Show min in the Graphs plot titles, rest a steady non-zero metric on the baseline as a row of dots, render a genuine zero as an empty plot, and keep a distinct no-data placeholder. - Replace the metric-by-peer grid, which squeezed plots to nothing once peers overflowed, with a master/detail Graphs view: a scrollable per-peer summary list that expands (Enter) to a full-pane btop plot, with up/down to flip peer, n/N to switch statistic, m to cycle mode, and Esc to return. - Put inline colored trend arrows on the Link and Session MMP values (drawn only on a rising or falling trend, with a fixed blank slot when stable so the value columns stay aligned), via a shared helper. - Cycle column sorting on the Link MMP, Session MMP, and Graphs by-peer tables (one key cycles the active column, another toggles direction), with the active column marked in each table's header. - Render the new daemon-surfaced fields: the dashboard root line (a self-is-root marker, otherwise a truncated root hex), a transports-by-type line, and an "approx. mesh estimate" line; an effective_depth column and lines on the Peers, peer-detail, and Tree sites from the single daemon derivation, showing a dash placeholder when unmeasured rather than a misleading zero; the full Tree root hex plus an Npub line; and the Bloom uptree fill and subtree-estimate lines. Interaction model: - Add a declarative keybinding registry keyed by (Tab, UiMode) that both the context footer and the ? help overlay render from, so the two cannot drift; a test asserts every registry key has a dispatch handler. - Add a modal ? help overlay, and a context-aware footer that shows the current state's actions first, drops global hints when the terminal is narrow, and always keeps a Help affordance as the overflow path. - Generalize per-pane focus and scroll state on App, wired across the Tree, Filters, Routing, and MMP tabs (f cycles pane focus and the focused pane scrolls instead of clipping its overflow); on the MMP tab the column sort acts on the focused pane. Esc deselects the active row when no detail is open (detail-close still takes priority). - Add a Del-disconnect confirmation modal naming the peer, the only state-mutating action, issuing the control-socket disconnect on confirm and noting that the peer stays disconnected until manually reconnected.
This commit is contained in:
@@ -1521,6 +1521,29 @@ impl Node {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Per-configured-transport-type peer counts (`show_status`). Seed every
|
||||
// configured transport type at 0 so an idle-but-configured type stays
|
||||
// visible, then tally the peers whose active link rides that type.
|
||||
let mut transport_peer_counts: std::collections::BTreeMap<String, usize> =
|
||||
std::collections::BTreeMap::new();
|
||||
for id in self.transport_ids() {
|
||||
if let Some(handle) = self.get_transport(id) {
|
||||
transport_peer_counts
|
||||
.entry(handle.transport_type().name.to_string())
|
||||
.or_insert(0);
|
||||
}
|
||||
}
|
||||
for peer in self.peers() {
|
||||
if let Some(link) = self.get_link(&peer.link_id())
|
||||
&& let Some(handle) = self.get_transport(&link.transport_id())
|
||||
{
|
||||
*transport_peer_counts
|
||||
.entry(handle.transport_type().name.to_string())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let tree = self.tree_state();
|
||||
let snapshot = crate::control::snapshot::StatsSnapshot {
|
||||
history: std::sync::Arc::new(self.stats_history.clone()),
|
||||
estimated_mesh_size: self.estimated_mesh_size,
|
||||
@@ -1533,6 +1556,9 @@ impl Node {
|
||||
link_count: self.links.len(),
|
||||
transport_count: self.transports.len(),
|
||||
session_count: self.sessions.len(),
|
||||
root: *tree.root(),
|
||||
is_root: tree.is_root(),
|
||||
transport_peer_counts,
|
||||
peer_aliases: std::sync::Arc::new(self.peer_aliases.clone()),
|
||||
acl_status: self.peer_acl_status(),
|
||||
peer_meta: std::sync::Arc::new(peer_meta),
|
||||
@@ -1548,6 +1574,28 @@ impl Node {
|
||||
self.publish_entities_snapshot();
|
||||
}
|
||||
|
||||
/// Resolve the npub of the spanning-tree root for `show_tree`'s `root_npub`.
|
||||
///
|
||||
/// Resolution order: this node when it is root, then the root as a live
|
||||
/// authenticated peer (cryptographically attested npub), then the
|
||||
/// identity-cache, else `None`.
|
||||
pub(crate) fn resolve_root_npub(&self, tree: &crate::tree::TreeState) -> Option<String> {
|
||||
if tree.is_root() {
|
||||
return Some(self.npub());
|
||||
}
|
||||
let root_addr = tree.root();
|
||||
if let Some(peer) = self.get_peer(root_addr) {
|
||||
return Some(peer.npub());
|
||||
}
|
||||
for (addr, pubkey, _last_seen) in self.identity_cache_iter() {
|
||||
if addr == root_addr {
|
||||
let (xonly, _parity) = pubkey.x_only_public_key();
|
||||
return Some(crate::identity::encode_npub(&xonly));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Project the Category-D derived/routing/cache state into a
|
||||
/// [`RoutingSnapshot`](crate::control::snapshot::RoutingSnapshot) and
|
||||
/// publish it via `ArcSwap`, so `show_tree` / `show_bloom` / `show_cache`
|
||||
@@ -1597,9 +1645,11 @@ impl Node {
|
||||
})
|
||||
.collect();
|
||||
let parent_addr = my_coords.parent_id();
|
||||
let root_npub = self.resolve_root_npub(tree);
|
||||
let tree_view = snap::TreeView {
|
||||
my_node_addr: *tree.my_node_addr(),
|
||||
root: *tree.root(),
|
||||
root_npub,
|
||||
is_root: tree.is_root(),
|
||||
depth: my_coords.depth(),
|
||||
my_coords: my_coords.entries().iter().map(|e| e.node_addr).collect(),
|
||||
@@ -1632,12 +1682,30 @@ impl Node {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Uptree filter metrics: the last filter actually sent to the tree
|
||||
// parent (`record_sent_filter`), which is what the parent currently
|
||||
// holds for us. `None` for a root node (nothing sent uptree) or before
|
||||
// the first announce. The estimate is this node's whole subtree
|
||||
// (split-horizon), not the mesh.
|
||||
let (uptree_fill_ratio, uptree_estimated_count) = if tree.is_root() {
|
||||
(None, None)
|
||||
} else {
|
||||
match bloom.last_sent_filter(parent_addr) {
|
||||
Some(filter) => (
|
||||
Some(filter.fill_ratio()),
|
||||
filter.estimated_count(max_inbound_fpr),
|
||||
),
|
||||
None => (None, None),
|
||||
}
|
||||
};
|
||||
let bloom_view = snap::BloomView {
|
||||
own_node_addr: *self.node_addr(),
|
||||
is_leaf_only: self.is_leaf_only(),
|
||||
sequence: bloom.sequence(),
|
||||
leaf_dependents: bloom.leaf_dependents().iter().copied().collect(),
|
||||
peer_filters: bloom_peers,
|
||||
uptree_fill_ratio,
|
||||
uptree_estimated_count,
|
||||
};
|
||||
|
||||
// --- coord cache (show_cache, show_routing) ---
|
||||
@@ -1779,6 +1847,12 @@ impl Node {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Cold-start gate for effective_depth, mirroring `evaluate_parent`:
|
||||
// if any peer has an SRTT measurement, unmeasured peers are excluded
|
||||
// (their effective_depth is `None`); during cold start (no peer has
|
||||
// SRTT) every peer falls back to the default link cost of 1.0.
|
||||
let any_peer_has_srtt = self.peers().any(|p| p.has_srtt());
|
||||
|
||||
let peer_rows: Vec<snap::PeerRow> = self
|
||||
.peers()
|
||||
.map(|peer| {
|
||||
@@ -1815,6 +1889,17 @@ impl Node {
|
||||
.mmp()
|
||||
.map(|mmp| project_entity_mmp(&mmp.metrics, format!("{}", mmp.mode()), None));
|
||||
|
||||
// effective_depth = tree_depth + link_cost, the value
|
||||
// `evaluate_parent` ranks on. Computed only when the peer has
|
||||
// coords and passes the cold-start measurement gate.
|
||||
let effective_depth = peer.coords().and_then(|coords| {
|
||||
if any_peer_has_srtt && !peer.has_srtt() {
|
||||
None
|
||||
} else {
|
||||
Some(coords.depth() as f64 + peer.link_cost())
|
||||
}
|
||||
});
|
||||
|
||||
snap::PeerRow {
|
||||
node_addr,
|
||||
npub: peer.npub(),
|
||||
@@ -1832,6 +1917,7 @@ impl Node {
|
||||
transport_addr: peer.current_addr().map(|a| format!("{}", a)),
|
||||
link_info,
|
||||
tree_depth: peer.coords().map(|c| c.depth()),
|
||||
effective_depth,
|
||||
stats: snap::PeerLinkStats {
|
||||
packets_sent: stats.packets_sent,
|
||||
packets_recv: stats.packets_recv,
|
||||
@@ -2015,6 +2101,10 @@ impl Node {
|
||||
(Some(srtt), Some(setx)) => Some(setx * (1.0 + srtt / 100.0)),
|
||||
_ => None,
|
||||
};
|
||||
let trend = |dual: &crate::mmp::algorithms::DualEwma| {
|
||||
dual.initialized()
|
||||
.then(|| crate::control::queries::trend_label(dual.short(), dual.long()))
|
||||
};
|
||||
Some(snap::MmpSessionRow {
|
||||
remote: *addr,
|
||||
display_name: self.peer_display_name(addr),
|
||||
@@ -2026,6 +2116,11 @@ impl Node {
|
||||
smoothed_etx,
|
||||
srtt_ms,
|
||||
sqi,
|
||||
trends: snap::MmpSessionTrends {
|
||||
rtt_trend: trend(&metrics.rtt_trend),
|
||||
loss_trend: trend(&metrics.loss_trend),
|
||||
etx_trend: trend(&metrics.etx_trend),
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Reference in New Issue
Block a user