use std::collections::{BTreeSet, HashMap};
use std::fs::File;
use std::io;
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
use chrono::{DateTime, Local};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::execute;
use crossterm::event::{DisableFocusChange, EnableFocusChange};
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::widgets::{Block, Borders, Cell, Clear, Padding, Paragraph, Row, Table, Wrap};
use ratatui::Terminal;
use lobe_core::engine::capture::{
CaptureProxy, CaptureSessionExport, CapturedExchange, GitContext,
};
use lobe_core::error::Result;
use lobe_core::models::TlsStatus;
use crate::upload;
const MAX_CAPTURED_EVENTS: usize = 300;
pub async fn run_capture_tui(
listen_addr: String,
upstream: String,
auto_upload: bool,
git_context: Option<GitContext>,
) -> Result<()> {
let mut stdout = io::stdout();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen, EnableFocusChange)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let _guard = TerminalGuard;
let (sender, receiver) = mpsc::channel();
let proxy = CaptureProxy::new(listen_addr.clone(), &upstream, sender.clone())?;
let listen_label = proxy.listen_addr().to_string();
let upstream_label = proxy.upstream().to_string();
tokio::spawn(async move {
if let Err(error) = proxy.run().await {
let _ = sender.send(CapturedExchange {
target: upstream_label,
request_host: "n/a".to_string(),
request_method: "SYSTEM".to_string(),
request_path: "/".to_string(),
request_headers: Vec::new(),
response_headers: Vec::new(),
protocol_version: None,
redirect_location: None,
response_status_code: Some(502),
response_status_text: Some("Proxy Error".to_string()),
response_bytes: 0,
report: Default::default(),
tls: None,
error_message: Some(error.to_string()),
created_at_ms: Local::now().timestamp_millis(),
connection_reuse: "new".to_string(),
});
}
});
let mut app = CaptureApp::new(listen_label, upstream, receiver, auto_upload, git_context);
let mut last_forced_refresh = Instant::now();
while !app.should_quit {
if last_forced_refresh.elapsed() >= Duration::from_secs(3) {
app.needs_full_redraw = true;
last_forced_refresh = Instant::now();
}
if app.needs_full_redraw {
terminal.clear()?;
app.needs_full_redraw = false;
}
app.drain_events();
terminal.draw(|frame| draw(frame, &app))?;
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);
}
}
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(), DisableFocusChange, LeaveAlternateScreen);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DetailPane {
Request,
Tls,
}
#[derive(Debug, Clone)]
struct RouteSummary {
path: String,
request_count: usize,
failure_count: usize,
average_total_ms: u64,
average_ttfb_ms: u64,
p95_total_ms: u64,
p95_ttfb_ms: u64,
}
#[derive(Debug, Clone)]
struct HostSummary {
host: String,
request_count: usize,
failure_count: usize,
average_total_ms: u64,
}
#[derive(Debug)]
struct CaptureApp {
listen_addr: String,
upstream: String,
receiver: Receiver<CapturedExchange>,
events: Vec<CapturedExchange>,
paused_buffer: Vec<CapturedExchange>,
paused: bool,
should_quit: bool,
help_visible: bool,
selected: usize,
method_filter_index: usize,
path_filter_index: usize,
detail_pane: DetailPane,
summary_mode: bool,
session_started_at: Instant,
pulse_on: bool,
last_pulse_flip: Instant,
flash_message: Option<String>,
needs_full_redraw: bool,
auto_upload: bool,
git_context: Option<GitContext>,
}
impl CaptureApp {
fn new(
listen_addr: String,
upstream: String,
receiver: Receiver<CapturedExchange>,
auto_upload: bool,
git_context: Option<GitContext>,
) -> Self {
let flash = if auto_upload {
"browse the local proxy URL to capture requests · press `u` to upload".to_string()
} else {
"browse the local proxy URL to capture real requests".to_string()
};
Self {
listen_addr,
upstream,
receiver,
events: Vec::new(),
paused_buffer: Vec::new(),
paused: false,
should_quit: false,
help_visible: false,
selected: 0,
method_filter_index: 0,
path_filter_index: 0,
detail_pane: DetailPane::Request,
summary_mode: false,
session_started_at: Instant::now(),
pulse_on: true,
last_pulse_flip: Instant::now(),
flash_message: Some(flash),
needs_full_redraw: false,
auto_upload,
git_context,
}
}
fn drain_events(&mut self) {
while let Ok(exchange) = self.receiver.try_recv() {
if self.paused {
self.paused_buffer.push(exchange);
} else {
self.push_event(exchange);
}
}
}
fn push_event(&mut self, exchange: CapturedExchange) {
self.events.insert(0, exchange);
if self.events.len() > MAX_CAPTURED_EVENTS {
self.events.truncate(MAX_CAPTURED_EVENTS);
}
self.selected = 0;
}
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 toggle_pause(&mut self) {
self.paused = !self.paused;
if !self.paused {
let pending = std::mem::take(&mut self.paused_buffer);
for exchange in pending.into_iter().rev() {
self.push_event(exchange);
}
self.flash_message = Some("capture resumed".to_string());
} else {
self.flash_message = Some("capture paused".to_string());
}
}
fn cycle_method_filter(&mut self) {
let len = self.method_filters().len();
self.method_filter_index = (self.method_filter_index + 1) % len.max(1);
self.selected = 0;
self.flash_message = Some(format!("method filter: {}", self.current_method_filter_label()));
}
fn cycle_path_filter(&mut self) {
let len = self.path_filters().len();
self.path_filter_index = (self.path_filter_index + 1) % len.max(1);
self.selected = 0;
self.flash_message = Some(format!("path filter: {}", self.current_path_filter_label()));
}
fn toggle_detail_pane(&mut self) {
self.detail_pane = match self.detail_pane {
DetailPane::Request => DetailPane::Tls,
DetailPane::Tls => DetailPane::Request,
};
self.flash_message = Some(match self.detail_pane {
DetailPane::Request => "detail pane: request".to_string(),
DetailPane::Tls => "detail pane: tls".to_string(),
});
}
fn toggle_summary_mode(&mut self) {
self.summary_mode = !self.summary_mode;
self.flash_message = Some(if self.summary_mode {
"summary view".to_string()
} else {
"request stream view".to_string()
});
}
fn method_filters(&self) -> Vec<String> {
let mut values = BTreeSet::new();
for event in &self.events {
values.insert(event.request_method.clone());
}
let mut filters = vec!["ALL".to_string()];
filters.extend(values);
filters
}
fn path_filters(&self) -> Vec<String> {
let mut values = BTreeSet::new();
for event in &self.events {
values.insert(event.request_path.clone());
}
let mut filters = vec!["ALL".to_string()];
filters.extend(values);
filters
}
fn current_method_filter_label(&self) -> String {
self.method_filters()
.get(self.method_filter_index)
.cloned()
.unwrap_or_else(|| "ALL".to_string())
}
fn current_path_filter_label(&self) -> String {
self.path_filters()
.get(self.path_filter_index)
.cloned()
.unwrap_or_else(|| "ALL".to_string())
}
fn filtered_events(&self) -> Vec<&CapturedExchange> {
let method_filter = self.current_method_filter_label();
let path_filter = self.current_path_filter_label();
self.events
.iter()
.filter(|event| {
if method_filter == "ALL" {
true
} else {
event.request_method.eq_ignore_ascii_case(&method_filter)
}
})
.filter(|event| {
if path_filter == "ALL" {
true
} else {
event.request_path == path_filter
}
})
.collect()
}
fn selected_event(&self) -> Option<&CapturedExchange> {
self.filtered_events().get(self.selected).copied()
}
fn move_selection_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
fn move_selection_down(&mut self) {
let filtered_len = self.filtered_events().len();
if self.selected + 1 < filtered_len {
self.selected += 1;
}
}
fn clear_session(&mut self) {
self.events.clear();
self.paused_buffer.clear();
self.selected = 0;
self.method_filter_index = 0;
self.path_filter_index = 0;
self.flash_message = Some("capture session cleared".to_string());
}
fn export_session_json(&mut self) -> Result<()> {
let path = PathBuf::from(format!(
"/tmp/lobe-capture-session-{}.json",
Local::now().format("%Y%m%d-%H%M%S")
));
let file = File::create(&path)?;
let payload = CaptureSessionExport {
version: 1,
listen_addr: self.listen_addr.clone(),
upstream: self.upstream.clone(),
exported_at_ms: Local::now().timestamp_millis(),
event_count: self.events.len(),
events: self.events.iter().rev().cloned().collect(),
git_context: self.git_context.clone(),
};
serde_json::to_writer_pretty(file, &payload)
.map_err(|error| io::Error::other(error.to_string()))?;
self.flash_message = Some(format!("✎ Exner exported to {}", path.display()));
Ok(())
}
fn upload_session_now(&mut self) {
if self.events.is_empty() {
self.flash_message = Some("no requests captured yet — nothing to upload".to_string());
return;
}
let payload = CaptureSessionExport {
version: 1,
listen_addr: self.listen_addr.clone(),
upstream: self.upstream.clone(),
exported_at_ms: Local::now().timestamp_millis(),
event_count: self.events.len(),
events: self.events.iter().rev().cloned().collect(),
git_context: self.git_context.clone(),
};
self.flash_message = Some("🧬 Hippocampus syncing…".to_string());
let result = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(upload::upload_session(&payload))
});
match result {
Ok(response) => {
self.flash_message = Some(format!(
"✓ Hippocampus synced {} requests",
response.event_count,
));
}
Err(err) => {
self.flash_message = Some(format!("upload failed: {err}"));
}
}
}
}
fn handle_key_event(app: &mut CaptureApp, key: KeyCode) {
match key {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('p') => app.toggle_pause(),
KeyCode::Char('m') => app.cycle_method_filter(),
KeyCode::Char('f') => app.cycle_path_filter(),
KeyCode::Char('s') => app.toggle_summary_mode(),
KeyCode::Char('c') => app.clear_session(),
KeyCode::Char('e') => {
if let Err(error) = app.export_session_json() {
app.flash_message = Some(format!("capture export failed: {error}"));
} else if app.auto_upload {
app.upload_session_now();
}
}
KeyCode::Char('u') => {
app.upload_session_now();
}
KeyCode::Tab | KeyCode::Char('d') => app.toggle_detail_pane(),
KeyCode::Char('?') => app.help_visible = !app.help_visible,
KeyCode::Up => app.move_selection_up(),
KeyCode::Down => app.move_selection_down(),
_ => {}
}
}
fn draw(frame: &mut ratatui::Frame<'_>, app: &CaptureApp) {
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);
}
}
fn draw_header(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
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 chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(20), Constraint::Length(30)])
.split(inner);
let left = Line::from(vec![
Span::styled("lobe", dim_style()),
Span::styled(" ▸ ", mute_style()),
Span::styled("capture", cyan_bold_style()),
Span::styled(" local ", mute_style()),
Span::styled(format!("http://{}", app.listen_addr), base_style()),
Span::styled(" → upstream ", mute_style()),
Span::styled(app.upstream.as_str(), base_style()),
]);
let 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 { "CAPTURING" },
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(format!("{} reqs", app.events.len()), dim_style()),
]);
frame.render_widget(Paragraph::new(left), chunks[0]);
frame.render_widget(Paragraph::new(right).alignment(Alignment::Right), chunks[1]);
}
fn draw_body(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(34), Constraint::Min(40)])
.margin(1)
.split(area);
draw_left_column(frame, chunks[0], app);
draw_right_column(frame, chunks[1], app);
}
fn draw_left_column(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6),
Constraint::Length(6),
Constraint::Length(10),
Constraint::Min(8),
])
.split(area);
draw_stream_panel(frame, chunks[0], app);
draw_filters_panel(frame, chunks[1], app);
draw_selected_request_panel(frame, chunks[2], app);
draw_detail_panel(frame, chunks[3], app);
}
fn draw_right_column(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
if app.summary_mode {
draw_capture_summary_view(frame, area, app);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(12), Constraint::Length(5)])
.split(area);
draw_capture_table(frame, chunks[0], app);
draw_summary_panel(frame, chunks[1], app);
}
fn draw_capture_summary_view(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Min(8),
])
.split(area);
draw_summary_panel(frame, chunks[0], app);
draw_top_slowest_paths(frame, chunks[1], app);
draw_top_failing_paths(frame, chunks[2], app);
let bottom = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[3]);
draw_top_hosts(frame, bottom[0], app);
draw_route_stats(frame, bottom[1], app);
}
fn draw_stream_panel(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
let block = panel_block(panel_title("STREAM", None), None);
let inner = block.inner(area);
frame.render_widget(block, area);
let session = format_elapsed(app.session_started_at.elapsed());
let lines = vec![
info_line("listen", format!("http://{}", app.listen_addr), cyan_style()),
info_line("upstream", app.upstream.clone(), base_style()),
info_line("session", session, mute_style()),
info_line(
"buffered",
app.paused_buffer.len().to_string(),
if app.paused_buffer.is_empty() { mute_style() } else { yellow_style() },
),
];
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_filters_panel(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
let block = panel_block(panel_title("FILTERS", None), None);
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = vec![
info_line("method", app.current_method_filter_label(), cyan_style()),
info_line("path", app.current_path_filter_label(), cyan_style()),
info_line(
"detail",
match app.detail_pane {
DetailPane::Request => "request".to_string(),
DetailPane::Tls => "tls".to_string(),
},
base_style(),
),
info_line(
"state",
if app.paused { "paused" } else { "live" }.to_string(),
if app.paused { yellow_style() } else { green_style() },
),
];
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_selected_request_panel(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
let block = panel_block(panel_title("SELECTED REQUEST", None), None);
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = if let Some(event) = app.selected_event() {
vec![
info_line("method", event.request_method.clone(), style_for_method(&event.request_method)),
info_line("host", event.request_host.clone(), mute_style()),
info_line("path", display_request_path(event), cyan_style()),
info_line("status", display_status(event), style_for_status_code(event.response_status_code)),
info_line("bytes", format_bytes(event.response_bytes), base_style()),
info_line("ttfb", format!("{}ms", event.report.ttfb_ms), style_for_ms(event.report.ttfb_ms, 120)),
info_line("download", format!("{}ms", event.report.download_ms), style_for_ms(event.report.download_ms, 120)),
info_line("total", format!("{}ms", event.report.total_ms), white_style()),
info_line("at", format_time(event.created_at_ms), mute_style()),
]
} else {
vec![Line::from(Span::styled("waiting for traffic", dim_style()))]
};
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_detail_panel(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
let title = match app.detail_pane {
DetailPane::Request => "REQUEST DETAIL",
DetailPane::Tls => "TLS DETAIL",
};
let block = panel_block(panel_title(title, None), None);
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = match app.selected_event() {
Some(event) if matches!(app.detail_pane, DetailPane::Request) => {
let mut lines = vec![
info_line(
"protocol",
event.protocol_version.clone().unwrap_or_else(|| "n/a".to_string()),
base_style(),
),
phase_bar_line(&event.report),
info_line("dns", format!("{}ms", event.report.dns_ms), style_for_ms(event.report.dns_ms, 30)),
info_line("tcp", format!("{}ms", event.report.tcp_ms), style_for_ms(event.report.tcp_ms, 30)),
info_line("tls", format!("{}ms", event.report.tls_ms), style_for_ms(event.report.tls_ms, 60)),
info_line("download", format!("{}ms", event.report.download_ms), style_for_ms(event.report.download_ms, 120)),
info_line("target", event.target.clone(), mute_style()),
];
if let Some(location) = &event.redirect_location {
lines.push(info_line("redirect", location.clone(), cyan_style()));
}
let request_summary = summarize_headers(&event.request_headers, REQUEST_HEADER_KEYS);
if !request_summary.is_empty() {
lines.push(Line::from(" "));
lines.push(Line::from(Span::styled("request headers", dim_style())));
lines.extend(request_summary.into_iter().map(|(name, value)| info_line(&name, value, mute_style())));
}
let response_summary = summarize_headers(&event.response_headers, RESPONSE_HEADER_KEYS);
if !response_summary.is_empty() {
lines.push(Line::from(" "));
lines.push(Line::from(Span::styled("response headers", dim_style())));
lines.extend(response_summary.into_iter().map(|(name, value)| info_line(&name, value, mute_style())));
}
if let Some(error) = &event.error_message {
lines.push(Line::from(" "));
lines.push(Line::from(vec![
Span::styled("error ", mute_style()),
Span::styled(error.clone(), red_style()),
]));
}
lines
}
Some(event) => {
let tls = event.tls.as_ref();
vec![
info_line(
"handshake",
tls.map(|value| match value.status {
TlsStatus::NotUsed => "not used".to_string(),
TlsStatus::HandshakeSucceeded => "verified".to_string(),
TlsStatus::HandshakeFailed => "failed".to_string(),
})
.unwrap_or_else(|| "n/a".to_string()),
match tls.map(|value| value.status) {
Some(TlsStatus::HandshakeSucceeded) => green_style(),
Some(TlsStatus::HandshakeFailed) => red_style(),
_ => mute_style(),
},
),
info_line(
"version",
tls.and_then(|value| value.version.clone()).unwrap_or_else(|| "n/a".to_string()),
base_style(),
),
info_line(
"cipher",
tls.and_then(|value| value.cipher_suite.clone()).unwrap_or_else(|| "n/a".to_string()),
mute_style(),
),
info_line(
"issuer",
tls.and_then(|value| value.certificate.as_ref().map(|cert| cert.issuer.clone()))
.unwrap_or_else(|| "n/a".to_string()),
mute_style(),
),
info_line(
"expires",
tls.and_then(|value| value.certificate.as_ref().and_then(|cert| cert.not_after.clone()))
.unwrap_or_else(|| "n/a".to_string()),
yellow_style(),
),
]
}
None => vec![Line::from(Span::styled("waiting for traffic", dim_style()))],
};
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_capture_table(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
let filtered = app.filtered_events();
let suffix = format!("{} shown · newest first", filtered.len());
let block = panel_block(panel_title("CAPTURED REQUESTS", 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("method").style(dim_style()),
Cell::from("host").style(dim_style()),
Cell::from("path").style(dim_style()),
Cell::from("status").style(dim_style()),
Cell::from("ttfb").style(dim_style()),
Cell::from("total").style(dim_style()),
Cell::from("bytes").style(dim_style()),
]);
let rows = filtered.iter().enumerate().map(|(index, event)| {
let highlight = index == app.selected;
let base = if highlight {
Style::default().bg(Color::Rgb(13, 43, 22))
} else {
Style::default()
};
Row::new(vec![
Cell::from((filtered.len().saturating_sub(index)).to_string()).style(base.patch(dim_style())),
Cell::from(format_time(event.created_at_ms)).style(base.patch(mute_style())),
Cell::from(event.request_method.clone()).style(base.patch(style_for_method(&event.request_method))),
Cell::from(event.request_host.clone()).style(base.patch(mute_style())),
Cell::from(display_request_path(event)).style(base.patch(cyan_style())),
Cell::from(status_badge(event)).style(base.patch(style_for_status_code(event.response_status_code))),
Cell::from(format!("{}ms", event.report.ttfb_ms)).style(base.patch(style_for_ms(event.report.ttfb_ms, 120))),
Cell::from(format!("{}ms", event.report.total_ms)).style(base.patch(white_style())),
Cell::from(format_bytes(event.response_bytes)).style(base.patch(base_style())),
])
});
let table = Table::new(
rows,
[
Constraint::Length(4),
Constraint::Length(10),
Constraint::Length(8),
Constraint::Length(20),
Constraint::Min(16),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(10),
],
)
.header(header)
.column_spacing(1);
frame.render_widget(table, inner);
}
fn draw_summary_panel(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
let block = panel_block(panel_title("SUMMARY", None), None);
let inner = block.inner(area);
frame.render_widget(block, area);
let filtered = app.filtered_events();
let success_count = filtered
.iter()
.filter(|event| event.response_status_code.map(|code| code < 400).unwrap_or(false))
.count();
let error_count = filtered.len().saturating_sub(success_count);
let overheads: Vec<u64> = filtered
.iter()
.map(|event| event.report.proxy_overhead_us)
.filter(|&us| us > 0)
.collect();
let overhead_span = if overheads.is_empty() {
Span::styled("—", mute_style())
} else {
let median_us = percentile_u64(&overheads, 0.5);
Span::styled(format!("+{:.2}ms", median_us as f64 / 1000.0), cyan_style())
};
let line = Line::from(vec![
Span::styled("shown ", mute_style()),
Span::styled(filtered.len().to_string(), base_style()),
Span::styled(" ok ", mute_style()),
Span::styled(success_count.to_string(), green_style()),
Span::styled(" err ", mute_style()),
Span::styled(error_count.to_string(), red_style()),
Span::styled(" proxy ", mute_style()),
overhead_span,
]);
frame.render_widget(Paragraph::new(line), inner);
}
fn draw_top_slowest_paths(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
let block = panel_block(panel_title("TOP SLOWEST PATHS", None), None);
let inner = block.inner(area);
frame.render_widget(block, area);
let mut summaries = route_summaries(&app.filtered_events());
summaries.sort_by(|a, b| {
b.p95_total_ms
.cmp(&a.p95_total_ms)
.then_with(|| b.average_total_ms.cmp(&a.average_total_ms))
.then_with(|| b.request_count.cmp(&a.request_count))
.then_with(|| a.path.cmp(&b.path))
});
let lines = if summaries.is_empty() {
vec![Line::from(Span::styled("waiting for traffic", dim_style()))]
} else {
summaries
.into_iter()
.take(5)
.map(|summary| {
Line::from(vec![
Span::styled(truncate_text(&summary.path, 26), cyan_style()),
Span::raw(" "),
Span::styled(format!("p95 {}ms", summary.p95_total_ms), yellow_style()),
Span::raw(" "),
Span::styled(format!("avg {}ms", summary.average_total_ms), mute_style()),
])
})
.collect()
};
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_top_failing_paths(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
let block = panel_block(panel_title("TOP FAILING PATHS", None), None);
let inner = block.inner(area);
frame.render_widget(block, area);
let mut summaries = route_summaries(&app.filtered_events());
summaries.sort_by(|a, b| {
b.failure_count
.cmp(&a.failure_count)
.then_with(|| b.request_count.cmp(&a.request_count))
.then_with(|| a.path.cmp(&b.path))
});
let failing = summaries
.into_iter()
.filter(|summary| summary.failure_count > 0)
.take(5)
.collect::<Vec<_>>();
let lines = if failing.is_empty() {
vec![Line::from(Span::styled("no failing paths in current view", dim_style()))]
} else {
failing
.into_iter()
.map(|summary| {
Line::from(vec![
Span::styled(truncate_text(&summary.path, 24), cyan_style()),
Span::raw(" "),
Span::styled(
format!("{}/{} fail", summary.failure_count, summary.request_count),
red_style(),
),
])
})
.collect()
};
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_top_hosts(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
let block = panel_block(panel_title("TOP HOSTS", None), None);
let inner = block.inner(area);
frame.render_widget(block, area);
let mut summaries = host_summaries(&app.filtered_events());
summaries.sort_by(|a, b| {
b.request_count
.cmp(&a.request_count)
.then_with(|| b.average_total_ms.cmp(&a.average_total_ms))
.then_with(|| a.host.cmp(&b.host))
});
let lines = if summaries.is_empty() {
vec![Line::from(Span::styled("waiting for traffic", dim_style()))]
} else {
summaries
.into_iter()
.take(5)
.map(|summary| {
Line::from(vec![
Span::styled(truncate_text(&summary.host, 24), cyan_style()),
Span::raw(" "),
Span::styled(format!("{} req", summary.request_count), base_style()),
Span::raw(" "),
Span::styled(format!("avg {}ms", summary.average_total_ms), mute_style()),
if summary.failure_count > 0 {
Span::styled(format!(" err {}", summary.failure_count), red_style())
} else {
Span::raw("")
},
])
})
.collect()
};
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_route_stats(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
let block = panel_block(panel_title("ROUTE STATS", None), None);
let inner = block.inner(area);
frame.render_widget(block, area);
let mut summaries = route_summaries(&app.filtered_events());
summaries.sort_by(|a, b| {
b.request_count
.cmp(&a.request_count)
.then_with(|| {
b.average_ttfb_ms
.cmp(&a.average_ttfb_ms)
.then_with(|| b.p95_ttfb_ms.cmp(&a.p95_ttfb_ms))
})
.then_with(|| a.path.cmp(&b.path))
});
let lines = if summaries.is_empty() {
vec![Line::from(Span::styled("waiting for traffic", dim_style()))]
} else {
summaries
.into_iter()
.take(5)
.map(|summary| {
Line::from(vec![
Span::styled(truncate_text(&summary.path, 20), cyan_style()),
Span::raw(" "),
Span::styled(format!("{}x", summary.request_count), base_style()),
Span::raw(" "),
Span::styled(format!("avg {}ms", summary.average_ttfb_ms), mute_style()),
Span::raw(" "),
Span::styled(format!("p95 {}ms", summary.p95_ttfb_ms), yellow_style()),
])
})
.collect()
};
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_footer(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CaptureApp) {
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(44)])
.split(inner);
let left = Line::from(vec![
footer_key("q", "quit"),
Span::raw(" "),
footer_key("p", "pause"),
Span::raw(" "),
footer_key("m", "method"),
Span::raw(" "),
footer_key("f", "path"),
Span::raw(" "),
footer_key("s", "summary"),
Span::raw(" "),
footer_key("tab", "detail"),
Span::raw(" "),
footer_key("e", "export"),
Span::raw(" "),
footer_key("u", "upload"),
Span::raw(" "),
footer_key("c", "clear"),
Span::raw(" "),
footer_key("?", "help"),
]);
let right = Line::from(vec![
Span::styled(
app.flash_message
.clone()
.unwrap_or_else(|| "point your browser or app at the local proxy".to_string()),
dim_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, 48, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(Span::styled(" Capture 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 capture mode")]),
Line::from(vec![footer_key("p", "pause"), Span::raw(" pause or resume ingesting the live stream")]),
Line::from(vec![footer_key("m", "method"), Span::raw(" cycle method filter")]),
Line::from(vec![footer_key("f", "path"), Span::raw(" cycle path filter")]),
Line::from(vec![footer_key("s", "summary"), Span::raw(" toggle summary view")]),
Line::from(vec![footer_key("tab", "detail"), Span::raw(" switch request/tls detail pane")]),
Line::from(vec![footer_key("e", "export"), Span::raw(" export the current capture session to JSON")]),
Line::from(vec![footer_key("u", "upload"), Span::raw(" sync the session to your Lobe cloud (requires `lobe login`)")]),
Line::from(vec![footer_key("↑↓", "select"), Span::raw(" move request selection")]),
Line::from(vec![footer_key("c", "clear"), Span::raw(" clear the current capture session")]),
];
frame.render_widget(Paragraph::new(lines).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, 30), 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 info_line(label: &str, value: String, style: Style) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{label:<10}"), mute_style()),
Span::styled(value, style),
])
}
const PHASE_BAR_WIDTH: usize = 40;
fn phase_bar_line(report: &lobe_core::models::TimingReport) -> Line<'static> {
let phases: [(u64, Color, &str); 5] = [
(report.dns_ms, Color::Rgb(142, 161, 198), "DNS"),
(report.tcp_ms, Color::Rgb(111, 166, 255), "TCP"),
(report.tls_ms, Color::Rgb(126, 164, 255), "TLS"),
(report.ttfb_ms, Color::Rgb(232, 184, 76), "TTFB"),
(report.download_ms, Color::Rgb(81, 200, 120), "Download"),
];
let phase_sum: u64 = phases.iter().map(|(v, _, _)| *v).sum();
let other_ms = report.total_ms.saturating_sub(phase_sum);
let total_ms = phase_sum + other_ms;
let mut spans = vec![Span::styled(format!("{:<10}", "phases"), mute_style())];
if total_ms == 0 {
spans.push(Span::styled("─".repeat(PHASE_BAR_WIDTH), dim_style()));
return Line::from(spans);
}
let mut cells_used: usize = 0;
for (i, (value_ms, color, _label)) in phases.iter().enumerate() {
if *value_ms == 0 {
continue;
}
let is_last = i == phases.len() - 1 && other_ms == 0;
let mut cells = ((*value_ms as usize * PHASE_BAR_WIDTH) / total_ms as usize).max(1);
if is_last {
cells = PHASE_BAR_WIDTH.saturating_sub(cells_used);
}
if cells_used + cells > PHASE_BAR_WIDTH {
cells = PHASE_BAR_WIDTH.saturating_sub(cells_used);
}
if cells == 0 {
continue;
}
spans.push(Span::styled("█".repeat(cells), Style::default().fg(*color)));
cells_used += cells;
}
if other_ms > 0 && cells_used < PHASE_BAR_WIDTH {
let remaining = PHASE_BAR_WIDTH - cells_used;
spans.push(Span::styled(
"▒".repeat(remaining),
Style::default().fg(Color::Rgb(90, 100, 120)),
));
}
Line::from(spans)
}
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 display_status(event: &CapturedExchange) -> String {
match (event.response_status_code, event.response_status_text.as_deref()) {
(Some(code), Some(text)) => format!("{code} {text}"),
(Some(code), None) => code.to_string(),
(None, _) => "ERR".to_string(),
}
}
const REQUEST_HEADER_KEYS: &[&str] = &[
"accept",
"accept-language",
"content-type",
"origin",
"referer",
"user-agent",
];
const RESPONSE_HEADER_KEYS: &[&str] = &[
"cache-control",
"content-encoding",
"content-type",
"location",
"server",
"set-cookie",
"via",
];
fn display_request_path(event: &CapturedExchange) -> String {
match &event.redirect_location {
Some(location) => format!("{} -> {}", event.request_path, shorten_redirect(location)),
None => event.request_path.clone(),
}
}
fn shorten_redirect(location: &str) -> String {
if let Ok(url) = url::Url::parse(location) {
let host = url.host_str().unwrap_or_default();
let mut path = url.path().to_string();
if path.is_empty() {
path.push('/');
}
if host.is_empty() {
path
} else {
format!("{host}{path}")
}
} else {
location.to_string()
}
}
fn summarize_headers(headers: &[(String, String)], interesting_keys: &[&str]) -> Vec<(String, String)> {
let mut summary = Vec::new();
for key in interesting_keys {
if let Some((_, value)) = headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(key))
{
summary.push(((*key).to_string(), value.clone()));
}
}
summary
}
fn route_summaries(events: &[&CapturedExchange]) -> Vec<RouteSummary> {
let mut by_path: HashMap<&str, Vec<&CapturedExchange>> = HashMap::new();
for event in events {
by_path.entry(event.request_path.as_str()).or_default().push(*event);
}
by_path
.into_iter()
.map(|(path, events)| {
let request_count = events.len();
let failure_count = events
.iter()
.filter(|event| event.response_status_code.map(|code| code >= 400).unwrap_or(true))
.count();
let total_values = events.iter().map(|event| event.report.total_ms).collect::<Vec<_>>();
let ttfb_values = events.iter().map(|event| event.report.ttfb_ms).collect::<Vec<_>>();
RouteSummary {
path: path.to_string(),
request_count,
failure_count,
average_total_ms: average_u64(&total_values),
average_ttfb_ms: average_u64(&ttfb_values),
p95_total_ms: percentile_u64(&total_values, 0.95),
p95_ttfb_ms: percentile_u64(&ttfb_values, 0.95),
}
})
.collect()
}
fn host_summaries(events: &[&CapturedExchange]) -> Vec<HostSummary> {
let mut by_host: HashMap<&str, Vec<&CapturedExchange>> = HashMap::new();
for event in events {
by_host.entry(event.request_host.as_str()).or_default().push(*event);
}
by_host
.into_iter()
.map(|(host, events)| {
let request_count = events.len();
let failure_count = events
.iter()
.filter(|event| event.response_status_code.map(|code| code >= 400).unwrap_or(true))
.count();
let total_values = events.iter().map(|event| event.report.total_ms).collect::<Vec<_>>();
HostSummary {
host: host.to_string(),
request_count,
failure_count,
average_total_ms: average_u64(&total_values),
}
})
.collect()
}
fn average_u64(values: &[u64]) -> u64 {
if values.is_empty() {
return 0;
}
values.iter().sum::<u64>() / values.len() as u64
}
fn percentile_u64(values: &[u64], percentile: f64) -> u64 {
if values.is_empty() {
return 0;
}
let mut sorted = values.to_vec();
sorted.sort_unstable();
let index = ((sorted.len() - 1) as f64 * percentile).round() as usize;
sorted[index]
}
fn truncate_text(value: &str, max_chars: usize) -> String {
let mut chars = value.chars();
let truncated = chars.by_ref().take(max_chars).collect::<String>();
if chars.next().is_some() {
format!("{truncated}…")
} else {
truncated
}
}
fn status_badge(event: &CapturedExchange) -> String {
event.response_status_code
.map(|code| code.to_string())
.unwrap_or_else(|| "ERR".to_string())
}
fn style_for_status_code(status_code: Option<u16>) -> Style {
match 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 => red_style(),
}
}
fn style_for_method(method: &str) -> Style {
match method {
"GET" => green_style(),
"POST" => cyan_style(),
"PUT" | "PATCH" => yellow_style(),
"DELETE" => red_style(),
_ => base_style(),
}
}
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(139, 148, 158))
}