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!("{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), 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 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 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 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}