max / alloy
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 files changed,
+507 insertions,
-0 deletions
| @@ -10,6 +10,7 @@ | |||
| 10 | 10 | mod cli; | |
| 11 | 11 | mod net; | |
| 12 | 12 | mod shell; | |
| 13 | + | mod tail; | |
| 13 | 14 | mod theme; | |
| 14 | 15 | ||
| 15 | 16 | use anyhow::Result; | |
| @@ -34,6 +35,8 @@ | |||
| 34 | 35 | Net, | |
| 35 | 36 | /// Audio outputs and inputs | |
| 36 | 37 | Audio, | |
| 38 | + | /// Tailnet peers and exit node | |
| 39 | + | Tail, | |
| 37 | 40 | } | |
| 38 | 41 | ||
| 39 | 42 | fn main() -> Result<()> { | |
| @@ -50,5 +53,9 @@ | |||
| 50 | 53 | let mut view = audio::AudioView::new(&mut log); | |
| 51 | 54 | shell::run(&theme, &mut view, &mut log) | |
| 52 | 55 | } | |
| 56 | + | Command::Tail => { | |
| 57 | + | let mut view = tail::TailView::new(&mut log); | |
| 58 | + | shell::run(&theme, &mut view, &mut log) | |
| 59 | + | } | |
| 53 | 60 | } | |
| 54 | 61 | } |
| @@ -1,0 +1,743 @@ | |||
| 1 | + | //! `alloy tail` — a Tailscale front. | |
| 2 | + | //! | |
| 3 | + | //! See docs/CONTINUITY.md: Tailscale and Syncthing are what make an Alloy | |
| 4 | + | //! machine feel like the same machine as the last one, so the console fronts | |
| 5 | + | //! both. This is the tailnet half. | |
| 6 | + | //! | |
| 7 | + | //! One invocation covers the whole screen. `tailscale status --json` is a | |
| 8 | + | //! documented contract carrying the local node, every peer, the backend state, | |
| 9 | + | //! and any health warnings, so unlike `alloy audio` this view costs a single | |
| 10 | + | //! logged line per refresh. | |
| 11 | + | ||
| 12 | + | use std::collections::HashMap; | |
| 13 | + | ||
| 14 | + | use alloy_tui::{AlloyBlock, AlloyList, Cursor, Hint, Severity, Theme, hint, text}; | |
| 15 | + | use anyhow::{Context, Result}; | |
| 16 | + | use ratatui::Frame; | |
| 17 | + | use ratatui::crossterm::event::{KeyCode, KeyEvent}; | |
| 18 | + | use ratatui::layout::Rect; | |
| 19 | + | use ratatui::text::{Line, Span}; | |
| 20 | + | use serde::Deserialize; | |
| 21 | + | ||
| 22 | + | use crate::cli::{CommandLog, Invocation}; | |
| 23 | + | use crate::shell::{Flow, View, block_title}; | |
| 24 | + | ||
| 25 | + | /// Ticks between background refreshes. Peers come and go on the scale of a | |
| 26 | + | /// laptop lid closing, not a keypress, so polling every second would spawn a | |
| 27 | + | /// process per second to learn nothing. | |
| 28 | + | const POLL_TICKS: u64 = 5; | |
| 29 | + | ||
| 30 | + | /// Go's zero time, which `tailscale status --json` emits for `LastSeen` on any | |
| 31 | + | /// peer that is currently online. Rendered literally it reads "last seen in | |
| 32 | + | /// year 1". | |
| 33 | + | const GO_ZERO_TIME_PREFIX: &str = "0001-01-01"; | |
| 34 | + | ||
| 35 | + | #[derive(Debug, Clone)] | |
| 36 | + | pub struct Peer { | |
| 37 | + | pub hostname: String, | |
| 38 | + | pub os: String, | |
| 39 | + | /// First tailnet address. Peers can hold both a v4 and a v6; the v4 is | |
| 40 | + | /// what people recognize and type. | |
| 41 | + | pub ip: Option<String>, | |
| 42 | + | pub online: bool, | |
| 43 | + | /// This machine. | |
| 44 | + | pub is_self: bool, | |
| 45 | + | /// Currently carrying this machine's traffic as its exit node. | |
| 46 | + | pub is_exit_node: bool, | |
| 47 | + | /// Advertises itself as available to be an exit node. | |
| 48 | + | pub offers_exit_node: bool, | |
| 49 | + | /// Date last seen, absent while online. | |
| 50 | + | pub last_seen: Option<String>, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | impl Peer { | |
| 54 | + | fn severity(&self) -> Severity { | |
| 55 | + | if self.is_exit_node { | |
| 56 | + | Severity::Info | |
| 57 | + | } else if self.online { | |
| 58 | + | Severity::Healthy | |
| 59 | + | } else { | |
| 60 | + | Severity::Warn | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | fn state_label(&self) -> String { | |
| 65 | + | let mut parts = Vec::new(); | |
| 66 | + | if self.is_self { | |
| 67 | + | parts.push("this machine".to_string()); | |
| 68 | + | } | |
| 69 | + | if self.is_exit_node { | |
| 70 | + | parts.push("exit node".to_string()); | |
| 71 | + | } else if self.offers_exit_node { | |
| 72 | + | parts.push("offers exit".to_string()); | |
| 73 | + | } | |
| 74 | + | if !self.online && let Some(seen) = &self.last_seen { | |
| 75 | + | parts.push(format!("seen {seen}")); | |
| 76 | + | } | |
| 77 | + | parts.join(", ") | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | #[derive(Debug, Clone)] | |
| 82 | + | pub struct TailStatus { | |
| 83 | + | /// `Running`, `Stopped`, `NeedsLogin`, and friends. Reported verbatim | |
| 84 | + | /// rather than mapped to an enum: it is a Tailscale-owned vocabulary that | |
| 85 | + | /// gains members, and showing an unfamiliar one is better than collapsing | |
| 86 | + | /// it to "unknown". | |
| 87 | + | pub backend_state: String, | |
| 88 | + | pub health: Vec<String>, | |
| 89 | + | pub peers: Vec<Peer>, | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | impl TailStatus { | |
| 93 | + | fn is_running(&self) -> bool { | |
| 94 | + | self.backend_state == "Running" | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | pub trait Backend { | |
| 99 | + | fn name(&self) -> &'static str; | |
| 100 | + | fn status(&self, log: &mut CommandLog) -> Result<TailStatus>; | |
| 101 | + | ||
| 102 | + | /// Route traffic through `peer`. | |
| 103 | + | fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()>; | |
| 104 | + | ||
| 105 | + | /// Stop routing through an exit node. | |
| 106 | + | fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()>; | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// Pick a backend: `tailscale` when it answers, the mock otherwise. | |
| 110 | + | pub fn detect() -> Box<dyn Backend> { | |
| 111 | + | if Invocation::new("tailscale").arg("version").probe() { | |
| 112 | + | Box::new(Tailscale) | |
| 113 | + | } else { | |
| 114 | + | Box::new(Mock) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | pub struct Tailscale; | |
| 119 | + | ||
| 120 | + | impl Backend for Tailscale { | |
| 121 | + | fn name(&self) -> &'static str { | |
| 122 | + | "tailscale" | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | fn status(&self, log: &mut CommandLog) -> Result<TailStatus> { | |
| 126 | + | let raw = Invocation::new("tailscale") | |
| 127 | + | .args(["status", "--json"]) | |
| 128 | + | .run(log)?; | |
| 129 | + | parse_status(&raw) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()> { | |
| 133 | + | // Addressed by IP rather than hostname: hostnames collide (three | |
| 134 | + | // devices on this tailnet answer to "localhost") and MagicDNS may be | |
| 135 | + | // off, while the tailnet IP is unique and always resolvable. | |
| 136 | + | let ip = peer | |
| 137 | + | .ip | |
| 138 | + | .as_deref() | |
| 139 | + | .context("peer has no tailnet address to route through")?; | |
| 140 | + | Invocation::new("tailscale") | |
| 141 | + | .arg("set") | |
| 142 | + | .arg(format!("--exit-node={ip}")) | |
| 143 | + | .run(log) | |
| 144 | + | .map(drop) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()> { | |
| 148 | + | Invocation::new("tailscale") | |
| 149 | + | .arg("set") | |
| 150 | + | .arg("--exit-node=") | |
| 151 | + | .run(log) | |
| 152 | + | .map(drop) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// Fixed sample state, for machines without Tailscale. | |
| 157 | + | pub struct Mock; | |
| 158 | + | ||
| 159 | + | impl Backend for Mock { | |
| 160 | + | fn name(&self) -> &'static str { | |
| 161 | + | "mock" | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | fn status(&self, log: &mut CommandLog) -> Result<TailStatus> { | |
| 165 | + | log.record("# no tailscale; showing mock tailnet", Severity::Warn); | |
| 166 | + | Ok(TailStatus { | |
| 167 | + | backend_state: "Running".into(), | |
| 168 | + | health: Vec::new(), | |
| 169 | + | peers: vec![ | |
| 170 | + | Peer { | |
| 171 | + | hostname: "fw13".into(), | |
| 172 | + | os: "linux".into(), | |
| 173 | + | ip: Some("100.64.0.1".into()), | |
| 174 | + | online: true, | |
| 175 | + | is_self: true, | |
| 176 | + | is_exit_node: false, | |
| 177 | + | offers_exit_node: false, | |
| 178 | + | last_seen: None, | |
| 179 | + | }, | |
| 180 | + | Peer { | |
| 181 | + | hostname: "astra".into(), | |
| 182 | + | os: "linux".into(), | |
| 183 | + | ip: Some("100.64.0.2".into()), | |
| 184 | + | online: true, | |
| 185 | + | is_self: false, | |
| 186 | + | is_exit_node: false, | |
| 187 | + | offers_exit_node: true, | |
| 188 | + | last_seen: None, | |
| 189 | + | }, | |
| 190 | + | Peer { | |
| 191 | + | hostname: "phone".into(), | |
| 192 | + | os: "iOS".into(), | |
| 193 | + | ip: Some("100.64.0.3".into()), | |
| 194 | + | online: false, | |
| 195 | + | is_self: false, | |
| 196 | + | is_exit_node: false, | |
| 197 | + | offers_exit_node: false, | |
| 198 | + | last_seen: Some("2026-05-21".into()), | |
| 199 | + | }, | |
| 200 | + | ], | |
| 201 | + | }) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // The mock is a display fixture, not a simulator. | |
| 205 | + | fn set_exit_node(&self, _peer: &Peer, log: &mut CommandLog) -> Result<()> { | |
| 206 | + | log.record("# mock backend: exit node unchanged", Severity::Warn); | |
| 207 | + | Ok(()) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()> { | |
| 211 | + | log.record("# mock backend: exit node unchanged", Severity::Warn); | |
| 212 | + | Ok(()) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // ---- tailscale status --json ---- | |
| 217 | + | ||
| 218 | + | #[derive(Deserialize)] | |
| 219 | + | struct TsStatus { | |
| 220 | + | #[serde(default)] | |
| 221 | + | #[serde(rename = "BackendState")] | |
| 222 | + | backend_state: String, | |
| 223 | + | #[serde(default)] | |
| 224 | + | #[serde(rename = "Health")] | |
| 225 | + | health: Option<Vec<String>>, | |
| 226 | + | #[serde(rename = "Self")] | |
| 227 | + | self_node: Option<TsPeer>, | |
| 228 | + | #[serde(default)] | |
| 229 | + | #[serde(rename = "Peer")] | |
| 230 | + | peer: HashMap<String, TsPeer>, | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | #[derive(Deserialize)] | |
| 234 | + | struct TsPeer { | |
| 235 | + | #[serde(default)] | |
| 236 | + | #[serde(rename = "HostName")] | |
| 237 | + | host_name: String, | |
| 238 | + | #[serde(default)] | |
| 239 | + | #[serde(rename = "OS")] | |
| 240 | + | os: String, | |
| 241 | + | #[serde(default)] | |
| 242 | + | #[serde(rename = "TailscaleIPs")] | |
| 243 | + | tailscale_ips: Option<Vec<String>>, | |
| 244 | + | #[serde(default)] | |
| 245 | + | #[serde(rename = "Online")] | |
| 246 | + | online: bool, | |
| 247 | + | #[serde(default)] | |
| 248 | + | #[serde(rename = "ExitNode")] | |
| 249 | + | exit_node: bool, | |
| 250 | + | #[serde(default)] | |
| 251 | + | #[serde(rename = "ExitNodeOption")] | |
| 252 | + | exit_node_option: bool, | |
| 253 | + | #[serde(default)] | |
| 254 | + | #[serde(rename = "LastSeen")] | |
| 255 | + | last_seen: Option<String>, | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | impl TsPeer { | |
| 259 | + | fn into_peer(self, is_self: bool) -> Peer { | |
| 260 | + | Peer { | |
| 261 | + | // A peer that reports no hostname still needs to occupy an | |
| 262 | + | // identifiable row. | |
| 263 | + | hostname: if self.host_name.is_empty() { | |
| 264 | + | "(unnamed)".to_string() | |
| 265 | + | } else { | |
| 266 | + | self.host_name | |
| 267 | + | }, | |
| 268 | + | os: self.os, | |
| 269 | + | ip: preferred_ip(self.tailscale_ips.as_deref().unwrap_or_default()), | |
| 270 | + | // The local node reports `Online: false` in some backend states | |
| 271 | + | // even while it is plainly the machine running the command. | |
| 272 | + | online: self.online || is_self, | |
| 273 | + | is_self, | |
| 274 | + | is_exit_node: self.exit_node, | |
| 275 | + | offers_exit_node: self.exit_node_option, | |
| 276 | + | last_seen: self.last_seen.as_deref().and_then(last_seen_date), | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | fn parse_status(raw: &str) -> Result<TailStatus> { | |
| 282 | + | let parsed: TsStatus = | |
| 283 | + | serde_json::from_str(raw).context("tailscale emitted invalid JSON")?; | |
| 284 | + | ||
| 285 | + | let mut peers: Vec<Peer> = parsed | |
| 286 | + | .peer | |
| 287 | + | .into_values() | |
| 288 | + | .map(|peer| peer.into_peer(false)) | |
| 289 | + | .collect(); | |
| 290 | + | ||
| 291 | + | // Sorted for a stable screen: this machine first, then online peers, then | |
| 292 | + | // by name. `Peer` arrives as a map keyed by public key, so iteration order | |
| 293 | + | // is arbitrary and the list would otherwise reshuffle on every refresh — | |
| 294 | + | // with the cursor sitting on whatever landed under it. | |
| 295 | + | peers.sort_by(|a, b| { | |
| 296 | + | b.online | |
| 297 | + | .cmp(&a.online) | |
| 298 | + | .then_with(|| a.hostname.to_lowercase().cmp(&b.hostname.to_lowercase())) | |
| 299 | + | }); | |
| 300 | + | if let Some(self_node) = parsed.self_node { | |
| 301 | + | peers.insert(0, self_node.into_peer(true)); | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | Ok(TailStatus { | |
| 305 | + | backend_state: parsed.backend_state, | |
| 306 | + | health: parsed.health.unwrap_or_default(), | |
| 307 | + | peers, | |
| 308 | + | }) | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | /// Pick the address to show: IPv4 when there is one. | |
| 312 | + | /// | |
| 313 | + | /// Tailscale hands out both, v4 first in practice, but ordering is not | |
| 314 | + | /// promised. The v4 is the one people recognize and type. | |
| 315 | + | fn preferred_ip(ips: &[String]) -> Option<String> { | |
| 316 | + | ips.iter() | |
| 317 | + | .find(|ip| !ip.contains(':')) | |
| 318 | + | .or_else(|| ips.first()) | |
| 319 | + | .cloned() | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | /// Date portion of a `LastSeen` timestamp, or `None` when it is Go's zero | |
| 323 | + | /// time. | |
| 324 | + | /// | |
| 325 | + | /// Online peers carry the zero value rather than omitting the field, so this | |
| 326 | + | /// has to be filtered rather than trusted. Only the date is kept: a relative | |
| 327 | + | /// "3 days ago" would need a date library for something a column this narrow | |
| 328 | + | /// cannot show anyway. | |
| 329 | + | fn last_seen_date(raw: &str) -> Option<String> { | |
| 330 | + | if raw.is_empty() || raw.starts_with(GO_ZERO_TIME_PREFIX) { | |
| 331 | + | return None; | |
| 332 | + | } | |
| 333 | + | raw.split('T').next().map(str::to_string) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | /// The `alloy tail` screen. | |
| 337 | + | pub struct TailView { | |
| 338 | + | backend: Box<dyn Backend>, | |
| 339 | + | status: Option<TailStatus>, | |
| 340 | + | cursor: Cursor, | |
| 341 | + | error: Option<String>, | |
| 342 | + | ticks: u64, | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | impl TailView { | |
| 346 | + | pub fn new(log: &mut CommandLog) -> Self { | |
| 347 | + | let mut view = Self { | |
| 348 | + | backend: detect(), | |
| 349 | + | status: None, | |
| 350 | + | cursor: Cursor::new(), | |
| 351 | + | error: None, | |
| 352 | + | ticks: 0, | |
| 353 | + | }; | |
| 354 | + | view.refresh(log); | |
| 355 | + | view | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | // As in `audio`: a successful refresh does not clear `error`, because | |
| 359 | + | // refreshes run on the background tick and would wipe an action's error | |
| 360 | + | // before it could be read. Keypresses clear it instead. | |
| 361 | + | fn refresh(&mut self, log: &mut CommandLog) { | |
| 362 | + | match self.backend.status(log) { | |
| 363 | + | Ok(status) => { | |
| 364 | + | self.cursor.resize(status.peers.len()); | |
| 365 | + | self.status = Some(status); | |
| 366 | + | } | |
| 367 | + | Err(err) => self.error = Some(err.to_string()), | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | fn peers(&self) -> &[Peer] { | |
| 372 | + | self.status.as_ref().map_or(&[], |status| &status.peers) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | fn selected(&self) -> Option<&Peer> { | |
| 376 | + | self.peers().get(self.cursor.selected()?) | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | fn set_exit_node(&mut self, log: &mut CommandLog) { | |
| 380 | + | let Some(peer) = self.selected() else { | |
| 381 | + | return; | |
| 382 | + | }; | |
| 383 | + | if peer.is_self { | |
| 384 | + | self.error = Some("cannot route through this machine".into()); | |
| 385 | + | return; | |
| 386 | + | } | |
| 387 | + | // Tailscale rejects this too, but saying it here names the peer and | |
| 388 | + | // avoids a failed command in the log for something knowable up front. | |
| 389 | + | if !peer.offers_exit_node { | |
| 390 | + | self.error = Some(format!("{} does not offer to be an exit node", peer.hostname)); | |
| 391 | + | return; | |
| 392 | + | } | |
| 393 | + | let result = self.backend.set_exit_node(peer, log); | |
| 394 | + | self.finish(result, log); | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | fn clear_exit_node(&mut self, log: &mut CommandLog) { | |
| 398 | + | let result = self.backend.clear_exit_node(log); | |
| 399 | + | self.finish(result, log); | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | fn finish(&mut self, result: Result<()>, log: &mut CommandLog) { | |
| 403 | + | match result { | |
| 404 | + | Ok(()) => log.quiet(|log| self.refresh(log)), | |
| 405 | + | Err(err) => self.error = Some(err.to_string()), | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | fn row<'a>(&self, theme: &Theme, peer: &'a Peer) -> Line<'a> { | |
| 410 | + | Line::from(vec![ | |
| 411 | + | text::bold(theme, format!("{:<18}", truncate(&peer.hostname, 17))), | |
| 412 | + | text::muted(theme, format!("{:<8}", truncate(&peer.os, 7))), | |
| 413 | + | text::secondary(theme, format!("{:<17}", peer.ip.as_deref().unwrap_or("-"))), | |
| 414 | + | Span::styled( | |
| 415 | + | format!("{:<9}", if peer.online { "online" } else { "offline" }), | |
| 416 | + | peer.severity().style(theme), | |
| 417 | + | ), | |
| 418 | + | text::muted(theme, peer.state_label()), | |
| 419 | + | ]) | |
| 420 | + | } | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | /// Clip to `width` columns, marking the clip. Counts `char`s rather than | |
| 424 | + | /// bytes: hostnames carry non-ASCII, and slicing those by byte index panics. | |
| 425 | + | fn truncate(text: &str, width: usize) -> String { | |
| 426 | + | if text.chars().count() <= width { | |
| 427 | + | return text.to_string(); | |
| 428 | + | } | |
| 429 | + | let kept: String = text.chars().take(width.saturating_sub(1)).collect(); | |
| 430 | + | format!("{kept}…") | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | impl View for TailView { | |
| 434 | + | fn title(&self) -> String { | |
| 435 | + | format!("tailnet ({})", self.backend.name()) | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | fn hints(&self) -> Vec<Hint> { | |
| 439 | + | vec![ | |
| 440 | + | hint("j/k", "select"), | |
| 441 | + | hint("e", "exit node"), | |
| 442 | + | hint("x", "clear exit"), | |
| 443 | + | hint("r", "refresh"), | |
| 444 | + | ] | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | fn status(&self) -> Option<(Severity, String)> { | |
| 448 | + | if let Some(error) = &self.error { | |
| 449 | + | return Some((Severity::Error, error.clone())); | |
| 450 | + | } | |
| 451 | + | let status = self.status.as_ref()?; | |
| 452 | + | // A health warning is Tailscale telling the user something is wrong | |
| 453 | + | // that the peer list alone will not show. | |
| 454 | + | if let Some(warning) = status.health.first() { | |
| 455 | + | return Some((Severity::Warn, warning.clone())); | |
| 456 | + | } | |
| 457 | + | // Backend state is only worth a line when it is not the normal one. | |
| 458 | + | (!status.is_running()).then(|| (Severity::Warn, status.backend_state.clone())) | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 462 | + | let block = AlloyBlock::new(theme) | |
| 463 | + | .focused(true) | |
| 464 | + | .build() | |
| 465 | + | .title(block_title(&self.title())); | |
| 466 | + | let inner = block.inner(area); | |
| 467 | + | frame.render_widget(block, area); | |
| 468 | + | ||
| 469 | + | let peers = self.peers(); | |
| 470 | + | if peers.is_empty() { | |
| 471 | + | frame.render_widget(Line::from(text::muted(theme, "no peers")), inner); | |
| 472 | + | return; | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | let rows: Vec<Line> = peers.iter().map(|peer| self.row(theme, peer)).collect(); | |
| 476 | + | frame.render_widget( | |
| 477 | + | AlloyList::new(theme, rows).selected(self.cursor.selected()), | |
| 478 | + | inner, | |
| 479 | + | ); | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow { | |
| 483 | + | self.error = None; | |
| 484 | + | ||
| 485 | + | match key.code { | |
| 486 | + | KeyCode::Char('j') | KeyCode::Down => self.cursor.next(), | |
| 487 | + | KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(), | |
| 488 | + | KeyCode::Char('e') => self.set_exit_node(log), | |
| 489 | + | KeyCode::Char('x') => self.clear_exit_node(log), | |
| 490 | + | KeyCode::Char('r') => self.refresh(log), | |
| 491 | + | _ => {} | |
| 492 | + | } | |
| 493 | + | Flow::Continue | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | fn tick(&mut self, log: &mut CommandLog) { | |
| 497 | + | self.ticks += 1; | |
| 498 | + | if self.ticks % POLL_TICKS == 0 { | |
| 499 | + | log.quiet(|log| self.refresh(log)); | |
| 500 | + | } |
Lines truncated