Compare commits
2
Commits
23af1a2d9d
...
8a6216bccb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6216bccb | ||
|
|
2e7e238459 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¤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 {
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user