use std::cmp::Ordering;
use std::fs::File;
use std::io::{self, Write};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use chrono::{DateTime, Local};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Margin, Rect};
use ratatui::prelude::{Color, Line, Modifier, Span, Style};
use ratatui::symbols;
use ratatui::widgets::{
Block, Borders, Cell, Clear, List, ListItem, Padding, Paragraph, Row, Table, Wrap,
};
use ratatui::Terminal;
use lobe_core::engine::history::{compare_ttfb, RegressionSummary};
use lobe_core::engine::proxy::SimpleProbe;
use lobe_core::error::Result;
use lobe_core::models::{ProbeResult, ProbeStatus, TlsStatus};
use lobe_core::storage::SqliteStore;
const MAX_RECENT_RUNS: usize = 8;
const MAX_STATS_RUNS: usize = 50;
pub async fn run_watch_tui(
store: SqliteStore,
url: String,
interval_secs: u64,
threshold_percent: f64,
count: Option<usize>,
) -> Result<()> {
let mut stdout = io::stdout();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let _guard = TerminalGuard;
let mut app = WatchApp::new(store, url, interval_secs, threshold_percent, count)?;
while !app.should_quit {
if app.needs_full_redraw {
terminal.clear()?;
app.needs_full_redraw = false;
}
terminal.draw(|frame| draw(frame, &app))?;
if app.should_probe() {
app.run_probe().await?;
}
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
handle_key_event(&mut app, key.code).await?;
}
}
Event::Resize(_, _) | Event::FocusGained => {
app.needs_full_redraw = true;
}
_ => {}
}
}
app.on_tick();
}
Ok(())
}
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
}
}
#[derive(Debug)]
struct WatchApp {
store: SqliteStore,
probe: SimpleProbe,
target: String,
interval_secs: u64,
threshold_percent: f64,
remaining_count: Option<usize>,
paused: bool,
should_quit: bool,
force_probe: bool,
history_expanded: bool,
tls_detail_expanded: bool,
help_visible: bool,
clear_confirm_visible: bool,
selected_history: usize,
session_started_at: Instant,
last_probe_at: Option<Instant>,
pulse_on: bool,
last_pulse_flip: Instant,
current_result: Option<ProbeResult>,
recent_runs: Vec<ProbeResult>,
stats_runs: Vec<ProbeResult>,
target_run_count: usize,
last_comparison: Option<RegressionSummary>,
comparison_note: Option<String>,
session_results: Vec<ProbeResult>,
flash_message: Option<String>,
last_export_path: Option<PathBuf>,
needs_full_redraw: bool,
}
impl WatchApp {
fn new(
store: SqliteStore,
target: String,
interval_secs: u64,
threshold_percent: f64,
remaining_count: Option<usize>,
) -> Result<Self> {
let recent_runs = store.recent_results_for_target(&target, MAX_RECENT_RUNS)?;
let stats_runs = store.recent_results_for_target(&target, MAX_STATS_RUNS)?;
let target_run_count = store.count_results_for_target(&target)?;
let current_result = recent_runs.first().cloned();
let selected_history = 0;
let last_comparison = if recent_runs.len() >= 2 {
compare_ttfb(&recent_runs[1], &recent_runs[0], threshold_percent)
} else {
None
};
let comparison_note = if recent_runs.is_empty() {
Some("waiting-for-first-run".to_string())
} else if recent_runs.len() == 1 {
Some("first-run".to_string())
} else if last_comparison.is_none() {
Some("previous-ttfb-zero".to_string())
} else {
None
};
Ok(Self {
store,
probe: SimpleProbe::new(),
target,
interval_secs,
threshold_percent,
remaining_count,
paused: false,
should_quit: false,
force_probe: true,
history_expanded: false,
tls_detail_expanded: false,
help_visible: false,
clear_confirm_visible: false,
selected_history,
session_started_at: Instant::now(),
last_probe_at: None,
pulse_on: true,
last_pulse_flip: Instant::now(),
current_result,
recent_runs,
stats_runs,
target_run_count,
last_comparison,
comparison_note,
session_results: Vec::new(),
flash_message: None,
last_export_path: None,
needs_full_redraw: false,
})
}
fn should_probe(&self) -> bool {
if self.paused {
return self.force_probe;
}
if self.force_probe {
return true;
}
match self.last_probe_at {
Some(last_probe_at) => last_probe_at.elapsed() >= Duration::from_secs(self.interval_secs),
None => true,
}
}
async fn run_probe(&mut self) -> Result<()> {
let result = self
.probe
.probe_and_store(&self.store, &self.target)
.await?;
self.last_probe_at = Some(Instant::now());
self.force_probe = false;
self.current_result = Some(result.clone());
self.session_results.push(result);
if let Some(remaining) = self.remaining_count.as_mut() {
*remaining -= 1;
if *remaining == 0 {
self.should_quit = true;
}
}
self.refresh_history()?;
Ok(())
}
fn refresh_history(&mut self) -> Result<()> {
self.recent_runs = self
.store
.recent_results_for_target(&self.target, MAX_RECENT_RUNS)?;
self.stats_runs = self
.store
.recent_results_for_target(&self.target, MAX_STATS_RUNS)?;
self.target_run_count = self.store.count_results_for_target(&self.target)?;
if self.selected_history >= self.recent_runs.len() && !self.recent_runs.is_empty() {
self.selected_history = self.recent_runs.len() - 1;
}
self.current_result = self.recent_runs.first().cloned();
self.last_comparison = if self.recent_runs.len() >= 2 {
compare_ttfb(&self.recent_runs[1], &self.recent_runs[0], self.threshold_percent)
} else {
None
};
self.comparison_note = if self.recent_runs.is_empty() {
Some("waiting-for-first-run".to_string())
} else if self.recent_runs.len() == 1 {
Some("first-run".to_string())
} else if self.last_comparison.is_none() {
Some("previous-ttfb-zero".to_string())
} else {
None
};
Ok(())
}
fn on_tick(&mut self) {
if self.last_pulse_flip.elapsed() >= Duration::from_millis(900) {
self.pulse_on = !self.pulse_on;
self.last_pulse_flip = Instant::now();
}
}
fn move_selection_up(&mut self) {
if self.selected_history > 0 {
self.selected_history -= 1;
}
}
fn move_selection_down(&mut self) {
if self.selected_history + 1 < self.recent_runs.len() {
self.selected_history += 1;
}
}
fn success_failure_counts(&self) -> (usize, usize) {
self.session_results.iter().fold((0, 0), |(ok, fail), result| {
if matches!(result.status, ProbeStatus::Succeeded) {
(ok + 1, fail)
} else {
(ok, fail + 1)
}
})
}
fn export_session_csv(&mut self) -> Result<()> {
let path = PathBuf::from(format!(
"/tmp/lobe-session-{}.csv",
Local::now().format("%Y%m%d-%H%M%S")
));
let mut file = File::create(&path)?;
writeln!(
file,
"id,target,status,dns_ms,tcp_ms,tls_ms,ttfb_ms,total_ms,created_at_ms,error_message"
)?;
for result in &self.session_results {
let error_message = result.error_message.as_deref().unwrap_or("").replace(',', ";");
writeln!(
file,
"{},{},{},{},{},{},{},{},{},{}",
result.id,
result.target,
format_status(result.status),
result.report.dns_ms,
result.report.tcp_ms,
result.report.tls_ms,
result.report.ttfb_ms,
result.report.total_ms,
result.created_at_ms,
error_message
)?;
}
self.last_export_path = Some(path.clone());
self.flash_message = Some(format!("exported session to {}", path.display()));
Ok(())
}
fn clear_target_history(&mut self) -> Result<()> {
let deleted = self.store.delete_results_for_target(&self.target)?;
self.current_result = None;
self.recent_runs.clear();
self.stats_runs.clear();
self.target_run_count = 0;
self.last_comparison = None;
self.comparison_note = Some("history-cleared".to_string());
self.selected_history = 0;
self.session_results.clear();
self.flash_message = Some(format!("cleared {deleted} saved runs for {}", self.target));
self.clear_confirm_visible = false;
Ok(())
}
fn display_run_number(&self, result: &ProbeResult) -> Option<usize> {
self.display_run_number_in_slice(result, &self.stats_runs)
.or_else(|| self.display_run_number_in_slice(result, &self.recent_runs))
}
fn display_run_number_in_slice(
&self,
result: &ProbeResult,
runs: &[ProbeResult],
) -> Option<usize> {
if self.target_run_count == 0 {
return None;
}
runs.iter()
.position(|run| run.id == result.id)
.map(|index| self.target_run_count.saturating_sub(index))
.filter(|display_number| *display_number > 0)
}
fn display_run_label(&self, result: &ProbeResult) -> String {
self.display_run_number(result)
.map(|display_number| format!("run #{display_number}"))
.unwrap_or_else(|| "run #--".to_string())
}
}
async fn handle_key_event(app: &mut WatchApp, key: KeyCode) -> Result<()> {
if app.clear_confirm_visible {
match key {
KeyCode::Char('y') | KeyCode::Char('Y') => {
app.clear_target_history()?;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.clear_confirm_visible = false;
app.flash_message = Some("clear cancelled".to_string());
}
_ => {}
}
return Ok(());
}
match key {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('p') => {
app.paused = !app.paused;
app.flash_message = Some(if app.paused {
"watch paused".to_string()
} else {
"watch resumed".to_string()
});
}
KeyCode::Char('r') => app.force_probe = true,
KeyCode::Char('h') => app.history_expanded = !app.history_expanded,
KeyCode::Char('t') => app.tls_detail_expanded = !app.tls_detail_expanded,
KeyCode::Char('e') => {
app.export_session_csv()?;
}
KeyCode::Char('c') => {
app.clear_confirm_visible = true;
app.flash_message = Some("confirm clear with y/n".to_string());
}
KeyCode::Char('?') => app.help_visible = !app.help_visible,
KeyCode::Up => app.move_selection_up(),
KeyCode::Down => app.move_selection_down(),
_ => {}
}
Ok(())
}
fn draw(frame: &mut ratatui::Frame<'_>, app: &WatchApp) {
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(2),
])
.split(frame.area());
draw_header(frame, areas[0], app);
draw_body(frame, areas[1], app);
draw_footer(frame, areas[2], app);
if app.help_visible {
draw_help_overlay(frame);
}
if app.clear_confirm_visible {
draw_clear_confirm_overlay(frame, app);
}
}
fn draw_header(frame: &mut ratatui::Frame<'_>, area: Rect, app: &WatchApp) {
let block = Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(Color::Rgb(33, 38, 45)));
frame.render_widget(block, area);
let inner = area.inner(Margin {
vertical: 0,
horizontal: 1,
});
let header_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(20), Constraint::Length(28)])
.split(inner);
let session = format_elapsed(app.session_started_at.elapsed());
let run_label = app
.current_result
.as_ref()
.map(|result| app.display_run_label(result))
.unwrap_or_else(|| "run #--".to_string());
let header_left = Line::from(vec![
Span::styled("lobe", dim_style()),
Span::styled(" ▸ ", mute_style()),
Span::styled(app.target.as_str(), cyan_bold_style()),
Span::styled(" probe every ", mute_style()),
Span::styled(format!("{}s", app.interval_secs), base_style()),
Span::styled(" · session ", mute_style()),
Span::styled(session, base_style()),
]);
let header_right = Line::from(vec![
Span::styled(
if app.pulse_on { "● " } else { "○ " },
Style::default().fg(Color::Rgb(86, 211, 100)),
),
Span::styled(
if app.paused { "PAUSED" } else { "WATCHING" },
Style::default()
.fg(if app.paused {
Color::Rgb(227, 179, 65)
} else {
Color::Rgb(86, 211, 100)
})
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(run_label, dim_style()),
]);
frame.render_widget(Paragraph::new(header_left), header_chunks[0]);
frame.render_widget(
Paragraph::new(header_right).alignment(Alignment::Right),
header_chunks[1],
);
}
fn draw_body(frame: &mut ratatui::Frame<'_>, area: Rect, app: &WatchApp) {
let body_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(28), Constraint::Min(40)])
.margin(1)
.split(area);
draw_left_column(frame, body_chunks[0], app);
draw_right_column(frame, body_chunks[1], app);
}
fn draw_left_column(frame: &mut ratatui::Frame<'_>, area: Rect, app: &WatchApp) {
let tls_height = if app.tls_detail_expanded { 11 } else { 7 };
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(10),
Constraint::Length(7),
Constraint::Length(tls_height),
Constraint::Min(8),
])
.split(area);
draw_latest_run(frame, chunks[0], app);
draw_percentiles(frame, chunks[1], app);
draw_tls_panel(frame, chunks[2], app);
draw_request_panel(frame, chunks[3], app);
}
fn draw_right_column(frame: &mut ratatui::Frame<'_>, area: Rect, app: &WatchApp) {
let chunks = if app.history_expanded {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8),
Constraint::Min(12),
Constraint::Length(5),
])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8),
Constraint::Min(10),
Constraint::Length(5),
])
.split(area)
};
draw_timing_breakdown(frame, chunks[0], app);
draw_recent_runs(frame, chunks[1], app);
draw_regressions(frame, chunks[2], app);
}
fn draw_latest_run(frame: &mut ratatui::Frame<'_>, area: Rect, app: &WatchApp) {
let title = panel_title("LATEST RUN", None);
let block = panel_block(title, None);
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines = Vec::new();
if let Some(result) = &app.current_result {
lines.extend(stat_lines(&[
(
"dns".to_string(),
format!("{}ms", result.report.dns_ms),
style_for_ms(result.report.dns_ms, 30),
),
(
"tcp".to_string(),
format!("{}ms", result.report.tcp_ms),
style_for_ms(result.report.tcp_ms, 30),
),
(
"tls".to_string(),
format!("{}ms", result.report.tls_ms),
style_for_ms(result.report.tls_ms, 60),
),
(
"ttfb".to_string(),
format!("{}ms", result.report.ttfb_ms),
style_for_ms(result.report.ttfb_ms, 120),
),
(
"total".to_string(),
format!("{}ms", result.report.total_ms),
white_style(),
),
]));
lines.push(Line::from(" "));
lines.extend(stat_lines(&[
(
"status".to_string(),
display_http_status(result),
style_for_result_status(result),
),
(
"bytes".to_string(),
result
.response_bytes
.map(format_bytes)
.unwrap_or_else(|| "n/a".to_string()),
base_style(),
),
(
"at".to_string(),
format_time(result.created_at_ms),
mute_style(),
),
]));
} else {
lines.push(Line::from(Span::styled("waiting for first run", dim_style())));
}
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_percentiles(frame: &mut ratatui::Frame<'_>, area: Rect, app: &WatchApp) {
let sample_count = app.stats_runs.len();
let title = panel_title("P50/P95", Some(format!("({sample_count} runs)")));
let block = panel_block(title, None);
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = vec![
percentile_line("dns", percentile_pair(&app.stats_runs, |run| run.report.dns_ms)),
percentile_line("tcp", percentile_pair(&app.stats_runs, |run| run.report.tcp_ms)),
percentile_line("tls", percentile_pair(&app.stats_runs, |run| run.report.tls_ms)),
percentile_line("ttfb", percentile_pair(&app.stats_runs, |run| run.report.ttfb_ms)),
percentile_line("total", percentile_pair(&app.stats_runs, |run| run.report.total_ms)),
];
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_tls_panel(frame: &mut ratatui::Frame<'_>, area: Rect, app: &WatchApp) {
let title = panel_title("TLS", None);
let block = panel_block(title, None);
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = if let Some(result) = &app.current_result {
if let Some(tls) = &result.tls {
let handshake_text = match tls.status {
TlsStatus::NotUsed => "not used".to_string(),
TlsStatus::HandshakeSucceeded => "✓ verified".to_string(),
TlsStatus::HandshakeFailed => "failed".to_string(),
};
let handshake_style = match tls.status {
TlsStatus::NotUsed => mute_style(),
TlsStatus::HandshakeSucceeded => green_style(),
TlsStatus::HandshakeFailed => red_style(),
};
let mut lines = vec![
tls_line("handshake", handshake_text, handshake_style),
tls_line(
"version",
tls.version.as_deref().unwrap_or("n/a").to_string(),
if tls.version.is_some() { cyan_style() } else { mute_style() },
),
tls_line(
"cipher",
tls.cipher_suite
.as_deref()
.unwrap_or("n/a")
.to_string(),
base_style(),
),
];
let cert_expiry = tls
.certificate
.as_ref()
.and_then(|certificate| certificate.not_after.as_ref())
.cloned()
.unwrap_or_else(|| "n/a".to_string());
let cert_issuer = tls
.certificate
.as_ref()
.map(|certificate| certificate.issuer.clone())
.unwrap_or_else(|| "n/a".to_string());
if app.tls_detail_expanded {
let cert_subject = tls
.certificate
.as_ref()
.map(|certificate| certificate.subject.clone())
.unwrap_or_else(|| "n/a".to_string());
let valid_from = tls
.certificate
.as_ref()
.and_then(|certificate| certificate.not_before.as_ref())
.cloned()
.unwrap_or_else(|| "n/a".to_string());
lines.push(tls_line("cert expires", cert_expiry, yellow_style()));
lines.push(tls_line("issuer", cert_issuer, mute_style()));
lines.push(tls_line("subject", cert_subject, base_style()));
lines.push(tls_line("valid from", valid_from, mute_style()));
if let Some(error_message) = &tls.error_message {
lines.push(tls_line("detail", error_message.clone(), red_style()));
}
} else {
lines.push(tls_line("cert expires", cert_expiry, yellow_style()));
lines.push(tls_line("issuer", cert_issuer, mute_style()));
}
lines
} else {
vec![Line::from(Span::styled("no tls data", dim_style()))]
}
} else {
vec![Line::from(Span::styled("waiting for first run", dim_style()))]
};
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_request_panel(frame: &mut ratatui::Frame<'_>, area: Rect, app: &WatchApp) {
let title = panel_title("REQUEST", None);
let block = panel_block(title, None);
let inner = block.inner(area);
frame.render_widget(block, area);
let result = app
.recent_runs
.get(app.selected_history)
.or(app.current_result.as_ref());
let lines = if let Some(result) = result {
let host = url::Url::parse(&result.target)
.ok()
.and_then(|url| url.host_str().map(|host| host.to_string()))
.unwrap_or_else(|| "n/a".to_string());
let scheme = url::Url::parse(&result.target)
.ok()
.map(|url| url.scheme().to_string())
.unwrap_or_else(|| "n/a".to_string());
let mut lines = vec![
tls_line("path", result.request_path.clone(), cyan_style()),
tls_line("host", host, base_style()),
tls_line("scheme", scheme, mute_style()),
tls_line("response", display_http_status(result), style_for_result_status(result)),
tls_line(
"bytes",
result
.response_bytes
.map(format_bytes)
.unwrap_or_else(|| "n/a".to_string()),
base_style(),
),
tls_line(
"run",
app.display_run_label(result).replace("run ", ""),
dim_style(),
),
];
if let Some(error_message) = &result.error_message {
lines.push(Line::from(" "));
lines.push(Line::from(vec![
Span::styled("error ", mute_style()),
Span::styled(error_message.clone(), red_style()),
]));
}
lines
} else {
vec![Line::from(Span::styled("waiting for first run", dim_style()))]
};
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_timing_breakdown(frame: &mut ratatui::Frame<'_>, area: Rect, app: &WatchApp) {
let title_suffix = app
.current_result
.as_ref()
.map(|result| format!("{} · {}ms total", app.display_run_label(result), result.report.total_ms));
let block = panel_block(panel_title("TIMING BREAKDOWN", None), title_suffix);
let inner = block.inner(area);
frame.render_widget(block, area);
if let Some(result) = &app.current_result {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
let total = result.report.total_ms.max(1);
render_phase_row(
frame,
rows[0],
"dns",
result.report.dns_ms,
total,
Color::Rgb(57, 208, 200),
);
render_phase_row(
frame,
rows[1],
"tcp",
result.report.tcp_ms,
total,
Color::Rgb(121, 192, 255),
);
render_phase_row(
frame,
rows[2],
"tls",
result.report.tls_ms,
total,
Color::Rgb(164, 143, 255),
);
render_phase_row(
frame,
rows[3],
"ttfb",
result.report.ttfb_ms,
total,
Color::Rgb(227, 179, 65),
);
render_phase_row(
frame,
rows[4],
"total",
result.report.total_ms,
total,
Color::Rgb(132, 141, 151),
);
} else {
frame.render_widget(
Paragraph::new("waiting for first run").style(dim_style()),
inner,
);
}
}
fn draw_recent_runs(frame: &mut ratatui::Frame<'_>, area: Rect, app: &WatchApp) {
let suffix = format!("last {} · sorted newest first", app.recent_runs.len());
let block = panel_block(
panel_title("RECENT RUNS", None),
Some(suffix),
);
let inner = block.inner(area);
frame.render_widget(block, area);
let header = Row::new(vec![
Cell::from("#").style(dim_style()),
Cell::from("time").style(dim_style()),
Cell::from("dns").style(dim_style()),
Cell::from("tls").style(dim_style()),
Cell::from("ttfb").style(dim_style()),
Cell::from("total").style(dim_style()),
Cell::from("status").style(dim_style()),
]);
let rows = app.recent_runs.iter().enumerate().map(|(index, result)| {
let highlight = index == app.selected_history;
let base = if highlight {
Style::default().bg(Color::Rgb(13, 43, 22))
} else {
Style::default()
};
let display_run_number = app
.display_run_number(result)
.map(|value| value.to_string())
.unwrap_or_else(|| "--".to_string());
Row::new(vec![
Cell::from(display_run_number).style(base.fg(Color::Rgb(132, 141, 151))),
Cell::from(format_time(result.created_at_ms)).style(base.fg(Color::Rgb(110, 118, 129))),
Cell::from(format!("{}ms", result.report.dns_ms)).style(base.patch(style_for_ms(result.report.dns_ms, 30))),
Cell::from(format!("{}ms", result.report.tls_ms)).style(base.patch(style_for_ms(result.report.tls_ms, 60))),
Cell::from(format!("{}ms", result.report.ttfb_ms)).style(base.patch(style_for_ms(result.report.ttfb_ms, 120))),
Cell::from(format!("{}ms", result.report.total_ms)).style(base.patch(white_style())),
Cell::from(status_badge(result)).style(base.patch(style_for_result_status(result))),
])
});
let table = Table::new(
rows,
[
Constraint::Length(4),
Constraint::Length(10),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(9),
Constraint::Length(9),
Constraint::Length(8),
],
)
.header(header)
.column_spacing(1);
frame.render_widget(table, inner);
}
fn draw_regressions(frame: &mut ratatui::Frame<'_>, area: Rect, app: &WatchApp) {
let block = panel_block(
panel_title("REGRESSIONS", None),
Some("vs p50 baseline".to_string()),
)
.border_style(Style::default().fg(Color::Rgb(94, 40, 40)));
let inner = block.inner(area);
frame.render_widget(block, area);
let items = build_regression_items(app)
.into_iter()
.map(ListItem::new)
.collect::<Vec<_>>();
let list = if items.is_empty() {
List::new(vec![ListItem::new(Line::from(Span::styled(
"no regressions above baseline",
dim_style(),
)))])
} else {
List::new(items)
};
frame.render_widget(list, inner);
}
fn draw_footer(frame: &mut ratatui::Frame<'_>, area: Rect, app: &WatchApp) {
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::Rgb(33, 38, 45)));
frame.render_widget(block, area);
let inner = area.inner(Margin {
vertical: 0,
horizontal: 1,
});
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(40), Constraint::Length(36)])
.split(inner);
let left = Line::from(vec![
footer_key("q", "quit"),
Span::raw(" "),
footer_key("p", "pause"),
Span::raw(" "),
footer_key("r", "probe now"),
Span::raw(" "),
footer_key("h", "history"),
Span::raw(" "),
footer_key("t", "tls detail"),
Span::raw(" "),
footer_key("e", "export"),
Span::raw(" "),
footer_key("c", "clear"),
Span::raw(" "),
footer_key("?", "help"),
]);
let (ok_count, fail_count) = app.success_failure_counts();
let db_hint = app
.last_export_path
.as_ref()
.map(|path| path.display().to_string())
.or_else(|| app.flash_message.clone())
.unwrap_or_else(|| "db: ~/.lobe/history.db".to_string());
let right = Line::from(vec![
Span::styled(db_hint, dim_style()),
Span::raw(" "),
Span::styled("✓", green_style()),
Span::raw(" "),
Span::styled(format!("{ok_count}/{}", app.session_results.len()), green_style()),
Span::raw(" "),
Span::styled("✕", red_style()),
Span::raw(" "),
Span::styled(format!("{fail_count}/{}", app.session_results.len()), red_style()),
]);
frame.render_widget(Paragraph::new(left), chunks[0]);
frame.render_widget(Paragraph::new(right).alignment(Alignment::Right), chunks[1]);
}
fn draw_help_overlay(frame: &mut ratatui::Frame<'_>) {
let area = centered_rect(58, 52, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(Span::styled(" Help ", white_style()))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(48, 54, 61)))
.padding(Padding::uniform(1))
.style(Style::default().bg(Color::Rgb(14, 17, 23)));
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = vec![
Line::from(vec![footer_key("q", "quit"), Span::raw(" exit the TUI")]),
Line::from(vec![footer_key("p", "pause"), Span::raw(" pause or resume probing")]),
Line::from(vec![footer_key("r", "probe"), Span::raw(" run a probe immediately")]),
Line::from(vec![footer_key("h", "history"), Span::raw(" expand or compress history area")]),
Line::from(vec![footer_key("t", "tls"), Span::raw(" expand TLS detail panel")]),
Line::from(vec![footer_key("e", "export"), Span::raw(" export current session to CSV")]),
Line::from(vec![footer_key("c", "clear"), Span::raw(" clear saved history for current target")]),
Line::from(vec![footer_key("↑↓", "select"), Span::raw(" move history selection")]),
Line::from(vec![footer_key("?", "help"), Span::raw(" toggle this overlay")]),
];
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_clear_confirm_overlay(frame: &mut ratatui::Frame<'_>, app: &WatchApp) {
let area = centered_rect(64, 22, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(Span::styled(" Clear History ", white_style()))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(94, 40, 40)))
.padding(Padding::uniform(1))
.style(Style::default().bg(Color::Rgb(14, 17, 23)));
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = vec![
Line::from(Span::styled(
format!("Clear saved history for {} ?", app.target),
base_style(),
)),
Line::from(" "),
Line::from(vec![
footer_key("y", "yes"),
Span::raw(" "),
footer_key("n", "no"),
]),
];
frame.render_widget(
Paragraph::new(lines)
.alignment(Alignment::Center)
.wrap(Wrap { trim: false }),
inner,
);
}
fn panel_block(title: Line<'static>, right_label: Option<String>) -> Block<'static> {
let title_line = if let Some(label) = right_label {
Line::from(vec![
Span::raw(" "),
Span::styled("▸ ", mute_style()),
Span::styled(extract_title_text(&title), dim_style().add_modifier(Modifier::BOLD)),
Span::styled(format!("{:>1$}", label, 44), dim_style()),
])
} else {
title
};
Block::default()
.title(title_line)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(48, 54, 61)))
.padding(Padding::new(1, 1, 1, 0))
.style(Style::default().bg(Color::Rgb(14, 17, 23)))
}
fn panel_title(title: &str, suffix: Option<String>) -> Line<'static> {
let mut spans = vec![
Span::styled("▸ ", mute_style()),
Span::styled(title.to_string(), dim_style().add_modifier(Modifier::BOLD)),
];
if let Some(suffix) = suffix {
spans.push(Span::raw(" "));
spans.push(Span::styled(suffix, dim_style()));
}
Line::from(spans)
}
fn render_phase_row(
frame: &mut ratatui::Frame<'_>,
area: Rect,
label: &str,
value_ms: u64,
total_ms: u64,
bar_color: Color,
) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(6),
Constraint::Min(10),
Constraint::Length(8),
Constraint::Length(5),
])
.split(area);
frame.render_widget(Paragraph::new(label).style(mute_style()), chunks[0]);
let percent = if total_ms == 0 {
0.0
} else {
value_ms as f64 / total_ms as f64
};
let bar_width = chunks[1].width.saturating_sub(1) as usize;
let filled = ((bar_width as f64) * percent).round() as usize;
let mut spans = Vec::with_capacity(bar_width);
for index in 0..bar_width {
let style = if index < filled {
Style::default().fg(bar_color)
} else {
Style::default().fg(Color::Rgb(33, 38, 45))
};
spans.push(Span::styled(symbols::block::FULL, style));
}
frame.render_widget(Paragraph::new(Line::from(spans)), chunks[1]);
frame.render_widget(
Paragraph::new(format!("{}ms", value_ms)).style(style_for_ms(value_ms, 100)),
chunks[2],
);
frame.render_widget(
Paragraph::new(format!("{:>3}%", (percent * 100.0).round() as u64)).style(dim_style()),
chunks[3],
);
}
fn build_regression_items(app: &WatchApp) -> Vec<Line<'static>> {
let Some(baseline) = percentile(&app.stats_runs, |run| run.report.ttfb_ms, 0.50) else {
return Vec::new();
};
let Some(dns_baseline) = percentile(&app.stats_runs, |run| run.report.dns_ms, 0.50) else {
return Vec::new();
};
let mut alerts = Vec::new();
for run in &app.recent_runs {
if baseline > 0 && run.report.ttfb_ms > baseline {
let delta_percent = ((run.report.ttfb_ms as f64 - baseline as f64) / baseline as f64) * 100.0;
if delta_percent >= app.threshold_percent {
alerts.push((
delta_percent,
Line::from(vec![
Span::styled("▲ ", red_style()),
Span::styled(format!("{} · ttfb spike: ", app.display_run_label(run)), base_style()),
Span::styled(format!("{}ms", run.report.ttfb_ms), red_style()),
Span::styled(format!(" vs baseline {}ms", baseline), dim_style()),
Span::raw(" "),
Span::styled(format!("+{:.0}%", delta_percent), red_style()),
]),
));
}
}
if dns_baseline > 0 && run.report.dns_ms > dns_baseline {
let delta_percent =
((run.report.dns_ms as f64 - dns_baseline as f64) / dns_baseline as f64) * 100.0;
if delta_percent >= app.threshold_percent {
alerts.push((
delta_percent,
Line::from(vec![
Span::styled("▲ ", yellow_style()),
Span::styled(format!("{} · dns elevated: ", app.display_run_label(run)), base_style()),
Span::styled(format!("{}ms", run.report.dns_ms), yellow_style()),
Span::styled(format!(" vs baseline {}ms", dns_baseline), dim_style()),
Span::raw(" "),
Span::styled(format!("+{:.0}%", delta_percent), yellow_style()),
]),
));
}
}
}
alerts.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(Ordering::Equal));
alerts.into_iter().take(2).map(|(_, line)| line).collect()
}
fn stat_lines(items: &[(String, String, Style)]) -> Vec<Line<'static>> {
items.iter()
.map(|(label, value, style)| {
Line::from(vec![
Span::styled(format!("{label:<8}"), mute_style()),
Span::styled(value.clone(), *style),
])
})
.collect()
}
fn percentile_line(label: &str, values: Option<(u64, u64)>) -> Line<'static> {
let value = values
.map(|(p50, p95)| format!("{p50}ms / {p95}ms"))
.unwrap_or_else(|| "n/a / n/a".to_string());
Line::from(vec![
Span::styled(format!("{label:<8}"), mute_style()),
Span::styled(value, base_style()),
])
}
fn percentile_pair<F>(runs: &[ProbeResult], selector: F) -> Option<(u64, u64)>
where
F: Fn(&ProbeResult) -> u64 + Copy,
{
let p50 = percentile(runs, selector, 0.50)?;
let p95 = percentile(runs, selector, 0.95)?;
Some((p50, p95))
}
fn percentile<F>(runs: &[ProbeResult], selector: F, percentile: f64) -> Option<u64>
where
F: Fn(&ProbeResult) -> u64,
{
if runs.is_empty() {
return None;
}
let mut values = runs.iter().map(selector).collect::<Vec<_>>();
values.sort_unstable();
let index = ((values.len() - 1) as f64 * percentile).round() as usize;
values.get(index).copied()
}
fn tls_line(label: &str, value: String, style: Style) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{label:<12}"), mute_style()),
Span::styled(value, style),
])
}
fn centered_rect(horizontal_percent: u16, vertical_percent: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - vertical_percent) / 2),
Constraint::Percentage(vertical_percent),
Constraint::Percentage((100 - vertical_percent) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - horizontal_percent) / 2),
Constraint::Percentage(horizontal_percent),
Constraint::Percentage((100 - horizontal_percent) / 2),
])
.split(vertical[1])[1]
}
fn footer_key(key: &str, label: &str) -> Span<'static> {
Span::styled(
format!(" {key} {label} "),
Style::default()
.fg(Color::Rgb(139, 148, 158))
.bg(Color::Rgb(33, 38, 45)),
)
}
fn extract_title_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
.trim()
.trim_start_matches('▸')
.trim()
.to_string()
}
fn format_elapsed(duration: Duration) -> String {
let minutes = duration.as_secs() / 60;
let seconds = duration.as_secs() % 60;
format!("00:{minutes:02}:{seconds:02}")
}
fn format_time(created_at_ms: i64) -> String {
let created_at = DateTime::<Local>::from(std::time::UNIX_EPOCH + Duration::from_millis(created_at_ms as u64));
created_at.format("%H:%M:%S").to_string()
}
fn format_status(status: ProbeStatus) -> &'static str {
match status {
ProbeStatus::Succeeded => "succeeded",
ProbeStatus::Failed => "failed",
}
}
fn style_for_status(status: ProbeStatus) -> Style {
match status {
ProbeStatus::Succeeded => green_style(),
ProbeStatus::Failed => red_style(),
}
}
fn status_badge(result: &ProbeResult) -> String {
match result.response_status_code {
Some(code) => code.to_string(),
None => status_badge_from_probe_status(result.status),
}
}
fn status_badge_from_probe_status(status: ProbeStatus) -> String {
match status {
ProbeStatus::Succeeded => "OK".to_string(),
ProbeStatus::Failed => "ERR".to_string(),
}
}
fn style_for_result_status(result: &ProbeResult) -> Style {
match result.response_status_code {
Some(code) if (200..300).contains(&code) => green_style(),
Some(code) if (300..400).contains(&code) => yellow_style(),
Some(code) if (400..600).contains(&code) => red_style(),
Some(_) => base_style(),
None => style_for_status(result.status),
}
}
fn display_http_status(result: &ProbeResult) -> String {
match (result.response_status_code, result.response_status_text.as_deref()) {
(Some(code), Some(text)) => format!("{code} {text}"),
(Some(code), None) => code.to_string(),
(None, _) if matches!(result.status, ProbeStatus::Failed) => "ERR".to_string(),
_ => format_status(result.status).to_string(),
}
}
fn format_bytes(bytes: u64) -> String {
if bytes >= 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{bytes} B")
}
}
fn style_for_ms(value_ms: u64, warn_threshold: u64) -> Style {
if value_ms == 0 {
base_style()
} else if value_ms >= warn_threshold {
yellow_style()
} else {
green_style()
}
}
fn cyan_bold_style() -> Style {
Style::default()
.fg(Color::Rgb(121, 192, 255))
.add_modifier(Modifier::BOLD)
}
fn white_style() -> Style {
Style::default()
.fg(Color::Rgb(230, 237, 243))
.add_modifier(Modifier::BOLD)
}
fn base_style() -> Style {
Style::default().fg(Color::Rgb(201, 209, 217))
}
fn green_style() -> Style {
Style::default().fg(Color::Rgb(86, 211, 100))
}
fn yellow_style() -> Style {
Style::default().fg(Color::Rgb(227, 179, 65))
}
fn red_style() -> Style {
Style::default().fg(Color::Rgb(248, 81, 73))
}
fn cyan_style() -> Style {
Style::default().fg(Color::Rgb(121, 192, 255))
}
fn mute_style() -> Style {
Style::default().fg(Color::Rgb(110, 118, 129))
}
fn dim_style() -> Style {
Style::default().fg(Color::Rgb(132, 141, 151))
}