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 stamps::Stamps,
29 swap::Swap,
30 tags::Tags,
31 warmup::Warmup,
32 watchlist::Watchlist,
33 },
34 config::Config,
35 config_doctor, durability, economics_oracle, log_capture,
36 manifest_walker::{self, InspectResult},
37 pprof_bundle, stamp_preview,
38 state::State,
39 theme,
40 tui::{Event, Tui},
41 utility_verbs, version_check,
42 watch::{BeeWatch, HealthSnapshot, RefreshProfile},
43};
44
45pub struct App {
46 config: Config,
47 tick_rate: f64,
48 frame_rate: f64,
49 screens: Vec<Box<dyn Component>>,
53 current_screen: usize,
55 log_pane: LogPane,
59 state_path: PathBuf,
62 should_quit: bool,
63 should_suspend: bool,
64 mode: Mode,
65 last_tick_key_events: Vec<KeyEvent>,
66 action_tx: mpsc::UnboundedSender<Action>,
67 action_rx: mpsc::UnboundedReceiver<Action>,
68 root_cancel: CancellationToken,
71 #[allow(dead_code)]
74 api: Arc<ApiClient>,
75 watch: BeeWatch,
77 health_rx: watch::Receiver<HealthSnapshot>,
80 command_buffer: Option<String>,
83 command_suggestion_index: usize,
88 command_status: Option<CommandStatus>,
92 help_visible: bool,
95 quit_pending: Option<Instant>,
101 supervisor: Option<BeeSupervisor>,
105 bee_status: BeeStatus,
110 bee_log_rx: Option<mpsc::UnboundedReceiver<(LogTab, BeeLogLine)>>,
114 cmd_status_tx: mpsc::UnboundedSender<CommandStatus>,
120 cmd_status_rx: mpsc::UnboundedReceiver<CommandStatus>,
121 durability_tx: mpsc::UnboundedSender<crate::durability::DurabilityResult>,
127 durability_rx: mpsc::UnboundedReceiver<crate::durability::DurabilityResult>,
128 feed_timeline_tx: mpsc::UnboundedSender<FeedTimelineMessage>,
132 feed_timeline_rx: mpsc::UnboundedReceiver<FeedTimelineMessage>,
133 watch_refs: std::collections::HashMap<String, CancellationToken>,
137 alert_state: crate::alerts::AlertState,
144}
145
146const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
149
150#[derive(Debug, Clone)]
153pub enum CommandStatus {
154 Info(String),
155 Err(String),
156}
157
158#[derive(Debug, Clone)]
162pub enum FeedTimelineMessage {
163 Loaded(crate::feed_timeline::Timeline),
164 Failed(String),
165}
166
167const SCREEN_NAMES: &[&str] = &[
170 "Health",
171 "Stamps",
172 "Swap",
173 "Lottery",
174 "Peers",
175 "Network",
176 "Warmup",
177 "API",
178 "Tags",
179 "Pins",
180 "Manifest",
181 "Watchlist",
182 "FeedTimeline",
183];
184
185const KNOWN_COMMANDS: &[(&str, &str)] = &[
196 ("health", "S1 Health screen"),
197 ("stamps", "S2 Stamps screen"),
198 ("swap", "S3 SWAP / cheques screen"),
199 ("lottery", "S4 Lottery + rchash"),
200 ("peers", "S6 Peers + bin saturation"),
201 ("network", "S7 Network / NAT"),
202 ("warmup", "S5 Warmup checklist"),
203 ("api", "S8 RPC / API health"),
204 ("tags", "S9 Tags / uploads"),
205 ("pins", "S11 Pins screen"),
206 ("topup-preview", "<batch> <amount-plur> — predict topup"),
207 ("dilute-preview", "<batch> <new-depth> — predict dilute"),
208 ("extend-preview", "<batch> <duration> — predict extend"),
209 ("buy-preview", "<depth> <amount-plur> — predict fresh buy"),
210 ("buy-suggest", "<size> <duration> — minimum (depth, amount)"),
211 (
212 "plan-batch",
213 "<batch> [usage-thr] [ttl-thr] [extra-depth] — unified topup+dilute plan",
214 ),
215 (
216 "check-version",
217 "compare running Bee version with GitHub's latest release",
218 ),
219 (
220 "config-doctor",
221 "audit bee.yaml for deprecated keys (read-only, never modifies)",
222 ),
223 ("price", "fetch xBZZ → USD spot price"),
224 (
225 "basefee",
226 "fetch Gnosis basefee + tip (requires [economics].gnosis_rpc_url)",
227 ),
228 (
229 "probe-upload",
230 "<batch> — single 4 KiB chunk, end-to-end probe",
231 ),
232 (
233 "upload-file",
234 "<path> <batch> — upload a single local file, return Swarm ref",
235 ),
236 (
237 "upload-collection",
238 "<dir> <batch> — recursive directory upload, return Swarm ref",
239 ),
240 (
241 "feed-probe",
242 "<owner> <topic> — latest update for a feed (read-only lookup)",
243 ),
244 (
245 "feed-timeline",
246 "<owner> <topic> [N] — walk a feed's history, open S14",
247 ),
248 (
249 "manifest",
250 "<ref> — open Mantaray tree browser at a reference",
251 ),
252 (
253 "inspect",
254 "<ref> — what is this? auto-detects manifest vs raw chunk",
255 ),
256 (
257 "durability-check",
258 "<ref> — walk chunk graph, report total / lost / errors",
259 ),
260 (
261 "watch-ref",
262 "<ref> [interval] — run :durability-check every interval (default 60s)",
263 ),
264 (
265 "watch-ref-stop",
266 "[ref] — stop one :watch-ref daemon (or all if no arg)",
267 ),
268 ("watchlist", "S13 Watchlist — durability-check history"),
269 (
270 "hash",
271 "<path> — Swarm reference of a local file/dir (offline)",
272 ),
273 ("cid", "<ref> [manifest|feed] — encode reference as CID"),
274 ("depth-table", "Print canonical depth → capacity table"),
275 (
276 "gsoc-mine",
277 "<overlay> <id> — mine a GSOC signer (CPU work)",
278 ),
279 (
280 "pss-target",
281 "<overlay> — first 4 hex chars (Bee's max prefix)",
282 ),
283 (
284 "diagnose",
285 "[--pprof[=N]] Export snapshot (+ optional CPU profile + trace)",
286 ),
287 ("pins-check", "Bulk integrity walk to a file"),
288 ("loggers", "Dump live logger registry"),
289 ("set-logger", "<expr> <level> — change a logger's verbosity"),
290 ("context", "<name> — switch node profile"),
291 ("quit", "Exit the cockpit"),
292];
293
294fn parse_pprof_arg(line: &str) -> Option<u32> {
299 for tok in line.split_whitespace() {
300 if tok == "--pprof" {
301 return Some(60);
302 }
303 if let Some(rest) = tok.strip_prefix("--pprof=") {
304 if let Ok(n) = rest.parse::<u32>() {
305 return Some(n.clamp(1, 600));
306 }
307 }
308 }
309 None
310}
311
312fn filter_command_suggestions<'a>(
316 buffer: &str,
317 catalog: &'a [(&'a str, &'a str)],
318) -> Vec<&'a (&'a str, &'a str)> {
319 let head = buffer
320 .split_whitespace()
321 .next()
322 .unwrap_or("")
323 .to_ascii_lowercase();
324 catalog
325 .iter()
326 .filter(|(name, _)| name.starts_with(&head))
327 .collect()
328}
329
330#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
331pub enum Mode {
332 #[default]
333 Home,
334}
335
336#[derive(Debug, Default)]
339pub struct AppOverrides {
340 pub ascii: bool,
342 pub no_color: bool,
344 pub bee_bin: Option<PathBuf>,
346 pub bee_config: Option<PathBuf>,
348}
349
350const BEE_API_READY_TIMEOUT: Duration = Duration::from_secs(60);
355
356impl App {
357 pub async fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
358 Self::with_overrides(tick_rate, frame_rate, AppOverrides::default()).await
359 }
360
361 pub async fn with_overrides(
366 tick_rate: f64,
367 frame_rate: f64,
368 overrides: AppOverrides,
369 ) -> color_eyre::Result<Self> {
370 let (action_tx, action_rx) = mpsc::unbounded_channel();
371 let (cmd_status_tx, cmd_status_rx) = mpsc::unbounded_channel();
372 let (durability_tx, durability_rx) = mpsc::unbounded_channel();
373 let (feed_timeline_tx, feed_timeline_rx) = mpsc::unbounded_channel();
374 let config = Config::new()?;
375 let force_no_color = overrides.no_color || theme::no_color_env();
378 theme::install_with_overrides(&config.ui, force_no_color, overrides.ascii);
379
380 let node = config
383 .active_node()
384 .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
385 let api = Arc::new(ApiClient::from_node(node)?);
386
387 let bee_bin = overrides
389 .bee_bin
390 .or_else(|| config.bee.as_ref().map(|b| b.bin.clone()));
391 let bee_config = overrides
392 .bee_config
393 .or_else(|| config.bee.as_ref().map(|b| b.config.clone()));
394 let bee_logs = config
397 .bee
398 .as_ref()
399 .map(|b| b.logs.clone())
400 .unwrap_or_default();
401 let supervisor = match (bee_bin, bee_config) {
402 (Some(bin), Some(cfg)) => {
403 eprintln!("bee-tui: spawning bee {bin:?} --config {cfg:?}");
404 let mut sup = BeeSupervisor::spawn(&bin, &cfg, bee_logs)?;
405 eprintln!(
406 "bee-tui: log → {} (will appear in the cockpit's bottom pane)",
407 sup.log_path().display()
408 );
409 eprintln!(
410 "bee-tui: waiting for {} to respond on /health (up to {:?})...",
411 api.url, BEE_API_READY_TIMEOUT
412 );
413 sup.wait_for_api(&api.url, BEE_API_READY_TIMEOUT).await?;
414 eprintln!("bee-tui: bee ready, opening cockpit");
415 Some(sup)
416 }
417 (Some(_), None) | (None, Some(_)) => {
418 return Err(eyre!(
419 "[bee].bin and [bee].config must both be set (or both unset). \
420 Use --bee-bin AND --bee-config, or both fields in config.toml."
421 ));
422 }
423 (None, None) => None,
424 };
425
426 let refresh = RefreshProfile::from_config(&config.ui.refresh);
433 let root_cancel = CancellationToken::new();
434 let watch = BeeWatch::start_with_profile(api.clone(), &root_cancel, refresh);
435 let health_rx = watch.health();
436
437 let market_rx = if config.economics.enable_market_tile {
441 Some(economics_oracle::spawn_poller(
442 config.economics.gnosis_rpc_url.clone(),
443 root_cancel.child_token(),
444 ))
445 } else {
446 None
447 };
448
449 let screens = build_screens(&api, &watch, market_rx);
450 let (persisted, state_path) = State::load();
455 let initial_tab = LogTab::from_kebab(&persisted.log_pane_active_tab);
456 let mut log_pane = LogPane::new(
457 log_capture::handle(),
458 initial_tab,
459 persisted.log_pane_height,
460 );
461 log_pane.set_spawn_active(supervisor.is_some());
462 if let Some(c) = log_capture::cockpit_handle() {
463 log_pane.set_cockpit_capture(c);
464 }
465
466 let bee_log_rx = supervisor.as_ref().map(|sup| {
472 let (tx, rx) = mpsc::unbounded_channel();
473 crate::bee_log_tailer::spawn(
474 sup.log_path().to_path_buf(),
475 tx,
476 root_cancel.child_token(),
477 );
478 rx
479 });
480
481 if config.metrics.enabled {
488 match config.metrics.addr.parse::<std::net::SocketAddr>() {
489 Ok(bind_addr) => {
490 let render_fn = build_metrics_render_fn(watch.clone(), log_capture::handle());
491 let cancel = root_cancel.child_token();
492 match crate::metrics_server::spawn(bind_addr, render_fn, cancel).await {
493 Ok(actual) => {
494 eprintln!(
495 "bee-tui: metrics endpoint serving /metrics on http://{actual}"
496 );
497 }
498 Err(e) => {
499 tracing::error!(
500 "metrics: failed to start endpoint on {bind_addr}: {e}"
501 );
502 }
503 }
504 }
505 Err(e) => {
506 tracing::error!(
507 "metrics: invalid [metrics].addr {:?}: {e}",
508 config.metrics.addr
509 );
510 }
511 }
512 }
513
514 let config_alerts_debounce = config.alerts.debounce_secs;
515
516 Ok(Self {
517 tick_rate,
518 frame_rate,
519 screens,
520 current_screen: 0,
521 log_pane,
522 state_path,
523 should_quit: false,
524 should_suspend: false,
525 config,
526 mode: Mode::Home,
527 last_tick_key_events: Vec::new(),
528 action_tx,
529 action_rx,
530 root_cancel,
531 api,
532 watch,
533 health_rx,
534 command_buffer: None,
535 command_suggestion_index: 0,
536 command_status: None,
537 help_visible: false,
538 quit_pending: None,
539 supervisor,
540 bee_status: BeeStatus::Running,
541 bee_log_rx,
542 cmd_status_tx,
543 cmd_status_rx,
544 durability_tx,
545 durability_rx,
546 feed_timeline_tx,
547 feed_timeline_rx,
548 watch_refs: std::collections::HashMap::new(),
549 alert_state: crate::alerts::AlertState::new(config_alerts_debounce),
550 })
551 }
552
553 pub async fn run(&mut self) -> color_eyre::Result<()> {
554 let mut tui = Tui::new()?
555 .tick_rate(self.tick_rate)
557 .frame_rate(self.frame_rate);
558 tui.enter()?;
559
560 let tx = self.action_tx.clone();
561 let cfg = self.config.clone();
562 let size = tui.size()?;
563 for component in self.iter_components_mut() {
564 component.register_action_handler(tx.clone())?;
565 component.register_config_handler(cfg.clone())?;
566 component.init(size)?;
567 }
568
569 let action_tx = self.action_tx.clone();
570 loop {
571 self.handle_events(&mut tui).await?;
572 self.handle_actions(&mut tui)?;
573 if self.should_suspend {
574 tui.suspend()?;
575 action_tx.send(Action::Resume)?;
576 action_tx.send(Action::ClearScreen)?;
577 tui.enter()?;
579 } else if self.should_quit {
580 tui.stop()?;
581 break;
582 }
583 }
584 self.watch.shutdown();
586 self.root_cancel.cancel();
587 let snapshot = State {
591 log_pane_height: self.log_pane.height(),
592 log_pane_active_tab: self.log_pane.active_tab().to_kebab().to_string(),
593 };
594 snapshot.save(&self.state_path);
595 if let Some(sup) = self.supervisor.take() {
599 let final_status = sup.shutdown_default().await;
600 tracing::info!("bee child exited: {}", final_status.label());
601 }
602 tui.exit()?;
603 Ok(())
604 }
605
606 async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
607 let Some(event) = tui.next_event().await else {
608 return Ok(());
609 };
610 let action_tx = self.action_tx.clone();
611 let modal_before = self.command_buffer.is_some() || self.help_visible;
618 match event {
619 Event::Quit => action_tx.send(Action::Quit)?,
620 Event::Tick => action_tx.send(Action::Tick)?,
621 Event::Render => action_tx.send(Action::Render)?,
622 Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
623 Event::Key(key) => self.handle_key_event(key)?,
624 _ => {}
625 }
626 let modal_after = self.command_buffer.is_some() || self.help_visible;
627 let propagate = !((modal_before || modal_after) && matches!(event, Event::Key(_)));
630 if propagate {
631 for component in self.iter_components_mut() {
632 if let Some(action) = component.handle_events(Some(event.clone()))? {
633 action_tx.send(action)?;
634 }
635 }
636 }
637 Ok(())
638 }
639
640 fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut dyn Component> {
645 self.screens
646 .iter_mut()
647 .map(|c| c.as_mut() as &mut dyn Component)
648 .chain(std::iter::once(&mut self.log_pane as &mut dyn Component))
649 }
650
651 fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
652 if self.command_buffer.is_some() {
656 self.handle_command_mode_key(key)?;
657 return Ok(());
658 }
659 if self.help_visible {
663 match key.code {
664 crossterm::event::KeyCode::Esc
665 | crossterm::event::KeyCode::Char('?')
666 | crossterm::event::KeyCode::Char('q') => {
667 self.help_visible = false;
668 }
669 _ => {}
670 }
671 return Ok(());
672 }
673 if matches!(key.code, crossterm::event::KeyCode::Char('?')) {
677 self.help_visible = true;
678 return Ok(());
679 }
680 let action_tx = self.action_tx.clone();
681 if matches!(key.code, crossterm::event::KeyCode::Char(':')) {
683 self.command_buffer = Some(String::new());
684 self.command_status = None;
685 return Ok(());
686 }
687 if matches!(key.code, crossterm::event::KeyCode::Tab) {
692 if !self.screens.is_empty() {
693 self.current_screen = (self.current_screen + 1) % self.screens.len();
694 debug!(
695 "switched to screen {}",
696 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
697 );
698 }
699 return Ok(());
700 }
701 if matches!(key.code, crossterm::event::KeyCode::BackTab) {
702 if !self.screens.is_empty() {
703 let len = self.screens.len();
704 self.current_screen = (self.current_screen + len - 1) % len;
705 debug!(
706 "switched to screen {}",
707 SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
708 );
709 }
710 return Ok(());
711 }
712 if matches!(key.code, crossterm::event::KeyCode::Char('['))
718 && key.modifiers == crossterm::event::KeyModifiers::NONE
719 {
720 self.log_pane.prev_tab();
721 return Ok(());
722 }
723 if matches!(key.code, crossterm::event::KeyCode::Char(']'))
724 && key.modifiers == crossterm::event::KeyModifiers::NONE
725 {
726 self.log_pane.next_tab();
727 return Ok(());
728 }
729 if matches!(key.code, crossterm::event::KeyCode::Char('+'))
730 && key.modifiers == crossterm::event::KeyModifiers::NONE
731 {
732 self.log_pane.grow();
733 return Ok(());
734 }
735 if matches!(key.code, crossterm::event::KeyCode::Char('-'))
736 && key.modifiers == crossterm::event::KeyModifiers::NONE
737 {
738 self.log_pane.shrink();
739 return Ok(());
740 }
741 if key.modifiers == crossterm::event::KeyModifiers::SHIFT {
746 match key.code {
747 crossterm::event::KeyCode::Up => {
748 self.log_pane.scroll_up(1);
749 return Ok(());
750 }
751 crossterm::event::KeyCode::Down => {
752 self.log_pane.scroll_down(1);
753 return Ok(());
754 }
755 crossterm::event::KeyCode::PageUp => {
756 self.log_pane.scroll_up(10);
757 return Ok(());
758 }
759 crossterm::event::KeyCode::PageDown => {
760 self.log_pane.scroll_down(10);
761 return Ok(());
762 }
763 crossterm::event::KeyCode::End => {
764 self.log_pane.resume_tail();
765 return Ok(());
766 }
767 crossterm::event::KeyCode::Left => {
773 self.log_pane.scroll_left(8);
774 return Ok(());
775 }
776 crossterm::event::KeyCode::Right => {
777 self.log_pane.scroll_right(8);
778 return Ok(());
779 }
780 _ => {}
781 }
782 }
783 if matches!(key.code, crossterm::event::KeyCode::Char('q'))
789 && key.modifiers == crossterm::event::KeyModifiers::NONE
790 {
791 match resolve_quit_press(self.quit_pending, Instant::now(), QUIT_CONFIRM_WINDOW) {
792 QuitResolution::Confirm => {
793 self.quit_pending = None;
794 self.action_tx.send(Action::Quit)?;
795 }
796 QuitResolution::Pending => {
797 self.quit_pending = Some(Instant::now());
798 self.command_status = Some(CommandStatus::Info(
799 "press q again to quit (Esc cancels)".into(),
800 ));
801 }
802 }
803 return Ok(());
804 }
805 if self.quit_pending.is_some() {
809 self.quit_pending = None;
810 }
811 let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
812 return Ok(());
813 };
814 match keymap.get(&vec![key]) {
815 Some(action) => {
816 info!("Got action: {action:?}");
817 action_tx.send(action.clone())?;
818 }
819 _ => {
820 self.last_tick_key_events.push(key);
823
824 if let Some(action) = keymap.get(&self.last_tick_key_events) {
826 info!("Got action: {action:?}");
827 action_tx.send(action.clone())?;
828 }
829 }
830 }
831 Ok(())
832 }
833
834 fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
835 use crossterm::event::KeyCode;
836 let buf = match self.command_buffer.as_mut() {
837 Some(b) => b,
838 None => return Ok(()),
839 };
840 match key.code {
841 KeyCode::Esc => {
842 self.command_buffer = None;
844 self.command_suggestion_index = 0;
845 }
846 KeyCode::Enter => {
847 let line = std::mem::take(buf);
848 self.command_buffer = None;
849 self.command_suggestion_index = 0;
850 self.execute_command(&line)?;
851 }
852 KeyCode::Up => {
853 self.command_suggestion_index = self.command_suggestion_index.saturating_sub(1);
856 }
857 KeyCode::Down => {
858 let n = filter_command_suggestions(buf, KNOWN_COMMANDS).len();
859 if n > 0 && self.command_suggestion_index + 1 < n {
860 self.command_suggestion_index += 1;
861 }
862 }
863 KeyCode::Tab => {
864 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
868 if let Some((name, _)) = matches.get(self.command_suggestion_index) {
869 let rest = buf
870 .split_once(char::is_whitespace)
871 .map(|(_, tail)| tail)
872 .unwrap_or("");
873 let new = if rest.is_empty() {
874 format!("{name} ")
875 } else {
876 format!("{name} {rest}")
877 };
878 buf.clear();
879 buf.push_str(&new);
880 self.command_suggestion_index = 0;
881 }
882 }
883 KeyCode::Backspace => {
884 buf.pop();
885 self.command_suggestion_index = 0;
886 }
887 KeyCode::Char(c) => {
888 buf.push(c);
889 self.command_suggestion_index = 0;
890 }
891 _ => {}
892 }
893 Ok(())
894 }
895
896 fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
899 let trimmed = line.trim();
900 if trimmed.is_empty() {
901 return Ok(());
902 }
903 let head = trimmed.split_whitespace().next().unwrap_or("");
904 match head {
905 "q" | "quit" => {
906 self.action_tx.send(Action::Quit)?;
907 self.command_status = Some(CommandStatus::Info("quitting".into()));
908 }
909 "diagnose" | "diag" => {
910 let pprof_secs = parse_pprof_arg(trimmed);
911 if let Some(secs) = pprof_secs {
912 self.command_status = Some(self.start_diagnose_with_pprof(secs));
913 } else {
914 self.command_status = Some(match self.export_diagnostic_bundle() {
915 Ok(path) => CommandStatus::Info(format!(
916 "diagnostic bundle exported to {}",
917 path.display()
918 )),
919 Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
920 });
921 }
922 }
923 "pins-check" => {
924 self.command_status = Some(match self.start_pins_check() {
930 Ok(path) => CommandStatus::Info(format!(
931 "pins integrity check running → {} (tail to watch progress)",
932 path.display()
933 )),
934 Err(e) => CommandStatus::Err(format!("pins-check failed to start: {e}")),
935 });
936 }
937 "loggers" => {
938 self.command_status = Some(match self.start_loggers_dump() {
939 Ok(path) => CommandStatus::Info(format!(
940 "loggers snapshot writing → {} (open when ready)",
941 path.display()
942 )),
943 Err(e) => CommandStatus::Err(format!("loggers failed to start: {e}")),
944 });
945 }
946 "set-logger" => {
947 let mut parts = trimmed.split_whitespace();
948 let _ = parts.next(); let expr = parts.next().unwrap_or("");
950 let level = parts.next().unwrap_or("");
951 if expr.is_empty() || level.is_empty() {
952 self.command_status = Some(CommandStatus::Err(
953 "usage: :set-logger <expr> <level> (level: none|error|warning|info|debug|all; expr: e.g. node/pushsync or '.' for all)"
954 .into(),
955 ));
956 return Ok(());
957 }
958 self.start_set_logger(expr.to_string(), level.to_string());
959 self.command_status = Some(CommandStatus::Info(format!(
960 "set-logger {expr:?} → {level:?} (PUT in-flight; check :loggers to verify)"
961 )));
962 }
963 "topup-preview" => {
964 self.command_status = Some(self.run_topup_preview(trimmed));
965 }
966 "dilute-preview" => {
967 self.command_status = Some(self.run_dilute_preview(trimmed));
968 }
969 "extend-preview" => {
970 self.command_status = Some(self.run_extend_preview(trimmed));
971 }
972 "buy-preview" => {
973 self.command_status = Some(self.run_buy_preview(trimmed));
974 }
975 "buy-suggest" => {
976 self.command_status = Some(self.run_buy_suggest(trimmed));
977 }
978 "plan-batch" => {
979 self.command_status = Some(self.run_plan_batch(trimmed));
980 }
981 "check-version" => {
982 self.command_status = Some(self.run_check_version());
983 }
984 "config-doctor" => {
985 self.command_status = Some(self.run_config_doctor());
986 }
987 "price" => {
988 self.command_status = Some(self.run_price());
989 }
990 "basefee" => {
991 self.command_status = Some(self.run_basefee());
992 }
993 "probe-upload" => {
994 self.command_status = Some(self.run_probe_upload(trimmed));
995 }
996 "upload-file" => {
997 self.command_status = Some(self.run_upload_file(trimmed));
998 }
999 "upload-collection" => {
1000 self.command_status = Some(self.run_upload_collection(trimmed));
1001 }
1002 "feed-probe" => {
1003 self.command_status = Some(self.run_feed_probe(trimmed));
1004 }
1005 "feed-timeline" => {
1006 self.command_status = Some(self.run_feed_timeline(trimmed));
1007 }
1008 "hash" => {
1009 self.command_status = Some(self.run_hash(trimmed));
1010 }
1011 "cid" => {
1012 self.command_status = Some(self.run_cid(trimmed));
1013 }
1014 "depth-table" => {
1015 self.command_status = Some(self.run_depth_table());
1016 }
1017 "gsoc-mine" => {
1018 self.command_status = Some(self.run_gsoc_mine(trimmed));
1019 }
1020 "pss-target" => {
1021 self.command_status = Some(self.run_pss_target(trimmed));
1022 }
1023 "manifest" => {
1024 self.command_status = Some(self.run_manifest(trimmed));
1025 }
1026 "inspect" => {
1027 self.command_status = Some(self.run_inspect(trimmed));
1028 }
1029 "durability-check" => {
1030 self.command_status = Some(self.run_durability_check(trimmed));
1031 }
1032 "watch-ref" => {
1033 self.command_status = Some(self.run_watch_ref(trimmed));
1034 }
1035 "watch-ref-stop" => {
1036 self.command_status = Some(self.run_watch_ref_stop(trimmed));
1037 }
1038 "context" | "ctx" => {
1039 let target = trimmed.split_whitespace().nth(1).unwrap_or("");
1040 if target.is_empty() {
1041 let known: Vec<String> =
1042 self.config.nodes.iter().map(|n| n.name.clone()).collect();
1043 self.command_status = Some(CommandStatus::Err(format!(
1044 "usage: :context <name> (known: {})",
1045 known.join(", ")
1046 )));
1047 return Ok(());
1048 }
1049 self.command_status = Some(match self.switch_context(target) {
1050 Ok(()) => CommandStatus::Info(format!(
1051 "switched to context {target} ({})",
1052 self.api.url
1053 )),
1054 Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
1055 });
1056 }
1057 screen
1058 if SCREEN_NAMES
1059 .iter()
1060 .any(|name| name.eq_ignore_ascii_case(screen)) =>
1061 {
1062 if let Some(idx) = SCREEN_NAMES
1063 .iter()
1064 .position(|name| name.eq_ignore_ascii_case(screen))
1065 {
1066 self.current_screen = idx;
1067 self.command_status =
1068 Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
1069 }
1070 }
1071 other => {
1072 self.command_status = Some(CommandStatus::Err(format!(
1073 "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, :hash, :cid, :depth-table, :gsoc-mine, :pss-target, :context, :quit)"
1074 )));
1075 }
1076 }
1077 Ok(())
1078 }
1079
1080 fn run_topup_preview(&self, line: &str) -> CommandStatus {
1084 let parts: Vec<&str> = line.split_whitespace().collect();
1085 let (prefix, amount_str) = match parts.as_slice() {
1086 [_, prefix, amount, ..] => (*prefix, *amount),
1087 _ => {
1088 return CommandStatus::Err(
1089 "usage: :topup-preview <batch-prefix> <amount-plur-per-chunk>".into(),
1090 );
1091 }
1092 };
1093 let chain = match self.health_rx.borrow().chain_state.clone() {
1094 Some(c) => c,
1095 None => return CommandStatus::Err("chain state not loaded yet".into()),
1096 };
1097 let stamps = self.watch.stamps().borrow().clone();
1098 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1099 Ok(b) => b.clone(),
1100 Err(e) => return CommandStatus::Err(e),
1101 };
1102 let amount = match stamp_preview::parse_plur_amount(amount_str) {
1103 Ok(a) => a,
1104 Err(e) => return CommandStatus::Err(e),
1105 };
1106 match stamp_preview::topup_preview(&batch, amount, &chain) {
1107 Ok(p) => CommandStatus::Info(p.summary()),
1108 Err(e) => CommandStatus::Err(e),
1109 }
1110 }
1111
1112 fn run_dilute_preview(&self, line: &str) -> CommandStatus {
1116 let parts: Vec<&str> = line.split_whitespace().collect();
1117 let (prefix, depth_str) = match parts.as_slice() {
1118 [_, prefix, depth, ..] => (*prefix, *depth),
1119 _ => {
1120 return CommandStatus::Err(
1121 "usage: :dilute-preview <batch-prefix> <new-depth>".into(),
1122 );
1123 }
1124 };
1125 let new_depth: u8 = match depth_str.parse() {
1126 Ok(d) => d,
1127 Err(_) => {
1128 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
1129 }
1130 };
1131 let stamps = self.watch.stamps().borrow().clone();
1132 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1133 Ok(b) => b.clone(),
1134 Err(e) => return CommandStatus::Err(e),
1135 };
1136 match stamp_preview::dilute_preview(&batch, new_depth) {
1137 Ok(p) => CommandStatus::Info(p.summary()),
1138 Err(e) => CommandStatus::Err(e),
1139 }
1140 }
1141
1142 fn run_extend_preview(&self, line: &str) -> CommandStatus {
1145 let parts: Vec<&str> = line.split_whitespace().collect();
1146 let (prefix, duration_str) = match parts.as_slice() {
1147 [_, prefix, duration, ..] => (*prefix, *duration),
1148 _ => {
1149 return CommandStatus::Err(
1150 "usage: :extend-preview <batch-prefix> <duration> (e.g. 30d, 12h, 90m, 45s, or plain seconds)".into(),
1151 );
1152 }
1153 };
1154 let extension_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
1155 Ok(s) => s,
1156 Err(e) => return CommandStatus::Err(e),
1157 };
1158 let chain = match self.health_rx.borrow().chain_state.clone() {
1159 Some(c) => c,
1160 None => return CommandStatus::Err("chain state not loaded yet".into()),
1161 };
1162 let stamps = self.watch.stamps().borrow().clone();
1163 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1164 Ok(b) => b.clone(),
1165 Err(e) => return CommandStatus::Err(e),
1166 };
1167 match stamp_preview::extend_preview(&batch, extension_seconds, &chain) {
1168 Ok(p) => CommandStatus::Info(p.summary()),
1169 Err(e) => CommandStatus::Err(e),
1170 }
1171 }
1172
1173 fn run_probe_upload(&self, line: &str) -> CommandStatus {
1185 let parts: Vec<&str> = line.split_whitespace().collect();
1186 let prefix = match parts.as_slice() {
1187 [_, prefix, ..] => *prefix,
1188 _ => {
1189 return CommandStatus::Err(
1190 "usage: :probe-upload <batch-prefix> (uploads one synthetic 4 KiB chunk)"
1191 .into(),
1192 );
1193 }
1194 };
1195 let stamps = self.watch.stamps().borrow().clone();
1196 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1197 Ok(b) => b.clone(),
1198 Err(e) => return CommandStatus::Err(e),
1199 };
1200 if !batch.usable {
1201 return CommandStatus::Err(format!(
1202 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1203 short_hex(&batch.batch_id.to_hex(), 8),
1204 ));
1205 }
1206 if batch.batch_ttl <= 0 {
1207 return CommandStatus::Err(format!(
1208 "batch {} is expired — pick another",
1209 short_hex(&batch.batch_id.to_hex(), 8),
1210 ));
1211 }
1212
1213 let api = self.api.clone();
1214 let tx = self.cmd_status_tx.clone();
1215 let batch_id = batch.batch_id;
1216 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1217 let task_short = batch_short.clone();
1218 tokio::spawn(async move {
1219 let chunk = build_synthetic_probe_chunk();
1220 let started = Instant::now();
1221 let result = api.bee().file().upload_chunk(&batch_id, chunk, None).await;
1222 let elapsed_ms = started.elapsed().as_millis();
1223 let status = match result {
1224 Ok(res) => CommandStatus::Info(format!(
1225 "probe-upload OK in {elapsed_ms}ms — batch {task_short}, ref {}",
1226 short_hex(&res.reference.to_hex(), 8),
1227 )),
1228 Err(e) => CommandStatus::Err(format!(
1229 "probe-upload FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1230 )),
1231 };
1232 let _ = tx.send(status);
1233 });
1234
1235 CommandStatus::Info(format!(
1236 "probe-upload to batch {batch_short} in flight — result will replace this line"
1237 ))
1238 }
1239
1240 fn run_upload_file(&self, line: &str) -> CommandStatus {
1248 let parts: Vec<&str> = line.split_whitespace().collect();
1249 let (path_str, prefix) = match parts.as_slice() {
1250 [_, p, b, ..] => (*p, *b),
1251 _ => {
1252 return CommandStatus::Err("usage: :upload-file <path> <batch-prefix>".into());
1253 }
1254 };
1255 let path = std::path::PathBuf::from(path_str);
1256 let meta = match std::fs::metadata(&path) {
1257 Ok(m) => m,
1258 Err(e) => return CommandStatus::Err(format!("stat {path_str}: {e}")),
1259 };
1260 if meta.is_dir() {
1261 return CommandStatus::Err(format!(
1262 "{path_str} is a directory — :upload-file is single-file only (collection upload coming in a later release)"
1263 ));
1264 }
1265 const MAX_FILE_BYTES: u64 = 256 * 1024 * 1024;
1266 if meta.len() > MAX_FILE_BYTES {
1267 return CommandStatus::Err(format!(
1268 "{path_str} is {} — over the {}-MiB cockpit ceiling; use swarm-cli for larger uploads",
1269 meta.len(),
1270 MAX_FILE_BYTES / (1024 * 1024),
1271 ));
1272 }
1273 let stamps = self.watch.stamps().borrow().clone();
1274 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1275 Ok(b) => b.clone(),
1276 Err(e) => return CommandStatus::Err(e),
1277 };
1278 if !batch.usable {
1279 return CommandStatus::Err(format!(
1280 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1281 short_hex(&batch.batch_id.to_hex(), 8),
1282 ));
1283 }
1284 if batch.batch_ttl <= 0 {
1285 return CommandStatus::Err(format!(
1286 "batch {} is expired — pick another",
1287 short_hex(&batch.batch_id.to_hex(), 8),
1288 ));
1289 }
1290
1291 let api = self.api.clone();
1292 let tx = self.cmd_status_tx.clone();
1293 let batch_id = batch.batch_id;
1294 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1295 let task_short = batch_short.clone();
1296 let file_size = meta.len();
1297 let name = path
1298 .file_name()
1299 .and_then(|n| n.to_str())
1300 .unwrap_or("")
1301 .to_string();
1302 let content_type = guess_content_type(&path);
1303 tokio::spawn(async move {
1304 let data = match tokio::fs::read(&path).await {
1305 Ok(b) => b,
1306 Err(e) => {
1307 let _ = tx.send(CommandStatus::Err(format!("read {}: {e}", path.display())));
1308 return;
1309 }
1310 };
1311 let started = Instant::now();
1312 let result = api
1313 .bee()
1314 .file()
1315 .upload_file(&batch_id, data, &name, &content_type, None)
1316 .await;
1317 let elapsed_ms = started.elapsed().as_millis();
1318 let status = match result {
1319 Ok(res) => CommandStatus::Info(format!(
1320 "upload-file OK in {elapsed_ms}ms — {file_size}B → ref {} (batch {task_short})",
1321 res.reference.to_hex(),
1322 )),
1323 Err(e) => CommandStatus::Err(format!(
1324 "upload-file FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
1325 )),
1326 };
1327 let _ = tx.send(status);
1328 });
1329
1330 CommandStatus::Info(format!(
1331 "upload-file ({file_size}B) to batch {batch_short} in flight — result will replace this line"
1332 ))
1333 }
1334
1335 fn run_upload_collection(&self, line: &str) -> CommandStatus {
1343 let parts: Vec<&str> = line.split_whitespace().collect();
1344 let (dir_str, prefix) = match parts.as_slice() {
1345 [_, d, b, ..] => (*d, *b),
1346 _ => {
1347 return CommandStatus::Err("usage: :upload-collection <dir> <batch-prefix>".into());
1348 }
1349 };
1350 let dir = std::path::PathBuf::from(dir_str);
1351 let walked = match crate::uploads::walk_dir(&dir) {
1352 Ok(w) => w,
1353 Err(e) => return CommandStatus::Err(format!("walk {dir_str}: {e}")),
1354 };
1355 if walked.entries.is_empty() {
1356 return CommandStatus::Err(format!(
1357 "{dir_str} contains no uploadable files (after skipping hidden + symlinks)"
1358 ));
1359 }
1360 let stamps = self.watch.stamps().borrow().clone();
1361 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
1362 Ok(b) => b.clone(),
1363 Err(e) => return CommandStatus::Err(e),
1364 };
1365 if !batch.usable {
1366 return CommandStatus::Err(format!(
1367 "batch {} is not usable yet (waiting on chain confirmation) — pick another",
1368 short_hex(&batch.batch_id.to_hex(), 8),
1369 ));
1370 }
1371 if batch.batch_ttl <= 0 {
1372 return CommandStatus::Err(format!(
1373 "batch {} is expired — pick another",
1374 short_hex(&batch.batch_id.to_hex(), 8),
1375 ));
1376 }
1377
1378 let api = self.api.clone();
1379 let tx = self.cmd_status_tx.clone();
1380 let batch_id = batch.batch_id;
1381 let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
1382 let task_short = batch_short.clone();
1383 let total_bytes = walked.total_bytes;
1384 let entry_count = walked.entries.len();
1385 let entries = walked.entries;
1386 let default_index = walked.default_index.clone();
1387 let dir_str_owned = dir_str.to_string();
1388 let default_index_for_msg = default_index.clone();
1389 tokio::spawn(async move {
1390 let opts = bee::api::CollectionUploadOptions {
1391 index_document: default_index,
1392 ..Default::default()
1393 };
1394 let started = Instant::now();
1395 let result = api
1396 .bee()
1397 .file()
1398 .upload_collection_entries(&batch_id, &entries, Some(&opts))
1399 .await;
1400 let elapsed_ms = started.elapsed().as_millis();
1401 let status = match result {
1402 Ok(res) => {
1403 let idx = default_index_for_msg
1404 .as_deref()
1405 .map(|i| format!(" · index={i}"))
1406 .unwrap_or_default();
1407 CommandStatus::Info(format!(
1408 "upload-collection OK in {elapsed_ms}ms — {entry_count} files, {total_bytes}B → ref {} (batch {task_short}){idx}",
1409 res.reference.to_hex(),
1410 ))
1411 }
1412 Err(e) => CommandStatus::Err(format!(
1413 "upload-collection FAILED after {elapsed_ms}ms — {dir_str_owned} → batch {task_short}: {e}"
1414 )),
1415 };
1416 let _ = tx.send(status);
1417 });
1418
1419 let idx_note = walked
1420 .default_index
1421 .as_deref()
1422 .map(|i| format!(" · default index={i}"))
1423 .unwrap_or_default();
1424 CommandStatus::Info(format!(
1425 "upload-collection {entry_count} files ({total_bytes}B){idx_note} to batch {batch_short} in flight — result will replace this line"
1426 ))
1427 }
1428
1429 fn run_feed_probe(&self, line: &str) -> CommandStatus {
1435 let parts: Vec<&str> = line.split_whitespace().collect();
1436 let (owner_str, topic_str) = match parts.as_slice() {
1437 [_, o, t, ..] => (*o, *t),
1438 _ => {
1439 return CommandStatus::Err(
1440 "usage: :feed-probe <owner> <topic> (topic = 64-hex or arbitrary string)"
1441 .into(),
1442 );
1443 }
1444 };
1445 let parsed = match crate::feed_probe::parse_args(owner_str, topic_str) {
1446 Ok(p) => p,
1447 Err(e) => return CommandStatus::Err(e),
1448 };
1449 let owner_short = short_hex(&parsed.owner.to_hex(), 8);
1450 let api = self.api.clone();
1451 let tx = self.cmd_status_tx.clone();
1452 tokio::spawn(async move {
1453 let started = Instant::now();
1454 let status = match crate::feed_probe::probe(api, parsed).await {
1455 Ok(r) => CommandStatus::Info(format!(
1456 "{} ({}ms)",
1457 r.summary(),
1458 started.elapsed().as_millis()
1459 )),
1460 Err(e) => CommandStatus::Err(format!("feed-probe failed: {e}")),
1461 };
1462 let _ = tx.send(status);
1463 });
1464 CommandStatus::Info(format!(
1465 "feed-probe owner={owner_short} in flight — result will replace this line (first lookup can take 30-60s)"
1466 ))
1467 }
1468
1469 fn run_feed_timeline(&mut self, line: &str) -> CommandStatus {
1476 let parts: Vec<&str> = line.split_whitespace().collect();
1477 let (owner_str, topic_str, n_arg) = match parts.as_slice() {
1478 [_, o, t] => (*o, *t, None),
1479 [_, o, t, n, ..] => (*o, *t, Some(*n)),
1480 _ => {
1481 return CommandStatus::Err(
1482 "usage: :feed-timeline <owner> <topic> [N] (default 50, hard max 1000)".into(),
1483 );
1484 }
1485 };
1486 let parsed = match crate::feed_probe::parse_args(owner_str, topic_str) {
1487 Ok(p) => p,
1488 Err(e) => return CommandStatus::Err(e),
1489 };
1490 let max_entries = match n_arg {
1491 None => crate::feed_timeline::DEFAULT_MAX_ENTRIES,
1492 Some(s) => match s.parse::<u64>() {
1493 Ok(n) if n > 0 => n,
1494 _ => return CommandStatus::Err(format!("invalid N: {s:?}")),
1495 },
1496 };
1497 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "FeedTimeline") {
1501 self.current_screen = idx;
1502 if let Some(ft) = self
1503 .screens
1504 .get_mut(idx)
1505 .and_then(|s| s.as_any_mut())
1506 .and_then(|a| a.downcast_mut::<FeedTimeline>())
1507 {
1508 let label = format!(
1509 "owner=0x{} · topic={} · N={max_entries}",
1510 short_hex(&parsed.owner.to_hex(), 8),
1511 short_hex(&parsed.topic.to_hex(), 8),
1512 );
1513 ft.set_loading(label);
1514 }
1515 }
1516 let api = self.api.clone();
1517 let tx = self.feed_timeline_tx.clone();
1518 tokio::spawn(async move {
1519 let msg = match crate::feed_timeline::walk(api, parsed.owner, parsed.topic, max_entries)
1520 .await
1521 {
1522 Ok(t) => FeedTimelineMessage::Loaded(t),
1523 Err(e) => FeedTimelineMessage::Failed(e),
1524 };
1525 let _ = tx.send(msg);
1526 });
1527 CommandStatus::Info(format!(
1528 "feed-timeline N={max_entries} in flight — switching to S14 (first lookup can take 30-60s)"
1529 ))
1530 }
1531
1532 fn run_hash(&self, line: &str) -> CommandStatus {
1537 let parts: Vec<&str> = line.split_whitespace().collect();
1538 let path = match parts.as_slice() {
1539 [_, p, ..] => *p,
1540 _ => {
1541 return CommandStatus::Err(
1542 "usage: :hash <path> (file or directory; computed locally)".into(),
1543 );
1544 }
1545 };
1546 match utility_verbs::hash_path(path) {
1547 Ok(r) => CommandStatus::Info(format!("hash {path}: {r}")),
1548 Err(e) => CommandStatus::Err(format!("hash failed: {e}")),
1549 }
1550 }
1551
1552 fn run_cid(&self, line: &str) -> CommandStatus {
1556 let parts: Vec<&str> = line.split_whitespace().collect();
1557 let (ref_hex, kind_arg) = match parts.as_slice() {
1558 [_, r, k, ..] => (*r, Some(*k)),
1559 [_, r] => (*r, None),
1560 _ => {
1561 return CommandStatus::Err(
1562 "usage: :cid <ref> [manifest|feed] (default manifest)".into(),
1563 );
1564 }
1565 };
1566 let kind = match utility_verbs::parse_cid_kind(kind_arg) {
1567 Ok(k) => k,
1568 Err(e) => return CommandStatus::Err(e),
1569 };
1570 match utility_verbs::cid_for_ref(ref_hex, kind) {
1571 Ok(cid) => CommandStatus::Info(format!("cid: {cid}")),
1572 Err(e) => CommandStatus::Err(format!("cid failed: {e}")),
1573 }
1574 }
1575
1576 fn run_depth_table(&self) -> CommandStatus {
1581 let body = utility_verbs::depth_table();
1582 let path = std::env::temp_dir().join("bee-tui-depth-table.txt");
1583 match std::fs::write(&path, &body) {
1584 Ok(()) => CommandStatus::Info(format!("depth table → {}", path.display())),
1585 Err(e) => CommandStatus::Err(format!("depth-table write failed: {e}")),
1586 }
1587 }
1588
1589 fn run_gsoc_mine(&self, line: &str) -> CommandStatus {
1594 let parts: Vec<&str> = line.split_whitespace().collect();
1595 let (overlay, ident) = match parts.as_slice() {
1596 [_, o, i, ..] => (*o, *i),
1597 _ => {
1598 return CommandStatus::Err(
1599 "usage: :gsoc-mine <overlay-hex> <identifier> (CPU work, no network)".into(),
1600 );
1601 }
1602 };
1603 match utility_verbs::gsoc_mine_for(overlay, ident) {
1604 Ok(out) => CommandStatus::Info(out.replace('\n', " · ")),
1605 Err(e) => CommandStatus::Err(format!("gsoc-mine failed: {e}")),
1606 }
1607 }
1608
1609 fn run_manifest(&mut self, line: &str) -> CommandStatus {
1613 let parts: Vec<&str> = line.split_whitespace().collect();
1614 let ref_arg = match parts.as_slice() {
1615 [_, r, ..] => *r,
1616 _ => {
1617 return CommandStatus::Err(
1618 "usage: :manifest <ref> (32-byte hex reference)".into(),
1619 );
1620 }
1621 };
1622 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1623 Ok(r) => r,
1624 Err(e) => return CommandStatus::Err(format!("manifest: bad ref: {e}")),
1625 };
1626 let idx = match SCREEN_NAMES.iter().position(|n| *n == "Manifest") {
1629 Some(i) => i,
1630 None => {
1631 return CommandStatus::Err("internal: Manifest screen not registered".into());
1632 }
1633 };
1634 let screen = self
1635 .screens
1636 .get_mut(idx)
1637 .and_then(|s| s.as_any_mut())
1638 .and_then(|a| a.downcast_mut::<Manifest>());
1639 let Some(manifest) = screen else {
1640 return CommandStatus::Err("internal: failed to access Manifest screen".into());
1641 };
1642 manifest.load(reference);
1643 self.current_screen = idx;
1644 CommandStatus::Info(format!("loading manifest {}", short_hex(ref_arg, 8)))
1645 }
1646
1647 fn run_inspect(&self, line: &str) -> CommandStatus {
1654 let parts: Vec<&str> = line.split_whitespace().collect();
1655 let ref_arg = match parts.as_slice() {
1656 [_, r, ..] => *r,
1657 _ => {
1658 return CommandStatus::Err("usage: :inspect <ref> (32-byte hex reference)".into());
1659 }
1660 };
1661 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1662 Ok(r) => r,
1663 Err(e) => return CommandStatus::Err(format!("inspect: bad ref: {e}")),
1664 };
1665 let api = self.api.clone();
1666 let tx = self.cmd_status_tx.clone();
1667 let label = short_hex(ref_arg, 8);
1668 let label_for_task = label.clone();
1669 tokio::spawn(async move {
1670 let result = manifest_walker::inspect(api, reference).await;
1671 let status = match result {
1672 InspectResult::Manifest { node, bytes_len } => CommandStatus::Info(format!(
1673 "inspect {label_for_task}: manifest · {bytes_len} bytes · {} forks (jump to :manifest {label_for_task})",
1674 node.forks.len(),
1675 )),
1676 InspectResult::RawChunk { bytes_len } => CommandStatus::Info(format!(
1677 "inspect {label_for_task}: raw chunk · {bytes_len} bytes · not a manifest"
1678 )),
1679 InspectResult::Error(e) => {
1680 CommandStatus::Err(format!("inspect {label_for_task} failed: {e}"))
1681 }
1682 };
1683 let _ = tx.send(status);
1684 });
1685 CommandStatus::Info(format!(
1686 "inspecting {label} — result will replace this line"
1687 ))
1688 }
1689
1690 fn run_durability_check(&mut self, line: &str) -> CommandStatus {
1700 let parts: Vec<&str> = line.split_whitespace().collect();
1701 let ref_arg = match parts.as_slice() {
1702 [_, r, ..] => *r,
1703 _ => {
1704 return CommandStatus::Err(
1705 "usage: :durability-check <ref> (32-byte hex reference)".into(),
1706 );
1707 }
1708 };
1709 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1710 Ok(r) => r,
1711 Err(e) => {
1712 return CommandStatus::Err(format!("durability-check: bad ref: {e}"));
1713 }
1714 };
1715 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Watchlist") {
1718 self.current_screen = idx;
1719 }
1720 let api = self.api.clone();
1721 let tx = self.cmd_status_tx.clone();
1722 let watchlist_tx = self.durability_tx.clone();
1723 let label = short_hex(ref_arg, 8);
1724 let label_for_task = label.clone();
1725 tokio::spawn(async move {
1726 let result = durability::check(api, reference).await;
1727 let summary = result.summary();
1728 let _ = watchlist_tx.send(result);
1729 let _ = tx.send(if summary.contains("UNHEALTHY") {
1730 CommandStatus::Err(summary)
1731 } else {
1732 CommandStatus::Info(summary)
1733 });
1734 });
1735 CommandStatus::Info(format!(
1736 "durability-check {label_for_task} in flight — see S13 Watchlist for the running history"
1737 ))
1738 }
1739
1740 fn run_watch_ref(&mut self, line: &str) -> CommandStatus {
1750 let parts: Vec<&str> = line.split_whitespace().collect();
1751 let (ref_arg, interval_arg) = match parts.as_slice() {
1752 [_, r] => (*r, None),
1753 [_, r, i, ..] => (*r, Some(*i)),
1754 _ => {
1755 return CommandStatus::Err(
1756 "usage: :watch-ref <ref> [interval-secs] (default 60s)".into(),
1757 );
1758 }
1759 };
1760 let reference = match bee::swarm::Reference::from_hex(ref_arg.trim()) {
1761 Ok(r) => r,
1762 Err(e) => return CommandStatus::Err(format!("watch-ref: bad ref: {e}")),
1763 };
1764 let interval_secs = match interval_arg {
1765 None => 60u64,
1766 Some(s) => match s.parse::<u64>() {
1767 Ok(n) if (10..=86_400).contains(&n) => n,
1768 Ok(n) => {
1769 return CommandStatus::Err(format!(
1770 "watch-ref: interval {n}s out of range (10..=86400)"
1771 ));
1772 }
1773 Err(_) => return CommandStatus::Err(format!("watch-ref: invalid interval: {s:?}")),
1774 },
1775 };
1776 let key = reference.to_hex();
1777 if let Some(prev) = self.watch_refs.remove(&key) {
1780 prev.cancel();
1781 }
1782 let cancel = self.root_cancel.child_token();
1783 self.watch_refs.insert(key.clone(), cancel.clone());
1784
1785 let api = self.api.clone();
1786 let watchlist_tx = self.durability_tx.clone();
1787 let label = short_hex(ref_arg, 8);
1788 let label_for_task = label.clone();
1789 tokio::spawn(async move {
1790 let interval = std::time::Duration::from_secs(interval_secs);
1791 loop {
1792 let result = durability::check(api.clone(), reference.clone()).await;
1793 let _ = watchlist_tx.send(result);
1794 tokio::select! {
1795 _ = tokio::time::sleep(interval) => {}
1796 _ = cancel.cancelled() => return,
1797 }
1798 }
1799 });
1800
1801 CommandStatus::Info(format!(
1802 "watch-ref {label_for_task} started — re-checking every {interval_secs}s; results in S13 Watchlist"
1803 ))
1804 }
1805
1806 fn run_watch_ref_stop(&mut self, line: &str) -> CommandStatus {
1813 let parts: Vec<&str> = line.split_whitespace().collect();
1814 match parts.as_slice() {
1815 [_] => {
1816 let n = self.watch_refs.len();
1817 for (_, c) in self.watch_refs.drain() {
1818 c.cancel();
1819 }
1820 CommandStatus::Info(format!("watch-ref-stop: cancelled {n} active daemon(s)"))
1821 }
1822 [_, r, ..] => {
1823 let reference = match bee::swarm::Reference::from_hex(r.trim()) {
1824 Ok(r) => r,
1825 Err(e) => return CommandStatus::Err(format!("watch-ref-stop: bad ref: {e}")),
1826 };
1827 let key = reference.to_hex();
1828 match self.watch_refs.remove(&key) {
1829 Some(c) => {
1830 c.cancel();
1831 CommandStatus::Info(format!(
1832 "watch-ref-stop: cancelled daemon for {}",
1833 short_hex(r, 8)
1834 ))
1835 }
1836 None => CommandStatus::Err(format!(
1837 "watch-ref-stop: no daemon running for {}",
1838 short_hex(r, 8)
1839 )),
1840 }
1841 }
1842 _ => CommandStatus::Err("usage: :watch-ref-stop [ref] (omit ref to stop all)".into()),
1843 }
1844 }
1845
1846 fn run_pss_target(&self, line: &str) -> CommandStatus {
1851 let parts: Vec<&str> = line.split_whitespace().collect();
1852 let overlay = match parts.as_slice() {
1853 [_, o, ..] => *o,
1854 _ => {
1855 return CommandStatus::Err(
1856 "usage: :pss-target <overlay-hex> (returns first 4 hex chars)".into(),
1857 );
1858 }
1859 };
1860 match utility_verbs::pss_target_for(overlay) {
1861 Ok(prefix) => CommandStatus::Info(format!("pss target prefix: {prefix}")),
1862 Err(e) => CommandStatus::Err(format!("pss-target failed: {e}")),
1863 }
1864 }
1865
1866 fn run_price(&self) -> CommandStatus {
1872 let tx = self.cmd_status_tx.clone();
1873 tokio::spawn(async move {
1874 let status = match economics_oracle::fetch_xbzz_price().await {
1875 Ok(p) => CommandStatus::Info(p.summary()),
1876 Err(e) => CommandStatus::Err(format!("price: {e}")),
1877 };
1878 let _ = tx.send(status);
1879 });
1880 CommandStatus::Info("price: querying tokenservice.ethswarm.org…".into())
1881 }
1882
1883 fn run_basefee(&self) -> CommandStatus {
1887 let url = match self.config.economics.gnosis_rpc_url.clone() {
1888 Some(u) => u,
1889 None => {
1890 return CommandStatus::Err(
1891 "basefee: set [economics].gnosis_rpc_url in config.toml (typically the same URL as Bee's --blockchain-rpc-endpoint)"
1892 .into(),
1893 );
1894 }
1895 };
1896 let tx = self.cmd_status_tx.clone();
1897 tokio::spawn(async move {
1898 let status = match economics_oracle::fetch_gnosis_gas(&url).await {
1899 Ok(g) => CommandStatus::Info(g.summary()),
1900 Err(e) => CommandStatus::Err(format!("basefee: {e}")),
1901 };
1902 let _ = tx.send(status);
1903 });
1904 CommandStatus::Info("basefee: querying gnosis RPC…".into())
1905 }
1906
1907 fn run_config_doctor(&self) -> CommandStatus {
1913 let path = match self.config.bee.as_ref().map(|b| b.config.clone()) {
1914 Some(p) => p,
1915 None => {
1916 return CommandStatus::Err(
1917 "config-doctor: no [bee].config in config.toml (or pass --bee-config) — point bee-tui at the bee.yaml you want audited"
1918 .into(),
1919 );
1920 }
1921 };
1922 let report = match config_doctor::audit(&path) {
1923 Ok(r) => r,
1924 Err(e) => return CommandStatus::Err(format!("config-doctor: {e}")),
1925 };
1926 let secs = SystemTime::now()
1927 .duration_since(UNIX_EPOCH)
1928 .map(|d| d.as_secs())
1929 .unwrap_or(0);
1930 let out_path = std::env::temp_dir().join(format!("bee-tui-config-doctor-{secs}.txt"));
1931 if let Err(e) = std::fs::write(&out_path, report.render()) {
1932 return CommandStatus::Err(format!("config-doctor write {}: {e}", out_path.display()));
1933 }
1934 CommandStatus::Info(format!("{} → {}", report.summary(), out_path.display()))
1935 }
1936
1937 fn run_check_version(&self) -> CommandStatus {
1945 let api = self.api.clone();
1946 let tx = self.cmd_status_tx.clone();
1947 tokio::spawn(async move {
1948 let running = api.bee().debug().health().await.ok().map(|h| h.version);
1949 let status = match version_check::check_latest(running).await {
1950 Ok(v) => CommandStatus::Info(v.summary()),
1951 Err(e) => CommandStatus::Err(format!("check-version failed: {e}")),
1952 };
1953 let _ = tx.send(status);
1954 });
1955 CommandStatus::Info("check-version: querying github.com/ethersphere/bee…".into())
1956 }
1957
1958 fn run_plan_batch(&self, line: &str) -> CommandStatus {
1964 let parts: Vec<&str> = line.split_whitespace().collect();
1965 let prefix = match parts.as_slice() {
1966 [_, prefix, ..] => *prefix,
1967 _ => {
1968 return CommandStatus::Err(
1969 "usage: :plan-batch <batch-prefix> [usage-thr] [ttl-thr] [extra-depth]".into(),
1970 );
1971 }
1972 };
1973 let usage_thr = match parts.get(2) {
1974 Some(s) => match s.parse::<f64>() {
1975 Ok(v) => v,
1976 Err(_) => {
1977 return CommandStatus::Err(format!(
1978 "invalid usage-thr {s:?} (expected float in [0,1], default 0.85)"
1979 ));
1980 }
1981 },
1982 None => stamp_preview::DEFAULT_USAGE_THRESHOLD,
1983 };
1984 let ttl_thr = match parts.get(3) {
1985 Some(s) => match stamp_preview::parse_duration_seconds(s) {
1986 Ok(v) => v,
1987 Err(e) => return CommandStatus::Err(format!("ttl-thr: {e}")),
1988 },
1989 None => stamp_preview::DEFAULT_TTL_THRESHOLD_SECONDS,
1990 };
1991 let extra_depth = match parts.get(4) {
1992 Some(s) => match s.parse::<u8>() {
1993 Ok(v) => v,
1994 Err(_) => {
1995 return CommandStatus::Err(format!(
1996 "invalid extra-depth {s:?} (expected u8, default 2)"
1997 ));
1998 }
1999 },
2000 None => stamp_preview::DEFAULT_EXTRA_DEPTH,
2001 };
2002 let chain = match self.health_rx.borrow().chain_state.clone() {
2003 Some(c) => c,
2004 None => return CommandStatus::Err("chain state not loaded yet".into()),
2005 };
2006 let stamps = self.watch.stamps().borrow().clone();
2007 let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
2008 Ok(b) => b.clone(),
2009 Err(e) => return CommandStatus::Err(e),
2010 };
2011 match stamp_preview::plan_batch(&batch, &chain, usage_thr, ttl_thr, extra_depth) {
2012 Ok(p) => CommandStatus::Info(p.summary()),
2013 Err(e) => CommandStatus::Err(e),
2014 }
2015 }
2016
2017 fn run_buy_suggest(&self, line: &str) -> CommandStatus {
2023 let parts: Vec<&str> = line.split_whitespace().collect();
2024 let (size_str, duration_str) = match parts.as_slice() {
2025 [_, size, duration, ..] => (*size, *duration),
2026 _ => {
2027 return CommandStatus::Err(
2028 "usage: :buy-suggest <size> <duration> (e.g. 5GiB 30d, 100MiB 12h)".into(),
2029 );
2030 }
2031 };
2032 let target_bytes = match stamp_preview::parse_size_bytes(size_str) {
2033 Ok(b) => b,
2034 Err(e) => return CommandStatus::Err(e),
2035 };
2036 let target_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
2037 Ok(s) => s,
2038 Err(e) => return CommandStatus::Err(e),
2039 };
2040 let chain = match self.health_rx.borrow().chain_state.clone() {
2041 Some(c) => c,
2042 None => return CommandStatus::Err("chain state not loaded yet".into()),
2043 };
2044 match stamp_preview::buy_suggest(target_bytes, target_seconds, &chain) {
2045 Ok(s) => CommandStatus::Info(s.summary()),
2046 Err(e) => CommandStatus::Err(e),
2047 }
2048 }
2049
2050 fn run_buy_preview(&self, line: &str) -> CommandStatus {
2053 let parts: Vec<&str> = line.split_whitespace().collect();
2054 let (depth_str, amount_str) = match parts.as_slice() {
2055 [_, depth, amount, ..] => (*depth, *amount),
2056 _ => {
2057 return CommandStatus::Err(
2058 "usage: :buy-preview <depth> <amount-plur-per-chunk>".into(),
2059 );
2060 }
2061 };
2062 let depth: u8 = match depth_str.parse() {
2063 Ok(d) => d,
2064 Err(_) => {
2065 return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
2066 }
2067 };
2068 let amount = match stamp_preview::parse_plur_amount(amount_str) {
2069 Ok(a) => a,
2070 Err(e) => return CommandStatus::Err(e),
2071 };
2072 let chain = match self.health_rx.borrow().chain_state.clone() {
2073 Some(c) => c,
2074 None => return CommandStatus::Err("chain state not loaded yet".into()),
2075 };
2076 match stamp_preview::buy_preview(depth, amount, &chain) {
2077 Ok(p) => CommandStatus::Info(p.summary()),
2078 Err(e) => CommandStatus::Err(e),
2079 }
2080 }
2081
2082 fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
2089 let node = self
2090 .config
2091 .nodes
2092 .iter()
2093 .find(|n| n.name == target)
2094 .ok_or_else(|| eyre!("no node configured with name {target:?}"))?
2095 .clone();
2096 let new_api = Arc::new(ApiClient::from_node(&node)?);
2097 self.watch.shutdown();
2101 let refresh = RefreshProfile::from_config(&self.config.ui.refresh);
2102 let new_watch = BeeWatch::start_with_profile(new_api.clone(), &self.root_cancel, refresh);
2103 let new_health_rx = new_watch.health();
2104 let new_market_rx = if self.config.economics.enable_market_tile {
2109 Some(economics_oracle::spawn_poller(
2110 self.config.economics.gnosis_rpc_url.clone(),
2111 self.root_cancel.child_token(),
2112 ))
2113 } else {
2114 None
2115 };
2116 let new_screens = build_screens(&new_api, &new_watch, new_market_rx);
2117 self.api = new_api;
2118 self.watch = new_watch;
2119 self.health_rx = new_health_rx;
2120 self.screens = new_screens;
2121 Ok(())
2124 }
2125
2126 fn start_pins_check(&self) -> std::io::Result<PathBuf> {
2143 let secs = SystemTime::now()
2144 .duration_since(UNIX_EPOCH)
2145 .map(|d| d.as_secs())
2146 .unwrap_or(0);
2147 let path = std::env::temp_dir().join(format!(
2148 "bee-tui-pins-check-{}-{secs}.txt",
2149 sanitize_for_filename(&self.api.name),
2150 ));
2151 std::fs::write(
2154 &path,
2155 format!(
2156 "# bee-tui :pins-check\n# profile {}\n# endpoint {}\n# started {}\n",
2157 self.api.name,
2158 self.api.url,
2159 format_utc_now(),
2160 ),
2161 )?;
2162
2163 let api = self.api.clone();
2164 let dest = path.clone();
2165 tokio::spawn(async move {
2166 let bee = api.bee();
2167 match bee.api().check_pins(None).await {
2168 Ok(entries) => {
2169 let mut body = String::new();
2170 for e in &entries {
2171 body.push_str(&format!(
2172 "{} total={} missing={} invalid={} {}\n",
2173 e.reference.to_hex(),
2174 e.total,
2175 e.missing,
2176 e.invalid,
2177 if e.is_healthy() {
2178 "healthy"
2179 } else {
2180 "UNHEALTHY"
2181 },
2182 ));
2183 }
2184 body.push_str(&format!("# done. {} pins checked.\n", entries.len()));
2185 if let Err(e) = append(&dest, &body) {
2186 let _ = append(&dest, &format!("# write error: {e}\n"));
2187 }
2188 }
2189 Err(e) => {
2190 let _ = append(&dest, &format!("# error: {e}\n"));
2191 }
2192 }
2193 });
2194 Ok(path)
2195 }
2196
2197 fn start_set_logger(&self, expression: String, level: String) {
2208 let secs = SystemTime::now()
2209 .duration_since(UNIX_EPOCH)
2210 .map(|d| d.as_secs())
2211 .unwrap_or(0);
2212 let dest = std::env::temp_dir().join(format!(
2213 "bee-tui-set-logger-{}-{secs}.txt",
2214 sanitize_for_filename(&self.api.name),
2215 ));
2216 let _ = std::fs::write(
2217 &dest,
2218 format!(
2219 "# bee-tui :set-logger\n# profile {}\n# endpoint {}\n# expr {expression}\n# level {level}\n# started {}\n",
2220 self.api.name,
2221 self.api.url,
2222 format_utc_now(),
2223 ),
2224 );
2225
2226 let api = self.api.clone();
2227 tokio::spawn(async move {
2228 let bee = api.bee();
2229 match bee.debug().set_logger(&expression, &level).await {
2230 Ok(()) => {
2231 let _ = append(
2232 &dest,
2233 &format!("# done. {expression} → {level} accepted by Bee.\n"),
2234 );
2235 }
2236 Err(e) => {
2237 let _ = append(&dest, &format!("# error: {e}\n"));
2238 }
2239 }
2240 });
2241 }
2242
2243 fn start_loggers_dump(&self) -> std::io::Result<PathBuf> {
2248 let secs = SystemTime::now()
2249 .duration_since(UNIX_EPOCH)
2250 .map(|d| d.as_secs())
2251 .unwrap_or(0);
2252 let path = std::env::temp_dir().join(format!(
2253 "bee-tui-loggers-{}-{secs}.txt",
2254 sanitize_for_filename(&self.api.name),
2255 ));
2256 std::fs::write(
2257 &path,
2258 format!(
2259 "# bee-tui :loggers\n# profile {}\n# endpoint {}\n# started {}\n",
2260 self.api.name,
2261 self.api.url,
2262 format_utc_now(),
2263 ),
2264 )?;
2265
2266 let api = self.api.clone();
2267 let dest = path.clone();
2268 tokio::spawn(async move {
2269 let bee = api.bee();
2270 match bee.debug().loggers().await {
2271 Ok(listing) => {
2272 let mut rows = listing.loggers.clone();
2273 rows.sort_by(|a, b| {
2277 verbosity_rank(&b.verbosity)
2278 .cmp(&verbosity_rank(&a.verbosity))
2279 .then_with(|| a.logger.cmp(&b.logger))
2280 });
2281 let mut body = String::new();
2282 body.push_str(&format!("# {} loggers registered\n", rows.len()));
2283 body.push_str("# VERBOSITY LOGGER\n");
2284 for r in &rows {
2285 body.push_str(&format!(" {:<9} {}\n", r.verbosity, r.logger,));
2286 }
2287 body.push_str("# done.\n");
2288 if let Err(e) = append(&dest, &body) {
2289 let _ = append(&dest, &format!("# write error: {e}\n"));
2290 }
2291 }
2292 Err(e) => {
2293 let _ = append(&dest, &format!("# error: {e}\n"));
2294 }
2295 }
2296 });
2297 Ok(path)
2298 }
2299
2300 fn start_diagnose_with_pprof(&self, seconds: u32) -> CommandStatus {
2312 let secs_unix = SystemTime::now()
2313 .duration_since(UNIX_EPOCH)
2314 .map(|d| d.as_secs())
2315 .unwrap_or(0);
2316 let dir = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs_unix}"));
2317 if let Err(e) = std::fs::create_dir_all(&dir) {
2318 return CommandStatus::Err(format!("diagnose --pprof: mkdir failed: {e}"));
2319 }
2320 let bundle_text = self.render_diagnostic_bundle();
2321 if let Err(e) = std::fs::write(dir.join("bundle.txt"), &bundle_text) {
2322 return CommandStatus::Err(format!("diagnose --pprof: write bundle.txt: {e}"));
2323 }
2324 let auth_token = self
2329 .config
2330 .nodes
2331 .iter()
2332 .find(|n| n.name == self.api.name)
2333 .and_then(|n| n.resolved_token());
2334 let base_url = self.api.url.clone();
2335 let dir_for_task = dir.clone();
2336 let tx = self.cmd_status_tx.clone();
2337 tokio::spawn(async move {
2338 let r =
2339 pprof_bundle::fetch_and_write(&base_url, auth_token, seconds, dir_for_task).await;
2340 let status = match r {
2341 Ok(b) => CommandStatus::Info(b.summary()),
2342 Err(e) => CommandStatus::Err(format!("diagnose --pprof failed: {e}")),
2343 };
2344 let _ = tx.send(status);
2345 });
2346 CommandStatus::Info(format!(
2347 "diagnose --pprof={seconds}s in flight (bundle.txt already at {}; profile + trace will join when sampling completes)",
2348 dir.display()
2349 ))
2350 }
2351
2352 fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
2353 let bundle = self.render_diagnostic_bundle();
2354 let secs = SystemTime::now()
2355 .duration_since(UNIX_EPOCH)
2356 .map(|d| d.as_secs())
2357 .unwrap_or(0);
2358 let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
2359 std::fs::write(&path, bundle)?;
2360 Ok(path)
2361 }
2362
2363 fn render_diagnostic_bundle(&self) -> String {
2364 let now = format_utc_now();
2365 let health = self.health_rx.borrow().clone();
2366 let topology = self.watch.topology().borrow().clone();
2367 let stamps = self.watch.stamps().borrow().clone();
2368 let gates = Health::gates_for_with_stamps(&health, Some(&topology), Some(&stamps));
2369 let recent: Vec<_> = log_capture::handle()
2370 .map(|c| {
2371 let mut snap = c.snapshot();
2372 let len = snap.len();
2373 if len > 50 {
2374 snap.drain(0..len - 50);
2375 }
2376 snap
2377 })
2378 .unwrap_or_default();
2379
2380 let mut out = String::new();
2381 out.push_str("# bee-tui diagnostic bundle\n");
2382 out.push_str(&format!("# generated UTC {now}\n\n"));
2383 out.push_str("## profile\n");
2384 out.push_str(&format!(" name {}\n", self.api.name));
2385 out.push_str(&format!(" endpoint {}\n\n", self.api.url));
2386 out.push_str("## health gates\n");
2387 for g in &gates {
2388 out.push_str(&format_gate_line(g));
2389 }
2390 out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
2391 for e in &recent {
2392 let status = e
2393 .status
2394 .map(|s| s.to_string())
2395 .unwrap_or_else(|| "—".into());
2396 let elapsed = e
2397 .elapsed_ms
2398 .map(|ms| format!("{ms}ms"))
2399 .unwrap_or_else(|| "—".into());
2400 out.push_str(&format!(
2401 " {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
2402 ts = e.ts,
2403 method = e.method,
2404 path = path_only(&e.url),
2405 status = status,
2406 elapsed = elapsed,
2407 ));
2408 }
2409 out.push_str(&format!(
2410 "\n## generated by bee-tui {}\n",
2411 env!("CARGO_PKG_VERSION"),
2412 ));
2413 out
2414 }
2415
2416 fn tick_alerts(&mut self) {
2423 let url = match self.config.alerts.webhook_url.as_deref() {
2424 Some(u) if !u.is_empty() => u.to_string(),
2425 _ => return,
2426 };
2427 let health = self.health_rx.borrow().clone();
2428 let topology = self.watch.topology().borrow().clone();
2429 let stamps = self.watch.stamps().borrow().clone();
2430 let gates = Health::gates_for_with_stamps(&health, Some(&topology), Some(&stamps));
2431 let alerts = self.alert_state.diff_and_record(&gates);
2432 for alert in alerts {
2433 let url = url.clone();
2434 tokio::spawn(async move {
2435 if let Err(e) = crate::alerts::fire(&url, &alert).await {
2436 tracing::warn!(target: "bee_tui::alerts", "webhook fire failed: {e}");
2437 }
2438 });
2439 }
2440 }
2441
2442 fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
2443 while let Ok(action) = self.action_rx.try_recv() {
2444 if action != Action::Tick && action != Action::Render {
2445 debug!("{action:?}");
2446 }
2447 match action {
2448 Action::Tick => {
2449 self.last_tick_key_events.drain(..);
2450 theme::advance_spinner();
2454 if let Some(sup) = self.supervisor.as_mut() {
2458 self.bee_status = sup.status();
2459 }
2460 if let Some(rx) = self.bee_log_rx.as_mut() {
2465 while let Ok((tab, line)) = rx.try_recv() {
2466 self.log_pane.push_bee(tab, line);
2467 }
2468 }
2469 while let Ok(status) = self.cmd_status_rx.try_recv() {
2474 self.command_status = Some(status);
2475 }
2476 while let Ok(result) = self.durability_rx.try_recv() {
2481 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "Watchlist") {
2482 if let Some(wl) = self
2483 .screens
2484 .get_mut(idx)
2485 .and_then(|s| s.as_any_mut())
2486 .and_then(|a| a.downcast_mut::<Watchlist>())
2487 {
2488 wl.record(result);
2489 }
2490 }
2491 }
2492 while let Ok(msg) = self.feed_timeline_rx.try_recv() {
2497 if let Some(idx) = SCREEN_NAMES.iter().position(|n| *n == "FeedTimeline") {
2498 if let Some(ft) = self
2499 .screens
2500 .get_mut(idx)
2501 .and_then(|s| s.as_any_mut())
2502 .and_then(|a| a.downcast_mut::<FeedTimeline>())
2503 {
2504 match msg {
2505 FeedTimelineMessage::Loaded(t) => ft.set_timeline(t),
2506 FeedTimelineMessage::Failed(e) => ft.set_error(e),
2507 }
2508 }
2509 }
2510 }
2511 self.tick_alerts();
2515 }
2516 Action::Quit => self.should_quit = true,
2517 Action::Suspend => self.should_suspend = true,
2518 Action::Resume => self.should_suspend = false,
2519 Action::ClearScreen => tui.terminal.clear()?,
2520 Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
2521 Action::Render => self.render(tui)?,
2522 _ => {}
2523 }
2524 let tx = self.action_tx.clone();
2525 for component in self.iter_components_mut() {
2526 if let Some(action) = component.update(action.clone())? {
2527 tx.send(action)?
2528 };
2529 }
2530 }
2531 Ok(())
2532 }
2533
2534 fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
2535 tui.resize(Rect::new(0, 0, w, h))?;
2536 self.render(tui)?;
2537 Ok(())
2538 }
2539
2540 fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
2541 let active = self.current_screen;
2542 let tx = self.action_tx.clone();
2543 let screens = &mut self.screens;
2544 let log_pane = &mut self.log_pane;
2545 let log_pane_height = log_pane.height();
2546 let command_buffer = self.command_buffer.clone();
2547 let command_suggestion_index = self.command_suggestion_index;
2548 let command_status = self.command_status.clone();
2549 let help_visible = self.help_visible;
2550 let profile = self.api.name.clone();
2551 let endpoint = self.api.url.clone();
2552 let last_ping = self.health_rx.borrow().last_ping;
2553 let now_utc = format_utc_now();
2554 let bee_status_label = if self.supervisor.is_some() && !self.bee_status.is_running() {
2555 Some(self.bee_status.label())
2559 } else {
2560 None
2561 };
2562 tui.draw(|frame| {
2563 use ratatui::layout::{Constraint, Layout};
2564 use ratatui::style::{Color, Modifier, Style};
2565 use ratatui::text::{Line, Span};
2566 use ratatui::widgets::Paragraph;
2567
2568 let chunks = Layout::vertical([
2569 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), Constraint::Length(log_pane_height), ])
2574 .split(frame.area());
2575
2576 let top_chunks =
2577 Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(chunks[0]);
2578
2579 let ping_str = match last_ping {
2581 Some(d) => format!("{}ms", d.as_millis()),
2582 None => "—".into(),
2583 };
2584 let t = theme::active();
2585 let mut metadata_spans = vec![
2586 Span::styled(
2587 " bee-tui ",
2588 Style::default()
2589 .fg(Color::Black)
2590 .bg(t.info)
2591 .add_modifier(Modifier::BOLD),
2592 ),
2593 Span::raw(" "),
2594 Span::styled(
2595 profile,
2596 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
2597 ),
2598 Span::styled(format!(" @ {endpoint}"), Style::default().fg(t.dim)),
2599 Span::raw(" "),
2600 Span::styled("ping ", Style::default().fg(t.dim)),
2601 Span::styled(ping_str, Style::default().fg(t.info)),
2602 Span::raw(" "),
2603 Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
2604 ];
2605 if let Some(label) = bee_status_label.as_ref() {
2609 metadata_spans.push(Span::raw(" "));
2610 metadata_spans.push(Span::styled(
2611 format!(" {label} "),
2612 Style::default()
2613 .fg(Color::Black)
2614 .bg(t.fail)
2615 .add_modifier(Modifier::BOLD),
2616 ));
2617 }
2618 let metadata_line = Line::from(metadata_spans);
2619 frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
2620
2621 let theme = *theme::active();
2623 let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
2624 for (i, name) in SCREEN_NAMES.iter().enumerate() {
2625 let style = if i == active {
2626 Style::default()
2627 .fg(theme.tab_active_fg)
2628 .bg(theme.tab_active_bg)
2629 .add_modifier(Modifier::BOLD)
2630 } else {
2631 Style::default().fg(theme.dim)
2632 };
2633 tabs.push(Span::styled(format!(" {name} "), style));
2634 tabs.push(Span::raw(" "));
2635 }
2636 tabs.push(Span::styled(
2637 ":cmd · Tab to cycle · ? help",
2638 Style::default().fg(theme.dim),
2639 ));
2640 frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
2641
2642 if let Some(screen) = screens.get_mut(active) {
2644 if let Err(err) = screen.draw(frame, chunks[1]) {
2645 let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
2646 }
2647 }
2648 let prompt = if let Some(buf) = &command_buffer {
2650 Line::from(vec![
2651 Span::styled(
2652 ":",
2653 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
2654 ),
2655 Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
2656 Span::styled("█", Style::default().fg(t.accent)),
2657 ])
2658 } else {
2659 match &command_status {
2660 Some(CommandStatus::Info(msg)) => {
2661 Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
2662 }
2663 Some(CommandStatus::Err(msg)) => {
2664 Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
2665 }
2666 None => Line::from(""),
2667 }
2668 };
2669 frame.render_widget(Paragraph::new(prompt), chunks[2]);
2670
2671 if let Some(buf) = &command_buffer {
2677 let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
2678 if !matches.is_empty() {
2679 draw_command_suggestions(
2680 frame,
2681 chunks[2],
2682 &matches,
2683 command_suggestion_index,
2684 &theme,
2685 );
2686 }
2687 }
2688
2689 if let Err(err) = log_pane.draw(frame, chunks[3]) {
2691 let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
2692 }
2693
2694 if help_visible {
2699 draw_help_overlay(frame, frame.area(), active, &theme);
2700 }
2701 })?;
2702 Ok(())
2703 }
2704}
2705
2706fn draw_command_suggestions(
2713 frame: &mut ratatui::Frame,
2714 bar_rect: ratatui::layout::Rect,
2715 matches: &[&(&str, &str)],
2716 selected: usize,
2717 theme: &theme::Theme,
2718) {
2719 use ratatui::layout::Rect;
2720 use ratatui::style::{Modifier, Style};
2721 use ratatui::text::{Line, Span};
2722 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2723
2724 const MAX_VISIBLE: usize = 10;
2725 let visible_rows = matches.len().min(MAX_VISIBLE);
2726 if visible_rows == 0 {
2727 return;
2728 }
2729 let height = (visible_rows as u16) + 2; let widest = matches
2734 .iter()
2735 .map(|(name, desc)| name.len() + desc.len() + 6)
2736 .max()
2737 .unwrap_or(40)
2738 .min(bar_rect.width as usize);
2739 let width = (widest as u16 + 2).min(bar_rect.width);
2740 let bottom = bar_rect.y;
2743 let y = bottom.saturating_sub(height);
2744 let popup = Rect {
2745 x: bar_rect.x,
2746 y,
2747 width,
2748 height: bottom - y,
2749 };
2750
2751 let scroll_start = if selected >= visible_rows {
2753 selected + 1 - visible_rows
2754 } else {
2755 0
2756 };
2757 let visible_slice = &matches[scroll_start..(scroll_start + visible_rows).min(matches.len())];
2758
2759 let mut lines: Vec<Line> = Vec::with_capacity(visible_slice.len());
2760 for (i, (name, desc)) in visible_slice.iter().enumerate() {
2761 let absolute_idx = scroll_start + i;
2762 let is_selected = absolute_idx == selected;
2763 let row_style = if is_selected {
2764 Style::default()
2765 .fg(theme.tab_active_fg)
2766 .bg(theme.tab_active_bg)
2767 .add_modifier(Modifier::BOLD)
2768 } else {
2769 Style::default()
2770 };
2771 let cursor = if is_selected { "▸ " } else { " " };
2772 lines.push(Line::from(vec![
2773 Span::styled(format!("{cursor}:{name:<16} "), row_style),
2774 Span::styled(
2775 desc.to_string(),
2776 if is_selected {
2777 row_style
2778 } else {
2779 Style::default().fg(theme.dim)
2780 },
2781 ),
2782 ]));
2783 }
2784
2785 let title = if matches.len() > MAX_VISIBLE {
2787 format!(" :commands ({}/{}) ", selected + 1, matches.len())
2788 } else {
2789 " :commands ".to_string()
2790 };
2791
2792 frame.render_widget(Clear, popup);
2793 frame.render_widget(
2794 Paragraph::new(lines).block(
2795 Block::default()
2796 .borders(Borders::ALL)
2797 .border_style(Style::default().fg(theme.accent))
2798 .title(title),
2799 ),
2800 popup,
2801 );
2802}
2803
2804fn draw_help_overlay(
2809 frame: &mut ratatui::Frame,
2810 area: ratatui::layout::Rect,
2811 active_screen: usize,
2812 theme: &theme::Theme,
2813) {
2814 use ratatui::layout::Rect;
2815 use ratatui::style::{Modifier, Style};
2816 use ratatui::text::{Line, Span};
2817 use ratatui::widgets::{Block, Borders, Clear, Paragraph};
2818
2819 let screen_name = SCREEN_NAMES.get(active_screen).copied().unwrap_or("?");
2820 let screen_rows = screen_keymap(active_screen);
2821 let global_rows: &[(&str, &str)] = &[
2822 ("Tab", "next screen"),
2823 ("Shift+Tab", "previous screen"),
2824 ("[ / ]", "previous / next log-pane tab"),
2825 ("+ / -", "grow / shrink log pane"),
2826 ("Shift+↑/↓", "scroll log pane (1 line); pauses auto-tail"),
2827 ("Shift+PgUp/PgDn", "scroll log pane (10 lines)"),
2828 ("Shift+←/→", "pan log pane horizontally (8 cols)"),
2829 ("Shift+End", "resume auto-tail + reset horizontal pan"),
2830 ("?", "toggle this help"),
2831 (":", "open command bar"),
2832 ("qq", "quit (double-tap; or :q)"),
2833 ("Ctrl+C / Ctrl+D", "quit immediately"),
2834 ];
2835
2836 let w = area.width.min(72);
2839 let h = area.height.min(22);
2840 let x = area.x + (area.width.saturating_sub(w)) / 2;
2841 let y = area.y + (area.height.saturating_sub(h)) / 2;
2842 let rect = Rect {
2843 x,
2844 y,
2845 width: w,
2846 height: h,
2847 };
2848
2849 let mut lines: Vec<Line> = Vec::new();
2850 lines.push(Line::from(vec![
2851 Span::styled(
2852 format!(" {screen_name} "),
2853 Style::default()
2854 .fg(theme.tab_active_fg)
2855 .bg(theme.tab_active_bg)
2856 .add_modifier(Modifier::BOLD),
2857 ),
2858 Span::raw(" screen-specific keys"),
2859 ]));
2860 lines.push(Line::from(""));
2861 if screen_rows.is_empty() {
2862 lines.push(Line::from(Span::styled(
2863 " (no extra keys for this screen — use the command bar via :)",
2864 Style::default()
2865 .fg(theme.dim)
2866 .add_modifier(Modifier::ITALIC),
2867 )));
2868 } else {
2869 for (key, desc) in screen_rows {
2870 lines.push(format_help_row(key, desc, theme));
2871 }
2872 }
2873 lines.push(Line::from(""));
2874 lines.push(Line::from(Span::styled(
2875 " global",
2876 Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
2877 )));
2878 for (key, desc) in global_rows {
2879 lines.push(format_help_row(key, desc, theme));
2880 }
2881 lines.push(Line::from(""));
2882 lines.push(Line::from(Span::styled(
2883 " Esc / ? / q to dismiss",
2884 Style::default()
2885 .fg(theme.dim)
2886 .add_modifier(Modifier::ITALIC),
2887 )));
2888
2889 frame.render_widget(Clear, rect);
2892 frame.render_widget(
2893 Paragraph::new(lines).block(
2894 Block::default()
2895 .borders(Borders::ALL)
2896 .border_style(Style::default().fg(theme.accent))
2897 .title(" help "),
2898 ),
2899 rect,
2900 );
2901}
2902
2903fn format_help_row<'a>(
2904 key: &'a str,
2905 desc: &'a str,
2906 theme: &theme::Theme,
2907) -> ratatui::text::Line<'a> {
2908 use ratatui::style::{Modifier, Style};
2909 use ratatui::text::{Line, Span};
2910 Line::from(vec![
2911 Span::raw(" "),
2912 Span::styled(
2913 format!("{key:<16}"),
2914 Style::default()
2915 .fg(theme.accent)
2916 .add_modifier(Modifier::BOLD),
2917 ),
2918 Span::raw(" "),
2919 Span::raw(desc),
2920 ])
2921}
2922
2923fn screen_keymap(active_screen: usize) -> &'static [(&'static str, &'static str)] {
2927 match active_screen {
2928 1 => &[
2930 ("↑↓ / j k", "move row selection"),
2931 ("Enter", "drill batch — bucket histogram + worst-N"),
2932 ("Esc", "close drill"),
2933 ],
2934 3 => &[("r", "run on-demand rchash benchmark")],
2936 4 => &[
2937 ("↑↓ / j k", "move peer selection"),
2938 (
2939 "Enter",
2940 "drill peer — balance / cheques / settlement / ping",
2941 ),
2942 ("Esc", "close drill"),
2943 ],
2944 8 => &[
2948 ("↑↓ / j k", "scroll one row"),
2949 ("PgUp / PgDn", "scroll ten rows"),
2950 ("Home", "back to top"),
2951 ],
2952 9 => &[
2954 ("↑↓ / j k", "move row selection"),
2955 ("Enter", "integrity-check the highlighted pin"),
2956 ("c", "integrity-check every unchecked pin"),
2957 ("s", "cycle sort: ref order / bad first / by size"),
2958 ],
2959 10 => &[
2961 ("↑↓ / j k", "move row selection"),
2962 ("Enter", "expand / collapse fork (loads child chunk)"),
2963 (":manifest <ref>", "open a manifest at a reference"),
2964 (":inspect <ref>", "what is this? auto-detects manifest"),
2965 ],
2966 11 => &[
2968 ("↑↓ / j k", "move row selection"),
2969 (":durability-check <ref>", "walk chunk graph + record"),
2970 ],
2971 12 => &[
2973 ("↑↓ / j k", "move row selection"),
2974 ("PgUp / PgDn", "jump 10 rows"),
2975 (
2976 ":feed-timeline <owner> <topic> [N]",
2977 "load history (default 50)",
2978 ),
2979 ],
2980 _ => &[],
2981 }
2982}
2983
2984fn build_screens(
2993 api: &Arc<ApiClient>,
2994 watch: &BeeWatch,
2995 market_rx: Option<watch::Receiver<crate::economics_oracle::EconomicsSnapshot>>,
2996) -> Vec<Box<dyn Component>> {
2997 let health = Health::new(api.clone(), watch.health(), watch.topology());
2998 let stamps = Stamps::new(api.clone(), watch.stamps());
2999 let swap = match market_rx {
3000 Some(rx) => Swap::new(watch.swap()).with_market_feed(rx),
3001 None => Swap::new(watch.swap()),
3002 };
3003 let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
3004 let peers = Peers::new(api.clone(), watch.topology());
3005 let network = Network::new(watch.network(), watch.topology());
3006 let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
3007 let api_health = ApiHealth::new(
3008 api.clone(),
3009 watch.health(),
3010 watch.transactions(),
3011 log_capture::handle(),
3012 );
3013 let tags = Tags::new(watch.tags());
3014 let pins = Pins::new(api.clone(), watch.pins());
3015 let manifest = Manifest::new(api.clone());
3016 let watchlist = Watchlist::new();
3017 let feed_timeline = FeedTimeline::new();
3018 vec![
3019 Box::new(health),
3020 Box::new(stamps),
3021 Box::new(swap),
3022 Box::new(lottery),
3023 Box::new(peers),
3024 Box::new(network),
3025 Box::new(warmup),
3026 Box::new(api_health),
3027 Box::new(tags),
3028 Box::new(pins),
3029 Box::new(manifest),
3030 Box::new(watchlist),
3031 Box::new(feed_timeline),
3032 ]
3033}
3034
3035fn build_synthetic_probe_chunk() -> Vec<u8> {
3043 use std::time::{SystemTime, UNIX_EPOCH};
3044 let nanos = SystemTime::now()
3045 .duration_since(UNIX_EPOCH)
3046 .map(|d| d.as_nanos())
3047 .unwrap_or(0);
3048 let mut data = Vec::with_capacity(8 + 4096);
3049 data.extend_from_slice(&4096u64.to_le_bytes());
3051 data.extend_from_slice(&nanos.to_le_bytes());
3053 data.resize(8 + 4096, 0);
3054 data
3055}
3056
3057fn short_hex(hex: &str, len: usize) -> String {
3060 if hex.len() > len {
3061 format!("{}…", &hex[..len])
3062 } else {
3063 hex.to_string()
3064 }
3065}
3066
3067fn guess_content_type(path: &std::path::Path) -> String {
3073 let ext = path
3074 .extension()
3075 .and_then(|e| e.to_str())
3076 .map(|s| s.to_ascii_lowercase());
3077 match ext.as_deref() {
3078 Some("html") | Some("htm") => "text/html",
3079 Some("txt") | Some("md") => "text/plain",
3080 Some("json") => "application/json",
3081 Some("css") => "text/css",
3082 Some("js") => "application/javascript",
3083 Some("png") => "image/png",
3084 Some("jpg") | Some("jpeg") => "image/jpeg",
3085 Some("gif") => "image/gif",
3086 Some("svg") => "image/svg+xml",
3087 Some("webp") => "image/webp",
3088 Some("pdf") => "application/pdf",
3089 Some("zip") => "application/zip",
3090 Some("tar") => "application/x-tar",
3091 Some("gz") | Some("tgz") => "application/gzip",
3092 Some("wasm") => "application/wasm",
3093 _ => "",
3094 }
3095 .to_string()
3096}
3097
3098fn build_metrics_render_fn(
3104 watch: BeeWatch,
3105 log_capture: Option<log_capture::LogCapture>,
3106) -> crate::metrics_server::RenderFn {
3107 use std::time::{SystemTime, UNIX_EPOCH};
3108 Arc::new(move || {
3109 let health = watch.health().borrow().clone();
3110 let stamps = watch.stamps().borrow().clone();
3111 let swap = watch.swap().borrow().clone();
3112 let lottery = watch.lottery().borrow().clone();
3113 let topology = watch.topology().borrow().clone();
3114 let network = watch.network().borrow().clone();
3115 let transactions = watch.transactions().borrow().clone();
3116 let recent = log_capture
3117 .as_ref()
3118 .map(|c| c.snapshot())
3119 .unwrap_or_default();
3120 let call_stats = crate::components::api_health::call_stats_for(&recent);
3121 let now_unix = SystemTime::now()
3122 .duration_since(UNIX_EPOCH)
3123 .map(|d| d.as_secs() as i64)
3124 .unwrap_or(0);
3125 let inputs = crate::metrics::MetricsInputs {
3126 bee_tui_version: env!("CARGO_PKG_VERSION"),
3127 health: &health,
3128 stamps: &stamps,
3129 swap: &swap,
3130 lottery: &lottery,
3131 topology: &topology,
3132 network: &network,
3133 transactions: &transactions,
3134 call_stats: &call_stats,
3135 now_unix,
3136 };
3137 crate::metrics::render(&inputs)
3138 })
3139}
3140
3141fn format_gate_line(g: &Gate) -> String {
3142 let glyphs = crate::theme::active().glyphs;
3143 let glyph = match g.status {
3144 GateStatus::Pass => glyphs.pass,
3145 GateStatus::Warn => glyphs.warn,
3146 GateStatus::Fail => glyphs.fail,
3147 GateStatus::Unknown => glyphs.bullet,
3148 };
3149 let mut s = format!(
3150 " [{glyph}] {label:<28} {value}\n",
3151 label = g.label,
3152 value = g.value
3153 );
3154 if let Some(why) = &g.why {
3155 s.push_str(&format!(" {} {why}\n", glyphs.continuation));
3156 }
3157 s
3158}
3159
3160fn path_only(url: &str) -> String {
3163 if let Some(idx) = url.find("//") {
3164 let after_scheme = &url[idx + 2..];
3165 if let Some(slash) = after_scheme.find('/') {
3166 return after_scheme[slash..].to_string();
3167 }
3168 return "/".into();
3169 }
3170 url.to_string()
3171}
3172
3173fn append(path: &PathBuf, s: &str) -> std::io::Result<()> {
3180 use std::io::Write;
3181 let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
3182 f.write_all(s.as_bytes())
3183}
3184
3185fn verbosity_rank(s: &str) -> u8 {
3191 match s {
3192 "all" | "trace" => 5,
3193 "debug" => 4,
3194 "info" | "1" => 3,
3195 "warning" | "warn" | "2" => 2,
3196 "error" | "3" => 1,
3197 _ => 0,
3198 }
3199}
3200
3201fn sanitize_for_filename(s: &str) -> String {
3205 s.chars()
3206 .map(|c| match c {
3207 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
3208 _ => '-',
3209 })
3210 .collect()
3211}
3212
3213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3217pub enum QuitResolution {
3218 Confirm,
3220 Pending,
3223}
3224
3225fn resolve_quit_press(prev: Option<Instant>, now: Instant, window: Duration) -> QuitResolution {
3230 match prev {
3231 Some(t) if now.duration_since(t) <= window => QuitResolution::Confirm,
3232 _ => QuitResolution::Pending,
3233 }
3234}
3235
3236fn format_utc_now() -> String {
3237 let secs = SystemTime::now()
3238 .duration_since(UNIX_EPOCH)
3239 .map(|d| d.as_secs())
3240 .unwrap_or(0);
3241 let secs_in_day = secs % 86_400;
3242 let h = secs_in_day / 3_600;
3243 let m = (secs_in_day % 3_600) / 60;
3244 let s = secs_in_day % 60;
3245 format!("{h:02}:{m:02}:{s:02}")
3246}
3247
3248#[cfg(test)]
3249mod tests {
3250 use super::*;
3251
3252 #[test]
3253 fn format_utc_now_returns_eight_chars() {
3254 let s = format_utc_now();
3255 assert_eq!(s.len(), 8);
3256 assert_eq!(s.chars().nth(2), Some(':'));
3257 assert_eq!(s.chars().nth(5), Some(':'));
3258 }
3259
3260 #[test]
3261 fn path_only_strips_scheme_and_host() {
3262 assert_eq!(path_only("http://10.0.1.5:1633/status"), "/status");
3263 assert_eq!(
3264 path_only("https://bee.example.com/stamps?limit=10"),
3265 "/stamps?limit=10"
3266 );
3267 }
3268
3269 #[test]
3270 fn path_only_handles_no_path() {
3271 assert_eq!(path_only("http://localhost:1633"), "/");
3272 }
3273
3274 #[test]
3275 fn path_only_passes_relative_through() {
3276 assert_eq!(path_only("/already/relative"), "/already/relative");
3277 }
3278
3279 #[test]
3280 fn parse_pprof_arg_default_60() {
3281 assert_eq!(parse_pprof_arg("diagnose --pprof"), Some(60));
3282 assert_eq!(parse_pprof_arg("diag --pprof some other"), Some(60));
3283 }
3284
3285 #[test]
3286 fn parse_pprof_arg_with_explicit_seconds() {
3287 assert_eq!(parse_pprof_arg("diagnose --pprof=120"), Some(120));
3288 assert_eq!(parse_pprof_arg("diagnose --pprof=15 trailing"), Some(15));
3289 }
3290
3291 #[test]
3292 fn parse_pprof_arg_clamps_extreme_values() {
3293 assert_eq!(parse_pprof_arg("diagnose --pprof=0"), Some(1));
3295 assert_eq!(parse_pprof_arg("diagnose --pprof=9999"), Some(600));
3296 }
3297
3298 #[test]
3299 fn parse_pprof_arg_none_when_absent() {
3300 assert_eq!(parse_pprof_arg("diagnose"), None);
3301 assert_eq!(parse_pprof_arg("diag"), None);
3302 assert_eq!(parse_pprof_arg(""), None);
3303 }
3304
3305 #[test]
3306 fn parse_pprof_arg_ignores_garbage_value() {
3307 assert_eq!(parse_pprof_arg("diagnose --pprof=lol"), None);
3310 }
3311
3312 #[test]
3313 fn guess_content_type_known_extensions() {
3314 let p = std::path::PathBuf::from;
3315 assert_eq!(guess_content_type(&p("/tmp/x.html")), "text/html");
3316 assert_eq!(guess_content_type(&p("/tmp/x.json")), "application/json");
3317 assert_eq!(guess_content_type(&p("/tmp/x.PNG")), "image/png");
3318 assert_eq!(guess_content_type(&p("/tmp/x.tar.gz")), "application/gzip");
3319 }
3320
3321 #[test]
3322 fn guess_content_type_unknown_returns_empty() {
3323 let p = std::path::PathBuf::from;
3324 assert_eq!(guess_content_type(&p("/tmp/x.unknownext")), "");
3327 assert_eq!(guess_content_type(&p("/tmp/no-extension")), "");
3328 }
3329
3330 #[test]
3331 fn sanitize_for_filename_keeps_safe_chars() {
3332 assert_eq!(sanitize_for_filename("prod-1"), "prod-1");
3333 assert_eq!(sanitize_for_filename("lab_node"), "lab_node");
3334 }
3335
3336 #[test]
3337 fn sanitize_for_filename_replaces_unsafe_chars() {
3338 assert_eq!(sanitize_for_filename("a/b\\c d"), "a-b-c-d");
3339 assert_eq!(sanitize_for_filename("name:colon"), "name-colon");
3340 }
3341
3342 #[test]
3343 fn resolve_quit_press_first_press_is_pending() {
3344 let now = Instant::now();
3345 assert_eq!(
3346 resolve_quit_press(None, now, Duration::from_millis(1500)),
3347 QuitResolution::Pending
3348 );
3349 }
3350
3351 #[test]
3352 fn resolve_quit_press_second_press_inside_window_confirms() {
3353 let first = Instant::now();
3354 let window = Duration::from_millis(1500);
3355 let second = first + Duration::from_millis(500);
3356 assert_eq!(
3357 resolve_quit_press(Some(first), second, window),
3358 QuitResolution::Confirm
3359 );
3360 }
3361
3362 #[test]
3363 fn resolve_quit_press_second_press_after_window_resets_to_pending() {
3364 let first = Instant::now();
3368 let window = Duration::from_millis(1500);
3369 let second = first + Duration::from_millis(2_000);
3370 assert_eq!(
3371 resolve_quit_press(Some(first), second, window),
3372 QuitResolution::Pending
3373 );
3374 }
3375
3376 #[test]
3377 fn resolve_quit_press_at_window_boundary_confirms() {
3378 let first = Instant::now();
3381 let window = Duration::from_millis(1500);
3382 let second = first + window;
3383 assert_eq!(
3384 resolve_quit_press(Some(first), second, window),
3385 QuitResolution::Confirm
3386 );
3387 }
3388
3389 #[test]
3390 fn screen_keymap_covers_drill_screens() {
3391 for idx in [1usize, 4] {
3394 let rows = screen_keymap(idx);
3395 assert!(
3396 rows.iter().any(|(k, _)| k.contains("Enter")),
3397 "screen {idx} keymap must mention Enter (drill)"
3398 );
3399 assert!(
3400 rows.iter().any(|(k, _)| k.contains("Esc")),
3401 "screen {idx} keymap must mention Esc (close drill)"
3402 );
3403 }
3404 }
3405
3406 #[test]
3407 fn screen_keymap_lottery_advertises_rchash() {
3408 let rows = screen_keymap(3);
3409 assert!(rows.iter().any(|(k, _)| k.contains("r")));
3410 }
3411
3412 #[test]
3413 fn screen_keymap_unknown_index_is_empty_not_panic() {
3414 assert!(screen_keymap(999).is_empty());
3415 }
3416
3417 #[test]
3418 fn verbosity_rank_orders_loud_to_silent() {
3419 assert!(verbosity_rank("all") > verbosity_rank("debug"));
3420 assert!(verbosity_rank("debug") > verbosity_rank("info"));
3421 assert!(verbosity_rank("info") > verbosity_rank("warning"));
3422 assert!(verbosity_rank("warning") > verbosity_rank("error"));
3423 assert!(verbosity_rank("error") > verbosity_rank("unknown"));
3424 assert_eq!(verbosity_rank("info"), verbosity_rank("1"));
3426 assert_eq!(verbosity_rank("warning"), verbosity_rank("2"));
3427 }
3428
3429 #[test]
3430 fn filter_command_suggestions_empty_buffer_returns_all() {
3431 let matches = filter_command_suggestions("", KNOWN_COMMANDS);
3432 assert_eq!(matches.len(), KNOWN_COMMANDS.len());
3433 }
3434
3435 #[test]
3436 fn filter_command_suggestions_prefix_matches_case_insensitive() {
3437 let matches = filter_command_suggestions("Bu", KNOWN_COMMANDS);
3438 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
3439 assert!(names.contains(&"buy-preview"));
3440 assert!(names.contains(&"buy-suggest"));
3441 assert_eq!(names.len(), 2);
3442 }
3443
3444 #[test]
3445 fn filter_command_suggestions_unknown_prefix_is_empty() {
3446 let matches = filter_command_suggestions("xyz", KNOWN_COMMANDS);
3447 assert!(matches.is_empty());
3448 }
3449
3450 #[test]
3451 fn filter_command_suggestions_uses_first_token_only() {
3452 let matches = filter_command_suggestions("topup-preview a1b2 1000", KNOWN_COMMANDS);
3455 let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
3456 assert_eq!(names, vec!["topup-preview"]);
3457 }
3458
3459 #[test]
3460 fn probe_chunk_is_4104_bytes_with_correct_span() {
3461 let chunk = build_synthetic_probe_chunk();
3463 assert_eq!(chunk.len(), 4104);
3464 let span = u64::from_le_bytes(chunk[..8].try_into().unwrap());
3465 assert_eq!(span, 4096);
3466 }
3467
3468 #[test]
3469 fn probe_chunk_payloads_are_unique_per_call() {
3470 let a = build_synthetic_probe_chunk();
3475 std::thread::sleep(Duration::from_micros(1));
3477 let b = build_synthetic_probe_chunk();
3478 assert_ne!(&a[8..24], &b[8..24]);
3479 }
3480
3481 #[test]
3482 fn short_hex_truncates_with_ellipsis() {
3483 assert_eq!(short_hex("a1b2c3d4e5f6", 8), "a1b2c3d4…");
3484 assert_eq!(short_hex("short", 8), "short");
3485 assert_eq!(short_hex("abcdefgh", 8), "abcdefgh");
3486 }
3487}