Skip to main content

max / everycycle

10.7 KB · 361 lines History Blame Raw
1 //! Audit screen.
2 //!
3 //! Renders a `DeviceInventory`: host header + per-device table. The
4 //! contract is "show the operator everything the kernel can tell us
5 //! about this box without running a single kernel on a GPU." Sister
6 //! screens (live ops, multi-box, incident response) come later.
7
8 use std::time::{Duration, SystemTime};
9
10 use everycycle_hal::{DeviceInventory, DeviceKind, EnumeratedDevice, HwmonSensor};
11 use ratatui::Frame;
12 use ratatui::layout::{Constraint, Direction, Layout, Rect};
13 use ratatui::style::{Color, Modifier, Style};
14 use ratatui::text::{Line, Span};
15 use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState};
16
17 /// Mutable state the audit screen carries between frames.
18 #[derive(Debug, Default)]
19 pub struct AuditState {
20 pub inventory: Option<DeviceInventory>,
21 pub error: Option<String>,
22 pub table: TableState,
23 }
24
25 impl AuditState {
26 /// Re-run enumeration and update state. Either fills `inventory`
27 /// on success or `error` on failure.
28 pub fn refresh(&mut self) {
29 match everycycle_hal::enumerate(everycycle_hal::InventoryConfig {
30 include_non_compute: true,
31 }) {
32 Ok(inv) => {
33 self.inventory = Some(inv);
34 self.error = None;
35 self.clamp_selection();
36 }
37 Err(e) => {
38 self.error = Some(e.to_string());
39 }
40 }
41 }
42
43 /// Move selection down one row. No-op when already at the last
44 /// row or when the inventory is empty.
45 pub fn select_next(&mut self) {
46 let Some(len) = self.row_count() else { return };
47 if len == 0 {
48 return;
49 }
50 let next = self.table.selected().map_or(0, |i| (i + 1).min(len - 1));
51 self.table.select(Some(next));
52 }
53
54 /// Move selection up one row.
55 pub fn select_prev(&mut self) {
56 let Some(len) = self.row_count() else { return };
57 if len == 0 {
58 return;
59 }
60 let next = self.table.selected().map_or(0, |i| i.saturating_sub(1));
61 self.table.select(Some(next));
62 }
63
64 fn row_count(&self) -> Option<usize> {
65 self.inventory.as_ref().map(|i| i.devices.len())
66 }
67
68 fn clamp_selection(&mut self) {
69 if let Some(len) = self.row_count() {
70 if len == 0 {
71 self.table.select(None);
72 } else if self.table.selected().is_none() {
73 self.table.select(Some(0));
74 } else if let Some(i) = self.table.selected()
75 && i >= len
76 {
77 self.table.select(Some(len - 1));
78 }
79 }
80 }
81 }
82
83 /// Render the whole audit screen to `frame`.
84 pub fn render(frame: &mut Frame, area: Rect, state: &mut AuditState) {
85 let chunks = Layout::default()
86 .direction(Direction::Vertical)
87 .constraints([
88 Constraint::Length(1), // title bar
89 Constraint::Length(6), // host pane
90 Constraint::Min(5), // device table
91 Constraint::Length(1), // footer
92 ])
93 .split(area);
94
95 render_title(frame, chunks[0]);
96 render_host(frame, chunks[1], state);
97 render_devices(frame, chunks[2], state);
98 render_footer(frame, chunks[3], state);
99 }
100
101 fn render_title(frame: &mut Frame, area: Rect) {
102 let line = Line::from(vec![
103 Span::styled(
104 "EveryCycle",
105 Style::default()
106 .fg(Color::White)
107 .add_modifier(Modifier::BOLD),
108 ),
109 Span::raw(" "),
110 Span::styled("audit", Style::default().fg(Color::Gray)),
111 ]);
112 frame.render_widget(Paragraph::new(line), area);
113 }
114
115 fn render_host(frame: &mut Frame, area: Rect, state: &AuditState) {
116 let block = Block::default()
117 .borders(Borders::ALL)
118 .title(" host ")
119 .title_style(Style::default().fg(Color::Gray));
120
121 if let Some(err) = &state.error {
122 let p = Paragraph::new(Line::from(vec![
123 Span::styled("error: ", Style::default().fg(Color::Red)),
124 Span::raw(err.clone()),
125 ]))
126 .block(block);
127 frame.render_widget(p, area);
128 return;
129 }
130
131 let Some(inv) = &state.inventory else {
132 frame.render_widget(Paragraph::new("no inventory yet").block(block), area);
133 return;
134 };
135
136 let probed = humanize_age(inv.probed_at);
137 let lines = vec![
138 labelled("hostname", &inv.host.hostname),
139 labelled("kernel", &inv.host.kernel_release),
140 labelled("cpu", &inv.host.cpu_model),
141 labelled("probed", &probed),
142 ];
143 frame.render_widget(Paragraph::new(lines).block(block), area);
144 }
145
146 fn labelled<'a>(label: &'a str, value: &'a str) -> Line<'a> {
147 Line::from(vec![
148 Span::styled(
149 format!("{label:>9}: "),
150 Style::default().fg(Color::DarkGray),
151 ),
152 Span::raw(value),
153 ])
154 }
155
156 fn humanize_age(t: SystemTime) -> String {
157 let now = SystemTime::now();
158 let age = now.duration_since(t).unwrap_or(Duration::ZERO);
159 let s = age.as_secs();
160 if s < 2 {
161 "just now".to_owned()
162 } else if s < 60 {
163 format!("{s} seconds ago")
164 } else if s < 3600 {
165 format!("{} minutes ago", s / 60)
166 } else {
167 format!("{} hours ago", s / 3600)
168 }
169 }
170
171 const HEADER_CELLS: &[&str] = &[
172 "bus address",
173 "kind",
174 "vendor:device",
175 "driver",
176 "numa",
177 "iommu",
178 "drm",
179 "hwmon",
180 ];
181
182 fn render_devices(frame: &mut Frame, area: Rect, state: &mut AuditState) {
183 let block = Block::default()
184 .borders(Borders::ALL)
185 .title(" devices ")
186 .title_style(Style::default().fg(Color::Gray));
187
188 let Some(inv) = &state.inventory else {
189 frame.render_widget(Paragraph::new("no devices enumerated").block(block), area);
190 return;
191 };
192
193 if inv.devices.is_empty() {
194 frame.render_widget(
195 Paragraph::new(
196 "no PCI devices found. either this is a non-Linux host or \
197 /sys/bus/pci is unavailable.",
198 )
199 .block(block),
200 area,
201 );
202 return;
203 }
204
205 let header = Row::new(
206 HEADER_CELLS
207 .iter()
208 .map(|h| Cell::from(*h).style(Style::default().fg(Color::DarkGray))),
209 )
210 .height(1);
211
212 let rows: Vec<Row> = inv.devices.iter().map(device_row).collect();
213
214 let widths = [
215 Constraint::Length(13),
216 Constraint::Length(14),
217 Constraint::Length(11),
218 Constraint::Length(12),
219 Constraint::Length(5),
220 Constraint::Length(6),
221 Constraint::Length(8),
222 Constraint::Min(10),
223 ];
224
225 let table = Table::new(rows, widths)
226 .header(header)
227 .block(block)
228 .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED))
229 .column_spacing(2);
230
231 frame.render_stateful_widget(table, area, &mut state.table);
232 }
233
234 fn device_row(d: &EnumeratedDevice) -> Row<'static> {
235 let kind_style = match d.kind {
236 DeviceKind::ComputeGpu => Style::default()
237 .fg(Color::Green)
238 .add_modifier(Modifier::BOLD),
239 DeviceKind::Accelerator => Style::default()
240 .fg(Color::Cyan)
241 .add_modifier(Modifier::BOLD),
242 DeviceKind::VgaController => Style::default().fg(Color::Yellow),
243 DeviceKind::OtherDisplay => Style::default().fg(Color::Gray),
244 DeviceKind::Other => Style::default().fg(Color::DarkGray),
245 };
246
247 Row::new(vec![
248 Cell::from(d.bus_address.to_string()),
249 Cell::from(kind_label(d.kind)).style(kind_style),
250 Cell::from(format!("{:04x}:{:04x}", d.ids.vendor, d.ids.device)),
251 Cell::from(d.driver.clone().unwrap_or_else(|| "-".to_owned())),
252 Cell::from(
253 d.numa_node
254 .map_or_else(|| "-".to_owned(), |n| n.to_string()),
255 ),
256 Cell::from(
257 d.iommu_group
258 .map_or_else(|| "-".to_owned(), |g| g.to_string()),
259 ),
260 Cell::from(drm_label(d)),
261 Cell::from(hwmon_label(&d.hwmon)),
262 ])
263 }
264
265 const fn kind_label(k: DeviceKind) -> &'static str {
266 match k {
267 DeviceKind::ComputeGpu => "compute-gpu",
268 DeviceKind::VgaController => "vga",
269 DeviceKind::OtherDisplay => "display",
270 DeviceKind::Accelerator => "accelerator",
271 DeviceKind::Other => "other",
272 }
273 }
274
275 fn drm_label(d: &EnumeratedDevice) -> String {
276 let card = d
277 .drm_node
278 .as_ref()
279 .and_then(|p| p.file_name())
280 .and_then(|n| n.to_str())
281 .unwrap_or("-");
282 let render = d
283 .render_node
284 .as_ref()
285 .and_then(|p| p.file_name())
286 .and_then(|n| n.to_str());
287 match render {
288 Some(r) => format!("{card}/{r}"),
289 None => card.to_owned(),
290 }
291 }
292
293 fn hwmon_label(sensors: &[HwmonSensor]) -> String {
294 match sensors {
295 [] => "-".to_owned(),
296 [one] => one.name.clone(),
297 many => format!("{} ({})", many[0].name, many.len()),
298 }
299 }
300
301 fn render_footer(frame: &mut Frame, area: Rect, state: &AuditState) {
302 let (total, compute) = state.inventory.as_ref().map_or((0, 0), |inv| {
303 let total = inv.devices.len();
304 let compute = inv
305 .devices
306 .iter()
307 .filter(|d| d.kind.is_compute_candidate())
308 .count();
309 (total, compute)
310 });
311
312 let line = Line::from(vec![
313 Span::styled(
314 format!("{total} devices"),
315 Style::default().fg(Color::White),
316 ),
317 Span::raw(" "),
318 Span::styled(
319 format!("{compute} compute candidates"),
320 Style::default().fg(Color::Green),
321 ),
322 Span::raw(" "),
323 Span::styled("q", Style::default().add_modifier(Modifier::BOLD)),
324 Span::raw(" quit "),
325 Span::styled("r", Style::default().add_modifier(Modifier::BOLD)),
326 Span::raw(" refresh "),
327 Span::styled("j/k", Style::default().add_modifier(Modifier::BOLD)),
328 Span::raw(" scroll"),
329 ]);
330 frame.render_widget(Paragraph::new(line), area);
331 }
332
333 #[cfg(test)]
334 mod tests {
335 use super::*;
336
337 #[test]
338 fn kind_labels_match_variants() {
339 assert_eq!(kind_label(DeviceKind::ComputeGpu), "compute-gpu");
340 assert_eq!(kind_label(DeviceKind::VgaController), "vga");
341 assert_eq!(kind_label(DeviceKind::Accelerator), "accelerator");
342 }
343
344 #[test]
345 fn humanize_age_buckets() {
346 let now = SystemTime::now();
347 assert_eq!(humanize_age(now), "just now");
348 assert!(humanize_age(now - Duration::from_secs(30)).contains("seconds"));
349 assert!(humanize_age(now - Duration::from_secs(120)).contains("minutes"));
350 assert!(humanize_age(now - Duration::from_secs(7200)).contains("hours"));
351 }
352
353 #[test]
354 fn selection_movement_no_inventory() {
355 let mut s = AuditState::default();
356 s.select_next();
357 s.select_prev();
358 assert!(s.table.selected().is_none());
359 }
360 }
361