Skip to main content

max / everycycle

wire tui audit screen against hal inventory Closes the last Phase 0 sketch: a single-screen ratatui dashboard that enumerates every PCI device the kernel can see and lays it out cleanly. Thread G's brand moment in v0 form. - crates/tui/Cargo.toml: ratatui 0.30 (crossterm_0_29 feature) and a bin target. - crates/tui/src/audit.rs: AuditState (inventory + error + table selection), render() laying out title / host pane / device table / footer. Kind column colored by classification; compute candidates counted in the footer. - crates/tui/src/main.rs: terminal init/restore, 250 ms poll, key bindings (q/Esc/Ctrl-C quit, r refresh, j/k or arrows scroll). - todo.md: Phase 0 TUI item marked done. cargo fmt + cargo clippy --all-targets --all-features -- -D warnings + cargo test --workspace all clean (13 tests).
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-21 15:29 UTC
Signed with PGP, not checked
Commit: e3cc515ba41ae1a31631763b30901fd5046ac27b
Parent: 6ad8ae0
4 files changed, +422 insertions, -0 deletions
@@ -8,5 +8,13 @@
8 8 authors.workspace = true
9 9 repository.workspace = true
10 10
11 + [dependencies]
12 + everycycle-hal = { path = "../hal" }
13 + ratatui = { version = "0.30", features = ["crossterm_0_29"] }
14 +
15 + [[bin]]
16 + name = "everycycle-tui"
17 + path = "src/main.rs"
18 +
11 19 [lints]
12 20 workspace = true
@@ -5,3 +5,11 @@
5 5 //! Live view of fleet state, per-box queues, model warmth, GPU/CPU/memory
6 6 //! utilization, thermal headroom. The structured-telemetry counterpart of the
7 7 //! Prometheus and OpenTelemetry exporters.
8 + //!
9 + //! Today: one screen — the audit. The brand-moment view that says "this
10 + //! runtime understands your hardware" by enumerating every PCI device the
11 + //! kernel can see and laying it out cleanly. Subsequent Thread G screens
12 + //! (live ops dashboard, multi-box view, incident response) will land as
13 + //! sibling modules.
14 +
15 + pub mod audit;
@@ -1,0 +1,360 @@
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 + }
@@ -1,0 +1,46 @@
1 + use std::io;
2 + use std::time::Duration;
3 +
4 + use everycycle_tui::audit::{AuditState, render};
5 + use ratatui::DefaultTerminal;
6 + use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
7 +
8 + fn main() -> io::Result<()> {
9 + let mut terminal = ratatui::init();
10 + let result = run(&mut terminal);
11 + ratatui::restore();
12 + result
13 + }
14 +
15 + fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
16 + let mut state = AuditState::default();
17 + state.refresh();
18 +
19 + loop {
20 + terminal.draw(|frame| render(frame, frame.area(), &mut state))?;
21 +
22 + // Block for up to 250 ms so the screen redraws periodically even
23 + // without input. The audit screen does not animate today; this
24 + // is forward-compat for the live-ops screen that will replace
25 + // it as the default landing surface.
26 + if !event::poll(Duration::from_millis(250))? {
27 + continue;
28 + }
29 +
30 + let Event::Key(key) = event::read()? else {
31 + continue;
32 + };
33 + if key.kind != KeyEventKind::Press {
34 + continue;
35 + }
36 +
37 + match (key.code, key.modifiers) {
38 + (KeyCode::Char('q' | 'Q') | KeyCode::Esc, _)
39 + | (KeyCode::Char('c' | 'C'), KeyModifiers::CONTROL) => return Ok(()),
40 + (KeyCode::Char('r' | 'R'), _) => state.refresh(),
41 + (KeyCode::Char('j') | KeyCode::Down, _) => state.select_next(),
42 + (KeyCode::Char('k') | KeyCode::Up, _) => state.select_prev(),
43 + _ => {}
44 + }
45 + }
46 + }