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