use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use crate::metrics::{MetricsSnapshot, SecurityEventCategory};
use crate::theme::Theme;
#[allow(clippy::too_many_lines)]
pub fn render(metrics: &MetricsSnapshot, frame: &mut Frame, area: Rect) {
let theme = Theme::default();
let block = Block::default()
.title(" Security ")
.borders(Borders::ALL)
.style(theme.panel_border);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
return;
}
let all_zero = metrics.sanitizer_runs == 0
&& metrics.sanitizer_injection_flags == 0
&& metrics.sanitizer_truncations == 0
&& metrics.quarantine_invocations == 0
&& metrics.quarantine_failures == 0
&& metrics.exfiltration_images_blocked == 0
&& metrics.exfiltration_tool_urls_flagged == 0
&& metrics.exfiltration_memory_guards == 0
&& metrics.security_events.is_empty();
if all_zero {
let msg = Paragraph::new("No security events.").style(theme.system_message);
frame.render_widget(msg, inner);
return;
}
let base = theme.system_message;
let flag_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let block_style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
let mut items: Vec<ListItem<'_>> = vec![
ListItem::new(Line::from(Span::styled(
format!("Sanitizer runs: {}", metrics.sanitizer_runs),
base,
))),
ListItem::new(Line::from(vec![
Span::styled("Inj flags: ", base),
Span::styled(
metrics.sanitizer_injection_flags.to_string(),
if metrics.sanitizer_injection_flags > 0 {
flag_style
} else {
base
},
),
])),
ListItem::new(Line::from(Span::styled(
format!("Truncations: {}", metrics.sanitizer_truncations),
base,
))),
ListItem::new(Line::from(Span::styled(
format!("Quarantine calls: {}", metrics.quarantine_invocations),
base,
))),
ListItem::new(Line::from(Span::styled(
format!("Quarantine fails: {}", metrics.quarantine_failures),
base,
))),
ListItem::new(Line::from(vec![
Span::styled("Exfil images: ", base),
Span::styled(
metrics.exfiltration_images_blocked.to_string(),
if metrics.exfiltration_images_blocked > 0 {
block_style
} else {
base
},
),
])),
ListItem::new(Line::from(vec![
Span::styled("Exfil URLs: ", base),
Span::styled(
metrics.exfiltration_tool_urls_flagged.to_string(),
if metrics.exfiltration_tool_urls_flagged > 0 {
block_style
} else {
base
},
),
])),
ListItem::new(Line::from(Span::styled(
format!("Memory guards: {}", metrics.exfiltration_memory_guards),
base,
))),
];
if !metrics.security_events.is_empty() {
items.push(ListItem::new(Line::from(Span::styled(
"Recent events:",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::UNDERLINED),
))));
let start = metrics.security_events.len().saturating_sub(5);
for ev in metrics.security_events.range(start..) {
let (cat_str, cat_style) = match ev.category {
SecurityEventCategory::InjectionFlag => ("[inj] ", flag_style),
SecurityEventCategory::ExfiltrationBlock => ("[exfil]", block_style),
SecurityEventCategory::Quarantine => ("[quar] ", Style::default().fg(Color::Cyan)),
SecurityEventCategory::Truncation => {
("[trunc]", Style::default().fg(Color::DarkGray))
}
};
let hm = format_hm(ev.timestamp);
items.push(ListItem::new(Line::from(vec![
Span::styled(format!("{hm} "), Style::default().fg(Color::DarkGray)),
Span::styled(cat_str, cat_style),
Span::styled(format!(" {}", ev.source), base),
])));
items.push(ListItem::new(Line::from(Span::styled(
format!(" {}", ev.detail),
Style::default().fg(Color::DarkGray),
))));
}
}
let list = List::new(items);
frame.render_widget(list, inner);
}
fn format_hm(ts: u64) -> String {
#[allow(clippy::cast_possible_wrap)]
chrono::DateTime::from_timestamp(ts as i64, 0).map_or_else(
|| "??:??".to_owned(),
|dt| dt.with_timezone(&chrono::Local).format("%H:%M").to_string(),
)
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use zeph_core::metrics::{SecurityEvent, SecurityEventCategory};
use super::*;
use crate::test_utils::render_to_string;
#[test]
fn renders_no_events_message_when_all_zero() {
let metrics = MetricsSnapshot::default();
let output = render_to_string(40, 10, |frame, area| {
render(&metrics, frame, area);
});
assert!(output.contains("No security events."));
}
#[test]
fn renders_injection_flag_count() {
let mut metrics = MetricsSnapshot::default();
metrics.sanitizer_injection_flags = 3;
let output = render_to_string(40, 12, |frame, area| {
render(&metrics, frame, area);
});
assert!(output.contains('3'));
}
#[test]
fn renders_recent_events() {
let mut metrics = MetricsSnapshot::default();
metrics.sanitizer_injection_flags = 1;
let mut events = VecDeque::new();
events.push_back(SecurityEvent::new(
SecurityEventCategory::InjectionFlag,
"web_scrape",
"Detected pattern: ignore previous",
));
metrics.security_events = events;
let output = render_to_string(50, 15, |frame, area| {
render(&metrics, frame, area);
});
assert!(output.contains("web_scrape") || output.contains("inj"));
}
#[test]
fn renders_exfiltration_block_category() {
let mut metrics = MetricsSnapshot::default();
metrics.exfiltration_images_blocked = 1;
let mut events = VecDeque::new();
events.push_back(SecurityEvent::new(
SecurityEventCategory::ExfiltrationBlock,
"llm_output",
"1 markdown image(s) blocked",
));
metrics.security_events = events;
let output = render_to_string(50, 15, |frame, area| {
render(&metrics, frame, area);
});
assert!(output.contains("exfil") || output.contains("llm_output"));
}
#[test]
fn renders_quarantine_category() {
let mut metrics = MetricsSnapshot::default();
metrics.quarantine_invocations = 1;
let mut events = VecDeque::new();
events.push_back(SecurityEvent::new(
SecurityEventCategory::Quarantine,
"web_scrape",
"Content quarantined, facts extracted",
));
metrics.security_events = events;
let output = render_to_string(50, 15, |frame, area| {
render(&metrics, frame, area);
});
assert!(output.contains("quar") || output.contains("web_scrape"));
}
#[test]
fn renders_only_last_5_events_when_more_exist() {
let mut metrics = MetricsSnapshot::default();
metrics.sanitizer_injection_flags = 8;
for i in 0..8u64 {
metrics.security_events.push_back(SecurityEvent::new(
SecurityEventCategory::InjectionFlag,
format!("source_{i}"),
format!("detail_{i}"),
));
}
let output = render_to_string(60, 30, |frame, area| {
render(&metrics, frame, area);
});
assert!(output.contains("source_7"), "last event must be rendered");
assert!(
output.contains("source_3"),
"5th-from-last must be rendered"
);
assert!(
!output.contains("source_2"),
"6th-from-last must NOT be rendered"
);
}
#[test]
fn renders_without_panic_on_zero_height() {
let metrics = MetricsSnapshot::default();
render_to_string(40, 0, |frame, area| {
render(&metrics, frame, area);
});
}
}