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