diff --git a/crates/tsunagi-tray/src/app.rs b/crates/tsunagi-tray/src/app.rs index d5e9dbb..c89c941 100644 --- a/crates/tsunagi-tray/src/app.rs +++ b/crates/tsunagi-tray/src/app.rs @@ -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)), diff --git a/crates/tsunagi-tray/src/devices.rs b/crates/tsunagi-tray/src/devices.rs index ba5bc51..dcb50bf 100644 --- a/crates/tsunagi-tray/src/devices.rs +++ b/crates/tsunagi-tray/src/devices.rs @@ -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) diff --git a/crates/tsunagi-tray/src/main.rs b/crates/tsunagi-tray/src/main.rs index 923427a..dd2019a 100644 --- a/crates/tsunagi-tray/src/main.rs +++ b/crates/tsunagi-tray/src/main.rs @@ -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; diff --git a/crates/tsunagi-tray/src/table.rs b/crates/tsunagi-tray/src/table.rs new file mode 100644 index 0000000..625caea --- /dev/null +++ b/crates/tsunagi-tray/src/table.rs @@ -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::() + 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(); + } + } + } +} diff --git a/crates/tsunagi-tray/src/vip.rs b/crates/tsunagi-tray/src/vip.rs index e0158fd..9c77981 100644 --- a/crates/tsunagi-tray/src/vip.rs +++ b/crates/tsunagi-tray/src/vip.rs @@ -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, close_question: bool, close_after_save: bool, - rates: BTreeMap, + live: vip_status::View, + configure: bool, generation: u64, acknowledged_generation: Option, } @@ -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(¤t.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 { diff --git a/crates/tsunagi-tray/src/vip_status.rs b/crates/tsunagi-tray/src/vip_status.rs new file mode 100644 index 0000000..daf484c --- /dev/null +++ b/crates/tsunagi-tray/src/vip_status.rs @@ -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, +} +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, +} +impl Default for Camera { + fn default() -> Self { + Self { + rect: egui::Rect::NOTHING, + members: Vec::new(), + } + } +} +pub(crate) struct View { + totals: BTreeMap, + backends: BTreeMap, + cameras: BTreeMap<(String, String, u16), Camera>, + selected: Option, + 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::() + 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}" + ); + }); + }, + ); + } + } + } +} diff --git a/docs/testing.md b/docs/testing.md index 2212d35..036fc49 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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. diff --git a/docs/vip.md b/docs/vip.md index f377aec..e060e70 100644 --- a/docs/vip.md +++ b/docs/vip.md @@ -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