Files
tsunagi/crates/tsunagi-tray/src/devices.rs
T
UltradesuandClaude Opus 4.8 db01c58a6f Tray: Phosphor icons, click-to-copy fields with feedback, steady plot column
- Bundle egui-phosphor (pinned to egui 0.31) and register its glyphs, so the
  icon buttons render real icons instead of tofu: refresh, shuffle (generate
  secret), pencil/check/x (rename host), copy (Copy secret).
- Copying is now the field itself, not a separate button: clicking the
  shortened device id, the network name, the network id, a range or an address
  copies the full value and the field flashes green "Copied" for a second
  (tracked in egui temp memory, so it clears itself). The standalone copy
  buttons are gone.
- In the devices window the connected-peers "rate / plot" column put the rate
  and the inline sparkline in a shared row, so the plot shifted as the rate
  text changed width ("0 B/s" vs "1.1 kB/s"). The rate now sits in a
  fixed-width, truncating cell, so the plot stays put.

Builds and is clippy-clean (-D warnings) on macOS; the UI has not been run in
this headless session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 16:00:23 +01:00

223 lines
7.4 KiB
Rust

//! 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.
//!
//! Rendered in a separate OS window (an egui viewport) opened from a tile in
//! the main window.
use std::collections::HashMap;
use eframe::egui;
use tsunagi::ipc::NetworkReport;
use crate::agent::AgentClient;
use crate::format;
use crate::stats::{Traffic, Unit};
/// A readable title for a network's window.
pub(crate) fn title(network: &NetworkReport) -> String {
format!("tsunagi · {}", network.name)
}
/// Draws the window body into its viewport.
pub(crate) fn show(
ctx: &egui::Context,
_agent: &AgentClient,
unit: Unit,
network: &NetworkReport,
traffic: &Traffic,
) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading(&network.name);
summary(ui, network);
ui.add_space(6.0);
ui.label(egui::RichText::new("traffic").strong());
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());
egui::ScrollArea::vertical()
.max_height(260.0)
.auto_shrink([false, false])
.show(ui, |ui| {
connected_table(ui, unit, &network.network_id, online, &hostnames, 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);
}
});
}
/// The network's own settings and counters.
fn summary(ui: &mut egui::Ui, network: &NetworkReport) {
egui::Grid::new("summary")
.num_columns(2)
.spacing([12.0, 3.0])
.show(ui, |ui| {
ui.weak("id");
format::copy_field(ui, &network.network_id, &network.network_id);
ui.end_row();
ui.weak("active");
ui.label(yes_no(network.active));
ui.end_row();
ui.weak("broadcast");
ui.label(yes_no(network.broadcast));
ui.end_row();
if let Some(range) = &network.range {
ui.weak("range");
format::copy_field(ui, range, range);
ui.end_row();
}
if let Some(address) = network.overlay.as_ref().and_then(|o| o.address.as_ref()) {
ui.weak("this device");
format::copy_field(ui, address, address);
ui.end_row();
}
ui.weak("candidates");
ui.label(network.candidates.to_string());
ui.end_row();
ui.weak("relay fwd/via/recv");
ui.label(format!(
"{} / {} / {}",
network.relay_forwarded, network.relay_sent_via, network.relay_received_via
));
ui.end_row();
ui.weak("dial/handshake fails");
ui.label(format!(
"{} / {}",
network.dial_failures, network.handshake_failures
));
ui.end_row();
});
}
/// The table of peers with a live tunnel.
fn connected_table(
ui: &mut egui::Ui,
unit: Unit,
network_id: &str,
peers: &[tsunagi::ipc::OverlayPeerReport],
hostnames: &HashMap<&str, &str>,
traffic: &Traffic,
) {
if peers.is_empty() {
ui.weak("none");
return;
}
egui::Grid::new("connected")
.num_columns(7)
.striped(true)
.spacing([10.0, 4.0])
.show(ui, |ui| {
for header in [
"peer",
"proto",
"address",
"handshake",
"pkts",
"bytes",
"rate / plot",
] {
ui.weak(header);
}
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 {
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),
);
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.horizontal(|ui| {
// A fixed-width rate so the inline plot does not shift as
// the number's length changes (e.g. "0 B/s" vs "1.1 kB/s").
ui.allocate_ui(egui::vec2(64.0, 18.0), |ui| {
ui.add(egui::Label::new(format::series_rate(series, unit)).truncate());
});
ui.allocate_ui(egui::vec2(90.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),
None => {
ui.weak("—");
}
}
ui.label(member.failed_dials.to_string());
ui.end_row();
}
});
}
fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}