Skip to main content

kindly_guard_server/shield/
display.rs

1// Copyright 2025 Kindly Software Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//! Terminal-based shield display using ratatui
15
16use 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
38/// Shield display for terminal UI
39pub struct ShieldDisplay {
40    shield: Arc<Shield>,
41    config: ShieldConfig,
42}
43
44impl ShieldDisplay {
45    /// Create a new shield display
46    pub const fn new(shield: Arc<Shield>, config: ShieldConfig) -> Self {
47        Self { shield, config }
48    }
49
50    /// Run the shield display
51    pub async fn run(&self) -> Result<()> {
52        // Setup terminal
53        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        // Create update interval
61        let mut ticker = interval(Duration::from_millis(self.config.update_interval_ms));
62
63        loop {
64            // Draw UI
65            terminal.draw(|f| self.draw_ui(f))?;
66
67            // Handle events
68            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        // Restore terminal
81        disable_raw_mode()?;
82        terminal.backend_mut().execute(LeaveAlternateScreen)?;
83        terminal.show_cursor()?;
84
85        Ok(())
86    }
87
88    /// Draw the UI
89    fn draw_ui(&self, frame: &mut Frame) {
90        let size = frame.area();
91
92        // Create main layout
93        let chunks = Layout::default()
94            .direction(Direction::Vertical)
95            .margin(1)
96            .constraints([
97                Constraint::Length(3),  // Title
98                Constraint::Length(5),  // Status
99                Constraint::Length(10), // Threat stats
100                Constraint::Length(5),  // Performance
101                Constraint::Min(0),     // Recent threats
102            ])
103            .split(size);
104
105        // Get shield info
106        let info = self.shield.get_info();
107        let threat_stats = self.shield.get_threat_stats();
108
109        // Title block - Purple if event processor enabled
110        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        // Status block - Purple when enhanced and active
137        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        // Threat statistics
174        let mut threat_items = vec![Line::from("Threats Blocked by Type:"), Line::from("")];
175
176        // Count threats by category
177        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        // Performance metrics
246        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        // Recent threats
280        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
313/// Format duration as human-readable string
314fn 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
329/// Shorten string to max length
330fn 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}