use anyhow::{Context, Result}; use crossterm::event::{self, Event, KeyCode}; use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use serde::Deserialize; use std::process::Command; use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { Wired, Wireless, Loopback, Other, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum State { Up, Down, } impl State { fn span(&self) -> Span<'static> { match self { State::Up => style::ok("up"), State::Down => style::fault("down"), } } } #[derive(Debug, Clone)] pub struct Interface { pub name: String, pub kind: Kind, pub state: State, pub addresses: Vec, pub ssid: Option, } impl Interface { fn is_routable(&self) -> bool { matches!(self.kind, Kind::Wired | Kind::Wireless) && self.state == State::Up } } pub trait Backend { fn list(&self) -> Result>; } fn detect() -> Box { if Iproute2.probe().is_ok() { Box::new(Iproute2) } else { Box::new(Mock) } } struct Iproute2; impl Iproute2 { fn probe(&self) -> Result<()> { let out = Command::new("ip") .arg("-V") .output() .context("invoke ip")?; if out.status.success() { Ok(()) } else { anyhow::bail!("ip unresponsive") } } } #[derive(Deserialize)] struct IpLink { ifname: String, #[serde(default)] flags: Vec, #[serde(default)] addr_info: Vec, } #[derive(Deserialize)] struct IpAddrEntry { local: String, prefixlen: u8, } impl Backend for Iproute2 { fn list(&self) -> Result> { let out = Command::new("ip") .args(["-json", "addr", "show"]) .output() .context("ip addr show")?; if !out.status.success() { anyhow::bail!("ip addr show failed"); } let links: Vec = serde_json::from_slice(&out.stdout).context("parse ip -json output")?; Ok(links .into_iter() .map(|link| { let kind = classify_iface(&link.ifname, &link.flags); let state = if link.flags.iter().any(|f| f == "UP") { State::Up } else { State::Down }; let addresses = link .addr_info .into_iter() .map(|a| format!("{}/{}", a.local, a.prefixlen)) .collect(); Interface { name: link.ifname, kind, state, addresses, ssid: None, } }) .collect()) } } fn classify_iface(name: &str, flags: &[String]) -> Kind { if flags.iter().any(|f| f == "LOOPBACK") { Kind::Loopback } else if name.starts_with("wl") { Kind::Wireless } else if name.starts_with("en") || name.starts_with("eth") { Kind::Wired } else { Kind::Other } } struct Mock; impl Backend for Mock { fn list(&self) -> Result> { Ok(vec![ Interface { name: "lo".into(), kind: Kind::Loopback, state: State::Up, addresses: vec!["127.0.0.1/8".into(), "::1/128".into()], ssid: None, }, Interface { name: "eth0".into(), kind: Kind::Wired, state: State::Up, addresses: vec!["192.168.1.50/24".into(), "fe80::a00:27ff:fe4e:66a1/64".into()], ssid: None, }, Interface { name: "wlan0".into(), kind: Kind::Wireless, state: State::Up, addresses: vec!["192.168.1.51/24".into()], ssid: Some("home-net".into()), }, ]) } } pub fn run(summary: bool) -> Result { let interfaces = detect().list()?; if summary { println!("{}", summary_line(&interfaces)); return Ok(0); } let mut terminal = ratatui::init(); let result = event_loop(&mut terminal, &interfaces); ratatui::restore(); result.map(|_| 0) } fn summary_line(interfaces: &[Interface]) -> String { let wired = interfaces.iter().find(|i| i.kind == Kind::Wired && i.is_routable()); let wireless = interfaces.iter().find(|i| i.kind == Kind::Wireless && i.is_routable()); match (wired, wireless) { (Some(w), _) => format!("{} ↑", w.name), (None, Some(w)) => match &w.ssid { Some(ssid) => format!("{} {}", w.name, ssid), None => format!("{} ↑", w.name), }, (None, None) => "net ↓".into(), } } fn event_loop( terminal: &mut ratatui::DefaultTerminal, interfaces: &[Interface], ) -> Result<()> { let mut selected: usize = 0; let footer = Footer::new([hint("j/k", "move"), hint("q", "quit")]); loop { terminal.draw(|frame| { let [body, foot] = layout::split(frame.area()); frame.render_widget(list_widget(interfaces, selected), body); frame.render_widget(&footer, foot); })?; let Event::Key(key) = event::read()? else { continue; }; match classify(key) { GlobalAction::Quit => return Ok(()), GlobalAction::Passthrough => match key.code { KeyCode::Char('j') | KeyCode::Down if selected + 1 < interfaces.len() => { selected += 1; } KeyCode::Char('k') | KeyCode::Up => { selected = selected.saturating_sub(1); } _ => {} }, _ => {} } } } fn list_widget(interfaces: &[Interface], selected: usize) -> Paragraph<'_> { let name_w = interfaces.iter().map(|i| i.name.len()).max().unwrap_or(0).max(4); let lines: Vec = interfaces .iter() .enumerate() .map(|(i, iface)| { let is_sel = i == selected; let marker = if is_sel { selection::MARKER } else { " " }; let addrs = if iface.addresses.is_empty() { String::new() } else { iface.addresses.join(", ") }; let ssid_seg = match &iface.ssid { Some(s) => format!(" {s}"), None => String::new(), }; let line = Line::from(vec![ Span::raw(format!(" {marker} ")), Span::raw(format!("{: