edr-research-rust/user-land/src/ui/mod.rs

182 lines
5.1 KiB
Rust
Raw Normal View History

2026-03-30 14:19:13 +02:00
use std::io;
use std::time::Duration;
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Row, Table, TableState},
Terminal,
};
use tokio::sync::mpsc::Receiver;
use crate::alert::Alert;
const TICK_RATE: Duration = Duration::from_millis(16); // ~60 fps
/// Point d'entrée de la TUI. Bloque jusqu'à ce que l'utilisateur quitte.
pub fn run(mut rx: Receiver<Alert>) -> Result<(), Box<dyn std::error::Error>>
{
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut alerts: Vec<Alert> = Vec::new();
let mut table_state = TableState::default();
let result = run_loop(&mut terminal, &mut rx, &mut alerts, &mut table_state);
// Restauration du terminal même en cas d'erreur
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
rx: &mut Receiver<Alert>,
alerts: &mut Vec<Alert>,
state: &mut TableState,
) -> Result<(), Box<dyn std::error::Error>>
{
loop
{
while let Ok(alert) = rx.try_recv()
{
alerts.push(alert);
state.select(Some(alerts.len().saturating_sub(1)));
}
terminal.draw(|frame| render(frame, alerts, state))?;
if event::poll(TICK_RATE)?
{
if let Event::Key(key) = event::read()?
{
if key.kind == KeyEventKind::Press
{
match key.code
{
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Down => scroll_down(state, alerts.len()),
KeyCode::Up => scroll_up(state),
_ => {}
}
}
}
}
}
}
fn render(
frame: &mut ratatui::Frame,
alerts: &[Alert],
state: &mut TableState,
)
{
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
// Barre de titre
let title = Line::from(vec![
Span::styled(" Vauban v0.0.1 ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(""),
Span::styled(
format!("{} alertes", alerts.len()),
Style::default().fg(Color::Yellow),
),
Span::raw(" "),
Span::styled(
"[↑↓] scroll [q] quitter",
Style::default().fg(Color::DarkGray),
),
]);
frame.render_widget(
Block::default().borders(Borders::ALL).title(title),
chunks[0],
);
// En-têtes de la table
let header = Row::new(
["Severity", "Rule", "PID", "Image", "CommandLine"].map(|h|
Cell::from(h).style(
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)
)
)
.height(1)
.bottom_margin(1);
// Coloration par sévérité
let severity_color = |s: &str| match s
{
"critical" => Color::Red,
"high" => Color::LightRed,
"medium" => Color::Yellow,
"low" => Color::Green,
_ => Color::White,
};
// Lignes
let rows: Vec<Row> = alerts
.iter()
.map(|a|
{
let color = severity_color(&a.severity);
Row::new([
Cell::from(a.severity.clone())
.style(Style::default().fg(color).add_modifier(Modifier::BOLD)),
Cell::from(a.rule.clone()),
Cell::from(a.event.get("ProcessId").cloned().unwrap_or_default()),
Cell::from(a.event.get("Image").cloned().unwrap_or_default()),
Cell::from(a.event.get("CommandLine").cloned().unwrap_or_default()),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Percentage(25),
Constraint::Length(8),
Constraint::Percentage(30),
Constraint::Min(0),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(" Alertes détectées "))
.row_highlight_style(Style::default().bg(Color::DarkGray));
frame.render_stateful_widget(table, chunks[1], state);
}
fn scroll_down(state: &mut TableState, len: usize)
{
if len == 0 { return; }
let next = state.selected().map(|i| (i + 1).min(len - 1)).unwrap_or(0);
state.select(Some(next));
}
fn scroll_up(state: &mut TableState)
{
let prev = state.selected().map(|i| i.saturating_sub(1)).unwrap_or(0);
state.select(Some(prev));
}