From d13bb00623aca9f1d379bf3f8ffbfd521ae13a0c Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 2 Oct 2026 16:09:33 +0100 Subject: [PATCH] Tray: one device table (online + offline), FQDN copy, green-fade copy feedback - The devices window now shows a single device table instead of a separate "connected" box and an easily-missed "offline" box below it. Every endpoint known from a live tunnel or the signed membership appears, online first; offline rows show name and address with dashes for the live columns. The table fills the window and scrolls, so nothing hides off the bottom. - Clicking an online peer's name copies hostname.network (the FQDN) so it resolves; a name without a hostname copies its short id. - Copy feedback no longer appends "Copied" text (which reflowed the table for a second). The field just flashes green and fades back over a second, with no change to its text or width. Builds and is clippy-clean (-D warnings) on macOS; not run in this headless session. Co-Authored-By: Claude Opus 4.8 --- crates/tsunagi-tray/src/devices.rs | 207 +++++++++++++++++------------ crates/tsunagi-tray/src/format.rs | 40 ++++-- 2 files changed, 146 insertions(+), 101 deletions(-) diff --git a/crates/tsunagi-tray/src/devices.rs b/crates/tsunagi-tray/src/devices.rs index a1ff3cb..d5c6f56 100644 --- a/crates/tsunagi-tray/src/devices.rs +++ b/crates/tsunagi-tray/src/devices.rs @@ -1,6 +1,6 @@ //! The per-network devices window: full detail about one network — its -//! settings and counters, every connected peer in a table with an inline -//! traffic plot, and the members that are currently offline below. +//! settings and counters, and one table of every device, online first, with +//! inline traffic plots for the connected ones. //! //! Rendered in a separate OS window (an egui viewport) opened from a tile in //! the main window. @@ -9,7 +9,7 @@ use std::collections::HashMap; use eframe::egui; -use tsunagi::ipc::NetworkReport; +use tsunagi::ipc::{NetworkReport, OverlayPeerReport}; use crate::agent::AgentClient; use crate::format; @@ -20,6 +20,19 @@ pub(crate) fn title(network: &NetworkReport) -> String { format!("tsunagi · {}", network.name) } +/// One row of the device table, built from a tunnel and/or a signed member. +struct Device<'a> { + online: bool, + name: String, + /// What clicking the name copies: `hostname.network` so it resolves, or + /// the short id when there is no hostname. + copy: String, + proto: &'a str, + address: Option, + handshake_secs: Option, + overlay: Option<&'a OverlayPeerReport>, +} + /// Draws the window body into its viewport. pub(crate) fn show( ctx: &egui::Context, @@ -39,6 +52,7 @@ pub(crate) fn show( }); // A copy of the chosen unit for the read-only rendering below. let unit = *unit; + summary(ui, network); ui.add_space(6.0); @@ -46,36 +60,21 @@ pub(crate) fn show( format::sparkline(ui, traffic.network(&network.network_id), unit, 48.0); ui.add_space(8.0); - let hostnames: HashMap<&str, &str> = network - .peers - .iter() - .filter_map(|p| p.hostname.as_deref().map(|h| (p.endpoint_id.as_str(), h))) - .collect(); - - let overlay = network.overlay.as_ref(); - let online = overlay.map(|o| o.peers.as_slice()).unwrap_or(&[]); - - ui.label(egui::RichText::new(format!("connected peers ({})", online.len())).strong()); + let devices = devices(network); + let online = devices.iter().filter(|d| d.online).count(); + ui.label( + egui::RichText::new(format!( + "devices ({} online / {} known)", + online, + devices.len() + )) + .strong(), + ); egui::ScrollArea::vertical() - .max_height(260.0) .auto_shrink([false, false]) .show(ui, |ui| { - connected_table(ui, unit, &network.network_id, online, &hostnames, traffic); + device_table(ui, unit, &network.network_id, &devices, traffic); }); - - // Members with no live tunnel right now. - let online_ids: std::collections::HashSet<&str> = - online.iter().map(|p| p.endpoint_id.as_str()).collect(); - let offline: Vec<_> = network - .members - .iter() - .filter(|m| !online_ids.contains(m.endpoint_id.as_str())) - .collect(); - if !offline.is_empty() { - ui.add_space(8.0); - ui.label(egui::RichText::new(format!("offline members ({})", offline.len())).strong()); - offline_table(ui, &offline, &hostnames); - } }); } @@ -124,26 +123,86 @@ fn summary(ui: &mut egui::Ui, network: &NetworkReport) { }); } -/// The table of peers with a live tunnel. -fn connected_table( +/// Builds the device list: every endpoint known from a live tunnel or the +/// signed membership, online ones first. +fn devices(network: &NetworkReport) -> Vec> { + let hostnames: HashMap<&str, &str> = network + .peers + .iter() + .filter_map(|p| p.hostname.as_deref().map(|h| (p.endpoint_id.as_str(), h))) + .collect(); + let overlay_peers = network + .overlay + .as_ref() + .map(|o| o.peers.as_slice()) + .unwrap_or(&[]); + + let mut ids: Vec<&str> = Vec::new(); + for peer in overlay_peers { + if !ids.contains(&peer.endpoint_id.as_str()) { + ids.push(&peer.endpoint_id); + } + } + for member in &network.members { + if !ids.contains(&member.endpoint_id.as_str()) { + ids.push(&member.endpoint_id); + } + } + + let mut devices: Vec = ids + .into_iter() + .map(|id| { + let overlay = overlay_peers.iter().find(|p| p.endpoint_id == id); + let member = network.members.iter().find(|m| m.endpoint_id == id); + let online = overlay.is_some_and(|p| p.handshake_secs_ago.is_some()); + let hostname = hostnames.get(id).copied(); + let name = hostname.map_or_else( + || format::short(overlay.map_or(id, |p| p.public_key.as_str())), + str::to_string, + ); + let copy = hostname.map_or_else(|| name.clone(), |h| format!("{h}.{}", network.name)); + Device { + online, + name, + copy, + proto: overlay.map_or("—", |p| p.protocol.as_str()), + address: overlay + .and_then(|p| p.address.clone()) + .or_else(|| member.and_then(|m| m.overlay_address_v4.clone())), + handshake_secs: overlay.and_then(|p| p.handshake_secs_ago), + overlay, + } + }) + .collect(); + + devices.sort_by(|a, b| { + b.online + .cmp(&a.online) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + devices +} + +/// One table of all devices: online rows carry live counters and a plot, +/// offline rows show what is known (name and address) and dashes. +fn device_table( ui: &mut egui::Ui, unit: Unit, network_id: &str, - peers: &[tsunagi::ipc::OverlayPeerReport], - hostnames: &HashMap<&str, &str>, + devices: &[Device<'_>], traffic: &Traffic, ) { - if peers.is_empty() { + if devices.is_empty() { ui.weak("none"); return; } - egui::Grid::new("connected") + egui::Grid::new("devices") .num_columns(8) .striped(true) .spacing([10.0, 4.0]) .show(ui, |ui| { for header in [ - "peer", + "device", "proto", "address", "handshake", @@ -156,69 +215,41 @@ fn connected_table( } ui.end_row(); - for peer in peers { - let name = hostnames - .get(peer.endpoint_id.as_str()) - .map_or_else(|| format::short(&peer.public_key), |h| (*h).to_string()); - format::copy_field(ui, &name, &name); - ui.label(&peer.protocol); - match &peer.address { + for device in devices { + format::copy_field(ui, &device.name, &device.copy); + ui.label(device.proto); + match &device.address { Some(address) => format::copy_field(ui, address, address), None => { ui.weak("—"); } } ui.label( - peer.handshake_secs_ago - .map_or_else(|| "never".to_string(), format::age), + device + .handshake_secs + .map_or_else(|| "offline".to_string(), format::age), ); - ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets)); - ui.label(format!( - "{} / {}", - format::bytes(peer.tx_bytes as f64), - format::bytes(peer.rx_bytes as f64) - )); - let series = traffic.peer(network_id, &peer.public_key); - // Rate and plot are separate grid columns, so the plot column - // starts at the same x on every row regardless of the rate's - // text width. - ui.add(egui::Label::new(format::series_rate(series, unit)).truncate()); - ui.allocate_ui(egui::vec2(100.0, 20.0), |ui| { - format::sparkline(ui, series, unit, 18.0); - }); - ui.end_row(); - } - }); -} -/// The table of members with no current tunnel. -fn offline_table( - ui: &mut egui::Ui, - members: &[&tsunagi::ipc::MemberReport], - hostnames: &HashMap<&str, &str>, -) { - egui::Grid::new("offline") - .num_columns(3) - .striped(true) - .spacing([10.0, 4.0]) - .show(ui, |ui| { - for header in ["member", "address", "failed dials"] { - ui.weak(header); - } - ui.end_row(); - - for member in members { - let name = hostnames - .get(member.endpoint_id.as_str()) - .map_or_else(|| format::short(&member.endpoint_id), |h| (*h).to_string()); - format::copy_field(ui, &name, &member.endpoint_id); - match &member.overlay_address_v4 { - Some(address) => format::copy_field(ui, address, address), + match device.overlay.filter(|_| device.online) { + Some(peer) => { + ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets)); + ui.label(format!( + "{} / {}", + format::bytes(peer.tx_bytes as f64), + format::bytes(peer.rx_bytes as f64) + )); + let series = traffic.peer(network_id, &peer.public_key); + ui.add(egui::Label::new(format::series_rate(series, unit)).truncate()); + ui.allocate_ui(egui::vec2(100.0, 20.0), |ui| { + format::sparkline(ui, series, unit, 18.0); + }); + } None => { - ui.weak("—"); + for _ in 0..4 { + ui.weak("—"); + } } } - ui.label(member.failed_dials.to_string()); ui.end_row(); } }); diff --git a/crates/tsunagi-tray/src/format.rs b/crates/tsunagi-tray/src/format.rs index 40bfd8d..259fbd4 100644 --- a/crates/tsunagi-tray/src/format.rs +++ b/crates/tsunagi-tray/src/format.rs @@ -1,5 +1,5 @@ //! Formatting and small shared widgets: humanized ages, byte sizes and rates, -//! a fixed-size sparkline, and a click-to-copy field with "Copied" feedback. +//! a fixed-size sparkline, and a click-to-copy field with a green-flash feedback. use std::time::{Duration, Instant}; @@ -9,9 +9,9 @@ use crate::stats::{Series, Unit}; /// Sparkline accent. const ACCENT: egui::Color32 = egui::Color32::from_rgb(0x2f, 0x80, 0xd8); -/// The green a field flashes when its value is copied. +/// The green a field flashes to when its value is copied. const COPIED_GREEN: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a); -/// How long the "Copied" feedback stays. +/// How long the copy flash takes to fade back to normal. const FLASH: Duration = Duration::from_secs(1); /// A short, readable prefix of a long identifier (char-safe, no panic). @@ -73,17 +73,24 @@ pub(crate) fn series_rate(series: Option<&Series>, unit: Unit) -> String { } /// A label whose text is `display` and which copies `value` to the clipboard -/// when clicked, flashing green with "Copied" for a second as feedback. The -/// copy affordance is the field itself — no separate button. +/// when clicked. Feedback is a green flash that fades back over a second — the +/// text itself never changes, so a dense table does not reflow on a copy. pub(crate) fn copy_field(ui: &mut egui::Ui, display: &str, value: &str) { let id = ui.make_persistent_id(("copy", value, display)); let copied_at: Option = ui.memory(|memory| memory.data.get_temp(id)); - let flashing = copied_at.is_some_and(|at| at.elapsed() < FLASH); + // 1.0 right after the click, fading to 0.0 a second later. + let flash = copied_at.and_then(|at| { + let elapsed = at.elapsed().as_secs_f32(); + let span = FLASH.as_secs_f32(); + (elapsed < span).then(|| 1.0 - elapsed / span) + }); - let text = if flashing { - egui::RichText::new(format!("{display} Copied")).color(COPIED_GREEN) - } else { - egui::RichText::new(display) + let text = match flash { + Some(factor) => { + let base = ui.visuals().text_color(); + egui::RichText::new(display).color(lerp_color(base, COPIED_GREEN, factor)) + } + None => egui::RichText::new(display), }; let response = ui .add(egui::Label::new(text).sense(egui::Sense::click())) @@ -93,12 +100,19 @@ pub(crate) fn copy_field(ui: &mut egui::Ui, display: &str, value: &str) { ui.ctx().copy_text(value.to_owned()); ui.memory_mut(|memory| memory.data.insert_temp(id, Instant::now())); } - if flashing { - // Keep repainting so the flash clears itself a second after the click. - ui.ctx().request_repaint_after(Duration::from_millis(120)); + if flash.is_some() { + // Animate the fade smoothly until it completes. + ui.ctx().request_repaint(); } } +/// Linear blend between two colours, `t` in `0.0..=1.0` toward `b`. +fn lerp_color(a: egui::Color32, b: egui::Color32, t: f32) -> egui::Color32 { + let t = t.clamp(0.0, 1.0); + let mix = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round() as u8; + egui::Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b())) +} + /// A fixed-height sparkline of a series' recent rate, with the peak (top-left) /// and current value (bottom-right) overlaid. The space is always reserved — /// even with no data — so the layout never jumps when data arrives.