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
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user