Skip to main content

bee_tui/
app.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::{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    components::{
17        Component, api_health::ApiHealth, command_log::CommandLog,
18        health::{Gate, GateStatus, Health},
19        lottery::Lottery, network::Network, peers::Peers, stamps::Stamps, swap::Swap,
20        tags::Tags, warmup::Warmup,
21    },
22    config::Config,
23    log_capture,
24    theme,
25    tui::{Event, Tui},
26    watch::{BeeWatch, HealthSnapshot},
27};
28
29pub struct App {
30    config: Config,
31    tick_rate: f64,
32    frame_rate: f64,
33    /// Top-level screens, in display order. Tab cycles among them.
34    /// v0.4 also wires the k9s-style `:command` switcher so users
35    /// can jump directly with `:peers`, `:stamps`, etc.
36    screens: Vec<Box<dyn Component>>,
37    /// Index into [`Self::screens`] for the currently visible screen.
38    current_screen: usize,
39    /// Always-on bottom strip; not part of `screens` because it
40    /// renders alongside whatever screen is active.
41    command_log: Box<dyn Component>,
42    should_quit: bool,
43    should_suspend: bool,
44    mode: Mode,
45    last_tick_key_events: Vec<KeyEvent>,
46    action_tx: mpsc::UnboundedSender<Action>,
47    action_rx: mpsc::UnboundedReceiver<Action>,
48    /// Root cancellation token. Children: BeeWatch hub → per-resource
49    /// pollers. Cancelling this on quit unwinds every spawned task.
50    root_cancel: CancellationToken,
51    /// Active Bee node connection; cheap to clone (`Arc<Inner>` under
52    /// the hood). Read by future header bar + multi-node switcher.
53    #[allow(dead_code)]
54    api: Arc<ApiClient>,
55    /// Watch / informer hub feeding screens.
56    watch: BeeWatch,
57    /// Top-bar reuses the health snapshot for the live ping
58    /// indicator. Cheap clone of the watch receiver.
59    health_rx: watch::Receiver<HealthSnapshot>,
60    /// `Some(buf)` while the user is typing a `:command`. The
61    /// buffer holds the characters typed *after* the leading colon.
62    command_buffer: Option<String>,
63    /// Status / error from the most recent `:command`, persisted on
64    /// the command-bar line until the user enters command mode again.
65    /// Cleared when `command_buffer` transitions to `Some`.
66    command_status: Option<CommandStatus>,
67    /// `true` while the `?` help overlay is up. Renders on top of
68    /// the active screen; `?` toggles, `Esc` dismisses.
69    help_visible: bool,
70}
71
72/// Outcome from the most recently executed `:command`. Drives the
73/// colour of the command-bar line in normal mode.
74#[derive(Debug, Clone)]
75pub enum CommandStatus {
76    Info(String),
77    Err(String),
78}
79
80/// Names the top-level screens. Index matches position in
81/// [`App::screens`].
82const SCREEN_NAMES: &[&str] = &[
83    "Health", "Stamps", "Swap", "Lottery", "Peers", "Network", "Warmup", "API", "Tags",
84];
85
86#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
87pub enum Mode {
88    #[default]
89    Home,
90}
91
92impl App {
93    pub fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
94        Self::with_overrides(tick_rate, frame_rate, false, false)
95    }
96
97    /// Build an App with explicit `--ascii` / `--no-color` overrides.
98    /// `ascii` and `no_color` are OR'd with the equivalent config /
99    /// env signals (see [`theme::install_with_overrides`] +
100    /// [`theme::no_color_env`]).
101    pub fn with_overrides(
102        tick_rate: f64,
103        frame_rate: f64,
104        ascii: bool,
105        no_color: bool,
106    ) -> color_eyre::Result<Self> {
107        let (action_tx, action_rx) = mpsc::unbounded_channel();
108        let config = Config::new()?;
109        // Install the theme first so any tracing emitted during the
110        // rest of `new` already reflects the operator's choice.
111        let force_no_color = no_color || theme::no_color_env();
112        theme::install_with_overrides(&config.ui, force_no_color, ascii);
113
114        // Pick the active node profile and build an ApiClient for it.
115        let node = config
116            .active_node()
117            .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
118        let api = Arc::new(ApiClient::from_node(node)?);
119
120        // Spawn the watch / informer hub. Pollers attach to children
121        // of `root_cancel`, so quitting cancels everything in one go.
122        let root_cancel = CancellationToken::new();
123        let watch = BeeWatch::start(api.clone(), &root_cancel);
124        let health_rx = watch.health();
125
126        let screens = build_screens(&api, &watch);
127        // S10 Command-log subscribes to the bee::http capture set up
128        // by logging::init. If logging hasn't initialised the capture
129        // (e.g. running in a test harness), the pane just shows
130        // "waiting for first request…".
131        let command_log: Box<dyn Component> = Box::new(CommandLog::new(log_capture::handle()));
132
133        Ok(Self {
134            tick_rate,
135            frame_rate,
136            screens,
137            current_screen: 0,
138            command_log,
139            should_quit: false,
140            should_suspend: false,
141            config,
142            mode: Mode::Home,
143            last_tick_key_events: Vec::new(),
144            action_tx,
145            action_rx,
146            root_cancel,
147            api,
148            watch,
149            health_rx,
150            command_buffer: None,
151            command_status: None,
152            help_visible: false,
153        })
154    }
155
156    pub async fn run(&mut self) -> color_eyre::Result<()> {
157        let mut tui = Tui::new()?
158            // .mouse(true) // uncomment this line to enable mouse support
159            .tick_rate(self.tick_rate)
160            .frame_rate(self.frame_rate);
161        tui.enter()?;
162
163        let tx = self.action_tx.clone();
164        let cfg = self.config.clone();
165        let size = tui.size()?;
166        for component in self.iter_components_mut() {
167            component.register_action_handler(tx.clone())?;
168            component.register_config_handler(cfg.clone())?;
169            component.init(size)?;
170        }
171
172        let action_tx = self.action_tx.clone();
173        loop {
174            self.handle_events(&mut tui).await?;
175            self.handle_actions(&mut tui)?;
176            if self.should_suspend {
177                tui.suspend()?;
178                action_tx.send(Action::Resume)?;
179                action_tx.send(Action::ClearScreen)?;
180                // tui.mouse(true);
181                tui.enter()?;
182            } else if self.should_quit {
183                tui.stop()?;
184                break;
185            }
186        }
187        // Unwind every spawned task before tearing down the terminal.
188        self.watch.shutdown();
189        self.root_cancel.cancel();
190        tui.exit()?;
191        Ok(())
192    }
193
194    async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
195        let Some(event) = tui.next_event().await else {
196            return Ok(());
197        };
198        let action_tx = self.action_tx.clone();
199        // Sample modal state both before and after handling: a key
200        // that *opens* a modal (`?` → help) only flips state inside
201        // handle, but the same key shouldn't propagate to screens;
202        // a key that *closes* one (Esc on help) flips it the other
203        // way but also shouldn't propagate. Either side of the
204        // transition counts as "modal" for swallowing purposes.
205        let modal_before = self.command_buffer.is_some() || self.help_visible;
206        match event {
207            Event::Quit => action_tx.send(Action::Quit)?,
208            Event::Tick => action_tx.send(Action::Tick)?,
209            Event::Render => action_tx.send(Action::Render)?,
210            Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
211            Event::Key(key) => self.handle_key_event(key)?,
212            _ => {}
213        }
214        let modal_after = self.command_buffer.is_some() || self.help_visible;
215        // Non-key events (Tick / Resize / Render) always propagate
216        // so screens keep refreshing under modals.
217        let propagate =
218            !((modal_before || modal_after) && matches!(event, Event::Key(_)));
219        if propagate {
220            for component in self.iter_components_mut() {
221                if let Some(action) = component.handle_events(Some(event.clone()))? {
222                    action_tx.send(action)?;
223                }
224            }
225        }
226        Ok(())
227    }
228
229    /// Iterate every component (screens + command-log strip) for
230    /// uniform lifecycle ticks. Doesn't conflict with rendering,
231    /// which only draws the active screen.
232    fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut Box<dyn Component>> {
233        self.screens
234            .iter_mut()
235            .chain(std::iter::once(&mut self.command_log))
236    }
237
238    fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
239        // While a `:command` is being typed every key edits the
240        // buffer or commits / cancels the line. No other keymap
241        // applies.
242        if self.command_buffer.is_some() {
243            self.handle_command_mode_key(key)?;
244            return Ok(());
245        }
246        // While the `?` help overlay is up, only Esc / ? / q close
247        // it. Don't propagate to components or process other keys
248        // — the operator is reading reference, not driving.
249        if self.help_visible {
250            match key.code {
251                crossterm::event::KeyCode::Esc
252                | crossterm::event::KeyCode::Char('?')
253                | crossterm::event::KeyCode::Char('q') => {
254                    self.help_visible = false;
255                }
256                _ => {}
257            }
258            return Ok(());
259        }
260        // `?` opens the help overlay. We capture it at the app level
261        // so every screen gets the overlay for free without each one
262        // having to wire its own.
263        if matches!(key.code, crossterm::event::KeyCode::Char('?')) {
264            self.help_visible = true;
265            return Ok(());
266        }
267        let action_tx = self.action_tx.clone();
268        // ':' opens the command bar.
269        if matches!(
270            key.code,
271            crossterm::event::KeyCode::Char(':')
272        ) {
273            self.command_buffer = Some(String::new());
274            self.command_status = None;
275            return Ok(());
276        }
277        // Tab keeps working as a quick screen-cycle shortcut even
278        // after the `:command` bar lands.
279        if matches!(key.code, crossterm::event::KeyCode::Tab) {
280            if !self.screens.is_empty() {
281                self.current_screen = (self.current_screen + 1) % self.screens.len();
282                debug!(
283                    "switched to screen {}",
284                    SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
285                );
286            }
287            return Ok(());
288        }
289        let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
290            return Ok(());
291        };
292        match keymap.get(&vec![key]) {
293            Some(action) => {
294                info!("Got action: {action:?}");
295                action_tx.send(action.clone())?;
296            }
297            _ => {
298                // If the key was not handled as a single key action,
299                // then consider it for multi-key combinations.
300                self.last_tick_key_events.push(key);
301
302                // Check for multi-key combinations
303                if let Some(action) = keymap.get(&self.last_tick_key_events) {
304                    info!("Got action: {action:?}");
305                    action_tx.send(action.clone())?;
306                }
307            }
308        }
309        Ok(())
310    }
311
312    fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
313        use crossterm::event::KeyCode;
314        let buf = match self.command_buffer.as_mut() {
315            Some(b) => b,
316            None => return Ok(()),
317        };
318        match key.code {
319            KeyCode::Esc => {
320                // Cancel without dispatching.
321                self.command_buffer = None;
322            }
323            KeyCode::Enter => {
324                let line = std::mem::take(buf);
325                self.command_buffer = None;
326                self.execute_command(&line)?;
327            }
328            KeyCode::Backspace => {
329                buf.pop();
330            }
331            KeyCode::Char(c) => {
332                buf.push(c);
333            }
334            _ => {}
335        }
336        Ok(())
337    }
338
339    /// Resolve a `:command` token to the action it represents.
340    /// Empty input is a silent no-op (operator typed `:` then Enter).
341    fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
342        let trimmed = line.trim();
343        if trimmed.is_empty() {
344            return Ok(());
345        }
346        let head = trimmed.split_whitespace().next().unwrap_or("");
347        match head {
348            "q" | "quit" => {
349                self.action_tx.send(Action::Quit)?;
350                self.command_status = Some(CommandStatus::Info("quitting".into()));
351            }
352            "diagnose" | "diag" => {
353                self.command_status = Some(match self.export_diagnostic_bundle() {
354                    Ok(path) => CommandStatus::Info(format!(
355                        "diagnostic bundle exported to {}",
356                        path.display()
357                    )),
358                    Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
359                });
360            }
361            "pins-check" | "pins" => {
362                self.command_status = Some(match self.start_pins_check() {
363                    Ok(path) => CommandStatus::Info(format!(
364                        "pins integrity check running → {} (tail to watch progress)",
365                        path.display()
366                    )),
367                    Err(e) => CommandStatus::Err(format!("pins-check failed to start: {e}")),
368                });
369            }
370            "loggers" => {
371                self.command_status = Some(match self.start_loggers_dump() {
372                    Ok(path) => CommandStatus::Info(format!(
373                        "loggers snapshot writing → {} (open when ready)",
374                        path.display()
375                    )),
376                    Err(e) => CommandStatus::Err(format!("loggers failed to start: {e}")),
377                });
378            }
379            "set-logger" => {
380                let mut parts = trimmed.split_whitespace();
381                let _ = parts.next(); // command head
382                let expr = parts.next().unwrap_or("");
383                let level = parts.next().unwrap_or("");
384                if expr.is_empty() || level.is_empty() {
385                    self.command_status = Some(CommandStatus::Err(
386                        "usage: :set-logger <expr> <level>  (level: none|error|warning|info|debug|all; expr: e.g. node/pushsync or '.' for all)"
387                            .into(),
388                    ));
389                    return Ok(());
390                }
391                self.start_set_logger(expr.to_string(), level.to_string());
392                self.command_status = Some(CommandStatus::Info(format!(
393                    "set-logger {expr:?} → {level:?} (PUT in-flight; check :loggers to verify)"
394                )));
395            }
396            "context" | "ctx" => {
397                let target = trimmed.split_whitespace().nth(1).unwrap_or("");
398                if target.is_empty() {
399                    let known: Vec<String> = self
400                        .config
401                        .nodes
402                        .iter()
403                        .map(|n| n.name.clone())
404                        .collect();
405                    self.command_status = Some(CommandStatus::Err(format!(
406                        "usage: :context <name>  (known: {})",
407                        known.join(", ")
408                    )));
409                    return Ok(());
410                }
411                self.command_status = Some(match self.switch_context(target) {
412                    Ok(()) => CommandStatus::Info(format!(
413                        "switched to context {target} ({})",
414                        self.api.url
415                    )),
416                    Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
417                });
418            }
419            screen if SCREEN_NAMES
420                .iter()
421                .any(|name| name.eq_ignore_ascii_case(screen)) =>
422            {
423                if let Some(idx) = SCREEN_NAMES
424                    .iter()
425                    .position(|name| name.eq_ignore_ascii_case(screen))
426                {
427                    self.current_screen = idx;
428                    self.command_status =
429                        Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
430                }
431            }
432            other => {
433                self.command_status = Some(CommandStatus::Err(format!(
434                    "unknown command: {other:?} (try :health, :stamps, :swap, :lottery, :peers, :network, :warmup, :api, :tags, :diagnose, :pins-check, :loggers, :set-logger, :context, :quit)"
435                )));
436            }
437        }
438        Ok(())
439    }
440
441    /// Tear down the current watch hub and ApiClient, build a new
442    /// connection against the named NodeConfig, and rebuild the
443    /// screen list against fresh receivers. Component-internal state
444    /// (Lottery's bench history, Network's reachability stability
445    /// timer, etc.) is intentionally lost — a profile switch is a
446    /// fresh slate, the same way it would be on app restart.
447    fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
448        let node = self
449            .config
450            .nodes
451            .iter()
452            .find(|n| n.name == target)
453            .ok_or_else(|| eyre!("no node configured with name {target:?}"))?
454            .clone();
455        let new_api = Arc::new(ApiClient::from_node(&node)?);
456        // Cancel the current hub's children and let it drop. The new
457        // hub spawns under the same root_cancel so quit-time teardown
458        // still walks the whole tree in one go.
459        self.watch.shutdown();
460        let new_watch = BeeWatch::start(new_api.clone(), &self.root_cancel);
461        let new_health_rx = new_watch.health();
462        let new_screens = build_screens(&new_api, &new_watch);
463        self.api = new_api;
464        self.watch = new_watch;
465        self.health_rx = new_health_rx;
466        self.screens = new_screens;
467        // Keep the same tab index so the operator stays on the
468        // screen they were looking at — same data shape, new node.
469        Ok(())
470    }
471
472    /// Build and persist a redacted diagnostic bundle to a file in
473    /// the system temp directory. Designed to be paste-ready into a
474    /// support thread (Discord, GitHub issue) without leaking
475    /// auth tokens — URLs are reduced to their path component, since
476    /// Bearer tokens live in headers, not URLs.
477    /// Kick off `GET /pins/check` in a background task. Returns the
478    /// destination file path immediately so the operator can `tail -f`
479    /// it while bee-rs streams the NDJSON response. Each pin is
480    /// appended as a single line: `<ref>  total=N  missing=N  invalid=N
481    /// (healthy|UNHEALTHY)`. A `# done. <n> pins checked.` trailer
482    /// signals completion.
483    ///
484    /// The task captures `Arc<ApiClient>` so a `:context` switch
485    /// mid-check still completes against the original profile — the
486    /// destination file's name pins the profile so two parallel
487    /// invocations against different profiles don't collide.
488    fn start_pins_check(&self) -> std::io::Result<PathBuf> {
489        let secs = SystemTime::now()
490            .duration_since(UNIX_EPOCH)
491            .map(|d| d.as_secs())
492            .unwrap_or(0);
493        let path = std::env::temp_dir().join(format!(
494            "bee-tui-pins-check-{}-{secs}.txt",
495            sanitize_for_filename(&self.api.name),
496        ));
497        // Pre-create with a header so the operator's `tail -f` finds
498        // something immediately, even before the first pin lands.
499        std::fs::write(
500            &path,
501            format!(
502                "# bee-tui :pins-check\n# profile  {}\n# endpoint {}\n# started  {}\n",
503                self.api.name,
504                self.api.url,
505                format_utc_now(),
506            ),
507        )?;
508
509        let api = self.api.clone();
510        let dest = path.clone();
511        tokio::spawn(async move {
512            let bee = api.bee();
513            match bee.api().check_pins(None).await {
514                Ok(entries) => {
515                    let mut body = String::new();
516                    for e in &entries {
517                        body.push_str(&format!(
518                            "{}  total={}  missing={}  invalid={}  {}\n",
519                            e.reference.to_hex(),
520                            e.total,
521                            e.missing,
522                            e.invalid,
523                            if e.is_healthy() { "healthy" } else { "UNHEALTHY" },
524                        ));
525                    }
526                    body.push_str(&format!("# done. {} pins checked.\n", entries.len()));
527                    if let Err(e) = append(&dest, &body) {
528                        let _ = append(&dest, &format!("# write error: {e}\n"));
529                    }
530                }
531                Err(e) => {
532                    let _ = append(&dest, &format!("# error: {e}\n"));
533                }
534            }
535        });
536        Ok(path)
537    }
538
539    /// Spawn a fire-and-forget task that calls
540    /// `set_logger(expression, level)` against the node. The result
541    /// (success or error) is appended to a `:loggers`-style log file
542    /// so the operator has a paper trail of mutations made from the
543    /// cockpit. Per-profile and per-call so multiple `:set-logger`
544    /// invocations don't trample each other's record.
545    ///
546    /// Bee will validate `level` against its own enum (`none|error|
547    /// warning|info|debug|all`); bee-rs does the same client-side, so
548    /// a mistyped level errors out before any HTTP request goes out.
549    fn start_set_logger(&self, expression: String, level: String) {
550        let secs = SystemTime::now()
551            .duration_since(UNIX_EPOCH)
552            .map(|d| d.as_secs())
553            .unwrap_or(0);
554        let dest = std::env::temp_dir().join(format!(
555            "bee-tui-set-logger-{}-{secs}.txt",
556            sanitize_for_filename(&self.api.name),
557        ));
558        let _ = std::fs::write(
559            &dest,
560            format!(
561                "# bee-tui :set-logger\n# profile  {}\n# endpoint {}\n# expr     {expression}\n# level    {level}\n# started  {}\n",
562                self.api.name,
563                self.api.url,
564                format_utc_now(),
565            ),
566        );
567
568        let api = self.api.clone();
569        tokio::spawn(async move {
570            let bee = api.bee();
571            match bee.debug().set_logger(&expression, &level).await {
572                Ok(()) => {
573                    let _ = append(
574                        &dest,
575                        &format!("# done. {expression} → {level} accepted by Bee.\n"),
576                    );
577                }
578                Err(e) => {
579                    let _ = append(&dest, &format!("# error: {e}\n"));
580                }
581            }
582        });
583    }
584
585    /// Snapshot Bee's logger configuration to a file. Same on-demand
586    /// pattern as `:pins-check`: capture the registered loggers + their
587    /// verbosity into a sortable text table so operators can answer
588    /// "is push-sync at debug right now?" without curling the API.
589    fn start_loggers_dump(&self) -> std::io::Result<PathBuf> {
590        let secs = SystemTime::now()
591            .duration_since(UNIX_EPOCH)
592            .map(|d| d.as_secs())
593            .unwrap_or(0);
594        let path = std::env::temp_dir().join(format!(
595            "bee-tui-loggers-{}-{secs}.txt",
596            sanitize_for_filename(&self.api.name),
597        ));
598        std::fs::write(
599            &path,
600            format!(
601                "# bee-tui :loggers\n# profile  {}\n# endpoint {}\n# started  {}\n",
602                self.api.name,
603                self.api.url,
604                format_utc_now(),
605            ),
606        )?;
607
608        let api = self.api.clone();
609        let dest = path.clone();
610        tokio::spawn(async move {
611            let bee = api.bee();
612            match bee.debug().loggers().await {
613                Ok(listing) => {
614                    let mut rows = listing.loggers.clone();
615                    // Stable sort: verbosity buckets first ("all"
616                    // before "1"/"info" etc. so the loud loggers
617                    // float to the top), then logger name.
618                    rows.sort_by(|a, b| {
619                        verbosity_rank(&b.verbosity)
620                            .cmp(&verbosity_rank(&a.verbosity))
621                            .then_with(|| a.logger.cmp(&b.logger))
622                    });
623                    let mut body = String::new();
624                    body.push_str(&format!("# {} loggers registered\n", rows.len()));
625                    body.push_str("# VERBOSITY  LOGGER\n");
626                    for r in &rows {
627                        body.push_str(&format!(
628                            "  {:<9}  {}\n",
629                            r.verbosity, r.logger,
630                        ));
631                    }
632                    body.push_str("# done.\n");
633                    if let Err(e) = append(&dest, &body) {
634                        let _ = append(&dest, &format!("# write error: {e}\n"));
635                    }
636                }
637                Err(e) => {
638                    let _ = append(&dest, &format!("# error: {e}\n"));
639                }
640            }
641        });
642        Ok(path)
643    }
644
645    fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
646        let bundle = self.render_diagnostic_bundle();
647        let secs = SystemTime::now()
648            .duration_since(UNIX_EPOCH)
649            .map(|d| d.as_secs())
650            .unwrap_or(0);
651        let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
652        std::fs::write(&path, bundle)?;
653        Ok(path)
654    }
655
656    fn render_diagnostic_bundle(&self) -> String {
657        let now = format_utc_now();
658        let health = self.health_rx.borrow().clone();
659        let topology = self.watch.topology().borrow().clone();
660        let gates = Health::gates_for(&health, Some(&topology));
661        let recent: Vec<_> = log_capture::handle()
662            .map(|c| {
663                let mut snap = c.snapshot();
664                let len = snap.len();
665                if len > 50 {
666                    snap.drain(0..len - 50);
667                }
668                snap
669            })
670            .unwrap_or_default();
671
672        let mut out = String::new();
673        out.push_str("# bee-tui diagnostic bundle\n");
674        out.push_str(&format!("# generated UTC {now}\n\n"));
675        out.push_str("## profile\n");
676        out.push_str(&format!("  name      {}\n", self.api.name));
677        out.push_str(&format!("  endpoint  {}\n\n", self.api.url));
678        out.push_str("## health gates\n");
679        for g in &gates {
680            out.push_str(&format_gate_line(g));
681        }
682        out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
683        for e in &recent {
684            let status = e.status.map(|s| s.to_string()).unwrap_or_else(|| "—".into());
685            let elapsed = e
686                .elapsed_ms
687                .map(|ms| format!("{ms}ms"))
688                .unwrap_or_else(|| "—".into());
689            out.push_str(&format!(
690                "  {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
691                ts = e.ts,
692                method = e.method,
693                path = path_only(&e.url),
694                status = status,
695                elapsed = elapsed,
696            ));
697        }
698        out.push_str(&format!(
699            "\n## generated by bee-tui {}\n",
700            env!("CARGO_PKG_VERSION"),
701        ));
702        out
703    }
704
705    fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
706        while let Ok(action) = self.action_rx.try_recv() {
707            if action != Action::Tick && action != Action::Render {
708                debug!("{action:?}");
709            }
710            match action {
711                Action::Tick => {
712                    self.last_tick_key_events.drain(..);
713                }
714                Action::Quit => self.should_quit = true,
715                Action::Suspend => self.should_suspend = true,
716                Action::Resume => self.should_suspend = false,
717                Action::ClearScreen => tui.terminal.clear()?,
718                Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
719                Action::Render => self.render(tui)?,
720                _ => {}
721            }
722            let tx = self.action_tx.clone();
723            for component in self.iter_components_mut() {
724                if let Some(action) = component.update(action.clone())? {
725                    tx.send(action)?
726                };
727            }
728        }
729        Ok(())
730    }
731
732    fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
733        tui.resize(Rect::new(0, 0, w, h))?;
734        self.render(tui)?;
735        Ok(())
736    }
737
738    fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
739        let active = self.current_screen;
740        let tx = self.action_tx.clone();
741        let screens = &mut self.screens;
742        let command_log = &mut self.command_log;
743        let command_buffer = self.command_buffer.clone();
744        let command_status = self.command_status.clone();
745        let help_visible = self.help_visible;
746        let profile = self.api.name.clone();
747        let endpoint = self.api.url.clone();
748        let last_ping = self.health_rx.borrow().last_ping;
749        let now_utc = format_utc_now();
750        tui.draw(|frame| {
751            use ratatui::layout::{Constraint, Layout};
752            use ratatui::style::{Color, Modifier, Style};
753            use ratatui::text::{Line, Span};
754            use ratatui::widgets::Paragraph;
755
756            let chunks = Layout::vertical([
757                Constraint::Length(2), // top-bar (metadata + tabs)
758                Constraint::Min(0),    // active screen
759                Constraint::Length(1), // command bar / status line
760                Constraint::Length(8), // command-log strip
761            ])
762            .split(frame.area());
763
764            let top_chunks =
765                Layout::vertical([Constraint::Length(1), Constraint::Length(1)])
766                    .split(chunks[0]);
767
768            // Metadata line: profile · endpoint · ping · clock.
769            let ping_str = match last_ping {
770                Some(d) => format!("{}ms", d.as_millis()),
771                None => "—".into(),
772            };
773            let t = theme::active();
774            let metadata_line = Line::from(vec![
775                Span::styled(
776                    " bee-tui ",
777                    Style::default()
778                        .fg(Color::Black)
779                        .bg(t.info)
780                        .add_modifier(Modifier::BOLD),
781                ),
782                Span::raw("  "),
783                Span::styled(
784                    profile,
785                    Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
786                ),
787                Span::styled(
788                    format!(" @ {endpoint}"),
789                    Style::default().fg(t.dim),
790                ),
791                Span::raw("   "),
792                Span::styled("ping ", Style::default().fg(t.dim)),
793                Span::styled(ping_str, Style::default().fg(t.info)),
794                Span::raw("   "),
795                Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
796            ]);
797            frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
798
799            // Tab strip with the active screen highlighted.
800            let theme = *theme::active();
801            let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
802            for (i, name) in SCREEN_NAMES.iter().enumerate() {
803                let style = if i == active {
804                    Style::default()
805                        .fg(theme.tab_active_fg)
806                        .bg(theme.tab_active_bg)
807                        .add_modifier(Modifier::BOLD)
808                } else {
809                    Style::default().fg(theme.dim)
810                };
811                tabs.push(Span::styled(format!(" {name} "), style));
812                tabs.push(Span::raw(" "));
813            }
814            tabs.push(Span::styled(
815                ":cmd · Tab to cycle · ? help",
816                Style::default().fg(theme.dim),
817            ));
818            frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
819
820            // Active screen
821            if let Some(screen) = screens.get_mut(active) {
822                if let Err(err) = screen.draw(frame, chunks[1]) {
823                    let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
824                }
825            }
826            // Command bar / status line
827            let prompt = if let Some(buf) = &command_buffer {
828                Line::from(vec![
829                    Span::styled(
830                        ":",
831                        Style::default()
832                            .fg(t.accent)
833                            .add_modifier(Modifier::BOLD),
834                    ),
835                    Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
836                    Span::styled("█", Style::default().fg(t.accent)),
837                ])
838            } else {
839                match &command_status {
840                    Some(CommandStatus::Info(msg)) => {
841                        Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
842                    }
843                    Some(CommandStatus::Err(msg)) => {
844                        Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
845                    }
846                    None => Line::from(""),
847                }
848            };
849            frame.render_widget(Paragraph::new(prompt), chunks[2]);
850
851            // Command-log strip
852            if let Err(err) = command_log.draw(frame, chunks[3]) {
853                let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
854            }
855
856            // Help overlay — drawn last so it floats above everything
857            // else. Centred with a fixed width that fits even narrow
858            // terminals (≥60 cols). Falls back to the full screen on
859            // anything narrower.
860            if help_visible {
861                draw_help_overlay(frame, frame.area(), active, &theme);
862            }
863        })?;
864        Ok(())
865    }
866}
867
868/// Render the `?` help overlay. Pulls a per-screen keymap from
869/// [`screen_keymap`] and pairs it with the global keys (Tab, `:`,
870/// `q`). Drawn as a centred floating box; everything outside is
871/// dimmed via a [`Clear`] underlay.
872fn draw_help_overlay(
873    frame: &mut ratatui::Frame,
874    area: ratatui::layout::Rect,
875    active_screen: usize,
876    theme: &theme::Theme,
877) {
878    use ratatui::layout::Rect;
879    use ratatui::style::{Modifier, Style};
880    use ratatui::text::{Line, Span};
881    use ratatui::widgets::{Block, Borders, Clear, Paragraph};
882
883    let screen_name = SCREEN_NAMES.get(active_screen).copied().unwrap_or("?");
884    let screen_rows = screen_keymap(active_screen);
885    let global_rows: &[(&str, &str)] = &[
886        ("Tab", "next screen"),
887        ("?", "toggle this help"),
888        (":", "open command bar"),
889        ("q  /  Ctrl+C", "quit"),
890    ];
891
892    // Layout: pick the smaller of (screen size, 70x22) so we always
893    // fit on small terminals.
894    let w = area.width.min(72);
895    let h = area.height.min(22);
896    let x = area.x + (area.width.saturating_sub(w)) / 2;
897    let y = area.y + (area.height.saturating_sub(h)) / 2;
898    let rect = Rect { x, y, width: w, height: h };
899
900    let mut lines: Vec<Line> = Vec::new();
901    lines.push(Line::from(vec![
902        Span::styled(
903            format!(" {screen_name} "),
904            Style::default()
905                .fg(theme.tab_active_fg)
906                .bg(theme.tab_active_bg)
907                .add_modifier(Modifier::BOLD),
908        ),
909        Span::raw("   screen-specific keys"),
910    ]));
911    lines.push(Line::from(""));
912    if screen_rows.is_empty() {
913        lines.push(Line::from(Span::styled(
914            "  (no extra keys for this screen — use the command bar via :)",
915            Style::default().fg(theme.dim).add_modifier(Modifier::ITALIC),
916        )));
917    } else {
918        for (key, desc) in screen_rows {
919            lines.push(format_help_row(key, desc, theme));
920        }
921    }
922    lines.push(Line::from(""));
923    lines.push(Line::from(Span::styled(
924        "  global",
925        Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
926    )));
927    for (key, desc) in global_rows {
928        lines.push(format_help_row(key, desc, theme));
929    }
930    lines.push(Line::from(""));
931    lines.push(Line::from(Span::styled(
932        "  Esc / ? / q to dismiss",
933        Style::default().fg(theme.dim).add_modifier(Modifier::ITALIC),
934    )));
935
936    // `Clear` blanks the underlying rendered region so the overlay
937    // doesn't ghost over screen content.
938    frame.render_widget(Clear, rect);
939    frame.render_widget(
940        Paragraph::new(lines).block(
941            Block::default()
942                .borders(Borders::ALL)
943                .border_style(Style::default().fg(theme.accent))
944                .title(" help "),
945        ),
946        rect,
947    );
948}
949
950fn format_help_row<'a>(
951    key: &'a str,
952    desc: &'a str,
953    theme: &theme::Theme,
954) -> ratatui::text::Line<'a> {
955    use ratatui::style::{Modifier, Style};
956    use ratatui::text::{Line, Span};
957    Line::from(vec![
958        Span::raw("  "),
959        Span::styled(
960            format!("{key:<14}"),
961            Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
962        ),
963        Span::raw("  "),
964        Span::raw(desc),
965    ])
966}
967
968/// Per-screen keymap rows, indexed by the same position as
969/// [`SCREEN_NAMES`]. Edit here when a screen grows new keys —
970/// no other place needs updating.
971fn screen_keymap(active_screen: usize) -> &'static [(&'static str, &'static str)] {
972    match active_screen {
973        // 0: Health — read-only
974        1 => &[
975            ("↑↓ / j k", "move row selection"),
976            ("Enter", "drill batch — bucket histogram + worst-N"),
977            ("Esc", "close drill"),
978        ],
979        // 2: Swap — read-only
980        3 => &[
981            ("r", "run on-demand rchash benchmark"),
982        ],
983        4 => &[
984            ("↑↓ / j k", "move peer selection"),
985            ("Enter", "drill peer — balance / cheques / settlement / ping"),
986            ("Esc", "close drill"),
987        ],
988        // 5: Network — read-only
989        // 6: Warmup — read-only
990        // 7: API — read-only
991        8 => &[
992            ("↑↓ / j k", "scroll one row"),
993            ("PgUp / PgDn", "scroll ten rows"),
994            ("Home", "back to top"),
995        ],
996        _ => &[],
997    }
998}
999
1000/// Construct the eight v0.3 screens with receivers from the supplied
1001/// hub. Extracted so `App::new` and the `:context` profile-switcher
1002/// can share the wiring — the screen list is the same on every
1003/// connection, only the underlying watch hub changes.
1004///
1005/// Order matters — the [`SCREEN_NAMES`] table assumes index 0 is
1006/// Health, 1 is Stamps, 2 is Swap, 3 is Lottery, 4 is Peers, 5 is
1007/// Network, 6 is Warmup, 7 is API, 8 is Tags.
1008fn build_screens(api: &Arc<ApiClient>, watch: &BeeWatch) -> Vec<Box<dyn Component>> {
1009    let health = Health::new(api.clone(), watch.health(), watch.topology());
1010    let stamps = Stamps::new(api.clone(), watch.stamps());
1011    let swap = Swap::new(watch.swap());
1012    let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
1013    let peers = Peers::new(api.clone(), watch.topology());
1014    let network = Network::new(watch.network(), watch.topology());
1015    let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
1016    let api_health = ApiHealth::new(
1017        api.clone(),
1018        watch.health(),
1019        watch.transactions(),
1020        log_capture::handle(),
1021    );
1022    let tags = Tags::new(watch.tags());
1023    vec![
1024        Box::new(health),
1025        Box::new(stamps),
1026        Box::new(swap),
1027        Box::new(lottery),
1028        Box::new(peers),
1029        Box::new(network),
1030        Box::new(warmup),
1031        Box::new(api_health),
1032        Box::new(tags),
1033    ]
1034}
1035
1036fn format_gate_line(g: &Gate) -> String {
1037    let glyphs = crate::theme::active().glyphs;
1038    let glyph = match g.status {
1039        GateStatus::Pass => glyphs.pass,
1040        GateStatus::Warn => glyphs.warn,
1041        GateStatus::Fail => glyphs.fail,
1042        GateStatus::Unknown => glyphs.bullet,
1043    };
1044    let mut s = format!("  [{glyph}] {label:<28} {value}\n", label = g.label, value = g.value);
1045    if let Some(why) = &g.why {
1046        s.push_str(&format!("        {} {why}\n", glyphs.continuation));
1047    }
1048    s
1049}
1050
1051/// Strip scheme + host from a URL, leaving only the path + query.
1052/// Mirrors the redaction the S10 command-log pane applies on render.
1053fn path_only(url: &str) -> String {
1054    if let Some(idx) = url.find("//") {
1055        let after_scheme = &url[idx + 2..];
1056        if let Some(slash) = after_scheme.find('/') {
1057            return after_scheme[slash..].to_string();
1058        }
1059        return "/".into();
1060    }
1061    url.to_string()
1062}
1063
1064/// Format the current wall-clock UTC time as `HH:MM:SS`. We compute
1065/// from `SystemTime::now()` directly so the binary stays free of a
1066/// chrono / time dep just for this one display string.
1067/// Append-write to `path`. Used by the `:pins-check` background task
1068/// to stream NDJSON-style results into a file the operator can
1069/// `tail -f`.
1070fn append(path: &PathBuf, s: &str) -> std::io::Result<()> {
1071    use std::io::Write;
1072    let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
1073    f.write_all(s.as_bytes())
1074}
1075
1076/// Bee returns logger verbosity as a free-form string — usually
1077/// `"all"`, `"trace"`, `"debug"`, `"info"`, `"warning"`, `"error"`,
1078/// `"none"`, plus the legacy numeric forms `"1"`/`"2"`/`"3"`. Map to
1079/// a coarse rank so the noisier loggers sort to the top of the
1080/// `:loggers` dump. Unknown strings get rank 0 (silent end).
1081fn verbosity_rank(s: &str) -> u8 {
1082    match s {
1083        "all" | "trace" => 5,
1084        "debug" => 4,
1085        "info" | "1" => 3,
1086        "warning" | "warn" | "2" => 2,
1087        "error" | "3" => 1,
1088        _ => 0,
1089    }
1090}
1091
1092/// Drop characters that are unsafe in a filename. Profile names come
1093/// from the user's `config.toml`, so we accept what's in there but
1094/// keep the path well-behaved on every shell.
1095fn sanitize_for_filename(s: &str) -> String {
1096    s.chars()
1097        .map(|c| match c {
1098            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
1099            _ => '-',
1100        })
1101        .collect()
1102}
1103
1104fn format_utc_now() -> String {
1105    let secs = SystemTime::now()
1106        .duration_since(UNIX_EPOCH)
1107        .map(|d| d.as_secs())
1108        .unwrap_or(0);
1109    let secs_in_day = secs % 86_400;
1110    let h = secs_in_day / 3_600;
1111    let m = (secs_in_day % 3_600) / 60;
1112    let s = secs_in_day % 60;
1113    format!("{h:02}:{m:02}:{s:02}")
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119
1120    #[test]
1121    fn format_utc_now_returns_eight_chars() {
1122        let s = format_utc_now();
1123        assert_eq!(s.len(), 8);
1124        assert_eq!(s.chars().nth(2), Some(':'));
1125        assert_eq!(s.chars().nth(5), Some(':'));
1126    }
1127
1128    #[test]
1129    fn path_only_strips_scheme_and_host() {
1130        assert_eq!(
1131            path_only("http://10.0.1.5:1633/status"),
1132            "/status"
1133        );
1134        assert_eq!(
1135            path_only("https://bee.example.com/stamps?limit=10"),
1136            "/stamps?limit=10"
1137        );
1138    }
1139
1140    #[test]
1141    fn path_only_handles_no_path() {
1142        assert_eq!(path_only("http://localhost:1633"), "/");
1143    }
1144
1145    #[test]
1146    fn path_only_passes_relative_through() {
1147        assert_eq!(path_only("/already/relative"), "/already/relative");
1148    }
1149
1150    #[test]
1151    fn sanitize_for_filename_keeps_safe_chars() {
1152        assert_eq!(sanitize_for_filename("prod-1"), "prod-1");
1153        assert_eq!(sanitize_for_filename("lab_node"), "lab_node");
1154    }
1155
1156    #[test]
1157    fn sanitize_for_filename_replaces_unsafe_chars() {
1158        assert_eq!(sanitize_for_filename("a/b\\c d"), "a-b-c-d");
1159        assert_eq!(sanitize_for_filename("name:colon"), "name-colon");
1160    }
1161
1162    #[test]
1163    fn screen_keymap_covers_drill_screens() {
1164        // Stamps (1) and Peers (4) are the two screens with drill
1165        // panes — both must list ↑↓ / Enter / Esc in the help.
1166        for idx in [1usize, 4] {
1167            let rows = screen_keymap(idx);
1168            assert!(
1169                rows.iter().any(|(k, _)| k.contains("Enter")),
1170                "screen {idx} keymap must mention Enter (drill)"
1171            );
1172            assert!(
1173                rows.iter().any(|(k, _)| k.contains("Esc")),
1174                "screen {idx} keymap must mention Esc (close drill)"
1175            );
1176        }
1177    }
1178
1179    #[test]
1180    fn screen_keymap_lottery_advertises_rchash() {
1181        let rows = screen_keymap(3);
1182        assert!(rows.iter().any(|(k, _)| k.contains("r")));
1183    }
1184
1185    #[test]
1186    fn screen_keymap_unknown_index_is_empty_not_panic() {
1187        assert!(screen_keymap(999).is_empty());
1188    }
1189
1190    #[test]
1191    fn verbosity_rank_orders_loud_to_silent() {
1192        assert!(verbosity_rank("all") > verbosity_rank("debug"));
1193        assert!(verbosity_rank("debug") > verbosity_rank("info"));
1194        assert!(verbosity_rank("info") > verbosity_rank("warning"));
1195        assert!(verbosity_rank("warning") > verbosity_rank("error"));
1196        assert!(verbosity_rank("error") > verbosity_rank("unknown"));
1197        // Numeric and named forms sort identically.
1198        assert_eq!(verbosity_rank("info"), verbosity_rank("1"));
1199        assert_eq!(verbosity_rank("warning"), verbosity_rank("2"));
1200    }
1201}