diff --git a/crates/tsunagi-tray/src/agent.rs b/crates/tsunagi-tray/src/agent.rs index 7558637..4f3ac7d 100644 --- a/crates/tsunagi-tray/src/agent.rs +++ b/crates/tsunagi-tray/src/agent.rs @@ -31,6 +31,8 @@ pub(crate) enum Command { Join { name: String, secret: String }, /// Leave a network entirely. Leave { network_id: String }, + /// Change the hostname this device announces. + SetHostname(String), } /// The latest the worker knows, read by the UI each frame. @@ -166,6 +168,10 @@ async fn run( .await .map(|report| format!("left {}", report.name)) .map_err(|err| err.to_string()), + Command::SetHostname(name) => ipc::set_hostname(socket, &name) + .await + .map(|accepted| format!("hostname: {accepted}")) + .map_err(|err| err.to_string()), }; { diff --git a/crates/tsunagi-tray/src/app.rs b/crates/tsunagi-tray/src/app.rs index 59fda26..bcf368d 100644 --- a/crates/tsunagi-tray/src/app.rs +++ b/crates/tsunagi-tray/src/app.rs @@ -1,26 +1,26 @@ -//! The eframe application: wires the tray, the window and the agent worker. +//! The eframe application: wires the tray, the main window, the per-network +//! devices windows, and the agent worker. use std::time::Duration; use eframe::egui; use crate::agent::{AgentClient, resolve_socket}; +use crate::devices; use crate::stats::Traffic; use crate::tray::{Action, Tray}; -use crate::ui::{self, JoinForm}; +use crate::ui::{self, UiState}; /// The tray application state. pub(crate) struct App { agent: AgentClient, tray: Tray, - join: JoinForm, + state: UiState, /// Traffic rates derived from successive snapshots. traffic: Traffic, - /// The snapshot generation last folded into `traffic`, so each refresh is - /// sampled exactly once rather than on every repaint. + /// The snapshot generation last folded into `traffic`. sampled: u64, - /// Set when the user chose Quit, so the next close actually exits instead - /// of hiding to the tray. + /// Set when the user chose Quit, so the next close exits rather than hides. quitting: bool, } @@ -36,7 +36,7 @@ impl App { Ok(Self { agent, tray, - join: JoinForm::default(), + state: UiState::default(), traffic: Traffic::default(), sampled: 0, quitting: false, @@ -59,24 +59,56 @@ impl eframe::App for App { } } - // Closing the window hides it to the tray; only Quit truly exits. + // Closing the main window hides it to the tray; only Quit truly exits. if ctx.input(|i| i.viewport().close_requested()) && !self.quitting { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); } let snapshot = self.agent.snapshot(); - // Fold each fresh status into the traffic rates exactly once. if snapshot.generation != self.sampled && let (Some(Ok(report)), Some(at)) = (&snapshot.status, snapshot.at) { self.traffic.observe(report, at); self.sampled = snapshot.generation; } - ui::draw(ctx, &self.agent, &mut self.join, &snapshot, &self.traffic); - // Keep ticking so fresh status and tray actions are picked up even when - // nothing else requests a repaint. + ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic); + self.show_devices_windows(ctx, &snapshot); + + // Keep ticking so fresh status and tray actions are picked up. ctx.request_repaint_after(Duration::from_millis(500)); } } + +impl App { + /// Renders one separate window per open network-devices view. + fn show_devices_windows(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) { + let Some(Ok(report)) = &snapshot.status else { + return; + }; + let unit = self.state.unit; + let open: Vec = self.state.open_devices.iter().cloned().collect(); + for id in open { + let Some(network) = report.networks.iter().find(|n| n.network_id == id) else { + // The network is gone (left/forgotten); close its window. + self.state.open_devices.remove(&id); + continue; + }; + let viewport_id = egui::ViewportId::from_hash_of(("devices", &id)); + let builder = egui::ViewportBuilder::default() + .with_title(devices::title(network)) + .with_inner_size([720.0, 540.0]) + .with_min_inner_size([480.0, 320.0]); + let agent = &self.agent; + let traffic = &self.traffic; + let keep = ctx.show_viewport_immediate(viewport_id, builder, |vctx, _class| { + devices::show(vctx, agent, unit, network, traffic); + !vctx.input(|i| i.viewport().close_requested()) + }); + if !keep { + self.state.open_devices.remove(&id); + } + } + } +} diff --git a/crates/tsunagi-tray/src/devices.rs b/crates/tsunagi-tray/src/devices.rs new file mode 100644 index 0000000..5406508 --- /dev/null +++ b/crates/tsunagi-tray/src/devices.rs @@ -0,0 +1,221 @@ +//! 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"); + ui.horizontal(|ui| { + ui.monospace(&network.network_id); + format::copy_button(ui, &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_label(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_label(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_label(ui, &name, &name); + ui.label(&peer.protocol); + match &peer.address { + Some(address) => format::copy_label(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| { + ui.label(format::series_rate(series, unit)); + 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_label(ui, &name, &member.endpoint_id); + ui.label( + member + .overlay_address_v4 + .clone() + .unwrap_or_else(|| "—".into()), + ); + ui.label(member.failed_dials.to_string()); + ui.end_row(); + } + }); +} + +fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} diff --git a/crates/tsunagi-tray/src/format.rs b/crates/tsunagi-tray/src/format.rs new file mode 100644 index 0000000..437da7d --- /dev/null +++ b/crates/tsunagi-tray/src/format.rs @@ -0,0 +1,144 @@ +//! Formatting and small shared widgets: humanized ages, byte sizes and rates, +//! a fixed-size sparkline, and click-to-copy labels. + +use eframe::egui; + +use crate::stats::{Series, Unit}; + +/// Sparkline accent. +const ACCENT: egui::Color32 = egui::Color32::from_rgb(0x2f, 0x80, 0xd8); + +/// A short, readable prefix of a long identifier (char-safe, no panic). +pub(crate) fn short(id: &str) -> String { + const KEEP: usize = 12; + if id.chars().count() <= KEEP { + id.to_string() + } else { + format!("{}…", id.chars().take(KEEP).collect::()) + } +} + +/// A duration in seconds as a compact age (`5s`, `12m`, `3h 20m`, `2d 4h`). +pub(crate) fn age(secs: u64) -> String { + if secs < 60 { + format!("{secs}s") + } else if secs < 3_600 { + format!("{}m", secs / 60) + } else if secs < 86_400 { + format!("{}h {}m", secs / 3_600, (secs % 3_600) / 60) + } else { + format!("{}d {}h", secs / 86_400, (secs % 86_400) / 3_600) + } +} + +/// A byte count in binary units. +pub(crate) fn bytes(n: f64) -> String { + const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"]; + let mut value = n.max(0.0); + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{} B", value as u64) + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +/// A rate in the chosen unit, per second. +pub(crate) fn rate(value: f32, unit: Unit) -> String { + match unit { + Unit::Packets => { + if value >= 1000.0 { + format!("{:.1}k pkt/s", value / 1000.0) + } else { + format!("{value:.0} pkt/s") + } + } + Unit::Bytes => format!("{}/s", bytes(f64::from(value))), + } +} + +/// 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. +pub(crate) fn sparkline(ui: &mut egui::Ui, series: Option<&Series>, unit: Unit, height: f32) { + let width = ui.available_width(); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let painter = ui.painter_at(rect); + let visuals = ui.visuals(); + painter.rect_filled(rect, 2.0, visuals.extreme_bg_color); + + let Some(series) = series else { + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + "no data yet", + egui::FontId::proportional(10.0), + visuals.weak_text_color(), + ); + return; + }; + + let history = series.history(unit); + let peak = history.iter().copied().fold(1.0_f32, f32::max); + if history.len() >= 2 { + let count = history.len(); + let points: Vec = history + .iter() + .enumerate() + .map(|(i, value)| { + let x = rect.left() + rect.width() * (i as f32 / (count - 1) as f32); + let y = rect.bottom() - 2.0 - (rect.height() - 12.0) * (value / peak); + egui::pos2(x, y) + }) + .collect(); + painter.add(egui::Shape::line( + points, + egui::Stroke::new(1.5_f32, ACCENT), + )); + } + + painter.text( + rect.left_top() + egui::vec2(4.0, 2.0), + egui::Align2::LEFT_TOP, + format!("peak {}", rate(peak, unit)), + egui::FontId::proportional(9.0), + visuals.weak_text_color(), + ); + painter.text( + rect.right_bottom() + egui::vec2(-4.0, -2.0), + egui::Align2::RIGHT_BOTTOM, + rate(series.rate(unit), unit), + egui::FontId::proportional(10.0), + visuals.strong_text_color(), + ); +} + +/// A label that copies `value` to the clipboard when clicked. +pub(crate) fn copy_label(ui: &mut egui::Ui, display: &str, value: &str) { + let response = ui + .add(egui::Label::new(display).sense(egui::Sense::click())) + .on_hover_text("click to copy"); + if response.clicked() { + ui.ctx().copy_text(value.to_owned()); + } +} + +/// A tiny clipboard button that copies `value`. +pub(crate) fn copy_button(ui: &mut egui::Ui, value: &str) { + if ui + .small_button("⧉") + .on_hover_text("copy to clipboard") + .clicked() + { + ui.ctx().copy_text(value.to_owned()); + } +} + +/// The current rate of a series as a string, or a dash when unsampled. +pub(crate) fn series_rate(series: Option<&Series>, unit: Unit) -> String { + series.map_or_else(|| "—".to_string(), |s| rate(s.rate(unit), unit)) +} diff --git a/crates/tsunagi-tray/src/main.rs b/crates/tsunagi-tray/src/main.rs index d2de2cd..05575a1 100644 --- a/crates/tsunagi-tray/src/main.rs +++ b/crates/tsunagi-tray/src/main.rs @@ -11,6 +11,8 @@ mod agent; mod app; +mod devices; +mod format; mod stats; mod tray; mod ui; diff --git a/crates/tsunagi-tray/src/stats.rs b/crates/tsunagi-tray/src/stats.rs index 2158761..3c1a3bd 100644 --- a/crates/tsunagi-tray/src/stats.rs +++ b/crates/tsunagi-tray/src/stats.rs @@ -1,9 +1,10 @@ //! Traffic rates derived from successive status snapshots. //! -//! The agent reports cumulative packet counters, not rates. The GUI samples -//! them each time a fresh snapshot arrives and turns the deltas into a -//! packets-per-second rate plus a short rolling history for a sparkline. One -//! series per key: a network total, and each peer within it. +//! The agent reports cumulative packet and byte counters, not rates. The GUI +//! samples them each time a fresh snapshot arrives and turns the deltas into +//! per-second rates plus short rolling histories for sparklines. One series per +//! key: a network total, and each peer within it. Both packets and bytes are +//! tracked so the UI can switch between them. use std::collections::{HashMap, VecDeque}; use std::time::Instant; @@ -11,32 +12,62 @@ use std::time::Instant; use tsunagi::ipc::StatusReport; /// How many rate samples to keep for a sparkline. -const HISTORY: usize = 48; +const HISTORY: usize = 60; -/// One series' rolling rate. +/// Which quantity a rate or history is of. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Unit { + /// Packets per second. + Packets, + /// Bytes per second. + Bytes, +} + +/// One series' rolling packet and byte rates. #[derive(Default)] pub(crate) struct Series { - last: Option<(Instant, u64)>, - /// Most recent packets-per-second (tx + rx). - pub rate: f32, - /// Recent rates, oldest first, for a sparkline. - pub history: VecDeque, + last: Option<(Instant, u64, u64)>, + packets_rate: f32, + bytes_rate: f32, + packets_history: VecDeque, + bytes_history: VecDeque, } impl Series { - fn sample(&mut self, now: Instant, total: u64) { - if let Some((then, previous)) = self.last { + fn sample(&mut self, now: Instant, packets: u64, bytes: u64) { + if let Some((then, prev_packets, prev_bytes)) = self.last { let dt = now.saturating_duration_since(then).as_secs_f32(); if dt > 0.0 { - let delta = total.saturating_sub(previous) as f32; - self.rate = delta / dt; - self.history.push_back(self.rate); - while self.history.len() > HISTORY { - self.history.pop_front(); - } + self.packets_rate = packets.saturating_sub(prev_packets) as f32 / dt; + self.bytes_rate = bytes.saturating_sub(prev_bytes) as f32 / dt; + push(&mut self.packets_history, self.packets_rate); + push(&mut self.bytes_history, self.bytes_rate); } } - self.last = Some((now, total)); + self.last = Some((now, packets, bytes)); + } + + /// The current rate in the chosen unit. + pub(crate) fn rate(&self, unit: Unit) -> f32 { + match unit { + Unit::Packets => self.packets_rate, + Unit::Bytes => self.bytes_rate, + } + } + + /// The rolling history in the chosen unit. + pub(crate) fn history(&self, unit: Unit) -> &VecDeque { + match unit { + Unit::Packets => &self.packets_history, + Unit::Bytes => &self.bytes_history, + } + } +} + +fn push(history: &mut VecDeque, value: f32) { + history.push_back(value); + while history.len() > HISTORY { + history.pop_front(); } } @@ -53,19 +84,21 @@ impl Traffic { let Some(overlay) = &network.overlay else { continue; }; - let mut net_total = 0u64; + let (mut net_packets, mut net_bytes) = (0u64, 0u64); for peer in &overlay.peers { - let total = peer.tx_packets.saturating_add(peer.rx_packets); - net_total = net_total.saturating_add(total); + let packets = peer.tx_packets.saturating_add(peer.rx_packets); + let bytes = peer.tx_bytes.saturating_add(peer.rx_bytes); + net_packets = net_packets.saturating_add(packets); + net_bytes = net_bytes.saturating_add(bytes); self.series .entry(peer_key(&network.network_id, &peer.public_key)) .or_default() - .sample(now, total); + .sample(now, packets, bytes); } self.series .entry(net_key(&network.network_id)) .or_default() - .sample(now, net_total); + .sample(now, net_packets, net_bytes); } } diff --git a/crates/tsunagi-tray/src/ui.rs b/crates/tsunagi-tray/src/ui.rs index 8229ed9..c52b92c 100644 --- a/crates/tsunagi-tray/src/ui.rs +++ b/crates/tsunagi-tray/src/ui.rs @@ -1,40 +1,61 @@ -//! The window contents, drawn from the latest agent snapshot. +//! The main window: a compact overview. Per-network detail (the full peer +//! table with inline plots) lives in a separate devices window (see +//! [`crate::devices`]), opened from each tile. //! -//! Pure rendering: state comes from the snapshot and the traffic sampler, and a -//! change to a switch or a button sends a [`Command`] to the worker. The layout -//! targets one fixed, small window, so everything is laid out on a grid and the -//! peer list hides behind a collapsing header. +//! Pure rendering: state comes from the snapshot and the traffic sampler, a +//! change to a switch or button sends a [`Command`] to the worker, and local UI +//! state (the join form, the unit toggle, which devices windows are open, an +//! in-progress hostname edit) lives in [`UiState`]. -use std::collections::VecDeque; +use std::collections::BTreeSet; use eframe::egui; -use tsunagi::identity::NetworkSecret; -use tsunagi::ipc::{NetworkReport, OverlayPeerReport, StatusReport}; +use tsunagi::ipc::{NetworkReport, StatusReport}; use crate::agent::{AgentClient, Command, Snapshot}; -use crate::stats::Traffic; +use crate::format; +use crate::stats::{Traffic, Unit}; -/// Width of the value column's input fields, so name and secret line up. -const INPUT_WIDTH: f32 = 208.0; -/// Accent used for the traffic sparkline. -const ACCENT: egui::Color32 = egui::Color32::from_rgb(0x2f, 0x80, 0xd8); const OK: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a); -const WARN: egui::Color32 = egui::Color32::from_rgb(0xd9, 0x9a, 0x00); const BAD: egui::Color32 = egui::Color32::from_rgb(0xd0, 0x4a, 0x3c); +/// A neutral, not-garish red for the Leave button. +const LEAVE_RED: egui::Color32 = egui::Color32::from_rgb(0xa8, 0x3a, 0x3a); -/// Join form state, kept across frames. +/// Join form fields. #[derive(Default)] pub(crate) struct JoinForm { pub(crate) name: String, pub(crate) secret: String, } -/// Draws the whole window. +/// Local UI state kept across frames. +pub(crate) struct UiState { + pub(crate) join: JoinForm, + /// Whether rates and sparklines are shown in packets or bytes. + pub(crate) unit: Unit, + /// An in-progress hostname edit (`None` when not editing). + pub(crate) host_edit: Option, + /// Network ids whose devices window is open. + pub(crate) open_devices: BTreeSet, +} + +impl Default for UiState { + fn default() -> Self { + Self { + join: JoinForm::default(), + unit: Unit::Packets, + host_edit: None, + open_devices: BTreeSet::new(), + } + } +} + +/// Draws the main window. pub(crate) fn draw( ctx: &egui::Context, agent: &AgentClient, - join: &mut JoinForm, + state: &mut UiState, snapshot: &Snapshot, traffic: &Traffic, ) { @@ -49,6 +70,10 @@ pub(crate) fn draw( if snapshot.busy { ui.add(egui::Spinner::new()); } + ui.separator(); + ui.selectable_value(&mut state.unit, Unit::Bytes, "bytes"); + ui.selectable_value(&mut state.unit, Unit::Packets, "pkts"); + ui.label("show:"); }); }); ui.add_space(4.0); @@ -63,7 +88,7 @@ pub(crate) fn draw( }; } ui.add_space(4.0); - draw_join(ui, agent, join); + draw_join(ui, agent, &mut state.join); ui.add_space(4.0); }); @@ -74,7 +99,7 @@ pub(crate) fn draw( } Some(Err(error)) => draw_unreachable(ui, agent, error), Some(Ok(report)) => { - draw_header(ui, report); + draw_header(ui, agent, state, report); ui.separator(); egui::ScrollArea::vertical() .auto_shrink([false, false]) @@ -84,19 +109,22 @@ pub(crate) fn draw( ui.weak("No networks yet — join one below."); } for network in &report.networks { - draw_network(ui, agent, traffic, network); + draw_network(ui, agent, state, traffic, network); } }); } }); } -/// The "agent cannot be reached" state, naming the socket it tried. +/// The "agent cannot be reached" state. fn draw_unreachable(ui: &mut egui::Ui, agent: &AgentClient, error: &str) { ui.add_space(8.0); ui.colored_label(BAD, "The agent is not reachable."); ui.add_space(6.0); - kv(ui, "socket", &agent.socket().display().to_string()); + ui.horizontal(|ui| { + ui.weak("socket"); + ui.monospace(agent.socket().display().to_string()); + }); ui.add_space(4.0); ui.weak(error); ui.add_space(6.0); @@ -106,34 +134,66 @@ fn draw_unreachable(ui: &mut egui::Ui, agent: &AgentClient, error: &str) { ); } -/// The device line. -fn draw_header(ui: &mut egui::Ui, report: &StatusReport) { +/// The device header: id (with a copy button) and an editable hostname. +fn draw_header(ui: &mut egui::Ui, agent: &AgentClient, state: &mut UiState, report: &StatusReport) { ui.add_space(4.0); - egui::Grid::new("device") - .num_columns(2) - .spacing([10.0, 3.0]) - .show(ui, |ui| { - kv_row(ui, "device", &short(&report.endpoint_id)); - kv_row(ui, "host", &report.hostname); - }); + ui.horizontal(|ui| { + ui.weak("device"); + ui.monospace(format::short(&report.endpoint_id)); + format::copy_button(ui, &report.endpoint_id); + }); + + ui.horizontal(|ui| { + ui.weak("host"); + match &mut state.host_edit { + None => { + ui.label(&report.hostname); + if ui.small_button("✎").on_hover_text("rename").clicked() { + state.host_edit = Some(report.hostname.clone()); + } + } + Some(buffer) => { + let response = ui.add(egui::TextEdit::singleline(buffer).desired_width(150.0)); + let submit = (response.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter))) + || ui.small_button("✔").clicked(); + if submit { + agent.send(Command::SetHostname(buffer.trim().to_string())); + state.host_edit = None; + } else if ui.small_button("✕").clicked() { + state.host_edit = None; + } + } + } + }); + if !report.cache_healthy { - ui.colored_label(WARN, "cache unavailable"); + ui.colored_label( + egui::Color32::from_rgb(0xd9, 0x9a, 0x00), + "cache unavailable", + ); } } -/// One network tile. +/// One compact network tile. fn draw_network( ui: &mut egui::Ui, agent: &AgentClient, + state: &mut UiState, traffic: &Traffic, network: &NetworkReport, ) { ui.add_space(6.0); egui::Frame::group(ui.style()).show(ui, |ui| { ui.horizontal(|ui| { + // The name copies on click. ui.strong(network.name.as_str()); + format::copy_label(ui, "⧉", &network.name); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.small_button("Leave").clicked() { + let leave = + egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE)) + .fill(LEAVE_RED); + if ui.add(leave).clicked() { agent.send(Command::Leave { network_id: network.network_id.clone(), }); @@ -141,40 +201,40 @@ fn draw_network( }); }); - let overlay = network.overlay.as_ref(); - let address = overlay.and_then(|o| o.address.as_ref()); + let address = network.overlay.as_ref().and_then(|o| o.address.as_ref()); let series = traffic.network(&network.network_id); egui::Grid::new(("net", &network.network_id)) .num_columns(2) .spacing([10.0, 3.0]) .show(ui, |ui| { - kv_row(ui, "id", &short(&network.network_id)); + ui.weak("id"); + ui.horizontal(|ui| { + ui.label(format::short(&network.network_id)); + format::copy_button(ui, &network.network_id); + }); + ui.end_row(); + if let Some(range) = &network.range { - kv_row(ui, "range", range); + ui.weak("range"); + format::copy_label(ui, range, range); + ui.end_row(); } if let Some(address) = address { - kv_row(ui, "addr", address); - } - kv_row( - ui, - "peers", - &format!( - "{} connected · {} known", - network.peers.len(), - network.members.len() - ), - ); - if let Some(series) = series { - kv_row(ui, "traffic", &rate(series.rate)); + ui.weak("addr"); + format::copy_label(ui, address, address); + ui.end_row(); } + ui.weak("peers"); + ui.label(format!( + "{} connected · {} known", + network.peers.len(), + network.members.len() + )); + ui.end_row(); }); - if let Some(series) = series - && series.history.len() >= 2 - { - sparkline(ui, &series.history); - } + format::sparkline(ui, series, state.unit, 34.0); ui.horizontal(|ui| { let mut active = network.active; @@ -191,51 +251,18 @@ fn draw_network( enabled: broadcast, }); } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Show devices").clicked() { + state.open_devices.insert(network.network_id.clone()); + } + }); }); - - if let Some(overlay) = overlay - && !overlay.peers.is_empty() - { - egui::CollapsingHeader::new(format!("peers ({})", overlay.peers.len())) - .id_salt(("peers", &network.network_id)) - .show(ui, |ui| { - draw_peers(ui, traffic, &network.network_id, &overlay.peers); - }); - } }); } -/// The per-peer table inside a network's collapsing header. -fn draw_peers(ui: &mut egui::Ui, traffic: &Traffic, network_id: &str, peers: &[OverlayPeerReport]) { - egui::Grid::new(("peertable", network_id)) - .num_columns(5) - .striped(true) - .spacing([10.0, 3.0]) - .show(ui, |ui| { - for header in ["peer", "address", "age", "tx/rx", "rate"] { - ui.weak(header); - } - ui.end_row(); - - for peer in peers { - ui.monospace(short(&peer.public_key)); - ui.label(peer.address.clone().unwrap_or_else(|| "—".into())); - ui.label(match peer.handshake_secs_ago { - Some(secs) => format!("{secs}s"), - None => "—".into(), - }); - ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets)); - let pps = traffic - .peer(network_id, &peer.public_key) - .map_or(0.0, |series| series.rate); - ui.label(rate(pps)); - ui.end_row(); - } - }); -} - /// The join form: aligned name and secret, a generate button, and Join. fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) { + const INPUT_WIDTH: f32 = 208.0; ui.label("Join or create a network"); egui::Grid::new("join") .num_columns(2) @@ -257,7 +284,10 @@ fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) { .on_hover_text("Generate a new random secret") .clicked() { - join.secret = NetworkSecret::generate().encode().as_str().to_owned(); + join.secret = tsunagi::identity::NetworkSecret::generate() + .encode() + .as_str() + .to_owned(); } }); ui.end_row(); @@ -275,62 +305,3 @@ fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) { join.secret.clear(); } } - -/// A small filled-ish line chart of recent rates, drawn with the painter. -fn sparkline(ui: &mut egui::Ui, history: &VecDeque) { - let width = ui.available_width(); - let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 26.0), egui::Sense::hover()); - let painter = ui.painter_at(rect); - let peak = history.iter().copied().fold(1.0_f32, f32::max); - let count = history.len(); - if count < 2 { - return; - } - let points: Vec = history - .iter() - .enumerate() - .map(|(i, value)| { - let x = rect.left() + rect.width() * (i as f32 / (count - 1) as f32); - let y = rect.bottom() - (rect.height() - 2.0) * (value / peak); - egui::pos2(x, y) - }) - .collect(); - painter.add(egui::Shape::line( - points, - egui::Stroke::new(1.5_f32, ACCENT), - )); -} - -/// A key/value label pair outside a grid. -fn kv(ui: &mut egui::Ui, key: &str, value: &str) { - ui.horizontal(|ui| { - ui.weak(key); - ui.monospace(value); - }); -} - -/// A key/value row inside a two-column grid. -fn kv_row(ui: &mut egui::Ui, key: &str, value: &str) { - ui.weak(key); - ui.label(value); - ui.end_row(); -} - -/// Formats a packets-per-second rate. -fn rate(pps: f32) -> String { - if pps >= 1000.0 { - format!("{:.1}k pkt/s", pps / 1000.0) - } else { - format!("{pps:.0} pkt/s") - } -} - -/// A short, readable prefix of a long identifier (char-safe, no panic). -fn short(id: &str) -> String { - const KEEP: usize = 12; - if id.chars().count() <= KEEP { - id.to_string() - } else { - format!("{}…", id.chars().take(KEEP).collect::()) - } -}