1use 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
33pub enum ConfirmationAction {
35 BlockIP(String),
36 UnblockIP(String),
37 ReloadRules,
38}
39
40pub struct TuiApp {
42 provider: Arc<dyn TuiDataProvider>,
44 snapshot: MetricsSnapshot,
46 entities: Arc<EntityManager>,
48 block_log: Arc<BlockLog>,
50 synapse: Arc<parking_lot::RwLock<Synapse>>,
52 start_time: Instant,
54 pub should_quit: bool,
56 pub paused: bool,
58 pub show_help: bool,
60 pub show_entity_detail: bool,
62 pub show_confirmation: bool,
64 pub confirmation_action: Option<ConfirmationAction>,
66 pub active_tab: usize,
68 pub show_actor_detail: bool,
70 pub system: System,
72 pub last_action_message: Option<String>,
74 pub message_time: Option<Instant>,
76 pub entity_table_state: TableState,
78 pub rule_table_state: TableState,
80 pub actor_table_state: TableState,
82 pub ja4_table_state: TableState,
84 pub last_system_refresh: Instant,
86 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 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 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 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 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 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 match std::fs::read(path) {
297 Ok(json) => {
298 let hash = hex::encode(Sha256::digest(&json));
301
302 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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), Constraint::Length(6), Constraint::Length(6), Constraint::Min(0), ]
920 .as_ref(),
921 )
922 .split(area);
923
924 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 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 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 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 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 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); 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
1337fn 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
1364pub 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 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 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 let mut app = TuiApp::new(provider, entities, block_log, synapse);
1388 let res = app.run(&mut terminal);
1389
1390 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}