Files
fips/src/bin/fipstop/ui/helpers.rs
T
Johnathan Corgan 5fc2359432 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.
2026-06-12 23:05:58 +00:00

413 lines
14 KiB
Rust

use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders};
use serde_json::Value;
/// A bordered pane block with a title that highlights its border (cyan, bold
/// title) when `focused`, so the multi-pane focus model has a clear visual
/// indicator of which pane the scroll keys act on.
pub fn pane_block(title: &str, focused: bool) -> Block<'static> {
let block = Block::default()
.borders(Borders::ALL)
.title(title.to_string());
if focused {
block
.border_style(Style::default().fg(Color::Cyan))
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
} else {
block
}
}
/// Clamp a desired scroll offset to a pane's content so an over-scroll (e.g.
/// from End, which passes `u16::MAX`) rests at the last full screen rather than
/// scrolling past the content. `content_rows` is the total rendered line count
/// and `visible_rows` the pane's inner height.
pub fn clamp_scroll(offset: u16, content_rows: usize, visible_rows: usize) -> u16 {
let max = content_rows.saturating_sub(visible_rows) as u16;
offset.min(max)
}
/// Extract a string field from JSON, returning "-" if missing.
pub fn str_field<'a>(data: &'a Value, key: &str) -> &'a str {
data.get(key).and_then(|v| v.as_str()).unwrap_or("-")
}
/// Extract a u64 field from JSON, returning "-" if missing.
pub fn u64_field(data: &Value, key: &str) -> String {
data.get(key)
.and_then(|v| v.as_u64())
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into())
}
/// Truncate a hex string to the given length, adding "..." if truncated.
pub fn truncate_hex(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len.saturating_sub(3)])
}
}
/// Truncate a display name to a fixed visible width, appending an ellipsis when
/// it overflows, then pad to exactly `width` columns. Unlike a bare `{:<width}`
/// format this guarantees the field never exceeds `width`, so a long npub-style
/// name can't push past its column and butt against the next label. Counts and
/// pads by `char`, which is correct for the ASCII/BMP names the daemon emits.
pub fn truncate_name(s: &str, width: usize) -> String {
let len = s.chars().count();
if len <= width {
format!("{s:<width$}")
} else if width <= 1 {
"\u{2026}".chars().take(width).collect()
} else {
let head: String = s.chars().take(width - 1).collect();
format!("{head}\u{2026}")
}
}
/// Format bytes-per-second with engineering units (B/s, KB/s, MB/s, GB/s) and 3 significant digits.
pub fn format_throughput(bytes_per_sec: f64) -> String {
if bytes_per_sec < 0.0 {
return "0 B/s".into();
}
let (scaled, unit) = if bytes_per_sec < 1_000.0 {
(bytes_per_sec, "B/s")
} else if bytes_per_sec < 1_000_000.0 {
(bytes_per_sec / 1_000.0, "KB/s")
} else if bytes_per_sec < 1_000_000_000.0 {
(bytes_per_sec / 1_000_000.0, "MB/s")
} else {
(bytes_per_sec / 1_000_000_000.0, "GB/s")
};
let decimals = if scaled >= 100.0 {
0
} else if scaled >= 10.0 {
1
} else {
2
};
format!("{:.prec$} {unit}", scaled, prec = decimals)
}
/// Extract a nested f64 field and format as engineering-unit throughput.
pub fn nested_throughput(data: &Value, outer: &str, inner: &str) -> String {
data.get(outer)
.and_then(|o| o.get(inner))
.and_then(|v| v.as_f64())
.map(format_throughput)
.unwrap_or_else(|| "-".into())
}
/// Format a byte count as human-readable (B, KB, MB, GB).
pub fn format_bytes(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes}B")
} else if bytes < 1024 * 1024 {
format!("{:.1}KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.2}GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
/// Format millisecond timestamp as relative duration from now (e.g., "3.2s ago").
pub fn format_elapsed_ms(ms: u64) -> String {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
if ms == 0 || ms > now_ms {
return "-".into();
}
let elapsed = now_ms - ms;
if elapsed < 1000 {
format!("{elapsed}ms")
} else if elapsed < 60_000 {
format!("{:.1}s", elapsed as f64 / 1000.0)
} else if elapsed < 3_600_000 {
format!("{:.1}m", elapsed as f64 / 60_000.0)
} else {
format!("{:.1}h", elapsed as f64 / 3_600_000.0)
}
}
/// Get a nested string field.
pub fn nested_str(data: &Value, outer: &str, inner: &str) -> String {
data.get(outer)
.and_then(|o| o.get(inner))
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string()
}
/// Get a nested field value (e.g., "stats.packets_sent").
pub fn nested_u64(data: &Value, outer: &str, inner: &str) -> String {
data.get(outer)
.and_then(|o| o.get(inner))
.and_then(|v| v.as_u64())
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into())
}
/// Get a nested f64 field formatted to given decimal places.
pub fn nested_f64(data: &Value, outer: &str, inner: &str, decimals: usize) -> String {
data.get(outer)
.and_then(|o| o.get(inner))
.and_then(|v| v.as_f64())
.map(|n| format!("{:.prec$}", n, prec = decimals))
.unwrap_or_else(|| "-".into())
}
/// Get a nested f64 field, preferring `preferred` key with fallback to `fallback` key.
pub fn nested_f64_prefer(
data: &Value,
outer: &str,
preferred: &str,
fallback: &str,
decimals: usize,
) -> String {
data.get(outer)
.and_then(|o| o.get(preferred).or_else(|| o.get(fallback)))
.and_then(|v| v.as_f64())
.map(|n| format!("{:.prec$}", n, prec = decimals))
.unwrap_or_else(|| "-".into())
}
/// Format an optional numeric field as a fixed-precision number, or an em-dash
/// placeholder when the value is JSON `null` or the key is absent. Used for
/// daemon-emitted `Option<f64>` fields (e.g. `effective_depth`) so an
/// unmeasured value renders distinctly from a real zero.
pub fn opt_f64_field(data: &Value, key: &str, decimals: usize) -> String {
match data.get(key).and_then(|v| v.as_f64()) {
Some(n) => format!("{:.prec$}", n, prec = decimals),
None => "\u{2014}".into(),
}
}
/// Extract a bool field from JSON, returning "yes"/"no" or "-" if missing.
pub fn bool_field(data: &Value, key: &str) -> &'static str {
data.get(key)
.and_then(|v| v.as_bool())
.map(|b| if b { "yes" } else { "no" })
.unwrap_or("-")
}
/// Format a duration in milliseconds as compact string (e.g., "42ms", "3.2s", "5.0m").
pub fn format_duration_ms(ms: u64) -> String {
if ms < 1000 {
format!("{ms}ms")
} else if ms < 60_000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else if ms < 3_600_000 {
format!("{:.1}m", ms as f64 / 60_000.0)
} else {
format!("{:.1}h", ms as f64 / 3_600_000.0)
}
}
/// Section header line for detail views.
pub fn section_header(title: &str) -> Line<'static> {
Line::from(Span::styled(
format!(" {title}"),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
}
/// Key-value line for detail views.
pub fn kv_line(key: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {key}: "), Style::default().fg(Color::DarkGray)),
Span::raw(value.to_string()),
])
}
/// Render a group of key-value pairs with the keys padded to a common
/// width so the values share a single left edge. Alignment is computed
/// once over the whole group rather than padded per call site, keeping
/// the convention (one aligned value column per stack) in one place.
pub fn kv_lines(pairs: &[(&str, String)]) -> Vec<Line<'static>> {
let key_width = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
pairs
.iter()
.map(|(key, value)| {
Line::from(vec![
Span::styled(
format!(" {key:<key_width$}: "),
Style::default().fg(Color::DarkGray),
),
Span::raw(value.clone()),
])
})
.collect()
}
/// Build a node-address -> (is_parent, is_child) map from the peers view's
/// `peers` array. Only the peers view carries the tree-role flags, so the Tree
/// and Bloom surfaces join their own peer lists against this map by node address
/// to recover each peer's role. A missing or malformed payload yields an empty
/// map (every peer then falls back to the Other group).
pub fn peer_role_map(
peers_data: Option<&Value>,
) -> std::collections::HashMap<String, (bool, bool)> {
let mut map = std::collections::HashMap::new();
if let Some(arr) = peers_data
.and_then(|d| d.get("peers"))
.and_then(|v| v.as_array())
{
for p in arr {
if let Some(addr) = p.get("node_addr").and_then(|v| v.as_str()) {
let is_parent = p
.get("is_parent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_child = p.get("is_child").and_then(|v| v.as_bool()).unwrap_or(false);
map.insert(addr.to_string(), (is_parent, is_child));
}
}
}
map
}
/// Enrich a tree/bloom peer Value with `is_parent`/`is_child` looked up in the
/// peers role map by `addr_key` (the peer's node-address field, which differs
/// per surface: `node_addr` on Tree, `peer` on Bloom). A peer not found in the
/// map is left without role flags, so `group_rank` places it under Other.
pub fn enrich_role(
mut peer: Value,
role_map: &std::collections::HashMap<String, (bool, bool)>,
addr_key: &str,
) -> Value {
let addr = peer
.get(addr_key)
.and_then(|v| v.as_str())
.map(String::from);
if let Some(addr) = addr
&& let Some(&(is_parent, is_child)) = role_map.get(&addr)
&& let Some(obj) = peer.as_object_mut()
{
obj.insert("is_parent".into(), Value::Bool(is_parent));
obj.insert("is_child".into(), Value::Bool(is_child));
}
peer
}
/// Tree-role group rank for a peer JSON object: parent first (0), then STP
/// children (1), then everything else (2). A node with no parent simply has
/// an empty group 0; a leaf with no children an empty group 1. Shared by the
/// Peers, Tree, and Bloom surfaces so they group peers identically.
pub fn group_rank(peer: &Value) -> u8 {
let is_parent = peer
.get("is_parent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_child = peer
.get("is_child")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_parent {
0
} else if is_child {
1
} else {
2
}
}
/// The section label for a tree-role group rank, matching the Peers tab's
/// box-drawing labels so all three surfaces read consistently.
pub fn group_label(rank: u8) -> &'static str {
match rank {
0 => "\u{2500}\u{2500} Parent \u{2500}\u{2500}",
1 => "\u{2500}\u{2500} STP Children \u{2500}\u{2500}",
_ => "\u{2500}\u{2500} Other \u{2500}\u{2500}",
}
}
/// Stable-sort `peers` in place by tree-role group rank, preserving the input
/// order within each group. Callers that want a finer secondary key (e.g. LQI)
/// should sort by that key first, then call this for the group partition, or
/// supply their own comparator keyed off `group_rank`.
pub fn sort_by_group(peers: &mut [Value]) {
peers.sort_by_key(group_rank);
}
/// Render a group of peers as `Paragraph` lines: a styled section label before
/// each non-empty group (in parent -> children -> other order), a blank
/// separator between groups, and each peer rendered by `render_peer`. Empty
/// groups are omitted (no label). `peers` is expected to already be grouped by
/// `group_rank` (callers sort first). This is the Paragraph-of-Lines analogue
/// of the Peers tab's grouped table, shared by the Tree and Bloom peer lists.
pub fn grouped_peer_lines<F>(peers: &[Value], render_peer: F) -> Vec<Line<'static>>
where
F: Fn(&Value) -> Line<'static>,
{
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut last_group: Option<u8> = None;
for peer in peers {
let g = group_rank(peer);
if last_group != Some(g) {
if last_group.is_some() {
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
format!(" {}", group_label(g)),
label_style,
)));
last_group = Some(g);
}
lines.push(render_peer(peer));
}
lines
}
/// Render a sequence of values as Unicode block characters.
///
/// Returns an empty string for empty input. Constant series render as a
/// mid-level row. Used inline beside metric values in the dashboard and
/// as the per-column renderer for the Graphs tab.
pub fn sparkline(values: &[f64]) -> String {
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
if values.is_empty() {
return String::new();
}
let (min, max) = values
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
(lo.min(v), hi.max(v))
});
let range = max - min;
values
.iter()
.map(|&v| {
if !range.is_finite() || range <= 0.0 {
BLOCKS[3]
} else {
let norm = ((v - min) / range).clamp(0.0, 1.0);
let idx = (norm * (BLOCKS.len() as f64 - 1.0)).round() as usize;
BLOCKS[idx.min(BLOCKS.len() - 1)]
}
})
.collect()
}
/// Extract a `Vec<f64>` from a nested JSON array (e.g., `sparklines.mesh_size`).
pub fn nested_f64_array(data: &Value, outer: &str, inner: &str) -> Vec<f64> {
data.get(outer)
.and_then(|o| o.get(inner))
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
.unwrap_or_default()
}