net-deck 0.45.1

Operator cyberdeck — terminal UI for the Net mesh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
//! NODES tab — full-height table of every peer in the cluster.
//! Used to share its area with a DAEMONS panel; the daemons
//! surface now lives on its own tab so this view is nodes-only.

use net_sdk::deck::MeshOsSnapshot;
use ratatui::{
    layout::{Alignment, Constraint, Rect},
    text::{Line, Span},
    widgets::{Block, Borders, Cell, Row, Table, TableState},
    Frame,
};

use crate::{nodes, theme, widgets};

/// Local-node row included in the NODES table alongside the
/// snapshot's remote peers. The substrate's `snapshot.peers`
/// map only contains *remote* peers — local probes never
/// sample self — so the App synthesizes this row to keep the
/// operator's own node visible alongside everyone else.
pub struct LocalNodeRow<'a> {
    pub id: net_sdk::deck::NodeId,
    pub peer: &'a net_sdk::deck::PeerSnapshot,
    pub local_maintenance: &'a net_sdk::deck::MaintenanceStateSnapshot,
}

pub fn render(
    frame: &mut Frame<'_>,
    area: Rect,
    snapshot: Option<&MeshOsSnapshot>,
    cursor: usize,
    local: Option<LocalNodeRow<'_>>,
    anchors: &crate::app::AnchorRollup,
    ice: &crate::app::IceRollup,
) {
    let has_peers = snapshot.map(|s| !s.peers.is_empty()).unwrap_or(false);
    let has_local = local.is_some();
    if has_peers || has_local {
        if let Some(s) = snapshot {
            render_live_nodes_table(frame, area, s, cursor, local, anchors, ice);
        }
    } else {
        render_empty_nodes_table(frame, area);
    }
}

/// Render the NODES-shaped table against a caller-supplied
/// iterator of `(node_id, peer)` rows. Used by both the
/// top-level NODES tab and the SUBNET focus page's MEMBERS
/// panel so each shows the same columns / styling / colors.
///
/// `title_spans` is rendered as the block title — caller picks
/// the section name + counters. `snapshot` is still passed in
/// so the daemon-count column can roll up against every
/// daemon's `placement` field. `local_id` + `local_maintenance`
/// let the helper render `self` in the RTT column + map the
/// local node's richer `MaintenanceStateSnapshot` to the
/// shared `MaintenanceMirrorSnapshot` chip vocabulary.
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_nodes_view(
    frame: &mut Frame<'_>,
    area: Rect,
    title_spans: Vec<Span<'static>>,
    nodes_iter: &[(u64, &net_sdk::deck::PeerSnapshot)],
    snapshot: &MeshOsSnapshot,
    cursor: usize,
    local_id: Option<u64>,
    local_maintenance_mirror: Option<net_sdk::deck::MaintenanceMirrorSnapshot>,
    // Stage 4b: the announced `rtc_addr` / `rtc_bootstrap`, which
    // do not ride `PeerSnapshot`. A row with no entry shows `—`
    // (not an anchor); a build that cannot read the fields at all
    // says so, because those are not the same statement.
    anchors: &crate::app::AnchorRollup,
    // Stage 6: plan §10's `ice_direct / ice_attempted` field
    // telemetry. NOT a per-peer fact — an attempt ledger is this
    // node's own and is not announced — so the column paints the
    // local row and `—` everywhere else.
    ice: &crate::app::IceRollup,
) {
    use net_sdk::deck::{MaintenanceMirrorSnapshot, PeerHealthSnapshot};

    let total = nodes_iter.len();
    let body_h = (area.height as usize).saturating_sub(2).saturating_sub(1);
    let effective_cursor = cursor.min(total.saturating_sub(1));
    let (start, end, hidden_above, hidden_below) =
        super::scroll_window(total, body_h, effective_cursor);

    let mut title_spans = title_spans;
    if hidden_above > 0 {
        title_spans.push(Span::styled(
            format!("    ▲ {hidden_above} more"),
            theme::dim(),
        ));
    }
    if hidden_below > 0 {
        title_spans.push(Span::styled(
            format!("    ▼ {hidden_below} more"),
            theme::dim(),
        ));
    }
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(theme::rule())
        .title(Line::from(title_spans))
        .title_alignment(Alignment::Left);

    let header = Row::new(vec![
        cell_dim(" "),
        cell_dim("NODE"),
        cell_dim("HEALTH"),
        cell_dim("RTT"),
        cell_dim("CPU"),
        cell_dim("MEM"),
        cell_dim("DISK"),
        cell_dim("SAT"),
        cell_dim("DAEMONS"),
        cell_dim("MAINT"),
        cell_dim("ANCHOR"),
        cell_dim("ICE"),
    ])
    .height(1);

    // Pre-aggregate daemon → peer placement counts once so the
    // DAEMONS column isn't O(peers × daemons) per frame.
    let mut daemon_counts: std::collections::HashMap<u64, usize> =
        std::collections::HashMap::with_capacity(total);
    for d in snapshot.daemons.values() {
        *daemon_counts.entry(d.placement).or_insert(0) += 1;
    }

    let mut table_rows: Vec<Row> = Vec::with_capacity(end.saturating_sub(start));
    for (offset, (peer_id, p)) in nodes_iter[start..end].iter().enumerate() {
        let i = start + offset;
        let peer_id = *peer_id;
        let is_local_row = Some(peer_id) == local_id;
        let is_cursor = i == effective_cursor;
        let marker = if is_cursor { "▶" } else { " " };
        let id_spans = if is_cursor {
            nodes::id_spans_styled(&format!("0x{peer_id:x}"), theme::green_hi())
        } else {
            nodes::id_spans(&format!("0x{peer_id:x}"))
        };
        let (health_style, health_text) = match p.health {
            Some(PeerHealthSnapshot::Healthy) => (theme::green(), "Healthy"),
            Some(PeerHealthSnapshot::Degraded) => (theme::amber(), "Degraded"),
            Some(PeerHealthSnapshot::Unreachable) => (theme::red(), "Unreachable"),
            None => (theme::chrome(), "—"),
            _ => (theme::chrome(), "?"),
        };
        let rtt_text = if is_local_row {
            "self".to_string()
        } else {
            match p.rtt_ms {
                Some(ms) => format!("{ms}ms"),
                None => "—".to_string(),
            }
        };
        let cpu_text = match p.cpu_load_1m {
            Some(load) => format!("{load:.2}"),
            None => "—".to_string(),
        };
        let mem_text = match (p.mem_used_bytes, p.mem_total_bytes) {
            (Some(used), Some(total)) if total > 0 => {
                format!("{}%", percent_u64(used, total))
            }
            _ => "—".to_string(),
        };
        let disk_text = match (p.disk_used_bytes, p.disk_total_bytes) {
            (Some(used), Some(total)) if total > 0 => {
                format!("{}%", percent_u64(used, total))
            }
            _ => "—".to_string(),
        };
        let (sat_text, sat_style) = match p.saturation_trend {
            Some(s) if s < 0.5 => (format!("{:.2}", s), theme::green()),
            Some(s) if s < 0.8 => (format!("{:.2}", s), theme::amber()),
            Some(s) => (format!("{:.2}", s), theme::red()),
            None => ("—".to_string(), theme::chrome()),
        };
        let mem_style = pressure_style(p.mem_used_bytes, p.mem_total_bytes);
        let disk_style = pressure_style(p.disk_used_bytes, p.disk_total_bytes);
        let daemon_count = daemon_counts.get(&peer_id).copied().unwrap_or(0);
        let maintenance = if is_local_row {
            local_maintenance_mirror
        } else {
            p.maintenance
        };
        let maint_style;
        let maint_text = match maintenance {
            Some(MaintenanceMirrorSnapshot::Active) | None => {
                maint_style = theme::chrome();
                "—".to_string()
            }
            Some(MaintenanceMirrorSnapshot::EnteringMaintenance) => {
                maint_style = theme::cyan();
                "drain".to_string()
            }
            Some(MaintenanceMirrorSnapshot::Maintenance) => {
                maint_style = theme::cyan();
                "maint".to_string()
            }
            Some(MaintenanceMirrorSnapshot::ExitingMaintenance) => {
                maint_style = theme::cyan();
                "exit".to_string()
            }
            Some(MaintenanceMirrorSnapshot::DrainFailed) => {
                maint_style = theme::red();
                "failed".to_string()
            }
            Some(MaintenanceMirrorSnapshot::Recovery) => {
                maint_style = theme::cyan();
                "recovery".to_string()
            }
            _ => {
                maint_style = theme::chrome();
                "?".to_string()
            }
        };
        table_rows.push(Row::new(vec![
            Cell::from(Span::styled(marker, theme::green_hi())),
            Cell::from(Line::from(id_spans)),
            Cell::from(Span::styled(health_text, health_style)),
            Cell::from(Span::styled(rtt_text, theme::text())),
            Cell::from(Span::styled(cpu_text, theme::text())),
            Cell::from(Span::styled(mem_text, mem_style)),
            Cell::from(Span::styled(disk_text, disk_style)),
            Cell::from(Span::styled(sat_text, sat_style)),
            Cell::from(Span::styled(format!("{daemon_count:>3}"), theme::text())),
            Cell::from(Span::styled(maint_text, maint_style)),
            Cell::from(anchor_span(anchors, peer_id)),
            Cell::from(ice_span(ice, is_local_row)),
        ]));
    }

    let table = Table::new(
        table_rows,
        [
            Constraint::Length(2),  // cursor marker
            Constraint::Length(22), // NODE
            Constraint::Length(11), // HEALTH
            Constraint::Length(7),  // RTT
            Constraint::Length(5),  // CPU
            Constraint::Length(5),  // MEM
            Constraint::Length(5),  // DISK
            Constraint::Length(5),  // SAT
            Constraint::Length(8),  // DAEMONS
            Constraint::Length(10), // MAINT
            // ANCHOR (rtc_addr / bootstrap host, plus the
            // separately announced STUN endpoint when there is one)
            Constraint::Length(anchor_column_width(anchors, area)),
            Constraint::Min(24), // ICE (direct/attempts + pending)
        ],
    )
    .header(header)
    .block(block)
    .column_spacing(2);
    let selected = effective_cursor
        .checked_sub(start)
        .filter(|s| start + *s < end);
    let mut state = TableState::default().with_selected(selected);
    frame.render_stateful_widget(table, area, &mut state);
}

/// The ANCHOR cell for one node row. Three outcomes, because the
/// column answers three different questions:
///
/// * this node announced the anchor role → its addresses, in the
///   accent color an operator can act on;
/// * it did not → `—`;
/// * this build has no `rtc_addr` / `rtc_bootstrap` to read at
///   all → say that, rather than borrow the `—` that means "not
///   an anchor" and turn an unreadable column into a claim about
///   the mesh.
fn anchor_span(anchors: &crate::app::AnchorRollup, peer_id: u64) -> Span<'static> {
    if anchors.not_this_build() {
        return Span::styled("not in this build", theme::dim());
    }
    match anchors.get(peer_id) {
        Some(addresses) => Span::styled(addresses.cell(), theme::cyan()),
        None => Span::styled("—".to_string(), theme::chrome()),
    }
}

/// The ANCHOR column's width for this frame.
///
/// 21 is the width the widest cell needed when `rtc_addr` was the
/// only endpoint a row could carry, and it stays 21 whenever that
/// is still true — a mesh where no anchor announced a separate
/// STUN endpoint (every mesh, until an operator opts in) is laid
/// out exactly as it was before Stage 6, to the character.
///
/// When an anchor does announce one, the column takes the width
/// its widest cell needs instead of truncating it: a clipped
/// `203.0.113.7:71` is not a narrower fact, it is a wrong one.
/// Bounded so the ICE column keeps its `Min(24)` floor — growing
/// one operator-facing column by squeezing another's numbers off
/// the screen would be a worse trade than a truncated address.
pub(crate) fn anchor_column_width(anchors: &crate::app::AnchorRollup, area: Rect) -> u16 {
    /// What `rtc_addr`-or-bootstrap-host alone needed: the width
    /// this column had before Stage 6, and its floor now.
    const BASE: u16 = 21;
    /// The ten fixed columns either side of ANCHOR.
    const FIXED_ELSEWHERE: u16 = 2 + 22 + 11 + 7 + 5 + 5 + 5 + 5 + 8 + 10;
    /// `column_spacing(2)` between the twelve columns.
    const SPACING: u16 = 2 * 11;
    /// ICE's own `Min`, which this column must not eat into.
    const ICE_FLOOR: u16 = 24;
    /// The surrounding block's left and right borders.
    const BORDERS: u16 = 2;

    let headroom = area
        .width
        .saturating_sub(BORDERS + FIXED_ELSEWHERE + SPACING + ICE_FLOOR);
    let widest = u16::try_from(anchors.widest_cell()).unwrap_or(u16::MAX);
    widest.clamp(BASE, headroom.max(BASE))
}

/// The ICE cell for one node row — plan §10's
/// `ice_direct / ice_attempted` deployment metric.
///
/// **The denominator is ATTEMPTS, not sessions**, and the cell
/// spells it out (`6/9 67%`, not a bare `67%`) for exactly that
/// reason: an attempt is one signalling dialog, so a peer reached
/// on a retry spends two of them, and the percentage is not a
/// session success rate. A relayed session is not a failed one.
///
/// Only the local row carries a value. An attempt ledger is this
/// node's own and is never announced, so a remote row gets `—`
/// meaning "not this node's ledger" — the same glyph the ANCHOR
/// column uses for its own "not this" fact, and for the same
/// reason: a number there would be a claim about a peer that
/// nothing on this node can support.
fn ice_span(ice: &crate::app::IceRollup, is_local_row: bool) -> Span<'static> {
    if !is_local_row {
        return Span::styled("—".to_string(), theme::chrome());
    }
    if ice.not_this_build() || ice.ledger().is_none() {
        return Span::styled(ice.cell(), theme::dim());
    }
    Span::styled(ice.cell(), theme::cyan())
}

/// Map the local node's `MaintenanceStateSnapshot` (state machine
/// with timestamps) onto the `MaintenanceMirrorSnapshot` chip
/// vocabulary used by the peer row. Public so the SUBNET focus
/// page builds the same chip when the local node belongs to the
/// drilled subnet.
pub(crate) fn local_maintenance_mirror(
    local_maintenance: &net_sdk::deck::MaintenanceStateSnapshot,
) -> net_sdk::deck::MaintenanceMirrorSnapshot {
    use net_sdk::deck::{MaintenanceMirrorSnapshot, MaintenanceStateSnapshot};
    match local_maintenance {
        MaintenanceStateSnapshot::Active => MaintenanceMirrorSnapshot::Active,
        MaintenanceStateSnapshot::EnteringMaintenance { .. } => {
            MaintenanceMirrorSnapshot::EnteringMaintenance
        }
        MaintenanceStateSnapshot::Maintenance { .. } => MaintenanceMirrorSnapshot::Maintenance,
        MaintenanceStateSnapshot::ExitingMaintenance { .. } => {
            MaintenanceMirrorSnapshot::ExitingMaintenance
        }
        MaintenanceStateSnapshot::DrainFailed { .. } => MaintenanceMirrorSnapshot::DrainFailed,
        MaintenanceStateSnapshot::Recovery { .. } => MaintenanceMirrorSnapshot::Recovery,
        _ => MaintenanceMirrorSnapshot::Active,
    }
}

// ───────────────────────── empty-state panel ─────────────────────────

fn render_empty_nodes_table(frame: &mut Frame<'_>, area: Rect) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(theme::rule())
        .title(Line::from(vec![
            Span::styled(format!("{} ", theme::SECTION_PREFIX), theme::green()),
            Span::styled("NODES", theme::green_hi()),
            Span::styled("    0 peers", theme::chrome()),
        ]));
    let inner = block.inner(area);
    frame.render_widget(block, area);
    widgets::empty::render(
        frame,
        inner,
        "no peers reported yet",
        "wire a proximity / health probe",
    );
}

// ───────────────────────── live render: nodes ─────────────────────────

fn render_live_nodes_table(
    frame: &mut Frame<'_>,
    area: Rect,
    snapshot: &MeshOsSnapshot,
    cursor: usize,
    local: Option<LocalNodeRow<'_>>,
    anchors: &crate::app::AnchorRollup,
    ice: &crate::app::IceRollup,
) {
    use net_sdk::deck::PeerHealthSnapshot;

    // Walk a single iterator that prepends the local node so
    // every cursor-aware downstream (`cursored_node` in
    // `app.rs`, clamp / step / cursor_to_bottom) treats the
    // table as `[local, ...peers]` consistently.
    let local_peer = local.as_ref().map(|r| (r.id, r.peer));
    let nodes_iter: Vec<(u64, &net_sdk::deck::PeerSnapshot)> = local_peer
        .into_iter()
        .chain(snapshot.peers.iter().map(|(id, p)| (*id, p)))
        .collect();
    let total = nodes_iter.len();
    let healthy = nodes_iter
        .iter()
        .filter(|(_, p)| matches!(p.health, Some(PeerHealthSnapshot::Healthy)))
        .count();
    let degraded = nodes_iter
        .iter()
        .filter(|(_, p)| matches!(p.health, Some(PeerHealthSnapshot::Degraded)))
        .count();
    let pos = cursor.min(total.saturating_sub(1)) + 1;
    let title_spans = vec![
        Span::styled(format!("{} ", theme::SECTION_PREFIX), theme::green()),
        Span::styled("NODES", theme::green_hi()),
        Span::styled(
            format!("    {total} live · {healthy} healthy · {degraded} degraded"),
            theme::chrome(),
        ),
        Span::styled(format!("    {pos}/{total}"), theme::dim()),
    ];
    let local_id = local.as_ref().map(|r| r.id);
    let local_maintenance_mirror = local
        .as_ref()
        .map(|r| local_maintenance_mirror(r.local_maintenance));
    render_nodes_view(
        frame,
        area,
        title_spans,
        &nodes_iter,
        snapshot,
        cursor,
        local_id,
        local_maintenance_mirror,
        anchors,
        ice,
    );
}

fn cell_dim(s: &'static str) -> Cell<'static> {
    Cell::from(Span::styled(s, theme::chrome()))
}

/// Color a percentage-style pressure value (used/total) green
/// Integer-percent of `used / total`, computed in u128 so a
/// large `used` (close to `u64::MAX / 100`) doesn't silently
/// overflow under release-mode wrapping arithmetic. Capped at
/// 999 to leave room for the rare over-100% case (drift between
/// usage reporting and the cap) without distorting the column.
fn percent_u64(used: u64, total: u64) -> u64 {
    if total == 0 {
        return 0;
    }
    let pct = (used as u128) * 100 / (total as u128);
    pct.min(999) as u64
}

/// when comfortable, amber under load, red at capacity. Same
/// thresholds the dataforts health gate uses (85% / 95%).
fn pressure_style(used: Option<u64>, total: Option<u64>) -> ratatui::style::Style {
    use net_sdk::dataforts::{HEALTH_GATE_CLEAR_THRESHOLD, HEALTH_GATE_EMIT_THRESHOLD};
    match (used, total) {
        (Some(u), Some(t)) if t > 0 => {
            let ratio = u as f64 / t as f64;
            if ratio >= HEALTH_GATE_EMIT_THRESHOLD {
                theme::red()
            } else if ratio >= HEALTH_GATE_CLEAR_THRESHOLD {
                theme::amber()
            } else {
                theme::text()
            }
        }
        _ => theme::chrome(),
    }
}