feat: rampart-tui — live metrics terminal dashboard
- ratatui + crossterm; separate bin reading Prometheus exposition format - counter table (xdp stats, auto-bans, intel), pps/drops history chart, status panel with error banner, q/quit r/refresh - prometheus text parser (labels, escapes, NaN/Inf), Fetcher trait for tests - 152 tests green
This commit is contained in:
parent
6863249ad4
commit
564613b82d
11 changed files with 2024 additions and 15 deletions
159
tests/tui_metrics.rs
Normal file
159
tests/tui_metrics.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
//! Тесты TUI-модуля: парсер Prometheus, расчёт rate, поведение при ошибках fetch.
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use rampart::tui::fetch::{FetchError, Fetcher, HttpFetcher};
|
||||
use rampart::tui::prometheus::{MetricSample, parse};
|
||||
use rampart::tui::state::{AppState, counter_rate};
|
||||
|
||||
// ---------- парсер ----------
|
||||
|
||||
#[test]
|
||||
fn parses_metric_with_labels_value_and_timestamp() {
|
||||
let set = parse("rampart_xdp_total{iface=\"eth0\",core=\"0\"} 123 1700000000000\n");
|
||||
assert_eq!(
|
||||
set.samples(),
|
||||
vec![MetricSample {
|
||||
name: "rampart_xdp_total".to_owned(),
|
||||
labels: [("iface", "eth0"), ("core", "0")]
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
|
||||
.collect(),
|
||||
value: 123.0,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_skips_comments_help_and_type() {
|
||||
let body = "\
|
||||
# HELP rampart_xdp_dropped Packets dropped\n\
|
||||
# TYPE rampart_xdp_dropped gauge\n\
|
||||
# обычный комментарий\n\
|
||||
rampart_xdp_dropped 42\n";
|
||||
let set = parse(body);
|
||||
assert_eq!(set.samples().len(), 1);
|
||||
assert_eq!(set.sum("rampart_xdp_dropped"), 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_sums_counter_vec_series() {
|
||||
let body = "rampart_auto_bans_total{reason=\"cps\"} 3\nrampart_auto_bans_total{reason=\"syn\"} 4\n";
|
||||
assert_eq!(parse(body).sum("rampart_auto_bans_total"), 7.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_missing_metric_is_zero() {
|
||||
assert_eq!(parse("# nothing here\n").sum("rampart_xdp_tcp"), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_tolerates_malformed_lines() {
|
||||
let set = parse("garbage\nm{unterminated=\"v} 1\nok_metric 5\n");
|
||||
assert_eq!(set.sum("ok_metric"), 5.0);
|
||||
assert_eq!(set.samples().len(), 1);
|
||||
}
|
||||
|
||||
// ---------- rate ----------
|
||||
|
||||
#[test]
|
||||
fn rate_from_two_samples_divides_by_elapsed() {
|
||||
// 1200 - 1100 = 100 пакетов за 2 сек
|
||||
assert_eq!(counter_rate(Some(1100.0), Some(1200.0), 2.0), 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_first_sample_and_counter_reset_are_zero() {
|
||||
assert_eq!(counter_rate(None, Some(10.0), 1.0), 0.0);
|
||||
assert_eq!(counter_rate(Some(20.0), Some(10.0), 1.0), 0.0);
|
||||
assert_eq!(counter_rate(Some(10.0), Some(12.0), 0.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_accumulates_history_rates() {
|
||||
let state = AppState::new("http://test/metrics");
|
||||
state.apply_fetch(Ok(parse("rampart_xdp_total 100\n")), 1.0);
|
||||
state.apply_fetch(Ok(parse("rampart_xdp_total 150\n")), 1.0);
|
||||
let frame = state.frame_data();
|
||||
assert_eq!(frame.history.points().len(), 2);
|
||||
assert_eq!(frame.history.points()[1].packets_per_sec, 50.0);
|
||||
assert!(frame.snapshot.error.is_none());
|
||||
}
|
||||
|
||||
// ---------- ошибки fetch (мок Fetcher + локальный HTTP-сервер) ----------
|
||||
|
||||
struct FailingFetcher {
|
||||
error: FetchError,
|
||||
}
|
||||
|
||||
impl Fetcher for FailingFetcher {
|
||||
fn fetch(&self) -> BoxFuture<'_, Result<String, FetchError>> {
|
||||
let error = self.error.clone();
|
||||
Box::pin(async { Err(error) })
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_error_sets_error_banner_not_panic() {
|
||||
let state = AppState::new("http://127.0.0.1:9/metrics");
|
||||
let fetcher = FailingFetcher {
|
||||
error: FetchError::Network("connection refused".to_owned()),
|
||||
};
|
||||
|
||||
// Тот же путь, что и в spawn_fetch_loop: normalize → apply_fetch.
|
||||
let normalized = rampart::tui::app::normalize(Err(fetcher.error));
|
||||
assert!(normalized.is_err());
|
||||
let message = match normalized {
|
||||
Err(message) => message,
|
||||
Ok(_) => return,
|
||||
};
|
||||
|
||||
state.apply_fetch(Err(message), 1.0);
|
||||
assert_eq!(state.frame_data().snapshot.error.as_deref(), Some("connection refused"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_fetcher_reads_prometheus_body_from_local_server() {
|
||||
let Ok(listener) = tokio::net::TcpListener::bind("127.0.0.1:0").await else {
|
||||
return;
|
||||
};
|
||||
let Ok(addr) = listener.local_addr() else {
|
||||
return;
|
||||
};
|
||||
let server = tokio::spawn(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
if let Ok((mut stream, _)) = listener.accept().await {
|
||||
let body = "rampart_xdp_total 99\n";
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(head.as_bytes()).await;
|
||||
let _ = stream.write_all(body.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
|
||||
let Some(fetcher) = HttpFetcher::new(format!("http://{addr}/metrics")) else {
|
||||
server.abort();
|
||||
return;
|
||||
};
|
||||
let Ok(body) = fetcher.fetch().await else {
|
||||
server.abort();
|
||||
return;
|
||||
};
|
||||
assert_eq!(parse(&body).sum("rampart_xdp_total"), 99.0);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_fetch_keeps_last_good_metrics_but_reports_error() {
|
||||
let state = AppState::new("http://test/metrics");
|
||||
state.apply_fetch(Ok(parse("rampart_xdp_total 7\n")), 1.0);
|
||||
state.apply_fetch(Err("connection refused".to_owned()), 1.0);
|
||||
|
||||
let frame = state.frame_data();
|
||||
assert_eq!(frame.snapshot.error.as_deref(), Some("connection refused"));
|
||||
assert_eq!(
|
||||
frame.snapshot.metrics.as_ref().map(|m| m.sum("rampart_xdp_total")),
|
||||
Some(7.0)
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue