Skip to main content

max / alloy

console: add alloy tail Third subcommand, chosen the same way as the second: swaymsg, wlr-randr, and rpm-ostree are all absent here, so display and update would still be mock-only. tailscale is installed and logged in, so this one can be checked against a real tailnet. One invocation for the whole screen. tailscale status --json carries the local node, every peer, the backend state, and health warnings together, so unlike audio this view costs a single logged line per refresh. Two traps in the real output, both now pinned by tests. LastSeen is Go's zero time, not an absent field, for any peer that is currently online. Rendered literally every online peer reads as last seen in year 1. It is filtered on the 0001-01-01 prefix, and the ignored live test asserts that value never reaches a row. Peer arrives as a map keyed by public key, so iteration order is arbitrary and the list would reshuffle on every refresh, with the cursor sitting on whatever landed under it. Sorted explicitly: this machine first, then online, then by name. Peers are addressed by IP rather than hostname when setting an exit node. Hostnames collide (three devices on this tailnet answer to localhost) and MagicDNS may be off; the tailnet IP is unique and always resolvable. Display prefers the v4 of the pair because it is the one people recognize, rather than taking whichever Tailscale happened to list first. BackendState is kept as a string. It is a Tailscale-owned vocabulary that gains members, and showing an unfamiliar one beats collapsing it to unknown. NeedsLogin and Stopped both arrive with no Self key and no peers, so both are parsed as an empty list rather than unwrapping something absent. Refusals happen before the command runs: routing through this machine, or through a peer that does not advertise itself as an exit node. Tailscale would reject both, but refusing here names the peer instead of leaving a failed command in the log for something knowable up front. Verified against the real tailnet: nine nodes, correct ordering, real last-seen dates on the offline ones, no zero-time leakage. The exit-node actions are not verified against tailscale. No peer on this tailnet advertises itself as an exit node, so set and clear are covered by the mock and by the refusal paths only. The commands themselves are one-liners, but nothing has run them for real. Not eyeballed.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-19 17:59 UTC
Signed with PGP, not checked
Commit: 1345e6151fea50c6b0276a026d0b77edfe418763
Parent: e28ba88
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