Skip to main content

max / mountaineer-sysop

Add sysop services -- s6-rc health view with mock backend fallback Tool-agnostic Backend trait per principle 14: S6rc (shells out to s6-rc -a/-d list) and Mock (12-service fixture). Auto-detects which backend to use, so the subcommand runs on macOS for development. Two output modes: TUI list (j/k/q via sysop-tui primitives) and --summary one-liner ('ok' or 'N down') for the yambar svc segment. Read-only for MVP. Enable/disable/restart verbs are deferred until live-system testing -- they cannot be meaningfully mocked.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-18 01:49 UTC
Commit: dbc1705906ed074efb2f119e961eb885f3795f7e
Parent: 68327d6
3 files changed, +229 insertions, -4 deletions
@@ -2,6 +2,7 @@ mod config;
2 2 mod info;
3 3 mod motd;
4 4 mod roles;
5 + mod services;
5 6
6 7 use clap::{Parser, Subcommand};
7 8
@@ -24,6 +25,12 @@ enum Command {
24 25 #[command(subcommand)]
25 26 action: Option<MotdAction>,
26 27 },
28 + /// s6-rc service health view
29 + Services {
30 + /// One-line summary for the yambar `svc` segment
31 + #[arg(long)]
32 + summary: bool,
33 + },
27 34 }
28 35
29 36 #[derive(Subcommand)]
@@ -46,6 +53,7 @@ fn main() -> anyhow::Result<()> {
46 53 Some(MotdAction::Off) => motd::set(false)?,
47 54 Some(MotdAction::Print) => motd::print()?,
48 55 },
56 + Command::Services { summary } => services::run(summary)?,
49 57 };
50 58 std::process::exit(code);
51 59 }
@@ -0,0 +1,212 @@
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 + }
M docs/todo.md +9 -4
@@ -1,6 +1,6 @@
1 1 # mountaineer-sysop — todo
2 2
3 - Status: Pre-v0. Active: next subcommand TBD (suggest `sysop services` to unblock the yambar `svc` segment). `sysop info` and `sysop motd` land end-to-end on host. Primitives exercised by `cargo run --example smoketest -p sysop-tui`.
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`.
4 4
5 5 ## Phase 1 — sysop-tui primitives (open items)
6 6
@@ -20,16 +20,21 @@ Status: Pre-v0. Active: next subcommand TBD (suggest `sysop services` to unblock
20 20 - [ ] Tip set worth revisiting once more `sysop` verbs exist — currently 6, drawn from AESTHETICS
21 21 - [ ] Decide whether `sysop motd print` should respect a "first-login-of-the-day" suppression to avoid tip spam in tmux-heavy sessions
22 22
23 + ## Phase 4 — `sysop services` (open items)
24 +
25 + - [ ] Detail view per service: dependencies, log path, time-in-state. Needs `s6-svstat` shell-out
26 + - [ ] Enable / disable / restart verbs. Requires real s6-rc; can't be meaningfully mocked, gate on live-system testing
27 + - [ ] Reflect transient state from `s6-rc-update` runs (currently only Up/Down are derived from `s6-rc -a list` + `-d list`)
28 + - [ ] Backend mismatch indicator — if mock is active on what looks like a Mountaineer system, surface that as a fault
29 +
23 30 ## Deferred (post-MVP scaffolding)
24 31
25 32 - [ ] `sysop wifi` — TUI front for iwctl
26 33 - [ ] `sysop net status` — first-boot route dependency
27 - - [ ] `sysop services` — s6-rc health list + enable/disable
28 - - [ ] `sysop storage` — ZFS pool view
34 + - [ ] `sysop storage` — ZFS pool view (yambar `zfs` segment dependency, mirrors `services --summary` shape)
29 35 - [ ] `sysop logs <service>` — lnav front
30 36 - [ ] `sysop id` — identity subsystem (full design in `mountaineer/todo.md`)
31 37 - [ ] `sysop upgrade`, `sysop wg`, `sysop notifications mute`, `sysop podman-s6`
32 - - [ ] Tool-agnostic backend pattern (principle 14): every TUI swappable when underlying tool changes
33 38 - [ ] Tab completion (bash/zsh/nushell)
34 39 - [ ] Man pages (mdoc; generated from clap?)
35 40 - [ ] CI / reproducible builds story