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 manifest::Manifest,
24 network::Network,
25 peers::Peers,
26 pins::Pins,
27 stamps::Stamps,
28 swap::Swap,
29 tags::Tags,
30 warmup::Warmup,
31 watchlist::Watchlist,
32 },
33 config::Config,
34 durability, log_capture,
35 manifest_walker::{self, InspectResult},
36 pprof_bundle, stamp_preview,
37 state::State,
38 theme,
39 tui::{Event, Tui},
40 utility_verbs,
41 watch::{BeeWatch, HealthSnapshot, RefreshProfile},
42};
43
44pub struct App {
45 config: Config,
46 tick_rate: f64,
47 frame_rate: f64,
48 screens: Vec<Box<dyn Component>>,
52 current_screen: usize,
54 log_pane: LogPane,
58 state_path: PathBuf,
61 should_quit: bool,
62 should_suspend: bool,
63 mode: Mode,
64 last_tick_key_events: Vec<KeyEvent>,
65 action_tx: mpsc::UnboundedSender<Action>,
66 action_rx: mpsc::UnboundedReceiver<Action>,
67 root_cancel: CancellationToken,
70 #[allow(dead_code)]
73 api: Arc<ApiClient>,
74 watch: BeeWatch,
76 health_rx: watch::Receiver<HealthSnapshot>,
79 command_buffer: Option<String>,
82 command_suggestion_index: usize,
87 command_status: Option<CommandStatus>,
91 help_visible: bool,
94 quit_pending: Option<Instant>,
100 supervisor: Option<BeeSupervisor>,
104 bee_status: BeeStatus,
109 bee_log_rx: Option<mpsc::UnboundedReceiver<(LogTab, BeeLogLine)>>,
113 cmd_status_tx: mpsc::UnboundedSender<CommandStatus>,
119 cmd_status_rx: mpsc::UnboundedReceiver<CommandStatus>,
120 durability_tx: mpsc::UnboundedSender<crate::durability::DurabilityResult>,
126 durability_rx: mpsc::UnboundedReceiver<crate::durability::DurabilityResult>,
127}
128
129const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
132
133#[derive(Debug, Clone)]
136pub enum CommandStatus {
137 Info(String),
138 Err(String),
139}
140
141const SCREEN_NAMES: &[&str] = &[
144 "Health",
145 "Stamps",
146 "Swap",
147 "Lottery",
148 "Peers",
149 "Network",
150 "Warmup",
151 "API",
152 "Tags",
153 "Pins",
154 "Manifest",
155 "Watchlist",
156];
157
158const KNOWN_COMMANDS: &[(&str, &str)] = &[
169 ("health", "S1 Health screen"),
170 ("stamps", "S2 Stamps screen"),
171 ("swap", "S3 SWAP / cheques screen"),
172 ("lottery", "S4 Lottery + rchash"),
173 ("peers", "S6 Peers + bin saturation"),
174 ("network", "S7 Network / NAT"),
175 ("warmup", "S5 Warmup checklist"),
176 ("api", "S8 RPC / API health"),
177 ("tags", "S9 Tags / uploads"),
178 ("pins", "S11 Pins screen"),
179 ("topup-preview", "<batch> <amount-plur> — predict topup"),
180 ("dilute-preview", "<batch> <new-depth> — predict dilute"),
181 ("extend-preview", "<batch> <duration> — predict extend"),
182 ("buy-preview", "<depth> <amount-plur> — predict fresh buy"),
183 ("buy-suggest", "<size> <duration> — minimum (depth, amount)"),
184 (
185 "plan-batch",
186 "<batch> [usage-thr] [ttl-thr] [extra-depth] — unified topup+dilute plan",
187 ),
188 (
189 "probe-upload",
190 "<batch> — single 4 KiB chunk, end-to-end probe",
191 ),
192 ("manifest", "<ref> — open Mantaray tree browser at a reference"),
193 ("inspect", "<ref> — what is this? auto-detects manifest vs raw chunk"),
194 (
195 "durability-check",
196 "<ref> — walk chunk graph, report total / lost / errors",
197 ),
198 ("watchlist", "S13 Watchlist — durability-check history"),
199 ("hash", "<path> — Swarm reference of a local file/dir (offline)"),
200 ("cid", "<ref> [manifest|feed] — encode reference as CID"),
201 ("depth-table", "Print canonical depth → capacity table"),
202 ("gsoc-mine", "<overlay> <id> — mine a GSOC signer (CPU work)"),
203 (
204 "pss-target",
205 "<overlay> — first 4 hex chars (Bee's max prefix)",
206 ),
207 (
208 "diagnose",
209 "[--pprof[=N]] Export snapshot (+ optional CPU profile + trace)",
210 ),
211 ("pins-check", "Bulk integrity walk to a file"),
212 ("loggers", "Dump live logger registry"),
213 ("set-logger", "<expr> <level> — change a logger's verbosity"),
214 ("context", "<name> — switch node profile"),
215 ("quit", "Exit the cockpit"),
216];
217
218fn parse_pprof_arg(line: &str) -> Option<u32> {
223 for tok in line.split_whitespace() {
224 if tok == "--pprof" {
225 return Some(60);
226 }
227 if let Some(rest) = tok.strip_prefix("--pprof=") {
228 if let Ok(n) = rest.parse::<u32>() {
229 return Some(n.clamp(1, 600));
230 }
231 }
232 }
233 None
234}
235
236fn filter_command_suggestions<'a>(
240 buffer: &str,
241 catalog: &'a [(&'a str, &'a str)],
242) -> Vec<&'a (&'a str, &'a str)> {
243 let head = buffer
244 .split_whitespace()
245 .next()
246 .unwrap_or("")
247 .to_ascii_lowercase();
248 catalog
249 .iter()
250 .filter(|(name, _)| name.starts_with(&head))
251 .collect()
252}
253
254#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
255pub enum Mode {
256 #[default]
257 Home,
258}
259
260#[derive(Debug, Default)]
263pub struct AppOverrides {
264 pub ascii: bool,
266 pub no_color: bool,
268 pub bee_bin: Option<PathBuf>,
270 pub bee_config: Option<PathBuf>,
272}
273
274const BEE_API_READY_TIMEOUT: Duration = Duration::from_secs(60);
279
280impl App {
281 pub async fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
282 Self::with_overrides(tick_rate, frame_rate, AppOverrides::default()).await
283 }
284
285 pub async fn with_overrides(
290 tick_rate: f64,
291 frame_rate: f64,
292 overrides: AppOverrides,
293 ) -> color_eyre::Result<Self> {
294 let (action_tx, action_rx) = mpsc::unbounded_channel();
295 let (cmd_status_tx, cmd_status_rx) = mpsc::unbounded_channel();
296 let (durability_tx, durability_rx) = mpsc::unbounded_channel();
297 let config = Config::new()?;
298 let force_no_color = overrides.no_color || theme::no_color_env();
301 theme::install_with_overrides(&config.ui, force_no_color, overrides.ascii);
302
303 let node = config
306 .active_node()
307 .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
308 let api = Arc::new(ApiClient::from_node(node)?);
309
310 let bee_bin = overrides
312 .bee_bin
313 .or_else(|| config.bee.as_ref().map(|b| b.bin.clone()));
314 let bee_config = overrides
315 .bee_config
316 .or_else(|| config.bee.as_ref().map(|b| b.config.clone()));
317 let bee_logs = config
320 .bee
321 .as_ref()
322 .map(|b| b.logs.clone())
323 .unwrap_or_default();
324 let supervisor = match (bee_bin, bee_config) {
325 (Some(bin), Some(cfg)) => {
326 eprintln!("bee-tui: spawning bee {bin:?} --config {cfg:?}");
327 let mut sup = BeeSupervisor::spawn(&bin, &cfg, bee_logs)?;
328 eprintln!(
329 "bee-tui: log → {} (will appear in the cockpit's bottom pane)",
330 sup.log_path().display()
331 );
332 eprintln!(
333 "bee-tui: waiting for {} to respond on /health (up to {:?})...",
334 api.url, BEE_API_READY_TIMEOUT
335 );
336 sup.wait_for_api(&api.url, BEE_API_READY_TIMEOUT).await?;
337 eprintln!("bee-tui: bee ready, opening cockpit");
338 Some(sup)
339 }
340 (Some(_), None) | (None, Some(_)) => {
341 return Err(eyre!(
342 "[bee].bin and [bee].config must both be set (or both unset). \
343 Use --bee-bin AND --bee-config, or both fields in config.toml."
344 ));
345 }
346 (None, None) => None,
347 };
348
349 let refresh = RefreshProfile::from_config(&config.ui.refresh);
356 let root_cancel = CancellationToken::new();
357 let watch = BeeWatch::start_with_profile(api.clone(), &root_cancel, refresh);
358 let health_rx = watch.health();
359
360 let screens = build_screens(&api, &watch);
361 let (persisted, state_path) = State::load();
366 let initial_tab = LogTab::from_kebab(&persisted.log_pane_active_tab);
367 let mut log_pane = LogPane::new(
368 log_capture::handle(),
369 initial_tab,
370 persisted.log_pane_height,
371 );
372 log_pane.set_spawn_active(supervisor.is_some());
373 if let Some(c) = log_capture::cockpit_handle() {
374 log_pane.set_cockpit_capture(c);
375 }
376
377 let bee_log_rx = supervisor.as_ref().map(|sup| {
383 let (tx, rx) = mpsc::unbounded_channel();
384 crate::bee_log_tailer::spawn(
385 sup.log_path().to_path_buf(),
386 tx,
387 root_cancel.child_token(),
388 );
389 rx
390 });
391
392 if config.metrics.enabled {
399 match config.metrics.addr.parse::<std::net::SocketAddr>() {
400 Ok(bind_addr) => {
401 let render_fn = build_metrics_render_fn(watch.clone(), log_capture::handle());
402 let cancel = root_cancel.child_token();
403 match crate::metrics_server::spawn(bind_addr, render_fn, cancel).await {
404 Ok(actual) => {
405 eprintln!(
406 "bee-tui: metrics endpoint serving /metrics on http://{actual}"
407 );
408 }
409 Err(e) => {
410 tracing::error!(
411 "metrics: failed to start endpoint on {bind_addr}: {e}"
412 );
413 }
414 }
415 }
416 Err(e) => {
417 tracing::error!(
418 "metrics: invalid [metrics].addr {:?}: {e}",
419 config.metrics.addr
420 );
421 }
422 }
423 }
424
425 Ok(Self {
426 tick_rate,
427 frame_rate,
428 screens,
429 current_screen: 0,
430 log_pane,
431 state_path,
432 should_quit: false,
433 should_suspend: false,
434 config,
435 mode: Mode::Home,
436 last_tick_key_events: Vec::new(),
437 action_tx,
438 action_rx,
439 root_cancel,
440 api,
441 watch,
442 health_rx,
443 command_buffer: None,
444 command_suggestion_index: 0,
445 command_status: None,
446 help_visible: false,
447 quit_pending: None,
448 supervisor,
449 bee_status: BeeStatus::Running,
450 bee_log_rx,
451 cmd_status_tx,
452 cmd_status_rx,
453 durability_tx,
454 durability_rx,
455 })
456 }
457
458 pub async fn run(&mut self) -> color_eyre::Result<()> {
459 let mut tui = Tui::new()?
460 .tick_rate(self.tick_rate)
462 .frame_rate(self.frame_rate);
463 tui.enter()?;
464
465 let tx = self.action_tx.clone();
466 let cfg = self.config.clone();
467 let size = tui.size()?;
468 for component in self.iter_components_mut() {
469 component.register_action_handler(tx.clone())?;
470 component.register_config_handler(cfg.clone())?;
471 component.init(size)?;
472 }
473
474 let action_tx = self.action_tx.clone();
475 loop {
476 self.handle_events(&mut tui).await?;
477 self.handle_actions(&mut tui)?;
478 if self.should_suspend {
479 tui.suspend()?;
480 action_tx.send(Action::Resume)?;
481 action_tx.send(Action::ClearScreen)?;
482 tui.enter()?;
484 } else if self.should_quit {
485 tui.stop()?;
486 break;
487 }
488 }
489 self.watch.shutdown();
491 self.root_cancel.cancel();
492 let snapshot = State {
496 log_pane_height: self.log_pane.height(),
497 log_pane_active_tab: self.log_pane.active_tab().to_kebab().to_string(),
498 };
499 snapshot.save(&self.state_path);
500 if let Some(sup) = self.supervisor.take() {
504 let final_status = sup.shutdown_default().await;
505 tracing::info!("bee child exited: {}", final_status.label());
506 }
507 tui.exit()?;
508 Ok(())
509 }
510
511 async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
512 let Some(event) = tui.next_event().await else {
513 return Ok(());
514 };
515 let action_tx = self.action_tx.clone();
516 let modal_before = self.command_buffer.is_some() || self.help_visible;
523 match event {
524 Event::Quit => action_tx.send(Action::Quit)?,
525 Event::Tick => action_tx.send(Action::Tick)?,
526 Event::Render => action_tx.send(Action::Render)?,
527 Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
528 Event::Key(key) => self.handle_key_event(key)?,
529 _ => {}
530 }
531 let modal_after = self.command_buffer.is_some() || self.help_visible;
532 let propagate = !((modal_before || modal_after) && matches!(event, Event::Key(_)));
535 if propagate {
536 for component in self.iter_components_mut() {
537 if let Some(action) = component.handle_events(Some(event.clone()))? {
538 action_tx.send(action)?;
539 }
540 }
541 }
542 Ok(())
543 }
544
545 fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut dyn Component> {
550 self.screens
551 .iter_mut()
552 .map(|c| c.as_mut() as &mut dyn Component)
553 .chain(std::iter::once(&mut self.log_pane as &mut dyn Component))
554 }
555
556 fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
557 if self.command_buffer.is_some() {
561 self.handle_command_mode_key(key)?;
562 return Ok(());
563 }
564 if self.help_visible {
568 match key.code {
569 crossterm::event::KeyCode::Esc
570 | crossterm::event::KeyCode::Char('?')
571 | crossterm::event::KeyCode::Char('q') => {
572 self.help_visible = false;
573 }
574 _ => {}
575 }
576 return Ok(());
577 }
578 if matches!(key.code, crossterm::event::KeyCode::Char('?')) {
582 self.help_visible = true;
583 return Ok(());
584 }
585 let action_tx = self.action_tx.clone();
586 if matches!(key.code, crossterm::event::KeyCode::Char(':')) {
588 self.command_buffer = Some(String::new());
589 self.command_status = None;
590 return Ok(());
591 }
592 if matches!(key.code, crossterm::event::KeyCode::Tab) {
597 if !self.screens.is_empty() {
598 self.current_screen = (self.current_screen + 1) % self.screens.len();
599 debug!(
600 "switched to screen {}",
601 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
602 );
603 }
604 return Ok(());
605 }
606 if matches!(key.code, crossterm::event::KeyCode::BackTab) {
607 if !self.screens.is_empty() {
608 let len = self.screens.len();
609 self.current_screen = (self.current_screen + len - 1) % len;
610 debug!(
611 "switched to screen {}",
612 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
613 );
614 }
615 return Ok(());
616 }
617 if matches!(key.code, crossterm::event::KeyCode::Char('['))
623 && key.modifiers == crossterm::event::KeyModifiers::NONE
624 {
625 self.log_pane.prev_tab();
626 return Ok(());
627 }
628 if matches!(key.code, crossterm::event::KeyCode::Char(']'))
629 && key.modifiers == crossterm::event::KeyModifiers::NONE
630 {
631 self.log_pane.next_tab();
632 return Ok(());
633 }
634 if matches!(key.code, crossterm::event::KeyCode::Char('+'))
635 && key.modifiers == crossterm::event::KeyModifiers::NONE
636 {
637 self.log_pane.grow();
638 return Ok(());
639 }
640 if matches!(key.code, crossterm::event::KeyCode::Char('-'))
641 && key.modifiers == crossterm::event::KeyModifiers::NONE
642 {
643 self.log_pane.shrink();
644 return Ok(());
645 }
646 if key.modifiers == crossterm::event::KeyModifiers::SHIFT {
651 match key.code {
652 crossterm::event::KeyCode::Up => {
653 self.log_pane.scroll_up(1);
654 return Ok(());
655 }
656 crossterm::event::KeyCode::Down => {
657 self.log_pane.scroll_down(1);
658 return Ok(());
659 }
660 crossterm::event::KeyCode::PageUp => {
661 self.log_pane.scroll_up(10);
662 return Ok(());
663 }
664 crossterm::event::KeyCode::PageDown => {
665 self.log_pane.scroll_down(10);
666 return Ok(());
667 }
668 crossterm::event::KeyCode::End => {
669 self.log_pane.resume_tail();
670 return Ok(());
671 }
672 crossterm::event::KeyCode::Left => {
678 self.log_pane.scroll_left(8);
679 return Ok(());
680 }
681 crossterm::event::KeyCode::Right => {
682 self.log_pane.scroll_right(8);
683 return Ok(());
684 }
685 _ => {}
686 }
687 }
688 if matches!(key.code, crossterm::event::KeyCode::Char('q'))
694 && key.modifiers == crossterm::event::KeyModifiers::NONE
695 {
696 match resolve_quit_press(self.quit_pending, Instant::now(), QUIT_CONFIRM_WINDOW) {
697 QuitResolution::Confirm => {
698 self.quit_pending = None;
699 self.action_tx.send(Action::Quit)?;
700 }
701 QuitResolution::Pending => {
702 self.quit_pending = Some(Instant::now());
703 self.command_status = Some(CommandStatus::Info(
704 "press q again to quit (Esc cancels)".into(),
705 ));
706 }
707 }
708 return Ok(());
709 }
710 if self.quit_pending.is_some() {
714 self.quit_pending = None;
715 }
716 let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
717 return Ok(());
718 };
719 match keymap.get(&vec![key]) {
720 Some(action) => {
721 info!("Got action: {action:?}");
722 action_tx.send(action.clone())?;
723 }
724 _ => {
725 self.last_tick_key_events.push(key);
728
729 if let Some(action) = keymap.get(&self.last_tick_key_events) {
731 info!("Got action: {action:?}");
732 action_tx.send(action.clone())?;
733 }
734 }
735 }
736 Ok(())
737 }
738
739 fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
740 use crossterm::event::KeyCode;
741 let buf = match self.command_buffer.as_mut() {
742 Some(b) => b,
743 None => return Ok(()),
744 };
745 match key.code {
746 KeyCode::Esc => {
747 self.command_buffer = None;
749 self.command_suggestion_index = 0;
750 }
751 KeyCode::Enter => {
752 let line = std::mem::take(buf);
753 self.command_buffer = None;
754 self.command_suggestion_index = 0;
755 self.execute_command(&line)?;
756 }
757 KeyCode::Up => {
758 self.command_suggestion_index = self.command_suggestion_index.saturating_sub(1);
761 }
762 KeyCode::Down => {
763 let n = filter_command_suggestions(buf, KNOWN_COMMANDS).len();
764 if n > 0 && self.command_suggestion_index + 1 < n {
765 self.command_suggestion_index += 1;
766 }
767 }
768 KeyCode::Tab => {
769 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
773 if let Some((name, _)) = matches.get(self.command_suggestion_index) {
774 let rest = buf
775 .split_once(char::is_whitespace)
776 .map(|(_, tail)| tail)
777 .unwrap_or("");
778 let new = if rest.is_empty() {
779 format!("{name} ")
780 } else {
781 format!("{name} {rest}")
782 };
783 buf.clear();
784 buf.push_str(&new);
785 self.command_suggestion_index = 0;
786 }
787 }
788 KeyCode::Backspace => {
789 buf.pop();
790 self.command_suggestion_index = 0;
791 }
792 KeyCode::Char(c) => {
793 buf.push(c);
794 self.command_suggestion_index = 0;
795 }
796 _ => {}
797 }
798 Ok(())
799 }
800
801 fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
804 let trimmed = line.trim();
805 if trimmed.is_empty() {
806 return Ok(());
807 }
808 let head = trimmed.split_whitespace().next().unwrap_or("");
809 match head {
810 "q" | "quit" => {
811 self.action_tx.send(Action::Quit)?;
812 self.command_status = Some(CommandStatus::Info("quitting".into()));
813 }
814 "diagnose" | "diag" => {
815 let pprof_secs = parse_pprof_arg(trimmed);
816 if let Some(secs) = pprof_secs {
817 self.command_status = Some(self.start_diagnose_with_pprof(secs));
818 } else {
819 self.command_status = Some(match self.export_diagnostic_bundle() {
820 Ok(path) => CommandStatus::Info(format!(
821 "diagnostic bundle exported to {}",
822 path.display()
823 )),
824 Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
825 });
826 }
827 }
828 "pins-check" => {
829 self.command_status = Some(match self.start_pins_check() {
835 Ok(path) => CommandStatus::Info(format!(
836 "pins integrity check running → {} (tail to watch progress)",
837 path.display()
838 )),
839 Err(e) => CommandStatus::Err(format!("pins-check failed to start: {e}")),
840 });
841 }
842 "loggers" => {
843 self.command_status = Some(match self.start_loggers_dump() {
844 Ok(path) => CommandStatus::Info(format!(
845 "loggers snapshot writing → {} (open when ready)",
846 path.display()
847 )),
848 Err(e) => CommandStatus::Err(format!("loggers failed to start: {e}")),
849 });
850 }
851 "set-logger" => {
852 let mut parts = trimmed.split_whitespace();
853 let _ = parts.next(); let expr = parts.next().unwrap_or("");
855 let level = parts.next().unwrap_or("");
856 if expr.is_empty() || level.is_empty() {
857 self.command_status = Some(CommandStatus::Err(
858 "usage: :set-logger <expr> <level> (level: none|error|warning|info|debug|all; expr: e.g. node/pushsync or '.' for all)"
859 .into(),
860 ));
861 return Ok(());
862 }
863 self.start_set_logger(expr.to_string(), level.to_string());
864 self.command_status = Some(CommandStatus::Info(format!(
865 "set-logger {expr:?} → {level:?} (PUT in-flight; check :loggers to verify)"
866 )));
867 }
868 "topup-preview" => {
869 self.command_status = Some(self.run_topup_preview(trimmed));
870 }
871 "dilute-preview" => {
872 self.command_status = Some(self.run_dilute_preview(trimmed));
873 }
874 "extend-preview" => {
875 self.command_status = Some(self.run_extend_preview(trimmed));
876 }
877 "buy-preview" => {
878 self.command_status = Some(self.run_buy_preview(trimmed));
879 }
880 "buy-suggest" => {
881 self.command_status = Some(self.run_buy_suggest(trimmed));
882 }
883 "plan-batch" => {
884 self.command_status = Some(self.run_plan_batch(trimmed));
885 }
886 "probe-upload" => {
887 self.command_status = Some(self.run_probe_upload(trimmed));
888 }
889 "hash" => {
890 self.command_status = Some(self.run_hash(trimmed));
891 }
892 "cid" => {
893 self.command_status = Some(self.run_cid(trimmed));
894 }
895 "depth-table" => {
896 self.command_status = Some(self.run_depth_table());
897 }
898 "gsoc-mine" => {
899 self.command_status = Some(self.run_gsoc_mine(trimmed));
900 }
901 "pss-target" => {
902 self.command_status = Some(self.run_pss_target(trimmed));
903 }
904 "manifest" => {
905 self.command_status = Some(self.run_manifest(trimmed));
906 }
907 "inspect" => {
908 self.command_status = Some(self.run_inspect(trimmed));
909 }
910 "durability-check" => {
911 self.command_status = Some(self.run_durability_check(trimmed));
912 }
913 "context" | "ctx" => {
914 let target = trimmed.split_whitespace().nth(1).unwrap_or("");
915 if target.is_empty() {
916 let known: Vec<String> =
917 self.config.nodes.iter().map(|n| n.name.clone()).collect();
918 self.command_status = Some(CommandStatus::Err(format!(
919 "usage: :context <name> (known: {})",
920 known.join(", ")
921 )));
922 return Ok(());
923 }
924 self.command_status = Some(match self.switch_context(target) {
925 Ok(()) => CommandStatus::Info(format!(
926 "switched to context {target} ({})",
927 self.api.url
928 )),
929 Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
930 });
931 }
932 screen
933 if SCREEN_NAMES
934 .iter()
935 .any(|name| name.eq_ignore_ascii_case(screen)) =>
936 {
937 if let Some(idx) = SCREEN_NAMES
938 .iter()
939 .position(|name| name.eq_ignore_ascii_case(screen))
940 {
941 self.current_screen = idx;
942 self.command_status =
943 Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
944 }
945 }
946 other => {
947 self.command_status = Some(CommandStatus::Err(format!(
948 "unknown command: {other:?} (try :health, :stamps, :swap, :lottery, :peers, :network, :warmup, :api, :tags, :pins, :manifest, :inspect, :diagnose, :pins-check, :loggers, :set-logger, :topup-preview, :dilute-preview, :extend-preview, :buy-preview, :buy-suggest, :plan-batch, :probe-upload, :hash, :cid, :depth-table, :gsoc-mine, :pss-target, :context, :quit)"
949 )));
950 }
951 }
952 Ok(())
953 }
954
955 fn run_topup_preview(&self, line: &str) -> CommandStatus {
959 let parts: Vec<&str> = line.split_whitespace().collect();
960 let (prefix, amount_str) = match parts.as_slice() {
961 [_, prefix, amount, ..] => (*prefix, *amount),
962 _ => {
963 return CommandStatus::Err(
964 "usage: :topup-preview <batch-prefix> <amount-plur-per-chunk>".into(),
965 );
966 }
967 };
968 let chain = match self.health_rx.borrow().chain_state.clone() {
969 Some(c) => c,
970 None => return CommandStatus::Err("chain state not loaded yet".into()),
971 };
972 let stamps = self.watch.stamps().borrow().clone();
973 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
974 Ok(b) => b.clone(),
975 Err(e) => return CommandStatus::Err(e),
976 };
977 let amount = match stamp_preview::parse_plur_amount(amount_str) {
978 Ok(a) => a,
979 Err(e) => return CommandStatus::Err(e),
980 };
981 match stamp_preview::topup_preview(&batch, amount, &chain) {
982 Ok(p) => CommandStatus::Info(p.summary()),
983 Err(e) => CommandStatus::Err(e),
984 }
985 }
986
987 fn run_dilute_preview(&self, line: &str) -> CommandStatus {
991 let parts: Vec<&str> = line.split_whitespace().collect();
992 let (prefix, depth_str) = match parts.as_slice() {
993 [_, prefix, depth, ..] => (*prefix, *depth),
994 _ => {
995 return CommandStatus::Err(
996 "usage: :dilute-preview <batch-prefix> <new-depth>".into(),
997 );
998 }
999 };
1000 let new_depth: u8 = match depth_str.parse() {
1001 Ok(d) => d,
1002 Err(_) => {
1003 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
1004 }
1005 };
1006 let stamps = self.watch.stamps().borrow().clone();
1007 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1008 Ok(b) => b.clone(),
1009 Err(e) => return CommandStatus::Err(e),
1010 };
1011 match stamp_preview::dilute_preview(&batch, new_depth) {
1012 Ok(p) => CommandStatus::Info(p.summary()),
1013 Err(e) => CommandStatus::Err(e),
1014 }
1015 }
1016
1017 fn run_extend_preview(&self, line: &str) -> CommandStatus {
1020 let parts: Vec<&str> = line.split_whitespace().collect();
1021 let (prefix, duration_str) = match parts.as_slice() {
1022 [_, prefix, duration, ..] => (*prefix, *duration),
1023 _ => {
1024 return CommandStatus::Err(
1025 "usage: :extend-preview <batch-prefix> <duration> (e.g. 30d, 12h, 90m, 45s, or plain seconds)".into(),
1026 );
1027 }
1028 };
1029 let extension_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
1030 Ok(s) => s,
1031 Err(e) => return CommandStatus::Err(e),
1032 };
1033 let chain = match self.health_rx.borrow().chain_state.clone() {
1034 Some(c) => c,
1035 None => return CommandStatus::Err("chain state not loaded yet".into()),
1036 };
1037 let stamps = self.watch.stamps().borrow().clone();
1038 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1039 Ok(b) => b.clone(),
1040 Err(e) => return CommandStatus::Err(e),
1041 };
1042 match stamp_preview::extend_preview(&batch, extension_seconds, &chain) {
1043 Ok(p) => CommandStatus::Info(p.summary()),
1044 Err(e) => CommandStatus::Err(e),
1045 }
1046 }
1047
1048 fn run_probe_upload(&self, line: &str) -> CommandStatus {
1060 let parts: Vec<&str> = line.split_whitespace().collect();
1061 let prefix = match parts.as_slice() {
1062 [_, prefix, ..] => *prefix,
1063 _ => {
1064 return CommandStatus::Err(
1065 "usage: :probe-upload <batch-prefix> (uploads one synthetic 4 KiB chunk)"
1066 .into(),
1067 );
1068 }
1069 };
1070 let stamps = self.watch.stamps().borrow().clone();
1071 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1072 Ok(b) => b.clone(),
1073 Err(e) => return CommandStatus::Err(e),
1074 };
1075 if !batch.usable {
1076 return CommandStatus::Err(format!(
1077 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1078 short_hex(&batch.batch_id.to_hex(), 8),
1079 ));
1080 }
1081 if batch.batch_ttl <= 0 {
1082 return CommandStatus::Err(format!(
1083 "batch {} is expired — pick another",
1084 short_hex(&batch.batch_id.to_hex(), 8),
1085 ));
1086 }
1087
1088 let api = self.api.clone();
1089 let tx = self.cmd_status_tx.clone();
1090 let batch_id = batch.batch_id;
1091 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1092 let task_short = batch_short.clone();
1093 tokio::spawn(async move {
1094 let chunk = build_synthetic_probe_chunk();
1095 let started = Instant::now();
1096 let result = api.bee().file().upload_chunk(&batch_id, chunk, None).await;
1097 let elapsed_ms = started.elapsed().as_millis();
1098 let status = match result {
1099 Ok(res) => CommandStatus::Info(format!(
1100 "probe-upload OK in {elapsed_ms}ms — batch {task_short}, ref {}",
1101 short_hex(&res.reference.to_hex(), 8),
1102 )),
1103 Err(e) => CommandStatus::Err(format!(
1104 "probe-upload FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1105 )),
1106 };
1107 let _ = tx.send(status);
1108 });
1109
1110 CommandStatus::Info(format!(
1111 "probe-upload to batch {batch_short} in flight — result will replace this line"
1112 ))
1113 }
1114
1115 fn run_hash(&self, line: &str) -> CommandStatus {
1120 let parts: Vec<&str> = line.split_whitespace().collect();
1121 let path = match parts.as_slice() {
1122 [_, p, ..] => *p,
1123 _ => {
1124 return CommandStatus::Err(
1125 "usage: :hash <path> (file or directory; computed locally)".into(),
1126 );
1127 }
1128 };
1129 match utility_verbs::hash_path(path) {
1130 Ok(r) => CommandStatus::Info(format!("hash {path}: {r}")),
1131 Err(e) => CommandStatus::Err(format!("hash failed: {e}")),
1132 }
1133 }
1134
1135 fn run_cid(&self, line: &str) -> CommandStatus {
1139 let parts: Vec<&str> = line.split_whitespace().collect();
1140 let (ref_hex, kind_arg) = match parts.as_slice() {
1141 [_, r, k, ..] => (*r, Some(*k)),
1142 [_, r] => (*r, None),
1143 _ => {
1144 return CommandStatus::Err(
1145 "usage: :cid <ref> [manifest|feed] (default manifest)".into(),
1146 );
1147 }
1148 };
1149 let kind = match utility_verbs::parse_cid_kind(kind_arg) {
1150 Ok(k) => k,
1151 Err(e) => return CommandStatus::Err(e),
1152 };
1153 match utility_verbs::cid_for_ref(ref_hex, kind) {
1154 Ok(cid) => CommandStatus::Info(format!("cid: {cid}")),
1155 Err(e) => CommandStatus::Err(format!("cid failed: {e}")),
1156 }
1157 }
1158
1159 fn run_depth_table(&self) -> CommandStatus {
1164 let body = utility_verbs::depth_table();
1165 let path = std::env::temp_dir().join("bee-tui-depth-table.txt");
1166 match std::fs::write(&path, &body) {
1167 Ok(()) => CommandStatus::Info(format!("depth table → {}", path.display())),
1168 Err(e) => CommandStatus::Err(format!("depth-table write failed: {e}")),
1169 }
1170 }
1171
1172 fn run_gsoc_mine(&self, line: &str) -> CommandStatus {
1177 let parts: Vec<&str> = line.split_whitespace().collect();
1178 let (overlay, ident) = match parts.as_slice() {
1179 [_, o, i, ..] => (*o, *i),
1180 _ => {
1181 return CommandStatus::Err(
1182 "usage: :gsoc-mine <overlay-hex> <identifier> (CPU work, no network)".into(),
1183 );
1184 }
1185 };
1186 match utility_verbs::gsoc_mine_for(overlay, ident) {
1187 Ok(out) => CommandStatus::Info(out.replace('\n', " · ")),
1188 Err(e) => CommandStatus::Err(format!("gsoc-mine failed: {e}")),
1189 }
1190 }
1191
1192 fn run_manifest(&mut self, line: &str) -> CommandStatus {
1196 let parts: Vec<&str> = line.split_whitespace().collect();
1197 let ref_arg = match parts.as_slice() {
1198 [_, r, ..] => *r,
1199 _ => {
1200 return CommandStatus::Err(
1201 "usage: :manifest <ref> (32-byte hex reference)".into(),
1202 );
1203 }
1204 };
1205 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1206 Ok(r) => r,
1207 Err(e) => return CommandStatus::Err(format!("manifest: bad ref: {e}")),
1208 };
1209 let idx = match SCREEN_NAMES.iter().position(|n| *n == "Manifest") {
1212 Some(i) => i,
1213 None => {
1214 return CommandStatus::Err("internal: Manifest screen not registered".into());
1215 }
1216 };
1217 let screen = self
1218 .screens
1219 .get_mut(idx)
1220 .and_then(|s| s.as_any_mut())
1221 .and_then(|a| a.downcast_mut::<Manifest>());
1222 let Some(manifest) = screen else {
1223 return CommandStatus::Err("internal: failed to access Manifest screen".into());
1224 };
1225 manifest.load(reference);
1226 self.current_screen = idx;
1227 CommandStatus::Info(format!("loading manifest {}", short_hex(ref_arg, 8)))
1228 }
1229
1230 fn run_inspect(&self, line: &str) -> CommandStatus {
1237 let parts: Vec<&str> = line.split_whitespace().collect();
1238 let ref_arg = match parts.as_slice() {
1239 [_, r, ..] => *r,
1240 _ => {
1241 return CommandStatus::Err("usage: :inspect <ref> (32-byte hex reference)".into());
1242 }
1243 };
1244 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1245 Ok(r) => r,
1246 Err(e) => return CommandStatus::Err(format!("inspect: bad ref: {e}")),
1247 };
1248 let api = self.api.clone();
1249 let tx = self.cmd_status_tx.clone();
1250 let label = short_hex(ref_arg, 8);
1251 let label_for_task = label.clone();
1252 tokio::spawn(async move {
1253 let result = manifest_walker::inspect(api, reference).await;
1254 let status = match result {
1255 InspectResult::Manifest { node, bytes_len } => CommandStatus::Info(format!(
1256 "inspect {label_for_task}: manifest · {bytes_len} bytes · {} forks (jump to :manifest {label_for_task})",
1257 node.forks.len(),
1258 )),
1259 InspectResult::RawChunk { bytes_len } => CommandStatus::Info(format!(
1260 "inspect {label_for_task}: raw chunk · {bytes_len} bytes · not a manifest"
1261 )),
1262 InspectResult::Error(e) => {
1263 CommandStatus::Err(format!("inspect {label_for_task} failed: {e}"))
1264 }
1265 };
1266 let _ = tx.send(status);
1267 });
1268 CommandStatus::Info(format!("inspecting {label} — result will replace this line"))
1269 }
1270
1271 fn run_durability_check(&mut self, line: &str) -> CommandStatus {
1281 let parts: Vec<&str> = line.split_whitespace().collect();
1282 let ref_arg = match parts.as_slice() {
1283 [_, r, ..] => *r,
1284 _ => {
1285 return CommandStatus::Err(
1286 "usage: :durability-check <ref> (32-byte hex reference)".into(),
1287 );
1288 }
1289 };
1290 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1291 Ok(r) => r,
1292 Err(e) => {
1293 return CommandStatus::Err(format!("durability-check: bad ref: {e}"));
1294 }
1295 };
1296 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Watchlist") {
1299 self.current_screen = idx;
1300 }
1301 let api = self.api.clone();
1302 let tx = self.cmd_status_tx.clone();
1303 let watchlist_tx = self.durability_tx.clone();
1304 let label = short_hex(ref_arg, 8);
1305 let label_for_task = label.clone();
1306 tokio::spawn(async move {
1307 let result = durability::check(api, reference).await;
1308 let summary = result.summary();
1309 let _ = watchlist_tx.send(result);
1310 let _ = tx.send(if summary.contains("UNHEALTHY") {
1311 CommandStatus::Err(summary)
1312 } else {
1313 CommandStatus::Info(summary)
1314 });
1315 });
1316 CommandStatus::Info(format!(
1317 "durability-check {label_for_task} in flight — see S13 Watchlist for the running history"
1318 ))
1319 }
1320
1321 fn run_pss_target(&self, line: &str) -> CommandStatus {
1326 let parts: Vec<&str> = line.split_whitespace().collect();
1327 let overlay = match parts.as_slice() {
1328 [_, o, ..] => *o,
1329 _ => {
1330 return CommandStatus::Err(
1331 "usage: :pss-target <overlay-hex> (returns first 4 hex chars)".into(),
1332 );
1333 }
1334 };
1335 match utility_verbs::pss_target_for(overlay) {
1336 Ok(prefix) => CommandStatus::Info(format!("pss target prefix: {prefix}")),
1337 Err(e) => CommandStatus::Err(format!("pss-target failed: {e}")),
1338 }
1339 }
1340
1341 fn run_plan_batch(&self, line: &str) -> CommandStatus {
1347 let parts: Vec<&str> = line.split_whitespace().collect();
1348 let prefix = match parts.as_slice() {
1349 [_, prefix, ..] => *prefix,
1350 _ => {
1351 return CommandStatus::Err(
1352 "usage: :plan-batch <batch-prefix> [usage-thr] [ttl-thr] [extra-depth]"
1353 .into(),
1354 );
1355 }
1356 };
1357 let usage_thr = match parts.get(2) {
1358 Some(s) => match s.parse::<f64>() {
1359 Ok(v) => v,
1360 Err(_) => {
1361 return CommandStatus::Err(format!(
1362 "invalid usage-thr {s:?} (expected float in [0,1], default 0.85)"
1363 ));
1364 }
1365 },
1366 None => stamp_preview::DEFAULT_USAGE_THRESHOLD,
1367 };
1368 let ttl_thr = match parts.get(3) {
1369 Some(s) => match stamp_preview::parse_duration_seconds(s) {
1370 Ok(v) => v,
1371 Err(e) => return CommandStatus::Err(format!("ttl-thr: {e}")),
1372 },
1373 None => stamp_preview::DEFAULT_TTL_THRESHOLD_SECONDS,
1374 };
1375 let extra_depth = match parts.get(4) {
1376 Some(s) => match s.parse::<u8>() {
1377 Ok(v) => v,
1378 Err(_) => {
1379 return CommandStatus::Err(format!(
1380 "invalid extra-depth {s:?} (expected u8, default 2)"
1381 ));
1382 }
1383 },
1384 None => stamp_preview::DEFAULT_EXTRA_DEPTH,
1385 };
1386 let chain = match self.health_rx.borrow().chain_state.clone() {
1387 Some(c) => c,
1388 None => return CommandStatus::Err("chain state not loaded yet".into()),
1389 };
1390 let stamps = self.watch.stamps().borrow().clone();
1391 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1392 Ok(b) => b.clone(),
1393 Err(e) => return CommandStatus::Err(e),
1394 };
1395 match stamp_preview::plan_batch(&batch, &chain, usage_thr, ttl_thr, extra_depth) {
1396 Ok(p) => CommandStatus::Info(p.summary()),
1397 Err(e) => CommandStatus::Err(e),
1398 }
1399 }
1400
1401 fn run_buy_suggest(&self, line: &str) -> CommandStatus {
1407 let parts: Vec<&str> = line.split_whitespace().collect();
1408 let (size_str, duration_str) = match parts.as_slice() {
1409 [_, size, duration, ..] => (*size, *duration),
1410 _ => {
1411 return CommandStatus::Err(
1412 "usage: :buy-suggest <size> <duration> (e.g. 5GiB 30d, 100MiB 12h)".into(),
1413 );
1414 }
1415 };
1416 let target_bytes = match stamp_preview::parse_size_bytes(size_str) {
1417 Ok(b) => b,
1418 Err(e) => return CommandStatus::Err(e),
1419 };
1420 let target_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
1421 Ok(s) => s,
1422 Err(e) => return CommandStatus::Err(e),
1423 };
1424 let chain = match self.health_rx.borrow().chain_state.clone() {
1425 Some(c) => c,
1426 None => return CommandStatus::Err("chain state not loaded yet".into()),
1427 };
1428 match stamp_preview::buy_suggest(target_bytes, target_seconds, &chain) {
1429 Ok(s) => CommandStatus::Info(s.summary()),
1430 Err(e) => CommandStatus::Err(e),
1431 }
1432 }
1433
1434 fn run_buy_preview(&self, line: &str) -> CommandStatus {
1437 let parts: Vec<&str> = line.split_whitespace().collect();
1438 let (depth_str, amount_str) = match parts.as_slice() {
1439 [_, depth, amount, ..] => (*depth, *amount),
1440 _ => {
1441 return CommandStatus::Err(
1442 "usage: :buy-preview <depth> <amount-plur-per-chunk>".into(),
1443 );
1444 }
1445 };
1446 let depth: u8 = match depth_str.parse() {
1447 Ok(d) => d,
1448 Err(_) => {
1449 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
1450 }
1451 };
1452 let amount = match stamp_preview::parse_plur_amount(amount_str) {
1453 Ok(a) => a,
1454 Err(e) => return CommandStatus::Err(e),
1455 };
1456 let chain = match self.health_rx.borrow().chain_state.clone() {
1457 Some(c) => c,
1458 None => return CommandStatus::Err("chain state not loaded yet".into()),
1459 };
1460 match stamp_preview::buy_preview(depth, amount, &chain) {
1461 Ok(p) => CommandStatus::Info(p.summary()),
1462 Err(e) => CommandStatus::Err(e),
1463 }
1464 }
1465
1466 fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
1473 let node = self
1474 .config
1475 .nodes
1476 .iter()
1477 .find(|n| n.name == target)
1478 .ok_or_else(|| eyre!("no node configured with name {target:?}"))?
1479 .clone();
1480 let new_api = Arc::new(ApiClient::from_node(&node)?);
1481 self.watch.shutdown();
1485 let refresh = RefreshProfile::from_config(&self.config.ui.refresh);
1486 let new_watch = BeeWatch::start_with_profile(new_api.clone(), &self.root_cancel, refresh);
1487 let new_health_rx = new_watch.health();
1488 let new_screens = build_screens(&new_api, &new_watch);
1489 self.api = new_api;
1490 self.watch = new_watch;
1491 self.health_rx = new_health_rx;
1492 self.screens = new_screens;
1493 Ok(())
1496 }
1497
1498 fn start_pins_check(&self) -> std::io::Result<PathBuf> {
1515 let secs = SystemTime::now()
1516 .duration_since(UNIX_EPOCH)
1517 .map(|d| d.as_secs())
1518 .unwrap_or(0);
1519 let path = std::env::temp_dir().join(format!(
1520 "bee-tui-pins-check-{}-{secs}.txt",
1521 sanitize_for_filename(&self.api.name),
1522 ));
1523 std::fs::write(
1526 &path,
1527 format!(
1528 "# bee-tui :pins-check\n# profile {}\n# endpoint {}\n# started {}\n",
1529 self.api.name,
1530 self.api.url,
1531 format_utc_now(),
1532 ),
1533 )?;
1534
1535 let api = self.api.clone();
1536 let dest = path.clone();
1537 tokio::spawn(async move {
1538 let bee = api.bee();
1539 match bee.api().check_pins(None).await {
1540 Ok(entries) => {
1541 let mut body = String::new();
1542 for e in &entries {
1543 body.push_str(&format!(
1544 "{} total={} missing={} invalid={} {}\n",
1545 e.reference.to_hex(),
1546 e.total,
1547 e.missing,
1548 e.invalid,
1549 if e.is_healthy() {
1550 "healthy"
1551 } else {
1552 "UNHEALTHY"
1553 },
1554 ));
1555 }
1556 body.push_str(&format!("# done. {} pins checked.\n", entries.len()));
1557 if let Err(e) = append(&dest, &body) {
1558 let _ = append(&dest, &format!("# write error: {e}\n"));
1559 }
1560 }
1561 Err(e) => {
1562 let _ = append(&dest, &format!("# error: {e}\n"));
1563 }
1564 }
1565 });
1566 Ok(path)
1567 }
1568
1569 fn start_set_logger(&self, expression: String, level: String) {
1580 let secs = SystemTime::now()
1581 .duration_since(UNIX_EPOCH)
1582 .map(|d| d.as_secs())
1583 .unwrap_or(0);
1584 let dest = std::env::temp_dir().join(format!(
1585 "bee-tui-set-logger-{}-{secs}.txt",
1586 sanitize_for_filename(&self.api.name),
1587 ));
1588 let _ = std::fs::write(
1589 &dest,
1590 format!(
1591 "# bee-tui :set-logger\n# profile {}\n# endpoint {}\n# expr {expression}\n# level {level}\n# started {}\n",
1592 self.api.name,
1593 self.api.url,
1594 format_utc_now(),
1595 ),
1596 );
1597
1598 let api = self.api.clone();
1599 tokio::spawn(async move {
1600 let bee = api.bee();
1601 match bee.debug().set_logger(&expression, &level).await {
1602 Ok(()) => {
1603 let _ = append(
1604 &dest,
1605 &format!("# done. {expression} → {level} accepted by Bee.\n"),
1606 );
1607 }
1608 Err(e) => {
1609 let _ = append(&dest, &format!("# error: {e}\n"));
1610 }
1611 }
1612 });
1613 }
1614
1615 fn start_loggers_dump(&self) -> std::io::Result<PathBuf> {
1620 let secs = SystemTime::now()
1621 .duration_since(UNIX_EPOCH)
1622 .map(|d| d.as_secs())
1623 .unwrap_or(0);
1624 let path = std::env::temp_dir().join(format!(
1625 "bee-tui-loggers-{}-{secs}.txt",
1626 sanitize_for_filename(&self.api.name),
1627 ));
1628 std::fs::write(
1629 &path,
1630 format!(
1631 "# bee-tui :loggers\n# profile {}\n# endpoint {}\n# started {}\n",
1632 self.api.name,
1633 self.api.url,
1634 format_utc_now(),
1635 ),
1636 )?;
1637
1638 let api = self.api.clone();
1639 let dest = path.clone();
1640 tokio::spawn(async move {
1641 let bee = api.bee();
1642 match bee.debug().loggers().await {
1643 Ok(listing) => {
1644 let mut rows = listing.loggers.clone();
1645 rows.sort_by(|a, b| {
1649 verbosity_rank(&b.verbosity)
1650 .cmp(&verbosity_rank(&a.verbosity))
1651 .then_with(|| a.logger.cmp(&b.logger))
1652 });
1653 let mut body = String::new();
1654 body.push_str(&format!("# {} loggers registered\n", rows.len()));
1655 body.push_str("# VERBOSITY LOGGER\n");
1656 for r in &rows {
1657 body.push_str(&format!(" {:<9} {}\n", r.verbosity, r.logger,));
1658 }
1659 body.push_str("# done.\n");
1660 if let Err(e) = append(&dest, &body) {
1661 let _ = append(&dest, &format!("# write error: {e}\n"));
1662 }
1663 }
1664 Err(e) => {
1665 let _ = append(&dest, &format!("# error: {e}\n"));
1666 }
1667 }
1668 });
1669 Ok(path)
1670 }
1671
1672 fn start_diagnose_with_pprof(&self, seconds: u32) -> CommandStatus {
1684 let secs_unix = SystemTime::now()
1685 .duration_since(UNIX_EPOCH)
1686 .map(|d| d.as_secs())
1687 .unwrap_or(0);
1688 let dir = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs_unix}"));
1689 if let Err(e) = std::fs::create_dir_all(&dir) {
1690 return CommandStatus::Err(format!("diagnose --pprof: mkdir failed: {e}"));
1691 }
1692 let bundle_text = self.render_diagnostic_bundle();
1693 if let Err(e) = std::fs::write(dir.join("bundle.txt"), &bundle_text) {
1694 return CommandStatus::Err(format!("diagnose --pprof: write bundle.txt: {e}"));
1695 }
1696 let auth_token = self
1701 .config
1702 .nodes
1703 .iter()
1704 .find(|n| n.name == self.api.name)
1705 .and_then(|n| n.resolved_token());
1706 let base_url = self.api.url.clone();
1707 let dir_for_task = dir.clone();
1708 let tx = self.cmd_status_tx.clone();
1709 tokio::spawn(async move {
1710 let r = pprof_bundle::fetch_and_write(&base_url, auth_token, seconds, dir_for_task)
1711 .await;
1712 let status = match r {
1713 Ok(b) => CommandStatus::Info(b.summary()),
1714 Err(e) => CommandStatus::Err(format!("diagnose --pprof failed: {e}")),
1715 };
1716 let _ = tx.send(status);
1717 });
1718 CommandStatus::Info(format!(
1719 "diagnose --pprof={seconds}s in flight (bundle.txt already at {}; profile + trace will join when sampling completes)",
1720 dir.display()
1721 ))
1722 }
1723
1724 fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
1725 let bundle = self.render_diagnostic_bundle();
1726 let secs = SystemTime::now()
1727 .duration_since(UNIX_EPOCH)
1728 .map(|d| d.as_secs())
1729 .unwrap_or(0);
1730 let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
1731 std::fs::write(&path, bundle)?;
1732 Ok(path)
1733 }
1734
1735 fn render_diagnostic_bundle(&self) -> String {
1736 let now = format_utc_now();
1737 let health = self.health_rx.borrow().clone();
1738 let topology = self.watch.topology().borrow().clone();
1739 let gates = Health::gates_for(&health, Some(&topology));
1740 let recent: Vec<_> = log_capture::handle()
1741 .map(|c| {
1742 let mut snap = c.snapshot();
1743 let len = snap.len();
1744 if len > 50 {
1745 snap.drain(0..len - 50);
1746 }
1747 snap
1748 })
1749 .unwrap_or_default();
1750
1751 let mut out = String::new();
1752 out.push_str("# bee-tui diagnostic bundle\n");
1753 out.push_str(&format!("# generated UTC {now}\n\n"));
1754 out.push_str("## profile\n");
1755 out.push_str(&format!(" name {}\n", self.api.name));
1756 out.push_str(&format!(" endpoint {}\n\n", self.api.url));
1757 out.push_str("## health gates\n");
1758 for g in &gates {
1759 out.push_str(&format_gate_line(g));
1760 }
1761 out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
1762 for e in &recent {
1763 let status = e
1764 .status
1765 .map(|s| s.to_string())
1766 .unwrap_or_else(|| "—".into());
1767 let elapsed = e
1768 .elapsed_ms
1769 .map(|ms| format!("{ms}ms"))
1770 .unwrap_or_else(|| "—".into());
1771 out.push_str(&format!(
1772 " {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
1773 ts = e.ts,
1774 method = e.method,
1775 path = path_only(&e.url),
1776 status = status,
1777 elapsed = elapsed,
1778 ));
1779 }
1780 out.push_str(&format!(
1781 "\n## generated by bee-tui {}\n",
1782 env!("CARGO_PKG_VERSION"),
1783 ));
1784 out
1785 }
1786
1787 fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
1788 while let Ok(action) = self.action_rx.try_recv() {
1789 if action != Action::Tick && action != Action::Render {
1790 debug!("{action:?}");
1791 }
1792 match action {
1793 Action::Tick => {
1794 self.last_tick_key_events.drain(..);
1795 theme::advance_spinner();
1799 if let Some(sup) = self.supervisor.as_mut() {
1803 self.bee_status = sup.status();
1804 }
1805 if let Some(rx) = self.bee_log_rx.as_mut() {
1810 while let Ok((tab, line)) = rx.try_recv() {
1811 self.log_pane.push_bee(tab, line);
1812 }
1813 }
1814 while let Ok(status) = self.cmd_status_rx.try_recv() {
1819 self.command_status = Some(status);
1820 }
1821 while let Ok(result) = self.durability_rx.try_recv() {
1826 if let Some(idx) =
1827 SCREEN_NAMES.iter().position(|n| *n == "Watchlist")
1828 {
1829 if let Some(wl) = self
1830 .screens
1831 .get_mut(idx)
1832 .and_then(|s| s.as_any_mut())
1833 .and_then(|a| a.downcast_mut::<Watchlist>())
1834 {
1835 wl.record(result);
1836 }
1837 }
1838 }
1839 }
1840 Action::Quit => self.should_quit = true,
1841 Action::Suspend => self.should_suspend = true,
1842 Action::Resume => self.should_suspend = false,
1843 Action::ClearScreen => tui.terminal.clear()?,
1844 Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
1845 Action::Render => self.render(tui)?,
1846 _ => {}
1847 }
1848 let tx = self.action_tx.clone();
1849 for component in self.iter_components_mut() {
1850 if let Some(action) = component.update(action.clone())? {
1851 tx.send(action)?
1852 };
1853 }
1854 }
1855 Ok(())
1856 }
1857
1858 fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
1859 tui.resize(Rect::new(0, 0, w, h))?;
1860 self.render(tui)?;
1861 Ok(())
1862 }
1863
1864 fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
1865 let active = self.current_screen;
1866 let tx = self.action_tx.clone();
1867 let screens = &mut self.screens;
1868 let log_pane = &mut self.log_pane;
1869 let log_pane_height = log_pane.height();
1870 let command_buffer = self.command_buffer.clone();
1871 let command_suggestion_index = self.command_suggestion_index;
1872 let command_status = self.command_status.clone();
1873 let help_visible = self.help_visible;
1874 let profile = self.api.name.clone();
1875 let endpoint = self.api.url.clone();
1876 let last_ping = self.health_rx.borrow().last_ping;
1877 let now_utc = format_utc_now();
1878 let bee_status_label = if self.supervisor.is_some() && !self.bee_status.is_running() {
1879 Some(self.bee_status.label())
1883 } else {
1884 None
1885 };
1886 tui.draw(|frame| {
1887 use ratatui::layout::{Constraint, Layout};
1888 use ratatui::style::{Color, Modifier, Style};
1889 use ratatui::text::{Line, Span};
1890 use ratatui::widgets::Paragraph;
1891
1892 let chunks = Layout::vertical([
1893 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), Constraint::Length(log_pane_height), ])
1898 .split(frame.area());
1899
1900 let top_chunks =
1901 Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(chunks[0]);
1902
1903 let ping_str = match last_ping {
1905 Some(d) => format!("{}ms", d.as_millis()),
1906 None => "—".into(),
1907 };
1908 let t = theme::active();
1909 let mut metadata_spans = vec![
1910 Span::styled(
1911 " bee-tui ",
1912 Style::default()
1913 .fg(Color::Black)
1914 .bg(t.info)
1915 .add_modifier(Modifier::BOLD),
1916 ),
1917 Span::raw(" "),
1918 Span::styled(
1919 profile,
1920 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
1921 ),
1922 Span::styled(format!(" @ {endpoint}"), Style::default().fg(t.dim)),
1923 Span::raw(" "),
1924 Span::styled("ping ", Style::default().fg(t.dim)),
1925 Span::styled(ping_str, Style::default().fg(t.info)),
1926 Span::raw(" "),
1927 Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
1928 ];
1929 if let Some(label) = bee_status_label.as_ref() {
1933 metadata_spans.push(Span::raw(" "));
1934 metadata_spans.push(Span::styled(
1935 format!(" {label} "),
1936 Style::default()
1937 .fg(Color::Black)
1938 .bg(t.fail)
1939 .add_modifier(Modifier::BOLD),
1940 ));
1941 }
1942 let metadata_line = Line::from(metadata_spans);
1943 frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
1944
1945 let theme = *theme::active();
1947 let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
1948 for (i, name) in SCREEN_NAMES.iter().enumerate() {
1949 let style = if i == active {
1950 Style::default()
1951 .fg(theme.tab_active_fg)
1952 .bg(theme.tab_active_bg)
1953 .add_modifier(Modifier::BOLD)
1954 } else {
1955 Style::default().fg(theme.dim)
1956 };
1957 tabs.push(Span::styled(format!(" {name} "), style));
1958 tabs.push(Span::raw(" "));
1959 }
1960 tabs.push(Span::styled(
1961 ":cmd · Tab to cycle · ? help",
1962 Style::default().fg(theme.dim),
1963 ));
1964 frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
1965
1966 if let Some(screen) = screens.get_mut(active) {
1968 if let Err(err) = screen.draw(frame, chunks[1]) {
1969 let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
1970 }
1971 }
1972 let prompt = if let Some(buf) = &command_buffer {
1974 Line::from(vec![
1975 Span::styled(
1976 ":",
1977 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
1978 ),
1979 Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
1980 Span::styled("█", Style::default().fg(t.accent)),
1981 ])
1982 } else {
1983 match &command_status {
1984 Some(CommandStatus::Info(msg)) => {
1985 Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
1986 }
1987 Some(CommandStatus::Err(msg)) => {
1988 Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
1989 }
1990 None => Line::from(""),
1991 }
1992 };
1993 frame.render_widget(Paragraph::new(prompt), chunks[2]);
1994
1995 if let Some(buf) = &command_buffer {
2001 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
2002 if !matches.is_empty() {
2003 draw_command_suggestions(
2004 frame,
2005 chunks[2],
2006 &matches,
2007 command_suggestion_index,
2008 &theme,
2009 );
2010 }
2011 }
2012
2013 if let Err(err) = log_pane.draw(frame, chunks[3]) {
2015 let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
2016 }
2017
2018 if help_visible {
2023 draw_help_overlay(frame, frame.area(), active, &theme);
2024 }
2025 })?;
2026 Ok(())
2027 }
2028}
2029
2030fn draw_command_suggestions(
2037 frame: &mut ratatui::Frame,
2038 bar_rect: ratatui::layout::Rect,
2039 matches: &[&(&str, &str)],
2040 selected: usize,
2041 theme: &theme::Theme,
2042) {
2043 use ratatui::layout::Rect;
2044 use ratatui::style::{Modifier, Style};
2045 use ratatui::text::{Line, Span};
2046 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2047
2048 const MAX_VISIBLE: usize = 10;
2049 let visible_rows = matches.len().min(MAX_VISIBLE);
2050 if visible_rows == 0 {
2051 return;
2052 }
2053 let height = (visible_rows as u16) + 2; let widest = matches
2058 .iter()
2059 .map(|(name, desc)| name.len() + desc.len() + 6)
2060 .max()
2061 .unwrap_or(40)
2062 .min(bar_rect.width as usize);
2063 let width = (widest as u16 + 2).min(bar_rect.width);
2064 let bottom = bar_rect.y;
2067 let y = bottom.saturating_sub(height);
2068 let popup = Rect {
2069 x: bar_rect.x,
2070 y,
2071 width,
2072 height: bottom - y,
2073 };
2074
2075 let scroll_start = if selected >= visible_rows {
2077 selected + 1 - visible_rows
2078 } else {
2079 0
2080 };
2081 let visible_slice = &matches[scroll_start..(scroll_start + visible_rows).min(matches.len())];
2082
2083 let mut lines: Vec<Line> = Vec::with_capacity(visible_slice.len());
2084 for (i, (name, desc)) in visible_slice.iter().enumerate() {
2085 let absolute_idx = scroll_start + i;
2086 let is_selected = absolute_idx == selected;
2087 let row_style = if is_selected {
2088 Style::default()
2089 .fg(theme.tab_active_fg)
2090 .bg(theme.tab_active_bg)
2091 .add_modifier(Modifier::BOLD)
2092 } else {
2093 Style::default()
2094 };
2095 let cursor = if is_selected { "▸ " } else { " " };
2096 lines.push(Line::from(vec![
2097 Span::styled(format!("{cursor}:{name:<16} "), row_style),
2098 Span::styled(
2099 desc.to_string(),
2100 if is_selected {
2101 row_style
2102 } else {
2103 Style::default().fg(theme.dim)
2104 },
2105 ),
2106 ]));
2107 }
2108
2109 let title = if matches.len() > MAX_VISIBLE {
2111 format!(" :commands ({}/{}) ", selected + 1, matches.len())
2112 } else {
2113 " :commands ".to_string()
2114 };
2115
2116 frame.render_widget(Clear, popup);
2117 frame.render_widget(
2118 Paragraph::new(lines).block(
2119 Block::default()
2120 .borders(Borders::ALL)
2121 .border_style(Style::default().fg(theme.accent))
2122 .title(title),
2123 ),
2124 popup,
2125 );
2126}
2127
2128fn draw_help_overlay(
2133 frame: &mut ratatui::Frame,
2134 area: ratatui::layout::Rect,
2135 active_screen: usize,
2136 theme: &theme::Theme,
2137) {
2138 use ratatui::layout::Rect;
2139 use ratatui::style::{Modifier, Style};
2140 use ratatui::text::{Line, Span};
2141 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2142
2143 let screen_name = SCREEN_NAMES.get(active_screen).copied().unwrap_or("?");
2144 let screen_rows = screen_keymap(active_screen);
2145 let global_rows: &[(&str, &str)] = &[
2146 ("Tab", "next screen"),
2147 ("Shift+Tab", "previous screen"),
2148 ("[ / ]", "previous / next log-pane tab"),
2149 ("+ / -", "grow / shrink log pane"),
2150 ("Shift+↑/↓", "scroll log pane (1 line); pauses auto-tail"),
2151 ("Shift+PgUp/PgDn", "scroll log pane (10 lines)"),
2152 ("Shift+←/→", "pan log pane horizontally (8 cols)"),
2153 ("Shift+End", "resume auto-tail + reset horizontal pan"),
2154 ("?", "toggle this help"),
2155 (":", "open command bar"),
2156 ("qq", "quit (double-tap; or :q)"),
2157 ("Ctrl+C / Ctrl+D", "quit immediately"),
2158 ];
2159
2160 let w = area.width.min(72);
2163 let h = area.height.min(22);
2164 let x = area.x + (area.width.saturating_sub(w)) / 2;
2165 let y = area.y + (area.height.saturating_sub(h)) / 2;
2166 let rect = Rect {
2167 x,
2168 y,
2169 width: w,
2170 height: h,
2171 };
2172
2173 let mut lines: Vec<Line> = Vec::new();
2174 lines.push(Line::from(vec![
2175 Span::styled(
2176 format!(" {screen_name} "),
2177 Style::default()
2178 .fg(theme.tab_active_fg)
2179 .bg(theme.tab_active_bg)
2180 .add_modifier(Modifier::BOLD),
2181 ),
2182 Span::raw(" screen-specific keys"),
2183 ]));
2184 lines.push(Line::from(""));
2185 if screen_rows.is_empty() {
2186 lines.push(Line::from(Span::styled(
2187 " (no extra keys for this screen — use the command bar via :)",
2188 Style::default()
2189 .fg(theme.dim)
2190 .add_modifier(Modifier::ITALIC),
2191 )));
2192 } else {
2193 for (key, desc) in screen_rows {
2194 lines.push(format_help_row(key, desc, theme));
2195 }
2196 }
2197 lines.push(Line::from(""));
2198 lines.push(Line::from(Span::styled(
2199 " global",
2200 Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
2201 )));
2202 for (key, desc) in global_rows {
2203 lines.push(format_help_row(key, desc, theme));
2204 }
2205 lines.push(Line::from(""));
2206 lines.push(Line::from(Span::styled(
2207 " Esc / ? / q to dismiss",
2208 Style::default()
2209 .fg(theme.dim)
2210 .add_modifier(Modifier::ITALIC),
2211 )));
2212
2213 frame.render_widget(Clear, rect);
2216 frame.render_widget(
2217 Paragraph::new(lines).block(
2218 Block::default()
2219 .borders(Borders::ALL)
2220 .border_style(Style::default().fg(theme.accent))
2221 .title(" help "),
2222 ),
2223 rect,
2224 );
2225}
2226
2227fn format_help_row<'a>(
2228 key: &'a str,
2229 desc: &'a str,
2230 theme: &theme::Theme,
2231) -> ratatui::text::Line<'a> {
2232 use ratatui::style::{Modifier, Style};
2233 use ratatui::text::{Line, Span};
2234 Line::from(vec![
2235 Span::raw(" "),
2236 Span::styled(
2237 format!("{key:<16}"),
2238 Style::default()
2239 .fg(theme.accent)
2240 .add_modifier(Modifier::BOLD),
2241 ),
2242 Span::raw(" "),
2243 Span::raw(desc),
2244 ])
2245}
2246
2247fn screen_keymap(active_screen: usize) -> &'static [(&'static str, &'static str)] {
2251 match active_screen {
2252 1 => &[
2254 ("↑↓ / j k", "move row selection"),
2255 ("Enter", "drill batch — bucket histogram + worst-N"),
2256 ("Esc", "close drill"),
2257 ],
2258 3 => &[("r", "run on-demand rchash benchmark")],
2260 4 => &[
2261 ("↑↓ / j k", "move peer selection"),
2262 (
2263 "Enter",
2264 "drill peer — balance / cheques / settlement / ping",
2265 ),
2266 ("Esc", "close drill"),
2267 ],
2268 8 => &[
2272 ("↑↓ / j k", "scroll one row"),
2273 ("PgUp / PgDn", "scroll ten rows"),
2274 ("Home", "back to top"),
2275 ],
2276 9 => &[
2278 ("↑↓ / j k", "move row selection"),
2279 ("Enter", "integrity-check the highlighted pin"),
2280 ("c", "integrity-check every unchecked pin"),
2281 ("s", "cycle sort: ref order / bad first / by size"),
2282 ],
2283 10 => &[
2285 ("↑↓ / j k", "move row selection"),
2286 ("Enter", "expand / collapse fork (loads child chunk)"),
2287 (":manifest <ref>", "open a manifest at a reference"),
2288 (":inspect <ref>", "what is this? auto-detects manifest"),
2289 ],
2290 11 => &[
2292 ("↑↓ / j k", "move row selection"),
2293 (":durability-check <ref>", "walk chunk graph + record"),
2294 ],
2295 _ => &[],
2296 }
2297}
2298
2299fn build_screens(api: &Arc<ApiClient>, watch: &BeeWatch) -> Vec<Box<dyn Component>> {
2308 let health = Health::new(api.clone(), watch.health(), watch.topology());
2309 let stamps = Stamps::new(api.clone(), watch.stamps());
2310 let swap = Swap::new(watch.swap());
2311 let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
2312 let peers = Peers::new(api.clone(), watch.topology());
2313 let network = Network::new(watch.network(), watch.topology());
2314 let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
2315 let api_health = ApiHealth::new(
2316 api.clone(),
2317 watch.health(),
2318 watch.transactions(),
2319 log_capture::handle(),
2320 );
2321 let tags = Tags::new(watch.tags());
2322 let pins = Pins::new(api.clone(), watch.pins());
2323 let manifest = Manifest::new(api.clone());
2324 let watchlist = Watchlist::new();
2325 vec![
2326 Box::new(health),
2327 Box::new(stamps),
2328 Box::new(swap),
2329 Box::new(lottery),
2330 Box::new(peers),
2331 Box::new(network),
2332 Box::new(warmup),
2333 Box::new(api_health),
2334 Box::new(tags),
2335 Box::new(pins),
2336 Box::new(manifest),
2337 Box::new(watchlist),
2338 ]
2339}
2340
2341fn build_synthetic_probe_chunk() -> Vec<u8> {
2349 use std::time::{SystemTime, UNIX_EPOCH};
2350 let nanos = SystemTime::now()
2351 .duration_since(UNIX_EPOCH)
2352 .map(|d| d.as_nanos())
2353 .unwrap_or(0);
2354 let mut data = Vec::with_capacity(8 + 4096);
2355 data.extend_from_slice(&4096u64.to_le_bytes());
2357 data.extend_from_slice(&nanos.to_le_bytes());
2359 data.resize(8 + 4096, 0);
2360 data
2361}
2362
2363fn short_hex(hex: &str, len: usize) -> String {
2366 if hex.len() > len {
2367 format!("{}…", &hex[..len])
2368 } else {
2369 hex.to_string()
2370 }
2371}
2372
2373fn build_metrics_render_fn(
2379 watch: BeeWatch,
2380 log_capture: Option<log_capture::LogCapture>,
2381) -> crate::metrics_server::RenderFn {
2382 use std::time::{SystemTime, UNIX_EPOCH};
2383 Arc::new(move || {
2384 let health = watch.health().borrow().clone();
2385 let stamps = watch.stamps().borrow().clone();
2386 let swap = watch.swap().borrow().clone();
2387 let lottery = watch.lottery().borrow().clone();
2388 let topology = watch.topology().borrow().clone();
2389 let network = watch.network().borrow().clone();
2390 let transactions = watch.transactions().borrow().clone();
2391 let recent = log_capture
2392 .as_ref()
2393 .map(|c| c.snapshot())
2394 .unwrap_or_default();
2395 let call_stats = crate::components::api_health::call_stats_for(&recent);
2396 let now_unix = SystemTime::now()
2397 .duration_since(UNIX_EPOCH)
2398 .map(|d| d.as_secs() as i64)
2399 .unwrap_or(0);
2400 let inputs = crate::metrics::MetricsInputs {
2401 bee_tui_version: env!("CARGO_PKG_VERSION"),
2402 health: &health,
2403 stamps: &stamps,
2404 swap: &swap,
2405 lottery: &lottery,
2406 topology: &topology,
2407 network: &network,
2408 transactions: &transactions,
2409 call_stats: &call_stats,
2410 now_unix,
2411 };
2412 crate::metrics::render(&inputs)
2413 })
2414}
2415
2416fn format_gate_line(g: &Gate) -> String {
2417 let glyphs = crate::theme::active().glyphs;
2418 let glyph = match g.status {
2419 GateStatus::Pass => glyphs.pass,
2420 GateStatus::Warn => glyphs.warn,
2421 GateStatus::Fail => glyphs.fail,
2422 GateStatus::Unknown => glyphs.bullet,
2423 };
2424 let mut s = format!(
2425 " [{glyph}] {label:<28} {value}\n",
2426 label = g.label,
2427 value = g.value
2428 );
2429 if let Some(why) = &g.why {
2430 s.push_str(&format!(" {} {why}\n", glyphs.continuation));
2431 }
2432 s
2433}
2434
2435fn path_only(url: &str) -> String {
2438 if let Some(idx) = url.find("//") {
2439 let after_scheme = &url[idx + 2..];
2440 if let Some(slash) = after_scheme.find('/') {
2441 return after_scheme[slash..].to_string();
2442 }
2443 return "/".into();
2444 }
2445 url.to_string()
2446}
2447
2448fn append(path: &PathBuf, s: &str) -> std::io::Result<()> {
2455 use std::io::Write;
2456 let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
2457 f.write_all(s.as_bytes())
2458}
2459
2460fn verbosity_rank(s: &str) -> u8 {
2466 match s {
2467 "all" | "trace" => 5,
2468 "debug" => 4,
2469 "info" | "1" => 3,
2470 "warning" | "warn" | "2" => 2,
2471 "error" | "3" => 1,
2472 _ => 0,
2473 }
2474}
2475
2476fn sanitize_for_filename(s: &str) -> String {
2480 s.chars()
2481 .map(|c| match c {
2482 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
2483 _ => '-',
2484 })
2485 .collect()
2486}
2487
2488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2492pub enum QuitResolution {
2493 Confirm,
2495 Pending,
2498}
2499
2500fn resolve_quit_press(prev: Option<Instant>, now: Instant, window: Duration) -> QuitResolution {
2505 match prev {
2506 Some(t) if now.duration_since(t) <= window => QuitResolution::Confirm,
2507 _ => QuitResolution::Pending,
2508 }
2509}
2510
2511fn format_utc_now() -> String {
2512 let secs = SystemTime::now()
2513 .duration_since(UNIX_EPOCH)
2514 .map(|d| d.as_secs())
2515 .unwrap_or(0);
2516 let secs_in_day = secs % 86_400;
2517 let h = secs_in_day / 3_600;
2518 let m = (secs_in_day % 3_600) / 60;
2519 let s = secs_in_day % 60;
2520 format!("{h:02}:{m:02}:{s:02}")
2521}
2522
2523#[cfg(test)]
2524mod tests {
2525 use super::*;
2526
2527 #[test]
2528 fn format_utc_now_returns_eight_chars() {
2529 let s = format_utc_now();
2530 assert_eq!(s.len(), 8);
2531 assert_eq!(s.chars().nth(2), Some(':'));
2532 assert_eq!(s.chars().nth(5), Some(':'));
2533 }
2534
2535 #[test]
2536 fn path_only_strips_scheme_and_host() {
2537 assert_eq!(path_only("http://10.0.1.5:1633/status"), "/status");
2538 assert_eq!(
2539 path_only("https://bee.example.com/stamps?limit=10"),
2540 "/stamps?limit=10"
2541 );
2542 }
2543
2544 #[test]
2545 fn path_only_handles_no_path() {
2546 assert_eq!(path_only("http://localhost:1633"), "/");
2547 }
2548
2549 #[test]
2550 fn path_only_passes_relative_through() {
2551 assert_eq!(path_only("/already/relative"), "/already/relative");
2552 }
2553
2554 #[test]
2555 fn parse_pprof_arg_default_60() {
2556 assert_eq!(parse_pprof_arg("diagnose --pprof"), Some(60));
2557 assert_eq!(parse_pprof_arg("diag --pprof some other"), Some(60));
2558 }
2559
2560 #[test]
2561 fn parse_pprof_arg_with_explicit_seconds() {
2562 assert_eq!(parse_pprof_arg("diagnose --pprof=120"), Some(120));
2563 assert_eq!(parse_pprof_arg("diagnose --pprof=15 trailing"), Some(15));
2564 }
2565
2566 #[test]
2567 fn parse_pprof_arg_clamps_extreme_values() {
2568 assert_eq!(parse_pprof_arg("diagnose --pprof=0"), Some(1));
2570 assert_eq!(parse_pprof_arg("diagnose --pprof=9999"), Some(600));
2571 }
2572
2573 #[test]
2574 fn parse_pprof_arg_none_when_absent() {
2575 assert_eq!(parse_pprof_arg("diagnose"), None);
2576 assert_eq!(parse_pprof_arg("diag"), None);
2577 assert_eq!(parse_pprof_arg(""), None);
2578 }
2579
2580 #[test]
2581 fn parse_pprof_arg_ignores_garbage_value() {
2582 assert_eq!(parse_pprof_arg("diagnose --pprof=lol"), None);
2585 }
2586
2587 #[test]
2588 fn sanitize_for_filename_keeps_safe_chars() {
2589 assert_eq!(sanitize_for_filename("prod-1"), "prod-1");
2590 assert_eq!(sanitize_for_filename("lab_node"), "lab_node");
2591 }
2592
2593 #[test]
2594 fn sanitize_for_filename_replaces_unsafe_chars() {
2595 assert_eq!(sanitize_for_filename("a/b\\c d"), "a-b-c-d");
2596 assert_eq!(sanitize_for_filename("name:colon"), "name-colon");
2597 }
2598
2599 #[test]
2600 fn resolve_quit_press_first_press_is_pending() {
2601 let now = Instant::now();
2602 assert_eq!(
2603 resolve_quit_press(None, now, Duration::from_millis(1500)),
2604 QuitResolution::Pending
2605 );
2606 }
2607
2608 #[test]
2609 fn resolve_quit_press_second_press_inside_window_confirms() {
2610 let first = Instant::now();
2611 let window = Duration::from_millis(1500);
2612 let second = first + Duration::from_millis(500);
2613 assert_eq!(
2614 resolve_quit_press(Some(first), second, window),
2615 QuitResolution::Confirm
2616 );
2617 }
2618
2619 #[test]
2620 fn resolve_quit_press_second_press_after_window_resets_to_pending() {
2621 let first = Instant::now();
2625 let window = Duration::from_millis(1500);
2626 let second = first + Duration::from_millis(2_000);
2627 assert_eq!(
2628 resolve_quit_press(Some(first), second, window),
2629 QuitResolution::Pending
2630 );
2631 }
2632
2633 #[test]
2634 fn resolve_quit_press_at_window_boundary_confirms() {
2635 let first = Instant::now();
2638 let window = Duration::from_millis(1500);
2639 let second = first + window;
2640 assert_eq!(
2641 resolve_quit_press(Some(first), second, window),
2642 QuitResolution::Confirm
2643 );
2644 }
2645
2646 #[test]
2647 fn screen_keymap_covers_drill_screens() {
2648 for idx in [1usize, 4] {
2651 let rows = screen_keymap(idx);
2652 assert!(
2653 rows.iter().any(|(k, _)| k.contains("Enter")),
2654 "screen {idx} keymap must mention Enter (drill)"
2655 );
2656 assert!(
2657 rows.iter().any(|(k, _)| k.contains("Esc")),
2658 "screen {idx} keymap must mention Esc (close drill)"
2659 );
2660 }
2661 }
2662
2663 #[test]
2664 fn screen_keymap_lottery_advertises_rchash() {
2665 let rows = screen_keymap(3);
2666 assert!(rows.iter().any(|(k, _)| k.contains("r")));
2667 }
2668
2669 #[test]
2670 fn screen_keymap_unknown_index_is_empty_not_panic() {
2671 assert!(screen_keymap(999).is_empty());
2672 }
2673
2674 #[test]
2675 fn verbosity_rank_orders_loud_to_silent() {
2676 assert!(verbosity_rank("all") > verbosity_rank("debug"));
2677 assert!(verbosity_rank("debug") > verbosity_rank("info"));
2678 assert!(verbosity_rank("info") > verbosity_rank("warning"));
2679 assert!(verbosity_rank("warning") > verbosity_rank("error"));
2680 assert!(verbosity_rank("error") > verbosity_rank("unknown"));
2681 assert_eq!(verbosity_rank("info"), verbosity_rank("1"));
2683 assert_eq!(verbosity_rank("warning"), verbosity_rank("2"));
2684 }
2685
2686 #[test]
2687 fn filter_command_suggestions_empty_buffer_returns_all() {
2688 let matches = filter_command_suggestions("", KNOWN_COMMANDS);
2689 assert_eq!(matches.len(), KNOWN_COMMANDS.len());
2690 }
2691
2692 #[test]
2693 fn filter_command_suggestions_prefix_matches_case_insensitive() {
2694 let matches = filter_command_suggestions("Bu", KNOWN_COMMANDS);
2695 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
2696 assert!(names.contains(&"buy-preview"));
2697 assert!(names.contains(&"buy-suggest"));
2698 assert_eq!(names.len(), 2);
2699 }
2700
2701 #[test]
2702 fn filter_command_suggestions_unknown_prefix_is_empty() {
2703 let matches = filter_command_suggestions("xyz", KNOWN_COMMANDS);
2704 assert!(matches.is_empty());
2705 }
2706
2707 #[test]
2708 fn filter_command_suggestions_uses_first_token_only() {
2709 let matches = filter_command_suggestions("topup-preview a1b2 1000", KNOWN_COMMANDS);
2712 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
2713 assert_eq!(names, vec!["topup-preview"]);
2714 }
2715
2716 #[test]
2717 fn probe_chunk_is_4104_bytes_with_correct_span() {
2718 let chunk = build_synthetic_probe_chunk();
2720 assert_eq!(chunk.len(), 4104);
2721 let span = u64::from_le_bytes(chunk[..8].try_into().unwrap());
2722 assert_eq!(span, 4096);
2723 }
2724
2725 #[test]
2726 fn probe_chunk_payloads_are_unique_per_call() {
2727 let a = build_synthetic_probe_chunk();
2732 std::thread::sleep(Duration::from_micros(1));
2734 let b = build_synthetic_probe_chunk();
2735 assert_ne!(&a[8..24], &b[8..24]);
2736 }
2737
2738 #[test]
2739 fn short_hex_truncates_with_ellipsis() {
2740 assert_eq!(short_hex("a1b2c3d4e5f6", 8), "a1b2c3d4…");
2741 assert_eq!(short_hex("short", 8), "short");
2742 assert_eq!(short_hex("abcdefgh", 8), "abcdefgh");
2743 }
2744}