use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use color_eyre::eyre::eyre;
use crossterm::event::KeyEvent;
use ratatui::prelude::Rect;
use serde::{Deserialize, Serialize};
use tokio::sync::{mpsc, watch};
use tokio_util::sync::CancellationToken;
use tracing::{debug, info};
use crate::{
action::Action,
api::ApiClient,
bee_supervisor::{BeeStatus, BeeSupervisor},
components::{
Component,
api_health::ApiHealth,
health::{Gate, GateStatus, Health},
log_pane::{BeeLogLine, LogPane, LogTab},
lottery::Lottery,
network::Network,
peers::Peers,
pins::Pins,
stamps::Stamps,
swap::Swap,
tags::Tags,
warmup::Warmup,
},
config::Config,
log_capture, stamp_preview,
state::State,
theme,
tui::{Event, Tui},
watch::{BeeWatch, HealthSnapshot, RefreshProfile},
};
pub struct App {
config: Config,
tick_rate: f64,
frame_rate: f64,
screens: Vec<Box<dyn Component>>,
current_screen: usize,
log_pane: LogPane,
state_path: PathBuf,
should_quit: bool,
should_suspend: bool,
mode: Mode,
last_tick_key_events: Vec<KeyEvent>,
action_tx: mpsc::UnboundedSender<Action>,
action_rx: mpsc::UnboundedReceiver<Action>,
root_cancel: CancellationToken,
#[allow(dead_code)]
api: Arc<ApiClient>,
watch: BeeWatch,
health_rx: watch::Receiver<HealthSnapshot>,
command_buffer: Option<String>,
command_suggestion_index: usize,
command_status: Option<CommandStatus>,
help_visible: bool,
quit_pending: Option<Instant>,
supervisor: Option<BeeSupervisor>,
bee_status: BeeStatus,
bee_log_rx: Option<mpsc::UnboundedReceiver<(LogTab, BeeLogLine)>>,
cmd_status_tx: mpsc::UnboundedSender<CommandStatus>,
cmd_status_rx: mpsc::UnboundedReceiver<CommandStatus>,
}
const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
#[derive(Debug, Clone)]
pub enum CommandStatus {
Info(String),
Err(String),
}
const SCREEN_NAMES: &[&str] = &[
"Health", "Stamps", "Swap", "Lottery", "Peers", "Network", "Warmup", "API", "Tags", "Pins",
];
const KNOWN_COMMANDS: &[(&str, &str)] = &[
("health", "S1 Health screen"),
("stamps", "S2 Stamps screen"),
("swap", "S3 SWAP / cheques screen"),
("lottery", "S4 Lottery + rchash"),
("peers", "S6 Peers + bin saturation"),
("network", "S7 Network / NAT"),
("warmup", "S5 Warmup checklist"),
("api", "S8 RPC / API health"),
("tags", "S9 Tags / uploads"),
("pins", "S11 Pins screen"),
("topup-preview", "<batch> <amount-plur> — predict topup"),
("dilute-preview", "<batch> <new-depth> — predict dilute"),
("extend-preview", "<batch> <duration> — predict extend"),
("buy-preview", "<depth> <amount-plur> — predict fresh buy"),
("buy-suggest", "<size> <duration> — minimum (depth, amount)"),
(
"probe-upload",
"<batch> — single 4 KiB chunk, end-to-end probe",
),
("diagnose", "Export full snapshot to a file"),
("pins-check", "Bulk integrity walk to a file"),
("loggers", "Dump live logger registry"),
("set-logger", "<expr> <level> — change a logger's verbosity"),
("context", "<name> — switch node profile"),
("quit", "Exit the cockpit"),
];
fn filter_command_suggestions<'a>(
buffer: &str,
catalog: &'a [(&'a str, &'a str)],
) -> Vec<&'a (&'a str, &'a str)> {
let head = buffer
.split_whitespace()
.next()
.unwrap_or("")
.to_ascii_lowercase();
catalog
.iter()
.filter(|(name, _)| name.starts_with(&head))
.collect()
}
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Mode {
#[default]
Home,
}
#[derive(Debug, Default)]
pub struct AppOverrides {
pub ascii: bool,
pub no_color: bool,
pub bee_bin: Option<PathBuf>,
pub bee_config: Option<PathBuf>,
}
const BEE_API_READY_TIMEOUT: Duration = Duration::from_secs(60);
impl App {
pub async fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
Self::with_overrides(tick_rate, frame_rate, AppOverrides::default()).await
}
pub async fn with_overrides(
tick_rate: f64,
frame_rate: f64,
overrides: AppOverrides,
) -> color_eyre::Result<Self> {
let (action_tx, action_rx) = mpsc::unbounded_channel();
let (cmd_status_tx, cmd_status_rx) = mpsc::unbounded_channel();
let config = Config::new()?;
let force_no_color = overrides.no_color || theme::no_color_env();
theme::install_with_overrides(&config.ui, force_no_color, overrides.ascii);
let node = config
.active_node()
.ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
let api = Arc::new(ApiClient::from_node(node)?);
let bee_bin = overrides
.bee_bin
.or_else(|| config.bee.as_ref().map(|b| b.bin.clone()));
let bee_config = overrides
.bee_config
.or_else(|| config.bee.as_ref().map(|b| b.config.clone()));
let bee_logs = config
.bee
.as_ref()
.map(|b| b.logs.clone())
.unwrap_or_default();
let supervisor = match (bee_bin, bee_config) {
(Some(bin), Some(cfg)) => {
eprintln!("bee-tui: spawning bee {bin:?} --config {cfg:?}");
let mut sup = BeeSupervisor::spawn(&bin, &cfg, bee_logs)?;
eprintln!(
"bee-tui: log → {} (will appear in the cockpit's bottom pane)",
sup.log_path().display()
);
eprintln!(
"bee-tui: waiting for {} to respond on /health (up to {:?})...",
api.url, BEE_API_READY_TIMEOUT
);
sup.wait_for_api(&api.url, BEE_API_READY_TIMEOUT).await?;
eprintln!("bee-tui: bee ready, opening cockpit");
Some(sup)
}
(Some(_), None) | (None, Some(_)) => {
return Err(eyre!(
"[bee].bin and [bee].config must both be set (or both unset). \
Use --bee-bin AND --bee-config, or both fields in config.toml."
));
}
(None, None) => None,
};
let refresh = RefreshProfile::from_config(&config.ui.refresh);
let root_cancel = CancellationToken::new();
let watch = BeeWatch::start_with_profile(api.clone(), &root_cancel, refresh);
let health_rx = watch.health();
let screens = build_screens(&api, &watch);
let (persisted, state_path) = State::load();
let initial_tab = LogTab::from_kebab(&persisted.log_pane_active_tab);
let mut log_pane = LogPane::new(
log_capture::handle(),
initial_tab,
persisted.log_pane_height,
);
log_pane.set_spawn_active(supervisor.is_some());
let bee_log_rx = supervisor.as_ref().map(|sup| {
let (tx, rx) = mpsc::unbounded_channel();
crate::bee_log_tailer::spawn(
sup.log_path().to_path_buf(),
tx,
root_cancel.child_token(),
);
rx
});
if config.metrics.enabled {
match config.metrics.addr.parse::<std::net::SocketAddr>() {
Ok(bind_addr) => {
let render_fn = build_metrics_render_fn(watch.clone(), log_capture::handle());
let cancel = root_cancel.child_token();
match crate::metrics_server::spawn(bind_addr, render_fn, cancel).await {
Ok(actual) => {
eprintln!(
"bee-tui: metrics endpoint serving /metrics on http://{actual}"
);
}
Err(e) => {
tracing::error!(
"metrics: failed to start endpoint on {bind_addr}: {e}"
);
}
}
}
Err(e) => {
tracing::error!(
"metrics: invalid [metrics].addr {:?}: {e}",
config.metrics.addr
);
}
}
}
Ok(Self {
tick_rate,
frame_rate,
screens,
current_screen: 0,
log_pane,
state_path,
should_quit: false,
should_suspend: false,
config,
mode: Mode::Home,
last_tick_key_events: Vec::new(),
action_tx,
action_rx,
root_cancel,
api,
watch,
health_rx,
command_buffer: None,
command_suggestion_index: 0,
command_status: None,
help_visible: false,
quit_pending: None,
supervisor,
bee_status: BeeStatus::Running,
bee_log_rx,
cmd_status_tx,
cmd_status_rx,
})
}
pub async fn run(&mut self) -> color_eyre::Result<()> {
let mut tui = Tui::new()?
.tick_rate(self.tick_rate)
.frame_rate(self.frame_rate);
tui.enter()?;
let tx = self.action_tx.clone();
let cfg = self.config.clone();
let size = tui.size()?;
for component in self.iter_components_mut() {
component.register_action_handler(tx.clone())?;
component.register_config_handler(cfg.clone())?;
component.init(size)?;
}
let action_tx = self.action_tx.clone();
loop {
self.handle_events(&mut tui).await?;
self.handle_actions(&mut tui)?;
if self.should_suspend {
tui.suspend()?;
action_tx.send(Action::Resume)?;
action_tx.send(Action::ClearScreen)?;
tui.enter()?;
} else if self.should_quit {
tui.stop()?;
break;
}
}
self.watch.shutdown();
self.root_cancel.cancel();
let snapshot = State {
log_pane_height: self.log_pane.height(),
log_pane_active_tab: self.log_pane.active_tab().to_kebab().to_string(),
};
snapshot.save(&self.state_path);
if let Some(sup) = self.supervisor.take() {
let final_status = sup.shutdown_default().await;
tracing::info!("bee child exited: {}", final_status.label());
}
tui.exit()?;
Ok(())
}
async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
let Some(event) = tui.next_event().await else {
return Ok(());
};
let action_tx = self.action_tx.clone();
let modal_before = self.command_buffer.is_some() || self.help_visible;
match event {
Event::Quit => action_tx.send(Action::Quit)?,
Event::Tick => action_tx.send(Action::Tick)?,
Event::Render => action_tx.send(Action::Render)?,
Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
Event::Key(key) => self.handle_key_event(key)?,
_ => {}
}
let modal_after = self.command_buffer.is_some() || self.help_visible;
let propagate = !((modal_before || modal_after) && matches!(event, Event::Key(_)));
if propagate {
for component in self.iter_components_mut() {
if let Some(action) = component.handle_events(Some(event.clone()))? {
action_tx.send(action)?;
}
}
}
Ok(())
}
fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut dyn Component> {
self.screens
.iter_mut()
.map(|c| c.as_mut() as &mut dyn Component)
.chain(std::iter::once(&mut self.log_pane as &mut dyn Component))
}
fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
if self.command_buffer.is_some() {
self.handle_command_mode_key(key)?;
return Ok(());
}
if self.help_visible {
match key.code {
crossterm::event::KeyCode::Esc
| crossterm::event::KeyCode::Char('?')
| crossterm::event::KeyCode::Char('q') => {
self.help_visible = false;
}
_ => {}
}
return Ok(());
}
if matches!(key.code, crossterm::event::KeyCode::Char('?')) {
self.help_visible = true;
return Ok(());
}
let action_tx = self.action_tx.clone();
if matches!(key.code, crossterm::event::KeyCode::Char(':')) {
self.command_buffer = Some(String::new());
self.command_status = None;
return Ok(());
}
if matches!(key.code, crossterm::event::KeyCode::Tab) {
if !self.screens.is_empty() {
self.current_screen = (self.current_screen + 1) % self.screens.len();
debug!(
"switched to screen {}",
SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
);
}
return Ok(());
}
if matches!(key.code, crossterm::event::KeyCode::BackTab) {
if !self.screens.is_empty() {
let len = self.screens.len();
self.current_screen = (self.current_screen + len - 1) % len;
debug!(
"switched to screen {}",
SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
);
}
return Ok(());
}
if matches!(key.code, crossterm::event::KeyCode::Char('['))
&& key.modifiers == crossterm::event::KeyModifiers::NONE
{
self.log_pane.prev_tab();
return Ok(());
}
if matches!(key.code, crossterm::event::KeyCode::Char(']'))
&& key.modifiers == crossterm::event::KeyModifiers::NONE
{
self.log_pane.next_tab();
return Ok(());
}
if matches!(key.code, crossterm::event::KeyCode::Char('+'))
&& key.modifiers == crossterm::event::KeyModifiers::NONE
{
self.log_pane.grow();
return Ok(());
}
if matches!(key.code, crossterm::event::KeyCode::Char('-'))
&& key.modifiers == crossterm::event::KeyModifiers::NONE
{
self.log_pane.shrink();
return Ok(());
}
if key.modifiers == crossterm::event::KeyModifiers::SHIFT {
match key.code {
crossterm::event::KeyCode::Up => {
self.log_pane.scroll_up(1);
return Ok(());
}
crossterm::event::KeyCode::Down => {
self.log_pane.scroll_down(1);
return Ok(());
}
crossterm::event::KeyCode::PageUp => {
self.log_pane.scroll_up(10);
return Ok(());
}
crossterm::event::KeyCode::PageDown => {
self.log_pane.scroll_down(10);
return Ok(());
}
crossterm::event::KeyCode::End => {
self.log_pane.resume_tail();
return Ok(());
}
crossterm::event::KeyCode::Left => {
self.log_pane.scroll_left(8);
return Ok(());
}
crossterm::event::KeyCode::Right => {
self.log_pane.scroll_right(8);
return Ok(());
}
_ => {}
}
}
if matches!(key.code, crossterm::event::KeyCode::Char('q'))
&& key.modifiers == crossterm::event::KeyModifiers::NONE
{
match resolve_quit_press(self.quit_pending, Instant::now(), QUIT_CONFIRM_WINDOW) {
QuitResolution::Confirm => {
self.quit_pending = None;
self.action_tx.send(Action::Quit)?;
}
QuitResolution::Pending => {
self.quit_pending = Some(Instant::now());
self.command_status = Some(CommandStatus::Info(
"press q again to quit (Esc cancels)".into(),
));
}
}
return Ok(());
}
if self.quit_pending.is_some() {
self.quit_pending = None;
}
let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
return Ok(());
};
match keymap.get(&vec![key]) {
Some(action) => {
info!("Got action: {action:?}");
action_tx.send(action.clone())?;
}
_ => {
self.last_tick_key_events.push(key);
if let Some(action) = keymap.get(&self.last_tick_key_events) {
info!("Got action: {action:?}");
action_tx.send(action.clone())?;
}
}
}
Ok(())
}
fn handle_command_mode_key(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
use crossterm::event::KeyCode;
let buf = match self.command_buffer.as_mut() {
Some(b) => b,
None => return Ok(()),
};
match key.code {
KeyCode::Esc => {
self.command_buffer = None;
self.command_suggestion_index = 0;
}
KeyCode::Enter => {
let line = std::mem::take(buf);
self.command_buffer = None;
self.command_suggestion_index = 0;
self.execute_command(&line)?;
}
KeyCode::Up => {
self.command_suggestion_index = self.command_suggestion_index.saturating_sub(1);
}
KeyCode::Down => {
let n = filter_command_suggestions(buf, KNOWN_COMMANDS).len();
if n > 0 && self.command_suggestion_index + 1 < n {
self.command_suggestion_index += 1;
}
}
KeyCode::Tab => {
let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
if let Some((name, _)) = matches.get(self.command_suggestion_index) {
let rest = buf
.split_once(char::is_whitespace)
.map(|(_, tail)| tail)
.unwrap_or("");
let new = if rest.is_empty() {
format!("{name} ")
} else {
format!("{name} {rest}")
};
buf.clear();
buf.push_str(&new);
self.command_suggestion_index = 0;
}
}
KeyCode::Backspace => {
buf.pop();
self.command_suggestion_index = 0;
}
KeyCode::Char(c) => {
buf.push(c);
self.command_suggestion_index = 0;
}
_ => {}
}
Ok(())
}
fn execute_command(&mut self, line: &str) -> color_eyre::Result<()> {
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(());
}
let head = trimmed.split_whitespace().next().unwrap_or("");
match head {
"q" | "quit" => {
self.action_tx.send(Action::Quit)?;
self.command_status = Some(CommandStatus::Info("quitting".into()));
}
"diagnose" | "diag" => {
self.command_status = Some(match self.export_diagnostic_bundle() {
Ok(path) => CommandStatus::Info(format!(
"diagnostic bundle exported to {}",
path.display()
)),
Err(e) => CommandStatus::Err(format!("diagnose failed: {e}")),
});
}
"pins-check" => {
self.command_status = Some(match self.start_pins_check() {
Ok(path) => CommandStatus::Info(format!(
"pins integrity check running → {} (tail to watch progress)",
path.display()
)),
Err(e) => CommandStatus::Err(format!("pins-check failed to start: {e}")),
});
}
"loggers" => {
self.command_status = Some(match self.start_loggers_dump() {
Ok(path) => CommandStatus::Info(format!(
"loggers snapshot writing → {} (open when ready)",
path.display()
)),
Err(e) => CommandStatus::Err(format!("loggers failed to start: {e}")),
});
}
"set-logger" => {
let mut parts = trimmed.split_whitespace();
let _ = parts.next(); let expr = parts.next().unwrap_or("");
let level = parts.next().unwrap_or("");
if expr.is_empty() || level.is_empty() {
self.command_status = Some(CommandStatus::Err(
"usage: :set-logger <expr> <level> (level: none|error|warning|info|debug|all; expr: e.g. node/pushsync or '.' for all)"
.into(),
));
return Ok(());
}
self.start_set_logger(expr.to_string(), level.to_string());
self.command_status = Some(CommandStatus::Info(format!(
"set-logger {expr:?} → {level:?} (PUT in-flight; check :loggers to verify)"
)));
}
"topup-preview" => {
self.command_status = Some(self.run_topup_preview(trimmed));
}
"dilute-preview" => {
self.command_status = Some(self.run_dilute_preview(trimmed));
}
"extend-preview" => {
self.command_status = Some(self.run_extend_preview(trimmed));
}
"buy-preview" => {
self.command_status = Some(self.run_buy_preview(trimmed));
}
"buy-suggest" => {
self.command_status = Some(self.run_buy_suggest(trimmed));
}
"probe-upload" => {
self.command_status = Some(self.run_probe_upload(trimmed));
}
"context" | "ctx" => {
let target = trimmed.split_whitespace().nth(1).unwrap_or("");
if target.is_empty() {
let known: Vec<String> =
self.config.nodes.iter().map(|n| n.name.clone()).collect();
self.command_status = Some(CommandStatus::Err(format!(
"usage: :context <name> (known: {})",
known.join(", ")
)));
return Ok(());
}
self.command_status = Some(match self.switch_context(target) {
Ok(()) => CommandStatus::Info(format!(
"switched to context {target} ({})",
self.api.url
)),
Err(e) => CommandStatus::Err(format!("context switch failed: {e}")),
});
}
screen
if SCREEN_NAMES
.iter()
.any(|name| name.eq_ignore_ascii_case(screen)) =>
{
if let Some(idx) = SCREEN_NAMES
.iter()
.position(|name| name.eq_ignore_ascii_case(screen))
{
self.current_screen = idx;
self.command_status =
Some(CommandStatus::Info(format!("→ {}", SCREEN_NAMES[idx])));
}
}
other => {
self.command_status = Some(CommandStatus::Err(format!(
"unknown command: {other:?} (try :health, :stamps, :swap, :lottery, :peers, :network, :warmup, :api, :tags, :pins, :diagnose, :pins-check, :loggers, :set-logger, :topup-preview, :dilute-preview, :extend-preview, :buy-preview, :buy-suggest, :probe-upload, :context, :quit)"
)));
}
}
Ok(())
}
fn run_topup_preview(&self, line: &str) -> CommandStatus {
let parts: Vec<&str> = line.split_whitespace().collect();
let (prefix, amount_str) = match parts.as_slice() {
[_, prefix, amount, ..] => (*prefix, *amount),
_ => {
return CommandStatus::Err(
"usage: :topup-preview <batch-prefix> <amount-plur-per-chunk>".into(),
);
}
};
let chain = match self.health_rx.borrow().chain_state.clone() {
Some(c) => c,
None => return CommandStatus::Err("chain state not loaded yet".into()),
};
let stamps = self.watch.stamps().borrow().clone();
let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
Ok(b) => b.clone(),
Err(e) => return CommandStatus::Err(e),
};
let amount = match stamp_preview::parse_plur_amount(amount_str) {
Ok(a) => a,
Err(e) => return CommandStatus::Err(e),
};
match stamp_preview::topup_preview(&batch, amount, &chain) {
Ok(p) => CommandStatus::Info(p.summary()),
Err(e) => CommandStatus::Err(e),
}
}
fn run_dilute_preview(&self, line: &str) -> CommandStatus {
let parts: Vec<&str> = line.split_whitespace().collect();
let (prefix, depth_str) = match parts.as_slice() {
[_, prefix, depth, ..] => (*prefix, *depth),
_ => {
return CommandStatus::Err(
"usage: :dilute-preview <batch-prefix> <new-depth>".into(),
);
}
};
let new_depth: u8 = match depth_str.parse() {
Ok(d) => d,
Err(_) => {
return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
}
};
let stamps = self.watch.stamps().borrow().clone();
let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
Ok(b) => b.clone(),
Err(e) => return CommandStatus::Err(e),
};
match stamp_preview::dilute_preview(&batch, new_depth) {
Ok(p) => CommandStatus::Info(p.summary()),
Err(e) => CommandStatus::Err(e),
}
}
fn run_extend_preview(&self, line: &str) -> CommandStatus {
let parts: Vec<&str> = line.split_whitespace().collect();
let (prefix, duration_str) = match parts.as_slice() {
[_, prefix, duration, ..] => (*prefix, *duration),
_ => {
return CommandStatus::Err(
"usage: :extend-preview <batch-prefix> <duration> (e.g. 30d, 12h, 90m, 45s, or plain seconds)".into(),
);
}
};
let extension_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
Ok(s) => s,
Err(e) => return CommandStatus::Err(e),
};
let chain = match self.health_rx.borrow().chain_state.clone() {
Some(c) => c,
None => return CommandStatus::Err("chain state not loaded yet".into()),
};
let stamps = self.watch.stamps().borrow().clone();
let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
Ok(b) => b.clone(),
Err(e) => return CommandStatus::Err(e),
};
match stamp_preview::extend_preview(&batch, extension_seconds, &chain) {
Ok(p) => CommandStatus::Info(p.summary()),
Err(e) => CommandStatus::Err(e),
}
}
fn run_probe_upload(&self, line: &str) -> CommandStatus {
let parts: Vec<&str> = line.split_whitespace().collect();
let prefix = match parts.as_slice() {
[_, prefix, ..] => *prefix,
_ => {
return CommandStatus::Err(
"usage: :probe-upload <batch-prefix> (uploads one synthetic 4 KiB chunk)"
.into(),
);
}
};
let stamps = self.watch.stamps().borrow().clone();
let batch = match stamp_preview::match_batch_prefix(&stamps.batches, prefix) {
Ok(b) => b.clone(),
Err(e) => return CommandStatus::Err(e),
};
if !batch.usable {
return CommandStatus::Err(format!(
"batch {} is not usable yet (waiting on chain confirmation) — pick another",
short_hex(&batch.batch_id.to_hex(), 8),
));
}
if batch.batch_ttl <= 0 {
return CommandStatus::Err(format!(
"batch {} is expired — pick another",
short_hex(&batch.batch_id.to_hex(), 8),
));
}
let api = self.api.clone();
let tx = self.cmd_status_tx.clone();
let batch_id = batch.batch_id;
let batch_short = short_hex(&batch.batch_id.to_hex(), 8);
let task_short = batch_short.clone();
tokio::spawn(async move {
let chunk = build_synthetic_probe_chunk();
let started = Instant::now();
let result = api.bee().file().upload_chunk(&batch_id, chunk, None).await;
let elapsed_ms = started.elapsed().as_millis();
let status = match result {
Ok(res) => CommandStatus::Info(format!(
"probe-upload OK in {elapsed_ms}ms — batch {task_short}, ref {}",
short_hex(&res.reference.to_hex(), 8),
)),
Err(e) => CommandStatus::Err(format!(
"probe-upload FAILED after {elapsed_ms}ms — batch {task_short}: {e}"
)),
};
let _ = tx.send(status);
});
CommandStatus::Info(format!(
"probe-upload to batch {batch_short} in flight — result will replace this line"
))
}
fn run_buy_suggest(&self, line: &str) -> CommandStatus {
let parts: Vec<&str> = line.split_whitespace().collect();
let (size_str, duration_str) = match parts.as_slice() {
[_, size, duration, ..] => (*size, *duration),
_ => {
return CommandStatus::Err(
"usage: :buy-suggest <size> <duration> (e.g. 5GiB 30d, 100MiB 12h)".into(),
);
}
};
let target_bytes = match stamp_preview::parse_size_bytes(size_str) {
Ok(b) => b,
Err(e) => return CommandStatus::Err(e),
};
let target_seconds = match stamp_preview::parse_duration_seconds(duration_str) {
Ok(s) => s,
Err(e) => return CommandStatus::Err(e),
};
let chain = match self.health_rx.borrow().chain_state.clone() {
Some(c) => c,
None => return CommandStatus::Err("chain state not loaded yet".into()),
};
match stamp_preview::buy_suggest(target_bytes, target_seconds, &chain) {
Ok(s) => CommandStatus::Info(s.summary()),
Err(e) => CommandStatus::Err(e),
}
}
fn run_buy_preview(&self, line: &str) -> CommandStatus {
let parts: Vec<&str> = line.split_whitespace().collect();
let (depth_str, amount_str) = match parts.as_slice() {
[_, depth, amount, ..] => (*depth, *amount),
_ => {
return CommandStatus::Err(
"usage: :buy-preview <depth> <amount-plur-per-chunk>".into(),
);
}
};
let depth: u8 = match depth_str.parse() {
Ok(d) => d,
Err(_) => {
return CommandStatus::Err(format!("invalid depth {depth_str:?} (expected u8)"));
}
};
let amount = match stamp_preview::parse_plur_amount(amount_str) {
Ok(a) => a,
Err(e) => return CommandStatus::Err(e),
};
let chain = match self.health_rx.borrow().chain_state.clone() {
Some(c) => c,
None => return CommandStatus::Err("chain state not loaded yet".into()),
};
match stamp_preview::buy_preview(depth, amount, &chain) {
Ok(p) => CommandStatus::Info(p.summary()),
Err(e) => CommandStatus::Err(e),
}
}
fn switch_context(&mut self, target: &str) -> color_eyre::Result<()> {
let node = self
.config
.nodes
.iter()
.find(|n| n.name == target)
.ok_or_else(|| eyre!("no node configured with name {target:?}"))?
.clone();
let new_api = Arc::new(ApiClient::from_node(&node)?);
self.watch.shutdown();
let refresh = RefreshProfile::from_config(&self.config.ui.refresh);
let new_watch = BeeWatch::start_with_profile(new_api.clone(), &self.root_cancel, refresh);
let new_health_rx = new_watch.health();
let new_screens = build_screens(&new_api, &new_watch);
self.api = new_api;
self.watch = new_watch;
self.health_rx = new_health_rx;
self.screens = new_screens;
Ok(())
}
fn start_pins_check(&self) -> std::io::Result<PathBuf> {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!(
"bee-tui-pins-check-{}-{secs}.txt",
sanitize_for_filename(&self.api.name),
));
std::fs::write(
&path,
format!(
"# bee-tui :pins-check\n# profile {}\n# endpoint {}\n# started {}\n",
self.api.name,
self.api.url,
format_utc_now(),
),
)?;
let api = self.api.clone();
let dest = path.clone();
tokio::spawn(async move {
let bee = api.bee();
match bee.api().check_pins(None).await {
Ok(entries) => {
let mut body = String::new();
for e in &entries {
body.push_str(&format!(
"{} total={} missing={} invalid={} {}\n",
e.reference.to_hex(),
e.total,
e.missing,
e.invalid,
if e.is_healthy() {
"healthy"
} else {
"UNHEALTHY"
},
));
}
body.push_str(&format!("# done. {} pins checked.\n", entries.len()));
if let Err(e) = append(&dest, &body) {
let _ = append(&dest, &format!("# write error: {e}\n"));
}
}
Err(e) => {
let _ = append(&dest, &format!("# error: {e}\n"));
}
}
});
Ok(path)
}
fn start_set_logger(&self, expression: String, level: String) {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let dest = std::env::temp_dir().join(format!(
"bee-tui-set-logger-{}-{secs}.txt",
sanitize_for_filename(&self.api.name),
));
let _ = std::fs::write(
&dest,
format!(
"# bee-tui :set-logger\n# profile {}\n# endpoint {}\n# expr {expression}\n# level {level}\n# started {}\n",
self.api.name,
self.api.url,
format_utc_now(),
),
);
let api = self.api.clone();
tokio::spawn(async move {
let bee = api.bee();
match bee.debug().set_logger(&expression, &level).await {
Ok(()) => {
let _ = append(
&dest,
&format!("# done. {expression} → {level} accepted by Bee.\n"),
);
}
Err(e) => {
let _ = append(&dest, &format!("# error: {e}\n"));
}
}
});
}
fn start_loggers_dump(&self) -> std::io::Result<PathBuf> {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!(
"bee-tui-loggers-{}-{secs}.txt",
sanitize_for_filename(&self.api.name),
));
std::fs::write(
&path,
format!(
"# bee-tui :loggers\n# profile {}\n# endpoint {}\n# started {}\n",
self.api.name,
self.api.url,
format_utc_now(),
),
)?;
let api = self.api.clone();
let dest = path.clone();
tokio::spawn(async move {
let bee = api.bee();
match bee.debug().loggers().await {
Ok(listing) => {
let mut rows = listing.loggers.clone();
rows.sort_by(|a, b| {
verbosity_rank(&b.verbosity)
.cmp(&verbosity_rank(&a.verbosity))
.then_with(|| a.logger.cmp(&b.logger))
});
let mut body = String::new();
body.push_str(&format!("# {} loggers registered\n", rows.len()));
body.push_str("# VERBOSITY LOGGER\n");
for r in &rows {
body.push_str(&format!(" {:<9} {}\n", r.verbosity, r.logger,));
}
body.push_str("# done.\n");
if let Err(e) = append(&dest, &body) {
let _ = append(&dest, &format!("# write error: {e}\n"));
}
}
Err(e) => {
let _ = append(&dest, &format!("# error: {e}\n"));
}
}
});
Ok(path)
}
fn export_diagnostic_bundle(&self) -> std::io::Result<PathBuf> {
let bundle = self.render_diagnostic_bundle();
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!("bee-tui-diagnostic-{secs}.txt"));
std::fs::write(&path, bundle)?;
Ok(path)
}
fn render_diagnostic_bundle(&self) -> String {
let now = format_utc_now();
let health = self.health_rx.borrow().clone();
let topology = self.watch.topology().borrow().clone();
let gates = Health::gates_for(&health, Some(&topology));
let recent: Vec<_> = log_capture::handle()
.map(|c| {
let mut snap = c.snapshot();
let len = snap.len();
if len > 50 {
snap.drain(0..len - 50);
}
snap
})
.unwrap_or_default();
let mut out = String::new();
out.push_str("# bee-tui diagnostic bundle\n");
out.push_str(&format!("# generated UTC {now}\n\n"));
out.push_str("## profile\n");
out.push_str(&format!(" name {}\n", self.api.name));
out.push_str(&format!(" endpoint {}\n\n", self.api.url));
out.push_str("## health gates\n");
for g in &gates {
out.push_str(&format_gate_line(g));
}
out.push_str("\n## last API calls (path only — Bearer tokens, if any, live in headers and aren't captured)\n");
for e in &recent {
let status = e
.status
.map(|s| s.to_string())
.unwrap_or_else(|| "—".into());
let elapsed = e
.elapsed_ms
.map(|ms| format!("{ms}ms"))
.unwrap_or_else(|| "—".into());
out.push_str(&format!(
" {ts} {method:<5} {path:<32} {status:>4} {elapsed:>7}\n",
ts = e.ts,
method = e.method,
path = path_only(&e.url),
status = status,
elapsed = elapsed,
));
}
out.push_str(&format!(
"\n## generated by bee-tui {}\n",
env!("CARGO_PKG_VERSION"),
));
out
}
fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
while let Ok(action) = self.action_rx.try_recv() {
if action != Action::Tick && action != Action::Render {
debug!("{action:?}");
}
match action {
Action::Tick => {
self.last_tick_key_events.drain(..);
theme::advance_spinner();
if let Some(sup) = self.supervisor.as_mut() {
self.bee_status = sup.status();
}
if let Some(rx) = self.bee_log_rx.as_mut() {
while let Ok((tab, line)) = rx.try_recv() {
self.log_pane.push_bee(tab, line);
}
}
while let Ok(status) = self.cmd_status_rx.try_recv() {
self.command_status = Some(status);
}
}
Action::Quit => self.should_quit = true,
Action::Suspend => self.should_suspend = true,
Action::Resume => self.should_suspend = false,
Action::ClearScreen => tui.terminal.clear()?,
Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
Action::Render => self.render(tui)?,
_ => {}
}
let tx = self.action_tx.clone();
for component in self.iter_components_mut() {
if let Some(action) = component.update(action.clone())? {
tx.send(action)?
};
}
}
Ok(())
}
fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
tui.resize(Rect::new(0, 0, w, h))?;
self.render(tui)?;
Ok(())
}
fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
let active = self.current_screen;
let tx = self.action_tx.clone();
let screens = &mut self.screens;
let log_pane = &mut self.log_pane;
let log_pane_height = log_pane.height();
let command_buffer = self.command_buffer.clone();
let command_suggestion_index = self.command_suggestion_index;
let command_status = self.command_status.clone();
let help_visible = self.help_visible;
let profile = self.api.name.clone();
let endpoint = self.api.url.clone();
let last_ping = self.health_rx.borrow().last_ping;
let now_utc = format_utc_now();
let bee_status_label = if self.supervisor.is_some() && !self.bee_status.is_running() {
Some(self.bee_status.label())
} else {
None
};
tui.draw(|frame| {
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
let chunks = Layout::vertical([
Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), Constraint::Length(log_pane_height), ])
.split(frame.area());
let top_chunks =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(chunks[0]);
let ping_str = match last_ping {
Some(d) => format!("{}ms", d.as_millis()),
None => "—".into(),
};
let t = theme::active();
let mut metadata_spans = vec![
Span::styled(
" bee-tui ",
Style::default()
.fg(Color::Black)
.bg(t.info)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
profile,
Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" @ {endpoint}"), Style::default().fg(t.dim)),
Span::raw(" "),
Span::styled("ping ", Style::default().fg(t.dim)),
Span::styled(ping_str, Style::default().fg(t.info)),
Span::raw(" "),
Span::styled(format!("UTC {now_utc}"), Style::default().fg(t.dim)),
];
if let Some(label) = bee_status_label.as_ref() {
metadata_spans.push(Span::raw(" "));
metadata_spans.push(Span::styled(
format!(" {label} "),
Style::default()
.fg(Color::Black)
.bg(t.fail)
.add_modifier(Modifier::BOLD),
));
}
let metadata_line = Line::from(metadata_spans);
frame.render_widget(Paragraph::new(metadata_line), top_chunks[0]);
let theme = *theme::active();
let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
for (i, name) in SCREEN_NAMES.iter().enumerate() {
let style = if i == active {
Style::default()
.fg(theme.tab_active_fg)
.bg(theme.tab_active_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.dim)
};
tabs.push(Span::styled(format!(" {name} "), style));
tabs.push(Span::raw(" "));
}
tabs.push(Span::styled(
":cmd · Tab to cycle · ? help",
Style::default().fg(theme.dim),
));
frame.render_widget(Paragraph::new(Line::from(tabs)), top_chunks[1]);
if let Some(screen) = screens.get_mut(active) {
if let Err(err) = screen.draw(frame, chunks[1]) {
let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
}
}
let prompt = if let Some(buf) = &command_buffer {
Line::from(vec![
Span::styled(
":",
Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
),
Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
Span::styled("█", Style::default().fg(t.accent)),
])
} else {
match &command_status {
Some(CommandStatus::Info(msg)) => {
Line::from(Span::styled(msg.clone(), Style::default().fg(t.pass)))
}
Some(CommandStatus::Err(msg)) => {
Line::from(Span::styled(msg.clone(), Style::default().fg(t.fail)))
}
None => Line::from(""),
}
};
frame.render_widget(Paragraph::new(prompt), chunks[2]);
if let Some(buf) = &command_buffer {
let matches = filter_command_suggestions(buf, KNOWN_COMMANDS);
if !matches.is_empty() {
draw_command_suggestions(
frame,
chunks[2],
&matches,
command_suggestion_index,
&theme,
);
}
}
if let Err(err) = log_pane.draw(frame, chunks[3]) {
let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
}
if help_visible {
draw_help_overlay(frame, frame.area(), active, &theme);
}
})?;
Ok(())
}
}
fn draw_command_suggestions(
frame: &mut ratatui::Frame,
bar_rect: ratatui::layout::Rect,
matches: &[&(&str, &str)],
selected: usize,
theme: &theme::Theme,
) {
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
const MAX_VISIBLE: usize = 10;
let visible_rows = matches.len().min(MAX_VISIBLE);
if visible_rows == 0 {
return;
}
let height = (visible_rows as u16) + 2; let widest = matches
.iter()
.map(|(name, desc)| name.len() + desc.len() + 6)
.max()
.unwrap_or(40)
.min(bar_rect.width as usize);
let width = (widest as u16 + 2).min(bar_rect.width);
let bottom = bar_rect.y;
let y = bottom.saturating_sub(height);
let popup = Rect {
x: bar_rect.x,
y,
width,
height: bottom - y,
};
let scroll_start = if selected >= visible_rows {
selected + 1 - visible_rows
} else {
0
};
let visible_slice = &matches[scroll_start..(scroll_start + visible_rows).min(matches.len())];
let mut lines: Vec<Line> = Vec::with_capacity(visible_slice.len());
for (i, (name, desc)) in visible_slice.iter().enumerate() {
let absolute_idx = scroll_start + i;
let is_selected = absolute_idx == selected;
let row_style = if is_selected {
Style::default()
.fg(theme.tab_active_fg)
.bg(theme.tab_active_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let cursor = if is_selected { "▸ " } else { " " };
lines.push(Line::from(vec![
Span::styled(format!("{cursor}:{name:<16} "), row_style),
Span::styled(
desc.to_string(),
if is_selected {
row_style
} else {
Style::default().fg(theme.dim)
},
),
]));
}
let title = if matches.len() > MAX_VISIBLE {
format!(" :commands ({}/{}) ", selected + 1, matches.len())
} else {
" :commands ".to_string()
};
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent))
.title(title),
),
popup,
);
}
fn draw_help_overlay(
frame: &mut ratatui::Frame,
area: ratatui::layout::Rect,
active_screen: usize,
theme: &theme::Theme,
) {
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
let screen_name = SCREEN_NAMES.get(active_screen).copied().unwrap_or("?");
let screen_rows = screen_keymap(active_screen);
let global_rows: &[(&str, &str)] = &[
("Tab", "next screen"),
("Shift+Tab", "previous screen"),
("[ / ]", "previous / next log-pane tab"),
("+ / -", "grow / shrink log pane"),
("Shift+↑/↓", "scroll log pane (1 line); pauses auto-tail"),
("Shift+PgUp/PgDn", "scroll log pane (10 lines)"),
("Shift+←/→", "pan log pane horizontally (8 cols)"),
("Shift+End", "resume auto-tail + reset horizontal pan"),
("?", "toggle this help"),
(":", "open command bar"),
("qq", "quit (double-tap; or :q)"),
("Ctrl+C / Ctrl+D", "quit immediately"),
];
let w = area.width.min(72);
let h = area.height.min(22);
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let rect = Rect {
x,
y,
width: w,
height: h,
};
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![
Span::styled(
format!(" {screen_name} "),
Style::default()
.fg(theme.tab_active_fg)
.bg(theme.tab_active_bg)
.add_modifier(Modifier::BOLD),
),
Span::raw(" screen-specific keys"),
]));
lines.push(Line::from(""));
if screen_rows.is_empty() {
lines.push(Line::from(Span::styled(
" (no extra keys for this screen — use the command bar via :)",
Style::default()
.fg(theme.dim)
.add_modifier(Modifier::ITALIC),
)));
} else {
for (key, desc) in screen_rows {
lines.push(format_help_row(key, desc, theme));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" global",
Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
)));
for (key, desc) in global_rows {
lines.push(format_help_row(key, desc, theme));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Esc / ? / q to dismiss",
Style::default()
.fg(theme.dim)
.add_modifier(Modifier::ITALIC),
)));
frame.render_widget(Clear, rect);
frame.render_widget(
Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent))
.title(" help "),
),
rect,
);
}
fn format_help_row<'a>(
key: &'a str,
desc: &'a str,
theme: &theme::Theme,
) -> ratatui::text::Line<'a> {
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{key:<16}"),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw(desc),
])
}
fn screen_keymap(active_screen: usize) -> &'static [(&'static str, &'static str)] {
match active_screen {
1 => &[
("↑↓ / j k", "move row selection"),
("Enter", "drill batch — bucket histogram + worst-N"),
("Esc", "close drill"),
],
3 => &[("r", "run on-demand rchash benchmark")],
4 => &[
("↑↓ / j k", "move peer selection"),
(
"Enter",
"drill peer — balance / cheques / settlement / ping",
),
("Esc", "close drill"),
],
8 => &[
("↑↓ / j k", "scroll one row"),
("PgUp / PgDn", "scroll ten rows"),
("Home", "back to top"),
],
9 => &[
("↑↓ / j k", "move row selection"),
("Enter", "integrity-check the highlighted pin"),
("c", "integrity-check every unchecked pin"),
("s", "cycle sort: ref order / bad first / by size"),
],
_ => &[],
}
}
fn build_screens(api: &Arc<ApiClient>, watch: &BeeWatch) -> Vec<Box<dyn Component>> {
let health = Health::new(api.clone(), watch.health(), watch.topology());
let stamps = Stamps::new(api.clone(), watch.stamps());
let swap = Swap::new(watch.swap());
let lottery = Lottery::new(api.clone(), watch.health(), watch.lottery());
let peers = Peers::new(api.clone(), watch.topology());
let network = Network::new(watch.network(), watch.topology());
let warmup = Warmup::new(watch.health(), watch.stamps(), watch.topology());
let api_health = ApiHealth::new(
api.clone(),
watch.health(),
watch.transactions(),
log_capture::handle(),
);
let tags = Tags::new(watch.tags());
let pins = Pins::new(api.clone(), watch.pins());
vec![
Box::new(health),
Box::new(stamps),
Box::new(swap),
Box::new(lottery),
Box::new(peers),
Box::new(network),
Box::new(warmup),
Box::new(api_health),
Box::new(tags),
Box::new(pins),
]
}
fn build_synthetic_probe_chunk() -> Vec<u8> {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut data = Vec::with_capacity(8 + 4096);
data.extend_from_slice(&4096u64.to_le_bytes());
data.extend_from_slice(&nanos.to_le_bytes());
data.resize(8 + 4096, 0);
data
}
fn short_hex(hex: &str, len: usize) -> String {
if hex.len() > len {
format!("{}…", &hex[..len])
} else {
hex.to_string()
}
}
fn build_metrics_render_fn(
watch: BeeWatch,
log_capture: Option<log_capture::LogCapture>,
) -> crate::metrics_server::RenderFn {
use std::time::{SystemTime, UNIX_EPOCH};
Arc::new(move || {
let health = watch.health().borrow().clone();
let stamps = watch.stamps().borrow().clone();
let swap = watch.swap().borrow().clone();
let lottery = watch.lottery().borrow().clone();
let topology = watch.topology().borrow().clone();
let network = watch.network().borrow().clone();
let transactions = watch.transactions().borrow().clone();
let recent = log_capture
.as_ref()
.map(|c| c.snapshot())
.unwrap_or_default();
let call_stats = crate::components::api_health::call_stats_for(&recent);
let now_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let inputs = crate::metrics::MetricsInputs {
bee_tui_version: env!("CARGO_PKG_VERSION"),
health: &health,
stamps: &stamps,
swap: &swap,
lottery: &lottery,
topology: &topology,
network: &network,
transactions: &transactions,
call_stats: &call_stats,
now_unix,
};
crate::metrics::render(&inputs)
})
}
fn format_gate_line(g: &Gate) -> String {
let glyphs = crate::theme::active().glyphs;
let glyph = match g.status {
GateStatus::Pass => glyphs.pass,
GateStatus::Warn => glyphs.warn,
GateStatus::Fail => glyphs.fail,
GateStatus::Unknown => glyphs.bullet,
};
let mut s = format!(
" [{glyph}] {label:<28} {value}\n",
label = g.label,
value = g.value
);
if let Some(why) = &g.why {
s.push_str(&format!(" {} {why}\n", glyphs.continuation));
}
s
}
fn path_only(url: &str) -> String {
if let Some(idx) = url.find("//") {
let after_scheme = &url[idx + 2..];
if let Some(slash) = after_scheme.find('/') {
return after_scheme[slash..].to_string();
}
return "/".into();
}
url.to_string()
}
fn append(path: &PathBuf, s: &str) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
f.write_all(s.as_bytes())
}
fn verbosity_rank(s: &str) -> u8 {
match s {
"all" | "trace" => 5,
"debug" => 4,
"info" | "1" => 3,
"warning" | "warn" | "2" => 2,
"error" | "3" => 1,
_ => 0,
}
}
fn sanitize_for_filename(s: &str) -> String {
s.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
_ => '-',
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuitResolution {
Confirm,
Pending,
}
fn resolve_quit_press(prev: Option<Instant>, now: Instant, window: Duration) -> QuitResolution {
match prev {
Some(t) if now.duration_since(t) <= window => QuitResolution::Confirm,
_ => QuitResolution::Pending,
}
}
fn format_utc_now() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let secs_in_day = secs % 86_400;
let h = secs_in_day / 3_600;
let m = (secs_in_day % 3_600) / 60;
let s = secs_in_day % 60;
format!("{h:02}:{m:02}:{s:02}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_utc_now_returns_eight_chars() {
let s = format_utc_now();
assert_eq!(s.len(), 8);
assert_eq!(s.chars().nth(2), Some(':'));
assert_eq!(s.chars().nth(5), Some(':'));
}
#[test]
fn path_only_strips_scheme_and_host() {
assert_eq!(path_only("http://10.0.1.5:1633/status"), "/status");
assert_eq!(
path_only("https://bee.example.com/stamps?limit=10"),
"/stamps?limit=10"
);
}
#[test]
fn path_only_handles_no_path() {
assert_eq!(path_only("http://localhost:1633"), "/");
}
#[test]
fn path_only_passes_relative_through() {
assert_eq!(path_only("/already/relative"), "/already/relative");
}
#[test]
fn sanitize_for_filename_keeps_safe_chars() {
assert_eq!(sanitize_for_filename("prod-1"), "prod-1");
assert_eq!(sanitize_for_filename("lab_node"), "lab_node");
}
#[test]
fn sanitize_for_filename_replaces_unsafe_chars() {
assert_eq!(sanitize_for_filename("a/b\\c d"), "a-b-c-d");
assert_eq!(sanitize_for_filename("name:colon"), "name-colon");
}
#[test]
fn resolve_quit_press_first_press_is_pending() {
let now = Instant::now();
assert_eq!(
resolve_quit_press(None, now, Duration::from_millis(1500)),
QuitResolution::Pending
);
}
#[test]
fn resolve_quit_press_second_press_inside_window_confirms() {
let first = Instant::now();
let window = Duration::from_millis(1500);
let second = first + Duration::from_millis(500);
assert_eq!(
resolve_quit_press(Some(first), second, window),
QuitResolution::Confirm
);
}
#[test]
fn resolve_quit_press_second_press_after_window_resets_to_pending() {
let first = Instant::now();
let window = Duration::from_millis(1500);
let second = first + Duration::from_millis(2_000);
assert_eq!(
resolve_quit_press(Some(first), second, window),
QuitResolution::Pending
);
}
#[test]
fn resolve_quit_press_at_window_boundary_confirms() {
let first = Instant::now();
let window = Duration::from_millis(1500);
let second = first + window;
assert_eq!(
resolve_quit_press(Some(first), second, window),
QuitResolution::Confirm
);
}
#[test]
fn screen_keymap_covers_drill_screens() {
for idx in [1usize, 4] {
let rows = screen_keymap(idx);
assert!(
rows.iter().any(|(k, _)| k.contains("Enter")),
"screen {idx} keymap must mention Enter (drill)"
);
assert!(
rows.iter().any(|(k, _)| k.contains("Esc")),
"screen {idx} keymap must mention Esc (close drill)"
);
}
}
#[test]
fn screen_keymap_lottery_advertises_rchash() {
let rows = screen_keymap(3);
assert!(rows.iter().any(|(k, _)| k.contains("r")));
}
#[test]
fn screen_keymap_unknown_index_is_empty_not_panic() {
assert!(screen_keymap(999).is_empty());
}
#[test]
fn verbosity_rank_orders_loud_to_silent() {
assert!(verbosity_rank("all") > verbosity_rank("debug"));
assert!(verbosity_rank("debug") > verbosity_rank("info"));
assert!(verbosity_rank("info") > verbosity_rank("warning"));
assert!(verbosity_rank("warning") > verbosity_rank("error"));
assert!(verbosity_rank("error") > verbosity_rank("unknown"));
assert_eq!(verbosity_rank("info"), verbosity_rank("1"));
assert_eq!(verbosity_rank("warning"), verbosity_rank("2"));
}
#[test]
fn filter_command_suggestions_empty_buffer_returns_all() {
let matches = filter_command_suggestions("", KNOWN_COMMANDS);
assert_eq!(matches.len(), KNOWN_COMMANDS.len());
}
#[test]
fn filter_command_suggestions_prefix_matches_case_insensitive() {
let matches = filter_command_suggestions("Bu", KNOWN_COMMANDS);
let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
assert!(names.contains(&"buy-preview"));
assert!(names.contains(&"buy-suggest"));
assert_eq!(names.len(), 2);
}
#[test]
fn filter_command_suggestions_unknown_prefix_is_empty() {
let matches = filter_command_suggestions("xyz", KNOWN_COMMANDS);
assert!(matches.is_empty());
}
#[test]
fn filter_command_suggestions_uses_first_token_only() {
let matches = filter_command_suggestions("topup-preview a1b2 1000", KNOWN_COMMANDS);
let names: Vec<&str> = matches.iter().map(|(n, _)| *n).collect();
assert_eq!(names, vec!["topup-preview"]);
}
#[test]
fn probe_chunk_is_4104_bytes_with_correct_span() {
let chunk = build_synthetic_probe_chunk();
assert_eq!(chunk.len(), 4104);
let span = u64::from_le_bytes(chunk[..8].try_into().unwrap());
assert_eq!(span, 4096);
}
#[test]
fn probe_chunk_payloads_are_unique_per_call() {
let a = build_synthetic_probe_chunk();
std::thread::sleep(Duration::from_micros(1));
let b = build_synthetic_probe_chunk();
assert_ne!(&a[8..24], &b[8..24]);
}
#[test]
fn short_hex_truncates_with_ellipsis() {
assert_eq!(short_hex("a1b2c3d4e5f6", 8), "a1b2c3d4…");
assert_eq!(short_hex("short", 8), "short");
assert_eq!(short_hex("abcdefgh", 8), "abcdefgh");
}
}