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) -> Result<(), Box> { 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 = 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>, rx: &mut Receiver, alerts: &mut Vec, state: &mut TableState, ) -> Result<(), Box> { 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 = 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)); }