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 config_doctor, durability, economics_oracle, log_capture,
35 manifest_walker::{self, InspectResult},
36 pprof_bundle, stamp_preview,
37 state::State,
38 theme,
39 tui::{Event, Tui},
40 utility_verbs, version_check,
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 alert_state: crate::alerts::AlertState,
134}
135
136const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
139
140#[derive(Debug, Clone)]
143pub enum CommandStatus {
144 Info(String),
145 Err(String),
146}
147
148const SCREEN_NAMES: &[&str] = &[
151 "Health",
152 "Stamps",
153 "Swap",
154 "Lottery",
155 "Peers",
156 "Network",
157 "Warmup",
158 "API",
159 "Tags",
160 "Pins",
161 "Manifest",
162 "Watchlist",
163];
164
165const KNOWN_COMMANDS: &[(&str, &str)] = &[
176 ("health", "S1 Health screen"),
177 ("stamps", "S2 Stamps screen"),
178 ("swap", "S3 SWAP / cheques screen"),
179 ("lottery", "S4 Lottery + rchash"),
180 ("peers", "S6 Peers + bin saturation"),
181 ("network", "S7 Network / NAT"),
182 ("warmup", "S5 Warmup checklist"),
183 ("api", "S8 RPC / API health"),
184 ("tags", "S9 Tags / uploads"),
185 ("pins", "S11 Pins screen"),
186 ("topup-preview", "<batch> <amount-plur> — predict topup"),
187 ("dilute-preview", "<batch> <new-depth> — predict dilute"),
188 ("extend-preview", "<batch> <duration> — predict extend"),
189 ("buy-preview", "<depth> <amount-plur> — predict fresh buy"),
190 ("buy-suggest", "<size> <duration> — minimum (depth, amount)"),
191 (
192 "plan-batch",
193 "<batch> [usage-thr] [ttl-thr] [extra-depth] — unified topup+dilute plan",
194 ),
195 (
196 "check-version",
197 "compare running Bee version with GitHub's latest release",
198 ),
199 (
200 "config-doctor",
201 "audit bee.yaml for deprecated keys (read-only, never modifies)",
202 ),
203 ("price", "fetch xBZZ → USD spot price"),
204 (
205 "basefee",
206 "fetch Gnosis basefee + tip (requires [economics].gnosis_rpc_url)",
207 ),
208 (
209 "probe-upload",
210 "<batch> — single 4 KiB chunk, end-to-end probe",
211 ),
212 (
213 "upload-file",
214 "<path> <batch> — upload a single local file, return Swarm ref",
215 ),
216 (
217 "upload-collection",
218 "<dir> <batch> — recursive directory upload, return Swarm ref",
219 ),
220 (
221 "feed-probe",
222 "<owner> <topic> — latest update for a feed (read-only lookup)",
223 ),
224 (
225 "manifest",
226 "<ref> — open Mantaray tree browser at a reference",
227 ),
228 (
229 "inspect",
230 "<ref> — what is this? auto-detects manifest vs raw chunk",
231 ),
232 (
233 "durability-check",
234 "<ref> — walk chunk graph, report total / lost / errors",
235 ),
236 ("watchlist", "S13 Watchlist — durability-check history"),
237 (
238 "hash",
239 "<path> — Swarm reference of a local file/dir (offline)",
240 ),
241 ("cid", "<ref> [manifest|feed] — encode reference as CID"),
242 ("depth-table", "Print canonical depth → capacity table"),
243 (
244 "gsoc-mine",
245 "<overlay> <id> — mine a GSOC signer (CPU work)",
246 ),
247 (
248 "pss-target",
249 "<overlay> — first 4 hex chars (Bee's max prefix)",
250 ),
251 (
252 "diagnose",
253 "[--pprof[=N]] Export snapshot (+ optional CPU profile + trace)",
254 ),
255 ("pins-check", "Bulk integrity walk to a file"),
256 ("loggers", "Dump live logger registry"),
257 ("set-logger", "<expr> <level> — change a logger's verbosity"),
258 ("context", "<name> — switch node profile"),
259 ("quit", "Exit the cockpit"),
260];
261
262fn parse_pprof_arg(line: &str) -> Option<u32> {
267 for tok in line.split_whitespace() {
268 if tok == "--pprof" {
269 return Some(60);
270 }
271 if let Some(rest) = tok.strip_prefix("--pprof=") {
272 if let Ok(n) = rest.parse::<u32>() {
273 return Some(n.clamp(1, 600));
274 }
275 }
276 }
277 None
278}
279
280fn filter_command_suggestions<'a>(
284 buffer: &str,
285 catalog: &'a [(&'a str, &'a str)],
286) -> Vec<&'a (&'a str, &'a str)> {
287 let head = buffer
288 .split_whitespace()
289 .next()
290 .unwrap_or("")
291 .to_ascii_lowercase();
292 catalog
293 .iter()
294 .filter(|(name, _)| name.starts_with(&head))
295 .collect()
296}
297
298#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
299pub enum Mode {
300 #[default]
301 Home,
302}
303
304#[derive(Debug, Default)]
307pub struct AppOverrides {
308 pub ascii: bool,
310 pub no_color: bool,
312 pub bee_bin: Option<PathBuf>,
314 pub bee_config: Option<PathBuf>,
316}
317
318const BEE_API_READY_TIMEOUT: Duration = Duration::from_secs(60);
323
324impl App {
325 pub async fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
326 Self::with_overrides(tick_rate, frame_rate, AppOverrides::default()).await
327 }
328
329 pub async fn with_overrides(
334 tick_rate: f64,
335 frame_rate: f64,
336 overrides: AppOverrides,
337 ) -> color_eyre::Result<Self> {
338 let (action_tx, action_rx) = mpsc::unbounded_channel();
339 let (cmd_status_tx, cmd_status_rx) = mpsc::unbounded_channel();
340 let (durability_tx, durability_rx) = mpsc::unbounded_channel();
341 let config = Config::new()?;
342 let force_no_color = overrides.no_color || theme::no_color_env();
345 theme::install_with_overrides(&config.ui, force_no_color, overrides.ascii);
346
347 let node = config
350 .active_node()
351 .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
352 let api = Arc::new(ApiClient::from_node(node)?);
353
354 let bee_bin = overrides
356 .bee_bin
357 .or_else(|| config.bee.as_ref().map(|b| b.bin.clone()));
358 let bee_config = overrides
359 .bee_config
360 .or_else(|| config.bee.as_ref().map(|b| b.config.clone()));
361 let bee_logs = config
364 .bee
365 .as_ref()
366 .map(|b| b.logs.clone())
367 .unwrap_or_default();
368 let supervisor = match (bee_bin, bee_config) {
369 (Some(bin), Some(cfg)) => {
370 eprintln!("bee-tui: spawning bee {bin:?} --config {cfg:?}");
371 let mut sup = BeeSupervisor::spawn(&bin, &cfg, bee_logs)?;
372 eprintln!(
373 "bee-tui: log → {} (will appear in the cockpit's bottom pane)",
374 sup.log_path().display()
375 );
376 eprintln!(
377 "bee-tui: waiting for {} to respond on /health (up to {:?})...",
378 api.url, BEE_API_READY_TIMEOUT
379 );
380 sup.wait_for_api(&api.url, BEE_API_READY_TIMEOUT).await?;
381 eprintln!("bee-tui: bee ready, opening cockpit");
382 Some(sup)
383 }
384 (Some(_), None) | (None, Some(_)) => {
385 return Err(eyre!(
386 "[bee].bin and [bee].config must both be set (or both unset). \
387 Use --bee-bin AND --bee-config, or both fields in config.toml."
388 ));
389 }
390 (None, None) => None,
391 };
392
393 let refresh = RefreshProfile::from_config(&config.ui.refresh);
400 let root_cancel = CancellationToken::new();
401 let watch = BeeWatch::start_with_profile(api.clone(), &root_cancel, refresh);
402 let health_rx = watch.health();
403
404 let market_rx = if config.economics.enable_market_tile {
408 Some(economics_oracle::spawn_poller(
409 config.economics.gnosis_rpc_url.clone(),
410 root_cancel.child_token(),
411 ))
412 } else {
413 None
414 };
415
416 let screens = build_screens(&api, &watch, market_rx);
417 let (persisted, state_path) = State::load();
422 let initial_tab = LogTab::from_kebab(&persisted.log_pane_active_tab);
423 let mut log_pane = LogPane::new(
424 log_capture::handle(),
425 initial_tab,
426 persisted.log_pane_height,
427 );
428 log_pane.set_spawn_active(supervisor.is_some());
429 if let Some(c) = log_capture::cockpit_handle() {
430 log_pane.set_cockpit_capture(c);
431 }
432
433 let bee_log_rx = supervisor.as_ref().map(|sup| {
439 let (tx, rx) = mpsc::unbounded_channel();
440 crate::bee_log_tailer::spawn(
441 sup.log_path().to_path_buf(),
442 tx,
443 root_cancel.child_token(),
444 );
445 rx
446 });
447
448 if config.metrics.enabled {
455 match config.metrics.addr.parse::<std::net::SocketAddr>() {
456 Ok(bind_addr) => {
457 let render_fn = build_metrics_render_fn(watch.clone(), log_capture::handle());
458 let cancel = root_cancel.child_token();
459 match crate::metrics_server::spawn(bind_addr, render_fn, cancel).await {
460 Ok(actual) => {
461 eprintln!(
462 "bee-tui: metrics endpoint serving /metrics on http://{actual}"
463 );
464 }
465 Err(e) => {
466 tracing::error!(
467 "metrics: failed to start endpoint on {bind_addr}: {e}"
468 );
469 }
470 }
471 }
472 Err(e) => {
473 tracing::error!(
474 "metrics: invalid [metrics].addr {:?}: {e}",
475 config.metrics.addr
476 );
477 }
478 }
479 }
480
481 let config_alerts_debounce = config.alerts.debounce_secs;
482
483 Ok(Self {
484 tick_rate,
485 frame_rate,
486 screens,
487 current_screen: 0,
488 log_pane,
489 state_path,
490 should_quit: false,
491 should_suspend: false,
492 config,
493 mode: Mode::Home,
494 last_tick_key_events: Vec::new(),
495 action_tx,
496 action_rx,
497 root_cancel,
498 api,
499 watch,
500 health_rx,
501 command_buffer: None,
502 command_suggestion_index: 0,
503 command_status: None,
504 help_visible: false,
505 quit_pending: None,
506 supervisor,
507 bee_status: BeeStatus::Running,
508 bee_log_rx,
509 cmd_status_tx,
510 cmd_status_rx,
511 durability_tx,
512 durability_rx,
513 alert_state: crate::alerts::AlertState::new(config_alerts_debounce),
514 })
515 }
516
517 pub async fn run(&mut self) -> color_eyre::Result<()> {
518 let mut tui = Tui::new()?
519 .tick_rate(self.tick_rate)
521 .frame_rate(self.frame_rate);
522 tui.enter()?;
523
524 let tx = self.action_tx.clone();
525 let cfg = self.config.clone();
526 let size = tui.size()?;
527 for component in self.iter_components_mut() {
528 component.register_action_handler(tx.clone())?;
529 component.register_config_handler(cfg.clone())?;
530 component.init(size)?;
531 }
532
533 let action_tx = self.action_tx.clone();
534 loop {
535 self.handle_events(&mut tui).await?;
536 self.handle_actions(&mut tui)?;
537 if self.should_suspend {
538 tui.suspend()?;
539 action_tx.send(Action::Resume)?;
540 action_tx.send(Action::ClearScreen)?;
541 tui.enter()?;
543 } else if self.should_quit {
544 tui.stop()?;
545 break;
546 }
547 }
548 self.watch.shutdown();
550 self.root_cancel.cancel();
551 let snapshot = State {
555 log_pane_height: self.log_pane.height(),
556 log_pane_active_tab: self.log_pane.active_tab().to_kebab().to_string(),
557 };
558 snapshot.save(&self.state_path);
559 if let Some(sup) = self.supervisor.take() {
563 let final_status = sup.shutdown_default().await;
564 tracing::info!("bee child exited: {}", final_status.label());
565 }
566 tui.exit()?;
567 Ok(())
568 }
569
570 async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
571 let Some(event) = tui.next_event().await else {
572 return Ok(());
573 };
574 let action_tx = self.action_tx.clone();
575 let modal_before = self.command_buffer.is_some() || self.help_visible;
582 match event {
583 Event::Quit => action_tx.send(Action::Quit)?,
584 Event::Tick => action_tx.send(Action::Tick)?,
585 Event::Render => action_tx.send(Action::Render)?,
586 Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
587 Event::Key(key) => self.handle_key_event(key)?,
588 _ => {}
589 }
590 let modal_after = self.command_buffer.is_some() || self.help_visible;
591 let propagate = !((modal_before || modal_after) && matches!(event, Event::Key(_)));
594 if propagate {
595 for component in self.iter_components_mut() {
596 if let Some(action) = component.handle_events(Some(event.clone()))? {
597 action_tx.send(action)?;
598 }
599 }
600 }
601 Ok(())
602 }
603
604 fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut dyn Component> {
609 self.screens
610 .iter_mut()
611 .map(|c| c.as_mut() as &mut dyn Component)
612 .chain(std::iter::once(&mut self.log_pane as &mut dyn Component))
613 }
614
615 fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
616 if self.command_buffer.is_some() {
620 self.handle_command_mode_key(key)?;
621 return Ok(());
622 }
623 if self.help_visible {
627 match key.code {
628 crossterm::event::KeyCode::Esc
629 | crossterm::event::KeyCode::Char('?')
630 | crossterm::event::KeyCode::Char('q') => {
631 self.help_visible = false;
632 }
633 _ => {}
634 }
635 return Ok(());
636 }
637 if matches!(key.code, crossterm::event::KeyCode::Char('?')) {
641 self.help_visible = true;
642 return Ok(());
643 }
644 let action_tx = self.action_tx.clone();
645 if matches!(key.code, crossterm::event::KeyCode::Char(':')) {
647 self.command_buffer = Some(String::new());
648 self.command_status = None;
649 return Ok(());
650 }
651 if matches!(key.code, crossterm::event::KeyCode::Tab) {
656 if !self.screens.is_empty() {
657 self.current_screen = (self.current_screen + 1) % self.screens.len();
658 debug!(
659 "switched to screen {}",
660 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
661 );
662 }
663 return Ok(());
664 }
665 if matches!(key.code, crossterm::event::KeyCode::BackTab) {
666 if !self.screens.is_empty() {
667 let len = self.screens.len();
668 self.current_screen = (self.current_screen + len - 1) % len;
669 debug!(
670 "switched to screen {}",
671 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
672 );
673 }
674 return Ok(());
675 }
676 if matches!(key.code, crossterm::event::KeyCode::Char('['))
682 && key.modifiers == crossterm::event::KeyModifiers::NONE
683 {
684 self.log_pane.prev_tab();
685 return Ok(());
686 }
687 if matches!(key.code, crossterm::event::KeyCode::Char(']'))
688 && key.modifiers == crossterm::event::KeyModifiers::NONE
689 {
690 self.log_pane.next_tab();
691 return Ok(());
692 }
693 if matches!(key.code, crossterm::event::KeyCode::Char('+'))
694 && key.modifiers == crossterm::event::KeyModifiers::NONE
695 {
696 self.log_pane.grow();
697 return Ok(());
698 }
699 if matches!(key.code, crossterm::event::KeyCode::Char('-'))
700 && key.modifiers == crossterm::event::KeyModifiers::NONE
701 {
702 self.log_pane.shrink();
703 return Ok(());
704 }
705 if key.modifiers == crossterm::event::KeyModifiers::SHIFT {
710 match key.code {
711 crossterm::event::KeyCode::Up => {
712 self.log_pane.scroll_up(1);
713 return Ok(());
714 }
715 crossterm::event::KeyCode::Down => {
716 self.log_pane.scroll_down(1);
717 return Ok(());
718 }
719 crossterm::event::KeyCode::PageUp => {
720 self.log_pane.scroll_up(10);
721 return Ok(());
722 }
723 crossterm::event::KeyCode::PageDown => {
724 self.log_pane.scroll_down(10);
725 return Ok(());
726 }
727 crossterm::event::KeyCode::End => {
728 self.log_pane.resume_tail();
729 return Ok(());
730 }
731 crossterm::event::KeyCode::Left => {
737 self.log_pane.scroll_left(8);
738 return Ok(());
739 }
740 crossterm::event::KeyCode::Right => {
741 self.log_pane.scroll_right(8);
742 return Ok(());
743 }
744 _ => {}
745 }
746 }
747 if matches!(key.code, crossterm::event::KeyCode::Char('q'))
753 && key.modifiers == crossterm::event::KeyModifiers::NONE
754 {
755 match resolve_quit_press(self.quit_pending, Instant::now(), QUIT_CONFIRM_WINDOW) {
756 QuitResolution::Confirm => {
757 self.quit_pending = None;
758 self.action_tx.send(Action::Quit)?;
759 }
760 QuitResolution::Pending => {
761 self.quit_pending = Some(Instant::now());
762 self.command_status = Some(CommandStatus::Info(
763 "press q again to quit (Esc cancels)".into(),
764 ));
765 }
766 }
767 return Ok(());
768 }
769 if self.quit_pending.is_some() {
773 self.quit_pending = None;
774 }
775 let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
776 return Ok(());
777 };
778 match keymap.get(&vec![key]) {
779 Some(action) => {
780 info!("Got action: {action:?}");
781 action_tx.send(action.clone())?;
782 }
783 _ => {
784 self.last_tick_key_events.push(key);
787
788 if let Some(action) = keymap.get(&self.last_tick_key_events) {
790 info!("Got action: {action:?}");
791 action_tx.send(action.clone())?;
792 }
793 }
794 }
795 Ok(())
796 }
797
798 fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
799 use crossterm::event::KeyCode;
800 let buf = match self.command_buffer.as_mut() {
801 Some(b) => b,
802 None => return Ok(()),
803 };
804 match key.code {
805 KeyCode::Esc => {
806 self.command_buffer = None;
808 self.command_suggestion_index = 0;
809 }
810 KeyCode::Enter => {
811 let line = std::mem::take(buf);
812 self.command_buffer = None;
813 self.command_suggestion_index = 0;
814 self.execute_command(&line)?;
815 }
816 KeyCode::Up => {
817 self.command_suggestion_index = self.command_suggestion_index.saturating_sub(1);
820 }
821 KeyCode::Down => {
822 let n = filter_command_suggestions(buf, KNOWN_COMMANDS).len();
823 if n > 0 && self.command_suggestion_index + 1 < n {
824 self.command_suggestion_index += 1;
825 }
826 }
827 KeyCode::Tab => {
828 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
832 if let Some((name, _)) = matches.get(self.command_suggestion_index) {
833 let rest = buf
834 .split_once(char::is_whitespace)
835 .map(|(_, tail)| tail)
836 .unwrap_or("");
837 let new = if rest.is_empty() {
838 format!("{name} ")
839 } else {
840 format!("{name} {rest}")
841 };
842 buf.clear();
843 buf.push_str(&new);
844 self.command_suggestion_index = 0;
845 }
846 }
847 KeyCode::Backspace => {
848 buf.pop();
849 self.command_suggestion_index = 0;
850 }
851 KeyCode::Char(c) => {
852 buf.push(c);
853 self.command_suggestion_index = 0;
854 }
855 _ => {}
856 }
857 Ok(())
858 }
859
860 fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
863 let trimmed = line.trim();
864 if trimmed.is_empty() {
865 return Ok(());
866 }
867 let head = trimmed.split_whitespace().next().unwrap_or("");
868 match head {
869 "q" | "quit" => {
870 self.action_tx.send(Action::Quit)?;
871 self.command_status = Some(CommandStatus::Info("quitting".into()));
872 }
873 "diagnose" | "diag" => {
874 let pprof_secs = parse_pprof_arg(trimmed);
875 if let Some(secs) = pprof_secs {
876 self.command_status = Some(self.start_diagnose_with_pprof(secs));
877 } else {
878 self.command_status = Some(match self.export_diagnostic_bundle() {
879 Ok(path) => CommandStatus::Info(format!(
880 "diagnostic bundle exported to {}",
881 path.display()
882 )),
883 Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
884 });
885 }
886 }
887 "pins-check" => {
888 self.command_status = Some(match self.start_pins_check() {
894 Ok(path) => CommandStatus::Info(format!(
895 "pins integrity check running → {} (tail to watch progress)",
896 path.display()
897 )),
898 Err(e) => CommandStatus::Err(format!("pins-check failed to start: {e}")),
899 });
900 }
901 "loggers" => {
902 self.command_status = Some(match self.start_loggers_dump() {
903 Ok(path) => CommandStatus::Info(format!(
904 "loggers snapshot writing → {} (open when ready)",
905 path.display()
906 )),
907 Err(e) => CommandStatus::Err(format!("loggers failed to start: {e}")),
908 });
909 }
910 "set-logger" => {
911 let mut parts = trimmed.split_whitespace();
912 let _ = parts.next(); let expr = parts.next().unwrap_or("");
914 let level = parts.next().unwrap_or("");
915 if expr.is_empty() || level.is_empty() {
916 self.command_status = Some(CommandStatus::Err(
917 "usage: :set-logger <expr> <level> (level: none|error|warning|info|debug|all; expr: e.g. node/pushsync or '.' for all)"
918 .into(),
919 ));
920 return Ok(());
921 }
922 self.start_set_logger(expr.to_string(), level.to_string());
923 self.command_status = Some(CommandStatus::Info(format!(
924 "set-logger {expr:?} → {level:?} (PUT in-flight; check :loggers to verify)"
925 )));
926 }
927 "topup-preview" => {
928 self.command_status = Some(self.run_topup_preview(trimmed));
929 }
930 "dilute-preview" => {
931 self.command_status = Some(self.run_dilute_preview(trimmed));
932 }
933 "extend-preview" => {
934 self.command_status = Some(self.run_extend_preview(trimmed));
935 }
936 "buy-preview" => {
937 self.command_status = Some(self.run_buy_preview(trimmed));
938 }
939 "buy-suggest" => {
940 self.command_status = Some(self.run_buy_suggest(trimmed));
941 }
942 "plan-batch" => {
943 self.command_status = Some(self.run_plan_batch(trimmed));
944 }
945 "check-version" => {
946 self.command_status = Some(self.run_check_version());
947 }
948 "config-doctor" => {
949 self.command_status = Some(self.run_config_doctor());
950 }
951 "price" => {
952 self.command_status = Some(self.run_price());
953 }
954 "basefee" => {
955 self.command_status = Some(self.run_basefee());
956 }
957 "probe-upload" => {
958 self.command_status = Some(self.run_probe_upload(trimmed));
959 }
960 "upload-file" => {
961 self.command_status = Some(self.run_upload_file(trimmed));
962 }
963 "upload-collection" => {
964 self.command_status = Some(self.run_upload_collection(trimmed));
965 }
966 "feed-probe" => {
967 self.command_status = Some(self.run_feed_probe(trimmed));
968 }
969 "hash" => {
970 self.command_status = Some(self.run_hash(trimmed));
971 }
972 "cid" => {
973 self.command_status = Some(self.run_cid(trimmed));
974 }
975 "depth-table" => {
976 self.command_status = Some(self.run_depth_table());
977 }
978 "gsoc-mine" => {
979 self.command_status = Some(self.run_gsoc_mine(trimmed));
980 }
981 "pss-target" => {
982 self.command_status = Some(self.run_pss_target(trimmed));
983 }
984 "manifest" => {
985 self.command_status = Some(self.run_manifest(trimmed));
986 }
987 "inspect" => {
988 self.command_status = Some(self.run_inspect(trimmed));
989 }
990 "durability-check" => {
991 self.command_status = Some(self.run_durability_check(trimmed));
992 }
993 "context" | "ctx" => {
994 let target = trimmed.split_whitespace().nth(1).unwrap_or("");
995 if target.is_empty() {
996 let known: Vec<String> =
997 self.config.nodes.iter().map(|n| n.name.clone()).collect();
998 self.command_status = Some(CommandStatus::Err(format!(
999 "usage: :context <name> (known: {})",
1000 known.join(", ")
1001 )));
1002 return Ok(());
1003 }
1004 self.command_status = Some(match self.switch_context(target) {
1005 Ok(()) => CommandStatus::Info(format!(
1006 "switched to context {target} ({})",
1007 self.api.url
1008 )),
1009 Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
1010 });
1011 }
1012 screen
1013 if SCREEN_NAMES
1014 .iter()
1015 .any(|name| name.eq_ignore_ascii_case(screen)) =>
1016 {
1017 if let Some(idx) = SCREEN_NAMES
1018 .iter()
1019 .position(|name| name.eq_ignore_ascii_case(screen))
1020 {
1021 self.current_screen = idx;
1022 self.command_status =
1023 Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
1024 }
1025 }
1026 other => {
1027 self.command_status = Some(CommandStatus::Err(format!(
1028 "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, :upload-file, :upload-collection, :feed-probe, :hash, :cid, :depth-table, :gsoc-mine, :pss-target, :context, :quit)"
1029 )));
1030 }
1031 }
1032 Ok(())
1033 }
1034
1035 fn run_topup_preview(&self, line: &str) -> CommandStatus {
1039 let parts: Vec<&str> = line.split_whitespace().collect();
1040 let (prefix, amount_str) = match parts.as_slice() {
1041 [_, prefix, amount, ..] => (*prefix, *amount),
1042 _ => {
1043 return CommandStatus::Err(
1044 "usage: :topup-preview <batch-prefix> <amount-plur-per-chunk>".into(),
1045 );
1046 }
1047 };
1048 let chain = match self.health_rx.borrow().chain_state.clone() {
1049 Some(c) => c,
1050 None => return CommandStatus::Err("chain state not loaded yet".into()),
1051 };
1052 let stamps = self.watch.stamps().borrow().clone();
1053 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1054 Ok(b) => b.clone(),
1055 Err(e) => return CommandStatus::Err(e),
1056 };
1057 let amount = match stamp_preview::parse_plur_amount(amount_str) {
1058 Ok(a) => a,
1059 Err(e) => return CommandStatus::Err(e),
1060 };
1061 match stamp_preview::topup_preview(&batch, amount, &chain) {
1062 Ok(p) => CommandStatus::Info(p.summary()),
1063 Err(e) => CommandStatus::Err(e),
1064 }
1065 }
1066
1067 fn run_dilute_preview(&self, line: &str) -> CommandStatus {
1071 let parts: Vec<&str> = line.split_whitespace().collect();
1072 let (prefix, depth_str) = match parts.as_slice() {
1073 [_, prefix, depth, ..] => (*prefix, *depth),
1074 _ => {
1075 return CommandStatus::Err(
1076 "usage: :dilute-preview <batch-prefix> <new-depth>".into(),
1077 );
1078 }
1079 };
1080 let new_depth: u8 = match depth_str.parse() {
1081 Ok(d) => d,
1082 Err(_) => {
1083 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
1084 }
1085 };
1086 let stamps = self.watch.stamps().borrow().clone();
1087 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1088 Ok(b) => b.clone(),
1089 Err(e) => return CommandStatus::Err(e),
1090 };
1091 match stamp_preview::dilute_preview(&batch, new_depth) {
1092 Ok(p) => CommandStatus::Info(p.summary()),
1093 Err(e) => CommandStatus::Err(e),
1094 }
1095 }
1096
1097 fn run_extend_preview(&self, line: &str) -> CommandStatus {
1100 let parts: Vec<&str> = line.split_whitespace().collect();
1101 let (prefix, duration_str) = match parts.as_slice() {
1102 [_, prefix, duration, ..] => (*prefix, *duration),
1103 _ => {
1104 return CommandStatus::Err(
1105 "usage: :extend-preview <batch-prefix> <duration> (e.g. 30d, 12h, 90m, 45s, or plain seconds)".into(),
1106 );
1107 }
1108 };
1109 let extension_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
1110 Ok(s) => s,
1111 Err(e) => return CommandStatus::Err(e),
1112 };
1113 let chain = match self.health_rx.borrow().chain_state.clone() {
1114 Some(c) => c,
1115 None => return CommandStatus::Err("chain state not loaded yet".into()),
1116 };
1117 let stamps = self.watch.stamps().borrow().clone();
1118 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1119 Ok(b) => b.clone(),
1120 Err(e) => return CommandStatus::Err(e),
1121 };
1122 match stamp_preview::extend_preview(&batch, extension_seconds, &chain) {
1123 Ok(p) => CommandStatus::Info(p.summary()),
1124 Err(e) => CommandStatus::Err(e),
1125 }
1126 }
1127
1128 fn run_probe_upload(&self, line: &str) -> CommandStatus {
1140 let parts: Vec<&str> = line.split_whitespace().collect();
1141 let prefix = match parts.as_slice() {
1142 [_, prefix, ..] => *prefix,
1143 _ => {
1144 return CommandStatus::Err(
1145 "usage: :probe-upload <batch-prefix> (uploads one synthetic 4 KiB chunk)"
1146 .into(),
1147 );
1148 }
1149 };
1150 let stamps = self.watch.stamps().borrow().clone();
1151 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1152 Ok(b) => b.clone(),
1153 Err(e) => return CommandStatus::Err(e),
1154 };
1155 if !batch.usable {
1156 return CommandStatus::Err(format!(
1157 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1158 short_hex(&batch.batch_id.to_hex(), 8),
1159 ));
1160 }
1161 if batch.batch_ttl <= 0 {
1162 return CommandStatus::Err(format!(
1163 "batch {} is expired — pick another",
1164 short_hex(&batch.batch_id.to_hex(), 8),
1165 ));
1166 }
1167
1168 let api = self.api.clone();
1169 let tx = self.cmd_status_tx.clone();
1170 let batch_id = batch.batch_id;
1171 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1172 let task_short = batch_short.clone();
1173 tokio::spawn(async move {
1174 let chunk = build_synthetic_probe_chunk();
1175 let started = Instant::now();
1176 let result = api.bee().file().upload_chunk(&batch_id, chunk, None).await;
1177 let elapsed_ms = started.elapsed().as_millis();
1178 let status = match result {
1179 Ok(res) => CommandStatus::Info(format!(
1180 "probe-upload OK in {elapsed_ms}ms — batch {task_short}, ref {}",
1181 short_hex(&res.reference.to_hex(), 8),
1182 )),
1183 Err(e) => CommandStatus::Err(format!(
1184 "probe-upload FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1185 )),
1186 };
1187 let _ = tx.send(status);
1188 });
1189
1190 CommandStatus::Info(format!(
1191 "probe-upload to batch {batch_short} in flight — result will replace this line"
1192 ))
1193 }
1194
1195 fn run_upload_file(&self, line: &str) -> CommandStatus {
1203 let parts: Vec<&str> = line.split_whitespace().collect();
1204 let (path_str, prefix) = match parts.as_slice() {
1205 [_, p, b, ..] => (*p, *b),
1206 _ => {
1207 return CommandStatus::Err("usage: :upload-file <path> <batch-prefix>".into());
1208 }
1209 };
1210 let path = std::path::PathBuf::from(path_str);
1211 let meta = match std::fs::metadata(&path) {
1212 Ok(m) => m,
1213 Err(e) => return CommandStatus::Err(format!("stat {path_str}: {e}")),
1214 };
1215 if meta.is_dir() {
1216 return CommandStatus::Err(format!(
1217 "{path_str} is a directory — :upload-file is single-file only (collection upload coming in a later release)"
1218 ));
1219 }
1220 const MAX_FILE_BYTES: u64 = 256 * 1024 * 1024;
1221 if meta.len() > MAX_FILE_BYTES {
1222 return CommandStatus::Err(format!(
1223 "{path_str} is {} — over the {}-MiB cockpit ceiling; use swarm-cli for larger uploads",
1224 meta.len(),
1225 MAX_FILE_BYTES / (1024 * 1024),
1226 ));
1227 }
1228 let stamps = self.watch.stamps().borrow().clone();
1229 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1230 Ok(b) => b.clone(),
1231 Err(e) => return CommandStatus::Err(e),
1232 };
1233 if !batch.usable {
1234 return CommandStatus::Err(format!(
1235 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1236 short_hex(&batch.batch_id.to_hex(), 8),
1237 ));
1238 }
1239 if batch.batch_ttl <= 0 {
1240 return CommandStatus::Err(format!(
1241 "batch {} is expired — pick another",
1242 short_hex(&batch.batch_id.to_hex(), 8),
1243 ));
1244 }
1245
1246 let api = self.api.clone();
1247 let tx = self.cmd_status_tx.clone();
1248 let batch_id = batch.batch_id;
1249 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1250 let task_short = batch_short.clone();
1251 let file_size = meta.len();
1252 let name = path
1253 .file_name()
1254 .and_then(|n| n.to_str())
1255 .unwrap_or("")
1256 .to_string();
1257 let content_type = guess_content_type(&path);
1258 tokio::spawn(async move {
1259 let data = match tokio::fs::read(&path).await {
1260 Ok(b) => b,
1261 Err(e) => {
1262 let _ = tx.send(CommandStatus::Err(format!("read {}: {e}", path.display())));
1263 return;
1264 }
1265 };
1266 let started = Instant::now();
1267 let result = api
1268 .bee()
1269 .file()
1270 .upload_file(&batch_id, data, &name, &content_type, None)
1271 .await;
1272 let elapsed_ms = started.elapsed().as_millis();
1273 let status = match result {
1274 Ok(res) => CommandStatus::Info(format!(
1275 "upload-file OK in {elapsed_ms}ms — {file_size}B → ref {} (batch {task_short})",
1276 res.reference.to_hex(),
1277 )),
1278 Err(e) => CommandStatus::Err(format!(
1279 "upload-file FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1280 )),
1281 };
1282 let _ = tx.send(status);
1283 });
1284
1285 CommandStatus::Info(format!(
1286 "upload-file ({file_size}B) to batch {batch_short} in flight — result will replace this line"
1287 ))
1288 }
1289
1290 fn run_upload_collection(&self, line: &str) -> CommandStatus {
1298 let parts: Vec<&str> = line.split_whitespace().collect();
1299 let (dir_str, prefix) = match parts.as_slice() {
1300 [_, d, b, ..] => (*d, *b),
1301 _ => {
1302 return CommandStatus::Err("usage: :upload-collection <dir> <batch-prefix>".into());
1303 }
1304 };
1305 let dir = std::path::PathBuf::from(dir_str);
1306 let walked = match crate::uploads::walk_dir(&dir) {
1307 Ok(w) => w,
1308 Err(e) => return CommandStatus::Err(format!("walk {dir_str}: {e}")),
1309 };
1310 if walked.entries.is_empty() {
1311 return CommandStatus::Err(format!(
1312 "{dir_str} contains no uploadable files (after skipping hidden + symlinks)"
1313 ));
1314 }
1315 let stamps = self.watch.stamps().borrow().clone();
1316 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1317 Ok(b) => b.clone(),
1318 Err(e) => return CommandStatus::Err(e),
1319 };
1320 if !batch.usable {
1321 return CommandStatus::Err(format!(
1322 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1323 short_hex(&batch.batch_id.to_hex(), 8),
1324 ));
1325 }
1326 if batch.batch_ttl <= 0 {
1327 return CommandStatus::Err(format!(
1328 "batch {} is expired — pick another",
1329 short_hex(&batch.batch_id.to_hex(), 8),
1330 ));
1331 }
1332
1333 let api = self.api.clone();
1334 let tx = self.cmd_status_tx.clone();
1335 let batch_id = batch.batch_id;
1336 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1337 let task_short = batch_short.clone();
1338 let total_bytes = walked.total_bytes;
1339 let entry_count = walked.entries.len();
1340 let entries = walked.entries;
1341 let default_index = walked.default_index.clone();
1342 let dir_str_owned = dir_str.to_string();
1343 let default_index_for_msg = default_index.clone();
1344 tokio::spawn(async move {
1345 let opts = bee::api::CollectionUploadOptions {
1346 index_document: default_index,
1347 ..Default::default()
1348 };
1349 let started = Instant::now();
1350 let result = api
1351 .bee()
1352 .file()
1353 .upload_collection_entries(&batch_id, &entries, Some(&opts))
1354 .await;
1355 let elapsed_ms = started.elapsed().as_millis();
1356 let status = match result {
1357 Ok(res) => {
1358 let idx = default_index_for_msg
1359 .as_deref()
1360 .map(|i| format!(" · index={i}"))
1361 .unwrap_or_default();
1362 CommandStatus::Info(format!(
1363 "upload-collection OK in {elapsed_ms}ms — {entry_count} files, {total_bytes}B → ref {} (batch {task_short}){idx}",
1364 res.reference.to_hex(),
1365 ))
1366 }
1367 Err(e) => CommandStatus::Err(format!(
1368 "upload-collection FAILED after {elapsed_ms}ms — {dir_str_owned} → batch {task_short}: {e}"
1369 )),
1370 };
1371 let _ = tx.send(status);
1372 });
1373
1374 let idx_note = walked
1375 .default_index
1376 .as_deref()
1377 .map(|i| format!(" · default index={i}"))
1378 .unwrap_or_default();
1379 CommandStatus::Info(format!(
1380 "upload-collection {entry_count} files ({total_bytes}B){idx_note} to batch {batch_short} in flight — result will replace this line"
1381 ))
1382 }
1383
1384 fn run_feed_probe(&self, line: &str) -> CommandStatus {
1390 let parts: Vec<&str> = line.split_whitespace().collect();
1391 let (owner_str, topic_str) = match parts.as_slice() {
1392 [_, o, t, ..] => (*o, *t),
1393 _ => {
1394 return CommandStatus::Err(
1395 "usage: :feed-probe <owner> <topic> (topic = 64-hex or arbitrary string)"
1396 .into(),
1397 );
1398 }
1399 };
1400 let parsed = match crate::feed_probe::parse_args(owner_str, topic_str) {
1401 Ok(p) => p,
1402 Err(e) => return CommandStatus::Err(e),
1403 };
1404 let owner_short = short_hex(&parsed.owner.to_hex(), 8);
1405 let api = self.api.clone();
1406 let tx = self.cmd_status_tx.clone();
1407 tokio::spawn(async move {
1408 let started = Instant::now();
1409 let status = match crate::feed_probe::probe(api, parsed).await {
1410 Ok(r) => CommandStatus::Info(format!(
1411 "{} ({}ms)",
1412 r.summary(),
1413 started.elapsed().as_millis()
1414 )),
1415 Err(e) => CommandStatus::Err(format!("feed-probe failed: {e}")),
1416 };
1417 let _ = tx.send(status);
1418 });
1419 CommandStatus::Info(format!(
1420 "feed-probe owner={owner_short} in flight — result will replace this line (first lookup can take 30-60s)"
1421 ))
1422 }
1423
1424 fn run_hash(&self, line: &str) -> CommandStatus {
1429 let parts: Vec<&str> = line.split_whitespace().collect();
1430 let path = match parts.as_slice() {
1431 [_, p, ..] => *p,
1432 _ => {
1433 return CommandStatus::Err(
1434 "usage: :hash <path> (file or directory; computed locally)".into(),
1435 );
1436 }
1437 };
1438 match utility_verbs::hash_path(path) {
1439 Ok(r) => CommandStatus::Info(format!("hash {path}: {r}")),
1440 Err(e) => CommandStatus::Err(format!("hash failed: {e}")),
1441 }
1442 }
1443
1444 fn run_cid(&self, line: &str) -> CommandStatus {
1448 let parts: Vec<&str> = line.split_whitespace().collect();
1449 let (ref_hex, kind_arg) = match parts.as_slice() {
1450 [_, r, k, ..] => (*r, Some(*k)),
1451 [_, r] => (*r, None),
1452 _ => {
1453 return CommandStatus::Err(
1454 "usage: :cid <ref> [manifest|feed] (default manifest)".into(),
1455 );
1456 }
1457 };
1458 let kind = match utility_verbs::parse_cid_kind(kind_arg) {
1459 Ok(k) => k,
1460 Err(e) => return CommandStatus::Err(e),
1461 };
1462 match utility_verbs::cid_for_ref(ref_hex, kind) {
1463 Ok(cid) => CommandStatus::Info(format!("cid: {cid}")),
1464 Err(e) => CommandStatus::Err(format!("cid failed: {e}")),
1465 }
1466 }
1467
1468 fn run_depth_table(&self) -> CommandStatus {
1473 let body = utility_verbs::depth_table();
1474 let path = std::env::temp_dir().join("bee-tui-depth-table.txt");
1475 match std::fs::write(&path, &body) {
1476 Ok(()) => CommandStatus::Info(format!("depth table → {}", path.display())),
1477 Err(e) => CommandStatus::Err(format!("depth-table write failed: {e}")),
1478 }
1479 }
1480
1481 fn run_gsoc_mine(&self, line: &str) -> CommandStatus {
1486 let parts: Vec<&str> = line.split_whitespace().collect();
1487 let (overlay, ident) = match parts.as_slice() {
1488 [_, o, i, ..] => (*o, *i),
1489 _ => {
1490 return CommandStatus::Err(
1491 "usage: :gsoc-mine <overlay-hex> <identifier> (CPU work, no network)".into(),
1492 );
1493 }
1494 };
1495 match utility_verbs::gsoc_mine_for(overlay, ident) {
1496 Ok(out) => CommandStatus::Info(out.replace('\n', " · ")),
1497 Err(e) => CommandStatus::Err(format!("gsoc-mine failed: {e}")),
1498 }
1499 }
1500
1501 fn run_manifest(&mut self, line: &str) -> CommandStatus {
1505 let parts: Vec<&str> = line.split_whitespace().collect();
1506 let ref_arg = match parts.as_slice() {
1507 [_, r, ..] => *r,
1508 _ => {
1509 return CommandStatus::Err(
1510 "usage: :manifest <ref> (32-byte hex reference)".into(),
1511 );
1512 }
1513 };
1514 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1515 Ok(r) => r,
1516 Err(e) => return CommandStatus::Err(format!("manifest: bad ref: {e}")),
1517 };
1518 let idx = match SCREEN_NAMES.iter().position(|n| *n == "Manifest") {
1521 Some(i) => i,
1522 None => {
1523 return CommandStatus::Err("internal: Manifest screen not registered".into());
1524 }
1525 };
1526 let screen = self
1527 .screens
1528 .get_mut(idx)
1529 .and_then(|s| s.as_any_mut())
1530 .and_then(|a| a.downcast_mut::<Manifest>());
1531 let Some(manifest) = screen else {
1532 return CommandStatus::Err("internal: failed to access Manifest screen".into());
1533 };
1534 manifest.load(reference);
1535 self.current_screen = idx;
1536 CommandStatus::Info(format!("loading manifest {}", short_hex(ref_arg, 8)))
1537 }
1538
1539 fn run_inspect(&self, line: &str) -> CommandStatus {
1546 let parts: Vec<&str> = line.split_whitespace().collect();
1547 let ref_arg = match parts.as_slice() {
1548 [_, r, ..] => *r,
1549 _ => {
1550 return CommandStatus::Err("usage: :inspect <ref> (32-byte hex reference)".into());
1551 }
1552 };
1553 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1554 Ok(r) => r,
1555 Err(e) => return CommandStatus::Err(format!("inspect: bad ref: {e}")),
1556 };
1557 let api = self.api.clone();
1558 let tx = self.cmd_status_tx.clone();
1559 let label = short_hex(ref_arg, 8);
1560 let label_for_task = label.clone();
1561 tokio::spawn(async move {
1562 let result = manifest_walker::inspect(api, reference).await;
1563 let status = match result {
1564 InspectResult::Manifest { node, bytes_len } => CommandStatus::Info(format!(
1565 "inspect {label_for_task}: manifest · {bytes_len} bytes · {} forks (jump to :manifest {label_for_task})",
1566 node.forks.len(),
1567 )),
1568 InspectResult::RawChunk { bytes_len } => CommandStatus::Info(format!(
1569 "inspect {label_for_task}: raw chunk · {bytes_len} bytes · not a manifest"
1570 )),
1571 InspectResult::Error(e) => {
1572 CommandStatus::Err(format!("inspect {label_for_task} failed: {e}"))
1573 }
1574 };
1575 let _ = tx.send(status);
1576 });
1577 CommandStatus::Info(format!(
1578 "inspecting {label} — result will replace this line"
1579 ))
1580 }
1581
1582 fn run_durability_check(&mut self, line: &str) -> CommandStatus {
1592 let parts: Vec<&str> = line.split_whitespace().collect();
1593 let ref_arg = match parts.as_slice() {
1594 [_, r, ..] => *r,
1595 _ => {
1596 return CommandStatus::Err(
1597 "usage: :durability-check <ref> (32-byte hex reference)".into(),
1598 );
1599 }
1600 };
1601 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1602 Ok(r) => r,
1603 Err(e) => {
1604 return CommandStatus::Err(format!("durability-check: bad ref: {e}"));
1605 }
1606 };
1607 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Watchlist") {
1610 self.current_screen = idx;
1611 }
1612 let api = self.api.clone();
1613 let tx = self.cmd_status_tx.clone();
1614 let watchlist_tx = self.durability_tx.clone();
1615 let label = short_hex(ref_arg, 8);
1616 let label_for_task = label.clone();
1617 tokio::spawn(async move {
1618 let result = durability::check(api, reference).await;
1619 let summary = result.summary();
1620 let _ = watchlist_tx.send(result);
1621 let _ = tx.send(if summary.contains("UNHEALTHY") {
1622 CommandStatus::Err(summary)
1623 } else {
1624 CommandStatus::Info(summary)
1625 });
1626 });
1627 CommandStatus::Info(format!(
1628 "durability-check {label_for_task} in flight — see S13 Watchlist for the running history"
1629 ))
1630 }
1631
1632 fn run_pss_target(&self, line: &str) -> CommandStatus {
1637 let parts: Vec<&str> = line.split_whitespace().collect();
1638 let overlay = match parts.as_slice() {
1639 [_, o, ..] => *o,
1640 _ => {
1641 return CommandStatus::Err(
1642 "usage: :pss-target <overlay-hex> (returns first 4 hex chars)".into(),
1643 );
1644 }
1645 };
1646 match utility_verbs::pss_target_for(overlay) {
1647 Ok(prefix) => CommandStatus::Info(format!("pss target prefix: {prefix}")),
1648 Err(e) => CommandStatus::Err(format!("pss-target failed: {e}")),
1649 }
1650 }
1651
1652 fn run_price(&self) -> CommandStatus {
1658 let tx = self.cmd_status_tx.clone();
1659 tokio::spawn(async move {
1660 let status = match economics_oracle::fetch_xbzz_price().await {
1661 Ok(p) => CommandStatus::Info(p.summary()),
1662 Err(e) => CommandStatus::Err(format!("price: {e}")),
1663 };
1664 let _ = tx.send(status);
1665 });
1666 CommandStatus::Info("price: querying tokenservice.ethswarm.org…".into())
1667 }
1668
1669 fn run_basefee(&self) -> CommandStatus {
1673 let url = match self.config.economics.gnosis_rpc_url.clone() {
1674 Some(u) => u,
1675 None => {
1676 return CommandStatus::Err(
1677 "basefee: set [economics].gnosis_rpc_url in config.toml (typically the same URL as Bee's --blockchain-rpc-endpoint)"
1678 .into(),
1679 );
1680 }
1681 };
1682 let tx = self.cmd_status_tx.clone();
1683 tokio::spawn(async move {
1684 let status = match economics_oracle::fetch_gnosis_gas(&url).await {
1685 Ok(g) => CommandStatus::Info(g.summary()),
1686 Err(e) => CommandStatus::Err(format!("basefee: {e}")),
1687 };
1688 let _ = tx.send(status);
1689 });
1690 CommandStatus::Info("basefee: querying gnosis RPC…".into())
1691 }
1692
1693 fn run_config_doctor(&self) -> CommandStatus {
1699 let path = match self.config.bee.as_ref().map(|b| b.config.clone()) {
1700 Some(p) => p,
1701 None => {
1702 return CommandStatus::Err(
1703 "config-doctor: no [bee].config in config.toml (or pass --bee-config) — point bee-tui at the bee.yaml you want audited"
1704 .into(),
1705 );
1706 }
1707 };
1708 let report = match config_doctor::audit(&path) {
1709 Ok(r) => r,
1710 Err(e) => return CommandStatus::Err(format!("config-doctor: {e}")),
1711 };
1712 let secs = SystemTime::now()
1713 .duration_since(UNIX_EPOCH)
1714 .map(|d| d.as_secs())
1715 .unwrap_or(0);
1716 let out_path = std::env::temp_dir().join(format!("bee-tui-config-doctor-{secs}.txt"));
1717 if let Err(e) = std::fs::write(&out_path, report.render()) {
1718 return CommandStatus::Err(format!("config-doctor write {}: {e}", out_path.display()));
1719 }
1720 CommandStatus::Info(format!("{} → {}", report.summary(), out_path.display()))
1721 }
1722
1723 fn run_check_version(&self) -> CommandStatus {
1731 let api = self.api.clone();
1732 let tx = self.cmd_status_tx.clone();
1733 tokio::spawn(async move {
1734 let running = api.bee().debug().health().await.ok().map(|h| h.version);
1735 let status = match version_check::check_latest(running).await {
1736 Ok(v) => CommandStatus::Info(v.summary()),
1737 Err(e) => CommandStatus::Err(format!("check-version failed: {e}")),
1738 };
1739 let _ = tx.send(status);
1740 });
1741 CommandStatus::Info("check-version: querying github.com/ethersphere/bee…".into())
1742 }
1743
1744 fn run_plan_batch(&self, line: &str) -> CommandStatus {
1750 let parts: Vec<&str> = line.split_whitespace().collect();
1751 let prefix = match parts.as_slice() {
1752 [_, prefix, ..] => *prefix,
1753 _ => {
1754 return CommandStatus::Err(
1755 "usage: :plan-batch <batch-prefix> [usage-thr] [ttl-thr] [extra-depth]".into(),
1756 );
1757 }
1758 };
1759 let usage_thr = match parts.get(2) {
1760 Some(s) => match s.parse::<f64>() {
1761 Ok(v) => v,
1762 Err(_) => {
1763 return CommandStatus::Err(format!(
1764 "invalid usage-thr {s:?} (expected float in [0,1], default 0.85)"
1765 ));
1766 }
1767 },
1768 None => stamp_preview::DEFAULT_USAGE_THRESHOLD,
1769 };
1770 let ttl_thr = match parts.get(3) {
1771 Some(s) => match stamp_preview::parse_duration_seconds(s) {
1772 Ok(v) => v,
1773 Err(e) => return CommandStatus::Err(format!("ttl-thr: {e}")),
1774 },
1775 None => stamp_preview::DEFAULT_TTL_THRESHOLD_SECONDS,
1776 };
1777 let extra_depth = match parts.get(4) {
1778 Some(s) => match s.parse::<u8>() {
1779 Ok(v) => v,
1780 Err(_) => {
1781 return CommandStatus::Err(format!(
1782 "invalid extra-depth {s:?} (expected u8, default 2)"
1783 ));
1784 }
1785 },
1786 None => stamp_preview::DEFAULT_EXTRA_DEPTH,
1787 };
1788 let chain = match self.health_rx.borrow().chain_state.clone() {
1789 Some(c) => c,
1790 None => return CommandStatus::Err("chain state not loaded yet".into()),
1791 };
1792 let stamps = self.watch.stamps().borrow().clone();
1793 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1794 Ok(b) => b.clone(),
1795 Err(e) => return CommandStatus::Err(e),
1796 };
1797 match stamp_preview::plan_batch(&batch, &chain, usage_thr, ttl_thr, extra_depth) {
1798 Ok(p) => CommandStatus::Info(p.summary()),
1799 Err(e) => CommandStatus::Err(e),
1800 }
1801 }
1802
1803 fn run_buy_suggest(&self, line: &str) -> CommandStatus {
1809 let parts: Vec<&str> = line.split_whitespace().collect();
1810 let (size_str, duration_str) = match parts.as_slice() {
1811 [_, size, duration, ..] => (*size, *duration),
1812 _ => {
1813 return CommandStatus::Err(
1814 "usage: :buy-suggest <size> <duration> (e.g. 5GiB 30d, 100MiB 12h)".into(),
1815 );
1816 }
1817 };
1818 let target_bytes = match stamp_preview::parse_size_bytes(size_str) {
1819 Ok(b) => b,
1820 Err(e) => return CommandStatus::Err(e),
1821 };
1822 let target_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
1823 Ok(s) => s,
1824 Err(e) => return CommandStatus::Err(e),
1825 };
1826 let chain = match self.health_rx.borrow().chain_state.clone() {
1827 Some(c) => c,
1828 None => return CommandStatus::Err("chain state not loaded yet".into()),
1829 };
1830 match stamp_preview::buy_suggest(target_bytes, target_seconds, &chain) {
1831 Ok(s) => CommandStatus::Info(s.summary()),
1832 Err(e) => CommandStatus::Err(e),
1833 }
1834 }
1835
1836 fn run_buy_preview(&self, line: &str) -> CommandStatus {
1839 let parts: Vec<&str> = line.split_whitespace().collect();
1840 let (depth_str, amount_str) = match parts.as_slice() {
1841 [_, depth, amount, ..] => (*depth, *amount),
1842 _ => {
1843 return CommandStatus::Err(
1844 "usage: :buy-preview <depth> <amount-plur-per-chunk>".into(),
1845 );
1846 }
1847 };
1848 let depth: u8 = match depth_str.parse() {
1849 Ok(d) => d,
1850 Err(_) => {
1851 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
1852 }
1853 };
1854 let amount = match stamp_preview::parse_plur_amount(amount_str) {
1855 Ok(a) => a,
1856 Err(e) => return CommandStatus::Err(e),
1857 };
1858 let chain = match self.health_rx.borrow().chain_state.clone() {
1859 Some(c) => c,
1860 None => return CommandStatus::Err("chain state not loaded yet".into()),
1861 };
1862 match stamp_preview::buy_preview(depth, amount, &chain) {
1863 Ok(p) => CommandStatus::Info(p.summary()),
1864 Err(e) => CommandStatus::Err(e),
1865 }
1866 }
1867
1868 fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
1875 let node = self
1876 .config
1877 .nodes
1878 .iter()
1879 .find(|n| n.name == target)
1880 .ok_or_else(|| eyre!("no node configured with name {target:?}"))?
1881 .clone();
1882 let new_api = Arc::new(ApiClient::from_node(&node)?);
1883 self.watch.shutdown();
1887 let refresh = RefreshProfile::from_config(&self.config.ui.refresh);
1888 let new_watch = BeeWatch::start_with_profile(new_api.clone(), &self.root_cancel, refresh);
1889 let new_health_rx = new_watch.health();
1890 let new_market_rx = if self.config.economics.enable_market_tile {
1895 Some(economics_oracle::spawn_poller(
1896 self.config.economics.gnosis_rpc_url.clone(),
1897 self.root_cancel.child_token(),
1898 ))
1899 } else {
1900 None
1901 };
1902 let new_screens = build_screens(&new_api, &new_watch, new_market_rx);
1903 self.api = new_api;
1904 self.watch = new_watch;
1905 self.health_rx = new_health_rx;
1906 self.screens = new_screens;
1907 Ok(())
1910 }
1911
1912 fn start_pins_check(&self) -> std::io::Result<PathBuf> {
1929 let secs = SystemTime::now()
1930 .duration_since(UNIX_EPOCH)
1931 .map(|d| d.as_secs())
1932 .unwrap_or(0);
1933 let path = std::env::temp_dir().join(format!(
1934 "bee-tui-pins-check-{}-{secs}.txt",
1935 sanitize_for_filename(&self.api.name),
1936 ));
1937 std::fs::write(
1940 &path,
1941 format!(
1942 "# bee-tui :pins-check\n# profile {}\n# endpoint {}\n# started {}\n",
1943 self.api.name,
1944 self.api.url,
1945 format_utc_now(),
1946 ),
1947 )?;
1948
1949 let api = self.api.clone();
1950 let dest = path.clone();
1951 tokio::spawn(async move {
1952 let bee = api.bee();
1953 match bee.api().check_pins(None).await {
1954 Ok(entries) => {
1955 let mut body = String::new();
1956 for e in &entries {
1957 body.push_str(&format!(
1958 "{} total={} missing={} invalid={} {}\n",
1959 e.reference.to_hex(),
1960 e.total,
1961 e.missing,
1962 e.invalid,
1963 if e.is_healthy() {
1964 "healthy"
1965 } else {
1966 "UNHEALTHY"
1967 },
1968 ));
1969 }
1970 body.push_str(&format!("# done. {} pins checked.\n", entries.len()));
1971 if let Err(e) = append(&dest, &body) {
1972 let _ = append(&dest, &format!("# write error: {e}\n"));
1973 }
1974 }
1975 Err(e) => {
1976 let _ = append(&dest, &format!("# error: {e}\n"));
1977 }
1978 }
1979 });
1980 Ok(path)
1981 }
1982
1983 fn start_set_logger(&self, expression: String, level: String) {
1994 let secs = SystemTime::now()
1995 .duration_since(UNIX_EPOCH)
1996 .map(|d| d.as_secs())
1997 .unwrap_or(0);
1998 let dest = std::env::temp_dir().join(format!(
1999 "bee-tui-set-logger-{}-{secs}.txt",
2000 sanitize_for_filename(&self.api.name),
2001 ));
2002 let _ = std::fs::write(
2003 &dest,
2004 format!(
2005 "# bee-tui :set-logger\n# profile {}\n# endpoint {}\n# expr {expression}\n# level {level}\n# started {}\n",
2006 self.api.name,
2007 self.api.url,
2008 format_utc_now(),
2009 ),
2010 );
2011
2012 let api = self.api.clone();
2013 tokio::spawn(async move {
2014 let bee = api.bee();
2015 match bee.debug().set_logger(&expression, &level).await {
2016 Ok(()) => {
2017 let _ = append(
2018 &dest,
2019 &format!("# done. {expression} → {level} accepted by Bee.\n"),
2020 );
2021 }
2022 Err(e) => {
2023 let _ = append(&dest, &format!("# error: {e}\n"));
2024 }
2025 }
2026 });
2027 }
2028
2029 fn start_loggers_dump(&self) -> std::io::Result<PathBuf> {
2034 let secs = SystemTime::now()
2035 .duration_since(UNIX_EPOCH)
2036 .map(|d| d.as_secs())
2037 .unwrap_or(0);
2038 let path = std::env::temp_dir().join(format!(
2039 "bee-tui-loggers-{}-{secs}.txt",
2040 sanitize_for_filename(&self.api.name),
2041 ));
2042 std::fs::write(
2043 &path,
2044 format!(
2045 "# bee-tui :loggers\n# profile {}\n# endpoint {}\n# started {}\n",
2046 self.api.name,
2047 self.api.url,
2048 format_utc_now(),
2049 ),
2050 )?;
2051
2052 let api = self.api.clone();
2053 let dest = path.clone();
2054 tokio::spawn(async move {
2055 let bee = api.bee();
2056 match bee.debug().loggers().await {
2057 Ok(listing) => {
2058 let mut rows = listing.loggers.clone();
2059 rows.sort_by(|a, b| {
2063 verbosity_rank(&b.verbosity)
2064 .cmp(&verbosity_rank(&a.verbosity))
2065 .then_with(|| a.logger.cmp(&b.logger))
2066 });
2067 let mut body = String::new();
2068 body.push_str(&format!("# {} loggers registered\n", rows.len()));
2069 body.push_str("# VERBOSITY LOGGER\n");
2070 for r in &rows {
2071 body.push_str(&format!(" {:<9} {}\n", r.verbosity, r.logger,));
2072 }
2073 body.push_str("# done.\n");
2074 if let Err(e) = append(&dest, &body) {
2075 let _ = append(&dest, &format!("# write error: {e}\n"));
2076 }
2077 }
2078 Err(e) => {
2079 let _ = append(&dest, &format!("# error: {e}\n"));
2080 }
2081 }
2082 });
2083 Ok(path)
2084 }
2085
2086 fn start_diagnose_with_pprof(&self, seconds: u32) -> CommandStatus {
2098 let secs_unix = SystemTime::now()
2099 .duration_since(UNIX_EPOCH)
2100 .map(|d| d.as_secs())
2101 .unwrap_or(0);
2102 let dir = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs_unix}"));
2103 if let Err(e) = std::fs::create_dir_all(&dir) {
2104 return CommandStatus::Err(format!("diagnose --pprof: mkdir failed: {e}"));
2105 }
2106 let bundle_text = self.render_diagnostic_bundle();
2107 if let Err(e) = std::fs::write(dir.join("bundle.txt"), &bundle_text) {
2108 return CommandStatus::Err(format!("diagnose --pprof: write bundle.txt: {e}"));
2109 }
2110 let auth_token = self
2115 .config
2116 .nodes
2117 .iter()
2118 .find(|n| n.name == self.api.name)
2119 .and_then(|n| n.resolved_token());
2120 let base_url = self.api.url.clone();
2121 let dir_for_task = dir.clone();
2122 let tx = self.cmd_status_tx.clone();
2123 tokio::spawn(async move {
2124 let r =
2125 pprof_bundle::fetch_and_write(&base_url, auth_token, seconds, dir_for_task).await;
2126 let status = match r {
2127 Ok(b) => CommandStatus::Info(b.summary()),
2128 Err(e) => CommandStatus::Err(format!("diagnose --pprof failed: {e}")),
2129 };
2130 let _ = tx.send(status);
2131 });
2132 CommandStatus::Info(format!(
2133 "diagnose --pprof={seconds}s in flight (bundle.txt already at {}; profile + trace will join when sampling completes)",
2134 dir.display()
2135 ))
2136 }
2137
2138 fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
2139 let bundle = self.render_diagnostic_bundle();
2140 let secs = SystemTime::now()
2141 .duration_since(UNIX_EPOCH)
2142 .map(|d| d.as_secs())
2143 .unwrap_or(0);
2144 let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
2145 std::fs::write(&path, bundle)?;
2146 Ok(path)
2147 }
2148
2149 fn render_diagnostic_bundle(&self) -> String {
2150 let now = format_utc_now();
2151 let health = self.health_rx.borrow().clone();
2152 let topology = self.watch.topology().borrow().clone();
2153 let stamps = self.watch.stamps().borrow().clone();
2154 let gates = Health::gates_for_with_stamps(&health, Some(&topology), Some(&stamps));
2155 let recent: Vec<_> = log_capture::handle()
2156 .map(|c| {
2157 let mut snap = c.snapshot();
2158 let len = snap.len();
2159 if len > 50 {
2160 snap.drain(0..len - 50);
2161 }
2162 snap
2163 })
2164 .unwrap_or_default();
2165
2166 let mut out = String::new();
2167 out.push_str("# bee-tui diagnostic bundle\n");
2168 out.push_str(&format!("# generated UTC {now}\n\n"));
2169 out.push_str("## profile\n");
2170 out.push_str(&format!(" name {}\n", self.api.name));
2171 out.push_str(&format!(" endpoint {}\n\n", self.api.url));
2172 out.push_str("## health gates\n");
2173 for g in &gates {
2174 out.push_str(&format_gate_line(g));
2175 }
2176 out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
2177 for e in &recent {
2178 let status = e
2179 .status
2180 .map(|s| s.to_string())
2181 .unwrap_or_else(|| "—".into());
2182 let elapsed = e
2183 .elapsed_ms
2184 .map(|ms| format!("{ms}ms"))
2185 .unwrap_or_else(|| "—".into());
2186 out.push_str(&format!(
2187 " {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
2188 ts = e.ts,
2189 method = e.method,
2190 path = path_only(&e.url),
2191 status = status,
2192 elapsed = elapsed,
2193 ));
2194 }
2195 out.push_str(&format!(
2196 "\n## generated by bee-tui {}\n",
2197 env!("CARGO_PKG_VERSION"),
2198 ));
2199 out
2200 }
2201
2202 fn tick_alerts(&mut self) {
2209 let url = match self.config.alerts.webhook_url.as_deref() {
2210 Some(u) if !u.is_empty() => u.to_string(),
2211 _ => return,
2212 };
2213 let health = self.health_rx.borrow().clone();
2214 let topology = self.watch.topology().borrow().clone();
2215 let stamps = self.watch.stamps().borrow().clone();
2216 let gates = Health::gates_for_with_stamps(&health, Some(&topology), Some(&stamps));
2217 let alerts = self.alert_state.diff_and_record(&gates);
2218 for alert in alerts {
2219 let url = url.clone();
2220 tokio::spawn(async move {
2221 if let Err(e) = crate::alerts::fire(&url, &alert).await {
2222 tracing::warn!(target: "bee_tui::alerts", "webhook fire failed: {e}");
2223 }
2224 });
2225 }
2226 }
2227
2228 fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
2229 while let Ok(action) = self.action_rx.try_recv() {
2230 if action != Action::Tick && action != Action::Render {
2231 debug!("{action:?}");
2232 }
2233 match action {
2234 Action::Tick => {
2235 self.last_tick_key_events.drain(..);
2236 theme::advance_spinner();
2240 if let Some(sup) = self.supervisor.as_mut() {
2244 self.bee_status = sup.status();
2245 }
2246 if let Some(rx) = self.bee_log_rx.as_mut() {
2251 while let Ok((tab, line)) = rx.try_recv() {
2252 self.log_pane.push_bee(tab, line);
2253 }
2254 }
2255 while let Ok(status) = self.cmd_status_rx.try_recv() {
2260 self.command_status = Some(status);
2261 }
2262 while let Ok(result) = self.durability_rx.try_recv() {
2267 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Watchlist") {
2268 if let Some(wl) = self
2269 .screens
2270 .get_mut(idx)
2271 .and_then(|s| s.as_any_mut())
2272 .and_then(|a| a.downcast_mut::<Watchlist>())
2273 {
2274 wl.record(result);
2275 }
2276 }
2277 }
2278 self.tick_alerts();
2282 }
2283 Action::Quit => self.should_quit = true,
2284 Action::Suspend => self.should_suspend = true,
2285 Action::Resume => self.should_suspend = false,
2286 Action::ClearScreen => tui.terminal.clear()?,
2287 Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
2288 Action::Render => self.render(tui)?,
2289 _ => {}
2290 }
2291 let tx = self.action_tx.clone();
2292 for component in self.iter_components_mut() {
2293 if let Some(action) = component.update(action.clone())? {
2294 tx.send(action)?
2295 };
2296 }
2297 }
2298 Ok(())
2299 }
2300
2301 fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
2302 tui.resize(Rect::new(0, 0, w, h))?;
2303 self.render(tui)?;
2304 Ok(())
2305 }
2306
2307 fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
2308 let active = self.current_screen;
2309 let tx = self.action_tx.clone();
2310 let screens = &mut self.screens;
2311 let log_pane = &mut self.log_pane;
2312 let log_pane_height = log_pane.height();
2313 let command_buffer = self.command_buffer.clone();
2314 let command_suggestion_index = self.command_suggestion_index;
2315 let command_status = self.command_status.clone();
2316 let help_visible = self.help_visible;
2317 let profile = self.api.name.clone();
2318 let endpoint = self.api.url.clone();
2319 let last_ping = self.health_rx.borrow().last_ping;
2320 let now_utc = format_utc_now();
2321 let bee_status_label = if self.supervisor.is_some() && !self.bee_status.is_running() {
2322 Some(self.bee_status.label())
2326 } else {
2327 None
2328 };
2329 tui.draw(|frame| {
2330 use ratatui::layout::{Constraint, Layout};
2331 use ratatui::style::{Color, Modifier, Style};
2332 use ratatui::text::{Line, Span};
2333 use ratatui::widgets::Paragraph;
2334
2335 let chunks = Layout::vertical([
2336 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), Constraint::Length(log_pane_height), ])
2341 .split(frame.area());
2342
2343 let top_chunks =
2344 Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(chunks[0]);
2345
2346 let ping_str = match last_ping {
2348 Some(d) => format!("{}ms", d.as_millis()),
2349 None => "—".into(),
2350 };
2351 let t = theme::active();
2352 let mut metadata_spans = vec![
2353 Span::styled(
2354 " bee-tui ",
2355 Style::default()
2356 .fg(Color::Black)
2357 .bg(t.info)
2358 .add_modifier(Modifier::BOLD),
2359 ),
2360 Span::raw(" "),
2361 Span::styled(
2362 profile,
2363 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
2364 ),
2365 Span::styled(format!(" @ {endpoint}"), Style::default().fg(t.dim)),
2366 Span::raw(" "),
2367 Span::styled("ping ", Style::default().fg(t.dim)),
2368 Span::styled(ping_str, Style::default().fg(t.info)),
2369 Span::raw(" "),
2370 Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
2371 ];
2372 if let Some(label) = bee_status_label.as_ref() {
2376 metadata_spans.push(Span::raw(" "));
2377 metadata_spans.push(Span::styled(
2378 format!(" {label} "),
2379 Style::default()
2380 .fg(Color::Black)
2381 .bg(t.fail)
2382 .add_modifier(Modifier::BOLD),
2383 ));
2384 }
2385 let metadata_line = Line::from(metadata_spans);
2386 frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
2387
2388 let theme = *theme::active();
2390 let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
2391 for (i, name) in SCREEN_NAMES.iter().enumerate() {
2392 let style = if i == active {
2393 Style::default()
2394 .fg(theme.tab_active_fg)
2395 .bg(theme.tab_active_bg)
2396 .add_modifier(Modifier::BOLD)
2397 } else {
2398 Style::default().fg(theme.dim)
2399 };
2400 tabs.push(Span::styled(format!(" {name} "), style));
2401 tabs.push(Span::raw(" "));
2402 }
2403 tabs.push(Span::styled(
2404 ":cmd · Tab to cycle · ? help",
2405 Style::default().fg(theme.dim),
2406 ));
2407 frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
2408
2409 if let Some(screen) = screens.get_mut(active) {
2411 if let Err(err) = screen.draw(frame, chunks[1]) {
2412 let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
2413 }
2414 }
2415 let prompt = if let Some(buf) = &command_buffer {
2417 Line::from(vec![
2418 Span::styled(
2419 ":",
2420 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
2421 ),
2422 Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
2423 Span::styled("█", Style::default().fg(t.accent)),
2424 ])
2425 } else {
2426 match &command_status {
2427 Some(CommandStatus::Info(msg)) => {
2428 Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
2429 }
2430 Some(CommandStatus::Err(msg)) => {
2431 Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
2432 }
2433 None => Line::from(""),
2434 }
2435 };
2436 frame.render_widget(Paragraph::new(prompt), chunks[2]);
2437
2438 if let Some(buf) = &command_buffer {
2444 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
2445 if !matches.is_empty() {
2446 draw_command_suggestions(
2447 frame,
2448 chunks[2],
2449 &matches,
2450 command_suggestion_index,
2451 &theme,
2452 );
2453 }
2454 }
2455
2456 if let Err(err) = log_pane.draw(frame, chunks[3]) {
2458 let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
2459 }
2460
2461 if help_visible {
2466 draw_help_overlay(frame, frame.area(), active, &theme);
2467 }
2468 })?;
2469 Ok(())
2470 }
2471}
2472
2473fn draw_command_suggestions(
2480 frame: &mut ratatui::Frame,
2481 bar_rect: ratatui::layout::Rect,
2482 matches: &[&(&str, &str)],
2483 selected: usize,
2484 theme: &theme::Theme,
2485) {
2486 use ratatui::layout::Rect;
2487 use ratatui::style::{Modifier, Style};
2488 use ratatui::text::{Line, Span};
2489 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2490
2491 const MAX_VISIBLE: usize = 10;
2492 let visible_rows = matches.len().min(MAX_VISIBLE);
2493 if visible_rows == 0 {
2494 return;
2495 }
2496 let height = (visible_rows as u16) + 2; let widest = matches
2501 .iter()
2502 .map(|(name, desc)| name.len() + desc.len() + 6)
2503 .max()
2504 .unwrap_or(40)
2505 .min(bar_rect.width as usize);
2506 let width = (widest as u16 + 2).min(bar_rect.width);
2507 let bottom = bar_rect.y;
2510 let y = bottom.saturating_sub(height);
2511 let popup = Rect {
2512 x: bar_rect.x,
2513 y,
2514 width,
2515 height: bottom - y,
2516 };
2517
2518 let scroll_start = if selected >= visible_rows {
2520 selected + 1 - visible_rows
2521 } else {
2522 0
2523 };
2524 let visible_slice = &matches[scroll_start..(scroll_start + visible_rows).min(matches.len())];
2525
2526 let mut lines: Vec<Line> = Vec::with_capacity(visible_slice.len());
2527 for (i, (name, desc)) in visible_slice.iter().enumerate() {
2528 let absolute_idx = scroll_start + i;
2529 let is_selected = absolute_idx == selected;
2530 let row_style = if is_selected {
2531 Style::default()
2532 .fg(theme.tab_active_fg)
2533 .bg(theme.tab_active_bg)
2534 .add_modifier(Modifier::BOLD)
2535 } else {
2536 Style::default()
2537 };
2538 let cursor = if is_selected { "▸ " } else { " " };
2539 lines.push(Line::from(vec![
2540 Span::styled(format!("{cursor}:{name:<16} "), row_style),
2541 Span::styled(
2542 desc.to_string(),
2543 if is_selected {
2544 row_style
2545 } else {
2546 Style::default().fg(theme.dim)
2547 },
2548 ),
2549 ]));
2550 }
2551
2552 let title = if matches.len() > MAX_VISIBLE {
2554 format!(" :commands ({}/{}) ", selected + 1, matches.len())
2555 } else {
2556 " :commands ".to_string()
2557 };
2558
2559 frame.render_widget(Clear, popup);
2560 frame.render_widget(
2561 Paragraph::new(lines).block(
2562 Block::default()
2563 .borders(Borders::ALL)
2564 .border_style(Style::default().fg(theme.accent))
2565 .title(title),
2566 ),
2567 popup,
2568 );
2569}
2570
2571fn draw_help_overlay(
2576 frame: &mut ratatui::Frame,
2577 area: ratatui::layout::Rect,
2578 active_screen: usize,
2579 theme: &theme::Theme,
2580) {
2581 use ratatui::layout::Rect;
2582 use ratatui::style::{Modifier, Style};
2583 use ratatui::text::{Line, Span};
2584 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2585
2586 let screen_name = SCREEN_NAMES.get(active_screen).copied().unwrap_or("?");
2587 let screen_rows = screen_keymap(active_screen);
2588 let global_rows: &[(&str, &str)] = &[
2589 ("Tab", "next screen"),
2590 ("Shift+Tab", "previous screen"),
2591 ("[ / ]", "previous / next log-pane tab"),
2592 ("+ / -", "grow / shrink log pane"),
2593 ("Shift+↑/↓", "scroll log pane (1 line); pauses auto-tail"),
2594 ("Shift+PgUp/PgDn", "scroll log pane (10 lines)"),
2595 ("Shift+←/→", "pan log pane horizontally (8 cols)"),
2596 ("Shift+End", "resume auto-tail + reset horizontal pan"),
2597 ("?", "toggle this help"),
2598 (":", "open command bar"),
2599 ("qq", "quit (double-tap; or :q)"),
2600 ("Ctrl+C / Ctrl+D", "quit immediately"),
2601 ];
2602
2603 let w = area.width.min(72);
2606 let h = area.height.min(22);
2607 let x = area.x + (area.width.saturating_sub(w)) / 2;
2608 let y = area.y + (area.height.saturating_sub(h)) / 2;
2609 let rect = Rect {
2610 x,
2611 y,
2612 width: w,
2613 height: h,
2614 };
2615
2616 let mut lines: Vec<Line> = Vec::new();
2617 lines.push(Line::from(vec![
2618 Span::styled(
2619 format!(" {screen_name} "),
2620 Style::default()
2621 .fg(theme.tab_active_fg)
2622 .bg(theme.tab_active_bg)
2623 .add_modifier(Modifier::BOLD),
2624 ),
2625 Span::raw(" screen-specific keys"),
2626 ]));
2627 lines.push(Line::from(""));
2628 if screen_rows.is_empty() {
2629 lines.push(Line::from(Span::styled(
2630 " (no extra keys for this screen — use the command bar via :)",
2631 Style::default()
2632 .fg(theme.dim)
2633 .add_modifier(Modifier::ITALIC),
2634 )));
2635 } else {
2636 for (key, desc) in screen_rows {
2637 lines.push(format_help_row(key, desc, theme));
2638 }
2639 }
2640 lines.push(Line::from(""));
2641 lines.push(Line::from(Span::styled(
2642 " global",
2643 Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
2644 )));
2645 for (key, desc) in global_rows {
2646 lines.push(format_help_row(key, desc, theme));
2647 }
2648 lines.push(Line::from(""));
2649 lines.push(Line::from(Span::styled(
2650 " Esc / ? / q to dismiss",
2651 Style::default()
2652 .fg(theme.dim)
2653 .add_modifier(Modifier::ITALIC),
2654 )));
2655
2656 frame.render_widget(Clear, rect);
2659 frame.render_widget(
2660 Paragraph::new(lines).block(
2661 Block::default()
2662 .borders(Borders::ALL)
2663 .border_style(Style::default().fg(theme.accent))
2664 .title(" help "),
2665 ),
2666 rect,
2667 );
2668}
2669
2670fn format_help_row<'a>(
2671 key: &'a str,
2672 desc: &'a str,
2673 theme: &theme::Theme,
2674) -> ratatui::text::Line<'a> {
2675 use ratatui::style::{Modifier, Style};
2676 use ratatui::text::{Line, Span};
2677 Line::from(vec![
2678 Span::raw(" "),
2679 Span::styled(
2680 format!("{key:<16}"),
2681 Style::default()
2682 .fg(theme.accent)
2683 .add_modifier(Modifier::BOLD),
2684 ),
2685 Span::raw(" "),
2686 Span::raw(desc),
2687 ])
2688}
2689
2690fn screen_keymap(active_screen: usize) -> &'static [(&'static str, &'static str)] {
2694 match active_screen {
2695 1 => &[
2697 ("↑↓ / j k", "move row selection"),
2698 ("Enter", "drill batch — bucket histogram + worst-N"),
2699 ("Esc", "close drill"),
2700 ],
2701 3 => &[("r", "run on-demand rchash benchmark")],
2703 4 => &[
2704 ("↑↓ / j k", "move peer selection"),
2705 (
2706 "Enter",
2707 "drill peer — balance / cheques / settlement / ping",
2708 ),
2709 ("Esc", "close drill"),
2710 ],
2711 8 => &[
2715 ("↑↓ / j k", "scroll one row"),
2716 ("PgUp / PgDn", "scroll ten rows"),
2717 ("Home", "back to top"),
2718 ],
2719 9 => &[
2721 ("↑↓ / j k", "move row selection"),
2722 ("Enter", "integrity-check the highlighted pin"),
2723 ("c", "integrity-check every unchecked pin"),
2724 ("s", "cycle sort: ref order / bad first / by size"),
2725 ],
2726 10 => &[
2728 ("↑↓ / j k", "move row selection"),
2729 ("Enter", "expand / collapse fork (loads child chunk)"),
2730 (":manifest <ref>", "open a manifest at a reference"),
2731 (":inspect <ref>", "what is this? auto-detects manifest"),
2732 ],
2733 11 => &[
2735 ("↑↓ / j k", "move row selection"),
2736 (":durability-check <ref>", "walk chunk graph + record"),
2737 ],
2738 _ => &[],
2739 }
2740}
2741
2742fn build_screens(
2751 api: &Arc<ApiClient>,
2752 watch: &BeeWatch,
2753 market_rx: Option<watch::Receiver<crate::economics_oracle::EconomicsSnapshot>>,
2754) -> Vec<Box<dyn Component>> {
2755 let health = Health::new(api.clone(), watch.health(), watch.topology());
2756 let stamps = Stamps::new(api.clone(), watch.stamps());
2757 let swap = match market_rx {
2758 Some(rx) => Swap::new(watch.swap()).with_market_feed(rx),
2759 None => Swap::new(watch.swap()),
2760 };
2761 let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
2762 let peers = Peers::new(api.clone(), watch.topology());
2763 let network = Network::new(watch.network(), watch.topology());
2764 let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
2765 let api_health = ApiHealth::new(
2766 api.clone(),
2767 watch.health(),
2768 watch.transactions(),
2769 log_capture::handle(),
2770 );
2771 let tags = Tags::new(watch.tags());
2772 let pins = Pins::new(api.clone(), watch.pins());
2773 let manifest = Manifest::new(api.clone());
2774 let watchlist = Watchlist::new();
2775 vec![
2776 Box::new(health),
2777 Box::new(stamps),
2778 Box::new(swap),
2779 Box::new(lottery),
2780 Box::new(peers),
2781 Box::new(network),
2782 Box::new(warmup),
2783 Box::new(api_health),
2784 Box::new(tags),
2785 Box::new(pins),
2786 Box::new(manifest),
2787 Box::new(watchlist),
2788 ]
2789}
2790
2791fn build_synthetic_probe_chunk() -> Vec<u8> {
2799 use std::time::{SystemTime, UNIX_EPOCH};
2800 let nanos = SystemTime::now()
2801 .duration_since(UNIX_EPOCH)
2802 .map(|d| d.as_nanos())
2803 .unwrap_or(0);
2804 let mut data = Vec::with_capacity(8 + 4096);
2805 data.extend_from_slice(&4096u64.to_le_bytes());
2807 data.extend_from_slice(&nanos.to_le_bytes());
2809 data.resize(8 + 4096, 0);
2810 data
2811}
2812
2813fn short_hex(hex: &str, len: usize) -> String {
2816 if hex.len() > len {
2817 format!("{}…", &hex[..len])
2818 } else {
2819 hex.to_string()
2820 }
2821}
2822
2823fn guess_content_type(path: &std::path::Path) -> String {
2829 let ext = path
2830 .extension()
2831 .and_then(|e| e.to_str())
2832 .map(|s| s.to_ascii_lowercase());
2833 match ext.as_deref() {
2834 Some("html") | Some("htm") => "text/html",
2835 Some("txt") | Some("md") => "text/plain",
2836 Some("json") => "application/json",
2837 Some("css") => "text/css",
2838 Some("js") => "application/javascript",
2839 Some("png") => "image/png",
2840 Some("jpg") | Some("jpeg") => "image/jpeg",
2841 Some("gif") => "image/gif",
2842 Some("svg") => "image/svg+xml",
2843 Some("webp") => "image/webp",
2844 Some("pdf") => "application/pdf",
2845 Some("zip") => "application/zip",
2846 Some("tar") => "application/x-tar",
2847 Some("gz") | Some("tgz") => "application/gzip",
2848 Some("wasm") => "application/wasm",
2849 _ => "",
2850 }
2851 .to_string()
2852}
2853
2854fn build_metrics_render_fn(
2860 watch: BeeWatch,
2861 log_capture: Option<log_capture::LogCapture>,
2862) -> crate::metrics_server::RenderFn {
2863 use std::time::{SystemTime, UNIX_EPOCH};
2864 Arc::new(move || {
2865 let health = watch.health().borrow().clone();
2866 let stamps = watch.stamps().borrow().clone();
2867 let swap = watch.swap().borrow().clone();
2868 let lottery = watch.lottery().borrow().clone();
2869 let topology = watch.topology().borrow().clone();
2870 let network = watch.network().borrow().clone();
2871 let transactions = watch.transactions().borrow().clone();
2872 let recent = log_capture
2873 .as_ref()
2874 .map(|c| c.snapshot())
2875 .unwrap_or_default();
2876 let call_stats = crate::components::api_health::call_stats_for(&recent);
2877 let now_unix = SystemTime::now()
2878 .duration_since(UNIX_EPOCH)
2879 .map(|d| d.as_secs() as i64)
2880 .unwrap_or(0);
2881 let inputs = crate::metrics::MetricsInputs {
2882 bee_tui_version: env!("CARGO_PKG_VERSION"),
2883 health: &health,
2884 stamps: &stamps,
2885 swap: &swap,
2886 lottery: &lottery,
2887 topology: &topology,
2888 network: &network,
2889 transactions: &transactions,
2890 call_stats: &call_stats,
2891 now_unix,
2892 };
2893 crate::metrics::render(&inputs)
2894 })
2895}
2896
2897fn format_gate_line(g: &Gate) -> String {
2898 let glyphs = crate::theme::active().glyphs;
2899 let glyph = match g.status {
2900 GateStatus::Pass => glyphs.pass,
2901 GateStatus::Warn => glyphs.warn,
2902 GateStatus::Fail => glyphs.fail,
2903 GateStatus::Unknown => glyphs.bullet,
2904 };
2905 let mut s = format!(
2906 " [{glyph}] {label:<28} {value}\n",
2907 label = g.label,
2908 value = g.value
2909 );
2910 if let Some(why) = &g.why {
2911 s.push_str(&format!(" {} {why}\n", glyphs.continuation));
2912 }
2913 s
2914}
2915
2916fn path_only(url: &str) -> String {
2919 if let Some(idx) = url.find("//") {
2920 let after_scheme = &url[idx + 2..];
2921 if let Some(slash) = after_scheme.find('/') {
2922 return after_scheme[slash..].to_string();
2923 }
2924 return "/".into();
2925 }
2926 url.to_string()
2927}
2928
2929fn append(path: &PathBuf, s: &str) -> std::io::Result<()> {
2936 use std::io::Write;
2937 let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
2938 f.write_all(s.as_bytes())
2939}
2940
2941fn verbosity_rank(s: &str) -> u8 {
2947 match s {
2948 "all" | "trace" => 5,
2949 "debug" => 4,
2950 "info" | "1" => 3,
2951 "warning" | "warn" | "2" => 2,
2952 "error" | "3" => 1,
2953 _ => 0,
2954 }
2955}
2956
2957fn sanitize_for_filename(s: &str) -> String {
2961 s.chars()
2962 .map(|c| match c {
2963 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
2964 _ => '-',
2965 })
2966 .collect()
2967}
2968
2969#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2973pub enum QuitResolution {
2974 Confirm,
2976 Pending,
2979}
2980
2981fn resolve_quit_press(prev: Option<Instant>, now: Instant, window: Duration) -> QuitResolution {
2986 match prev {
2987 Some(t) if now.duration_since(t) <= window => QuitResolution::Confirm,
2988 _ => QuitResolution::Pending,
2989 }
2990}
2991
2992fn format_utc_now() -> String {
2993 let secs = SystemTime::now()
2994 .duration_since(UNIX_EPOCH)
2995 .map(|d| d.as_secs())
2996 .unwrap_or(0);
2997 let secs_in_day = secs % 86_400;
2998 let h = secs_in_day / 3_600;
2999 let m = (secs_in_day % 3_600) / 60;
3000 let s = secs_in_day % 60;
3001 format!("{h:02}:{m:02}:{s:02}")
3002}
3003
3004#[cfg(test)]
3005mod tests {
3006 use super::*;
3007
3008 #[test]
3009 fn format_utc_now_returns_eight_chars() {
3010 let s = format_utc_now();
3011 assert_eq!(s.len(), 8);
3012 assert_eq!(s.chars().nth(2), Some(':'));
3013 assert_eq!(s.chars().nth(5), Some(':'));
3014 }
3015
3016 #[test]
3017 fn path_only_strips_scheme_and_host() {
3018 assert_eq!(path_only("http://10.0.1.5:1633/status"), "/status");
3019 assert_eq!(
3020 path_only("https://bee.example.com/stamps?limit=10"),
3021 "/stamps?limit=10"
3022 );
3023 }
3024
3025 #[test]
3026 fn path_only_handles_no_path() {
3027 assert_eq!(path_only("http://localhost:1633"), "/");
3028 }
3029
3030 #[test]
3031 fn path_only_passes_relative_through() {
3032 assert_eq!(path_only("/already/relative"), "/already/relative");
3033 }
3034
3035 #[test]
3036 fn parse_pprof_arg_default_60() {
3037 assert_eq!(parse_pprof_arg("diagnose --pprof"), Some(60));
3038 assert_eq!(parse_pprof_arg("diag --pprof some other"), Some(60));
3039 }
3040
3041 #[test]
3042 fn parse_pprof_arg_with_explicit_seconds() {
3043 assert_eq!(parse_pprof_arg("diagnose --pprof=120"), Some(120));
3044 assert_eq!(parse_pprof_arg("diagnose --pprof=15 trailing"), Some(15));
3045 }
3046
3047 #[test]
3048 fn parse_pprof_arg_clamps_extreme_values() {
3049 assert_eq!(parse_pprof_arg("diagnose --pprof=0"), Some(1));
3051 assert_eq!(parse_pprof_arg("diagnose --pprof=9999"), Some(600));
3052 }
3053
3054 #[test]
3055 fn parse_pprof_arg_none_when_absent() {
3056 assert_eq!(parse_pprof_arg("diagnose"), None);
3057 assert_eq!(parse_pprof_arg("diag"), None);
3058 assert_eq!(parse_pprof_arg(""), None);
3059 }
3060
3061 #[test]
3062 fn parse_pprof_arg_ignores_garbage_value() {
3063 assert_eq!(parse_pprof_arg("diagnose --pprof=lol"), None);
3066 }
3067
3068 #[test]
3069 fn guess_content_type_known_extensions() {
3070 let p = std::path::PathBuf::from;
3071 assert_eq!(guess_content_type(&p("/tmp/x.html")), "text/html");
3072 assert_eq!(guess_content_type(&p("/tmp/x.json")), "application/json");
3073 assert_eq!(guess_content_type(&p("/tmp/x.PNG")), "image/png");
3074 assert_eq!(guess_content_type(&p("/tmp/x.tar.gz")), "application/gzip");
3075 }
3076
3077 #[test]
3078 fn guess_content_type_unknown_returns_empty() {
3079 let p = std::path::PathBuf::from;
3080 assert_eq!(guess_content_type(&p("/tmp/x.unknownext")), "");
3083 assert_eq!(guess_content_type(&p("/tmp/no-extension")), "");
3084 }
3085
3086 #[test]
3087 fn sanitize_for_filename_keeps_safe_chars() {
3088 assert_eq!(sanitize_for_filename("prod-1"), "prod-1");
3089 assert_eq!(sanitize_for_filename("lab_node"), "lab_node");
3090 }
3091
3092 #[test]
3093 fn sanitize_for_filename_replaces_unsafe_chars() {
3094 assert_eq!(sanitize_for_filename("a/b\\c d"), "a-b-c-d");
3095 assert_eq!(sanitize_for_filename("name:colon"), "name-colon");
3096 }
3097
3098 #[test]
3099 fn resolve_quit_press_first_press_is_pending() {
3100 let now = Instant::now();
3101 assert_eq!(
3102 resolve_quit_press(None, now, Duration::from_millis(1500)),
3103 QuitResolution::Pending
3104 );
3105 }
3106
3107 #[test]
3108 fn resolve_quit_press_second_press_inside_window_confirms() {
3109 let first = Instant::now();
3110 let window = Duration::from_millis(1500);
3111 let second = first + Duration::from_millis(500);
3112 assert_eq!(
3113 resolve_quit_press(Some(first), second, window),
3114 QuitResolution::Confirm
3115 );
3116 }
3117
3118 #[test]
3119 fn resolve_quit_press_second_press_after_window_resets_to_pending() {
3120 let first = Instant::now();
3124 let window = Duration::from_millis(1500);
3125 let second = first + Duration::from_millis(2_000);
3126 assert_eq!(
3127 resolve_quit_press(Some(first), second, window),
3128 QuitResolution::Pending
3129 );
3130 }
3131
3132 #[test]
3133 fn resolve_quit_press_at_window_boundary_confirms() {
3134 let first = Instant::now();
3137 let window = Duration::from_millis(1500);
3138 let second = first + window;
3139 assert_eq!(
3140 resolve_quit_press(Some(first), second, window),
3141 QuitResolution::Confirm
3142 );
3143 }
3144
3145 #[test]
3146 fn screen_keymap_covers_drill_screens() {
3147 for idx in [1usize, 4] {
3150 let rows = screen_keymap(idx);
3151 assert!(
3152 rows.iter().any(|(k, _)| k.contains("Enter")),
3153 "screen {idx} keymap must mention Enter (drill)"
3154 );
3155 assert!(
3156 rows.iter().any(|(k, _)| k.contains("Esc")),
3157 "screen {idx} keymap must mention Esc (close drill)"
3158 );
3159 }
3160 }
3161
3162 #[test]
3163 fn screen_keymap_lottery_advertises_rchash() {
3164 let rows = screen_keymap(3);
3165 assert!(rows.iter().any(|(k, _)| k.contains("r")));
3166 }
3167
3168 #[test]
3169 fn screen_keymap_unknown_index_is_empty_not_panic() {
3170 assert!(screen_keymap(999).is_empty());
3171 }
3172
3173 #[test]
3174 fn verbosity_rank_orders_loud_to_silent() {
3175 assert!(verbosity_rank("all") > verbosity_rank("debug"));
3176 assert!(verbosity_rank("debug") > verbosity_rank("info"));
3177 assert!(verbosity_rank("info") > verbosity_rank("warning"));
3178 assert!(verbosity_rank("warning") > verbosity_rank("error"));
3179 assert!(verbosity_rank("error") > verbosity_rank("unknown"));
3180 assert_eq!(verbosity_rank("info"), verbosity_rank("1"));
3182 assert_eq!(verbosity_rank("warning"), verbosity_rank("2"));
3183 }
3184
3185 #[test]
3186 fn filter_command_suggestions_empty_buffer_returns_all() {
3187 let matches = filter_command_suggestions("", KNOWN_COMMANDS);
3188 assert_eq!(matches.len(), KNOWN_COMMANDS.len());
3189 }
3190
3191 #[test]
3192 fn filter_command_suggestions_prefix_matches_case_insensitive() {
3193 let matches = filter_command_suggestions("Bu", KNOWN_COMMANDS);
3194 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
3195 assert!(names.contains(&"buy-preview"));
3196 assert!(names.contains(&"buy-suggest"));
3197 assert_eq!(names.len(), 2);
3198 }
3199
3200 #[test]
3201 fn filter_command_suggestions_unknown_prefix_is_empty() {
3202 let matches = filter_command_suggestions("xyz", KNOWN_COMMANDS);
3203 assert!(matches.is_empty());
3204 }
3205
3206 #[test]
3207 fn filter_command_suggestions_uses_first_token_only() {
3208 let matches = filter_command_suggestions("topup-preview a1b2 1000", KNOWN_COMMANDS);
3211 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
3212 assert_eq!(names, vec!["topup-preview"]);
3213 }
3214
3215 #[test]
3216 fn probe_chunk_is_4104_bytes_with_correct_span() {
3217 let chunk = build_synthetic_probe_chunk();
3219 assert_eq!(chunk.len(), 4104);
3220 let span = u64::from_le_bytes(chunk[..8].try_into().unwrap());
3221 assert_eq!(span, 4096);
3222 }
3223
3224 #[test]
3225 fn probe_chunk_payloads_are_unique_per_call() {
3226 let a = build_synthetic_probe_chunk();
3231 std::thread::sleep(Duration::from_micros(1));
3233 let b = build_synthetic_probe_chunk();
3234 assert_ne!(&a[8..24], &b[8..24]);
3235 }
3236
3237 #[test]
3238 fn short_hex_truncates_with_ellipsis() {
3239 assert_eq!(short_hex("a1b2c3d4e5f6", 8), "a1b2c3d4…");
3240 assert_eq!(short_hex("short", 8), "short");
3241 assert_eq!(short_hex("abcdefgh", 8), "abcdefgh");
3242 }
3243}