use ratatui::Frame;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Cell, Row, Table};
use super::format::truncate;
use super::state::State;
pub fn draw(f: &mut Frame, area: Rect, state: &State) {
let header = Row::new(vec![
Cell::from("HOST"),
Cell::from("WAF"),
Cell::from("SENT"),
Cell::from("BLOCKED"),
Cell::from("BYPASSED"),
Cell::from("BYPASS%"),
Cell::from("TOP TECHNIQUE"),
])
.style(
Style::default()
.fg(Color::LightCyan)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.top_hosts(20)
.into_iter()
.map(|(host, hs)| {
#[allow(clippy::cast_precision_loss)]
let pct = if hs.sent == 0 {
0.0
} else {
(hs.bypassed as f64 / hs.sent as f64) * 100.0
};
let pct_color = match pct {
p if p >= 75.0 => Color::LightGreen,
p if p >= 25.0 => Color::Yellow,
_ => Color::DarkGray,
};
let waf_label = hs.waf_name.as_deref().unwrap_or("—").to_string();
Row::new(vec![
Cell::from(host.clone()).style(Style::default().fg(Color::White)),
Cell::from(waf_label).style(Style::default().fg(Color::LightMagenta)),
Cell::from(hs.sent.to_string()),
Cell::from(hs.blocked.to_string()).style(Style::default().fg(Color::LightRed)),
Cell::from(hs.bypassed.to_string()).style(Style::default().fg(Color::LightGreen)),
Cell::from(format!("{pct:.1}%")).style(Style::default().fg(pct_color)),
Cell::from(truncate(&hs.top_technique, 36).to_string())
.style(Style::default().fg(Color::Magenta)),
])
})
.collect();
let widths = [
Constraint::Percentage(22),
Constraint::Length(14),
Constraint::Length(7),
Constraint::Length(8),
Constraint::Length(9),
Constraint::Length(8),
Constraint::Percentage(36),
];
let table = Table::new(rows, widths).header(header).block(
Block::default()
.borders(Borders::ALL)
.border_style(crate::tui::style::DIM)
.title(Span::styled(
" Per-Host (top 20 by volume) ",
Style::default().fg(Color::LightCyan),
)),
);
f.render_widget(table, area);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::state::{Event, State};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn req(host: &str, bypassed: bool) -> Event {
Event::Request {
host: host.into(),
method: "GET".into(),
path: "/".into(),
status: 200,
bypassed,
blocked: !bypassed,
techniques: "encoding:UrlEncode".into(),
tls_profile: None,
body_padded: false,
upstream_latency_ms: 1,
waf_name: None,
req_headers: vec![],
req_body_excerpt: vec![],
req_headers_pre: vec![],
req_body_pre_excerpt: vec![],
resp_headers: vec![],
resp_body_excerpt: vec![],
resp_body_total: 0,
attempts: 0,
}
}
fn req_with_waf(host: &str, bypassed: bool, waf: &str, technique: &str) -> Event {
Event::Request {
host: host.into(),
method: "GET".into(),
path: "/".into(),
status: 200,
bypassed,
blocked: !bypassed,
techniques: technique.into(),
tls_profile: None,
body_padded: false,
upstream_latency_ms: 1,
waf_name: Some(waf.into()),
req_headers: vec![],
req_body_excerpt: vec![],
req_headers_pre: vec![],
req_body_pre_excerpt: vec![],
resp_headers: vec![],
resp_body_excerpt: vec![],
resp_body_total: 0,
attempts: 0,
}
}
fn render(width: u16, height: u16, state: &State) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("backend");
terminal
.draw(|f| {
let area = f.area();
draw(f, area, state);
})
.expect("draw");
let buffer = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buffer.area().height {
let mut line = String::new();
for x in 0..buffer.area().width {
line.push_str(buffer[(x, y)].symbol());
}
out.push_str(line.trim_end());
out.push('\n');
}
out
}
#[test]
fn top_hosts_sorted_by_volume() {
let mut s = State::new();
for _ in 0..10 {
s.record(&req("a", true));
}
for _ in 0..3 {
s.record(&req("b", true));
}
for _ in 0..7 {
s.record(&req("c", true));
}
let top = s.top_hosts(5);
assert_eq!(top[0].0, "a");
assert_eq!(top[1].0, "c");
assert_eq!(top[2].0, "b");
}
#[test]
fn render_shows_table_headers() {
let s = State::new();
let buf = render(140, 12, &s);
for header in [
"HOST",
"WAF",
"SENT",
"BLOCKED",
"BYPASSED",
"BYPASS%",
"TOP TECHNIQUE",
] {
assert!(buf.contains(header), "expected header `{header}` in: {buf}");
}
}
#[test]
fn render_shows_panel_title() {
let s = State::new();
let buf = render(140, 12, &s);
assert!(buf.contains("Per-Host"));
assert!(buf.contains("top 20 by volume"));
}
#[test]
fn render_with_no_hosts_does_not_panic() {
let s = State::new();
let _ = render(120, 12, &s);
}
#[test]
fn render_with_single_host_shows_counts() {
let mut s = State::new();
for _ in 0..5 {
s.record(&req("example.com", true));
}
for _ in 0..2 {
s.record(&req("example.com", false));
}
let buf = render(180, 12, &s);
assert!(buf.contains("example.com"));
assert!(buf.contains("7")); assert!(buf.contains("UrlEncode") || buf.contains("encoding"));
}
#[test]
fn render_long_hostname_is_visible() {
let mut s = State::new();
let long_host = "very-long-subdomain.example.long-tld.co.uk";
s.record(&req(long_host, true));
let buf = render(200, 12, &s);
assert!(buf.contains("very-long-subdomain") || buf.contains("example.long-tld"));
}
#[test]
fn render_shows_waf_label_when_present() {
let mut s = State::new();
s.record(&req_with_waf(
"fastly-host.example",
true,
"Fastly",
"encoding:UrlEncode",
));
let buf = render(200, 12, &s);
assert!(buf.contains("Fastly"));
}
#[test]
fn render_shows_em_dash_when_waf_unknown() {
let mut s = State::new();
s.record(&req("unknown.example", true));
let buf = render(200, 12, &s);
assert!(buf.contains('—') || buf.contains("-"));
}
#[test]
fn render_bypass_percentage_formatted_to_one_decimal() {
let mut s = State::new();
for _ in 0..3 {
s.record(&req("pct.example", true));
}
s.record(&req("pct.example", false));
let buf = render(200, 12, &s);
assert!(buf.contains("75.0%"));
}
#[test]
fn render_top_hosts_capped_at_20() {
let mut s = State::new();
for i in 0..30 {
let h = format!("host{i:02}.example");
for _ in 0..(30 - i) {
s.record(&req(&h, true));
}
}
let buf = render(180, 25, &s);
assert!(buf.contains("host00"));
assert!(!buf.contains("host29"));
}
#[test]
fn render_technique_truncated_to_36_chars() {
let mut s = State::new();
let long_technique = "a".repeat(60);
s.record(&req_with_waf(
"long-tech.example",
true,
"Test",
&long_technique,
));
let buf = render(200, 12, &s);
assert!(!buf.contains(&"a".repeat(60)));
}
#[test]
fn render_narrow_width_does_not_panic() {
let mut s = State::new();
for i in 0..5 {
s.record(&req(&format!("n{i}.example"), true));
}
let _ = render(40, 12, &s);
}
}