use crate::model::{DnsSummary, IpVersionComparison, Phase, RunResult, TlsSummary, TracerouteHop, TracerouteSummary};
use ratatui::{
style::Color,
style::Style,
text::{Line, Span},
};
use std::time::Instant;
use ratatui_textarea::TextArea;
pub struct UiState {
pub tab: usize,
pub paused: bool,
pub phase: Phase,
pub info: String,
pub comments: Option<String>,
pub dl_series: Vec<u64>,
pub ul_series: Vec<u64>,
pub idle_lat_series: Vec<u64>,
pub loaded_dl_lat_series: Vec<u64>,
pub loaded_ul_lat_series: Vec<u64>,
pub run_start: Instant,
pub dl_points: Vec<(f64, f64)>,
pub ul_points: Vec<(f64, f64)>,
pub idle_lat_points: Vec<(f64, f64)>,
pub loaded_dl_lat_points: Vec<(f64, f64)>,
pub loaded_ul_lat_points: Vec<(f64, f64)>,
pub dl_mbps: f64,
pub ul_mbps: f64,
pub dl_avg_mbps: f64,
pub ul_avg_mbps: f64,
pub dl_bytes_total: u64,
pub ul_bytes_total: u64,
pub dl_phase_start: Option<Instant>,
pub ul_phase_start: Option<Instant>,
pub idle_latency_samples: Vec<f64>,
pub loaded_dl_latency_samples: Vec<f64>,
pub loaded_ul_latency_samples: Vec<f64>,
pub idle_latency_sent: u64,
pub idle_latency_received: u64,
pub loaded_dl_latency_sent: u64,
pub loaded_dl_latency_received: u64,
pub loaded_ul_latency_sent: u64,
pub loaded_ul_latency_received: u64,
pub udp_loss_sent: u64,
pub udp_loss_received: u64,
pub udp_loss_total: u64,
pub udp_loss_latest_rtt_ms: Option<f64>,
pub last_result: Option<RunResult>,
pub history: Vec<RunResult>,
pub history_selected: usize, pub history_scroll_offset: usize,
pub history_loaded_count: usize,
pub initial_history_load_size: usize, pub history_filter: String, pub history_filter_editing: bool, pub charts_network_filter: Option<String>, pub charts_available_networks: Vec<String>, pub history_detail_view: bool, pub history_detail_textarea: TextArea<'static>,
pub history_detail_search: String, pub history_detail_search_editing: bool, pub history_detail_search_error: Option<String>, pub history_menu_open: bool, pub history_menu_selected: usize, pub ip: Option<String>,
pub colo: Option<String>,
pub server: Option<String>,
pub asn: Option<String>,
pub as_org: Option<String>,
pub auto_save: bool,
pub history_export_modal_open: bool,
pub history_export_modal_path: Option<String>,
pub history_export_modal_copied: bool,
pub history_comment_modal_open: bool,
pub history_comment_modal_textarea: TextArea<'static>,
pub interface_name: Option<String>,
pub network_name: Option<String>,
pub is_wireless: Option<bool>,
pub interface_mac: Option<String>,
pub local_ipv4: Option<String>,
pub local_ipv6: Option<String>,
pub external_ipv4: Option<String>,
pub external_ipv6: Option<String>,
pub certificate_filename: Option<String>,
pub proxy_url: Option<String>,
pub dns_summary: Option<DnsSummary>,
pub tls_summary: Option<TlsSummary>,
pub ip_comparison: Option<IpVersionComparison>,
pub traceroute_summary: Option<TracerouteSummary>,
pub traceroute_enabled: bool,
pub traceroute_max_hops: u8,
pub traceroute_hops: Vec<TracerouteHop>,
pub update_status: Option<Option<String>>,
pub text_log: Vec<String>,
pub dashboard_log_scroll: usize,
}
impl Default for UiState {
fn default() -> Self {
Self {
tab: 0,
paused: false,
phase: Phase::IdleLatency,
info: String::new(),
comments: None,
dl_series: Vec::new(),
ul_series: Vec::new(),
idle_lat_series: Vec::new(),
loaded_dl_lat_series: Vec::new(),
loaded_ul_lat_series: Vec::new(),
run_start: Instant::now(),
dl_points: Vec::new(),
ul_points: Vec::new(),
idle_lat_points: Vec::new(),
loaded_dl_lat_points: Vec::new(),
loaded_ul_lat_points: Vec::new(),
dl_mbps: 0.0,
ul_mbps: 0.0,
dl_avg_mbps: 0.0,
ul_avg_mbps: 0.0,
dl_bytes_total: 0,
ul_bytes_total: 0,
dl_phase_start: None,
ul_phase_start: None,
idle_latency_samples: Vec::new(),
loaded_dl_latency_samples: Vec::new(),
loaded_ul_latency_samples: Vec::new(),
idle_latency_sent: 0,
idle_latency_received: 0,
loaded_dl_latency_sent: 0,
loaded_dl_latency_received: 0,
loaded_ul_latency_sent: 0,
loaded_ul_latency_received: 0,
udp_loss_sent: 0,
udp_loss_received: 0,
udp_loss_total: 0,
udp_loss_latest_rtt_ms: None,
last_result: None,
history: Vec::new(),
history_selected: 0,
history_scroll_offset: 0,
history_loaded_count: 0,
initial_history_load_size: 66, history_filter: String::new(),
history_filter_editing: false,
charts_network_filter: None,
charts_available_networks: Vec::new(),
history_detail_view: false,
history_detail_textarea: TextArea::default(),
history_detail_search: String::new(),
history_detail_search_editing: false,
history_detail_search_error: None,
history_menu_open: false,
history_menu_selected: 0,
ip: None,
colo: None,
server: None,
asn: None,
as_org: None,
auto_save: true,
history_export_modal_open: false,
history_export_modal_path: None,
history_export_modal_copied: false,
history_comment_modal_open: false,
history_comment_modal_textarea: TextArea::default(),
interface_name: None,
network_name: None,
is_wireless: None,
interface_mac: None,
local_ipv4: None,
local_ipv6: None,
external_ipv4: None,
external_ipv6: None,
certificate_filename: None,
proxy_url: None,
dns_summary: None,
tls_summary: None,
ip_comparison: None,
traceroute_summary: None,
traceroute_enabled: false,
traceroute_max_hops: 30,
traceroute_hops: Vec::new(),
update_status: None,
text_log: Vec::new(),
dashboard_log_scroll: 0,
}
}
}
pub fn update_available_networks(state: &mut UiState) {
let mut networks: Vec<String> = state
.history
.iter()
.filter_map(|r| r.network_name.clone())
.collect();
networks.sort();
networks.dedup();
state.charts_available_networks = networks;
if let Some(ref current) = state.charts_network_filter {
if !state.charts_available_networks.contains(current) {
state.charts_network_filter = None;
}
}
}
pub fn push_wrapped_status_kv(
out: &mut Vec<Line<'static>>,
label: &str,
value: &str,
status_area_width: u16,
) {
let value = value.trim();
if value.is_empty() {
return;
}
let usable_width = status_area_width.saturating_sub(4).max(1);
let label_text = format!("{label}:");
let label_width = label_text.chars().count() as u16;
let value_chars: Vec<char> = value.chars().collect();
let mut remaining = value_chars.as_slice();
let mut first = true;
while !remaining.is_empty() {
let line_width = if first {
usable_width.saturating_sub(label_width + 1).max(1)
} else {
usable_width.saturating_sub(2).max(1)
};
let chars_to_take = (remaining.len() as u16).min(line_width) as usize;
let (line_chars, rest) = remaining.split_at(chars_to_take);
let line_text: String = line_chars.iter().collect();
if first {
out.push(Line::from(vec![
Span::styled(label_text.clone(), Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::raw(line_text),
]));
first = false;
} else {
out.push(Line::from(vec![Span::raw(" "), Span::raw(line_text)]));
}
remaining = rest;
}
}
impl UiState {
pub fn push_series(series: &mut Vec<u64>, v: u64) {
const MAX: usize = 120;
series.push(v);
if series.len() > MAX {
let _ = series.drain(0..(series.len() - MAX));
}
}
pub fn push_point(points: &mut Vec<(f64, f64)>, x: f64, y: f64) {
const MAX: usize = 1200; points.push((x, y));
if points.len() > MAX {
let _ = points.drain(0..(points.len() - MAX));
}
}
pub fn push_log_line(log: &mut Vec<String>, line: String) {
const MAX: usize = 500;
log.push(line);
if log.len() > MAX {
let _ = log.drain(0..(log.len() - MAX));
}
}
pub fn compute_live_latency_stats(
samples: &[f64],
sent: u64,
received: u64,
) -> crate::model::LatencySummary {
let loss = if sent == 0 {
0.0
} else {
((sent - received) as f64) / (sent as f64)
};
if samples.is_empty() {
return crate::model::LatencySummary {
sent,
received,
loss,
..Default::default()
};
}
let mut sorted = samples.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = sorted.len();
let min_ms = Some(sorted[0]);
let max_ms = Some(sorted[n - 1]);
if let Some((mean, median, p25, p75)) = crate::metrics::compute_metrics(samples) {
let jitter_ms = crate::metrics::compute_jitter(samples);
crate::model::LatencySummary {
sent,
received,
loss,
min_ms,
mean_ms: Some(mean),
median_ms: Some(median),
p25_ms: Some(p25),
p75_ms: Some(p75),
max_ms,
jitter_ms,
}
} else {
crate::model::LatencySummary {
sent,
received,
loss,
..Default::default()
}
}
}
}