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::theme;
40use crate::watch::{HealthSnapshot, StampsSnapshot, TopologySnapshot};
41
42pub const RESERVE_TARGET_CHUNKS: i64 = 65_536;
45pub const PEER_BOOTSTRAP_TARGET: u64 = 50;
50pub const DEPTH_STABILITY_WINDOW: usize = 5;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum StepState {
59 Pending,
61 InProgress(u32),
64 Done,
66 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#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct WarmupStep {
93 pub label: &'static str,
94 pub state: StepState,
95 pub detail: String,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct WarmupView {
106 pub is_warming_up: bool,
111 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 started_at: Option<Instant>,
127 frozen_elapsed: Option<Duration>,
131 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 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 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 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 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), Constraint::Min(0), Constraint::Length(1), ])
427 .split(area);
428
429 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 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 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}