Skip to main content

max / mountaineer-sysop

Add sysop net -- interface status with yambar net producer Backend trait + Iproute2 (ip -json addr show, parsed via serde_json) + Mock fixture (lo, eth0, wlan0 with realistic addresses and an SSID on the wireless interface). TUI list shows interface, state, SSID for wireless, and addresses. --summary emits the yambar net segment shape: 'eth0 \u{2191}' / 'wlan0 <ssid>' / 'net \u{2193}', wired-first preference. Completes the three yambar identity-segment producers (svc, zfs, net). First-boot route now has its 'is the network up' check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-18 02:13 UTC
Commit: 4488d9a3350664b6e4dea93d714576107cf0fb99
Parent: 0c9fd21
6 files changed, +302 insertions, -2 deletions
M Cargo.lock +20
@@ -526,6 +526,19 @@ dependencies = [
526 526 ]
527 527
528 528 [[package]]
529 + name = "serde_json"
530 + version = "1.0.149"
531 + source = "registry+https://github.com/rust-lang/crates.io-index"
532 + checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
533 + dependencies = [
534 + "itoa",
535 + "memchr",
536 + "serde",
537 + "serde_core",
538 + "zmij",
539 + ]
540 +
541 + [[package]]
529 542 name = "serde_spanned"
530 543 version = "0.6.9"
531 544 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -625,6 +638,7 @@ dependencies = [
625 638 "crossterm",
626 639 "ratatui",
627 640 "serde",
641 + "serde_json",
628 642 "sysop-tui",
629 643 "toml",
630 644 ]
@@ -843,3 +857,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
843 857 dependencies = [
844 858 "memchr",
845 859 ]
860 +
861 + [[package]]
862 + name = "zmij"
863 + version = "1.0.21"
864 + source = "registry+https://github.com/rust-lang/crates.io-index"
865 + checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
M Cargo.toml +1
@@ -14,4 +14,5 @@ crossterm = "0.28"
14 14 anyhow = "1"
15 15 clap = { version = "4", features = ["derive"] }
16 16 serde = { version = "1", features = ["derive"] }
17 + serde_json = "1"
17 18 toml = "0.8"
@@ -16,4 +16,5 @@ crossterm.workspace = true
16 16 anyhow.workspace = true
17 17 clap.workspace = true
18 18 serde.workspace = true
19 + serde_json.workspace = true
19 20 toml.workspace = true
@@ -1,6 +1,7 @@
1 1 mod config;
2 2 mod info;
3 3 mod motd;
4 + mod net;
4 5 mod roles;
5 6 mod services;
6 7 mod storage;
@@ -41,6 +42,12 @@ enum Command {
41 42 },
42 43 /// Connect to a wireless network
43 44 Wifi,
45 + /// Network interface status
46 + Net {
47 + /// One-line summary for the yambar `net` segment
48 + #[arg(long)]
49 + summary: bool,
50 + },
44 51 }
45 52
46 53 #[derive(Subcommand)]
@@ -66,6 +73,7 @@ fn main() -> anyhow::Result<()> {
66 73 Command::Services { summary } => services::run(summary)?,
67 74 Command::Storage { summary } => storage::run(summary)?,
68 75 Command::Wifi => wifi::run()?,
76 + Command::Net { summary } => net::run(summary)?,
69 77 };
70 78 std::process::exit(code);
71 79 }
@@ -0,0 +1,263 @@
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 serde::Deserialize;
7 + use std::process::Command;
8 + use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style};
9 +
10 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
11 + pub enum Kind {
12 + Wired,
13 + Wireless,
14 + Loopback,
15 + Other,
16 + }
17 +
18 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19 + pub enum State {
20 + Up,
21 + Down,
22 + }
23 +
24 + impl State {
25 + fn span(&self) -> Span<'static> {
26 + match self {
27 + State::Up => style::ok("up"),
28 + State::Down => style::fault("down"),
29 + }
30 + }
31 + }
32 +
33 + #[derive(Debug, Clone)]
34 + pub struct Interface {
35 + pub name: String,
36 + pub kind: Kind,
37 + pub state: State,
38 + pub addresses: Vec<String>,
39 + pub ssid: Option<String>,
40 + }
41 +
42 + impl Interface {
43 + fn is_routable(&self) -> bool {
44 + matches!(self.kind, Kind::Wired | Kind::Wireless) && self.state == State::Up
45 + }
46 + }
47 +
48 + pub trait Backend {
49 + fn list(&self) -> Result<Vec<Interface>>;
50 + }
51 +
52 + fn detect() -> Box<dyn Backend> {
53 + if Iproute2.probe().is_ok() {
54 + Box::new(Iproute2)
55 + } else {
56 + Box::new(Mock)
57 + }
58 + }
59 +
60 + struct Iproute2;
61 +
62 + impl Iproute2 {
63 + fn probe(&self) -> Result<()> {
64 + let out = Command::new("ip")
65 + .arg("-V")
66 + .output()
67 + .context("invoke ip")?;
68 + if out.status.success() {
69 + Ok(())
70 + } else {
71 + anyhow::bail!("ip unresponsive")
72 + }
73 + }
74 + }
75 +
76 + #[derive(Deserialize)]
77 + struct IpLink {
78 + ifname: String,
79 + #[serde(default)]
80 + flags: Vec<String>,
81 + #[serde(default)]
82 + addr_info: Vec<IpAddrEntry>,
83 + }
84 +
85 + #[derive(Deserialize)]
86 + struct IpAddrEntry {
87 + local: String,
88 + prefixlen: u8,
89 + }
90 +
91 + impl Backend for Iproute2 {
92 + fn list(&self) -> Result<Vec<Interface>> {
93 + let out = Command::new("ip")
94 + .args(["-json", "addr", "show"])
95 + .output()
96 + .context("ip addr show")?;
97 + if !out.status.success() {
98 + anyhow::bail!("ip addr show failed");
99 + }
100 + let links: Vec<IpLink> =
101 + serde_json::from_slice(&out.stdout).context("parse ip -json output")?;
102 + Ok(links
103 + .into_iter()
104 + .map(|link| {
105 + let kind = classify_iface(&link.ifname, &link.flags);
106 + let state = if link.flags.iter().any(|f| f == "UP") {
107 + State::Up
108 + } else {
109 + State::Down
110 + };
111 + let addresses = link
112 + .addr_info
113 + .into_iter()
114 + .map(|a| format!("{}/{}", a.local, a.prefixlen))
115 + .collect();
116 + Interface {
117 + name: link.ifname,
118 + kind,
119 + state,
120 + addresses,
121 + ssid: None,
122 + }
123 + })
124 + .collect())
125 + }
126 + }
127 +
128 + fn classify_iface(name: &str, flags: &[String]) -> Kind {
129 + if flags.iter().any(|f| f == "LOOPBACK") {
130 + Kind::Loopback
131 + } else if name.starts_with("wl") {
132 + Kind::Wireless
133 + } else if name.starts_with("en") || name.starts_with("eth") {
134 + Kind::Wired
135 + } else {
136 + Kind::Other
137 + }
138 + }
139 +
140 + struct Mock;
141 +
142 + impl Backend for Mock {
143 + fn list(&self) -> Result<Vec<Interface>> {
144 + Ok(vec![
145 + Interface {
146 + name: "lo".into(),
147 + kind: Kind::Loopback,
148 + state: State::Up,
149 + addresses: vec!["127.0.0.1/8".into(), "::1/128".into()],
150 + ssid: None,
151 + },
152 + Interface {
153 + name: "eth0".into(),
154 + kind: Kind::Wired,
155 + state: State::Up,
156 + addresses: vec!["192.168.1.50/24".into(), "fe80::a00:27ff:fe4e:66a1/64".into()],
157 + ssid: None,
158 + },
159 + Interface {
160 + name: "wlan0".into(),
161 + kind: Kind::Wireless,
162 + state: State::Up,
163 + addresses: vec!["192.168.1.51/24".into()],
164 + ssid: Some("home-net".into()),
165 + },
166 + ])
167 + }
168 + }
169 +
170 + pub fn run(summary: bool) -> Result<i32> {
171 + let interfaces = detect().list()?;
172 +
173 + if summary {
174 + println!("{}", summary_line(&interfaces));
175 + return Ok(0);
176 + }
177 +
178 + let mut terminal = ratatui::init();
179 + let result = event_loop(&mut terminal, &interfaces);
180 + ratatui::restore();
181 + result.map(|_| 0)
182 + }
183 +
184 + fn summary_line(interfaces: &[Interface]) -> String {
185 + let wired = interfaces.iter().find(|i| i.kind == Kind::Wired && i.is_routable());
186 + let wireless = interfaces.iter().find(|i| i.kind == Kind::Wireless && i.is_routable());
187 + match (wired, wireless) {
188 + (Some(w), _) => format!("{} ↑", w.name),
189 + (None, Some(w)) => match &w.ssid {
190 + Some(ssid) => format!("{} {}", w.name, ssid),
191 + None => format!("{} ↑", w.name),
192 + },
193 + (None, None) => "net ↓".into(),
194 + }
195 + }
196 +
197 + fn event_loop(
198 + terminal: &mut ratatui::DefaultTerminal,
199 + interfaces: &[Interface],
200 + ) -> Result<()> {
201 + let mut selected: usize = 0;
202 + let footer = Footer::new([hint("j/k", "move"), hint("q", "quit")]);
203 + loop {
204 + terminal.draw(|frame| {
205 + let [body, foot] = layout::split(frame.area());
206 + frame.render_widget(list_widget(interfaces, selected), body);
207 + frame.render_widget(&footer, foot);
208 + })?;
209 +
210 + let Event::Key(key) = event::read()? else {
211 + continue;
212 + };
213 + match classify(key) {
214 + GlobalAction::Quit => return Ok(()),
215 + GlobalAction::Passthrough => match key.code {
216 + KeyCode::Char('j') | KeyCode::Down if selected + 1 < interfaces.len() => {
217 + selected += 1;
218 + }
219 + KeyCode::Char('k') | KeyCode::Up => {
220 + selected = selected.saturating_sub(1);
221 + }
222 + _ => {}
223 + },
224 + _ => {}
225 + }
226 + }
227 + }
228 +
229 + fn list_widget(interfaces: &[Interface], selected: usize) -> Paragraph<'_> {
230 + let name_w = interfaces.iter().map(|i| i.name.len()).max().unwrap_or(0).max(4);
231 + let lines: Vec<Line> = interfaces
232 + .iter()
233 + .enumerate()
234 + .map(|(i, iface)| {
235 + let is_sel = i == selected;
236 + let marker = if is_sel { selection::MARKER } else { " " };
237 + let addrs = if iface.addresses.is_empty() {
238 + String::new()
239 + } else {
240 + iface.addresses.join(", ")
241 + };
242 + let ssid_seg = match &iface.ssid {
243 + Some(s) => format!(" {s}"),
244 + None => String::new(),
245 + };
246 + let line = Line::from(vec![
247 + Span::raw(format!(" {marker} ")),
248 + Span::raw(format!("{:<name_w$}", iface.name)),
249 + Span::raw(" "),
250 + iface.state.span(),
251 + style::fg(ssid_seg),
252 + Span::raw(" "),
253 + style::dim(addrs),
254 + ]);
255 + if is_sel {
256 + line.style(selection::selected_style())
257 + } else {
258 + line
259 + }
260 + })
261 + .collect();
262 + Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
263 + }
M docs/todo.md +9 -2
@@ -1,6 +1,6 @@
1 1 # mountaineer-sysop — todo
2 2
3 - Status: Pre-v0. Active: next subcommand TBD. `sysop info`, `sysop motd`, `sysop services`, `sysop storage`, `sysop wifi` 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 have producers. `sysop wifi` is the first interactive subcommand (password input).
3 + Status: Pre-v0. MVP slice complete: `sysop info`, `sysop motd`, `sysop services`, `sysop storage`, `sysop wifi`, `sysop net` all run on host with mock-or-real backend auto-detection. Yambar producers ready for `svc`, `zfs`, `net`. First interactive subcommand (`wifi`) lands a masked password input. Active: TBD — natural next is `sysop id` (large) or pause sysop for overlay/installer work.
4 4
5 5 ## Phase 1 — sysop-tui primitives (open items)
6 6
@@ -44,9 +44,16 @@ Status: Pre-v0. Active: next subcommand TBD. `sysop info`, `sysop motd`, `sysop
44 44 - [ ] Snapshot management — read-only first (list), write later
45 45 - [ ] Scrub trigger verb. Same caveat as services enable/disable — gate on live system
46 46
47 + ## Phase 7 — `sysop net` (open items)
48 +
49 + - [ ] SSID enrichment for wireless: read `/var/lib/iwd/` or `iwctl station <iface> show` to populate `ssid` on the real backend (mock already returns it)
50 + - [ ] Default route / gateway display (`ip -json route show default` parsing)
51 + - [ ] Resolver liveness check — show whether unbound is reachable on 127.0.0.1:53 (would also feed a future `sysop info resolver` detail screen)
52 + - [ ] Yambar `net` segment should reflect the active default-route interface, not just wired-first. Currently wired-first heuristic
53 + - [ ] Detail view: per-interface MAC, MTU, link speed (ethtool for wired)
54 +
47 55 ## Deferred (post-MVP scaffolding)
48 56
49 - - [ ] `sysop net status` — first-boot route dependency
50 57 - [ ] `sysop logs <service>` — lnav front
51 58 - [ ] `sysop id` — identity subsystem (full design in `mountaineer/todo.md`)
52 59 - [ ] `sysop upgrade`, `sysop wg`, `sysop notifications mute`, `sysop podman-s6`