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>
This commit is contained in:
1 parent
19509dc833
commit
d1de61ba29
7 files changed
+416
-75
No files matched your search
Generated
+52
@@ -200,6 +200,12 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayref"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayvec"
|
name = "arrayvec"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
@@ -5175,6 +5181,19 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sctk-adwaita"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||||
|
dependencies = [
|
||||||
|
"ab_glyph",
|
||||||
|
"log",
|
||||||
|
"memmap2",
|
||||||
|
"smithay-client-toolkit 0.19.2",
|
||||||
|
"tiny-skia",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -5575,6 +5594,12 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strict-num"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -5849,6 +5874,31 @@ dependencies = [
|
|||||||
"time-core",
|
"time-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny-skia"
|
||||||
|
version = "0.11.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||||
|
dependencies = [
|
||||||
|
"arrayref",
|
||||||
|
"arrayvec",
|
||||||
|
"bytemuck",
|
||||||
|
"cfg-if",
|
||||||
|
"log",
|
||||||
|
"tiny-skia-path",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny-skia-path"
|
||||||
|
version = "0.11.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||||
|
dependencies = [
|
||||||
|
"arrayref",
|
||||||
|
"bytemuck",
|
||||||
|
"strict-num",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -6274,6 +6324,7 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tray-icon",
|
"tray-icon",
|
||||||
"tsunagi",
|
"tsunagi",
|
||||||
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7365,6 +7416,7 @@ dependencies = [
|
|||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"redox_syscall 0.4.1",
|
"redox_syscall 0.4.1",
|
||||||
"rustix 0.38.44",
|
"rustix 0.38.44",
|
||||||
|
"sctk-adwaita",
|
||||||
"smithay-client-toolkit 0.19.2",
|
"smithay-client-toolkit 0.19.2",
|
||||||
"smol_str",
|
"smol_str",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -25,5 +25,10 @@ tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time",
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
|
# macOS only: set the app's activation policy to Accessory so it lives in the
|
||||||
|
# menu bar with no Dock icon. Pinned to eframe's winit so the types unify.
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
winit = "0.30"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
@@ -43,6 +43,11 @@ pub(crate) struct Snapshot {
|
|||||||
pub last_action: Option<Result<String, String>>,
|
pub last_action: Option<Result<String, String>>,
|
||||||
/// Whether a command is currently being executed.
|
/// Whether a command is currently being executed.
|
||||||
pub busy: bool,
|
pub busy: bool,
|
||||||
|
/// Bumped each time `status` is refreshed, so the UI can tell new data from
|
||||||
|
/// a repaint of the same data and sample traffic rates only on change.
|
||||||
|
pub generation: u64,
|
||||||
|
/// When this status was read, for computing rates between refreshes.
|
||||||
|
pub at: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle the UI holds: send commands, read the latest snapshot.
|
/// Handle the UI holds: send commands, read the latest snapshot.
|
||||||
@@ -121,6 +126,8 @@ async fn refresh(socket: &std::path::Path, shared: &Arc<Mutex<Snapshot>>, ctx: &
|
|||||||
{
|
{
|
||||||
let mut guard = lock(shared);
|
let mut guard = lock(shared);
|
||||||
guard.status = Some(status);
|
guard.status = Some(status);
|
||||||
|
guard.generation = guard.generation.wrapping_add(1);
|
||||||
|
guard.at = Some(Instant::now());
|
||||||
}
|
}
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::time::Duration;
|
|||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|
||||||
use crate::agent::{AgentClient, resolve_socket};
|
use crate::agent::{AgentClient, resolve_socket};
|
||||||
|
use crate::stats::Traffic;
|
||||||
use crate::tray::{Action, Tray};
|
use crate::tray::{Action, Tray};
|
||||||
use crate::ui::{self, JoinForm};
|
use crate::ui::{self, JoinForm};
|
||||||
|
|
||||||
@@ -13,6 +14,11 @@ pub(crate) struct App {
|
|||||||
agent: AgentClient,
|
agent: AgentClient,
|
||||||
tray: Tray,
|
tray: Tray,
|
||||||
join: JoinForm,
|
join: JoinForm,
|
||||||
|
/// 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.
|
||||||
|
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 actually exits instead
|
||||||
/// of hiding to the tray.
|
/// of hiding to the tray.
|
||||||
quitting: bool,
|
quitting: bool,
|
||||||
@@ -31,6 +37,8 @@ impl App {
|
|||||||
agent,
|
agent,
|
||||||
tray,
|
tray,
|
||||||
join: JoinForm::default(),
|
join: JoinForm::default(),
|
||||||
|
traffic: Traffic::default(),
|
||||||
|
sampled: 0,
|
||||||
quitting: false,
|
quitting: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -58,7 +66,14 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let snapshot = self.agent.snapshot();
|
let snapshot = self.agent.snapshot();
|
||||||
ui::draw(ctx, &self.agent, &mut self.join, &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
|
// Keep ticking so fresh status and tray actions are picked up even when
|
||||||
// nothing else requests a repaint.
|
// nothing else requests a repaint.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
mod agent;
|
mod agent;
|
||||||
mod app;
|
mod app;
|
||||||
|
mod stats;
|
||||||
mod tray;
|
mod tray;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
@@ -28,14 +29,28 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.build()?;
|
.build()?;
|
||||||
let handle = runtime.handle().clone();
|
let handle = runtime.handle().clone();
|
||||||
|
|
||||||
let options = eframe::NativeOptions {
|
#[cfg_attr(not(target_os = "macos"), expect(unused_mut))]
|
||||||
|
let mut options = eframe::NativeOptions {
|
||||||
|
// A fixed, small window; the whole layout targets this size.
|
||||||
viewport: egui::ViewportBuilder::default()
|
viewport: egui::ViewportBuilder::default()
|
||||||
.with_title("tsunagi")
|
.with_title("tsunagi")
|
||||||
.with_inner_size([380.0, 540.0])
|
.with_inner_size([360.0, 600.0])
|
||||||
.with_min_inner_size([320.0, 360.0]),
|
.with_resizable(false)
|
||||||
|
// Start hidden: the app lives in the tray and the window opens from
|
||||||
|
// the menu.
|
||||||
|
.with_visible(false),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// macOS: live in the menu bar with no Dock icon (tray-only).
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
options.event_loop_builder = Some(Box::new(|builder| {
|
||||||
|
use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS};
|
||||||
|
builder.with_activation_policy(ActivationPolicy::Accessory);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"tsunagi",
|
"tsunagi",
|
||||||
options,
|
options,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//! 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}")
|
||||||
|
}
|
||||||
+228
-70
@@ -1,15 +1,27 @@
|
|||||||
//! The window contents, drawn from the latest agent snapshot.
|
//! The window contents, drawn from the latest agent snapshot.
|
||||||
//!
|
//!
|
||||||
//! Pure rendering: the current state comes from the snapshot, and a change to a
|
//! Pure rendering: state comes from the snapshot and the traffic sampler, and a
|
||||||
//! switch or a button sends a [`Command`] to the worker. egui is immediate
|
//! change to a switch or a button sends a [`Command`] to the worker. The layout
|
||||||
//! mode, so the switches reflect the agent's state and only a user change
|
//! targets one fixed, small window, so everything is laid out on a grid and the
|
||||||
//! dispatches a command.
|
//! peer list hides behind a collapsing header.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|
||||||
use tsunagi::ipc::{NetworkReport, StatusReport};
|
use tsunagi::identity::NetworkSecret;
|
||||||
|
use tsunagi::ipc::{NetworkReport, OverlayPeerReport, StatusReport};
|
||||||
|
|
||||||
use crate::agent::{AgentClient, Command, Snapshot};
|
use crate::agent::{AgentClient, Command, Snapshot};
|
||||||
|
use crate::stats::Traffic;
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
|
||||||
/// Join form state, kept across frames.
|
/// Join form state, kept across frames.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -24,8 +36,10 @@ pub(crate) fn draw(
|
|||||||
agent: &AgentClient,
|
agent: &AgentClient,
|
||||||
join: &mut JoinForm,
|
join: &mut JoinForm,
|
||||||
snapshot: &Snapshot,
|
snapshot: &Snapshot,
|
||||||
|
traffic: &Traffic,
|
||||||
) {
|
) {
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::TopBottomPanel::top("top").show(ctx, |ui| {
|
||||||
|
ui.add_space(4.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.heading("tsunagi");
|
ui.heading("tsunagi");
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
@@ -37,89 +51,129 @@ pub(crate) fn draw(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
ui.add_space(4.0);
|
||||||
|
});
|
||||||
|
|
||||||
match &snapshot.status {
|
egui::TopBottomPanel::bottom("bottom").show(ctx, |ui| {
|
||||||
None => {
|
if let Some(action) = &snapshot.last_action {
|
||||||
ui.add_space(8.0);
|
ui.add_space(4.0);
|
||||||
ui.label("Connecting to the agent…");
|
match action {
|
||||||
}
|
Ok(message) => ui.colored_label(OK, message),
|
||||||
Some(Err(error)) => draw_unreachable(ui, agent, error),
|
Err(error) => ui.colored_label(BAD, error),
|
||||||
Some(Ok(report)) => {
|
};
|
||||||
draw_header(ui, report);
|
}
|
||||||
ui.separator();
|
ui.add_space(4.0);
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
draw_join(ui, agent, join);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| match &snapshot.status {
|
||||||
|
None => {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label("Connecting to the agent…");
|
||||||
|
}
|
||||||
|
Some(Err(error)) => draw_unreachable(ui, agent, error),
|
||||||
|
Some(Ok(report)) => {
|
||||||
|
draw_header(ui, report);
|
||||||
|
ui.separator();
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
if report.networks.is_empty() {
|
if report.networks.is_empty() {
|
||||||
ui.label("No networks yet. Join one below.");
|
ui.add_space(6.0);
|
||||||
|
ui.weak("No networks yet — join one below.");
|
||||||
}
|
}
|
||||||
for network in &report.networks {
|
for network in &report.networks {
|
||||||
draw_network(ui, agent, network);
|
draw_network(ui, agent, traffic, network);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.separator();
|
|
||||||
draw_join(ui, agent, join);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(action) = &snapshot.last_action {
|
|
||||||
ui.add_space(6.0);
|
|
||||||
match action {
|
|
||||||
Ok(message) => ui.colored_label(egui::Color32::from_rgb(0x3c, 0xb0, 0x4a), message),
|
|
||||||
Err(error) => ui.colored_label(egui::Color32::from_rgb(0xd0, 0x4a, 0x3c), error),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The "agent cannot be reached" state, with the socket it tried.
|
/// The "agent cannot be reached" state, naming the socket it tried.
|
||||||
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(
|
ui.colored_label(BAD, "The agent is not reachable.");
|
||||||
egui::Color32::from_rgb(0xd0, 0x4a, 0x3c),
|
|
||||||
"The agent is not reachable.",
|
|
||||||
);
|
|
||||||
ui.add_space(4.0);
|
|
||||||
ui.label(format!("socket: {}", agent.socket().display()));
|
|
||||||
ui.label(error);
|
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
ui.label(
|
kv(ui, "socket", &agent.socket().display().to_string());
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.weak(error);
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.weak(
|
||||||
"Start the agent, or check that this user may open its control socket \
|
"Start the agent, or check that this user may open its control socket \
|
||||||
(its group, mode 0660).",
|
(its group, mode 0660).",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The device line: id, hostname, cache health.
|
/// The device line.
|
||||||
fn draw_header(ui: &mut egui::Ui, report: &StatusReport) {
|
fn draw_header(ui: &mut egui::Ui, report: &StatusReport) {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.label(format!("device {}", short(&report.endpoint_id)));
|
egui::Grid::new("device")
|
||||||
ui.label(format!("host {}", report.hostname));
|
.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);
|
||||||
|
});
|
||||||
if !report.cache_healthy {
|
if !report.cache_healthy {
|
||||||
ui.colored_label(
|
ui.colored_label(WARN, "cache unavailable");
|
||||||
egui::Color32::from_rgb(0xd9, 0x9a, 0x00),
|
|
||||||
"cache unavailable",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One network: name, range, this device's address, switches, peers, leave.
|
/// One network tile.
|
||||||
fn draw_network(ui: &mut egui::Ui, agent: &AgentClient, network: &NetworkReport) {
|
fn draw_network(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
agent: &AgentClient,
|
||||||
|
traffic: &Traffic,
|
||||||
|
network: &NetworkReport,
|
||||||
|
) {
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
ui.group(|ui| {
|
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.strong(network.name.as_str());
|
ui.strong(network.name.as_str());
|
||||||
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.button("Leave").clicked() {
|
if ui.small_button("Leave").clicked() {
|
||||||
agent.send(Command::Leave {
|
agent.send(Command::Leave {
|
||||||
network_id: network.network_id.clone(),
|
network_id: network.network_id.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.label(format!("id {}", short(&network.network_id)));
|
|
||||||
if let Some(range) = &network.range {
|
let overlay = network.overlay.as_ref();
|
||||||
ui.label(format!("range {range}"));
|
let address = overlay.and_then(|o| o.address.as_ref());
|
||||||
}
|
let series = traffic.network(&network.network_id);
|
||||||
if let Some(address) = network.overlay.as_ref().and_then(|o| o.address.as_ref()) {
|
|
||||||
ui.label(format!("addr {address}"));
|
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));
|
||||||
|
if let Some(range) = &network.range {
|
||||||
|
kv_row(ui, "range", range);
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(series) = series
|
||||||
|
&& series.history.len() >= 2
|
||||||
|
{
|
||||||
|
sparkline(ui, &series.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -139,26 +193,81 @@ fn draw_network(ui: &mut egui::Ui, agent: &AgentClient, network: &NetworkReport)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let connected = network.peers.len();
|
if let Some(overlay) = overlay
|
||||||
let known = network.members.len();
|
&& !overlay.peers.is_empty()
|
||||||
ui.label(format!("peers {connected} connected · {known} known"));
|
{
|
||||||
|
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 join form: name + secret + button.
|
/// 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) {
|
fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) {
|
||||||
ui.add_space(4.0);
|
ui.label("Join or create a network");
|
||||||
ui.label("Join a network");
|
egui::Grid::new("join")
|
||||||
ui.horizontal(|ui| {
|
.num_columns(2)
|
||||||
ui.label("name");
|
.spacing([8.0, 6.0])
|
||||||
ui.text_edit_singleline(&mut join.name);
|
.show(ui, |ui| {
|
||||||
});
|
ui.label("name");
|
||||||
ui.horizontal(|ui| {
|
ui.add(egui::TextEdit::singleline(&mut join.name).desired_width(INPUT_WIDTH));
|
||||||
ui.label("secret");
|
ui.end_row();
|
||||||
ui.add(egui::TextEdit::singleline(&mut join.secret).password(true));
|
|
||||||
});
|
ui.label("secret");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut join.secret)
|
||||||
|
.desired_width(INPUT_WIDTH - 26.0)
|
||||||
|
.password(true),
|
||||||
|
);
|
||||||
|
if ui
|
||||||
|
.button("⟳")
|
||||||
|
.on_hover_text("Generate a new random secret")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
join.secret = NetworkSecret::generate().encode().as_str().to_owned();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
|
||||||
let ready = !join.name.trim().is_empty() && !join.secret.trim().is_empty();
|
let ready = !join.name.trim().is_empty() && !join.secret.trim().is_empty();
|
||||||
if ui.add_enabled(ready, egui::Button::new("Join")).clicked() {
|
if ui
|
||||||
|
.add_enabled(ready, egui::Button::new("Join / create"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
agent.send(Command::Join {
|
agent.send(Command::Join {
|
||||||
name: join.name.trim().to_string(),
|
name: join.name.trim().to_string(),
|
||||||
secret: join.secret.trim().to_string(),
|
secret: join.secret.trim().to_string(),
|
||||||
@@ -167,6 +276,55 @@ fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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).
|
/// A short, readable prefix of a long identifier (char-safe, no panic).
|
||||||
fn short(id: &str) -> String {
|
fn short(id: &str) -> String {
|
||||||
const KEEP: usize = 12;
|
const KEEP: usize = 12;
|
||||||
|
|||||||
Reference in new issue
Block a user