1use 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
41pub const RESERVE_TARGET_CHUNKS: i64 = 65_536;
44pub const PEER_BOOTSTRAP_TARGET: u64 = 50;
49pub const DEPTH_STABILITY_WINDOW: usize = 5;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum StepState {
58 Pending,
60 InProgress(u32),
63 Done,
65 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#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct WarmupStep {
91 pub label: &'static str,
92 pub state: StepState,
93 pub detail: String,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct WarmupView {
104 pub is_warming_up: bool,
109 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 started_at: Option<Instant>,
125 frozen_elapsed: Option<Duration>,
129 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 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 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 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 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), Constraint::Min(0), Constraint::Length(1), ])
425 .split(area);
426
427 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 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 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}