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:
loki5512344 2026-08-24 12:35:09 +02:00
parent 6863249ad4
commit 564613b82d
Signed by: boba
GPG key ID: 253067914055423B
11 changed files with 2024 additions and 15 deletions

1067
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -62,6 +62,10 @@ rand = { version = "0.8", default-features = false, features = ["std", "std_rng"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
# TUI (rampart-tui): лёгкие deps, без feature-gate
ratatui = "0.30"
crossterm = "0.29"
axum = "0.8" axum = "0.8"
tower-http = { version = "0.6", features = ["cors"] } tower-http = { version = "0.6", features = ["cors"] }
jsonwebtoken = "9" jsonwebtoken = "9"

64
src/bin/rampart-tui.rs Normal file
View file

@ -0,0 +1,64 @@
//! `rampart-tui` — интерактивный терминальный дашборд live-метрик Rampart.
use std::sync::Arc;
use std::time::Duration;
use clap::Parser;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::Notify;
use rampart::tui::app;
use rampart::tui::fetch::{Fetcher, HttpFetcher};
use rampart::tui::state::AppState;
#[derive(Parser)]
#[command(
name = "rampart-tui",
about = "Rampart live metrics TUI (Prometheus /metrics source)"
)]
struct Cli {
/// URL Prometheus metrics endpoint
#[arg(long, default_value = "http://127.0.0.1:9090/metrics")]
url: String,
/// Период опроса, секунды
#[arg(long, default_value_t = 1)]
refresh_secs: u64,
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let Some(fetcher) = HttpFetcher::new(cli.url.clone()) else {
anyhow::bail!("failed to build http client");
};
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
let terminal = ratatui::try_init()?;
let result = runtime.block_on(run(
terminal,
Arc::new(fetcher) as Arc<dyn Fetcher>,
cli.url,
Duration::from_secs(cli.refresh_secs.max(1)),
));
ratatui::restore();
result
}
async fn run(
mut terminal: Terminal<CrosstermBackend<std::io::Stdout>>,
fetcher: Arc<dyn Fetcher>,
url: String,
refresh: Duration,
) -> anyhow::Result<()> {
let state = Arc::new(AppState::new(url));
let notify = Arc::new(Notify::new());
tokio::spawn(app::spawn_fetch_loop(
fetcher,
Arc::clone(&state),
refresh,
Arc::clone(&notify),
));
app::run_ui(&mut terminal, state, notify)
.await
.map_err(anyhow::Error::from)
}

View file

@ -8,4 +8,5 @@ pub mod metrics;
pub mod protocol; pub mod protocol;
pub mod store; pub mod store;
pub mod traffic; pub mod traffic;
pub mod tui;
pub mod xdp; pub mod xdp;

86
src/tui/app.rs Normal file
View file

@ -0,0 +1,86 @@
//! Циклы приложения: fetch-таска (tokio) и UI-цикл (tick + события клавиатуры).
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Notify;
use crate::tui::fetch::{FetchError, Fetcher};
use crate::tui::prometheus::parse;
use crate::tui::state::AppState;
use crate::tui::ui;
/// Управляемые пользователем команды (клавиши).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
Quit,
Refresh,
}
/// Маппинг клавиш: `q`/`Esc` — выход, `r` — обновить немедленно.
#[must_use]
pub fn handle_key(code: crossterm::event::KeyCode) -> Option<Command> {
match code {
crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Esc => Some(Command::Quit),
crossterm::event::KeyCode::Char('r') => Some(Command::Refresh),
_ => None,
}
}
/// Приводит результат fetch к виду для [`AppState::apply_fetch`].
pub fn normalize(result: Result<String, FetchError>) -> Result<crate::tui::prometheus::MetricSet, String> {
match result {
Ok(body) => Ok(parse(&body)),
Err(FetchError::Network(message)) => Err(message),
Err(FetchError::Status(status)) => Err(format!("http status {status}")),
}
}
/// Фоновая задача: fetch → [`AppState`]. Ошибки сети пишутся в снапшот баннером.
pub async fn spawn_fetch_loop(fetcher: Arc<dyn Fetcher>, state: Arc<AppState>, refresh: Duration, notify: Arc<Notify>) {
loop {
let started = std::time::Instant::now();
let result = normalize(fetcher.fetch().await);
let elapsed_secs = started.elapsed().as_secs_f64();
state.apply_fetch(result, elapsed_secs);
tokio::select! {
() = tokio::time::sleep(refresh) => {},
() = notify.notified() => {},
}
}
}
/// UI-цикл: рисует кадр каждые ~100 мс, обрабатывает клавиши.
///
/// # Errors
/// Возвращает ошибку терминала (draw/poll) — вызывающий восстанавливает TTY.
pub async fn run_ui(
terminal: &mut ratatui::Terminal<impl ratatui::backend::Backend<Error = std::io::Error>>,
state: Arc<AppState>,
notify: Arc<Notify>,
) -> std::io::Result<()> {
loop {
terminal.draw(|frame| ui::render(frame, &state.frame_data()))?;
if let Some(command) = poll_command(Duration::from_millis(100))? {
match command {
Command::Quit => return Ok(()),
Command::Refresh => notify.notify_one(),
}
}
}
}
/// Неблокирующее ожидание клавиши в течение `timeout`.
fn poll_command(timeout: Duration) -> std::io::Result<Option<Command>> {
if !crossterm::event::poll(timeout)? {
return Ok(None);
}
if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
// Реагируем только на нажатие (не автоповтор/отпускание).
if key.kind == crossterm::event::KeyEventKind::Press {
return Ok(handle_key(key.code));
}
}
Ok(None)
}

65
src/tui/fetch.rs Normal file
View file

@ -0,0 +1,65 @@
//! Получение тела `/metrics`: трейт [`Fetcher`] для мока в тестах и HTTP-реализация.
use std::time::Duration;
use futures::future::BoxFuture;
use thiserror::Error;
/// Ошибка загрузки метрик.
#[derive(Debug, Error, Clone)]
pub enum FetchError {
/// Сетевая ошибка / DNS / таймаут.
#[error("network error: {0}")]
Network(String),
/// Endpoint ответил не-200 статусом.
#[error("http status {0}")]
Status(u16),
}
/// Абстракция источника exposition-текста Prometheus.
pub trait Fetcher: Send + Sync {
/// Загружает текущий срез метрик.
///
/// # Errors
/// Возвращает [`FetchError`] при сетевом сбое или плохом HTTP-статусе.
fn fetch(&self) -> BoxFuture<'_, Result<String, FetchError>>;
}
/// Реализация [`Fetcher`] поверх reqwest GET.
pub struct HttpFetcher {
url: String,
client: reqwest::Client,
}
impl HttpFetcher {
/// Создаёт fetcher с таймаутом запроса 5 секунд.
pub fn new(url: impl Into<String>) -> Option<Self> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.ok()?;
Some(Self {
url: url.into(),
client,
})
}
}
impl Fetcher for HttpFetcher {
fn fetch(&self) -> BoxFuture<'_, Result<String, FetchError>> {
let url = self.url.clone();
let client = self.client.clone();
Box::pin(async move {
let response = client
.get(&url)
.send()
.await
.map_err(|e| FetchError::Network(e.to_string()))?;
let status = response.status();
if !status.is_success() {
return Err(FetchError::Status(status.as_u16()));
}
response.text().await.map_err(|e| FetchError::Network(e.to_string()))
})
}
}

10
src/tui/mod.rs Normal file
View file

@ -0,0 +1,10 @@
//! TUI-модуль Rampart: live-метрики edge-ноды/manager в терминале (ratatui).
//!
//! Используется бинарём `rampart-tui`; источник данных — Prometheus
//! exposition endpoint (`/metrics`).
pub mod app;
pub mod fetch;
pub mod prometheus;
pub mod state;
pub mod ui;

238
src/tui/prometheus.rs Normal file
View file

@ -0,0 +1,238 @@
//! Минималистичный парсер текстового формата Prometheus (version=0.0.4).
//!
//! Поддерживает: комментарии (`# HELP`, `# TYPE`, `#`), строки вида
//! `metric{label="value",...} value [timestamp]` и `metric value [timestamp]`.
//! Значения `NaN`/`+Inf`/`-Inf` парсятся штатно через `f64::from_str`.
use std::collections::BTreeMap;
/// Один сэмпл из exposition-формата Prometheus.
#[derive(Debug, Clone, PartialEq)]
pub struct MetricSample {
pub name: String,
pub labels: BTreeMap<String, String>,
pub value: f64,
}
/// Набор сэмплов одного ответа `/metrics`.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct MetricSet {
samples: Vec<MetricSample>,
}
impl MetricSet {
/// Сумма значений всех серий с данным именем (для counter/gauge vec).
/// Отсутствующая метрика даёт `0.0`.
#[must_use]
pub fn sum(&self, name: &str) -> f64 {
self.samples.iter().filter(|s| s.name == name).map(|s| s.value).sum()
}
/// Значение серии с именем и одной меткой; `None`, если не найдено.
#[must_use]
pub fn labeled(&self, name: &str, label: &str, value: &str) -> Option<f64> {
self.samples
.iter()
.find(|s| s.name == name && s.labels.get(label).map(String::as_str) == Some(value))
.map(|s| s.value)
}
/// Все сэмплы (для тестов и отладки).
#[must_use]
pub fn samples(&self) -> &[MetricSample] {
&self.samples
}
}
/// Парсит тело ответа `/metrics`.
///
/// Строки с ошибкой формата пропускаются (exposition-поток может быть частично
/// повреждён — TUI не должен падать на этом).
#[must_use]
pub fn parse(input: &str) -> MetricSet {
let mut samples = Vec::new();
for line in input.lines() {
if let Some(sample) = parse_line(line) {
samples.push(sample);
}
}
MetricSet { samples }
}
fn parse_line(line: &str) -> Option<MetricSample> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
// Формат: metric[{labels}] value [timestamp]; значения меток могут
// содержать пробелы, поэтому голову отрезаем с учётом кавычек.
let (head, rest) = split_head_and_rest(line);
let value_str = rest.split_whitespace().next()?;
// Timestamp после value игнорируем.
let (name, labels) = split_metric(head)?;
let value = parse_value(value_str)?;
Some(MetricSample { name, labels, value })
}
/// Отделяет `metric{...}` от хвоста `value [timestamp]`.
fn split_head_and_rest(line: &str) -> (&str, &str) {
let Some(open) = line.find('{') else {
return match line.find(char::is_whitespace) {
Some(space) => (&line[..space], &line[space..]),
None => (line, ""),
};
};
match find_closing_brace(line, open + 1) {
Some(close) => (&line[..=close], &line[close + 1..]),
None => match line.find(char::is_whitespace) {
Some(space) => (&line[..space.min(open)], &line[space.min(open)..]),
None => (line, ""),
},
}
}
/// Индекс `}` вне кавычек, начиная с позиции `from`.
fn find_closing_brace(line: &str, from: usize) -> Option<usize> {
let mut in_quotes = false;
let mut escaped = false;
for (idx, ch) in line[from..].char_indices() {
match ch {
'\\' if in_quotes => escaped = !escaped,
'"' if !escaped => in_quotes = !in_quotes,
'}' if !in_quotes => return Some(from + idx),
_ => {},
}
if ch != '\\' {
escaped = false;
}
}
None
}
fn split_metric(head: &str) -> Option<(String, BTreeMap<String, String>)> {
match head.find('{') {
None => Some((head.to_owned(), BTreeMap::new())),
Some(open) => {
if !head.ends_with('}') || open == 0 {
return None;
}
let name = head[..open].trim().to_owned();
let inner = &head[open + 1..head.len() - 1];
Some((name, parse_labels(inner)))
},
}
}
fn parse_labels(inner: &str) -> BTreeMap<String, String> {
let mut labels = BTreeMap::new();
for pair in split_label_pairs(inner) {
if let Some(eq) = pair.find('=') {
let key = pair[..eq].trim();
let raw_value = pair[eq + 1..].trim();
if let Some(unquoted) = raw_value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
labels.insert(key.to_owned(), unescape_label(unquoted));
}
}
}
labels
}
/// Разбивает `a="1", b="2"` по запятым вне кавычек.
fn split_label_pairs(inner: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut in_quotes = false;
let mut escaped = false;
let mut start = 0;
for (idx, ch) in inner.char_indices() {
match ch {
'\\' if in_quotes => escaped = !escaped,
'"' if !escaped => in_quotes = !in_quotes,
',' if !in_quotes => {
parts.push(&inner[start..idx]);
start = idx + 1;
},
_ => {},
}
if ch != '\\' {
escaped = false;
}
}
parts.push(&inner[start..]);
parts
}
fn unescape_label(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some(next) => out.push(next),
None => {},
}
} else {
out.push(ch);
}
}
out
}
fn parse_value(value: &str) -> Option<f64> {
match value {
"+Inf" | "Inf" => Some(f64::INFINITY),
"-Inf" => Some(f64::NEG_INFINITY),
"NaN" => Some(f64::NAN),
v => v.parse::<f64>().ok(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_and_labeled_lines() {
let set = parse("up 1\nxdp_total{iface=\"eth0\"} 42\n");
assert_eq!(set.sum("up"), 1.0);
assert_eq!(set.labeled("xdp_total", "iface", "eth0"), Some(42.0));
}
#[test]
fn skips_comments_help_type_and_empty_lines() {
let input = "# HELP rampart_xdp_total Total\n# TYPE rampart_xdp_total gauge\n\nrampart_xdp_total 7\n";
let set = parse(input);
assert_eq!(set.samples().len(), 1);
assert_eq!(set.sum("rampart_xdp_total"), 7.0);
}
#[test]
fn ignores_timestamp_and_special_values() {
let set = parse("m1 10 1700000000000\nm2 +Inf\nm3 NaN\nbroken_line_no_value\nbad{} \nm4 -2.5e3\n");
assert_eq!(set.sum("m1"), 10.0);
assert_eq!(set.sum("m2"), f64::INFINITY);
assert!(set.labeled("m3", "x", "y").is_none());
assert_eq!(set.sum("m4"), -2500.0);
}
#[test]
fn sums_series_with_same_name() {
let input = "bans{reason=\"cps\"} 3\nbans{reason=\"syn\"} 4\nbans 5\n";
assert_eq!(parse(input).sum("bans"), 12.0);
}
#[test]
fn handles_spaces_in_label_values() {
let set = parse("m{k=\"a b\", j=\"v w\"} 1\n");
assert_eq!(set.labeled("m", "k", "a b"), Some(1.0));
assert_eq!(set.labeled("m", "j", "v w"), Some(1.0));
}
#[test]
fn handles_escaped_quotes_and_backslashes_in_labels() {
let set = parse("m{q=\"a\\\"b\", s=\"c\\\\d\"} 1\n");
assert_eq!(set.labeled("m", "q", "a\"b"), Some(1.0));
assert_eq!(set.labeled("m", "s", "c\\d"), Some(1.0));
}
}

166
src/tui/state.rs Normal file
View file

@ -0,0 +1,166 @@
//! Общее состояние TUI: последний срез метрик, история pps/drops, расчёт rate.
use std::collections::VecDeque;
use std::sync::Mutex;
use std::time::Instant;
use crate::tui::prometheus::MetricSet;
/// Максимальная длина скользящего окна графика.
pub const HISTORY_CAPACITY: usize = 120;
/// Один сэмпл истории (значения — rates, единиц/сек).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HistoryPoint {
pub packets_per_sec: f64,
pub drops_per_sec: f64,
}
/// Скользящее окно истории фиксированной ёмкости.
#[derive(Debug, Clone)]
pub struct History {
points: VecDeque<HistoryPoint>,
}
impl Default for History {
fn default() -> Self {
Self {
points: VecDeque::with_capacity(HISTORY_CAPACITY),
}
}
}
impl History {
/// Добавляет точку, вытесняя старейшую при переполнении окна.
pub fn push(&mut self, point: HistoryPoint) {
if self.points.len() == HISTORY_CAPACITY {
self.points.pop_front();
}
self.points.push_back(point);
}
/// Точки в хронологическом порядке.
#[must_use]
pub fn points(&self) -> &VecDeque<HistoryPoint> {
&self.points
}
}
/// Состояние приложения, общее между fetch-потоком и UI-циклом.
#[derive(Debug)]
pub struct AppState {
url: String,
snapshot: Mutex<Snapshot>,
history: Mutex<History>,
started_at: Instant,
}
impl AppState {
/// Создаёт состояние с URL источника метрик (для статус-панели).
#[must_use]
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
snapshot: Mutex::new(Snapshot::default()),
history: Mutex::new(History::default()),
started_at: Instant::now(),
}
}
/// URL источника метрик.
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
}
/// Последний успешный/неуспешный результат fetch.
#[derive(Debug, Default, Clone)]
pub struct Snapshot {
pub metrics: Option<MetricSet>,
pub fetched_at: Option<Instant>,
pub error: Option<String>,
}
impl AppState {
/// Записывает результат очередного fetch и обновляет историю rate.
pub fn apply_fetch(&self, result: Result<MetricSet, String>, elapsed_secs: f64) {
let mut snapshot = match self.snapshot.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match result {
Ok(metrics) => {
let mut history = match self.history.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let previous = snapshot.metrics.take();
let point = HistoryPoint {
packets_per_sec: counter_rate(
previous.as_ref().map(|p| p.sum("rampart_xdp_total")),
Some(metrics.sum("rampart_xdp_total")),
elapsed_secs,
),
drops_per_sec: counter_rate(
previous.as_ref().map(|p| p.sum("rampart_xdp_dropped")),
Some(metrics.sum("rampart_xdp_dropped")),
elapsed_secs,
),
};
history.push(point);
snapshot.metrics = Some(metrics);
snapshot.fetched_at = Some(Instant::now());
snapshot.error = None;
},
Err(message) => {
snapshot.error = Some(message);
},
}
}
/// Копия среза метрик + истории для отрисовки кадра.
///
/// UI не держит лок — рисуем по снимку.
#[must_use]
pub fn frame_data(&self) -> FrameData {
let snapshot = self.snapshot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let history = self
.history
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
FrameData {
url: self.url.clone(),
snapshot: snapshot.clone(),
history,
uptime_secs: self.started_at.elapsed().as_secs(),
}
}
}
/// Снимок для одного кадра UI.
#[derive(Debug, Default, Clone)]
pub struct FrameData {
/// URL источника метрик.
pub url: String,
pub snapshot: Snapshot,
pub history: History,
/// Время работы TUI (сек).
pub uptime_secs: u64,
}
/// Rate счётчика из двух сэмплов: `(current - previous) / dt`.
///
/// `None` у предыдущего значения (первый сэмпл) даёт 0.0. Сброс счётчика
/// (`current < previous`) трактуетcя как рестарт процесса → 0.0.
#[must_use]
pub fn counter_rate(previous: Option<f64>, current: Option<f64>, elapsed_secs: f64) -> f64 {
let (Some(previous), Some(current)) = (previous, current) else {
return 0.0;
};
if !(elapsed_secs > 0.0 && current.is_finite()) || current < previous {
return 0.0;
}
(current - previous) / elapsed_secs
}

179
src/tui/ui.rs Normal file
View file

@ -0,0 +1,179 @@
//! Отрисовка кадра ratatui: статус-панель, таблица счётчиков, график pps/drops, помощь.
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::symbols;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Row, Table};
use crate::tui::state::{FrameData, HistoryPoint};
/// Имена метрик edge/manager (см. src/metrics.rs и src/xdp/metrics.rs).
mod names {
pub const XDP_TOTAL: &str = "rampart_xdp_total";
pub const XDP_TCP: &str = "rampart_xdp_tcp";
pub const XDP_UDP: &str = "rampart_xdp_udp";
pub const XDP_PASSED: &str = "rampart_xdp_passed";
pub const XDP_DROPPED: &str = "rampart_xdp_dropped";
pub const XDP_THROTTLE: &str = "rampart_xdp_syn_throttle";
pub const XDP_RATE_LIMIT: &str = "rampart_xdp_rate_limit";
pub const XDP_BLACKLIST: &str = "rampart_xdp_blacklist";
pub const RATE_LIMIT_HITS: &str = "rampart_rate_limit_hits";
pub const AUTO_BANS_TOTAL: &str = "rampart_auto_bans_total";
pub const INTEL_DROPS: &str = "rampart_intel_rate_limit_drops_total";
pub const INTEL_CPS: &str = "rampart_intel_cps";
pub const INTEL_ALERTS: &str = "rampart_intel_alerts_total";
pub const ATTACK_STATUS: &str = "rampart_attack_status";
}
/// Рисует весь кадр.
pub fn render(frame: &mut Frame, data: &FrameData) {
let [header, body, help] =
Layout::vertical([Constraint::Length(3), Constraint::Min(3), Constraint::Length(1)]).areas(frame.area());
render_header(frame, header, data);
let [table_area, chart_area] =
Layout::vertical([Constraint::Percentage(55), Constraint::Percentage(45)]).areas(body);
render_table(frame, table_area, data);
render_chart(frame, chart_area, data);
render_help(frame, help);
}
fn status_color(data: &FrameData) -> Color {
match data.snapshot.error {
Some(_) => Color::Red,
None if data.snapshot.fetched_at.is_some() => Color::Green,
None => Color::Yellow,
}
}
fn render_header(frame: &mut Frame, area: Rect, data: &FrameData) {
let dot = Span::styled(
"● ",
Style::default().fg(status_color(data)).add_modifier(Modifier::BOLD),
);
let title = Span::styled(" Rampart live metrics", Style::default().add_modifier(Modifier::BOLD));
let state_text = match (&data.snapshot.error, data.snapshot.fetched_at) {
(Some(err), _) => format!("ERROR: {err}"),
(_, Some(at)) => format!("OK, last update {}s ago", at.elapsed().as_secs()),
(None, None) => "waiting for first fetch...".to_owned(),
};
let line = Line::from(vec![
dot,
title,
Span::raw(format!(
" {} | uptime {}s | {state_text}",
data.url, data.uptime_secs
)),
]);
frame.render_widget(Paragraph::new(line).block(Block::new().borders(Borders::ALL)), area);
}
/// Пары «подпись → имя метрики» для таблицы.
const COUNTER_ROWS: &[(&str, &str)] = &[
("XDP total", names::XDP_TOTAL),
("XDP tcp", names::XDP_TCP),
("XDP udp", names::XDP_UDP),
("XDP passed", names::XDP_PASSED),
("XDP dropped", names::XDP_DROPPED),
("SYN throttle", names::XDP_THROTTLE),
("Rate limited", names::XDP_RATE_LIMIT),
("Blacklisted", names::XDP_BLACKLIST),
("HTTP rate-limit hits", names::RATE_LIMIT_HITS),
("Auto bans", names::AUTO_BANS_TOTAL),
("Intel rl drops", names::INTEL_DROPS),
("Intel cps", names::INTEL_CPS),
("Intel alerts", names::INTEL_ALERTS),
];
fn attack_status_label(value: Option<f64>) -> String {
match value {
None => "-".to_owned(),
Some(v) if v <= 0.0 => "normal".to_owned(),
Some(v) if v < 2.0 => "suspicious".to_owned(),
Some(_) => "under attack".to_owned(),
}
}
fn render_table(frame: &mut Frame, area: Rect, data: &FrameData) {
let rows: Vec<Row> = COUNTER_ROWS
.iter()
.map(|(label, metric)| {
let value = data.snapshot.metrics.as_ref().map(|m| m.sum(metric));
Row::new(vec![(*label).to_owned(), fmt_value(value)])
})
.chain(std::iter::once(Row::new(vec![
"Attack status".to_owned(),
attack_status_label(data.snapshot.metrics.as_ref().map(|m| m.sum(names::ATTACK_STATUS))),
])))
.collect();
let table = Table::new(rows, [Constraint::Percentage(60), Constraint::Percentage(40)])
.block(Block::new().borders(Borders::ALL).title(" Counters "))
.header(Row::new(vec!["metric", "value"]).style(Style::default().add_modifier(Modifier::BOLD)));
frame.render_widget(table, area);
}
#[allow(clippy::float_arithmetic)]
fn fmt_value(value: Option<f64>) -> String {
match value {
None => "-".to_owned(),
Some(v) if !v.is_finite() => format!("{v}"),
Some(v) if v.fract() == 0.0 => format!("{}", v as u64),
Some(v) => format!("{v:.1}"),
}
}
fn history_series(history: &[HistoryPoint], pick: impl Fn(&HistoryPoint) -> f64) -> Vec<(f64, f64)> {
history
.iter()
.enumerate()
.map(|(idx, point)| (f64::from(idx as u16), pick(point)))
.collect()
}
fn render_chart(frame: &mut Frame, area: Rect, data: &FrameData) {
let points: Vec<HistoryPoint> = data.history.points().iter().copied().collect();
let pps_series = history_series(&points, |p| p.packets_per_sec);
let drops_series = history_series(&points, |p| p.drops_per_sec);
let pps = Dataset::default()
.name("pps")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::Cyan))
.data(&pps_series);
let drops = Dataset::default()
.name("drops/s")
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::Red))
.data(&drops_series);
let x_max = points.len().max(2) as f64 - 1.0;
let y_max = points
.iter()
.flat_map(|p| [p.packets_per_sec, p.drops_per_sec])
.fold(1.0_f64, f64::max);
let chart = Chart::new(vec![pps, drops])
.block(
Block::new()
.borders(Borders::ALL)
.title(" pps / drops (last 120 samples) "),
)
.x_axis(Axis::default().bounds([0.0, x_max]))
.y_axis(Axis::default().bounds([0.0, y_max]));
frame.render_widget(chart, area);
}
fn render_help(frame: &mut Frame, area: Rect) {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" quit "),
Span::styled("r", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" refresh now"),
])),
area,
);
}

159
tests/tui_metrics.rs Normal file
View 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)
);
}