//! Audit screen. //! //! Renders a `DeviceInventory`: host header + per-device table. The //! contract is "show the operator everything the kernel can tell us //! about this box without running a single kernel on a GPU." Sister //! screens (live ops, multi-box, incident response) come later. use std::time::{Duration, SystemTime}; use everycycle_hal::{DeviceInventory, DeviceKind, EnumeratedDevice, HwmonSensor}; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}; /// Mutable state the audit screen carries between frames. #[derive(Debug, Default)] pub struct AuditState { pub inventory: Option, pub error: Option, pub table: TableState, } impl AuditState { /// Re-run enumeration and update state. Either fills `inventory` /// on success or `error` on failure. pub fn refresh(&mut self) { match everycycle_hal::enumerate(everycycle_hal::InventoryConfig { include_non_compute: true, }) { Ok(inv) => { self.inventory = Some(inv); self.error = None; self.clamp_selection(); } Err(e) => { self.error = Some(e.to_string()); } } } /// Move selection down one row. No-op when already at the last /// row or when the inventory is empty. pub fn select_next(&mut self) { let Some(len) = self.row_count() else { return }; if len == 0 { return; } let next = self.table.selected().map_or(0, |i| (i + 1).min(len - 1)); self.table.select(Some(next)); } /// Move selection up one row. pub fn select_prev(&mut self) { let Some(len) = self.row_count() else { return }; if len == 0 { return; } let next = self.table.selected().map_or(0, |i| i.saturating_sub(1)); self.table.select(Some(next)); } fn row_count(&self) -> Option { self.inventory.as_ref().map(|i| i.devices.len()) } fn clamp_selection(&mut self) { if let Some(len) = self.row_count() { if len == 0 { self.table.select(None); } else if self.table.selected().is_none() { self.table.select(Some(0)); } else if let Some(i) = self.table.selected() && i >= len { self.table.select(Some(len - 1)); } } } } /// Render the whole audit screen to `frame`. pub fn render(frame: &mut Frame, area: Rect, state: &mut AuditState) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // title bar Constraint::Length(6), // host pane Constraint::Min(5), // device table Constraint::Length(1), // footer ]) .split(area); render_title(frame, chunks[0]); render_host(frame, chunks[1], state); render_devices(frame, chunks[2], state); render_footer(frame, chunks[3], state); } fn render_title(frame: &mut Frame, area: Rect) { let line = Line::from(vec![ Span::styled( "EveryCycle", Style::default() .fg(Color::White) .add_modifier(Modifier::BOLD), ), Span::raw(" "), Span::styled("audit", Style::default().fg(Color::Gray)), ]); frame.render_widget(Paragraph::new(line), area); } fn render_host(frame: &mut Frame, area: Rect, state: &AuditState) { let block = Block::default() .borders(Borders::ALL) .title(" host ") .title_style(Style::default().fg(Color::Gray)); if let Some(err) = &state.error { let p = Paragraph::new(Line::from(vec![ Span::styled("error: ", Style::default().fg(Color::Red)), Span::raw(err.clone()), ])) .block(block); frame.render_widget(p, area); return; } let Some(inv) = &state.inventory else { frame.render_widget(Paragraph::new("no inventory yet").block(block), area); return; }; let probed = humanize_age(inv.probed_at); let lines = vec![ labelled("hostname", &inv.host.hostname), labelled("kernel", &inv.host.kernel_release), labelled("cpu", &inv.host.cpu_model), labelled("probed", &probed), ]; frame.render_widget(Paragraph::new(lines).block(block), area); } fn labelled<'a>(label: &'a str, value: &'a str) -> Line<'a> { Line::from(vec![ Span::styled( format!("{label:>9}: "), Style::default().fg(Color::DarkGray), ), Span::raw(value), ]) } fn humanize_age(t: SystemTime) -> String { let now = SystemTime::now(); let age = now.duration_since(t).unwrap_or(Duration::ZERO); let s = age.as_secs(); if s < 2 { "just now".to_owned() } else if s < 60 { format!("{s} seconds ago") } else if s < 3600 { format!("{} minutes ago", s / 60) } else { format!("{} hours ago", s / 3600) } } const HEADER_CELLS: &[&str] = &[ "bus address", "kind", "vendor:device", "driver", "numa", "iommu", "drm", "hwmon", ]; fn render_devices(frame: &mut Frame, area: Rect, state: &mut AuditState) { let block = Block::default() .borders(Borders::ALL) .title(" devices ") .title_style(Style::default().fg(Color::Gray)); let Some(inv) = &state.inventory else { frame.render_widget(Paragraph::new("no devices enumerated").block(block), area); return; }; if inv.devices.is_empty() { frame.render_widget( Paragraph::new( "no PCI devices found. either this is a non-Linux host or \ /sys/bus/pci is unavailable.", ) .block(block), area, ); return; } let header = Row::new( HEADER_CELLS .iter() .map(|h| Cell::from(*h).style(Style::default().fg(Color::DarkGray))), ) .height(1); let rows: Vec = inv.devices.iter().map(device_row).collect(); let widths = [ Constraint::Length(13), Constraint::Length(14), Constraint::Length(11), Constraint::Length(12), Constraint::Length(5), Constraint::Length(6), Constraint::Length(8), Constraint::Min(10), ]; let table = Table::new(rows, widths) .header(header) .block(block) .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .column_spacing(2); frame.render_stateful_widget(table, area, &mut state.table); } fn device_row(d: &EnumeratedDevice) -> Row<'static> { let kind_style = match d.kind { DeviceKind::ComputeGpu => Style::default() .fg(Color::Green) .add_modifier(Modifier::BOLD), DeviceKind::Accelerator => Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), DeviceKind::VgaController => Style::default().fg(Color::Yellow), DeviceKind::OtherDisplay => Style::default().fg(Color::Gray), DeviceKind::Other => Style::default().fg(Color::DarkGray), }; Row::new(vec![ Cell::from(d.bus_address.to_string()), Cell::from(kind_label(d.kind)).style(kind_style), Cell::from(format!("{:04x}:{:04x}", d.ids.vendor, d.ids.device)), Cell::from(d.driver.clone().unwrap_or_else(|| "-".to_owned())), Cell::from( d.numa_node .map_or_else(|| "-".to_owned(), |n| n.to_string()), ), Cell::from( d.iommu_group .map_or_else(|| "-".to_owned(), |g| g.to_string()), ), Cell::from(drm_label(d)), Cell::from(hwmon_label(&d.hwmon)), ]) } const fn kind_label(k: DeviceKind) -> &'static str { match k { DeviceKind::ComputeGpu => "compute-gpu", DeviceKind::VgaController => "vga", DeviceKind::OtherDisplay => "display", DeviceKind::Accelerator => "accelerator", DeviceKind::Other => "other", } } fn drm_label(d: &EnumeratedDevice) -> String { let card = d .drm_node .as_ref() .and_then(|p| p.file_name()) .and_then(|n| n.to_str()) .unwrap_or("-"); let render = d .render_node .as_ref() .and_then(|p| p.file_name()) .and_then(|n| n.to_str()); match render { Some(r) => format!("{card}/{r}"), None => card.to_owned(), } } fn hwmon_label(sensors: &[HwmonSensor]) -> String { match sensors { [] => "-".to_owned(), [one] => one.name.clone(), many => format!("{} ({})", many[0].name, many.len()), } } fn render_footer(frame: &mut Frame, area: Rect, state: &AuditState) { let (total, compute) = state.inventory.as_ref().map_or((0, 0), |inv| { let total = inv.devices.len(); let compute = inv .devices .iter() .filter(|d| d.kind.is_compute_candidate()) .count(); (total, compute) }); let line = Line::from(vec![ Span::styled( format!("{total} devices"), Style::default().fg(Color::White), ), Span::raw(" "), Span::styled( format!("{compute} compute candidates"), Style::default().fg(Color::Green), ), Span::raw(" "), Span::styled("q", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" quit "), Span::styled("r", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" refresh "), Span::styled("j/k", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" scroll"), ]); frame.render_widget(Paragraph::new(line), area); } #[cfg(test)] mod tests { use super::*; #[test] fn kind_labels_match_variants() { assert_eq!(kind_label(DeviceKind::ComputeGpu), "compute-gpu"); assert_eq!(kind_label(DeviceKind::VgaController), "vga"); assert_eq!(kind_label(DeviceKind::Accelerator), "accelerator"); } #[test] fn humanize_age_buckets() { let now = SystemTime::now(); assert_eq!(humanize_age(now), "just now"); assert!(humanize_age(now - Duration::from_secs(30)).contains("seconds")); assert!(humanize_age(now - Duration::from_secs(120)).contains("minutes")); assert!(humanize_age(now - Duration::from_secs(7200)).contains("hours")); } #[test] fn selection_movement_no_inventory() { let mut s = AuditState::default(); s.select_next(); s.select_prev(); assert!(s.table.selected().is_none()); } }