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 "probe-upload",
186 "<batch> — single 4 KiB chunk, end-to-end probe",
187 ),
188 ("manifest", "<ref> — open Mantaray tree browser at a reference"),
189 ("inspect", "<ref> — what is this? auto-detects manifest vs raw chunk"),
190 (
191 "durability-check",
192 "<ref> — walk chunk graph, report total / lost / errors",
193 ),
194 ("watchlist", "S13 Watchlist — durability-check history"),
195 ("hash", "<path> — Swarm reference of a local file/dir (offline)"),
196 ("cid", "<ref> [manifest|feed] — encode reference as CID"),
197 ("depth-table", "Print canonical depth → capacity table"),
198 ("gsoc-mine", "<overlay> <id> — mine a GSOC signer (CPU work)"),
199 (
200 "pss-target",
201 "<overlay> — first 4 hex chars (Bee's max prefix)",
202 ),
203 (
204 "diagnose",
205 "[--pprof[=N]] Export snapshot (+ optional CPU profile + trace)",
206 ),
207 ("pins-check", "Bulk integrity walk to a file"),
208 ("loggers", "Dump live logger registry"),
209 ("set-logger", "<expr> <level> — change a logger's verbosity"),
210 ("context", "<name> — switch node profile"),
211 ("quit", "Exit the cockpit"),
212];
213
214fn parse_pprof_arg(line: &str) -> Option<u32> {
219 for tok in line.split_whitespace() {
220 if tok == "--pprof" {
221 return Some(60);
222 }
223 if let Some(rest) = tok.strip_prefix("--pprof=") {
224 if let Ok(n) = rest.parse::<u32>() {
225 return Some(n.clamp(1, 600));
226 }
227 }
228 }
229 None
230}
231
232fn filter_command_suggestions<'a>(
236 buffer: &str,
237 catalog: &'a [(&'a str, &'a str)],
238) -> Vec<&'a (&'a str, &'a str)> {
239 let head = buffer
240 .split_whitespace()
241 .next()
242 .unwrap_or("")
243 .to_ascii_lowercase();
244 catalog
245 .iter()
246 .filter(|(name, _)| name.starts_with(&head))
247 .collect()
248}
249
250#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
251pub enum Mode {
252 #[default]
253 Home,
254}
255
256#[derive(Debug, Default)]
259pub struct AppOverrides {
260 pub ascii: bool,
262 pub no_color: bool,
264 pub bee_bin: Option<PathBuf>,
266 pub bee_config: Option<PathBuf>,
268}
269
270const BEE_API_READY_TIMEOUT: Duration = Duration::from_secs(60);
275
276impl App {
277 pub async fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
278 Self::with_overrides(tick_rate, frame_rate, AppOverrides::default()).await
279 }
280
281 pub async fn with_overrides(
286 tick_rate: f64,
287 frame_rate: f64,
288 overrides: AppOverrides,
289 ) -> color_eyre::Result<Self> {
290 let (action_tx, action_rx) = mpsc::unbounded_channel();
291 let (cmd_status_tx, cmd_status_rx) = mpsc::unbounded_channel();
292 let (durability_tx, durability_rx) = mpsc::unbounded_channel();
293 let config = Config::new()?;
294 let force_no_color = overrides.no_color || theme::no_color_env();
297 theme::install_with_overrides(&config.ui, force_no_color, overrides.ascii);
298
299 let node = config
302 .active_node()
303 .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
304 let api = Arc::new(ApiClient::from_node(node)?);
305
306 let bee_bin = overrides
308 .bee_bin
309 .or_else(|| config.bee.as_ref().map(|b| b.bin.clone()));
310 let bee_config = overrides
311 .bee_config
312 .or_else(|| config.bee.as_ref().map(|b| b.config.clone()));
313 let bee_logs = config
316 .bee
317 .as_ref()
318 .map(|b| b.logs.clone())
319 .unwrap_or_default();
320 let supervisor = match (bee_bin, bee_config) {
321 (Some(bin), Some(cfg)) => {
322 eprintln!("bee-tui: spawning bee {bin:?} --config {cfg:?}");
323 let mut sup = BeeSupervisor::spawn(&bin, &cfg, bee_logs)?;
324 eprintln!(
325 "bee-tui: log → {} (will appear in the cockpit's bottom pane)",
326 sup.log_path().display()
327 );
328 eprintln!(
329 "bee-tui: waiting for {} to respond on /health (up to {:?})...",
330 api.url, BEE_API_READY_TIMEOUT
331 );
332 sup.wait_for_api(&api.url, BEE_API_READY_TIMEOUT).await?;
333 eprintln!("bee-tui: bee ready, opening cockpit");
334 Some(sup)
335 }
336 (Some(_), None) | (None, Some(_)) => {
337 return Err(eyre!(
338 "[bee].bin and [bee].config must both be set (or both unset). \
339 Use --bee-bin AND --bee-config, or both fields in config.toml."
340 ));
341 }
342 (None, None) => None,
343 };
344
345 let refresh = RefreshProfile::from_config(&config.ui.refresh);
352 let root_cancel = CancellationToken::new();
353 let watch = BeeWatch::start_with_profile(api.clone(), &root_cancel, refresh);
354 let health_rx = watch.health();
355
356 let screens = build_screens(&api, &watch);
357 let (persisted, state_path) = State::load();
362 let initial_tab = LogTab::from_kebab(&persisted.log_pane_active_tab);
363 let mut log_pane = LogPane::new(
364 log_capture::handle(),
365 initial_tab,
366 persisted.log_pane_height,
367 );
368 log_pane.set_spawn_active(supervisor.is_some());
369 if let Some(c) = log_capture::cockpit_handle() {
370 log_pane.set_cockpit_capture(c);
371 }
372
373 let bee_log_rx = supervisor.as_ref().map(|sup| {
379 let (tx, rx) = mpsc::unbounded_channel();
380 crate::bee_log_tailer::spawn(
381 sup.log_path().to_path_buf(),
382 tx,
383 root_cancel.child_token(),
384 );
385 rx
386 });
387
388 if config.metrics.enabled {
395 match config.metrics.addr.parse::<std::net::SocketAddr>() {
396 Ok(bind_addr) => {
397 let render_fn = build_metrics_render_fn(watch.clone(), log_capture::handle());
398 let cancel = root_cancel.child_token();
399 match crate::metrics_server::spawn(bind_addr, render_fn, cancel).await {
400 Ok(actual) => {
401 eprintln!(
402 "bee-tui: metrics endpoint serving /metrics on http://{actual}"
403 );
404 }
405 Err(e) => {
406 tracing::error!(
407 "metrics: failed to start endpoint on {bind_addr}: {e}"
408 );
409 }
410 }
411 }
412 Err(e) => {
413 tracing::error!(
414 "metrics: invalid [metrics].addr {:?}: {e}",
415 config.metrics.addr
416 );
417 }
418 }
419 }
420
421 Ok(Self {
422 tick_rate,
423 frame_rate,
424 screens,
425 current_screen: 0,
426 log_pane,
427 state_path,
428 should_quit: false,
429 should_suspend: false,
430 config,
431 mode: Mode::Home,
432 last_tick_key_events: Vec::new(),
433 action_tx,
434 action_rx,
435 root_cancel,
436 api,
437 watch,
438 health_rx,
439 command_buffer: None,
440 command_suggestion_index: 0,
441 command_status: None,
442 help_visible: false,
443 quit_pending: None,
444 supervisor,
445 bee_status: BeeStatus::Running,
446 bee_log_rx,
447 cmd_status_tx,
448 cmd_status_rx,
449 durability_tx,
450 durability_rx,
451 })
452 }
453
454 pub async fn run(&mut self) -> color_eyre::Result<()> {
455 let mut tui = Tui::new()?
456 .tick_rate(self.tick_rate)
458 .frame_rate(self.frame_rate);
459 tui.enter()?;
460
461 let tx = self.action_tx.clone();
462 let cfg = self.config.clone();
463 let size = tui.size()?;
464 for component in self.iter_components_mut() {
465 component.register_action_handler(tx.clone())?;
466 component.register_config_handler(cfg.clone())?;
467 component.init(size)?;
468 }
469
470 let action_tx = self.action_tx.clone();
471 loop {
472 self.handle_events(&mut tui).await?;
473 self.handle_actions(&mut tui)?;
474 if self.should_suspend {
475 tui.suspend()?;
476 action_tx.send(Action::Resume)?;
477 action_tx.send(Action::ClearScreen)?;
478 tui.enter()?;
480 } else if self.should_quit {
481 tui.stop()?;
482 break;
483 }
484 }
485 self.watch.shutdown();
487 self.root_cancel.cancel();
488 let snapshot = State {
492 log_pane_height: self.log_pane.height(),
493 log_pane_active_tab: self.log_pane.active_tab().to_kebab().to_string(),
494 };
495 snapshot.save(&self.state_path);
496 if let Some(sup) = self.supervisor.take() {
500 let final_status = sup.shutdown_default().await;
501 tracing::info!("bee child exited: {}", final_status.label());
502 }
503 tui.exit()?;
504 Ok(())
505 }
506
507 async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
508 let Some(event) = tui.next_event().await else {
509 return Ok(());
510 };
511 let action_tx = self.action_tx.clone();
512 let modal_before = self.command_buffer.is_some() || self.help_visible;
519 match event {
520 Event::Quit => action_tx.send(Action::Quit)?,
521 Event::Tick => action_tx.send(Action::Tick)?,
522 Event::Render => action_tx.send(Action::Render)?,
523 Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
524 Event::Key(key) => self.handle_key_event(key)?,
525 _ => {}
526 }
527 let modal_after = self.command_buffer.is_some() || self.help_visible;
528 let propagate = !((modal_before || modal_after) && matches!(event, Event::Key(_)));
531 if propagate {
532 for component in self.iter_components_mut() {
533 if let Some(action) = component.handle_events(Some(event.clone()))? {
534 action_tx.send(action)?;
535 }
536 }
537 }
538 Ok(())
539 }
540
541 fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut dyn Component> {
546 self.screens
547 .iter_mut()
548 .map(|c| c.as_mut() as &mut dyn Component)
549 .chain(std::iter::once(&mut self.log_pane as &mut dyn Component))
550 }
551
552 fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
553 if self.command_buffer.is_some() {
557 self.handle_command_mode_key(key)?;
558 return Ok(());
559 }
560 if self.help_visible {
564 match key.code {
565 crossterm::event::KeyCode::Esc
566 | crossterm::event::KeyCode::Char('?')
567 | crossterm::event::KeyCode::Char('q') => {
568 self.help_visible = false;
569 }
570 _ => {}
571 }
572 return Ok(());
573 }
574 if matches!(key.code, crossterm::event::KeyCode::Char('?')) {
578 self.help_visible = true;
579 return Ok(());
580 }
581 let action_tx = self.action_tx.clone();
582 if matches!(key.code, crossterm::event::KeyCode::Char(':')) {
584 self.command_buffer = Some(String::new());
585 self.command_status = None;
586 return Ok(());
587 }
588 if matches!(key.code, crossterm::event::KeyCode::Tab) {
593 if !self.screens.is_empty() {
594 self.current_screen = (self.current_screen + 1) % self.screens.len();
595 debug!(
596 "switched to screen {}",
597 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
598 );
599 }
600 return Ok(());
601 }
602 if matches!(key.code, crossterm::event::KeyCode::BackTab) {
603 if !self.screens.is_empty() {
604 let len = self.screens.len();
605 self.current_screen = (self.current_screen + len - 1) % len;
606 debug!(
607 "switched to screen {}",
608 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
609 );
610 }
611 return Ok(());
612 }
613 if matches!(key.code, crossterm::event::KeyCode::Char('['))
619 && key.modifiers == crossterm::event::KeyModifiers::NONE
620 {
621 self.log_pane.prev_tab();
622 return Ok(());
623 }
624 if matches!(key.code, crossterm::event::KeyCode::Char(']'))
625 && key.modifiers == crossterm::event::KeyModifiers::NONE
626 {
627 self.log_pane.next_tab();
628 return Ok(());
629 }
630 if matches!(key.code, crossterm::event::KeyCode::Char('+'))
631 && key.modifiers == crossterm::event::KeyModifiers::NONE
632 {
633 self.log_pane.grow();
634 return Ok(());
635 }
636 if matches!(key.code, crossterm::event::KeyCode::Char('-'))
637 && key.modifiers == crossterm::event::KeyModifiers::NONE
638 {
639 self.log_pane.shrink();
640 return Ok(());
641 }
642 if key.modifiers == crossterm::event::KeyModifiers::SHIFT {
647 match key.code {
648 crossterm::event::KeyCode::Up => {
649 self.log_pane.scroll_up(1);
650 return Ok(());
651 }
652 crossterm::event::KeyCode::Down => {
653 self.log_pane.scroll_down(1);
654 return Ok(());
655 }
656 crossterm::event::KeyCode::PageUp => {
657 self.log_pane.scroll_up(10);
658 return Ok(());
659 }
660 crossterm::event::KeyCode::PageDown => {
661 self.log_pane.scroll_down(10);
662 return Ok(());
663 }
664 crossterm::event::KeyCode::End => {
665 self.log_pane.resume_tail();
666 return Ok(());
667 }
668 crossterm::event::KeyCode::Left => {
674 self.log_pane.scroll_left(8);
675 return Ok(());
676 }
677 crossterm::event::KeyCode::Right => {
678 self.log_pane.scroll_right(8);
679 return Ok(());
680 }
681 _ => {}
682 }
683 }
684 if matches!(key.code, crossterm::event::KeyCode::Char('q'))
690 && key.modifiers == crossterm::event::KeyModifiers::NONE
691 {
692 match resolve_quit_press(self.quit_pending, Instant::now(), QUIT_CONFIRM_WINDOW) {
693 QuitResolution::Confirm => {
694 self.quit_pending = None;
695 self.action_tx.send(Action::Quit)?;
696 }
697 QuitResolution::Pending => {
698 self.quit_pending = Some(Instant::now());
699 self.command_status = Some(CommandStatus::Info(
700 "press q again to quit (Esc cancels)".into(),
701 ));
702 }
703 }
704 return Ok(());
705 }
706 if self.quit_pending.is_some() {
710 self.quit_pending = None;
711 }
712 let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
713 return Ok(());
714 };
715 match keymap.get(&vec![key]) {
716 Some(action) => {
717 info!("Got action: {action:?}");
718 action_tx.send(action.clone())?;
719 }
720 _ => {
721 self.last_tick_key_events.push(key);
724
725 if let Some(action) = keymap.get(&self.last_tick_key_events) {
727 info!("Got action: {action:?}");
728 action_tx.send(action.clone())?;
729 }
730 }
731 }
732 Ok(())
733 }
734
735 fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
736 use crossterm::event::KeyCode;
737 let buf = match self.command_buffer.as_mut() {
738 Some(b) => b,
739 None => return Ok(()),
740 };
741 match key.code {
742 KeyCode::Esc => {
743 self.command_buffer = None;
745 self.command_suggestion_index = 0;
746 }
747 KeyCode::Enter => {
748 let line = std::mem::take(buf);
749 self.command_buffer = None;
750 self.command_suggestion_index = 0;
751 self.execute_command(&line)?;
752 }
753 KeyCode::Up => {
754 self.command_suggestion_index = self.command_suggestion_index.saturating_sub(1);
757 }
758 KeyCode::Down => {
759 let n = filter_command_suggestions(buf, KNOWN_COMMANDS).len();
760 if n > 0 && self.command_suggestion_index + 1 < n {
761 self.command_suggestion_index += 1;
762 }
763 }
764 KeyCode::Tab => {
765 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
769 if let Some((name, _)) = matches.get(self.command_suggestion_index) {
770 let rest = buf
771 .split_once(char::is_whitespace)
772 .map(|(_, tail)| tail)
773 .unwrap_or("");
774 let new = if rest.is_empty() {
775 format!("{name} ")
776 } else {
777 format!("{name} {rest}")
778 };
779 buf.clear();
780 buf.push_str(&new);
781 self.command_suggestion_index = 0;
782 }
783 }
784 KeyCode::Backspace => {
785 buf.pop();
786 self.command_suggestion_index = 0;
787 }
788 KeyCode::Char(c) => {
789 buf.push(c);
790 self.command_suggestion_index = 0;
791 }
792 _ => {}
793 }
794 Ok(())
795 }
796
797 fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
800 let trimmed = line.trim();
801 if trimmed.is_empty() {
802 return Ok(());
803 }
804 let head = trimmed.split_whitespace().next().unwrap_or("");
805 match head {
806 "q" | "quit" => {
807 self.action_tx.send(Action::Quit)?;
808 self.command_status = Some(CommandStatus::Info("quitting".into()));
809 }
810 "diagnose" | "diag" => {
811 let pprof_secs = parse_pprof_arg(trimmed);
812 if let Some(secs) = pprof_secs {
813 self.command_status = Some(self.start_diagnose_with_pprof(secs));
814 } else {
815 self.command_status = Some(match self.export_diagnostic_bundle() {
816 Ok(path) => CommandStatus::Info(format!(
817 "diagnostic bundle exported to {}",
818 path.display()
819 )),
820 Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
821 });
822 }
823 }
824 "pins-check" => {
825 self.command_status = Some(match self.start_pins_check() {
831 Ok(path) => CommandStatus::Info(format!(
832 "pins integrity check running → {} (tail to watch progress)",
833 path.display()
834 )),
835 Err(e) => CommandStatus::Err(format!("pins-check failed to start: {e}")),
836 });
837 }
838 "loggers" => {
839 self.command_status = Some(match self.start_loggers_dump() {
840 Ok(path) => CommandStatus::Info(format!(
841 "loggers snapshot writing → {} (open when ready)",
842 path.display()
843 )),
844 Err(e) => CommandStatus::Err(format!("loggers failed to start: {e}")),
845 });
846 }
847 "set-logger" => {
848 let mut parts = trimmed.split_whitespace();
849 let _ = parts.next(); let expr = parts.next().unwrap_or("");
851 let level = parts.next().unwrap_or("");
852 if expr.is_empty() || level.is_empty() {
853 self.command_status = Some(CommandStatus::Err(
854 "usage: :set-logger <expr> <level> (level: none|error|warning|info|debug|all; expr: e.g. node/pushsync or '.' for all)"
855 .into(),
856 ));
857 return Ok(());
858 }
859 self.start_set_logger(expr.to_string(), level.to_string());
860 self.command_status = Some(CommandStatus::Info(format!(
861 "set-logger {expr:?} → {level:?} (PUT in-flight; check :loggers to verify)"
862 )));
863 }
864 "topup-preview" => {
865 self.command_status = Some(self.run_topup_preview(trimmed));
866 }
867 "dilute-preview" => {
868 self.command_status = Some(self.run_dilute_preview(trimmed));
869 }
870 "extend-preview" => {
871 self.command_status = Some(self.run_extend_preview(trimmed));
872 }
873 "buy-preview" => {
874 self.command_status = Some(self.run_buy_preview(trimmed));
875 }
876 "buy-suggest" => {
877 self.command_status = Some(self.run_buy_suggest(trimmed));
878 }
879 "probe-upload" => {
880 self.command_status = Some(self.run_probe_upload(trimmed));
881 }
882 "hash" => {
883 self.command_status = Some(self.run_hash(trimmed));
884 }
885 "cid" => {
886 self.command_status = Some(self.run_cid(trimmed));
887 }
888 "depth-table" => {
889 self.command_status = Some(self.run_depth_table());
890 }
891 "gsoc-mine" => {
892 self.command_status = Some(self.run_gsoc_mine(trimmed));
893 }
894 "pss-target" => {
895 self.command_status = Some(self.run_pss_target(trimmed));
896 }
897 "manifest" => {
898 self.command_status = Some(self.run_manifest(trimmed));
899 }
900 "inspect" => {
901 self.command_status = Some(self.run_inspect(trimmed));
902 }
903 "durability-check" => {
904 self.command_status = Some(self.run_durability_check(trimmed));
905 }
906 "context" | "ctx" => {
907 let target = trimmed.split_whitespace().nth(1).unwrap_or("");
908 if target.is_empty() {
909 let known: Vec<String> =
910 self.config.nodes.iter().map(|n| n.name.clone()).collect();
911 self.command_status = Some(CommandStatus::Err(format!(
912 "usage: :context <name> (known: {})",
913 known.join(", ")
914 )));
915 return Ok(());
916 }
917 self.command_status = Some(match self.switch_context(target) {
918 Ok(()) => CommandStatus::Info(format!(
919 "switched to context {target} ({})",
920 self.api.url
921 )),
922 Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
923 });
924 }
925 screen
926 if SCREEN_NAMES
927 .iter()
928 .any(|name| name.eq_ignore_ascii_case(screen)) =>
929 {
930 if let Some(idx) = SCREEN_NAMES
931 .iter()
932 .position(|name| name.eq_ignore_ascii_case(screen))
933 {
934 self.current_screen = idx;
935 self.command_status =
936 Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
937 }
938 }
939 other => {
940 self.command_status = Some(CommandStatus::Err(format!(
941 "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, :probe-upload, :hash, :cid, :depth-table, :gsoc-mine, :pss-target, :context, :quit)"
942 )));
943 }
944 }
945 Ok(())
946 }
947
948 fn run_topup_preview(&self, line: &str) -> CommandStatus {
952 let parts: Vec<&str> = line.split_whitespace().collect();
953 let (prefix, amount_str) = match parts.as_slice() {
954 [_, prefix, amount, ..] => (*prefix, *amount),
955 _ => {
956 return CommandStatus::Err(
957 "usage: :topup-preview <batch-prefix> <amount-plur-per-chunk>".into(),
958 );
959 }
960 };
961 let chain = match self.health_rx.borrow().chain_state.clone() {
962 Some(c) => c,
963 None => return CommandStatus::Err("chain state not loaded yet".into()),
964 };
965 let stamps = self.watch.stamps().borrow().clone();
966 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
967 Ok(b) => b.clone(),
968 Err(e) => return CommandStatus::Err(e),
969 };
970 let amount = match stamp_preview::parse_plur_amount(amount_str) {
971 Ok(a) => a,
972 Err(e) => return CommandStatus::Err(e),
973 };
974 match stamp_preview::topup_preview(&batch, amount, &chain) {
975 Ok(p) => CommandStatus::Info(p.summary()),
976 Err(e) => CommandStatus::Err(e),
977 }
978 }
979
980 fn run_dilute_preview(&self, line: &str) -> CommandStatus {
984 let parts: Vec<&str> = line.split_whitespace().collect();
985 let (prefix, depth_str) = match parts.as_slice() {
986 [_, prefix, depth, ..] => (*prefix, *depth),
987 _ => {
988 return CommandStatus::Err(
989 "usage: :dilute-preview <batch-prefix> <new-depth>".into(),
990 );
991 }
992 };
993 let new_depth: u8 = match depth_str.parse() {
994 Ok(d) => d,
995 Err(_) => {
996 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
997 }
998 };
999 let stamps = self.watch.stamps().borrow().clone();
1000 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1001 Ok(b) => b.clone(),
1002 Err(e) => return CommandStatus::Err(e),
1003 };
1004 match stamp_preview::dilute_preview(&batch, new_depth) {
1005 Ok(p) => CommandStatus::Info(p.summary()),
1006 Err(e) => CommandStatus::Err(e),
1007 }
1008 }
1009
1010 fn run_extend_preview(&self, line: &str) -> CommandStatus {
1013 let parts: Vec<&str> = line.split_whitespace().collect();
1014 let (prefix, duration_str) = match parts.as_slice() {
1015 [_, prefix, duration, ..] => (*prefix, *duration),
1016 _ => {
1017 return CommandStatus::Err(
1018 "usage: :extend-preview <batch-prefix> <duration> (e.g. 30d, 12h, 90m, 45s, or plain seconds)".into(),
1019 );
1020 }
1021 };
1022 let extension_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
1023 Ok(s) => s,
1024 Err(e) => return CommandStatus::Err(e),
1025 };
1026 let chain = match self.health_rx.borrow().chain_state.clone() {
1027 Some(c) => c,
1028 None => return CommandStatus::Err("chain state not loaded yet".into()),
1029 };
1030 let stamps = self.watch.stamps().borrow().clone();
1031 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1032 Ok(b) => b.clone(),
1033 Err(e) => return CommandStatus::Err(e),
1034 };
1035 match stamp_preview::extend_preview(&batch, extension_seconds, &chain) {
1036 Ok(p) => CommandStatus::Info(p.summary()),
1037 Err(e) => CommandStatus::Err(e),
1038 }
1039 }
1040
1041 fn run_probe_upload(&self, line: &str) -> CommandStatus {
1053 let parts: Vec<&str> = line.split_whitespace().collect();
1054 let prefix = match parts.as_slice() {
1055 [_, prefix, ..] => *prefix,
1056 _ => {
1057 return CommandStatus::Err(
1058 "usage: :probe-upload <batch-prefix> (uploads one synthetic 4 KiB chunk)"
1059 .into(),
1060 );
1061 }
1062 };
1063 let stamps = self.watch.stamps().borrow().clone();
1064 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1065 Ok(b) => b.clone(),
1066 Err(e) => return CommandStatus::Err(e),
1067 };
1068 if !batch.usable {
1069 return CommandStatus::Err(format!(
1070 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1071 short_hex(&batch.batch_id.to_hex(), 8),
1072 ));
1073 }
1074 if batch.batch_ttl <= 0 {
1075 return CommandStatus::Err(format!(
1076 "batch {} is expired — pick another",
1077 short_hex(&batch.batch_id.to_hex(), 8),
1078 ));
1079 }
1080
1081 let api = self.api.clone();
1082 let tx = self.cmd_status_tx.clone();
1083 let batch_id = batch.batch_id;
1084 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1085 let task_short = batch_short.clone();
1086 tokio::spawn(async move {
1087 let chunk = build_synthetic_probe_chunk();
1088 let started = Instant::now();
1089 let result = api.bee().file().upload_chunk(&batch_id, chunk, None).await;
1090 let elapsed_ms = started.elapsed().as_millis();
1091 let status = match result {
1092 Ok(res) => CommandStatus::Info(format!(
1093 "probe-upload OK in {elapsed_ms}ms — batch {task_short}, ref {}",
1094 short_hex(&res.reference.to_hex(), 8),
1095 )),
1096 Err(e) => CommandStatus::Err(format!(
1097 "probe-upload FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1098 )),
1099 };
1100 let _ = tx.send(status);
1101 });
1102
1103 CommandStatus::Info(format!(
1104 "probe-upload to batch {batch_short} in flight — result will replace this line"
1105 ))
1106 }
1107
1108 fn run_hash(&self, line: &str) -> CommandStatus {
1113 let parts: Vec<&str> = line.split_whitespace().collect();
1114 let path = match parts.as_slice() {
1115 [_, p, ..] => *p,
1116 _ => {
1117 return CommandStatus::Err(
1118 "usage: :hash <path> (file or directory; computed locally)".into(),
1119 );
1120 }
1121 };
1122 match utility_verbs::hash_path(path) {
1123 Ok(r) => CommandStatus::Info(format!("hash {path}: {r}")),
1124 Err(e) => CommandStatus::Err(format!("hash failed: {e}")),
1125 }
1126 }
1127
1128 fn run_cid(&self, line: &str) -> CommandStatus {
1132 let parts: Vec<&str> = line.split_whitespace().collect();
1133 let (ref_hex, kind_arg) = match parts.as_slice() {
1134 [_, r, k, ..] => (*r, Some(*k)),
1135 [_, r] => (*r, None),
1136 _ => {
1137 return CommandStatus::Err(
1138 "usage: :cid <ref> [manifest|feed] (default manifest)".into(),
1139 );
1140 }
1141 };
1142 let kind = match utility_verbs::parse_cid_kind(kind_arg) {
1143 Ok(k) => k,
1144 Err(e) => return CommandStatus::Err(e),
1145 };
1146 match utility_verbs::cid_for_ref(ref_hex, kind) {
1147 Ok(cid) => CommandStatus::Info(format!("cid: {cid}")),
1148 Err(e) => CommandStatus::Err(format!("cid failed: {e}")),
1149 }
1150 }
1151
1152 fn run_depth_table(&self) -> CommandStatus {
1157 let body = utility_verbs::depth_table();
1158 let path = std::env::temp_dir().join("bee-tui-depth-table.txt");
1159 match std::fs::write(&path, &body) {
1160 Ok(()) => CommandStatus::Info(format!("depth table → {}", path.display())),
1161 Err(e) => CommandStatus::Err(format!("depth-table write failed: {e}")),
1162 }
1163 }
1164
1165 fn run_gsoc_mine(&self, line: &str) -> CommandStatus {
1170 let parts: Vec<&str> = line.split_whitespace().collect();
1171 let (overlay, ident) = match parts.as_slice() {
1172 [_, o, i, ..] => (*o, *i),
1173 _ => {
1174 return CommandStatus::Err(
1175 "usage: :gsoc-mine <overlay-hex> <identifier> (CPU work, no network)".into(),
1176 );
1177 }
1178 };
1179 match utility_verbs::gsoc_mine_for(overlay, ident) {
1180 Ok(out) => CommandStatus::Info(out.replace('\n', " · ")),
1181 Err(e) => CommandStatus::Err(format!("gsoc-mine failed: {e}")),
1182 }
1183 }
1184
1185 fn run_manifest(&mut self, line: &str) -> CommandStatus {
1189 let parts: Vec<&str> = line.split_whitespace().collect();
1190 let ref_arg = match parts.as_slice() {
1191 [_, r, ..] => *r,
1192 _ => {
1193 return CommandStatus::Err(
1194 "usage: :manifest <ref> (32-byte hex reference)".into(),
1195 );
1196 }
1197 };
1198 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1199 Ok(r) => r,
1200 Err(e) => return CommandStatus::Err(format!("manifest: bad ref: {e}")),
1201 };
1202 let idx = match SCREEN_NAMES.iter().position(|n| *n == "Manifest") {
1205 Some(i) => i,
1206 None => {
1207 return CommandStatus::Err("internal: Manifest screen not registered".into());
1208 }
1209 };
1210 let screen = self
1211 .screens
1212 .get_mut(idx)
1213 .and_then(|s| s.as_any_mut())
1214 .and_then(|a| a.downcast_mut::<Manifest>());
1215 let Some(manifest) = screen else {
1216 return CommandStatus::Err("internal: failed to access Manifest screen".into());
1217 };
1218 manifest.load(reference);
1219 self.current_screen = idx;
1220 CommandStatus::Info(format!("loading manifest {}", short_hex(ref_arg, 8)))
1221 }
1222
1223 fn run_inspect(&self, line: &str) -> CommandStatus {
1230 let parts: Vec<&str> = line.split_whitespace().collect();
1231 let ref_arg = match parts.as_slice() {
1232 [_, r, ..] => *r,
1233 _ => {
1234 return CommandStatus::Err("usage: :inspect <ref> (32-byte hex reference)".into());
1235 }
1236 };
1237 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1238 Ok(r) => r,
1239 Err(e) => return CommandStatus::Err(format!("inspect: bad ref: {e}")),
1240 };
1241 let api = self.api.clone();
1242 let tx = self.cmd_status_tx.clone();
1243 let label = short_hex(ref_arg, 8);
1244 let label_for_task = label.clone();
1245 tokio::spawn(async move {
1246 let result = manifest_walker::inspect(api, reference).await;
1247 let status = match result {
1248 InspectResult::Manifest { node, bytes_len } => CommandStatus::Info(format!(
1249 "inspect {label_for_task}: manifest · {bytes_len} bytes · {} forks (jump to :manifest {label_for_task})",
1250 node.forks.len(),
1251 )),
1252 InspectResult::RawChunk { bytes_len } => CommandStatus::Info(format!(
1253 "inspect {label_for_task}: raw chunk · {bytes_len} bytes · not a manifest"
1254 )),
1255 InspectResult::Error(e) => {
1256 CommandStatus::Err(format!("inspect {label_for_task} failed: {e}"))
1257 }
1258 };
1259 let _ = tx.send(status);
1260 });
1261 CommandStatus::Info(format!("inspecting {label} — result will replace this line"))
1262 }
1263
1264 fn run_durability_check(&mut self, line: &str) -> CommandStatus {
1274 let parts: Vec<&str> = line.split_whitespace().collect();
1275 let ref_arg = match parts.as_slice() {
1276 [_, r, ..] => *r,
1277 _ => {
1278 return CommandStatus::Err(
1279 "usage: :durability-check <ref> (32-byte hex reference)".into(),
1280 );
1281 }
1282 };
1283 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1284 Ok(r) => r,
1285 Err(e) => {
1286 return CommandStatus::Err(format!("durability-check: bad ref: {e}"));
1287 }
1288 };
1289 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Watchlist") {
1292 self.current_screen = idx;
1293 }
1294 let api = self.api.clone();
1295 let tx = self.cmd_status_tx.clone();
1296 let watchlist_tx = self.durability_tx.clone();
1297 let label = short_hex(ref_arg, 8);
1298 let label_for_task = label.clone();
1299 tokio::spawn(async move {
1300 let result = durability::check(api, reference).await;
1301 let summary = result.summary();
1302 let _ = watchlist_tx.send(result);
1303 let _ = tx.send(if summary.contains("UNHEALTHY") {
1304 CommandStatus::Err(summary)
1305 } else {
1306 CommandStatus::Info(summary)
1307 });
1308 });
1309 CommandStatus::Info(format!(
1310 "durability-check {label_for_task} in flight — see S13 Watchlist for the running history"
1311 ))
1312 }
1313
1314 fn run_pss_target(&self, line: &str) -> CommandStatus {
1319 let parts: Vec<&str> = line.split_whitespace().collect();
1320 let overlay = match parts.as_slice() {
1321 [_, o, ..] => *o,
1322 _ => {
1323 return CommandStatus::Err(
1324 "usage: :pss-target <overlay-hex> (returns first 4 hex chars)".into(),
1325 );
1326 }
1327 };
1328 match utility_verbs::pss_target_for(overlay) {
1329 Ok(prefix) => CommandStatus::Info(format!("pss target prefix: {prefix}")),
1330 Err(e) => CommandStatus::Err(format!("pss-target failed: {e}")),
1331 }
1332 }
1333
1334 fn run_buy_suggest(&self, line: &str) -> CommandStatus {
1340 let parts: Vec<&str> = line.split_whitespace().collect();
1341 let (size_str, duration_str) = match parts.as_slice() {
1342 [_, size, duration, ..] => (*size, *duration),
1343 _ => {
1344 return CommandStatus::Err(
1345 "usage: :buy-suggest <size> <duration> (e.g. 5GiB 30d, 100MiB 12h)".into(),
1346 );
1347 }
1348 };
1349 let target_bytes = match stamp_preview::parse_size_bytes(size_str) {
1350 Ok(b) => b,
1351 Err(e) => return CommandStatus::Err(e),
1352 };
1353 let target_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
1354 Ok(s) => s,
1355 Err(e) => return CommandStatus::Err(e),
1356 };
1357 let chain = match self.health_rx.borrow().chain_state.clone() {
1358 Some(c) => c,
1359 None => return CommandStatus::Err("chain state not loaded yet".into()),
1360 };
1361 match stamp_preview::buy_suggest(target_bytes, target_seconds, &chain) {
1362 Ok(s) => CommandStatus::Info(s.summary()),
1363 Err(e) => CommandStatus::Err(e),
1364 }
1365 }
1366
1367 fn run_buy_preview(&self, line: &str) -> CommandStatus {
1370 let parts: Vec<&str> = line.split_whitespace().collect();
1371 let (depth_str, amount_str) = match parts.as_slice() {
1372 [_, depth, amount, ..] => (*depth, *amount),
1373 _ => {
1374 return CommandStatus::Err(
1375 "usage: :buy-preview <depth> <amount-plur-per-chunk>".into(),
1376 );
1377 }
1378 };
1379 let depth: u8 = match depth_str.parse() {
1380 Ok(d) => d,
1381 Err(_) => {
1382 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
1383 }
1384 };
1385 let amount = match stamp_preview::parse_plur_amount(amount_str) {
1386 Ok(a) => a,
1387 Err(e) => return CommandStatus::Err(e),
1388 };
1389 let chain = match self.health_rx.borrow().chain_state.clone() {
1390 Some(c) => c,
1391 None => return CommandStatus::Err("chain state not loaded yet".into()),
1392 };
1393 match stamp_preview::buy_preview(depth, amount, &chain) {
1394 Ok(p) => CommandStatus::Info(p.summary()),
1395 Err(e) => CommandStatus::Err(e),
1396 }
1397 }
1398
1399 fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
1406 let node = self
1407 .config
1408 .nodes
1409 .iter()
1410 .find(|n| n.name == target)
1411 .ok_or_else(|| eyre!("no node configured with name {target:?}"))?
1412 .clone();
1413 let new_api = Arc::new(ApiClient::from_node(&node)?);
1414 self.watch.shutdown();
1418 let refresh = RefreshProfile::from_config(&self.config.ui.refresh);
1419 let new_watch = BeeWatch::start_with_profile(new_api.clone(), &self.root_cancel, refresh);
1420 let new_health_rx = new_watch.health();
1421 let new_screens = build_screens(&new_api, &new_watch);
1422 self.api = new_api;
1423 self.watch = new_watch;
1424 self.health_rx = new_health_rx;
1425 self.screens = new_screens;
1426 Ok(())
1429 }
1430
1431 fn start_pins_check(&self) -> std::io::Result<PathBuf> {
1448 let secs = SystemTime::now()
1449 .duration_since(UNIX_EPOCH)
1450 .map(|d| d.as_secs())
1451 .unwrap_or(0);
1452 let path = std::env::temp_dir().join(format!(
1453 "bee-tui-pins-check-{}-{secs}.txt",
1454 sanitize_for_filename(&self.api.name),
1455 ));
1456 std::fs::write(
1459 &path,
1460 format!(
1461 "# bee-tui :pins-check\n# profile {}\n# endpoint {}\n# started {}\n",
1462 self.api.name,
1463 self.api.url,
1464 format_utc_now(),
1465 ),
1466 )?;
1467
1468 let api = self.api.clone();
1469 let dest = path.clone();
1470 tokio::spawn(async move {
1471 let bee = api.bee();
1472 match bee.api().check_pins(None).await {
1473 Ok(entries) => {
1474 let mut body = String::new();
1475 for e in &entries {
1476 body.push_str(&format!(
1477 "{} total={} missing={} invalid={} {}\n",
1478 e.reference.to_hex(),
1479 e.total,
1480 e.missing,
1481 e.invalid,
1482 if e.is_healthy() {
1483 "healthy"
1484 } else {
1485 "UNHEALTHY"
1486 },
1487 ));
1488 }
1489 body.push_str(&format!("# done. {} pins checked.\n", entries.len()));
1490 if let Err(e) = append(&dest, &body) {
1491 let _ = append(&dest, &format!("# write error: {e}\n"));
1492 }
1493 }
1494 Err(e) => {
1495 let _ = append(&dest, &format!("# error: {e}\n"));
1496 }
1497 }
1498 });
1499 Ok(path)
1500 }
1501
1502 fn start_set_logger(&self, expression: String, level: String) {
1513 let secs = SystemTime::now()
1514 .duration_since(UNIX_EPOCH)
1515 .map(|d| d.as_secs())
1516 .unwrap_or(0);
1517 let dest = std::env::temp_dir().join(format!(
1518 "bee-tui-set-logger-{}-{secs}.txt",
1519 sanitize_for_filename(&self.api.name),
1520 ));
1521 let _ = std::fs::write(
1522 &dest,
1523 format!(
1524 "# bee-tui :set-logger\n# profile {}\n# endpoint {}\n# expr {expression}\n# level {level}\n# started {}\n",
1525 self.api.name,
1526 self.api.url,
1527 format_utc_now(),
1528 ),
1529 );
1530
1531 let api = self.api.clone();
1532 tokio::spawn(async move {
1533 let bee = api.bee();
1534 match bee.debug().set_logger(&expression, &level).await {
1535 Ok(()) => {
1536 let _ = append(
1537 &dest,
1538 &format!("# done. {expression} → {level} accepted by Bee.\n"),
1539 );
1540 }
1541 Err(e) => {
1542 let _ = append(&dest, &format!("# error: {e}\n"));
1543 }
1544 }
1545 });
1546 }
1547
1548 fn start_loggers_dump(&self) -> std::io::Result<PathBuf> {
1553 let secs = SystemTime::now()
1554 .duration_since(UNIX_EPOCH)
1555 .map(|d| d.as_secs())
1556 .unwrap_or(0);
1557 let path = std::env::temp_dir().join(format!(
1558 "bee-tui-loggers-{}-{secs}.txt",
1559 sanitize_for_filename(&self.api.name),
1560 ));
1561 std::fs::write(
1562 &path,
1563 format!(
1564 "# bee-tui :loggers\n# profile {}\n# endpoint {}\n# started {}\n",
1565 self.api.name,
1566 self.api.url,
1567 format_utc_now(),
1568 ),
1569 )?;
1570
1571 let api = self.api.clone();
1572 let dest = path.clone();
1573 tokio::spawn(async move {
1574 let bee = api.bee();
1575 match bee.debug().loggers().await {
1576 Ok(listing) => {
1577 let mut rows = listing.loggers.clone();
1578 rows.sort_by(|a, b| {
1582 verbosity_rank(&b.verbosity)
1583 .cmp(&verbosity_rank(&a.verbosity))
1584 .then_with(|| a.logger.cmp(&b.logger))
1585 });
1586 let mut body = String::new();
1587 body.push_str(&format!("# {} loggers registered\n", rows.len()));
1588 body.push_str("# VERBOSITY LOGGER\n");
1589 for r in &rows {
1590 body.push_str(&format!(" {:<9} {}\n", r.verbosity, r.logger,));
1591 }
1592 body.push_str("# done.\n");
1593 if let Err(e) = append(&dest, &body) {
1594 let _ = append(&dest, &format!("# write error: {e}\n"));
1595 }
1596 }
1597 Err(e) => {
1598 let _ = append(&dest, &format!("# error: {e}\n"));
1599 }
1600 }
1601 });
1602 Ok(path)
1603 }
1604
1605 fn start_diagnose_with_pprof(&self, seconds: u32) -> CommandStatus {
1617 let secs_unix = SystemTime::now()
1618 .duration_since(UNIX_EPOCH)
1619 .map(|d| d.as_secs())
1620 .unwrap_or(0);
1621 let dir = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs_unix}"));
1622 if let Err(e) = std::fs::create_dir_all(&dir) {
1623 return CommandStatus::Err(format!("diagnose --pprof: mkdir failed: {e}"));
1624 }
1625 let bundle_text = self.render_diagnostic_bundle();
1626 if let Err(e) = std::fs::write(dir.join("bundle.txt"), &bundle_text) {
1627 return CommandStatus::Err(format!("diagnose --pprof: write bundle.txt: {e}"));
1628 }
1629 let auth_token = self
1634 .config
1635 .nodes
1636 .iter()
1637 .find(|n| n.name == self.api.name)
1638 .and_then(|n| n.resolved_token());
1639 let base_url = self.api.url.clone();
1640 let dir_for_task = dir.clone();
1641 let tx = self.cmd_status_tx.clone();
1642 tokio::spawn(async move {
1643 let r = pprof_bundle::fetch_and_write(&base_url, auth_token, seconds, dir_for_task)
1644 .await;
1645 let status = match r {
1646 Ok(b) => CommandStatus::Info(b.summary()),
1647 Err(e) => CommandStatus::Err(format!("diagnose --pprof failed: {e}")),
1648 };
1649 let _ = tx.send(status);
1650 });
1651 CommandStatus::Info(format!(
1652 "diagnose --pprof={seconds}s in flight (bundle.txt already at {}; profile + trace will join when sampling completes)",
1653 dir.display()
1654 ))
1655 }
1656
1657 fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
1658 let bundle = self.render_diagnostic_bundle();
1659 let secs = SystemTime::now()
1660 .duration_since(UNIX_EPOCH)
1661 .map(|d| d.as_secs())
1662 .unwrap_or(0);
1663 let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
1664 std::fs::write(&path, bundle)?;
1665 Ok(path)
1666 }
1667
1668 fn render_diagnostic_bundle(&self) -> String {
1669 let now = format_utc_now();
1670 let health = self.health_rx.borrow().clone();
1671 let topology = self.watch.topology().borrow().clone();
1672 let gates = Health::gates_for(&health, Some(&topology));
1673 let recent: Vec<_> = log_capture::handle()
1674 .map(|c| {
1675 let mut snap = c.snapshot();
1676 let len = snap.len();
1677 if len > 50 {
1678 snap.drain(0..len - 50);
1679 }
1680 snap
1681 })
1682 .unwrap_or_default();
1683
1684 let mut out = String::new();
1685 out.push_str("# bee-tui diagnostic bundle\n");
1686 out.push_str(&format!("# generated UTC {now}\n\n"));
1687 out.push_str("## profile\n");
1688 out.push_str(&format!(" name {}\n", self.api.name));
1689 out.push_str(&format!(" endpoint {}\n\n", self.api.url));
1690 out.push_str("## health gates\n");
1691 for g in &gates {
1692 out.push_str(&format_gate_line(g));
1693 }
1694 out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
1695 for e in &recent {
1696 let status = e
1697 .status
1698 .map(|s| s.to_string())
1699 .unwrap_or_else(|| "—".into());
1700 let elapsed = e
1701 .elapsed_ms
1702 .map(|ms| format!("{ms}ms"))
1703 .unwrap_or_else(|| "—".into());
1704 out.push_str(&format!(
1705 " {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
1706 ts = e.ts,
1707 method = e.method,
1708 path = path_only(&e.url),
1709 status = status,
1710 elapsed = elapsed,
1711 ));
1712 }
1713 out.push_str(&format!(
1714 "\n## generated by bee-tui {}\n",
1715 env!("CARGO_PKG_VERSION"),
1716 ));
1717 out
1718 }
1719
1720 fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
1721 while let Ok(action) = self.action_rx.try_recv() {
1722 if action != Action::Tick && action != Action::Render {
1723 debug!("{action:?}");
1724 }
1725 match action {
1726 Action::Tick => {
1727 self.last_tick_key_events.drain(..);
1728 theme::advance_spinner();
1732 if let Some(sup) = self.supervisor.as_mut() {
1736 self.bee_status = sup.status();
1737 }
1738 if let Some(rx) = self.bee_log_rx.as_mut() {
1743 while let Ok((tab, line)) = rx.try_recv() {
1744 self.log_pane.push_bee(tab, line);
1745 }
1746 }
1747 while let Ok(status) = self.cmd_status_rx.try_recv() {
1752 self.command_status = Some(status);
1753 }
1754 while let Ok(result) = self.durability_rx.try_recv() {
1759 if let Some(idx) =
1760 SCREEN_NAMES.iter().position(|n| *n == "Watchlist")
1761 {
1762 if let Some(wl) = self
1763 .screens
1764 .get_mut(idx)
1765 .and_then(|s| s.as_any_mut())
1766 .and_then(|a| a.downcast_mut::<Watchlist>())
1767 {
1768 wl.record(result);
1769 }
1770 }
1771 }
1772 }
1773 Action::Quit => self.should_quit = true,
1774 Action::Suspend => self.should_suspend = true,
1775 Action::Resume => self.should_suspend = false,
1776 Action::ClearScreen => tui.terminal.clear()?,
1777 Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
1778 Action::Render => self.render(tui)?,
1779 _ => {}
1780 }
1781 let tx = self.action_tx.clone();
1782 for component in self.iter_components_mut() {
1783 if let Some(action) = component.update(action.clone())? {
1784 tx.send(action)?
1785 };
1786 }
1787 }
1788 Ok(())
1789 }
1790
1791 fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
1792 tui.resize(Rect::new(0, 0, w, h))?;
1793 self.render(tui)?;
1794 Ok(())
1795 }
1796
1797 fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
1798 let active = self.current_screen;
1799 let tx = self.action_tx.clone();
1800 let screens = &mut self.screens;
1801 let log_pane = &mut self.log_pane;
1802 let log_pane_height = log_pane.height();
1803 let command_buffer = self.command_buffer.clone();
1804 let command_suggestion_index = self.command_suggestion_index;
1805 let command_status = self.command_status.clone();
1806 let help_visible = self.help_visible;
1807 let profile = self.api.name.clone();
1808 let endpoint = self.api.url.clone();
1809 let last_ping = self.health_rx.borrow().last_ping;
1810 let now_utc = format_utc_now();
1811 let bee_status_label = if self.supervisor.is_some() && !self.bee_status.is_running() {
1812 Some(self.bee_status.label())
1816 } else {
1817 None
1818 };
1819 tui.draw(|frame| {
1820 use ratatui::layout::{Constraint, Layout};
1821 use ratatui::style::{Color, Modifier, Style};
1822 use ratatui::text::{Line, Span};
1823 use ratatui::widgets::Paragraph;
1824
1825 let chunks = Layout::vertical([
1826 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), Constraint::Length(log_pane_height), ])
1831 .split(frame.area());
1832
1833 let top_chunks =
1834 Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(chunks[0]);
1835
1836 let ping_str = match last_ping {
1838 Some(d) => format!("{}ms", d.as_millis()),
1839 None => "—".into(),
1840 };
1841 let t = theme::active();
1842 let mut metadata_spans = vec![
1843 Span::styled(
1844 " bee-tui ",
1845 Style::default()
1846 .fg(Color::Black)
1847 .bg(t.info)
1848 .add_modifier(Modifier::BOLD),
1849 ),
1850 Span::raw(" "),
1851 Span::styled(
1852 profile,
1853 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
1854 ),
1855 Span::styled(format!(" @ {endpoint}"), Style::default().fg(t.dim)),
1856 Span::raw(" "),
1857 Span::styled("ping ", Style::default().fg(t.dim)),
1858 Span::styled(ping_str, Style::default().fg(t.info)),
1859 Span::raw(" "),
1860 Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
1861 ];
1862 if let Some(label) = bee_status_label.as_ref() {
1866 metadata_spans.push(Span::raw(" "));
1867 metadata_spans.push(Span::styled(
1868 format!(" {label} "),
1869 Style::default()
1870 .fg(Color::Black)
1871 .bg(t.fail)
1872 .add_modifier(Modifier::BOLD),
1873 ));
1874 }
1875 let metadata_line = Line::from(metadata_spans);
1876 frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
1877
1878 let theme = *theme::active();
1880 let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
1881 for (i, name) in SCREEN_NAMES.iter().enumerate() {
1882 let style = if i == active {
1883 Style::default()
1884 .fg(theme.tab_active_fg)
1885 .bg(theme.tab_active_bg)
1886 .add_modifier(Modifier::BOLD)
1887 } else {
1888 Style::default().fg(theme.dim)
1889 };
1890 tabs.push(Span::styled(format!(" {name} "), style));
1891 tabs.push(Span::raw(" "));
1892 }
1893 tabs.push(Span::styled(
1894 ":cmd · Tab to cycle · ? help",
1895 Style::default().fg(theme.dim),
1896 ));
1897 frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
1898
1899 if let Some(screen) = screens.get_mut(active) {
1901 if let Err(err) = screen.draw(frame, chunks[1]) {
1902 let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
1903 }
1904 }
1905 let prompt = if let Some(buf) = &command_buffer {
1907 Line::from(vec![
1908 Span::styled(
1909 ":",
1910 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
1911 ),
1912 Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
1913 Span::styled("█", Style::default().fg(t.accent)),
1914 ])
1915 } else {
1916 match &command_status {
1917 Some(CommandStatus::Info(msg)) => {
1918 Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
1919 }
1920 Some(CommandStatus::Err(msg)) => {
1921 Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
1922 }
1923 None => Line::from(""),
1924 }
1925 };
1926 frame.render_widget(Paragraph::new(prompt), chunks[2]);
1927
1928 if let Some(buf) = &command_buffer {
1934 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
1935 if !matches.is_empty() {
1936 draw_command_suggestions(
1937 frame,
1938 chunks[2],
1939 &matches,
1940 command_suggestion_index,
1941 &theme,
1942 );
1943 }
1944 }
1945
1946 if let Err(err) = log_pane.draw(frame, chunks[3]) {
1948 let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
1949 }
1950
1951 if help_visible {
1956 draw_help_overlay(frame, frame.area(), active, &theme);
1957 }
1958 })?;
1959 Ok(())
1960 }
1961}
1962
1963fn draw_command_suggestions(
1970 frame: &mut ratatui::Frame,
1971 bar_rect: ratatui::layout::Rect,
1972 matches: &[&(&str, &str)],
1973 selected: usize,
1974 theme: &theme::Theme,
1975) {
1976 use ratatui::layout::Rect;
1977 use ratatui::style::{Modifier, Style};
1978 use ratatui::text::{Line, Span};
1979 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1980
1981 const MAX_VISIBLE: usize = 10;
1982 let visible_rows = matches.len().min(MAX_VISIBLE);
1983 if visible_rows == 0 {
1984 return;
1985 }
1986 let height = (visible_rows as u16) + 2; let widest = matches
1991 .iter()
1992 .map(|(name, desc)| name.len() + desc.len() + 6)
1993 .max()
1994 .unwrap_or(40)
1995 .min(bar_rect.width as usize);
1996 let width = (widest as u16 + 2).min(bar_rect.width);
1997 let bottom = bar_rect.y;
2000 let y = bottom.saturating_sub(height);
2001 let popup = Rect {
2002 x: bar_rect.x,
2003 y,
2004 width,
2005 height: bottom - y,
2006 };
2007
2008 let scroll_start = if selected >= visible_rows {
2010 selected + 1 - visible_rows
2011 } else {
2012 0
2013 };
2014 let visible_slice = &matches[scroll_start..(scroll_start + visible_rows).min(matches.len())];
2015
2016 let mut lines: Vec<Line> = Vec::with_capacity(visible_slice.len());
2017 for (i, (name, desc)) in visible_slice.iter().enumerate() {
2018 let absolute_idx = scroll_start + i;
2019 let is_selected = absolute_idx == selected;
2020 let row_style = if is_selected {
2021 Style::default()
2022 .fg(theme.tab_active_fg)
2023 .bg(theme.tab_active_bg)
2024 .add_modifier(Modifier::BOLD)
2025 } else {
2026 Style::default()
2027 };
2028 let cursor = if is_selected { "▸ " } else { " " };
2029 lines.push(Line::from(vec![
2030 Span::styled(format!("{cursor}:{name:<16} "), row_style),
2031 Span::styled(
2032 desc.to_string(),
2033 if is_selected {
2034 row_style
2035 } else {
2036 Style::default().fg(theme.dim)
2037 },
2038 ),
2039 ]));
2040 }
2041
2042 let title = if matches.len() > MAX_VISIBLE {
2044 format!(" :commands ({}/{}) ", selected + 1, matches.len())
2045 } else {
2046 " :commands ".to_string()
2047 };
2048
2049 frame.render_widget(Clear, popup);
2050 frame.render_widget(
2051 Paragraph::new(lines).block(
2052 Block::default()
2053 .borders(Borders::ALL)
2054 .border_style(Style::default().fg(theme.accent))
2055 .title(title),
2056 ),
2057 popup,
2058 );
2059}
2060
2061fn draw_help_overlay(
2066 frame: &mut ratatui::Frame,
2067 area: ratatui::layout::Rect,
2068 active_screen: usize,
2069 theme: &theme::Theme,
2070) {
2071 use ratatui::layout::Rect;
2072 use ratatui::style::{Modifier, Style};
2073 use ratatui::text::{Line, Span};
2074 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2075
2076 let screen_name = SCREEN_NAMES.get(active_screen).copied().unwrap_or("?");
2077 let screen_rows = screen_keymap(active_screen);
2078 let global_rows: &[(&str, &str)] = &[
2079 ("Tab", "next screen"),
2080 ("Shift+Tab", "previous screen"),
2081 ("[ / ]", "previous / next log-pane tab"),
2082 ("+ / -", "grow / shrink log pane"),
2083 ("Shift+↑/↓", "scroll log pane (1 line); pauses auto-tail"),
2084 ("Shift+PgUp/PgDn", "scroll log pane (10 lines)"),
2085 ("Shift+←/→", "pan log pane horizontally (8 cols)"),
2086 ("Shift+End", "resume auto-tail + reset horizontal pan"),
2087 ("?", "toggle this help"),
2088 (":", "open command bar"),
2089 ("qq", "quit (double-tap; or :q)"),
2090 ("Ctrl+C / Ctrl+D", "quit immediately"),
2091 ];
2092
2093 let w = area.width.min(72);
2096 let h = area.height.min(22);
2097 let x = area.x + (area.width.saturating_sub(w)) / 2;
2098 let y = area.y + (area.height.saturating_sub(h)) / 2;
2099 let rect = Rect {
2100 x,
2101 y,
2102 width: w,
2103 height: h,
2104 };
2105
2106 let mut lines: Vec<Line> = Vec::new();
2107 lines.push(Line::from(vec![
2108 Span::styled(
2109 format!(" {screen_name} "),
2110 Style::default()
2111 .fg(theme.tab_active_fg)
2112 .bg(theme.tab_active_bg)
2113 .add_modifier(Modifier::BOLD),
2114 ),
2115 Span::raw(" screen-specific keys"),
2116 ]));
2117 lines.push(Line::from(""));
2118 if screen_rows.is_empty() {
2119 lines.push(Line::from(Span::styled(
2120 " (no extra keys for this screen — use the command bar via :)",
2121 Style::default()
2122 .fg(theme.dim)
2123 .add_modifier(Modifier::ITALIC),
2124 )));
2125 } else {
2126 for (key, desc) in screen_rows {
2127 lines.push(format_help_row(key, desc, theme));
2128 }
2129 }
2130 lines.push(Line::from(""));
2131 lines.push(Line::from(Span::styled(
2132 " global",
2133 Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
2134 )));
2135 for (key, desc) in global_rows {
2136 lines.push(format_help_row(key, desc, theme));
2137 }
2138 lines.push(Line::from(""));
2139 lines.push(Line::from(Span::styled(
2140 " Esc / ? / q to dismiss",
2141 Style::default()
2142 .fg(theme.dim)
2143 .add_modifier(Modifier::ITALIC),
2144 )));
2145
2146 frame.render_widget(Clear, rect);
2149 frame.render_widget(
2150 Paragraph::new(lines).block(
2151 Block::default()
2152 .borders(Borders::ALL)
2153 .border_style(Style::default().fg(theme.accent))
2154 .title(" help "),
2155 ),
2156 rect,
2157 );
2158}
2159
2160fn format_help_row<'a>(
2161 key: &'a str,
2162 desc: &'a str,
2163 theme: &theme::Theme,
2164) -> ratatui::text::Line<'a> {
2165 use ratatui::style::{Modifier, Style};
2166 use ratatui::text::{Line, Span};
2167 Line::from(vec![
2168 Span::raw(" "),
2169 Span::styled(
2170 format!("{key:<16}"),
2171 Style::default()
2172 .fg(theme.accent)
2173 .add_modifier(Modifier::BOLD),
2174 ),
2175 Span::raw(" "),
2176 Span::raw(desc),
2177 ])
2178}
2179
2180fn screen_keymap(active_screen: usize) -> &'static [(&'static str, &'static str)] {
2184 match active_screen {
2185 1 => &[
2187 ("↑↓ / j k", "move row selection"),
2188 ("Enter", "drill batch — bucket histogram + worst-N"),
2189 ("Esc", "close drill"),
2190 ],
2191 3 => &[("r", "run on-demand rchash benchmark")],
2193 4 => &[
2194 ("↑↓ / j k", "move peer selection"),
2195 (
2196 "Enter",
2197 "drill peer — balance / cheques / settlement / ping",
2198 ),
2199 ("Esc", "close drill"),
2200 ],
2201 8 => &[
2205 ("↑↓ / j k", "scroll one row"),
2206 ("PgUp / PgDn", "scroll ten rows"),
2207 ("Home", "back to top"),
2208 ],
2209 9 => &[
2211 ("↑↓ / j k", "move row selection"),
2212 ("Enter", "integrity-check the highlighted pin"),
2213 ("c", "integrity-check every unchecked pin"),
2214 ("s", "cycle sort: ref order / bad first / by size"),
2215 ],
2216 10 => &[
2218 ("↑↓ / j k", "move row selection"),
2219 ("Enter", "expand / collapse fork (loads child chunk)"),
2220 (":manifest <ref>", "open a manifest at a reference"),
2221 (":inspect <ref>", "what is this? auto-detects manifest"),
2222 ],
2223 11 => &[
2225 ("↑↓ / j k", "move row selection"),
2226 (":durability-check <ref>", "walk chunk graph + record"),
2227 ],
2228 _ => &[],
2229 }
2230}
2231
2232fn build_screens(api: &Arc<ApiClient>, watch: &BeeWatch) -> Vec<Box<dyn Component>> {
2241 let health = Health::new(api.clone(), watch.health(), watch.topology());
2242 let stamps = Stamps::new(api.clone(), watch.stamps());
2243 let swap = Swap::new(watch.swap());
2244 let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
2245 let peers = Peers::new(api.clone(), watch.topology());
2246 let network = Network::new(watch.network(), watch.topology());
2247 let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
2248 let api_health = ApiHealth::new(
2249 api.clone(),
2250 watch.health(),
2251 watch.transactions(),
2252 log_capture::handle(),
2253 );
2254 let tags = Tags::new(watch.tags());
2255 let pins = Pins::new(api.clone(), watch.pins());
2256 let manifest = Manifest::new(api.clone());
2257 let watchlist = Watchlist::new();
2258 vec![
2259 Box::new(health),
2260 Box::new(stamps),
2261 Box::new(swap),
2262 Box::new(lottery),
2263 Box::new(peers),
2264 Box::new(network),
2265 Box::new(warmup),
2266 Box::new(api_health),
2267 Box::new(tags),
2268 Box::new(pins),
2269 Box::new(manifest),
2270 Box::new(watchlist),
2271 ]
2272}
2273
2274fn build_synthetic_probe_chunk() -> Vec<u8> {
2282 use std::time::{SystemTime, UNIX_EPOCH};
2283 let nanos = SystemTime::now()
2284 .duration_since(UNIX_EPOCH)
2285 .map(|d| d.as_nanos())
2286 .unwrap_or(0);
2287 let mut data = Vec::with_capacity(8 + 4096);
2288 data.extend_from_slice(&4096u64.to_le_bytes());
2290 data.extend_from_slice(&nanos.to_le_bytes());
2292 data.resize(8 + 4096, 0);
2293 data
2294}
2295
2296fn short_hex(hex: &str, len: usize) -> String {
2299 if hex.len() > len {
2300 format!("{}…", &hex[..len])
2301 } else {
2302 hex.to_string()
2303 }
2304}
2305
2306fn build_metrics_render_fn(
2312 watch: BeeWatch,
2313 log_capture: Option<log_capture::LogCapture>,
2314) -> crate::metrics_server::RenderFn {
2315 use std::time::{SystemTime, UNIX_EPOCH};
2316 Arc::new(move || {
2317 let health = watch.health().borrow().clone();
2318 let stamps = watch.stamps().borrow().clone();
2319 let swap = watch.swap().borrow().clone();
2320 let lottery = watch.lottery().borrow().clone();
2321 let topology = watch.topology().borrow().clone();
2322 let network = watch.network().borrow().clone();
2323 let transactions = watch.transactions().borrow().clone();
2324 let recent = log_capture
2325 .as_ref()
2326 .map(|c| c.snapshot())
2327 .unwrap_or_default();
2328 let call_stats = crate::components::api_health::call_stats_for(&recent);
2329 let now_unix = SystemTime::now()
2330 .duration_since(UNIX_EPOCH)
2331 .map(|d| d.as_secs() as i64)
2332 .unwrap_or(0);
2333 let inputs = crate::metrics::MetricsInputs {
2334 bee_tui_version: env!("CARGO_PKG_VERSION"),
2335 health: &health,
2336 stamps: &stamps,
2337 swap: &swap,
2338 lottery: &lottery,
2339 topology: &topology,
2340 network: &network,
2341 transactions: &transactions,
2342 call_stats: &call_stats,
2343 now_unix,
2344 };
2345 crate::metrics::render(&inputs)
2346 })
2347}
2348
2349fn format_gate_line(g: &Gate) -> String {
2350 let glyphs = crate::theme::active().glyphs;
2351 let glyph = match g.status {
2352 GateStatus::Pass => glyphs.pass,
2353 GateStatus::Warn => glyphs.warn,
2354 GateStatus::Fail => glyphs.fail,
2355 GateStatus::Unknown => glyphs.bullet,
2356 };
2357 let mut s = format!(
2358 " [{glyph}] {label:<28} {value}\n",
2359 label = g.label,
2360 value = g.value
2361 );
2362 if let Some(why) = &g.why {
2363 s.push_str(&format!(" {} {why}\n", glyphs.continuation));
2364 }
2365 s
2366}
2367
2368fn path_only(url: &str) -> String {
2371 if let Some(idx) = url.find("//") {
2372 let after_scheme = &url[idx + 2..];
2373 if let Some(slash) = after_scheme.find('/') {
2374 return after_scheme[slash..].to_string();
2375 }
2376 return "/".into();
2377 }
2378 url.to_string()
2379}
2380
2381fn append(path: &PathBuf, s: &str) -> std::io::Result<()> {
2388 use std::io::Write;
2389 let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
2390 f.write_all(s.as_bytes())
2391}
2392
2393fn verbosity_rank(s: &str) -> u8 {
2399 match s {
2400 "all" | "trace" => 5,
2401 "debug" => 4,
2402 "info" | "1" => 3,
2403 "warning" | "warn" | "2" => 2,
2404 "error" | "3" => 1,
2405 _ => 0,
2406 }
2407}
2408
2409fn sanitize_for_filename(s: &str) -> String {
2413 s.chars()
2414 .map(|c| match c {
2415 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
2416 _ => '-',
2417 })
2418 .collect()
2419}
2420
2421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2425pub enum QuitResolution {
2426 Confirm,
2428 Pending,
2431}
2432
2433fn resolve_quit_press(prev: Option<Instant>, now: Instant, window: Duration) -> QuitResolution {
2438 match prev {
2439 Some(t) if now.duration_since(t) <= window => QuitResolution::Confirm,
2440 _ => QuitResolution::Pending,
2441 }
2442}
2443
2444fn format_utc_now() -> String {
2445 let secs = SystemTime::now()
2446 .duration_since(UNIX_EPOCH)
2447 .map(|d| d.as_secs())
2448 .unwrap_or(0);
2449 let secs_in_day = secs % 86_400;
2450 let h = secs_in_day / 3_600;
2451 let m = (secs_in_day % 3_600) / 60;
2452 let s = secs_in_day % 60;
2453 format!("{h:02}:{m:02}:{s:02}")
2454}
2455
2456#[cfg(test)]
2457mod tests {
2458 use super::*;
2459
2460 #[test]
2461 fn format_utc_now_returns_eight_chars() {
2462 let s = format_utc_now();
2463 assert_eq!(s.len(), 8);
2464 assert_eq!(s.chars().nth(2), Some(':'));
2465 assert_eq!(s.chars().nth(5), Some(':'));
2466 }
2467
2468 #[test]
2469 fn path_only_strips_scheme_and_host() {
2470 assert_eq!(path_only("http://10.0.1.5:1633/status"), "/status");
2471 assert_eq!(
2472 path_only("https://bee.example.com/stamps?limit=10"),
2473 "/stamps?limit=10"
2474 );
2475 }
2476
2477 #[test]
2478 fn path_only_handles_no_path() {
2479 assert_eq!(path_only("http://localhost:1633"), "/");
2480 }
2481
2482 #[test]
2483 fn path_only_passes_relative_through() {
2484 assert_eq!(path_only("/already/relative"), "/already/relative");
2485 }
2486
2487 #[test]
2488 fn parse_pprof_arg_default_60() {
2489 assert_eq!(parse_pprof_arg("diagnose --pprof"), Some(60));
2490 assert_eq!(parse_pprof_arg("diag --pprof some other"), Some(60));
2491 }
2492
2493 #[test]
2494 fn parse_pprof_arg_with_explicit_seconds() {
2495 assert_eq!(parse_pprof_arg("diagnose --pprof=120"), Some(120));
2496 assert_eq!(parse_pprof_arg("diagnose --pprof=15 trailing"), Some(15));
2497 }
2498
2499 #[test]
2500 fn parse_pprof_arg_clamps_extreme_values() {
2501 assert_eq!(parse_pprof_arg("diagnose --pprof=0"), Some(1));
2503 assert_eq!(parse_pprof_arg("diagnose --pprof=9999"), Some(600));
2504 }
2505
2506 #[test]
2507 fn parse_pprof_arg_none_when_absent() {
2508 assert_eq!(parse_pprof_arg("diagnose"), None);
2509 assert_eq!(parse_pprof_arg("diag"), None);
2510 assert_eq!(parse_pprof_arg(""), None);
2511 }
2512
2513 #[test]
2514 fn parse_pprof_arg_ignores_garbage_value() {
2515 assert_eq!(parse_pprof_arg("diagnose --pprof=lol"), None);
2518 }
2519
2520 #[test]
2521 fn sanitize_for_filename_keeps_safe_chars() {
2522 assert_eq!(sanitize_for_filename("prod-1"), "prod-1");
2523 assert_eq!(sanitize_for_filename("lab_node"), "lab_node");
2524 }
2525
2526 #[test]
2527 fn sanitize_for_filename_replaces_unsafe_chars() {
2528 assert_eq!(sanitize_for_filename("a/b\\c d"), "a-b-c-d");
2529 assert_eq!(sanitize_for_filename("name:colon"), "name-colon");
2530 }
2531
2532 #[test]
2533 fn resolve_quit_press_first_press_is_pending() {
2534 let now = Instant::now();
2535 assert_eq!(
2536 resolve_quit_press(None, now, Duration::from_millis(1500)),
2537 QuitResolution::Pending
2538 );
2539 }
2540
2541 #[test]
2542 fn resolve_quit_press_second_press_inside_window_confirms() {
2543 let first = Instant::now();
2544 let window = Duration::from_millis(1500);
2545 let second = first + Duration::from_millis(500);
2546 assert_eq!(
2547 resolve_quit_press(Some(first), second, window),
2548 QuitResolution::Confirm
2549 );
2550 }
2551
2552 #[test]
2553 fn resolve_quit_press_second_press_after_window_resets_to_pending() {
2554 let first = Instant::now();
2558 let window = Duration::from_millis(1500);
2559 let second = first + Duration::from_millis(2_000);
2560 assert_eq!(
2561 resolve_quit_press(Some(first), second, window),
2562 QuitResolution::Pending
2563 );
2564 }
2565
2566 #[test]
2567 fn resolve_quit_press_at_window_boundary_confirms() {
2568 let first = Instant::now();
2571 let window = Duration::from_millis(1500);
2572 let second = first + window;
2573 assert_eq!(
2574 resolve_quit_press(Some(first), second, window),
2575 QuitResolution::Confirm
2576 );
2577 }
2578
2579 #[test]
2580 fn screen_keymap_covers_drill_screens() {
2581 for idx in [1usize, 4] {
2584 let rows = screen_keymap(idx);
2585 assert!(
2586 rows.iter().any(|(k, _)| k.contains("Enter")),
2587 "screen {idx} keymap must mention Enter (drill)"
2588 );
2589 assert!(
2590 rows.iter().any(|(k, _)| k.contains("Esc")),
2591 "screen {idx} keymap must mention Esc (close drill)"
2592 );
2593 }
2594 }
2595
2596 #[test]
2597 fn screen_keymap_lottery_advertises_rchash() {
2598 let rows = screen_keymap(3);
2599 assert!(rows.iter().any(|(k, _)| k.contains("r")));
2600 }
2601
2602 #[test]
2603 fn screen_keymap_unknown_index_is_empty_not_panic() {
2604 assert!(screen_keymap(999).is_empty());
2605 }
2606
2607 #[test]
2608 fn verbosity_rank_orders_loud_to_silent() {
2609 assert!(verbosity_rank("all") > verbosity_rank("debug"));
2610 assert!(verbosity_rank("debug") > verbosity_rank("info"));
2611 assert!(verbosity_rank("info") > verbosity_rank("warning"));
2612 assert!(verbosity_rank("warning") > verbosity_rank("error"));
2613 assert!(verbosity_rank("error") > verbosity_rank("unknown"));
2614 assert_eq!(verbosity_rank("info"), verbosity_rank("1"));
2616 assert_eq!(verbosity_rank("warning"), verbosity_rank("2"));
2617 }
2618
2619 #[test]
2620 fn filter_command_suggestions_empty_buffer_returns_all() {
2621 let matches = filter_command_suggestions("", KNOWN_COMMANDS);
2622 assert_eq!(matches.len(), KNOWN_COMMANDS.len());
2623 }
2624
2625 #[test]
2626 fn filter_command_suggestions_prefix_matches_case_insensitive() {
2627 let matches = filter_command_suggestions("Bu", KNOWN_COMMANDS);
2628 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
2629 assert!(names.contains(&"buy-preview"));
2630 assert!(names.contains(&"buy-suggest"));
2631 assert_eq!(names.len(), 2);
2632 }
2633
2634 #[test]
2635 fn filter_command_suggestions_unknown_prefix_is_empty() {
2636 let matches = filter_command_suggestions("xyz", KNOWN_COMMANDS);
2637 assert!(matches.is_empty());
2638 }
2639
2640 #[test]
2641 fn filter_command_suggestions_uses_first_token_only() {
2642 let matches = filter_command_suggestions("topup-preview a1b2 1000", KNOWN_COMMANDS);
2645 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
2646 assert_eq!(names, vec!["topup-preview"]);
2647 }
2648
2649 #[test]
2650 fn probe_chunk_is_4104_bytes_with_correct_span() {
2651 let chunk = build_synthetic_probe_chunk();
2653 assert_eq!(chunk.len(), 4104);
2654 let span = u64::from_le_bytes(chunk[..8].try_into().unwrap());
2655 assert_eq!(span, 4096);
2656 }
2657
2658 #[test]
2659 fn probe_chunk_payloads_are_unique_per_call() {
2660 let a = build_synthetic_probe_chunk();
2665 std::thread::sleep(Duration::from_micros(1));
2667 let b = build_synthetic_probe_chunk();
2668 assert_ne!(&a[8..24], &b[8..24]);
2669 }
2670
2671 #[test]
2672 fn short_hex_truncates_with_ellipsis() {
2673 assert_eq!(short_hex("a1b2c3d4e5f6", 8), "a1b2c3d4…");
2674 assert_eq!(short_hex("short", 8), "short");
2675 assert_eq!(short_hex("abcdefgh", 8), "abcdefgh");
2676 }
2677}