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>
123 lines
4.0 KiB
Rust
123 lines
4.0 KiB
Rust
//! Traffic rates derived from successive status snapshots.
|
|
//!
|
|
//! 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;
|
|
|
|
use tsunagi::ipc::StatusReport;
|
|
|
|
/// How many rate samples to keep for a sparkline.
|
|
const HISTORY: usize = 60;
|
|
|
|
/// 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, u64)>,
|
|
packets_rate: f32,
|
|
bytes_rate: f32,
|
|
packets_history: VecDeque<f32>,
|
|
bytes_history: VecDeque<f32>,
|
|
}
|
|
|
|
impl Series {
|
|
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 {
|
|
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, 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();
|
|
}
|
|
}
|
|
|
|
/// All traffic series, keyed by a stable string.
|
|
#[derive(Default)]
|
|
pub(crate) struct Traffic {
|
|
series: HashMap<String, Series>,
|
|
}
|
|
|
|
impl Traffic {
|
|
/// Folds a fresh snapshot into the rates. Call once per new generation.
|
|
pub(crate) fn observe(&mut self, report: &StatusReport, now: Instant) {
|
|
for network in &report.networks {
|
|
let Some(overlay) = &network.overlay else {
|
|
continue;
|
|
};
|
|
let (mut net_packets, mut net_bytes) = (0u64, 0u64);
|
|
for peer in &overlay.peers {
|
|
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, packets, bytes);
|
|
}
|
|
self.series
|
|
.entry(net_key(&network.network_id))
|
|
.or_default()
|
|
.sample(now, net_packets, net_bytes);
|
|
}
|
|
}
|
|
|
|
/// The network-total series, if sampled.
|
|
pub(crate) fn network(&self, network_id: &str) -> Option<&Series> {
|
|
self.series.get(&net_key(network_id))
|
|
}
|
|
|
|
/// One peer's series, if sampled.
|
|
pub(crate) fn peer(&self, network_id: &str, public_key: &str) -> Option<&Series> {
|
|
self.series.get(&peer_key(network_id, public_key))
|
|
}
|
|
}
|
|
|
|
fn net_key(network_id: &str) -> String {
|
|
format!("net:{network_id}")
|
|
}
|
|
|
|
fn peer_key(network_id: &str, public_key: &str) -> String {
|
|
format!("peer:{network_id}:{public_key}")
|
|
}
|