1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::{Duration, Instant, 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 bee_supervisor::{BeeStatus, BeeSupervisor},
17 components::{
18 Component,
19 api_health::ApiHealth,
20 health::{Gate, GateStatus, Health},
21 log_pane::{BeeLogLine, LogPane, LogTab},
22 lottery::Lottery,
23 network::Network,
24 peers::Peers,
25 pins::Pins,
26 stamps::Stamps,
27 swap::Swap,
28 tags::Tags,
29 warmup::Warmup,
30 },
31 config::Config,
32 log_capture, stamp_preview,
33 state::State,
34 theme,
35 tui::{Event, Tui},
36 watch::{BeeWatch, HealthSnapshot, RefreshProfile},
37};
38
39pub struct App {
40 config: Config,
41 tick_rate: f64,
42 frame_rate: f64,
43 screens: Vec<Box<dyn Component>>,
47 current_screen: usize,
49 log_pane: LogPane,
53 state_path: PathBuf,
56 should_quit: bool,
57 should_suspend: bool,
58 mode: Mode,
59 last_tick_key_events: Vec<KeyEvent>,
60 action_tx: mpsc::UnboundedSender<Action>,
61 action_rx: mpsc::UnboundedReceiver<Action>,
62 root_cancel: CancellationToken,
65 #[allow(dead_code)]
68 api: Arc<ApiClient>,
69 watch: BeeWatch,
71 health_rx: watch::Receiver<HealthSnapshot>,
74 command_buffer: Option<String>,
77 command_suggestion_index: usize,
82 command_status: Option<CommandStatus>,
86 help_visible: bool,
89 quit_pending: Option<Instant>,
95 supervisor: Option<BeeSupervisor>,
99 bee_status: BeeStatus,
104 bee_log_rx: Option<mpsc::UnboundedReceiver<(LogTab, BeeLogLine)>>,
108 cmd_status_tx: mpsc::UnboundedSender<CommandStatus>,
114 cmd_status_rx: mpsc::UnboundedReceiver<CommandStatus>,
115}
116
117const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
120
121#[derive(Debug, Clone)]
124pub enum CommandStatus {
125 Info(String),
126 Err(String),
127}
128
129const SCREEN_NAMES: &[&str] = &[
132 "Health", "Stamps", "Swap", "Lottery", "Peers", "Network", "Warmup", "API", "Tags", "Pins",
133];
134
135const KNOWN_COMMANDS: &[(&str, &str)] = &[
146 ("health", "S1 Health screen"),
147 ("stamps", "S2 Stamps screen"),
148 ("swap", "S3 SWAP / cheques screen"),
149 ("lottery", "S4 Lottery + rchash"),
150 ("peers", "S6 Peers + bin saturation"),
151 ("network", "S7 Network / NAT"),
152 ("warmup", "S5 Warmup checklist"),
153 ("api", "S8 RPC / API health"),
154 ("tags", "S9 Tags / uploads"),
155 ("pins", "S11 Pins screen"),
156 ("topup-preview", "<batch> <amount-plur> — predict topup"),
157 ("dilute-preview", "<batch> <new-depth> — predict dilute"),
158 ("extend-preview", "<batch> <duration> — predict extend"),
159 ("buy-preview", "<depth> <amount-plur> — predict fresh buy"),
160 ("buy-suggest", "<size> <duration> — minimum (depth, amount)"),
161 (
162 "probe-upload",
163 "<batch> — single 4 KiB chunk, end-to-end probe",
164 ),
165 ("diagnose", "Export full snapshot to a file"),
166 ("pins-check", "Bulk integrity walk to a file"),
167 ("loggers", "Dump live logger registry"),
168 ("set-logger", "<expr> <level> — change a logger's verbosity"),
169 ("context", "<name> — switch node profile"),
170 ("quit", "Exit the cockpit"),
171];
172
173fn filter_command_suggestions<'a>(
177 buffer: &str,
178 catalog: &'a [(&'a str, &'a str)],
179) -> Vec<&'a (&'a str, &'a str)> {
180 let head = buffer
181 .split_whitespace()
182 .next()
183 .unwrap_or("")
184 .to_ascii_lowercase();
185 catalog
186 .iter()
187 .filter(|(name, _)| name.starts_with(&head))
188 .collect()
189}
190
191#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
192pub enum Mode {
193 #[default]
194 Home,
195}
196
197#[derive(Debug, Default)]
200pub struct AppOverrides {
201 pub ascii: bool,
203 pub no_color: bool,
205 pub bee_bin: Option<PathBuf>,
207 pub bee_config: Option<PathBuf>,
209}
210
211const BEE_API_READY_TIMEOUT: Duration = Duration::from_secs(60);
216
217impl App {
218 pub async fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
219 Self::with_overrides(tick_rate, frame_rate, AppOverrides::default()).await
220 }
221
222 pub async fn with_overrides(
227 tick_rate: f64,
228 frame_rate: f64,
229 overrides: AppOverrides,
230 ) -> color_eyre::Result<Self> {
231 let (action_tx, action_rx) = mpsc::unbounded_channel();
232 let (cmd_status_tx, cmd_status_rx) = mpsc::unbounded_channel();
233 let config = Config::new()?;
234 let force_no_color = overrides.no_color || theme::no_color_env();
237 theme::install_with_overrides(&config.ui, force_no_color, overrides.ascii);
238
239 let node = config
242 .active_node()
243 .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
244 let api = Arc::new(ApiClient::from_node(node)?);
245
246 let bee_bin = overrides
248 .bee_bin
249 .or_else(|| config.bee.as_ref().map(|b| b.bin.clone()));
250 let bee_config = overrides
251 .bee_config
252 .or_else(|| config.bee.as_ref().map(|b| b.config.clone()));
253 let bee_logs = config
256 .bee
257 .as_ref()
258 .map(|b| b.logs.clone())
259 .unwrap_or_default();
260 let supervisor = match (bee_bin, bee_config) {
261 (Some(bin), Some(cfg)) => {
262 eprintln!("bee-tui: spawning bee {bin:?} --config {cfg:?}");
263 let mut sup = BeeSupervisor::spawn(&bin, &cfg, bee_logs)?;
264 eprintln!(
265 "bee-tui: log → {} (will appear in the cockpit's bottom pane)",
266 sup.log_path().display()
267 );
268 eprintln!(
269 "bee-tui: waiting for {} to respond on /health (up to {:?})...",
270 api.url, BEE_API_READY_TIMEOUT
271 );
272 sup.wait_for_api(&api.url, BEE_API_READY_TIMEOUT).await?;
273 eprintln!("bee-tui: bee ready, opening cockpit");
274 Some(sup)
275 }
276 (Some(_), None) | (None, Some(_)) => {
277 return Err(eyre!(
278 "[bee].bin and [bee].config must both be set (or both unset). \
279 Use --bee-bin AND --bee-config, or both fields in config.toml."
280 ));
281 }
282 (None, None) => None,
283 };
284
285 let refresh = RefreshProfile::from_config(&config.ui.refresh);
292 let root_cancel = CancellationToken::new();
293 let watch = BeeWatch::start_with_profile(api.clone(), &root_cancel, refresh);
294 let health_rx = watch.health();
295
296 let screens = build_screens(&api, &watch);
297 let (persisted, state_path) = State::load();
302 let initial_tab = LogTab::from_kebab(&persisted.log_pane_active_tab);
303 let mut log_pane = LogPane::new(
304 log_capture::handle(),
305 initial_tab,
306 persisted.log_pane_height,
307 );
308 log_pane.set_spawn_active(supervisor.is_some());
309
310 let bee_log_rx = supervisor.as_ref().map(|sup| {
316 let (tx, rx) = mpsc::unbounded_channel();
317 crate::bee_log_tailer::spawn(
318 sup.log_path().to_path_buf(),
319 tx,
320 root_cancel.child_token(),
321 );
322 rx
323 });
324
325 if config.metrics.enabled {
332 match config.metrics.addr.parse::<std::net::SocketAddr>() {
333 Ok(bind_addr) => {
334 let render_fn = build_metrics_render_fn(watch.clone(), log_capture::handle());
335 let cancel = root_cancel.child_token();
336 match crate::metrics_server::spawn(bind_addr, render_fn, cancel).await {
337 Ok(actual) => {
338 eprintln!(
339 "bee-tui: metrics endpoint serving /metrics on http://{actual}"
340 );
341 }
342 Err(e) => {
343 tracing::error!(
344 "metrics: failed to start endpoint on {bind_addr}: {e}"
345 );
346 }
347 }
348 }
349 Err(e) => {
350 tracing::error!(
351 "metrics: invalid [metrics].addr {:?}: {e}",
352 config.metrics.addr
353 );
354 }
355 }
356 }
357
358 Ok(Self {
359 tick_rate,
360 frame_rate,
361 screens,
362 current_screen: 0,
363 log_pane,
364 state_path,
365 should_quit: false,
366 should_suspend: false,
367 config,
368 mode: Mode::Home,
369 last_tick_key_events: Vec::new(),
370 action_tx,
371 action_rx,
372 root_cancel,
373 api,
374 watch,
375 health_rx,
376 command_buffer: None,
377 command_suggestion_index: 0,
378 command_status: None,
379 help_visible: false,
380 quit_pending: None,
381 supervisor,
382 bee_status: BeeStatus::Running,
383 bee_log_rx,
384 cmd_status_tx,
385 cmd_status_rx,
386 })
387 }
388
389 pub async fn run(&mut self) -> color_eyre::Result<()> {
390 let mut tui = Tui::new()?
391 .tick_rate(self.tick_rate)
393 .frame_rate(self.frame_rate);
394 tui.enter()?;
395
396 let tx = self.action_tx.clone();
397 let cfg = self.config.clone();
398 let size = tui.size()?;
399 for component in self.iter_components_mut() {
400 component.register_action_handler(tx.clone())?;
401 component.register_config_handler(cfg.clone())?;
402 component.init(size)?;
403 }
404
405 let action_tx = self.action_tx.clone();
406 loop {
407 self.handle_events(&mut tui).await?;
408 self.handle_actions(&mut tui)?;
409 if self.should_suspend {
410 tui.suspend()?;
411 action_tx.send(Action::Resume)?;
412 action_tx.send(Action::ClearScreen)?;
413 tui.enter()?;
415 } else if self.should_quit {
416 tui.stop()?;
417 break;
418 }
419 }
420 self.watch.shutdown();
422 self.root_cancel.cancel();
423 let snapshot = State {
427 log_pane_height: self.log_pane.height(),
428 log_pane_active_tab: self.log_pane.active_tab().to_kebab().to_string(),
429 };
430 snapshot.save(&self.state_path);
431 if let Some(sup) = self.supervisor.take() {
435 let final_status = sup.shutdown_default().await;
436 tracing::info!("bee child exited: {}", final_status.label());
437 }
438 tui.exit()?;
439 Ok(())
440 }
441
442 async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
443 let Some(event) = tui.next_event().await else {
444 return Ok(());
445 };
446 let action_tx = self.action_tx.clone();
447 let modal_before = self.command_buffer.is_some() || self.help_visible;
454 match event {
455 Event::Quit => action_tx.send(Action::Quit)?,
456 Event::Tick => action_tx.send(Action::Tick)?,
457 Event::Render => action_tx.send(Action::Render)?,
458 Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
459 Event::Key(key) => self.handle_key_event(key)?,
460 _ => {}
461 }
462 let modal_after = self.command_buffer.is_some() || self.help_visible;
463 let propagate = !((modal_before || modal_after) && matches!(event, Event::Key(_)));
466 if propagate {
467 for component in self.iter_components_mut() {
468 if let Some(action) = component.handle_events(Some(event.clone()))? {
469 action_tx.send(action)?;
470 }
471 }
472 }
473 Ok(())
474 }
475
476 fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut dyn Component> {
481 self.screens
482 .iter_mut()
483 .map(|c| c.as_mut() as &mut dyn Component)
484 .chain(std::iter::once(&mut self.log_pane as &mut dyn Component))
485 }
486
487 fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
488 if self.command_buffer.is_some() {
492 self.handle_command_mode_key(key)?;
493 return Ok(());
494 }
495 if self.help_visible {
499 match key.code {
500 crossterm::event::KeyCode::Esc
501 | crossterm::event::KeyCode::Char('?')
502 | crossterm::event::KeyCode::Char('q') => {
503 self.help_visible = false;
504 }
505 _ => {}
506 }
507 return Ok(());
508 }
509 if matches!(key.code, crossterm::event::KeyCode::Char('?')) {
513 self.help_visible = true;
514 return Ok(());
515 }
516 let action_tx = self.action_tx.clone();
517 if matches!(key.code, crossterm::event::KeyCode::Char(':')) {
519 self.command_buffer = Some(String::new());
520 self.command_status = None;
521 return Ok(());
522 }
523 if matches!(key.code, crossterm::event::KeyCode::Tab) {
528 if !self.screens.is_empty() {
529 self.current_screen = (self.current_screen + 1) % self.screens.len();
530 debug!(
531 "switched to screen {}",
532 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
533 );
534 }
535 return Ok(());
536 }
537 if matches!(key.code, crossterm::event::KeyCode::BackTab) {
538 if !self.screens.is_empty() {
539 let len = self.screens.len();
540 self.current_screen = (self.current_screen + len - 1) % len;
541 debug!(
542 "switched to screen {}",
543 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
544 );
545 }
546 return Ok(());
547 }
548 if matches!(key.code, crossterm::event::KeyCode::Char('['))
554 && key.modifiers == crossterm::event::KeyModifiers::NONE
555 {
556 self.log_pane.prev_tab();
557 return Ok(());
558 }
559 if matches!(key.code, crossterm::event::KeyCode::Char(']'))
560 && key.modifiers == crossterm::event::KeyModifiers::NONE
561 {
562 self.log_pane.next_tab();
563 return Ok(());
564 }
565 if matches!(key.code, crossterm::event::KeyCode::Char('+'))
566 && key.modifiers == crossterm::event::KeyModifiers::NONE
567 {
568 self.log_pane.grow();
569 return Ok(());
570 }
571 if matches!(key.code, crossterm::event::KeyCode::Char('-'))
572 && key.modifiers == crossterm::event::KeyModifiers::NONE
573 {
574 self.log_pane.shrink();
575 return Ok(());
576 }
577 if key.modifiers == crossterm::event::KeyModifiers::SHIFT {
582 match key.code {
583 crossterm::event::KeyCode::Up => {
584 self.log_pane.scroll_up(1);
585 return Ok(());
586 }
587 crossterm::event::KeyCode::Down => {
588 self.log_pane.scroll_down(1);
589 return Ok(());
590 }
591 crossterm::event::KeyCode::PageUp => {
592 self.log_pane.scroll_up(10);
593 return Ok(());
594 }
595 crossterm::event::KeyCode::PageDown => {
596 self.log_pane.scroll_down(10);
597 return Ok(());
598 }
599 crossterm::event::KeyCode::End => {
600 self.log_pane.resume_tail();
601 return Ok(());
602 }
603 crossterm::event::KeyCode::Left => {
609 self.log_pane.scroll_left(8);
610 return Ok(());
611 }
612 crossterm::event::KeyCode::Right => {
613 self.log_pane.scroll_right(8);
614 return Ok(());
615 }
616 _ => {}
617 }
618 }
619 if matches!(key.code, crossterm::event::KeyCode::Char('q'))
625 && key.modifiers == crossterm::event::KeyModifiers::NONE
626 {
627 match resolve_quit_press(self.quit_pending, Instant::now(), QUIT_CONFIRM_WINDOW) {
628 QuitResolution::Confirm => {
629 self.quit_pending = None;
630 self.action_tx.send(Action::Quit)?;
631 }
632 QuitResolution::Pending => {
633 self.quit_pending = Some(Instant::now());
634 self.command_status = Some(CommandStatus::Info(
635 "press q again to quit (Esc cancels)".into(),
636 ));
637 }
638 }
639 return Ok(());
640 }
641 if self.quit_pending.is_some() {
645 self.quit_pending = None;
646 }
647 let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
648 return Ok(());
649 };
650 match keymap.get(&vec![key]) {
651 Some(action) => {
652 info!("Got action: {action:?}");
653 action_tx.send(action.clone())?;
654 }
655 _ => {
656 self.last_tick_key_events.push(key);
659
660 if let Some(action) = keymap.get(&self.last_tick_key_events) {
662 info!("Got action: {action:?}");
663 action_tx.send(action.clone())?;
664 }
665 }
666 }
667 Ok(())
668 }
669
670 fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
671 use crossterm::event::KeyCode;
672 let buf = match self.command_buffer.as_mut() {
673 Some(b) => b,
674 None => return Ok(()),
675 };
676 match key.code {
677 KeyCode::Esc => {
678 self.command_buffer = None;
680 self.command_suggestion_index = 0;
681 }
682 KeyCode::Enter => {
683 let line = std::mem::take(buf);
684 self.command_buffer = None;
685 self.command_suggestion_index = 0;
686 self.execute_command(&line)?;
687 }
688 KeyCode::Up => {
689 self.command_suggestion_index = self.command_suggestion_index.saturating_sub(1);
692 }
693 KeyCode::Down => {
694 let n = filter_command_suggestions(buf, KNOWN_COMMANDS).len();
695 if n > 0 && self.command_suggestion_index + 1 < n {
696 self.command_suggestion_index += 1;
697 }
698 }
699 KeyCode::Tab => {
700 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
704 if let Some((name, _)) = matches.get(self.command_suggestion_index) {
705 let rest = buf
706 .split_once(char::is_whitespace)
707 .map(|(_, tail)| tail)
708 .unwrap_or("");
709 let new = if rest.is_empty() {
710 format!("{name} ")
711 } else {
712 format!("{name} {rest}")
713 };
714 buf.clear();
715 buf.push_str(&new);
716 self.command_suggestion_index = 0;
717 }
718 }
719 KeyCode::Backspace => {
720 buf.pop();
721 self.command_suggestion_index = 0;
722 }
723 KeyCode::Char(c) => {
724 buf.push(c);
725 self.command_suggestion_index = 0;
726 }
727 _ => {}
728 }
729 Ok(())
730 }
731
732 fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
735 let trimmed = line.trim();
736 if trimmed.is_empty() {
737 return Ok(());
738 }
739 let head = trimmed.split_whitespace().next().unwrap_or("");
740 match head {
741 "q" | "quit" => {
742 self.action_tx.send(Action::Quit)?;
743 self.command_status = Some(CommandStatus::Info("quitting".into()));
744 }
745 "diagnose" | "diag" => {
746 self.command_status = Some(match self.export_diagnostic_bundle() {
747 Ok(path) => CommandStatus::Info(format!(
748 "diagnostic bundle exported to {}",
749 path.display()
750 )),
751 Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
752 });
753 }
754 "pins-check" => {
755 self.command_status = Some(match self.start_pins_check() {
761 Ok(path) => CommandStatus::Info(format!(
762 "pins integrity check running → {} (tail to watch progress)",
763 path.display()
764 )),
765 Err(e) => CommandStatus::Err(format!("pins-check failed to start: {e}")),
766 });
767 }
768 "loggers" => {
769 self.command_status = Some(match self.start_loggers_dump() {
770 Ok(path) => CommandStatus::Info(format!(
771 "loggers snapshot writing → {} (open when ready)",
772 path.display()
773 )),
774 Err(e) => CommandStatus::Err(format!("loggers failed to start: {e}")),
775 });
776 }
777 "set-logger" => {
778 let mut parts = trimmed.split_whitespace();
779 let _ = parts.next(); let expr = parts.next().unwrap_or("");
781 let level = parts.next().unwrap_or("");
782 if expr.is_empty() || level.is_empty() {
783 self.command_status = Some(CommandStatus::Err(
784 "usage: :set-logger <expr> <level> (level: none|error|warning|info|debug|all; expr: e.g. node/pushsync or '.' for all)"
785 .into(),
786 ));
787 return Ok(());
788 }
789 self.start_set_logger(expr.to_string(), level.to_string());
790 self.command_status = Some(CommandStatus::Info(format!(
791 "set-logger {expr:?} → {level:?} (PUT in-flight; check :loggers to verify)"
792 )));
793 }
794 "topup-preview" => {
795 self.command_status = Some(self.run_topup_preview(trimmed));
796 }
797 "dilute-preview" => {
798 self.command_status = Some(self.run_dilute_preview(trimmed));
799 }
800 "extend-preview" => {
801 self.command_status = Some(self.run_extend_preview(trimmed));
802 }
803 "buy-preview" => {
804 self.command_status = Some(self.run_buy_preview(trimmed));
805 }
806 "buy-suggest" => {
807 self.command_status = Some(self.run_buy_suggest(trimmed));
808 }
809 "probe-upload" => {
810 self.command_status = Some(self.run_probe_upload(trimmed));
811 }
812 "context" | "ctx" => {
813 let target = trimmed.split_whitespace().nth(1).unwrap_or("");
814 if target.is_empty() {
815 let known: Vec<String> =
816 self.config.nodes.iter().map(|n| n.name.clone()).collect();
817 self.command_status = Some(CommandStatus::Err(format!(
818 "usage: :context <name> (known: {})",
819 known.join(", ")
820 )));
821 return Ok(());
822 }
823 self.command_status = Some(match self.switch_context(target) {
824 Ok(()) => CommandStatus::Info(format!(
825 "switched to context {target} ({})",
826 self.api.url
827 )),
828 Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
829 });
830 }
831 screen
832 if SCREEN_NAMES
833 .iter()
834 .any(|name| name.eq_ignore_ascii_case(screen)) =>
835 {
836 if let Some(idx) = SCREEN_NAMES
837 .iter()
838 .position(|name| name.eq_ignore_ascii_case(screen))
839 {
840 self.current_screen = idx;
841 self.command_status =
842 Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
843 }
844 }
845 other => {
846 self.command_status = Some(CommandStatus::Err(format!(
847 "unknown command: {other:?} (try :health, :stamps, :swap, :lottery, :peers, :network, :warmup, :api, :tags, :pins, :diagnose, :pins-check, :loggers, :set-logger, :topup-preview, :dilute-preview, :extend-preview, :buy-preview, :buy-suggest, :probe-upload, :context, :quit)"
848 )));
849 }
850 }
851 Ok(())
852 }
853
854 fn run_topup_preview(&self, line: &str) -> CommandStatus {
858 let parts: Vec<&str> = line.split_whitespace().collect();
859 let (prefix, amount_str) = match parts.as_slice() {
860 [_, prefix, amount, ..] => (*prefix, *amount),
861 _ => {
862 return CommandStatus::Err(
863 "usage: :topup-preview <batch-prefix> <amount-plur-per-chunk>".into(),
864 );
865 }
866 };
867 let chain = match self.health_rx.borrow().chain_state.clone() {
868 Some(c) => c,
869 None => return CommandStatus::Err("chain state not loaded yet".into()),
870 };
871 let stamps = self.watch.stamps().borrow().clone();
872 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
873 Ok(b) => b.clone(),
874 Err(e) => return CommandStatus::Err(e),
875 };
876 let amount = match stamp_preview::parse_plur_amount(amount_str) {
877 Ok(a) => a,
878 Err(e) => return CommandStatus::Err(e),
879 };
880 match stamp_preview::topup_preview(&batch, amount, &chain) {
881 Ok(p) => CommandStatus::Info(p.summary()),
882 Err(e) => CommandStatus::Err(e),
883 }
884 }
885
886 fn run_dilute_preview(&self, line: &str) -> CommandStatus {
890 let parts: Vec<&str> = line.split_whitespace().collect();
891 let (prefix, depth_str) = match parts.as_slice() {
892 [_, prefix, depth, ..] => (*prefix, *depth),
893 _ => {
894 return CommandStatus::Err(
895 "usage: :dilute-preview <batch-prefix> <new-depth>".into(),
896 );
897 }
898 };
899 let new_depth: u8 = match depth_str.parse() {
900 Ok(d) => d,
901 Err(_) => {
902 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
903 }
904 };
905 let stamps = self.watch.stamps().borrow().clone();
906 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
907 Ok(b) => b.clone(),
908 Err(e) => return CommandStatus::Err(e),
909 };
910 match stamp_preview::dilute_preview(&batch, new_depth) {
911 Ok(p) => CommandStatus::Info(p.summary()),
912 Err(e) => CommandStatus::Err(e),
913 }
914 }
915
916 fn run_extend_preview(&self, line: &str) -> CommandStatus {
919 let parts: Vec<&str> = line.split_whitespace().collect();
920 let (prefix, duration_str) = match parts.as_slice() {
921 [_, prefix, duration, ..] => (*prefix, *duration),
922 _ => {
923 return CommandStatus::Err(
924 "usage: :extend-preview <batch-prefix> <duration> (e.g. 30d, 12h, 90m, 45s, or plain seconds)".into(),
925 );
926 }
927 };
928 let extension_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
929 Ok(s) => s,
930 Err(e) => return CommandStatus::Err(e),
931 };
932 let chain = match self.health_rx.borrow().chain_state.clone() {
933 Some(c) => c,
934 None => return CommandStatus::Err("chain state not loaded yet".into()),
935 };
936 let stamps = self.watch.stamps().borrow().clone();
937 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
938 Ok(b) => b.clone(),
939 Err(e) => return CommandStatus::Err(e),
940 };
941 match stamp_preview::extend_preview(&batch, extension_seconds, &chain) {
942 Ok(p) => CommandStatus::Info(p.summary()),
943 Err(e) => CommandStatus::Err(e),
944 }
945 }
946
947 fn run_probe_upload(&self, line: &str) -> CommandStatus {
959 let parts: Vec<&str> = line.split_whitespace().collect();
960 let prefix = match parts.as_slice() {
961 [_, prefix, ..] => *prefix,
962 _ => {
963 return CommandStatus::Err(
964 "usage: :probe-upload <batch-prefix> (uploads one synthetic 4 KiB chunk)"
965 .into(),
966 );
967 }
968 };
969 let stamps = self.watch.stamps().borrow().clone();
970 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
971 Ok(b) => b.clone(),
972 Err(e) => return CommandStatus::Err(e),
973 };
974 if !batch.usable {
975 return CommandStatus::Err(format!(
976 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
977 short_hex(&batch.batch_id.to_hex(), 8),
978 ));
979 }
980 if batch.batch_ttl <= 0 {
981 return CommandStatus::Err(format!(
982 "batch {} is expired — pick another",
983 short_hex(&batch.batch_id.to_hex(), 8),
984 ));
985 }
986
987 let api = self.api.clone();
988 let tx = self.cmd_status_tx.clone();
989 let batch_id = batch.batch_id;
990 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
991 let task_short = batch_short.clone();
992 tokio::spawn(async move {
993 let chunk = build_synthetic_probe_chunk();
994 let started = Instant::now();
995 let result = api.bee().file().upload_chunk(&batch_id, chunk, None).await;
996 let elapsed_ms = started.elapsed().as_millis();
997 let status = match result {
998 Ok(res) => CommandStatus::Info(format!(
999 "probe-upload OK in {elapsed_ms}ms — batch {task_short}, ref {}",
1000 short_hex(&res.reference.to_hex(), 8),
1001 )),
1002 Err(e) => CommandStatus::Err(format!(
1003 "probe-upload FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1004 )),
1005 };
1006 let _ = tx.send(status);
1007 });
1008
1009 CommandStatus::Info(format!(
1010 "probe-upload to batch {batch_short} in flight — result will replace this line"
1011 ))
1012 }
1013
1014 fn run_buy_suggest(&self, line: &str) -> CommandStatus {
1020 let parts: Vec<&str> = line.split_whitespace().collect();
1021 let (size_str, duration_str) = match parts.as_slice() {
1022 [_, size, duration, ..] => (*size, *duration),
1023 _ => {
1024 return CommandStatus::Err(
1025 "usage: :buy-suggest <size> <duration> (e.g. 5GiB 30d, 100MiB 12h)".into(),
1026 );
1027 }
1028 };
1029 let target_bytes = match stamp_preview::parse_size_bytes(size_str) {
1030 Ok(b) => b,
1031 Err(e) => return CommandStatus::Err(e),
1032 };
1033 let target_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
1034 Ok(s) => s,
1035 Err(e) => return CommandStatus::Err(e),
1036 };
1037 let chain = match self.health_rx.borrow().chain_state.clone() {
1038 Some(c) => c,
1039 None => return CommandStatus::Err("chain state not loaded yet".into()),
1040 };
1041 match stamp_preview::buy_suggest(target_bytes, target_seconds, &chain) {
1042 Ok(s) => CommandStatus::Info(s.summary()),
1043 Err(e) => CommandStatus::Err(e),
1044 }
1045 }
1046
1047 fn run_buy_preview(&self, line: &str) -> CommandStatus {
1050 let parts: Vec<&str> = line.split_whitespace().collect();
1051 let (depth_str, amount_str) = match parts.as_slice() {
1052 [_, depth, amount, ..] => (*depth, *amount),
1053 _ => {
1054 return CommandStatus::Err(
1055 "usage: :buy-preview <depth> <amount-plur-per-chunk>".into(),
1056 );
1057 }
1058 };
1059 let depth: u8 = match depth_str.parse() {
1060 Ok(d) => d,
1061 Err(_) => {
1062 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
1063 }
1064 };
1065 let amount = match stamp_preview::parse_plur_amount(amount_str) {
1066 Ok(a) => a,
1067 Err(e) => return CommandStatus::Err(e),
1068 };
1069 let chain = match self.health_rx.borrow().chain_state.clone() {
1070 Some(c) => c,
1071 None => return CommandStatus::Err("chain state not loaded yet".into()),
1072 };
1073 match stamp_preview::buy_preview(depth, amount, &chain) {
1074 Ok(p) => CommandStatus::Info(p.summary()),
1075 Err(e) => CommandStatus::Err(e),
1076 }
1077 }
1078
1079 fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
1086 let node = self
1087 .config
1088 .nodes
1089 .iter()
1090 .find(|n| n.name == target)
1091 .ok_or_else(|| eyre!("no node configured with name {target:?}"))?
1092 .clone();
1093 let new_api = Arc::new(ApiClient::from_node(&node)?);
1094 self.watch.shutdown();
1098 let refresh = RefreshProfile::from_config(&self.config.ui.refresh);
1099 let new_watch = BeeWatch::start_with_profile(new_api.clone(), &self.root_cancel, refresh);
1100 let new_health_rx = new_watch.health();
1101 let new_screens = build_screens(&new_api, &new_watch);
1102 self.api = new_api;
1103 self.watch = new_watch;
1104 self.health_rx = new_health_rx;
1105 self.screens = new_screens;
1106 Ok(())
1109 }
1110
1111 fn start_pins_check(&self) -> std::io::Result<PathBuf> {
1128 let secs = SystemTime::now()
1129 .duration_since(UNIX_EPOCH)
1130 .map(|d| d.as_secs())
1131 .unwrap_or(0);
1132 let path = std::env::temp_dir().join(format!(
1133 "bee-tui-pins-check-{}-{secs}.txt",
1134 sanitize_for_filename(&self.api.name),
1135 ));
1136 std::fs::write(
1139 &path,
1140 format!(
1141 "# bee-tui :pins-check\n# profile {}\n# endpoint {}\n# started {}\n",
1142 self.api.name,
1143 self.api.url,
1144 format_utc_now(),
1145 ),
1146 )?;
1147
1148 let api = self.api.clone();
1149 let dest = path.clone();
1150 tokio::spawn(async move {
1151 let bee = api.bee();
1152 match bee.api().check_pins(None).await {
1153 Ok(entries) => {
1154 let mut body = String::new();
1155 for e in &entries {
1156 body.push_str(&format!(
1157 "{} total={} missing={} invalid={} {}\n",
1158 e.reference.to_hex(),
1159 e.total,
1160 e.missing,
1161 e.invalid,
1162 if e.is_healthy() {
1163 "healthy"
1164 } else {
1165 "UNHEALTHY"
1166 },
1167 ));
1168 }
1169 body.push_str(&format!("# done. {} pins checked.\n", entries.len()));
1170 if let Err(e) = append(&dest, &body) {
1171 let _ = append(&dest, &format!("# write error: {e}\n"));
1172 }
1173 }
1174 Err(e) => {
1175 let _ = append(&dest, &format!("# error: {e}\n"));
1176 }
1177 }
1178 });
1179 Ok(path)
1180 }
1181
1182 fn start_set_logger(&self, expression: String, level: String) {
1193 let secs = SystemTime::now()
1194 .duration_since(UNIX_EPOCH)
1195 .map(|d| d.as_secs())
1196 .unwrap_or(0);
1197 let dest = std::env::temp_dir().join(format!(
1198 "bee-tui-set-logger-{}-{secs}.txt",
1199 sanitize_for_filename(&self.api.name),
1200 ));
1201 let _ = std::fs::write(
1202 &dest,
1203 format!(
1204 "# bee-tui :set-logger\n# profile {}\n# endpoint {}\n# expr {expression}\n# level {level}\n# started {}\n",
1205 self.api.name,
1206 self.api.url,
1207 format_utc_now(),
1208 ),
1209 );
1210
1211 let api = self.api.clone();
1212 tokio::spawn(async move {
1213 let bee = api.bee();
1214 match bee.debug().set_logger(&expression, &level).await {
1215 Ok(()) => {
1216 let _ = append(
1217 &dest,
1218 &format!("# done. {expression} → {level} accepted by Bee.\n"),
1219 );
1220 }
1221 Err(e) => {
1222 let _ = append(&dest, &format!("# error: {e}\n"));
1223 }
1224 }
1225 });
1226 }
1227
1228 fn start_loggers_dump(&self) -> std::io::Result<PathBuf> {
1233 let secs = SystemTime::now()
1234 .duration_since(UNIX_EPOCH)
1235 .map(|d| d.as_secs())
1236 .unwrap_or(0);
1237 let path = std::env::temp_dir().join(format!(
1238 "bee-tui-loggers-{}-{secs}.txt",
1239 sanitize_for_filename(&self.api.name),
1240 ));
1241 std::fs::write(
1242 &path,
1243 format!(
1244 "# bee-tui :loggers\n# profile {}\n# endpoint {}\n# started {}\n",
1245 self.api.name,
1246 self.api.url,
1247 format_utc_now(),
1248 ),
1249 )?;
1250
1251 let api = self.api.clone();
1252 let dest = path.clone();
1253 tokio::spawn(async move {
1254 let bee = api.bee();
1255 match bee.debug().loggers().await {
1256 Ok(listing) => {
1257 let mut rows = listing.loggers.clone();
1258 rows.sort_by(|a, b| {
1262 verbosity_rank(&b.verbosity)
1263 .cmp(&verbosity_rank(&a.verbosity))
1264 .then_with(|| a.logger.cmp(&b.logger))
1265 });
1266 let mut body = String::new();
1267 body.push_str(&format!("# {} loggers registered\n", rows.len()));
1268 body.push_str("# VERBOSITY LOGGER\n");
1269 for r in &rows {
1270 body.push_str(&format!(" {:<9} {}\n", r.verbosity, r.logger,));
1271 }
1272 body.push_str("# done.\n");
1273 if let Err(e) = append(&dest, &body) {
1274 let _ = append(&dest, &format!("# write error: {e}\n"));
1275 }
1276 }
1277 Err(e) => {
1278 let _ = append(&dest, &format!("# error: {e}\n"));
1279 }
1280 }
1281 });
1282 Ok(path)
1283 }
1284
1285 fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
1286 let bundle = self.render_diagnostic_bundle();
1287 let secs = SystemTime::now()
1288 .duration_since(UNIX_EPOCH)
1289 .map(|d| d.as_secs())
1290 .unwrap_or(0);
1291 let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
1292 std::fs::write(&path, bundle)?;
1293 Ok(path)
1294 }
1295
1296 fn render_diagnostic_bundle(&self) -> String {
1297 let now = format_utc_now();
1298 let health = self.health_rx.borrow().clone();
1299 let topology = self.watch.topology().borrow().clone();
1300 let gates = Health::gates_for(&health, Some(&topology));
1301 let recent: Vec<_> = log_capture::handle()
1302 .map(|c| {
1303 let mut snap = c.snapshot();
1304 let len = snap.len();
1305 if len > 50 {
1306 snap.drain(0..len - 50);
1307 }
1308 snap
1309 })
1310 .unwrap_or_default();
1311
1312 let mut out = String::new();
1313 out.push_str("# bee-tui diagnostic bundle\n");
1314 out.push_str(&format!("# generated UTC {now}\n\n"));
1315 out.push_str("## profile\n");
1316 out.push_str(&format!(" name {}\n", self.api.name));
1317 out.push_str(&format!(" endpoint {}\n\n", self.api.url));
1318 out.push_str("## health gates\n");
1319 for g in &gates {
1320 out.push_str(&format_gate_line(g));
1321 }
1322 out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
1323 for e in &recent {
1324 let status = e
1325 .status
1326 .map(|s| s.to_string())
1327 .unwrap_or_else(|| "—".into());
1328 let elapsed = e
1329 .elapsed_ms
1330 .map(|ms| format!("{ms}ms"))
1331 .unwrap_or_else(|| "—".into());
1332 out.push_str(&format!(
1333 " {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
1334 ts = e.ts,
1335 method = e.method,
1336 path = path_only(&e.url),
1337 status = status,
1338 elapsed = elapsed,
1339 ));
1340 }
1341 out.push_str(&format!(
1342 "\n## generated by bee-tui {}\n",
1343 env!("CARGO_PKG_VERSION"),
1344 ));
1345 out
1346 }
1347
1348 fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
1349 while let Ok(action) = self.action_rx.try_recv() {
1350 if action != Action::Tick && action != Action::Render {
1351 debug!("{action:?}");
1352 }
1353 match action {
1354 Action::Tick => {
1355 self.last_tick_key_events.drain(..);
1356 theme::advance_spinner();
1360 if let Some(sup) = self.supervisor.as_mut() {
1364 self.bee_status = sup.status();
1365 }
1366 if let Some(rx) = self.bee_log_rx.as_mut() {
1371 while let Ok((tab, line)) = rx.try_recv() {
1372 self.log_pane.push_bee(tab, line);
1373 }
1374 }
1375 while let Ok(status) = self.cmd_status_rx.try_recv() {
1380 self.command_status = Some(status);
1381 }
1382 }
1383 Action::Quit => self.should_quit = true,
1384 Action::Suspend => self.should_suspend = true,
1385 Action::Resume => self.should_suspend = false,
1386 Action::ClearScreen => tui.terminal.clear()?,
1387 Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
1388 Action::Render => self.render(tui)?,
1389 _ => {}
1390 }
1391 let tx = self.action_tx.clone();
1392 for component in self.iter_components_mut() {
1393 if let Some(action) = component.update(action.clone())? {
1394 tx.send(action)?
1395 };
1396 }
1397 }
1398 Ok(())
1399 }
1400
1401 fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
1402 tui.resize(Rect::new(0, 0, w, h))?;
1403 self.render(tui)?;
1404 Ok(())
1405 }
1406
1407 fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
1408 let active = self.current_screen;
1409 let tx = self.action_tx.clone();
1410 let screens = &mut self.screens;
1411 let log_pane = &mut self.log_pane;
1412 let log_pane_height = log_pane.height();
1413 let command_buffer = self.command_buffer.clone();
1414 let command_suggestion_index = self.command_suggestion_index;
1415 let command_status = self.command_status.clone();
1416 let help_visible = self.help_visible;
1417 let profile = self.api.name.clone();
1418 let endpoint = self.api.url.clone();
1419 let last_ping = self.health_rx.borrow().last_ping;
1420 let now_utc = format_utc_now();
1421 let bee_status_label = if self.supervisor.is_some() && !self.bee_status.is_running() {
1422 Some(self.bee_status.label())
1426 } else {
1427 None
1428 };
1429 tui.draw(|frame| {
1430 use ratatui::layout::{Constraint, Layout};
1431 use ratatui::style::{Color, Modifier, Style};
1432 use ratatui::text::{Line, Span};
1433 use ratatui::widgets::Paragraph;
1434
1435 let chunks = Layout::vertical([
1436 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), Constraint::Length(log_pane_height), ])
1441 .split(frame.area());
1442
1443 let top_chunks =
1444 Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(chunks[0]);
1445
1446 let ping_str = match last_ping {
1448 Some(d) => format!("{}ms", d.as_millis()),
1449 None => "—".into(),
1450 };
1451 let t = theme::active();
1452 let mut metadata_spans = vec![
1453 Span::styled(
1454 " bee-tui ",
1455 Style::default()
1456 .fg(Color::Black)
1457 .bg(t.info)
1458 .add_modifier(Modifier::BOLD),
1459 ),
1460 Span::raw(" "),
1461 Span::styled(
1462 profile,
1463 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
1464 ),
1465 Span::styled(format!(" @ {endpoint}"), Style::default().fg(t.dim)),
1466 Span::raw(" "),
1467 Span::styled("ping ", Style::default().fg(t.dim)),
1468 Span::styled(ping_str, Style::default().fg(t.info)),
1469 Span::raw(" "),
1470 Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
1471 ];
1472 if let Some(label) = bee_status_label.as_ref() {
1476 metadata_spans.push(Span::raw(" "));
1477 metadata_spans.push(Span::styled(
1478 format!(" {label} "),
1479 Style::default()
1480 .fg(Color::Black)
1481 .bg(t.fail)
1482 .add_modifier(Modifier::BOLD),
1483 ));
1484 }
1485 let metadata_line = Line::from(metadata_spans);
1486 frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
1487
1488 let theme = *theme::active();
1490 let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
1491 for (i, name) in SCREEN_NAMES.iter().enumerate() {
1492 let style = if i == active {
1493 Style::default()
1494 .fg(theme.tab_active_fg)
1495 .bg(theme.tab_active_bg)
1496 .add_modifier(Modifier::BOLD)
1497 } else {
1498 Style::default().fg(theme.dim)
1499 };
1500 tabs.push(Span::styled(format!(" {name} "), style));
1501 tabs.push(Span::raw(" "));
1502 }
1503 tabs.push(Span::styled(
1504 ":cmd · Tab to cycle · ? help",
1505 Style::default().fg(theme.dim),
1506 ));
1507 frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
1508
1509 if let Some(screen) = screens.get_mut(active) {
1511 if let Err(err) = screen.draw(frame, chunks[1]) {
1512 let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
1513 }
1514 }
1515 let prompt = if let Some(buf) = &command_buffer {
1517 Line::from(vec![
1518 Span::styled(
1519 ":",
1520 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
1521 ),
1522 Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
1523 Span::styled("█", Style::default().fg(t.accent)),
1524 ])
1525 } else {
1526 match &command_status {
1527 Some(CommandStatus::Info(msg)) => {
1528 Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
1529 }
1530 Some(CommandStatus::Err(msg)) => {
1531 Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
1532 }
1533 None => Line::from(""),
1534 }
1535 };
1536 frame.render_widget(Paragraph::new(prompt), chunks[2]);
1537
1538 if let Some(buf) = &command_buffer {
1544 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
1545 if !matches.is_empty() {
1546 draw_command_suggestions(
1547 frame,
1548 chunks[2],
1549 &matches,
1550 command_suggestion_index,
1551 &theme,
1552 );
1553 }
1554 }
1555
1556 if let Err(err) = log_pane.draw(frame, chunks[3]) {
1558 let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
1559 }
1560
1561 if help_visible {
1566 draw_help_overlay(frame, frame.area(), active, &theme);
1567 }
1568 })?;
1569 Ok(())
1570 }
1571}
1572
1573fn draw_command_suggestions(
1580 frame: &mut ratatui::Frame,
1581 bar_rect: ratatui::layout::Rect,
1582 matches: &[&(&str, &str)],
1583 selected: usize,
1584 theme: &theme::Theme,
1585) {
1586 use ratatui::layout::Rect;
1587 use ratatui::style::{Modifier, Style};
1588 use ratatui::text::{Line, Span};
1589 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1590
1591 const MAX_VISIBLE: usize = 10;
1592 let visible_rows = matches.len().min(MAX_VISIBLE);
1593 if visible_rows == 0 {
1594 return;
1595 }
1596 let height = (visible_rows as u16) + 2; let widest = matches
1601 .iter()
1602 .map(|(name, desc)| name.len() + desc.len() + 6)
1603 .max()
1604 .unwrap_or(40)
1605 .min(bar_rect.width as usize);
1606 let width = (widest as u16 + 2).min(bar_rect.width);
1607 let bottom = bar_rect.y;
1610 let y = bottom.saturating_sub(height);
1611 let popup = Rect {
1612 x: bar_rect.x,
1613 y,
1614 width,
1615 height: bottom - y,
1616 };
1617
1618 let scroll_start = if selected >= visible_rows {
1620 selected + 1 - visible_rows
1621 } else {
1622 0
1623 };
1624 let visible_slice = &matches[scroll_start..(scroll_start + visible_rows).min(matches.len())];
1625
1626 let mut lines: Vec<Line> = Vec::with_capacity(visible_slice.len());
1627 for (i, (name, desc)) in visible_slice.iter().enumerate() {
1628 let absolute_idx = scroll_start + i;
1629 let is_selected = absolute_idx == selected;
1630 let row_style = if is_selected {
1631 Style::default()
1632 .fg(theme.tab_active_fg)
1633 .bg(theme.tab_active_bg)
1634 .add_modifier(Modifier::BOLD)
1635 } else {
1636 Style::default()
1637 };
1638 let cursor = if is_selected { "▸ " } else { " " };
1639 lines.push(Line::from(vec![
1640 Span::styled(format!("{cursor}:{name:<16} "), row_style),
1641 Span::styled(
1642 desc.to_string(),
1643 if is_selected {
1644 row_style
1645 } else {
1646 Style::default().fg(theme.dim)
1647 },
1648 ),
1649 ]));
1650 }
1651
1652 let title = if matches.len() > MAX_VISIBLE {
1654 format!(" :commands ({}/{}) ", selected + 1, matches.len())
1655 } else {
1656 " :commands ".to_string()
1657 };
1658
1659 frame.render_widget(Clear, popup);
1660 frame.render_widget(
1661 Paragraph::new(lines).block(
1662 Block::default()
1663 .borders(Borders::ALL)
1664 .border_style(Style::default().fg(theme.accent))
1665 .title(title),
1666 ),
1667 popup,
1668 );
1669}
1670
1671fn draw_help_overlay(
1676 frame: &mut ratatui::Frame,
1677 area: ratatui::layout::Rect,
1678 active_screen: usize,
1679 theme: &theme::Theme,
1680) {
1681 use ratatui::layout::Rect;
1682 use ratatui::style::{Modifier, Style};
1683 use ratatui::text::{Line, Span};
1684 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1685
1686 let screen_name = SCREEN_NAMES.get(active_screen).copied().unwrap_or("?");
1687 let screen_rows = screen_keymap(active_screen);
1688 let global_rows: &[(&str, &str)] = &[
1689 ("Tab", "next screen"),
1690 ("Shift+Tab", "previous screen"),
1691 ("[ / ]", "previous / next log-pane tab"),
1692 ("+ / -", "grow / shrink log pane"),
1693 ("Shift+↑/↓", "scroll log pane (1 line); pauses auto-tail"),
1694 ("Shift+PgUp/PgDn", "scroll log pane (10 lines)"),
1695 ("Shift+←/→", "pan log pane horizontally (8 cols)"),
1696 ("Shift+End", "resume auto-tail + reset horizontal pan"),
1697 ("?", "toggle this help"),
1698 (":", "open command bar"),
1699 ("qq", "quit (double-tap; or :q)"),
1700 ("Ctrl+C / Ctrl+D", "quit immediately"),
1701 ];
1702
1703 let w = area.width.min(72);
1706 let h = area.height.min(22);
1707 let x = area.x + (area.width.saturating_sub(w)) / 2;
1708 let y = area.y + (area.height.saturating_sub(h)) / 2;
1709 let rect = Rect {
1710 x,
1711 y,
1712 width: w,
1713 height: h,
1714 };
1715
1716 let mut lines: Vec<Line> = Vec::new();
1717 lines.push(Line::from(vec![
1718 Span::styled(
1719 format!(" {screen_name} "),
1720 Style::default()
1721 .fg(theme.tab_active_fg)
1722 .bg(theme.tab_active_bg)
1723 .add_modifier(Modifier::BOLD),
1724 ),
1725 Span::raw(" screen-specific keys"),
1726 ]));
1727 lines.push(Line::from(""));
1728 if screen_rows.is_empty() {
1729 lines.push(Line::from(Span::styled(
1730 " (no extra keys for this screen — use the command bar via :)",
1731 Style::default()
1732 .fg(theme.dim)
1733 .add_modifier(Modifier::ITALIC),
1734 )));
1735 } else {
1736 for (key, desc) in screen_rows {
1737 lines.push(format_help_row(key, desc, theme));
1738 }
1739 }
1740 lines.push(Line::from(""));
1741 lines.push(Line::from(Span::styled(
1742 " global",
1743 Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
1744 )));
1745 for (key, desc) in global_rows {
1746 lines.push(format_help_row(key, desc, theme));
1747 }
1748 lines.push(Line::from(""));
1749 lines.push(Line::from(Span::styled(
1750 " Esc / ? / q to dismiss",
1751 Style::default()
1752 .fg(theme.dim)
1753 .add_modifier(Modifier::ITALIC),
1754 )));
1755
1756 frame.render_widget(Clear, rect);
1759 frame.render_widget(
1760 Paragraph::new(lines).block(
1761 Block::default()
1762 .borders(Borders::ALL)
1763 .border_style(Style::default().fg(theme.accent))
1764 .title(" help "),
1765 ),
1766 rect,
1767 );
1768}
1769
1770fn format_help_row<'a>(
1771 key: &'a str,
1772 desc: &'a str,
1773 theme: &theme::Theme,
1774) -> ratatui::text::Line<'a> {
1775 use ratatui::style::{Modifier, Style};
1776 use ratatui::text::{Line, Span};
1777 Line::from(vec![
1778 Span::raw(" "),
1779 Span::styled(
1780 format!("{key:<16}"),
1781 Style::default()
1782 .fg(theme.accent)
1783 .add_modifier(Modifier::BOLD),
1784 ),
1785 Span::raw(" "),
1786 Span::raw(desc),
1787 ])
1788}
1789
1790fn screen_keymap(active_screen: usize) -> &'static [(&'static str, &'static str)] {
1794 match active_screen {
1795 1 => &[
1797 ("↑↓ / j k", "move row selection"),
1798 ("Enter", "drill batch — bucket histogram + worst-N"),
1799 ("Esc", "close drill"),
1800 ],
1801 3 => &[("r", "run on-demand rchash benchmark")],
1803 4 => &[
1804 ("↑↓ / j k", "move peer selection"),
1805 (
1806 "Enter",
1807 "drill peer — balance / cheques / settlement / ping",
1808 ),
1809 ("Esc", "close drill"),
1810 ],
1811 8 => &[
1815 ("↑↓ / j k", "scroll one row"),
1816 ("PgUp / PgDn", "scroll ten rows"),
1817 ("Home", "back to top"),
1818 ],
1819 9 => &[
1821 ("↑↓ / j k", "move row selection"),
1822 ("Enter", "integrity-check the highlighted pin"),
1823 ("c", "integrity-check every unchecked pin"),
1824 ("s", "cycle sort: ref order / bad first / by size"),
1825 ],
1826 _ => &[],
1827 }
1828}
1829
1830fn build_screens(api: &Arc<ApiClient>, watch: &BeeWatch) -> Vec<Box<dyn Component>> {
1839 let health = Health::new(api.clone(), watch.health(), watch.topology());
1840 let stamps = Stamps::new(api.clone(), watch.stamps());
1841 let swap = Swap::new(watch.swap());
1842 let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
1843 let peers = Peers::new(api.clone(), watch.topology());
1844 let network = Network::new(watch.network(), watch.topology());
1845 let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
1846 let api_health = ApiHealth::new(
1847 api.clone(),
1848 watch.health(),
1849 watch.transactions(),
1850 log_capture::handle(),
1851 );
1852 let tags = Tags::new(watch.tags());
1853 let pins = Pins::new(api.clone(), watch.pins());
1854 vec![
1855 Box::new(health),
1856 Box::new(stamps),
1857 Box::new(swap),
1858 Box::new(lottery),
1859 Box::new(peers),
1860 Box::new(network),
1861 Box::new(warmup),
1862 Box::new(api_health),
1863 Box::new(tags),
1864 Box::new(pins),
1865 ]
1866}
1867
1868fn build_synthetic_probe_chunk() -> Vec<u8> {
1876 use std::time::{SystemTime, UNIX_EPOCH};
1877 let nanos = SystemTime::now()
1878 .duration_since(UNIX_EPOCH)
1879 .map(|d| d.as_nanos())
1880 .unwrap_or(0);
1881 let mut data = Vec::with_capacity(8 + 4096);
1882 data.extend_from_slice(&4096u64.to_le_bytes());
1884 data.extend_from_slice(&nanos.to_le_bytes());
1886 data.resize(8 + 4096, 0);
1887 data
1888}
1889
1890fn short_hex(hex: &str, len: usize) -> String {
1893 if hex.len() > len {
1894 format!("{}…", &hex[..len])
1895 } else {
1896 hex.to_string()
1897 }
1898}
1899
1900fn build_metrics_render_fn(
1906 watch: BeeWatch,
1907 log_capture: Option<log_capture::LogCapture>,
1908) -> crate::metrics_server::RenderFn {
1909 use std::time::{SystemTime, UNIX_EPOCH};
1910 Arc::new(move || {
1911 let health = watch.health().borrow().clone();
1912 let stamps = watch.stamps().borrow().clone();
1913 let swap = watch.swap().borrow().clone();
1914 let lottery = watch.lottery().borrow().clone();
1915 let topology = watch.topology().borrow().clone();
1916 let network = watch.network().borrow().clone();
1917 let transactions = watch.transactions().borrow().clone();
1918 let recent = log_capture
1919 .as_ref()
1920 .map(|c| c.snapshot())
1921 .unwrap_or_default();
1922 let call_stats = crate::components::api_health::call_stats_for(&recent);
1923 let now_unix = SystemTime::now()
1924 .duration_since(UNIX_EPOCH)
1925 .map(|d| d.as_secs() as i64)
1926 .unwrap_or(0);
1927 let inputs = crate::metrics::MetricsInputs {
1928 bee_tui_version: env!("CARGO_PKG_VERSION"),
1929 health: &health,
1930 stamps: &stamps,
1931 swap: &swap,
1932 lottery: &lottery,
1933 topology: &topology,
1934 network: &network,
1935 transactions: &transactions,
1936 call_stats: &call_stats,
1937 now_unix,
1938 };
1939 crate::metrics::render(&inputs)
1940 })
1941}
1942
1943fn format_gate_line(g: &Gate) -> String {
1944 let glyphs = crate::theme::active().glyphs;
1945 let glyph = match g.status {
1946 GateStatus::Pass => glyphs.pass,
1947 GateStatus::Warn => glyphs.warn,
1948 GateStatus::Fail => glyphs.fail,
1949 GateStatus::Unknown => glyphs.bullet,
1950 };
1951 let mut s = format!(
1952 " [{glyph}] {label:<28} {value}\n",
1953 label = g.label,
1954 value = g.value
1955 );
1956 if let Some(why) = &g.why {
1957 s.push_str(&format!(" {} {why}\n", glyphs.continuation));
1958 }
1959 s
1960}
1961
1962fn path_only(url: &str) -> String {
1965 if let Some(idx) = url.find("//") {
1966 let after_scheme = &url[idx + 2..];
1967 if let Some(slash) = after_scheme.find('/') {
1968 return after_scheme[slash..].to_string();
1969 }
1970 return "/".into();
1971 }
1972 url.to_string()
1973}
1974
1975fn append(path: &PathBuf, s: &str) -> std::io::Result<()> {
1982 use std::io::Write;
1983 let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
1984 f.write_all(s.as_bytes())
1985}
1986
1987fn verbosity_rank(s: &str) -> u8 {
1993 match s {
1994 "all" | "trace" => 5,
1995 "debug" => 4,
1996 "info" | "1" => 3,
1997 "warning" | "warn" | "2" => 2,
1998 "error" | "3" => 1,
1999 _ => 0,
2000 }
2001}
2002
2003fn sanitize_for_filename(s: &str) -> String {
2007 s.chars()
2008 .map(|c| match c {
2009 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
2010 _ => '-',
2011 })
2012 .collect()
2013}
2014
2015#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2019pub enum QuitResolution {
2020 Confirm,
2022 Pending,
2025}
2026
2027fn resolve_quit_press(prev: Option<Instant>, now: Instant, window: Duration) -> QuitResolution {
2032 match prev {
2033 Some(t) if now.duration_since(t) <= window => QuitResolution::Confirm,
2034 _ => QuitResolution::Pending,
2035 }
2036}
2037
2038fn format_utc_now() -> String {
2039 let secs = SystemTime::now()
2040 .duration_since(UNIX_EPOCH)
2041 .map(|d| d.as_secs())
2042 .unwrap_or(0);
2043 let secs_in_day = secs % 86_400;
2044 let h = secs_in_day / 3_600;
2045 let m = (secs_in_day % 3_600) / 60;
2046 let s = secs_in_day % 60;
2047 format!("{h:02}:{m:02}:{s:02}")
2048}
2049
2050#[cfg(test)]
2051mod tests {
2052 use super::*;
2053
2054 #[test]
2055 fn format_utc_now_returns_eight_chars() {
2056 let s = format_utc_now();
2057 assert_eq!(s.len(), 8);
2058 assert_eq!(s.chars().nth(2), Some(':'));
2059 assert_eq!(s.chars().nth(5), Some(':'));
2060 }
2061
2062 #[test]
2063 fn path_only_strips_scheme_and_host() {
2064 assert_eq!(path_only("http://10.0.1.5:1633/status"), "/status");
2065 assert_eq!(
2066 path_only("https://bee.example.com/stamps?limit=10"),
2067 "/stamps?limit=10"
2068 );
2069 }
2070
2071 #[test]
2072 fn path_only_handles_no_path() {
2073 assert_eq!(path_only("http://localhost:1633"), "/");
2074 }
2075
2076 #[test]
2077 fn path_only_passes_relative_through() {
2078 assert_eq!(path_only("/already/relative"), "/already/relative");
2079 }
2080
2081 #[test]
2082 fn sanitize_for_filename_keeps_safe_chars() {
2083 assert_eq!(sanitize_for_filename("prod-1"), "prod-1");
2084 assert_eq!(sanitize_for_filename("lab_node"), "lab_node");
2085 }
2086
2087 #[test]
2088 fn sanitize_for_filename_replaces_unsafe_chars() {
2089 assert_eq!(sanitize_for_filename("a/b\\c d"), "a-b-c-d");
2090 assert_eq!(sanitize_for_filename("name:colon"), "name-colon");
2091 }
2092
2093 #[test]
2094 fn resolve_quit_press_first_press_is_pending() {
2095 let now = Instant::now();
2096 assert_eq!(
2097 resolve_quit_press(None, now, Duration::from_millis(1500)),
2098 QuitResolution::Pending
2099 );
2100 }
2101
2102 #[test]
2103 fn resolve_quit_press_second_press_inside_window_confirms() {
2104 let first = Instant::now();
2105 let window = Duration::from_millis(1500);
2106 let second = first + Duration::from_millis(500);
2107 assert_eq!(
2108 resolve_quit_press(Some(first), second, window),
2109 QuitResolution::Confirm
2110 );
2111 }
2112
2113 #[test]
2114 fn resolve_quit_press_second_press_after_window_resets_to_pending() {
2115 let first = Instant::now();
2119 let window = Duration::from_millis(1500);
2120 let second = first + Duration::from_millis(2_000);
2121 assert_eq!(
2122 resolve_quit_press(Some(first), second, window),
2123 QuitResolution::Pending
2124 );
2125 }
2126
2127 #[test]
2128 fn resolve_quit_press_at_window_boundary_confirms() {
2129 let first = Instant::now();
2132 let window = Duration::from_millis(1500);
2133 let second = first + window;
2134 assert_eq!(
2135 resolve_quit_press(Some(first), second, window),
2136 QuitResolution::Confirm
2137 );
2138 }
2139
2140 #[test]
2141 fn screen_keymap_covers_drill_screens() {
2142 for idx in [1usize, 4] {
2145 let rows = screen_keymap(idx);
2146 assert!(
2147 rows.iter().any(|(k, _)| k.contains("Enter")),
2148 "screen {idx} keymap must mention Enter (drill)"
2149 );
2150 assert!(
2151 rows.iter().any(|(k, _)| k.contains("Esc")),
2152 "screen {idx} keymap must mention Esc (close drill)"
2153 );
2154 }
2155 }
2156
2157 #[test]
2158 fn screen_keymap_lottery_advertises_rchash() {
2159 let rows = screen_keymap(3);
2160 assert!(rows.iter().any(|(k, _)| k.contains("r")));
2161 }
2162
2163 #[test]
2164 fn screen_keymap_unknown_index_is_empty_not_panic() {
2165 assert!(screen_keymap(999).is_empty());
2166 }
2167
2168 #[test]
2169 fn verbosity_rank_orders_loud_to_silent() {
2170 assert!(verbosity_rank("all") > verbosity_rank("debug"));
2171 assert!(verbosity_rank("debug") > verbosity_rank("info"));
2172 assert!(verbosity_rank("info") > verbosity_rank("warning"));
2173 assert!(verbosity_rank("warning") > verbosity_rank("error"));
2174 assert!(verbosity_rank("error") > verbosity_rank("unknown"));
2175 assert_eq!(verbosity_rank("info"), verbosity_rank("1"));
2177 assert_eq!(verbosity_rank("warning"), verbosity_rank("2"));
2178 }
2179
2180 #[test]
2181 fn filter_command_suggestions_empty_buffer_returns_all() {
2182 let matches = filter_command_suggestions("", KNOWN_COMMANDS);
2183 assert_eq!(matches.len(), KNOWN_COMMANDS.len());
2184 }
2185
2186 #[test]
2187 fn filter_command_suggestions_prefix_matches_case_insensitive() {
2188 let matches = filter_command_suggestions("Bu", KNOWN_COMMANDS);
2189 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
2190 assert!(names.contains(&"buy-preview"));
2191 assert!(names.contains(&"buy-suggest"));
2192 assert_eq!(names.len(), 2);
2193 }
2194
2195 #[test]
2196 fn filter_command_suggestions_unknown_prefix_is_empty() {
2197 let matches = filter_command_suggestions("xyz", KNOWN_COMMANDS);
2198 assert!(matches.is_empty());
2199 }
2200
2201 #[test]
2202 fn filter_command_suggestions_uses_first_token_only() {
2203 let matches = filter_command_suggestions("topup-preview a1b2 1000", KNOWN_COMMANDS);
2206 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
2207 assert_eq!(names, vec!["topup-preview"]);
2208 }
2209
2210 #[test]
2211 fn probe_chunk_is_4104_bytes_with_correct_span() {
2212 let chunk = build_synthetic_probe_chunk();
2214 assert_eq!(chunk.len(), 4104);
2215 let span = u64::from_le_bytes(chunk[..8].try_into().unwrap());
2216 assert_eq!(span, 4096);
2217 }
2218
2219 #[test]
2220 fn probe_chunk_payloads_are_unique_per_call() {
2221 let a = build_synthetic_probe_chunk();
2226 std::thread::sleep(Duration::from_micros(1));
2228 let b = build_synthetic_probe_chunk();
2229 assert_ne!(&a[8..24], &b[8..24]);
2230 }
2231
2232 #[test]
2233 fn short_hex_truncates_with_ellipsis() {
2234 assert_eq!(short_hex("a1b2c3d4e5f6", 8), "a1b2c3d4…");
2235 assert_eq!(short_hex("short", 8), "short");
2236 assert_eq!(short_hex("abcdefgh", 8), "abcdefgh");
2237 }
2238}