Tray: devices window, bytes/packets toggle, copy & edit affordances
Replaces the in-tile collapsing peer list with a "Show devices" button that opens a separate, normal-sized window per network, and fills in the rest of the feedback. Main window: - A global show: pkts/bytes toggle; all rates and sparklines follow it. - Sparklines now reserve their space always (fixed height, "no data yet" placeholder) so the layout no longer jumps when data arrives, and each one overlays its peak (top-left) and current value (bottom-right) with units. - Device id has a copy button; the hostname is editable in place (✎ → edit → ✔/Enter sends SetHostname); network name, range and address copy on click. - The Leave button is a neutral red. Per-tile "Show devices" opens the detail. Devices window (a separate egui viewport per network): - Network summary: id (copy), active/broadcast, range, this device's address, candidates, relay fwd/via/recv, dial/handshake failures. - A larger network traffic sparkline. - Connected peers in a table: hostname when known (else short key), protocol, address, handshake age formatted (s/m/h/d), tx/rx packets, tx/rx bytes, and current rate with an inline sparkline. Names and addresses copy on click. - Offline members listed below with address and failed-dial count. Agent: a SetHostname command (over the existing ipc) backs the in-place rename. Traffic sampler now tracks both packet and byte rates; the toggle selects which. Deferred: a "copy secret" button on the tile — the secret is deliberately not exported to the GUI (secrets never leave the agent), so it needs a small local secret-export ipc, which is the next step. 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>
This commit is contained in:
1 parent
0a344c1b4f
commit
8f5ce21ce9
7 files changed
+602
-193
No files matched your search
@@ -31,6 +31,8 @@ pub(crate) enum Command {
|
|||||||
Join { name: String, secret: String },
|
Join { name: String, secret: String },
|
||||||
/// Leave a network entirely.
|
/// Leave a network entirely.
|
||||||
Leave { network_id: String },
|
Leave { network_id: String },
|
||||||
|
/// Change the hostname this device announces.
|
||||||
|
SetHostname(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The latest the worker knows, read by the UI each frame.
|
/// The latest the worker knows, read by the UI each frame.
|
||||||
@@ -166,6 +168,10 @@ async fn run(
|
|||||||
.await
|
.await
|
||||||
.map(|report| format!("left {}", report.name))
|
.map(|report| format!("left {}", report.name))
|
||||||
.map_err(|err| err.to_string()),
|
.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()),
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 std::time::Duration;
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|
||||||
use crate::agent::{AgentClient, resolve_socket};
|
use crate::agent::{AgentClient, resolve_socket};
|
||||||
|
use crate::devices;
|
||||||
use crate::stats::Traffic;
|
use crate::stats::Traffic;
|
||||||
use crate::tray::{Action, Tray};
|
use crate::tray::{Action, Tray};
|
||||||
use crate::ui::{self, JoinForm};
|
use crate::ui::{self, UiState};
|
||||||
|
|
||||||
/// The tray application state.
|
/// The tray application state.
|
||||||
pub(crate) struct App {
|
pub(crate) struct App {
|
||||||
agent: AgentClient,
|
agent: AgentClient,
|
||||||
tray: Tray,
|
tray: Tray,
|
||||||
join: JoinForm,
|
state: UiState,
|
||||||
/// Traffic rates derived from successive snapshots.
|
/// Traffic rates derived from successive snapshots.
|
||||||
traffic: Traffic,
|
traffic: Traffic,
|
||||||
/// The snapshot generation last folded into `traffic`, so each refresh is
|
/// The snapshot generation last folded into `traffic`.
|
||||||
/// sampled exactly once rather than on every repaint.
|
|
||||||
sampled: u64,
|
sampled: u64,
|
||||||
/// Set when the user chose Quit, so the next close actually exits instead
|
/// Set when the user chose Quit, so the next close exits rather than hides.
|
||||||
/// of hiding to the tray.
|
|
||||||
quitting: bool,
|
quitting: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ impl App {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
agent,
|
agent,
|
||||||
tray,
|
tray,
|
||||||
join: JoinForm::default(),
|
state: UiState::default(),
|
||||||
traffic: Traffic::default(),
|
traffic: Traffic::default(),
|
||||||
sampled: 0,
|
sampled: 0,
|
||||||
quitting: false,
|
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 {
|
if ctx.input(|i| i.viewport().close_requested()) && !self.quitting {
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
|
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
let snapshot = self.agent.snapshot();
|
let snapshot = self.agent.snapshot();
|
||||||
// Fold each fresh status into the traffic rates exactly once.
|
|
||||||
if snapshot.generation != self.sampled
|
if snapshot.generation != self.sampled
|
||||||
&& let (Some(Ok(report)), Some(at)) = (&snapshot.status, snapshot.at)
|
&& let (Some(Ok(report)), Some(at)) = (&snapshot.status, snapshot.at)
|
||||||
{
|
{
|
||||||
self.traffic.observe(report, at);
|
self.traffic.observe(report, at);
|
||||||
self.sampled = snapshot.generation;
|
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
|
ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic);
|
||||||
// nothing else requests a repaint.
|
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));
|
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<String> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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::<String>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<egui::Pos2> = 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))
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
mod agent;
|
mod agent;
|
||||||
mod app;
|
mod app;
|
||||||
|
mod devices;
|
||||||
|
mod format;
|
||||||
mod stats;
|
mod stats;
|
||||||
mod tray;
|
mod tray;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
//! Traffic rates derived from successive status snapshots.
|
//! Traffic rates derived from successive status snapshots.
|
||||||
//!
|
//!
|
||||||
//! The agent reports cumulative packet counters, not rates. The GUI samples
|
//! The agent reports cumulative packet and byte counters, not rates. The GUI
|
||||||
//! them each time a fresh snapshot arrives and turns the deltas into a
|
//! samples them each time a fresh snapshot arrives and turns the deltas into
|
||||||
//! packets-per-second rate plus a short rolling history for a sparkline. One
|
//! per-second rates plus short rolling histories for sparklines. One series per
|
||||||
//! series per key: a network total, and each peer within it.
|
//! 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::collections::{HashMap, VecDeque};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
@@ -11,32 +12,62 @@ use std::time::Instant;
|
|||||||
use tsunagi::ipc::StatusReport;
|
use tsunagi::ipc::StatusReport;
|
||||||
|
|
||||||
/// How many rate samples to keep for a sparkline.
|
/// 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)]
|
#[derive(Default)]
|
||||||
pub(crate) struct Series {
|
pub(crate) struct Series {
|
||||||
last: Option<(Instant, u64)>,
|
last: Option<(Instant, u64, u64)>,
|
||||||
/// Most recent packets-per-second (tx + rx).
|
packets_rate: f32,
|
||||||
pub rate: f32,
|
bytes_rate: f32,
|
||||||
/// Recent rates, oldest first, for a sparkline.
|
packets_history: VecDeque<f32>,
|
||||||
pub history: VecDeque<f32>,
|
bytes_history: VecDeque<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Series {
|
impl Series {
|
||||||
fn sample(&mut self, now: Instant, total: u64) {
|
fn sample(&mut self, now: Instant, packets: u64, bytes: u64) {
|
||||||
if let Some((then, previous)) = self.last {
|
if let Some((then, prev_packets, prev_bytes)) = self.last {
|
||||||
let dt = now.saturating_duration_since(then).as_secs_f32();
|
let dt = now.saturating_duration_since(then).as_secs_f32();
|
||||||
if dt > 0.0 {
|
if dt > 0.0 {
|
||||||
let delta = total.saturating_sub(previous) as f32;
|
self.packets_rate = packets.saturating_sub(prev_packets) as f32 / dt;
|
||||||
self.rate = delta / dt;
|
self.bytes_rate = bytes.saturating_sub(prev_bytes) as f32 / dt;
|
||||||
self.history.push_back(self.rate);
|
push(&mut self.packets_history, self.packets_rate);
|
||||||
while self.history.len() > HISTORY {
|
push(&mut self.bytes_history, self.bytes_rate);
|
||||||
self.history.pop_front();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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<f32> {
|
||||||
|
match unit {
|
||||||
|
Unit::Packets => &self.packets_history,
|
||||||
|
Unit::Bytes => &self.bytes_history,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(history: &mut VecDeque<f32>, 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 {
|
let Some(overlay) = &network.overlay else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let mut net_total = 0u64;
|
let (mut net_packets, mut net_bytes) = (0u64, 0u64);
|
||||||
for peer in &overlay.peers {
|
for peer in &overlay.peers {
|
||||||
let total = peer.tx_packets.saturating_add(peer.rx_packets);
|
let packets = peer.tx_packets.saturating_add(peer.rx_packets);
|
||||||
net_total = net_total.saturating_add(total);
|
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
|
self.series
|
||||||
.entry(peer_key(&network.network_id, &peer.public_key))
|
.entry(peer_key(&network.network_id, &peer.public_key))
|
||||||
.or_default()
|
.or_default()
|
||||||
.sample(now, total);
|
.sample(now, packets, bytes);
|
||||||
}
|
}
|
||||||
self.series
|
self.series
|
||||||
.entry(net_key(&network.network_id))
|
.entry(net_key(&network.network_id))
|
||||||
.or_default()
|
.or_default()
|
||||||
.sample(now, net_total);
|
.sample(now, net_packets, net_bytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+126
-155
@@ -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
|
//! Pure rendering: state comes from the snapshot and the traffic sampler, a
|
||||||
//! change to a switch or a button sends a [`Command`] to the worker. The layout
|
//! change to a switch or button sends a [`Command`] to the worker, and local UI
|
||||||
//! targets one fixed, small window, so everything is laid out on a grid and the
|
//! state (the join form, the unit toggle, which devices windows are open, an
|
||||||
//! peer list hides behind a collapsing header.
|
//! in-progress hostname edit) lives in [`UiState`].
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|
||||||
use tsunagi::identity::NetworkSecret;
|
use tsunagi::ipc::{NetworkReport, StatusReport};
|
||||||
use tsunagi::ipc::{NetworkReport, OverlayPeerReport, StatusReport};
|
|
||||||
|
|
||||||
use crate::agent::{AgentClient, Command, Snapshot};
|
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 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);
|
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)]
|
#[derive(Default)]
|
||||||
pub(crate) struct JoinForm {
|
pub(crate) struct JoinForm {
|
||||||
pub(crate) name: String,
|
pub(crate) name: String,
|
||||||
pub(crate) secret: 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<String>,
|
||||||
|
/// Network ids whose devices window is open.
|
||||||
|
pub(crate) open_devices: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
pub(crate) fn draw(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
agent: &AgentClient,
|
agent: &AgentClient,
|
||||||
join: &mut JoinForm,
|
state: &mut UiState,
|
||||||
snapshot: &Snapshot,
|
snapshot: &Snapshot,
|
||||||
traffic: &Traffic,
|
traffic: &Traffic,
|
||||||
) {
|
) {
|
||||||
@@ -49,6 +70,10 @@ pub(crate) fn draw(
|
|||||||
if snapshot.busy {
|
if snapshot.busy {
|
||||||
ui.add(egui::Spinner::new());
|
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);
|
ui.add_space(4.0);
|
||||||
@@ -63,7 +88,7 @@ pub(crate) fn draw(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
draw_join(ui, agent, join);
|
draw_join(ui, agent, &mut state.join);
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,7 +99,7 @@ pub(crate) fn draw(
|
|||||||
}
|
}
|
||||||
Some(Err(error)) => draw_unreachable(ui, agent, error),
|
Some(Err(error)) => draw_unreachable(ui, agent, error),
|
||||||
Some(Ok(report)) => {
|
Some(Ok(report)) => {
|
||||||
draw_header(ui, report);
|
draw_header(ui, agent, state, report);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
@@ -84,19 +109,22 @@ pub(crate) fn draw(
|
|||||||
ui.weak("No networks yet — join one below.");
|
ui.weak("No networks yet — join one below.");
|
||||||
}
|
}
|
||||||
for network in &report.networks {
|
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) {
|
fn draw_unreachable(ui: &mut egui::Ui, agent: &AgentClient, error: &str) {
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.colored_label(BAD, "The agent is not reachable.");
|
ui.colored_label(BAD, "The agent is not reachable.");
|
||||||
ui.add_space(6.0);
|
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.add_space(4.0);
|
||||||
ui.weak(error);
|
ui.weak(error);
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
@@ -106,34 +134,66 @@ fn draw_unreachable(ui: &mut egui::Ui, agent: &AgentClient, error: &str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The device line.
|
/// The device header: id (with a copy button) and an editable hostname.
|
||||||
fn draw_header(ui: &mut egui::Ui, report: &StatusReport) {
|
fn draw_header(ui: &mut egui::Ui, agent: &AgentClient, state: &mut UiState, report: &StatusReport) {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
egui::Grid::new("device")
|
ui.horizontal(|ui| {
|
||||||
.num_columns(2)
|
ui.weak("device");
|
||||||
.spacing([10.0, 3.0])
|
ui.monospace(format::short(&report.endpoint_id));
|
||||||
.show(ui, |ui| {
|
format::copy_button(ui, &report.endpoint_id);
|
||||||
kv_row(ui, "device", &short(&report.endpoint_id));
|
});
|
||||||
kv_row(ui, "host", &report.hostname);
|
|
||||||
});
|
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 {
|
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(
|
fn draw_network(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
agent: &AgentClient,
|
agent: &AgentClient,
|
||||||
|
state: &mut UiState,
|
||||||
traffic: &Traffic,
|
traffic: &Traffic,
|
||||||
network: &NetworkReport,
|
network: &NetworkReport,
|
||||||
) {
|
) {
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
// The name copies on click.
|
||||||
ui.strong(network.name.as_str());
|
ui.strong(network.name.as_str());
|
||||||
|
format::copy_label(ui, "⧉", &network.name);
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
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 {
|
agent.send(Command::Leave {
|
||||||
network_id: network.network_id.clone(),
|
network_id: network.network_id.clone(),
|
||||||
});
|
});
|
||||||
@@ -141,40 +201,40 @@ fn draw_network(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let overlay = network.overlay.as_ref();
|
let address = network.overlay.as_ref().and_then(|o| o.address.as_ref());
|
||||||
let address = overlay.and_then(|o| o.address.as_ref());
|
|
||||||
let series = traffic.network(&network.network_id);
|
let series = traffic.network(&network.network_id);
|
||||||
|
|
||||||
egui::Grid::new(("net", &network.network_id))
|
egui::Grid::new(("net", &network.network_id))
|
||||||
.num_columns(2)
|
.num_columns(2)
|
||||||
.spacing([10.0, 3.0])
|
.spacing([10.0, 3.0])
|
||||||
.show(ui, |ui| {
|
.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 {
|
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 {
|
if let Some(address) = address {
|
||||||
kv_row(ui, "addr", address);
|
ui.weak("addr");
|
||||||
}
|
format::copy_label(ui, address, address);
|
||||||
kv_row(
|
ui.end_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("peers");
|
||||||
|
ui.label(format!(
|
||||||
|
"{} connected · {} known",
|
||||||
|
network.peers.len(),
|
||||||
|
network.members.len()
|
||||||
|
));
|
||||||
|
ui.end_row();
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(series) = series
|
format::sparkline(ui, series, state.unit, 34.0);
|
||||||
&& series.history.len() >= 2
|
|
||||||
{
|
|
||||||
sparkline(ui, &series.history);
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let mut active = network.active;
|
let mut active = network.active;
|
||||||
@@ -191,51 +251,18 @@ fn draw_network(
|
|||||||
enabled: broadcast,
|
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.
|
/// The join form: aligned name and secret, a generate button, and Join.
|
||||||
fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) {
|
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");
|
ui.label("Join or create a network");
|
||||||
egui::Grid::new("join")
|
egui::Grid::new("join")
|
||||||
.num_columns(2)
|
.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")
|
.on_hover_text("Generate a new random secret")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
join.secret = NetworkSecret::generate().encode().as_str().to_owned();
|
join.secret = tsunagi::identity::NetworkSecret::generate()
|
||||||
|
.encode()
|
||||||
|
.as_str()
|
||||||
|
.to_owned();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
@@ -275,62 +305,3 @@ fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) {
|
|||||||
join.secret.clear();
|
join.secret.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A small filled-ish line chart of recent rates, drawn with the painter.
|
|
||||||
fn sparkline(ui: &mut egui::Ui, history: &VecDeque<f32>) {
|
|
||||||
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<egui::Pos2> = 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::<String>())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user