Skip to main content

max / alloy_tui

17.2 KB · 545 lines History Blame Raw
1 //! `alloy net` — a NetworkManager front.
2 //!
3 //! Descended from sysop's `net.rs`, which fronted `ip` on Alpine. Alloy is
4 //! Fedora, so the backend is `nmcli`; the mock-or-real detection pattern is
5 //! carried over unchanged, because it is what lets the console be developed
6 //! and demoed on a machine whose real network state you would rather not
7 //! touch.
8
9 use alloy_tui::{AlloyBlock, AlloyList, Cursor, Hint, Severity, Theme, hint, text};
10 use anyhow::Result;
11 use ratatui::Frame;
12 use ratatui::crossterm::event::{KeyCode, KeyEvent};
13 use ratatui::layout::Rect;
14 use ratatui::text::{Line, Span};
15
16 use crate::cli::{CommandLog, Invocation};
17 use crate::shell::{Flow, View, block_title};
18
19 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
20 pub enum Kind {
21 Wired,
22 Wireless,
23 Loopback,
24 Other,
25 }
26
27 impl Kind {
28 /// Map NetworkManager's device type. NM's vocabulary is open-ended
29 /// (`bridge`, `tun`, `wireguard`, `bond`, ...); everything Alloy does not
30 /// name specifically is `Other` and still listed, because hiding an
31 /// interface the user can see in `nmcli` would make the console look
32 /// broken.
33 fn from_nm(raw: &str) -> Self {
34 match raw {
35 "ethernet" => Kind::Wired,
36 "wifi" => Kind::Wireless,
37 "loopback" => Kind::Loopback,
38 _ => Kind::Other,
39 }
40 }
41
42 const fn label(self) -> &'static str {
43 match self {
44 Kind::Wired => "wired",
45 Kind::Wireless => "wireless",
46 Kind::Loopback => "loopback",
47 Kind::Other => "other",
48 }
49 }
50 }
51
52 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
53 pub enum State {
54 Connected,
55 Disconnected,
56 Unavailable,
57 Unmanaged,
58 }
59
60 impl State {
61 /// NM reports `GENERAL.STATE` as `"100 (connected)"`. The numeric code is
62 /// the stable part — the parenthesized text is localized — so parse the
63 /// number and ignore the rest.
64 fn from_nm(raw: &str) -> Self {
65 let code = raw
66 .split_whitespace()
67 .next()
68 .and_then(|n| n.parse::<u16>().ok())
69 .unwrap_or(0);
70 match code {
71 100 => State::Connected,
72 30 => State::Disconnected,
73 20 => State::Unavailable,
74 _ => State::Unmanaged,
75 }
76 }
77
78 const fn label(self) -> &'static str {
79 match self {
80 State::Connected => "connected",
81 State::Disconnected => "disconnected",
82 State::Unavailable => "unavailable",
83 State::Unmanaged => "unmanaged",
84 }
85 }
86
87 const fn severity(self) -> Severity {
88 match self {
89 State::Connected => Severity::Healthy,
90 State::Disconnected => Severity::Warn,
91 State::Unavailable | State::Unmanaged => Severity::Info,
92 }
93 }
94 }
95
96 #[derive(Debug, Clone)]
97 pub struct Interface {
98 pub name: String,
99 pub kind: Kind,
100 pub state: State,
101 pub connection: Option<String>,
102 pub addresses: Vec<String>,
103 }
104
105 /// A source of interface state.
106 pub trait Backend {
107 fn name(&self) -> &'static str;
108 fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>>;
109 }
110
111 /// Pick a backend: the real one when `nmcli` answers, the mock otherwise.
112 ///
113 /// The probe is a real invocation rather than a `which` check — an `nmcli`
114 /// binary that cannot reach a NetworkManager daemon (a container, a live ISO
115 /// mid-boot) is worse than no `nmcli` at all, and only running it reveals that.
116 pub fn detect() -> Box<dyn Backend> {
117 if Invocation::new("nmcli").arg("--version").probe() {
118 Box::new(NmCli)
119 } else {
120 Box::new(Mock)
121 }
122 }
123
124 pub struct NmCli;
125
126 impl NmCli {
127 /// One invocation for the whole device table. `nmcli device show` with no
128 /// device dumps every device, which keeps the log pane to a single
129 /// copy-pasteable line instead of one per interface.
130 fn invocation() -> Invocation {
131 Invocation::new("nmcli").args([
132 "-t",
133 "-f",
134 "GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS",
135 "device",
136 "show",
137 ])
138 }
139 }
140
141 impl Backend for NmCli {
142 fn name(&self) -> &'static str {
143 "nmcli"
144 }
145
146 fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>> {
147 Ok(parse_device_show(&Self::invocation().run(log)?))
148 }
149 }
150
151 /// Fixed sample state, for machines without NetworkManager.
152 pub struct Mock;
153
154 impl Backend for Mock {
155 fn name(&self) -> &'static str {
156 "mock"
157 }
158
159 fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>> {
160 // Logged as a comment rather than a command: the pane's contract is
161 // that every line is something you could run, and there is nothing to
162 // run here. The `#` marks it as commentary in the same way a shell
163 // would.
164 log.record("# no NetworkManager; showing mock interfaces", Severity::Warn);
165 Ok(vec![
166 Interface {
167 name: "wlp1s0".into(),
168 kind: Kind::Wireless,
169 state: State::Connected,
170 connection: Some("Example Network".into()),
171 addresses: vec!["192.168.1.42/24".into()],
172 },
173 Interface {
174 name: "enp2s0".into(),
175 kind: Kind::Wired,
176 state: State::Disconnected,
177 connection: None,
178 addresses: vec![],
179 },
180 Interface {
181 name: "lo".into(),
182 kind: Kind::Loopback,
183 state: State::Unmanaged,
184 connection: None,
185 addresses: vec!["127.0.0.1/8".into()],
186 },
187 ])
188 }
189 }
190
191 /// Parse `nmcli -t -f ... device show` output.
192 ///
193 /// Terse mode emits `KEY:value` per line with devices separated by blank
194 /// lines. Keys never contain a colon, so splitting on the first one is
195 /// unambiguous and values are taken verbatim.
196 ///
197 /// That verbatim part is worth stating, because nmcli's terse *tabular* output
198 /// (`device status`) does escape colons as `\:` — it has to, since its fields
199 /// are colon-separated. Multiline output does not, and an IPv6 address here
200 /// arrives as plain `fe80::1`. Unescaping it anyway would corrupt any value
201 /// containing a legitimate backslash.
202 fn parse_device_show(raw: &str) -> Vec<Interface> {
203 let mut interfaces = Vec::new();
204 let mut current: Option<Interface> = None;
205
206 for line in raw.lines() {
207 let line = line.trim_end();
208 if line.is_empty() {
209 continue;
210 }
211 let Some((key, value)) = line.split_once(':') else {
212 continue;
213 };
214 let value = value.to_string();
215
216 // A device block starts at GENERAL.DEVICE. Keying off that rather than
217 // the blank-line separator means a missing separator merges nothing:
218 // the next DEVICE always opens a new record.
219 if key == "GENERAL.DEVICE" {
220 if let Some(iface) = current.take() {
221 interfaces.push(iface);
222 }
223 current = Some(Interface {
224 name: value,
225 kind: Kind::Other,
226 state: State::Unmanaged,
227 connection: None,
228 addresses: Vec::new(),
229 });
230 continue;
231 }
232
233 let Some(iface) = current.as_mut() else {
234 continue;
235 };
236
237 match key {
238 "GENERAL.TYPE" => iface.kind = Kind::from_nm(&value),
239 "GENERAL.STATE" => iface.state = State::from_nm(&value),
240 // NM writes `--` for an absent connection, which would otherwise
241 // render as a connection literally named "--".
242 "GENERAL.CONNECTION" if value != "--" && !value.is_empty() => {
243 iface.connection = Some(value);
244 }
245 // Address keys are indexed: IP4.ADDRESS[1], IP6.ADDRESS[2], ...
246 _ if !value.is_empty()
247 && (key.starts_with("IP4.ADDRESS") || key.starts_with("IP6.ADDRESS")) =>
248 {
249 iface.addresses.push(value);
250 }
251 _ => {}
252 }
253 }
254
255 interfaces.extend(current);
256 interfaces
257 }
258
259 /// The `alloy net` screen.
260 pub struct NetView {
261 backend: Box<dyn Backend>,
262 interfaces: Vec<Interface>,
263 cursor: Cursor,
264 error: Option<String>,
265 }
266
267 impl NetView {
268 pub fn new(log: &mut CommandLog) -> Self {
269 let mut view = Self {
270 backend: detect(),
271 interfaces: Vec::new(),
272 cursor: Cursor::new(),
273 error: None,
274 };
275 view.refresh(log);
276 view
277 }
278
279 fn refresh(&mut self, log: &mut CommandLog) {
280 match self.backend.list(log) {
281 Ok(interfaces) => {
282 self.interfaces = interfaces;
283 // Refresh can shrink the list (an interface went away); the
284 // cursor clamps itself back into range.
285 self.cursor.resize(self.interfaces.len());
286 self.error = None;
287 }
288 Err(err) => self.error = Some(err.to_string()),
289 }
290 }
291
292 fn row<'a>(&self, theme: &Theme, iface: &'a Interface) -> Line<'a> {
293 let address = iface
294 .addresses
295 .first()
296 .cloned()
297 .or_else(|| iface.connection.clone())
298 .unwrap_or_default();
299
300 Line::from(vec![
301 text::bold(theme, format!("{:<12}", iface.name)),
302 text::muted(theme, format!("{:<10}", iface.kind.label())),
303 Span::styled(
304 format!("{:<14}", iface.state.label()),
305 iface.state.severity().style(theme),
306 ),
307 text::secondary(theme, address),
308 ])
309 }
310 }
311
312 impl View for NetView {
313 fn title(&self) -> String {
314 format!("network ({})", self.backend.name())
315 }
316
317 fn hints(&self) -> Vec<Hint> {
318 vec![hint("j/k", "select"), hint("r", "refresh")]
319 }
320
321 fn status(&self) -> Option<(Severity, String)> {
322 self.error
323 .as_ref()
324 .map(|message| (Severity::Error, message.clone()))
325 }
326
327 fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
328 let block = AlloyBlock::new(theme)
329 .focused(true)
330 .build()
331 .title(block_title(&self.title()));
332 let inner = block.inner(area);
333 frame.render_widget(block, area);
334
335 if self.interfaces.is_empty() {
336 frame.render_widget(
337 Line::from(text::muted(theme, "no interfaces")),
338 inner,
339 );
340 return;
341 }
342
343 let rows: Vec<Line> = self
344 .interfaces
345 .iter()
346 .map(|iface| self.row(theme, iface))
347 .collect();
348 frame.render_widget(
349 AlloyList::new(theme, rows).selected(self.cursor.selected()),
350 inner,
351 );
352 }
353
354 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
355 match key.code {
356 KeyCode::Char('j') | KeyCode::Down => self.cursor.next(),
357 KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
358 KeyCode::Char('r') => self.refresh(log),
359 _ => {}
360 }
361 Flow::Continue
362 }
363 }
364
365 #[cfg(test)]
366 mod tests {
367 use super::*;
368
369 // Captured verbatim from `nmcli -t -f GENERAL.DEVICE,GENERAL.TYPE,
370 // GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS device show` on
371 // a NetworkManager 1.5x box, hostname and SSID aside. Kept real rather
372 // than tidied: the awkward parts below (nested parens in the state, an
373 // empty trailing connection, a bare `::1`) are all things nmcli actually
374 // emits, and a hand-written fixture is where a parser goes to pass tests
375 // it would fail in production.
376 const SAMPLE: &str = "\
377 GENERAL.DEVICE:wlp192s0
378 GENERAL.TYPE:wifi
379 GENERAL.STATE:100 (connected)
380 GENERAL.CONNECTION:Example Network
381 IP4.ADDRESS[1]:192.168.0.16/24
382 IP6.ADDRESS[1]:fe80::59a3:bc22:d95f:c06b/64
383
384 GENERAL.DEVICE:tailscale0
385 GENERAL.TYPE:tun
386 GENERAL.STATE:100 (connected (externally))
387 GENERAL.CONNECTION:tailscale0
388 IP4.ADDRESS[1]:100.103.89.95/32
389 IP6.ADDRESS[1]:fd7a:115c:a1e0::af3b:595f/128
390 IP6.ADDRESS[2]:fe80::ccae:60fc:a1c5:3b13/64
391
392 GENERAL.DEVICE:lo
393 GENERAL.TYPE:loopback
394 GENERAL.STATE:100 (connected (externally))
395 GENERAL.CONNECTION:lo
396 IP4.ADDRESS[1]:127.0.0.1/8
397 IP6.ADDRESS[1]:::1/128
398
399 GENERAL.DEVICE:p2p-dev-wlp192s0
400 GENERAL.TYPE:wifi-p2p
401 GENERAL.STATE:30 (disconnected)
402 GENERAL.CONNECTION:
403 ";
404
405 #[test]
406 fn parses_every_device_block() {
407 let ifaces = parse_device_show(SAMPLE);
408 assert_eq!(ifaces.len(), 4);
409 assert_eq!(ifaces[0].name, "wlp192s0");
410 assert_eq!(ifaces[0].kind, Kind::Wireless);
411 assert_eq!(ifaces[0].state, State::Connected);
412 assert_eq!(ifaces[0].connection.as_deref(), Some("Example Network"));
413 assert_eq!(
414 ifaces[3].name, "p2p-dev-wlp192s0",
415 "the last block is not dropped for want of a trailing blank line"
416 );
417 }
418
419 // IPv6 values carry colons and arrive unescaped, so the split has to be on
420 // the *first* colon only. Splitting on every colon shows `fe80` as the
421 // address; `::1/128` is the case that breaks a naive rsplit as well.
422 #[test]
423 fn ipv6_addresses_survive_the_key_value_split() {
424 let ifaces = parse_device_show(SAMPLE);
425 assert_eq!(
426 ifaces[0].addresses,
427 vec!["192.168.0.16/24", "fe80::59a3:bc22:d95f:c06b/64"]
428 );
429 assert_eq!(
430 ifaces[1].addresses,
431 vec![
432 "100.103.89.95/32",
433 "fd7a:115c:a1e0::af3b:595f/128",
434 "fe80::ccae:60fc:a1c5:3b13/64",
435 ],
436 "every indexed address is collected, not just the first"
437 );
438 assert_eq!(ifaces[2].addresses[1], "::1/128");
439 }
440
441 // NM leaves the connection field empty for a device with no active
442 // connection. Empty must read as absent, not as a connection named "".
443 #[test]
444 fn treats_an_empty_connection_as_absent() {
445 let ifaces = parse_device_show(SAMPLE);
446 assert_eq!(ifaces[3].connection, None);
447 assert!(ifaces[3].addresses.is_empty());
448 }
449
450 // `--` is NM's other placeholder for "none", used where a field is
451 // tabulated rather than left blank.
452 #[test]
453 fn treats_double_dash_connection_as_absent() {
454 let raw = "GENERAL.DEVICE:enp2s0\nGENERAL.TYPE:ethernet\nGENERAL.CONNECTION:--\n";
455 assert_eq!(parse_device_show(raw)[0].connection, None);
456 }
457
458 // The state field nests parentheses: "100 (connected (externally))". Only
459 // the leading numeric code is stable across locales, so that is what is
460 // parsed; anything reading the text would misclassify this as unmanaged.
461 #[test]
462 fn parses_state_from_the_numeric_code_not_the_text() {
463 let ifaces = parse_device_show(SAMPLE);
464 assert_eq!(ifaces[1].state, State::Connected);
465 assert_eq!(ifaces[3].state, State::Disconnected);
466 }
467
468 #[test]
469 fn empty_output_yields_no_interfaces() {
470 assert!(parse_device_show("").is_empty());
471 }
472
473 // NM's device-type vocabulary is open-ended; an unknown type must still
474 // list rather than vanish.
475 #[test]
476 fn unknown_device_types_are_listed_as_other() {
477 let raw = "GENERAL.DEVICE:wg0\nGENERAL.TYPE:wireguard\nGENERAL.STATE:100 (connected)\n";
478 let ifaces = parse_device_show(raw);
479 assert_eq!(ifaces.len(), 1);
480 assert_eq!(ifaces[0].kind, Kind::Other);
481 assert_eq!(ifaces[0].state, State::Connected);
482 }
483
484 fn mock_view() -> (NetView, CommandLog) {
485 let mut log = CommandLog::new();
486 let mut view = NetView {
487 backend: Box::new(Mock),
488 interfaces: Vec::new(),
489 cursor: Cursor::new(),
490 error: None,
491 };
492 view.refresh(&mut log);
493 (view, log)
494 }
495
496 // Cursor's own tests cover the clamping; this checks the wiring, that
497 // refresh actually tells the cursor the new length. Without that call the
498 // cursor keeps pointing at a row that no longer exists.
499 #[test]
500 fn refresh_resizes_the_cursor_when_the_list_shrinks() {
501 let (mut view, mut log) = mock_view();
502 view.cursor.move_by(2);
503 assert_eq!(view.cursor.selected(), Some(2));
504
505 view.backend = Box::new(EmptyBackend);
506 view.refresh(&mut log);
507 assert_eq!(view.cursor.selected(), None, "no selection in an empty list");
508 }
509
510 // A failed refresh must leave the last good list on screen rather than
511 // blanking it, and surface the error in the status area.
512 #[test]
513 fn a_failed_refresh_keeps_the_previous_interfaces() {
514 let (mut view, mut log) = mock_view();
515 assert_eq!(view.interfaces.len(), 3);
516
517 view.backend = Box::new(FailingBackend);
518 view.refresh(&mut log);
519 assert_eq!(view.interfaces.len(), 3, "the stale list is still shown");
520 assert!(view.error.is_some(), "the failure is surfaced");
521 }
522
523 struct EmptyBackend;
524
525 struct FailingBackend;
526
527 impl Backend for FailingBackend {
528 fn name(&self) -> &'static str {
529 "failing"
530 }
531 fn list(&self, _log: &mut CommandLog) -> Result<Vec<Interface>> {
532 anyhow::bail!("nmcli went away")
533 }
534 }
535
536 impl Backend for EmptyBackend {
537 fn name(&self) -> &'static str {
538 "empty"
539 }
540 fn list(&self, _log: &mut CommandLog) -> Result<Vec<Interface>> {
541 Ok(Vec::new())
542 }
543 }
544 }
545