mod charts;
mod dashboard;
mod export;
mod help;
mod history;
mod log_style;
mod state;
mod traceroute;
pub use state::UiState;
use crate::cli::{build_config, Cli};
use crate::engine::{EngineControl, TestEngine};
use crate::model::{Phase, RunResult, TestEvent};
use anyhow::{Context, Result};
use crossterm::{
event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::{future, StreamExt};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::Color,
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Tabs},
Terminal,
};
use std::{io, time::Duration, time::Instant};
use tokio::sync::mpsc;
use charts::draw_charts;
use dashboard::draw_dashboard;
use export::{copy_to_clipboard, enrich_result_with_network_info, export_result_csv, export_result_json, save_and_show_path};
use help::draw_help;
use history::{
draw_history_comment_modal, draw_history_detail, draw_history_export_modal, draw_history_menu,
show_history,
};
use state::update_available_networks;
pub async fn run(args: Cli) -> Result<()> {
enable_raw_mode().context("enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).ok();
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).context("create terminal")?;
terminal.clear().ok();
let initial_load = terminal
.size()
.map(|size| ((size.height as usize).saturating_sub(2) * 3).max(20))
.unwrap_or(66);
let mut state = UiState {
phase: Phase::IdleLatency,
auto_save: args.auto_save,
comments: args.comments.clone(),
..Default::default()
};
state.initial_history_load_size = initial_load;
state.history = crate::storage::load_recent(initial_load).unwrap_or_default();
state.history_loaded_count = state.history.len();
update_available_networks(&mut state);
let network_info = crate::network::gather_network_info(&args);
state.interface_name = network_info.interface_name.clone();
state.network_name = network_info.network_name.clone();
state.is_wireless = network_info.is_wireless;
state.interface_mac = network_info.interface_mac.clone();
state.local_ipv4 = network_info.local_ipv4.clone();
state.local_ipv6 = network_info.local_ipv6.clone();
state.certificate_filename = args
.certificate
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|s| s.to_string());
state.proxy_url = args.proxy.clone();
state.traceroute_enabled = args.traceroute;
state.traceroute_max_hops = args.traceroute_max_hops;
let (update_tx, mut update_rx) = tokio::sync::mpsc::channel::<Option<String>>(1);
tokio::spawn(async move {
if let Some(status) = crate::update::check_for_update().await {
let _ = update_tx.send(status).await;
}
});
let mut events = EventStream::new();
let mut tick = tokio::time::interval(Duration::from_millis(100));
let mut run_ctx = if args.test_on_launch {
Some(start_run(&args).await?)
} else {
None
};
let res = loop {
tokio::select! {
_ = tick.tick() => {
terminal.draw(|f| draw(f.area(), f, &mut state)).ok();
}
Some(status) = update_rx.recv() => {
state.update_status = Some(status);
}
maybe_ev = events.next() => {
let Some(Ok(ev)) = maybe_ev else { continue };
if let Event::Key(k) = ev {
if k.kind != KeyEventKind::Press {
continue;
}
if state.tab == 1 && state.history_filter_editing {
match k.code {
KeyCode::Esc => {
state.history_filter_editing = false;
state.history_filter.clear();
state.history_selected = 0;
state.history_scroll_offset = 0;
}
KeyCode::Enter => {
state.history_filter_editing = false;
state.history_selected = 0;
state.history_scroll_offset = 0;
}
KeyCode::Backspace => {
state.history_filter.pop();
}
KeyCode::Char(c) => {
state.history_filter.push(c);
}
_ => {}
}
continue;
}
if state.tab == 1 && state.history_comment_modal_open {
handle_history_comment_modal_key(&mut state, k);
continue;
}
if state.tab == 1 && state.history_export_modal_open {
handle_history_export_modal_key(&mut state, k.code);
continue;
}
if state.tab == 1 && state.history_menu_open {
handle_history_menu_key(&mut state, k.code);
continue;
}
if state.tab == 1 && state.history_detail_view {
handle_history_detail_key(&mut state, k);
continue;
}
match (k.modifiers, k.code) {
(_, KeyCode::Char('q')) | (KeyModifiers::CONTROL, KeyCode::Char('c')) => {
if let Some(ref ctx) = run_ctx {
ctx.ctrl_tx.send(EngineControl::Cancel).await.ok();
}
break Ok(());
}
(_, KeyCode::Char('p')) => {
if let Some(ref ctx) = run_ctx {
state.paused = !state.paused;
ctx.ctrl_tx.send(EngineControl::Pause(state.paused)).await.ok();
}
}
(_, KeyCode::Char('r')) => {
if state.tab == 1 {
let reload_size = state.initial_history_load_size.max(state.history_loaded_count);
match crate::storage::load_recent(reload_size) {
Ok(new_history) => {
let old_count = state.history.len();
state.history = new_history;
state.history_loaded_count = state.history.len();
update_available_networks(&mut state);
if state.history_selected >= state.history.len() && !state.history.is_empty() {
state.history_selected = state.history.len() - 1;
} else if state.history.is_empty() {
state.history_selected = 0;
state.history_scroll_offset = 0;
}
if state.history_scroll_offset >= state.history.len() && !state.history.is_empty() {
state.history_scroll_offset = state.history.len().saturating_sub(20).max(0);
}
let new_count = state.history.len();
if new_count > old_count {
state.info = format!("Refreshed: {} new run(s)", new_count - old_count);
} else if new_count < old_count {
state.info = format!("Refreshed: {} run(s) removed", old_count - new_count);
} else {
state.info = "Refreshed".into();
}
}
Err(e) => {
state.info = format!("Refresh failed: {e:#}");
}
}
} else {
state.info = "Restarting…".into();
if let Some(ref mut ctx) = run_ctx {
ctx.ctrl_tx.send(EngineControl::Cancel).await.ok();
if let Some(h) = ctx.handle.take() {
let _ = h.await;
}
}
state.last_result = None;
state.run_start = Instant::now();
state.dl_series.clear();
state.ul_series.clear();
state.idle_lat_series.clear();
state.loaded_dl_lat_series.clear();
state.loaded_ul_lat_series.clear();
state.dl_points.clear();
state.ul_points.clear();
state.idle_lat_points.clear();
state.loaded_dl_lat_points.clear();
state.loaded_ul_lat_points.clear();
state.dl_mbps = 0.0;
state.ul_mbps = 0.0;
state.dl_avg_mbps = 0.0;
state.ul_avg_mbps = 0.0;
state.dl_bytes_total = 0;
state.ul_bytes_total = 0;
state.dl_phase_start = None;
state.ul_phase_start = None;
state.idle_latency_samples.clear();
state.loaded_dl_latency_samples.clear();
state.loaded_ul_latency_samples.clear();
state.idle_latency_sent = 0;
state.idle_latency_received = 0;
state.loaded_dl_latency_sent = 0;
state.loaded_dl_latency_received = 0;
state.loaded_ul_latency_sent = 0;
state.loaded_ul_latency_received = 0;
state.phase = Phase::IdleLatency;
state.paused = false;
state.udp_loss_sent = 0;
state.udp_loss_received = 0;
state.udp_loss_total = 0;
state.udp_loss_latest_rtt_ms = None;
state.dns_summary = None;
state.tls_summary = None;
state.ip_comparison = None;
state.traceroute_summary = None;
state.traceroute_hops.clear();
state.text_log.clear();
state.dashboard_log_scroll = 0;
run_ctx = Some(start_run(&args).await?);
}
}
(_, KeyCode::Char('s')) => {
if state.tab == 0 {
if let Some(r) = state.last_result.clone() {
save_and_show_path(&r, &mut state);
} else {
state.info = "No completed run to save yet.".into();
}
}
}
(_, KeyCode::Char('a')) => {
state.auto_save = !state.auto_save;
state.info = if state.auto_save {
"Auto-save enabled".into()
} else {
"Auto-save disabled".into()
};
}
(KeyModifiers::SHIFT, KeyCode::BackTab) => {
let tab_count = if state.traceroute_enabled { 5 } else { 4 };
let new_tab = if state.tab == 0 { tab_count - 1 } else { state.tab - 1 };
state.tab = new_tab;
if new_tab == 1 {
state.history_selected = 0;
state.history_scroll_offset = 0;
}
}
(_, KeyCode::Tab) => {
let tab_count = if state.traceroute_enabled { 5 } else { 4 };
let new_tab = (state.tab + 1) % tab_count;
state.tab = new_tab;
if new_tab == 1 {
state.history_selected = 0;
state.history_scroll_offset = 0;
}
}
(_, KeyCode::Char('?')) => {
state.tab = if state.traceroute_enabled { 4 } else { 3 }; }
(_, KeyCode::Enter) => {
if state.tab == 1 {
open_history_detail(&mut state);
}
}
(_, KeyCode::Char(' ')) => {
if state.tab == 1
&& !state.history.is_empty()
&& !state.history_filter_editing
&& !state.history_detail_view
&& !state.history_export_modal_open
&& !state.history_comment_modal_open
{
state.history_menu_open = true;
state.history_menu_selected = history::MENU_ITEM_VIEW;
}
}
(_, KeyCode::Up) | (_, KeyCode::Char('k')) => {
if state.tab == 1 && !state.history.is_empty() {
if state.history_selected > 0 {
state.history_selected -= 1;
}
} else if state.tab == 0 {
let max_scroll = state.text_log.len().saturating_sub(1);
state.dashboard_log_scroll =
(state.dashboard_log_scroll + 1).min(max_scroll);
}
}
(_, KeyCode::Down) | (_, KeyCode::Char('j')) => {
if state.tab == 0 {
state.dashboard_log_scroll =
state.dashboard_log_scroll.saturating_sub(1);
} else if state.tab == 1 && !state.history.is_empty() {
if state.history_selected < state.history.len().saturating_sub(1) {
state.history_selected += 1;
let load_threshold = state.history_loaded_count.saturating_sub(10);
if state.history_selected >= load_threshold && state.history_loaded_count == state.history.len() {
let current_count = state.history.len();
let load_more = current_count.max(20);
if let Ok(more_history) = crate::storage::load_recent(load_more) {
let existing_ids: std::collections::HashSet<_> = state.history
.iter()
.map(|r| &r.meas_id)
.collect();
let new_items: Vec<_> = more_history
.into_iter()
.filter(|r| !existing_ids.contains(&r.meas_id))
.collect();
if !new_items.is_empty() {
state.history.extend(new_items);
state.history_loaded_count = state.history.len();
update_available_networks(&mut state);
}
}
}
}
}
}
(_, KeyCode::PageUp) => {
if state.tab == 1 && !state.history.is_empty() {
let page_size = 20;
state.history_selected = state.history_selected.saturating_sub(page_size);
} else if state.tab == 0 {
let max_scroll = state.text_log.len().saturating_sub(1);
state.dashboard_log_scroll =
(state.dashboard_log_scroll + 10).min(max_scroll);
}
}
(_, KeyCode::PageDown) => {
if state.tab == 0 {
state.dashboard_log_scroll =
state.dashboard_log_scroll.saturating_sub(10);
} else if state.tab == 1 && !state.history.is_empty() {
let page_size = 20;
let max_idx = state.history.len().saturating_sub(1);
state.history_selected = (state.history_selected + page_size).min(max_idx);
let load_threshold = state.history_loaded_count.saturating_sub(10);
if state.history_selected >= load_threshold && state.history_loaded_count == state.history.len() {
let current_count = state.history.len();
let load_more = current_count.max(20);
if let Ok(more_history) = crate::storage::load_recent(load_more) {
let existing_ids: std::collections::HashSet<_> = state.history
.iter()
.map(|r| &r.meas_id)
.collect();
let new_items: Vec<_> = more_history
.into_iter()
.filter(|r| !existing_ids.contains(&r.meas_id))
.collect();
if !new_items.is_empty() {
state.history.extend(new_items);
state.history_loaded_count = state.history.len();
update_available_networks(&mut state);
}
}
}
}
}
(_, KeyCode::Char('/')) => {
if state.tab == 1 {
state.history_filter_editing = true;
}
}
(_, KeyCode::Esc) => {
if state.tab == 1 && !state.history_filter.is_empty() {
state.history_filter.clear();
state.history_selected = 0;
state.history_scroll_offset = 0;
}
}
(_, KeyCode::Left) | (_, KeyCode::Char('h')) => {
if state.tab == 2 && !state.charts_available_networks.is_empty() {
match &state.charts_network_filter {
None => {
state.charts_network_filter = Some(
state.charts_available_networks.last().unwrap().clone(),
);
}
Some(current) => {
if let Some(idx) = state
.charts_available_networks
.iter()
.position(|n| n == current)
{
if idx == 0 {
state.charts_network_filter = None; } else {
state.charts_network_filter = Some(
state.charts_available_networks[idx - 1].clone(),
);
}
} else {
state.charts_network_filter = None;
}
}
}
}
}
(_, KeyCode::Right) | (_, KeyCode::Char('l')) => {
if state.tab == 2 && !state.charts_available_networks.is_empty() {
match &state.charts_network_filter {
None => {
state.charts_network_filter = Some(
state.charts_available_networks.first().unwrap().clone(),
);
}
Some(current) => {
if let Some(idx) = state
.charts_available_networks
.iter()
.position(|n| n == current)
{
if idx >= state.charts_available_networks.len() - 1 {
state.charts_network_filter = None; } else {
state.charts_network_filter = Some(
state.charts_available_networks[idx + 1].clone(),
);
}
} else {
state.charts_network_filter = None;
}
}
}
}
}
_ => {}
}
}
}
maybe_engine_ev = async {
if let Some(ref mut ctx) = run_ctx {
ctx.event_rx.recv().await
} else {
future::pending().await
}
} => {
match maybe_engine_ev {
None => {
if let Some(ctx) = &mut run_ctx {
if let Some(h) = ctx.handle.take() {
match h.await {
Ok(Ok(r)) => {
if state.auto_save {
save_and_show_path(&r, &mut state);
}
if let Some(meta) = r.meta.as_ref() {
let extracted = crate::network::extract_metadata(meta);
state.ip = extracted.ip;
state.colo = extracted.colo;
state.asn = extracted.asn;
state.as_org = extracted.as_org;
}
if r.server.is_some() {
state.server = r.server.clone();
}
let enriched = enrich_result_with_network_info(&r, &state);
state.last_result = Some(enriched.clone());
for line in crate::event_format::format_result_summary(
&enriched,
&state.dl_points,
&state.ul_points,
&state.idle_latency_samples,
&state.loaded_dl_latency_samples,
&state.loaded_ul_latency_samples,
) {
UiState::push_log_line(&mut state.text_log, line);
}
let mut export_messages = Vec::new();
if let Some(export_path) = args.export_json.as_deref() {
match crate::storage::export_json(export_path, &enriched) {
Ok(_) => export_messages.push(format!("Exported JSON: {}", export_path.display())),
Err(e) => export_messages.push(format!("Export JSON failed: {e:#}")),
}
}
if let Some(export_path) = args.export_csv.as_deref() {
match crate::storage::export_csv(export_path, &enriched) {
Ok(_) => export_messages.push(format!("Exported CSV: {}", export_path.display())),
Err(e) => export_messages.push(format!("Export CSV failed: {e:#}")),
}
}
if !export_messages.is_empty() {
state.info = export_messages.join("; ");
}
let reload_size = (state.history_loaded_count + 1).max(state.initial_history_load_size);
state.history = crate::storage::load_recent(reload_size).unwrap_or_default();
state.history_loaded_count = state.history.len();
update_available_networks(&mut state);
if state.tab == 1 {
state.history_selected = 0;
state.history_scroll_offset = 0;
}
}
Ok(Err(e)) => state.info = format!("Run failed: {e:#}"),
Err(e) => state.info = format!("Run join failed: {e}"),
}
}
run_ctx = None;
}
}
Some(ev) => apply_event(&mut state, ev),
}
}
}
};
disable_raw_mode().ok();
let mut stdout = io::stdout();
execute!(stdout, LeaveAlternateScreen).ok();
res
}
struct RunCtx {
ctrl_tx: mpsc::Sender<EngineControl>,
event_rx: mpsc::Receiver<TestEvent>,
handle: Option<tokio::task::JoinHandle<Result<RunResult>>>,
}
async fn start_run(args: &Cli) -> Result<RunCtx> {
let cfg = build_config(args)?;
let (event_tx, event_rx) = mpsc::channel::<TestEvent>(4096);
let (ctrl_tx, ctrl_rx) = mpsc::channel::<EngineControl>(32);
let engine = TestEngine::new(cfg);
let handle = tokio::spawn(async move { engine.run(event_tx, ctrl_rx).await });
Ok(RunCtx {
ctrl_tx,
event_rx,
handle: Some(handle),
})
}
fn apply_event(state: &mut UiState, ev: TestEvent) {
let added = crate::event_format::format_event_lines(&ev);
if !added.is_empty() {
let added_count = added.len();
for line in added {
UiState::push_log_line(&mut state.text_log, line);
}
if state.dashboard_log_scroll > 0 {
let max_scroll = state.text_log.len().saturating_sub(1);
state.dashboard_log_scroll = state
.dashboard_log_scroll
.saturating_add(added_count)
.min(max_scroll);
}
}
match ev {
TestEvent::PhaseStarted { phase } => {
state.phase = phase;
state.info = format!("Phase: {phase:?}");
match phase {
Phase::IdleLatency => {
state.idle_latency_samples.clear();
state.idle_latency_sent = 0;
state.idle_latency_received = 0;
}
Phase::Download => {
state.dl_phase_start = Some(Instant::now());
state.dl_bytes_total = 0;
state.dl_avg_mbps = 0.0;
state.loaded_dl_latency_samples.clear();
state.loaded_dl_latency_sent = 0;
state.loaded_dl_latency_received = 0;
}
Phase::Upload => {
state.ul_phase_start = Some(Instant::now());
state.ul_bytes_total = 0;
state.ul_avg_mbps = 0.0;
state.loaded_ul_latency_samples.clear();
state.loaded_ul_latency_sent = 0;
state.loaded_ul_latency_received = 0;
}
Phase::PacketLoss => {
state.udp_loss_sent = 0;
state.udp_loss_received = 0;
state.udp_loss_total = 0;
state.udp_loss_latest_rtt_ms = None;
}
_ => {}
}
}
TestEvent::Info { message } => state.info = message,
TestEvent::MetaInfo { meta } => {
let extracted = crate::network::extract_metadata(&meta);
state.ip = extracted.ip;
state.colo = extracted.colo;
state.asn = extracted.asn;
state.as_org = extracted.as_org;
if let Some(city) = meta.get("city").and_then(|v| v.as_str()) {
if let Some(country) = meta.get("country").and_then(|v| v.as_str()) {
state.server = Some(format!("{}, {}", city, country));
} else {
state.server = Some(city.to_string());
}
} else if let Some(ref colo) = state.colo {
state.server = Some(colo.clone());
}
}
TestEvent::LatencySample {
phase,
during,
rtt_ms,
ok,
} => {
let t = state.run_start.elapsed().as_secs_f64();
match (phase, during) {
(Phase::IdleLatency, _) => {
state.idle_latency_sent += 1;
if ok {
state.idle_latency_received += 1;
if let Some(ms) = rtt_ms {
let v = ms.round().clamp(0.0, 5000.0) as u64;
UiState::push_series(&mut state.idle_lat_series, v);
UiState::push_point(&mut state.idle_lat_points, t, ms);
state.idle_latency_samples.push(ms);
if state.idle_latency_samples.len() > 10000 {
state
.idle_latency_samples
.drain(0..(state.idle_latency_samples.len() - 10000));
}
}
}
}
(Phase::Download, Some(Phase::Download)) => {
state.loaded_dl_latency_sent += 1;
if ok {
state.loaded_dl_latency_received += 1;
if let Some(ms) = rtt_ms {
let v = ms.round().clamp(0.0, 5000.0) as u64;
UiState::push_series(&mut state.loaded_dl_lat_series, v);
UiState::push_point(&mut state.loaded_dl_lat_points, t, ms);
state.loaded_dl_latency_samples.push(ms);
if state.loaded_dl_latency_samples.len() > 10000 {
state
.loaded_dl_latency_samples
.drain(0..(state.loaded_dl_latency_samples.len() - 10000));
}
}
}
}
(Phase::Upload, Some(Phase::Upload)) => {
state.loaded_ul_latency_sent += 1;
if ok {
state.loaded_ul_latency_received += 1;
if let Some(ms) = rtt_ms {
let v = ms.round().clamp(0.0, 5000.0) as u64;
UiState::push_series(&mut state.loaded_ul_lat_series, v);
UiState::push_point(&mut state.loaded_ul_lat_points, t, ms);
state.loaded_ul_latency_samples.push(ms);
if state.loaded_ul_latency_samples.len() > 10000 {
state
.loaded_ul_latency_samples
.drain(0..(state.loaded_ul_latency_samples.len() - 10000));
}
}
}
}
_ => {}
}
}
TestEvent::ThroughputTick {
phase,
bytes_total,
bps_instant,
} => {
let mbps = (bps_instant * 8.0) / 1_000_000.0;
let t = state.run_start.elapsed().as_secs_f64();
match phase {
Phase::Download => {
state.dl_mbps = mbps;
state.dl_bytes_total = bytes_total;
if let Some(t0) = state.dl_phase_start {
let secs = t0.elapsed().as_secs_f64().max(1e-9);
state.dl_avg_mbps = ((bytes_total as f64) / secs) * 8.0 / 1_000_000.0;
}
let v = state.dl_mbps.round().clamp(0.0, 10_000.0) as u64;
UiState::push_series(&mut state.dl_series, v);
UiState::push_point(&mut state.dl_points, t, state.dl_mbps.max(0.0));
}
Phase::Upload => {
state.ul_mbps = mbps;
state.ul_bytes_total = bytes_total;
if let Some(t0) = state.ul_phase_start {
let secs = t0.elapsed().as_secs_f64().max(1e-9);
state.ul_avg_mbps = ((bytes_total as f64) / secs) * 8.0 / 1_000_000.0;
}
let v = state.ul_mbps.round().clamp(0.0, 10_000.0) as u64;
UiState::push_series(&mut state.ul_series, v);
UiState::push_point(&mut state.ul_points, t, state.ul_mbps.max(0.0));
}
_ => {}
}
}
TestEvent::UdpLossProgress {
sent,
received,
total,
rtt_ms,
} => {
state.udp_loss_sent = sent;
state.udp_loss_received = received;
state.udp_loss_total = total;
state.udp_loss_latest_rtt_ms = rtt_ms;
let loss_pct = if sent == 0 {
0.0
} else {
((sent.saturating_sub(received)) as f64) * 100.0 / sent as f64
};
state.info = format!(
"Packet loss probe: {}/{} (loss {:.1}%)",
sent, total, loss_pct
);
}
TestEvent::DiagnosticDns { summary } => {
state.info = format!(
"DNS: {} resolved in {:.2}ms ({} IPs)",
summary.hostname,
summary.resolution_time_ms,
summary.resolved_ips.len()
);
state.dns_summary = Some(summary);
}
TestEvent::DiagnosticTls { summary } => {
state.info = format!(
"TLS: {:.2}ms, {}",
summary.handshake_time_ms,
summary.protocol_version.as_deref().unwrap_or("-")
);
state.tls_summary = Some(summary);
}
TestEvent::DiagnosticIpComparison { comparison } => {
let v4_info = comparison
.ipv4_result
.as_ref()
.map(|r| {
if r.available {
format!("v4:{:.0}Mbps", r.download_mbps)
} else {
"v4:N/A".to_string()
}
})
.unwrap_or_else(|| "-".to_string());
let v6_info = comparison
.ipv6_result
.as_ref()
.map(|r| {
if r.available {
format!("v6:{:.0}Mbps", r.download_mbps)
} else {
"v6:N/A".to_string()
}
})
.unwrap_or_else(|| "-".to_string());
state.info = format!("IP Comparison: {} / {}", v4_info, v6_info);
state.ip_comparison = Some(comparison);
}
TestEvent::TracerouteHop { hop_number, hop } => {
let addr = hop.ip_address.as_deref().unwrap_or("*");
let rtt = hop
.rtt_ms
.first()
.map(|r| format!("{:.1}ms", r))
.unwrap_or_else(|| "*".to_string());
state.info = format!("Traceroute hop {}: {} {}", hop_number, addr, rtt);
state.traceroute_hops.push(hop);
}
TestEvent::TracerouteComplete { summary } => {
state.info = format!(
"Traceroute: {} hops to {}",
summary.hops.len(),
summary.destination
);
state.traceroute_summary = Some(summary);
}
TestEvent::ExternalIps { ipv4, ipv6 } => {
state.external_ipv4 = ipv4;
state.external_ipv6 = ipv6;
}
}
}
fn open_history_detail(state: &mut UiState) {
if state.history.is_empty() || state.history_selected >= state.history.len() {
return;
}
let r = &state.history[state.history_selected];
let json = serde_json::to_string_pretty(r)
.unwrap_or_else(|e| format!("Error serializing JSON: {}", e));
let lines: Vec<String> = json.lines().map(String::from).collect();
let mut textarea = ratatui_textarea::TextArea::new(if lines.is_empty() {
vec![String::new()]
} else {
lines
});
textarea.set_cursor_line_style(ratatui::style::Style::default());
state.history_detail_textarea = textarea;
state.history_detail_search.clear();
state.history_detail_search_editing = false;
state.history_detail_search_error = None;
state.history_detail_view = true;
}
fn close_history_detail(state: &mut UiState) {
state.history_detail_view = false;
state.history_detail_textarea = ratatui_textarea::TextArea::default();
state.history_detail_search.clear();
state.history_detail_search_editing = false;
state.history_detail_search_error = None;
}
fn apply_detail_search_pattern(state: &mut UiState) {
let pattern = state.history_detail_search.clone();
match state.history_detail_textarea.set_search_pattern(&pattern) {
Ok(_) => {
state.history_detail_search_error = None;
if !pattern.is_empty() {
state.history_detail_textarea.search_forward(false);
}
}
Err(e) => {
state.history_detail_search_error = Some(e.to_string());
}
}
}
fn handle_history_detail_key(state: &mut UiState, k: crossterm::event::KeyEvent) {
use crossterm::event::KeyCode;
if state.history_detail_search_editing {
match k.code {
KeyCode::Esc => {
state.history_detail_search.clear();
state.history_detail_search_editing = false;
state.history_detail_search_error = None;
let _ = state.history_detail_textarea.set_search_pattern("");
}
KeyCode::Enter => {
state.history_detail_search_editing = false;
apply_detail_search_pattern(state);
}
KeyCode::Backspace => {
state.history_detail_search.pop();
apply_detail_search_pattern(state);
}
KeyCode::Char(c) => {
state.history_detail_search.push(c);
apply_detail_search_pattern(state);
}
_ => {}
}
return;
}
match k.code {
KeyCode::Esc => {
if !state.history_detail_search.is_empty() {
state.history_detail_search.clear();
state.history_detail_search_error = None;
let _ = state.history_detail_textarea.set_search_pattern("");
} else {
close_history_detail(state);
}
}
KeyCode::Char('q') => close_history_detail(state),
KeyCode::Char('/') => {
state.history_detail_search_editing = true;
state.history_detail_search_error = None;
}
KeyCode::Char('n') => {
if !state.history_detail_search.is_empty() {
state.history_detail_textarea.search_forward(false);
}
}
KeyCode::Char('N') => {
if !state.history_detail_search.is_empty() {
state.history_detail_textarea.search_back(false);
}
}
KeyCode::Up | KeyCode::Char('k') => {
state
.history_detail_textarea
.move_cursor(ratatui_textarea::CursorMove::Up);
}
KeyCode::Down | KeyCode::Char('j') => {
state
.history_detail_textarea
.move_cursor(ratatui_textarea::CursorMove::Down);
}
KeyCode::PageUp => {
state
.history_detail_textarea
.scroll(ratatui_textarea::Scrolling::PageUp);
}
KeyCode::PageDown => {
state
.history_detail_textarea
.scroll(ratatui_textarea::Scrolling::PageDown);
}
KeyCode::Home => {
state
.history_detail_textarea
.move_cursor(ratatui_textarea::CursorMove::Top);
}
KeyCode::End => {
state
.history_detail_textarea
.move_cursor(ratatui_textarea::CursorMove::Bottom);
}
_ => {}
}
}
fn open_export_modal(state: &mut UiState, path: String) {
state.history_export_modal_open = true;
state.history_export_modal_path = Some(path);
state.history_export_modal_copied = false;
}
fn export_history_selected_json(state: &mut UiState) {
if state.history.is_empty() || state.history_selected >= state.history.len() {
return;
}
let r = &state.history[state.history_selected];
match export_result_json(r, state) {
Ok(p) => open_export_modal(state, p.to_string_lossy().to_string()),
Err(e) => state.info = format!("JSON export failed: {e:#}"),
}
}
fn export_history_selected_csv(state: &mut UiState) {
if state.history.is_empty() || state.history_selected >= state.history.len() {
return;
}
let r = &state.history[state.history_selected];
match export_result_csv(r, state) {
Ok(p) => open_export_modal(state, p.to_string_lossy().to_string()),
Err(e) => state.info = format!("CSV export failed: {e:#}"),
}
}
fn open_comment_modal(state: &mut UiState) {
if state.history.is_empty() || state.history_selected >= state.history.len() {
return;
}
let existing = state.history[state.history_selected]
.comments
.clone()
.unwrap_or_default();
let lines = if existing.is_empty() {
vec![String::new()]
} else {
existing.lines().map(String::from).collect()
};
let mut textarea = ratatui_textarea::TextArea::new(lines);
textarea.move_cursor(ratatui_textarea::CursorMove::End);
textarea.set_cursor_line_style(ratatui::style::Style::default());
textarea.set_placeholder_text("Type a comment\u{2026}");
state.history_comment_modal_textarea = textarea;
state.history_comment_modal_open = true;
}
fn handle_history_comment_modal_key(state: &mut UiState, k: crossterm::event::KeyEvent) {
match k.code {
KeyCode::Esc => {
state.history_comment_modal_open = false;
state.history_comment_modal_textarea = ratatui_textarea::TextArea::default();
}
KeyCode::Enter => {
if state.history_selected < state.history.len() {
let value = state
.history_comment_modal_textarea
.lines()
.join(" ")
.trim()
.to_string();
let new_comment = if value.is_empty() { None } else { Some(value) };
state.history[state.history_selected].comments = new_comment;
if let Err(e) = crate::storage::save_run(&state.history[state.history_selected]) {
state.info = format!("Save comment failed: {e:#}");
} else {
state.info = "Comment saved".into();
}
}
state.history_comment_modal_open = false;
state.history_comment_modal_textarea = ratatui_textarea::TextArea::default();
}
_ => {
state.history_comment_modal_textarea.input(Event::Key(k));
}
}
}
fn handle_history_export_modal_key(state: &mut UiState, code: KeyCode) {
match code {
KeyCode::Esc | KeyCode::Enter => {
state.history_export_modal_open = false;
}
KeyCode::Char('c') => {
if let Some(ref path) = state.history_export_modal_path {
match copy_to_clipboard(path) {
Ok(_) => state.history_export_modal_copied = true,
Err(e) => state.info = format!("Clipboard copy failed: {e:#}"),
}
}
}
_ => {}
}
}
fn delete_history_selected(state: &mut UiState) {
if state.history.is_empty() || state.history_selected >= state.history.len() {
return;
}
let to_delete = state.history[state.history_selected].clone();
if let Err(e) = crate::storage::delete_run(&to_delete) {
state.info = format!("Delete failed: {e:#}");
return;
}
state.history.remove(state.history_selected);
if state.history_scroll_offset >= state.history.len() && !state.history.is_empty() {
state.history_scroll_offset = state.history.len().saturating_sub(20).max(0);
}
if state.history_selected >= state.history.len() && !state.history.is_empty() {
state.history_selected = state.history.len() - 1;
} else if state.history.is_empty() {
state.history_selected = 0;
state.history_scroll_offset = 0;
}
state.info = "Deleted".into();
}
fn handle_history_menu_key(state: &mut UiState, code: KeyCode) {
let n = history::MENU_ITEM_COUNT;
match code {
KeyCode::Esc | KeyCode::Char(' ') => {
state.history_menu_open = false;
}
KeyCode::Up | KeyCode::Char('k') => {
state.history_menu_selected = (state.history_menu_selected + n - 1) % n;
}
KeyCode::Down | KeyCode::Char('j') => {
state.history_menu_selected = (state.history_menu_selected + 1) % n;
}
KeyCode::Enter => {
let idx = state.history_menu_selected;
state.history_menu_open = false;
match idx {
history::MENU_ITEM_VIEW => open_history_detail(state),
history::MENU_ITEM_EDIT_COMMENT => open_comment_modal(state),
history::MENU_ITEM_EXPORT_JSON => export_history_selected_json(state),
history::MENU_ITEM_EXPORT_CSV => export_history_selected_csv(state),
history::MENU_ITEM_DELETE => delete_history_selected(state),
_ => {}
}
}
_ => {}
}
}
fn draw(area: Rect, f: &mut ratatui::Frame, state: &mut UiState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
.split(area);
let mut tab_titles: Vec<Line> = vec![
Line::from("Dashboard"),
Line::from("History"),
];
if state.traceroute_enabled {
tab_titles.push(Line::from("Traceroute"));
}
tab_titles.push(Line::from("Charts"));
tab_titles.push(Line::from("Help"));
let tabs = Tabs::new(tab_titles)
.select(state.tab)
.block(
Block::default()
.borders(Borders::ALL)
.title(match &state.update_status {
Some(Some(v)) => Line::from(vec![
Span::raw(format!("cloudflare-speed-cli v{} ", env!("CARGO_PKG_VERSION"))),
Span::styled(format!("(v{} available)", v), Style::default().fg(Color::Cyan)),
]),
Some(None) => Line::from(format!("cloudflare-speed-cli v{} (latest)", env!("CARGO_PKG_VERSION"))),
None => Line::from(format!("cloudflare-speed-cli v{}", env!("CARGO_PKG_VERSION"))),
}),
)
.highlight_style(Style::default().fg(Color::Yellow));
f.render_widget(tabs, chunks[0]);
let traceroute_idx: Option<usize> = if state.traceroute_enabled { Some(2) } else { None };
let charts_idx: usize = if state.traceroute_enabled { 3 } else { 2 };
match state.tab {
0 => draw_dashboard(chunks[1], f, state),
1 => {
if state.history_detail_view {
draw_history_detail(chunks[1], f, &mut *state)
} else {
show_history(chunks[1], f, &mut *state);
if state.history_menu_open {
draw_history_menu(chunks[1], f, &*state);
}
if state.history_export_modal_open {
draw_history_export_modal(chunks[1], f, &*state);
}
if state.history_comment_modal_open {
draw_history_comment_modal(chunks[1], f, &*state);
}
}
}
i if Some(i) == traceroute_idx => traceroute::draw_traceroute(chunks[1], f, state),
i if i == charts_idx => draw_charts(chunks[1], f, state),
_ => draw_help(chunks[1], f),
}
}