Skip to main content

synapse_pingora/
tui.rs

1//! Terminal User Interface for Synapse-Pingora monitoring.
2//! Built with ratatui for high-performance terminal visualization.
3
4use chrono;
5use crossterm::{
6    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
7    execute,
8    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
9};
10use hex;
11use ratatui::{
12    backend::{Backend, CrosstermBackend},
13    layout::{Constraint, Direction, Layout, Rect},
14    style::{Color, Modifier, Style},
15    text::{Line, Span},
16    widgets::{
17        Block, Borders, Cell, Clear, Gauge, List, ListItem, Paragraph, Row, Sparkline, Table,
18        TableState, Tabs,
19    },
20    Frame, Terminal,
21};
22use sha2::{Digest, Sha256};
23use std::io;
24use std::sync::Arc;
25use std::time::{Duration, Instant};
26use sysinfo::System;
27
28use crate::block_log::BlockLog;
29use crate::entity::EntityManager;
30use crate::metrics::{MetricsSnapshot, TuiDataProvider};
31use crate::waf::Synapse;
32
33/// Action that requires operator confirmation
34pub enum ConfirmationAction {
35    BlockIP(String),
36    UnblockIP(String),
37    ReloadRules,
38}
39
40/// TUI Dashboard Application
41pub struct TuiApp {
42    /// Data provider for real-time stats
43    provider: Arc<dyn TuiDataProvider>,
44    /// Last snapshot of metrics
45    snapshot: MetricsSnapshot,
46    /// Entity manager for risk tracking
47    entities: Arc<EntityManager>,
48    /// Block log for recent events
49    block_log: Arc<BlockLog>,
50    /// Shared Synapse engine for rule reloading
51    synapse: Arc<parking_lot::RwLock<Synapse>>,
52    /// Application start time
53    start_time: Instant,
54    /// Whether the app should quit
55    pub should_quit: bool,
56    /// Whether the UI is paused
57    pub paused: bool,
58    /// Whether to show the help modal
59    pub show_help: bool,
60    /// Whether to show the entity detail modal
61    pub show_entity_detail: bool,
62    /// Whether to show the confirmation modal
63    pub show_confirmation: bool,
64    /// Action to confirm
65    pub confirmation_action: Option<ConfirmationAction>,
66    /// Active tab index
67    pub active_tab: usize,
68    /// Whether to show the actor detail modal
69    pub show_actor_detail: bool,
70    /// System info for resource monitoring
71    pub system: System,
72    /// Message from last action
73    pub last_action_message: Option<String>,
74    /// When the message was set
75    pub message_time: Option<Instant>,
76    /// State for entity table
77    pub entity_table_state: TableState,
78    /// State for rule table
79    pub rule_table_state: TableState,
80    /// State for actor table
81    pub actor_table_state: TableState,
82    /// State for JA4 table
83    pub ja4_table_state: TableState,
84    /// Last time system info was refreshed
85    pub last_system_refresh: Instant,
86    /// Tick rate for updates
87    tick_rate: Duration,
88}
89
90impl TuiApp {
91    pub fn new(
92        provider: Arc<dyn TuiDataProvider>,
93        entities: Arc<EntityManager>,
94        block_log: Arc<BlockLog>,
95        synapse: Arc<parking_lot::RwLock<Synapse>>,
96    ) -> Self {
97        Self {
98            provider,
99            snapshot: MetricsSnapshot::default(),
100            entities,
101            block_log,
102            synapse,
103            start_time: Instant::now(),
104            should_quit: false,
105            paused: false,
106            show_help: false,
107            show_entity_detail: false,
108            show_confirmation: false,
109            confirmation_action: None,
110            show_actor_detail: false,
111            active_tab: 0,
112            system: System::new_all(),
113            last_action_message: None,
114            message_time: None,
115            entity_table_state: TableState::default(),
116            rule_table_state: TableState::default(),
117            actor_table_state: TableState::default(),
118            ja4_table_state: TableState::default(),
119            last_system_refresh: Instant::now(),
120            tick_rate: Duration::from_millis(250),
121        }
122    }
123
124    /// Run the TUI event loop
125    pub fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> io::Result<()> {
126        let mut last_tick = Instant::now();
127        while !self.should_quit {
128            // Update snapshot if not paused
129            if !self.paused {
130                self.snapshot = self.provider.get_snapshot();
131            }
132
133            if !self.paused || self.show_help {
134                terminal.draw(|f| self.ui(f))?;
135            }
136
137            let timeout = self
138                .tick_rate
139                .checked_sub(last_tick.elapsed())
140                .unwrap_or_else(|| Duration::from_secs(0));
141
142            if event::poll(timeout)? {
143                if let Event::Key(key) = event::read()? {
144                    if self.show_help {
145                        match key.code {
146                            KeyCode::Char('h')
147                            | KeyCode::Char('?')
148                            | KeyCode::Esc
149                            | KeyCode::Enter => {
150                                self.show_help = false;
151                            }
152                            _ => {}
153                        }
154                    } else if self.show_entity_detail {
155                        match key.code {
156                            KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
157                                self.show_entity_detail = false;
158                            }
159                            _ => {}
160                        }
161                    } else if self.show_confirmation {
162                        match key.code {
163                            KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
164                                self.execute_confirmed_action();
165                                self.show_confirmation = false;
166                            }
167                            KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
168                                self.show_confirmation = false;
169                                self.confirmation_action = None;
170                            }
171                            _ => {}
172                        }
173                    } else {
174                        match key.code {
175                            KeyCode::Char('q') => self.should_quit = true,
176                            KeyCode::Char('r') => self.provider.reset_all(),
177                            KeyCode::Char('p') | KeyCode::Char(' ') => self.paused = !self.paused,
178                            KeyCode::Char('?') | KeyCode::Char('h') => {
179                                self.show_help = !self.show_help
180                            }
181                            KeyCode::Char('1') => self.active_tab = 0,
182                            KeyCode::Char('2') => self.active_tab = 1,
183                            KeyCode::Char('3') => self.active_tab = 2,
184                            KeyCode::Char('4') => self.active_tab = 3,
185                            KeyCode::Tab => {
186                                self.active_tab = (self.active_tab + 1) % 4;
187                            }
188                            KeyCode::Down | KeyCode::Char('j') => self.next_row(),
189                            KeyCode::Up | KeyCode::Char('k') => self.previous_row(),
190                            KeyCode::Char('u') => self.action_unblock(),
191                            KeyCode::Char('b') => self.action_block(),
192                            KeyCode::Char('L') => self.action_reload_rules(),
193                            KeyCode::Enter => {
194                                if self.active_tab == 0 {
195                                    self.show_entity_detail = true;
196                                } else if self.active_tab == 2 {
197                                    self.show_actor_detail = true;
198                                }
199                            }
200                            _ => {}
201                        }
202                    }
203                }
204            }
205
206            if last_tick.elapsed() >= self.tick_rate {
207                // Finding #13: System refresh is expensive, do it every 2 seconds instead of 4 FPS
208                if self.last_system_refresh.elapsed() >= Duration::from_secs(2) {
209                    self.system.refresh_cpu_all();
210                    self.system.refresh_memory();
211                    self.last_system_refresh = Instant::now();
212                }
213
214                // Clear old messages
215                if let Some(msg_time) = self.message_time {
216                    if msg_time.elapsed() >= Duration::from_secs(3) {
217                        self.last_action_message = None;
218                        self.message_time = None;
219                    }
220                }
221
222                last_tick = Instant::now();
223            }
224        }
225        Ok(())
226    }
227
228    fn set_message(&mut self, message: &str) {
229        self.last_action_message = Some(message.to_string());
230        self.message_time = Some(Instant::now());
231    }
232
233    fn action_unblock(&mut self) {
234        if self.active_tab != 0 {
235            return;
236        }
237        let selected = self.entity_table_state.selected().unwrap_or(0);
238        let top_entities = self.entities.list_top_risk(10);
239        if let Some(entity) = top_entities.get(selected) {
240            self.confirmation_action =
241                Some(ConfirmationAction::UnblockIP(entity.entity_id.clone()));
242            self.show_confirmation = true;
243        }
244    }
245
246    fn action_block(&mut self) {
247        if self.active_tab != 0 {
248            return;
249        }
250        let selected = self.entity_table_state.selected().unwrap_or(0);
251        let top_entities = self.entities.list_top_risk(10);
252        if let Some(entity) = top_entities.get(selected) {
253            self.confirmation_action = Some(ConfirmationAction::BlockIP(entity.entity_id.clone()));
254            self.show_confirmation = true;
255        }
256    }
257
258    fn action_reload_rules(&mut self) {
259        self.confirmation_action = Some(ConfirmationAction::ReloadRules);
260        self.show_confirmation = true;
261    }
262
263    fn execute_confirmed_action(&mut self) {
264        let action = self.confirmation_action.take();
265        match action {
266            Some(ConfirmationAction::BlockIP(ip)) => {
267                let reason = format!(
268                    "Manual TUI block at {}",
269                    chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
270                );
271                self.entities.manual_block(&ip, &reason);
272                self.set_message(&format!("Blocked IP: {}", ip));
273            }
274            Some(ConfirmationAction::UnblockIP(ip)) => {
275                self.entities.release_entity(&ip);
276                self.set_message(&format!("Unblocked IP: {}", ip));
277            }
278            Some(ConfirmationAction::ReloadRules) => {
279                self.perform_reload_rules();
280            }
281            None => {}
282        }
283    }
284
285    fn perform_reload_rules(&mut self) {
286        // SAFETY: Paths are hardcoded and verified.
287        let rules_paths = [
288            "data/rules.json",
289            "rules.json",
290            "/etc/synapse-pingora/rules.json",
291        ];
292
293        let mut reloaded = false;
294        for path in &rules_paths {
295            // Finding #2: Use std::fs::read directly to avoid TOCTOU race
296            match std::fs::read(path) {
297                Ok(json) => {
298                    // Finding #4: Read and parse BEFORE taking the write lock to minimize blocking
299                    // Finding #3: Simple integrity check (checksum)
300                    let hash = hex::encode(Sha256::digest(&json));
301
302                    // Precompute everything (expensive regex compilation) outside of lock
303                    let synapse_read = self.synapse.read();
304                    match synapse_read.precompute_rules(&json) {
305                        Ok(compiled) => {
306                            drop(synapse_read);
307                            let count = compiled.rules.len();
308                            let mut synapse = self.synapse.write();
309                            // Fast swap inside lock
310                            synapse.reload_from_compiled(compiled);
311                            drop(synapse);
312                            self.set_message(&format!(
313                                "Reloaded {} rules (Hash: {}...)",
314                                count,
315                                &hash[..8]
316                            ));
317                            reloaded = true;
318                            break;
319                        }
320                        Err(e) => {
321                            drop(synapse_read);
322                            self.set_message(&format!(
323                                "Failed to compile rules from {}: {}",
324                                path, e
325                            ));
326                            reloaded = true;
327                            break;
328                        }
329                    }
330                }
331                Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => {
332                    continue;
333                }
334                Err(e) => {
335                    self.set_message(&format!("Failed to read {}: {}", path, e));
336                    reloaded = true;
337                    break;
338                }
339            }
340        }
341
342        if !reloaded {
343            self.set_message("No rules.json found to reload");
344        }
345    }
346
347    fn next_row(&mut self) {
348        match self.active_tab {
349            0 => {
350                let len = self.entities.list_top_risk(10).len();
351                if len == 0 {
352                    return;
353                }
354                let i = match self.entity_table_state.selected() {
355                    Some(i) => {
356                        if i >= len.saturating_sub(1) {
357                            0
358                        } else {
359                            i + 1
360                        }
361                    }
362                    None => 0,
363                };
364                self.entity_table_state.select(Some(i));
365            }
366            1 => {
367                let len = self.snapshot.top_rules.len();
368                if len == 0 {
369                    return;
370                }
371                let i = match self.rule_table_state.selected() {
372                    Some(i) => {
373                        if i >= len.saturating_sub(1) {
374                            0
375                        } else {
376                            i + 1
377                        }
378                    }
379                    None => 0,
380                };
381                self.rule_table_state.select(Some(i));
382            }
383            2 => {
384                let len = self.snapshot.top_risky_actors.len();
385                if len == 0 {
386                    return;
387                }
388                let i = match self.actor_table_state.selected() {
389                    Some(i) => {
390                        if i >= len.saturating_sub(1) {
391                            0
392                        } else {
393                            i + 1
394                        }
395                    }
396                    None => 0,
397                };
398                self.actor_table_state.select(Some(i));
399            }
400            _ => {}
401        }
402    }
403
404    fn previous_row(&mut self) {
405        match self.active_tab {
406            0 => {
407                let len = self.entities.list_top_risk(10).len();
408                if len == 0 {
409                    return;
410                }
411                let i = match self.entity_table_state.selected() {
412                    Some(i) => {
413                        if i == 0 {
414                            len.saturating_sub(1)
415                        } else {
416                            i - 1
417                        }
418                    }
419                    None => 0,
420                };
421                self.entity_table_state.select(Some(i));
422            }
423            1 => {
424                let len = self.snapshot.top_rules.len();
425                if len == 0 {
426                    return;
427                }
428                let i = match self.rule_table_state.selected() {
429                    Some(i) => {
430                        if i == 0 {
431                            len.saturating_sub(1)
432                        } else {
433                            i - 1
434                        }
435                    }
436                    None => 0,
437                };
438                self.rule_table_state.select(Some(i));
439            }
440            2 => {
441                let len = self.snapshot.top_risky_actors.len();
442                if len == 0 {
443                    return;
444                }
445                let i = match self.actor_table_state.selected() {
446                    Some(i) => {
447                        if i == 0 {
448                            len.saturating_sub(1)
449                        } else {
450                            i - 1
451                        }
452                    }
453                    None => 0,
454                };
455                self.actor_table_state.select(Some(i));
456            }
457            _ => {}
458        }
459    }
460
461    fn ui(&mut self, f: &mut Frame) {
462        let size = f.size();
463
464        // Vertical layout: Header (3), Tabs (3), Main Content (1fr), Footer (1)
465        let chunks = Layout::default()
466            .direction(Direction::Vertical)
467            .constraints(
468                [
469                    Constraint::Length(3),
470                    Constraint::Length(3),
471                    Constraint::Min(10),
472                    Constraint::Length(1),
473                ]
474                .as_ref(),
475            )
476            .split(size);
477
478        self.render_header(f, chunks[0]);
479        self.render_tabs(f, chunks[1]);
480
481        match self.active_tab {
482            0 => self.render_monitor_tab(f, chunks[2]),
483            1 => self.render_waf_tab(f, chunks[2]),
484            2 => self.render_intelligence_tab(f, chunks[2]),
485            3 => self.render_threat_ops_tab(f, chunks[2]),
486            _ => {}
487        }
488
489        self.render_footer(f, chunks[3]);
490
491        if self.show_help {
492            self.render_help_modal(f);
493        }
494
495        if self.show_confirmation {
496            self.render_confirmation_modal(f);
497        }
498
499        if self.show_entity_detail {
500            self.render_entity_detail_modal(f);
501        }
502
503        if self.show_actor_detail {
504            self.render_actor_detail_modal(f);
505        }
506    }
507
508    fn render_header(&self, f: &mut Frame, area: Rect) {
509        let uptime = self.snapshot.uptime_secs;
510        let total_requests = self.snapshot.total_requests;
511        let blocked = self.snapshot.total_blocked;
512
513        let block_rate = if total_requests > 0 {
514            (blocked as f64 / total_requests as f64) * 100.0
515        } else {
516            0.0
517        };
518
519        let status_mode = if self.paused { " {PAUSED} " } else { "" };
520
521        let header_text = format!(
522            " Synapse-Pingora v0.1.0 | Uptime: {}s | Requests: {} | Blocked: {} ({:.1}%){} ",
523            uptime, total_requests, blocked, block_rate, status_mode
524        );
525
526        let mut header_spans = vec![Span::styled(
527            header_text,
528            Style::default()
529                .fg(Color::Cyan)
530                .add_modifier(Modifier::BOLD),
531        )];
532
533        if let Some(ref msg) = self.last_action_message {
534            header_spans.push(Span::styled(
535                format!(" [ {} ] ", msg),
536                Style::default()
537                    .bg(Color::Yellow)
538                    .fg(Color::Black)
539                    .add_modifier(Modifier::BOLD),
540            ));
541        }
542
543        let header = Paragraph::new(Line::from(header_spans))
544            .block(Block::default().borders(Borders::ALL).title(" Status "));
545
546        f.render_widget(header, area);
547    }
548
549    fn render_tabs(&self, f: &mut Frame, area: Rect) {
550        let titles = vec![
551            Line::from(" [1] Monitor "),
552            Line::from(" [2] WAF & Upstream "),
553            Line::from(" [3] Intelligence "),
554            Line::from(" [4] Threat Ops "),
555        ];
556        let tabs = Tabs::new(titles)
557            .block(Block::default().borders(Borders::ALL).title(" Navigation "))
558            .select(self.active_tab)
559            .style(Style::default().fg(Color::White))
560            .highlight_style(
561                Style::default()
562                    .add_modifier(Modifier::BOLD)
563                    .bg(Color::Cyan)
564                    .fg(Color::Black),
565            );
566        f.render_widget(tabs, area);
567    }
568
569    fn render_monitor_tab(&mut self, f: &mut Frame, area: Rect) {
570        // Horizontal layout: Left (Metrics + Chart), Right (Entities + Blocks)
571        let main_chunks = Layout::default()
572            .direction(Direction::Horizontal)
573            .constraints([Constraint::Percentage(40), Constraint::Percentage(60)].as_ref())
574            .split(area);
575
576        self.render_left_panel(f, main_chunks[0]);
577        self.render_right_panel(f, main_chunks[1]);
578    }
579
580    fn render_waf_tab(&mut self, f: &mut Frame, area: Rect) {
581        let chunks = Layout::default()
582            .direction(Direction::Horizontal)
583            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
584            .split(area);
585
586        // Top WAF Rules
587        let top_rules = &self.snapshot.top_rules;
588        let header = Row::new(vec![Cell::from("Rule ID"), Cell::from("Hits")]).style(
589            Style::default()
590                .add_modifier(Modifier::BOLD)
591                .fg(Color::Magenta),
592        );
593
594        let rows = top_rules
595            .iter()
596            .map(|(id, hits)| Row::new(vec![Cell::from(id.clone()), Cell::from(hits.to_string())]));
597
598        let rule_table = Table::new(rows, [Constraint::Min(20), Constraint::Length(10)])
599            .header(header)
600            .highlight_style(Style::default().bg(Color::DarkGray))
601            .block(
602                Block::default()
603                    .borders(Borders::ALL)
604                    .title(" Top Triggered WAF Rules "),
605            );
606
607        f.render_stateful_widget(rule_table, chunks[0], &mut self.rule_table_state);
608
609        // Upstream Status
610        let backends = &self.snapshot.backend_status;
611        let b_header = Row::new(vec![
612            Cell::from("Upstream"),
613            Cell::from("Status"),
614            Cell::from("Reqs"),
615            Cell::from("Latency"),
616        ])
617        .style(
618            Style::default()
619                .add_modifier(Modifier::BOLD)
620                .fg(Color::Yellow),
621        );
622
623        let b_rows = backends.iter().map(|(host, m)| {
624            let status = if m.healthy { "HEALTHY" } else { "ERROR" };
625            let status_color = if m.healthy { Color::Green } else { Color::Red };
626            let avg_ms = if m.requests > 0 {
627                m.response_time_us as f64 / m.requests as f64 / 1000.0
628            } else {
629                0.0
630            };
631
632            Row::new(vec![
633                Cell::from(host.clone()),
634                Cell::from(status).style(Style::default().fg(status_color)),
635                Cell::from(m.requests.to_string()),
636                Cell::from(format!("{:.1}ms", avg_ms)),
637            ])
638        });
639
640        let backend_table = Table::new(
641            b_rows,
642            [
643                Constraint::Min(20),
644                Constraint::Length(10),
645                Constraint::Length(8),
646                Constraint::Length(10),
647            ],
648        )
649        .header(b_header)
650        .block(
651            Block::default()
652                .borders(Borders::ALL)
653                .title(" Upstream Backend Health "),
654        );
655        f.render_widget(backend_table, chunks[1]);
656    }
657
658    fn render_intelligence_tab(&mut self, f: &mut Frame, area: Rect) {
659        let chunks = Layout::default()
660            .direction(Direction::Horizontal)
661            .constraints([Constraint::Percentage(40), Constraint::Percentage(60)].as_ref())
662            .split(area);
663
664        let left_chunks = Layout::default()
665            .direction(Direction::Vertical)
666            .constraints(
667                [
668                    Constraint::Percentage(33),
669                    Constraint::Percentage(33),
670                    Constraint::Percentage(34),
671                ]
672                .as_ref(),
673            )
674            .split(chunks[0]);
675
676        // Legitimate Crawlers
677        let crawlers = &self.snapshot.top_crawlers;
678        let c_items: Vec<ListItem> = crawlers
679            .iter()
680            .map(|(name, hits)| {
681                ListItem::new(format!("{:<15} : {} hits", name, hits))
682                    .style(Style::default().fg(Color::Green))
683            })
684            .collect();
685        let c_list = List::new(c_items).block(
686            Block::default()
687                .borders(Borders::ALL)
688                .title(" Legitimate Crawlers "),
689        );
690        f.render_widget(c_list, left_chunks[0]);
691
692        // Bad Bots
693        let bad_bots = &self.snapshot.top_bad_bots;
694        let b_items: Vec<ListItem> = bad_bots
695            .iter()
696            .map(|(name, hits)| {
697                ListItem::new(format!("{:<15} : {} hits", name, hits))
698                    .style(Style::default().fg(Color::Red))
699            })
700            .collect();
701        let b_list = List::new(b_items).block(
702            Block::default()
703                .borders(Borders::ALL)
704                .title(" Malicious Bots / Scrapers "),
705        );
706        f.render_widget(b_list, left_chunks[1]);
707
708        // DLP Hits
709        let dlp_hits = &self.snapshot.top_dlp_hits;
710        let d_items: Vec<ListItem> = dlp_hits
711            .iter()
712            .map(|(name, hits)| {
713                ListItem::new(format!("{:<15} : {} matches", name, hits))
714                    .style(Style::default().fg(Color::Magenta))
715            })
716            .collect();
717        let d_list = List::new(d_items).block(
718            Block::default()
719                .borders(Borders::ALL)
720                .title(" DLP Security Scan "),
721        );
722        f.render_widget(d_list, left_chunks[2]);
723
724        let right_chunks = Layout::default()
725            .direction(Direction::Vertical)
726            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
727            .split(chunks[1]);
728
729        // JA4 clusters
730        let clusters = &self.snapshot.top_ja4_clusters;
731        let header = Row::new(vec![
732            Cell::from("Fingerprint (JA4)"),
733            Cell::from("Nodes"),
734            Cell::from("Max Risk"),
735        ])
736        .style(
737            Style::default()
738                .add_modifier(Modifier::BOLD)
739                .fg(Color::Cyan),
740        );
741
742        let rows = clusters.iter().map(|(fp, nodes, max_risk)| {
743            Row::new(vec![
744                Cell::from(fp.clone()),
745                Cell::from(nodes.len().to_string()),
746                Cell::from(format!("{:.1}", max_risk)),
747            ])
748        });
749
750        let table = Table::new(
751            rows,
752            [
753                Constraint::Min(30),
754                Constraint::Length(8),
755                Constraint::Length(10),
756            ],
757        )
758        .header(header)
759        .block(
760            Block::default()
761                .borders(Borders::ALL)
762                .title(" JA4 Fingerprint Clusters "),
763        );
764        f.render_widget(table, right_chunks[0]);
765
766        // Top Risky Actors (Fingerprint correlated)
767        let top_actors = &self.snapshot.top_risky_actors;
768        let a_header = Row::new(vec![
769            Cell::from("Actor ID (Correlated)"),
770            Cell::from("Risk"),
771            Cell::from("IPs"),
772        ])
773        .style(Style::default().add_modifier(Modifier::BOLD).fg(Color::Red));
774
775        let a_rows = top_actors.iter().map(|actor| {
776            Row::new(vec![
777                Cell::from(actor.actor_id.clone()),
778                Cell::from(format!("{:.1}", actor.risk_score)),
779                Cell::from(actor.ips.len().to_string()),
780            ])
781        });
782
783        let actor_table = Table::new(
784            a_rows,
785            [
786                Constraint::Min(30),
787                Constraint::Length(8),
788                Constraint::Length(8),
789            ],
790        )
791        .header(a_header)
792        .highlight_style(Style::default().bg(Color::DarkGray))
793        .block(
794            Block::default()
795                .borders(Borders::ALL)
796                .title(" Top Correlated Actors "),
797        );
798
799        f.render_stateful_widget(actor_table, right_chunks[1], &mut self.actor_table_state);
800    }
801
802    fn render_threat_ops_tab(&mut self, f: &mut Frame, area: Rect) {
803        let chunks = Layout::default()
804            .direction(Direction::Horizontal)
805            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
806            .split(area);
807
808        let left_chunks = Layout::default()
809            .direction(Direction::Vertical)
810            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
811            .split(chunks[0]);
812
813        // Tarpit Status
814        if let Some(ref tarpit) = self.snapshot.tarpit_stats {
815            let items = vec![
816                ListItem::new(format!("Tracked States: {}", tarpit.total_states)),
817                ListItem::new(format!("Active Tarpits: {}", tarpit.active_tarpits)),
818                ListItem::new(format!("Total Hits:     {}", tarpit.total_hits)),
819                ListItem::new(format!("Total Delay:    {}ms", tarpit.total_delay_ms)),
820            ];
821            let list = List::new(items).block(
822                Block::default()
823                    .borders(Borders::ALL)
824                    .title(" Tarpit Mitigation (Level 4) "),
825            );
826            f.render_widget(list, left_chunks[0]);
827        } else {
828            let paragraph = Paragraph::new("\n  Tarpit Manager not initialized.\n  Check configuration to enable Level 4 mitigation.")
829                .block(Block::default().borders(Borders::ALL).title(" Tarpit Mitigation (Level 4) "));
830            f.render_widget(paragraph, left_chunks[0]);
831        }
832
833        // Challenge Stats
834        if let Some(ref prog) = self.snapshot.progression_stats {
835            let items = vec![
836                ListItem::new(format!("Actors Tracked: {}", prog.actors_tracked)),
837                ListItem::new(format!("Issued:         {}", prog.challenges_issued)),
838                ListItem::new(format!(
839                    "Success/Fail:   {} / {}",
840                    prog.successes, prog.failures
841                )),
842                ListItem::new(format!("Escalations:    {}", prog.escalations)),
843            ];
844            let list = List::new(items).block(
845                Block::default()
846                    .borders(Borders::ALL)
847                    .title(" Interrogator Challenges (Level 1-3) "),
848            );
849            f.render_widget(list, left_chunks[1]);
850        } else {
851            let paragraph = Paragraph::new("\n  Interrogator System not initialized.\n  Check configuration to enable Level 1-3 challenges.")
852                .block(Block::default().borders(Borders::ALL).title(" Interrogator Challenges (Level 1-3) "));
853            f.render_widget(paragraph, left_chunks[1]);
854        }
855
856        let right_chunks = Layout::default()
857            .direction(Direction::Vertical)
858            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
859            .split(chunks[1]);
860
861        // Shadow Mirroring
862        if let Some(ref shadow) = self.snapshot.shadow_stats {
863            let items = vec![
864                ListItem::new(format!(
865                    "Mirror Mode:   {}",
866                    if shadow.enabled { "ACTIVE" } else { "OFF" }
867                )),
868                ListItem::new(format!("Success:       {}", shadow.delivery_successes)),
869                ListItem::new(format!("Failures:      {}", shadow.delivery_failures)),
870                ListItem::new(format!(
871                    "Queue Load:    {}/{}",
872                    shadow.max_concurrent - shadow.queue_available,
873                    shadow.max_concurrent
874                )),
875            ];
876            let list = List::new(items).block(
877                Block::default()
878                    .borders(Borders::ALL)
879                    .title(" Honeypot Shadow Mirroring "),
880            );
881            f.render_widget(list, right_chunks[0]);
882        } else {
883            let paragraph = Paragraph::new("\n  Shadow Mirroring not initialized.\n  Check configuration to enable honeypot mirroring.")
884                .block(Block::default().borders(Borders::ALL).title(" Honeypot Shadow Mirroring "));
885            f.render_widget(paragraph, right_chunks[0]);
886        }
887
888        // Geo Anomalies
889        let geo_anomalies = &self.snapshot.recent_geo_anomalies;
890        let items: Vec<ListItem> = geo_anomalies
891            .iter()
892            .map(|a| {
893                ListItem::new(format!("[{:?}] {}", a.severity, a.description)).style(
894                    Style::default().fg(match a.severity {
895                        crate::trends::AnomalySeverity::Critical => Color::Red,
896                        crate::trends::AnomalySeverity::High => Color::LightRed,
897                        _ => Color::Yellow,
898                    }),
899                )
900            })
901            .collect();
902        let list = List::new(items).block(
903            Block::default()
904                .borders(Borders::ALL)
905                .title(" Geographic / Travel Anomalies "),
906        );
907        f.render_widget(list, right_chunks[1]);
908    }
909
910    fn render_left_panel(&self, f: &mut Frame, area: Rect) {
911        let chunks = Layout::default()
912            .direction(Direction::Vertical)
913            .constraints(
914                [
915                    Constraint::Length(6), // RPS Gauge
916                    Constraint::Length(6), // Sparkline
917                    Constraint::Length(6), // Resource Gauges
918                    Constraint::Min(0),    // Detailed Metrics
919                ]
920                .as_ref(),
921            )
922            .split(area);
923
924        // RPS Gauge
925        let history = &self.snapshot.request_history;
926        let rps = history.last().copied().unwrap_or(0);
927        let rps_gauge = Gauge::default()
928            .block(
929                Block::default()
930                    .borders(Borders::ALL)
931                    .title(" Requests/sec "),
932            )
933            .gauge_style(Style::default().fg(Color::Green))
934            .percent(rps.min(100) as u16)
935            .label(format!("{} RPS", rps));
936        f.render_widget(rps_gauge, chunks[0]);
937
938        // Traffic Trend (Sparkline)
939        let sparkline = Sparkline::default()
940            .block(
941                Block::default()
942                    .borders(Borders::ALL)
943                    .title(" Traffic Trend (60s) "),
944            )
945            .data(history)
946            .style(Style::default().fg(Color::Green));
947        f.render_widget(sparkline, chunks[1]);
948
949        // System Resource Gauges
950        let res_chunks = Layout::default()
951            .direction(Direction::Vertical)
952            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
953            .margin(1)
954            .split(chunks[2]);
955
956        let cpu_usage = self.system.global_cpu_usage();
957        let cpu_gauge = Gauge::default()
958            .block(Block::default().title(" CPU Usage ").borders(Borders::NONE))
959            .gauge_style(Style::default().fg(Color::Yellow))
960            .percent(cpu_usage as u16)
961            .label(format!("{:.1}%", cpu_usage));
962        f.render_widget(cpu_gauge, res_chunks[0]);
963
964        let mem_used = self.system.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
965        let mem_total = self.system.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
966        let mem_percent = (mem_used / mem_total * 100.0) as u16;
967        let mem_gauge = Gauge::default()
968            .block(
969                Block::default()
970                    .title(" Memory Usage ")
971                    .borders(Borders::NONE),
972            )
973            .gauge_style(Style::default().fg(Color::Magenta))
974            .percent(mem_percent)
975            .label(format!("{:.1}G / {:.1}G", mem_used, mem_total));
976        f.render_widget(mem_gauge, res_chunks[1]);
977
978        // Detailed Metrics
979        let avg_latency = self.snapshot.avg_latency_ms;
980        let avg_waf = self.snapshot.avg_waf_detection_us;
981
982        let metrics_list = vec![
983            ListItem::new(format!("Avg Latency:   {:.2} ms", avg_latency)),
984            ListItem::new(format!("WAF Detection: {:.2} μs", avg_waf)),
985            ListItem::new(format!("Active Conns:  {}", self.snapshot.active_requests)),
986            ListItem::new(format!("Rules Loaded:  {}", self.snapshot.top_rules.len())),
987        ];
988
989        let metrics = List::new(metrics_list).block(
990            Block::default()
991                .borders(Borders::ALL)
992                .title(" System Metrics "),
993        );
994        f.render_widget(metrics, chunks[3]);
995    }
996
997    fn render_right_panel(&mut self, f: &mut Frame, area: Rect) {
998        let chunks = Layout::default()
999            .direction(Direction::Vertical)
1000            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
1001            .split(area);
1002
1003        // Top Risky Entities
1004        let top_entities = &self.snapshot.top_entities;
1005        let header = Row::new(vec![
1006            Cell::from("IP Address"),
1007            Cell::from("Risk"),
1008            Cell::from("Reqs"),
1009            Cell::from("Status"),
1010        ])
1011        .style(
1012            Style::default()
1013                .add_modifier(Modifier::BOLD)
1014                .fg(Color::Yellow),
1015        );
1016
1017        let rows = top_entities.iter().map(|e| {
1018            let status = if e.blocked { "BLOCKED" } else { "OK" };
1019            let status_color = if e.blocked { Color::Red } else { Color::Green };
1020            let risk_color = if e.risk >= 70.0 {
1021                Color::Red
1022            } else if e.risk >= 30.0 {
1023                Color::Yellow
1024            } else {
1025                Color::Green
1026            };
1027
1028            Row::new(vec![
1029                Cell::from(e.entity_id.clone()),
1030                Cell::from(format!("{:.1}", e.risk)).style(Style::default().fg(risk_color)),
1031                Cell::from(e.request_count.to_string()),
1032                Cell::from(status).style(Style::default().fg(status_color)),
1033            ])
1034        });
1035
1036        let table = Table::new(
1037            rows,
1038            [
1039                Constraint::Min(15),
1040                Constraint::Length(8),
1041                Constraint::Length(8),
1042                Constraint::Length(10),
1043            ],
1044        )
1045        .header(header)
1046        .highlight_style(Style::default().bg(Color::DarkGray))
1047        .block(
1048            Block::default()
1049                .borders(Borders::ALL)
1050                .title(" Top Risky Entities (↑/↓ Select) "),
1051        );
1052
1053        f.render_stateful_widget(table, chunks[0], &mut self.entity_table_state);
1054
1055        // Recent Blocks
1056        let recent_blocks = &self.snapshot.recent_blocks;
1057        let block_items: Vec<ListItem> = recent_blocks
1058            .iter()
1059            .map(|b| {
1060                let time = chrono::DateTime::from_timestamp_millis(b.timestamp as i64)
1061                    .map(|dt| dt.format("%H:%M:%S").to_string())
1062                    .unwrap_or_else(|| "00:00:00".to_string());
1063
1064                ListItem::new(format!(
1065                    "[{}] {} blocked on {} (Risk: {})",
1066                    time, b.client_ip, b.path, b.risk_score
1067                ))
1068                .style(Style::default().fg(Color::Red))
1069            })
1070            .collect();
1071
1072        let blocks = List::new(block_items).block(
1073            Block::default()
1074                .borders(Borders::ALL)
1075                .title(" Recent WAF Blocks "),
1076        );
1077        f.render_widget(blocks, chunks[1]);
1078    }
1079
1080    fn render_footer(&self, f: &mut Frame, area: Rect) {
1081        let footer_text = if self.paused {
1082            " [p] Resume | [q] Quit | [b/u] Block/Unblock | [L] Reload | [Tab] Switch Tab | [h] Help "
1083        } else {
1084            " [p] Pause | [q] Quit | [b/u] Block/Unblock | [L] Reload | [Tab] Switch Tab | [h] Help "
1085        };
1086        let footer =
1087            Paragraph::new(footer_text).style(Style::default().bg(Color::Blue).fg(Color::White));
1088        f.render_widget(footer, area);
1089    }
1090
1091    fn render_help_modal(&self, f: &mut Frame) {
1092        let area = centered_rect(60, 55, f.size());
1093        f.render_widget(Clear, area); // Clear the area before rendering the modal
1094
1095        let help_text = vec![
1096            Line::from(" Synapse-Pingora TUI Dashboard "),
1097            Line::from(""),
1098            Line::from(vec![
1099                Span::styled("  q           ", Style::default().fg(Color::Yellow)),
1100                Span::raw(": Quit proxy and dashboard"),
1101            ]),
1102            Line::from(vec![
1103                Span::styled("  p/space     ", Style::default().fg(Color::Yellow)),
1104                Span::raw(": Pause/Resume UI updates"),
1105            ]),
1106            Line::from(vec![
1107                Span::styled("  Tab / 1-4   ", Style::default().fg(Color::Yellow)),
1108                Span::raw(": Switch between dashboard tabs"),
1109            ]),
1110            Line::from(vec![
1111                Span::styled("  j/k / ↑/↓   ", Style::default().fg(Color::Yellow)),
1112                Span::raw(": Navigate through table rows"),
1113            ]),
1114            Line::from(vec![
1115                Span::styled("  b / u       ", Style::default().fg(Color::Yellow)),
1116                Span::raw(": Manual Block / Unblock selected IP"),
1117            ]),
1118            Line::from(vec![
1119                Span::styled("  L           ", Style::default().fg(Color::Yellow)),
1120                Span::raw(": Reload rules from disk (Shift+L)"),
1121            ]),
1122            Line::from(vec![
1123                Span::styled("  r           ", Style::default().fg(Color::Yellow)),
1124                Span::raw(": Reset global statistics"),
1125            ]),
1126            Line::from(vec![
1127                Span::styled("  h/?         ", Style::default().fg(Color::Yellow)),
1128                Span::raw(": Toggle this help screen"),
1129            ]),
1130            Line::from(""),
1131            Line::from(" Press any key to return "),
1132        ];
1133
1134        let help_paragraph = Paragraph::new(help_text)
1135            .block(Block::default().title(" Help ").borders(Borders::ALL))
1136            .style(Style::default().fg(Color::White));
1137        f.render_widget(help_paragraph, area);
1138    }
1139
1140    fn render_confirmation_modal(&self, f: &mut Frame) {
1141        let area = centered_rect(50, 25, f.size());
1142        f.render_widget(Clear, area);
1143
1144        let (title, message) = match &self.confirmation_action {
1145            Some(ConfirmationAction::BlockIP(ip)) => (
1146                " Confirm Block IP ",
1147                format!("Are you sure you want to BLOCK traffic from {}?\n\nThis will take immediate effect.", ip),
1148            ),
1149            Some(ConfirmationAction::UnblockIP(ip)) => (
1150                " Confirm Unblock IP ",
1151                format!("Are you sure you want to UNBLOCK traffic from {}?", ip),
1152            ),
1153            Some(ConfirmationAction::ReloadRules) => (
1154                " Confirm Rule Reload ",
1155                "Are you sure you want to RELOAD rules from disk?\n\nThis may briefly impact performance during parsing.".to_string(),
1156            ),
1157            None => (" Confirmation ", "No action selected.".to_string()),
1158        };
1159
1160        let content = vec![
1161            Line::from(""),
1162            Line::from(Span::styled(message, Style::default())),
1163            Line::from(""),
1164            Line::from(""),
1165            Line::from(vec![
1166                Span::styled(
1167                    " [Y] Yes, proceed ",
1168                    Style::default()
1169                        .fg(Color::Green)
1170                        .add_modifier(Modifier::BOLD),
1171                ),
1172                Span::raw("   "),
1173                Span::styled(" [N] No, cancel ", Style::default().fg(Color::Red)),
1174            ]),
1175        ];
1176
1177        let paragraph = Paragraph::new(content)
1178            .block(Block::default().title(title).borders(Borders::ALL))
1179            .style(Style::default().fg(Color::White));
1180        f.render_widget(paragraph, area);
1181    }
1182
1183    fn render_entity_detail_modal(&self, f: &mut Frame) {
1184        let area = centered_rect(70, 60, f.size());
1185        f.render_widget(Clear, area);
1186
1187        let top_entities = &self.snapshot.top_entities;
1188        let selected_idx = self.entity_table_state.selected().unwrap_or(0);
1189
1190        if let Some(snapshot) = top_entities.get(selected_idx) {
1191            let mut details = vec![
1192                Line::from(vec![
1193                    Span::styled(" Entity ID:    ", Style::default().fg(Color::Cyan)),
1194                    Span::styled(
1195                        &snapshot.entity_id,
1196                        Style::default().add_modifier(Modifier::BOLD),
1197                    ),
1198                ]),
1199                Line::from(vec![
1200                    Span::styled(" Risk Score:   ", Style::default().fg(Color::Cyan)),
1201                    Span::styled(
1202                        format!("{:.1}", snapshot.risk),
1203                        Style::default().fg(if snapshot.risk > 70.0 {
1204                            Color::Red
1205                        } else {
1206                            Color::Yellow
1207                        }),
1208                    ),
1209                ]),
1210                Line::from(vec![
1211                    Span::styled(" Total Reqs:   ", Style::default().fg(Color::Cyan)),
1212                    Span::raw(snapshot.request_count.to_string()),
1213                ]),
1214                Line::from(vec![
1215                    Span::styled(" Status:       ", Style::default().fg(Color::Cyan)),
1216                    Span::styled(
1217                        if snapshot.blocked { "BLOCKED" } else { "OK" },
1218                        Style::default().fg(if snapshot.blocked {
1219                            Color::Red
1220                        } else {
1221                            Color::Green
1222                        }),
1223                    ),
1224                ]),
1225            ];
1226
1227            if let Some(ref reason) = snapshot.blocked_reason {
1228                details.push(Line::from(vec![
1229                    Span::styled(" Block Reason: ", Style::default().fg(Color::Cyan)),
1230                    Span::styled(reason, Style::default().fg(Color::Gray)),
1231                ]));
1232            }
1233
1234            details.push(Line::from(""));
1235            details.push(Line::from(" [ Press Enter or Esc to return ] "));
1236
1237            let paragraph = Paragraph::new(details)
1238                .block(
1239                    Block::default()
1240                        .title(" Entity Analysis ")
1241                        .borders(Borders::ALL),
1242                )
1243                .style(Style::default().fg(Color::White));
1244            f.render_widget(paragraph, area);
1245        }
1246    }
1247
1248    fn render_actor_detail_modal(&self, f: &mut Frame) {
1249        let area = centered_rect(80, 70, f.size());
1250        f.render_widget(Clear, area);
1251
1252        let actors = &self.snapshot.top_risky_actors;
1253        let selected_idx = self.actor_table_state.selected().unwrap_or(0);
1254
1255        if let Some(actor) = actors.get(selected_idx) {
1256            let mut details = vec![
1257                Line::from(vec![
1258                    Span::styled(" Actor ID:     ", Style::default().fg(Color::Cyan)),
1259                    Span::styled(
1260                        &actor.actor_id,
1261                        Style::default().add_modifier(Modifier::BOLD),
1262                    ),
1263                ]),
1264                Line::from(vec![
1265                    Span::styled(" Risk Score:   ", Style::default().fg(Color::Cyan)),
1266                    Span::styled(
1267                        format!("{:.1}", actor.risk_score),
1268                        Style::default().fg(if actor.risk_score > 70.0 {
1269                            Color::Red
1270                        } else {
1271                            Color::Yellow
1272                        }),
1273                    ),
1274                ]),
1275                Line::from(vec![
1276                    Span::styled(" IPs:          ", Style::default().fg(Color::Cyan)),
1277                    Span::raw(
1278                        actor
1279                            .ips
1280                            .iter()
1281                            .map(|ip: &std::net::IpAddr| ip.to_string())
1282                            .collect::<Vec<_>>()
1283                            .join(", "),
1284                    ),
1285                ]),
1286                Line::from(vec![
1287                    Span::styled(" Fingerprints: ", Style::default().fg(Color::Cyan)),
1288                    Span::raw(
1289                        actor
1290                            .fingerprints
1291                            .iter()
1292                            .cloned()
1293                            .collect::<Vec<_>>()
1294                            .join(", "),
1295                    ),
1296                ]),
1297                Line::from(vec![
1298                    Span::styled(" Status:       ", Style::default().fg(Color::Cyan)),
1299                    Span::styled(
1300                        if actor.is_blocked { "BLOCKED" } else { "OK" },
1301                        Style::default().fg(if actor.is_blocked {
1302                            Color::Red
1303                        } else {
1304                            Color::Green
1305                        }),
1306                    ),
1307                ]),
1308                Line::from(""),
1309                Line::from(Span::styled(
1310                    " Recent Rule Matches:",
1311                    Style::default().add_modifier(Modifier::UNDERLINED),
1312                )),
1313            ];
1314
1315            for m in actor.rule_matches.iter().rev().take(5) {
1316                details.push(Line::from(format!(
1317                    "  - {} ({}) : +{:.1} risk",
1318                    m.rule_id, m.category, m.risk_contribution
1319                )));
1320            }
1321
1322            details.push(Line::from(""));
1323            details.push(Line::from(" [ Press Enter or Esc to return ] "));
1324
1325            let paragraph = Paragraph::new(details)
1326                .block(
1327                    Block::default()
1328                        .title(" Actor Behavior Analysis ")
1329                        .borders(Borders::ALL),
1330                )
1331                .style(Style::default().fg(Color::White));
1332            f.render_widget(paragraph, area);
1333        }
1334    }
1335}
1336
1337/// Helper function to create a centered rect
1338fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1339    let popup_layout = Layout::default()
1340        .direction(Direction::Vertical)
1341        .constraints(
1342            [
1343                Constraint::Percentage((100 - percent_y) / 2),
1344                Constraint::Percentage(percent_y),
1345                Constraint::Percentage((100 - percent_y) / 2),
1346            ]
1347            .as_ref(),
1348        )
1349        .split(r);
1350
1351    Layout::default()
1352        .direction(Direction::Horizontal)
1353        .constraints(
1354            [
1355                Constraint::Percentage((100 - percent_x) / 2),
1356                Constraint::Percentage(percent_x),
1357                Constraint::Percentage((100 - percent_x) / 2),
1358            ]
1359            .as_ref(),
1360        )
1361        .split(popup_layout[1])[1]
1362}
1363
1364/// Start the TUI application
1365pub fn start_tui(
1366    provider: Arc<dyn TuiDataProvider>,
1367    entities: Arc<EntityManager>,
1368    block_log: Arc<BlockLog>,
1369    synapse: Arc<parking_lot::RwLock<Synapse>>,
1370) -> io::Result<()> {
1371    // Finding #1: Set panic hook to restore terminal on crash
1372    let original_hook = std::panic::take_hook();
1373    std::panic::set_hook(Box::new(move |panic| {
1374        let _ = disable_raw_mode();
1375        let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
1376        original_hook(panic);
1377    }));
1378
1379    // Setup terminal
1380    enable_raw_mode()?;
1381    let mut stdout = io::stdout();
1382    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
1383    let backend = CrosstermBackend::new(stdout);
1384    let mut terminal = Terminal::new(backend)?;
1385
1386    // Create app and run
1387    let mut app = TuiApp::new(provider, entities, block_log, synapse);
1388    let res = app.run(&mut terminal);
1389
1390    // Restore terminal
1391    disable_raw_mode()?;
1392    execute!(
1393        terminal.backend_mut(),
1394        LeaveAlternateScreen,
1395        DisableMouseCapture
1396    )?;
1397    terminal.show_cursor()?;
1398
1399    if let Err(err) = res {
1400        println!("{:?}", err);
1401    }
1402
1403    Ok(())
1404}