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!(
275        "{connected} connected (target ≥ {PEER_BOOTSTRAP_TARGET})"
276    );
277    if connected >= PEER_BOOTSTRAP_TARGET {
278        WarmupStep {
279            label: "Peer bootstrap",
280            state: StepState::Done,
281            detail,
282        }
283    } else if connected == 0 {
284        WarmupStep {
285            label: "Peer bootstrap",
286            state: StepState::Pending,
287            detail,
288        }
289    } else {
290        WarmupStep {
291            label: "Peer bootstrap",
292            state: StepState::InProgress(pct),
293            detail,
294        }
295    }
296}
297
298fn depth_step(topology: &TopologySnapshot, depth_stable: bool) -> WarmupStep {
299    let Some(t) = &topology.topology else {
300        return WarmupStep {
301            label: "Kademlia depth stable",
302            state: StepState::Unknown,
303            detail: "(awaiting first /topology poll)".into(),
304        };
305    };
306    let detail = if depth_stable {
307        format!("depth {} (stable across the observation window)", t.depth)
308    } else {
309        format!("depth {} (still settling)", t.depth)
310    };
311    let state = if depth_stable {
312        StepState::Done
313    } else {
314        StepState::InProgress(50)
315    };
316    WarmupStep {
317        label: "Kademlia depth stable",
318        state,
319        detail,
320    }
321}
322
323fn reserve_step(health: &HealthSnapshot) -> WarmupStep {
324    let Some(s) = &health.status else {
325        return WarmupStep {
326            label: "Reserve fill",
327            state: StepState::Unknown,
328            detail: "(awaiting first /status poll)".into(),
329        };
330    };
331    let in_radius = s.reserve_size_within_radius.max(0);
332    let pct = pct_of(in_radius as u64, RESERVE_TARGET_CHUNKS as u64);
333    let detail = format!("{in_radius} / {RESERVE_TARGET_CHUNKS} in-radius chunks");
334    if in_radius >= RESERVE_TARGET_CHUNKS {
335        WarmupStep {
336            label: "Reserve fill",
337            state: StepState::Done,
338            detail,
339        }
340    } else if in_radius == 0 {
341        WarmupStep {
342            label: "Reserve fill",
343            state: StepState::Pending,
344            detail,
345        }
346    } else {
347        WarmupStep {
348            label: "Reserve fill",
349            state: StepState::InProgress(pct),
350            detail,
351        }
352    }
353}
354
355fn stabilization_step(health: &HealthSnapshot) -> WarmupStep {
356    let Some(s) = &health.status else {
357        return WarmupStep {
358            label: "Stabilization",
359            state: StepState::Unknown,
360            detail: "(awaiting first /status poll)".into(),
361        };
362    };
363    if !s.is_warming_up {
364        WarmupStep {
365            label: "Stabilization",
366            state: StepState::Done,
367            detail: "Bee reports warmup complete".into(),
368        }
369    } else {
370        WarmupStep {
371            label: "Stabilization",
372            state: StepState::InProgress(50),
373            detail: "Bee still reports is_warming_up=true".into(),
374        }
375    }
376}
377
378fn pct_of(num: u64, denom: u64) -> u32 {
379    if denom == 0 {
380        return 0;
381    }
382    let q = num.saturating_mul(100) / denom;
383    q.min(100) as u32
384}
385
386fn format_elapsed(d: Duration) -> String {
387    let secs = d.as_secs();
388    if secs >= 3_600 {
389        let h = secs / 3_600;
390        let m = (secs % 3_600) / 60;
391        let s = secs % 60;
392        format!("{h}h {m:>2}m {s:>2}s")
393    } else if secs >= 60 {
394        let m = secs / 60;
395        let s = secs % 60;
396        format!("{m}m {s:>2}s")
397    } else {
398        format!("{secs}s")
399    }
400}
401
402impl Component for Warmup {
403    fn update(&mut self, action: Action) -> Result<Option<Action>> {
404        if matches!(action, Action::Tick) {
405            self.pull_latest();
406        }
407        Ok(None)
408    }
409
410    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
411        let elapsed = self.current_elapsed();
412        let depth_stable = self.depth_stable();
413
414        let view = Self::view_for(
415            &self.health,
416            &self.stamps,
417            &self.topology,
418            elapsed,
419            depth_stable,
420        );
421
422        let chunks = Layout::vertical([
423            Constraint::Length(3), // header
424            Constraint::Min(0),    // step list
425            Constraint::Length(1), // footer
426        ])
427        .split(area);
428
429        // Header
430        let elapsed_str = view
431            .elapsed
432            .map(format_elapsed)
433            .unwrap_or_else(|| "—".into());
434        let t = theme::active();
435        let status_label = if view.is_warming_up {
436            Span::styled(
437                "warming up",
438                Style::default().fg(t.warn).add_modifier(Modifier::BOLD),
439            )
440        } else if view.elapsed.is_some() {
441            Span::styled(
442                "complete (post-warmup view)",
443                Style::default().fg(t.pass),
444            )
445        } else {
446            Span::styled(
447                "(no /status snapshot yet)",
448                Style::default().fg(t.dim),
449            )
450        };
451        let header_l1 = Line::from(vec![
452            Span::styled(
453                "WARMUP",
454                Style::default().add_modifier(Modifier::BOLD),
455            ),
456            Span::raw("  ·  "),
457            status_label,
458            Span::raw("  ·  elapsed "),
459            Span::styled(elapsed_str, Style::default().fg(t.info)),
460        ]);
461        let header_l2 = Line::from(Span::styled(
462            "  Bee bootstrap is opaque (bee#4746); these checks reconstruct the steps from /status, /stamps, /topology.",
463            Style::default()
464                .fg(t.dim)
465                .add_modifier(Modifier::ITALIC),
466        ));
467        frame.render_widget(
468            Paragraph::new(vec![header_l1, header_l2])
469                .block(Block::default().borders(Borders::BOTTOM)),
470            chunks[0],
471        );
472
473        // Step list
474        let mut step_lines: Vec<Line> = Vec::new();
475        for s in &view.steps {
476            let progress_suffix = match s.state {
477                StepState::InProgress(pct) => format!("  ({pct}%)"),
478                _ => String::new(),
479            };
480            step_lines.push(Line::from(vec![
481                Span::raw("  "),
482                Span::styled(
483                    s.state.glyph(),
484                    Style::default()
485                        .fg(s.state.color())
486                        .add_modifier(Modifier::BOLD),
487                ),
488                Span::raw(" "),
489                Span::styled(
490                    format!("{:<28}", s.label),
491                    Style::default().add_modifier(Modifier::BOLD),
492                ),
493                Span::styled(
494                    s.detail.clone(),
495                    Style::default().fg(t.dim),
496                ),
497                Span::styled(progress_suffix, Style::default().fg(s.state.color())),
498            ]));
499        }
500        frame.render_widget(Paragraph::new(step_lines), chunks[1]);
501
502        // Footer
503        frame.render_widget(
504            Paragraph::new(Line::from(vec![
505                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
506                Span::raw(" switch screen  "),
507                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
508                Span::raw(" quit  "),
509                Span::styled(
510                    "warmup typically takes 25–60 minutes on a fresh mainnet node",
511                    Style::default().fg(t.dim),
512                ),
513            ])),
514            chunks[2],
515        );
516
517        Ok(())
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn pct_of_handles_zero_denom() {
527        assert_eq!(pct_of(10, 0), 0);
528    }
529
530    #[test]
531    fn pct_of_clamps_to_100() {
532        assert_eq!(pct_of(200, 100), 100);
533    }
534
535    #[test]
536    fn format_elapsed_unit_thresholds() {
537        assert_eq!(format_elapsed(Duration::from_secs(45)), "45s");
538        assert_eq!(format_elapsed(Duration::from_secs(125)), "2m  5s");
539        assert_eq!(format_elapsed(Duration::from_secs(3_725)), "1h  2m  5s");
540    }
541}