Skip to main content

bee_tui/components/
warmup.rs

1//! S5 — Warmup screen (`docs/PLAN.md` § 8.S5).
2//!
3//! Surfaces the 25–60 minute cold-start opacity (bee#4746) — the
4//! interval where Bee is internally bootstrapping but the operator
5//! sees nothing actionable. Renders an elapsed counter plus a
6//! checklist of warmup steps driven by:
7//!
8//! - [`crate::watch::HealthSnapshot`] — `is_warming_up`,
9//!   `connected_peers`, `reserve_size_within_radius`.
10//! - [`crate::watch::StampsSnapshot`] — postage batch count for the
11//!   "snapshot loaded" step.
12//! - [`crate::watch::TopologySnapshot`] — kademlia depth and per-bin
13//!   data for the "depth stable" and bin-relative steps.
14//!
15//! The component remains useful after warmup completes: every step
16//! latches to Done and the elapsed counter freezes, so operators can
17//! see a "definition of done" of the bootstrap process even on a
18//! healthy node.
19//!
20//! Render delegates to the pure [`Warmup::view_for`] so the snapshot
21//! tests in `tests/s5_warmup_view.rs` can pin every step's state
22//! without launching a TUI or dealing with wall-clock jitter.
23
24use std::collections::VecDeque;
25use std::time::{Duration, Instant};
26
27use color_eyre::Result;
28use ratatui::{
29    Frame,
30    layout::{Constraint, Layout, Rect},
31    style::{Color, Modifier, Style},
32    text::{Line, Span},
33    widgets::{Block, Borders, Paragraph},
34};
35use tokio::sync::watch;
36
37use super::Component;
38use crate::action::Action;
39use crate::watch::{HealthSnapshot, StampsSnapshot, TopologySnapshot};
40
41/// Bee's reserve size at depth (`pkg/storer/storer.go`). The reserve
42/// fill step uses this as the denominator for the percentage line.
43pub const RESERVE_TARGET_CHUNKS: i64 = 65_536;
44/// Heuristic peer-bootstrap target. Bee doesn't publish a single
45/// "we're done discovering peers" threshold — different versions
46/// converge anywhere from 30 to 100. We use 50 as a representative
47/// midpoint so the bar reaches Done on a typical mainnet node.
48pub const PEER_BOOTSTRAP_TARGET: u64 = 50;
49/// Number of consecutive depth observations that must agree for the
50/// "kademlia depth stable" step to flip Done. Five ticks at the 1 s
51/// cadence ≈ five seconds, long enough to ride out the depth churn
52/// during peer bootstrap without dragging a steady node down.
53pub const DEPTH_STABILITY_WINDOW: usize = 5;
54
55/// One step of the bootstrap checklist.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum StepState {
58    /// Step hasn't started yet — `░` in the rendered output.
59    Pending,
60    /// Step in progress, with an integer percentage in `0..=100`. `▒`
61    /// in the rendered output.
62    InProgress(u32),
63    /// Step latched done — `✓`.
64    Done,
65    /// Insufficient data to classify (snapshots not loaded yet) — `·`.
66    Unknown,
67}
68
69impl StepState {
70    fn glyph(self) -> &'static str {
71        match self {
72            Self::Pending => "░",
73            Self::InProgress(_) => "▒",
74            Self::Done => "✓",
75            Self::Unknown => "·",
76        }
77    }
78    fn color(self) -> Color {
79        match self {
80            Self::Pending => Color::DarkGray,
81            Self::InProgress(_) => Color::Yellow,
82            Self::Done => Color::Green,
83            Self::Unknown => Color::DarkGray,
84        }
85    }
86}
87
88/// One row of the warmup checklist.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct WarmupStep {
91    pub label: &'static str,
92    pub state: StepState,
93    /// Per-step detail line (e.g. `"487 batches"`, `"depth 8 (5/5
94    /// ticks stable)"`). Rendered dimmed under the step glyph.
95    pub detail: String,
96}
97
98/// Aggregated view fed to renderer and snapshot tests. The elapsed
99/// counter is part of the view (as a `Duration`) but tracked in the
100/// component because it depends on the component's first observation
101/// of `is_warming_up=true`. Tests pass deterministic values.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct WarmupView {
104    /// `true` if Bee currently reports `is_warming_up=true`. After
105    /// transition to `false` the component freezes the elapsed
106    /// counter but leaves the screen useful as a "definition of
107    /// done" view.
108    pub is_warming_up: bool,
109    /// Wall-clock duration since the component first observed
110    /// `is_warming_up=true`. `None` when the snapshot hasn't loaded.
111    pub elapsed: Option<Duration>,
112    pub steps: Vec<WarmupStep>,
113}
114
115pub struct Warmup {
116    health_rx: watch::Receiver<HealthSnapshot>,
117    stamps_rx: watch::Receiver<StampsSnapshot>,
118    topology_rx: watch::Receiver<TopologySnapshot>,
119    health: HealthSnapshot,
120    stamps: StampsSnapshot,
121    topology: TopologySnapshot,
122    /// Set the first time we see a [`HealthSnapshot`] with
123    /// `is_warming_up=true`. Frozen at the moment warmup completes.
124    started_at: Option<Instant>,
125    /// Elapsed duration captured the moment we observe the warmup-
126    /// complete edge — preserved so the screen remains a useful
127    /// post-mortem even after `is_warming_up` flips back to false.
128    frozen_elapsed: Option<Duration>,
129    /// Last few observed kademlia depths. When the window is full and
130    /// every entry agrees, the "depth stable" step flips Done.
131    depth_history: VecDeque<u8>,
132}
133
134impl Warmup {
135    pub fn new(
136        health_rx: watch::Receiver<HealthSnapshot>,
137        stamps_rx: watch::Receiver<StampsSnapshot>,
138        topology_rx: watch::Receiver<TopologySnapshot>,
139    ) -> Self {
140        let health = health_rx.borrow().clone();
141        let stamps = stamps_rx.borrow().clone();
142        let topology = topology_rx.borrow().clone();
143        Self {
144            health_rx,
145            stamps_rx,
146            topology_rx,
147            health,
148            stamps,
149            topology,
150            started_at: None,
151            frozen_elapsed: None,
152            depth_history: VecDeque::with_capacity(DEPTH_STABILITY_WINDOW),
153        }
154    }
155
156    fn pull_latest(&mut self) {
157        self.health = self.health_rx.borrow().clone();
158        self.stamps = self.stamps_rx.borrow().clone();
159        self.topology = self.topology_rx.borrow().clone();
160        // Track depth stability window.
161        if let Some(t) = &self.topology.topology {
162            if self.depth_history.len() == DEPTH_STABILITY_WINDOW {
163                self.depth_history.pop_front();
164            }
165            self.depth_history.push_back(t.depth);
166        }
167        // Elapsed bookkeeping.
168        let warming = self
169            .health
170            .status
171            .as_ref()
172            .map(|s| s.is_warming_up)
173            .unwrap_or(false);
174        if warming {
175            if self.started_at.is_none() {
176                self.started_at = Some(Instant::now());
177            }
178            self.frozen_elapsed = None;
179        } else if let Some(start) = self.started_at {
180            // Edge: warming → not warming. Freeze the elapsed counter
181            // once and forget the start instant so a future warmup
182            // gets a fresh start.
183            if self.frozen_elapsed.is_none() {
184                self.frozen_elapsed = Some(Instant::now().saturating_duration_since(start));
185            }
186            self.started_at = None;
187        }
188    }
189
190    fn current_elapsed(&self) -> Option<Duration> {
191        if let Some(start) = self.started_at {
192            Some(Instant::now().saturating_duration_since(start))
193        } else {
194            self.frozen_elapsed
195        }
196    }
197
198    fn depth_stable(&self) -> bool {
199        if self.depth_history.len() < DEPTH_STABILITY_WINDOW {
200            return false;
201        }
202        let first = match self.depth_history.front() {
203            Some(d) => *d,
204            None => return false,
205        };
206        self.depth_history.iter().all(|d| *d == first)
207    }
208
209    /// Pure, snapshot-driven view computation. Exposed for snapshot
210    /// tests so the depth-stable bit and elapsed counter can be
211    /// passed in deterministically.
212    pub fn view_for(
213        health: &HealthSnapshot,
214        stamps: &StampsSnapshot,
215        topology: &TopologySnapshot,
216        elapsed: Option<Duration>,
217        depth_stable: bool,
218    ) -> WarmupView {
219        let is_warming_up = health
220            .status
221            .as_ref()
222            .map(|s| s.is_warming_up)
223            .unwrap_or(false);
224        let steps = vec![
225            postage_step(stamps),
226            peers_step(health),
227            depth_step(topology, depth_stable),
228            reserve_step(health),
229            stabilization_step(health),
230        ];
231        WarmupView {
232            is_warming_up,
233            elapsed,
234            steps,
235        }
236    }
237}
238
239fn postage_step(stamps: &StampsSnapshot) -> WarmupStep {
240    if stamps.last_update.is_none() {
241        return WarmupStep {
242            label: "Postage snapshot loaded",
243            state: StepState::Unknown,
244            detail: "(awaiting first /stamps poll)".into(),
245        };
246    }
247    let count = stamps.batches.len();
248    if count == 0 {
249        return WarmupStep {
250            label: "Postage snapshot loaded",
251            state: StepState::Pending,
252            detail: "no batches yet — node may not have any postage attached".into(),
253        };
254    }
255    WarmupStep {
256        label: "Postage snapshot loaded",
257        state: StepState::Done,
258        detail: format!("{count} batch(es)"),
259    }
260}
261
262fn peers_step(health: &HealthSnapshot) -> WarmupStep {
263    let Some(s) = &health.status else {
264        return WarmupStep {
265            label: "Peer bootstrap",
266            state: StepState::Unknown,
267            detail: "(awaiting first /status poll)".into(),
268        };
269    };
270    let connected = s.connected_peers as u64;
271    let pct = pct_of(connected, PEER_BOOTSTRAP_TARGET);
272    let detail = format!(
273        "{connected} connected (target ≥ {PEER_BOOTSTRAP_TARGET})"
274    );
275    if connected >= PEER_BOOTSTRAP_TARGET {
276        WarmupStep {
277            label: "Peer bootstrap",
278            state: StepState::Done,
279            detail,
280        }
281    } else if connected == 0 {
282        WarmupStep {
283            label: "Peer bootstrap",
284            state: StepState::Pending,
285            detail,
286        }
287    } else {
288        WarmupStep {
289            label: "Peer bootstrap",
290            state: StepState::InProgress(pct),
291            detail,
292        }
293    }
294}
295
296fn depth_step(topology: &TopologySnapshot, depth_stable: bool) -> WarmupStep {
297    let Some(t) = &topology.topology else {
298        return WarmupStep {
299            label: "Kademlia depth stable",
300            state: StepState::Unknown,
301            detail: "(awaiting first /topology poll)".into(),
302        };
303    };
304    let detail = if depth_stable {
305        format!("depth {} (stable across the observation window)", t.depth)
306    } else {
307        format!("depth {} (still settling)", t.depth)
308    };
309    let state = if depth_stable {
310        StepState::Done
311    } else {
312        StepState::InProgress(50)
313    };
314    WarmupStep {
315        label: "Kademlia depth stable",
316        state,
317        detail,
318    }
319}
320
321fn reserve_step(health: &HealthSnapshot) -> WarmupStep {
322    let Some(s) = &health.status else {
323        return WarmupStep {
324            label: "Reserve fill",
325            state: StepState::Unknown,
326            detail: "(awaiting first /status poll)".into(),
327        };
328    };
329    let in_radius = s.reserve_size_within_radius.max(0);
330    let pct = pct_of(in_radius as u64, RESERVE_TARGET_CHUNKS as u64);
331    let detail = format!("{in_radius} / {RESERVE_TARGET_CHUNKS} in-radius chunks");
332    if in_radius >= RESERVE_TARGET_CHUNKS {
333        WarmupStep {
334            label: "Reserve fill",
335            state: StepState::Done,
336            detail,
337        }
338    } else if in_radius == 0 {
339        WarmupStep {
340            label: "Reserve fill",
341            state: StepState::Pending,
342            detail,
343        }
344    } else {
345        WarmupStep {
346            label: "Reserve fill",
347            state: StepState::InProgress(pct),
348            detail,
349        }
350    }
351}
352
353fn stabilization_step(health: &HealthSnapshot) -> WarmupStep {
354    let Some(s) = &health.status else {
355        return WarmupStep {
356            label: "Stabilization",
357            state: StepState::Unknown,
358            detail: "(awaiting first /status poll)".into(),
359        };
360    };
361    if !s.is_warming_up {
362        WarmupStep {
363            label: "Stabilization",
364            state: StepState::Done,
365            detail: "Bee reports warmup complete".into(),
366        }
367    } else {
368        WarmupStep {
369            label: "Stabilization",
370            state: StepState::InProgress(50),
371            detail: "Bee still reports is_warming_up=true".into(),
372        }
373    }
374}
375
376fn pct_of(num: u64, denom: u64) -> u32 {
377    if denom == 0 {
378        return 0;
379    }
380    let q = num.saturating_mul(100) / denom;
381    q.min(100) as u32
382}
383
384fn format_elapsed(d: Duration) -> String {
385    let secs = d.as_secs();
386    if secs >= 3_600 {
387        let h = secs / 3_600;
388        let m = (secs % 3_600) / 60;
389        let s = secs % 60;
390        format!("{h}h {m:>2}m {s:>2}s")
391    } else if secs >= 60 {
392        let m = secs / 60;
393        let s = secs % 60;
394        format!("{m}m {s:>2}s")
395    } else {
396        format!("{secs}s")
397    }
398}
399
400impl Component for Warmup {
401    fn update(&mut self, action: Action) -> Result<Option<Action>> {
402        if matches!(action, Action::Tick) {
403            self.pull_latest();
404        }
405        Ok(None)
406    }
407
408    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
409        let elapsed = self.current_elapsed();
410        let depth_stable = self.depth_stable();
411
412        let view = Self::view_for(
413            &self.health,
414            &self.stamps,
415            &self.topology,
416            elapsed,
417            depth_stable,
418        );
419
420        let chunks = Layout::vertical([
421            Constraint::Length(3), // header
422            Constraint::Min(0),    // step list
423            Constraint::Length(1), // footer
424        ])
425        .split(area);
426
427        // Header
428        let elapsed_str = view
429            .elapsed
430            .map(format_elapsed)
431            .unwrap_or_else(|| "—".into());
432        let status_label = if view.is_warming_up {
433            Span::styled(
434                "warming up",
435                Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
436            )
437        } else if view.elapsed.is_some() {
438            Span::styled(
439                "complete (post-warmup view)",
440                Style::default().fg(Color::Green),
441            )
442        } else {
443            Span::styled(
444                "(no /status snapshot yet)",
445                Style::default().fg(Color::DarkGray),
446            )
447        };
448        let header_l1 = Line::from(vec![
449            Span::styled(
450                "WARMUP",
451                Style::default().add_modifier(Modifier::BOLD),
452            ),
453            Span::raw("  ·  "),
454            status_label,
455            Span::raw("  ·  elapsed "),
456            Span::styled(elapsed_str, Style::default().fg(Color::Cyan)),
457        ]);
458        let header_l2 = Line::from(Span::styled(
459            "  Bee bootstrap is opaque (bee#4746); these checks reconstruct the steps from /status, /stamps, /topology.",
460            Style::default()
461                .fg(Color::DarkGray)
462                .add_modifier(Modifier::ITALIC),
463        ));
464        frame.render_widget(
465            Paragraph::new(vec![header_l1, header_l2])
466                .block(Block::default().borders(Borders::BOTTOM)),
467            chunks[0],
468        );
469
470        // Step list
471        let mut step_lines: Vec<Line> = Vec::new();
472        for s in &view.steps {
473            let progress_suffix = match s.state {
474                StepState::InProgress(pct) => format!("  ({pct}%)"),
475                _ => String::new(),
476            };
477            step_lines.push(Line::from(vec![
478                Span::raw("  "),
479                Span::styled(
480                    s.state.glyph(),
481                    Style::default()
482                        .fg(s.state.color())
483                        .add_modifier(Modifier::BOLD),
484                ),
485                Span::raw(" "),
486                Span::styled(
487                    format!("{:<28}", s.label),
488                    Style::default().add_modifier(Modifier::BOLD),
489                ),
490                Span::styled(
491                    s.detail.clone(),
492                    Style::default().fg(Color::DarkGray),
493                ),
494                Span::styled(progress_suffix, Style::default().fg(s.state.color())),
495            ]));
496        }
497        frame.render_widget(Paragraph::new(step_lines), chunks[1]);
498
499        // Footer
500        frame.render_widget(
501            Paragraph::new(Line::from(vec![
502                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
503                Span::raw(" switch screen  "),
504                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
505                Span::raw(" quit  "),
506                Span::styled(
507                    "warmup typically takes 25–60 minutes on a fresh mainnet node",
508                    Style::default().fg(Color::DarkGray),
509                ),
510            ])),
511            chunks[2],
512        );
513
514        Ok(())
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn pct_of_handles_zero_denom() {
524        assert_eq!(pct_of(10, 0), 0);
525    }
526
527    #[test]
528    fn pct_of_clamps_to_100() {
529        assert_eq!(pct_of(200, 100), 100);
530    }
531
532    #[test]
533    fn format_elapsed_unit_thresholds() {
534        assert_eq!(format_elapsed(Duration::from_secs(45)), "45s");
535        assert_eq!(format_elapsed(Duration::from_secs(125)), "2m  5s");
536        assert_eq!(format_elapsed(Duration::from_secs(3_725)), "1h  2m  5s");
537    }
538}