Files
tsunagi/crates/tsunagi-tray/src/stats.rs
T
UltradesuandClaude Opus 4.8 d1de61ba29 Tray UI: start hidden, fixed window, grid layout, traffic + peers
Reworks the tray window per feedback and fills it in.

- Starts hidden in the tray (no window on launch); the window opens from the
  menu. On macOS the app now uses the Accessory activation policy, so it lives
  in the menu bar with no Dock icon (via a winit event-loop hook, pinned to
  eframe's winit). Fixed, non-resizable 360x600 window; the layout targets it.
- Tidied layout: top bar (title + refresh), a bottom panel with the join form
  and the action banner, and a scrolling list of network tiles between them.
  Labels and values sit on two-column grids so they align; the join name and
  secret inputs are the same fixed width.
- A ⟳ button beside the secret generates a fresh random secret
  (NetworkSecret::generate), for creating a network.
- Each network tile shows id, range, this device's address, peers
  (connected/known), the active and broadcast switches, a Leave button, and a
  collapsing "peers (N)" section with a formatted per-peer table (short key,
  address, handshake age, tx/rx packets, rate).
- Traffic: a new sampler turns the agent's cumulative packet counters into a
  packets-per-second rate per network and per peer, from the deltas between
  snapshots (the snapshot now carries a generation and timestamp so each
  refresh is sampled once). The network tile shows the current rate and a small
  hand-drawn sparkline of recent rates (no extra dependency).

Note: rates are packets/s — the report has no byte counters yet; bytes would
need an agent change. Builds and is clippy-clean (-D warnings) on macOS; the UI
has not been run in this headless session.

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

90 lines
2.9 KiB
Rust

//! 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.
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 = 48;
/// One series' rolling rate.
#[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<f32>,
}
impl Series {
fn sample(&mut self, now: Instant, total: u64) {
if let Some((then, previous)) = 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.last = Some((now, total));
}
}
/// 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_total = 0u64;
for peer in &overlay.peers {
let total = peer.tx_packets.saturating_add(peer.rx_packets);
net_total = net_total.saturating_add(total);
self.series
.entry(peer_key(&network.network_id, &peer.public_key))
.or_default()
.sample(now, total);
}
self.series
.entry(net_key(&network.network_id))
.or_default()
.sample(now, net_total);
}
}
/// 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}")
}