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 feed_timeline::FeedTimeline,
21 health::{Gate, GateStatus, Health},
22 log_pane::{BeeLogLine, LogPane, LogTab},
23 lottery::Lottery,
24 manifest::Manifest,
25 network::Network,
26 peers::Peers,
27 pins::Pins,
28 pubsub::Pubsub,
29 stamps::Stamps,
30 swap::Swap,
31 tags::Tags,
32 warmup::Warmup,
33 watchlist::Watchlist,
34 },
35 config::Config,
36 config_doctor, durability, economics_oracle, log_capture,
37 manifest_walker::{self, InspectResult},
38 pprof_bundle, stamp_preview,
39 state::State,
40 theme,
41 tui::{Event, Tui},
42 utility_verbs, version_check,
43 watch::{BeeWatch, HealthSnapshot, RefreshProfile},
44};
45
46pub struct App {
47 config: Config,
48 tick_rate: f64,
49 frame_rate: f64,
50 screens: Vec<Box<dyn Component>>,
54 current_screen: usize,
56 log_pane: LogPane,
60 state_path: PathBuf,
63 should_quit: bool,
64 should_suspend: bool,
65 mode: Mode,
66 last_tick_key_events: Vec<KeyEvent>,
67 action_tx: mpsc::UnboundedSender<Action>,
68 action_rx: mpsc::UnboundedReceiver<Action>,
69 root_cancel: CancellationToken,
72 #[allow(dead_code)]
75 api: Arc<ApiClient>,
76 watch: BeeWatch,
78 health_rx: watch::Receiver<HealthSnapshot>,
81 command_buffer: Option<String>,
84 command_suggestion_index: usize,
89 command_status: Option<CommandStatus>,
93 help_visible: bool,
96 quit_pending: Option<Instant>,
102 supervisor: Option<BeeSupervisor>,
106 bee_status: BeeStatus,
111 bee_log_rx: Option<mpsc::UnboundedReceiver<(LogTab, BeeLogLine)>>,
115 cmd_status_tx: mpsc::UnboundedSender<CommandStatus>,
121 cmd_status_rx: mpsc::UnboundedReceiver<CommandStatus>,
122 durability_tx: mpsc::UnboundedSender<crate::durability::DurabilityResult>,
128 durability_rx: mpsc::UnboundedReceiver<crate::durability::DurabilityResult>,
129 feed_timeline_tx: mpsc::UnboundedSender<FeedTimelineMessage>,
133 feed_timeline_rx: mpsc::UnboundedReceiver<FeedTimelineMessage>,
134 watch_refs: std::collections::HashMap<String, CancellationToken>,
138 pubsub_subs: std::collections::HashMap<String, CancellationToken>,
143 pubsub_msg_tx: mpsc::UnboundedSender<crate::pubsub::PubsubMessage>,
146 pubsub_msg_rx: mpsc::UnboundedReceiver<crate::pubsub::PubsubMessage>,
147 alert_state: crate::alerts::AlertState,
154}
155
156const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
159
160#[derive(Debug, Clone)]
163pub enum CommandStatus {
164 Info(String),
165 Err(String),
166}
167
168#[derive(Debug, Clone)]
172pub enum FeedTimelineMessage {
173 Loaded(crate::feed_timeline::Timeline),
174 Failed(String),
175}
176
177const SCREEN_NAMES: &[&str] = &[
180 "Health",
181 "Stamps",
182 "Swap",
183 "Lottery",
184 "Peers",
185 "Network",
186 "Warmup",
187 "API",
188 "Tags",
189 "Pins",
190 "Manifest",
191 "Watchlist",
192 "FeedTimeline",
193 "Pubsub",
194];
195
196const KNOWN_COMMANDS: &[(&str, &str)] = &[
207 ("health", "S1 Health screen"),
208 ("stamps", "S2 Stamps screen"),
209 ("swap", "S3 SWAP / cheques screen"),
210 ("lottery", "S4 Lottery + rchash"),
211 ("peers", "S6 Peers + bin saturation"),
212 ("network", "S7 Network / NAT"),
213 ("warmup", "S5 Warmup checklist"),
214 ("api", "S8 RPC / API health"),
215 ("tags", "S9 Tags / uploads"),
216 ("pins", "S11 Pins screen"),
217 ("topup-preview", "<batch> <amount-plur> — predict topup"),
218 ("dilute-preview", "<batch> <new-depth> — predict dilute"),
219 ("extend-preview", "<batch> <duration> — predict extend"),
220 ("buy-preview", "<depth> <amount-plur> — predict fresh buy"),
221 ("buy-suggest", "<size> <duration> — minimum (depth, amount)"),
222 (
223 "plan-batch",
224 "<batch> [usage-thr] [ttl-thr] [extra-depth] — unified topup+dilute plan",
225 ),
226 (
227 "check-version",
228 "compare running Bee version with GitHub's latest release",
229 ),
230 (
231 "config-doctor",
232 "audit bee.yaml for deprecated keys (read-only, never modifies)",
233 ),
234 ("price", "fetch xBZZ → USD spot price"),
235 (
236 "basefee",
237 "fetch Gnosis basefee + tip (requires [economics].gnosis_rpc_url)",
238 ),
239 (
240 "probe-upload",
241 "<batch> — single 4 KiB chunk, end-to-end probe",
242 ),
243 (
244 "upload-file",
245 "<path> <batch> — upload a single local file, return Swarm ref",
246 ),
247 (
248 "upload-collection",
249 "<dir> <batch> — recursive directory upload, return Swarm ref",
250 ),
251 (
252 "feed-probe",
253 "<owner> <topic> — latest update for a feed (read-only lookup)",
254 ),
255 (
256 "feed-timeline",
257 "<owner> <topic> [N] — walk a feed's history, open S14",
258 ),
259 (
260 "manifest",
261 "<ref> — open Mantaray tree browser at a reference",
262 ),
263 (
264 "inspect",
265 "<ref> — what is this? auto-detects manifest vs raw chunk",
266 ),
267 (
268 "durability-check",
269 "<ref> — walk chunk graph, report total / lost / errors",
270 ),
271 (
272 "watch-ref",
273 "<ref> [interval] — run :durability-check every interval (default 60s)",
274 ),
275 (
276 "watch-ref-stop",
277 "[ref] — stop one :watch-ref daemon (or all if no arg)",
278 ),
279 (
280 "pubsub-pss",
281 "<topic> — subscribe to PSS messages on a topic, surface in S15",
282 ),
283 (
284 "pubsub-gsoc",
285 "<owner> <identifier> — subscribe to a GSOC SOC, surface in S15",
286 ),
287 (
288 "pubsub-stop",
289 "[sub-id] — stop one pubsub subscription (or all if no arg)",
290 ),
291 ("watchlist", "S13 Watchlist — durability-check history"),
292 (
293 "hash",
294 "<path> — Swarm reference of a local file/dir (offline)",
295 ),
296 ("cid", "<ref> [manifest|feed] — encode reference as CID"),
297 ("depth-table", "Print canonical depth → capacity table"),
298 (
299 "gsoc-mine",
300 "<overlay> <id> — mine a GSOC signer (CPU work)",
301 ),
302 (
303 "pss-target",
304 "<overlay> — first 4 hex chars (Bee's max prefix)",
305 ),
306 (
307 "diagnose",
308 "[--pprof[=N]] Export snapshot (+ optional CPU profile + trace)",
309 ),
310 ("pins-check", "Bulk integrity walk to a file"),
311 ("loggers", "Dump live logger registry"),
312 ("set-logger", "<expr> <level> — change a logger's verbosity"),
313 ("context", "<name> — switch node profile"),
314 ("quit", "Exit the cockpit"),
315];
316
317fn parse_pprof_arg(line: &str) -> Option<u32> {
322 for tok in line.split_whitespace() {
323 if tok == "--pprof" {
324 return Some(60);
325 }
326 if let Some(rest) = tok.strip_prefix("--pprof=") {
327 if let Ok(n) = rest.parse::<u32>() {
328 return Some(n.clamp(1, 600));
329 }
330 }
331 }
332 None
333}
334
335fn filter_command_suggestions<'a>(
339 buffer: &str,
340 catalog: &'a [(&'a str, &'a str)],
341) -> Vec<&'a (&'a str, &'a str)> {
342 let head = buffer
343 .split_whitespace()
344 .next()
345 .unwrap_or("")
346 .to_ascii_lowercase();
347 catalog
348 .iter()
349 .filter(|(name, _)| name.starts_with(&head))
350 .collect()
351}
352
353#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
354pub enum Mode {
355 #[default]
356 Home,
357}
358
359#[derive(Debug, Default)]
362pub struct AppOverrides {
363 pub ascii: bool,
365 pub no_color: bool,
367 pub bee_bin: Option<PathBuf>,
369 pub bee_config: Option<PathBuf>,
371}
372
373const BEE_API_READY_TIMEOUT: Duration = Duration::from_secs(60);
378
379impl App {
380 pub async fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
381 Self::with_overrides(tick_rate, frame_rate, AppOverrides::default()).await
382 }
383
384 pub async fn with_overrides(
389 tick_rate: f64,
390 frame_rate: f64,
391 overrides: AppOverrides,
392 ) -> color_eyre::Result<Self> {
393 let (action_tx, action_rx) = mpsc::unbounded_channel();
394 let (cmd_status_tx, cmd_status_rx) = mpsc::unbounded_channel();
395 let (durability_tx, durability_rx) = mpsc::unbounded_channel();
396 let (feed_timeline_tx, feed_timeline_rx) = mpsc::unbounded_channel();
397 let (pubsub_msg_tx, pubsub_msg_rx) = mpsc::unbounded_channel();
398 let config = Config::new()?;
399 let force_no_color = overrides.no_color || theme::no_color_env();
402 theme::install_with_overrides(&config.ui, force_no_color, overrides.ascii);
403
404 let node = config
407 .active_node()
408 .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
409 let api = Arc::new(ApiClient::from_node(node)?);
410
411 let bee_bin = overrides
413 .bee_bin
414 .or_else(|| config.bee.as_ref().map(|b| b.bin.clone()));
415 let bee_config = overrides
416 .bee_config
417 .or_else(|| config.bee.as_ref().map(|b| b.config.clone()));
418 let bee_logs = config
421 .bee
422 .as_ref()
423 .map(|b| b.logs.clone())
424 .unwrap_or_default();
425 let supervisor = match (bee_bin, bee_config) {
426 (Some(bin), Some(cfg)) => {
427 eprintln!("bee-tui: spawning bee {bin:?} --config {cfg:?}");
428 let mut sup = BeeSupervisor::spawn(&bin, &cfg, bee_logs)?;
429 eprintln!(
430 "bee-tui: log → {} (will appear in the cockpit's bottom pane)",
431 sup.log_path().display()
432 );
433 eprintln!(
434 "bee-tui: waiting for {} to respond on /health (up to {:?})...",
435 api.url, BEE_API_READY_TIMEOUT
436 );
437 sup.wait_for_api(&api.url, BEE_API_READY_TIMEOUT).await?;
438 eprintln!("bee-tui: bee ready, opening cockpit");
439 Some(sup)
440 }
441 (Some(_), None) | (None, Some(_)) => {
442 return Err(eyre!(
443 "[bee].bin and [bee].config must both be set (or both unset). \
444 Use --bee-bin AND --bee-config, or both fields in config.toml."
445 ));
446 }
447 (None, None) => None,
448 };
449
450 let refresh = RefreshProfile::from_config(&config.ui.refresh);
457 let root_cancel = CancellationToken::new();
458 let watch = BeeWatch::start_with_profile(api.clone(), &root_cancel, refresh);
459 let health_rx = watch.health();
460
461 let market_rx = if config.economics.enable_market_tile {
465 Some(economics_oracle::spawn_poller(
466 config.economics.gnosis_rpc_url.clone(),
467 root_cancel.child_token(),
468 ))
469 } else {
470 None
471 };
472
473 let screens = build_screens(&api, &watch, market_rx);
474 let (persisted, state_path) = State::load();
479 let initial_tab = LogTab::from_kebab(&persisted.log_pane_active_tab);
480 let mut log_pane = LogPane::new(
481 log_capture::handle(),
482 initial_tab,
483 persisted.log_pane_height,
484 );
485 log_pane.set_spawn_active(supervisor.is_some());
486 if let Some(c) = log_capture::cockpit_handle() {
487 log_pane.set_cockpit_capture(c);
488 }
489
490 let bee_log_rx = supervisor.as_ref().map(|sup| {
496 let (tx, rx) = mpsc::unbounded_channel();
497 crate::bee_log_tailer::spawn(
498 sup.log_path().to_path_buf(),
499 tx,
500 root_cancel.child_token(),
501 );
502 rx
503 });
504
505 if config.metrics.enabled {
512 match config.metrics.addr.parse::<std::net::SocketAddr>() {
513 Ok(bind_addr) => {
514 let render_fn = build_metrics_render_fn(watch.clone(), log_capture::handle());
515 let cancel = root_cancel.child_token();
516 match crate::metrics_server::spawn(bind_addr, render_fn, cancel).await {
517 Ok(actual) => {
518 eprintln!(
519 "bee-tui: metrics endpoint serving /metrics on http://{actual}"
520 );
521 }
522 Err(e) => {
523 tracing::error!(
524 "metrics: failed to start endpoint on {bind_addr}: {e}"
525 );
526 }
527 }
528 }
529 Err(e) => {
530 tracing::error!(
531 "metrics: invalid [metrics].addr {:?}: {e}",
532 config.metrics.addr
533 );
534 }
535 }
536 }
537
538 let config_alerts_debounce = config.alerts.debounce_secs;
539
540 Ok(Self {
541 tick_rate,
542 frame_rate,
543 screens,
544 current_screen: 0,
545 log_pane,
546 state_path,
547 should_quit: false,
548 should_suspend: false,
549 config,
550 mode: Mode::Home,
551 last_tick_key_events: Vec::new(),
552 action_tx,
553 action_rx,
554 root_cancel,
555 api,
556 watch,
557 health_rx,
558 command_buffer: None,
559 command_suggestion_index: 0,
560 command_status: None,
561 help_visible: false,
562 quit_pending: None,
563 supervisor,
564 bee_status: BeeStatus::Running,
565 bee_log_rx,
566 cmd_status_tx,
567 cmd_status_rx,
568 durability_tx,
569 durability_rx,
570 feed_timeline_tx,
571 feed_timeline_rx,
572 watch_refs: std::collections::HashMap::new(),
573 pubsub_subs: std::collections::HashMap::new(),
574 pubsub_msg_tx,
575 pubsub_msg_rx,
576 alert_state: crate::alerts::AlertState::new(config_alerts_debounce),
577 })
578 }
579
580 pub async fn run(&mut self) -> color_eyre::Result<()> {
581 let mut tui = Tui::new()?
582 .tick_rate(self.tick_rate)
584 .frame_rate(self.frame_rate);
585 tui.enter()?;
586
587 let tx = self.action_tx.clone();
588 let cfg = self.config.clone();
589 let size = tui.size()?;
590 for component in self.iter_components_mut() {
591 component.register_action_handler(tx.clone())?;
592 component.register_config_handler(cfg.clone())?;
593 component.init(size)?;
594 }
595
596 let action_tx = self.action_tx.clone();
597 loop {
598 self.handle_events(&mut tui).await?;
599 self.handle_actions(&mut tui)?;
600 if self.should_suspend {
601 tui.suspend()?;
602 action_tx.send(Action::Resume)?;
603 action_tx.send(Action::ClearScreen)?;
604 tui.enter()?;
606 } else if self.should_quit {
607 tui.stop()?;
608 break;
609 }
610 }
611 self.watch.shutdown();
613 self.root_cancel.cancel();
614 let snapshot = State {
618 log_pane_height: self.log_pane.height(),
619 log_pane_active_tab: self.log_pane.active_tab().to_kebab().to_string(),
620 };
621 snapshot.save(&self.state_path);
622 if let Some(sup) = self.supervisor.take() {
626 let final_status = sup.shutdown_default().await;
627 tracing::info!("bee child exited: {}", final_status.label());
628 }
629 tui.exit()?;
630 Ok(())
631 }
632
633 async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
634 let Some(event) = tui.next_event().await else {
635 return Ok(());
636 };
637 let action_tx = self.action_tx.clone();
638 let modal_before = self.command_buffer.is_some() || self.help_visible;
645 match event {
646 Event::Quit => action_tx.send(Action::Quit)?,
647 Event::Tick => action_tx.send(Action::Tick)?,
648 Event::Render => action_tx.send(Action::Render)?,
649 Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
650 Event::Key(key) => self.handle_key_event(key)?,
651 _ => {}
652 }
653 let modal_after = self.command_buffer.is_some() || self.help_visible;
654 let propagate = !((modal_before || modal_after) && matches!(event, Event::Key(_)));
657 if propagate {
658 for component in self.iter_components_mut() {
659 if let Some(action) = component.handle_events(Some(event.clone()))? {
660 action_tx.send(action)?;
661 }
662 }
663 }
664 Ok(())
665 }
666
667 fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut dyn Component> {
672 self.screens
673 .iter_mut()
674 .map(|c| c.as_mut() as &mut dyn Component)
675 .chain(std::iter::once(&mut self.log_pane as &mut dyn Component))
676 }
677
678 fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
679 if self.command_buffer.is_some() {
683 self.handle_command_mode_key(key)?;
684 return Ok(());
685 }
686 if self.help_visible {
690 match key.code {
691 crossterm::event::KeyCode::Esc
692 | crossterm::event::KeyCode::Char('?')
693 | crossterm::event::KeyCode::Char('q') => {
694 self.help_visible = false;
695 }
696 _ => {}
697 }
698 return Ok(());
699 }
700 if matches!(key.code, crossterm::event::KeyCode::Char('?')) {
704 self.help_visible = true;
705 return Ok(());
706 }
707 let action_tx = self.action_tx.clone();
708 if matches!(key.code, crossterm::event::KeyCode::Char(':')) {
710 self.command_buffer = Some(String::new());
711 self.command_status = None;
712 return Ok(());
713 }
714 if matches!(key.code, crossterm::event::KeyCode::Tab) {
719 if !self.screens.is_empty() {
720 self.current_screen = (self.current_screen + 1) % self.screens.len();
721 debug!(
722 "switched to screen {}",
723 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
724 );
725 }
726 return Ok(());
727 }
728 if matches!(key.code, crossterm::event::KeyCode::BackTab) {
729 if !self.screens.is_empty() {
730 let len = self.screens.len();
731 self.current_screen = (self.current_screen + len - 1) % len;
732 debug!(
733 "switched to screen {}",
734 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
735 );
736 }
737 return Ok(());
738 }
739 if matches!(key.code, crossterm::event::KeyCode::Char('['))
745 && key.modifiers == crossterm::event::KeyModifiers::NONE
746 {
747 self.log_pane.prev_tab();
748 return Ok(());
749 }
750 if matches!(key.code, crossterm::event::KeyCode::Char(']'))
751 && key.modifiers == crossterm::event::KeyModifiers::NONE
752 {
753 self.log_pane.next_tab();
754 return Ok(());
755 }
756 if matches!(key.code, crossterm::event::KeyCode::Char('+'))
757 && key.modifiers == crossterm::event::KeyModifiers::NONE
758 {
759 self.log_pane.grow();
760 return Ok(());
761 }
762 if matches!(key.code, crossterm::event::KeyCode::Char('-'))
763 && key.modifiers == crossterm::event::KeyModifiers::NONE
764 {
765 self.log_pane.shrink();
766 return Ok(());
767 }
768 if key.modifiers == crossterm::event::KeyModifiers::SHIFT {
773 match key.code {
774 crossterm::event::KeyCode::Up => {
775 self.log_pane.scroll_up(1);
776 return Ok(());
777 }
778 crossterm::event::KeyCode::Down => {
779 self.log_pane.scroll_down(1);
780 return Ok(());
781 }
782 crossterm::event::KeyCode::PageUp => {
783 self.log_pane.scroll_up(10);
784 return Ok(());
785 }
786 crossterm::event::KeyCode::PageDown => {
787 self.log_pane.scroll_down(10);
788 return Ok(());
789 }
790 crossterm::event::KeyCode::End => {
791 self.log_pane.resume_tail();
792 return Ok(());
793 }
794 crossterm::event::KeyCode::Left => {
800 self.log_pane.scroll_left(8);
801 return Ok(());
802 }
803 crossterm::event::KeyCode::Right => {
804 self.log_pane.scroll_right(8);
805 return Ok(());
806 }
807 _ => {}
808 }
809 }
810 if matches!(key.code, crossterm::event::KeyCode::Char('q'))
816 && key.modifiers == crossterm::event::KeyModifiers::NONE
817 {
818 match resolve_quit_press(self.quit_pending, Instant::now(), QUIT_CONFIRM_WINDOW) {
819 QuitResolution::Confirm => {
820 self.quit_pending = None;
821 self.action_tx.send(Action::Quit)?;
822 }
823 QuitResolution::Pending => {
824 self.quit_pending = Some(Instant::now());
825 self.command_status = Some(CommandStatus::Info(
826 "press q again to quit (Esc cancels)".into(),
827 ));
828 }
829 }
830 return Ok(());
831 }
832 if self.quit_pending.is_some() {
836 self.quit_pending = None;
837 }
838 let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
839 return Ok(());
840 };
841 match keymap.get(&vec![key]) {
842 Some(action) => {
843 info!("Got action: {action:?}");
844 action_tx.send(action.clone())?;
845 }
846 _ => {
847 self.last_tick_key_events.push(key);
850
851 if let Some(action) = keymap.get(&self.last_tick_key_events) {
853 info!("Got action: {action:?}");
854 action_tx.send(action.clone())?;
855 }
856 }
857 }
858 Ok(())
859 }
860
861 fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
862 use crossterm::event::KeyCode;
863 let buf = match self.command_buffer.as_mut() {
864 Some(b) => b,
865 None => return Ok(()),
866 };
867 match key.code {
868 KeyCode::Esc => {
869 self.command_buffer = None;
871 self.command_suggestion_index = 0;
872 }
873 KeyCode::Enter => {
874 let line = std::mem::take(buf);
875 self.command_buffer = None;
876 self.command_suggestion_index = 0;
877 self.execute_command(&line)?;
878 }
879 KeyCode::Up => {
880 self.command_suggestion_index = self.command_suggestion_index.saturating_sub(1);
883 }
884 KeyCode::Down => {
885 let n = filter_command_suggestions(buf, KNOWN_COMMANDS).len();
886 if n > 0 && self.command_suggestion_index + 1 < n {
887 self.command_suggestion_index += 1;
888 }
889 }
890 KeyCode::Tab => {
891 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
895 if let Some((name, _)) = matches.get(self.command_suggestion_index) {
896 let rest = buf
897 .split_once(char::is_whitespace)
898 .map(|(_, tail)| tail)
899 .unwrap_or("");
900 let new = if rest.is_empty() {
901 format!("{name} ")
902 } else {
903 format!("{name} {rest}")
904 };
905 buf.clear();
906 buf.push_str(&new);
907 self.command_suggestion_index = 0;
908 }
909 }
910 KeyCode::Backspace => {
911 buf.pop();
912 self.command_suggestion_index = 0;
913 }
914 KeyCode::Char(c) => {
915 buf.push(c);
916 self.command_suggestion_index = 0;
917 }
918 _ => {}
919 }
920 Ok(())
921 }
922
923 fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
926 let trimmed = line.trim();
927 if trimmed.is_empty() {
928 return Ok(());
929 }
930 let head = trimmed.split_whitespace().next().unwrap_or("");
931 match head {
932 "q" | "quit" => {
933 self.action_tx.send(Action::Quit)?;
934 self.command_status = Some(CommandStatus::Info("quitting".into()));
935 }
936 "diagnose" | "diag" => {
937 let pprof_secs = parse_pprof_arg(trimmed);
938 if let Some(secs) = pprof_secs {
939 self.command_status = Some(self.start_diagnose_with_pprof(secs));
940 } else {
941 self.command_status = Some(match self.export_diagnostic_bundle() {
942 Ok(path) => CommandStatus::Info(format!(
943 "diagnostic bundle exported to {}",
944 path.display()
945 )),
946 Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
947 });
948 }
949 }
950 "pins-check" => {
951 self.command_status = Some(match self.start_pins_check() {
957 Ok(path) => CommandStatus::Info(format!(
958 "pins integrity check running → {} (tail to watch progress)",
959 path.display()
960 )),
961 Err(e) => CommandStatus::Err(format!("pins-check failed to start: {e}")),
962 });
963 }
964 "loggers" => {
965 self.command_status = Some(match self.start_loggers_dump() {
966 Ok(path) => CommandStatus::Info(format!(
967 "loggers snapshot writing → {} (open when ready)",
968 path.display()
969 )),
970 Err(e) => CommandStatus::Err(format!("loggers failed to start: {e}")),
971 });
972 }
973 "set-logger" => {
974 let mut parts = trimmed.split_whitespace();
975 let _ = parts.next(); let expr = parts.next().unwrap_or("");
977 let level = parts.next().unwrap_or("");
978 if expr.is_empty() || level.is_empty() {
979 self.command_status = Some(CommandStatus::Err(
980 "usage: :set-logger <expr> <level> (level: none|error|warning|info|debug|all; expr: e.g. node/pushsync or '.' for all)"
981 .into(),
982 ));
983 return Ok(());
984 }
985 self.start_set_logger(expr.to_string(), level.to_string());
986 self.command_status = Some(CommandStatus::Info(format!(
987 "set-logger {expr:?} → {level:?} (PUT in-flight; check :loggers to verify)"
988 )));
989 }
990 "topup-preview" => {
991 self.command_status = Some(self.run_topup_preview(trimmed));
992 }
993 "dilute-preview" => {
994 self.command_status = Some(self.run_dilute_preview(trimmed));
995 }
996 "extend-preview" => {
997 self.command_status = Some(self.run_extend_preview(trimmed));
998 }
999 "buy-preview" => {
1000 self.command_status = Some(self.run_buy_preview(trimmed));
1001 }
1002 "buy-suggest" => {
1003 self.command_status = Some(self.run_buy_suggest(trimmed));
1004 }
1005 "plan-batch" => {
1006 self.command_status = Some(self.run_plan_batch(trimmed));
1007 }
1008 "check-version" => {
1009 self.command_status = Some(self.run_check_version());
1010 }
1011 "config-doctor" => {
1012 self.command_status = Some(self.run_config_doctor());
1013 }
1014 "price" => {
1015 self.command_status = Some(self.run_price());
1016 }
1017 "basefee" => {
1018 self.command_status = Some(self.run_basefee());
1019 }
1020 "probe-upload" => {
1021 self.command_status = Some(self.run_probe_upload(trimmed));
1022 }
1023 "upload-file" => {
1024 self.command_status = Some(self.run_upload_file(trimmed));
1025 }
1026 "upload-collection" => {
1027 self.command_status = Some(self.run_upload_collection(trimmed));
1028 }
1029 "feed-probe" => {
1030 self.command_status = Some(self.run_feed_probe(trimmed));
1031 }
1032 "feed-timeline" => {
1033 self.command_status = Some(self.run_feed_timeline(trimmed));
1034 }
1035 "hash" => {
1036 self.command_status = Some(self.run_hash(trimmed));
1037 }
1038 "cid" => {
1039 self.command_status = Some(self.run_cid(trimmed));
1040 }
1041 "depth-table" => {
1042 self.command_status = Some(self.run_depth_table());
1043 }
1044 "gsoc-mine" => {
1045 self.command_status = Some(self.run_gsoc_mine(trimmed));
1046 }
1047 "pss-target" => {
1048 self.command_status = Some(self.run_pss_target(trimmed));
1049 }
1050 "manifest" => {
1051 self.command_status = Some(self.run_manifest(trimmed));
1052 }
1053 "inspect" => {
1054 self.command_status = Some(self.run_inspect(trimmed));
1055 }
1056 "durability-check" => {
1057 self.command_status = Some(self.run_durability_check(trimmed));
1058 }
1059 "watch-ref" => {
1060 self.command_status = Some(self.run_watch_ref(trimmed));
1061 }
1062 "watch-ref-stop" => {
1063 self.command_status = Some(self.run_watch_ref_stop(trimmed));
1064 }
1065 "pubsub-pss" => {
1066 self.command_status = Some(self.run_pubsub_pss(trimmed));
1067 }
1068 "pubsub-gsoc" => {
1069 self.command_status = Some(self.run_pubsub_gsoc(trimmed));
1070 }
1071 "pubsub-stop" => {
1072 self.command_status = Some(self.run_pubsub_stop(trimmed));
1073 }
1074 "context" | "ctx" => {
1075 let target = trimmed.split_whitespace().nth(1).unwrap_or("");
1076 if target.is_empty() {
1077 let known: Vec<String> =
1078 self.config.nodes.iter().map(|n| n.name.clone()).collect();
1079 self.command_status = Some(CommandStatus::Err(format!(
1080 "usage: :context <name> (known: {})",
1081 known.join(", ")
1082 )));
1083 return Ok(());
1084 }
1085 self.command_status = Some(match self.switch_context(target) {
1086 Ok(()) => CommandStatus::Info(format!(
1087 "switched to context {target} ({})",
1088 self.api.url
1089 )),
1090 Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
1091 });
1092 }
1093 screen
1094 if SCREEN_NAMES
1095 .iter()
1096 .any(|name| name.eq_ignore_ascii_case(screen)) =>
1097 {
1098 if let Some(idx) = SCREEN_NAMES
1099 .iter()
1100 .position(|name| name.eq_ignore_ascii_case(screen))
1101 {
1102 self.current_screen = idx;
1103 self.command_status =
1104 Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
1105 }
1106 }
1107 other => {
1108 self.command_status = Some(CommandStatus::Err(format!(
1109 "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, :feed-timeline, :watch-ref, :watch-ref-stop, :pubsub-pss, :pubsub-gsoc, :pubsub-stop, :hash, :cid, :depth-table, :gsoc-mine, :pss-target, :context, :quit)"
1110 )));
1111 }
1112 }
1113 Ok(())
1114 }
1115
1116 fn run_topup_preview(&self, line: &str) -> CommandStatus {
1120 let parts: Vec<&str> = line.split_whitespace().collect();
1121 let (prefix, amount_str) = match parts.as_slice() {
1122 [_, prefix, amount, ..] => (*prefix, *amount),
1123 _ => {
1124 return CommandStatus::Err(
1125 "usage: :topup-preview <batch-prefix> <amount-plur-per-chunk>".into(),
1126 );
1127 }
1128 };
1129 let chain = match self.health_rx.borrow().chain_state.clone() {
1130 Some(c) => c,
1131 None => return CommandStatus::Err("chain state not loaded yet".into()),
1132 };
1133 let stamps = self.watch.stamps().borrow().clone();
1134 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1135 Ok(b) => b.clone(),
1136 Err(e) => return CommandStatus::Err(e),
1137 };
1138 let amount = match stamp_preview::parse_plur_amount(amount_str) {
1139 Ok(a) => a,
1140 Err(e) => return CommandStatus::Err(e),
1141 };
1142 match stamp_preview::topup_preview(&batch, amount, &chain) {
1143 Ok(p) => CommandStatus::Info(p.summary()),
1144 Err(e) => CommandStatus::Err(e),
1145 }
1146 }
1147
1148 fn run_dilute_preview(&self, line: &str) -> CommandStatus {
1152 let parts: Vec<&str> = line.split_whitespace().collect();
1153 let (prefix, depth_str) = match parts.as_slice() {
1154 [_, prefix, depth, ..] => (*prefix, *depth),
1155 _ => {
1156 return CommandStatus::Err(
1157 "usage: :dilute-preview <batch-prefix> <new-depth>".into(),
1158 );
1159 }
1160 };
1161 let new_depth: u8 = match depth_str.parse() {
1162 Ok(d) => d,
1163 Err(_) => {
1164 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
1165 }
1166 };
1167 let stamps = self.watch.stamps().borrow().clone();
1168 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1169 Ok(b) => b.clone(),
1170 Err(e) => return CommandStatus::Err(e),
1171 };
1172 match stamp_preview::dilute_preview(&batch, new_depth) {
1173 Ok(p) => CommandStatus::Info(p.summary()),
1174 Err(e) => CommandStatus::Err(e),
1175 }
1176 }
1177
1178 fn run_extend_preview(&self, line: &str) -> CommandStatus {
1181 let parts: Vec<&str> = line.split_whitespace().collect();
1182 let (prefix, duration_str) = match parts.as_slice() {
1183 [_, prefix, duration, ..] => (*prefix, *duration),
1184 _ => {
1185 return CommandStatus::Err(
1186 "usage: :extend-preview <batch-prefix> <duration> (e.g. 30d, 12h, 90m, 45s, or plain seconds)".into(),
1187 );
1188 }
1189 };
1190 let extension_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
1191 Ok(s) => s,
1192 Err(e) => return CommandStatus::Err(e),
1193 };
1194 let chain = match self.health_rx.borrow().chain_state.clone() {
1195 Some(c) => c,
1196 None => return CommandStatus::Err("chain state not loaded yet".into()),
1197 };
1198 let stamps = self.watch.stamps().borrow().clone();
1199 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1200 Ok(b) => b.clone(),
1201 Err(e) => return CommandStatus::Err(e),
1202 };
1203 match stamp_preview::extend_preview(&batch, extension_seconds, &chain) {
1204 Ok(p) => CommandStatus::Info(p.summary()),
1205 Err(e) => CommandStatus::Err(e),
1206 }
1207 }
1208
1209 fn run_probe_upload(&self, line: &str) -> CommandStatus {
1221 let parts: Vec<&str> = line.split_whitespace().collect();
1222 let prefix = match parts.as_slice() {
1223 [_, prefix, ..] => *prefix,
1224 _ => {
1225 return CommandStatus::Err(
1226 "usage: :probe-upload <batch-prefix> (uploads one synthetic 4 KiB chunk)"
1227 .into(),
1228 );
1229 }
1230 };
1231 let stamps = self.watch.stamps().borrow().clone();
1232 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1233 Ok(b) => b.clone(),
1234 Err(e) => return CommandStatus::Err(e),
1235 };
1236 if !batch.usable {
1237 return CommandStatus::Err(format!(
1238 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1239 short_hex(&batch.batch_id.to_hex(), 8),
1240 ));
1241 }
1242 if batch.batch_ttl <= 0 {
1243 return CommandStatus::Err(format!(
1244 "batch {} is expired — pick another",
1245 short_hex(&batch.batch_id.to_hex(), 8),
1246 ));
1247 }
1248
1249 let api = self.api.clone();
1250 let tx = self.cmd_status_tx.clone();
1251 let batch_id = batch.batch_id;
1252 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1253 let task_short = batch_short.clone();
1254 tokio::spawn(async move {
1255 let chunk = build_synthetic_probe_chunk();
1256 let started = Instant::now();
1257 let result = api.bee().file().upload_chunk(&batch_id, chunk, None).await;
1258 let elapsed_ms = started.elapsed().as_millis();
1259 let status = match result {
1260 Ok(res) => CommandStatus::Info(format!(
1261 "probe-upload OK in {elapsed_ms}ms — batch {task_short}, ref {}",
1262 short_hex(&res.reference.to_hex(), 8),
1263 )),
1264 Err(e) => CommandStatus::Err(format!(
1265 "probe-upload FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1266 )),
1267 };
1268 let _ = tx.send(status);
1269 });
1270
1271 CommandStatus::Info(format!(
1272 "probe-upload to batch {batch_short} in flight — result will replace this line"
1273 ))
1274 }
1275
1276 fn run_upload_file(&self, line: &str) -> CommandStatus {
1284 let parts: Vec<&str> = line.split_whitespace().collect();
1285 let (path_str, prefix) = match parts.as_slice() {
1286 [_, p, b, ..] => (*p, *b),
1287 _ => {
1288 return CommandStatus::Err("usage: :upload-file <path> <batch-prefix>".into());
1289 }
1290 };
1291 let path = std::path::PathBuf::from(path_str);
1292 let meta = match std::fs::metadata(&path) {
1293 Ok(m) => m,
1294 Err(e) => return CommandStatus::Err(format!("stat {path_str}: {e}")),
1295 };
1296 if meta.is_dir() {
1297 return CommandStatus::Err(format!(
1298 "{path_str} is a directory — :upload-file is single-file only (collection upload coming in a later release)"
1299 ));
1300 }
1301 const MAX_FILE_BYTES: u64 = 256 * 1024 * 1024;
1302 if meta.len() > MAX_FILE_BYTES {
1303 return CommandStatus::Err(format!(
1304 "{path_str} is {} — over the {}-MiB cockpit ceiling; use swarm-cli for larger uploads",
1305 meta.len(),
1306 MAX_FILE_BYTES / (1024 * 1024),
1307 ));
1308 }
1309 let stamps = self.watch.stamps().borrow().clone();
1310 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1311 Ok(b) => b.clone(),
1312 Err(e) => return CommandStatus::Err(e),
1313 };
1314 if !batch.usable {
1315 return CommandStatus::Err(format!(
1316 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1317 short_hex(&batch.batch_id.to_hex(), 8),
1318 ));
1319 }
1320 if batch.batch_ttl <= 0 {
1321 return CommandStatus::Err(format!(
1322 "batch {} is expired — pick another",
1323 short_hex(&batch.batch_id.to_hex(), 8),
1324 ));
1325 }
1326
1327 let api = self.api.clone();
1328 let tx = self.cmd_status_tx.clone();
1329 let batch_id = batch.batch_id;
1330 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1331 let task_short = batch_short.clone();
1332 let file_size = meta.len();
1333 let name = path
1334 .file_name()
1335 .and_then(|n| n.to_str())
1336 .unwrap_or("")
1337 .to_string();
1338 let content_type = guess_content_type(&path);
1339 tokio::spawn(async move {
1340 let data = match tokio::fs::read(&path).await {
1341 Ok(b) => b,
1342 Err(e) => {
1343 let _ = tx.send(CommandStatus::Err(format!("read {}: {e}", path.display())));
1344 return;
1345 }
1346 };
1347 let started = Instant::now();
1348 let result = api
1349 .bee()
1350 .file()
1351 .upload_file(&batch_id, data, &name, &content_type, None)
1352 .await;
1353 let elapsed_ms = started.elapsed().as_millis();
1354 let status = match result {
1355 Ok(res) => CommandStatus::Info(format!(
1356 "upload-file OK in {elapsed_ms}ms — {file_size}B → ref {} (batch {task_short})",
1357 res.reference.to_hex(),
1358 )),
1359 Err(e) => CommandStatus::Err(format!(
1360 "upload-file FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1361 )),
1362 };
1363 let _ = tx.send(status);
1364 });
1365
1366 CommandStatus::Info(format!(
1367 "upload-file ({file_size}B) to batch {batch_short} in flight — result will replace this line"
1368 ))
1369 }
1370
1371 fn run_upload_collection(&self, line: &str) -> CommandStatus {
1379 let parts: Vec<&str> = line.split_whitespace().collect();
1380 let (dir_str, prefix) = match parts.as_slice() {
1381 [_, d, b, ..] => (*d, *b),
1382 _ => {
1383 return CommandStatus::Err("usage: :upload-collection <dir> <batch-prefix>".into());
1384 }
1385 };
1386 let dir = std::path::PathBuf::from(dir_str);
1387 let walked = match crate::uploads::walk_dir(&dir) {
1388 Ok(w) => w,
1389 Err(e) => return CommandStatus::Err(format!("walk {dir_str}: {e}")),
1390 };
1391 if walked.entries.is_empty() {
1392 return CommandStatus::Err(format!(
1393 "{dir_str} contains no uploadable files (after skipping hidden + symlinks)"
1394 ));
1395 }
1396 let stamps = self.watch.stamps().borrow().clone();
1397 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1398 Ok(b) => b.clone(),
1399 Err(e) => return CommandStatus::Err(e),
1400 };
1401 if !batch.usable {
1402 return CommandStatus::Err(format!(
1403 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1404 short_hex(&batch.batch_id.to_hex(), 8),
1405 ));
1406 }
1407 if batch.batch_ttl <= 0 {
1408 return CommandStatus::Err(format!(
1409 "batch {} is expired — pick another",
1410 short_hex(&batch.batch_id.to_hex(), 8),
1411 ));
1412 }
1413
1414 let api = self.api.clone();
1415 let tx = self.cmd_status_tx.clone();
1416 let batch_id = batch.batch_id;
1417 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1418 let task_short = batch_short.clone();
1419 let total_bytes = walked.total_bytes;
1420 let entry_count = walked.entries.len();
1421 let entries = walked.entries;
1422 let default_index = walked.default_index.clone();
1423 let dir_str_owned = dir_str.to_string();
1424 let default_index_for_msg = default_index.clone();
1425 tokio::spawn(async move {
1426 let opts = bee::api::CollectionUploadOptions {
1427 index_document: default_index,
1428 ..Default::default()
1429 };
1430 let started = Instant::now();
1431 let result = api
1432 .bee()
1433 .file()
1434 .upload_collection_entries(&batch_id, &entries, Some(&opts))
1435 .await;
1436 let elapsed_ms = started.elapsed().as_millis();
1437 let status = match result {
1438 Ok(res) => {
1439 let idx = default_index_for_msg
1440 .as_deref()
1441 .map(|i| format!(" · index={i}"))
1442 .unwrap_or_default();
1443 CommandStatus::Info(format!(
1444 "upload-collection OK in {elapsed_ms}ms — {entry_count} files, {total_bytes}B → ref {} (batch {task_short}){idx}",
1445 res.reference.to_hex(),
1446 ))
1447 }
1448 Err(e) => CommandStatus::Err(format!(
1449 "upload-collection FAILED after {elapsed_ms}ms — {dir_str_owned} → batch {task_short}: {e}"
1450 )),
1451 };
1452 let _ = tx.send(status);
1453 });
1454
1455 let idx_note = walked
1456 .default_index
1457 .as_deref()
1458 .map(|i| format!(" · default index={i}"))
1459 .unwrap_or_default();
1460 CommandStatus::Info(format!(
1461 "upload-collection {entry_count} files ({total_bytes}B){idx_note} to batch {batch_short} in flight — result will replace this line"
1462 ))
1463 }
1464
1465 fn run_feed_probe(&self, line: &str) -> CommandStatus {
1471 let parts: Vec<&str> = line.split_whitespace().collect();
1472 let (owner_str, topic_str) = match parts.as_slice() {
1473 [_, o, t, ..] => (*o, *t),
1474 _ => {
1475 return CommandStatus::Err(
1476 "usage: :feed-probe <owner> <topic> (topic = 64-hex or arbitrary string)"
1477 .into(),
1478 );
1479 }
1480 };
1481 let parsed = match crate::feed_probe::parse_args(owner_str, topic_str) {
1482 Ok(p) => p,
1483 Err(e) => return CommandStatus::Err(e),
1484 };
1485 let owner_short = short_hex(&parsed.owner.to_hex(), 8);
1486 let api = self.api.clone();
1487 let tx = self.cmd_status_tx.clone();
1488 tokio::spawn(async move {
1489 let started = Instant::now();
1490 let status = match crate::feed_probe::probe(api, parsed).await {
1491 Ok(r) => CommandStatus::Info(format!(
1492 "{} ({}ms)",
1493 r.summary(),
1494 started.elapsed().as_millis()
1495 )),
1496 Err(e) => CommandStatus::Err(format!("feed-probe failed: {e}")),
1497 };
1498 let _ = tx.send(status);
1499 });
1500 CommandStatus::Info(format!(
1501 "feed-probe owner={owner_short} in flight — result will replace this line (first lookup can take 30-60s)"
1502 ))
1503 }
1504
1505 fn run_feed_timeline(&mut self, line: &str) -> CommandStatus {
1512 let parts: Vec<&str> = line.split_whitespace().collect();
1513 let (owner_str, topic_str, n_arg) = match parts.as_slice() {
1514 [_, o, t] => (*o, *t, None),
1515 [_, o, t, n, ..] => (*o, *t, Some(*n)),
1516 _ => {
1517 return CommandStatus::Err(
1518 "usage: :feed-timeline <owner> <topic> [N] (default 50, hard max 1000)".into(),
1519 );
1520 }
1521 };
1522 let parsed = match crate::feed_probe::parse_args(owner_str, topic_str) {
1523 Ok(p) => p,
1524 Err(e) => return CommandStatus::Err(e),
1525 };
1526 let max_entries = match n_arg {
1527 None => crate::feed_timeline::DEFAULT_MAX_ENTRIES,
1528 Some(s) => match s.parse::<u64>() {
1529 Ok(n) if n > 0 => n,
1530 _ => return CommandStatus::Err(format!("invalid N: {s:?}")),
1531 },
1532 };
1533 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "FeedTimeline") {
1537 self.current_screen = idx;
1538 if let Some(ft) = self
1539 .screens
1540 .get_mut(idx)
1541 .and_then(|s| s.as_any_mut())
1542 .and_then(|a| a.downcast_mut::<FeedTimeline>())
1543 {
1544 let label = format!(
1545 "owner=0x{} · topic={} · N={max_entries}",
1546 short_hex(&parsed.owner.to_hex(), 8),
1547 short_hex(&parsed.topic.to_hex(), 8),
1548 );
1549 ft.set_loading(label);
1550 }
1551 }
1552 let api = self.api.clone();
1553 let tx = self.feed_timeline_tx.clone();
1554 tokio::spawn(async move {
1555 let msg = match crate::feed_timeline::walk(api, parsed.owner, parsed.topic, max_entries)
1556 .await
1557 {
1558 Ok(t) => FeedTimelineMessage::Loaded(t),
1559 Err(e) => FeedTimelineMessage::Failed(e),
1560 };
1561 let _ = tx.send(msg);
1562 });
1563 CommandStatus::Info(format!(
1564 "feed-timeline N={max_entries} in flight — switching to S14 (first lookup can take 30-60s)"
1565 ))
1566 }
1567
1568 fn run_hash(&self, line: &str) -> CommandStatus {
1573 let parts: Vec<&str> = line.split_whitespace().collect();
1574 let path = match parts.as_slice() {
1575 [_, p, ..] => *p,
1576 _ => {
1577 return CommandStatus::Err(
1578 "usage: :hash <path> (file or directory; computed locally)".into(),
1579 );
1580 }
1581 };
1582 match utility_verbs::hash_path(path) {
1583 Ok(r) => CommandStatus::Info(format!("hash {path}: {r}")),
1584 Err(e) => CommandStatus::Err(format!("hash failed: {e}")),
1585 }
1586 }
1587
1588 fn run_cid(&self, line: &str) -> CommandStatus {
1592 let parts: Vec<&str> = line.split_whitespace().collect();
1593 let (ref_hex, kind_arg) = match parts.as_slice() {
1594 [_, r, k, ..] => (*r, Some(*k)),
1595 [_, r] => (*r, None),
1596 _ => {
1597 return CommandStatus::Err(
1598 "usage: :cid <ref> [manifest|feed] (default manifest)".into(),
1599 );
1600 }
1601 };
1602 let kind = match utility_verbs::parse_cid_kind(kind_arg) {
1603 Ok(k) => k,
1604 Err(e) => return CommandStatus::Err(e),
1605 };
1606 match utility_verbs::cid_for_ref(ref_hex, kind) {
1607 Ok(cid) => CommandStatus::Info(format!("cid: {cid}")),
1608 Err(e) => CommandStatus::Err(format!("cid failed: {e}")),
1609 }
1610 }
1611
1612 fn run_depth_table(&self) -> CommandStatus {
1617 let body = utility_verbs::depth_table();
1618 let path = std::env::temp_dir().join("bee-tui-depth-table.txt");
1619 match std::fs::write(&path, &body) {
1620 Ok(()) => CommandStatus::Info(format!("depth table → {}", path.display())),
1621 Err(e) => CommandStatus::Err(format!("depth-table write failed: {e}")),
1622 }
1623 }
1624
1625 fn run_gsoc_mine(&self, line: &str) -> CommandStatus {
1630 let parts: Vec<&str> = line.split_whitespace().collect();
1631 let (overlay, ident) = match parts.as_slice() {
1632 [_, o, i, ..] => (*o, *i),
1633 _ => {
1634 return CommandStatus::Err(
1635 "usage: :gsoc-mine <overlay-hex> <identifier> (CPU work, no network)".into(),
1636 );
1637 }
1638 };
1639 match utility_verbs::gsoc_mine_for(overlay, ident) {
1640 Ok(out) => CommandStatus::Info(out.replace('\n', " · ")),
1641 Err(e) => CommandStatus::Err(format!("gsoc-mine failed: {e}")),
1642 }
1643 }
1644
1645 fn run_manifest(&mut self, line: &str) -> CommandStatus {
1649 let parts: Vec<&str> = line.split_whitespace().collect();
1650 let ref_arg = match parts.as_slice() {
1651 [_, r, ..] => *r,
1652 _ => {
1653 return CommandStatus::Err(
1654 "usage: :manifest <ref> (32-byte hex reference)".into(),
1655 );
1656 }
1657 };
1658 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1659 Ok(r) => r,
1660 Err(e) => return CommandStatus::Err(format!("manifest: bad ref: {e}")),
1661 };
1662 let idx = match SCREEN_NAMES.iter().position(|n| *n == "Manifest") {
1665 Some(i) => i,
1666 None => {
1667 return CommandStatus::Err("internal: Manifest screen not registered".into());
1668 }
1669 };
1670 let screen = self
1671 .screens
1672 .get_mut(idx)
1673 .and_then(|s| s.as_any_mut())
1674 .and_then(|a| a.downcast_mut::<Manifest>());
1675 let Some(manifest) = screen else {
1676 return CommandStatus::Err("internal: failed to access Manifest screen".into());
1677 };
1678 manifest.load(reference);
1679 self.current_screen = idx;
1680 CommandStatus::Info(format!("loading manifest {}", short_hex(ref_arg, 8)))
1681 }
1682
1683 fn run_inspect(&self, line: &str) -> CommandStatus {
1690 let parts: Vec<&str> = line.split_whitespace().collect();
1691 let ref_arg = match parts.as_slice() {
1692 [_, r, ..] => *r,
1693 _ => {
1694 return CommandStatus::Err("usage: :inspect <ref> (32-byte hex reference)".into());
1695 }
1696 };
1697 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1698 Ok(r) => r,
1699 Err(e) => return CommandStatus::Err(format!("inspect: bad ref: {e}")),
1700 };
1701 let api = self.api.clone();
1702 let tx = self.cmd_status_tx.clone();
1703 let label = short_hex(ref_arg, 8);
1704 let label_for_task = label.clone();
1705 tokio::spawn(async move {
1706 let result = manifest_walker::inspect(api, reference).await;
1707 let status = match result {
1708 InspectResult::Manifest { node, bytes_len } => CommandStatus::Info(format!(
1709 "inspect {label_for_task}: manifest · {bytes_len} bytes · {} forks (jump to :manifest {label_for_task})",
1710 node.forks.len(),
1711 )),
1712 InspectResult::RawChunk { bytes_len } => CommandStatus::Info(format!(
1713 "inspect {label_for_task}: raw chunk · {bytes_len} bytes · not a manifest"
1714 )),
1715 InspectResult::Error(e) => {
1716 CommandStatus::Err(format!("inspect {label_for_task} failed: {e}"))
1717 }
1718 };
1719 let _ = tx.send(status);
1720 });
1721 CommandStatus::Info(format!(
1722 "inspecting {label} — result will replace this line"
1723 ))
1724 }
1725
1726 fn run_durability_check(&mut self, line: &str) -> CommandStatus {
1736 let parts: Vec<&str> = line.split_whitespace().collect();
1737 let ref_arg = match parts.as_slice() {
1738 [_, r, ..] => *r,
1739 _ => {
1740 return CommandStatus::Err(
1741 "usage: :durability-check <ref> (32-byte hex reference)".into(),
1742 );
1743 }
1744 };
1745 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1746 Ok(r) => r,
1747 Err(e) => {
1748 return CommandStatus::Err(format!("durability-check: bad ref: {e}"));
1749 }
1750 };
1751 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Watchlist") {
1754 self.current_screen = idx;
1755 }
1756 let api = self.api.clone();
1757 let tx = self.cmd_status_tx.clone();
1758 let watchlist_tx = self.durability_tx.clone();
1759 let label = short_hex(ref_arg, 8);
1760 let label_for_task = label.clone();
1761 let opts = self.durability_check_options();
1762 tokio::spawn(async move {
1763 let result = durability::check_with_options(api, reference, opts).await;
1764 let summary = result.summary();
1765 let _ = watchlist_tx.send(result);
1766 let _ = tx.send(if summary.contains("UNHEALTHY") {
1767 CommandStatus::Err(summary)
1768 } else {
1769 CommandStatus::Info(summary)
1770 });
1771 });
1772 CommandStatus::Info(format!(
1773 "durability-check {label_for_task} in flight — see S13 Watchlist for the running history"
1774 ))
1775 }
1776
1777 fn durability_check_options(&self) -> durability::CheckOptions {
1782 durability::CheckOptions {
1783 bmt_verify: true,
1784 swarmscan_url: if self.config.durability.swarmscan_check {
1785 Some(self.config.durability.swarmscan_url.clone())
1786 } else {
1787 None
1788 },
1789 }
1790 }
1791
1792 fn run_watch_ref(&mut self, line: &str) -> CommandStatus {
1802 let parts: Vec<&str> = line.split_whitespace().collect();
1803 let (ref_arg, interval_arg) = match parts.as_slice() {
1804 [_, r] => (*r, None),
1805 [_, r, i, ..] => (*r, Some(*i)),
1806 _ => {
1807 return CommandStatus::Err(
1808 "usage: :watch-ref <ref> [interval-secs] (default 60s)".into(),
1809 );
1810 }
1811 };
1812 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1813 Ok(r) => r,
1814 Err(e) => return CommandStatus::Err(format!("watch-ref: bad ref: {e}")),
1815 };
1816 let interval_secs = match interval_arg {
1817 None => 60u64,
1818 Some(s) => match s.parse::<u64>() {
1819 Ok(n) if (10..=86_400).contains(&n) => n,
1820 Ok(n) => {
1821 return CommandStatus::Err(format!(
1822 "watch-ref: interval {n}s out of range (10..=86400)"
1823 ));
1824 }
1825 Err(_) => return CommandStatus::Err(format!("watch-ref: invalid interval: {s:?}")),
1826 },
1827 };
1828 let key = reference.to_hex();
1829 if let Some(prev) = self.watch_refs.remove(&key) {
1832 prev.cancel();
1833 }
1834 let cancel = self.root_cancel.child_token();
1835 self.watch_refs.insert(key.clone(), cancel.clone());
1836
1837 let api = self.api.clone();
1838 let watchlist_tx = self.durability_tx.clone();
1839 let label = short_hex(ref_arg, 8);
1840 let label_for_task = label.clone();
1841 let opts = self.durability_check_options();
1842 tokio::spawn(async move {
1843 let interval = std::time::Duration::from_secs(interval_secs);
1844 loop {
1845 let result =
1846 durability::check_with_options(api.clone(), reference.clone(), opts.clone())
1847 .await;
1848 let _ = watchlist_tx.send(result);
1849 tokio::select! {
1850 _ = tokio::time::sleep(interval) => {}
1851 _ = cancel.cancelled() => return,
1852 }
1853 }
1854 });
1855
1856 CommandStatus::Info(format!(
1857 "watch-ref {label_for_task} started — re-checking every {interval_secs}s; results in S13 Watchlist"
1858 ))
1859 }
1860
1861 fn run_watch_ref_stop(&mut self, line: &str) -> CommandStatus {
1868 let parts: Vec<&str> = line.split_whitespace().collect();
1869 match parts.as_slice() {
1870 [_] => {
1871 let n = self.watch_refs.len();
1872 for (_, c) in self.watch_refs.drain() {
1873 c.cancel();
1874 }
1875 CommandStatus::Info(format!("watch-ref-stop: cancelled {n} active daemon(s)"))
1876 }
1877 [_, r, ..] => {
1878 let reference = match bee::swarm::Reference::from_hex(r.trim()) {
1879 Ok(r) => r,
1880 Err(e) => return CommandStatus::Err(format!("watch-ref-stop: bad ref: {e}")),
1881 };
1882 let key = reference.to_hex();
1883 match self.watch_refs.remove(&key) {
1884 Some(c) => {
1885 c.cancel();
1886 CommandStatus::Info(format!(
1887 "watch-ref-stop: cancelled daemon for {}",
1888 short_hex(r, 8)
1889 ))
1890 }
1891 None => CommandStatus::Err(format!(
1892 "watch-ref-stop: no daemon running for {}",
1893 short_hex(r, 8)
1894 )),
1895 }
1896 }
1897 _ => CommandStatus::Err("usage: :watch-ref-stop [ref] (omit ref to stop all)".into()),
1898 }
1899 }
1900
1901 fn run_pubsub_pss(&mut self, line: &str) -> CommandStatus {
1909 let parts: Vec<&str> = line.split_whitespace().collect();
1910 let topic_str = match parts.as_slice() {
1911 [_, t, ..] => *t,
1912 _ => return CommandStatus::Err("usage: :pubsub-pss <topic>".into()),
1913 };
1914 let parsed = match crate::feed_probe::parse_args(
1916 "0x0000000000000000000000000000000000000000",
1917 topic_str,
1918 ) {
1919 Ok(p) => p,
1920 Err(e) => return CommandStatus::Err(format!("pubsub-pss: {e}")),
1921 };
1922 let topic = parsed.topic;
1923 let sub_id = crate::pubsub::pss_sub_id(&topic);
1924 if self.pubsub_subs.contains_key(&sub_id) {
1925 return CommandStatus::Err(format!(
1926 "pubsub-pss: already subscribed to {sub_id} (use :pubsub-stop {sub_id} first)"
1927 ));
1928 }
1929 let cancel = self.root_cancel.child_token();
1930 self.pubsub_subs.insert(sub_id.clone(), cancel.clone());
1931 self.jump_to_pubsub_screen();
1932 let api = self.api.clone();
1933 let tx = self.pubsub_msg_tx.clone();
1934 let status_tx = self.cmd_status_tx.clone();
1935 let sub_id_for_task = sub_id.clone();
1936 tokio::spawn(async move {
1937 if let Err(e) = crate::pubsub::spawn_pss_watcher(api, topic, cancel, tx).await {
1938 let _ = status_tx.send(CommandStatus::Err(format!(
1939 "pubsub-pss {sub_id_for_task}: {e}"
1940 )));
1941 }
1942 });
1943 CommandStatus::Info(format!("pubsub-pss subscribed: {sub_id}"))
1944 }
1945
1946 fn run_pubsub_gsoc(&mut self, line: &str) -> CommandStatus {
1951 let parts: Vec<&str> = line.split_whitespace().collect();
1952 let (owner_str, id_str) = match parts.as_slice() {
1953 [_, o, i, ..] => (*o, *i),
1954 _ => return CommandStatus::Err("usage: :pubsub-gsoc <owner> <identifier>".into()),
1955 };
1956 let owner = match bee::swarm::EthAddress::from_hex(owner_str.trim()) {
1957 Ok(o) => o,
1958 Err(e) => return CommandStatus::Err(format!("pubsub-gsoc: bad owner: {e}")),
1959 };
1960 let identifier = match bee::swarm::Identifier::from_hex(id_str.trim()) {
1961 Ok(i) => i,
1962 Err(e) => return CommandStatus::Err(format!("pubsub-gsoc: bad identifier: {e}")),
1963 };
1964 let sub_id = crate::pubsub::gsoc_sub_id(&owner, &identifier);
1965 if self.pubsub_subs.contains_key(&sub_id) {
1966 return CommandStatus::Err(format!(
1967 "pubsub-gsoc: already subscribed to {sub_id} (use :pubsub-stop first)"
1968 ));
1969 }
1970 let cancel = self.root_cancel.child_token();
1971 self.pubsub_subs.insert(sub_id.clone(), cancel.clone());
1972 self.jump_to_pubsub_screen();
1973 let api = self.api.clone();
1974 let tx = self.pubsub_msg_tx.clone();
1975 let status_tx = self.cmd_status_tx.clone();
1976 let sub_id_for_task = sub_id.clone();
1977 tokio::spawn(async move {
1978 if let Err(e) =
1979 crate::pubsub::spawn_gsoc_watcher(api, owner, identifier, cancel, tx).await
1980 {
1981 let _ = status_tx.send(CommandStatus::Err(format!(
1982 "pubsub-gsoc {sub_id_for_task}: {e}"
1983 )));
1984 }
1985 });
1986 CommandStatus::Info(format!("pubsub-gsoc subscribed: {sub_id}"))
1987 }
1988
1989 fn run_pubsub_stop(&mut self, line: &str) -> CommandStatus {
1993 let parts: Vec<&str> = line.split_whitespace().collect();
1994 match parts.as_slice() {
1995 [_] => {
1996 let n = self.pubsub_subs.len();
1997 for (_, c) in self.pubsub_subs.drain() {
1998 c.cancel();
1999 }
2000 CommandStatus::Info(format!("pubsub-stop: cancelled {n} subscription(s)"))
2001 }
2002 [_, id, ..] => match self.pubsub_subs.remove(*id) {
2003 Some(c) => {
2004 c.cancel();
2005 CommandStatus::Info(format!("pubsub-stop: cancelled {id}"))
2006 }
2007 None => CommandStatus::Err(format!("pubsub-stop: no active subscription {id}")),
2008 },
2009 _ => CommandStatus::Err("usage: :pubsub-stop [sub-id]".into()),
2010 }
2011 }
2012
2013 fn jump_to_pubsub_screen(&mut self) {
2016 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Pubsub") {
2017 self.current_screen = idx;
2018 }
2019 }
2020
2021 fn run_pss_target(&self, line: &str) -> CommandStatus {
2026 let parts: Vec<&str> = line.split_whitespace().collect();
2027 let overlay = match parts.as_slice() {
2028 [_, o, ..] => *o,
2029 _ => {
2030 return CommandStatus::Err(
2031 "usage: :pss-target <overlay-hex> (returns first 4 hex chars)".into(),
2032 );
2033 }
2034 };
2035 match utility_verbs::pss_target_for(overlay) {
2036 Ok(prefix) => CommandStatus::Info(format!("pss target prefix: {prefix}")),
2037 Err(e) => CommandStatus::Err(format!("pss-target failed: {e}")),
2038 }
2039 }
2040
2041 fn run_price(&self) -> CommandStatus {
2047 let tx = self.cmd_status_tx.clone();
2048 tokio::spawn(async move {
2049 let status = match economics_oracle::fetch_xbzz_price().await {
2050 Ok(p) => CommandStatus::Info(p.summary()),
2051 Err(e) => CommandStatus::Err(format!("price: {e}")),
2052 };
2053 let _ = tx.send(status);
2054 });
2055 CommandStatus::Info("price: querying tokenservice.ethswarm.org…".into())
2056 }
2057
2058 fn run_basefee(&self) -> CommandStatus {
2062 let url = match self.config.economics.gnosis_rpc_url.clone() {
2063 Some(u) => u,
2064 None => {
2065 return CommandStatus::Err(
2066 "basefee: set [economics].gnosis_rpc_url in config.toml (typically the same URL as Bee's --blockchain-rpc-endpoint)"
2067 .into(),
2068 );
2069 }
2070 };
2071 let tx = self.cmd_status_tx.clone();
2072 tokio::spawn(async move {
2073 let status = match economics_oracle::fetch_gnosis_gas(&url).await {
2074 Ok(g) => CommandStatus::Info(g.summary()),
2075 Err(e) => CommandStatus::Err(format!("basefee: {e}")),
2076 };
2077 let _ = tx.send(status);
2078 });
2079 CommandStatus::Info("basefee: querying gnosis RPC…".into())
2080 }
2081
2082 fn run_config_doctor(&self) -> CommandStatus {
2088 let path = match self.config.bee.as_ref().map(|b| b.config.clone()) {
2089 Some(p) => p,
2090 None => {
2091 return CommandStatus::Err(
2092 "config-doctor: no [bee].config in config.toml (or pass --bee-config) — point bee-tui at the bee.yaml you want audited"
2093 .into(),
2094 );
2095 }
2096 };
2097 let report = match config_doctor::audit(&path) {
2098 Ok(r) => r,
2099 Err(e) => return CommandStatus::Err(format!("config-doctor: {e}")),
2100 };
2101 let secs = SystemTime::now()
2102 .duration_since(UNIX_EPOCH)
2103 .map(|d| d.as_secs())
2104 .unwrap_or(0);
2105 let out_path = std::env::temp_dir().join(format!("bee-tui-config-doctor-{secs}.txt"));
2106 if let Err(e) = std::fs::write(&out_path, report.render()) {
2107 return CommandStatus::Err(format!("config-doctor write {}: {e}", out_path.display()));
2108 }
2109 CommandStatus::Info(format!("{} → {}", report.summary(), out_path.display()))
2110 }
2111
2112 fn run_check_version(&self) -> CommandStatus {
2120 let api = self.api.clone();
2121 let tx = self.cmd_status_tx.clone();
2122 tokio::spawn(async move {
2123 let running = api.bee().debug().health().await.ok().map(|h| h.version);
2124 let status = match version_check::check_latest(running).await {
2125 Ok(v) => CommandStatus::Info(v.summary()),
2126 Err(e) => CommandStatus::Err(format!("check-version failed: {e}")),
2127 };
2128 let _ = tx.send(status);
2129 });
2130 CommandStatus::Info("check-version: querying github.com/ethersphere/bee…".into())
2131 }
2132
2133 fn run_plan_batch(&self, line: &str) -> CommandStatus {
2139 let parts: Vec<&str> = line.split_whitespace().collect();
2140 let prefix = match parts.as_slice() {
2141 [_, prefix, ..] => *prefix,
2142 _ => {
2143 return CommandStatus::Err(
2144 "usage: :plan-batch <batch-prefix> [usage-thr] [ttl-thr] [extra-depth]".into(),
2145 );
2146 }
2147 };
2148 let usage_thr = match parts.get(2) {
2149 Some(s) => match s.parse::<f64>() {
2150 Ok(v) => v,
2151 Err(_) => {
2152 return CommandStatus::Err(format!(
2153 "invalid usage-thr {s:?} (expected float in [0,1], default 0.85)"
2154 ));
2155 }
2156 },
2157 None => stamp_preview::DEFAULT_USAGE_THRESHOLD,
2158 };
2159 let ttl_thr = match parts.get(3) {
2160 Some(s) => match stamp_preview::parse_duration_seconds(s) {
2161 Ok(v) => v,
2162 Err(e) => return CommandStatus::Err(format!("ttl-thr: {e}")),
2163 },
2164 None => stamp_preview::DEFAULT_TTL_THRESHOLD_SECONDS,
2165 };
2166 let extra_depth = match parts.get(4) {
2167 Some(s) => match s.parse::<u8>() {
2168 Ok(v) => v,
2169 Err(_) => {
2170 return CommandStatus::Err(format!(
2171 "invalid extra-depth {s:?} (expected u8, default 2)"
2172 ));
2173 }
2174 },
2175 None => stamp_preview::DEFAULT_EXTRA_DEPTH,
2176 };
2177 let chain = match self.health_rx.borrow().chain_state.clone() {
2178 Some(c) => c,
2179 None => return CommandStatus::Err("chain state not loaded yet".into()),
2180 };
2181 let stamps = self.watch.stamps().borrow().clone();
2182 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
2183 Ok(b) => b.clone(),
2184 Err(e) => return CommandStatus::Err(e),
2185 };
2186 match stamp_preview::plan_batch(&batch, &chain, usage_thr, ttl_thr, extra_depth) {
2187 Ok(p) => CommandStatus::Info(p.summary()),
2188 Err(e) => CommandStatus::Err(e),
2189 }
2190 }
2191
2192 fn run_buy_suggest(&self, line: &str) -> CommandStatus {
2198 let parts: Vec<&str> = line.split_whitespace().collect();
2199 let (size_str, duration_str) = match parts.as_slice() {
2200 [_, size, duration, ..] => (*size, *duration),
2201 _ => {
2202 return CommandStatus::Err(
2203 "usage: :buy-suggest <size> <duration> (e.g. 5GiB 30d, 100MiB 12h)".into(),
2204 );
2205 }
2206 };
2207 let target_bytes = match stamp_preview::parse_size_bytes(size_str) {
2208 Ok(b) => b,
2209 Err(e) => return CommandStatus::Err(e),
2210 };
2211 let target_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
2212 Ok(s) => s,
2213 Err(e) => return CommandStatus::Err(e),
2214 };
2215 let chain = match self.health_rx.borrow().chain_state.clone() {
2216 Some(c) => c,
2217 None => return CommandStatus::Err("chain state not loaded yet".into()),
2218 };
2219 match stamp_preview::buy_suggest(target_bytes, target_seconds, &chain) {
2220 Ok(s) => CommandStatus::Info(s.summary()),
2221 Err(e) => CommandStatus::Err(e),
2222 }
2223 }
2224
2225 fn run_buy_preview(&self, line: &str) -> CommandStatus {
2228 let parts: Vec<&str> = line.split_whitespace().collect();
2229 let (depth_str, amount_str) = match parts.as_slice() {
2230 [_, depth, amount, ..] => (*depth, *amount),
2231 _ => {
2232 return CommandStatus::Err(
2233 "usage: :buy-preview <depth> <amount-plur-per-chunk>".into(),
2234 );
2235 }
2236 };
2237 let depth: u8 = match depth_str.parse() {
2238 Ok(d) => d,
2239 Err(_) => {
2240 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
2241 }
2242 };
2243 let amount = match stamp_preview::parse_plur_amount(amount_str) {
2244 Ok(a) => a,
2245 Err(e) => return CommandStatus::Err(e),
2246 };
2247 let chain = match self.health_rx.borrow().chain_state.clone() {
2248 Some(c) => c,
2249 None => return CommandStatus::Err("chain state not loaded yet".into()),
2250 };
2251 match stamp_preview::buy_preview(depth, amount, &chain) {
2252 Ok(p) => CommandStatus::Info(p.summary()),
2253 Err(e) => CommandStatus::Err(e),
2254 }
2255 }
2256
2257 fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
2264 let node = self
2265 .config
2266 .nodes
2267 .iter()
2268 .find(|n| n.name == target)
2269 .ok_or_else(|| eyre!("no node configured with name {target:?}"))?
2270 .clone();
2271 let new_api = Arc::new(ApiClient::from_node(&node)?);
2272 self.watch.shutdown();
2276 let refresh = RefreshProfile::from_config(&self.config.ui.refresh);
2277 let new_watch = BeeWatch::start_with_profile(new_api.clone(), &self.root_cancel, refresh);
2278 let new_health_rx = new_watch.health();
2279 let new_market_rx = if self.config.economics.enable_market_tile {
2284 Some(economics_oracle::spawn_poller(
2285 self.config.economics.gnosis_rpc_url.clone(),
2286 self.root_cancel.child_token(),
2287 ))
2288 } else {
2289 None
2290 };
2291 let new_screens = build_screens(&new_api, &new_watch, new_market_rx);
2292 self.api = new_api;
2293 self.watch = new_watch;
2294 self.health_rx = new_health_rx;
2295 self.screens = new_screens;
2296 Ok(())
2299 }
2300
2301 fn start_pins_check(&self) -> std::io::Result<PathBuf> {
2318 let secs = SystemTime::now()
2319 .duration_since(UNIX_EPOCH)
2320 .map(|d| d.as_secs())
2321 .unwrap_or(0);
2322 let path = std::env::temp_dir().join(format!(
2323 "bee-tui-pins-check-{}-{secs}.txt",
2324 sanitize_for_filename(&self.api.name),
2325 ));
2326 std::fs::write(
2329 &path,
2330 format!(
2331 "# bee-tui :pins-check\n# profile {}\n# endpoint {}\n# started {}\n",
2332 self.api.name,
2333 self.api.url,
2334 format_utc_now(),
2335 ),
2336 )?;
2337
2338 let api = self.api.clone();
2339 let dest = path.clone();
2340 tokio::spawn(async move {
2341 let bee = api.bee();
2342 match bee.api().check_pins(None).await {
2343 Ok(entries) => {
2344 let mut body = String::new();
2345 for e in &entries {
2346 body.push_str(&format!(
2347 "{} total={} missing={} invalid={} {}\n",
2348 e.reference.to_hex(),
2349 e.total,
2350 e.missing,
2351 e.invalid,
2352 if e.is_healthy() {
2353 "healthy"
2354 } else {
2355 "UNHEALTHY"
2356 },
2357 ));
2358 }
2359 body.push_str(&format!("# done. {} pins checked.\n", entries.len()));
2360 if let Err(e) = append(&dest, &body) {
2361 let _ = append(&dest, &format!("# write error: {e}\n"));
2362 }
2363 }
2364 Err(e) => {
2365 let _ = append(&dest, &format!("# error: {e}\n"));
2366 }
2367 }
2368 });
2369 Ok(path)
2370 }
2371
2372 fn start_set_logger(&self, expression: String, level: String) {
2383 let secs = SystemTime::now()
2384 .duration_since(UNIX_EPOCH)
2385 .map(|d| d.as_secs())
2386 .unwrap_or(0);
2387 let dest = std::env::temp_dir().join(format!(
2388 "bee-tui-set-logger-{}-{secs}.txt",
2389 sanitize_for_filename(&self.api.name),
2390 ));
2391 let _ = std::fs::write(
2392 &dest,
2393 format!(
2394 "# bee-tui :set-logger\n# profile {}\n# endpoint {}\n# expr {expression}\n# level {level}\n# started {}\n",
2395 self.api.name,
2396 self.api.url,
2397 format_utc_now(),
2398 ),
2399 );
2400
2401 let api = self.api.clone();
2402 tokio::spawn(async move {
2403 let bee = api.bee();
2404 match bee.debug().set_logger(&expression, &level).await {
2405 Ok(()) => {
2406 let _ = append(
2407 &dest,
2408 &format!("# done. {expression} → {level} accepted by Bee.\n"),
2409 );
2410 }
2411 Err(e) => {
2412 let _ = append(&dest, &format!("# error: {e}\n"));
2413 }
2414 }
2415 });
2416 }
2417
2418 fn start_loggers_dump(&self) -> std::io::Result<PathBuf> {
2423 let secs = SystemTime::now()
2424 .duration_since(UNIX_EPOCH)
2425 .map(|d| d.as_secs())
2426 .unwrap_or(0);
2427 let path = std::env::temp_dir().join(format!(
2428 "bee-tui-loggers-{}-{secs}.txt",
2429 sanitize_for_filename(&self.api.name),
2430 ));
2431 std::fs::write(
2432 &path,
2433 format!(
2434 "# bee-tui :loggers\n# profile {}\n# endpoint {}\n# started {}\n",
2435 self.api.name,
2436 self.api.url,
2437 format_utc_now(),
2438 ),
2439 )?;
2440
2441 let api = self.api.clone();
2442 let dest = path.clone();
2443 tokio::spawn(async move {
2444 let bee = api.bee();
2445 match bee.debug().loggers().await {
2446 Ok(listing) => {
2447 let mut rows = listing.loggers.clone();
2448 rows.sort_by(|a, b| {
2452 verbosity_rank(&b.verbosity)
2453 .cmp(&verbosity_rank(&a.verbosity))
2454 .then_with(|| a.logger.cmp(&b.logger))
2455 });
2456 let mut body = String::new();
2457 body.push_str(&format!("# {} loggers registered\n", rows.len()));
2458 body.push_str("# VERBOSITY LOGGER\n");
2459 for r in &rows {
2460 body.push_str(&format!(" {:<9} {}\n", r.verbosity, r.logger,));
2461 }
2462 body.push_str("# done.\n");
2463 if let Err(e) = append(&dest, &body) {
2464 let _ = append(&dest, &format!("# write error: {e}\n"));
2465 }
2466 }
2467 Err(e) => {
2468 let _ = append(&dest, &format!("# error: {e}\n"));
2469 }
2470 }
2471 });
2472 Ok(path)
2473 }
2474
2475 fn start_diagnose_with_pprof(&self, seconds: u32) -> CommandStatus {
2487 let secs_unix = SystemTime::now()
2488 .duration_since(UNIX_EPOCH)
2489 .map(|d| d.as_secs())
2490 .unwrap_or(0);
2491 let dir = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs_unix}"));
2492 if let Err(e) = std::fs::create_dir_all(&dir) {
2493 return CommandStatus::Err(format!("diagnose --pprof: mkdir failed: {e}"));
2494 }
2495 let bundle_text = self.render_diagnostic_bundle();
2496 if let Err(e) = std::fs::write(dir.join("bundle.txt"), &bundle_text) {
2497 return CommandStatus::Err(format!("diagnose --pprof: write bundle.txt: {e}"));
2498 }
2499 let auth_token = self
2504 .config
2505 .nodes
2506 .iter()
2507 .find(|n| n.name == self.api.name)
2508 .and_then(|n| n.resolved_token());
2509 let base_url = self.api.url.clone();
2510 let dir_for_task = dir.clone();
2511 let tx = self.cmd_status_tx.clone();
2512 tokio::spawn(async move {
2513 let r =
2514 pprof_bundle::fetch_and_write(&base_url, auth_token, seconds, dir_for_task).await;
2515 let status = match r {
2516 Ok(b) => CommandStatus::Info(b.summary()),
2517 Err(e) => CommandStatus::Err(format!("diagnose --pprof failed: {e}")),
2518 };
2519 let _ = tx.send(status);
2520 });
2521 CommandStatus::Info(format!(
2522 "diagnose --pprof={seconds}s in flight (bundle.txt already at {}; profile + trace will join when sampling completes)",
2523 dir.display()
2524 ))
2525 }
2526
2527 fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
2528 let bundle = self.render_diagnostic_bundle();
2529 let secs = SystemTime::now()
2530 .duration_since(UNIX_EPOCH)
2531 .map(|d| d.as_secs())
2532 .unwrap_or(0);
2533 let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
2534 std::fs::write(&path, bundle)?;
2535 Ok(path)
2536 }
2537
2538 fn render_diagnostic_bundle(&self) -> String {
2539 let now = format_utc_now();
2540 let health = self.health_rx.borrow().clone();
2541 let topology = self.watch.topology().borrow().clone();
2542 let stamps = self.watch.stamps().borrow().clone();
2543 let gates = Health::gates_for_with_stamps(&health, Some(&topology), Some(&stamps));
2544 let recent: Vec<_> = log_capture::handle()
2545 .map(|c| {
2546 let mut snap = c.snapshot();
2547 let len = snap.len();
2548 if len > 50 {
2549 snap.drain(0..len - 50);
2550 }
2551 snap
2552 })
2553 .unwrap_or_default();
2554
2555 let mut out = String::new();
2556 out.push_str("# bee-tui diagnostic bundle\n");
2557 out.push_str(&format!("# generated UTC {now}\n\n"));
2558 out.push_str("## profile\n");
2559 out.push_str(&format!(" name {}\n", self.api.name));
2560 out.push_str(&format!(" endpoint {}\n\n", self.api.url));
2561 out.push_str("## health gates\n");
2562 for g in &gates {
2563 out.push_str(&format_gate_line(g));
2564 }
2565 out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
2566 for e in &recent {
2567 let status = e
2568 .status
2569 .map(|s| s.to_string())
2570 .unwrap_or_else(|| "—".into());
2571 let elapsed = e
2572 .elapsed_ms
2573 .map(|ms| format!("{ms}ms"))
2574 .unwrap_or_else(|| "—".into());
2575 out.push_str(&format!(
2576 " {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
2577 ts = e.ts,
2578 method = e.method,
2579 path = path_only(&e.url),
2580 status = status,
2581 elapsed = elapsed,
2582 ));
2583 }
2584 out.push_str(&format!(
2585 "\n## generated by bee-tui {}\n",
2586 env!("CARGO_PKG_VERSION"),
2587 ));
2588 out
2589 }
2590
2591 fn tick_alerts(&mut self) {
2598 let url = match self.config.alerts.webhook_url.as_deref() {
2599 Some(u) if !u.is_empty() => u.to_string(),
2600 _ => return,
2601 };
2602 let health = self.health_rx.borrow().clone();
2603 let topology = self.watch.topology().borrow().clone();
2604 let stamps = self.watch.stamps().borrow().clone();
2605 let gates = Health::gates_for_with_stamps(&health, Some(&topology), Some(&stamps));
2606 let alerts = self.alert_state.diff_and_record(&gates);
2607 for alert in alerts {
2608 let url = url.clone();
2609 tokio::spawn(async move {
2610 if let Err(e) = crate::alerts::fire(&url, &alert).await {
2611 tracing::warn!(target: "bee_tui::alerts", "webhook fire failed: {e}");
2612 }
2613 });
2614 }
2615 }
2616
2617 fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
2618 while let Ok(action) = self.action_rx.try_recv() {
2619 if action != Action::Tick && action != Action::Render {
2620 debug!("{action:?}");
2621 }
2622 match action {
2623 Action::Tick => {
2624 self.last_tick_key_events.drain(..);
2625 theme::advance_spinner();
2629 if let Some(sup) = self.supervisor.as_mut() {
2633 self.bee_status = sup.status();
2634 }
2635 if let Some(rx) = self.bee_log_rx.as_mut() {
2640 while let Ok((tab, line)) = rx.try_recv() {
2641 self.log_pane.push_bee(tab, line);
2642 }
2643 }
2644 while let Ok(status) = self.cmd_status_rx.try_recv() {
2649 self.command_status = Some(status);
2650 }
2651 while let Ok(result) = self.durability_rx.try_recv() {
2656 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Watchlist") {
2657 if let Some(wl) = self
2658 .screens
2659 .get_mut(idx)
2660 .and_then(|s| s.as_any_mut())
2661 .and_then(|a| a.downcast_mut::<Watchlist>())
2662 {
2663 wl.record(result);
2664 }
2665 }
2666 }
2667 while let Ok(msg) = self.feed_timeline_rx.try_recv() {
2672 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "FeedTimeline") {
2673 if let Some(ft) = self
2674 .screens
2675 .get_mut(idx)
2676 .and_then(|s| s.as_any_mut())
2677 .and_then(|a| a.downcast_mut::<FeedTimeline>())
2678 {
2679 match msg {
2680 FeedTimelineMessage::Loaded(t) => ft.set_timeline(t),
2681 FeedTimelineMessage::Failed(e) => ft.set_error(e),
2682 }
2683 }
2684 }
2685 }
2686 let mut buffered: Vec<crate::pubsub::PubsubMessage> = Vec::new();
2690 while let Ok(msg) = self.pubsub_msg_rx.try_recv() {
2691 buffered.push(msg);
2692 }
2693 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Pubsub") {
2694 if let Some(ps) = self
2695 .screens
2696 .get_mut(idx)
2697 .and_then(|s| s.as_any_mut())
2698 .and_then(|a| a.downcast_mut::<Pubsub>())
2699 {
2700 for m in buffered {
2701 ps.record(m);
2702 }
2703 ps.set_active_count(self.pubsub_subs.len());
2704 }
2705 }
2706 self.tick_alerts();
2710 }
2711 Action::Quit => self.should_quit = true,
2712 Action::Suspend => self.should_suspend = true,
2713 Action::Resume => self.should_suspend = false,
2714 Action::ClearScreen => tui.terminal.clear()?,
2715 Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
2716 Action::Render => self.render(tui)?,
2717 _ => {}
2718 }
2719 let tx = self.action_tx.clone();
2720 for component in self.iter_components_mut() {
2721 if let Some(action) = component.update(action.clone())? {
2722 tx.send(action)?
2723 };
2724 }
2725 }
2726 Ok(())
2727 }
2728
2729 fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
2730 tui.resize(Rect::new(0, 0, w, h))?;
2731 self.render(tui)?;
2732 Ok(())
2733 }
2734
2735 fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
2736 let active = self.current_screen;
2737 let tx = self.action_tx.clone();
2738 let screens = &mut self.screens;
2739 let log_pane = &mut self.log_pane;
2740 let log_pane_height = log_pane.height();
2741 let command_buffer = self.command_buffer.clone();
2742 let command_suggestion_index = self.command_suggestion_index;
2743 let command_status = self.command_status.clone();
2744 let help_visible = self.help_visible;
2745 let profile = self.api.name.clone();
2746 let endpoint = self.api.url.clone();
2747 let last_ping = self.health_rx.borrow().last_ping;
2748 let now_utc = format_utc_now();
2749 let bee_status_label = if self.supervisor.is_some() && !self.bee_status.is_running() {
2750 Some(self.bee_status.label())
2754 } else {
2755 None
2756 };
2757 tui.draw(|frame| {
2758 use ratatui::layout::{Constraint, Layout};
2759 use ratatui::style::{Color, Modifier, Style};
2760 use ratatui::text::{Line, Span};
2761 use ratatui::widgets::Paragraph;
2762
2763 let chunks = Layout::vertical([
2764 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), Constraint::Length(log_pane_height), ])
2769 .split(frame.area());
2770
2771 let top_chunks =
2772 Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(chunks[0]);
2773
2774 let ping_str = match last_ping {
2776 Some(d) => format!("{}ms", d.as_millis()),
2777 None => "—".into(),
2778 };
2779 let t = theme::active();
2780 let mut metadata_spans = vec![
2781 Span::styled(
2782 " bee-tui ",
2783 Style::default()
2784 .fg(Color::Black)
2785 .bg(t.info)
2786 .add_modifier(Modifier::BOLD),
2787 ),
2788 Span::raw(" "),
2789 Span::styled(
2790 profile,
2791 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
2792 ),
2793 Span::styled(format!(" @ {endpoint}"), Style::default().fg(t.dim)),
2794 Span::raw(" "),
2795 Span::styled("ping ", Style::default().fg(t.dim)),
2796 Span::styled(ping_str, Style::default().fg(t.info)),
2797 Span::raw(" "),
2798 Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
2799 ];
2800 if let Some(label) = bee_status_label.as_ref() {
2804 metadata_spans.push(Span::raw(" "));
2805 metadata_spans.push(Span::styled(
2806 format!(" {label} "),
2807 Style::default()
2808 .fg(Color::Black)
2809 .bg(t.fail)
2810 .add_modifier(Modifier::BOLD),
2811 ));
2812 }
2813 let metadata_line = Line::from(metadata_spans);
2814 frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
2815
2816 let theme = *theme::active();
2818 let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
2819 for (i, name) in SCREEN_NAMES.iter().enumerate() {
2820 let style = if i == active {
2821 Style::default()
2822 .fg(theme.tab_active_fg)
2823 .bg(theme.tab_active_bg)
2824 .add_modifier(Modifier::BOLD)
2825 } else {
2826 Style::default().fg(theme.dim)
2827 };
2828 tabs.push(Span::styled(format!(" {name} "), style));
2829 tabs.push(Span::raw(" "));
2830 }
2831 tabs.push(Span::styled(
2832 ":cmd · Tab to cycle · ? help",
2833 Style::default().fg(theme.dim),
2834 ));
2835 frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
2836
2837 if let Some(screen) = screens.get_mut(active) {
2839 if let Err(err) = screen.draw(frame, chunks[1]) {
2840 let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
2841 }
2842 }
2843 let prompt = if let Some(buf) = &command_buffer {
2845 Line::from(vec![
2846 Span::styled(
2847 ":",
2848 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
2849 ),
2850 Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
2851 Span::styled("█", Style::default().fg(t.accent)),
2852 ])
2853 } else {
2854 match &command_status {
2855 Some(CommandStatus::Info(msg)) => {
2856 Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
2857 }
2858 Some(CommandStatus::Err(msg)) => {
2859 Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
2860 }
2861 None => Line::from(""),
2862 }
2863 };
2864 frame.render_widget(Paragraph::new(prompt), chunks[2]);
2865
2866 if let Some(buf) = &command_buffer {
2872 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
2873 if !matches.is_empty() {
2874 draw_command_suggestions(
2875 frame,
2876 chunks[2],
2877 &matches,
2878 command_suggestion_index,
2879 &theme,
2880 );
2881 }
2882 }
2883
2884 if let Err(err) = log_pane.draw(frame, chunks[3]) {
2886 let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
2887 }
2888
2889 if help_visible {
2894 draw_help_overlay(frame, frame.area(), active, &theme);
2895 }
2896 })?;
2897 Ok(())
2898 }
2899}
2900
2901fn draw_command_suggestions(
2908 frame: &mut ratatui::Frame,
2909 bar_rect: ratatui::layout::Rect,
2910 matches: &[&(&str, &str)],
2911 selected: usize,
2912 theme: &theme::Theme,
2913) {
2914 use ratatui::layout::Rect;
2915 use ratatui::style::{Modifier, Style};
2916 use ratatui::text::{Line, Span};
2917 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2918
2919 const MAX_VISIBLE: usize = 10;
2920 let visible_rows = matches.len().min(MAX_VISIBLE);
2921 if visible_rows == 0 {
2922 return;
2923 }
2924 let height = (visible_rows as u16) + 2; let widest = matches
2929 .iter()
2930 .map(|(name, desc)| name.len() + desc.len() + 6)
2931 .max()
2932 .unwrap_or(40)
2933 .min(bar_rect.width as usize);
2934 let width = (widest as u16 + 2).min(bar_rect.width);
2935 let bottom = bar_rect.y;
2938 let y = bottom.saturating_sub(height);
2939 let popup = Rect {
2940 x: bar_rect.x,
2941 y,
2942 width,
2943 height: bottom - y,
2944 };
2945
2946 let scroll_start = if selected >= visible_rows {
2948 selected + 1 - visible_rows
2949 } else {
2950 0
2951 };
2952 let visible_slice = &matches[scroll_start..(scroll_start + visible_rows).min(matches.len())];
2953
2954 let mut lines: Vec<Line> = Vec::with_capacity(visible_slice.len());
2955 for (i, (name, desc)) in visible_slice.iter().enumerate() {
2956 let absolute_idx = scroll_start + i;
2957 let is_selected = absolute_idx == selected;
2958 let row_style = if is_selected {
2959 Style::default()
2960 .fg(theme.tab_active_fg)
2961 .bg(theme.tab_active_bg)
2962 .add_modifier(Modifier::BOLD)
2963 } else {
2964 Style::default()
2965 };
2966 let cursor = if is_selected { "▸ " } else { " " };
2967 lines.push(Line::from(vec![
2968 Span::styled(format!("{cursor}:{name:<16} "), row_style),
2969 Span::styled(
2970 desc.to_string(),
2971 if is_selected {
2972 row_style
2973 } else {
2974 Style::default().fg(theme.dim)
2975 },
2976 ),
2977 ]));
2978 }
2979
2980 let title = if matches.len() > MAX_VISIBLE {
2982 format!(" :commands ({}/{}) ", selected + 1, matches.len())
2983 } else {
2984 " :commands ".to_string()
2985 };
2986
2987 frame.render_widget(Clear, popup);
2988 frame.render_widget(
2989 Paragraph::new(lines).block(
2990 Block::default()
2991 .borders(Borders::ALL)
2992 .border_style(Style::default().fg(theme.accent))
2993 .title(title),
2994 ),
2995 popup,
2996 );
2997}
2998
2999fn draw_help_overlay(
3004 frame: &mut ratatui::Frame,
3005 area: ratatui::layout::Rect,
3006 active_screen: usize,
3007 theme: &theme::Theme,
3008) {
3009 use ratatui::layout::Rect;
3010 use ratatui::style::{Modifier, Style};
3011 use ratatui::text::{Line, Span};
3012 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
3013
3014 let screen_name = SCREEN_NAMES.get(active_screen).copied().unwrap_or("?");
3015 let screen_rows = screen_keymap(active_screen);
3016 let global_rows: &[(&str, &str)] = &[
3017 ("Tab", "next screen"),
3018 ("Shift+Tab", "previous screen"),
3019 ("[ / ]", "previous / next log-pane tab"),
3020 ("+ / -", "grow / shrink log pane"),
3021 ("Shift+↑/↓", "scroll log pane (1 line); pauses auto-tail"),
3022 ("Shift+PgUp/PgDn", "scroll log pane (10 lines)"),
3023 ("Shift+←/→", "pan log pane horizontally (8 cols)"),
3024 ("Shift+End", "resume auto-tail + reset horizontal pan"),
3025 ("?", "toggle this help"),
3026 (":", "open command bar"),
3027 ("qq", "quit (double-tap; or :q)"),
3028 ("Ctrl+C / Ctrl+D", "quit immediately"),
3029 ];
3030
3031 let w = area.width.min(72);
3034 let h = area.height.min(22);
3035 let x = area.x + (area.width.saturating_sub(w)) / 2;
3036 let y = area.y + (area.height.saturating_sub(h)) / 2;
3037 let rect = Rect {
3038 x,
3039 y,
3040 width: w,
3041 height: h,
3042 };
3043
3044 let mut lines: Vec<Line> = Vec::new();
3045 lines.push(Line::from(vec![
3046 Span::styled(
3047 format!(" {screen_name} "),
3048 Style::default()
3049 .fg(theme.tab_active_fg)
3050 .bg(theme.tab_active_bg)
3051 .add_modifier(Modifier::BOLD),
3052 ),
3053 Span::raw(" screen-specific keys"),
3054 ]));
3055 lines.push(Line::from(""));
3056 if screen_rows.is_empty() {
3057 lines.push(Line::from(Span::styled(
3058 " (no extra keys for this screen — use the command bar via :)",
3059 Style::default()
3060 .fg(theme.dim)
3061 .add_modifier(Modifier::ITALIC),
3062 )));
3063 } else {
3064 for (key, desc) in screen_rows {
3065 lines.push(format_help_row(key, desc, theme));
3066 }
3067 }
3068 lines.push(Line::from(""));
3069 lines.push(Line::from(Span::styled(
3070 " global",
3071 Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
3072 )));
3073 for (key, desc) in global_rows {
3074 lines.push(format_help_row(key, desc, theme));
3075 }
3076 lines.push(Line::from(""));
3077 lines.push(Line::from(Span::styled(
3078 " Esc / ? / q to dismiss",
3079 Style::default()
3080 .fg(theme.dim)
3081 .add_modifier(Modifier::ITALIC),
3082 )));
3083
3084 frame.render_widget(Clear, rect);
3087 frame.render_widget(
3088 Paragraph::new(lines).block(
3089 Block::default()
3090 .borders(Borders::ALL)
3091 .border_style(Style::default().fg(theme.accent))
3092 .title(" help "),
3093 ),
3094 rect,
3095 );
3096}
3097
3098fn format_help_row<'a>(
3099 key: &'a str,
3100 desc: &'a str,
3101 theme: &theme::Theme,
3102) -> ratatui::text::Line<'a> {
3103 use ratatui::style::{Modifier, Style};
3104 use ratatui::text::{Line, Span};
3105 Line::from(vec![
3106 Span::raw(" "),
3107 Span::styled(
3108 format!("{key:<16}"),
3109 Style::default()
3110 .fg(theme.accent)
3111 .add_modifier(Modifier::BOLD),
3112 ),
3113 Span::raw(" "),
3114 Span::raw(desc),
3115 ])
3116}
3117
3118fn screen_keymap(active_screen: usize) -> &'static [(&'static str, &'static str)] {
3122 match active_screen {
3123 1 => &[
3125 ("↑↓ / j k", "move row selection"),
3126 ("Enter", "drill batch — bucket histogram + worst-N"),
3127 ("Esc", "close drill"),
3128 ],
3129 3 => &[("r", "run on-demand rchash benchmark")],
3131 4 => &[
3132 ("↑↓ / j k", "move peer selection"),
3133 (
3134 "Enter",
3135 "drill peer — balance / cheques / settlement / ping",
3136 ),
3137 ("Esc", "close drill"),
3138 ],
3139 8 => &[
3143 ("↑↓ / j k", "scroll one row"),
3144 ("PgUp / PgDn", "scroll ten rows"),
3145 ("Home", "back to top"),
3146 ],
3147 9 => &[
3149 ("↑↓ / j k", "move row selection"),
3150 ("Enter", "integrity-check the highlighted pin"),
3151 ("c", "integrity-check every unchecked pin"),
3152 ("s", "cycle sort: ref order / bad first / by size"),
3153 ],
3154 10 => &[
3156 ("↑↓ / j k", "move row selection"),
3157 ("Enter", "expand / collapse fork (loads child chunk)"),
3158 (":manifest <ref>", "open a manifest at a reference"),
3159 (":inspect <ref>", "what is this? auto-detects manifest"),
3160 ],
3161 11 => &[
3163 ("↑↓ / j k", "move row selection"),
3164 (":durability-check <ref>", "walk chunk graph + record"),
3165 ],
3166 12 => &[
3168 ("↑↓ / j k", "move row selection"),
3169 ("PgUp / PgDn", "jump 10 rows"),
3170 (
3171 ":feed-timeline <owner> <topic> [N]",
3172 "load history (default 50)",
3173 ),
3174 ],
3175 13 => &[
3177 ("↑↓ / j k", "move row selection"),
3178 ("PgUp / PgDn", "jump 10 rows"),
3179 ("c", "clear timeline"),
3180 (":pubsub-pss <topic>", "subscribe to a PSS topic"),
3181 (":pubsub-gsoc <owner> <id>", "subscribe to a GSOC SOC"),
3182 (":pubsub-stop [sub-id]", "stop one (or all) subscriptions"),
3183 ],
3184 _ => &[],
3185 }
3186}
3187
3188fn build_screens(
3197 api: &Arc<ApiClient>,
3198 watch: &BeeWatch,
3199 market_rx: Option<watch::Receiver<crate::economics_oracle::EconomicsSnapshot>>,
3200) -> Vec<Box<dyn Component>> {
3201 let health = Health::new(api.clone(), watch.health(), watch.topology());
3202 let stamps = Stamps::new(api.clone(), watch.stamps());
3203 let swap = match market_rx {
3204 Some(rx) => Swap::new(watch.swap()).with_market_feed(rx),
3205 None => Swap::new(watch.swap()),
3206 };
3207 let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
3208 let peers = Peers::new(api.clone(), watch.topology());
3209 let network = Network::new(watch.network(), watch.topology());
3210 let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
3211 let api_health = ApiHealth::new(
3212 api.clone(),
3213 watch.health(),
3214 watch.transactions(),
3215 log_capture::handle(),
3216 );
3217 let tags = Tags::new(watch.tags());
3218 let pins = Pins::new(api.clone(), watch.pins());
3219 let manifest = Manifest::new(api.clone());
3220 let watchlist = Watchlist::new();
3221 let feed_timeline = FeedTimeline::new();
3222 let pubsub_screen = Pubsub::new();
3223 vec![
3224 Box::new(health),
3225 Box::new(stamps),
3226 Box::new(swap),
3227 Box::new(lottery),
3228 Box::new(peers),
3229 Box::new(network),
3230 Box::new(warmup),
3231 Box::new(api_health),
3232 Box::new(tags),
3233 Box::new(pins),
3234 Box::new(manifest),
3235 Box::new(watchlist),
3236 Box::new(feed_timeline),
3237 Box::new(pubsub_screen),
3238 ]
3239}
3240
3241fn build_synthetic_probe_chunk() -> Vec<u8> {
3249 use std::time::{SystemTime, UNIX_EPOCH};
3250 let nanos = SystemTime::now()
3251 .duration_since(UNIX_EPOCH)
3252 .map(|d| d.as_nanos())
3253 .unwrap_or(0);
3254 let mut data = Vec::with_capacity(8 + 4096);
3255 data.extend_from_slice(&4096u64.to_le_bytes());
3257 data.extend_from_slice(&nanos.to_le_bytes());
3259 data.resize(8 + 4096, 0);
3260 data
3261}
3262
3263fn short_hex(hex: &str, len: usize) -> String {
3266 if hex.len() > len {
3267 format!("{}…", &hex[..len])
3268 } else {
3269 hex.to_string()
3270 }
3271}
3272
3273fn guess_content_type(path: &std::path::Path) -> String {
3279 let ext = path
3280 .extension()
3281 .and_then(|e| e.to_str())
3282 .map(|s| s.to_ascii_lowercase());
3283 match ext.as_deref() {
3284 Some("html") | Some("htm") => "text/html",
3285 Some("txt") | Some("md") => "text/plain",
3286 Some("json") => "application/json",
3287 Some("css") => "text/css",
3288 Some("js") => "application/javascript",
3289 Some("png") => "image/png",
3290 Some("jpg") | Some("jpeg") => "image/jpeg",
3291 Some("gif") => "image/gif",
3292 Some("svg") => "image/svg+xml",
3293 Some("webp") => "image/webp",
3294 Some("pdf") => "application/pdf",
3295 Some("zip") => "application/zip",
3296 Some("tar") => "application/x-tar",
3297 Some("gz") | Some("tgz") => "application/gzip",
3298 Some("wasm") => "application/wasm",
3299 _ => "",
3300 }
3301 .to_string()
3302}
3303
3304fn build_metrics_render_fn(
3310 watch: BeeWatch,
3311 log_capture: Option<log_capture::LogCapture>,
3312) -> crate::metrics_server::RenderFn {
3313 use std::time::{SystemTime, UNIX_EPOCH};
3314 Arc::new(move || {
3315 let health = watch.health().borrow().clone();
3316 let stamps = watch.stamps().borrow().clone();
3317 let swap = watch.swap().borrow().clone();
3318 let lottery = watch.lottery().borrow().clone();
3319 let topology = watch.topology().borrow().clone();
3320 let network = watch.network().borrow().clone();
3321 let transactions = watch.transactions().borrow().clone();
3322 let recent = log_capture
3323 .as_ref()
3324 .map(|c| c.snapshot())
3325 .unwrap_or_default();
3326 let call_stats = crate::components::api_health::call_stats_for(&recent);
3327 let now_unix = SystemTime::now()
3328 .duration_since(UNIX_EPOCH)
3329 .map(|d| d.as_secs() as i64)
3330 .unwrap_or(0);
3331 let inputs = crate::metrics::MetricsInputs {
3332 bee_tui_version: env!("CARGO_PKG_VERSION"),
3333 health: &health,
3334 stamps: &stamps,
3335 swap: &swap,
3336 lottery: &lottery,
3337 topology: &topology,
3338 network: &network,
3339 transactions: &transactions,
3340 call_stats: &call_stats,
3341 now_unix,
3342 };
3343 crate::metrics::render(&inputs)
3344 })
3345}
3346
3347fn format_gate_line(g: &Gate) -> String {
3348 let glyphs = crate::theme::active().glyphs;
3349 let glyph = match g.status {
3350 GateStatus::Pass => glyphs.pass,
3351 GateStatus::Warn => glyphs.warn,
3352 GateStatus::Fail => glyphs.fail,
3353 GateStatus::Unknown => glyphs.bullet,
3354 };
3355 let mut s = format!(
3356 " [{glyph}] {label:<28} {value}\n",
3357 label = g.label,
3358 value = g.value
3359 );
3360 if let Some(why) = &g.why {
3361 s.push_str(&format!(" {} {why}\n", glyphs.continuation));
3362 }
3363 s
3364}
3365
3366fn path_only(url: &str) -> String {
3369 if let Some(idx) = url.find("//") {
3370 let after_scheme = &url[idx + 2..];
3371 if let Some(slash) = after_scheme.find('/') {
3372 return after_scheme[slash..].to_string();
3373 }
3374 return "/".into();
3375 }
3376 url.to_string()
3377}
3378
3379fn append(path: &PathBuf, s: &str) -> std::io::Result<()> {
3386 use std::io::Write;
3387 let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
3388 f.write_all(s.as_bytes())
3389}
3390
3391fn verbosity_rank(s: &str) -> u8 {
3397 match s {
3398 "all" | "trace" => 5,
3399 "debug" => 4,
3400 "info" | "1" => 3,
3401 "warning" | "warn" | "2" => 2,
3402 "error" | "3" => 1,
3403 _ => 0,
3404 }
3405}
3406
3407fn sanitize_for_filename(s: &str) -> String {
3411 s.chars()
3412 .map(|c| match c {
3413 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
3414 _ => '-',
3415 })
3416 .collect()
3417}
3418
3419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3423pub enum QuitResolution {
3424 Confirm,
3426 Pending,
3429}
3430
3431fn resolve_quit_press(prev: Option<Instant>, now: Instant, window: Duration) -> QuitResolution {
3436 match prev {
3437 Some(t) if now.duration_since(t) <= window => QuitResolution::Confirm,
3438 _ => QuitResolution::Pending,
3439 }
3440}
3441
3442fn format_utc_now() -> String {
3443 let secs = SystemTime::now()
3444 .duration_since(UNIX_EPOCH)
3445 .map(|d| d.as_secs())
3446 .unwrap_or(0);
3447 let secs_in_day = secs % 86_400;
3448 let h = secs_in_day / 3_600;
3449 let m = (secs_in_day % 3_600) / 60;
3450 let s = secs_in_day % 60;
3451 format!("{h:02}:{m:02}:{s:02}")
3452}
3453
3454#[cfg(test)]
3455mod tests {
3456 use super::*;
3457
3458 #[test]
3459 fn format_utc_now_returns_eight_chars() {
3460 let s = format_utc_now();
3461 assert_eq!(s.len(), 8);
3462 assert_eq!(s.chars().nth(2), Some(':'));
3463 assert_eq!(s.chars().nth(5), Some(':'));
3464 }
3465
3466 #[test]
3467 fn path_only_strips_scheme_and_host() {
3468 assert_eq!(path_only("http://10.0.1.5:1633/status"), "/status");
3469 assert_eq!(
3470 path_only("https://bee.example.com/stamps?limit=10"),
3471 "/stamps?limit=10"
3472 );
3473 }
3474
3475 #[test]
3476 fn path_only_handles_no_path() {
3477 assert_eq!(path_only("http://localhost:1633"), "/");
3478 }
3479
3480 #[test]
3481 fn path_only_passes_relative_through() {
3482 assert_eq!(path_only("/already/relative"), "/already/relative");
3483 }
3484
3485 #[test]
3486 fn parse_pprof_arg_default_60() {
3487 assert_eq!(parse_pprof_arg("diagnose --pprof"), Some(60));
3488 assert_eq!(parse_pprof_arg("diag --pprof some other"), Some(60));
3489 }
3490
3491 #[test]
3492 fn parse_pprof_arg_with_explicit_seconds() {
3493 assert_eq!(parse_pprof_arg("diagnose --pprof=120"), Some(120));
3494 assert_eq!(parse_pprof_arg("diagnose --pprof=15 trailing"), Some(15));
3495 }
3496
3497 #[test]
3498 fn parse_pprof_arg_clamps_extreme_values() {
3499 assert_eq!(parse_pprof_arg("diagnose --pprof=0"), Some(1));
3501 assert_eq!(parse_pprof_arg("diagnose --pprof=9999"), Some(600));
3502 }
3503
3504 #[test]
3505 fn parse_pprof_arg_none_when_absent() {
3506 assert_eq!(parse_pprof_arg("diagnose"), None);
3507 assert_eq!(parse_pprof_arg("diag"), None);
3508 assert_eq!(parse_pprof_arg(""), None);
3509 }
3510
3511 #[test]
3512 fn parse_pprof_arg_ignores_garbage_value() {
3513 assert_eq!(parse_pprof_arg("diagnose --pprof=lol"), None);
3516 }
3517
3518 #[test]
3519 fn guess_content_type_known_extensions() {
3520 let p = std::path::PathBuf::from;
3521 assert_eq!(guess_content_type(&p("/tmp/x.html")), "text/html");
3522 assert_eq!(guess_content_type(&p("/tmp/x.json")), "application/json");
3523 assert_eq!(guess_content_type(&p("/tmp/x.PNG")), "image/png");
3524 assert_eq!(guess_content_type(&p("/tmp/x.tar.gz")), "application/gzip");
3525 }
3526
3527 #[test]
3528 fn guess_content_type_unknown_returns_empty() {
3529 let p = std::path::PathBuf::from;
3530 assert_eq!(guess_content_type(&p("/tmp/x.unknownext")), "");
3533 assert_eq!(guess_content_type(&p("/tmp/no-extension")), "");
3534 }
3535
3536 #[test]
3537 fn sanitize_for_filename_keeps_safe_chars() {
3538 assert_eq!(sanitize_for_filename("prod-1"), "prod-1");
3539 assert_eq!(sanitize_for_filename("lab_node"), "lab_node");
3540 }
3541
3542 #[test]
3543 fn sanitize_for_filename_replaces_unsafe_chars() {
3544 assert_eq!(sanitize_for_filename("a/b\\c d"), "a-b-c-d");
3545 assert_eq!(sanitize_for_filename("name:colon"), "name-colon");
3546 }
3547
3548 #[test]
3549 fn resolve_quit_press_first_press_is_pending() {
3550 let now = Instant::now();
3551 assert_eq!(
3552 resolve_quit_press(None, now, Duration::from_millis(1500)),
3553 QuitResolution::Pending
3554 );
3555 }
3556
3557 #[test]
3558 fn resolve_quit_press_second_press_inside_window_confirms() {
3559 let first = Instant::now();
3560 let window = Duration::from_millis(1500);
3561 let second = first + Duration::from_millis(500);
3562 assert_eq!(
3563 resolve_quit_press(Some(first), second, window),
3564 QuitResolution::Confirm
3565 );
3566 }
3567
3568 #[test]
3569 fn resolve_quit_press_second_press_after_window_resets_to_pending() {
3570 let first = Instant::now();
3574 let window = Duration::from_millis(1500);
3575 let second = first + Duration::from_millis(2_000);
3576 assert_eq!(
3577 resolve_quit_press(Some(first), second, window),
3578 QuitResolution::Pending
3579 );
3580 }
3581
3582 #[test]
3583 fn resolve_quit_press_at_window_boundary_confirms() {
3584 let first = Instant::now();
3587 let window = Duration::from_millis(1500);
3588 let second = first + window;
3589 assert_eq!(
3590 resolve_quit_press(Some(first), second, window),
3591 QuitResolution::Confirm
3592 );
3593 }
3594
3595 #[test]
3596 fn screen_keymap_covers_drill_screens() {
3597 for idx in [1usize, 4] {
3600 let rows = screen_keymap(idx);
3601 assert!(
3602 rows.iter().any(|(k, _)| k.contains("Enter")),
3603 "screen {idx} keymap must mention Enter (drill)"
3604 );
3605 assert!(
3606 rows.iter().any(|(k, _)| k.contains("Esc")),
3607 "screen {idx} keymap must mention Esc (close drill)"
3608 );
3609 }
3610 }
3611
3612 #[test]
3613 fn screen_keymap_lottery_advertises_rchash() {
3614 let rows = screen_keymap(3);
3615 assert!(rows.iter().any(|(k, _)| k.contains("r")));
3616 }
3617
3618 #[test]
3619 fn screen_keymap_unknown_index_is_empty_not_panic() {
3620 assert!(screen_keymap(999).is_empty());
3621 }
3622
3623 #[test]
3624 fn verbosity_rank_orders_loud_to_silent() {
3625 assert!(verbosity_rank("all") > verbosity_rank("debug"));
3626 assert!(verbosity_rank("debug") > verbosity_rank("info"));
3627 assert!(verbosity_rank("info") > verbosity_rank("warning"));
3628 assert!(verbosity_rank("warning") > verbosity_rank("error"));
3629 assert!(verbosity_rank("error") > verbosity_rank("unknown"));
3630 assert_eq!(verbosity_rank("info"), verbosity_rank("1"));
3632 assert_eq!(verbosity_rank("warning"), verbosity_rank("2"));
3633 }
3634
3635 #[test]
3636 fn filter_command_suggestions_empty_buffer_returns_all() {
3637 let matches = filter_command_suggestions("", KNOWN_COMMANDS);
3638 assert_eq!(matches.len(), KNOWN_COMMANDS.len());
3639 }
3640
3641 #[test]
3642 fn filter_command_suggestions_prefix_matches_case_insensitive() {
3643 let matches = filter_command_suggestions("Bu", KNOWN_COMMANDS);
3644 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
3645 assert!(names.contains(&"buy-preview"));
3646 assert!(names.contains(&"buy-suggest"));
3647 assert_eq!(names.len(), 2);
3648 }
3649
3650 #[test]
3651 fn filter_command_suggestions_unknown_prefix_is_empty() {
3652 let matches = filter_command_suggestions("xyz", KNOWN_COMMANDS);
3653 assert!(matches.is_empty());
3654 }
3655
3656 #[test]
3657 fn filter_command_suggestions_uses_first_token_only() {
3658 let matches = filter_command_suggestions("topup-preview a1b2 1000", KNOWN_COMMANDS);
3661 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
3662 assert_eq!(names, vec!["topup-preview"]);
3663 }
3664
3665 #[test]
3666 fn probe_chunk_is_4104_bytes_with_correct_span() {
3667 let chunk = build_synthetic_probe_chunk();
3669 assert_eq!(chunk.len(), 4104);
3670 let span = u64::from_le_bytes(chunk[..8].try_into().unwrap());
3671 assert_eq!(span, 4096);
3672 }
3673
3674 #[test]
3675 fn probe_chunk_payloads_are_unique_per_call() {
3676 let a = build_synthetic_probe_chunk();
3681 std::thread::sleep(Duration::from_micros(1));
3683 let b = build_synthetic_probe_chunk();
3684 assert_ne!(&a[8..24], &b[8..24]);
3685 }
3686
3687 #[test]
3688 fn short_hex_truncates_with_ellipsis() {
3689 assert_eq!(short_hex("a1b2c3d4e5f6", 8), "a1b2c3d4…");
3690 assert_eq!(short_hex("short", 8), "short");
3691 assert_eq!(short_hex("abcdefgh", 8), "abcdefgh");
3692 }
3693}