Files
tsunagi/crates/tsunagi-tray/src/devices.rs
T
Ultradesu 4e235cbac6 Keep device data paths on one line
The tray used a fixed sample for the data-path column, so longer relay names wrapped and made device rows uneven. Measure each rendered path and retain the widest value, then render path labels without wrapping. Replace direct/via words with embedded Phosphor route icons while preserving transport, relay name, hop count and explanatory tooltips. The app already ships the font; keep its setup in one function and verify registration alongside a long-relay-width regression test. No wire or stored-state changes. Checks: cargo fmt --all -- --check; cargo clippy --locked --workspace --all-targets -- -D warnings; cargo test --locked --workspace --all-targets.
2026-10-14 13:50:05 +01:00

1142 lines
40 KiB
Rust

//! The per-network devices window: full detail about one network — its
//! 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.
use std::collections::HashMap;
use eframe::egui;
use egui_phosphor::regular as icon;
use tsunagi::ipc::{DataPathReport, NetworkReport, OverlayPeerReport};
use crate::agent::{AgentClient, Command};
use crate::exit;
use crate::format;
use crate::stats::{Traffic, Unit};
/// A device that works but not as well as it could: reached through others,
/// or with a name somebody else holds.
const AMBER: egui::Color32 = egui::Color32::from_rgb(0xd9, 0xa4, 0x2c);
/// A readable title for a network's window.
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> {
/// Observed claim version, if this row came from signed membership.
record_version: Option<u64>,
/// The agent's own answer to whether it can be reached by any path.
online: bool,
/// How packets reach it.
path: Option<&'a DataPathReport>,
/// What to call the member its traffic goes through, when it does.
via_name: Option<String>,
/// The name it claimed that another member holds, and what to call that
/// member.
name_taken: Option<(&'a str, String)>,
/// The last hostname seen for it, kept while it is offline.
hostname: Option<&'a str>,
/// What clicking the hostname copies: `hostname.network`, so it resolves.
copy: Option<String>,
/// The full endpoint id, which clicking the short form copies.
id: &'a str,
/// End-to-end tunnel protocol, independent of the path carrying it.
tunnel_protocol: &'a str,
address: Option<String>,
handshake_secs: Option<u64>,
control_rtt_ms: Option<u64>,
overlay: Option<&'a OverlayPeerReport>,
/// It is connected and offers to be an exit node.
exit_node: bool,
/// It is the exit node this device sends its internet traffic through.
in_use: bool,
}
/// An exit-node choice the user has to confirm: all their traffic goes through
/// somebody else's device, or stops doing so.
pub(crate) struct ExitConfirm {
network_id: String,
peer_id: String,
name: String,
/// Stop using it, rather than start.
stop: bool,
/// Asked in the main window, not in the devices window of the network.
main: bool,
}
/// An exact offline claim selected for durable cleanup.
pub(crate) struct ForgetConfirm {
network_id: String,
peer: String,
version: u64,
name: String,
address: Option<String>,
}
impl ExitConfirm {
/// A question about stopping the exit node a network's tile shows.
pub(crate) fn stop_from_tile(network: &NetworkReport, peer_id: &str) -> Self {
Self {
network_id: network.network_id.clone(),
peer_id: peer_id.to_string(),
name: exit::name_of(network, peer_id),
stop: true,
main: true,
}
}
}
impl Device<'_> {
fn name(&self) -> String {
self.hostname
.map_or_else(|| format::short(self.id), str::to_string)
}
fn sort_key(&self) -> String {
self.hostname.unwrap_or(self.id).to_lowercase()
}
}
/// The first characters of an endpoint id, enough to tell devices apart.
fn short_id(id: &str) -> String {
id.chars().take(5).collect()
}
/// The device cell: the hostname and, in brackets, the start of the id.
/// Clicking either copies its own full value.
fn name_cell(ui: &mut egui::Ui, device: &Device<'_>) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
match (device.hostname, &device.copy) {
(Some(hostname), Some(copy)) => {
format::copy_field(ui, hostname, copy);
format::copy_field(ui, &format!("({})", short_id(device.id)), device.id);
}
_ => format::copy_field(ui, &short_id(device.id), device.id),
}
if let Some((name, holder)) = &device.name_taken {
ui.label(egui::RichText::new(egui_phosphor::regular::WARNING).color(AMBER))
.on_hover_text(format!(
"Name `{name}` belongs to {holder}. Use this device's address."
));
}
});
}
/// Draws the window body into its viewport.
pub(crate) fn show(
ctx: &egui::Context,
agent: &AgentClient,
unit: &mut Unit,
confirmations: (&mut Option<ExitConfirm>, &mut Option<ForgetConfirm>),
network: &NetworkReport,
own_id: &str,
traffic: &Traffic,
) {
let (confirm, forget_confirm) = confirmations;
egui::CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
ui.heading(&network.name);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.selectable_value(unit, Unit::Bytes, "bytes");
ui.selectable_value(unit, Unit::Packets, "pkts");
ui.label("show:");
});
});
// A copy of the chosen unit for the read-only rendering below.
let unit = *unit;
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 devices = devices(network, own_id);
let online = devices.iter().filter(|d| d.online).count();
ui.label(
egui::RichText::new(format!(
"devices ({} online / {} known)",
online,
devices.len()
))
.strong(),
);
egui::ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| {
device_table(
ui,
unit,
network,
&devices,
traffic,
confirm,
forget_confirm,
);
});
});
confirm_exit(ctx, agent, confirm, Some(&network.network_id));
confirm_forget(ctx, agent, forget_confirm, &network.network_id);
}
fn confirm_forget(
ctx: &egui::Context,
agent: &AgentClient,
confirm: &mut Option<ForgetConfirm>,
network: &str,
) {
let Some(pending) = confirm.as_ref().filter(|c| c.network_id == network) else {
return;
};
egui::Area::new(egui::Id::new("forget node backdrop"))
.order(egui::Order::Middle)
.fixed_pos(egui::Pos2::ZERO)
.show(ctx, |ui| {
let screen = ctx.screen_rect();
ui.allocate_response(screen.size(), egui::Sense::click_and_drag());
ui.painter()
.rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140));
});
let (mut accepted, mut cancelled) = (false, false);
egui::Window::new("Forget offline node")
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.set_max_width(440.0);
ui.label(format!("Forget {}?", pending.name));
ui.monospace(&pending.peer);
if let Some(address) = &pending.address {
ui.label(format!("IP: {address}"));
}
ui.label("The network will forget this claim and free its name and IP. A returning device can publish a new claim; this does not ban it.");
ui.horizontal(|ui| {
accepted = ui.add_enabled(!agent.snapshot().busy, egui::Button::new("Forget")).clicked();
cancelled = ui.button("Cancel").clicked();
});
});
if accepted {
agent.send(Command::ForgetNode {
network_id: pending.network_id.clone(),
peer: pending.peer.clone(),
expected_version: pending.version,
});
}
if accepted || cancelled || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
*confirm = None;
}
}
/// The confirmation for an exit-node choice, drawn in the window it was
/// made in. Everything behind it is blocked until it is answered.
///
/// `network_id` is the devices window the question is drawn in, or `None` for
/// the main window; a question appears in the window it was asked in only.
pub(crate) fn confirm_exit(
ctx: &egui::Context,
agent: &AgentClient,
confirm: &mut Option<ExitConfirm>,
network_id: Option<&str>,
) {
let Some(pending) = confirm.as_ref().filter(|c| match network_id {
Some(id) => !c.main && c.network_id == id,
None => c.main,
}) else {
return;
};
egui::Area::new(egui::Id::new("exit confirm backdrop"))
.order(egui::Order::Middle)
.fixed_pos(egui::Pos2::ZERO)
.show(ctx, |ui| {
let screen = ctx.screen_rect();
ui.allocate_response(screen.size(), egui::Sense::click_and_drag());
ui.painter()
.rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140));
});
let (mut accepted, mut cancelled) = (false, false);
egui::Window::new("Exit node")
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.set_max_width(380.0);
ui.label(if pending.stop {
format!("Stop using {}?", pending.name)
} else {
format!("Use {} as exit node?", pending.name)
});
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui
.button(if pending.stop { "Stop" } else { "Use" })
.clicked()
{
accepted = true;
}
if ui.button("Cancel").clicked() {
cancelled = true;
}
});
});
if accepted {
agent.send(Command::SetExitNode {
network_id: pending.network_id.clone(),
peer: (!pending.stop).then(|| pending.peer_id.clone()),
});
}
if accepted || cancelled || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
*confirm = None;
}
}
/// 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();
});
}
/// Every other device this network knows of, from a live tunnel or the signed
/// membership. This device is in the roster too but is not one of the others.
fn known_ids<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<&'a str> {
let overlay_peers = network
.overlay
.as_ref()
.map(|o| o.peers.as_slice())
.unwrap_or(&[]);
let mut ids: Vec<&str> = Vec::new();
let candidates = overlay_peers
.iter()
.map(|p| p.endpoint_id.as_str())
.chain(network.peers.iter().map(|p| p.endpoint_id.as_str()))
.chain(network.members.iter().map(|m| m.endpoint_id.as_str()))
.chain(network.exit.via.as_deref());
for id in candidates {
if id != own_id && !ids.contains(&id) {
ids.push(id);
}
}
ids
}
/// How many other devices can be reached now, by any path.
pub(crate) fn online_count(network: &NetworkReport, own_id: &str) -> usize {
devices(network, own_id).iter().filter(|d| d.online).count()
}
/// How many other devices this network knows of, connected or not.
pub(crate) fn known_count(network: &NetworkReport, own_id: &str) -> usize {
known_ids(network, own_id).len()
}
/// Builds the device list: every endpoint known from a live tunnel or the
/// signed membership, online ones first.
fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<Device<'a>> {
// The signed members carry the last name seen, so a device that is away
// is still called by it; a live announcement is fresher and wins.
let mut hostnames: HashMap<&str, &str> = network
.members
.iter()
.filter_map(|m| m.hostname.as_deref().map(|h| (m.endpoint_id.as_str(), h)))
.collect();
hostnames.extend(
network
.peers
.iter()
.filter_map(|p| p.hostname.as_deref().map(|h| (p.endpoint_id.as_str(), h))),
);
let overlay_peers = network
.overlay
.as_ref()
.map(|o| o.peers.as_slice())
.unwrap_or(&[]);
let ids = known_ids(network, own_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 = member.is_some_and(|m| m.online)
|| network.peers.iter().any(|p| p.endpoint_id == id);
let path = network
.peers
.iter()
.find(|p| p.endpoint_id == id)
.map(|p| &p.path)
.or_else(|| member.map(|m| &m.path));
let via_name = match path {
Some(DataPathReport::Relayed { via, .. }) => Some(exit::name_of(network, via)),
_ => None,
};
let name_taken = member.and_then(|m| {
m.hostname_conflict
.as_ref()
.map(|c| (c.name.as_str(), exit::name_of(network, &c.holder)))
});
let hostname = hostnames.get(id).copied();
Device {
record_version: member.map(|m| m.record_version),
online,
path,
via_name,
name_taken,
hostname,
copy: hostname.map(|h| format!("{h}.{}", network.name)),
id,
tunnel_protocol: 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),
control_rtt_ms: network
.peers
.iter()
.find(|p| p.endpoint_id == id)
.and_then(|p| p.rtt_ms),
overlay,
exit_node: online
&& network
.peers
.iter()
.any(|p| p.endpoint_id == id && p.exit_node),
in_use: network.exit.via.as_deref() == Some(id),
}
})
.collect();
devices.sort_by(|a, b| {
b.online
.cmp(&a.online)
.then_with(|| a.sort_key().cmp(&b.sort_key()))
});
devices
}
/// The widest value each column is expected to hold, so the table starts out
/// wide enough and does not reflow as numbers grow.
const COLUMN_SAMPLES: [&str; 10] = [
"device",
"exit",
"end-to-end",
"999d / 9999 ms",
"255.255.255.255",
"data path",
"99999/99999",
"1023.9 MB / 1023.9 MB",
"1023.9 kB/s",
"plot",
];
/// Column widths that only ever grow, kept across frames.
///
/// A grid sizes each column to its widest cell *this frame*, so a value that
/// gets shorter — `14.4 MB/s` giving way to `382 pkt/s` — would pull the
/// column in and push every cell after it. Remembering the widest seen keeps
/// the table where it is.
fn column_widths(ui: &egui::Ui, id: egui::Id) -> [f32; 10] {
let stored: Option<[f32; 10]> = ui.memory(|m| m.data.get_temp(id));
stored.unwrap_or_else(|| {
let font = egui::TextStyle::Body.resolve(ui.style());
let mut widths = [0.0; 10];
for (width, sample) in widths.iter_mut().zip(COLUMN_SAMPLES) {
if !sample.is_empty() {
*width = ui.fonts(|fonts| {
fonts
.layout_no_wrap(sample.to_owned(), font.clone(), egui::Color32::WHITE)
.size()
.x
});
}
}
widths
})
}
/// One cell, at least as wide as its column has ever been.
fn cell(ui: &mut egui::Ui, column: usize, widths: &[f32; 10], add: impl FnOnce(&mut egui::Ui)) {
let floor = widths[column];
ui.allocate_ui_with_layout(
egui::vec2(floor, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| {
ui.set_min_width(floor);
ui.set_max_width(floor);
add(ui);
},
);
}
fn stretch_columns(mut widths: [f32; 10], available: f32) -> [f32; 10] {
let gaps = 10.0 * (widths.len() - 1) as f32;
widths[9] = widths[9].max(100.0);
widths[9] += (available - gaps - widths.iter().sum::<f32>()).max(0.0);
widths
}
/// The exit-node mark of a device: shown when it offers to be one, bright
/// for the one in use, and clickable (to confirm) only while that can be done.
///
/// The one in use stays clickable even when it has gone away, since stopping
/// is the only way out of the traffic being blocked.
fn exit_cell(
ui: &mut egui::Ui,
device: &Device<'_>,
network_id: &str,
confirm: &mut Option<ExitConfirm>,
) {
if !device.exit_node && !device.in_use {
ui.label("");
return;
}
let (color, hint) = if device.in_use {
let color = if device.online {
exit::GOOD
} else {
exit::LOUD
};
(color, "in use as your exit node — click to stop")
} else {
(ui.visuals().text_color(), "Use this exit node")
};
let response = ui
.add(
egui::Label::new(
egui::RichText::new(egui_phosphor::regular::SIGN_OUT)
.size(16.0)
.color(color),
)
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(hint);
if response.clicked() {
*confirm = Some(ExitConfirm {
network_id: network_id.to_string(),
peer_id: device.id.to_string(),
name: device.name(),
stop: device.in_use,
main: false,
});
}
}
/// 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: &NetworkReport,
devices: &[Device<'_>],
traffic: &Traffic,
confirm: &mut Option<ExitConfirm>,
forget_confirm: &mut Option<ForgetConfirm>,
) {
let network_id = network.network_id.as_str();
if devices.is_empty() {
ui.weak("none");
return;
}
let widths_id = egui::Id::new(("devices column widths", network_id));
let mut widths = column_widths(ui, widths_id);
let font = egui::TextStyle::Body.resolve(ui.style());
for device in devices {
let name = format!(
"{} ({}){}",
device.name(),
short_id(device.id),
if !device.online { " Forget" } else { "" }
);
let natural = ui.fonts(|fonts| {
fonts
.layout_no_wrap(name, font.clone(), egui::Color32::WHITE)
.size()
.x
});
widths[0] = widths[0].max(natural + 24.0);
let (path, _) = data_path_text(device.path, device.via_name.as_deref(), device.online);
let path_width = ui.fonts(|fonts| {
fonts
.layout_no_wrap(path, font.clone(), egui::Color32::WHITE)
.size()
.x
});
widths[5] = widths[5].max(path_width);
}
ui.memory_mut(|m| m.data.insert_temp(widths_id, widths));
let widths = stretch_columns(widths, ui.available_width());
egui::Grid::new("devices")
.num_columns(10)
.min_col_width(0.0)
.striped(true)
.spacing([10.0, 4.0])
.show(ui, |ui| {
for (column, header) in [
"device",
"exit",
"end-to-end",
"handshake / RTT",
"address",
"data path",
"pkts",
"bytes",
"rate",
"plot",
]
.into_iter()
.enumerate()
{
cell(ui, column, &widths, |ui| {
let response = ui.weak(header);
match column {
2 => {
response.on_hover_text("End-to-end encryption.");
}
3 => {
response.on_hover_text("Handshake age / control-connection RTT.");
}
5 => {
response.on_hover_text("Transport carrying the encrypted tunnel.");
}
_ => {}
}
});
}
ui.end_row();
for device in devices {
cell(ui, 0, &widths, |ui| {
ui.horizontal(|ui| {
name_cell(ui, device);
if !device.online
&& !device.in_use
&& let Some(version) = device.record_version
&& ui
.add_enabled(network.active, egui::Button::new("Forget"))
.on_hover_text("Free this offline device's name and IPv4")
.clicked()
{
*forget_confirm = Some(ForgetConfirm {
network_id: network.network_id.clone(),
peer: device.id.into(),
version,
name: device.name(),
address: device.address.clone(),
});
}
});
});
cell(ui, 1, &widths, |ui| {
exit_cell(ui, device, network_id, confirm);
});
cell(ui, 2, &widths, |ui| {
ui.push_id((device.id, "end-to-end"), |ui| {
tunnel_cell(ui, device);
});
});
cell(ui, 3, &widths, |ui| {
ui.label(handshake_rtt_text(
device.handshake_secs,
device.control_rtt_ms,
displayed_protocol(device.tunnel_protocol) != "—",
))
.on_hover_text(if device.tunnel_protocol == "tcp-tls" {
"Session age / control RTT."
} else {
"Last handshake age / control RTT."
});
});
cell(ui, 4, &widths, |ui| match &device.address {
Some(address) => format::copy_field(ui, address, address),
None => {
ui.weak("—");
}
});
cell(ui, 5, &widths, |ui| {
ui.push_id((device.id, "data-path"), |ui| data_path_cell(ui, device));
});
match device.overlay.filter(|_| device.online) {
Some(peer) => {
let series = traffic.peer(network_id, &peer.public_key);
cell(ui, 6, &widths, |ui| {
ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets));
});
cell(ui, 7, &widths, |ui| {
ui.label(format!(
"{} / {}",
format::bytes(peer.tx_bytes as f64),
format::bytes(peer.rx_bytes as f64)
));
});
cell(ui, 8, &widths, |ui| {
ui.add(
egui::Label::new(format::series_rate(series, unit))
.wrap_mode(egui::TextWrapMode::Extend),
);
});
ui.allocate_ui(egui::vec2(widths[9], 20.0), |ui| {
format::sparkline(ui, series, unit, 18.0);
});
}
None => {
for column in 6..9 {
cell(ui, column, &widths, |ui| {
ui.weak("—");
});
}
cell(ui, 9, &widths, |ui| {
ui.weak("—");
});
}
}
ui.end_row();
}
// Everything this agent has moved through the overlay, including
// devices that have since gone away.
let (mut tx_packets, mut rx_packets, mut tx_bytes, mut rx_bytes) = (0u64, 0, 0, 0);
for peer in devices.iter().filter_map(|d| d.overlay) {
tx_packets += peer.tx_packets;
rx_packets += peer.rx_packets;
tx_bytes += peer.tx_bytes;
rx_bytes += peer.rx_bytes;
}
cell(ui, 0, &widths, |ui| {
ui.strong("total");
});
for column in 1..6 {
cell(ui, column, &widths, |ui| {
ui.label("");
});
}
cell(ui, 6, &widths, |ui| {
ui.strong(format!("{tx_packets}/{rx_packets}"));
});
cell(ui, 7, &widths, |ui| {
ui.strong(format!(
"{} / {}",
format::bytes(tx_bytes as f64),
format::bytes(rx_bytes as f64)
));
});
cell(ui, 8, &widths, |ui| {
ui.add(
egui::Label::new(
egui::RichText::new(format::series_rate(traffic.network(network_id), unit))
.strong(),
)
.wrap_mode(egui::TextWrapMode::Extend),
);
});
ui.end_row();
});
}
/// A protocol name as it should appear in the table.
fn displayed_protocol(protocol: &str) -> &str {
if protocol.is_empty() { "—" } else { protocol }
}
/// The end-to-end tunnel and its own health, without transport details.
fn handshake_rtt_text(handshake: Option<u64>, rtt: Option<u64>, has_tunnel: bool) -> String {
let age = handshake.map_or_else(
|| {
if has_tunnel {
"waiting".into()
} else {
"—".into()
}
},
format::age,
);
let rtt = rtt.map_or_else(|| "—".into(), |value| format!("{value} ms"));
format!("{age} / {rtt}")
}
fn tunnel_hint(protocol: &str) -> String {
let name = displayed_protocol(protocol);
if name == "tcp-tls" {
format!("{name}: Noise end-to-end encryption")
} else {
format!("{name}: WireGuard end-to-end encryption")
}
}
fn tunnel_cell(ui: &mut egui::Ui, device: &Device<'_>) {
let protocol = displayed_protocol(device.tunnel_protocol);
if protocol == "—" {
ui.weak(protocol);
return;
}
ui.label(protocol).on_hover_text(tunnel_hint(protocol));
}
/// Compact route label and whether that route is the preferred direct form.
/// The icon font is embedded by egui-phosphor and installed in `App::new`.
fn data_path_text(
path: Option<&DataPathReport>,
via_name: Option<&str>,
online: bool,
) -> (String, bool) {
if !online {
return ("offline".into(), false);
}
match path {
Some(DataPathReport::Direct { transport }) => (format!("{} {transport}", icon::LINK), true),
Some(DataPathReport::Relayed { hops, .. }) => (
format!(
"{} {} · {hops} hops",
icon::SHARE_NETWORK,
via_name.unwrap_or("another device")
),
false,
),
Some(DataPathReport::None) | None => ("no data path yet".into(), false),
}
}
/// How a device is reached right now.
///
/// A device reached through others is online, over a longer path, and says
/// so: the colour is the only difference in how it is treated.
fn data_path_cell(ui: &mut egui::Ui, device: &Device<'_>) {
let (text, good) = data_path_text(device.path, device.via_name.as_deref(), device.online);
let response = if good {
ui.add(egui::Label::new(text).wrap_mode(egui::TextWrapMode::Extend))
} else {
ui.add(
egui::Label::new(egui::RichText::new(text).color(AMBER))
.wrap_mode(egui::TextWrapMode::Extend),
)
};
response.on_hover_text(match device.path {
Some(DataPathReport::Direct { .. }) => "Direct transport; no mesh relay.",
Some(DataPathReport::Relayed { .. }) => "Traffic forwarded through another device.",
_ if device.online => "Control connected; data transport unavailable.",
_ => "Unreachable.",
});
}
fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use tsunagi::ipc::{HostnameConflictReport, MemberReport};
fn member(id: &str, online: bool, path: DataPathReport) -> MemberReport {
MemberReport {
record_version: 1,
endpoint_id: id.into(),
online,
path,
..Default::default()
}
}
#[test]
fn a_device_reached_through_another_is_online_whatever_its_tunnel_says() {
// The tray used to call a device online only while its tunnel had
// handshaken, which disagreed with the command line about the same
// device. Both now read the agent's answer.
let network = NetworkReport {
members: vec![
member("own", true, DataPathReport::None),
member(
"far",
true,
DataPathReport::Relayed {
hops: 2,
via: "near".into(),
},
),
member("away", false, DataPathReport::None),
],
..Default::default()
};
let all = devices(&network, "own");
let far = all.iter().find(|d| d.id == "far").unwrap();
assert!(far.online);
assert!(matches!(
far.path,
Some(DataPathReport::Relayed { hops: 2, .. })
));
assert!(!all.iter().find(|d| d.id == "away").unwrap().online);
assert_eq!(online_count(&network, "own"), 1);
assert_eq!(known_count(&network, "own"), 2);
}
#[test]
fn a_name_somebody_else_holds_is_carried_to_the_row() {
let mut taken = member("late", true, DataPathReport::None);
taken.hostname_conflict = Some(HostnameConflictReport {
name: "music".into(),
holder: "early".into(),
});
let network = NetworkReport {
members: vec![member("early", true, DataPathReport::None), taken],
..Default::default()
};
let all = devices(&network, "own");
let late = all.iter().find(|d| d.id == "late").unwrap();
assert_eq!(
late.name_taken.as_ref().map(|(name, _)| *name),
Some("music")
);
assert!(
all.iter()
.find(|d| d.id == "early")
.unwrap()
.name_taken
.is_none()
);
}
#[test]
fn tunnel_and_direct_transport_are_named_as_different_layers() {
assert_eq!(displayed_protocol("wg"), "wg");
assert_eq!(tunnel_hint("wg"), "wg: WireGuard end-to-end encryption");
assert_eq!(
data_path_text(
Some(&DataPathReport::Direct {
transport: "tcp-tls".into(),
}),
None,
true,
),
(format!("{} tcp-tls", icon::LINK), true)
);
assert_eq!(
tunnel_hint("tcp-tls"),
"tcp-tls: Noise end-to-end encryption"
);
}
#[test]
fn incomplete_and_longer_paths_stay_explicit() {
assert_eq!(handshake_rtt_text(None, Some(25), true), "waiting / 25 ms");
assert_eq!(
data_path_text(
Some(&DataPathReport::Relayed {
hops: 2,
via: "near".into(),
}),
Some("iris"),
true,
),
(format!("{} iris · 2 hops", icon::SHARE_NETWORK), false)
);
assert_eq!(
data_path_text(Some(&DataPathReport::None), None, true),
("no data path yet".into(), false)
);
assert_eq!(data_path_text(None, None, false), ("offline".into(), false));
}
#[test]
fn handshake_rtt_has_no_invented_values_and_uses_control_measurements() {
use tsunagi::ipc::{OverlayReport, PeerReport};
let network = NetworkReport {
peers: vec![PeerReport {
endpoint_id: "peer".into(),
rtt_ms: Some(27),
..Default::default()
}],
overlay: Some(OverlayReport {
peers: vec![OverlayPeerReport {
endpoint_id: "peer".into(),
protocol: "wg".into(),
handshake_secs_ago: Some(5),
..Default::default()
}],
..Default::default()
}),
..Default::default()
};
let rows = devices(&network, "own");
assert_eq!(
handshake_rtt_text(rows[0].handshake_secs, rows[0].control_rtt_ms, true),
"5s / 27 ms"
);
assert_eq!(handshake_rtt_text(None, None, false), "— / —");
assert_eq!(rows[0].tunnel_protocol, "wg");
assert!(!tunnel_hint(rows[0].tunnel_protocol).contains("wg-quic"));
}
#[test]
fn resized_table_fills_the_window_without_growing_fixed_columns() {
let minimum = [100.0; 10];
let wide = stretch_columns(minimum, 1440.0);
assert!((wide.iter().sum::<f32>() + 90.0 - 1440.0).abs() < 0.1);
assert_eq!(&wide[..9], &minimum[..9]);
let narrow = stretch_columns(minimum, 800.0);
assert_eq!(
narrow, minimum,
"narrow windows scroll rather than clipping columns"
);
assert_eq!(
stretch_columns(minimum, 1440.0),
wide,
"a resize does not accumulate padding"
);
}
#[test]
fn a_long_relay_name_gets_its_full_column_width() {
let mut relay = member("near", true, DataPathReport::None);
relay.hostname = Some("a-relay-with-a-long-device-name".into());
let network = NetworkReport {
network_id: "network".into(),
members: vec![
relay,
member(
"far",
true,
DataPathReport::Relayed {
hops: 2,
via: "near".into(),
},
),
],
..Default::default()
};
let rows = devices(&network, "own");
let far = rows.iter().find(|device| device.id == "far").unwrap();
let (label, _) = data_path_text(far.path, far.via_name.as_deref(), far.online);
let context = egui::Context::default();
let mut fonts = egui::FontDefinitions::default();
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
context.set_fonts(fonts);
let _ = context.run(egui::RawInput::default(), |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
device_table(
ui,
Unit::Bytes,
&network,
&rows,
&Traffic::default(),
&mut None,
&mut None,
);
let stored: [f32; 10] = ui
.memory(|memory| {
memory
.data
.get_temp(egui::Id::new(("devices column widths", "network")))
})
.unwrap();
let font = egui::TextStyle::Body.resolve(ui.style());
let needed = ui.fonts(|fonts| {
fonts
.layout_no_wrap(label.clone(), font, egui::Color32::WHITE)
.size()
.x
});
assert!(stored[5] >= needed);
});
});
}
#[test]
fn rendered_table_uses_window_width_without_an_expanding_device_cell() {
let context = egui::Context::default();
let network = NetworkReport {
members: vec![MemberReport {
endpoint_id: "0123456789abcdef".into(),
hostname: Some("laptop".into()),
online: true,
..Default::default()
}],
..Default::default()
};
let rows = devices(&network, "own");
for width in [1440.0, 1200.0, 1440.0] {
for _ in 0..3 {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(width, 640.0),
)),
..Default::default()
};
let _output = context.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
let right = ui.max_rect().right();
egui::ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| {
device_table(
ui,
Unit::Bytes,
&network,
&rows,
&Traffic::default(),
&mut None,
&mut None,
);
});
assert!(
(ui.min_rect().right() - right).abs() < 2.0,
"table width after resize: {} vs {right}",
ui.min_rect().right()
);
});
});
}
}
}
}