Skip to main content

max / mountaineer-sysop

5.9 KB · 216 lines History Blame Raw
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 }
216