1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use color_eyre::eyre::eyre;
6use crossterm::event::KeyEvent;
7use ratatui::prelude::Rect;
8use serde::{Deserialize, Serialize};
9use tokio::sync::{mpsc, watch};
10use tokio_util::sync::CancellationToken;
11use tracing::{debug, info};
12
13use crate::{
14 action::Action,
15 api::ApiClient,
16 components::{
17 Component, api_health::ApiHealth, command_log::CommandLog,
18 health::{Gate, GateStatus, Health},
19 lottery::Lottery, network::Network, peers::Peers, stamps::Stamps, swap::Swap,
20 tags::Tags, warmup::Warmup,
21 },
22 config::Config,
23 log_capture,
24 theme,
25 tui::{Event, Tui},
26 watch::{BeeWatch, HealthSnapshot},
27};
28
29pub struct App {
30 config: Config,
31 tick_rate: f64,
32 frame_rate: f64,
33 screens: Vec<Box<dyn Component>>,
37 current_screen: usize,
39 command_log: Box<dyn Component>,
42 should_quit: bool,
43 should_suspend: bool,
44 mode: Mode,
45 last_tick_key_events: Vec<KeyEvent>,
46 action_tx: mpsc::UnboundedSender<Action>,
47 action_rx: mpsc::UnboundedReceiver<Action>,
48 root_cancel: CancellationToken,
51 #[allow(dead_code)]
54 api: Arc<ApiClient>,
55 watch: BeeWatch,
57 health_rx: watch::Receiver<HealthSnapshot>,
60 command_buffer: Option<String>,
63 command_status: Option<CommandStatus>,
67 help_visible: bool,
70}
71
72#[derive(Debug, Clone)]
75pub enum CommandStatus {
76 Info(String),
77 Err(String),
78}
79
80const SCREEN_NAMES: &[&str] = &[
83 "Health", "Stamps", "Swap", "Lottery", "Peers", "Network", "Warmup", "API", "Tags",
84];
85
86#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
87pub enum Mode {
88 #[default]
89 Home,
90}
91
92impl App {
93 pub fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
94 Self::with_overrides(tick_rate, frame_rate, false, false)
95 }
96
97 pub fn with_overrides(
102 tick_rate: f64,
103 frame_rate: f64,
104 ascii: bool,
105 no_color: bool,
106 ) -> color_eyre::Result<Self> {
107 let (action_tx, action_rx) = mpsc::unbounded_channel();
108 let config = Config::new()?;
109 let force_no_color = no_color || theme::no_color_env();
112 theme::install_with_overrides(&config.ui, force_no_color, ascii);
113
114 let node = config
116 .active_node()
117 .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
118 let api = Arc::new(ApiClient::from_node(node)?);
119
120 let root_cancel = CancellationToken::new();
123 let watch = BeeWatch::start(api.clone(), &root_cancel);
124 let health_rx = watch.health();
125
126 let screens = build_screens(&api, &watch);
127 let command_log: Box<dyn Component> = Box::new(CommandLog::new(log_capture::handle()));
132
133 Ok(Self {
134 tick_rate,
135 frame_rate,
136 screens,
137 current_screen: 0,
138 command_log,
139 should_quit: false,
140 should_suspend: false,
141 config,
142 mode: Mode::Home,
143 last_tick_key_events: Vec::new(),
144 action_tx,
145 action_rx,
146 root_cancel,
147 api,
148 watch,
149 health_rx,
150 command_buffer: None,
151 command_status: None,
152 help_visible: false,
153 })
154 }
155
156 pub async fn run(&mut self) -> color_eyre::Result<()> {
157 let mut tui = Tui::new()?
158 .tick_rate(self.tick_rate)
160 .frame_rate(self.frame_rate);
161 tui.enter()?;
162
163 let tx = self.action_tx.clone();
164 let cfg = self.config.clone();
165 let size = tui.size()?;
166 for component in self.iter_components_mut() {
167 component.register_action_handler(tx.clone())?;
168 component.register_config_handler(cfg.clone())?;
169 component.init(size)?;
170 }
171
172 let action_tx = self.action_tx.clone();
173 loop {
174 self.handle_events(&mut tui).await?;
175 self.handle_actions(&mut tui)?;
176 if self.should_suspend {
177 tui.suspend()?;
178 action_tx.send(Action::Resume)?;
179 action_tx.send(Action::ClearScreen)?;
180 tui.enter()?;
182 } else if self.should_quit {
183 tui.stop()?;
184 break;
185 }
186 }
187 self.watch.shutdown();
189 self.root_cancel.cancel();
190 tui.exit()?;
191 Ok(())
192 }
193
194 async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
195 let Some(event) = tui.next_event().await else {
196 return Ok(());
197 };
198 let action_tx = self.action_tx.clone();
199 let modal_before = self.command_buffer.is_some() || self.help_visible;
206 match event {
207 Event::Quit => action_tx.send(Action::Quit)?,
208 Event::Tick => action_tx.send(Action::Tick)?,
209 Event::Render => action_tx.send(Action::Render)?,
210 Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
211 Event::Key(key) => self.handle_key_event(key)?,
212 _ => {}
213 }
214 let modal_after = self.command_buffer.is_some() || self.help_visible;
215 let propagate =
218 !((modal_before || modal_after) && matches!(event, Event::Key(_)));
219 if propagate {
220 for component in self.iter_components_mut() {
221 if let Some(action) = component.handle_events(Some(event.clone()))? {
222 action_tx.send(action)?;
223 }
224 }
225 }
226 Ok(())
227 }
228
229 fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut Box<dyn Component>> {
233 self.screens
234 .iter_mut()
235 .chain(std::iter::once(&mut self.command_log))
236 }
237
238 fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
239 if self.command_buffer.is_some() {
243 self.handle_command_mode_key(key)?;
244 return Ok(());
245 }
246 if self.help_visible {
250 match key.code {
251 crossterm::event::KeyCode::Esc
252 | crossterm::event::KeyCode::Char('?')
253 | crossterm::event::KeyCode::Char('q') => {
254 self.help_visible = false;
255 }
256 _ => {}
257 }
258 return Ok(());
259 }
260 if matches!(key.code, crossterm::event::KeyCode::Char('?')) {
264 self.help_visible = true;
265 return Ok(());
266 }
267 let action_tx = self.action_tx.clone();
268 if matches!(
270 key.code,
271 crossterm::event::KeyCode::Char(':')
272 ) {
273 self.command_buffer = Some(String::new());
274 self.command_status = None;
275 return Ok(());
276 }
277 if matches!(key.code, crossterm::event::KeyCode::Tab) {
280 if !self.screens.is_empty() {
281 self.current_screen = (self.current_screen + 1) % self.screens.len();
282 debug!(
283 "switched to screen {}",
284 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
285 );
286 }
287 return Ok(());
288 }
289 let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
290 return Ok(());
291 };
292 match keymap.get(&vec![key]) {
293 Some(action) => {
294 info!("Got action: {action:?}");
295 action_tx.send(action.clone())?;
296 }
297 _ => {
298 self.last_tick_key_events.push(key);
301
302 if let Some(action) = keymap.get(&self.last_tick_key_events) {
304 info!("Got action: {action:?}");
305 action_tx.send(action.clone())?;
306 }
307 }
308 }
309 Ok(())
310 }
311
312 fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
313 use crossterm::event::KeyCode;
314 let buf = match self.command_buffer.as_mut() {
315 Some(b) => b,
316 None => return Ok(()),
317 };
318 match key.code {
319 KeyCode::Esc => {
320 self.command_buffer = None;
322 }
323 KeyCode::Enter => {
324 let line = std::mem::take(buf);
325 self.command_buffer = None;
326 self.execute_command(&line)?;
327 }
328 KeyCode::Backspace => {
329 buf.pop();
330 }
331 KeyCode::Char(c) => {
332 buf.push(c);
333 }
334 _ => {}
335 }
336 Ok(())
337 }
338
339 fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
342 let trimmed = line.trim();
343 if trimmed.is_empty() {
344 return Ok(());
345 }
346 let head = trimmed.split_whitespace().next().unwrap_or("");
347 match head {
348 "q" | "quit" => {
349 self.action_tx.send(Action::Quit)?;
350 self.command_status = Some(CommandStatus::Info("quitting".into()));
351 }
352 "diagnose" | "diag" => {
353 self.command_status = Some(match self.export_diagnostic_bundle() {
354 Ok(path) => CommandStatus::Info(format!(
355 "diagnostic bundle exported to {}",
356 path.display()
357 )),
358 Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
359 });
360 }
361 "pins-check" | "pins" => {
362 self.command_status = Some(match self.start_pins_check() {
363 Ok(path) => CommandStatus::Info(format!(
364 "pins integrity check running → {} (tail to watch progress)",
365 path.display()
366 )),
367 Err(e) => CommandStatus::Err(format!("pins-check failed to start: {e}")),
368 });
369 }
370 "loggers" => {
371 self.command_status = Some(match self.start_loggers_dump() {
372 Ok(path) => CommandStatus::Info(format!(
373 "loggers snapshot writing → {} (open when ready)",
374 path.display()
375 )),
376 Err(e) => CommandStatus::Err(format!("loggers failed to start: {e}")),
377 });
378 }
379 "set-logger" => {
380 let mut parts = trimmed.split_whitespace();
381 let _ = parts.next(); let expr = parts.next().unwrap_or("");
383 let level = parts.next().unwrap_or("");
384 if expr.is_empty() || level.is_empty() {
385 self.command_status = Some(CommandStatus::Err(
386 "usage: :set-logger <expr> <level> (level: none|error|warning|info|debug|all; expr: e.g. node/pushsync or '.' for all)"
387 .into(),
388 ));
389 return Ok(());
390 }
391 self.start_set_logger(expr.to_string(), level.to_string());
392 self.command_status = Some(CommandStatus::Info(format!(
393 "set-logger {expr:?} → {level:?} (PUT in-flight; check :loggers to verify)"
394 )));
395 }
396 "context" | "ctx" => {
397 let target = trimmed.split_whitespace().nth(1).unwrap_or("");
398 if target.is_empty() {
399 let known: Vec<String> = self
400 .config
401 .nodes
402 .iter()
403 .map(|n| n.name.clone())
404 .collect();
405 self.command_status = Some(CommandStatus::Err(format!(
406 "usage: :context <name> (known: {})",
407 known.join(", ")
408 )));
409 return Ok(());
410 }
411 self.command_status = Some(match self.switch_context(target) {
412 Ok(()) => CommandStatus::Info(format!(
413 "switched to context {target} ({})",
414 self.api.url
415 )),
416 Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
417 });
418 }
419 screen if SCREEN_NAMES
420 .iter()
421 .any(|name| name.eq_ignore_ascii_case(screen)) =>
422 {
423 if let Some(idx) = SCREEN_NAMES
424 .iter()
425 .position(|name| name.eq_ignore_ascii_case(screen))
426 {
427 self.current_screen = idx;
428 self.command_status =
429 Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
430 }
431 }
432 other => {
433 self.command_status = Some(CommandStatus::Err(format!(
434 "unknown command: {other:?} (try :health, :stamps, :swap, :lottery, :peers, :network, :warmup, :api, :tags, :diagnose, :pins-check, :loggers, :set-logger, :context, :quit)"
435 )));
436 }
437 }
438 Ok(())
439 }
440
441 fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
448 let node = self
449 .config
450 .nodes
451 .iter()
452 .find(|n| n.name == target)
453 .ok_or_else(|| eyre!("no node configured with name {target:?}"))?
454 .clone();
455 let new_api = Arc::new(ApiClient::from_node(&node)?);
456 self.watch.shutdown();
460 let new_watch = BeeWatch::start(new_api.clone(), &self.root_cancel);
461 let new_health_rx = new_watch.health();
462 let new_screens = build_screens(&new_api, &new_watch);
463 self.api = new_api;
464 self.watch = new_watch;
465 self.health_rx = new_health_rx;
466 self.screens = new_screens;
467 Ok(())
470 }
471
472 fn start_pins_check(&self) -> std::io::Result<PathBuf> {
489 let secs = SystemTime::now()
490 .duration_since(UNIX_EPOCH)
491 .map(|d| d.as_secs())
492 .unwrap_or(0);
493 let path = std::env::temp_dir().join(format!(
494 "bee-tui-pins-check-{}-{secs}.txt",
495 sanitize_for_filename(&self.api.name),
496 ));
497 std::fs::write(
500 &path,
501 format!(
502 "# bee-tui :pins-check\n# profile {}\n# endpoint {}\n# started {}\n",
503 self.api.name,
504 self.api.url,
505 format_utc_now(),
506 ),
507 )?;
508
509 let api = self.api.clone();
510 let dest = path.clone();
511 tokio::spawn(async move {
512 let bee = api.bee();
513 match bee.api().check_pins(None).await {
514 Ok(entries) => {
515 let mut body = String::new();
516 for e in &entries {
517 body.push_str(&format!(
518 "{} total={} missing={} invalid={} {}\n",
519 e.reference.to_hex(),
520 e.total,
521 e.missing,
522 e.invalid,
523 if e.is_healthy() { "healthy" } else { "UNHEALTHY" },
524 ));
525 }
526 body.push_str(&format!("# done. {} pins checked.\n", entries.len()));
527 if let Err(e) = append(&dest, &body) {
528 let _ = append(&dest, &format!("# write error: {e}\n"));
529 }
530 }
531 Err(e) => {
532 let _ = append(&dest, &format!("# error: {e}\n"));
533 }
534 }
535 });
536 Ok(path)
537 }
538
539 fn start_set_logger(&self, expression: String, level: String) {
550 let secs = SystemTime::now()
551 .duration_since(UNIX_EPOCH)
552 .map(|d| d.as_secs())
553 .unwrap_or(0);
554 let dest = std::env::temp_dir().join(format!(
555 "bee-tui-set-logger-{}-{secs}.txt",
556 sanitize_for_filename(&self.api.name),
557 ));
558 let _ = std::fs::write(
559 &dest,
560 format!(
561 "# bee-tui :set-logger\n# profile {}\n# endpoint {}\n# expr {expression}\n# level {level}\n# started {}\n",
562 self.api.name,
563 self.api.url,
564 format_utc_now(),
565 ),
566 );
567
568 let api = self.api.clone();
569 tokio::spawn(async move {
570 let bee = api.bee();
571 match bee.debug().set_logger(&expression, &level).await {
572 Ok(()) => {
573 let _ = append(
574 &dest,
575 &format!("# done. {expression} → {level} accepted by Bee.\n"),
576 );
577 }
578 Err(e) => {
579 let _ = append(&dest, &format!("# error: {e}\n"));
580 }
581 }
582 });
583 }
584
585 fn start_loggers_dump(&self) -> std::io::Result<PathBuf> {
590 let secs = SystemTime::now()
591 .duration_since(UNIX_EPOCH)
592 .map(|d| d.as_secs())
593 .unwrap_or(0);
594 let path = std::env::temp_dir().join(format!(
595 "bee-tui-loggers-{}-{secs}.txt",
596 sanitize_for_filename(&self.api.name),
597 ));
598 std::fs::write(
599 &path,
600 format!(
601 "# bee-tui :loggers\n# profile {}\n# endpoint {}\n# started {}\n",
602 self.api.name,
603 self.api.url,
604 format_utc_now(),
605 ),
606 )?;
607
608 let api = self.api.clone();
609 let dest = path.clone();
610 tokio::spawn(async move {
611 let bee = api.bee();
612 match bee.debug().loggers().await {
613 Ok(listing) => {
614 let mut rows = listing.loggers.clone();
615 rows.sort_by(|a, b| {
619 verbosity_rank(&b.verbosity)
620 .cmp(&verbosity_rank(&a.verbosity))
621 .then_with(|| a.logger.cmp(&b.logger))
622 });
623 let mut body = String::new();
624 body.push_str(&format!("# {} loggers registered\n", rows.len()));
625 body.push_str("# VERBOSITY LOGGER\n");
626 for r in &rows {
627 body.push_str(&format!(
628 " {:<9} {}\n",
629 r.verbosity, r.logger,
630 ));
631 }
632 body.push_str("# done.\n");
633 if let Err(e) = append(&dest, &body) {
634 let _ = append(&dest, &format!("# write error: {e}\n"));
635 }
636 }
637 Err(e) => {
638 let _ = append(&dest, &format!("# error: {e}\n"));
639 }
640 }
641 });
642 Ok(path)
643 }
644
645 fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
646 let bundle = self.render_diagnostic_bundle();
647 let secs = SystemTime::now()
648 .duration_since(UNIX_EPOCH)
649 .map(|d| d.as_secs())
650 .unwrap_or(0);
651 let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
652 std::fs::write(&path, bundle)?;
653 Ok(path)
654 }
655
656 fn render_diagnostic_bundle(&self) -> String {
657 let now = format_utc_now();
658 let health = self.health_rx.borrow().clone();
659 let topology = self.watch.topology().borrow().clone();
660 let gates = Health::gates_for(&health, Some(&topology));
661 let recent: Vec<_> = log_capture::handle()
662 .map(|c| {
663 let mut snap = c.snapshot();
664 let len = snap.len();
665 if len > 50 {
666 snap.drain(0..len - 50);
667 }
668 snap
669 })
670 .unwrap_or_default();
671
672 let mut out = String::new();
673 out.push_str("# bee-tui diagnostic bundle\n");
674 out.push_str(&format!("# generated UTC {now}\n\n"));
675 out.push_str("## profile\n");
676 out.push_str(&format!(" name {}\n", self.api.name));
677 out.push_str(&format!(" endpoint {}\n\n", self.api.url));
678 out.push_str("## health gates\n");
679 for g in &gates {
680 out.push_str(&format_gate_line(g));
681 }
682 out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
683 for e in &recent {
684 let status = e.status.map(|s| s.to_string()).unwrap_or_else(|| "—".into());
685 let elapsed = e
686 .elapsed_ms
687 .map(|ms| format!("{ms}ms"))
688 .unwrap_or_else(|| "—".into());
689 out.push_str(&format!(
690 " {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
691 ts = e.ts,
692 method = e.method,
693 path = path_only(&e.url),
694 status = status,
695 elapsed = elapsed,
696 ));
697 }
698 out.push_str(&format!(
699 "\n## generated by bee-tui {}\n",
700 env!("CARGO_PKG_VERSION"),
701 ));
702 out
703 }
704
705 fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
706 while let Ok(action) = self.action_rx.try_recv() {
707 if action != Action::Tick && action != Action::Render {
708 debug!("{action:?}");
709 }
710 match action {
711 Action::Tick => {
712 self.last_tick_key_events.drain(..);
713 }
714 Action::Quit => self.should_quit = true,
715 Action::Suspend => self.should_suspend = true,
716 Action::Resume => self.should_suspend = false,
717 Action::ClearScreen => tui.terminal.clear()?,
718 Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
719 Action::Render => self.render(tui)?,
720 _ => {}
721 }
722 let tx = self.action_tx.clone();
723 for component in self.iter_components_mut() {
724 if let Some(action) = component.update(action.clone())? {
725 tx.send(action)?
726 };
727 }
728 }
729 Ok(())
730 }
731
732 fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
733 tui.resize(Rect::new(0, 0, w, h))?;
734 self.render(tui)?;
735 Ok(())
736 }
737
738 fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
739 let active = self.current_screen;
740 let tx = self.action_tx.clone();
741 let screens = &mut self.screens;
742 let command_log = &mut self.command_log;
743 let command_buffer = self.command_buffer.clone();
744 let command_status = self.command_status.clone();
745 let help_visible = self.help_visible;
746 let profile = self.api.name.clone();
747 let endpoint = self.api.url.clone();
748 let last_ping = self.health_rx.borrow().last_ping;
749 let now_utc = format_utc_now();
750 tui.draw(|frame| {
751 use ratatui::layout::{Constraint, Layout};
752 use ratatui::style::{Color, Modifier, Style};
753 use ratatui::text::{Line, Span};
754 use ratatui::widgets::Paragraph;
755
756 let chunks = Layout::vertical([
757 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), Constraint::Length(8), ])
762 .split(frame.area());
763
764 let top_chunks =
765 Layout::vertical([Constraint::Length(1), Constraint::Length(1)])
766 .split(chunks[0]);
767
768 let ping_str = match last_ping {
770 Some(d) => format!("{}ms", d.as_millis()),
771 None => "—".into(),
772 };
773 let t = theme::active();
774 let metadata_line = Line::from(vec![
775 Span::styled(
776 " bee-tui ",
777 Style::default()
778 .fg(Color::Black)
779 .bg(t.info)
780 .add_modifier(Modifier::BOLD),
781 ),
782 Span::raw(" "),
783 Span::styled(
784 profile,
785 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
786 ),
787 Span::styled(
788 format!(" @ {endpoint}"),
789 Style::default().fg(t.dim),
790 ),
791 Span::raw(" "),
792 Span::styled("ping ", Style::default().fg(t.dim)),
793 Span::styled(ping_str, Style::default().fg(t.info)),
794 Span::raw(" "),
795 Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
796 ]);
797 frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
798
799 let theme = *theme::active();
801 let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
802 for (i, name) in SCREEN_NAMES.iter().enumerate() {
803 let style = if i == active {
804 Style::default()
805 .fg(theme.tab_active_fg)
806 .bg(theme.tab_active_bg)
807 .add_modifier(Modifier::BOLD)
808 } else {
809 Style::default().fg(theme.dim)
810 };
811 tabs.push(Span::styled(format!(" {name} "), style));
812 tabs.push(Span::raw(" "));
813 }
814 tabs.push(Span::styled(
815 ":cmd · Tab to cycle · ? help",
816 Style::default().fg(theme.dim),
817 ));
818 frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
819
820 if let Some(screen) = screens.get_mut(active) {
822 if let Err(err) = screen.draw(frame, chunks[1]) {
823 let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
824 }
825 }
826 let prompt = if let Some(buf) = &command_buffer {
828 Line::from(vec![
829 Span::styled(
830 ":",
831 Style::default()
832 .fg(t.accent)
833 .add_modifier(Modifier::BOLD),
834 ),
835 Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
836 Span::styled("█", Style::default().fg(t.accent)),
837 ])
838 } else {
839 match &command_status {
840 Some(CommandStatus::Info(msg)) => {
841 Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
842 }
843 Some(CommandStatus::Err(msg)) => {
844 Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
845 }
846 None => Line::from(""),
847 }
848 };
849 frame.render_widget(Paragraph::new(prompt), chunks[2]);
850
851 if let Err(err) = command_log.draw(frame, chunks[3]) {
853 let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
854 }
855
856 if help_visible {
861 draw_help_overlay(frame, frame.area(), active, &theme);
862 }
863 })?;
864 Ok(())
865 }
866}
867
868fn draw_help_overlay(
873 frame: &mut ratatui::Frame,
874 area: ratatui::layout::Rect,
875 active_screen: usize,
876 theme: &theme::Theme,
877) {
878 use ratatui::layout::Rect;
879 use ratatui::style::{Modifier, Style};
880 use ratatui::text::{Line, Span};
881 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
882
883 let screen_name = SCREEN_NAMES.get(active_screen).copied().unwrap_or("?");
884 let screen_rows = screen_keymap(active_screen);
885 let global_rows: &[(&str, &str)] = &[
886 ("Tab", "next screen"),
887 ("?", "toggle this help"),
888 (":", "open command bar"),
889 ("q / Ctrl+C", "quit"),
890 ];
891
892 let w = area.width.min(72);
895 let h = area.height.min(22);
896 let x = area.x + (area.width.saturating_sub(w)) / 2;
897 let y = area.y + (area.height.saturating_sub(h)) / 2;
898 let rect = Rect { x, y, width: w, height: h };
899
900 let mut lines: Vec<Line> = Vec::new();
901 lines.push(Line::from(vec![
902 Span::styled(
903 format!(" {screen_name} "),
904 Style::default()
905 .fg(theme.tab_active_fg)
906 .bg(theme.tab_active_bg)
907 .add_modifier(Modifier::BOLD),
908 ),
909 Span::raw(" screen-specific keys"),
910 ]));
911 lines.push(Line::from(""));
912 if screen_rows.is_empty() {
913 lines.push(Line::from(Span::styled(
914 " (no extra keys for this screen — use the command bar via :)",
915 Style::default().fg(theme.dim).add_modifier(Modifier::ITALIC),
916 )));
917 } else {
918 for (key, desc) in screen_rows {
919 lines.push(format_help_row(key, desc, theme));
920 }
921 }
922 lines.push(Line::from(""));
923 lines.push(Line::from(Span::styled(
924 " global",
925 Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
926 )));
927 for (key, desc) in global_rows {
928 lines.push(format_help_row(key, desc, theme));
929 }
930 lines.push(Line::from(""));
931 lines.push(Line::from(Span::styled(
932 " Esc / ? / q to dismiss",
933 Style::default().fg(theme.dim).add_modifier(Modifier::ITALIC),
934 )));
935
936 frame.render_widget(Clear, rect);
939 frame.render_widget(
940 Paragraph::new(lines).block(
941 Block::default()
942 .borders(Borders::ALL)
943 .border_style(Style::default().fg(theme.accent))
944 .title(" help "),
945 ),
946 rect,
947 );
948}
949
950fn format_help_row<'a>(
951 key: &'a str,
952 desc: &'a str,
953 theme: &theme::Theme,
954) -> ratatui::text::Line<'a> {
955 use ratatui::style::{Modifier, Style};
956 use ratatui::text::{Line, Span};
957 Line::from(vec![
958 Span::raw(" "),
959 Span::styled(
960 format!("{key:<14}"),
961 Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
962 ),
963 Span::raw(" "),
964 Span::raw(desc),
965 ])
966}
967
968fn screen_keymap(active_screen: usize) -> &'static [(&'static str, &'static str)] {
972 match active_screen {
973 1 => &[
975 ("↑↓ / j k", "move row selection"),
976 ("Enter", "drill batch — bucket histogram + worst-N"),
977 ("Esc", "close drill"),
978 ],
979 3 => &[
981 ("r", "run on-demand rchash benchmark"),
982 ],
983 4 => &[
984 ("↑↓ / j k", "move peer selection"),
985 ("Enter", "drill peer — balance / cheques / settlement / ping"),
986 ("Esc", "close drill"),
987 ],
988 8 => &[
992 ("↑↓ / j k", "scroll one row"),
993 ("PgUp / PgDn", "scroll ten rows"),
994 ("Home", "back to top"),
995 ],
996 _ => &[],
997 }
998}
999
1000fn build_screens(api: &Arc<ApiClient>, watch: &BeeWatch) -> Vec<Box<dyn Component>> {
1009 let health = Health::new(api.clone(), watch.health(), watch.topology());
1010 let stamps = Stamps::new(api.clone(), watch.stamps());
1011 let swap = Swap::new(watch.swap());
1012 let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
1013 let peers = Peers::new(api.clone(), watch.topology());
1014 let network = Network::new(watch.network(), watch.topology());
1015 let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
1016 let api_health = ApiHealth::new(
1017 api.clone(),
1018 watch.health(),
1019 watch.transactions(),
1020 log_capture::handle(),
1021 );
1022 let tags = Tags::new(watch.tags());
1023 vec![
1024 Box::new(health),
1025 Box::new(stamps),
1026 Box::new(swap),
1027 Box::new(lottery),
1028 Box::new(peers),
1029 Box::new(network),
1030 Box::new(warmup),
1031 Box::new(api_health),
1032 Box::new(tags),
1033 ]
1034}
1035
1036fn format_gate_line(g: &Gate) -> String {
1037 let glyphs = crate::theme::active().glyphs;
1038 let glyph = match g.status {
1039 GateStatus::Pass => glyphs.pass,
1040 GateStatus::Warn => glyphs.warn,
1041 GateStatus::Fail => glyphs.fail,
1042 GateStatus::Unknown => glyphs.bullet,
1043 };
1044 let mut s = format!(" [{glyph}] {label:<28} {value}\n", label = g.label, value = g.value);
1045 if let Some(why) = &g.why {
1046 s.push_str(&format!(" {} {why}\n", glyphs.continuation));
1047 }
1048 s
1049}
1050
1051fn path_only(url: &str) -> String {
1054 if let Some(idx) = url.find("//") {
1055 let after_scheme = &url[idx + 2..];
1056 if let Some(slash) = after_scheme.find('/') {
1057 return after_scheme[slash..].to_string();
1058 }
1059 return "/".into();
1060 }
1061 url.to_string()
1062}
1063
1064fn append(path: &PathBuf, s: &str) -> std::io::Result<()> {
1071 use std::io::Write;
1072 let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
1073 f.write_all(s.as_bytes())
1074}
1075
1076fn verbosity_rank(s: &str) -> u8 {
1082 match s {
1083 "all" | "trace" => 5,
1084 "debug" => 4,
1085 "info" | "1" => 3,
1086 "warning" | "warn" | "2" => 2,
1087 "error" | "3" => 1,
1088 _ => 0,
1089 }
1090}
1091
1092fn sanitize_for_filename(s: &str) -> String {
1096 s.chars()
1097 .map(|c| match c {
1098 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
1099 _ => '-',
1100 })
1101 .collect()
1102}
1103
1104fn format_utc_now() -> String {
1105 let secs = SystemTime::now()
1106 .duration_since(UNIX_EPOCH)
1107 .map(|d| d.as_secs())
1108 .unwrap_or(0);
1109 let secs_in_day = secs % 86_400;
1110 let h = secs_in_day / 3_600;
1111 let m = (secs_in_day % 3_600) / 60;
1112 let s = secs_in_day % 60;
1113 format!("{h:02}:{m:02}:{s:02}")
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118 use super::*;
1119
1120 #[test]
1121 fn format_utc_now_returns_eight_chars() {
1122 let s = format_utc_now();
1123 assert_eq!(s.len(), 8);
1124 assert_eq!(s.chars().nth(2), Some(':'));
1125 assert_eq!(s.chars().nth(5), Some(':'));
1126 }
1127
1128 #[test]
1129 fn path_only_strips_scheme_and_host() {
1130 assert_eq!(
1131 path_only("http://10.0.1.5:1633/status"),
1132 "/status"
1133 );
1134 assert_eq!(
1135 path_only("https://bee.example.com/stamps?limit=10"),
1136 "/stamps?limit=10"
1137 );
1138 }
1139
1140 #[test]
1141 fn path_only_handles_no_path() {
1142 assert_eq!(path_only("http://localhost:1633"), "/");
1143 }
1144
1145 #[test]
1146 fn path_only_passes_relative_through() {
1147 assert_eq!(path_only("/already/relative"), "/already/relative");
1148 }
1149
1150 #[test]
1151 fn sanitize_for_filename_keeps_safe_chars() {
1152 assert_eq!(sanitize_for_filename("prod-1"), "prod-1");
1153 assert_eq!(sanitize_for_filename("lab_node"), "lab_node");
1154 }
1155
1156 #[test]
1157 fn sanitize_for_filename_replaces_unsafe_chars() {
1158 assert_eq!(sanitize_for_filename("a/b\\c d"), "a-b-c-d");
1159 assert_eq!(sanitize_for_filename("name:colon"), "name-colon");
1160 }
1161
1162 #[test]
1163 fn screen_keymap_covers_drill_screens() {
1164 for idx in [1usize, 4] {
1167 let rows = screen_keymap(idx);
1168 assert!(
1169 rows.iter().any(|(k, _)| k.contains("Enter")),
1170 "screen {idx} keymap must mention Enter (drill)"
1171 );
1172 assert!(
1173 rows.iter().any(|(k, _)| k.contains("Esc")),
1174 "screen {idx} keymap must mention Esc (close drill)"
1175 );
1176 }
1177 }
1178
1179 #[test]
1180 fn screen_keymap_lottery_advertises_rchash() {
1181 let rows = screen_keymap(3);
1182 assert!(rows.iter().any(|(k, _)| k.contains("r")));
1183 }
1184
1185 #[test]
1186 fn screen_keymap_unknown_index_is_empty_not_panic() {
1187 assert!(screen_keymap(999).is_empty());
1188 }
1189
1190 #[test]
1191 fn verbosity_rank_orders_loud_to_silent() {
1192 assert!(verbosity_rank("all") > verbosity_rank("debug"));
1193 assert!(verbosity_rank("debug") > verbosity_rank("info"));
1194 assert!(verbosity_rank("info") > verbosity_rank("warning"));
1195 assert!(verbosity_rank("warning") > verbosity_rank("error"));
1196 assert!(verbosity_rank("error") > verbosity_rank("unknown"));
1197 assert_eq!(verbosity_rank("info"), verbosity_rank("1"));
1199 assert_eq!(verbosity_rank("warning"), verbosity_rank("2"));
1200 }
1201}