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::theme;
40use crate::watch::{HealthSnapshot, StampsSnapshot, TopologySnapshot};
41
42/// Bee's reserve size at depth (`pkg/storer/storer.go`). The reserve
43/// fill step uses this as the denominator for the percentage line.
44pub const RESERVE_TARGET_CHUNKS: i64 = 65_536;
45/// Heuristic peer-bootstrap target. Bee doesn't publish a single
46/// "we're done discovering peers" threshold — different versions
47/// converge anywhere from 30 to 100. We use 50 as a representative
48/// midpoint so the bar reaches Done on a typical mainnet node.
49pub const PEER_BOOTSTRAP_TARGET: u64 = 50;
50/// Number of consecutive depth observations that must agree for the
51/// "kademlia depth stable" step to flip Done. Five ticks at the 1 s
52/// cadence ≈ five seconds, long enough to ride out the depth churn
53/// during peer bootstrap without dragging a steady node down.
54pub const DEPTH_STABILITY_WINDOW: usize = 5;
55
56/// One step of the bootstrap checklist.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum StepState {
59    /// Step hasn't started yet — `░` in the rendered output.
60    Pending,
61    /// Step in progress, with an integer percentage in `0..=100`. `▒`
62    /// in the rendered output.
63    InProgress(u32),
64    /// Step latched done — `✓`.
65    Done,
66    /// Insufficient data to classify (snapshots not loaded yet) — `·`.
67    Unknown,
68}
69
70impl StepState {
71    fn glyph(self) -> &'static str {
72        let g = theme::active().glyphs;
73        match self {
74            Self::Pending => g.bar_empty,
75            Self::InProgress(_) => g.in_progress,
76            Self::Done => g.pass,
77            Self::Unknown => g.bullet,
78        }
79    }
80    fn color(self) -> Color {
81        match self {
82            Self::Pending => theme::active().dim,
83            Self::InProgress(_) => theme::active().warn,
84            Self::Done => theme::active().pass,
85            Self::Unknown => theme::active().dim,
86        }
87    }
88}
89
90/// One row of the warmup checklist.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct WarmupStep {
93    pub label: &'static str,
94    pub state: StepState,
95    /// Per-step detail line (e.g. `"487 batches"`, `"depth 8 (5/5
96    /// ticks stable)"`). Rendered dimmed under the step glyph.
97    pub detail: String,
98}
99
100/// Aggregated view fed to renderer and snapshot tests. The elapsed
101/// counter is part of the view (as a `Duration`) but tracked in the
102/// component because it depends on the component's first observation
103/// of `is_warming_up=true`. Tests pass deterministic values.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct WarmupView {
106    /// `true` if Bee currently reports `is_warming_up=true`. After
107    /// transition to `false` the component freezes the elapsed
108    /// counter but leaves the screen useful as a "definition of
109    /// done" view.
110    pub is_warming_up: bool,
111    /// Wall-clock duration since the component first observed
112    /// `is_warming_up=true`. `None` when the snapshot hasn't loaded.
113    pub elapsed: Option<Duration>,
114    pub steps: Vec<WarmupStep>,
115}
116
117pub struct Warmup {
118    health_rx: watch::Receiver<HealthSnapshot>,
119    stamps_rx: watch::Receiver<StampsSnapshot>,
120    topology_rx: watch::Receiver<TopologySnapshot>,
121    health: HealthSnapshot,
122    stamps: StampsSnapshot,
123    topology: TopologySnapshot,
124    /// Set the first time we see a [`HealthSnapshot`] with
125    /// `is_warming_up=true`. Frozen at the moment warmup completes.
126    started_at: Option<Instant>,
127    /// Elapsed duration captured the moment we observe the warmup-
128    /// complete edge — preserved so the screen remains a useful
129    /// post-mortem even after `is_warming_up` flips back to false.
130    frozen_elapsed: Option<Duration>,
131    /// Last few observed kademlia depths. When the window is full and
132    /// every entry agrees, the "depth stable" step flips Done.
133    depth_history: VecDeque<u8>,
134}
135
136impl Warmup {
137    pub fn new(
138        health_rx: watch::Receiver<HealthSnapshot>,
139        stamps_rx: watch::Receiver<StampsSnapshot>,
140        topology_rx: watch::Receiver<TopologySnapshot>,
141    ) -> Self {
142        let health = health_rx.borrow().clone();
143        let stamps = stamps_rx.borrow().clone();
144        let topology = topology_rx.borrow().clone();
145        Self {
146            health_rx,
147            stamps_rx,
148            topology_rx,
149            health,
150            stamps,
151            topology,
152            started_at: None,
153            frozen_elapsed: None,
154            depth_history: VecDeque::with_capacity(DEPTH_STABILITY_WINDOW),
155        }
156    }
157
158    fn pull_latest(&mut self) {
159        self.health = self.health_rx.borrow().clone();
160        self.stamps = self.stamps_rx.borrow().clone();
161        self.topology = self.topology_rx.borrow().clone();
162        // Track depth stability window.
163        if let Some(t) = &self.topology.topology {
164            if self.depth_history.len() == DEPTH_STABILITY_WINDOW {
165                self.depth_history.pop_front();
166            }
167            self.depth_history.push_back(t.depth);
168        }
169        // Elapsed bookkeeping.
170        let warming = self
171            .health
172            .status
173            .as_ref()
174            .map(|s| s.is_warming_up)
175            .unwrap_or(false);
176        if warming {
177            if self.started_at.is_none() {
178                self.started_at = Some(Instant::now());
179            }
180            self.frozen_elapsed = None;
181        } else if let Some(start) = self.started_at {
182            // Edge: warming → not warming. Freeze the elapsed counter
183            // once and forget the start instant so a future warmup
184            // gets a fresh start.
185            if self.frozen_elapsed.is_none() {
186                self.frozen_elapsed = Some(Instant::now().saturating_duration_since(start));
187            }
188            self.started_at = None;
189        }
190    }
191
192    fn current_elapsed(&self) -> Option<Duration> {
193        if let Some(start) = self.started_at {
194            Some(Instant::now().saturating_duration_since(start))
195        } else {
196            self.frozen_elapsed
197        }
198    }
199
200    fn depth_stable(&self) -> bool {
201        if self.depth_history.len() < DEPTH_STABILITY_WINDOW {
202            return false;
203        }
204        let first = match self.depth_history.front() {
205            Some(d) => *d,
206            None => return false,
207        };
208        self.depth_history.iter().all(|d| *d == first)
209    }
210
211    /// Pure, snapshot-driven view computation. Exposed for snapshot
212    /// tests so the depth-stable bit and elapsed counter can be
213    /// passed in deterministically.
214    pub fn view_for(
215        health: &HealthSnapshot,
216        stamps: &StampsSnapshot,
217        topology: &TopologySnapshot,
218        elapsed: Option<Duration>,
219        depth_stable: bool,
220    ) -> WarmupView {
221        let is_warming_up = health
222            .status
223            .as_ref()
224            .map(|s| s.is_warming_up)
225            .unwrap_or(false);
226        let steps = vec![
227            postage_step(stamps),
228            peers_step(health),
229            depth_step(topology, depth_stable),
230            reserve_step(health),
231            stabilization_step(health),
232        ];
233        WarmupView {
234            is_warming_up,
235            elapsed,
236            steps,
237        }
238    }
239}
240
241fn postage_step(stamps: &StampsSnapshot) -> WarmupStep {
242    if stamps.last_update.is_none() {
243        return WarmupStep {
244            label: "Postage snapshot loaded",
245            state: StepState::Unknown,
246            detail: "(awaiting first /stamps poll)".into(),
247        };
248    }
249    let count = stamps.batches.len();
250    if count == 0 {
251        return WarmupStep {
252            label: "Postage snapshot loaded",
253            state: StepState::Pending,
254            detail: "no batches yet — node may not have any postage attached".into(),
255        };
256    }
257    WarmupStep {
258        label: "Postage snapshot loaded",
259        state: StepState::Done,
260        detail: format!("{count} batch(es)"),
261    }
262}
263
264fn peers_step(health: &HealthSnapshot) -> WarmupStep {
265    let Some(s) = &health.status else {
266        return WarmupStep {
267            label: "Peer bootstrap",
268            state: StepState::Unknown,
269            detail: "(awaiting first /status poll)".into(),
270        };
271    };
272    let connected = s.connected_peers as u64;
273    let pct = pct_of(connected, PEER_BOOTSTRAP_TARGET);
274    let detail = format!("{connected} connected (target ≥ {PEER_BOOTSTRAP_TARGET})");
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 t = theme::active();
433        let status_label = if view.is_warming_up {
434            Span::styled(
435                "warming up",
436                Style::default().fg(t.warn).add_modifier(Modifier::BOLD),
437            )
438        } else if view.elapsed.is_some() {
439            Span::styled("complete (post-warmup view)", Style::default().fg(t.pass))
440        } else {
441            Span::styled("(no /status snapshot yet)", Style::default().fg(t.dim))
442        };
443        let header_l1 = Line::from(vec![
444            Span::styled("WARMUP", Style::default().add_modifier(Modifier::BOLD)),
445            Span::raw("  ·  "),
446            status_label,
447            Span::raw("  ·  elapsed "),
448            Span::styled(elapsed_str, Style::default().fg(t.info)),
449        ]);
450        let header_l2 = Line::from(Span::styled(
451            "  Bee bootstrap is opaque (bee#4746); these checks reconstruct the steps from /status, /stamps, /topology.",
452            Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
453        ));
454        frame.render_widget(
455            Paragraph::new(vec![header_l1, header_l2])
456                .block(Block::default().borders(Borders::BOTTOM)),
457            chunks[0],
458        );
459
460        // Step list
461        let mut step_lines: Vec<Line> = Vec::new();
462        for s in &view.steps {
463            let progress_suffix = match s.state {
464                StepState::InProgress(pct) => format!("  ({pct}%)"),
465                _ => String::new(),
466            };
467            step_lines.push(Line::from(vec![
468                Span::raw("  "),
469                Span::styled(
470                    s.state.glyph(),
471                    Style::default()
472                        .fg(s.state.color())
473                        .add_modifier(Modifier::BOLD),
474                ),
475                Span::raw(" "),
476                Span::styled(
477                    format!("{:<28}", s.label),
478                    Style::default().add_modifier(Modifier::BOLD),
479                ),
480                Span::styled(s.detail.clone(), Style::default().fg(t.dim)),
481                Span::styled(progress_suffix, Style::default().fg(s.state.color())),
482            ]));
483        }
484        frame.render_widget(Paragraph::new(step_lines), chunks[1]);
485
486        // Footer
487        frame.render_widget(
488            Paragraph::new(Line::from(vec![
489                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
490                Span::raw(" switch screen  "),
491                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
492                Span::raw(" help  "),
493                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
494                Span::raw(" quit  "),
495                Span::styled(
496                    "warmup typically takes 25–60 minutes on a fresh mainnet node",
497                    Style::default().fg(t.dim),
498                ),
499            ])),
500            chunks[2],
501        );
502
503        Ok(())
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn pct_of_handles_zero_denom() {
513        assert_eq!(pct_of(10, 0), 0);
514    }
515
516    #[test]
517    fn pct_of_clamps_to_100() {
518        assert_eq!(pct_of(200, 100), 100);
519    }
520
521    #[test]
522    fn format_elapsed_unit_thresholds() {
523        assert_eq!(format_elapsed(Duration::from_secs(45)), "45s");
524        assert_eq!(format_elapsed(Duration::from_secs(125)), "2m  5s");
525        assert_eq!(format_elapsed(Duration::from_secs(3_725)), "1h  2m  5s");
526    }
527}