use std::collections::HashMap;
use std::io;
use std::time::Duration;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseButton,
MouseEvent, MouseEventKind,
};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use sentinel_core::correlate::Trace;
#[cfg(feature = "daemon")]
use sentinel_core::daemon::query_api::AckSource;
use sentinel_core::detect::correlate_cross::CrossTraceCorrelation;
use sentinel_core::detect::{Finding, FindingType, Severity};
use sentinel_core::explain;
use sentinel_core::report::interpret::InterpretationLevel;
use sentinel_core::report::{Analysis, GreenSummary, QualityGate};
use sentinel_core::text_safety::{sanitize_for_terminal, strip_code_ticks};
use crate::disclose::DiscloseState;
use crate::tui_resize::{
Axis, DragTarget, MIN_PCT, boundary_cell, in_range, near, pos_to_pct, set_cut,
};
#[cfg(feature = "daemon")]
use tokio::sync::mpsc;
#[cfg(feature = "daemon")]
pub use ack_modal::AckModalState;
#[cfg(feature = "daemon")]
use ack_modal::{AckOutcome, apply_ack_outcome, dispatch_modal_key, open_ack_modal_for_current};
use draw::draw;
#[cfg(feature = "daemon")]
pub(crate) use draw::{highlight_hline, highlight_vline, resize_highlight_style, tab_label_style};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Panel {
Traces,
Findings,
Detail,
Correlations,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
Analyze,
Inspect,
Explain,
Disclose,
}
pub struct AnalyzeSummary {
pub green_summary: GreenSummary,
pub quality_gate: QualityGate,
pub analysis: Analysis,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TraceSort {
ById,
Severity,
Impact,
}
impl From<crate::render::FindingsSort> for TraceSort {
fn from(mode: crate::render::FindingsSort) -> Self {
match mode {
crate::render::FindingsSort::Severity => Self::Severity,
crate::render::FindingsSort::Impact => Self::Impact,
}
}
}
impl TraceSort {
fn next(self) -> Self {
match self {
Self::Impact => Self::Severity,
Self::Severity => Self::ById,
Self::ById => Self::Impact,
}
}
fn label(self) -> &'static str {
match self {
Self::ById => "id",
Self::Severity => "severity",
Self::Impact => "impact",
}
}
}
fn build_recurrence(findings: &[Finding]) -> HashMap<String, crate::render::RecurrenceStats> {
let mut index: HashMap<String, crate::render::RecurrenceStats> = HashMap::new();
for f in findings {
let entry = index.entry(crate::render::recurrence_key(f)).or_insert(
crate::render::RecurrenceStats {
count: 0,
total_ops: 0,
},
);
entry.count += 1;
entry.total_ops += f
.green_impact
.as_ref()
.map_or(0, |gi| gi.estimated_extra_io_ops);
}
index
}
pub struct App {
pub traces: Vec<Trace>,
all_findings: Vec<Finding>,
findings_by_trace: Vec<Vec<usize>>,
visible_by_trace: Vec<Vec<usize>>,
severity_filter: Option<Severity>,
trace_ids: Vec<String>,
trace_index: HashMap<String, usize>,
pub view: View,
summary: Option<AnalyzeSummary>,
analyze_line_count: u16,
recurrence: HashMap<String, crate::render::RecurrenceStats>,
avoidable_ops: Vec<u64>,
pub trace_sort: TraceSort,
pub selected_trace: usize,
pub selected_finding: usize,
pub active_panel: Panel,
pub scroll_offset: u16,
cached_detail: Option<(usize, String)>,
pre_rendered_trees: HashMap<String, String>,
correlations: Vec<CrossTraceCorrelation>,
pub selected_correlation: usize,
warnings: Vec<sentinel_core::report::warnings::Warning>,
detail_origin: Panel,
#[cfg(feature = "daemon")]
pub daemon_url: Option<String>,
#[cfg(feature = "daemon")]
pub api_key: Option<String>,
#[cfg(feature = "daemon")]
pub acks_by_signature: HashMap<String, AckSource>,
#[cfg(feature = "daemon")]
pub ack_modal: AckModalState,
disclose: Option<DiscloseState>,
inspect_rows: [u16; 2],
inspect_cols: [u16; 3],
mouse_mode: bool,
drag: Option<DragTarget>,
hover: Option<DragTarget>,
inspect_area: std::cell::Cell<Rect>,
}
const INSPECT_ROWS_DEFAULT: [u16; 2] = [50, 50];
const INSPECT_COLS_DEFAULT: [u16; 3] = [20, 30, 50];
impl App {
#[must_use]
pub fn new(findings: Vec<Finding>, mut traces: Vec<Trace>) -> Self {
traces.sort_unstable_by(|a, b| a.trace_id.cmp(&b.trace_id));
let trace_ids: Vec<String> = traces.iter().map(|t| t.trace_id.clone()).collect();
let trace_index: HashMap<String, usize> = traces
.iter()
.enumerate()
.map(|(i, t)| (t.trace_id.clone(), i))
.collect();
let mut findings_by_trace: Vec<Vec<usize>> = vec![Vec::new(); traces.len()];
for (idx, finding) in findings.iter().enumerate() {
if let Some(&trace_vec_idx) = trace_index.get(&finding.trace_id) {
findings_by_trace[trace_vec_idx].push(idx);
}
}
let recurrence = build_recurrence(&findings);
let avoidable_ops: Vec<u64> = findings
.iter()
.map(|f| {
recurrence
.get(&crate::render::recurrence_key(f))
.map_or(0, |s| s.total_ops) as u64
})
.collect();
let mut app = Self {
traces,
recurrence,
avoidable_ops,
trace_sort: crate::render::FindingsSort::default().into(),
all_findings: findings,
visible_by_trace: findings_by_trace.clone(),
findings_by_trace,
severity_filter: None,
trace_ids,
trace_index,
view: View::Inspect,
summary: None,
analyze_line_count: 0,
selected_trace: 0,
selected_finding: 0,
active_panel: Panel::Traces,
scroll_offset: 0,
cached_detail: None,
pre_rendered_trees: HashMap::new(),
correlations: Vec::new(),
warnings: Vec::new(),
selected_correlation: 0,
detail_origin: Panel::Findings,
#[cfg(feature = "daemon")]
daemon_url: None,
#[cfg(feature = "daemon")]
api_key: None,
#[cfg(feature = "daemon")]
acks_by_signature: HashMap::new(),
#[cfg(feature = "daemon")]
ack_modal: AckModalState::default(),
disclose: None,
inspect_rows: INSPECT_ROWS_DEFAULT,
inspect_cols: INSPECT_COLS_DEFAULT,
mouse_mode: false,
drag: None,
hover: None,
inspect_area: std::cell::Cell::new(Rect::default()),
};
app.reorder_traces();
app
}
pub(crate) fn with_disclose(mut self, state: DiscloseState) -> Self {
self.disclose = Some(state);
self
}
#[cfg(feature = "daemon")]
pub(crate) fn with_daemon_handle(
mut self,
daemon_url: String,
api_key: Option<String>,
acks_by_signature: HashMap<String, AckSource>,
) -> Self {
self.daemon_url = Some(daemon_url);
self.api_key = api_key;
self.acks_by_signature = acks_by_signature;
self
}
#[cfg_attr(not(feature = "daemon"), allow(dead_code))]
pub(crate) fn with_pre_rendered_trees(mut self, trees: HashMap<String, String>) -> Self {
self.pre_rendered_trees = trees;
self
}
pub(crate) fn with_correlations(mut self, correlations: Vec<CrossTraceCorrelation>) -> Self {
self.correlations = correlations;
self
}
pub(crate) fn with_warnings(
mut self,
warnings: Vec<sentinel_core::report::warnings::Warning>,
) -> Self {
self.warnings = warnings;
self.refresh_analyze_line_count();
self
}
fn refresh_analyze_line_count(&mut self) {
self.analyze_line_count =
u16::try_from(self.build_analyze_lines().len()).unwrap_or(u16::MAX);
}
pub(crate) fn with_summary(mut self, summary: AnalyzeSummary) -> Self {
self.summary = Some(summary);
self.refresh_analyze_line_count();
self
}
pub(crate) fn with_initial_view(mut self, view: View) -> Self {
self.view = view;
self
}
pub(crate) fn with_focus_trace(mut self, trace_id: &str) -> Self {
if let Some(&idx) = self.trace_index.get(trace_id) {
self.selected_trace = idx;
self.selected_finding = 0;
self.cached_detail = None;
}
self
}
#[must_use]
pub fn correlation_count(&self) -> usize {
self.correlations.len()
}
#[must_use]
pub fn trace_count(&self) -> usize {
self.trace_ids.len()
}
#[must_use]
pub fn finding_count(&self) -> usize {
self.current_finding_indices().len()
}
fn current_finding_indices(&self) -> &[usize] {
self.visible_by_trace
.get(self.selected_trace)
.map_or(&[], Vec::as_slice)
}
pub(crate) fn current_finding(&self) -> Option<&Finding> {
let indices = self.current_finding_indices();
indices
.get(self.selected_finding)
.map(|&idx| &self.all_findings[idx])
}
fn detail_panel_line_count(&self) -> u16 {
let Some(finding) = self.current_finding() else {
return 0;
};
let mut count: u16 = 8;
if finding
.code_location
.as_ref()
.is_some_and(|loc| !loc.display_string().is_empty())
{
count = count.saturating_add(1);
}
if finding.suggested_fix.is_some() {
count = count.saturating_add(1);
}
if crate::render::format_span_timing(&finding.pattern).is_some() {
count = count.saturating_add(1);
}
if crate::render::classification_label(finding).is_some() {
count = count.saturating_add(1);
}
if !finding.confidence.is_batch() {
count = count.saturating_add(1);
}
count = count.saturating_add(1);
if finding.green_impact.is_some() {
count = count.saturating_add(1);
}
if let Some((ct, ref text)) = self.cached_detail
&& ct == self.selected_trace
{
let tree_count = u16::try_from(text.lines().count()).unwrap_or(u16::MAX);
count = count.saturating_add(2).saturating_add(tree_count);
} else {
count = count.saturating_add(5);
}
count
}
fn detail_tree_text(&mut self) -> Option<String> {
let trace_idx = self.selected_trace;
if let Some((ct, ref text)) = self.cached_detail
&& ct == trace_idx
{
return Some(text.clone());
}
let trace_id = self.trace_ids.get(trace_idx)?.clone();
if let Some(text) = self.pre_rendered_trees.get(&trace_id) {
let text = text.clone();
self.cached_detail = Some((trace_idx, text.clone()));
return Some(text);
}
let trace_vec_idx = self.trace_index.get(&trace_id).copied()?;
let trace = &self.traces[trace_vec_idx];
if trace.spans.is_empty() {
return None;
}
let per_trace_findings: Vec<Finding> = self.findings_by_trace[trace_vec_idx]
.iter()
.filter_map(|&i| self.all_findings.get(i).cloned())
.collect();
let tree = explain::build_tree(trace, &per_trace_findings);
let text = explain::format_tree_text(&tree, false);
self.cached_detail = Some((trace_idx, text.clone()));
Some(text)
}
pub fn cycle_trace_sort(&mut self) {
self.trace_sort = self.trace_sort.next();
self.reorder_traces();
}
pub fn cycle_severity_filter(&mut self) {
self.severity_filter = match self.severity_filter {
None => Some(Severity::Critical),
Some(Severity::Critical) => Some(Severity::Warning),
Some(Severity::Warning) => Some(Severity::Info),
Some(Severity::Info) => None,
};
self.apply_finding_filter();
}
fn severity_filter_label(&self) -> &'static str {
match self.severity_filter {
None => "all",
Some(Severity::Critical) => "critical",
Some(Severity::Warning) => "warning",
Some(Severity::Info) => "info",
}
}
fn apply_finding_filter(&mut self) {
let findings = &self.all_findings;
let wanted = self.severity_filter.as_ref();
self.visible_by_trace = self
.findings_by_trace
.iter()
.map(|indices| {
indices
.iter()
.copied()
.filter(|&i| wanted.is_none_or(|s| findings[i].severity == *s))
.collect()
})
.collect();
self.selected_finding = 0;
self.scroll_offset = 0;
}
fn reorder_traces(&mut self) {
let mut order: Vec<usize> = (0..self.traces.len()).collect();
let mode = match self.trace_sort {
TraceSort::ById => {
order.sort_by(|&a, &b| self.trace_ids[a].cmp(&self.trace_ids[b]));
self.apply_trace_order(&order);
return;
}
TraceSort::Severity => crate::render::FindingsSort::Severity,
TraceSort::Impact => crate::render::FindingsSort::Impact,
};
order.sort_by(|&a, &b| {
crate::render::compare_severity_impact(
mode,
(&self.trace_rank(a), self.trace_ops(a) as u64),
(&self.trace_rank(b), self.trace_ops(b) as u64),
)
.then_with(|| self.trace_ids[a].cmp(&self.trace_ids[b]))
});
self.apply_trace_order(&order);
}
fn reorder_findings(&mut self) {
let mode = match self.trace_sort {
TraceSort::ById => {
for indices in &mut self.findings_by_trace {
indices.sort_unstable();
}
self.apply_finding_filter();
return;
}
TraceSort::Severity => crate::render::FindingsSort::Severity,
TraceSort::Impact => crate::render::FindingsSort::Impact,
};
let findings = &self.all_findings;
let ops = &self.avoidable_ops;
let mut sorted = std::mem::take(&mut self.findings_by_trace);
for indices in &mut sorted {
indices.sort_by(|&a, &b| {
crate::render::compare_severity_impact(
mode,
(&findings[a].severity, ops[a]),
(&findings[b].severity, ops[b]),
)
.then(a.cmp(&b))
});
}
self.findings_by_trace = sorted;
self.apply_finding_filter();
}
pub fn with_initial_sort(mut self, mode: Option<crate::render::FindingsSort>) -> Self {
if let Some(mode) = mode {
let target = TraceSort::from(mode);
if target != self.trace_sort {
self.trace_sort = target;
self.reorder_traces();
}
}
self
}
fn trace_rank(&self, idx: usize) -> Severity {
self.findings_by_trace[idx]
.iter()
.map(|&i| self.all_findings[i].severity.clone())
.min()
.unwrap_or(Severity::Info)
}
fn trace_ops(&self, idx: usize) -> usize {
self.findings_by_trace[idx]
.iter()
.map(|&i| {
let key = crate::render::recurrence_key(&self.all_findings[i]);
self.recurrence.get(&key).map_or(0, |s| s.total_ops)
})
.sum()
}
fn apply_trace_order(&mut self, order: &[usize]) {
let mut traces: Vec<Option<Trace>> = std::mem::take(&mut self.traces)
.into_iter()
.map(Some)
.collect();
self.traces = order
.iter()
.map(|&i| traces[i].take().expect("permutation"))
.collect();
let mut ids: Vec<Option<String>> = std::mem::take(&mut self.trace_ids)
.into_iter()
.map(Some)
.collect();
self.trace_ids = order
.iter()
.map(|&i| ids[i].take().expect("permutation"))
.collect();
let mut by_trace: Vec<Option<Vec<usize>>> = std::mem::take(&mut self.findings_by_trace)
.into_iter()
.map(Some)
.collect();
self.findings_by_trace = order
.iter()
.map(|&i| by_trace[i].take().expect("permutation"))
.collect();
self.trace_index = self
.trace_ids
.iter()
.enumerate()
.map(|(i, id)| (id.clone(), i))
.collect();
self.selected_trace = 0;
self.selected_finding = 0;
self.cached_detail = None;
self.scroll_offset = 0;
self.reorder_findings();
}
pub fn move_up(&mut self) {
match self.active_panel {
Panel::Traces => {
if self.selected_trace > 0 {
self.selected_trace -= 1;
self.selected_finding = 0;
self.scroll_offset = 0;
self.cached_detail = None;
}
}
Panel::Findings => {
if self.selected_finding > 0 {
self.selected_finding -= 1;
self.scroll_offset = 0;
}
}
Panel::Detail => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
Panel::Correlations => {
if self.selected_correlation > 0 {
self.selected_correlation -= 1;
}
}
}
}
pub fn move_down(&mut self) {
match self.active_panel {
Panel::Traces => {
if self.selected_trace + 1 < self.trace_count() {
self.selected_trace += 1;
self.selected_finding = 0;
self.scroll_offset = 0;
self.cached_detail = None;
}
}
Panel::Findings => {
if self.selected_finding + 1 < self.finding_count() {
self.selected_finding += 1;
self.scroll_offset = 0;
}
}
Panel::Detail => {
let max_offset = self.detail_panel_line_count().saturating_sub(1);
if self.scroll_offset < max_offset {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
}
Panel::Correlations => {
if self.selected_correlation + 1 < self.correlation_count() {
self.selected_correlation += 1;
}
}
}
}
pub fn next_panel(&mut self) {
self.active_panel = match self.active_panel {
Panel::Traces => Panel::Findings,
Panel::Findings => Panel::Detail,
Panel::Detail => Panel::Correlations,
Panel::Correlations => Panel::Traces,
};
}
pub fn prev_panel(&mut self) {
self.active_panel = match self.active_panel {
Panel::Traces => Panel::Correlations,
Panel::Findings => Panel::Traces,
Panel::Detail => Panel::Findings,
Panel::Correlations => Panel::Detail,
};
}
fn toggle_mouse_mode(&mut self) {
self.mouse_mode = !self.mouse_mode;
if !self.mouse_mode {
self.drag = None;
self.hover = None;
}
}
fn reset_layout(&mut self) {
self.inspect_rows = INSPECT_ROWS_DEFAULT;
self.inspect_cols = INSPECT_COLS_DEFAULT;
}
fn accepts_panel_drag(&self) -> bool {
if self.view != View::Inspect {
return false;
}
#[cfg(feature = "daemon")]
if self.ack_modal.is_visible() {
return false;
}
true
}
fn hit_test(&self, col: u16, row: u16) -> Option<DragTarget> {
let area = self.inspect_area.get();
if area.width == 0 || area.height == 0 {
return None;
}
let top_h = u16::try_from(u32::from(area.height) * u32::from(self.inspect_rows[0]) / 100)
.unwrap_or(area.height);
if in_range(row, area.y, top_h) {
for b in 0..self.inspect_cols.len() - 1 {
if near(
col,
boundary_cell(&self.inspect_cols, b, area.x, area.width),
) {
return Some(DragTarget {
axis: Axis::Horizontal,
boundary: b,
});
}
}
}
let vy = boundary_cell(&self.inspect_rows, 0, area.y, area.height);
if near(row, vy) && in_range(col, area.x, area.width) {
return Some(DragTarget {
axis: Axis::Vertical,
boundary: 0,
});
}
None
}
fn begin_drag(&mut self, col: u16, row: u16) {
self.drag = self.hit_test(col, row);
}
fn resize_target(&self) -> Option<DragTarget> {
self.drag.or(self.hover)
}
fn apply_drag(&mut self, col: u16, row: u16) {
let Some(target) = self.drag else {
return;
};
let area = self.inspect_area.get();
match target.axis {
Axis::Vertical => set_cut(
&mut self.inspect_rows,
target.boundary,
pos_to_pct(row, area.y, area.height),
MIN_PCT,
),
Axis::Horizontal => set_cut(
&mut self.inspect_cols,
target.boundary,
pos_to_pct(col, area.x, area.width),
MIN_PCT,
),
}
}
pub fn enter(&mut self) {
match self.active_panel {
Panel::Traces => {
if self.finding_count() > 0 {
self.active_panel = Panel::Findings;
self.selected_finding = 0;
}
}
Panel::Findings => {
self.enter_detail(Panel::Findings);
}
Panel::Correlations => {
self.jump_to_correlation_sample_trace();
}
Panel::Detail => {
self.view = View::Explain;
self.scroll_offset = 0;
}
}
}
fn enter_detail(&mut self, origin: Panel) {
self.active_panel = Panel::Detail;
self.detail_origin = origin;
self.scroll_offset = 0;
}
fn jump_to_correlation_sample_trace(&mut self) {
let Some(correlation) = self.correlations.get(self.selected_correlation) else {
return;
};
let Some(sample_trace_id) = correlation.sample_trace_id.as_deref() else {
return;
};
let Some(&position) = self.trace_index.get(sample_trace_id) else {
return;
};
if position != self.selected_trace {
self.selected_trace = position;
self.cached_detail = None;
}
self.selected_finding = 0;
self.enter_detail(Panel::Correlations);
}
pub fn escape(&mut self) {
match self.active_panel {
Panel::Traces | Panel::Correlations => {
self.view = View::Analyze;
self.scroll_offset = 0;
}
Panel::Findings => self.active_panel = Panel::Traces,
Panel::Detail => self.active_panel = self.detail_origin,
}
}
fn analyze_content_line_count(&self) -> u16 {
self.analyze_line_count
}
fn explain_content_line_count(&self) -> u16 {
match &self.cached_detail {
Some((ct, text)) if *ct == self.selected_trace => {
u16::try_from(text.lines().count()).unwrap_or(u16::MAX)
}
_ => 2,
}
}
fn severity_counts(&self) -> (usize, usize, usize) {
let mut counts = (0usize, 0usize, 0usize);
for finding in &self.all_findings {
match finding.severity {
Severity::Critical => counts.0 += 1,
Severity::Warning => counts.1 += 1,
Severity::Info => counts.2 += 1,
}
}
counts
}
fn push_warning_lines(&self, lines: &mut Vec<Line<'static>>) {
if self.warnings.is_empty() {
return;
}
lines.push(Line::from(Span::styled(
"Warnings:".to_string(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
for w in &self.warnings {
let plain = strip_code_ticks(&w.message);
lines.push(Line::from(Span::raw(format!(
" [{}] {}",
sanitize_for_terminal(&w.kind),
sanitize_for_terminal(&plain),
))));
}
lines.push(Line::from(""));
}
const TOP_FINDINGS_ROWS: usize = 5;
fn push_top_findings_lines(&self, lines: &mut Vec<Line<'static>>, dim: Style) {
if self.all_findings.is_empty() {
return;
}
let mut ranked: Vec<(&Finding, u64)> = self
.all_findings
.iter()
.zip(self.avoidable_ops.iter().copied())
.collect();
ranked.sort_by(|a, b| {
crate::render::compare_severity_impact(
crate::render::FindingsSort::Impact,
(&a.0.severity, a.1),
(&b.0.severity, b.1),
)
});
lines.push(Line::from(Span::styled(
"Top findings by impact:".to_string(),
Style::default().add_modifier(Modifier::BOLD),
)));
for &(f, avoidable) in ranked.iter().take(Self::TOP_FINDINGS_ROWS) {
lines.push(Line::from(vec![
Span::raw(" - ".to_string()),
Span::styled(
format!("{:<9}", severity_label(&f.severity)),
Style::default().fg(severity_color(&f.severity)),
),
Span::raw(finding_type_label(&f.finding_type)),
Span::styled(
format!(
" {} \u{b7} {}",
sanitize_for_terminal(&f.service),
sanitize_for_terminal(&f.source_endpoint)
),
dim,
),
Span::styled(format!(" ~{avoidable} avoidable ops"), dim),
]));
}
lines.push(Line::from(""));
}
fn build_analyze_lines(&self) -> Vec<Line<'static>> {
let dim = dim_style();
let Some(summary) = &self.summary else {
let mut lines: Vec<Line<'static>> = Vec::new();
self.push_warning_lines(&mut lines);
lines.extend([
Line::from(Span::styled("Summary unavailable.".to_string(), dim)),
Line::from(Span::styled(
"No analysis summary was supplied (older daemon without /api/export/report?)."
.to_string(),
dim,
)),
Line::from(""),
Line::from(Span::styled(
"Press Enter to inspect traces and findings.".to_string(),
dim,
)),
]);
return lines;
};
let gs = &summary.green_summary;
let (crit, warn, info) = self.severity_counts();
let total = self.all_findings.len();
let mut lines: Vec<Line<'static>> = Vec::new();
self.push_warning_lines(&mut lines);
lines.push(Line::from(vec![
Span::styled("Traces analyzed: ".to_string(), dim),
Span::raw(summary.analysis.traces_analyzed.to_string()),
Span::styled(" Events: ".to_string(), dim),
Span::raw(summary.analysis.events_processed.to_string()),
Span::styled(" Duration: ".to_string(), dim),
Span::raw(format!("{} ms", summary.analysis.duration_ms)),
]));
if let Some(ingest) = &summary.analysis.ingest
&& ingest.spans_filtered > 0
{
let gaps = ingest.filtered_missing_db_statement + ingest.filtered_missing_http_url;
let mut spans = vec![
Span::styled("Spans ingested: ".to_string(), dim),
Span::raw(ingest.spans_received.to_string()),
Span::styled(" Filtered: ".to_string(), dim),
Span::raw(ingest.spans_filtered.to_string()),
];
if gaps > 0 {
spans.push(Span::styled(
" Missing db.statement/http.url: ".to_string(),
dim,
));
spans.push(Span::styled(
gaps.to_string(),
Style::default().fg(Color::Yellow),
));
}
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Findings: ".to_string(), dim),
Span::styled(
total.to_string(),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(format!("{crit} critical"), Style::default().fg(Color::Red)),
Span::raw(", "),
Span::styled(
format!("{warn} warning"),
Style::default().fg(Color::Yellow),
),
Span::raw(", "),
Span::styled(format!("{info} info"), Style::default().fg(Color::Cyan)),
]));
lines.push(Line::from(""));
let band = gs.io_waste_ratio_band;
lines.push(Line::from(vec![
Span::styled("I/O waste ratio: ".to_string(), dim),
Span::styled(
format!("{:.1}%", gs.io_waste_ratio * 100.0),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("({})", band.short_label()),
Style::default().fg(interpret_band_color(band)),
),
]));
lines.push(Line::from(vec![
Span::styled("Avoidable I/O: ".to_string(), dim),
Span::raw(format!(
"{} of {} ops",
gs.avoidable_io_ops, gs.total_io_ops
)),
]));
if let Some(src) = gs.energy_source_label() {
let split = gs
.energy_split_label()
.map_or_else(String::new, |split| format!(" = {split}"));
lines.push(Line::from(vec![
Span::styled("Energy: ".to_string(), dim),
Span::raw(format!("{:.6} kWh", gs.energy_kwh)),
Span::styled(format!(" ({src}){split}"), dim),
]));
}
push_carbon_lines(&mut lines, gs, summary.analysis.traces_analyzed, dim);
push_subsystem_waste_lines(&mut lines, gs, dim);
lines.push(Line::from(""));
if !gs.top_offenders.is_empty() {
lines.push(Line::from(Span::styled(
"Top offenders:".to_string(),
Style::default().add_modifier(Modifier::BOLD),
)));
for offender in &gs.top_offenders {
let oband = offender.io_intensity_band;
let co2 = offender
.co2_grams
.map_or(String::new(), |c| format!(", {c:.6} gCO2"));
lines.push(Line::from(vec![
Span::raw(" - ".to_string()),
Span::raw(sanitize_for_terminal(&offender.endpoint).into_owned()),
Span::styled(format!(" IIS {:.1} ", offender.io_intensity_score), dim),
Span::styled(
format!("({})", oband.short_label()),
Style::default().fg(interpret_band_color(oband)),
),
Span::styled(
format!(
" service: {}{}",
sanitize_for_terminal(&offender.service),
co2
),
dim,
),
]));
}
lines.push(Line::from(""));
}
self.push_top_findings_lines(&mut lines, dim);
push_quality_gate_lines(&mut lines, &summary.quality_gate, dim);
if gs.co2.is_some() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Note: CO\u{2082} estimates have ~2\u{00d7} multiplicative uncertainty (low = mid/2, high = mid\u{00d7}2). See docs/LIMITATIONS.md.".to_string(),
dim,
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Note: (healthy/moderate/high/critical) bands use fixed heuristic thresholds, independent of your config overrides.".to_string(),
dim,
)));
lines.push(Line::from(Span::styled(
"Per-region carbon breakdown and scoring config: run `analyze` for the full report."
.to_string(),
dim,
)));
lines
}
}
fn restore_terminal_if_raw() {
if crossterm::terminal::is_raw_mode_enabled().unwrap_or(true) {
let _ = disable_raw_mode();
let _ = crossterm::execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen);
}
}
pub(crate) fn set_mouse_capture(on: bool) {
let _ = if on {
crossterm::execute!(io::stdout(), EnableMouseCapture)
} else {
crossterm::execute!(io::stdout(), DisableMouseCapture)
};
}
pub(crate) struct RawModeGuard;
impl Drop for RawModeGuard {
fn drop(&mut self) {
restore_terminal_if_raw();
}
}
pub(crate) fn install_terminal_restore_panic_hook() {
static INSTALL: std::sync::Once = std::sync::Once::new();
INSTALL.call_once(|| {
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal_if_raw();
prev_hook(info);
}));
});
}
fn push_carbon_lines(
lines: &mut Vec<Line<'static>>,
gs: &GreenSummary,
traces_analyzed: usize,
dim: Style,
) {
let Some(carbon) = gs.co2.as_ref() else {
lines.push(Line::from(Span::styled(
if traces_analyzed == 0 {
"Carbon: not computed (no traces analyzed)"
} else {
"Carbon: not computed ([green] enabled = false)"
},
dim,
)));
return;
};
let model = sanitize_for_terminal(&carbon.total.model);
let methodology = sanitize_for_terminal(&carbon.total.methodology);
lines.push(Line::from(Span::raw(format!(
"Est. CO\u{2082}: {:.6} g (low {:.6}, high {:.6}, model {model}{})",
carbon.total.mid,
carbon.total.low,
carbon.total.high,
gs.host_model_suffix(),
))));
lines.push(Line::from(Span::raw(format!(
"Avoidable CO\u{2082}: {:.6} g (low {:.6}, high {:.6})",
carbon.avoidable.mid, carbon.avoidable.low, carbon.avoidable.high,
))));
lines.push(Line::from(Span::raw(format!(
"Operational: {:.6} g Embodied: {:.6} g Methodology: {methodology}",
carbon.operational_gco2, carbon.embodied_gco2,
))));
if let Some(transport) = carbon.transport_gco2 {
lines.push(Line::from(Span::raw(format!(
"Transport: {transport:.6} g (cross-region network bytes)"
))));
}
for (label, gco2, detail) in crate::render::host_carbon_terms(carbon, gs) {
lines.push(Line::from(Span::raw(format!(
"{label} {gco2:.6} g ({detail})"
))));
}
}
fn push_quality_gate_lines(lines: &mut Vec<Line<'static>>, gate: &QualityGate, dim: Style) {
let (gate_label, gate_color) = if gate.rules.is_empty() {
("not evaluated", Color::DarkGray)
} else if gate.passed {
("PASSED", Color::Green)
} else {
("FAILED", Color::Red)
};
lines.push(Line::from(vec![
Span::styled(
"Quality gate: ".to_string(),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(
gate_label.to_string(),
Style::default().fg(gate_color).add_modifier(Modifier::BOLD),
),
]));
for rule in &gate.rules {
let (rule_label, rule_color) = if rule.passed {
("PASS", Color::Green)
} else {
("FAIL", Color::Red)
};
lines.push(Line::from(vec![
Span::raw(format!(
" - {}: ",
sentinel_core::quality_gate::rule_label(&rule.rule).map_or_else(
|| sanitize_for_terminal(&rule.rule).into_owned(),
str::to_string
)
)),
Span::styled(
format!("{:.2} (actual {:.2}) ", rule.threshold, rule.actual),
dim,
),
Span::styled(rule_label.to_string(), Style::default().fg(rule_color)),
]));
}
}
fn push_subsystem_waste_lines(lines: &mut Vec<Line<'static>>, gs: &GreenSummary, dim: Style) {
use sentinel_core::report::{DATABASE_WASTE_ABSENT, MESSAGING_WASTE_ABSENT};
let figures = [
(
"Database waste:",
gs.database_waste
.as_ref()
.map(|w| (w.waste_kwh, w.energy_kwh, w.model.as_str(), w.in_totals)),
DATABASE_WASTE_ABSENT,
),
(
"Broker waste:",
gs.messaging_waste
.as_ref()
.map(|w| (w.waste_kwh, w.energy_kwh, w.model.as_str(), w.in_totals)),
MESSAGING_WASTE_ABSENT,
),
];
for (label, figure, absent) in figures {
lines.push(match figure {
Some((waste_kwh, energy_kwh, model, in_totals)) => Line::from(Span::raw(format!(
"{label} {waste_kwh:.6} kWh of {energy_kwh:.6} kWh (model {}) [{}]",
sanitize_for_terminal(model),
sentinel_core::report::waste_scope_label(model, in_totals),
))),
None => Line::from(Span::styled(format!("{label} {absent}"), dim)),
});
}
}
pub(crate) fn dim_style() -> Style {
Style::default().add_modifier(Modifier::DIM)
}
#[cfg(test)]
pub(crate) fn line_text(lines: &[Line]) -> String {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn run(app: &mut App) -> io::Result<()> {
install_terminal_restore_panic_hook();
enable_raw_mode()?;
let _restore = RawModeGuard;
let mut stdout = io::stdout();
crossterm::execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_loop(&mut terminal, app);
terminal.show_cursor()?;
result
}
#[derive(Debug)]
enum KeyOutcome {
Continue,
Quit,
}
fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
) -> io::Result<()> {
#[cfg(feature = "daemon")]
let (tx_outcome, mut rx_outcome) = mpsc::unbounded_channel::<AckOutcome>();
loop {
#[cfg(feature = "daemon")]
while let Ok(outcome) = rx_outcome.try_recv() {
apply_ack_outcome(app, outcome);
}
if matches!(app.view, View::Inspect | View::Explain) {
app.detail_tree_text();
}
terminal.draw(|f| draw(f, app))?;
#[cfg(feature = "daemon")]
let submitting = app.ack_modal.submitting;
#[cfg(not(feature = "daemon"))]
let submitting = false;
if submitting && !event::poll(Duration::from_millis(50))? {
continue;
}
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
#[cfg(feature = "daemon")]
let outcome = handle_keystroke(app, key.code, &tx_outcome);
#[cfg(not(feature = "daemon"))]
let outcome = handle_keystroke(app, key.code);
if matches!(outcome, KeyOutcome::Quit) {
return Ok(());
}
}
Event::Mouse(me) if app.mouse_mode && app.accepts_panel_drag() => {
handle_mouse(app, me);
}
_ => {}
}
}
}
fn handle_mouse(app: &mut App, me: MouseEvent) {
match me.kind {
MouseEventKind::Down(MouseButton::Left) => app.begin_drag(me.column, me.row),
MouseEventKind::Drag(MouseButton::Left) => app.apply_drag(me.column, me.row),
MouseEventKind::Up(MouseButton::Left) => {
app.drag = None;
app.hover = app.hit_test(me.column, me.row);
}
MouseEventKind::Moved => app.hover = app.hit_test(me.column, me.row),
_ => {}
}
}
fn handle_keystroke(
app: &mut App,
code: KeyCode,
#[cfg(feature = "daemon")] tx_outcome: &mpsc::UnboundedSender<AckOutcome>,
) -> KeyOutcome {
#[cfg(feature = "daemon")]
if app.ack_modal.is_visible() {
dispatch_modal_key(app, code, tx_outcome);
return KeyOutcome::Continue;
}
if app.disclose.is_none() && code == KeyCode::Char('m') {
app.toggle_mouse_mode();
set_mouse_capture(app.mouse_mode);
return KeyOutcome::Continue;
}
let prev_view = app.view;
let outcome = match app.view {
View::Analyze => dispatch_analyze_key(app, code),
View::Inspect => dispatch_panel_key(app, code),
View::Explain => dispatch_explain_key(app, code),
View::Disclose => dispatch_disclose_key(app, code),
};
if app.view != prev_view {
app.hover = None;
app.drag = None;
}
outcome
}
fn dispatch_disclose_key(app: &mut App, code: KeyCode) -> KeyOutcome {
let Some(state) = app.disclose.as_mut() else {
return KeyOutcome::Continue;
};
match code {
KeyCode::Char('q') => return KeyOutcome::Quit,
KeyCode::Char('g') => state.cycle_granularity(),
KeyCode::Left | KeyCode::Char('h') => state.step(false),
KeyCode::Right | KeyCode::Char('l') => state.step(true),
KeyCode::Char('[') => state.step_month(false),
KeyCode::Char(']') => state.step_month(true),
KeyCode::Tab | KeyCode::BackTab => state.toggle_custom_field(),
KeyCode::Char('i') => state.toggle_intent(),
KeyCode::Char('c') => state.toggle_confidentiality(),
KeyCode::Up | KeyCode::Char('k') => state.scroll(false),
KeyCode::Down | KeyCode::Char('j') => state.scroll(true),
_ => {}
}
KeyOutcome::Continue
}
fn dispatch_analyze_key(app: &mut App, code: KeyCode) -> KeyOutcome {
match code {
KeyCode::Char('q') => return KeyOutcome::Quit,
KeyCode::Up | KeyCode::Char('k') => {
app.scroll_offset = app.scroll_offset.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
let max = app.analyze_content_line_count().saturating_sub(1);
if app.scroll_offset < max {
app.scroll_offset = app.scroll_offset.saturating_add(1);
}
}
KeyCode::Enter => {
app.view = View::Inspect;
app.scroll_offset = 0;
}
_ => {}
}
KeyOutcome::Continue
}
fn dispatch_explain_key(app: &mut App, code: KeyCode) -> KeyOutcome {
match code {
KeyCode::Char('q') => return KeyOutcome::Quit,
KeyCode::Up | KeyCode::Char('k') => {
app.scroll_offset = app.scroll_offset.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
let max = app.explain_content_line_count().saturating_sub(1);
if app.scroll_offset < max {
app.scroll_offset = app.scroll_offset.saturating_add(1);
}
}
KeyCode::Esc => {
app.view = View::Inspect;
app.active_panel = Panel::Detail;
app.scroll_offset = 0;
}
_ => {}
}
KeyOutcome::Continue
}
fn dispatch_panel_key(app: &mut App, code: KeyCode) -> KeyOutcome {
match code {
KeyCode::Char('q') => return KeyOutcome::Quit,
KeyCode::Up | KeyCode::Char('k') => app.move_up(),
KeyCode::Down | KeyCode::Char('j') => app.move_down(),
KeyCode::Right | KeyCode::Tab | KeyCode::Char('l') => app.next_panel(),
KeyCode::Left | KeyCode::BackTab | KeyCode::Char('h') => app.prev_panel(),
KeyCode::Enter => app.enter(),
KeyCode::Esc => app.escape(),
KeyCode::Char('r') => app.reset_layout(),
KeyCode::Char('s') => app.cycle_trace_sort(),
KeyCode::Char('f') => app.cycle_severity_filter(),
#[cfg(feature = "daemon")]
KeyCode::Char('a') => open_ack_modal_for_current(app, false),
#[cfg(feature = "daemon")]
KeyCode::Char('u') => open_ack_modal_for_current(app, true),
_ => {}
}
KeyOutcome::Continue
}
fn interpret_band_color(level: InterpretationLevel) -> Color {
match level {
InterpretationLevel::Healthy => Color::Green,
InterpretationLevel::Moderate => Color::Reset,
InterpretationLevel::High => Color::Yellow,
InterpretationLevel::Critical => Color::Red,
}
}
fn severity_color(severity: &Severity) -> Color {
match severity {
Severity::Critical => Color::Red,
Severity::Warning => Color::Yellow,
Severity::Info => Color::Cyan,
}
}
fn severity_label(severity: &Severity) -> &'static str {
match severity {
Severity::Critical => "CRITICAL",
Severity::Warning => "WARNING",
Severity::Info => "INFO",
}
}
fn finding_type_label(ft: &FindingType) -> &'static str {
ft.display_label()
}
#[cfg(feature = "daemon")]
mod ack_modal;
mod draw;
#[cfg(test)]
mod tests;