Skip to main content

max / mountaineer-sysop

Add sysop storage -- ZFS pool health view with mock fallback Mirrors sysop services shape: Backend trait, Zfs (zpool list -H) and Mock implementations, auto-detect. TUI list shows pool, state, alloc/size/capacity. --summary emits 'ok' or 'degraded' for the yambar zfs segment. The mountaineer/todo.md surfaces-and-sysop doctrine now has both yambar identity segments (svc, zfs) wired to real sysop producers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-18 01:54 UTC
Commit: 8bb40cf7cd522e5d44101f7da2bfbf9968b4ab33
Parent: dbc1705
3 files changed, +231 insertions, -2 deletions
@@ -3,6 +3,7 @@ mod info;
3 3 mod motd;
4 4 mod roles;
5 5 mod services;
6 + mod storage;
6 7
7 8 use clap::{Parser, Subcommand};
8 9
@@ -31,6 +32,12 @@ enum Command {
31 32 #[arg(long)]
32 33 summary: bool,
33 34 },
35 + /// ZFS pool health view
36 + Storage {
37 + /// One-line summary for the yambar `zfs` segment
38 + #[arg(long)]
39 + summary: bool,
40 + },
34 41 }
35 42
36 43 #[derive(Subcommand)]
@@ -54,6 +61,7 @@ fn main() -> anyhow::Result<()> {
54 61 Some(MotdAction::Print) => motd::print()?,
55 62 },
56 63 Command::Services { summary } => services::run(summary)?,
64 + Command::Storage { summary } => storage::run(summary)?,
57 65 };
58 66 std::process::exit(code);
59 67 }
@@ -0,0 +1,215 @@
1 + use anyhow::{Context, Result};
2 + use crossterm::event::{self, Event, KeyCode};
3 + use ratatui::style::Style;
4 + use ratatui::text::{Line, Span};
5 + use ratatui::widgets::Paragraph;
6 + use std::process::Command;
7 + use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style};
8 +
9 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
10 + pub enum State {
11 + Online,
12 + Degraded,
13 + Faulted,
14 + Offline,
15 + Unavail,
16 + Suspended,
17 + }
18 +
19 + impl State {
20 + fn parse(s: &str) -> Self {
21 + match s {
22 + "ONLINE" => State::Online,
23 + "DEGRADED" => State::Degraded,
24 + "FAULTED" => State::Faulted,
25 + "OFFLINE" => State::Offline,
26 + "UNAVAIL" => State::Unavail,
27 + "SUSPENDED" => State::Suspended,
28 + _ => State::Faulted,
29 + }
30 + }
31 +
32 + fn label(&self) -> &'static str {
33 + match self {
34 + State::Online => "ONLINE",
35 + State::Degraded => "DEGRADED",
36 + State::Faulted => "FAULTED",
37 + State::Offline => "OFFLINE",
38 + State::Unavail => "UNAVAIL",
39 + State::Suspended => "SUSPENDED",
40 + }
41 + }
42 +
43 + fn span(&self) -> Span<'static> {
44 + match self {
45 + State::Online => style::ok(self.label()),
46 + State::Degraded => style::warn(self.label()),
47 + State::Faulted | State::Unavail | State::Suspended => style::fault(self.label()),
48 + State::Offline => style::inactive(self.label()),
49 + }
50 + }
51 +
52 + fn is_healthy(&self) -> bool {
53 + matches!(self, State::Online)
54 + }
55 + }
56 +
57 + #[derive(Debug, Clone)]
58 + pub struct Pool {
59 + pub name: String,
60 + pub state: State,
61 + pub size: String,
62 + pub alloc: String,
63 + pub capacity_pct: String,
64 + }
65 +
66 + pub trait Backend {
67 + fn list(&self) -> Result<Vec<Pool>>;
68 + }
69 +
70 + fn detect() -> Box<dyn Backend> {
71 + if Zfs.probe().is_ok() {
72 + Box::new(Zfs)
73 + } else {
74 + Box::new(Mock)
75 + }
76 + }
77 +
78 + struct Zfs;
79 +
80 + impl Zfs {
81 + fn probe(&self) -> Result<()> {
82 + let out = Command::new("zpool")
83 + .arg("-V")
84 + .output()
85 + .context("invoke zpool")?;
86 + if out.status.success() {
87 + Ok(())
88 + } else {
89 + anyhow::bail!("zpool unresponsive")
90 + }
91 + }
92 + }
93 +
94 + impl Backend for Zfs {
95 + fn list(&self) -> Result<Vec<Pool>> {
96 + let out = Command::new("zpool")
97 + .args(["list", "-H", "-o", "name,health,size,alloc,capacity"])
98 + .output()
99 + .context("zpool list")?;
100 + if !out.status.success() {
101 + anyhow::bail!("zpool list failed");
102 + }
103 + let mut pools = Vec::new();
104 + for line in String::from_utf8_lossy(&out.stdout).lines() {
105 + let cols: Vec<&str> = line.split('\t').collect();
106 + if cols.len() < 5 {
107 + continue;
108 + }
109 + pools.push(Pool {
110 + name: cols[0].to_string(),
111 + state: State::parse(cols[1]),
112 + size: cols[2].to_string(),
113 + alloc: cols[3].to_string(),
114 + capacity_pct: cols[4].to_string(),
115 + });
116 + }
117 + Ok(pools)
118 + }
119 + }
120 +
121 + struct Mock;
122 +
123 + impl Backend for Mock {
124 + fn list(&self) -> Result<Vec<Pool>> {
125 + Ok(vec![
126 + Pool {
127 + name: "rpool".into(),
128 + state: State::Online,
129 + size: "476G".into(),
130 + alloc: "62.1G".into(),
131 + capacity_pct: "13%".into(),
132 + },
133 + Pool {
134 + name: "tank".into(),
135 + state: State::Degraded,
136 + size: "1.81T".into(),
137 + alloc: "1.20T".into(),
138 + capacity_pct: "66%".into(),
139 + },
140 + ])
141 + }
142 + }
143 +
144 + pub fn run(summary: bool) -> Result<i32> {
145 + let pools = detect().list()?;
146 +
147 + if summary {
148 + let healthy = pools.iter().all(|p| p.state.is_healthy());
149 + println!("{}", if healthy { "ok" } else { "degraded" });
150 + return Ok(0);
151 + }
152 +
153 + let mut terminal = ratatui::init();
154 + let result = event_loop(&mut terminal, &pools);
155 + ratatui::restore();
156 + result.map(|_| 0)
157 + }
158 +
159 + fn event_loop(terminal: &mut ratatui::DefaultTerminal, pools: &[Pool]) -> Result<()> {
160 + let mut selected: usize = 0;
161 + let footer = Footer::new([hint("j/k", "move"), hint("q", "quit")]);
162 + loop {
163 + terminal.draw(|frame| {
164 + let [body, foot] = layout::split(frame.area());
165 + frame.render_widget(list_widget(pools, selected), body);
166 + frame.render_widget(&footer, foot);
167 + })?;
168 +
169 + let Event::Key(key) = event::read()? else {
170 + continue;
171 + };
172 + match classify(key) {
173 + GlobalAction::Quit => return Ok(()),
174 + GlobalAction::Passthrough => match key.code {
175 + KeyCode::Char('j') | KeyCode::Down if selected + 1 < pools.len() => {
176 + selected += 1;
177 + }
178 + KeyCode::Char('k') | KeyCode::Up => {
179 + selected = selected.saturating_sub(1);
180 + }
181 + _ => {}
182 + },
183 + _ => {}
184 + }
185 + }
186 + }
187 +
188 + fn list_widget(pools: &[Pool], selected: usize) -> Paragraph<'_> {
189 + let name_w = pools.iter().map(|p| p.name.len()).max().unwrap_or(0).max(4);
190 + let lines: Vec<Line> = pools
191 + .iter()
192 + .enumerate()
193 + .map(|(i, p)| {
194 + let is_sel = i == selected;
195 + let marker = if is_sel { selection::MARKER } else { " " };
196 + let line = Line::from(vec![
197 + Span::raw(format!(" {marker} ")),
198 + Span::raw(format!("{:<name_w$}", p.name)),
199 + Span::raw(" "),
200 + p.state.span(),
201 + Span::raw(" "),
202 + style::dim(format!(
203 + "{} / {} ({})",
204 + p.alloc, p.size, p.capacity_pct
205 + )),
206 + ]);
207 + if is_sel {
208 + line.style(selection::selected_style())
209 + } else {
210 + line
211 + }
212 + })
213 + .collect();
214 + Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
215 + }
M docs/todo.md +8 -2
@@ -1,6 +1,6 @@
1 1 # mountaineer-sysop — todo
2 2
3 - Status: Pre-v0. Active: next subcommand TBD. `sysop info`, `sysop motd`, and `sysop services` land end-to-end on host (mock backend when s6-rc unreachable). Primitives exercised by `cargo run --example smoketest -p sysop-tui`.
3 + Status: Pre-v0. Active: next subcommand TBD. `sysop info`, `sysop motd`, `sysop services`, `sysop storage` land end-to-end on host (mock backends when the real tool is unreachable). Primitives exercised by `cargo run --example smoketest -p sysop-tui`. Yambar `svc` and `zfs` segments now have producers.
4 4
5 5 ## Phase 1 — sysop-tui primitives (open items)
6 6
@@ -27,11 +27,17 @@ Status: Pre-v0. Active: next subcommand TBD. `sysop info`, `sysop motd`, and `sy
27 27 - [ ] Reflect transient state from `s6-rc-update` runs (currently only Up/Down are derived from `s6-rc -a list` + `-d list`)
28 28 - [ ] Backend mismatch indicator — if mock is active on what looks like a Mountaineer system, surface that as a fault
29 29
30 + ## Phase 5 — `sysop storage` (open items)
31 +
32 + - [ ] Detail view: vdev tree, error counters (read/write/cksum), scrub progress. Needs `zpool status -P <name>` parsing
33 + - [ ] Dataset view: `zfs list` — separate verb (`sysop storage datasets`?) or filtered detail screen
34 + - [ ] Snapshot management — read-only first (list), write later
35 + - [ ] Scrub trigger verb. Same caveat as services enable/disable — gate on live system
36 +
30 37 ## Deferred (post-MVP scaffolding)
31 38
32 39 - [ ] `sysop wifi` — TUI front for iwctl
33 40 - [ ] `sysop net status` — first-boot route dependency
34 - - [ ] `sysop storage` — ZFS pool view (yambar `zfs` segment dependency, mirrors `services --summary` shape)
35 41 - [ ] `sysop logs <service>` — lnav front
36 42 - [ ] `sysop id` — identity subsystem (full design in `mountaineer/todo.md`)
37 43 - [ ] `sysop upgrade`, `sysop wg`, `sysop notifications mute`, `sysop podman-s6`