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}
68
69#[derive(Debug, Clone)]
72pub enum CommandStatus {
73 Info(String),
74 Err(String),
75}
76
77const SCREEN_NAMES: &[&str] = &[
80 "Health", "Stamps", "Swap", "Lottery", "Peers", "Network", "Warmup", "API", "Tags",
81];
82
83#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
84pub enum Mode {
85 #[default]
86 Home,
87}
88
89impl App {
90 pub fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
91 let (action_tx, action_rx) = mpsc::unbounded_channel();
92 let config = Config::new()?;
93 theme::install(&config.ui);
96
97 let node = config
99 .active_node()
100 .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
101 let api = Arc::new(ApiClient::from_node(node)?);
102
103 let root_cancel = CancellationToken::new();
106 let watch = BeeWatch::start(api.clone(), &root_cancel);
107 let health_rx = watch.health();
108
109 let screens = build_screens(&api, &watch);
110 let command_log: Box<dyn Component> = Box::new(CommandLog::new(log_capture::handle()));
115
116 Ok(Self {
117 tick_rate,
118 frame_rate,
119 screens,
120 current_screen: 0,
121 command_log,
122 should_quit: false,
123 should_suspend: false,
124 config,
125 mode: Mode::Home,
126 last_tick_key_events: Vec::new(),
127 action_tx,
128 action_rx,
129 root_cancel,
130 api,
131 watch,
132 health_rx,
133 command_buffer: None,
134 command_status: None,
135 })
136 }
137
138 pub async fn run(&mut self) -> color_eyre::Result<()> {
139 let mut tui = Tui::new()?
140 .tick_rate(self.tick_rate)
142 .frame_rate(self.frame_rate);
143 tui.enter()?;
144
145 let tx = self.action_tx.clone();
146 let cfg = self.config.clone();
147 let size = tui.size()?;
148 for component in self.iter_components_mut() {
149 component.register_action_handler(tx.clone())?;
150 component.register_config_handler(cfg.clone())?;
151 component.init(size)?;
152 }
153
154 let action_tx = self.action_tx.clone();
155 loop {
156 self.handle_events(&mut tui).await?;
157 self.handle_actions(&mut tui)?;
158 if self.should_suspend {
159 tui.suspend()?;
160 action_tx.send(Action::Resume)?;
161 action_tx.send(Action::ClearScreen)?;
162 tui.enter()?;
164 } else if self.should_quit {
165 tui.stop()?;
166 break;
167 }
168 }
169 self.watch.shutdown();
171 self.root_cancel.cancel();
172 tui.exit()?;
173 Ok(())
174 }
175
176 async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
177 let Some(event) = tui.next_event().await else {
178 return Ok(());
179 };
180 let action_tx = self.action_tx.clone();
181 let in_command_mode = self.command_buffer.is_some();
182 match event {
183 Event::Quit => action_tx.send(Action::Quit)?,
184 Event::Tick => action_tx.send(Action::Tick)?,
185 Event::Render => action_tx.send(Action::Render)?,
186 Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
187 Event::Key(key) => self.handle_key_event(key)?,
188 _ => {}
189 }
190 let propagate = !(in_command_mode && matches!(event, Event::Key(_)));
195 if propagate {
196 for component in self.iter_components_mut() {
197 if let Some(action) = component.handle_events(Some(event.clone()))? {
198 action_tx.send(action)?;
199 }
200 }
201 }
202 Ok(())
203 }
204
205 fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut Box<dyn Component>> {
209 self.screens
210 .iter_mut()
211 .chain(std::iter::once(&mut self.command_log))
212 }
213
214 fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
215 if self.command_buffer.is_some() {
219 self.handle_command_mode_key(key)?;
220 return Ok(());
221 }
222 let action_tx = self.action_tx.clone();
223 if matches!(
225 key.code,
226 crossterm::event::KeyCode::Char(':')
227 ) {
228 self.command_buffer = Some(String::new());
229 self.command_status = None;
230 return Ok(());
231 }
232 if matches!(key.code, crossterm::event::KeyCode::Tab) {
235 if !self.screens.is_empty() {
236 self.current_screen = (self.current_screen + 1) % self.screens.len();
237 debug!(
238 "switched to screen {}",
239 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
240 );
241 }
242 return Ok(());
243 }
244 let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
245 return Ok(());
246 };
247 match keymap.get(&vec![key]) {
248 Some(action) => {
249 info!("Got action: {action:?}");
250 action_tx.send(action.clone())?;
251 }
252 _ => {
253 self.last_tick_key_events.push(key);
256
257 if let Some(action) = keymap.get(&self.last_tick_key_events) {
259 info!("Got action: {action:?}");
260 action_tx.send(action.clone())?;
261 }
262 }
263 }
264 Ok(())
265 }
266
267 fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
268 use crossterm::event::KeyCode;
269 let buf = match self.command_buffer.as_mut() {
270 Some(b) => b,
271 None => return Ok(()),
272 };
273 match key.code {
274 KeyCode::Esc => {
275 self.command_buffer = None;
277 }
278 KeyCode::Enter => {
279 let line = std::mem::take(buf);
280 self.command_buffer = None;
281 self.execute_command(&line)?;
282 }
283 KeyCode::Backspace => {
284 buf.pop();
285 }
286 KeyCode::Char(c) => {
287 buf.push(c);
288 }
289 _ => {}
290 }
291 Ok(())
292 }
293
294 fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
297 let trimmed = line.trim();
298 if trimmed.is_empty() {
299 return Ok(());
300 }
301 let head = trimmed.split_whitespace().next().unwrap_or("");
302 match head {
303 "q" | "quit" => {
304 self.action_tx.send(Action::Quit)?;
305 self.command_status = Some(CommandStatus::Info("quitting".into()));
306 }
307 "diagnose" | "diag" => {
308 self.command_status = Some(match self.export_diagnostic_bundle() {
309 Ok(path) => CommandStatus::Info(format!(
310 "diagnostic bundle exported to {}",
311 path.display()
312 )),
313 Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
314 });
315 }
316 "context" | "ctx" => {
317 let target = trimmed.split_whitespace().nth(1).unwrap_or("");
318 if target.is_empty() {
319 let known: Vec<String> = self
320 .config
321 .nodes
322 .iter()
323 .map(|n| n.name.clone())
324 .collect();
325 self.command_status = Some(CommandStatus::Err(format!(
326 "usage: :context <name> (known: {})",
327 known.join(", ")
328 )));
329 return Ok(());
330 }
331 self.command_status = Some(match self.switch_context(target) {
332 Ok(()) => CommandStatus::Info(format!(
333 "switched to context {target} ({})",
334 self.api.url
335 )),
336 Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
337 });
338 }
339 screen if SCREEN_NAMES
340 .iter()
341 .any(|name| name.eq_ignore_ascii_case(screen)) =>
342 {
343 if let Some(idx) = SCREEN_NAMES
344 .iter()
345 .position(|name| name.eq_ignore_ascii_case(screen))
346 {
347 self.current_screen = idx;
348 self.command_status =
349 Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
350 }
351 }
352 other => {
353 self.command_status = Some(CommandStatus::Err(format!(
354 "unknown command: {other:?} (try :health, :stamps, :swap, :lottery, :peers, :network, :warmup, :api, :diagnose, :quit)"
355 )));
356 }
357 }
358 Ok(())
359 }
360
361 fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
368 let node = self
369 .config
370 .nodes
371 .iter()
372 .find(|n| n.name == target)
373 .ok_or_else(|| eyre!("no node configured with name {target:?}"))?
374 .clone();
375 let new_api = Arc::new(ApiClient::from_node(&node)?);
376 self.watch.shutdown();
380 let new_watch = BeeWatch::start(new_api.clone(), &self.root_cancel);
381 let new_health_rx = new_watch.health();
382 let new_screens = build_screens(&new_api, &new_watch);
383 self.api = new_api;
384 self.watch = new_watch;
385 self.health_rx = new_health_rx;
386 self.screens = new_screens;
387 Ok(())
390 }
391
392 fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
398 let bundle = self.render_diagnostic_bundle();
399 let secs = SystemTime::now()
400 .duration_since(UNIX_EPOCH)
401 .map(|d| d.as_secs())
402 .unwrap_or(0);
403 let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
404 std::fs::write(&path, bundle)?;
405 Ok(path)
406 }
407
408 fn render_diagnostic_bundle(&self) -> String {
409 let now = format_utc_now();
410 let health = self.health_rx.borrow().clone();
411 let topology = self.watch.topology().borrow().clone();
412 let gates = Health::gates_for(&health, Some(&topology));
413 let recent: Vec<_> = log_capture::handle()
414 .map(|c| {
415 let mut snap = c.snapshot();
416 let len = snap.len();
417 if len > 50 {
418 snap.drain(0..len - 50);
419 }
420 snap
421 })
422 .unwrap_or_default();
423
424 let mut out = String::new();
425 out.push_str("# bee-tui diagnostic bundle\n");
426 out.push_str(&format!("# generated UTC {now}\n\n"));
427 out.push_str("## profile\n");
428 out.push_str(&format!(" name {}\n", self.api.name));
429 out.push_str(&format!(" endpoint {}\n\n", self.api.url));
430 out.push_str("## health gates\n");
431 for g in &gates {
432 out.push_str(&format_gate_line(g));
433 }
434 out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
435 for e in &recent {
436 let status = e.status.map(|s| s.to_string()).unwrap_or_else(|| "—".into());
437 let elapsed = e
438 .elapsed_ms
439 .map(|ms| format!("{ms}ms"))
440 .unwrap_or_else(|| "—".into());
441 out.push_str(&format!(
442 " {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
443 ts = e.ts,
444 method = e.method,
445 path = path_only(&e.url),
446 status = status,
447 elapsed = elapsed,
448 ));
449 }
450 out.push_str(&format!(
451 "\n## generated by bee-tui {}\n",
452 env!("CARGO_PKG_VERSION"),
453 ));
454 out
455 }
456
457 fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
458 while let Ok(action) = self.action_rx.try_recv() {
459 if action != Action::Tick && action != Action::Render {
460 debug!("{action:?}");
461 }
462 match action {
463 Action::Tick => {
464 self.last_tick_key_events.drain(..);
465 }
466 Action::Quit => self.should_quit = true,
467 Action::Suspend => self.should_suspend = true,
468 Action::Resume => self.should_suspend = false,
469 Action::ClearScreen => tui.terminal.clear()?,
470 Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
471 Action::Render => self.render(tui)?,
472 _ => {}
473 }
474 let tx = self.action_tx.clone();
475 for component in self.iter_components_mut() {
476 if let Some(action) = component.update(action.clone())? {
477 tx.send(action)?
478 };
479 }
480 }
481 Ok(())
482 }
483
484 fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
485 tui.resize(Rect::new(0, 0, w, h))?;
486 self.render(tui)?;
487 Ok(())
488 }
489
490 fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
491 let active = self.current_screen;
492 let tx = self.action_tx.clone();
493 let screens = &mut self.screens;
494 let command_log = &mut self.command_log;
495 let command_buffer = self.command_buffer.clone();
496 let command_status = self.command_status.clone();
497 let profile = self.api.name.clone();
498 let endpoint = self.api.url.clone();
499 let last_ping = self.health_rx.borrow().last_ping;
500 let now_utc = format_utc_now();
501 tui.draw(|frame| {
502 use ratatui::layout::{Constraint, Layout};
503 use ratatui::style::{Color, Modifier, Style};
504 use ratatui::text::{Line, Span};
505 use ratatui::widgets::Paragraph;
506
507 let chunks = Layout::vertical([
508 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), Constraint::Length(8), ])
513 .split(frame.area());
514
515 let top_chunks =
516 Layout::vertical([Constraint::Length(1), Constraint::Length(1)])
517 .split(chunks[0]);
518
519 let ping_str = match last_ping {
521 Some(d) => format!("{}ms", d.as_millis()),
522 None => "—".into(),
523 };
524 let t = theme::active();
525 let metadata_line = Line::from(vec![
526 Span::styled(
527 " bee-tui ",
528 Style::default()
529 .fg(Color::Black)
530 .bg(t.info)
531 .add_modifier(Modifier::BOLD),
532 ),
533 Span::raw(" "),
534 Span::styled(
535 profile,
536 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
537 ),
538 Span::styled(
539 format!(" @ {endpoint}"),
540 Style::default().fg(t.dim),
541 ),
542 Span::raw(" "),
543 Span::styled("ping ", Style::default().fg(t.dim)),
544 Span::styled(ping_str, Style::default().fg(t.info)),
545 Span::raw(" "),
546 Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
547 ]);
548 frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
549
550 let theme = *theme::active();
552 let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
553 for (i, name) in SCREEN_NAMES.iter().enumerate() {
554 let style = if i == active {
555 Style::default()
556 .fg(theme.tab_active_fg)
557 .bg(theme.tab_active_bg)
558 .add_modifier(Modifier::BOLD)
559 } else {
560 Style::default().fg(theme.dim)
561 };
562 tabs.push(Span::styled(format!(" {name} "), style));
563 tabs.push(Span::raw(" "));
564 }
565 tabs.push(Span::styled(
566 ":cmd · Tab to cycle",
567 Style::default().fg(theme.dim),
568 ));
569 frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
570
571 if let Some(screen) = screens.get_mut(active) {
573 if let Err(err) = screen.draw(frame, chunks[1]) {
574 let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
575 }
576 }
577 let prompt = if let Some(buf) = &command_buffer {
579 Line::from(vec![
580 Span::styled(
581 ":",
582 Style::default()
583 .fg(t.accent)
584 .add_modifier(Modifier::BOLD),
585 ),
586 Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
587 Span::styled("█", Style::default().fg(t.accent)),
588 ])
589 } else {
590 match &command_status {
591 Some(CommandStatus::Info(msg)) => {
592 Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
593 }
594 Some(CommandStatus::Err(msg)) => {
595 Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
596 }
597 None => Line::from(""),
598 }
599 };
600 frame.render_widget(Paragraph::new(prompt), chunks[2]);
601
602 if let Err(err) = command_log.draw(frame, chunks[3]) {
604 let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
605 }
606 })?;
607 Ok(())
608 }
609}
610
611fn build_screens(api: &Arc<ApiClient>, watch: &BeeWatch) -> Vec<Box<dyn Component>> {
620 let health = Health::new(api.clone(), watch.health(), watch.topology());
621 let stamps = Stamps::new(watch.stamps());
622 let swap = Swap::new(watch.swap());
623 let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
624 let peers = Peers::new(watch.topology());
625 let network = Network::new(watch.network(), watch.topology());
626 let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
627 let api_health = ApiHealth::new(
628 api.clone(),
629 watch.health(),
630 watch.transactions(),
631 log_capture::handle(),
632 );
633 let tags = Tags::new(watch.tags());
634 vec![
635 Box::new(health),
636 Box::new(stamps),
637 Box::new(swap),
638 Box::new(lottery),
639 Box::new(peers),
640 Box::new(network),
641 Box::new(warmup),
642 Box::new(api_health),
643 Box::new(tags),
644 ]
645}
646
647fn format_gate_line(g: &Gate) -> String {
648 let glyph = match g.status {
649 GateStatus::Pass => "✓",
650 GateStatus::Warn => "⚠",
651 GateStatus::Fail => "✗",
652 GateStatus::Unknown => "·",
653 };
654 let mut s = format!(" [{glyph}] {label:<28} {value}\n", label = g.label, value = g.value);
655 if let Some(why) = &g.why {
656 s.push_str(&format!(" └─ {why}\n"));
657 }
658 s
659}
660
661fn path_only(url: &str) -> String {
664 if let Some(idx) = url.find("//") {
665 let after_scheme = &url[idx + 2..];
666 if let Some(slash) = after_scheme.find('/') {
667 return after_scheme[slash..].to_string();
668 }
669 return "/".into();
670 }
671 url.to_string()
672}
673
674fn format_utc_now() -> String {
678 let secs = SystemTime::now()
679 .duration_since(UNIX_EPOCH)
680 .map(|d| d.as_secs())
681 .unwrap_or(0);
682 let secs_in_day = secs % 86_400;
683 let h = secs_in_day / 3_600;
684 let m = (secs_in_day % 3_600) / 60;
685 let s = secs_in_day % 60;
686 format!("{h:02}:{m:02}:{s:02}")
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 #[test]
694 fn format_utc_now_returns_eight_chars() {
695 let s = format_utc_now();
696 assert_eq!(s.len(), 8);
697 assert_eq!(s.chars().nth(2), Some(':'));
698 assert_eq!(s.chars().nth(5), Some(':'));
699 }
700
701 #[test]
702 fn path_only_strips_scheme_and_host() {
703 assert_eq!(
704 path_only("http://10.0.1.5:1633/status"),
705 "/status"
706 );
707 assert_eq!(
708 path_only("https://bee.example.com/stamps?limit=10"),
709 "/stamps?limit=10"
710 );
711 }
712
713 #[test]
714 fn path_only_handles_no_path() {
715 assert_eq!(path_only("http://localhost:1633"), "/");
716 }
717
718 #[test]
719 fn path_only_passes_relative_through() {
720 assert_eq!(path_only("/already/relative"), "/already/relative");
721 }
722}