#![cfg(all(feature = "daemon", feature = "tui"))]
use std::collections::VecDeque;
use std::io;
use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::terminal::{EnterAlternateScreen, enable_raw_mode};
use ratatui::Frame;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::symbols;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Axis, Block, Borders, Chart, Clear, Dataset, GraphType, Paragraph, Wrap};
use sentinel_core::config::DaemonConfig;
use sentinel_core::daemon::query_api::EnergyStatusResponse;
use sentinel_core::report::{GreenSummary, Warning};
use sentinel_core::score::carbon::IntensitySource;
use sentinel_core::text_safety::sanitize_for_terminal;
use tokio::sync::mpsc;
const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(250);
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
const TREND_CAPACITY: usize = 240;
const ADVISOR_THRESHOLD_PCT: f64 = 90.0;
const CARBON_BULLET: Color = Color::Rgb(0x27, 0xBE, 0x6E);
const CARBON_CURVE: Color = Color::Rgb(0x00, 0xF5, 0x66);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Tab {
Advisor,
Energy,
Trends,
Scrapers,
Config,
}
const TABS: [(Tab, &str); 5] = [
(Tab::Advisor, "Advisor"),
(Tab::Energy, "Energy"),
(Tab::Trends, "Trends"),
(Tab::Scrapers, "Scrapers"),
(Tab::Config, "Config"),
];
#[derive(serde::Deserialize)]
struct ReportSlim {
green_summary: GreenSummary,
#[serde(default)]
warning_details: Vec<Warning>,
#[serde(default)]
warnings: Vec<String>,
}
#[derive(serde::Deserialize)]
struct StatusSlim {
active_traces: u64,
#[serde(default)]
max_active_traces: u64,
#[serde(default)]
analysis_queue_depth: i64,
#[serde(default)]
analysis_queue_capacity: u64,
stored_findings: u64,
#[serde(default)]
max_retained_findings: u64,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(serde::Deserialize, Default)]
struct ConfigSlim {
#[serde(default)]
listen_addr: String,
#[serde(default)]
listen_port: u16,
#[serde(default)]
listen_port_grpc: u16,
#[serde(default)]
json_socket: String,
#[serde(default)]
max_active_traces: usize,
#[serde(default)]
trace_ttl_ms: u64,
#[serde(default)]
sampling_rate: f64,
#[serde(default)]
max_events_per_trace: usize,
#[serde(default)]
max_payload_size: usize,
#[serde(default)]
environment: String,
#[serde(default)]
max_retained_findings: usize,
#[serde(default)]
ingest_queue_capacity: usize,
#[serde(default)]
analysis_queue_capacity: usize,
#[serde(default)]
api_enabled: bool,
#[serde(default)]
tls_configured: bool,
#[serde(default)]
ack_enabled: bool,
#[serde(default)]
ack_api_key_set: bool,
#[serde(default)]
cors_allowed_origins: Vec<String>,
#[serde(default)]
archive_configured: bool,
#[serde(default)]
correlation_enabled: bool,
#[serde(default)]
correlation_window_ms: u64,
#[serde(default)]
correlation_lag_threshold_ms: u64,
#[serde(default)]
correlation_min_co_occurrences: u32,
#[serde(default)]
correlation_min_confidence: f64,
#[serde(default)]
correlation_max_tracked_pairs: usize,
}
struct Snapshot {
green_summary: GreenSummary,
warning_details: Vec<Warning>,
warnings: Vec<String>,
scrapers: Option<EnergyStatusResponse>,
status: Option<StatusSlim>,
config: Option<ConfigSlim>,
}
struct TrendPoint {
energy_kwh: f64,
carbon_gco2: f64,
traces_pct: Option<f64>,
queue_pct: Option<f64>,
findings_pct: Option<f64>,
}
#[allow(clippy::cast_precision_loss)] fn trend_point(s: &Snapshot) -> TrendPoint {
let gs = &s.green_summary;
let pct = |value: f64, cap: u64| {
if cap == 0 {
None
} else {
Some((value / cap as f64 * 100.0).clamp(0.0, 100.0))
}
};
let st = s.status.as_ref();
TrendPoint {
energy_kwh: gs.energy_kwh,
carbon_gco2: gs.regions.iter().map(|r| r.co2_gco2).sum(),
traces_pct: st.and_then(|st| pct(st.active_traces as f64, st.max_active_traces)),
queue_pct: st.and_then(|st| {
pct(
st.analysis_queue_depth.max(0) as f64,
st.analysis_queue_capacity,
)
}),
findings_pct: st.and_then(|st| pct(st.stored_findings as f64, st.max_retained_findings)),
}
}
enum FetchOutcome {
Snapshot(Box<Snapshot>),
Unreachable,
}
struct MonitorState {
daemon_url: String,
refresh_secs: u64,
tab: Tab,
scroll: u16,
latest: Option<Snapshot>,
stale: bool,
last_update: Option<Instant>,
line_counts: [u16; TABS.len()],
history: VecDeque<TrendPoint>,
dirty: bool,
}
impl MonitorState {
fn new(daemon_url: String, refresh_secs: u64) -> Self {
Self {
daemon_url,
refresh_secs,
tab: Tab::Advisor,
scroll: 0,
latest: None,
stale: false,
last_update: None,
line_counts: [0; TABS.len()],
history: VecDeque::new(),
dirty: true,
}
}
fn refresh_line_counts(&mut self) {
let latest = self.latest.as_ref();
let count = |lines: Vec<Line<'static>>| u16::try_from(lines.len()).unwrap_or(u16::MAX);
self.line_counts = [
count(build_advisor_lines(latest)),
count(build_energy_lines(latest)),
0,
count(build_scrapers_lines(latest)),
count(build_config_lines(latest)),
];
}
fn apply(&mut self, outcome: FetchOutcome) {
match outcome {
FetchOutcome::Snapshot(s) => {
let mut s = *s;
if s.scrapers.is_none()
&& let Some(prev) = self.latest.as_mut().and_then(|p| p.scrapers.take())
{
s.scrapers = Some(prev);
}
if s.config.is_none()
&& let Some(prev) = self.latest.as_mut().and_then(|p| p.config.take())
{
s.config = Some(prev);
}
self.history.push_back(trend_point(&s));
if self.history.len() > TREND_CAPACITY {
self.history.pop_front();
}
self.latest = Some(s);
self.stale = false;
self.last_update = Some(Instant::now());
self.refresh_line_counts();
self.scroll = self.scroll.min(self.line_count().saturating_sub(1));
self.dirty = true;
}
FetchOutcome::Unreachable => {
self.dirty = self.dirty || !self.stale;
self.stale = true;
}
}
}
fn cycle_tab(&mut self, forward: bool) {
let n = TABS.len();
let i = TABS
.iter()
.position(|(t, _)| *t == self.tab)
.unwrap_or_default();
let next = if forward {
(i + 1) % n
} else {
(i + n - 1) % n
};
self.tab = TABS[next].0;
self.scroll = 0;
self.dirty = true;
}
fn line_count(&self) -> u16 {
let i = TABS
.iter()
.position(|(t, _)| *t == self.tab)
.unwrap_or_default();
self.line_counts[i]
}
fn scroll_up(&mut self) {
let prev = self.scroll;
self.scroll = self.scroll.saturating_sub(1);
self.dirty = self.dirty || self.scroll != prev;
}
fn scroll_down(&mut self) {
let max = self.line_count().saturating_sub(1);
if self.scroll < max {
self.scroll = self.scroll.saturating_add(1);
self.dirty = true;
}
}
}
pub(crate) fn cmd_monitor(base_url: &str, refresh_secs: u64) {
let (tx, mut rx) = mpsc::channel::<FetchOutcome>(4);
let poller = tokio::spawn(poll_loop(
base_url.to_string(),
Duration::from_secs(refresh_secs),
tx,
));
let mut state = MonitorState::new(base_url.to_string(), refresh_secs);
let result = tokio::task::block_in_place(|| run(&mut state, &mut rx));
poller.abort();
if let Err(e) = result {
eprintln!("TUI error: {e}");
std::process::exit(1);
}
}
async fn poll_loop(base_url: String, refresh: Duration, tx: mpsc::Sender<FetchOutcome>) {
let client = sentinel_core::http_client::build_client();
loop {
let outcome = fetch_snapshot(&client, &base_url).await;
match tx.try_send(outcome) {
Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {}
Err(mpsc::error::TrySendError::Closed(_)) => return,
}
tokio::time::sleep(refresh).await;
}
}
async fn fetch_snapshot(
client: &sentinel_core::http_client::HttpClient,
base_url: &str,
) -> FetchOutcome {
let (report, scrapers, status, config) = tokio::join!(
crate::query::fetch_json::<ReportSlim>(
client,
base_url,
"/api/export/report",
FETCH_TIMEOUT
),
crate::query::fetch_json::<EnergyStatusResponse>(
client,
base_url,
"/api/energy",
FETCH_TIMEOUT
),
crate::query::fetch_json::<StatusSlim>(client, base_url, "/api/status", FETCH_TIMEOUT),
crate::query::fetch_json::<ConfigSlim>(client, base_url, "/api/config", FETCH_TIMEOUT),
);
match report {
Some(report) => FetchOutcome::Snapshot(Box::new(Snapshot {
green_summary: report.green_summary,
warning_details: report.warning_details,
warnings: report.warnings,
scrapers,
status,
config,
})),
None => FetchOutcome::Unreachable,
}
}
fn run(state: &mut MonitorState, rx: &mut mpsc::Receiver<FetchOutcome>) -> io::Result<()> {
crate::tui::install_terminal_restore_panic_hook();
enable_raw_mode()?;
let _restore = crate::tui::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, state, rx);
terminal.show_cursor()?;
result
}
fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
state: &mut MonitorState,
rx: &mut mpsc::Receiver<FetchOutcome>,
) -> io::Result<()> {
let mut last_age: Option<Option<u64>> = None;
loop {
while let Ok(outcome) = rx.try_recv() {
state.apply(outcome);
}
let age = state.last_update.map(|t| t.elapsed().as_secs());
if state.dirty || last_age != Some(age) {
terminal.draw(|f| draw(f, state))?;
state.dirty = false;
last_age = Some(age);
}
if !event::poll(EVENT_POLL_INTERVAL)? {
continue;
}
if let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
&& handle_key(state, key.code)
{
return Ok(());
}
}
}
fn handle_key(state: &mut MonitorState, code: KeyCode) -> bool {
match code {
KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Tab => state.cycle_tab(true),
KeyCode::BackTab => state.cycle_tab(false),
KeyCode::Up | KeyCode::Char('k') => state.scroll_up(),
KeyCode::Down | KeyCode::Char('j') => state.scroll_down(),
_ => {}
}
false
}
fn draw(f: &mut Frame, state: &MonitorState) {
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(f.area());
draw_header(f, state, outer[0]);
let (title, lines, wrap) = match state.tab {
Tab::Advisor => (
" Advisor \u{00b7} Tab \u{21c4} \u{00b7} j/k \u{2195} \u{00b7} q ",
build_advisor_lines(state.latest.as_ref()),
true,
),
Tab::Energy => (
" Energy \u{00b7} Tab \u{21c4} \u{00b7} j/k \u{2195} \u{00b7} q ",
build_energy_lines(state.latest.as_ref()),
false,
),
Tab::Trends => {
draw_trends(f, state, outer[1]);
return;
}
Tab::Scrapers => (
" Scrapers \u{00b7} Tab \u{21c4} \u{00b7} j/k \u{2195} \u{00b7} q ",
build_scrapers_lines(state.latest.as_ref()),
false,
),
Tab::Config => (
" Config \u{00b7} Tab \u{21c4} \u{00b7} j/k \u{2195} \u{00b7} q ",
build_config_lines(state.latest.as_ref()),
true,
),
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let mut paragraph = Paragraph::new(lines).block(block).scroll((state.scroll, 0));
if wrap {
paragraph = paragraph.wrap(Wrap { trim: false });
}
f.render_widget(paragraph, outer[1]);
}
fn draw_header(f: &mut Frame, state: &MonitorState, area: Rect) {
let dim = crate::tui::dim_style();
let mut spans = vec![Span::raw(" ")];
for (i, (tab, label)) in TABS.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" \u{00b7} ", dim));
}
spans.push(Span::styled(
format!(" {label} "),
crate::tui::tab_label_style(state.tab == *tab),
));
}
let age = state.last_update.map_or_else(
|| "waiting".to_string(),
|t| format!("{}s ago", t.elapsed().as_secs()),
);
spans.push(Span::styled(
format!(
" {} \u{00b7} {}s \u{00b7} {age}",
state.daemon_url, state.refresh_secs
),
dim,
));
if state.stale {
spans.push(Span::styled(
" [STALE]",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn snapshot_or_waiting<'a>(
latest: Option<&'a Snapshot>,
lines: &mut Vec<Line<'static>>,
) -> Option<&'a Snapshot> {
if latest.is_none() {
lines.push(Line::from(Span::styled(
"Waiting for the first snapshot from /api/export/report...",
crate::tui::dim_style(),
)));
}
latest
}
fn build_advisor_lines(latest: Option<&Snapshot>) -> Vec<Line<'static>> {
let dim = crate::tui::dim_style();
let mut lines: Vec<Line<'static>> = vec![
Line::from(Span::styled(
"Settings advisor",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"Config tuning hints the daemon emits when a setting looks undersized for the load.",
dim,
)),
Line::from(""),
];
let Some(snapshot) = snapshot_or_waiting(latest, &mut lines) else {
return lines;
};
if snapshot.warning_details.is_empty() && snapshot.warnings.is_empty() {
lines.push(Line::from(Span::styled(
"No hints: the daemon reports no undersized setting.",
dim,
)));
return lines;
}
for w in &snapshot.warning_details {
lines.push(Line::from(vec![
Span::raw(" ["),
Span::styled(
sanitize_for_terminal(&w.kind).into_owned(),
warning_kind_style(&w.kind),
),
Span::raw("] "),
Span::raw(sanitize_for_terminal(&w.message).into_owned()),
]));
}
if snapshot.warning_details.is_empty() {
for w in &snapshot.warnings {
lines.push(Line::from(Span::raw(format!(
" {}",
sanitize_for_terminal(w)
))));
}
}
lines
}
fn build_energy_lines(latest: Option<&Snapshot>) -> Vec<Line<'static>> {
let dim = crate::tui::dim_style();
let bold = Style::default().add_modifier(Modifier::BOLD);
let mut lines: Vec<Line<'static>> = vec![
Line::from(Span::styled("Energy / carbon mix", bold)),
Line::from(Span::styled(
"Effective source per service, grid intensity per region (cold vs hot).",
dim,
)),
Line::from(""),
];
let Some(snapshot) = snapshot_or_waiting(latest, &mut lines) else {
return lines;
};
let gs = &snapshot.green_summary;
if gs.per_service_energy_kwh.is_empty() && gs.regions.is_empty() {
lines.push(Line::from(Span::styled(
"No energy/carbon data (green scoring disabled, or no events analyzed yet).",
dim,
)));
return lines;
}
lines.push(Line::from(vec![
Span::styled("Window energy: ", dim),
Span::raw(format!("{} kWh", fmt_tiny(gs.energy_kwh))),
Span::styled(
format!(" model: {}", truncate_cell(&gs.energy_model, 32)),
dim,
),
]));
lines.push(Line::from(""));
if !gs.per_service_energy_kwh.is_empty() {
lines.push(Line::from(Span::styled("By service", bold)));
lines.push(Line::from(Span::styled(
format!(
" {:<22} {:<14} {:<16} {:>6} {:>12} {}",
"service", "region", "source", "meas%", "kWh", "kgCO2eq"
),
dim,
)));
for (svc, kwh) in &gs.per_service_energy_kwh {
let region = gs.per_service_region.get(svc).map_or("-", String::as_str);
let model = gs
.per_service_energy_model
.get(svc)
.map_or("-", String::as_str);
let meas = gs
.per_service_measured_ratio
.get(svc)
.map_or_else(|| "-".to_string(), |r| format!("{:.0}%", r * 100.0));
let co2 = gs
.per_service_carbon_kgco2eq
.get(svc)
.copied()
.unwrap_or(0.0);
lines.push(Line::from(Span::raw(format!(
" {:<22} {:<14} {:<16} {:>6} {:>12} {:.9}",
truncate_cell(svc, 22),
truncate_cell(region, 14),
truncate_cell(model, 16),
meas,
fmt_tiny(*kwh),
co2,
))));
}
lines.push(Line::from(""));
}
if !gs.regions.is_empty() {
lines.push(Line::from(Span::styled("By region", bold)));
lines.push(Line::from(Span::styled(
format!(
" {:<14} {:>10} {:<22} {:<10} {:>8} {}",
"region", "gCO2/kWh", "source", "estimated", "ops", "gCO2"
),
dim,
)));
for r in &gs.regions {
let estimated = match r.intensity_estimated {
Some(true) => "yes",
Some(false) => "no",
None => "-",
};
lines.push(Line::from(Span::raw(format!(
" {:<14} {:>10.1} {:<22} {:<10} {:>8} {:.6}",
truncate_cell(&r.region, 14),
r.grid_intensity_gco2_kwh,
intensity_source_label(r.intensity_source),
estimated,
r.io_ops,
r.co2_gco2,
))));
}
}
lines
}
fn build_scrapers_lines(latest: Option<&Snapshot>) -> Vec<Line<'static>> {
let dim = crate::tui::dim_style();
let bold = Style::default().add_modifier(Modifier::BOLD);
let mut lines: Vec<Line<'static>> = vec![
Line::from(Span::styled("Energy scrapers", bold)),
Line::from(Span::styled(
"Live health of the measured-energy and grid-intensity backends.",
dim,
)),
Line::from(""),
];
let Some(snapshot) = snapshot_or_waiting(latest, &mut lines) else {
return lines;
};
let Some(scrapers) = snapshot.scrapers.as_ref() else {
lines.push(Line::from(Span::styled(
"/api/energy unavailable (daemon predates the endpoint?). Scraper freshness is also on /metrics.",
dim,
)));
return lines;
};
lines.push(Line::from(Span::styled(
format!(
" {:<18} {:<12} {:>10} {:>8} {:>8}",
"backend", "configured", "age (s)", "ok", "failed"
),
dim,
)));
let fmt_u64 = |v: Option<u64>| v.map_or_else(|| "-".to_string(), |n| n.to_string());
for b in &scrapers.backends {
let configured = if b.configured { "yes" } else { "no" };
let age = b
.last_scrape_age_seconds
.map_or_else(|| "-".to_string(), |a| format!("{a:.0}"));
let style = if b.configured { Style::default() } else { dim };
lines.push(Line::from(Span::styled(
format!(
" {:<18} {:<12} {:>10} {:>8} {:>8}",
truncate_cell(&b.backend, 18),
configured,
age,
fmt_u64(b.scrapes_ok),
fmt_u64(b.scrapes_failed),
),
style,
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"electricity_maps has no freshness gauge: its liveness shows as",
dim,
)));
lines.push(Line::from(Span::styled(
"RealTime intensity sources on the Energy tab.",
dim,
)));
lines
}
fn config_row(
lines: &mut Vec<Line<'static>>,
name: &str,
current: &str,
default: &str,
desc: &str,
) {
let dim = crate::tui::dim_style();
let current = sanitize_for_terminal(current);
let modified = current.as_ref() != default;
let suffix = if modified {
Span::styled(
format!(" (default {default}, modified)"),
Style::default().fg(Color::Yellow),
)
} else {
Span::styled(format!(" (default {default})"), dim)
};
lines.push(Line::from(vec![
Span::styled(
format!("{name} = "),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(current.into_owned()),
suffix,
]));
lines.push(Line::from(Span::styled(format!(" {desc}"), dim)));
}
#[allow(clippy::cast_precision_loss)]
fn fmt_mib(bytes: usize) -> String {
format!("{:.0} MiB", bytes as f64 / (1024.0 * 1024.0))
}
#[allow(clippy::too_many_lines)] fn build_config_lines(latest: Option<&Snapshot>) -> Vec<Line<'static>> {
let dim = crate::tui::dim_style();
let bold = Style::default().add_modifier(Modifier::BOLD);
let mut lines: Vec<Line<'static>> = vec![
Line::from(Span::styled("Daemon configuration", bold)),
Line::from(Span::styled(
"Effective [daemon] settings (read-only), with the compiled-in default and what each does."
.to_string(),
dim,
)),
Line::from(""),
];
let Some(snapshot) = snapshot_or_waiting(latest, &mut lines) else {
return lines;
};
let Some(c) = snapshot.config.as_ref() else {
lines.push(Line::from(Span::styled(
"/api/config unavailable (daemon predates the endpoint?). Upgrade the daemon to 0.8.8+."
.to_string(),
dim,
)));
return lines;
};
let d = DaemonConfig::default();
let bool_str = |b: bool| if b { "yes" } else { "no" }.to_string();
config_row(
&mut lines,
"max_active_traces",
&c.max_active_traces.to_string(),
&d.max_active_traces.to_string(),
"Cap of the in-memory correlation window; the oldest trace is evicted (LRU) past it. The advisor hints at 90%.",
);
config_row(
&mut lines,
"trace_ttl_ms",
&c.trace_ttl_ms.to_string(),
&d.trace_ttl_ms.to_string(),
"How long a trace waits for more spans before it is evicted and analyzed (ms).",
);
config_row(
&mut lines,
"sampling_rate",
&format!("{:.2}", c.sampling_rate),
&format!("{:.2}", d.sampling_rate),
"Fraction of incoming traces analyzed (0.0-1.0); lower it to shed load under heavy traffic.",
);
config_row(
&mut lines,
"max_events_per_trace",
&c.max_events_per_trace.to_string(),
&d.max_events_per_trace.to_string(),
"Ring-buffer size per trace; oldest spans drop once exceeded.",
);
config_row(
&mut lines,
"max_payload_size",
&fmt_mib(c.max_payload_size),
&fmt_mib(d.max_payload_size),
"Largest JSON payload the daemon will deserialize from one request.",
);
config_row(
&mut lines,
"ingest_queue_capacity",
&c.ingest_queue_capacity.to_string(),
&d.ingest_queue_capacity.to_string(),
"Span-event batches buffered between listeners and the event loop; full applies backpressure (OTLP 503).",
);
config_row(
&mut lines,
"analysis_queue_capacity",
&c.analysis_queue_capacity.to_string(),
&d.analysis_queue_capacity.to_string(),
"Batches awaiting detect+score; full sheds whole batches (perf_sentinel_analysis_shed_*).",
);
config_row(
&mut lines,
"max_retained_findings",
&c.max_retained_findings.to_string(),
&d.max_retained_findings.to_string(),
"Findings kept in the query ring buffer; oldest evicted past it.",
);
config_row(
&mut lines,
"environment",
&c.environment,
d.environment.as_str(),
"Deployment label stamped on findings as a Confidence (staging = medium, production = high).",
);
config_row(
&mut lines,
"api_enabled",
&bool_str(c.api_enabled),
&bool_str(d.api_enabled),
"Whether the daemon query API (/api/*) is served at all.",
);
config_row(
&mut lines,
"listen_addr",
&c.listen_addr,
&d.listen_addr,
"Bind address for OTLP and /metrics. A non-loopback value exposes unauthenticated endpoints.",
);
config_row(
&mut lines,
"listen_port",
&c.listen_port.to_string(),
&d.listen_port.to_string(),
"OTLP HTTP receiver and /metrics port.",
);
config_row(
&mut lines,
"listen_port_grpc",
&c.listen_port_grpc.to_string(),
&d.listen_port_grpc.to_string(),
"OTLP gRPC receiver port.",
);
config_row(
&mut lines,
"json_socket",
&c.json_socket,
&d.json_socket,
"Unix domain socket path for native NDJSON event ingestion.",
);
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Sub-systems", bold)));
lines.push(Line::from(""));
config_row(
&mut lines,
"tls",
if c.tls_configured {
"configured"
} else {
"not configured"
},
"not configured",
"TLS for the OTLP listeners (cert/key paths summarized; never shown).",
);
config_row(
&mut lines,
"ack_enabled",
&bool_str(c.ack_enabled),
&bool_str(d.ack.enabled),
"Daemon-side acknowledgment store (JSONL persistence + ack HTTP routes).",
);
config_row(
&mut lines,
"ack_api_key",
if c.ack_api_key_set { "set" } else { "unset" },
"unset",
"Whether the ack mutation routes require an X-API-Key (the key itself is never exposed).",
);
config_row(
&mut lines,
"cors_allowed_origins",
&if c.cors_allowed_origins.is_empty() {
"(none)".to_string()
} else {
c.cors_allowed_origins.join(", ")
},
"(none)",
"Origins allowed by the HTTP API CORS layer; empty emits no CORS headers.",
);
config_row(
&mut lines,
"archive",
if c.archive_configured {
"configured"
} else {
"not configured"
},
"not configured",
"Per-window Report NDJSON archive writer consumed by `perf-sentinel disclose`.",
);
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Correlation", bold)));
lines.push(Line::from(""));
let cd = d.correlation;
config_row(
&mut lines,
"correlation.enabled",
&bool_str(c.correlation_enabled),
&bool_str(cd.enabled),
"Whether the cross-trace correlator runs; off by default, the fields below apply only when on.",
);
config_row(
&mut lines,
"correlation.window_ms",
&c.correlation_window_ms.to_string(),
&cd.window_ms.to_string(),
"Rolling window (ms) over which finding co-occurrences are tracked.",
);
config_row(
&mut lines,
"correlation.lag_threshold_ms",
&c.correlation_lag_threshold_ms.to_string(),
&cd.lag_threshold_ms.to_string(),
"Max delay (ms) between two findings to count them as co-occurring.",
);
config_row(
&mut lines,
"correlation.min_co_occurrences",
&c.correlation_min_co_occurrences.to_string(),
&cd.min_co_occurrences.to_string(),
"Minimum co-occurrence count before a correlation is reported.",
);
config_row(
&mut lines,
"correlation.min_confidence",
&format!("{:.2}", c.correlation_min_confidence),
&format!("{:.2}", cd.min_confidence),
"Minimum confidence (co-occurrences / occurrences of A) to report a correlation.",
);
config_row(
&mut lines,
"correlation.max_tracked_pairs",
&c.correlation_max_tracked_pairs.to_string(),
&cd.max_tracked_pairs.to_string(),
"Cap on tracked finding pairs; lowest-co-occurrence pairs are evicted past it.",
);
lines
}
#[derive(Default)]
struct TrendSeries {
energy: Vec<(f64, f64)>,
carbon: Vec<(f64, f64)>,
traces_pct: Vec<(f64, f64)>,
queue_pct: Vec<(f64, f64)>,
findings_pct: Vec<(f64, f64)>,
}
#[allow(clippy::cast_precision_loss)] fn build_trend_series(history: &VecDeque<TrendPoint>) -> TrendSeries {
let mut s = TrendSeries::default();
for (i, p) in history.iter().enumerate() {
let x = i as f64;
s.energy.push((x, p.energy_kwh));
s.carbon.push((x, p.carbon_gco2));
if let Some(v) = p.traces_pct {
s.traces_pct.push((x, v));
}
if let Some(v) = p.queue_pct {
s.queue_pct.push((x, v));
}
if let Some(v) = p.findings_pct {
s.findings_pct.push((x, v));
}
}
s
}
fn draw_trends(f: &mut Frame, state: &MonitorState, area: Rect) {
let outer_block = Block::default()
.title(" Trends \u{00b7} Tab \u{21c4} \u{00b7} q ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = outer_block.inner(area);
f.render_widget(outer_block, area);
if state.history.len() < 2 {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(
"Collecting trend points ({}/2): one lands per refresh tick ({}s)...",
state.history.len(),
state.refresh_secs
),
crate::tui::dim_style(),
))),
inner,
);
return;
}
let series = build_trend_series(&state.history);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(inner);
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[0]);
#[allow(clippy::cast_precision_loss)]
let x_max = (state.history.len() - 1).max(1) as f64;
let span_label = format!(
"-{}",
fmt_span_secs((state.history.len() as u64 - 1) * state.refresh_secs)
);
draw_metric_chart(
f,
top[0],
" Energy \u{00b7} kWh/window ",
&series.energy,
(Color::Yellow, Color::Yellow),
x_max,
&span_label,
);
draw_metric_chart(
f,
top[1],
" Carbon \u{00b7} gCO2e/window ",
&series.carbon,
(CARBON_CURVE, CARBON_BULLET),
x_max,
&span_label,
);
draw_headroom_chart(f, rows[1], &series, x_max, &span_label);
}
fn curve_style(color: Color) -> Style {
let style = Style::default().fg(color);
if matches!(color, Color::Green | Color::LightGreen | Color::Rgb(..)) {
style
} else {
style.add_modifier(Modifier::BOLD)
}
}
fn thicken_dy(area: Rect, y_span: f64) -> f64 {
let plot_rows = f64::from(area.height.saturating_sub(3)).max(1.0);
y_span / (plot_rows * 4.0)
}
fn offset_series(data: &[(f64, f64)], dy: f64) -> Vec<(f64, f64)> {
data.iter().map(|(x, y)| (*x, y + dy)).collect()
}
fn draw_chart_legend(f: &mut Frame, area: Rect, entries: &[(Color, String)]) {
if entries.is_empty() || area.width < 6 || area.height < 4 {
return;
}
let lines: Vec<Line<'static>> = entries
.iter()
.map(|(color, label)| {
Line::from(vec![
Span::styled("\u{25cf} ", Style::default().fg(*color)),
Span::styled(label.clone(), Style::default().fg(Color::Reset)),
])
})
.collect();
#[allow(clippy::cast_possible_truncation)]
let want_w = (entries
.iter()
.map(|(_, l)| l.chars().count() + 2)
.max()
.unwrap_or(0) as u16)
.min(area.width.saturating_sub(2));
#[allow(clippy::cast_possible_truncation)]
let want_h = (entries.len() as u16).min(area.height.saturating_sub(2));
let rect = Rect {
x: area.right().saturating_sub(want_w + 1),
y: area.y + 1,
width: want_w,
height: want_h,
};
f.render_widget(Clear, rect);
f.render_widget(Paragraph::new(lines), rect);
}
fn draw_metric_chart(
f: &mut Frame,
area: Rect,
title: &'static str,
data: &[(f64, f64)],
colors: (Color, Color),
x_max: f64,
span_label: &str,
) {
let (curve_color, bullet_color) = colors;
let dim = crate::tui::dim_style();
let y_max = data.iter().map(|p| p.1).fold(0.0_f64, f64::max);
let y_top = if y_max > 0.0 { y_max * 1.15 } else { 1.0 };
let last = data.last().map_or(0.0, |p| p.1);
let twin = offset_series(data, thicken_dy(area, y_top));
let datasets = vec![
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(curve_style(curve_color))
.data(data),
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(curve_style(curve_color))
.data(&twin),
];
let chart = Chart::new(datasets)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(dim),
)
.x_axis(
Axis::default()
.bounds([0.0, x_max])
.labels([span_label.to_string(), "now".to_string()])
.style(dim),
)
.y_axis(
Axis::default()
.bounds([0.0, y_top])
.labels(["0".to_string(), fmt_tiny(y_top)])
.style(dim),
);
f.render_widget(chart, area);
draw_chart_legend(
f,
area,
&[(bullet_color, format!("now {}", fmt_tiny(last)))],
);
}
fn draw_headroom_chart(
f: &mut Frame,
area: Rect,
series: &TrendSeries,
x_max: f64,
span_label: &str,
) {
let dim = crate::tui::dim_style();
if series.traces_pct.is_empty() && series.queue_pct.is_empty() && series.findings_pct.is_empty()
{
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"Headroom unavailable: /api/status predates the capacity fields (0.8.8).",
dim,
)))
.block(
Block::default()
.title(" Headroom ")
.borders(Borders::ALL)
.border_style(dim),
),
area,
);
return;
}
let threshold = [(0.0, ADVISOR_THRESHOLD_PCT), (x_max, ADVISOR_THRESHOLD_PCT)];
let last_pct = |s: &[(f64, f64)]| s.last().map_or(0.0, |p| p.1);
let dy = thicken_dy(area, 100.0);
let twin_traces = offset_series(&series.traces_pct, dy);
let twin_queue = offset_series(&series.queue_pct, dy);
let twin_findings = offset_series(&series.findings_pct, dy);
let braille_line = || {
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
};
let datasets = vec![
braille_line()
.style(curve_style(Color::Yellow))
.data(&series.traces_pct),
braille_line()
.style(curve_style(Color::Yellow))
.data(&twin_traces),
braille_line()
.style(curve_style(Color::LightBlue))
.data(&series.queue_pct),
braille_line()
.style(curve_style(Color::LightBlue))
.data(&twin_queue),
braille_line()
.style(curve_style(Color::Cyan))
.data(&series.findings_pct),
braille_line()
.style(curve_style(Color::Cyan))
.data(&twin_findings),
braille_line()
.style(curve_style(Color::Red))
.data(&threshold),
];
let chart = Chart::new(datasets)
.block(
Block::default()
.title(" Headroom \u{00b7} % of configured cap ")
.borders(Borders::ALL)
.border_style(dim),
)
.x_axis(
Axis::default()
.bounds([0.0, x_max])
.labels([span_label.to_string(), "now".to_string()])
.style(dim),
)
.y_axis(
Axis::default()
.bounds([0.0, 100.0])
.labels(["0", "50", "100%"])
.style(dim),
);
f.render_widget(chart, area);
draw_chart_legend(
f,
area,
&[
(
Color::Yellow,
format!("active_traces {:.0}%", last_pct(&series.traces_pct)),
),
(
Color::LightBlue,
format!("analysis_queue {:.0}%", last_pct(&series.queue_pct)),
),
(
Color::Cyan,
format!("findings_store {:.0}%", last_pct(&series.findings_pct)),
),
(Color::Red, "advisor threshold 90%".to_string()),
],
);
}
fn fmt_span_secs(secs: u64) -> String {
if secs < 120 {
format!("{secs}s")
} else if secs < 7200 {
format!("{}m", secs / 60)
} else {
#[allow(clippy::cast_precision_loss)]
let hours = secs as f64 / 3600.0;
format!("{hours:.1}h")
}
}
fn warning_kind_style(kind: &str) -> Style {
use sentinel_core::report::warnings::{COLD_START, INGESTION_DROPS, TUNING};
match kind {
TUNING => Style::default().fg(Color::Yellow),
INGESTION_DROPS => Style::default().fg(Color::Red),
COLD_START => crate::tui::dim_style(),
_ => Style::default().fg(Color::Gray),
}
}
fn intensity_source_label(src: IntensitySource) -> &'static str {
match src {
IntensitySource::RealTime => "RealTime (hot)",
IntensitySource::MonthlyHourly => "MonthlyHourly (cold)",
IntensitySource::Hourly => "Hourly (cold)",
IntensitySource::Annual => "Annual (cold)",
}
}
fn fmt_tiny(v: f64) -> String {
let v = if v == 0.0 { 0.0 } else { v };
if v == 0.0 || v >= 1e-5 {
format!("{v:.6}")
} else {
format!("{v:.3e}")
}
}
fn truncate_cell(s: &str, max: usize) -> String {
let safe = sanitize_for_terminal(s);
if safe.chars().count() <= max {
return safe.into_owned();
}
let mut out: String = safe.chars().take(max.saturating_sub(1)).collect();
out.push('\u{2026}');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::line_text;
fn snapshot_with_warnings(warning_details: Vec<Warning>) -> Snapshot {
Snapshot {
green_summary: GreenSummary::disabled(0),
warning_details,
warnings: Vec::new(),
scrapers: None,
status: None,
config: None,
}
}
fn snapshot_with_energy_mix() -> Snapshot {
let green_summary: GreenSummary = serde_json::from_str(
r#"{
"total_io_ops":150,"avoidable_io_ops":30,"io_waste_ratio":0.2,
"io_waste_ratio_band":"moderate","top_offenders":[],
"energy_kwh":1.6,"energy_model":"scaphandre_rapl",
"per_service_energy_kwh":{"order-svc":1.2,"cart-svc":0.4},
"per_service_region":{"order-svc":"eu-west-3","cart-svc":"us-east-1"},
"per_service_energy_model":{"order-svc":"scaphandre_rapl","cart-svc":"io_proxy_v3"},
"per_service_measured_ratio":{"order-svc":0.92,"cart-svc":0.0},
"per_service_carbon_kgco2eq":{"order-svc":0.00005,"cart-svc":0.00012},
"regions":[
{"status":"known","region":"eu-west-3","grid_intensity_gco2_kwh":41.0,"pue":1.2,"io_ops":100,"co2_gco2":0.5,"intensity_source":"real_time","intensity_estimated":false},
{"status":"known","region":"us-east-1","grid_intensity_gco2_kwh":368.0,"pue":1.2,"io_ops":50,"co2_gco2":2.0,"intensity_source":"annual"}
]
}"#,
)
.unwrap();
Snapshot {
green_summary,
warning_details: Vec::new(),
warnings: Vec::new(),
scrapers: None,
status: None,
config: None,
}
}
fn full_status() -> StatusSlim {
StatusSlim {
active_traces: 62,
max_active_traces: 100,
analysis_queue_depth: 8,
analysis_queue_capacity: 256,
stored_findings: 410,
max_retained_findings: 1000,
}
}
#[test]
fn advisor_renders_warning_details() {
let snapshot = snapshot_with_warnings(vec![
Warning::new(
"tuning",
"raise [daemon] analysis_queue_capacity (currently 1024)",
),
Warning::new("ingestion_drops", "412 OTLP requests rejected"),
]);
let text = line_text(&build_advisor_lines(Some(&snapshot)));
assert!(text.contains("Settings advisor"), "got: {text}");
assert!(text.contains("[tuning]"), "got: {text}");
assert!(
text.contains("analysis_queue_capacity (currently 1024)"),
"got: {text}"
);
assert!(text.contains("[ingestion_drops]"), "got: {text}");
}
#[test]
fn advisor_empty_shows_no_hints() {
let snapshot = snapshot_with_warnings(Vec::new());
let text = line_text(&build_advisor_lines(Some(&snapshot)));
assert!(text.contains("No hints"), "got: {text}");
}
#[test]
fn advisor_waits_before_first_snapshot() {
let text = line_text(&build_advisor_lines(None));
assert!(
text.contains("Waiting for the first snapshot"),
"got: {text}"
);
}
#[test]
fn energy_renders_service_and_region_tables() {
let snapshot = snapshot_with_energy_mix();
let text = line_text(&build_energy_lines(Some(&snapshot)));
assert!(text.contains("Energy / carbon mix"), "got: {text}");
assert!(text.contains("By service"), "got: {text}");
assert!(text.contains("order-svc"), "got: {text}");
assert!(text.contains("scaphandre_rapl"), "got: {text}");
assert!(text.contains("eu-west-3"), "got: {text}");
assert!(text.contains("io_proxy_v3"), "got: {text}");
assert!(text.contains("By region"), "got: {text}");
assert!(text.contains("RealTime (hot)"), "got: {text}");
assert!(text.contains("Annual (cold)"), "got: {text}");
}
#[test]
fn energy_empty_when_green_disabled() {
let snapshot = snapshot_with_warnings(Vec::new());
let text = line_text(&build_energy_lines(Some(&snapshot)));
assert!(text.contains("No energy/carbon data"), "got: {text}");
}
#[test]
fn warning_kind_style_maps_kinds() {
assert_eq!(
warning_kind_style("tuning"),
Style::default().fg(Color::Yellow)
);
assert_eq!(
warning_kind_style("ingestion_drops"),
Style::default().fg(Color::Red)
);
assert_eq!(warning_kind_style("cold_start"), crate::tui::dim_style());
assert_eq!(
warning_kind_style("something_else"),
Style::default().fg(Color::Gray)
);
}
#[test]
fn fmt_tiny_switches_to_scientific_below_floor() {
assert_eq!(fmt_tiny(1.6), "1.600000");
assert_eq!(fmt_tiny(0.0), "0.000000");
assert_eq!(fmt_tiny(1e-5), "0.000010");
let tiny = fmt_tiny(3.2e-7);
assert!(tiny.contains('e'), "got: {tiny}");
assert!(!tiny.starts_with("0.000000"), "got: {tiny}");
}
#[test]
fn fmt_tiny_normalizes_negative_zero() {
assert_eq!(fmt_tiny(-0.0), "0.000000");
let empty: Vec<f64> = Vec::new();
let carbon: f64 = empty.iter().sum();
assert_eq!(fmt_tiny(carbon), "0.000000");
}
#[test]
fn energy_meas_dash_when_ratio_missing() {
let mut snapshot = snapshot_with_energy_mix();
snapshot
.green_summary
.per_service_measured_ratio
.remove("cart-svc");
let text = line_text(&build_energy_lines(Some(&snapshot)));
assert!(text.contains("92%"), "order-svc keeps its ratio: {text}");
let cart_row = text
.lines()
.find(|l| l.contains("cart-svc"))
.expect("cart-svc row");
assert!(!cart_row.contains('%'), "no fabricated 0%: {cart_row}");
assert!(cart_row.contains(" - "), "got: {cart_row}");
}
#[test]
fn intensity_source_label_tags_cold_and_hot() {
assert!(intensity_source_label(IntensitySource::RealTime).contains("hot"));
assert!(intensity_source_label(IntensitySource::Annual).contains("cold"));
assert!(intensity_source_label(IntensitySource::Hourly).contains("cold"));
assert!(intensity_source_label(IntensitySource::MonthlyHourly).contains("cold"));
}
#[test]
fn truncate_cell_caps_with_ellipsis() {
assert_eq!(truncate_cell("short", 10), "short");
let long = truncate_cell("a-very-long-service-name", 8);
assert_eq!(long.chars().count(), 8);
assert!(long.ends_with('\u{2026}'));
}
#[test]
fn tab_cycles_and_wraps() {
let mut state = MonitorState::new("http://localhost:4318".into(), 5);
assert_eq!(state.tab, Tab::Advisor);
state.cycle_tab(true);
assert_eq!(state.tab, Tab::Energy);
state.cycle_tab(true);
assert_eq!(state.tab, Tab::Trends);
state.cycle_tab(true);
assert_eq!(state.tab, Tab::Scrapers);
state.cycle_tab(true);
assert_eq!(state.tab, Tab::Config);
state.cycle_tab(true);
assert_eq!(state.tab, Tab::Advisor, "Tab wraps back");
state.cycle_tab(false);
assert_eq!(state.tab, Tab::Config, "Shift-Tab wraps the other way");
}
fn full_config() -> ConfigSlim {
let d = DaemonConfig::default();
ConfigSlim {
max_active_traces: d.max_active_traces,
trace_ttl_ms: d.trace_ttl_ms,
sampling_rate: d.sampling_rate,
environment: d.environment.as_str().to_string(),
listen_addr: d.listen_addr.clone(),
..Default::default()
}
}
#[test]
fn config_renders_params_with_defaults() {
let mut snapshot = snapshot_with_warnings(Vec::new());
snapshot.config = Some(full_config());
let text = line_text(&build_config_lines(Some(&snapshot)));
assert!(text.contains("Daemon configuration"), "got: {text}");
assert!(text.contains("max_active_traces ="), "got: {text}");
assert!(text.contains("environment ="), "got: {text}");
assert!(
text.contains("correlation.max_tracked_pairs ="),
"got: {text}"
);
let dline = text
.lines()
.find(|l| l.contains("max_active_traces ="))
.expect("max_active_traces row");
assert!(
!dline.contains("modified"),
"default value not modified: {dline}"
);
}
#[test]
fn config_flags_modified_params() {
let mut snapshot = snapshot_with_warnings(Vec::new());
let mut cfg = full_config();
cfg.trace_ttl_ms = 400; snapshot.config = Some(cfg);
let text = line_text(&build_config_lines(Some(&snapshot)));
let ttl = text
.lines()
.find(|l| l.contains("trace_ttl_ms ="))
.expect("trace_ttl_ms row");
assert!(ttl.contains("400"), "got: {ttl}");
assert!(ttl.contains("modified"), "non-default value flagged: {ttl}");
}
#[test]
fn config_degrades_when_endpoint_missing() {
let snapshot = snapshot_with_warnings(Vec::new());
let text = line_text(&build_config_lines(Some(&snapshot)));
assert!(text.contains("/api/config unavailable"), "got: {text}");
}
#[test]
fn config_never_shows_secret_values() {
let mut snapshot = snapshot_with_warnings(Vec::new());
let mut cfg = full_config();
cfg.ack_api_key_set = true;
cfg.tls_configured = true;
snapshot.config = Some(cfg);
let text = line_text(&build_config_lines(Some(&snapshot)));
assert!(text.contains("ack_api_key = set"), "got: {text}");
assert!(text.contains("tls = configured"), "got: {text}");
}
#[test]
fn config_sanitizes_daemon_controlled_strings() {
let mut snapshot = snapshot_with_warnings(Vec::new());
let mut cfg = full_config();
cfg.listen_addr = "0.0.0.0\u{1b}[31m\u{202e}evil".to_string();
cfg.environment = "prod\u{1b}[0m".to_string();
snapshot.config = Some(cfg);
let text = line_text(&build_config_lines(Some(&snapshot)));
assert!(!text.contains('\u{1b}'), "ANSI escape leaked: {text:?}");
assert!(!text.contains('\u{202e}'), "BiDi override leaked: {text:?}");
}
#[test]
fn unreachable_keeps_last_snapshot_and_flags_stale() {
let mut state = MonitorState::new("http://localhost:4318".into(), 5);
state.apply(FetchOutcome::Snapshot(Box::new(snapshot_with_warnings(
vec![Warning::new("tuning", "hint")],
))));
assert!(!state.stale);
assert!(state.latest.is_some());
state.apply(FetchOutcome::Unreachable);
assert!(state.stale, "stale flag set on failed poll");
assert!(
state.latest.is_some(),
"last good snapshot must stay on screen"
);
}
#[test]
fn handle_key_quits_cycles_and_scrolls() {
let mut state = MonitorState::new("http://localhost:4318".into(), 5);
state.apply(FetchOutcome::Snapshot(Box::new(snapshot_with_warnings(
vec![
Warning::new("tuning", "hint one"),
Warning::new("cold_start", "hint two"),
],
))));
state.tab = Tab::Advisor;
assert!(handle_key(&mut state, KeyCode::Char('q')));
assert!(handle_key(&mut state, KeyCode::Esc));
assert!(!handle_key(&mut state, KeyCode::Tab));
assert_eq!(state.tab, Tab::Energy);
state.tab = Tab::Advisor;
state.scroll = 0;
assert!(!handle_key(&mut state, KeyCode::Down));
assert_eq!(state.scroll, 1);
assert!(!handle_key(&mut state, KeyCode::Up));
assert_eq!(state.scroll, 0);
assert!(!handle_key(&mut state, KeyCode::Up));
assert_eq!(state.scroll, 0, "Up clamps at the top");
}
#[test]
fn transient_energy_failure_keeps_last_scraper_table() {
use sentinel_core::daemon::query_api::EnergyBackendStatus;
let mut state = MonitorState::new("http://localhost:4318".into(), 5);
let mut first = snapshot_with_warnings(Vec::new());
first.scrapers = Some(EnergyStatusResponse {
backends: vec![EnergyBackendStatus {
backend: "scaphandre".to_string(),
configured: true,
last_scrape_age_seconds: Some(1.0),
scrapes_ok: Some(10),
scrapes_failed: Some(0),
}],
});
state.apply(FetchOutcome::Snapshot(Box::new(first)));
state.apply(FetchOutcome::Snapshot(Box::new(snapshot_with_warnings(
Vec::new(),
))));
let scrapers = state
.latest
.as_ref()
.and_then(|s| s.scrapers.as_ref())
.expect("previous scraper table carried forward");
assert_eq!(scrapers.backends.len(), 1);
assert!(!state.stale, "a good report tick is not stale");
}
#[test]
fn advisor_falls_back_to_legacy_warnings() {
let mut snapshot = snapshot_with_warnings(Vec::new());
snapshot.warnings = vec!["legacy warning text".to_string()];
let text = line_text(&build_advisor_lines(Some(&snapshot)));
assert!(text.contains("legacy warning text"), "got: {text}");
assert!(!text.contains("No hints"), "got: {text}");
}
#[test]
fn energy_service_rows_align_with_header() {
let snapshot = snapshot_with_energy_mix();
let text = line_text(&build_energy_lines(Some(&snapshot)));
let lines: Vec<&str> = text.lines().collect();
let header_idx = lines
.iter()
.position(|l| l.contains("kWh") && l.contains("meas%"))
.expect("service table header");
let header = lines[header_idx];
let row = lines[header_idx + 1];
let h_kwh_end = header.find("kWh").expect("kWh in header") + 3;
let row_prefix = row.get(..h_kwh_end).unwrap_or(row);
assert!(
!row_prefix.ends_with(' '),
"kWh value must right-align under its header label:\nH: {header}\nR: {row}"
);
}
#[test]
fn scrapers_renders_backend_rows() {
use sentinel_core::daemon::query_api::EnergyBackendStatus;
let mut snapshot = snapshot_with_warnings(Vec::new());
snapshot.scrapers = Some(EnergyStatusResponse {
backends: vec![
EnergyBackendStatus {
backend: "scaphandre".to_string(),
configured: true,
last_scrape_age_seconds: Some(3.0),
scrapes_ok: Some(120),
scrapes_failed: Some(2),
},
EnergyBackendStatus {
backend: "kepler".to_string(),
configured: false,
last_scrape_age_seconds: None,
scrapes_ok: None,
scrapes_failed: None,
},
],
});
let text = line_text(&build_scrapers_lines(Some(&snapshot)));
assert!(text.contains("Energy scrapers"), "got: {text}");
assert!(text.contains("scaphandre"), "got: {text}");
assert!(text.contains("120"), "got: {text}");
assert!(text.contains("yes"), "got: {text}");
assert!(text.contains("kepler"), "got: {text}");
assert!(text.contains("no"), "got: {text}");
assert!(text.contains('-'), "got: {text}");
}
#[test]
fn scrapers_degrades_when_endpoint_missing() {
let snapshot = snapshot_with_warnings(Vec::new());
let text = line_text(&build_scrapers_lines(Some(&snapshot)));
assert!(text.contains("/api/energy unavailable"), "got: {text}");
}
#[test]
fn scrapers_waits_before_first_snapshot() {
let text = line_text(&build_scrapers_lines(None));
assert!(
text.contains("Waiting for the first snapshot"),
"got: {text}"
);
}
#[test]
fn trend_history_caps_at_capacity() {
let mut state = MonitorState::new("http://localhost:4318".into(), 5);
for _ in 0..(TREND_CAPACITY + 10) {
state.apply(FetchOutcome::Snapshot(Box::new(snapshot_with_warnings(
Vec::new(),
))));
}
assert_eq!(state.history.len(), TREND_CAPACITY);
}
#[test]
fn trend_point_computes_percentages() {
let mut snapshot = snapshot_with_energy_mix();
snapshot.status = Some(full_status());
let p = trend_point(&snapshot);
assert_eq!(p.traces_pct, Some(62.0));
assert!((p.queue_pct.unwrap() - 3.125).abs() < 1e-9);
assert_eq!(p.findings_pct, Some(41.0));
assert!((p.carbon_gco2 - 2.5).abs() < 1e-9, "got {}", p.carbon_gco2);
assert!((p.energy_kwh - 1.6).abs() < 1e-9);
}
#[test]
fn trend_point_clamps_gauge_over_cap_to_100() {
let mut snapshot = snapshot_with_warnings(Vec::new());
let mut status = full_status();
status.active_traces = 150;
status.max_active_traces = 100;
snapshot.status = Some(status);
let p = trend_point(&snapshot);
assert_eq!(p.traces_pct, Some(100.0));
}
#[test]
fn trend_point_suppresses_ratio_on_zero_cap() {
let mut snapshot = snapshot_with_warnings(Vec::new());
let mut status = full_status();
status.max_active_traces = 0;
status.analysis_queue_capacity = 0;
status.max_retained_findings = 0;
snapshot.status = Some(status);
let p = trend_point(&snapshot);
assert_eq!(p.traces_pct, None);
assert_eq!(p.queue_pct, None);
assert_eq!(p.findings_pct, None);
}
#[test]
fn trend_point_clamps_negative_queue_depth() {
let mut snapshot = snapshot_with_warnings(Vec::new());
let mut status = full_status();
status.analysis_queue_depth = -3;
snapshot.status = Some(status);
let p = trend_point(&snapshot);
assert_eq!(p.queue_pct, Some(0.0));
}
#[test]
fn trend_series_keeps_x_aligned_across_missing_status() {
let mut state = MonitorState::new("http://localhost:4318".into(), 5);
let mut first = snapshot_with_warnings(Vec::new());
first.status = Some(full_status());
state.apply(FetchOutcome::Snapshot(Box::new(first)));
state.apply(FetchOutcome::Snapshot(Box::new(snapshot_with_warnings(
Vec::new(),
))));
let mut third = snapshot_with_warnings(Vec::new());
third.status = Some(full_status());
state.apply(FetchOutcome::Snapshot(Box::new(third)));
let series = build_trend_series(&state.history);
assert_eq!(series.energy.len(), 3);
assert_eq!(series.traces_pct.len(), 2, "middle tick lacks status");
#[allow(clippy::cast_possible_truncation)]
let xs: Vec<i64> = series.traces_pct.iter().map(|p| p.0 as i64).collect();
assert_eq!(xs, vec![0, 2], "x is the global tick index");
}
#[test]
fn status_slim_parses_old_daemon_payload() {
let old =
r#"{"version":"0.8.7","uptime_seconds":12,"active_traces":4,"stored_findings":7}"#;
let parsed: StatusSlim = serde_json::from_str(old).unwrap();
assert_eq!(parsed.active_traces, 4);
assert_eq!(parsed.max_active_traces, 0);
assert_eq!(parsed.analysis_queue_capacity, 0);
assert_eq!(parsed.max_retained_findings, 0);
}
#[test]
fn fmt_span_secs_picks_compact_unit() {
assert_eq!(fmt_span_secs(90), "90s");
assert_eq!(fmt_span_secs(600), "10m");
assert_eq!(fmt_span_secs(7200), "2.0h");
}
}