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 ("manifest", "<ref> — open Mantaray tree browser at a reference"),
217 ("inspect", "<ref> — what is this? auto-detects manifest vs raw chunk"),
218 (
219 "durability-check",
220 "<ref> — walk chunk graph, report total / lost / errors",
221 ),
222 ("watchlist", "S13 Watchlist — durability-check history"),
223 ("hash", "<path> — Swarm reference of a local file/dir (offline)"),
224 ("cid", "<ref> [manifest|feed] — encode reference as CID"),
225 ("depth-table", "Print canonical depth → capacity table"),
226 ("gsoc-mine", "<overlay> <id> — mine a GSOC signer (CPU work)"),
227 (
228 "pss-target",
229 "<overlay> — first 4 hex chars (Bee's max prefix)",
230 ),
231 (
232 "diagnose",
233 "[--pprof[=N]] Export snapshot (+ optional CPU profile + trace)",
234 ),
235 ("pins-check", "Bulk integrity walk to a file"),
236 ("loggers", "Dump live logger registry"),
237 ("set-logger", "<expr> <level> — change a logger's verbosity"),
238 ("context", "<name> — switch node profile"),
239 ("quit", "Exit the cockpit"),
240];
241
242fn parse_pprof_arg(line: &str) -> Option<u32> {
247 for tok in line.split_whitespace() {
248 if tok == "--pprof" {
249 return Some(60);
250 }
251 if let Some(rest) = tok.strip_prefix("--pprof=") {
252 if let Ok(n) = rest.parse::<u32>() {
253 return Some(n.clamp(1, 600));
254 }
255 }
256 }
257 None
258}
259
260fn filter_command_suggestions<'a>(
264 buffer: &str,
265 catalog: &'a [(&'a str, &'a str)],
266) -> Vec<&'a (&'a str, &'a str)> {
267 let head = buffer
268 .split_whitespace()
269 .next()
270 .unwrap_or("")
271 .to_ascii_lowercase();
272 catalog
273 .iter()
274 .filter(|(name, _)| name.starts_with(&head))
275 .collect()
276}
277
278#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
279pub enum Mode {
280 #[default]
281 Home,
282}
283
284#[derive(Debug, Default)]
287pub struct AppOverrides {
288 pub ascii: bool,
290 pub no_color: bool,
292 pub bee_bin: Option<PathBuf>,
294 pub bee_config: Option<PathBuf>,
296}
297
298const BEE_API_READY_TIMEOUT: Duration = Duration::from_secs(60);
303
304impl App {
305 pub async fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
306 Self::with_overrides(tick_rate, frame_rate, AppOverrides::default()).await
307 }
308
309 pub async fn with_overrides(
314 tick_rate: f64,
315 frame_rate: f64,
316 overrides: AppOverrides,
317 ) -> color_eyre::Result<Self> {
318 let (action_tx, action_rx) = mpsc::unbounded_channel();
319 let (cmd_status_tx, cmd_status_rx) = mpsc::unbounded_channel();
320 let (durability_tx, durability_rx) = mpsc::unbounded_channel();
321 let config = Config::new()?;
322 let force_no_color = overrides.no_color || theme::no_color_env();
325 theme::install_with_overrides(&config.ui, force_no_color, overrides.ascii);
326
327 let node = config
330 .active_node()
331 .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
332 let api = Arc::new(ApiClient::from_node(node)?);
333
334 let bee_bin = overrides
336 .bee_bin
337 .or_else(|| config.bee.as_ref().map(|b| b.bin.clone()));
338 let bee_config = overrides
339 .bee_config
340 .or_else(|| config.bee.as_ref().map(|b| b.config.clone()));
341 let bee_logs = config
344 .bee
345 .as_ref()
346 .map(|b| b.logs.clone())
347 .unwrap_or_default();
348 let supervisor = match (bee_bin, bee_config) {
349 (Some(bin), Some(cfg)) => {
350 eprintln!("bee-tui: spawning bee {bin:?} --config {cfg:?}");
351 let mut sup = BeeSupervisor::spawn(&bin, &cfg, bee_logs)?;
352 eprintln!(
353 "bee-tui: log → {} (will appear in the cockpit's bottom pane)",
354 sup.log_path().display()
355 );
356 eprintln!(
357 "bee-tui: waiting for {} to respond on /health (up to {:?})...",
358 api.url, BEE_API_READY_TIMEOUT
359 );
360 sup.wait_for_api(&api.url, BEE_API_READY_TIMEOUT).await?;
361 eprintln!("bee-tui: bee ready, opening cockpit");
362 Some(sup)
363 }
364 (Some(_), None) | (None, Some(_)) => {
365 return Err(eyre!(
366 "[bee].bin and [bee].config must both be set (or both unset). \
367 Use --bee-bin AND --bee-config, or both fields in config.toml."
368 ));
369 }
370 (None, None) => None,
371 };
372
373 let refresh = RefreshProfile::from_config(&config.ui.refresh);
380 let root_cancel = CancellationToken::new();
381 let watch = BeeWatch::start_with_profile(api.clone(), &root_cancel, refresh);
382 let health_rx = watch.health();
383
384 let market_rx = if config.economics.enable_market_tile {
388 Some(economics_oracle::spawn_poller(
389 config.economics.gnosis_rpc_url.clone(),
390 root_cancel.child_token(),
391 ))
392 } else {
393 None
394 };
395
396 let screens = build_screens(&api, &watch, market_rx);
397 let (persisted, state_path) = State::load();
402 let initial_tab = LogTab::from_kebab(&persisted.log_pane_active_tab);
403 let mut log_pane = LogPane::new(
404 log_capture::handle(),
405 initial_tab,
406 persisted.log_pane_height,
407 );
408 log_pane.set_spawn_active(supervisor.is_some());
409 if let Some(c) = log_capture::cockpit_handle() {
410 log_pane.set_cockpit_capture(c);
411 }
412
413 let bee_log_rx = supervisor.as_ref().map(|sup| {
419 let (tx, rx) = mpsc::unbounded_channel();
420 crate::bee_log_tailer::spawn(
421 sup.log_path().to_path_buf(),
422 tx,
423 root_cancel.child_token(),
424 );
425 rx
426 });
427
428 if config.metrics.enabled {
435 match config.metrics.addr.parse::<std::net::SocketAddr>() {
436 Ok(bind_addr) => {
437 let render_fn = build_metrics_render_fn(watch.clone(), log_capture::handle());
438 let cancel = root_cancel.child_token();
439 match crate::metrics_server::spawn(bind_addr, render_fn, cancel).await {
440 Ok(actual) => {
441 eprintln!(
442 "bee-tui: metrics endpoint serving /metrics on http://{actual}"
443 );
444 }
445 Err(e) => {
446 tracing::error!(
447 "metrics: failed to start endpoint on {bind_addr}: {e}"
448 );
449 }
450 }
451 }
452 Err(e) => {
453 tracing::error!(
454 "metrics: invalid [metrics].addr {:?}: {e}",
455 config.metrics.addr
456 );
457 }
458 }
459 }
460
461 let config_alerts_debounce = config.alerts.debounce_secs;
462
463 Ok(Self {
464 tick_rate,
465 frame_rate,
466 screens,
467 current_screen: 0,
468 log_pane,
469 state_path,
470 should_quit: false,
471 should_suspend: false,
472 config,
473 mode: Mode::Home,
474 last_tick_key_events: Vec::new(),
475 action_tx,
476 action_rx,
477 root_cancel,
478 api,
479 watch,
480 health_rx,
481 command_buffer: None,
482 command_suggestion_index: 0,
483 command_status: None,
484 help_visible: false,
485 quit_pending: None,
486 supervisor,
487 bee_status: BeeStatus::Running,
488 bee_log_rx,
489 cmd_status_tx,
490 cmd_status_rx,
491 durability_tx,
492 durability_rx,
493 alert_state: crate::alerts::AlertState::new(config_alerts_debounce),
494 })
495 }
496
497 pub async fn run(&mut self) -> color_eyre::Result<()> {
498 let mut tui = Tui::new()?
499 .tick_rate(self.tick_rate)
501 .frame_rate(self.frame_rate);
502 tui.enter()?;
503
504 let tx = self.action_tx.clone();
505 let cfg = self.config.clone();
506 let size = tui.size()?;
507 for component in self.iter_components_mut() {
508 component.register_action_handler(tx.clone())?;
509 component.register_config_handler(cfg.clone())?;
510 component.init(size)?;
511 }
512
513 let action_tx = self.action_tx.clone();
514 loop {
515 self.handle_events(&mut tui).await?;
516 self.handle_actions(&mut tui)?;
517 if self.should_suspend {
518 tui.suspend()?;
519 action_tx.send(Action::Resume)?;
520 action_tx.send(Action::ClearScreen)?;
521 tui.enter()?;
523 } else if self.should_quit {
524 tui.stop()?;
525 break;
526 }
527 }
528 self.watch.shutdown();
530 self.root_cancel.cancel();
531 let snapshot = State {
535 log_pane_height: self.log_pane.height(),
536 log_pane_active_tab: self.log_pane.active_tab().to_kebab().to_string(),
537 };
538 snapshot.save(&self.state_path);
539 if let Some(sup) = self.supervisor.take() {
543 let final_status = sup.shutdown_default().await;
544 tracing::info!("bee child exited: {}", final_status.label());
545 }
546 tui.exit()?;
547 Ok(())
548 }
549
550 async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
551 let Some(event) = tui.next_event().await else {
552 return Ok(());
553 };
554 let action_tx = self.action_tx.clone();
555 let modal_before = self.command_buffer.is_some() || self.help_visible;
562 match event {
563 Event::Quit => action_tx.send(Action::Quit)?,
564 Event::Tick => action_tx.send(Action::Tick)?,
565 Event::Render => action_tx.send(Action::Render)?,
566 Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
567 Event::Key(key) => self.handle_key_event(key)?,
568 _ => {}
569 }
570 let modal_after = self.command_buffer.is_some() || self.help_visible;
571 let propagate = !((modal_before || modal_after) && matches!(event, Event::Key(_)));
574 if propagate {
575 for component in self.iter_components_mut() {
576 if let Some(action) = component.handle_events(Some(event.clone()))? {
577 action_tx.send(action)?;
578 }
579 }
580 }
581 Ok(())
582 }
583
584 fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut dyn Component> {
589 self.screens
590 .iter_mut()
591 .map(|c| c.as_mut() as &mut dyn Component)
592 .chain(std::iter::once(&mut self.log_pane as &mut dyn Component))
593 }
594
595 fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
596 if self.command_buffer.is_some() {
600 self.handle_command_mode_key(key)?;
601 return Ok(());
602 }
603 if self.help_visible {
607 match key.code {
608 crossterm::event::KeyCode::Esc
609 | crossterm::event::KeyCode::Char('?')
610 | crossterm::event::KeyCode::Char('q') => {
611 self.help_visible = false;
612 }
613 _ => {}
614 }
615 return Ok(());
616 }
617 if matches!(key.code, crossterm::event::KeyCode::Char('?')) {
621 self.help_visible = true;
622 return Ok(());
623 }
624 let action_tx = self.action_tx.clone();
625 if matches!(key.code, crossterm::event::KeyCode::Char(':')) {
627 self.command_buffer = Some(String::new());
628 self.command_status = None;
629 return Ok(());
630 }
631 if matches!(key.code, crossterm::event::KeyCode::Tab) {
636 if !self.screens.is_empty() {
637 self.current_screen = (self.current_screen + 1) % self.screens.len();
638 debug!(
639 "switched to screen {}",
640 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
641 );
642 }
643 return Ok(());
644 }
645 if matches!(key.code, crossterm::event::KeyCode::BackTab) {
646 if !self.screens.is_empty() {
647 let len = self.screens.len();
648 self.current_screen = (self.current_screen + len - 1) % len;
649 debug!(
650 "switched to screen {}",
651 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
652 );
653 }
654 return Ok(());
655 }
656 if matches!(key.code, crossterm::event::KeyCode::Char('['))
662 && key.modifiers == crossterm::event::KeyModifiers::NONE
663 {
664 self.log_pane.prev_tab();
665 return Ok(());
666 }
667 if matches!(key.code, crossterm::event::KeyCode::Char(']'))
668 && key.modifiers == crossterm::event::KeyModifiers::NONE
669 {
670 self.log_pane.next_tab();
671 return Ok(());
672 }
673 if matches!(key.code, crossterm::event::KeyCode::Char('+'))
674 && key.modifiers == crossterm::event::KeyModifiers::NONE
675 {
676 self.log_pane.grow();
677 return Ok(());
678 }
679 if matches!(key.code, crossterm::event::KeyCode::Char('-'))
680 && key.modifiers == crossterm::event::KeyModifiers::NONE
681 {
682 self.log_pane.shrink();
683 return Ok(());
684 }
685 if key.modifiers == crossterm::event::KeyModifiers::SHIFT {
690 match key.code {
691 crossterm::event::KeyCode::Up => {
692 self.log_pane.scroll_up(1);
693 return Ok(());
694 }
695 crossterm::event::KeyCode::Down => {
696 self.log_pane.scroll_down(1);
697 return Ok(());
698 }
699 crossterm::event::KeyCode::PageUp => {
700 self.log_pane.scroll_up(10);
701 return Ok(());
702 }
703 crossterm::event::KeyCode::PageDown => {
704 self.log_pane.scroll_down(10);
705 return Ok(());
706 }
707 crossterm::event::KeyCode::End => {
708 self.log_pane.resume_tail();
709 return Ok(());
710 }
711 crossterm::event::KeyCode::Left => {
717 self.log_pane.scroll_left(8);
718 return Ok(());
719 }
720 crossterm::event::KeyCode::Right => {
721 self.log_pane.scroll_right(8);
722 return Ok(());
723 }
724 _ => {}
725 }
726 }
727 if matches!(key.code, crossterm::event::KeyCode::Char('q'))
733 && key.modifiers == crossterm::event::KeyModifiers::NONE
734 {
735 match resolve_quit_press(self.quit_pending, Instant::now(), QUIT_CONFIRM_WINDOW) {
736 QuitResolution::Confirm => {
737 self.quit_pending = None;
738 self.action_tx.send(Action::Quit)?;
739 }
740 QuitResolution::Pending => {
741 self.quit_pending = Some(Instant::now());
742 self.command_status = Some(CommandStatus::Info(
743 "press q again to quit (Esc cancels)".into(),
744 ));
745 }
746 }
747 return Ok(());
748 }
749 if self.quit_pending.is_some() {
753 self.quit_pending = None;
754 }
755 let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
756 return Ok(());
757 };
758 match keymap.get(&vec![key]) {
759 Some(action) => {
760 info!("Got action: {action:?}");
761 action_tx.send(action.clone())?;
762 }
763 _ => {
764 self.last_tick_key_events.push(key);
767
768 if let Some(action) = keymap.get(&self.last_tick_key_events) {
770 info!("Got action: {action:?}");
771 action_tx.send(action.clone())?;
772 }
773 }
774 }
775 Ok(())
776 }
777
778 fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
779 use crossterm::event::KeyCode;
780 let buf = match self.command_buffer.as_mut() {
781 Some(b) => b,
782 None => return Ok(()),
783 };
784 match key.code {
785 KeyCode::Esc => {
786 self.command_buffer = None;
788 self.command_suggestion_index = 0;
789 }
790 KeyCode::Enter => {
791 let line = std::mem::take(buf);
792 self.command_buffer = None;
793 self.command_suggestion_index = 0;
794 self.execute_command(&line)?;
795 }
796 KeyCode::Up => {
797 self.command_suggestion_index = self.command_suggestion_index.saturating_sub(1);
800 }
801 KeyCode::Down => {
802 let n = filter_command_suggestions(buf, KNOWN_COMMANDS).len();
803 if n > 0 && self.command_suggestion_index + 1 < n {
804 self.command_suggestion_index += 1;
805 }
806 }
807 KeyCode::Tab => {
808 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
812 if let Some((name, _)) = matches.get(self.command_suggestion_index) {
813 let rest = buf
814 .split_once(char::is_whitespace)
815 .map(|(_, tail)| tail)
816 .unwrap_or("");
817 let new = if rest.is_empty() {
818 format!("{name} ")
819 } else {
820 format!("{name} {rest}")
821 };
822 buf.clear();
823 buf.push_str(&new);
824 self.command_suggestion_index = 0;
825 }
826 }
827 KeyCode::Backspace => {
828 buf.pop();
829 self.command_suggestion_index = 0;
830 }
831 KeyCode::Char(c) => {
832 buf.push(c);
833 self.command_suggestion_index = 0;
834 }
835 _ => {}
836 }
837 Ok(())
838 }
839
840 fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
843 let trimmed = line.trim();
844 if trimmed.is_empty() {
845 return Ok(());
846 }
847 let head = trimmed.split_whitespace().next().unwrap_or("");
848 match head {
849 "q" | "quit" => {
850 self.action_tx.send(Action::Quit)?;
851 self.command_status = Some(CommandStatus::Info("quitting".into()));
852 }
853 "diagnose" | "diag" => {
854 let pprof_secs = parse_pprof_arg(trimmed);
855 if let Some(secs) = pprof_secs {
856 self.command_status = Some(self.start_diagnose_with_pprof(secs));
857 } else {
858 self.command_status = Some(match self.export_diagnostic_bundle() {
859 Ok(path) => CommandStatus::Info(format!(
860 "diagnostic bundle exported to {}",
861 path.display()
862 )),
863 Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
864 });
865 }
866 }
867 "pins-check" => {
868 self.command_status = Some(match self.start_pins_check() {
874 Ok(path) => CommandStatus::Info(format!(
875 "pins integrity check running → {} (tail to watch progress)",
876 path.display()
877 )),
878 Err(e) => CommandStatus::Err(format!("pins-check failed to start: {e}")),
879 });
880 }
881 "loggers" => {
882 self.command_status = Some(match self.start_loggers_dump() {
883 Ok(path) => CommandStatus::Info(format!(
884 "loggers snapshot writing → {} (open when ready)",
885 path.display()
886 )),
887 Err(e) => CommandStatus::Err(format!("loggers failed to start: {e}")),
888 });
889 }
890 "set-logger" => {
891 let mut parts = trimmed.split_whitespace();
892 let _ = parts.next(); let expr = parts.next().unwrap_or("");
894 let level = parts.next().unwrap_or("");
895 if expr.is_empty() || level.is_empty() {
896 self.command_status = Some(CommandStatus::Err(
897 "usage: :set-logger <expr> <level> (level: none|error|warning|info|debug|all; expr: e.g. node/pushsync or '.' for all)"
898 .into(),
899 ));
900 return Ok(());
901 }
902 self.start_set_logger(expr.to_string(), level.to_string());
903 self.command_status = Some(CommandStatus::Info(format!(
904 "set-logger {expr:?} → {level:?} (PUT in-flight; check :loggers to verify)"
905 )));
906 }
907 "topup-preview" => {
908 self.command_status = Some(self.run_topup_preview(trimmed));
909 }
910 "dilute-preview" => {
911 self.command_status = Some(self.run_dilute_preview(trimmed));
912 }
913 "extend-preview" => {
914 self.command_status = Some(self.run_extend_preview(trimmed));
915 }
916 "buy-preview" => {
917 self.command_status = Some(self.run_buy_preview(trimmed));
918 }
919 "buy-suggest" => {
920 self.command_status = Some(self.run_buy_suggest(trimmed));
921 }
922 "plan-batch" => {
923 self.command_status = Some(self.run_plan_batch(trimmed));
924 }
925 "check-version" => {
926 self.command_status = Some(self.run_check_version());
927 }
928 "config-doctor" => {
929 self.command_status = Some(self.run_config_doctor());
930 }
931 "price" => {
932 self.command_status = Some(self.run_price());
933 }
934 "basefee" => {
935 self.command_status = Some(self.run_basefee());
936 }
937 "probe-upload" => {
938 self.command_status = Some(self.run_probe_upload(trimmed));
939 }
940 "upload-file" => {
941 self.command_status = Some(self.run_upload_file(trimmed));
942 }
943 "hash" => {
944 self.command_status = Some(self.run_hash(trimmed));
945 }
946 "cid" => {
947 self.command_status = Some(self.run_cid(trimmed));
948 }
949 "depth-table" => {
950 self.command_status = Some(self.run_depth_table());
951 }
952 "gsoc-mine" => {
953 self.command_status = Some(self.run_gsoc_mine(trimmed));
954 }
955 "pss-target" => {
956 self.command_status = Some(self.run_pss_target(trimmed));
957 }
958 "manifest" => {
959 self.command_status = Some(self.run_manifest(trimmed));
960 }
961 "inspect" => {
962 self.command_status = Some(self.run_inspect(trimmed));
963 }
964 "durability-check" => {
965 self.command_status = Some(self.run_durability_check(trimmed));
966 }
967 "context" | "ctx" => {
968 let target = trimmed.split_whitespace().nth(1).unwrap_or("");
969 if target.is_empty() {
970 let known: Vec<String> =
971 self.config.nodes.iter().map(|n| n.name.clone()).collect();
972 self.command_status = Some(CommandStatus::Err(format!(
973 "usage: :context <name> (known: {})",
974 known.join(", ")
975 )));
976 return Ok(());
977 }
978 self.command_status = Some(match self.switch_context(target) {
979 Ok(()) => CommandStatus::Info(format!(
980 "switched to context {target} ({})",
981 self.api.url
982 )),
983 Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
984 });
985 }
986 screen
987 if SCREEN_NAMES
988 .iter()
989 .any(|name| name.eq_ignore_ascii_case(screen)) =>
990 {
991 if let Some(idx) = SCREEN_NAMES
992 .iter()
993 .position(|name| name.eq_ignore_ascii_case(screen))
994 {
995 self.current_screen = idx;
996 self.command_status =
997 Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
998 }
999 }
1000 other => {
1001 self.command_status = Some(CommandStatus::Err(format!(
1002 "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, :hash, :cid, :depth-table, :gsoc-mine, :pss-target, :context, :quit)"
1003 )));
1004 }
1005 }
1006 Ok(())
1007 }
1008
1009 fn run_topup_preview(&self, line: &str) -> CommandStatus {
1013 let parts: Vec<&str> = line.split_whitespace().collect();
1014 let (prefix, amount_str) = match parts.as_slice() {
1015 [_, prefix, amount, ..] => (*prefix, *amount),
1016 _ => {
1017 return CommandStatus::Err(
1018 "usage: :topup-preview <batch-prefix> <amount-plur-per-chunk>".into(),
1019 );
1020 }
1021 };
1022 let chain = match self.health_rx.borrow().chain_state.clone() {
1023 Some(c) => c,
1024 None => return CommandStatus::Err("chain state not loaded yet".into()),
1025 };
1026 let stamps = self.watch.stamps().borrow().clone();
1027 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1028 Ok(b) => b.clone(),
1029 Err(e) => return CommandStatus::Err(e),
1030 };
1031 let amount = match stamp_preview::parse_plur_amount(amount_str) {
1032 Ok(a) => a,
1033 Err(e) => return CommandStatus::Err(e),
1034 };
1035 match stamp_preview::topup_preview(&batch, amount, &chain) {
1036 Ok(p) => CommandStatus::Info(p.summary()),
1037 Err(e) => CommandStatus::Err(e),
1038 }
1039 }
1040
1041 fn run_dilute_preview(&self, line: &str) -> CommandStatus {
1045 let parts: Vec<&str> = line.split_whitespace().collect();
1046 let (prefix, depth_str) = match parts.as_slice() {
1047 [_, prefix, depth, ..] => (*prefix, *depth),
1048 _ => {
1049 return CommandStatus::Err(
1050 "usage: :dilute-preview <batch-prefix> <new-depth>".into(),
1051 );
1052 }
1053 };
1054 let new_depth: u8 = match depth_str.parse() {
1055 Ok(d) => d,
1056 Err(_) => {
1057 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
1058 }
1059 };
1060 let stamps = self.watch.stamps().borrow().clone();
1061 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1062 Ok(b) => b.clone(),
1063 Err(e) => return CommandStatus::Err(e),
1064 };
1065 match stamp_preview::dilute_preview(&batch, new_depth) {
1066 Ok(p) => CommandStatus::Info(p.summary()),
1067 Err(e) => CommandStatus::Err(e),
1068 }
1069 }
1070
1071 fn run_extend_preview(&self, line: &str) -> CommandStatus {
1074 let parts: Vec<&str> = line.split_whitespace().collect();
1075 let (prefix, duration_str) = match parts.as_slice() {
1076 [_, prefix, duration, ..] => (*prefix, *duration),
1077 _ => {
1078 return CommandStatus::Err(
1079 "usage: :extend-preview <batch-prefix> <duration> (e.g. 30d, 12h, 90m, 45s, or plain seconds)".into(),
1080 );
1081 }
1082 };
1083 let extension_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
1084 Ok(s) => s,
1085 Err(e) => return CommandStatus::Err(e),
1086 };
1087 let chain = match self.health_rx.borrow().chain_state.clone() {
1088 Some(c) => c,
1089 None => return CommandStatus::Err("chain state not loaded yet".into()),
1090 };
1091 let stamps = self.watch.stamps().borrow().clone();
1092 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1093 Ok(b) => b.clone(),
1094 Err(e) => return CommandStatus::Err(e),
1095 };
1096 match stamp_preview::extend_preview(&batch, extension_seconds, &chain) {
1097 Ok(p) => CommandStatus::Info(p.summary()),
1098 Err(e) => CommandStatus::Err(e),
1099 }
1100 }
1101
1102 fn run_probe_upload(&self, line: &str) -> CommandStatus {
1114 let parts: Vec<&str> = line.split_whitespace().collect();
1115 let prefix = match parts.as_slice() {
1116 [_, prefix, ..] => *prefix,
1117 _ => {
1118 return CommandStatus::Err(
1119 "usage: :probe-upload <batch-prefix> (uploads one synthetic 4 KiB chunk)"
1120 .into(),
1121 );
1122 }
1123 };
1124 let stamps = self.watch.stamps().borrow().clone();
1125 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1126 Ok(b) => b.clone(),
1127 Err(e) => return CommandStatus::Err(e),
1128 };
1129 if !batch.usable {
1130 return CommandStatus::Err(format!(
1131 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1132 short_hex(&batch.batch_id.to_hex(), 8),
1133 ));
1134 }
1135 if batch.batch_ttl <= 0 {
1136 return CommandStatus::Err(format!(
1137 "batch {} is expired — pick another",
1138 short_hex(&batch.batch_id.to_hex(), 8),
1139 ));
1140 }
1141
1142 let api = self.api.clone();
1143 let tx = self.cmd_status_tx.clone();
1144 let batch_id = batch.batch_id;
1145 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1146 let task_short = batch_short.clone();
1147 tokio::spawn(async move {
1148 let chunk = build_synthetic_probe_chunk();
1149 let started = Instant::now();
1150 let result = api.bee().file().upload_chunk(&batch_id, chunk, None).await;
1151 let elapsed_ms = started.elapsed().as_millis();
1152 let status = match result {
1153 Ok(res) => CommandStatus::Info(format!(
1154 "probe-upload OK in {elapsed_ms}ms — batch {task_short}, ref {}",
1155 short_hex(&res.reference.to_hex(), 8),
1156 )),
1157 Err(e) => CommandStatus::Err(format!(
1158 "probe-upload FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1159 )),
1160 };
1161 let _ = tx.send(status);
1162 });
1163
1164 CommandStatus::Info(format!(
1165 "probe-upload to batch {batch_short} in flight — result will replace this line"
1166 ))
1167 }
1168
1169 fn run_upload_file(&self, line: &str) -> CommandStatus {
1177 let parts: Vec<&str> = line.split_whitespace().collect();
1178 let (path_str, prefix) = match parts.as_slice() {
1179 [_, p, b, ..] => (*p, *b),
1180 _ => {
1181 return CommandStatus::Err(
1182 "usage: :upload-file <path> <batch-prefix>".into(),
1183 );
1184 }
1185 };
1186 let path = std::path::PathBuf::from(path_str);
1187 let meta = match std::fs::metadata(&path) {
1188 Ok(m) => m,
1189 Err(e) => return CommandStatus::Err(format!("stat {path_str}: {e}")),
1190 };
1191 if meta.is_dir() {
1192 return CommandStatus::Err(format!(
1193 "{path_str} is a directory — :upload-file is single-file only (collection upload coming in a later release)"
1194 ));
1195 }
1196 const MAX_FILE_BYTES: u64 = 256 * 1024 * 1024;
1197 if meta.len() > MAX_FILE_BYTES {
1198 return CommandStatus::Err(format!(
1199 "{path_str} is {} — over the {}-MiB cockpit ceiling; use swarm-cli for larger uploads",
1200 meta.len(),
1201 MAX_FILE_BYTES / (1024 * 1024),
1202 ));
1203 }
1204 let stamps = self.watch.stamps().borrow().clone();
1205 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1206 Ok(b) => b.clone(),
1207 Err(e) => return CommandStatus::Err(e),
1208 };
1209 if !batch.usable {
1210 return CommandStatus::Err(format!(
1211 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1212 short_hex(&batch.batch_id.to_hex(), 8),
1213 ));
1214 }
1215 if batch.batch_ttl <= 0 {
1216 return CommandStatus::Err(format!(
1217 "batch {} is expired — pick another",
1218 short_hex(&batch.batch_id.to_hex(), 8),
1219 ));
1220 }
1221
1222 let api = self.api.clone();
1223 let tx = self.cmd_status_tx.clone();
1224 let batch_id = batch.batch_id;
1225 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1226 let task_short = batch_short.clone();
1227 let file_size = meta.len();
1228 let name = path
1229 .file_name()
1230 .and_then(|n| n.to_str())
1231 .unwrap_or("")
1232 .to_string();
1233 let content_type = guess_content_type(&path);
1234 tokio::spawn(async move {
1235 let data = match tokio::fs::read(&path).await {
1236 Ok(b) => b,
1237 Err(e) => {
1238 let _ = tx.send(CommandStatus::Err(format!(
1239 "read {}: {e}",
1240 path.display()
1241 )));
1242 return;
1243 }
1244 };
1245 let started = Instant::now();
1246 let result = api
1247 .bee()
1248 .file()
1249 .upload_file(&batch_id, data, &name, &content_type, None)
1250 .await;
1251 let elapsed_ms = started.elapsed().as_millis();
1252 let status = match result {
1253 Ok(res) => CommandStatus::Info(format!(
1254 "upload-file OK in {elapsed_ms}ms — {file_size}B → ref {} (batch {task_short})",
1255 res.reference.to_hex(),
1256 )),
1257 Err(e) => CommandStatus::Err(format!(
1258 "upload-file FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1259 )),
1260 };
1261 let _ = tx.send(status);
1262 });
1263
1264 CommandStatus::Info(format!(
1265 "upload-file ({file_size}B) to batch {batch_short} in flight — result will replace this line"
1266 ))
1267 }
1268
1269 fn run_hash(&self, line: &str) -> CommandStatus {
1274 let parts: Vec<&str> = line.split_whitespace().collect();
1275 let path = match parts.as_slice() {
1276 [_, p, ..] => *p,
1277 _ => {
1278 return CommandStatus::Err(
1279 "usage: :hash <path> (file or directory; computed locally)".into(),
1280 );
1281 }
1282 };
1283 match utility_verbs::hash_path(path) {
1284 Ok(r) => CommandStatus::Info(format!("hash {path}: {r}")),
1285 Err(e) => CommandStatus::Err(format!("hash failed: {e}")),
1286 }
1287 }
1288
1289 fn run_cid(&self, line: &str) -> CommandStatus {
1293 let parts: Vec<&str> = line.split_whitespace().collect();
1294 let (ref_hex, kind_arg) = match parts.as_slice() {
1295 [_, r, k, ..] => (*r, Some(*k)),
1296 [_, r] => (*r, None),
1297 _ => {
1298 return CommandStatus::Err(
1299 "usage: :cid <ref> [manifest|feed] (default manifest)".into(),
1300 );
1301 }
1302 };
1303 let kind = match utility_verbs::parse_cid_kind(kind_arg) {
1304 Ok(k) => k,
1305 Err(e) => return CommandStatus::Err(e),
1306 };
1307 match utility_verbs::cid_for_ref(ref_hex, kind) {
1308 Ok(cid) => CommandStatus::Info(format!("cid: {cid}")),
1309 Err(e) => CommandStatus::Err(format!("cid failed: {e}")),
1310 }
1311 }
1312
1313 fn run_depth_table(&self) -> CommandStatus {
1318 let body = utility_verbs::depth_table();
1319 let path = std::env::temp_dir().join("bee-tui-depth-table.txt");
1320 match std::fs::write(&path, &body) {
1321 Ok(()) => CommandStatus::Info(format!("depth table → {}", path.display())),
1322 Err(e) => CommandStatus::Err(format!("depth-table write failed: {e}")),
1323 }
1324 }
1325
1326 fn run_gsoc_mine(&self, line: &str) -> CommandStatus {
1331 let parts: Vec<&str> = line.split_whitespace().collect();
1332 let (overlay, ident) = match parts.as_slice() {
1333 [_, o, i, ..] => (*o, *i),
1334 _ => {
1335 return CommandStatus::Err(
1336 "usage: :gsoc-mine <overlay-hex> <identifier> (CPU work, no network)".into(),
1337 );
1338 }
1339 };
1340 match utility_verbs::gsoc_mine_for(overlay, ident) {
1341 Ok(out) => CommandStatus::Info(out.replace('\n', " · ")),
1342 Err(e) => CommandStatus::Err(format!("gsoc-mine failed: {e}")),
1343 }
1344 }
1345
1346 fn run_manifest(&mut self, line: &str) -> CommandStatus {
1350 let parts: Vec<&str> = line.split_whitespace().collect();
1351 let ref_arg = match parts.as_slice() {
1352 [_, r, ..] => *r,
1353 _ => {
1354 return CommandStatus::Err(
1355 "usage: :manifest <ref> (32-byte hex reference)".into(),
1356 );
1357 }
1358 };
1359 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1360 Ok(r) => r,
1361 Err(e) => return CommandStatus::Err(format!("manifest: bad ref: {e}")),
1362 };
1363 let idx = match SCREEN_NAMES.iter().position(|n| *n == "Manifest") {
1366 Some(i) => i,
1367 None => {
1368 return CommandStatus::Err("internal: Manifest screen not registered".into());
1369 }
1370 };
1371 let screen = self
1372 .screens
1373 .get_mut(idx)
1374 .and_then(|s| s.as_any_mut())
1375 .and_then(|a| a.downcast_mut::<Manifest>());
1376 let Some(manifest) = screen else {
1377 return CommandStatus::Err("internal: failed to access Manifest screen".into());
1378 };
1379 manifest.load(reference);
1380 self.current_screen = idx;
1381 CommandStatus::Info(format!("loading manifest {}", short_hex(ref_arg, 8)))
1382 }
1383
1384 fn run_inspect(&self, line: &str) -> CommandStatus {
1391 let parts: Vec<&str> = line.split_whitespace().collect();
1392 let ref_arg = match parts.as_slice() {
1393 [_, r, ..] => *r,
1394 _ => {
1395 return CommandStatus::Err("usage: :inspect <ref> (32-byte hex reference)".into());
1396 }
1397 };
1398 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1399 Ok(r) => r,
1400 Err(e) => return CommandStatus::Err(format!("inspect: bad ref: {e}")),
1401 };
1402 let api = self.api.clone();
1403 let tx = self.cmd_status_tx.clone();
1404 let label = short_hex(ref_arg, 8);
1405 let label_for_task = label.clone();
1406 tokio::spawn(async move {
1407 let result = manifest_walker::inspect(api, reference).await;
1408 let status = match result {
1409 InspectResult::Manifest { node, bytes_len } => CommandStatus::Info(format!(
1410 "inspect {label_for_task}: manifest · {bytes_len} bytes · {} forks (jump to :manifest {label_for_task})",
1411 node.forks.len(),
1412 )),
1413 InspectResult::RawChunk { bytes_len } => CommandStatus::Info(format!(
1414 "inspect {label_for_task}: raw chunk · {bytes_len} bytes · not a manifest"
1415 )),
1416 InspectResult::Error(e) => {
1417 CommandStatus::Err(format!("inspect {label_for_task} failed: {e}"))
1418 }
1419 };
1420 let _ = tx.send(status);
1421 });
1422 CommandStatus::Info(format!("inspecting {label} — result will replace this line"))
1423 }
1424
1425 fn run_durability_check(&mut self, line: &str) -> CommandStatus {
1435 let parts: Vec<&str> = line.split_whitespace().collect();
1436 let ref_arg = match parts.as_slice() {
1437 [_, r, ..] => *r,
1438 _ => {
1439 return CommandStatus::Err(
1440 "usage: :durability-check <ref> (32-byte hex reference)".into(),
1441 );
1442 }
1443 };
1444 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1445 Ok(r) => r,
1446 Err(e) => {
1447 return CommandStatus::Err(format!("durability-check: bad ref: {e}"));
1448 }
1449 };
1450 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Watchlist") {
1453 self.current_screen = idx;
1454 }
1455 let api = self.api.clone();
1456 let tx = self.cmd_status_tx.clone();
1457 let watchlist_tx = self.durability_tx.clone();
1458 let label = short_hex(ref_arg, 8);
1459 let label_for_task = label.clone();
1460 tokio::spawn(async move {
1461 let result = durability::check(api, reference).await;
1462 let summary = result.summary();
1463 let _ = watchlist_tx.send(result);
1464 let _ = tx.send(if summary.contains("UNHEALTHY") {
1465 CommandStatus::Err(summary)
1466 } else {
1467 CommandStatus::Info(summary)
1468 });
1469 });
1470 CommandStatus::Info(format!(
1471 "durability-check {label_for_task} in flight — see S13 Watchlist for the running history"
1472 ))
1473 }
1474
1475 fn run_pss_target(&self, line: &str) -> CommandStatus {
1480 let parts: Vec<&str> = line.split_whitespace().collect();
1481 let overlay = match parts.as_slice() {
1482 [_, o, ..] => *o,
1483 _ => {
1484 return CommandStatus::Err(
1485 "usage: :pss-target <overlay-hex> (returns first 4 hex chars)".into(),
1486 );
1487 }
1488 };
1489 match utility_verbs::pss_target_for(overlay) {
1490 Ok(prefix) => CommandStatus::Info(format!("pss target prefix: {prefix}")),
1491 Err(e) => CommandStatus::Err(format!("pss-target failed: {e}")),
1492 }
1493 }
1494
1495 fn run_price(&self) -> CommandStatus {
1501 let tx = self.cmd_status_tx.clone();
1502 tokio::spawn(async move {
1503 let status = match economics_oracle::fetch_xbzz_price().await {
1504 Ok(p) => CommandStatus::Info(p.summary()),
1505 Err(e) => CommandStatus::Err(format!("price: {e}")),
1506 };
1507 let _ = tx.send(status);
1508 });
1509 CommandStatus::Info("price: querying tokenservice.ethswarm.org…".into())
1510 }
1511
1512 fn run_basefee(&self) -> CommandStatus {
1516 let url = match self.config.economics.gnosis_rpc_url.clone() {
1517 Some(u) => u,
1518 None => {
1519 return CommandStatus::Err(
1520 "basefee: set [economics].gnosis_rpc_url in config.toml (typically the same URL as Bee's --blockchain-rpc-endpoint)"
1521 .into(),
1522 );
1523 }
1524 };
1525 let tx = self.cmd_status_tx.clone();
1526 tokio::spawn(async move {
1527 let status = match economics_oracle::fetch_gnosis_gas(&url).await {
1528 Ok(g) => CommandStatus::Info(g.summary()),
1529 Err(e) => CommandStatus::Err(format!("basefee: {e}")),
1530 };
1531 let _ = tx.send(status);
1532 });
1533 CommandStatus::Info("basefee: querying gnosis RPC…".into())
1534 }
1535
1536 fn run_config_doctor(&self) -> CommandStatus {
1542 let path = match self
1543 .config
1544 .bee
1545 .as_ref()
1546 .map(|b| b.config.clone())
1547 {
1548 Some(p) => p,
1549 None => {
1550 return CommandStatus::Err(
1551 "config-doctor: no [bee].config in config.toml (or pass --bee-config) — point bee-tui at the bee.yaml you want audited"
1552 .into(),
1553 );
1554 }
1555 };
1556 let report = match config_doctor::audit(&path) {
1557 Ok(r) => r,
1558 Err(e) => return CommandStatus::Err(format!("config-doctor: {e}")),
1559 };
1560 let secs = SystemTime::now()
1561 .duration_since(UNIX_EPOCH)
1562 .map(|d| d.as_secs())
1563 .unwrap_or(0);
1564 let out_path = std::env::temp_dir().join(format!("bee-tui-config-doctor-{secs}.txt"));
1565 if let Err(e) = std::fs::write(&out_path, report.render()) {
1566 return CommandStatus::Err(format!("config-doctor write {}: {e}", out_path.display()));
1567 }
1568 CommandStatus::Info(format!(
1569 "{} → {}",
1570 report.summary(),
1571 out_path.display()
1572 ))
1573 }
1574
1575 fn run_check_version(&self) -> CommandStatus {
1583 let api = self.api.clone();
1584 let tx = self.cmd_status_tx.clone();
1585 tokio::spawn(async move {
1586 let running = api
1587 .bee()
1588 .debug()
1589 .health()
1590 .await
1591 .ok()
1592 .map(|h| h.version);
1593 let status = match version_check::check_latest(running).await {
1594 Ok(v) => CommandStatus::Info(v.summary()),
1595 Err(e) => CommandStatus::Err(format!("check-version failed: {e}")),
1596 };
1597 let _ = tx.send(status);
1598 });
1599 CommandStatus::Info("check-version: querying github.com/ethersphere/bee…".into())
1600 }
1601
1602 fn run_plan_batch(&self, line: &str) -> CommandStatus {
1608 let parts: Vec<&str> = line.split_whitespace().collect();
1609 let prefix = match parts.as_slice() {
1610 [_, prefix, ..] => *prefix,
1611 _ => {
1612 return CommandStatus::Err(
1613 "usage: :plan-batch <batch-prefix> [usage-thr] [ttl-thr] [extra-depth]"
1614 .into(),
1615 );
1616 }
1617 };
1618 let usage_thr = match parts.get(2) {
1619 Some(s) => match s.parse::<f64>() {
1620 Ok(v) => v,
1621 Err(_) => {
1622 return CommandStatus::Err(format!(
1623 "invalid usage-thr {s:?} (expected float in [0,1], default 0.85)"
1624 ));
1625 }
1626 },
1627 None => stamp_preview::DEFAULT_USAGE_THRESHOLD,
1628 };
1629 let ttl_thr = match parts.get(3) {
1630 Some(s) => match stamp_preview::parse_duration_seconds(s) {
1631 Ok(v) => v,
1632 Err(e) => return CommandStatus::Err(format!("ttl-thr: {e}")),
1633 },
1634 None => stamp_preview::DEFAULT_TTL_THRESHOLD_SECONDS,
1635 };
1636 let extra_depth = match parts.get(4) {
1637 Some(s) => match s.parse::<u8>() {
1638 Ok(v) => v,
1639 Err(_) => {
1640 return CommandStatus::Err(format!(
1641 "invalid extra-depth {s:?} (expected u8, default 2)"
1642 ));
1643 }
1644 },
1645 None => stamp_preview::DEFAULT_EXTRA_DEPTH,
1646 };
1647 let chain = match self.health_rx.borrow().chain_state.clone() {
1648 Some(c) => c,
1649 None => return CommandStatus::Err("chain state not loaded yet".into()),
1650 };
1651 let stamps = self.watch.stamps().borrow().clone();
1652 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1653 Ok(b) => b.clone(),
1654 Err(e) => return CommandStatus::Err(e),
1655 };
1656 match stamp_preview::plan_batch(&batch, &chain, usage_thr, ttl_thr, extra_depth) {
1657 Ok(p) => CommandStatus::Info(p.summary()),
1658 Err(e) => CommandStatus::Err(e),
1659 }
1660 }
1661
1662 fn run_buy_suggest(&self, line: &str) -> CommandStatus {
1668 let parts: Vec<&str> = line.split_whitespace().collect();
1669 let (size_str, duration_str) = match parts.as_slice() {
1670 [_, size, duration, ..] => (*size, *duration),
1671 _ => {
1672 return CommandStatus::Err(
1673 "usage: :buy-suggest <size> <duration> (e.g. 5GiB 30d, 100MiB 12h)".into(),
1674 );
1675 }
1676 };
1677 let target_bytes = match stamp_preview::parse_size_bytes(size_str) {
1678 Ok(b) => b,
1679 Err(e) => return CommandStatus::Err(e),
1680 };
1681 let target_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
1682 Ok(s) => s,
1683 Err(e) => return CommandStatus::Err(e),
1684 };
1685 let chain = match self.health_rx.borrow().chain_state.clone() {
1686 Some(c) => c,
1687 None => return CommandStatus::Err("chain state not loaded yet".into()),
1688 };
1689 match stamp_preview::buy_suggest(target_bytes, target_seconds, &chain) {
1690 Ok(s) => CommandStatus::Info(s.summary()),
1691 Err(e) => CommandStatus::Err(e),
1692 }
1693 }
1694
1695 fn run_buy_preview(&self, line: &str) -> CommandStatus {
1698 let parts: Vec<&str> = line.split_whitespace().collect();
1699 let (depth_str, amount_str) = match parts.as_slice() {
1700 [_, depth, amount, ..] => (*depth, *amount),
1701 _ => {
1702 return CommandStatus::Err(
1703 "usage: :buy-preview <depth> <amount-plur-per-chunk>".into(),
1704 );
1705 }
1706 };
1707 let depth: u8 = match depth_str.parse() {
1708 Ok(d) => d,
1709 Err(_) => {
1710 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
1711 }
1712 };
1713 let amount = match stamp_preview::parse_plur_amount(amount_str) {
1714 Ok(a) => a,
1715 Err(e) => return CommandStatus::Err(e),
1716 };
1717 let chain = match self.health_rx.borrow().chain_state.clone() {
1718 Some(c) => c,
1719 None => return CommandStatus::Err("chain state not loaded yet".into()),
1720 };
1721 match stamp_preview::buy_preview(depth, amount, &chain) {
1722 Ok(p) => CommandStatus::Info(p.summary()),
1723 Err(e) => CommandStatus::Err(e),
1724 }
1725 }
1726
1727 fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
1734 let node = self
1735 .config
1736 .nodes
1737 .iter()
1738 .find(|n| n.name == target)
1739 .ok_or_else(|| eyre!("no node configured with name {target:?}"))?
1740 .clone();
1741 let new_api = Arc::new(ApiClient::from_node(&node)?);
1742 self.watch.shutdown();
1746 let refresh = RefreshProfile::from_config(&self.config.ui.refresh);
1747 let new_watch = BeeWatch::start_with_profile(new_api.clone(), &self.root_cancel, refresh);
1748 let new_health_rx = new_watch.health();
1749 let new_market_rx = if self.config.economics.enable_market_tile {
1754 Some(economics_oracle::spawn_poller(
1755 self.config.economics.gnosis_rpc_url.clone(),
1756 self.root_cancel.child_token(),
1757 ))
1758 } else {
1759 None
1760 };
1761 let new_screens = build_screens(&new_api, &new_watch, new_market_rx);
1762 self.api = new_api;
1763 self.watch = new_watch;
1764 self.health_rx = new_health_rx;
1765 self.screens = new_screens;
1766 Ok(())
1769 }
1770
1771 fn start_pins_check(&self) -> std::io::Result<PathBuf> {
1788 let secs = SystemTime::now()
1789 .duration_since(UNIX_EPOCH)
1790 .map(|d| d.as_secs())
1791 .unwrap_or(0);
1792 let path = std::env::temp_dir().join(format!(
1793 "bee-tui-pins-check-{}-{secs}.txt",
1794 sanitize_for_filename(&self.api.name),
1795 ));
1796 std::fs::write(
1799 &path,
1800 format!(
1801 "# bee-tui :pins-check\n# profile {}\n# endpoint {}\n# started {}\n",
1802 self.api.name,
1803 self.api.url,
1804 format_utc_now(),
1805 ),
1806 )?;
1807
1808 let api = self.api.clone();
1809 let dest = path.clone();
1810 tokio::spawn(async move {
1811 let bee = api.bee();
1812 match bee.api().check_pins(None).await {
1813 Ok(entries) => {
1814 let mut body = String::new();
1815 for e in &entries {
1816 body.push_str(&format!(
1817 "{} total={} missing={} invalid={} {}\n",
1818 e.reference.to_hex(),
1819 e.total,
1820 e.missing,
1821 e.invalid,
1822 if e.is_healthy() {
1823 "healthy"
1824 } else {
1825 "UNHEALTHY"
1826 },
1827 ));
1828 }
1829 body.push_str(&format!("# done. {} pins checked.\n", entries.len()));
1830 if let Err(e) = append(&dest, &body) {
1831 let _ = append(&dest, &format!("# write error: {e}\n"));
1832 }
1833 }
1834 Err(e) => {
1835 let _ = append(&dest, &format!("# error: {e}\n"));
1836 }
1837 }
1838 });
1839 Ok(path)
1840 }
1841
1842 fn start_set_logger(&self, expression: String, level: String) {
1853 let secs = SystemTime::now()
1854 .duration_since(UNIX_EPOCH)
1855 .map(|d| d.as_secs())
1856 .unwrap_or(0);
1857 let dest = std::env::temp_dir().join(format!(
1858 "bee-tui-set-logger-{}-{secs}.txt",
1859 sanitize_for_filename(&self.api.name),
1860 ));
1861 let _ = std::fs::write(
1862 &dest,
1863 format!(
1864 "# bee-tui :set-logger\n# profile {}\n# endpoint {}\n# expr {expression}\n# level {level}\n# started {}\n",
1865 self.api.name,
1866 self.api.url,
1867 format_utc_now(),
1868 ),
1869 );
1870
1871 let api = self.api.clone();
1872 tokio::spawn(async move {
1873 let bee = api.bee();
1874 match bee.debug().set_logger(&expression, &level).await {
1875 Ok(()) => {
1876 let _ = append(
1877 &dest,
1878 &format!("# done. {expression} → {level} accepted by Bee.\n"),
1879 );
1880 }
1881 Err(e) => {
1882 let _ = append(&dest, &format!("# error: {e}\n"));
1883 }
1884 }
1885 });
1886 }
1887
1888 fn start_loggers_dump(&self) -> std::io::Result<PathBuf> {
1893 let secs = SystemTime::now()
1894 .duration_since(UNIX_EPOCH)
1895 .map(|d| d.as_secs())
1896 .unwrap_or(0);
1897 let path = std::env::temp_dir().join(format!(
1898 "bee-tui-loggers-{}-{secs}.txt",
1899 sanitize_for_filename(&self.api.name),
1900 ));
1901 std::fs::write(
1902 &path,
1903 format!(
1904 "# bee-tui :loggers\n# profile {}\n# endpoint {}\n# started {}\n",
1905 self.api.name,
1906 self.api.url,
1907 format_utc_now(),
1908 ),
1909 )?;
1910
1911 let api = self.api.clone();
1912 let dest = path.clone();
1913 tokio::spawn(async move {
1914 let bee = api.bee();
1915 match bee.debug().loggers().await {
1916 Ok(listing) => {
1917 let mut rows = listing.loggers.clone();
1918 rows.sort_by(|a, b| {
1922 verbosity_rank(&b.verbosity)
1923 .cmp(&verbosity_rank(&a.verbosity))
1924 .then_with(|| a.logger.cmp(&b.logger))
1925 });
1926 let mut body = String::new();
1927 body.push_str(&format!("# {} loggers registered\n", rows.len()));
1928 body.push_str("# VERBOSITY LOGGER\n");
1929 for r in &rows {
1930 body.push_str(&format!(" {:<9} {}\n", r.verbosity, r.logger,));
1931 }
1932 body.push_str("# done.\n");
1933 if let Err(e) = append(&dest, &body) {
1934 let _ = append(&dest, &format!("# write error: {e}\n"));
1935 }
1936 }
1937 Err(e) => {
1938 let _ = append(&dest, &format!("# error: {e}\n"));
1939 }
1940 }
1941 });
1942 Ok(path)
1943 }
1944
1945 fn start_diagnose_with_pprof(&self, seconds: u32) -> CommandStatus {
1957 let secs_unix = SystemTime::now()
1958 .duration_since(UNIX_EPOCH)
1959 .map(|d| d.as_secs())
1960 .unwrap_or(0);
1961 let dir = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs_unix}"));
1962 if let Err(e) = std::fs::create_dir_all(&dir) {
1963 return CommandStatus::Err(format!("diagnose --pprof: mkdir failed: {e}"));
1964 }
1965 let bundle_text = self.render_diagnostic_bundle();
1966 if let Err(e) = std::fs::write(dir.join("bundle.txt"), &bundle_text) {
1967 return CommandStatus::Err(format!("diagnose --pprof: write bundle.txt: {e}"));
1968 }
1969 let auth_token = self
1974 .config
1975 .nodes
1976 .iter()
1977 .find(|n| n.name == self.api.name)
1978 .and_then(|n| n.resolved_token());
1979 let base_url = self.api.url.clone();
1980 let dir_for_task = dir.clone();
1981 let tx = self.cmd_status_tx.clone();
1982 tokio::spawn(async move {
1983 let r = pprof_bundle::fetch_and_write(&base_url, auth_token, seconds, dir_for_task)
1984 .await;
1985 let status = match r {
1986 Ok(b) => CommandStatus::Info(b.summary()),
1987 Err(e) => CommandStatus::Err(format!("diagnose --pprof failed: {e}")),
1988 };
1989 let _ = tx.send(status);
1990 });
1991 CommandStatus::Info(format!(
1992 "diagnose --pprof={seconds}s in flight (bundle.txt already at {}; profile + trace will join when sampling completes)",
1993 dir.display()
1994 ))
1995 }
1996
1997 fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
1998 let bundle = self.render_diagnostic_bundle();
1999 let secs = SystemTime::now()
2000 .duration_since(UNIX_EPOCH)
2001 .map(|d| d.as_secs())
2002 .unwrap_or(0);
2003 let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
2004 std::fs::write(&path, bundle)?;
2005 Ok(path)
2006 }
2007
2008 fn render_diagnostic_bundle(&self) -> String {
2009 let now = format_utc_now();
2010 let health = self.health_rx.borrow().clone();
2011 let topology = self.watch.topology().borrow().clone();
2012 let stamps = self.watch.stamps().borrow().clone();
2013 let gates = Health::gates_for_with_stamps(&health, Some(&topology), Some(&stamps));
2014 let recent: Vec<_> = log_capture::handle()
2015 .map(|c| {
2016 let mut snap = c.snapshot();
2017 let len = snap.len();
2018 if len > 50 {
2019 snap.drain(0..len - 50);
2020 }
2021 snap
2022 })
2023 .unwrap_or_default();
2024
2025 let mut out = String::new();
2026 out.push_str("# bee-tui diagnostic bundle\n");
2027 out.push_str(&format!("# generated UTC {now}\n\n"));
2028 out.push_str("## profile\n");
2029 out.push_str(&format!(" name {}\n", self.api.name));
2030 out.push_str(&format!(" endpoint {}\n\n", self.api.url));
2031 out.push_str("## health gates\n");
2032 for g in &gates {
2033 out.push_str(&format_gate_line(g));
2034 }
2035 out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
2036 for e in &recent {
2037 let status = e
2038 .status
2039 .map(|s| s.to_string())
2040 .unwrap_or_else(|| "—".into());
2041 let elapsed = e
2042 .elapsed_ms
2043 .map(|ms| format!("{ms}ms"))
2044 .unwrap_or_else(|| "—".into());
2045 out.push_str(&format!(
2046 " {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
2047 ts = e.ts,
2048 method = e.method,
2049 path = path_only(&e.url),
2050 status = status,
2051 elapsed = elapsed,
2052 ));
2053 }
2054 out.push_str(&format!(
2055 "\n## generated by bee-tui {}\n",
2056 env!("CARGO_PKG_VERSION"),
2057 ));
2058 out
2059 }
2060
2061 fn tick_alerts(&mut self) {
2068 let url = match self.config.alerts.webhook_url.as_deref() {
2069 Some(u) if !u.is_empty() => u.to_string(),
2070 _ => return,
2071 };
2072 let health = self.health_rx.borrow().clone();
2073 let topology = self.watch.topology().borrow().clone();
2074 let stamps = self.watch.stamps().borrow().clone();
2075 let gates = Health::gates_for_with_stamps(&health, Some(&topology), Some(&stamps));
2076 let alerts = self.alert_state.diff_and_record(&gates);
2077 for alert in alerts {
2078 let url = url.clone();
2079 tokio::spawn(async move {
2080 if let Err(e) = crate::alerts::fire(&url, &alert).await {
2081 tracing::warn!(target: "bee_tui::alerts", "webhook fire failed: {e}");
2082 }
2083 });
2084 }
2085 }
2086
2087 fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
2088 while let Ok(action) = self.action_rx.try_recv() {
2089 if action != Action::Tick && action != Action::Render {
2090 debug!("{action:?}");
2091 }
2092 match action {
2093 Action::Tick => {
2094 self.last_tick_key_events.drain(..);
2095 theme::advance_spinner();
2099 if let Some(sup) = self.supervisor.as_mut() {
2103 self.bee_status = sup.status();
2104 }
2105 if let Some(rx) = self.bee_log_rx.as_mut() {
2110 while let Ok((tab, line)) = rx.try_recv() {
2111 self.log_pane.push_bee(tab, line);
2112 }
2113 }
2114 while let Ok(status) = self.cmd_status_rx.try_recv() {
2119 self.command_status = Some(status);
2120 }
2121 while let Ok(result) = self.durability_rx.try_recv() {
2126 if let Some(idx) =
2127 SCREEN_NAMES.iter().position(|n| *n == "Watchlist")
2128 {
2129 if let Some(wl) = self
2130 .screens
2131 .get_mut(idx)
2132 .and_then(|s| s.as_any_mut())
2133 .and_then(|a| a.downcast_mut::<Watchlist>())
2134 {
2135 wl.record(result);
2136 }
2137 }
2138 }
2139 self.tick_alerts();
2143 }
2144 Action::Quit => self.should_quit = true,
2145 Action::Suspend => self.should_suspend = true,
2146 Action::Resume => self.should_suspend = false,
2147 Action::ClearScreen => tui.terminal.clear()?,
2148 Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
2149 Action::Render => self.render(tui)?,
2150 _ => {}
2151 }
2152 let tx = self.action_tx.clone();
2153 for component in self.iter_components_mut() {
2154 if let Some(action) = component.update(action.clone())? {
2155 tx.send(action)?
2156 };
2157 }
2158 }
2159 Ok(())
2160 }
2161
2162 fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
2163 tui.resize(Rect::new(0, 0, w, h))?;
2164 self.render(tui)?;
2165 Ok(())
2166 }
2167
2168 fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
2169 let active = self.current_screen;
2170 let tx = self.action_tx.clone();
2171 let screens = &mut self.screens;
2172 let log_pane = &mut self.log_pane;
2173 let log_pane_height = log_pane.height();
2174 let command_buffer = self.command_buffer.clone();
2175 let command_suggestion_index = self.command_suggestion_index;
2176 let command_status = self.command_status.clone();
2177 let help_visible = self.help_visible;
2178 let profile = self.api.name.clone();
2179 let endpoint = self.api.url.clone();
2180 let last_ping = self.health_rx.borrow().last_ping;
2181 let now_utc = format_utc_now();
2182 let bee_status_label = if self.supervisor.is_some() && !self.bee_status.is_running() {
2183 Some(self.bee_status.label())
2187 } else {
2188 None
2189 };
2190 tui.draw(|frame| {
2191 use ratatui::layout::{Constraint, Layout};
2192 use ratatui::style::{Color, Modifier, Style};
2193 use ratatui::text::{Line, Span};
2194 use ratatui::widgets::Paragraph;
2195
2196 let chunks = Layout::vertical([
2197 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), Constraint::Length(log_pane_height), ])
2202 .split(frame.area());
2203
2204 let top_chunks =
2205 Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(chunks[0]);
2206
2207 let ping_str = match last_ping {
2209 Some(d) => format!("{}ms", d.as_millis()),
2210 None => "—".into(),
2211 };
2212 let t = theme::active();
2213 let mut metadata_spans = vec![
2214 Span::styled(
2215 " bee-tui ",
2216 Style::default()
2217 .fg(Color::Black)
2218 .bg(t.info)
2219 .add_modifier(Modifier::BOLD),
2220 ),
2221 Span::raw(" "),
2222 Span::styled(
2223 profile,
2224 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
2225 ),
2226 Span::styled(format!(" @ {endpoint}"), Style::default().fg(t.dim)),
2227 Span::raw(" "),
2228 Span::styled("ping ", Style::default().fg(t.dim)),
2229 Span::styled(ping_str, Style::default().fg(t.info)),
2230 Span::raw(" "),
2231 Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
2232 ];
2233 if let Some(label) = bee_status_label.as_ref() {
2237 metadata_spans.push(Span::raw(" "));
2238 metadata_spans.push(Span::styled(
2239 format!(" {label} "),
2240 Style::default()
2241 .fg(Color::Black)
2242 .bg(t.fail)
2243 .add_modifier(Modifier::BOLD),
2244 ));
2245 }
2246 let metadata_line = Line::from(metadata_spans);
2247 frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
2248
2249 let theme = *theme::active();
2251 let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
2252 for (i, name) in SCREEN_NAMES.iter().enumerate() {
2253 let style = if i == active {
2254 Style::default()
2255 .fg(theme.tab_active_fg)
2256 .bg(theme.tab_active_bg)
2257 .add_modifier(Modifier::BOLD)
2258 } else {
2259 Style::default().fg(theme.dim)
2260 };
2261 tabs.push(Span::styled(format!(" {name} "), style));
2262 tabs.push(Span::raw(" "));
2263 }
2264 tabs.push(Span::styled(
2265 ":cmd · Tab to cycle · ? help",
2266 Style::default().fg(theme.dim),
2267 ));
2268 frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
2269
2270 if let Some(screen) = screens.get_mut(active) {
2272 if let Err(err) = screen.draw(frame, chunks[1]) {
2273 let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
2274 }
2275 }
2276 let prompt = if let Some(buf) = &command_buffer {
2278 Line::from(vec![
2279 Span::styled(
2280 ":",
2281 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
2282 ),
2283 Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
2284 Span::styled("█", Style::default().fg(t.accent)),
2285 ])
2286 } else {
2287 match &command_status {
2288 Some(CommandStatus::Info(msg)) => {
2289 Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
2290 }
2291 Some(CommandStatus::Err(msg)) => {
2292 Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
2293 }
2294 None => Line::from(""),
2295 }
2296 };
2297 frame.render_widget(Paragraph::new(prompt), chunks[2]);
2298
2299 if let Some(buf) = &command_buffer {
2305 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
2306 if !matches.is_empty() {
2307 draw_command_suggestions(
2308 frame,
2309 chunks[2],
2310 &matches,
2311 command_suggestion_index,
2312 &theme,
2313 );
2314 }
2315 }
2316
2317 if let Err(err) = log_pane.draw(frame, chunks[3]) {
2319 let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
2320 }
2321
2322 if help_visible {
2327 draw_help_overlay(frame, frame.area(), active, &theme);
2328 }
2329 })?;
2330 Ok(())
2331 }
2332}
2333
2334fn draw_command_suggestions(
2341 frame: &mut ratatui::Frame,
2342 bar_rect: ratatui::layout::Rect,
2343 matches: &[&(&str, &str)],
2344 selected: usize,
2345 theme: &theme::Theme,
2346) {
2347 use ratatui::layout::Rect;
2348 use ratatui::style::{Modifier, Style};
2349 use ratatui::text::{Line, Span};
2350 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2351
2352 const MAX_VISIBLE: usize = 10;
2353 let visible_rows = matches.len().min(MAX_VISIBLE);
2354 if visible_rows == 0 {
2355 return;
2356 }
2357 let height = (visible_rows as u16) + 2; let widest = matches
2362 .iter()
2363 .map(|(name, desc)| name.len() + desc.len() + 6)
2364 .max()
2365 .unwrap_or(40)
2366 .min(bar_rect.width as usize);
2367 let width = (widest as u16 + 2).min(bar_rect.width);
2368 let bottom = bar_rect.y;
2371 let y = bottom.saturating_sub(height);
2372 let popup = Rect {
2373 x: bar_rect.x,
2374 y,
2375 width,
2376 height: bottom - y,
2377 };
2378
2379 let scroll_start = if selected >= visible_rows {
2381 selected + 1 - visible_rows
2382 } else {
2383 0
2384 };
2385 let visible_slice = &matches[scroll_start..(scroll_start + visible_rows).min(matches.len())];
2386
2387 let mut lines: Vec<Line> = Vec::with_capacity(visible_slice.len());
2388 for (i, (name, desc)) in visible_slice.iter().enumerate() {
2389 let absolute_idx = scroll_start + i;
2390 let is_selected = absolute_idx == selected;
2391 let row_style = if is_selected {
2392 Style::default()
2393 .fg(theme.tab_active_fg)
2394 .bg(theme.tab_active_bg)
2395 .add_modifier(Modifier::BOLD)
2396 } else {
2397 Style::default()
2398 };
2399 let cursor = if is_selected { "▸ " } else { " " };
2400 lines.push(Line::from(vec![
2401 Span::styled(format!("{cursor}:{name:<16} "), row_style),
2402 Span::styled(
2403 desc.to_string(),
2404 if is_selected {
2405 row_style
2406 } else {
2407 Style::default().fg(theme.dim)
2408 },
2409 ),
2410 ]));
2411 }
2412
2413 let title = if matches.len() > MAX_VISIBLE {
2415 format!(" :commands ({}/{}) ", selected + 1, matches.len())
2416 } else {
2417 " :commands ".to_string()
2418 };
2419
2420 frame.render_widget(Clear, popup);
2421 frame.render_widget(
2422 Paragraph::new(lines).block(
2423 Block::default()
2424 .borders(Borders::ALL)
2425 .border_style(Style::default().fg(theme.accent))
2426 .title(title),
2427 ),
2428 popup,
2429 );
2430}
2431
2432fn draw_help_overlay(
2437 frame: &mut ratatui::Frame,
2438 area: ratatui::layout::Rect,
2439 active_screen: usize,
2440 theme: &theme::Theme,
2441) {
2442 use ratatui::layout::Rect;
2443 use ratatui::style::{Modifier, Style};
2444 use ratatui::text::{Line, Span};
2445 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2446
2447 let screen_name = SCREEN_NAMES.get(active_screen).copied().unwrap_or("?");
2448 let screen_rows = screen_keymap(active_screen);
2449 let global_rows: &[(&str, &str)] = &[
2450 ("Tab", "next screen"),
2451 ("Shift+Tab", "previous screen"),
2452 ("[ / ]", "previous / next log-pane tab"),
2453 ("+ / -", "grow / shrink log pane"),
2454 ("Shift+↑/↓", "scroll log pane (1 line); pauses auto-tail"),
2455 ("Shift+PgUp/PgDn", "scroll log pane (10 lines)"),
2456 ("Shift+←/→", "pan log pane horizontally (8 cols)"),
2457 ("Shift+End", "resume auto-tail + reset horizontal pan"),
2458 ("?", "toggle this help"),
2459 (":", "open command bar"),
2460 ("qq", "quit (double-tap; or :q)"),
2461 ("Ctrl+C / Ctrl+D", "quit immediately"),
2462 ];
2463
2464 let w = area.width.min(72);
2467 let h = area.height.min(22);
2468 let x = area.x + (area.width.saturating_sub(w)) / 2;
2469 let y = area.y + (area.height.saturating_sub(h)) / 2;
2470 let rect = Rect {
2471 x,
2472 y,
2473 width: w,
2474 height: h,
2475 };
2476
2477 let mut lines: Vec<Line> = Vec::new();
2478 lines.push(Line::from(vec![
2479 Span::styled(
2480 format!(" {screen_name} "),
2481 Style::default()
2482 .fg(theme.tab_active_fg)
2483 .bg(theme.tab_active_bg)
2484 .add_modifier(Modifier::BOLD),
2485 ),
2486 Span::raw(" screen-specific keys"),
2487 ]));
2488 lines.push(Line::from(""));
2489 if screen_rows.is_empty() {
2490 lines.push(Line::from(Span::styled(
2491 " (no extra keys for this screen — use the command bar via :)",
2492 Style::default()
2493 .fg(theme.dim)
2494 .add_modifier(Modifier::ITALIC),
2495 )));
2496 } else {
2497 for (key, desc) in screen_rows {
2498 lines.push(format_help_row(key, desc, theme));
2499 }
2500 }
2501 lines.push(Line::from(""));
2502 lines.push(Line::from(Span::styled(
2503 " global",
2504 Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
2505 )));
2506 for (key, desc) in global_rows {
2507 lines.push(format_help_row(key, desc, theme));
2508 }
2509 lines.push(Line::from(""));
2510 lines.push(Line::from(Span::styled(
2511 " Esc / ? / q to dismiss",
2512 Style::default()
2513 .fg(theme.dim)
2514 .add_modifier(Modifier::ITALIC),
2515 )));
2516
2517 frame.render_widget(Clear, rect);
2520 frame.render_widget(
2521 Paragraph::new(lines).block(
2522 Block::default()
2523 .borders(Borders::ALL)
2524 .border_style(Style::default().fg(theme.accent))
2525 .title(" help "),
2526 ),
2527 rect,
2528 );
2529}
2530
2531fn format_help_row<'a>(
2532 key: &'a str,
2533 desc: &'a str,
2534 theme: &theme::Theme,
2535) -> ratatui::text::Line<'a> {
2536 use ratatui::style::{Modifier, Style};
2537 use ratatui::text::{Line, Span};
2538 Line::from(vec![
2539 Span::raw(" "),
2540 Span::styled(
2541 format!("{key:<16}"),
2542 Style::default()
2543 .fg(theme.accent)
2544 .add_modifier(Modifier::BOLD),
2545 ),
2546 Span::raw(" "),
2547 Span::raw(desc),
2548 ])
2549}
2550
2551fn screen_keymap(active_screen: usize) -> &'static [(&'static str, &'static str)] {
2555 match active_screen {
2556 1 => &[
2558 ("↑↓ / j k", "move row selection"),
2559 ("Enter", "drill batch — bucket histogram + worst-N"),
2560 ("Esc", "close drill"),
2561 ],
2562 3 => &[("r", "run on-demand rchash benchmark")],
2564 4 => &[
2565 ("↑↓ / j k", "move peer selection"),
2566 (
2567 "Enter",
2568 "drill peer — balance / cheques / settlement / ping",
2569 ),
2570 ("Esc", "close drill"),
2571 ],
2572 8 => &[
2576 ("↑↓ / j k", "scroll one row"),
2577 ("PgUp / PgDn", "scroll ten rows"),
2578 ("Home", "back to top"),
2579 ],
2580 9 => &[
2582 ("↑↓ / j k", "move row selection"),
2583 ("Enter", "integrity-check the highlighted pin"),
2584 ("c", "integrity-check every unchecked pin"),
2585 ("s", "cycle sort: ref order / bad first / by size"),
2586 ],
2587 10 => &[
2589 ("↑↓ / j k", "move row selection"),
2590 ("Enter", "expand / collapse fork (loads child chunk)"),
2591 (":manifest <ref>", "open a manifest at a reference"),
2592 (":inspect <ref>", "what is this? auto-detects manifest"),
2593 ],
2594 11 => &[
2596 ("↑↓ / j k", "move row selection"),
2597 (":durability-check <ref>", "walk chunk graph + record"),
2598 ],
2599 _ => &[],
2600 }
2601}
2602
2603fn build_screens(
2612 api: &Arc<ApiClient>,
2613 watch: &BeeWatch,
2614 market_rx: Option<watch::Receiver<crate::economics_oracle::EconomicsSnapshot>>,
2615) -> Vec<Box<dyn Component>> {
2616 let health = Health::new(api.clone(), watch.health(), watch.topology());
2617 let stamps = Stamps::new(api.clone(), watch.stamps());
2618 let swap = match market_rx {
2619 Some(rx) => Swap::new(watch.swap()).with_market_feed(rx),
2620 None => Swap::new(watch.swap()),
2621 };
2622 let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
2623 let peers = Peers::new(api.clone(), watch.topology());
2624 let network = Network::new(watch.network(), watch.topology());
2625 let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
2626 let api_health = ApiHealth::new(
2627 api.clone(),
2628 watch.health(),
2629 watch.transactions(),
2630 log_capture::handle(),
2631 );
2632 let tags = Tags::new(watch.tags());
2633 let pins = Pins::new(api.clone(), watch.pins());
2634 let manifest = Manifest::new(api.clone());
2635 let watchlist = Watchlist::new();
2636 vec![
2637 Box::new(health),
2638 Box::new(stamps),
2639 Box::new(swap),
2640 Box::new(lottery),
2641 Box::new(peers),
2642 Box::new(network),
2643 Box::new(warmup),
2644 Box::new(api_health),
2645 Box::new(tags),
2646 Box::new(pins),
2647 Box::new(manifest),
2648 Box::new(watchlist),
2649 ]
2650}
2651
2652fn build_synthetic_probe_chunk() -> Vec<u8> {
2660 use std::time::{SystemTime, UNIX_EPOCH};
2661 let nanos = SystemTime::now()
2662 .duration_since(UNIX_EPOCH)
2663 .map(|d| d.as_nanos())
2664 .unwrap_or(0);
2665 let mut data = Vec::with_capacity(8 + 4096);
2666 data.extend_from_slice(&4096u64.to_le_bytes());
2668 data.extend_from_slice(&nanos.to_le_bytes());
2670 data.resize(8 + 4096, 0);
2671 data
2672}
2673
2674fn short_hex(hex: &str, len: usize) -> String {
2677 if hex.len() > len {
2678 format!("{}…", &hex[..len])
2679 } else {
2680 hex.to_string()
2681 }
2682}
2683
2684fn guess_content_type(path: &std::path::Path) -> String {
2690 let ext = path
2691 .extension()
2692 .and_then(|e| e.to_str())
2693 .map(|s| s.to_ascii_lowercase());
2694 match ext.as_deref() {
2695 Some("html") | Some("htm") => "text/html",
2696 Some("txt") | Some("md") => "text/plain",
2697 Some("json") => "application/json",
2698 Some("css") => "text/css",
2699 Some("js") => "application/javascript",
2700 Some("png") => "image/png",
2701 Some("jpg") | Some("jpeg") => "image/jpeg",
2702 Some("gif") => "image/gif",
2703 Some("svg") => "image/svg+xml",
2704 Some("webp") => "image/webp",
2705 Some("pdf") => "application/pdf",
2706 Some("zip") => "application/zip",
2707 Some("tar") => "application/x-tar",
2708 Some("gz") | Some("tgz") => "application/gzip",
2709 Some("wasm") => "application/wasm",
2710 _ => "",
2711 }
2712 .to_string()
2713}
2714
2715fn build_metrics_render_fn(
2721 watch: BeeWatch,
2722 log_capture: Option<log_capture::LogCapture>,
2723) -> crate::metrics_server::RenderFn {
2724 use std::time::{SystemTime, UNIX_EPOCH};
2725 Arc::new(move || {
2726 let health = watch.health().borrow().clone();
2727 let stamps = watch.stamps().borrow().clone();
2728 let swap = watch.swap().borrow().clone();
2729 let lottery = watch.lottery().borrow().clone();
2730 let topology = watch.topology().borrow().clone();
2731 let network = watch.network().borrow().clone();
2732 let transactions = watch.transactions().borrow().clone();
2733 let recent = log_capture
2734 .as_ref()
2735 .map(|c| c.snapshot())
2736 .unwrap_or_default();
2737 let call_stats = crate::components::api_health::call_stats_for(&recent);
2738 let now_unix = SystemTime::now()
2739 .duration_since(UNIX_EPOCH)
2740 .map(|d| d.as_secs() as i64)
2741 .unwrap_or(0);
2742 let inputs = crate::metrics::MetricsInputs {
2743 bee_tui_version: env!("CARGO_PKG_VERSION"),
2744 health: &health,
2745 stamps: &stamps,
2746 swap: &swap,
2747 lottery: &lottery,
2748 topology: &topology,
2749 network: &network,
2750 transactions: &transactions,
2751 call_stats: &call_stats,
2752 now_unix,
2753 };
2754 crate::metrics::render(&inputs)
2755 })
2756}
2757
2758fn format_gate_line(g: &Gate) -> String {
2759 let glyphs = crate::theme::active().glyphs;
2760 let glyph = match g.status {
2761 GateStatus::Pass => glyphs.pass,
2762 GateStatus::Warn => glyphs.warn,
2763 GateStatus::Fail => glyphs.fail,
2764 GateStatus::Unknown => glyphs.bullet,
2765 };
2766 let mut s = format!(
2767 " [{glyph}] {label:<28} {value}\n",
2768 label = g.label,
2769 value = g.value
2770 );
2771 if let Some(why) = &g.why {
2772 s.push_str(&format!(" {} {why}\n", glyphs.continuation));
2773 }
2774 s
2775}
2776
2777fn path_only(url: &str) -> String {
2780 if let Some(idx) = url.find("//") {
2781 let after_scheme = &url[idx + 2..];
2782 if let Some(slash) = after_scheme.find('/') {
2783 return after_scheme[slash..].to_string();
2784 }
2785 return "/".into();
2786 }
2787 url.to_string()
2788}
2789
2790fn append(path: &PathBuf, s: &str) -> std::io::Result<()> {
2797 use std::io::Write;
2798 let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
2799 f.write_all(s.as_bytes())
2800}
2801
2802fn verbosity_rank(s: &str) -> u8 {
2808 match s {
2809 "all" | "trace" => 5,
2810 "debug" => 4,
2811 "info" | "1" => 3,
2812 "warning" | "warn" | "2" => 2,
2813 "error" | "3" => 1,
2814 _ => 0,
2815 }
2816}
2817
2818fn sanitize_for_filename(s: &str) -> String {
2822 s.chars()
2823 .map(|c| match c {
2824 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
2825 _ => '-',
2826 })
2827 .collect()
2828}
2829
2830#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2834pub enum QuitResolution {
2835 Confirm,
2837 Pending,
2840}
2841
2842fn resolve_quit_press(prev: Option<Instant>, now: Instant, window: Duration) -> QuitResolution {
2847 match prev {
2848 Some(t) if now.duration_since(t) <= window => QuitResolution::Confirm,
2849 _ => QuitResolution::Pending,
2850 }
2851}
2852
2853fn format_utc_now() -> String {
2854 let secs = SystemTime::now()
2855 .duration_since(UNIX_EPOCH)
2856 .map(|d| d.as_secs())
2857 .unwrap_or(0);
2858 let secs_in_day = secs % 86_400;
2859 let h = secs_in_day / 3_600;
2860 let m = (secs_in_day % 3_600) / 60;
2861 let s = secs_in_day % 60;
2862 format!("{h:02}:{m:02}:{s:02}")
2863}
2864
2865#[cfg(test)]
2866mod tests {
2867 use super::*;
2868
2869 #[test]
2870 fn format_utc_now_returns_eight_chars() {
2871 let s = format_utc_now();
2872 assert_eq!(s.len(), 8);
2873 assert_eq!(s.chars().nth(2), Some(':'));
2874 assert_eq!(s.chars().nth(5), Some(':'));
2875 }
2876
2877 #[test]
2878 fn path_only_strips_scheme_and_host() {
2879 assert_eq!(path_only("http://10.0.1.5:1633/status"), "/status");
2880 assert_eq!(
2881 path_only("https://bee.example.com/stamps?limit=10"),
2882 "/stamps?limit=10"
2883 );
2884 }
2885
2886 #[test]
2887 fn path_only_handles_no_path() {
2888 assert_eq!(path_only("http://localhost:1633"), "/");
2889 }
2890
2891 #[test]
2892 fn path_only_passes_relative_through() {
2893 assert_eq!(path_only("/already/relative"), "/already/relative");
2894 }
2895
2896 #[test]
2897 fn parse_pprof_arg_default_60() {
2898 assert_eq!(parse_pprof_arg("diagnose --pprof"), Some(60));
2899 assert_eq!(parse_pprof_arg("diag --pprof some other"), Some(60));
2900 }
2901
2902 #[test]
2903 fn parse_pprof_arg_with_explicit_seconds() {
2904 assert_eq!(parse_pprof_arg("diagnose --pprof=120"), Some(120));
2905 assert_eq!(parse_pprof_arg("diagnose --pprof=15 trailing"), Some(15));
2906 }
2907
2908 #[test]
2909 fn parse_pprof_arg_clamps_extreme_values() {
2910 assert_eq!(parse_pprof_arg("diagnose --pprof=0"), Some(1));
2912 assert_eq!(parse_pprof_arg("diagnose --pprof=9999"), Some(600));
2913 }
2914
2915 #[test]
2916 fn parse_pprof_arg_none_when_absent() {
2917 assert_eq!(parse_pprof_arg("diagnose"), None);
2918 assert_eq!(parse_pprof_arg("diag"), None);
2919 assert_eq!(parse_pprof_arg(""), None);
2920 }
2921
2922 #[test]
2923 fn parse_pprof_arg_ignores_garbage_value() {
2924 assert_eq!(parse_pprof_arg("diagnose --pprof=lol"), None);
2927 }
2928
2929 #[test]
2930 fn guess_content_type_known_extensions() {
2931 let p = std::path::PathBuf::from;
2932 assert_eq!(guess_content_type(&p("/tmp/x.html")), "text/html");
2933 assert_eq!(guess_content_type(&p("/tmp/x.json")), "application/json");
2934 assert_eq!(guess_content_type(&p("/tmp/x.PNG")), "image/png");
2935 assert_eq!(guess_content_type(&p("/tmp/x.tar.gz")), "application/gzip");
2936 }
2937
2938 #[test]
2939 fn guess_content_type_unknown_returns_empty() {
2940 let p = std::path::PathBuf::from;
2941 assert_eq!(guess_content_type(&p("/tmp/x.unknownext")), "");
2944 assert_eq!(guess_content_type(&p("/tmp/no-extension")), "");
2945 }
2946
2947 #[test]
2948 fn sanitize_for_filename_keeps_safe_chars() {
2949 assert_eq!(sanitize_for_filename("prod-1"), "prod-1");
2950 assert_eq!(sanitize_for_filename("lab_node"), "lab_node");
2951 }
2952
2953 #[test]
2954 fn sanitize_for_filename_replaces_unsafe_chars() {
2955 assert_eq!(sanitize_for_filename("a/b\\c d"), "a-b-c-d");
2956 assert_eq!(sanitize_for_filename("name:colon"), "name-colon");
2957 }
2958
2959 #[test]
2960 fn resolve_quit_press_first_press_is_pending() {
2961 let now = Instant::now();
2962 assert_eq!(
2963 resolve_quit_press(None, now, Duration::from_millis(1500)),
2964 QuitResolution::Pending
2965 );
2966 }
2967
2968 #[test]
2969 fn resolve_quit_press_second_press_inside_window_confirms() {
2970 let first = Instant::now();
2971 let window = Duration::from_millis(1500);
2972 let second = first + Duration::from_millis(500);
2973 assert_eq!(
2974 resolve_quit_press(Some(first), second, window),
2975 QuitResolution::Confirm
2976 );
2977 }
2978
2979 #[test]
2980 fn resolve_quit_press_second_press_after_window_resets_to_pending() {
2981 let first = Instant::now();
2985 let window = Duration::from_millis(1500);
2986 let second = first + Duration::from_millis(2_000);
2987 assert_eq!(
2988 resolve_quit_press(Some(first), second, window),
2989 QuitResolution::Pending
2990 );
2991 }
2992
2993 #[test]
2994 fn resolve_quit_press_at_window_boundary_confirms() {
2995 let first = Instant::now();
2998 let window = Duration::from_millis(1500);
2999 let second = first + window;
3000 assert_eq!(
3001 resolve_quit_press(Some(first), second, window),
3002 QuitResolution::Confirm
3003 );
3004 }
3005
3006 #[test]
3007 fn screen_keymap_covers_drill_screens() {
3008 for idx in [1usize, 4] {
3011 let rows = screen_keymap(idx);
3012 assert!(
3013 rows.iter().any(|(k, _)| k.contains("Enter")),
3014 "screen {idx} keymap must mention Enter (drill)"
3015 );
3016 assert!(
3017 rows.iter().any(|(k, _)| k.contains("Esc")),
3018 "screen {idx} keymap must mention Esc (close drill)"
3019 );
3020 }
3021 }
3022
3023 #[test]
3024 fn screen_keymap_lottery_advertises_rchash() {
3025 let rows = screen_keymap(3);
3026 assert!(rows.iter().any(|(k, _)| k.contains("r")));
3027 }
3028
3029 #[test]
3030 fn screen_keymap_unknown_index_is_empty_not_panic() {
3031 assert!(screen_keymap(999).is_empty());
3032 }
3033
3034 #[test]
3035 fn verbosity_rank_orders_loud_to_silent() {
3036 assert!(verbosity_rank("all") > verbosity_rank("debug"));
3037 assert!(verbosity_rank("debug") > verbosity_rank("info"));
3038 assert!(verbosity_rank("info") > verbosity_rank("warning"));
3039 assert!(verbosity_rank("warning") > verbosity_rank("error"));
3040 assert!(verbosity_rank("error") > verbosity_rank("unknown"));
3041 assert_eq!(verbosity_rank("info"), verbosity_rank("1"));
3043 assert_eq!(verbosity_rank("warning"), verbosity_rank("2"));
3044 }
3045
3046 #[test]
3047 fn filter_command_suggestions_empty_buffer_returns_all() {
3048 let matches = filter_command_suggestions("", KNOWN_COMMANDS);
3049 assert_eq!(matches.len(), KNOWN_COMMANDS.len());
3050 }
3051
3052 #[test]
3053 fn filter_command_suggestions_prefix_matches_case_insensitive() {
3054 let matches = filter_command_suggestions("Bu", KNOWN_COMMANDS);
3055 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
3056 assert!(names.contains(&"buy-preview"));
3057 assert!(names.contains(&"buy-suggest"));
3058 assert_eq!(names.len(), 2);
3059 }
3060
3061 #[test]
3062 fn filter_command_suggestions_unknown_prefix_is_empty() {
3063 let matches = filter_command_suggestions("xyz", KNOWN_COMMANDS);
3064 assert!(matches.is_empty());
3065 }
3066
3067 #[test]
3068 fn filter_command_suggestions_uses_first_token_only() {
3069 let matches = filter_command_suggestions("topup-preview a1b2 1000", KNOWN_COMMANDS);
3072 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
3073 assert_eq!(names, vec!["topup-preview"]);
3074 }
3075
3076 #[test]
3077 fn probe_chunk_is_4104_bytes_with_correct_span() {
3078 let chunk = build_synthetic_probe_chunk();
3080 assert_eq!(chunk.len(), 4104);
3081 let span = u64::from_le_bytes(chunk[..8].try_into().unwrap());
3082 assert_eq!(span, 4096);
3083 }
3084
3085 #[test]
3086 fn probe_chunk_payloads_are_unique_per_call() {
3087 let a = build_synthetic_probe_chunk();
3092 std::thread::sleep(Duration::from_micros(1));
3094 let b = build_synthetic_probe_chunk();
3095 assert_ne!(&a[8..24], &b[8..24]);
3096 }
3097
3098 #[test]
3099 fn short_hex_truncates_with_ellipsis() {
3100 assert_eq!(short_hex("a1b2c3d4e5f6", 8), "a1b2c3d4…");
3101 assert_eq!(short_hex("short", 8), "short");
3102 assert_eq!(short_hex("abcdefgh", 8), "abcdefgh");
3103 }
3104}