Files
fips/src/control/snapshot.rs
T
Arjen 502f7b6067 feat(fipstop): show every path to a peer in the Peers tab
`show_peers` now carries `paths[]` per peer — the `path_show` fields
minus the now-relative ages — from both the on-loop query and the
tick-published snapshot, projected once by `Node::project_peer_paths`.

fipstop's Peers tab draws a peer with more than one path as a tree: the
peer row keeps the peer-level columns (Dir, EffD, Goodput, packets) and
one child row per path carries transport, address, state with pinned /
backup markers, RTT and score. The active path is green, a live standby
dim, a probing path (never acked, as with a peer that predates
multi-path) yellow, suspect/dead red. A single-path peer stays one line.
The detail pane gains a Paths section. Cursor and selection still land
only on peer rows.
2026-09-23 11:48:33 -03:00

913 lines
35 KiB
Rust

//! Read-side state snapshots published from the node's natural mutators so
//! pure-snapshot `show_*` queries render off the rx_loop hot path.
//!
//! [`StatsSnapshot`] is the reference implementation of the canonical snapshot pattern: a
//! read-only data bundle published via `ArcSwap` from the tick after
//! `StatsHistory::tick()`. It carries
//!
//! - the `stats_history` read-side rings (the "dual-ring": the live mutable
//! ring stays on the tick; this is the cloned read copy), and
//! - the cheap scalar gauges `show_status` needs (`estimated_mesh_size`,
//! node `state`, `tun_state`, `tun_name`, `effective_ipv6_mtu`, and the
//! peer / session / link / connection / transport counts), plus
//! `peer_aliases` (effectively immutable after construction).
//!
//! The snapshot holds *data*, not rendered `Response` envelopes:
//! rendering happens in the control task off the rx_loop. Staleness is bounded
//! by the tick interval and is never staler than the underlying data, which
//! also advances only on the tick.
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use crate::identity::NodeAddr;
use crate::node::NodeState;
use crate::node::acl::PeerAclStatus;
use crate::node::stats_history::StatsHistory;
use crate::upper::tun::TunState;
use secp256k1::XOnlyPublicKey;
/// Read-only snapshot of the stats-history rings plus the scalar gauges and
/// counts `show_status` reports. Published from the tick.
#[derive(Clone)]
pub(crate) struct StatsSnapshot {
/// Cloned read copy of the history rings (the dual-ring read side).
pub history: Arc<StatsHistory>,
/// Cached estimated mesh size, or `None` when no estimate is available.
pub estimated_mesh_size: Option<u64>,
/// Node operational state.
pub state: NodeState,
/// TUN device state.
pub tun_state: TunState,
/// TUN interface name, if active.
pub tun_name: Option<String>,
/// Effective IPv6 MTU over the mesh.
pub effective_ipv6_mtu: u16,
/// Number of pending connections (handshake in progress).
pub connection_count: usize,
/// Number of authenticated peers.
pub peer_count: usize,
/// Number of active links.
pub link_count: usize,
/// Number of active transports.
pub transport_count: usize,
/// Number of active sessions.
pub session_count: usize,
/// Current spanning-tree root `NodeAddr` (rendered as hex by `show_status`).
pub root: NodeAddr,
/// Whether this node is the spanning-tree root.
pub is_root: bool,
/// Per-configured-transport-type count of peers whose active link rides that
/// transport type. Configured-but-idle types appear with a zero count.
/// Keyed by the transport type name (`"udp"`, `"tcp"`, `"tor"`, ...).
pub transport_peer_counts: std::collections::BTreeMap<String, usize>,
/// Configured peer aliases, keyed by `NodeAddr`. Effectively immutable
/// after construction; shared to avoid a per-tick map clone.
pub peer_aliases: Arc<HashMap<NodeAddr, String>>,
/// Loaded peer-ACL status (`show_acl`). The ACL itself is an
/// `arc_swap::ArcSwap<PeerAcl>` mutated only by the tick's `reload_peer_acl`;
/// the human-readable status is a cheap projection of it.
pub acl_status: PeerAclStatus,
/// Per-stats-history-peer metadata resolved against the live peer/session
/// tables and host map at publish time (`show_stats_peers` /
/// `show_stats_history_all_peers`), keyed by `NodeAddr`. The lifecycle
/// timestamps and per-peer metric rings stay in `history`; this map carries
/// only the cross-subsystem fields a renderer can't derive from the rings
/// alone (`is_active`, resolved `npub`, resolved `display_name`).
pub peer_meta: Arc<HashMap<NodeAddr, StatsPeerMeta>>,
}
/// Cross-subsystem metadata for one peer tracked in the stats-history rings,
/// resolved at publish time. Joined against `StatsSnapshot::history`'s rings
/// (lifecycle timestamps + metric series) by the off-loop `show_stats_peers` /
/// `show_stats_history_all_peers` renderers.
#[derive(Clone)]
pub(crate) struct StatsPeerMeta {
/// Whether this peer is currently in the live authenticated-peer table.
pub is_active: bool,
/// Resolved npub (live peer npub, or `node_addr` hex when not a live peer),
/// matching the on-loop `show_stats_peers` fallback.
pub npub: String,
/// Display name resolved via `Node::peer_display_name` at publish time.
pub display_name: String,
}
impl StatsSnapshot {
/// Build an empty snapshot for seeding the `ArcSwap` cell at construction,
/// before the first tick has published real state.
pub(crate) fn empty() -> Self {
Self {
history: Arc::new(StatsHistory::new()),
estimated_mesh_size: None,
state: NodeState::Created,
tun_state: TunState::Disabled,
tun_name: None,
effective_ipv6_mtu: 0,
connection_count: 0,
peer_count: 0,
link_count: 0,
transport_count: 0,
session_count: 0,
root: zero_addr(),
is_root: false,
transport_peer_counts: std::collections::BTreeMap::new(),
peer_aliases: Arc::new(HashMap::new()),
acl_status: empty_acl_status(),
peer_meta: Arc::new(HashMap::new()),
}
}
}
/// An empty/default [`PeerAclStatus`] for seeding the snapshot before the first
/// tick publishes the real ACL status. `PeerAclStatus` does not derive
/// `Default`, so this spells out the inert "no ACL loaded" shape.
fn empty_acl_status() -> PeerAclStatus {
PeerAclStatus {
allow_file: String::new(),
deny_file: String::new(),
enforcement_active: false,
effective_mode: String::new(),
default_decision: String::new(),
allow_all: false,
deny_all: false,
allow_file_entries: Vec::new(),
deny_file_entries: Vec::new(),
allow_entries: Vec::new(),
deny_entries: Vec::new(),
stale: false,
}
}
// =====================================================================
// RoutingSnapshot (derived/routing/cache read view)
// =====================================================================
/// Read-only snapshot of the derived/routing/cache subsystems that
/// the pure-snapshot `show_tree` / `show_bloom` / `show_cache` / `show_routing`
/// / `show_identity_cache` queries render. Published via `ArcSwap`.
///
/// This is the single combined `ArcSwap<RoutingSnapshot>` cell for the routing
/// subsystems: one
/// cohesive routing view holding the four subsystems (tree / bloom / coord
/// cache / identity cache) plus the F-queue summary scalars.
///
/// **Publisher placement.** The four subsystems mutate at many scattered
/// handler sites (28 `coord_cache_mut` call sites, 16 `tree_state_mut`, ~32
/// identity-cache touches), and every projected row needs a *display name*
/// resolved against the live peer/session tables and host map — per-entity
/// state reachable only with `&Node`. Wiring an on-change `publish_*` at each
/// mutation site would be large, error-prone surgery, and each call would still
/// need `&Node` to resolve names across subsystem boundaries. So this snapshot
/// is published from the **tick**, the same placement the stats snapshot
/// above uses: the tick is the one site with coherent `&Node` access to
/// resolve all display names together. A
/// single combined cell is the natural shape because there is exactly one
/// publisher — the multi-mutator "rebuild the whole snapshot N times" hazard
/// does not arise.
///
/// The snapshot holds *data* (typed rows + scalars), not rendered `Response`
/// envelopes; rendering happens off the rx_loop in the control task. The
/// counter-family `stats` blocks the queries also emit come from the
/// `MetricsRegistry` (already `Arc`-shared in the handle) at render time, not
/// from this snapshot.
///
/// Time-relative fields (`age_ms`, `idle_ms`) are derived at render time from
/// the captured absolute timestamps, so the rendered age stays fresh relative
/// to the read, exactly as the on-loop queries computed it.
///
/// Forward-compat: if the derived/routing/cache subsystems are later
/// extracted into typed types, these projections become thin views over them
/// without changing the read-handle interface or this publisher placement.
#[derive(Clone)]
pub(crate) struct RoutingSnapshot {
/// Spanning-tree read view (`show_tree`).
pub tree: TreeView,
/// Bloom-filter read view (`show_bloom`).
pub bloom: BloomView,
/// Coordinate-cache read view (`show_cache`, `show_routing`).
pub cache: CacheView,
/// F-queue / discovery routing scalars + rows (`show_routing`).
pub routing: RoutingView,
/// Identity-cache read view (`show_identity_cache`, `show_routing`).
pub identity: IdentityView,
}
impl RoutingSnapshot {
/// Build an empty snapshot for seeding the `ArcSwap` cell at construction,
/// before the first tick has published real state.
pub(crate) fn empty() -> Self {
Self {
tree: TreeView::default(),
bloom: BloomView::default(),
cache: CacheView::default(),
routing: RoutingView::default(),
identity: IdentityView::default(),
}
}
}
/// Zero `NodeAddr` for empty/seed views (all-zero 16 bytes).
fn zero_addr() -> NodeAddr {
NodeAddr::from_bytes([0u8; 16])
}
/// Spanning-tree read view for `show_tree`.
#[derive(Clone)]
pub(crate) struct TreeView {
pub my_node_addr: NodeAddr,
pub root: NodeAddr,
/// Resolved npub of the root node, when discoverable (self when root, a live
/// peer's attested npub, or an identity-cache hit); `None` otherwise.
pub root_npub: Option<String>,
pub is_root: bool,
pub depth: usize,
/// `my_coords` entries as `NodeAddr`s (rendered as hex).
pub my_coords: Vec<NodeAddr>,
pub parent: NodeAddr,
pub parent_display_name: String,
pub declaration_sequence: u64,
pub declaration_signed: bool,
pub peer_tree_count: usize,
pub peers: Vec<TreePeerRow>,
}
impl Default for TreeView {
fn default() -> Self {
Self {
my_node_addr: zero_addr(),
root: zero_addr(),
root_npub: None,
is_root: false,
depth: 0,
my_coords: Vec::new(),
parent: zero_addr(),
parent_display_name: String::new(),
declaration_sequence: 0,
declaration_signed: false,
peer_tree_count: 0,
peers: Vec::new(),
}
}
}
/// One peer's tree position in `show_tree`.
#[derive(Clone)]
pub(crate) struct TreePeerRow {
pub node_addr: NodeAddr,
pub display_name: String,
/// Present only when the peer's coordinates are known.
pub coords: Option<TreePeerCoords>,
}
/// Coordinate detail for a tree peer (present only when known).
#[derive(Clone)]
pub(crate) struct TreePeerCoords {
pub depth: usize,
pub root: NodeAddr,
pub coord_path: Vec<NodeAddr>,
pub distance_to_us: usize,
}
/// Bloom-filter read view for `show_bloom`.
#[derive(Clone)]
pub(crate) struct BloomView {
pub own_node_addr: NodeAddr,
pub is_leaf_only: bool,
pub sequence: u64,
pub leaf_dependents: Vec<NodeAddr>,
pub peer_filters: Vec<BloomPeerRow>,
/// Fill ratio of the last filter actually sent uptree (to the tree parent).
/// `None` for a root node (nothing sent uptree) or before the first
/// announce has been sent.
pub uptree_fill_ratio: Option<f64>,
/// Estimated cardinality of the last filter sent uptree — this node's whole
/// subtree (self + tree-descendants, parent excluded), since bloom is
/// split-horizon. `None` for root, pre-first-announce, or when the estimate
/// is undefined for the saturation.
pub uptree_estimated_count: Option<f64>,
}
impl Default for BloomView {
fn default() -> Self {
Self {
own_node_addr: zero_addr(),
is_leaf_only: false,
sequence: 0,
leaf_dependents: Vec::new(),
peer_filters: Vec::new(),
uptree_fill_ratio: None,
uptree_estimated_count: None,
}
}
}
/// One peer's bloom-filter state in `show_bloom`.
#[derive(Clone)]
pub(crate) struct BloomPeerRow {
pub peer: NodeAddr,
pub display_name: String,
pub has_filter: bool,
pub filter_sequence: u64,
/// Present only when the peer has supplied an inbound filter.
pub filter: Option<BloomPeerFilter>,
}
/// Inbound-filter statistics for a bloom peer (present only when known).
#[derive(Clone)]
pub(crate) struct BloomPeerFilter {
/// Estimated cardinality (`None` when undefined for the saturation),
/// matching `BloomFilter::estimated_count`'s `Option<f64>`.
pub estimated_count: Option<f64>,
pub set_bits: usize,
pub fill_ratio: f64,
}
/// Coordinate-cache read view for `show_cache` (and the cache scalars in
/// `show_routing`).
#[derive(Clone, Default)]
pub(crate) struct CacheView {
pub count: usize,
pub max_entries: usize,
pub fill_ratio: f64,
pub default_ttl_ms: u64,
pub expired: usize,
pub avg_age_ms: u64,
pub entries: Vec<CacheEntryRow>,
}
/// One coordinate-cache entry in `show_cache`.
#[derive(Clone)]
pub(crate) struct CacheEntryRow {
pub node_addr: NodeAddr,
pub display_name: String,
pub depth: usize,
pub coord_path: Vec<NodeAddr>,
/// Absolute creation time (Unix ms); `age_ms` derived at render time.
pub created_at: u64,
pub last_used_ms: u64,
pub path_mtu: Option<u16>,
}
/// F-queue / discovery routing read view for `show_routing`.
#[derive(Clone, Default)]
pub(crate) struct RoutingView {
pub pending_lookups: Vec<PendingLookupRow>,
pub pending_tun_destinations: usize,
pub pending_tun_packets: usize,
pub recent_requests: usize,
pub retries: Vec<RetryRow>,
}
/// One in-flight discovery lookup in `show_routing`.
#[derive(Clone)]
pub(crate) struct PendingLookupRow {
pub target: NodeAddr,
pub display_name: String,
/// Absolute initiation time (Unix ms); `age_ms` derived at render time.
pub initiated_ms: u64,
pub last_sent_ms: u64,
pub attempt: u8,
}
/// One connection-retry entry in `show_routing`.
#[derive(Clone)]
pub(crate) struct RetryRow {
pub node_addr: NodeAddr,
pub display_name: String,
pub retry_count: u32,
pub retry_after_ms: u64,
pub auto_reconnect: bool,
}
/// Identity-cache read view for `show_identity_cache` (and the
/// `identity_cache_entries` scalar in `show_routing`).
#[derive(Clone, Default)]
pub(crate) struct IdentityView {
pub entries: Vec<IdentityRow>,
pub max_entries: usize,
}
/// One identity-cache entry in `show_identity_cache`.
#[derive(Clone)]
pub(crate) struct IdentityRow {
pub node_addr: NodeAddr,
pub npub: String,
pub display_name: String,
pub ipv6_addr: String,
pub last_seen_ms: u64,
}
// =====================================================================
// NativeSnapshot (native datagram API read view)
// =====================================================================
/// Read-only snapshot of the native datagram API registry that
/// `show_native_flows` renders. Published via `ArcSwap`.
///
/// **Publisher placement.** The registry lives inside `Node` and is touched
/// only by the rx_loop, which is precisely why the native receive path takes no
/// lock; a control task cannot read it at all, and putting a lock on it to allow
/// that would give back the property the design was built for. So the
/// projection is published from the tick beside the other snapshot cells.
///
/// **Cost.** A field read per flow. The peer's key is captured where its
/// session authenticated it and rides on the registry entry, so publishing a
/// row resolves nothing and consults no cache.
///
/// Time-relative fields (`age_ms`) are derived at render time from the captured
/// absolute timestamps, so a rendered age stays fresh relative to the read.
#[derive(Clone, Default)]
pub(crate) struct NativeSnapshot {
/// One row per flow, established and pending, ordered by identifier.
pub flows: Vec<NativeFlowRow>,
/// One row per listener, ordered by port.
pub listeners: Vec<NativeListenerRow>,
}
impl NativeSnapshot {
/// Build an empty snapshot for seeding the `ArcSwap` cell at construction,
/// before the first tick has published real state.
pub(crate) fn empty() -> Self {
Self::default()
}
}
/// One native API flow in `show_native_flows`.
#[derive(Clone)]
pub(crate) struct NativeFlowRow {
/// The identifier the client names the flow by.
pub flow: u64,
/// The far end, by node address. Kept because this is an operator surface
/// and the node address is what `show_sessions` and `show_routing` key on,
/// which is what lets a reader correlate the three.
pub peer: NodeAddr,
/// The far end's address, as the x-only public key. Always present: a
/// connected flow decoded it from the npub its client named, and an
/// accepted one took it from the session that authenticated the peer.
pub peer_key: XOnlyPublicKey,
/// This node's port.
pub local_port: u16,
/// The far end's port.
pub remote_port: u16,
/// Whether a client has taken the flow, as opposed to still awaiting accept.
pub established: bool,
/// Datagrams the node is holding for it.
pub queued: usize,
/// Absolute open / accept / announce time (Unix ms); `age_ms` derived at
/// render time.
pub since_ms: u64,
}
/// One native API listener in `show_native_flows`.
#[derive(Clone)]
pub(crate) struct NativeListenerRow {
/// The local port it holds.
pub local_port: u16,
/// Flows announced on it and not yet answered.
pub backlog: usize,
}
// =====================================================================
// EntitySnapshot (per-entity table read views)
// =====================================================================
/// Read-only snapshot of the per-entity tables that the
/// pure-snapshot `show_peers` / `show_sessions` / `show_links` /
/// `show_connections` / `show_transports` / `show_mmp` queries render.
/// Published via `ArcSwap`.
///
/// This is the `entities` cell: peers / sessions / links / connections /
/// transports, published per-entity with `Vec<Arc<Row>>` structural sharing.
///
/// **Structural sharing.** Every entity table is a
/// `Vec<Arc<Row>>`, so a republish in which only one row changed re-allocates
/// only that one `Arc<Row>` — the unchanged rows are reused by pointer from the
/// previous snapshot (`Arc::ptr_eq`-stable). The publisher diffs each freshly
/// projected row against the prior published row by value (`PartialEq`) and
/// keeps the old `Arc` when they are equal. A clone of the snapshot for each
/// accepted control connection is then a vector of cheap pointer clones, not a
/// deep table copy. This is what keeps the per-tick publish cost off the hot
/// path at scale.
///
/// **Publisher placement.** Like the routing snapshot, this is published from
/// the **tick**, not per-mutator. Two reasons, both stronger here than for the
/// routing snapshot:
///
/// 1. Every projected row needs a *display name* resolved against the live
/// peer/session tables and host map (`&Node`), and `show_peers` additionally
/// needs the live tree state to derive `is_parent` / `is_child` and the
/// Nostr-discovery failure-state map — cross-subsystem reads available only
/// with `&Node`.
/// 2. Most of the projected fields (link/session traffic counters, MMP
/// metrics, `last_seen`, noise counters, replay/decrypt counters) are
/// mutated continuously on the **data plane / rx_loop**, not at the discrete
/// peer/session/link lifecycle mutators. Per-lifecycle-mutator publication
/// would therefore not even capture freshness for those fields; the
/// tick is the natural cadence at which this read view advances.
///
/// The diff-and-reuse therefore satisfies the structural-sharing goal (only
/// changed rows re-allocate) while keeping a single coherent `&Node`
/// publisher: there is no monolithic per-tick *re-allocation* of every row,
/// because unchanged rows are reused rather than rebuilt. This is the same
/// tick publish placement the routing snapshot uses, for the same reason.
///
/// The snapshot holds typed rows (data, not rendered `Response`
/// envelopes). Time-relative fields (`idle_ms`) are derived at render time from
/// captured absolute timestamps, so the rendered age stays fresh relative to
/// the read, exactly as the on-loop queries computed it.
///
/// Forward-compat: if the session table is later extracted into a typed
/// `(transport_id, our_index)`-indexed type, these projections become thin
/// views over it without changing the read-handle interface or this publisher
/// placement.
#[derive(Clone)]
pub(crate) struct EntitySnapshot {
/// `show_peers` rows.
pub peers: Vec<Arc<PeerRow>>,
/// `show_sessions` rows.
pub sessions: Vec<Arc<SessionRow>>,
/// `show_links` rows.
pub links: Vec<Arc<LinkRow>>,
/// `show_connections` rows.
pub connections: Vec<Arc<ConnectionRow>>,
/// `show_transports` rows.
pub transports: Vec<Arc<TransportRow>>,
/// `show_mmp` link-layer rows (peers with an MMP instance).
pub mmp_peers: Vec<Arc<MmpPeerRow>>,
/// `show_mmp` session-layer rows (sessions with an MMP instance).
pub mmp_sessions: Vec<Arc<MmpSessionRow>>,
}
impl EntitySnapshot {
/// Build an empty snapshot for seeding the `ArcSwap` cell at construction,
/// before the first tick has published real state.
pub(crate) fn empty() -> Self {
Self {
peers: Vec::new(),
sessions: Vec::new(),
links: Vec::new(),
connections: Vec::new(),
transports: Vec::new(),
mmp_peers: Vec::new(),
mmp_sessions: Vec::new(),
}
}
}
/// Per-peer link/transport/connectivity fields for `show_peers` derived from a
/// peer's resolved link (present only when the link is found).
#[derive(Clone, PartialEq)]
pub(crate) struct PeerLinkInfo {
pub direction: String,
/// Transport type name, present only when the transport handle is found.
pub transport_type: Option<String>,
}
/// Nostr-traversal failure-state for a peer's npub in `show_peers`. Always
/// emitted (the on-loop query emits a default object even when absent); the
/// `present` flag distinguishes "seen by Nostr discovery" from the default.
#[derive(Clone, PartialEq)]
pub(crate) struct PeerNostrState {
pub consecutive_failures: u32,
pub cooldown_until_ms: Option<u64>,
pub last_observed_skew_ms: Option<i64>,
}
/// Noise session counters surfaced in `show_peers` (present when the peer has a
/// Noise session).
#[derive(Clone, PartialEq)]
pub(crate) struct PeerNoiseCounters {
pub send_counter: u64,
pub highest_recv_counter: u64,
}
/// Link/session MMP metrics surfaced inline in `show_peers` (and the
/// per-session block in `show_sessions`). Fields mirror the on-loop projection;
/// `Option` fields are emitted only when present.
#[derive(Clone, PartialEq)]
pub(crate) struct EntityMmp {
pub mode: String,
pub srtt_ms: Option<f64>,
pub loss_rate: f64,
pub etx: f64,
pub goodput_bps: f64,
pub delivery_ratio_forward: f64,
pub delivery_ratio_reverse: f64,
pub smoothed_loss: Option<f64>,
pub smoothed_etx: Option<f64>,
/// `lqi` (peers) / `sqi` (sessions): present only when both `srtt_ms` and
/// `smoothed_etx` are present. Precomputed so the render is a plain emit.
pub quality_index: Option<f64>,
/// Session-only: path MTU (`show_sessions`). `None` for peer rows.
pub path_mtu: Option<u16>,
}
/// One path to a peer in `show_peers`: the transport binding it runs over
/// and the selection state that decides whether it carries traffic. The
/// per-peer `fipsctl path show` emits the same fields plus the
/// now-relative liveness ages, which a tick-published snapshot cannot carry.
#[derive(Clone, PartialEq)]
pub(crate) struct PeerPathRow {
pub transport_id: u32,
/// Transport instance name (`cable`, `main`), when it has one.
pub transport: Option<String>,
/// Transport type name, present only when the transport handle is found.
pub transport_type: Option<String>,
pub addr: String,
/// `probing` / `live` / `suspect` / `dead`.
pub state: String,
/// Whether this is the path we currently send on.
pub active: bool,
/// Whether the peer last said it sends on this path.
pub remote_active: bool,
/// `normal` / `backup`.
pub role: String,
pub pinned: bool,
pub last_rtt_ms: Option<u64>,
pub min_rtt_ms: Option<u64>,
pub rtt_samples: u32,
pub etx: f64,
pub score: Option<f64>,
}
/// Link-layer stat counters for a peer in `show_peers`.
#[derive(Clone, PartialEq)]
pub(crate) struct PeerLinkStats {
pub packets_sent: u64,
pub packets_recv: u64,
pub bytes_sent: u64,
pub bytes_recv: u64,
}
/// One authenticated peer in `show_peers`. Holds every field the on-loop
/// `show_peers` emits; `Option` fields gate the conditionally-emitted keys.
#[derive(Clone, PartialEq)]
pub(crate) struct PeerRow {
pub node_addr: NodeAddr,
pub npub: String,
pub display_name: String,
pub ipv6_addr: String,
pub connectivity: String,
pub link_id: u64,
pub authenticated_at_ms: u64,
pub last_seen_ms: u64,
pub has_tree_position: bool,
pub has_bloom_filter: bool,
pub filter_sequence: u64,
pub is_parent: bool,
pub is_child: bool,
pub transport_addr: Option<String>,
/// The peer's current transport address as a numeric IP endpoint, when it
/// is one. Not rendered anywhere: this is the medium-change detector's
/// read of the peer table (see [`crate::node::netmon`]), carried here
/// because the detector is a detached task and this snapshot is the
/// node's existing lock-free read side.
///
/// `None` covers everything that is not a probeable IP destination — a
/// MAC on Ethernet or BLE, a `.onion` or Nym recipient, a peer still
/// carrying the hostname it was configured with, an IPv6 literal with a
/// scope suffix. Typed rather than re-parsed from `transport_addr` above
/// so a change to that string's rendering cannot silently leave the
/// detector with nothing to probe.
pub probe_target: Option<SocketAddr>,
/// Source address this peer's per-peer `connect()`-ed UDP socket was
/// pinned to by `connect(2)`, when it has one. Also not rendered, and read by the same detector:
/// it is what the send path is *actually* using, as against the
/// `probe_target` lookup's answer for what the kernel would choose now.
///
/// `None` where there is no such socket — every platform but Linux and
/// macOS, a peer on another transport, and a peer whose socket has not
/// been installed yet or was just released — and also where the kernel
/// declined to name a source, which is not an address and must not be
/// compared as one.
pub bound_source: Option<IpAddr>,
/// Address this peer's transport is bound to, when that bind is not the
/// wildcard. Read by the same detector, which has to put its probe the
/// same constrained question the send path answers.
///
/// `open_connected_fd` binds the transport's configured address verbatim
/// and only then connects, so a non-wildcard `bind_addr` pins the source
/// whatever the routing table says, while an unconstrained probe takes the
/// kernel's choice. Left unequal, those two answers differ permanently and
/// every first-seen peer reports a move that never happened.
///
/// `None` for the wildcard bind, which is the default and the case where
/// the kernel chooses on both sides.
pub probe_bind: Option<IpAddr>,
pub link_info: Option<PeerLinkInfo>,
/// Every path to the peer, in the peer's own order (the active path
/// first is not guaranteed; `active` marks it).
pub paths: Vec<PeerPathRow>,
pub tree_depth: Option<usize>,
/// `effective_depth = tree_depth + link_cost` — the same quantity
/// `evaluate_parent` ranks parent candidates on. `None` when the peer is
/// unmeasured (no SRTT while other peers have it) or has no coords,
/// mirroring the candidacy/cold-start rules. Pre-computed daemon-side so
/// fipstop never recomputes it.
pub effective_depth: Option<f64>,
pub stats: PeerLinkStats,
pub replay_suppressed: u32,
pub consecutive_decrypt_failures: u32,
pub nostr_traversal: PeerNostrState,
pub noise: Option<PeerNoiseCounters>,
pub our_session_index: Option<u32>,
pub rekey_in_progress: bool,
pub rekey_draining: bool,
pub current_k_bit: bool,
pub mmp: Option<EntityMmp>,
}
/// Traffic counters for a session in `show_sessions`.
#[derive(Clone, PartialEq)]
pub(crate) struct SessionStats {
pub packets_sent: u64,
pub packets_recv: u64,
pub bytes_sent: u64,
pub bytes_recv: u64,
}
/// One end-to-end session in `show_sessions`.
#[derive(Clone, PartialEq)]
pub(crate) struct SessionRow {
pub remote_addr: NodeAddr,
pub display_name: String,
pub state: &'static str,
pub is_initiator: bool,
pub last_activity_ms: u64,
pub npub: String,
pub stats: SessionStats,
/// Handshake resend count, emitted only while not established.
pub resend_count: Option<u32>,
/// Established-only health block (session_start_ms, current_k_bit,
/// coords_warmup_remaining, is_draining). `None` while handshaking.
pub established: Option<SessionEstablished>,
pub mmp: Option<EntityMmp>,
}
/// Established-session health fields in `show_sessions` (emitted only when the
/// session is established).
#[derive(Clone, PartialEq)]
pub(crate) struct SessionEstablished {
pub session_start_ms: u64,
pub current_k_bit: bool,
pub coords_warmup_remaining: u8,
pub is_draining: bool,
}
/// Stat counters for a link in `show_links`.
#[derive(Clone, PartialEq)]
pub(crate) struct LinkStats {
pub packets_sent: u64,
pub packets_recv: u64,
pub bytes_sent: u64,
pub bytes_recv: u64,
pub last_recv_ms: u64,
}
/// One active link in `show_links`.
#[derive(Clone, PartialEq)]
pub(crate) struct LinkRow {
pub link_id: u64,
pub transport_id: u32,
pub remote_addr: String,
pub direction: String,
pub state: String,
pub created_at_ms: u64,
pub stats: LinkStats,
}
/// One pending handshake in `show_connections`. `idle_ms` is derived at render
/// time from the captured `last_activity_ms`.
#[derive(Clone, PartialEq)]
pub(crate) struct ConnectionRow {
pub link_id: u64,
pub direction: String,
pub handshake_state: String,
pub started_at_ms: u64,
/// Absolute last-activity time (Unix ms); `idle_ms` derived at render time.
pub last_activity_ms: u64,
pub resend_count: u32,
/// Expected peer npub, emitted only when the connection has an expected
/// identity.
pub expected_peer: Option<String>,
}
/// One transport instance in `show_transports`. The `stats` and
/// `tor_monitoring` fields are stored as already-projected `serde_json::Value`
/// (data, produced by the transport handle), not as rendered `Response`
/// envelopes.
#[derive(Clone, PartialEq)]
pub(crate) struct TransportRow {
pub transport_id: u32,
pub transport_type: String,
pub state: String,
pub mtu: u16,
pub name: Option<String>,
pub local_addr: Option<String>,
pub tor_mode: Option<String>,
pub onion_address: Option<String>,
pub tor_monitoring: Option<serde_json::Value>,
pub stats: serde_json::Value,
/// Interface presence for interface-bound transports; `None` for the rest.
pub interface: Option<InterfaceRow>,
}
/// Interface name, presence and policy for an interface-bound transport, as
/// `show_transports` renders it.
#[derive(Clone, PartialEq)]
pub(crate) struct InterfaceRow {
pub name: String,
pub presence: &'static str,
pub carrier: bool,
pub policy: &'static str,
pub since_secs: u64,
pub binds: u64,
pub failed_attempts: u32,
}
/// MMP trend labels for a peer's link-layer block in `show_mmp` (each present
/// only when the corresponding trend is initialized).
#[derive(Clone, PartialEq)]
pub(crate) struct MmpTrends {
pub rtt_trend: Option<&'static str>,
pub loss_trend: Option<&'static str>,
pub goodput_trend: Option<&'static str>,
pub jitter_trend: Option<&'static str>,
}
/// One peer's link-layer MMP block in `show_mmp`.
#[derive(Clone, PartialEq)]
pub(crate) struct MmpPeerRow {
pub peer: NodeAddr,
pub display_name: String,
pub mode: String,
pub loss_rate: f64,
pub etx: f64,
pub goodput_bps: f64,
pub spin_bit_initiator: bool,
pub smoothed_loss: Option<f64>,
pub smoothed_etx: Option<f64>,
pub srtt_ms: Option<f64>,
/// `lqi`: present only when both `srtt_ms` and `smoothed_etx` are present.
pub lqi: Option<f64>,
pub trends: MmpTrends,
pub delivery_ratio_forward: f64,
pub delivery_ratio_reverse: f64,
pub ecn_ce_count: u32,
}
/// MMP trend labels for a session's session-layer block in `show_mmp` (each
/// present only when the corresponding trend is initialized). Mirrors the
/// link-layer [`MmpTrends`] arrow semantics on the session columns: srtt
/// (`rtt_trend`), loss (`loss_trend`), etx (`etx_trend`).
#[derive(Clone, PartialEq)]
pub(crate) struct MmpSessionTrends {
pub rtt_trend: Option<&'static str>,
pub loss_trend: Option<&'static str>,
pub etx_trend: Option<&'static str>,
}
/// One session's session-layer MMP block in `show_mmp`.
#[derive(Clone, PartialEq)]
pub(crate) struct MmpSessionRow {
pub remote: NodeAddr,
pub display_name: String,
pub mode: String,
pub loss_rate: f64,
pub etx: f64,
pub path_mtu: u16,
pub smoothed_loss: Option<f64>,
pub smoothed_etx: Option<f64>,
pub srtt_ms: Option<f64>,
/// `sqi`: present only when both `srtt_ms` and `smoothed_etx` are present.
pub sqi: Option<f64>,
pub trends: MmpSessionTrends,
}
/// Reconcile a freshly-projected entity table against the previously published
/// one, preserving structural sharing: an `Arc<Row>` from `prev` is reused
/// (kept by pointer) whenever a new row matches an old row by identity `key`
/// **and** compares equal by value, so only changed/new rows allocate a fresh
/// `Arc`. This is the `Vec<Arc<Row>>` structural-sharing discipline — a
/// single-row change re-allocates one row, not the whole table, keeping the
/// per-tick publish cost off the hot path at scale.
///
/// `key` extracts a stable, hashable identity (e.g. `node_addr`, `link_id`) so
/// matching is order-independent across the source table's iteration order.
pub(crate) fn reconcile_rows<R, K, F>(prev: &[Arc<R>], new_rows: Vec<R>, key: F) -> Vec<Arc<R>>
where
R: PartialEq,
K: std::hash::Hash + Eq,
F: Fn(&R) -> K,
{
let index: HashMap<K, &Arc<R>> = prev.iter().map(|arc| (key(arc), arc)).collect();
new_rows
.into_iter()
.map(|row| match index.get(&key(&row)) {
Some(old) if ***old == row => Arc::clone(old),
_ => Arc::new(row),
})
.collect()
}