Skip to main content

max / mountaineer-sysop

7.2 KB · 264 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 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 }
264