Skip to main content

max / mountaineer

5.7 KB · 213 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 Up,
12 Down,
13 Syncing,
14 Disabled,
15 }
16
17 impl State {
18 fn label(&self) -> &'static str {
19 match self {
20 State::Up => "up",
21 State::Down => "down",
22 State::Syncing => "syncing",
23 State::Disabled => "disabled",
24 }
25 }
26
27 fn span(&self) -> Span<'static> {
28 match self {
29 State::Up => style::ok(self.label()),
30 State::Syncing => style::warn(self.label()),
31 State::Down => style::fault(self.label()),
32 State::Disabled => style::inactive(self.label()),
33 }
34 }
35 }
36
37 #[derive(Debug, Clone)]
38 pub struct Service {
39 pub name: String,
40 pub state: State,
41 }
42
43 pub trait Backend {
44 fn list(&self) -> Result<Vec<Service>>;
45 }
46
47 fn detect() -> Box<dyn Backend> {
48 if S6rc.probe().is_ok() {
49 Box::new(S6rc)
50 } else {
51 Box::new(Mock)
52 }
53 }
54
55 struct S6rc;
56
57 impl S6rc {
58 fn probe(&self) -> Result<()> {
59 let out = Command::new("s6-rc")
60 .arg("-h")
61 .output()
62 .context("invoke s6-rc")?;
63 if out.status.success() || !out.stderr.is_empty() {
64 Ok(())
65 } else {
66 anyhow::bail!("s6-rc unresponsive")
67 }
68 }
69 }
70
71 impl Backend for S6rc {
72 fn list(&self) -> Result<Vec<Service>> {
73 let up = lines_from(&["-a", "list"])?;
74 let down = lines_from(&["-d", "list"])?;
75 let mut services: Vec<Service> = up
76 .iter()
77 .map(|n| Service {
78 name: n.clone(),
79 state: State::Up,
80 })
81 .collect();
82 for n in down {
83 if !services.iter().any(|s| s.name == n) {
84 services.push(Service {
85 name: n,
86 state: State::Down,
87 });
88 }
89 }
90 services.sort_by(|a, b| a.name.cmp(&b.name));
91 Ok(services)
92 }
93 }
94
95 fn lines_from(args: &[&str]) -> Result<Vec<String>> {
96 let out = Command::new("s6-rc")
97 .args(args)
98 .output()
99 .with_context(|| format!("s6-rc {}", args.join(" ")))?;
100 if !out.status.success() {
101 anyhow::bail!("s6-rc {} failed", args.join(" "));
102 }
103 Ok(String::from_utf8_lossy(&out.stdout)
104 .lines()
105 .map(|l| l.trim().to_string())
106 .filter(|l| !l.is_empty())
107 .collect())
108 }
109
110 struct Mock;
111
112 impl Backend for Mock {
113 fn list(&self) -> Result<Vec<Service>> {
114 Ok(vec![
115 svc("caddy", State::Syncing),
116 svc("chronyd", State::Up),
117 svc("ifupdown-eth0", State::Up),
118 svc("iwd", State::Up),
119 svc("nebula", State::Down),
120 svc("nftables", State::Up),
121 svc("pipewire", State::Disabled),
122 svc("podman", State::Up),
123 svc("seatd", State::Up),
124 svc("sshd", State::Up),
125 svc("udev", State::Up),
126 svc("unbound", State::Up),
127 svc("vector", State::Up),
128 ])
129 }
130 }
131
132 fn svc(name: &str, state: State) -> Service {
133 Service {
134 name: name.to_string(),
135 state,
136 }
137 }
138
139 pub fn run(summary: bool) -> Result<i32> {
140 let services = detect().list()?;
141
142 if summary {
143 let down = services
144 .iter()
145 .filter(|s| matches!(s.state, State::Down))
146 .count();
147 if down == 0 {
148 println!("ok");
149 } else {
150 println!("{down} down");
151 }
152 return Ok(0);
153 }
154
155 let mut terminal = ratatui::init();
156 let result = event_loop(&mut terminal, &services);
157 ratatui::restore();
158 result.map(|_| 0)
159 }
160
161 fn event_loop(terminal: &mut ratatui::DefaultTerminal, services: &[Service]) -> Result<()> {
162 let mut selected: usize = 0;
163 let footer = Footer::new([hint("j/k", "move"), hint("q", "quit")]);
164 loop {
165 terminal.draw(|frame| {
166 let [body, foot] = layout::split(frame.area());
167 frame.render_widget(list_widget(services, selected), body);
168 frame.render_widget(&footer, foot);
169 })?;
170
171 let Event::Key(key) = event::read()? else {
172 continue;
173 };
174 match classify(key) {
175 GlobalAction::Quit => return Ok(()),
176 GlobalAction::Passthrough => match key.code {
177 KeyCode::Char('j') | KeyCode::Down if selected + 1 < services.len() => {
178 selected += 1;
179 }
180 KeyCode::Char('k') | KeyCode::Up => {
181 selected = selected.saturating_sub(1);
182 }
183 _ => {}
184 },
185 _ => {}
186 }
187 }
188 }
189
190 fn list_widget(services: &[Service], selected: usize) -> Paragraph<'_> {
191 let name_w = services.iter().map(|s| s.name.len()).max().unwrap_or(0);
192 let lines: Vec<Line> = services
193 .iter()
194 .enumerate()
195 .map(|(i, s)| {
196 let is_sel = i == selected;
197 let marker = if is_sel { selection::MARKER } else { " " };
198 let line = Line::from(vec![
199 Span::raw(format!(" {marker} ")),
200 Span::raw(format!("{:<name_w$}", s.name)),
201 Span::raw(" "),
202 s.state.span(),
203 ]);
204 if is_sel {
205 line.style(selection::selected_style())
206 } else {
207 line
208 }
209 })
210 .collect();
211 Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
212 }
213