kindly_guard_server/shield/
display.rs1use anyhow::Result;
17use crossterm::{
18 event::{self, Event, KeyCode},
19 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
20 ExecutableCommand,
21};
22use ratatui::{
23 backend::CrosstermBackend,
24 layout::{Alignment, Constraint, Direction, Layout},
25 style::{Color, Modifier, Style},
26 text::{Line, Span},
27 widgets::{Block, Borders, List, ListItem, Paragraph},
28 Frame, Terminal,
29};
30use std::sync::Arc;
31use std::time::Duration;
32use tokio::time::interval;
33
34use super::Shield;
35use crate::config::ShieldConfig;
36use crate::scanner::ThreatType;
37
38pub struct ShieldDisplay {
40 shield: Arc<Shield>,
41 config: ShieldConfig,
42}
43
44impl ShieldDisplay {
45 pub const fn new(shield: Arc<Shield>, config: ShieldConfig) -> Self {
47 Self { shield, config }
48 }
49
50 pub async fn run(&self) -> Result<()> {
52 enable_raw_mode()?;
54 let mut stdout = std::io::stdout();
55 stdout.execute(EnterAlternateScreen)?;
56
57 let backend = CrosstermBackend::new(stdout);
58 let mut terminal = Terminal::new(backend)?;
59
60 let mut ticker = interval(Duration::from_millis(self.config.update_interval_ms));
62
63 loop {
64 terminal.draw(|f| self.draw_ui(f))?;
66
67 if event::poll(Duration::from_millis(100))? {
69 if let Event::Key(key) = event::read()? {
70 match key.code {
71 KeyCode::Char('q') | KeyCode::Esc => break,
72 _ => {},
73 }
74 }
75 }
76
77 ticker.tick().await;
78 }
79
80 disable_raw_mode()?;
82 terminal.backend_mut().execute(LeaveAlternateScreen)?;
83 terminal.show_cursor()?;
84
85 Ok(())
86 }
87
88 fn draw_ui(&self, frame: &mut Frame) {
90 let size = frame.area();
91
92 let chunks = Layout::default()
94 .direction(Direction::Vertical)
95 .margin(1)
96 .constraints([
97 Constraint::Length(3), Constraint::Length(5), Constraint::Length(10), Constraint::Length(5), Constraint::Min(0), ])
103 .split(size);
104
105 let info = self.shield.get_info();
107 let threat_stats = self.shield.get_threat_stats();
108
109 let enhanced_mode = self.shield.is_event_processor_enabled();
111 let title_color = if enhanced_mode {
112 Color::Magenta
113 } else {
114 Color::Cyan
115 };
116 let title_text = if enhanced_mode {
117 "🛡️ KindlyGuard Security Shield ⚡ Enhanced Protection Active"
118 } else {
119 "🛡️ KindlyGuard Security Shield"
120 };
121
122 let title = Paragraph::new(title_text)
123 .style(
124 Style::default()
125 .fg(title_color)
126 .add_modifier(Modifier::BOLD),
127 )
128 .alignment(Alignment::Center)
129 .block(
130 Block::default()
131 .borders(Borders::ALL)
132 .border_style(Style::default().fg(title_color)),
133 );
134 frame.render_widget(title, chunks[0]);
135
136 let status_color = if enhanced_mode && info.active {
138 Color::Magenta
139 } else if info.active {
140 Color::Green
141 } else {
142 Color::Red
143 };
144 let status_text = if info.active {
145 "● Protected"
146 } else {
147 "○ Inactive"
148 };
149
150 let uptime = format_duration(info.uptime);
151 let status = vec![
152 Line::from(vec![
153 Span::raw("Status: "),
154 Span::styled(status_text, Style::default().fg(status_color)),
155 ]),
156 Line::from(format!("Uptime: {uptime}")),
157 Line::from(format!("Total Threats Blocked: {}", info.threats_blocked)),
158 ];
159
160 let border_color = if enhanced_mode {
161 Color::Magenta
162 } else {
163 Color::Reset
164 };
165 let status_widget = Paragraph::new(status).block(
166 Block::default()
167 .title("Status")
168 .borders(Borders::ALL)
169 .border_style(Style::default().fg(border_color)),
170 );
171 frame.render_widget(status_widget, chunks[1]);
172
173 let mut threat_items = vec![Line::from("Threats Blocked by Type:"), Line::from("")];
175
176 let unicode_count: u64 = threat_stats
178 .iter()
179 .filter(|(k, _)| {
180 matches!(
181 k,
182 ThreatType::UnicodeInvisible
183 | ThreatType::UnicodeBiDi
184 | ThreatType::UnicodeHomograph
185 | ThreatType::UnicodeControl
186 )
187 })
188 .map(|(_, v)| v)
189 .sum();
190
191 let injection_count: u64 = threat_stats
192 .iter()
193 .filter(|(k, _)| {
194 matches!(
195 k,
196 ThreatType::PromptInjection
197 | ThreatType::CommandInjection
198 | ThreatType::SqlInjection
199 )
200 })
201 .map(|(_, v)| v)
202 .sum();
203
204 let traversal_count = threat_stats
205 .get(&ThreatType::PathTraversal)
206 .copied()
207 .unwrap_or(0);
208
209 let mcp_count: u64 = threat_stats
210 .iter()
211 .filter(|(k, _)| {
212 matches!(
213 k,
214 ThreatType::SessionIdExposure
215 | ThreatType::ToolPoisoning
216 | ThreatType::TokenTheft
217 )
218 })
219 .map(|(_, v)| v)
220 .sum();
221
222 threat_items.push(Line::from(format!(
223 "├─ Unicode Attacks: {unicode_count}"
224 )));
225 threat_items.push(Line::from(format!(
226 "├─ Injection Attempts: {injection_count}"
227 )));
228 threat_items.push(Line::from(format!(
229 "├─ Path Traversal: {traversal_count}"
230 )));
231 threat_items.push(Line::from(format!("├─ MCP Threats: {mcp_count}")));
232 threat_items.push(Line::from(format!(
233 "└─ Total: {}",
234 info.threats_blocked
235 )));
236
237 let threats_widget = Paragraph::new(threat_items).block(
238 Block::default()
239 .title("Threat Statistics")
240 .borders(Borders::ALL)
241 .border_style(Style::default().fg(border_color)),
242 );
243 frame.render_widget(threats_widget, chunks[2]);
244
245 let mut perf_items = vec![
247 Line::from("Performance:"),
248 Line::from(""),
249 Line::from(format!(
250 "├─ Threat Rate: {:.1} /min",
251 info.recent_threat_rate
252 )),
253 ];
254
255 if enhanced_mode {
256 perf_items.push(
257 Line::from("├─ Pattern Recognition: Active")
258 .style(Style::default().fg(Color::Magenta)),
259 );
260 perf_items.push(
261 Line::from("├─ Advanced Analytics: Enabled")
262 .style(Style::default().fg(Color::Magenta)),
263 );
264 }
265
266 perf_items.push(Line::from(format!(
267 "└─ Shield Active: {}",
268 if info.active { "Yes" } else { "No" }
269 )));
270
271 let perf_widget = Paragraph::new(perf_items).block(
272 Block::default()
273 .title("Performance")
274 .borders(Borders::ALL)
275 .border_style(Style::default().fg(border_color)),
276 );
277 frame.render_widget(perf_widget, chunks[3]);
278
279 if self.config.detailed_stats && chunks.len() > 4 {
281 let recent_threats = self.shield.get_recent_threats(10);
282 let threat_items: Vec<ListItem> = recent_threats
283 .iter()
284 .map(|threat| {
285 let style = match threat.severity {
286 crate::scanner::Severity::Critical => Style::default().fg(Color::Red),
287 crate::scanner::Severity::High => Style::default().fg(Color::Yellow),
288 crate::scanner::Severity::Medium => Style::default().fg(Color::Blue),
289 crate::scanner::Severity::Low => Style::default().fg(Color::Gray),
290 };
291
292 ListItem::new(format!(
293 "[{}] {}: {}",
294 threat.severity,
295 threat.threat_type,
296 shorten_string(&threat.description, 50)
297 ))
298 .style(style)
299 })
300 .collect();
301
302 let threats_list = List::new(threat_items).block(
303 Block::default()
304 .title("Recent Threats")
305 .borders(Borders::ALL)
306 .border_style(Style::default().fg(border_color)),
307 );
308 frame.render_widget(threats_list, chunks[4]);
309 }
310 }
311}
312
313fn format_duration(duration: Duration) -> String {
315 let total_secs = duration.as_secs();
316 let hours = total_secs / 3600;
317 let minutes = (total_secs % 3600) / 60;
318 let seconds = total_secs % 60;
319
320 if hours > 0 {
321 format!("{hours}h {minutes}m {seconds}s")
322 } else if minutes > 0 {
323 format!("{minutes}m {seconds}s")
324 } else {
325 format!("{seconds}s")
326 }
327}
328
329fn shorten_string(s: &str, max_len: usize) -> String {
331 if s.len() <= max_len {
332 s.to_string()
333 } else {
334 format!("{}...", &s[..max_len - 3])
335 }
336}