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:
UltradesuandClaude Opus 4.8 committed 2026-10-02 15:08:29 +01:00
1 parent 19509dc833
commit d1de61ba29
7 files changed
+416 -75

No files matched your search

+228 -70
View File
@@ -1,15 +1,27 @@
//! The window contents, drawn from the latest agent snapshot.
//!
//! Pure rendering: the current state comes from the snapshot, and a change to a
//! switch or a button sends a [`Command`] to the worker. egui is immediate
//! mode, so the switches reflect the agent's state and only a user change
//! dispatches a command.
//! 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.
use std::collections::VecDeque;
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::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.
#[derive(Default)]
@@ -24,8 +36,10 @@ pub(crate) fn draw(
agent: &AgentClient,
join: &mut JoinForm,
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.heading("tsunagi");
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 {
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().show(ui, |ui| {
egui::TopBottomPanel::bottom("bottom").show(ctx, |ui| {
if let Some(action) = &snapshot.last_action {
ui.add_space(4.0);
match action {
Ok(message) => ui.colored_label(OK, message),
Err(error) => ui.colored_label(BAD, error),
};
}
ui.add_space(4.0);
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() {
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 {
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) {
ui.add_space(8.0);
ui.colored_label(
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.colored_label(BAD, "The agent is not reachable.");
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 \
(its group, mode 0660).",
);
}
/// The device line: id, hostname, cache health.
/// The device line.
fn draw_header(ui: &mut egui::Ui, report: &StatusReport) {
ui.add_space(4.0);
ui.label(format!("device {}", short(&report.endpoint_id)));
ui.label(format!("host {}", report.hostname));
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);
});
if !report.cache_healthy {
ui.colored_label(
egui::Color32::from_rgb(0xd9, 0x9a, 0x00),
"cache unavailable",
);
ui.colored_label(WARN, "cache unavailable");
}
}
/// One network: name, range, this device's address, switches, peers, leave.
fn draw_network(ui: &mut egui::Ui, agent: &AgentClient, network: &NetworkReport) {
/// One network tile.
fn draw_network(
ui: &mut egui::Ui,
agent: &AgentClient,
traffic: &Traffic,
network: &NetworkReport,
) {
ui.add_space(6.0);
ui.group(|ui| {
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
ui.strong(network.name.as_str());
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 {
network_id: network.network_id.clone(),
});
}
});
});
ui.label(format!("id {}", short(&network.network_id)));
if let Some(range) = &network.range {
ui.label(format!("range {range}"));
}
if let Some(address) = network.overlay.as_ref().and_then(|o| o.address.as_ref()) {
ui.label(format!("addr {address}"));
let overlay = network.overlay.as_ref();
let address = overlay.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));
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| {
@@ -139,26 +193,81 @@ fn draw_network(ui: &mut egui::Ui, agent: &AgentClient, network: &NetworkReport)
}
});
let connected = network.peers.len();
let known = network.members.len();
ui.label(format!("peers {connected} connected · {known} known"));
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 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) {
ui.add_space(4.0);
ui.label("Join a network");
ui.horizontal(|ui| {
ui.label("name");
ui.text_edit_singleline(&mut join.name);
});
ui.horizontal(|ui| {
ui.label("secret");
ui.add(egui::TextEdit::singleline(&mut join.secret).password(true));
});
ui.label("Join or create a network");
egui::Grid::new("join")
.num_columns(2)
.spacing([8.0, 6.0])
.show(ui, |ui| {
ui.label("name");
ui.add(egui::TextEdit::singleline(&mut join.name).desired_width(INPUT_WIDTH));
ui.end_row();
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();
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 {
name: join.name.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).
fn short(id: &str) -> String {
const KEEP: usize = 12;