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 <noreply@anthropic.com>
This commit is contained in:
1 parent
2a0a8abd60
commit
d13bb00623
2 files changed
+146
-101
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
//! The per-network devices window: full detail about one network — its
|
//! The per-network devices window: full detail about one network — its
|
||||||
//! settings and counters, every connected peer in a table with an inline
|
//! settings and counters, and one table of every device, online first, with
|
||||||
//! traffic plot, and the members that are currently offline below.
|
//! inline traffic plots for the connected ones.
|
||||||
//!
|
//!
|
||||||
//! Rendered in a separate OS window (an egui viewport) opened from a tile in
|
//! Rendered in a separate OS window (an egui viewport) opened from a tile in
|
||||||
//! the main window.
|
//! the main window.
|
||||||
@@ -9,7 +9,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|
||||||
use tsunagi::ipc::NetworkReport;
|
use tsunagi::ipc::{NetworkReport, OverlayPeerReport};
|
||||||
|
|
||||||
use crate::agent::AgentClient;
|
use crate::agent::AgentClient;
|
||||||
use crate::format;
|
use crate::format;
|
||||||
@@ -20,6 +20,19 @@ pub(crate) fn title(network: &NetworkReport) -> String {
|
|||||||
format!("tsunagi · {}", network.name)
|
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<String>,
|
||||||
|
handshake_secs: Option<u64>,
|
||||||
|
overlay: Option<&'a OverlayPeerReport>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Draws the window body into its viewport.
|
/// Draws the window body into its viewport.
|
||||||
pub(crate) fn show(
|
pub(crate) fn show(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
@@ -39,6 +52,7 @@ pub(crate) fn show(
|
|||||||
});
|
});
|
||||||
// A copy of the chosen unit for the read-only rendering below.
|
// A copy of the chosen unit for the read-only rendering below.
|
||||||
let unit = *unit;
|
let unit = *unit;
|
||||||
|
|
||||||
summary(ui, network);
|
summary(ui, network);
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
|
|
||||||
@@ -46,36 +60,21 @@ pub(crate) fn show(
|
|||||||
format::sparkline(ui, traffic.network(&network.network_id), unit, 48.0);
|
format::sparkline(ui, traffic.network(&network.network_id), unit, 48.0);
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
|
|
||||||
let hostnames: HashMap<&str, &str> = network
|
let devices = devices(network);
|
||||||
.peers
|
let online = devices.iter().filter(|d| d.online).count();
|
||||||
.iter()
|
ui.label(
|
||||||
.filter_map(|p| p.hostname.as_deref().map(|h| (p.endpoint_id.as_str(), h)))
|
egui::RichText::new(format!(
|
||||||
.collect();
|
"devices ({} online / {} known)",
|
||||||
|
online,
|
||||||
let overlay = network.overlay.as_ref();
|
devices.len()
|
||||||
let online = overlay.map(|o| o.peers.as_slice()).unwrap_or(&[]);
|
))
|
||||||
|
.strong(),
|
||||||
ui.label(egui::RichText::new(format!("connected peers ({})", online.len())).strong());
|
);
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.max_height(260.0)
|
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.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.
|
/// Builds the device list: every endpoint known from a live tunnel or the
|
||||||
fn connected_table(
|
/// signed membership, online ones first.
|
||||||
|
fn devices(network: &NetworkReport) -> Vec<Device<'_>> {
|
||||||
|
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<Device> = 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,
|
ui: &mut egui::Ui,
|
||||||
unit: Unit,
|
unit: Unit,
|
||||||
network_id: &str,
|
network_id: &str,
|
||||||
peers: &[tsunagi::ipc::OverlayPeerReport],
|
devices: &[Device<'_>],
|
||||||
hostnames: &HashMap<&str, &str>,
|
|
||||||
traffic: &Traffic,
|
traffic: &Traffic,
|
||||||
) {
|
) {
|
||||||
if peers.is_empty() {
|
if devices.is_empty() {
|
||||||
ui.weak("none");
|
ui.weak("none");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
egui::Grid::new("connected")
|
egui::Grid::new("devices")
|
||||||
.num_columns(8)
|
.num_columns(8)
|
||||||
.striped(true)
|
.striped(true)
|
||||||
.spacing([10.0, 4.0])
|
.spacing([10.0, 4.0])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
for header in [
|
for header in [
|
||||||
"peer",
|
"device",
|
||||||
"proto",
|
"proto",
|
||||||
"address",
|
"address",
|
||||||
"handshake",
|
"handshake",
|
||||||
@@ -156,69 +215,41 @@ fn connected_table(
|
|||||||
}
|
}
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
|
||||||
for peer in peers {
|
for device in devices {
|
||||||
let name = hostnames
|
format::copy_field(ui, &device.name, &device.copy);
|
||||||
.get(peer.endpoint_id.as_str())
|
ui.label(device.proto);
|
||||||
.map_or_else(|| format::short(&peer.public_key), |h| (*h).to_string());
|
match &device.address {
|
||||||
format::copy_field(ui, &name, &name);
|
|
||||||
ui.label(&peer.protocol);
|
|
||||||
match &peer.address {
|
|
||||||
Some(address) => format::copy_field(ui, address, address),
|
Some(address) => format::copy_field(ui, address, address),
|
||||||
None => {
|
None => {
|
||||||
ui.weak("—");
|
ui.weak("—");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ui.label(
|
ui.label(
|
||||||
peer.handshake_secs_ago
|
device
|
||||||
.map_or_else(|| "never".to_string(), format::age),
|
.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.
|
match device.overlay.filter(|_| device.online) {
|
||||||
fn offline_table(
|
Some(peer) => {
|
||||||
ui: &mut egui::Ui,
|
ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets));
|
||||||
members: &[&tsunagi::ipc::MemberReport],
|
ui.label(format!(
|
||||||
hostnames: &HashMap<&str, &str>,
|
"{} / {}",
|
||||||
) {
|
format::bytes(peer.tx_bytes as f64),
|
||||||
egui::Grid::new("offline")
|
format::bytes(peer.rx_bytes as f64)
|
||||||
.num_columns(3)
|
));
|
||||||
.striped(true)
|
let series = traffic.peer(network_id, &peer.public_key);
|
||||||
.spacing([10.0, 4.0])
|
ui.add(egui::Label::new(format::series_rate(series, unit)).truncate());
|
||||||
.show(ui, |ui| {
|
ui.allocate_ui(egui::vec2(100.0, 20.0), |ui| {
|
||||||
for header in ["member", "address", "failed dials"] {
|
format::sparkline(ui, series, unit, 18.0);
|
||||||
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),
|
|
||||||
None => {
|
None => {
|
||||||
ui.weak("—");
|
for _ in 0..4 {
|
||||||
|
ui.weak("—");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ui.label(member.failed_dials.to_string());
|
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Formatting and small shared widgets: humanized ages, byte sizes and rates,
|
//! 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};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -9,9 +9,9 @@ use crate::stats::{Series, Unit};
|
|||||||
|
|
||||||
/// Sparkline accent.
|
/// Sparkline accent.
|
||||||
const ACCENT: egui::Color32 = egui::Color32::from_rgb(0x2f, 0x80, 0xd8);
|
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);
|
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);
|
const FLASH: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
/// A short, readable prefix of a long identifier (char-safe, no panic).
|
/// 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
|
/// 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
|
/// when clicked. Feedback is a green flash that fades back over a second — the
|
||||||
/// copy affordance is the field itself — no separate button.
|
/// 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) {
|
pub(crate) fn copy_field(ui: &mut egui::Ui, display: &str, value: &str) {
|
||||||
let id = ui.make_persistent_id(("copy", value, display));
|
let id = ui.make_persistent_id(("copy", value, display));
|
||||||
let copied_at: Option<Instant> = ui.memory(|memory| memory.data.get_temp(id));
|
let copied_at: Option<Instant> = 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 {
|
let text = match flash {
|
||||||
egui::RichText::new(format!("{display} Copied")).color(COPIED_GREEN)
|
Some(factor) => {
|
||||||
} else {
|
let base = ui.visuals().text_color();
|
||||||
egui::RichText::new(display)
|
egui::RichText::new(display).color(lerp_color(base, COPIED_GREEN, factor))
|
||||||
|
}
|
||||||
|
None => egui::RichText::new(display),
|
||||||
};
|
};
|
||||||
let response = ui
|
let response = ui
|
||||||
.add(egui::Label::new(text).sense(egui::Sense::click()))
|
.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.ctx().copy_text(value.to_owned());
|
||||||
ui.memory_mut(|memory| memory.data.insert_temp(id, Instant::now()));
|
ui.memory_mut(|memory| memory.data.insert_temp(id, Instant::now()));
|
||||||
}
|
}
|
||||||
if flashing {
|
if flash.is_some() {
|
||||||
// Keep repainting so the flash clears itself a second after the click.
|
// Animate the fade smoothly until it completes.
|
||||||
ui.ctx().request_repaint_after(Duration::from_millis(120));
|
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)
|
/// 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 —
|
/// and current value (bottom-right) overlaid. The space is always reserved —
|
||||||
/// even with no data — so the layout never jumps when data arrives.
|
/// even with no data — so the layout never jumps when data arrives.
|
||||||
|
|||||||
Reference in new issue
Block a user