Compare commits

..
Author SHA1 Message Date
Aleksandr Bogomiakov 8a6216bccb Expose tray runtime libraries in the Nix development shell
The development shell inherited build dependencies but Cargo-built tray
binaries lack the installed package's wrapper. AppIndicator was present in
the store yet its dlopen lookup failed because the runtime library path was
missing. The same problem affected dynamically loaded graphics libraries.

Expose the package's existing trayRuntimeLibs through passthru and reuse that
list in mkShell packages and its LD_LIBRARY_PATH hook. Include the existing
NixOS OpenGL driver path and preserve any caller-supplied library paths.
The development shell and installed tray now share one dependency list.
Document direct execution of Cargo-built tray binaries inside nix develop.
No agent, protocol, package version or system configuration changes.

Validation on Linux:
- Reproduced the missing AppIndicator dlopen failure before the change.
- Loaded libayatana-appindicator3.so.1, libxkbcommon-x11.so.0, libEGL.so.1,
  libGL.so.1 and libwayland-client.so.0 inside the updated shell.
- Verified AppIndicator still loads with an inherited LD_LIBRARY_PATH and
  that the existing value is preserved. The first probe used an older host
  Python with an incompatible glibc; probes using Python from the pinned
  nixpkgs input passed.
- Evaluated the x86_64-linux CLI package and shared runtime dependency list,
  and the aarch64-linux development shell; no aarch64 execution claimed.
- nixfmt --check flake.nix nix/package.nix
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets: 566 passed, 3 ignored
Cargo checks ran through nix develop, with TSUNAGI_CONTROL_SOCKET unset for
Clippy and tests. Existing agent and tray processes were not restarted.
2026-10-21 17:05:25 +01:00
Aleksandr Bogomiakov 2e7e238459 Improve tray VIP monitoring and table layout
The VIP editor put routing and traffic observations in a dense text block,
while nested layouts in the devices grid could let text escape its row.
Separate the VIP overview from configuration and draw both live tables with
explicit cell bounds, font-aware row heights, clipping and horizontal overflow.
Measure columns before painting and keep widths stable as live values change.

The overview reuses the existing traffic sparklines for service totals and
individual listener backends, with TX/RX rates, health, RTT, open flows and
counters. Retain bounded histories by service/listener/backend identity and
release them when their resources disappear. Recreated listeners start fresh.

Use egui 0.31 Scene and Painter for a pannable, zoomable routing diagram with
Fit, clickable backend details and linked table selection. Highlight recent
counter growth separately from the backend selected for new connections;
idle bindings alone do not imply traffic, and stale activity expires. Draw
legend markers directly rather than depending on optional font glyphs.
The overview only reads saved observations; edits and deletion still require
Save. No dependencies, wire formats, IPC or persistent schemas change.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets: 566 passed, 3 ignored
- cargo build --locked -p tsunagi-tray
The inherited TSUNAGI_CONTROL_SOCKET was unset for Clippy and tests.
Seven new GUI tests cover enlarged fonts, mixed online/offline rows, long
names, large counters, narrow/wide layouts, scene clicks, traffic selection,
stale samples, counter resets, listener recreation and history cleanup.
The first enlarged-font regression exposed an override-font height mismatch;
row sizing now resolves the actual selected font and the regression passes.
A disposable fixture preview rendered successfully with software OpenGL on
an isolated Xvfb display. Initial Xvfb attempts needed explicit xkbcommon and
OpenGL runtime paths in the Nix environment. The preview never contacted an
agent; its temporary source was removed. macOS, Windows, Wayland and live host
TUN traffic were not exercised by this UI change.
2026-10-21 16:57:27 +01:00
11 changed files with 1279 additions and 267 deletions

No files matched your search

+5
View File
@@ -207,6 +207,11 @@ Just trying it: `nix run github:house-of-vanity/tsunagi#tsunagi-cli -- --help`.
`packages.<system>.tsunagi` (with the tray) and `tsunagi-cli` are also exported,
along with `overlays.default`.
For development from a checkout, `nix develop` also exposes the tray's native
runtime libraries, including AppIndicator and graphics/windowing libraries.
Cargo-built binaries such as `target/debug/tsunagi-tray` can run directly inside
this shell, without installing the packaged wrapper.
## Build from source
Rust 1.91 or newer.
+1 -1
View File
@@ -96,7 +96,7 @@ impl App {
.with_title(format!("tsunagi · VIP · {}", network.name))
.with_app_id(crate::icon::APP_ID)
.with_icon(crate::icon::window())
.with_inner_size([1000., 720.])
.with_inner_size([1280., 860.])
.with_min_inner_size([650., 400.]);
let keep = ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of(("vip", &id)),
+207 -160
View File
@@ -110,22 +110,20 @@ fn short_id(id: &str) -> String {
/// The device cell: the hostname and, in brackets, the start of the id.
/// Clicking either copies its own full value.
fn name_cell(ui: &mut egui::Ui, device: &Device<'_>) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
match (device.hostname, &device.copy) {
(Some(hostname), Some(copy)) => {
format::copy_field(ui, hostname, copy);
format::copy_field(ui, &format!("({})", short_id(device.id)), device.id);
}
_ => format::copy_field(ui, &short_id(device.id), device.id),
ui.spacing_mut().item_spacing.x = 4.0;
match (device.hostname, &device.copy) {
(Some(hostname), Some(copy)) => {
format::copy_field(ui, hostname, copy);
format::copy_field(ui, &format!("({})", short_id(device.id)), device.id);
}
if let Some((name, holder)) = &device.name_taken {
ui.label(egui::RichText::new(egui_phosphor::regular::WARNING).color(AMBER))
.on_hover_text(format!(
"Name `{name}` belongs to {holder}. Use this device's address."
));
}
});
_ => format::copy_field(ui, &short_id(device.id), device.id),
}
if let Some((name, holder)) = &device.name_taken {
ui.label(egui::RichText::new(egui_phosphor::regular::WARNING).color(AMBER))
.on_hover_text(format!(
"Name `{name}` belongs to {holder}. Use this device's address."
));
}
}
/// Draws the window body into its viewport.
@@ -491,14 +489,14 @@ const COLUMN_SAMPLES: [&str; 10] = [
/// Column widths that only ever grow, kept across frames.
///
/// A grid sizes each column to its widest cell *this frame*, so a value that
/// Live values can change width between frames. A value that
/// gets shorter — `14.4 MB/s` giving way to `382 pkt/s` — would pull the
/// column in and push every cell after it. Remembering the widest seen keeps
/// the table where it is.
fn column_widths(ui: &egui::Ui, id: egui::Id) -> [f32; 10] {
let stored: Option<[f32; 10]> = ui.memory(|m| m.data.get_temp(id));
stored.unwrap_or_else(|| {
let font = egui::TextStyle::Body.resolve(ui.style());
let font = egui::FontSelection::Default.resolve(ui.style());
let mut widths = [0.0; 10];
for (width, sample) in widths.iter_mut().zip(COLUMN_SAMPLES) {
if !sample.is_empty() {
@@ -514,21 +512,6 @@ fn column_widths(ui: &egui::Ui, id: egui::Id) -> [f32; 10] {
})
}
/// One cell, at least as wide as its column has ever been.
fn cell(ui: &mut egui::Ui, column: usize, widths: &[f32; 10], add: impl FnOnce(&mut egui::Ui)) {
let floor = widths[column];
ui.allocate_ui_with_layout(
egui::vec2(floor, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| {
ui.set_min_width(floor);
ui.set_max_width(floor);
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
add(ui);
},
);
}
fn stretch_columns(mut widths: [f32; 10], available: f32) -> [f32; 10] {
let gaps = 10.0 * (widths.len() - 1) as f32;
widths[9] = widths[9].max(100.0);
@@ -602,7 +585,7 @@ fn device_table(
let widths_id = egui::Id::new(("devices column widths", network_id));
let mut widths = column_widths(ui, widths_id);
let previous_widths = widths;
let font = egui::TextStyle::Body.resolve(ui.style());
let font = egui::FontSelection::Default.resolve(ui.style());
let measure = |text: String| {
ui.fonts(|fonts| {
fonts
@@ -678,157 +661,119 @@ fn device_table(
widths[column] = widths[column].max(measure(text));
}
if widths != previous_widths {
// Grid positions use the previous pass's widths. Let it settle
// before painting newly widened values over the next column.
ui.ctx().request_discard("device table columns widened");
ui.ctx().request_repaint();
}
ui.memory_mut(|m| m.data.insert_temp(widths_id, widths));
let widths = stretch_columns(widths, ui.available_width());
egui::Grid::new("devices")
.num_columns(10)
.min_col_width(0.0)
.striped(true)
.spacing([10.0, 4.0])
.show(ui, |ui| {
for (column, header) in COLUMN_HEADERS.into_iter().enumerate() {
cell(ui, column, &widths, |ui| {
let response = ui.weak(header);
match column {
2 => {
response.on_hover_text("End-to-end encryption.");
}
3 => {
response.on_hover_text("Handshake age / control-connection RTT.");
}
5 => {
response.on_hover_text("Transport carrying the encrypted tunnel.");
}
_ => {}
}
});
crate::table::row(ui, "device headers", &widths, false, |column, ui| {
let response = ui.weak(COLUMN_HEADERS[column]);
match column {
2 => {
response.on_hover_text("End-to-end encryption.");
}
ui.end_row();
for device in devices {
cell(ui, 0, &widths, |ui| {
ui.horizontal(|ui| {
name_cell(ui, device);
if !device.online
&& !device.in_use
&& let Some(version) = device.record_version
&& ui
.add_enabled(network.active, egui::Button::new("Forget"))
.on_hover_text("Free this offline device's name and IPv4")
.clicked()
{
*forget_confirm = Some(ForgetConfirm {
network_id: network.network_id.clone(),
peer: device.id.into(),
version,
name: device.name(),
address: device.address.clone(),
});
}
});
});
cell(ui, 1, &widths, |ui| {
exit_cell(ui, device, network_id, confirm);
});
cell(ui, 2, &widths, |ui| {
ui.push_id((device.id, "end-to-end"), |ui| {
tunnel_cell(ui, device);
});
});
cell(ui, 3, &widths, |ui| {
3 => {
response.on_hover_text("Handshake age / control-connection RTT.");
}
5 => {
response.on_hover_text("Transport carrying the encrypted tunnel.");
}
_ => {}
}
});
for (index, device) in devices.iter().enumerate() {
crate::table::row(
ui,
device.id,
&widths,
index % 2 == 0,
|column, ui| match column {
0 => {
name_cell(ui, device);
if !device.online
&& !device.in_use
&& let Some(version) = device.record_version
&& ui
.add_enabled(network.active, egui::Button::new("Forget"))
.on_hover_text("Free this offline device's name and IPv4")
.clicked()
{
*forget_confirm = Some(ForgetConfirm {
network_id: network.network_id.clone(),
peer: device.id.into(),
version,
name: device.name(),
address: device.address.clone(),
});
}
}
1 => exit_cell(ui, device, network_id, confirm),
2 => tunnel_cell(ui, device),
3 => {
ui.label(handshake_rtt_text(
device.handshake_secs,
device.control_rtt_ms,
displayed_protocol(device.tunnel_protocol) != "—",
))
.on_hover_text(if device.tunnel_protocol == "tcp-tls" {
"Session age / control RTT."
} else {
"Last handshake age / control RTT."
});
});
cell(ui, 4, &widths, |ui| match &device.address {
.on_hover_text("Last handshake / control RTT.");
}
4 => match &device.address {
Some(address) => format::copy_field(ui, address, address),
None => {
ui.weak("—");
}
});
cell(ui, 5, &widths, |ui| {
ui.push_id((device.id, "data-path"), |ui| data_path_cell(ui, device));
});
match device.overlay.filter(|_| device.online) {
Some(peer) => {
},
5 => data_path_cell(ui, device),
_ => {
if let Some(peer) = device.overlay.filter(|_| device.online) {
let series = traffic.peer(network_id, &peer.public_key);
cell(ui, 6, &widths, |ui| {
ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets));
});
cell(ui, 7, &widths, |ui| {
ui.label(format!(
"{} / {}",
format::bytes(peer.tx_bytes as f64),
format::bytes(peer.rx_bytes as f64)
));
});
cell(ui, 8, &widths, |ui| {
ui.add(
egui::Label::new(format::series_rate(series, unit))
.wrap_mode(egui::TextWrapMode::Extend),
);
});
ui.allocate_ui(egui::vec2(widths[9], 20.0), |ui| {
format::sparkline(ui, series, unit, 18.0);
});
}
None => {
for column in 6..9 {
cell(ui, column, &widths, |ui| {
ui.weak("—");
});
match column {
6 => {
ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets));
}
7 => {
ui.label(format!(
"{} / {}",
format::bytes(peer.tx_bytes as f64),
format::bytes(peer.rx_bytes as f64)
));
}
8 => {
ui.label(format::series_rate(series, unit));
}
_ => format::sparkline(ui, series, unit, 24.0),
}
cell(ui, 9, &widths, |ui| {
ui.weak("—");
});
} else {
ui.weak("—");
}
}
ui.end_row();
}
// Everything this agent has moved through the overlay, including
// devices that have since gone away.
cell(ui, 0, &widths, |ui| {
},
);
}
crate::table::row(
ui,
"device totals",
&widths,
false,
|column, ui| match column {
0 => {
ui.strong("total");
});
for column in 1..6 {
cell(ui, column, &widths, |ui| {
ui.label("");
});
}
cell(ui, 6, &widths, |ui| {
6 => {
ui.strong(format!("{tx_packets}/{rx_packets}"));
});
cell(ui, 7, &widths, |ui| {
}
7 => {
ui.strong(format!(
"{} / {}",
format::bytes(tx_bytes as f64),
format::bytes(rx_bytes as f64)
));
});
cell(ui, 8, &widths, |ui| {
ui.add(
egui::Label::new(
egui::RichText::new(format::series_rate(traffic.network(network_id), unit))
.strong(),
)
.wrap_mode(egui::TextWrapMode::Extend),
);
});
ui.end_row();
});
}
8 => {
ui.strong(format::series_rate(traffic.network(network_id), unit));
}
_ => {}
},
);
}
/// A protocol name as it should appear in the table.
@@ -1196,6 +1141,108 @@ mod tests {
}
}
#[test]
fn online_and_offline_rows_keep_text_buttons_and_graphs_in_their_own_bounds() {
use tsunagi::ipc::OverlayReport;
let mut network = NetworkReport {
network_id: "row regression".into(),
active: true,
..Default::default()
};
for index in 0..14 {
let id = format!("node-{index:02}");
let mut member = member(
&id,
index < 10,
DataPathReport::Direct {
transport: "wg-quic".into(),
},
);
member.hostname = Some(format!("node-with-a-long-name-{index:02}"));
network.members.push(member);
}
network.overlay = Some(OverlayReport {
peers: network.members[..10]
.iter()
.map(|member| OverlayPeerReport {
endpoint_id: member.endpoint_id.clone(),
protocol: "wg-quic".into(),
tx_packets: 9_876_543_210,
rx_packets: 12_345_678_901,
..Default::default()
})
.collect(),
..Default::default()
});
let rows = devices(&network, "own");
for font_size in [14.0, 22.0] {
let ctx = egui::Context::default();
ctx.style_mut(|style| {
style.override_font_id = Some(egui::FontId::proportional(font_size))
});
for width in [900.0, 2100.0] {
let output = ctx.run(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(width, 1600.0),
)),
..Default::default()
},
|ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
egui::ScrollArea::both().show(ui, |ui| {
device_table(
ui,
Unit::Bytes,
&network,
&rows,
&Traffic::default(),
&mut None,
&mut None,
)
});
});
},
);
let mut row_bounds = Vec::new();
for member in &network.members {
let shape = output.shapes.iter().find(|s| matches!(&s.shape, egui::Shape::Text(text) if Some(text.galley.text()) == member.hostname.as_deref())).unwrap();
let egui::Shape::Text(text) = &shape.shape else {
unreachable!()
};
assert_eq!(text.galley.rows.len(), 1);
assert!(shape.clip_rect.top() <= text.visual_bounding_rect().top());
assert!(shape.clip_rect.bottom() >= text.visual_bounding_rect().bottom());
row_bounds.push(shape.clip_rect);
}
for pair in row_bounds.windows(2) {
assert!(
pair[0].bottom() < pair[1].top(),
"rows overlap at font size {font_size}"
);
}
let buttons: Vec<_> = output
.shapes
.iter()
.filter(
|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "Forget"),
)
.collect();
assert_eq!(buttons.len(), 4, "offline cleanup remains visible");
for button in buttons {
let egui::Shape::Text(text) = &button.shape else {
unreachable!()
};
assert!(
button.clip_rect.contains_rect(text.visual_bounding_rect()),
"Forget button clipped at {font_size}"
);
}
}
}
}
#[test]
fn a_long_relay_name_gets_its_full_column_width() {
let mut relay = member("near", true, DataPathReport::None);
@@ -1240,7 +1287,7 @@ mod tests {
.get_temp(egui::Id::new(("devices column widths", "network")))
})
.unwrap();
let font = egui::TextStyle::Body.resolve(ui.style());
let font = egui::FontSelection::Default.resolve(ui.style());
let needed = ui.fonts(|fonts| {
fonts
.layout_no_wrap(label.clone(), font, egui::Color32::WHITE)
+2
View File
@@ -17,9 +17,11 @@ mod exit;
mod format;
mod icon;
mod stats;
mod table;
mod tray;
mod ui;
mod vip;
mod vip_status;
mod watch;
use eframe::egui;
+88
View File
@@ -0,0 +1,88 @@
//! Fixed row geometry for dense live tables. Children cannot resize a row or
//! paint into its neighbours; the enclosing scroll area owns overflow.
use eframe::egui;
pub(crate) const GAP: f32 = 10.0;
pub(crate) fn row(
ui: &mut egui::Ui,
id: impl std::hash::Hash,
widths: &[f32],
stripe: bool,
mut cell: impl FnMut(usize, &mut egui::Ui),
) {
let font = egui::FontSelection::Default.resolve(ui.style());
let text_height = ui.fonts(|fonts| fonts.row_height(&font));
let height = ui.spacing().interact_size.y.max(text_height) + 10.0;
let width = widths.iter().sum::<f32>() + GAP * widths.len().saturating_sub(1) as f32;
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
if stripe {
ui.painter()
.rect_filled(rect, 2.0, ui.visuals().faint_bg_color);
}
let mut x = rect.left();
for (column, width) in widths.iter().enumerate() {
let bounds = egui::Rect::from_min_size(
egui::pos2(x, rect.top() + 3.0),
egui::vec2(*width, height - 6.0),
);
let mut child = ui.new_child(
egui::UiBuilder::new()
.id_salt((&id, column))
.max_rect(bounds)
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
child.set_clip_rect(ui.clip_rect().intersect(bounds));
child.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
cell(column, &mut child);
x += width + GAP;
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn oversized_content_is_clipped_to_its_cell_at_every_scale() {
for size in [13.0, 20.0, 28.0] {
let ctx = egui::Context::default();
ctx.style_mut(|style| {
style.override_font_id = Some(egui::FontId::proportional(size));
});
let output = ctx.run(egui::RawInput::default(), |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
for index in 0..4 {
row(ui, index, &[90.0, 120.0], index % 2 == 0, |column, ui| {
if column == 0 {
ui.horizontal(|ui| {
ui.label("very long device name that cannot fit");
ui.button("Forget").on_hover_text("Forget");
});
} else {
ui.label(format!("row {index}"));
}
});
}
});
});
let mut previous_bottom = 0.0;
for index in 0..4 {
let shape = output.shapes.iter().find(|shape| matches!(&shape.shape, egui::Shape::Text(t) if t.galley.text() == format!("row {index}"))).unwrap();
let egui::Shape::Text(text) = &shape.shape else {
unreachable!()
};
assert!(text.visual_bounding_rect().top() >= previous_bottom);
assert!(
shape.clip_rect.contains_rect(text.visual_bounding_rect()),
"font {size}: clip {:?}, text {:?}",
shape.clip_rect,
text.visual_bounding_rect()
);
assert!(shape.clip_rect.left() >= 108.0);
previous_bottom = shape.clip_rect.bottom();
}
}
}
}
+36 -95
View File
@@ -1,10 +1,9 @@
//! Per-network VIP editor. Drafts never issue commands until Save is pressed.
use crate::agent::{AgentClient, Command, Snapshot};
use crate::stats::{Series, Unit};
use crate::vip_status;
use eframe::egui;
use std::collections::BTreeMap;
use tsunagi::ipc::NetworkReport;
use tsunagi::vip::{Backend, Listener, Protocol, VipConfig, VipStatus};
use tsunagi::vip::{Backend, Listener, Protocol, VipConfig};
#[derive(Clone)]
struct Draft {
config: VipConfig,
@@ -32,7 +31,8 @@ pub(crate) struct Window {
error: Option<String>,
close_question: bool,
close_after_save: bool,
rates: BTreeMap<String, (Series, Series)>,
live: vip_status::View,
configure: bool,
generation: u64,
acknowledged_generation: Option<u64>,
}
@@ -62,19 +62,7 @@ pub(crate) fn show(
if state.generation != snapshot.generation {
state.generation = snapshot.generation;
if let Some(at) = snapshot.at {
for v in &n.vips {
let counters = v.listeners.iter().fold((0, 0, 0, 0), |a, l| {
(
a.0 + l.counters.tx_packets,
a.1 + l.counters.tx_bytes,
a.2 + l.counters.rx_packets,
a.3 + l.counters.rx_bytes,
)
});
let (tx, rx) = state.rates.entry(v.config.id.clone()).or_default();
tx.sample(at, counters.0, counters.1);
rx.sample(at, counters.2, counters.3);
}
state.live.observe(n, at);
}
}
if let Some(result) = &snapshot.vip_result
@@ -151,6 +139,7 @@ pub(crate) fn show(
state.error = Some("Save or discard the current draft first.".into());
} else {
state.draft = Some(Draft::new(VipConfig::new(""), None));
state.configure = true;
state.error = None;
}
}
@@ -231,28 +220,36 @@ pub(crate) fn show(
}
});
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
ui.add_enabled_ui(!busy, |ui| {
ui.horizontal(|ui| {
ui.label("Name");
ui.add(
egui::TextEdit::singleline(&mut draft.config.name)
.id(egui::Id::new(("vip-name", &draft.config.id))),
);
ui.weak(format!(".{}", n.name));
});
if draft.revision.is_some() {
ui.checkbox(&mut draft.delete, "Delete this VIP when saved");
}
if !draft.delete {
edit_listeners(ui, &mut draft.config, n);
ui.horizontal(|ui| {
ui.selectable_value(&mut state.configure, false, "Overview");
ui.selectable_value(&mut state.configure, true, "Configuration");
});
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if state.configure {
ui.add_enabled_ui(!busy, |ui| {
ui.horizontal(|ui| {
ui.label("Name");
ui.add(
egui::TextEdit::singleline(&mut draft.config.name)
.id(egui::Id::new(("vip-name", &draft.config.id))),
);
ui.weak(format!(".{}", n.name));
});
if draft.revision.is_some() {
ui.checkbox(&mut draft.delete, "Delete this VIP when saved");
}
if !draft.delete {
edit_listeners(ui, &mut draft.config, n);
}
});
} else if let Some(current) = current {
vip_status::show(ui, current, n, &mut state.live);
} else {
ui.weak("Save this VIP to see its live status.");
}
});
if let Some(current) = current {
ui.separator();
status(ui, current, n, state.rates.get(&current.config.id));
}
});
});
if discard_new {
state.draft = None;
@@ -287,68 +284,11 @@ fn edit_listeners(ui: &mut egui::Ui, config: &mut VipConfig, n: &NetworkReport)
config.listeners.push(Listener::new(Protocol::Tcp, 443));
}
}
fn status(ui: &mut egui::Ui, v: &VipStatus, n: &NetworkReport, rates: Option<&(Series, Series)>) {
ui.heading("Status · this device");
if let Some(error) = &v.error {
ui.colored_label(egui::Color32::LIGHT_RED, error);
}
if let Some((tx, rx)) = rates {
ui.label(format!(
"TX {:.1} KiB/s · RX {:.1} KiB/s",
tx.rate(Unit::Bytes) / 1024.,
rx.rate(Unit::Bytes) / 1024.
));
}
for l in &v.listeners {
ui.strong(format!("{}/{}", l.protocol, l.port));
let selected = l
.selected
.as_ref()
.map(|b| format!("{}:{}", peer_name(n, &b.peer), b.port))
.unwrap_or_else(|| "none".into());
ui.label(format!("Next new flow: {selected}"));
ui.label(format!(
"TX {} bytes / {} packets · RX {} bytes / {} packets · drops {} · UDP failovers {}",
l.counters.tx_bytes,
l.counters.tx_packets,
l.counters.rx_bytes,
l.counters.rx_packets,
l.counters.dropped,
l.counters.failovers
));
for b in &l.backends {
let rtt = b
.rtt_us
.map(|v| format!("{:.2} ms", v as f64 / 1000.))
.unwrap_or_else(|| "unknown".into());
let age = b
.measurement_age_ms
.map(|v| format!("{:.1}s", v as f64 / 1000.))
.unwrap_or_else(|| "unknown".into());
ui.label(format!(
"{}:{} · {} · RTT {} (age {}) · {} active · TX/RX {}/{} bytes",
peer_name(n, &b.backend.peer),
b.backend.port,
b.state,
rtt,
age,
b.active_flows,
b.counters.tx_bytes,
b.counters.rx_bytes
));
}
}
ui.collapsing("Internal address and identity", |ui| {
ui.monospace(&v.config.id);
if let Some(address) = v.address {
ui.monospace(address.to_string());
}
});
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use tsunagi::vip::VipStatus;
#[test]
fn editing_the_actual_form_sends_nothing_until_save_is_clicked() {
let (agent, mut commands) = AgentClient::test_client();
@@ -357,6 +297,7 @@ mod tests {
config.listeners.push(Listener::new(Protocol::Tcp, 443));
let mut state = Window {
draft: Some(Draft::new(config.clone(), Some("revision".into()))),
configure: true,
..Default::default()
};
let network = NetworkReport {
+885
View File
@@ -0,0 +1,885 @@
//! Local VIP observations, kept apart from editable configuration drafts.
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use eframe::egui;
use tsunagi::ipc::NetworkReport;
use tsunagi::vip::{Backend, BackendStatus, Counters, ListenerStatus, Protocol, VipStatus};
use crate::{
format,
stats::{Series, Unit},
table,
};
const ACTIVE: egui::Color32 = egui::Color32::from_rgb(73, 190, 133);
const NEXT: egui::Color32 = egui::Color32::from_rgb(78, 157, 237);
const WARN: egui::Color32 = egui::Color32::from_rgb(225, 170, 73);
const BAD: egui::Color32 = egui::Color32::from_rgb(228, 112, 112);
#[derive(Default)]
struct Rates {
tx: Series,
rx: Series,
total: Series,
at: Option<Instant>,
}
impl Rates {
fn sample(&mut self, at: Instant, c: &Counters) {
self.tx.sample(at, c.tx_packets, c.tx_bytes);
self.rx.sample(at, c.rx_packets, c.rx_bytes);
self.total.sample(
at,
c.tx_packets.saturating_add(c.rx_packets),
c.tx_bytes.saturating_add(c.rx_bytes),
);
self.at = Some(at);
}
fn active(&self, now: Instant) -> bool {
self.at
.is_some_and(|at| now.saturating_duration_since(at) <= Duration::from_secs(5))
&& self.total.rate(Unit::Packets) > 0.0
}
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct Key(String, String, u16, String, u16);
fn key(v: &VipStatus, l: &ListenerStatus, b: &Backend) -> Key {
// Listener identity separates counter histories when a port is recreated.
let listener = v
.config
.listeners
.iter()
.find(|entry| entry.protocol == l.protocol && entry.port == l.port)
.map(|entry| entry.id.clone())
.unwrap_or_else(|| l.protocol.to_string());
Key(
v.config.id.clone(),
listener,
l.port,
b.peer.clone(),
b.port,
)
}
struct Camera {
rect: egui::Rect,
members: Vec<Backend>,
}
impl Default for Camera {
fn default() -> Self {
Self {
rect: egui::Rect::NOTHING,
members: Vec::new(),
}
}
}
pub(crate) struct View {
totals: BTreeMap<String, Rates>,
backends: BTreeMap<Key, Rates>,
cameras: BTreeMap<(String, String, u16), Camera>,
selected: Option<Key>,
unit: Unit,
}
impl Default for View {
fn default() -> Self {
Self {
totals: BTreeMap::new(),
backends: BTreeMap::new(),
cameras: BTreeMap::new(),
selected: None,
unit: Unit::Bytes,
}
}
}
impl View {
pub(crate) fn observe(&mut self, network: &NetworkReport, at: Instant) {
self.totals
.retain(|id, _| network.vips.iter().any(|v| &v.config.id == id));
let mut present = std::collections::BTreeSet::new();
for v in &network.vips {
let mut counters = Counters::default();
for l in &v.listeners {
counters.tx_packets = counters.tx_packets.saturating_add(l.counters.tx_packets);
counters.rx_packets = counters.rx_packets.saturating_add(l.counters.rx_packets);
counters.tx_bytes = counters.tx_bytes.saturating_add(l.counters.tx_bytes);
counters.rx_bytes = counters.rx_bytes.saturating_add(l.counters.rx_bytes);
for b in &l.backends {
let key = key(v, l, &b.backend);
self.backends
.entry(key.clone())
.or_default()
.sample(at, &b.counters);
present.insert(key);
}
}
self.totals
.entry(v.config.id.clone())
.or_default()
.sample(at, &counters);
}
self.backends.retain(|key, _| present.contains(key));
if self
.selected
.as_ref()
.is_some_and(|key| !present.contains(key))
{
self.selected = None;
}
self.cameras.retain(|(id, protocol, port), _| {
network.vips.iter().any(|v| {
&v.config.id == id
&& v.listeners
.iter()
.any(|l| l.protocol.to_string() == *protocol && l.port == *port)
})
});
}
}
fn name<'a>(n: &'a NetworkReport, id: &'a str) -> &'a str {
n.members
.iter()
.find(|m| m.endpoint_id == id)
.and_then(|m| m.hostname.as_deref())
.unwrap_or(id)
}
fn backend_name(n: &NetworkReport, b: &Backend) -> String {
format!("{}:{}", name(n, &b.peer), b.port)
}
fn rtt(b: &BackendStatus) -> String {
b.rtt_us
.map_or_else(|| "—".into(), |us| format!("{:.2} ms", us as f64 / 1000.0))
}
fn health_color(state: &str) -> egui::Color32 {
match state {
"ready" => ACTIVE,
"checking port" | "measuring RTT" | "draining" => WARN,
_ => BAD,
}
}
pub(crate) fn show(ui: &mut egui::Ui, v: &VipStatus, n: &NetworkReport, view: &mut View) {
ui.horizontal_wrapped(|ui| {
ui.heading(format!("{}.{}", v.config.name, n.name));
ui.weak("Traffic seen by this device");
ui.selectable_value(&mut view.unit, Unit::Bytes, "bytes");
ui.selectable_value(&mut view.unit, Unit::Packets, "pkts");
});
if let Some(error) = &v.error {
ui.colored_label(BAD, error);
}
let total = view.totals.get(&v.config.id);
ui.horizontal_wrapped(|ui| {
metric(
ui,
"TX",
&format::series_rate(total.map(|r| &r.tx), view.unit),
);
metric(
ui,
"RX",
&format::series_rate(total.map(|r| &r.rx), view.unit),
);
let flows: u64 = v
.listeners
.iter()
.flat_map(|l| &l.backends)
.map(|b| u64::from(b.active_flows))
.sum();
metric(ui, "Open flows", &flows.to_string());
});
format::sparkline(ui, total.map(|r| &r.total), view.unit, 64.0);
ui.add_space(8.0);
if v.listeners.is_empty() {
ui.weak("No listeners configured. Add one in Configuration.");
}
for l in &v.listeners {
ui.push_id(
(
"vip live listener",
&v.config.id,
l.protocol.to_string(),
l.port,
),
|ui| {
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.set_min_width((ui.available_width() - 2.0).max(0.0));
ui.horizontal_wrapped(|ui| {
ui.heading(format!("{}/{}", l.protocol, l.port));
ui.weak("New connections:");
if let Some(b) = &l.selected {
ui.colored_label(NEXT, backend_name(n, b));
} else {
ui.colored_label(WARN, "No eligible backend");
}
});
ui.horizontal_wrapped(|ui| {
metric(ui, "TX", &format::bytes(l.counters.tx_bytes as f64));
metric(ui, "RX", &format::bytes(l.counters.rx_bytes as f64));
metric(
ui,
"Packets TX / RX",
&format!("{} / {}", l.counters.tx_packets, l.counters.rx_packets),
);
metric(ui, "Drops", &l.counters.dropped.to_string());
if l.protocol == Protocol::Udp {
metric(ui, "Failovers", &l.counters.failovers.to_string());
}
});
ui.separator();
diagram(ui, v, l, n, view);
ui.add_space(6.0);
egui::ScrollArea::horizontal()
.id_salt("backend table scroll")
.auto_shrink([false, true])
.show(ui, |ui| {
backend_table(ui, v, l, n, view);
});
if let Some(b) = l
.backends
.iter()
.find(|b| view.selected.as_ref() == Some(&key(v, l, &b.backend)))
{
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.strong(backend_name(n, &b.backend));
ui.colored_label(health_color(&b.state), &b.state);
ui.weak(format!("{} open flows", b.active_flows));
if let Some(age) = b.measurement_age_ms {
ui.weak(format!(
"RTT measured {:.1}s ago",
age as f64 / 1000.0
));
} else {
ui.weak("No RTT measurement yet");
}
});
ui.horizontal_wrapped(|ui| {
ui.weak("Node");
format::copy_field(
ui,
&format::short(&b.backend.peer),
&b.backend.peer,
);
if let Some(address) = n
.members
.iter()
.find(|m| m.endpoint_id == b.backend.peer)
.and_then(|m| m.overlay_address_v4.as_deref())
{
ui.weak("Address");
format::copy_field(ui, address, address);
}
});
});
}
});
},
);
ui.add_space(8.0);
}
ui.collapsing("Internal address and identity", |ui| {
egui::Grid::new(("vip identity", &v.config.id)).show(ui, |ui| {
ui.weak("Address");
if let Some(address) = v.address {
format::copy_field(ui, &address.to_string(), &address.to_string());
} else {
ui.weak("Not allocated");
}
ui.end_row();
ui.weak("ID");
format::copy_field(ui, &v.config.id, &v.config.id);
ui.end_row();
});
});
}
fn metric(ui: &mut egui::Ui, title: &str, value: &str) {
ui.horizontal(|ui| {
ui.weak(title);
ui.strong(value);
});
ui.add_space(8.0);
}
fn backend_table(
ui: &mut egui::Ui,
v: &VipStatus,
l: &ListenerStatus,
n: &NetworkReport,
view: &mut View,
) {
let headers = [
"Backend",
"Health",
"Route",
"RTT",
"Flows",
"TX / RX",
"TX / RX rate",
"Traffic",
];
let mut widths = [170.0_f32, 110.0, 100.0, 90.0, 50.0, 160.0, 160.0, 160.0];
// Measure every value before layout, including long identities and counters.
let values: Vec<_> = l
.backends
.iter()
.map(|b| {
let rates = view.backends.get(&key(v, l, &b.backend));
let route = if rates.is_some_and(|r| r.active(Instant::now())) {
"Traffic"
} else if l.selected.as_ref() == Some(&b.backend) {
"Next flow"
} else {
"—"
};
[
backend_name(n, &b.backend),
b.state.clone(),
route.into(),
rtt(b),
b.active_flows.to_string(),
format!(
"{} / {}",
format::bytes(b.counters.tx_bytes as f64),
format::bytes(b.counters.rx_bytes as f64)
),
format!(
"{} / {}",
format::series_rate(rates.map(|r| &r.tx), view.unit),
format::series_rate(rates.map(|r| &r.rx), view.unit)
),
]
})
.collect();
for (column, width) in widths.iter_mut().enumerate().take(7) {
let font = egui::FontSelection::Default.resolve(ui.style());
for text in
std::iter::once(headers[column]).chain(values.iter().map(|row| row[column].as_str()))
{
*width = width.max(
ui.fonts(|f| {
f.layout_no_wrap(text.to_owned(), font.clone(), egui::Color32::WHITE)
.size()
.x
}) + 12.0,
);
}
}
let widths_id = ui.id().with("backend column widths");
if let Some(previous) = ui.memory(|memory| memory.data.get_temp::<[f32; 8]>(widths_id)) {
for (width, previous) in widths.iter_mut().zip(previous) {
*width = width.max(previous);
}
}
ui.memory_mut(|memory| memory.data.insert_temp(widths_id, widths));
let fixed = widths.iter().sum::<f32>() + table::GAP * 7.0;
widths[7] += (ui.available_width() - fixed).max(0.0);
table::row(ui, "backend headers", &widths, false, |column, ui| {
ui.weak(headers[column]);
});
for (index, b) in l.backends.iter().enumerate() {
let key = key(v, l, &b.backend);
let rates = view.backends.get(&key);
table::row(
ui,
&key,
&widths,
index % 2 == 0,
|column, ui| match column {
0 => {
if ui
.selectable_label(view.selected.as_ref() == Some(&key), &values[index][0])
.clicked()
{
view.selected = Some(key.clone());
}
}
1 => {
ui.colored_label(health_color(&b.state), &values[index][1])
.on_hover_text(&b.state);
}
2 => {
ui.colored_label(
if values[index][2] == "Traffic" {
ACTIVE
} else {
NEXT
},
&values[index][2],
)
.on_hover_text(
if l.selected.as_ref() == Some(&b.backend) {
"Selected for new connections. Existing flows can use other backends."
} else {
"Traffic during the latest sample interval."
},
);
}
3..=6 => {
ui.label(&values[index][column]);
}
_ => format::sparkline(ui, rates.map(|r| &r.total), view.unit, 24.0),
},
);
}
if l.backends.is_empty() {
ui.weak("No backends. Add a node and target port in Configuration.");
}
}
#[derive(Clone, Copy)]
struct Branch {
active: bool,
next: bool,
}
fn branch(l: &ListenerStatus, b: &BackendStatus, rates: Option<&Rates>, now: Instant) -> Branch {
Branch {
active: rates.is_some_and(|r| r.active(now)),
next: l.selected.as_ref() == Some(&b.backend),
}
}
fn diagram(
ui: &mut egui::Ui,
v: &VipStatus,
l: &ListenerStatus,
n: &NetworkReport,
view: &mut View,
) {
let camera = view
.cameras
.entry((v.config.id.clone(), l.protocol.to_string(), l.port))
.or_default();
let members: Vec<_> = l.backends.iter().map(|b| b.backend.clone()).collect();
let height = (members.len().max(1) as f32 * 92.0 + 32.0).max(180.0);
let bounds = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(860.0, height));
if camera.members != members || !camera.rect.is_positive() {
camera.rect = bounds;
camera.members = members;
}
ui.horizontal_wrapped(|ui| {
legend(ui, "Traffic", ACTIVE, true);
legend(ui, "Next new flow", NEXT, false);
ui.weak("Click a backend · drag to pan · Ctrl+scroll to zoom");
if ui.small_button("Fit").clicked() {
camera.rect = bounds;
}
});
let size = egui::vec2(ui.available_width(), height.clamp(180.0, 360.0));
let (outer, _) = ui.allocate_exact_size(size, egui::Sense::hover());
ui.painter()
.rect_filled(outer, 6.0, ui.visuals().extreme_bg_color);
let mut child = ui.new_child(egui::UiBuilder::new().id_salt("vip graph").max_rect(outer));
egui::Scene::new()
.zoom_range(0.05..=1.5)
.max_inner_size(bounds.size())
.show(&mut child, &mut camera.rect, |ui| {
ui.set_min_size(bounds.size());
let center = height / 2.0;
let source =
egui::Rect::from_min_size(egui::pos2(18.0, center - 32.0), egui::vec2(204.0, 64.0));
let listener = egui::Rect::from_min_size(
egui::pos2(290.0, center - 32.0),
egui::vec2(146.0, 64.0),
);
let active = l.backends.iter().any(|b| {
view.backends
.get(&key(v, l, &b.backend))
.is_some_and(|r| r.active(Instant::now()))
});
edge(
ui,
source.right_center(),
listener.left_center(),
if active { ACTIVE } else { NEXT },
active,
);
node(
ui,
source,
&format!("{}.{}", v.config.name, n.name),
"This device",
NEXT,
false,
);
node(
ui,
listener,
&format!("{}/{}", l.protocol, l.port),
"Listener",
NEXT,
false,
);
for (index, b) in l.backends.iter().enumerate() {
let key = key(v, l, &b.backend);
let rates = view.backends.get(&key);
let state = branch(l, b, rates, Instant::now());
let rect = egui::Rect::from_min_size(
egui::pos2(558.0, 24.0 + index as f32 * 92.0),
egui::vec2(278.0, 68.0),
);
let color = if state.active {
ACTIVE
} else if state.next {
NEXT
} else if b.state == "ready" {
ui.visuals().weak_text_color()
} else {
health_color(&b.state)
};
edge(
ui,
listener.right_center(),
rect.left_center(),
color,
state.active,
);
if state.next {
ui.painter().circle_stroke(
rect.left_center(),
5.0,
egui::Stroke::new(2.0_f32, NEXT),
);
}
let response = ui
.interact(rect, ui.id().with((&key.3, key.4)), egui::Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(format!(
"{}\n{} · RTT {}\n{} open flows\n{}{}",
backend_name(n, &b.backend),
b.state,
rtt(b),
b.active_flows,
if state.active {
"Traffic in latest sample. "
} else {
""
},
if state.next {
"Selected for new connections."
} else {
""
}
));
if response.clicked() {
view.selected = Some(key.clone());
}
let detail = format!("{} · {} · {} flows", b.state, rtt(b), b.active_flows);
node(
ui,
rect,
&backend_name(n, &b.backend),
&detail,
color,
view.selected.as_ref() == Some(&key) || response.hovered(),
);
}
if l.backends.is_empty() {
ui.painter().text(
egui::pos2(570.0, center),
egui::Align2::LEFT_CENTER,
"No backends",
egui::FontId::proportional(15.0),
ui.visuals().weak_text_color(),
);
}
});
}
fn legend(ui: &mut egui::Ui, text: &str, color: egui::Color32, filled: bool) {
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover());
if filled {
ui.painter().circle_filled(rect.center(), 4.0, color);
} else {
ui.painter()
.circle_stroke(rect.center(), 4.0, egui::Stroke::new(1.5_f32, color));
}
ui.colored_label(color, text);
});
}
fn node(
ui: &egui::Ui,
rect: egui::Rect,
title: &str,
detail: &str,
color: egui::Color32,
selected: bool,
) {
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 8.0, ui.visuals().widgets.inactive.bg_fill);
painter.rect_stroke(
rect.shrink(1.0),
8.0,
egui::Stroke::new(if selected { 3.0_f32 } else { 1.5_f32 }, color),
egui::StrokeKind::Inside,
);
text_line(
&painter,
rect.min + egui::vec2(12.0, 9.0),
title,
16.0,
ui.visuals().strong_text_color(),
rect.width() - 24.0,
);
text_line(
&painter,
rect.min + egui::vec2(12.0, 34.0),
detail,
12.0,
ui.visuals().text_color(),
rect.width() - 24.0,
);
}
fn text_line(
painter: &egui::Painter,
position: egui::Pos2,
text: &str,
size: f32,
color: egui::Color32,
width: f32,
) {
let mut job = egui::text::LayoutJob::simple_singleline(
text.into(),
egui::FontId::proportional(size),
color,
);
job.wrap.max_width = width;
job.wrap.max_rows = 1;
job.wrap.break_anywhere = true;
painter.galley(position, painter.layout_job(job), color);
}
fn edge(ui: &egui::Ui, start: egui::Pos2, end: egui::Pos2, color: egui::Color32, active: bool) {
let bend = (end.x - start.x) * 0.5;
let curve = egui::epaint::CubicBezierShape::from_points_stroke(
[
start,
start + egui::vec2(bend, 0.0),
end - egui::vec2(bend, 0.0),
end,
],
false,
egui::Color32::TRANSPARENT,
egui::Stroke::new(if active { 3.0_f32 } else { 1.5_f32 }, color),
);
ui.painter().add(curve.clone());
if active {
let t = ui.input(|i| i.time) as f32 * 0.5 % 1.0;
let position = curve.sample(t);
ui.painter().circle_filled(position, 4.0, color);
ui.ctx().request_repaint_after(Duration::from_millis(40));
}
ui.painter().arrow(
end - egui::vec2(12.0, 0.0),
egui::vec2(10.0, 0.0),
egui::Stroke::new(1.5_f32, color),
);
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use tsunagi::vip::{Listener, VipConfig};
fn fixture() -> (NetworkReport, VipStatus) {
let mut config = VipConfig::new("k8s");
config.listeners.push(Listener::new(Protocol::Tcp, 6443));
let backends: Vec<_> = ["ai", "master", "music"]
.into_iter()
.map(|peer| BackendStatus {
backend: Backend {
peer: peer.into(),
port: 7443,
},
state: "ready".into(),
rtt_us: Some(4500),
measurement_age_ms: Some(500),
active_flows: 5,
counters: Counters::default(),
})
.collect();
let v = VipStatus {
config,
revision: "r1".into(),
address: None,
error: None,
listeners: vec![ListenerStatus {
protocol: Protocol::Tcp,
port: 6443,
selected: Some(backends[0].backend.clone()),
backends,
counters: Counters::default(),
}],
};
let n = NetworkReport {
name: "test".into(),
vips: vec![v.clone()],
..Default::default()
};
(n, v)
}
#[test]
fn traffic_branches_are_sampled_independently_of_new_flow_selection_and_open_flows() {
let (mut n, _) = fixture();
let now = Instant::now();
let mut view = View::default();
view.observe(&n, now);
n.vips[0].listeners[0].backends[2].counters.tx_packets = 20;
n.vips[0].listeners[0].backends[2].counters.tx_bytes = 4000;
n.vips[0].listeners[0].counters.tx_packets = 20;
n.vips[0].listeners[0].counters.tx_bytes = 4000;
view.observe(&n, now + Duration::from_secs(2));
let v = &n.vips[0];
let l = &v.listeners[0];
let selected = branch(
l,
&l.backends[0],
view.backends.get(&key(v, l, &l.backends[0].backend)),
now + Duration::from_secs(2),
);
let carrying = branch(
l,
&l.backends[2],
view.backends.get(&key(v, l, &l.backends[2].backend)),
now + Duration::from_secs(2),
);
assert!(selected.next && !selected.active);
assert!(carrying.active && !carrying.next);
assert_eq!(view.totals[&v.config.id].total.rate(Unit::Bytes), 2000.0);
assert!(
!view.backends[&key(v, l, &l.backends[2].backend)].active(now + Duration::from_secs(8))
);
view.observe(&n, now + Duration::from_secs(4));
assert!(
!view.backends[&key(v, l, &l.backends[2].backend)].active(now + Duration::from_secs(4))
);
}
#[test]
fn removed_services_and_backends_release_history_selection_and_cameras() {
let (mut n, v) = fixture();
let mut view = View::default();
view.observe(&n, Instant::now());
view.selected = Some(key(
&v,
&v.listeners[0],
&v.listeners[0].backends[0].backend,
));
n.vips.clear();
view.observe(&n, Instant::now());
assert!(
view.backends.is_empty()
&& view.totals.is_empty()
&& view.selected.is_none()
&& view.cameras.is_empty()
);
}
#[test]
fn clicking_a_scene_node_selects_its_backend_without_changing_configuration() {
let (n, v) = fixture();
let ctx = egui::Context::default();
let mut view = View::default();
let frame = |view: &mut View, events| {
ctx.run(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1000.0, 600.0),
)),
events,
..Default::default()
},
|ctx| {
egui::CentralPanel::default()
.show(ctx, |ui| diagram(ui, &v, &v.listeners[0], &n, view));
},
)
};
frame(&mut view, vec![]);
let output = frame(&mut view, vec![]);
let pos = output
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == "music:7443" => {
Some(t.pos + egui::vec2(20.0, 10.0))
}
_ => None,
})
.expect("scene draws the backend node");
for pressed in [true, false] {
frame(
&mut view,
vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
},
],
);
}
assert!(
view.selected
== Some(key(
&v,
&v.listeners[0],
&v.listeners[0].backends[2].backend
))
);
assert_eq!(n.vips[0].config, v.config);
}
#[test]
fn reset_counters_and_recreated_listeners_do_not_inherit_active_traffic() {
let (mut n, _) = fixture();
let mut view = View::default();
let at = Instant::now();
n.vips[0].listeners[0].backends[0].counters.tx_packets = 100;
view.observe(&n, at);
n.vips[0].listeners[0].backends[0].counters.tx_packets = 0;
view.observe(&n, at + Duration::from_secs(2));
let v = &n.vips[0];
let l = &v.listeners[0];
assert!(
!view.backends[&key(v, l, &l.backends[0].backend)].active(at + Duration::from_secs(2))
);
n.vips[0].config.listeners[0].id = "new listener".into();
n.vips[0].listeners[0].backends[0].counters.tx_packets = 200;
view.observe(&n, at + Duration::from_secs(4));
let v = &n.vips[0];
let l = &v.listeners[0];
assert!(
!view.backends[&key(v, l, &l.backends[0].backend)].active(at + Duration::from_secs(4))
);
assert_eq!(view.backends.len(), 3);
}
#[test]
fn live_tables_and_scenes_render_at_narrow_and_wide_sizes() {
let (n, v) = fixture();
let ctx = egui::Context::default();
let mut view = View::default();
for width in [650.0, 1000.0, 1800.0, 650.0] {
for _ in 0..2 {
let _ = ctx.run(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(width, 1000.0),
)),
..Default::default()
},
|ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| show(ui, &v, &n, &mut view));
assert!(
ui.min_rect().right() <= width + 1.0,
"VIP content expands the window at {width}"
);
});
},
);
}
}
}
}
+9
View File
@@ -243,3 +243,12 @@ binary, configure independent listeners, disable checks, reject an invalid
change atomically and observe DNS creation/deletion. GUI tests cover draft
isolation and form rendering. These do not establish live host TUN behavior or
native desktop behavior on Linux, macOS or Windows. See [VIP semantics](vip.md).
Tray layout tests render bounded table rows with long names, online/offline
members, Forget buttons, large counters and enlarged fonts. They assert text
stays within its own row and that narrow windows scroll without expanding the
viewport. VIP overview tests render live tables and egui scenes at multiple
widths, click a diagram backend, and distinguish the new-flow selection from
traffic on an existing binding. Sampling checks cover idle open flows, stale
observations, counter resets, recreated listeners and removal of cached
histories. These are headless egui checks, not native desktop screenshots.
+17 -1
View File
@@ -43,7 +43,23 @@ listeners, backend nodes/ports, check settings and flow timeouts. All edits,
including deletion, remain drafts until **Save**. Status updates do not
replace a dirty draft. A save conflict retains the draft; **Discard / reload**
loads the latest configuration. Closing a dirty window offers explicit discard
or continued editing. Addresses and ids are available in a collapsed detail.
or continued editing.
The **Overview** tab shows a total VIP traffic plot and one backend table per
listener, with health, RTT, open flows, totals, TX/RX rates and individual
traffic plots. Plots switch between bytes and packets. Observations describe
traffic seen by this device, not a network-wide aggregate.
An interactive diagram connects the service name through each listener to its
node and target port. Drag to pan, Ctrl+scroll/pinch to zoom, and use **Fit**
to restore the view. Click a backend in the diagram or table to highlight it
and see its identity, address and measurement age. Green branches with moving
dots indicate counter growth during the latest status sample; a blue ring
marks the backend selected for new connections. Existing flows can therefore
keep a different branch active. Idle open flows alone do not light up a branch;
activity expires after five seconds without a fresh sample. Graph interaction
never edits or saves configuration. The **Configuration** tab retains the
explicit Save workflow. Addresses and ids are available in a collapsed detail.
## Delivery and recovery
+21 -9
View File
@@ -26,14 +26,26 @@
nixosModules.default = import ./nix/module.nix self;
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
inputsFrom = [ self.packages.${pkgs.stdenv.hostPlatform.system}.tsunagi ];
packages = [
pkgs.rustfmt
pkgs.clippy
];
};
});
devShells = forAllSystems (
pkgs:
let
trayPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.tsunagi;
in
{
default = pkgs.mkShell {
inputsFrom = [ trayPackage ];
packages = [
pkgs.rustfmt
pkgs.clippy
]
++ trayPackage.trayRuntimeLibs;
# Cargo binaries have no installed wrapper. Expose runtime-loaded
# libraries here too, while preserving the caller's library path.
shellHook = ''
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath trayPackage.trayRuntimeLibs}:/run/opengl-driver/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
'';
};
}
);
};
}
+8 -1
View File
@@ -52,7 +52,10 @@ rustPlatform.buildRustPackage {
# The suite opens real sockets and interfaces; it runs in CI instead.
doCheck = false;
nativeBuildInputs = [ pkg-config ] ++ lib.optionals withTray [
nativeBuildInputs = [
pkg-config
]
++ lib.optionals withTray [
copyDesktopItems
wrapGAppsHook3
];
@@ -64,6 +67,10 @@ rustPlatform.buildRustPackage {
dontWrapGApps = true;
# The development shell runs unwrapped Cargo binaries and needs the same
# dynamically loaded libraries as the installed tray wrapper.
passthru = { inherit trayRuntimeLibs; };
postInstall = ''
install -Dm644 dist/linux/50-tsunagi-resolved.rules \
"$out/share/doc/tsunagi/50-tsunagi-resolved.rules"