From 1551cda4dc9497fb5793fd899e46524678e87649 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Thu, 18 Dec 2025 02:02:37 +0100 Subject: [PATCH 01/77] feat(gui): add a graph view in the visualization tab --- Cargo.lock | 76 +++++++++++++++++++ crates/fosr-gui/Cargo.toml | 4 +- crates/fosr-gui/src/visualization/mod.rs | 3 +- .../src/visualization/visualization_tab.rs | 40 +++++++++- .../src/visualization/visualization_utils.rs | 14 ++++ 5 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 crates/fosr-gui/src/visualization/visualization_utils.rs diff --git a/Cargo.lock b/Cargo.lock index 3527c053..4d1aeb8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,10 @@ name = "accesskit" version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" +dependencies = [ + "enumn", + "serde", +] [[package]] name = "accesskit_atspi_common" @@ -123,6 +127,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -664,6 +669,9 @@ name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] [[package]] name = "block" @@ -1270,6 +1278,7 @@ checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" dependencies = [ "bytemuck", "emath", + "serde", ] [[package]] @@ -1321,6 +1330,8 @@ dependencies = [ "log", "nohash-hasher", "profiling", + "ron", + "serde", "smallvec", "unicode-segmentation", ] @@ -1383,6 +1394,7 @@ dependencies = [ "mime_guess2", "profiling", "resvg", + "serde", "syntect", ] @@ -1403,6 +1415,20 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_graphs" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0673eb7bb77708b8f59ef30fd90fa6537026f078fe72579ee68720073b6c8423" +dependencies = [ + "egui", + "getrandom 0.2.16", + "petgraph", + "rand 0.9.2", + "serde", + "web-time", +] + [[package]] name = "ehttp" version = "0.6.0" @@ -1430,6 +1456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -1451,6 +1478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" dependencies = [ "enum-map-derive", + "serde", ] [[package]] @@ -1485,6 +1513,17 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -1529,6 +1568,7 @@ dependencies = [ "nohash-hasher", "parking_lot", "profiling", + "serde", ] [[package]] @@ -1641,6 +1681,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.5" @@ -1756,11 +1802,13 @@ dependencies = [ "chrono-tz", "eframe", "egui_extras", + "egui_graphs", "env_logger", "fosr-lib", "humantime", "indicatif", "log", + "petgraph", "rfd", "wasm-bindgen", "wasm-bindgen-futures", @@ -1891,8 +1939,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -3162,6 +3212,19 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", + "serde", + "serde_derive", +] + [[package]] name = "phf" version = "0.11.3" @@ -3794,6 +3857,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ron" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468" +dependencies = [ + "base64", + "bitflags 2.10.0", + "serde", + "serde_derive", + "unicode-ident", +] + [[package]] name = "roxmltree" version = "0.20.0" diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index 5075dc67..82c0ce4a 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -29,7 +29,7 @@ native = [] chrono = "0.4.42" chrono-tz = "0.10.4" eframe = "0.33.3" -egui_extras = { version = "0.33.3", features = ["chrono", "all_loaders", "syntect"] } +egui_extras = { version = "0.33.3", features = ["chrono", "all_loaders", "syntect", "serde"] } log = "0.4.28" wasm-bindgen = "0.2.106" fosr-lib = { version = "1.0.0-dev", path = "../fosr-lib", features = ["wasm_js"] } @@ -37,6 +37,8 @@ rfd = "0.16.0" humantime = "2.3.0" indicatif = "0.18.3" web-time = "1.1.0" +petgraph = "0.8.3" +egui_graphs = "0.29.0" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4.55" diff --git a/crates/fosr-gui/src/visualization/mod.rs b/crates/fosr-gui/src/visualization/mod.rs index e2f33c50..fe4468e4 100644 --- a/crates/fosr-gui/src/visualization/mod.rs +++ b/crates/fosr-gui/src/visualization/mod.rs @@ -1 +1,2 @@ -pub mod visualization_tab; \ No newline at end of file +pub mod visualization_tab; +mod visualization_utils; diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 0cd3c8c5..4c146929 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -1,19 +1,51 @@ +use super::visualization_utils::distribute_nodes_circle; use eframe::egui; +use egui_graphs::{ + FruchtermanReingoldWithCenterGravity, FruchtermanReingoldWithCenterGravityState, + LayoutForceDirected, +}; /** * Represents the state of the visualization tab. */ -pub struct VisualizationTabState {} +pub struct VisualizationTabState { + pub graph: egui_graphs::Graph<(), (), petgraph::Undirected>, +} impl Default for VisualizationTabState { fn default() -> Self { - Self {} + let mut g = petgraph::stable_graph::StableGraph::default(); + let a = g.add_node(()); + let b = g.add_node(()); + let c = g.add_node(()); + + g.add_edge(a, b, ()); + g.add_edge(b, c, ()); + g.add_edge(c, a, ()); + + let mut graph = egui_graphs::Graph::<(), (), petgraph::Undirected>::from(&g); + + // Initially, the nodes are placed at position (0, 0) and need to be distributed + distribute_nodes_circle(&mut graph); + + Self { graph } } } pub fn show_visualization_tab_content( ui: &mut egui::Ui, - _visualization_tab_state: &mut VisualizationTabState, + visualization_tab_state: &mut VisualizationTabState, ) { - ui.heading("COMING SOON"); + egui::CentralPanel::default().show(ui.ctx(), |ui| { + ui.add(&mut egui_graphs::GraphView::< + (), + (), + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + egui_graphs::DefaultNodeShape, + egui_graphs::DefaultEdgeShape, + FruchtermanReingoldWithCenterGravityState, + LayoutForceDirected, + >::new(&mut visualization_tab_state.graph)); + }); } diff --git a/crates/fosr-gui/src/visualization/visualization_utils.rs b/crates/fosr-gui/src/visualization/visualization_utils.rs new file mode 100644 index 00000000..c1227fd8 --- /dev/null +++ b/crates/fosr-gui/src/visualization/visualization_utils.rs @@ -0,0 +1,14 @@ +use eframe::egui; + +pub fn distribute_nodes_circle(graph: &mut egui_graphs::Graph<(), (), petgraph::Undirected>) { + let n = graph.node_count().max(1) as f32; + let radius = n.sqrt() * 50.0 + 50.0; + + let indices: Vec<_> = graph.g().node_indices().collect(); + for (i, idx) in indices.into_iter().enumerate() { + if let Some(node) = graph.g_mut().node_weight_mut(idx) { + let angle = (i as f32 / n) * std::f32::consts::TAU; + node.set_location(egui::pos2(radius * angle.cos(), radius * angle.sin())); + } + } +} From edc4602f1c16f422a6c979f743dca2874c4d2c67 Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 8 Jan 2026 14:32:03 +0100 Subject: [PATCH 02/77] Feat: yaml parsing --- crates/fosr-gui/Cargo.toml | 2 + .../src/configuration/configuration_tab.rs | 19 ++++++++ crates/fosr-gui/src/shared/config_model.rs | 45 +++++++++++++++++++ .../fosr-gui/src/shared/configuration_file.rs | 37 +++++++++++++++ crates/fosr-gui/src/shared/mod.rs | 1 + 5 files changed, 104 insertions(+) create mode 100644 crates/fosr-gui/src/shared/config_model.rs diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index 5075dc67..7561e46f 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -37,6 +37,8 @@ rfd = "0.16.0" humantime = "2.3.0" indicatif = "0.18.3" web-time = "1.1.0" +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4.55" diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 26f96b43..83f0064d 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -24,6 +24,25 @@ pub fn show_configuration_tab_content( ui.separator(); + // --- Parsing status --- + if configuration_file_state.picked_config_file.is_some() { + if let Some(err) = &configuration_file_state.parse_error { + ui.colored_label(egui::Color32::RED, "YAML parsing failed:"); + ui.label(err); + } else if configuration_file_state.config_model.is_some() { + ui.colored_label(egui::Color32::GREEN, "YAML parsed successfully ✅"); + } else if configuration_file_state.config_file_content.is_some() { + // Content loaded but model not set -> should not happen often, but safe + ui.colored_label(egui::Color32::YELLOW, "YAML loaded, but not parsed yet."); + } + ui.separator(); + } + if let Some(model) = &configuration_file_state.config_model { + let title = model.metadata.title.as_deref().unwrap_or(""); + ui.label(format!("Title: {title}")); + ui.label(format!("Hosts: {}", model.hosts.len())); + ui.separator(); + } // Config file editor if configuration_file_state.picked_config_file.is_none() { ui.label("No configuration file selected"); diff --git a/crates/fosr-gui/src/shared/config_model.rs b/crates/fosr-gui/src/shared/config_model.rs new file mode 100644 index 00000000..166b4b08 --- /dev/null +++ b/crates/fosr-gui/src/shared/config_model.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Configuration { + #[serde(default)] + pub metadata: Metadata, + + #[serde(default)] + pub hosts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Metadata { + pub title: Option, + pub desc: Option, + pub author: Option, + pub date: Option, + pub version: Option, + pub format: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Host { + pub hostname: Option, + pub os: Option, + pub usage: Option, + pub r#type: Option, + + #[serde(default)] + pub client: Vec, + + #[serde(default)] + pub interfaces: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Interface { + pub mac_addr: Option, + + // Mandatory dans ton YAML -> String (si absent, parsing échoue) + pub ip_addr: String, + + #[serde(default)] + pub services: Vec, +} diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 7eeb848f..dea7f83b 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -1,3 +1,4 @@ +use crate::shared::config_model::Configuration; #[cfg(not(target_arch = "wasm32"))] use crate::shared::file_io::{read_file_desktop, show_file_picker_desktop}; #[cfg(target_arch = "wasm32")] @@ -14,6 +15,8 @@ pub struct ConfigurationFileState { pub config_file_content: Option, #[cfg(target_arch = "wasm32")] pub config_file_content_receiver: Option>>, + pub config_model: Option, + pub parse_error: Option, } impl Default for ConfigurationFileState { @@ -25,6 +28,8 @@ impl Default for ConfigurationFileState { config_file_content: None, #[cfg(target_arch = "wasm32")] config_file_content_receiver: None, + config_model: None, + parse_error: None, } } } @@ -44,6 +49,7 @@ pub fn configuration_file_picker( let file = show_file_picker_desktop(); if file.is_some() { configuration_file_state.picked_config_file = file; + reset_loaded_config(configuration_file_state); } } @@ -69,6 +75,7 @@ pub fn configuration_file_picker( // Only update if a file was actually selected if file.is_some() { configuration_file_state.picked_config_file = file; + reset_loaded_config(configuration_file_state); } configuration_file_state.config_file_receiver = None; // Dialog finished } @@ -84,6 +91,7 @@ pub fn configuration_file_picker( if configuration_file_state.picked_config_file.is_some() && ui.button("Remove").clicked() { configuration_file_state.picked_config_file = None; + reset_loaded_config(configuration_file_state); }; // On desktop: filename with its full path on hover, on WASM: just the filename @@ -108,6 +116,7 @@ pub fn load_config_file_contents(configuration_file_state: &mut ConfigurationFil { let content = read_file_desktop(file_handle); configuration_file_state.config_file_content = Some(content); + parse_config_yaml(configuration_file_state); } #[cfg(target_arch = "wasm32")] @@ -129,7 +138,35 @@ pub fn load_config_file_contents(configuration_file_state: &mut ConfigurationFil configuration_file_state.config_file_content = content; configuration_file_state.config_file_content_receiver = None; } + if configuration_file_state.config_file_content.is_some() { + parse_config_yaml(configuration_file_state); + } } } } } + +fn parse_config_yaml(configuration_file_state: &mut ConfigurationFileState) { + configuration_file_state.config_model = None; + configuration_file_state.parse_error = None; + + let Some(yaml) = configuration_file_state.config_file_content.as_deref() else { + return; + }; + + match serde_yaml::from_str::(yaml) { + Ok(model) => configuration_file_state.config_model = Some(model), + Err(e) => configuration_file_state.parse_error = Some(e.to_string()), + } +} + +pub fn reset_loaded_config(configuration_file_state: &mut ConfigurationFileState) { + configuration_file_state.config_file_content = None; + configuration_file_state.config_model = None; + configuration_file_state.parse_error = None; + + #[cfg(target_arch = "wasm32")] + { + configuration_file_state.config_file_content_receiver = None; + } +} diff --git a/crates/fosr-gui/src/shared/mod.rs b/crates/fosr-gui/src/shared/mod.rs index 65b099e1..1b9380bf 100644 --- a/crates/fosr-gui/src/shared/mod.rs +++ b/crates/fosr-gui/src/shared/mod.rs @@ -1,2 +1,3 @@ +pub mod config_model; pub mod configuration_file; pub mod file_io; From 54596d74d86b35cbf77a9647a42236bd4c13e9c0 Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 8 Jan 2026 15:20:01 +0100 Subject: [PATCH 03/77] Feat: modifiable metadata in config --- .../src/configuration/configuration_tab.rs | 77 ++++++++++++++++++- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 83f0064d..adbeb5a4 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -37,12 +37,81 @@ pub fn show_configuration_tab_content( } ui.separator(); } - if let Some(model) = &configuration_file_state.config_model { - let title = model.metadata.title.as_deref().unwrap_or(""); - ui.label(format!("Title: {title}")); - ui.label(format!("Hosts: {}", model.hosts.len())); + if let Some(model) = configuration_file_state.config_model.as_mut() { + ui.heading("Metadata"); + ui.add_space(6.0); + + // --- title (mandatory in spec, but keep Option for editing) --- + ui.horizontal(|ui| { + ui.label("Title:"); + let title = model.metadata.title.get_or_insert_with(String::new); + ui.text_edit_singleline(title); + }); + + // --- desc (optional, multiline) --- + ui.label("Description:"); + { + let desc = model.metadata.desc.get_or_insert_with(String::new); + ui.add( + egui::TextEdit::multiline(desc) + .desired_rows(3) + .hint_text("Optional description"), + ); + } + + // --- author (optional) --- + ui.horizontal(|ui| { + ui.label("Author:"); + let author = model.metadata.author.get_or_insert_with(String::new); + ui.text_edit_singleline(author); + }); + + // --- date (optional, keep as string for now) --- + // TODO : utiliser un date picker comme dans l'onglet génération + ui.horizontal(|ui| { + ui.label("Date:"); + let date = model.metadata.date.get_or_insert_with(String::new); + ui.text_edit_singleline(date) + .on_hover_text("Optional. Example: 2025/11/05"); + }); + + // --- version (optional) --- + ui.horizontal(|ui| { + ui.label("Version:"); + let version = model.metadata.version.get_or_insert_with(String::new); + ui.text_edit_singleline(version); + }); + + // --- format (reserved) --- + ui.horizontal(|ui| { + ui.label("Format:"); + let current = model.metadata.format.unwrap_or(1); + ui.label(current.to_string()) + .on_hover_text("Reserved for now. Should remain 1."); + + if ui.button("Set to 1").clicked() { + model.metadata.format = Some(1); + } + + if ui.button("Clear").clicked() { + model.metadata.format = None; + } + }); + ui.separator(); + if ui.button("Export YAML (preview)").clicked() { + match serde_yaml::to_string(&*model) { + Ok(yaml) => { + configuration_file_state.config_file_content = Some(yaml); + configuration_file_state.parse_error = None; + } + Err(e) => { + configuration_file_state.parse_error = Some(e.to_string()); + } + } + } } + // Config file editor if configuration_file_state.picked_config_file.is_none() { ui.label("No configuration file selected"); From b926600b1b7a30a160515ee3ead8f600401cf1d6 Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 8 Jan 2026 15:34:02 +0100 Subject: [PATCH 04/77] Feat: add host visualisation --- .../src/configuration/configuration_tab.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index adbeb5a4..c6c798d2 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -98,6 +98,99 @@ pub fn show_configuration_tab_content( } }); + ui.separator(); + + ui.heading("Hosts"); + ui.add_space(6.0); + + if model.hosts.is_empty() { + ui.label("No hosts in this configuration."); + } else { + for (host_idx, host) in model.hosts.iter().enumerate() { + let hostname = host.hostname.as_deref().unwrap_or(""); + let host_type = host.r#type.as_deref().unwrap_or(""); + let if_count = host.interfaces.len(); + + let header = format!( + "Host #{host_idx}: {hostname} | type: {host_type} | interfaces: {if_count}" + ); + + egui::CollapsingHeader::new(header) + .default_open(host_idx == 0) // optionnel: ouvre le premier host par défaut + .show(ui, |ui| { + // Host fields (read-only for now) + ui.horizontal(|ui| { + ui.label("Hostname:"); + ui.monospace(hostname); + }); + + ui.horizontal(|ui| { + ui.label("OS:"); + ui.monospace(host.os.as_deref().unwrap_or("")); + }); + + ui.horizontal(|ui| { + ui.label("Usage:"); + match host.usage { + Some(u) => ui.monospace(format!("{u}")), + None => ui.monospace(""), + }; + }); + + ui.horizontal(|ui| { + ui.label("Type:"); + ui.monospace(host_type); + }); + + // Client protocols + ui.horizontal(|ui| { + ui.label("Client:"); + if host.client.is_empty() { + ui.monospace(""); + } else { + ui.monospace(host.client.join(", ")); + } + }); + + ui.separator(); + ui.label("Interfaces:"); + + if host.interfaces.is_empty() { + ui.label("No interfaces."); + } else { + for (if_idx, iface) in host.interfaces.iter().enumerate() { + egui::CollapsingHeader::new(format!( + "Interface #{if_idx} — {}", + iface.ip_addr + )) + .default_open(if_idx == 0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("IP:"); + ui.monospace(&iface.ip_addr); + }); + + ui.horizontal(|ui| { + ui.label("MAC:"); + ui.monospace(iface.mac_addr.as_deref().unwrap_or("")); + }); + + ui.horizontal(|ui| { + ui.label("Services:"); + if iface.services.is_empty() { + ui.monospace(""); + } else { + ui.monospace(iface.services.join(", ")); + } + }); + }); + } + } + }); + + ui.add_space(6.0); + } + } ui.separator(); if ui.button("Export YAML (preview)").clicked() { match serde_yaml::to_string(&*model) { From 4c5f2dd0e4352a9cc3c47575983648a689871308 Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 8 Jan 2026 16:23:25 +0100 Subject: [PATCH 05/77] Feat: fix some issues --- .../src/configuration/configuration_tab.rs | 92 +++++++++++------- crates/fosr-gui/src/shared/config_model.rs | 19 +++- crates/fosr-gui/src/shared/mod.rs | 1 + crates/fosr-gui/src/shared/ui_utils.rs | 95 +++++++++++++++++++ 4 files changed, 167 insertions(+), 40 deletions(-) create mode 100644 crates/fosr-gui/src/shared/ui_utils.rs diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index c6c798d2..4f8e3e40 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -1,7 +1,10 @@ use crate::shared::configuration_file::{ ConfigurationFileState, configuration_file_picker, load_config_file_contents, }; +use crate::shared::ui_utils::{edit_optional_string,edit_optional_multiline_string}; +use chrono::NaiveDate; use eframe::egui; +use egui_extras::DatePickerButton; /** * Represents the state of the configuration tab. @@ -48,40 +51,49 @@ pub fn show_configuration_tab_content( ui.text_edit_singleline(title); }); - // --- desc (optional, multiline) --- - ui.label("Description:"); - { - let desc = model.metadata.desc.get_or_insert_with(String::new); - ui.add( - egui::TextEdit::multiline(desc) - .desired_rows(3) - .hint_text("Optional description"), - ); - } + edit_optional_multiline_string( + ui, + "Description (optional):", + &mut model.metadata.desc, + "Optional description", + 3, + ); - // --- author (optional) --- - ui.horizontal(|ui| { - ui.label("Author:"); - let author = model.metadata.author.get_or_insert_with(String::new); - ui.text_edit_singleline(author); - }); + edit_optional_string( + ui, + "Author (optional):", + &mut model.metadata.author, + "Jane Doe", + ); - // --- date (optional, keep as string for now) --- - // TODO : utiliser un date picker comme dans l'onglet génération ui.horizontal(|ui| { - ui.label("Date:"); - let date = model.metadata.date.get_or_insert_with(String::new); - ui.text_edit_singleline(date) - .on_hover_text("Optional. Example: 2025/11/05"); - }); + ui.label("Date (optional):"); - // --- version (optional) --- - ui.horizontal(|ui| { - ui.label("Version:"); - let version = model.metadata.version.get_or_insert_with(String::new); - ui.text_edit_singleline(version); + let mut date_val = model + .metadata + .date + .as_deref() + .and_then(|s| NaiveDate::parse_from_str(s, "%Y/%m/%d").ok()) + .unwrap_or_else(|| NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()); + + let resp = ui.add(DatePickerButton::new(&mut date_val)); + + if resp.changed() { + model.metadata.date = Some(date_val.format("%Y/%m/%d").to_string()); + } + + if ui.button("Clear").clicked() { + model.metadata.date = None; + } }); + edit_optional_string( + ui, + "Version (optional):", + &mut model.metadata.author, + "0.1.0", + ); + // --- format (reserved) --- ui.horizontal(|ui| { ui.label("Format:"); @@ -175,14 +187,22 @@ pub fn show_configuration_tab_content( ui.monospace(iface.mac_addr.as_deref().unwrap_or("")); }); - ui.horizontal(|ui| { - ui.label("Services:"); - if iface.services.is_empty() { - ui.monospace(""); - } else { - ui.monospace(iface.services.join(", ")); - } - }); + let svc_count = iface.services.len(); + egui::CollapsingHeader::new(format!("Services ({svc_count})")) + .default_open(false) + .show(ui, |ui| { + if iface.services.is_empty() { + ui.monospace(""); + } else { + egui::ScrollArea::vertical() + .max_height(80.0) + .show(ui, |ui| { + for svc in &iface.services { + ui.monospace(format!("- {svc}")); + } + }); + } + }); }); } } diff --git a/crates/fosr-gui/src/shared/config_model.rs b/crates/fosr-gui/src/shared/config_model.rs index 166b4b08..cfe961f1 100644 --- a/crates/fosr-gui/src/shared/config_model.rs +++ b/crates/fosr-gui/src/shared/config_model.rs @@ -5,41 +5,52 @@ pub struct Configuration { #[serde(default)] pub metadata: Metadata, - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub hosts: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Metadata { + #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub desc: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub author: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub date: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub format: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Host { + #[serde(skip_serializing_if = "Option::is_none")] pub hostname: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub os: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub usage: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub r#type: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub client: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub interfaces: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Interface { + #[serde(skip_serializing_if = "Option::is_none")] pub mac_addr: Option, // Mandatory dans ton YAML -> String (si absent, parsing échoue) pub ip_addr: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub services: Vec, } diff --git a/crates/fosr-gui/src/shared/mod.rs b/crates/fosr-gui/src/shared/mod.rs index 1b9380bf..69e0f1f5 100644 --- a/crates/fosr-gui/src/shared/mod.rs +++ b/crates/fosr-gui/src/shared/mod.rs @@ -1,3 +1,4 @@ pub mod config_model; pub mod configuration_file; pub mod file_io; +pub mod ui_utils; diff --git a/crates/fosr-gui/src/shared/ui_utils.rs b/crates/fosr-gui/src/shared/ui_utils.rs new file mode 100644 index 00000000..3c22c441 --- /dev/null +++ b/crates/fosr-gui/src/shared/ui_utils.rs @@ -0,0 +1,95 @@ +use eframe::egui; + +/// Displays an editor for an `Option` field in an egui UI. +/// +/// This helper is designed for configuration fields that are **optional**: +/// - If the field is `None`, the text input starts empty. +/// - If the user types a non-empty value, the field becomes `Some(String)`. +/// - If the user clears the input (or clicks the "Clear" button), +/// the field is set back to `None`. +/// +/// # Parameters +/// - `ui`: The egui UI context. +/// - `label`: The label displayed next to the input field. +/// - `value`: The optional string being edited. +/// - `hint`: Placeholder text shown when the field is empty. +/// +/// # Typical usage +/// ```ignore +/// edit_optional_string( +/// ui, +/// "Author (optional):", +/// &mut model.metadata.author, +/// "Jane Doe", +/// ); +/// ``` +pub fn edit_optional_string( + ui: &mut egui::Ui, + label: &str, + value: &mut Option, + hint: &str, +) { + ui.horizontal(|ui| { + ui.label(label); + + // Temporary editable buffer: + // - empty if the value is None + // - otherwise contains the current value + let mut buffer = value.clone().unwrap_or_default(); + + let response = ui.add(egui::TextEdit::singleline(&mut buffer).hint_text(hint)); + + // Commit changes back to the Option + if response.changed() { + let trimmed = buffer.trim(); + if trimmed.is_empty() { + *value = None; + } else { + *value = Some(trimmed.to_string()); + } + } + + // Explicit clear button + if ui.button("Clear").clicked() { + *value = None; + } + }); +} + + +/// Displays a multiline editor for an `Option`. +/// +/// - `None` is represented as an empty text box. +/// - If the user enters non-empty text, it becomes `Some(text)`. +/// - If the user clears the text (or clicks "Clear"), it becomes `None`. +/// +/// This prevents exporting empty strings as `''` in YAML. +pub fn edit_optional_multiline_string( + ui: &mut egui::Ui, + label: &str, + value: &mut Option, + hint: &str, + rows: usize, +) { + ui.label(label); + + let mut buffer = value.clone().unwrap_or_default(); + let response = ui.add( + egui::TextEdit::multiline(&mut buffer) + .desired_rows(rows) + .hint_text(hint), + ); + + if response.changed() { + let trimmed = buffer.trim(); + if trimmed.is_empty() { + *value = None; + } else { + *value = Some(buffer); // garde les retours à la ligne + } + } + + if ui.button("Clear").clicked() { + *value = None; + } +} From 27fcaa34087e6ae2f4719af4307dd70cf2fe6e70 Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 8 Jan 2026 16:44:38 +0100 Subject: [PATCH 06/77] Feat: remove and add host (basic no editing for now) --- .../src/configuration/configuration_tab.rs | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 4f8e3e40..7662ce36 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -5,6 +5,7 @@ use crate::shared::ui_utils::{edit_optional_string,edit_optional_multiline_strin use chrono::NaiveDate; use eframe::egui; use egui_extras::DatePickerButton; +use crate::shared::config_model::Host; /** * Represents the state of the configuration tab. @@ -114,22 +115,42 @@ pub fn show_configuration_tab_content( ui.heading("Hosts"); ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("+ Add host").clicked() { + model.hosts.push(Host::default()); + } + }); + ui.add_space(6.0); if model.hosts.is_empty() { ui.label("No hosts in this configuration."); } else { - for (host_idx, host) in model.hosts.iter().enumerate() { + let mut host_to_remove: Option = None; + for (host_idx, host) in model.hosts.iter_mut().enumerate() { let hostname = host.hostname.as_deref().unwrap_or(""); let host_type = host.r#type.as_deref().unwrap_or(""); let if_count = host.interfaces.len(); - let header = format!( - "Host #{host_idx}: {hostname} | type: {host_type} | interfaces: {if_count}" - ); - - egui::CollapsingHeader::new(header) - .default_open(host_idx == 0) // optionnel: ouvre le premier host par défaut + // let header = format!( + // "Host #{host_idx}: {hostname} | type: {host_type} | interfaces: {if_count}" + // ); + // + // egui::CollapsingHeader::new(header) + // .default_open(host_idx == 0) // optionnel: ouvre le premier host par défaut + // .show(ui, |ui| { + egui::CollapsingHeader::new(format!("Host #{host_idx}")) + .default_open(host_idx == 0) .show(ui, |ui| { + ui.horizontal(|ui| { + ui.strong(format!( + "{hostname} | type: {host_type} | interfaces: {if_count}" + )); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Remove host").clicked() { + host_to_remove = Some(host_idx); + } + }); + }); // Host fields (read-only for now) ui.horizontal(|ui| { ui.label("Hostname:"); @@ -210,6 +231,9 @@ pub fn show_configuration_tab_content( ui.add_space(6.0); } + if let Some(idx) = host_to_remove{ + model.hosts.remove(idx); + } } ui.separator(); if ui.button("Export YAML (preview)").clicked() { From 51e38ddd2d0ace9cbf5b57d053703d6c339f3d47 Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 8 Jan 2026 18:06:48 +0100 Subject: [PATCH 07/77] Feat: modifiable host data --- .../src/configuration/configuration_tab.rs | 177 +++++++++++++----- 1 file changed, 128 insertions(+), 49 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 7662ce36..4fb9fb06 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -1,11 +1,11 @@ +use crate::shared::config_model::Host; use crate::shared::configuration_file::{ ConfigurationFileState, configuration_file_picker, load_config_file_contents, }; -use crate::shared::ui_utils::{edit_optional_string,edit_optional_multiline_string}; +use crate::shared::ui_utils::{edit_optional_multiline_string, edit_optional_string}; use chrono::NaiveDate; use eframe::egui; use egui_extras::DatePickerButton; -use crate::shared::config_model::Host; /** * Represents the state of the configuration tab. @@ -127,8 +127,12 @@ pub fn show_configuration_tab_content( } else { let mut host_to_remove: Option = None; for (host_idx, host) in model.hosts.iter_mut().enumerate() { - let hostname = host.hostname.as_deref().unwrap_or(""); - let host_type = host.r#type.as_deref().unwrap_or(""); + let hostname_for_header = host + .hostname + .clone() + .unwrap_or_else(|| "".to_string()); + let host_type_for_header = + host.r#type.clone().unwrap_or_else(|| "".to_string()); let if_count = host.interfaces.len(); // let header = format!( @@ -143,45 +147,119 @@ pub fn show_configuration_tab_content( .show(ui, |ui| { ui.horizontal(|ui| { ui.strong(format!( - "{hostname} | type: {host_type} | interfaces: {if_count}" - )); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Remove host").clicked() { - host_to_remove = Some(host_idx); - } - }); - }); - // Host fields (read-only for now) - ui.horizontal(|ui| { - ui.label("Hostname:"); - ui.monospace(hostname); + "{hostname_for_header} | type: {host_type_for_header} | interfaces: {if_count}" + )); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui.button("Remove host").clicked() { + host_to_remove = Some(host_idx); + } + }, + ); }); + // --- Editable host fields --- - ui.horizontal(|ui| { - ui.label("OS:"); - ui.monospace(host.os.as_deref().unwrap_or("")); - }); + // Hostname (optional) + edit_optional_string( + ui, + "Hostname (optional):", + &mut host.hostname, + "host1", + ); + + // OS (optional, default is Linux) + edit_optional_string(ui, "OS (optional):", &mut host.os, "Linux"); + // Usage (optional, default is 1.0) ui.horizontal(|ui| { - ui.label("Usage:"); - match host.usage { - Some(u) => ui.monospace(format!("{u}")), - None => ui.monospace(""), - }; + ui.label("Usage (optional):"); + + let mut usage_val = host.usage.unwrap_or(1.0); + let resp = ui.add(egui::DragValue::new(&mut usage_val).speed(0.1)); + + if resp.changed() { + // If equals default, keep None to avoid polluting YAML + if (usage_val - 1.0).abs() < f32::EPSILON { + host.usage = None; + } else { + host.usage = Some(usage_val); + } + } + + if ui.button("Clear").clicked() { + host.usage = None; + } }); + // Type (optional, auto if None) ui.horizontal(|ui| { - ui.label("Type:"); - ui.monospace(host_type); + ui.label("Type (optional):"); + + let selected_text = + host.r#type.as_deref().unwrap_or("").to_string(); + + egui::ComboBox::from_id_salt((host_idx, "host_type")) + .selected_text(selected_text) + .show_ui(ui, |ui| { + if ui + .selectable_label(host.r#type.is_none(), "") + .clicked() + { + host.r#type = None; + } + if ui + .selectable_label( + host.r#type.as_deref() == Some("server"), + "server", + ) + .clicked() + { + host.r#type = Some("server".to_string()); + } + if ui + .selectable_label( + host.r#type.as_deref() == Some("user"), + "user", + ) + .clicked() + { + host.r#type = Some("user".to_string()); + } + }); + + if ui.button("Clear").clicked() { + host.r#type = None; + } }); - // Client protocols + // Client protocols (optional list) + // For now: comma-separated editor ui.horizontal(|ui| { - ui.label("Client:"); - if host.client.is_empty() { - ui.monospace(""); + ui.label("Client protocols (optional):"); + + let mut buf = if host.client.is_empty() { + String::new() } else { - ui.monospace(host.client.join(", ")); + host.client.join(",") + }; + + let resp = ui.add( + egui::TextEdit::singleline(&mut buf) + .hint_text("ex: http,https,ssh"), + ); + + if resp.changed() { + host.client = buf + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + } + + if ui.button("Clear").clicked() { + host.client.clear(); } }); @@ -208,22 +286,23 @@ pub fn show_configuration_tab_content( ui.monospace(iface.mac_addr.as_deref().unwrap_or("")); }); - let svc_count = iface.services.len(); - egui::CollapsingHeader::new(format!("Services ({svc_count})")) - .default_open(false) - .show(ui, |ui| { - if iface.services.is_empty() { - ui.monospace(""); - } else { - egui::ScrollArea::vertical() - .max_height(80.0) - .show(ui, |ui| { - for svc in &iface.services { - ui.monospace(format!("- {svc}")); - } - }); - } - }); + let svc_count = iface.services.len(); + egui::CollapsingHeader::new(format!("Services ({svc_count})")) + .default_open(false) + .show(ui, |ui| { + if iface.services.is_empty() { + ui.monospace(""); + } else { + egui::ScrollArea::vertical().max_height(80.0).show( + ui, + |ui| { + for svc in &iface.services { + ui.monospace(format!("- {svc}")); + } + }, + ); + } + }); }); } } @@ -231,7 +310,7 @@ pub fn show_configuration_tab_content( ui.add_space(6.0); } - if let Some(idx) = host_to_remove{ + if let Some(idx) = host_to_remove { model.hosts.remove(idx); } } From 7277b888d72285f6db9ea92a3e8ed6fd2a79593a Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 8 Jan 2026 18:50:17 +0100 Subject: [PATCH 08/77] Feat: modifiable interfaces --- .../src/configuration/configuration_tab.rs | 97 +++++++++++-------- 1 file changed, 57 insertions(+), 40 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 4fb9fb06..bc5f56af 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -1,4 +1,4 @@ -use crate::shared::config_model::Host; +use crate::shared::config_model::{Host, Interface}; use crate::shared::configuration_file::{ ConfigurationFileState, configuration_file_picker, load_config_file_contents, }; @@ -8,8 +8,8 @@ use eframe::egui; use egui_extras::DatePickerButton; /** - * Represents the state of the configuration tab. - */ +* Represents the state of the configuration tab. +*/ pub struct ConfigurationTabState {} impl Default for ConfigurationTabState { @@ -147,8 +147,8 @@ pub fn show_configuration_tab_content( .show(ui, |ui| { ui.horizontal(|ui| { ui.strong(format!( - "{hostname_for_header} | type: {host_type_for_header} | interfaces: {if_count}" - )); + "{hostname_for_header} | type: {host_type_for_header} | interfaces: {if_count}" + )); ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -197,7 +197,7 @@ pub fn show_configuration_tab_content( ui.label("Type (optional):"); let selected_text = - host.r#type.as_deref().unwrap_or("").to_string(); + host.r#type.as_deref().unwrap_or("").to_string(); egui::ComboBox::from_id_salt((host_idx, "host_type")) .selected_text(selected_text) @@ -262,48 +262,65 @@ pub fn show_configuration_tab_content( host.client.clear(); } }); - ui.separator(); - ui.label("Interfaces:"); + ui.horizontal(|ui| { + ui.label("Interfaces:"); + if ui.button("+ Add interface").clicked() { + // IP is mandatory, so give a placeholder that user must edit + host.interfaces.push(Interface { + ip_addr: "192.168.0.1".to_string(), + mac_addr: None, + services: Vec::new(), + }); + } + }); if host.interfaces.is_empty() { ui.label("No interfaces."); } else { - for (if_idx, iface) in host.interfaces.iter().enumerate() { - egui::CollapsingHeader::new(format!( - "Interface #{if_idx} — {}", - iface.ip_addr - )) - .default_open(if_idx == 0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label("IP:"); - ui.monospace(&iface.ip_addr); - }); + let mut iface_to_remove: Option = None; + + for (if_idx, iface) in host.interfaces.iter_mut().enumerate() { + let ip_for_header = iface.ip_addr.clone(); + egui::CollapsingHeader::new(format!("Interface #{if_idx} — {ip_for_header}")) + .default_open(if_idx == 0) + .show(ui, |ui| { + // Remove interface (mark for removal) + if ui.button("Remove interface").clicked() { + iface_to_remove = Some(if_idx); + } + + ui.add_space(4.0); + + // Mandatory IP + ui.horizontal(|ui| { + ui.label("IP (mandatory):"); + ui.text_edit_singleline(&mut iface.ip_addr); + }); - ui.horizontal(|ui| { - ui.label("MAC:"); - ui.monospace(iface.mac_addr.as_deref().unwrap_or("")); + // Optional MAC + edit_optional_string(ui, "MAC (optional):", &mut iface.mac_addr, "00:14:2A:3F:47:D8"); + + // Services: keep read-only for now (next micro-step) + let svc_count = iface.services.len(); + egui::CollapsingHeader::new(format!("Services ({svc_count})")) + .default_open(false) + .show(ui, |ui| { + if iface.services.is_empty() { + ui.monospace(""); + } else { + for svc in &iface.services { + ui.monospace(format!("- {svc}")); + } + } + }); }); - let svc_count = iface.services.len(); - egui::CollapsingHeader::new(format!("Services ({svc_count})")) - .default_open(false) - .show(ui, |ui| { - if iface.services.is_empty() { - ui.monospace(""); - } else { - egui::ScrollArea::vertical().max_height(80.0).show( - ui, - |ui| { - for svc in &iface.services { - ui.monospace(format!("- {svc}")); - } - }, - ); - } - }); - }); + ui.add_space(6.0); + } + + if let Some(idx) = iface_to_remove { + host.interfaces.remove(idx); } } }); From c0a75c0b5b91484209c11b24ff60ac5e133dd9e7 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Thu, 15 Jan 2026 10:45:06 +0100 Subject: [PATCH 09/77] feat(gui/visualization): pass the file as tab arg and properly clean up file content on config change --- crates/fosr-gui/src/app.rs | 6 +++++- crates/fosr-gui/src/shared/configuration_file.rs | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index 1c52912c..f6a2120e 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -114,7 +114,11 @@ impl eframe::App for FosrApp { ); } CurrentTab::Visualization => { - show_visualization_tab_content(ui, &mut self.visualization_tab_state); + show_visualization_tab_content( + ui, + &mut self.visualization_tab_state, + &self.configuration_file_state, + ); } #[cfg(not(target_arch = "wasm32"))] CurrentTab::Injection => { diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 7eeb848f..1e6af7c5 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -38,6 +38,13 @@ pub fn configuration_file_picker( // File Dialog to pick a config file if ui.button("Select file").clicked() { + // Clear previous config content when selecting a new file + configuration_file_state.config_file_content = None; + #[cfg(target_arch = "wasm32")] + { + configuration_file_state.config_file_content_receiver = None; + } + #[cfg(not(target_arch = "wasm32"))] { // Only update if a file was actually selected @@ -84,6 +91,7 @@ pub fn configuration_file_picker( if configuration_file_state.picked_config_file.is_some() && ui.button("Remove").clicked() { configuration_file_state.picked_config_file = None; + configuration_file_state.config_file_content = None; }; // On desktop: filename with its full path on hover, on WASM: just the filename From a53c886d0bae256aed6c61864b1e5a3bffada4a6 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Thu, 15 Jan 2026 10:58:16 +0100 Subject: [PATCH 10/77] style(gui): use standard comment style --- .../src/configuration/configuration_tab.rs | 4 +-- .../fosr-gui/src/generation/generation_tab.rs | 5 ++-- .../generation/generation_ui_components.rs | 4 +-- .../src/generation/generation_utils.rs | 26 ++++++------------- .../src/generation/generation_validation.rs | 4 +-- 5 files changed, 13 insertions(+), 30 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 26f96b43..e1f3c225 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -3,9 +3,7 @@ use crate::shared::configuration_file::{ }; use eframe::egui; -/** - * Represents the state of the configuration tab. - */ +/// Represents the state of the configuration tab. pub struct ConfigurationTabState {} impl Default for ConfigurationTabState { diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index b033df90..5d9824c0 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -34,9 +34,8 @@ pub enum UiStatus { Error(String), } -/** - * Represents the state of the generation tab. - */ + +/// Represents the state of the generation tab. pub struct GenerationTabState { pub progress: f32, pub progress_receiver: Option>, diff --git a/crates/fosr-gui/src/generation/generation_ui_components.rs b/crates/fosr-gui/src/generation/generation_ui_components.rs index 026779e5..62d2c384 100644 --- a/crates/fosr-gui/src/generation/generation_ui_components.rs +++ b/crates/fosr-gui/src/generation/generation_ui_components.rs @@ -3,9 +3,7 @@ use super::generation_validation::FieldValidation; use chrono_tz::TZ_VARIANTS; use eframe::{egui, egui::PopupCloseBehavior}; -/** - * Display the error in red - */ +/// Display the error in red pub fn show_field_error(ui: &mut egui::Ui, validation: &FieldValidation) { if let Some(msg) = &validation.error { ui.add_space(6.0); diff --git a/crates/fosr-gui/src/generation/generation_utils.rs b/crates/fosr-gui/src/generation/generation_utils.rs index cafcb8f6..f15d7efe 100644 --- a/crates/fosr-gui/src/generation/generation_utils.rs +++ b/crates/fosr-gui/src/generation/generation_utils.rs @@ -1,18 +1,14 @@ use super::generation_tab::{DURATION_MAX, DURATION_MIN}; use std::time::Duration; -/** - * Returns the minimum and maximum durations, expressed in seconds (f64), - * used as bounds when converting between a duration and the slider position. - */ +/// Returns the minimum and maximum durations, expressed in seconds (f64), +/// used as bounds when converting between a duration and the slider position. fn duration_range_secs() -> (f64, f64) { (DURATION_MIN.as_secs_f64(), DURATION_MAX.as_secs_f64()) } -/** - * Converts the slider position (between 0.0 and 1.0) into an actual Duration, - * by logarithmically interpolating between DURATION_MIN and DURATION_MAX. - */ +/// Converts the slider position (between 0.0 and 1.0) into an actual Duration, +/// by logarithmically interpolating between DURATION_MIN and DURATION_MAX. fn slider_to_duration(value: f32) -> Duration { let (min, max) = duration_range_secs(); let v = value.clamp(0.0, 1.0) as f64; @@ -24,10 +20,8 @@ fn slider_to_duration(value: f32) -> Duration { Duration::from_secs_f64(rounded_secs.clamp(min, max).round()) } -/** - * Converts a real Duration into a slider position (between 0.0 and 1.0), - * based on its proportion within the [DURATION_MIN, DURATION_MAX] interval. - */ +/// Converts a real Duration into a slider position (between 0.0 and 1.0), +/// based on its proportion within the [DURATION_MIN, DURATION_MAX] interval. pub fn duration_to_slider(d: Duration) -> f32 { let (min, max) = duration_range_secs(); let secs = d.as_secs_f64().clamp(min, max); @@ -42,17 +36,13 @@ pub fn duration_to_slider(d: Duration) -> f32 { v.clamp(0.0, 1.0) as f32 } -/** - * Produces a human-readable duration string from the given slider position. - */ +/// Produces a human-readable duration string from the given slider position. pub fn duration_string_from_slider(value: f32) -> String { let duration = slider_to_duration(value); humantime::format_duration(duration).to_string() } -/** - * Converts a human-readable duration string into a slider position. - */ +/// Converts a human-readable duration string into a slider position. pub fn slider_from_duration_string(duration_str: String) -> Option { let result = humantime::parse_duration(&duration_str); match result { diff --git a/crates/fosr-gui/src/generation/generation_validation.rs b/crates/fosr-gui/src/generation/generation_validation.rs index cd8d13e5..f5ad32e5 100644 --- a/crates/fosr-gui/src/generation/generation_validation.rs +++ b/crates/fosr-gui/src/generation/generation_validation.rs @@ -2,9 +2,7 @@ use super::generation_tab::{DURATION_MAX, DURATION_MIN, GenerationTabState}; use chrono_tz::Tz; use std::time::Duration; -/** - * Structure to handle inputs errors from the user - */ +/// Structure to handle inputs errors from the user #[derive(Default, Clone)] pub struct FieldValidation { pub error: Option, From 7a1ba85f1d23331f87919c507e24b81cce5a3bbd Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:28:28 +0100 Subject: [PATCH 11/77] feat(gui): implement first comprehensive version of visualization tab --- crates/fosr-gui/src/visualization/mod.rs | 2 + .../src/visualization/visualization_shapes.rs | 293 ++++++++ .../src/visualization/visualization_stream.rs | 385 ++++++++++ .../src/visualization/visualization_tab.rs | 692 +++++++++++++++++- .../src/visualization/visualization_utils.rs | 16 +- crates/fosr-lib/src/config.rs | 7 +- 6 files changed, 1367 insertions(+), 28 deletions(-) create mode 100644 crates/fosr-gui/src/visualization/visualization_shapes.rs create mode 100644 crates/fosr-gui/src/visualization/visualization_stream.rs diff --git a/crates/fosr-gui/src/visualization/mod.rs b/crates/fosr-gui/src/visualization/mod.rs index fe4468e4..b04ef22d 100644 --- a/crates/fosr-gui/src/visualization/mod.rs +++ b/crates/fosr-gui/src/visualization/mod.rs @@ -1,2 +1,4 @@ pub mod visualization_tab; +mod visualization_shapes; +mod visualization_stream; mod visualization_utils; diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/visualization_shapes.rs new file mode 100644 index 00000000..7682fe1a --- /dev/null +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -0,0 +1,293 @@ +//! Custom node and edge shapes for network visualization + +use crate::visualization::visualization_tab::{EdgeData, LinkDirection, NodeData, NodeType}; +use eframe::egui; +use egui::{Color32, Pos2, Shape, Vec2}; +use egui_graphs::{DisplayEdge, DisplayNode, DrawContext, Node, NodeProps}; +use fosr_lib::L7Proto; + +// Color constants for node types +pub const COLOR_SERVER: Color32 = Color32::from_rgb(46, 204, 113); // Green +pub const COLOR_USER: Color32 = Color32::from_rgb(52, 152, 219); // Blue +pub const COLOR_INTERNET: Color32 = Color32::from_rgb(231, 76, 60); // Red + +// Color constants for edge states +pub const COLOR_INACTIVE: Color32 = Color32::from_rgb(200, 200, 200); // Light gray +pub const COLOR_HTTP: Color32 = Color32::from_rgb(52, 152, 219); // Blue +pub const COLOR_HTTPS: Color32 = Color32::from_rgb(46, 204, 113); // Green +pub const COLOR_SSH: Color32 = Color32::from_rgb(155, 89, 182); // Purple +pub const COLOR_DNS: Color32 = Color32::from_rgb(230, 126, 34); // Orange + +const RADIUS_NORMAL: f32 = 20.0; +const RADIUS_INTERNET: f32 = 30.0; + +const EDGE_WIDTH_INACTIVE: f32 = 0.5; +const EDGE_WIDTH_ACTIVE: f32 = 3.0; + +/// Custom node shape that displays hostname and IP, with color based on node type +#[derive(Clone)] +pub struct NetworkNodeShape { + radius: f32, + color: Color32, + label: String, + location: Pos2, + is_internet: bool, +} + +impl From> for NetworkNodeShape { + fn from(props: NodeProps) -> Self { + let payload = &props.payload; + let (color, radius, is_internet) = match payload.node_type { + NodeType::Server => (COLOR_SERVER, RADIUS_NORMAL, false), + NodeType::User => (COLOR_USER, RADIUS_NORMAL, false), + NodeType::Internet => (COLOR_INTERNET, RADIUS_INTERNET, true), + }; + + let label = if let Some(ref hostname) = payload.hostname { + if payload.node_type == NodeType::Internet { + hostname.clone() + } else { + format!("{}\n{}", hostname, payload.ip_addr) + } + } else { + format!("{}", payload.ip_addr) + }; + + Self { + radius, + color, + label, + location: props.location(), + is_internet, + } + } +} + +impl DisplayNode +for NetworkNodeShape +{ + /// Determines where edges should connect to the node shape + fn closest_boundary_point(&self, dir: Vec2) -> Pos2 { + if dir.length() == 0.0 { + self.location + } else { + self.location + dir.normalized() * self.radius + } + } + + /// Set how a node is drawn in the graph + /// A node can be composed of several shapes + fn shapes(&mut self, ctx: &DrawContext) -> Vec { + let mut shapes = Vec::new(); + let pos = ctx.meta.canvas_to_screen_pos(self.location); + let radius = ctx.meta.canvas_to_screen_size(self.radius); + + // Draw filled circle + shapes.push(Shape::circle_filled(pos, radius, self.color)); + + // Draw circle stroke (thicker for Internet node) + let stroke_width = if self.is_internet { 2.5 } else { 1.5 }; + shapes.push(Shape::circle_stroke( + pos, + radius, + egui::Stroke::new(stroke_width, Color32::DARK_GRAY), + )); + + // Draw text label + let font_size = if self.is_internet { 14.0 } else { 10.0 }; + let font_id = egui::FontId::proportional(font_size); + + let job = egui::text::LayoutJob::simple( + self.label.clone(), + font_id, + Color32::GRAY, + f32::INFINITY, + ); + + ctx.ctx.fonts_mut(|f| { + let galley = f.layout_job(job); + let label_pos = Pos2::new(pos.x - galley.size().x / 2.0, pos.y + radius + 2.0); + shapes.push(Shape::galley(label_pos, galley, Color32::GRAY)); + }); + + shapes + } + + fn update(&mut self, state: &NodeProps) { + let payload = &state.payload; + + let (color, radius, is_internet) = match payload.node_type { + NodeType::Server => (COLOR_SERVER, RADIUS_NORMAL, false), + NodeType::User => (COLOR_USER, RADIUS_NORMAL, false), + NodeType::Internet => (COLOR_INTERNET, RADIUS_INTERNET, true), + }; + + self.color = color; + self.radius = radius; + self.is_internet = is_internet; + + self.label = if let Some(ref hostname) = payload.hostname { + if payload.node_type == NodeType::Internet { + hostname.clone() + } else { + format!("{}\n{}", hostname, payload.ip_addr) + } + } else { + format!("{}", payload.ip_addr) + }; + + self.location = state.location(); + } + + /// Defines the zone where we can click to drag the node + fn is_inside(&self, pos: Pos2) -> bool { + pos.distance(self.location) <= self.radius + } +} + +/// Get edge style based on protocol and direction +fn edge_style(edge_data: &EdgeData) -> (Color32, f32, bool, bool) { + match edge_data { + EdgeData::Inactive => (COLOR_INACTIVE, EDGE_WIDTH_INACTIVE, false, false), + EdgeData::Active { protocol, direction, .. } => { + let color = match protocol { + L7Proto::HTTP => Color32::from_rgb(52, 152, 219), // Blue + L7Proto::HTTPS => Color32::from_rgb(46, 204, 113), // Green + L7Proto::SSH => Color32::from_rgb(155, 89, 182), // Purple + L7Proto::DNS => Color32::from_rgb(230, 126, 34), // Orange + _ => Color32::from_rgb(149, 165, 166), // Gray + }; + let (arrow_start, arrow_end) = match direction { + LinkDirection::Forward => (false, true), + LinkDirection::Backward => (true, false), + LinkDirection::Bidirectional => (true, true), + }; + (color, EDGE_WIDTH_ACTIVE, arrow_start, arrow_end) + } + } +} + +/// Custom edge shape that uses color/width/arrows based on protocol and direction state +#[derive(Clone)] +pub struct NetworkEdgeShape { + color: Color32, + width: f32, + arrow_start: bool, + arrow_end: bool, +} + +impl From> for NetworkEdgeShape { + fn from(props: egui_graphs::EdgeProps) -> Self { + let (color, width, arrow_start, arrow_end) = edge_style(&props.payload); + Self { color, width, arrow_start, arrow_end } + } +} + +// Defines an arrow shape to use at the ends of an Edge +fn arrow_head(from: Pos2, to: Pos2, size: f32, angle: f32, color: Color32) -> Shape { + let dir = (from - to).normalized(); + let p1 = to + Vec2::angled(dir.angle() + angle) * size; + let p2 = to + Vec2::angled(dir.angle() - angle) * size; + Shape::convex_polygon(vec![to, p1, p2], color, egui::Stroke::NONE) +} + +impl +DisplayEdge< + NodeData, + EdgeData, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, +> for NetworkEdgeShape +{ + fn shapes( + &mut self, + start: &Node< + NodeData, + EdgeData, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, + >, + end: &Node< + NodeData, + EdgeData, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, + >, + ctx: &DrawContext, + ) -> Vec { + let start_center = start.location(); + let end_center = end.location(); + let dir = end_center - start_center; + + let start_boundary = start.display().closest_boundary_point(dir); + let end_boundary = end.display().closest_boundary_point(-dir); + + let start_pos = ctx.meta.canvas_to_screen_pos(start_boundary); + let end_pos = ctx.meta.canvas_to_screen_pos(end_boundary); + + let mut shapes = vec![Shape::line_segment( + [start_pos, end_pos], + egui::Stroke::new(ctx.meta.canvas_to_screen_size(self.width), self.color), + )]; + + let arrow_size = ctx.meta.canvas_to_screen_size(16.0); + let arrow_angle = std::f32::consts::PI / 6.0; + + if self.arrow_end { + shapes.push(arrow_head(start_pos, end_pos, arrow_size, arrow_angle, self.color)); + } + if self.arrow_start { + shapes.push(arrow_head(end_pos, start_pos, arrow_size, arrow_angle, self.color)); + } + + shapes + } + + fn update(&mut self, state: &egui_graphs::EdgeProps) { + let (color, width, arrow_start, arrow_end) = edge_style(&state.payload); + self.color = color; + self.width = width; + self.arrow_start = arrow_start; + self.arrow_end = arrow_end; + } + + fn is_inside( + &self, + start: &Node< + NodeData, + EdgeData, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, + >, + end: &Node< + NodeData, + EdgeData, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, + >, + pos: Pos2, + ) -> bool { + let start_pos = start.location(); + let end_pos = end.location(); + let line_vec = end_pos - start_pos; + let point_vec = pos - start_pos; + + let line_len = line_vec.length(); + if line_len == 0.0 { + return false; + } + + let projection = point_vec.dot(line_vec) / line_len; + if projection < 0.0 || projection > line_len { + return false; + } + + let closest_point = start_pos + line_vec.normalized() * projection; + closest_point.distance(pos) < self.width + } +} diff --git a/crates/fosr-gui/src/visualization/visualization_stream.rs b/crates/fosr-gui/src/visualization/visualization_stream.rs new file mode 100644 index 00000000..8da4d6c5 --- /dev/null +++ b/crates/fosr-gui/src/visualization/visualization_stream.rs @@ -0,0 +1,385 @@ +//! Flow streaming engine for real-time visualization +//! +//! This module provides a streaming generator that runs Stage 0 (time generation) +//! and Stage 1 (flow generation) to produce flow events for the visualization tab. +//! +//! Flows are emitted based on their timestamps relative to visualization start, +//! allowing multiple flows to be displayed in parallel. + +use chrono::{DateTime, Offset, TimeZone}; +use fosr_lib::{models, stage0, stage1::bayesian_networks::BNGenerator, stage1::Stage1, L7Proto}; +use std::collections::BinaryHeap; +use std::net::Ipv4Addr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, RwLock}; +use std::time::Duration; +use web_time::{Instant, SystemTime, UNIX_EPOCH}; + +/// A flow event (subset of FlowData) +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FlowEvent { + pub src_ip: Ipv4Addr, + pub dst_ip: Ipv4Addr, + pub protocol: L7Proto, + #[allow(dead_code)] // Kept for possible future UI features + pub timestamp: Duration, +} + +/// A flow event with its scheduled display time +#[derive(Eq)] +struct ScheduledFlow { + event: FlowEvent, + /// Timestamp relative to generation start (for scheduling) + scheduled_time: Duration, +} + +impl PartialEq for ScheduledFlow { + fn eq(&self, other: &Self) -> bool { + self.scheduled_time == other.scheduled_time + } +} + + +impl PartialOrd for ScheduledFlow { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ScheduledFlow { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Reverse ordering for min-heap behavior (earliest first) + other.scheduled_time.cmp(&self.scheduled_time) + } +} + +/// Flow streamer that continuously generates flow events +pub struct FlowStreamer { + s0: stage0::BinBasedGenerator, + s1: BNGenerator, + sender: Sender, + running: Arc, + /// The initial timestamp from Stage 0 (for calculating relative times) + initial_timestamp: Duration, + /// Speed multiplier (1.0 = real-time) - shared for runtime updates + speed: Arc>, +} + +impl FlowStreamer { + /// Create a new flow streamer + /// If config_content is None, uses the default BN model without any config applied + /// If config_content is Some, applies the config to remap IPs + /// Speed controls how fast flows are emitted (1.0 = real-time) - can be updated at runtime + pub fn new( + config_content: Option<&str>, + speed: Arc>, + sender: Sender, + ) -> Result { + // Load models + let source = models::ModelsSource::Legacy; + let mut model = models::Models::from_source(source) + .map_err(|e| format!("Failed to load models: {}", e))?; + + // Only apply config if provided + if let Some(config) = config_content { + model = model + .with_string_config(config) + .map_err(|e| format!("Failed to apply config: {}", e))?; + log::info!("FlowStreamer: config applied"); + } else { + log::info!("FlowStreamer: using default BN model (no config)"); + } + + let _automata_library = Arc::new(model.automata); + let bn = Arc::new(model.bn); + + // Get initial timestamp (current time) + let initial_ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| format!("Failed to get time: {}", e))?; + + // Get local timezone + // TODO: use the value from the generation tab? + // TODO: extract the logic in utils to share it with generation_core + let tz_offset = { + let date = DateTime::from_timestamp(initial_ts.as_secs() as i64, 0) + .unwrap() + .naive_utc(); + let tz = chrono::Local::now() + .timezone() + .offset_from_local_datetime(&date) + .single() + .expect("Ambiguous local date from timestamp") + .fix(); + log::info!("Using local timezone (UTC{tz})"); + tz + }; + + // Create Stage 0 generator + let s0 = stage0::BinBasedGenerator::new( + None, // Random seed + false, + None, + model.time_bins, + initial_ts, + None, // Infinite duration + tz_offset, + ); + + // Create Stage 1 generator + let s1 = BNGenerator::new(bn, false); + + Ok(Self { + s0, + s1, + sender, + running: Arc::new(AtomicBool::new(false)), + initial_timestamp: initial_ts, + speed, + }) + } + + /// Start streaming flows in the background + pub fn start(&self) { + self.running.store(true, Ordering::SeqCst); + let sender = self.sender.clone(); + let running = self.running.clone(); + let s0 = self.s0.clone(); + let s1 = self.s1.clone(); + let initial_timestamp = self.initial_timestamp; + let speed = self.speed.clone(); + + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(move || { + Self::streaming_loop(s0, s1, sender, running, initial_timestamp, speed); + }); + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_futures::spawn_local(async move { + Self::streaming_loop_wasm(s0, s1, sender, running, initial_timestamp, speed).await; + }); + } + + /// Stop streaming flows + pub fn stop(&self) { + self.running.store(false, Ordering::SeqCst); + } + + #[cfg(not(target_arch = "wasm32"))] + fn streaming_loop( + mut s0: stage0::BinBasedGenerator, + s1: BNGenerator, + sender: Sender, + running: Arc, + initial_timestamp: Duration, + speed: Arc>, + ) { + // Use a binary heap to generate flows (timestamp ordered) in the correct order + let mut pending_flows: BinaryHeap = BinaryHeap::new(); + let mut flow_count = 0; + let mut last_generation = Instant::now(); + + // Buffer size: generate flows up to this much ahead of current time + // This avoids overloading the CPU by continuously generating flows + let buffer_ahead = Duration::from_secs(5); + // How often to check for flows to emit + let check_interval = Duration::from_millis(50); + + // Track virtual time by integrating speed changes + // + // Why do we need virtual time? Because when we change the speed value at runtime, + // the elapsed time so far should not be scaled, only the time that elapses from the moment + // the speed has changed. If we scale the whole elapsed time, and reduce the speed, we will + // "go back in time" (e.g., elapsed time is 20s, we reduce speed from 1.0 to 0.5, elapsed time + // would become 10s. We would need to wait 10s before new packets are emitted again). + let mut virtual_elapsed = Duration::ZERO; + + let mut last_loop_time = Instant::now(); + + log::info!( + "Flow streaming loop started (timestamp-based, speed: {}x)", + *speed.read().unwrap() + ); + + while running.load(Ordering::SeqCst) { + let now = Instant::now(); + let delta = now.duration_since(last_loop_time); + last_loop_time = now; + + // Integrate speed over time to avoid discontinuities (see previous comment) + let speed = *speed.read().unwrap(); + let virtual_delta = Duration::from_secs_f64(delta.as_secs_f64() * speed as f64); + virtual_elapsed += virtual_delta; + + // Generate more flows if buffer is running low + let buffer_target = virtual_elapsed + buffer_ahead; + while pending_flows + // since we use a binary heap, we get the flow with the biggest timestamp + .peek() + // if the heap is empty, `.peek()` returns None, and `.map_or()` returns true + .map_or(true, |f| f.scheduled_time < buffer_target) + { + // Limit generation rate to avoid CPU spinning + if last_generation.elapsed() < Duration::from_millis(100) && !pending_flows.is_empty() + { + break; + } + + if let Some(timestamp) = s0.next() { + if let Ok(flows) = s1.generate_flows(timestamp) { + for seeded_flow in flows { + let flow_data = seeded_flow.data.get_data(); + + // Calculate scheduled time relative to start + let flow_timestamp = flow_data.timestamp; + let scheduled_time = if flow_timestamp >= initial_timestamp { + flow_timestamp - initial_timestamp + } else { + Duration::ZERO + }; + + let event = FlowEvent { + src_ip: flow_data.src_ip, + dst_ip: flow_data.dst_ip, + protocol: flow_data.l7_proto, + timestamp: flow_timestamp, + }; + + pending_flows.push(ScheduledFlow { + event, + scheduled_time, + }); + + flow_count += 1; + } + } + } else { + // No more timestamps available + break; + } + + last_generation = Instant::now(); + + // Check if we should stop + if !running.load(Ordering::SeqCst) { + break; + } + } + + + // Emit flows whose scheduled time has passed (in virtual time) + while let Some(scheduled) = pending_flows.peek() { + if scheduled.scheduled_time <= virtual_elapsed { + let scheduled = pending_flows.pop().unwrap(); + log::debug!( + "Emitting flow #{}: {} -> {} ({:?}) at virtual {:?}", + flow_count, + scheduled.event.src_ip, + scheduled.event.dst_ip, + scheduled.event.protocol, + virtual_elapsed + ); + + if let Err(e) = sender.send(scheduled.event) { + log::error!("Failed to send flow event: {}", e); + break; + } + } else { + break; + } + } + + // Sleep until next check + std::thread::sleep(check_interval); + } + + log::info!( + "Flow streaming loop stopped ({} flows generated, {} pending)", + flow_count, + pending_flows.len() + ); + } + + // TODO: check if this works properly and update comments to match with native arch + #[cfg(target_arch = "wasm32")] + async fn streaming_loop_wasm( + mut s0: stage0::BinBasedGenerator, + s1: BNGenerator, + sender: Sender, + running: Arc, + initial_timestamp: Duration, + speed: Arc>, + ) { + let mut pending_flows: BinaryHeap = BinaryHeap::new(); + let buffer_ahead = Duration::from_secs(5); + let check_interval = Duration::from_millis(50); + + // Track virtual time by integrating speed changes + let mut virtual_elapsed = Duration::ZERO; + let mut last_loop_time = Instant::now(); + + while running.load(Ordering::SeqCst) { + let now = Instant::now(); + let delta = now.duration_since(last_loop_time); + last_loop_time = now; + + // Integrate speed over time to avoid discontinuities + let speed = *speed.read().unwrap(); + let virtual_delta = Duration::from_secs_f64(delta.as_secs_f64() * speed as f64); + virtual_elapsed += virtual_delta; + + // Generate more flows if buffer is running low + let buffer_target = virtual_elapsed + buffer_ahead; + let mut generated_this_cycle = 0; + + while pending_flows + .peek() + .map_or(true, |f| f.scheduled_time < buffer_target) + && generated_this_cycle < 10 + { + if let Some(timestamp) = s0.next() { + if let Ok(flows) = s1.generate_flows(timestamp) { + for seeded_flow in flows { + let flow_data = seeded_flow.data.get_data(); + + let flow_timestamp = flow_data.timestamp; + let scheduled_time = if flow_timestamp >= initial_timestamp { + flow_timestamp - initial_timestamp + } else { + Duration::ZERO + }; + + let event = FlowEvent { + src_ip: flow_data.src_ip, + dst_ip: flow_data.dst_ip, + protocol: flow_data.l7_proto, + timestamp: flow_timestamp, + }; + + pending_flows.push(ScheduledFlow { + event, + scheduled_time, + }); + } + } + generated_this_cycle += 1; + } else { + break; + } + } + + // Emit flows whose scheduled time has passed + while let Some(scheduled) = pending_flows.peek() { + if scheduled.scheduled_time <= virtual_elapsed { + let scheduled = pending_flows.pop().unwrap(); + let _ = sender.send(scheduled.event); + } else { + break; + } + } + + web_time::sleep(check_interval).await; + } + } +} diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 4c146929..055b8eac 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -1,51 +1,697 @@ +use super::visualization_shapes::{ + NetworkEdgeShape, NetworkNodeShape, COLOR_DNS, COLOR_HTTP, COLOR_HTTPS, COLOR_INACTIVE, + COLOR_INTERNET, COLOR_SERVER, COLOR_SSH, COLOR_USER, +}; +use super::visualization_stream::{FlowEvent, FlowStreamer}; use super::visualization_utils::distribute_nodes_circle; +use crate::shared::configuration_file::ConfigurationFileState; use eframe::egui; use egui_graphs::{ - FruchtermanReingoldWithCenterGravity, FruchtermanReingoldWithCenterGravityState, - LayoutForceDirected, + FruchtermanReingoldState, FruchtermanReingoldWithCenterGravity, + FruchtermanReingoldWithCenterGravityState, LayoutForceDirected, set_layout_state, }; +use fosr_lib::{config, config::HostType, L7Proto, OS}; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::net::Ipv4Addr; +use std::sync::mpsc::Receiver; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +/// Special IP address representing "The Internet" node +pub const INTERNET_IP: Ipv4Addr = Ipv4Addr::new(0, 0, 0, 1); + +/// Node type for visualization (extends HostType with Internet) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NodeType { + Server, + User, + Internet, +} + +impl From for NodeType { + fn from(host_type: HostType) -> Self { + match host_type { + HostType::Server => NodeType::Server, + HostType::User => NodeType::User, + } + } +} -/** - * Represents the state of the visualization tab. - */ +/// Node data: host information +#[derive(Clone, Debug)] +pub struct NodeData { + pub ip_addr: Ipv4Addr, + pub hostname: Option, + pub node_type: NodeType, + #[allow(dead_code)] // Kept for possible future use (node styling by OS?) + pub os: OS, +} + +impl NodeData { + /// Create an Internet node + pub fn internet() -> Self { + Self { + ip_addr: INTERNET_IP, + hostname: Some("Internet".to_string()), + node_type: NodeType::Internet, + os: OS::Linux, // Doesn't matter for Internet node + } + } +} + +// Display the IP address, plus the hostname if available +impl fmt::Display for NodeData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(ref hostname) = self.hostname { + if self.node_type == NodeType::Internet { + write!(f, "{}", hostname) + } else { + write!(f, "{}\n{}", hostname, self.ip_addr) + } + } else { + write!(f, "{}", self.ip_addr) + } + } +} + +/// Edge data: communication state +#[derive(Clone, Debug, Default)] +pub enum EdgeData { + #[default] + Inactive, + Active { + protocol: L7Proto, + #[allow(dead_code)] // Kept for possible future animation effects? + start_time: Instant, + #[allow(dead_code)] // Kept for directional arrows + direction: LinkDirection, + }, +} + +impl fmt::Display for EdgeData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EdgeData::Inactive => write!(f, ""), + EdgeData::Active { protocol, .. } => write!(f, "{:?}", protocol), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum LinkDirection { + Forward, + Backward, + Bidirectional, +} + + +/// An active link being displayed +pub struct ActiveLink { + pub protocol: L7Proto, + pub start_time: Instant, + pub direction: LinkDirection, +} + +type VisualizationGraph = egui_graphs::Graph< + NodeData, + EdgeData, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, + NetworkEdgeShape, +>; + +/// Represents the state of the visualization tab. pub struct VisualizationTabState { - pub graph: egui_graphs::Graph<(), (), petgraph::Undirected>, + pub graph: VisualizationGraph, + pub flow_receiver: Option>, + pub active_links: HashMap<(Ipv4Addr, Ipv4Addr), ActiveLink>, + pub visualization_running: bool, + pub config_content: Option, + streamer: Option, + pub layout_initialized: bool, + /// Set of known IPs from the configuration (for filtering Internet flows) + known_ips: HashSet, + /// Map from IP to node index for quick lookup + ip_to_node: HashMap, + /// Visualization start time (for timestamp-based flow display) + visualization_start: Option, + /// Speed multiplier (0.5 to 4.0) - shared for runtime updates + pub speed: Arc>, } impl Default for VisualizationTabState { fn default() -> Self { - let mut g = petgraph::stable_graph::StableGraph::default(); - let a = g.add_node(()); - let b = g.add_node(()); - let c = g.add_node(()); + Self::create_demo_state() + } +} + +impl VisualizationTabState { + /// Create a demo state with all IPs from the BN models (bn_additional_data.json) + /// TODO: only a subset of them seems to appear in the generated data, prune the unused ones + fn create_demo_state() -> Self { + // All IPs from bn_additional_data.json (excluding 0.0.0.0) + // Servers are x.x.x.2, Users are x.x.x.3+ + let demo_hosts: Vec<(Ipv4Addr, NodeType)> = vec![ + // 192.168.100.x + (Ipv4Addr::new(192, 168, 100, 2), NodeType::Server), + (Ipv4Addr::new(192, 168, 100, 3), NodeType::User), + (Ipv4Addr::new(192, 168, 100, 4), NodeType::User), + (Ipv4Addr::new(192, 168, 100, 5), NodeType::User), + (Ipv4Addr::new(192, 168, 100, 6), NodeType::User), + // 192.168.200.x + (Ipv4Addr::new(192, 168, 200, 2), NodeType::Server), + (Ipv4Addr::new(192, 168, 200, 3), NodeType::User), + (Ipv4Addr::new(192, 168, 200, 4), NodeType::User), + (Ipv4Addr::new(192, 168, 200, 5), NodeType::User), + (Ipv4Addr::new(192, 168, 200, 8), NodeType::User), + (Ipv4Addr::new(192, 168, 200, 9), NodeType::User), + // 192.168.210.x + (Ipv4Addr::new(192, 168, 210, 2), NodeType::Server), + (Ipv4Addr::new(192, 168, 210, 3), NodeType::User), + (Ipv4Addr::new(192, 168, 210, 4), NodeType::User), + (Ipv4Addr::new(192, 168, 210, 5), NodeType::User), + // 192.168.220.x + (Ipv4Addr::new(192, 168, 220, 2), NodeType::Server), + (Ipv4Addr::new(192, 168, 220, 3), NodeType::User), + (Ipv4Addr::new(192, 168, 220, 4), NodeType::User), + (Ipv4Addr::new(192, 168, 220, 5), NodeType::User), + (Ipv4Addr::new(192, 168, 220, 6), NodeType::User), + (Ipv4Addr::new(192, 168, 220, 7), NodeType::User), + (Ipv4Addr::new(192, 168, 220, 8), NodeType::User), + (Ipv4Addr::new(192, 168, 220, 9), NodeType::User), + (Ipv4Addr::new(192, 168, 220, 10), NodeType::User), + (Ipv4Addr::new(192, 168, 220, 11), NodeType::User), + (Ipv4Addr::new(192, 168, 220, 12), NodeType::User), + (Ipv4Addr::new(192, 168, 220, 13), NodeType::User), + (Ipv4Addr::new(192, 168, 220, 14), NodeType::User), + (Ipv4Addr::new(192, 168, 220, 15), NodeType::User), + (Ipv4Addr::new(192, 168, 220, 16), NodeType::User), + ]; + + let mut graph = VisualizationGraph::new(petgraph::stable_graph::StableGraph::default()); + let mut known_ips = HashSet::new(); + let mut ip_to_node = HashMap::new(); + + // Add demo nodes + for (ip, node_type) in &demo_hosts { + let node_data = NodeData { + ip_addr: *ip, + hostname: None, // No hostname, just show IP + node_type: *node_type, + os: OS::Linux, // Does not matter + }; + // Nodes are initially placed at the center. They are manually distributed later. + let idx = graph.add_node_with_location(node_data, egui::pos2(0.0, 0.0)); + known_ips.insert(*ip); + ip_to_node.insert(*ip, idx); + } + + // Distribute nodes before adding the Internet node, so that it stays in the center + distribute_nodes_circle(&mut graph); + + // Add Internet node + let internet_idx = + graph.add_node_with_location(NodeData::internet(), egui::pos2(0.0, 0.0)); + ip_to_node.insert(INTERNET_IP, internet_idx); + + // Add edges between users and servers + // TODO: make sure that all flows occur between a server and a user, never between 2 servers or 2 users or a server and the Internet + let users: Vec<_> = demo_hosts + .iter() + .filter(|(_, t)| *t == NodeType::User) + .collect(); + let servers: Vec<_> = demo_hosts + .iter() + .filter(|(_, t)| *t == NodeType::Server) + .collect(); + + for (user_ip, _) in &users { + for (server_ip, _) in &servers { + let user_idx = ip_to_node[user_ip]; + let server_idx = ip_to_node[server_ip]; + graph.add_edge(user_idx, server_idx, EdgeData::Inactive); + } + // Add edge to Internet for each user + let user_idx = ip_to_node[user_ip]; + graph.add_edge(user_idx, internet_idx, EdgeData::Inactive); + } + + // Add edges from servers to Internet + for (server_ip, _) in &servers { + let server_idx = ip_to_node[server_ip]; + graph.add_edge(server_idx, internet_idx, EdgeData::Inactive); + } + + Self { + graph, + flow_receiver: None, + active_links: HashMap::new(), + visualization_running: false, + config_content: None, + streamer: None, + layout_initialized: false, + known_ips, + ip_to_node, + visualization_start: None, + speed: Arc::new(RwLock::new(1.0)), + } + } + + /// Update state from a configuration (preserves some state) + pub fn update_from_config(&mut self, config: &config::Configuration) { + // Don't update while running + // TODO: provide better handling if configuration changes during generation + if self.visualization_running { + return; + } - g.add_edge(a, b, ()); - g.add_edge(b, c, ()); - g.add_edge(c, a, ()); + let (graph, known_ips, ip_to_node) = Self::build_graph_from_config(config); + self.graph = graph; + self.known_ips = known_ips; + self.ip_to_node = ip_to_node; + self.layout_initialized = false; + } + + /// Build graph from configuration (shared logic) + fn build_graph_from_config( + config: &config::Configuration, + ) -> ( + VisualizationGraph, + HashSet, + HashMap, + ) { + let mut graph = VisualizationGraph::new(petgraph::stable_graph::StableGraph::default()); + let mut known_ips = HashSet::new(); + let mut ip_to_node: HashMap = HashMap::new(); - let mut graph = egui_graphs::Graph::<(), (), petgraph::Undirected>::from(&g); + // Add nodes for each host interface + // TODO: it would be better to have one single node per host + for host in &config.hosts { + for interface in &host.interfaces { + let node_data = NodeData { + ip_addr: interface.ip_addr, + hostname: host.hostname.clone(), + node_type: host.host_type.into(), + os: host.os, + }; + let idx = graph.add_node_with_location(node_data, egui::pos2(0.0, 0.0)); + known_ips.insert(interface.ip_addr); + ip_to_node.insert(interface.ip_addr, idx); + } + } - // Initially, the nodes are placed at position (0, 0) and need to be distributed + // Distribute nodes before adding the Internet node, so that it stays in the center distribute_nodes_circle(&mut graph); - Self { graph } + // Add Internet node + let internet_idx = graph.add_node_with_location(NodeData::internet(), egui::pos2(0.0, 0.0)); + ip_to_node.insert(INTERNET_IP, internet_idx); + + // Add edges for all possible connections between users and servers + for &user_ip in &config.users { + if let Some(&user_idx) = ip_to_node.get(&user_ip) { + for &server_ip in &config.servers { + if let Some(&server_idx) = ip_to_node.get(&server_ip) { + graph.add_edge(user_idx, server_idx, EdgeData::Inactive); + } + } + // Add edge to Internet for each user + graph.add_edge(user_idx, internet_idx, EdgeData::Inactive); + } + } + + // Add edges from servers to Internet + for &server_ip in &config.servers { + if let Some(&server_idx) = ip_to_node.get(&server_ip) { + graph.add_edge(server_idx, internet_idx, EdgeData::Inactive); + } + } + + (graph, known_ips, ip_to_node) + } + + /// Check if an IP is a known (configured) IP + fn is_known_ip(&self, ip: Ipv4Addr) -> bool { + self.known_ips.contains(&ip) + } + + /// Start visualization + /// If config_content is None, the FlowStreamer uses the default BN model (no config applied) + /// Speed controls how fast flows are emitted (1.0 = real-time, 2.0 = 2x faster) - can be updated at runtime via slider + pub fn start_visualization( + &mut self, + config_content: Option<&str>, + speed: Arc>, + ) -> Result<(), String> { + log::debug!("Starting visualization with {} known IPs:", self.known_ips.len()); + for ip in &self.known_ips { + log::debug!(" - {}", ip); + } + + let (sender, receiver) = std::sync::mpsc::channel(); + + let streamer = FlowStreamer::new(config_content, speed.clone(), sender)?; + streamer.start(); + + self.streamer = Some(streamer); + self.flow_receiver = Some(receiver); + self.visualization_running = true; + self.visualization_start = Some(Instant::now()); + log::info!( + "Flow visualization started (config: {}, speed: {}x)", + if config_content.is_some() { + "user-provided" + } else { + "default BN model" + }, + *speed.read().unwrap() + ); + + Ok(()) + } + + /// Stop visualization + pub fn stop_visualization(&mut self) { + self.visualization_running = false; + if let Some(streamer) = &self.streamer { + streamer.stop(); + } + self.streamer = None; + self.flow_receiver = None; + self.active_links.clear(); + self.visualization_start = None; + log::info!("Flow visualization stopped"); } } pub fn show_visualization_tab_content( ui: &mut egui::Ui, - visualization_tab_state: &mut VisualizationTabState, + state: &mut VisualizationTabState, + configuration_file_state: &ConfigurationFileState, +) { + // Handle config changes + handle_config_changes(state, configuration_file_state); + + // Process incoming flow events + process_flow_events(state); + + // Update active links (remove expired ones) + update_active_links(state); + + // Update graph edges based on active links + update_graph_edges(state); + + // Render UI + render_control_panel(ui, state); + render_graph_view(ui, state); +} + +/// Handle configuration file changes +fn handle_config_changes( + state: &mut VisualizationTabState, + configuration_file_state: &ConfigurationFileState, ) { + // Check if config was removed + let was_config_removed = + state.config_content.is_some() && configuration_file_state.config_file_content.is_none(); + + if was_config_removed && !state.visualization_running { + state.config_content = None; + *state = VisualizationTabState::default(); + return; + } + + // Check if config content has changed + let needs_update = match (&state.config_content, &configuration_file_state.config_file_content) + { + (Some(current), Some(new)) => current != new, + (None, Some(_)) => true, + _ => false, + }; + + if needs_update && !state.visualization_running { + if let Some(ref config_content) = configuration_file_state.config_file_content { + state.active_links.clear(); + if let Some(streamer) = &state.streamer { + streamer.stop(); + } + state.streamer = None; + state.flow_receiver = None; + + let config = config::import_config(config_content); + state.update_from_config(&config); + state.config_content = Some(config_content.clone()); + } + } +} + +/// Process incoming flow events from the streamer +fn process_flow_events(state: &mut VisualizationTabState) { + let events: Vec = if let Some(ref receiver) = state.flow_receiver { + receiver.try_iter().collect() + } else { + return; + }; + + let now = Instant::now(); + + for event in events { + // Determine if this flow should be displayed: + // - Both IPs known: display + // - One IP known, one unknown: display as host<->Internet + // - Both IPs unknown: skip (Internet<->Internet) + let src_known = state.is_known_ip(event.src_ip); + let dst_known = state.is_known_ip(event.dst_ip); + + log::debug!( + "Flow: {} -> {} | src_known={}, dst_known={}", + event.src_ip, + event.dst_ip, + src_known, + dst_known + ); + + if !src_known && !dst_known { + // Both are Internet IPs - skip this flow + log::debug!(" -> Skipping (Internet<->Internet)"); + continue; + } + + // Map IPs to display IPs (unknown -> INTERNET_IP) + let display_src = if src_known { + event.src_ip + } else { + INTERNET_IP + }; + let display_dst = if dst_known { + event.dst_ip + } else { + INTERNET_IP + }; + + log::debug!( + " -> Displayed as: {} -> {} ({:?})", + display_src, + display_dst, + event.protocol + ); + + let key = (display_src, display_dst); + let reverse_key = (display_dst, display_src); + + let direction = if state.active_links.contains_key(&reverse_key) { + LinkDirection::Bidirectional + } else { + LinkDirection::Forward + }; + + state.active_links.insert( + key, + ActiveLink { + protocol: event.protocol, + start_time: now, + direction, + }, + ); + } +} + +/// Update active links (remove expired ones) +fn update_active_links(state: &mut VisualizationTabState) { + let now = Instant::now(); + // Base display time is 0.5s, adjusted by speed (faster = shorter display) + let base_timeout_ms = 500.0; + let speed = *state.speed.read().unwrap(); + let timeout = Duration::from_millis((base_timeout_ms / speed) as u64); + + state + .active_links + .retain(|_, link| now.duration_since(link.start_time) < timeout); +} + +/// Update graph edges based on active links +fn update_graph_edges(state: &mut VisualizationTabState) { + let graph = &mut state.graph; + + // Collect edge info first to avoid borrow issues + let edges_data: Vec<(petgraph::graph::EdgeIndex, Ipv4Addr, Ipv4Addr)> = graph + .g() + .edge_indices() + .map(|edge| { + let (source, target) = graph.g().edge_endpoints(edge).unwrap(); + let src_ip = graph.g()[source].payload().ip_addr; + let dst_ip = graph.g()[target].payload().ip_addr; + (edge, src_ip, dst_ip) + }) + .collect(); + + for (edge, src_ip, dst_ip) in edges_data { + let forward_key = (src_ip, dst_ip); + let reverse_key = (dst_ip, src_ip); + + let new_edge_data = if let Some(link) = state.active_links.get(&forward_key) { + EdgeData::Active { + protocol: link.protocol, + start_time: link.start_time, + direction: link.direction.clone(), + } + } else if let Some(link) = state.active_links.get(&reverse_key) { + EdgeData::Active { + protocol: link.protocol, + start_time: link.start_time, + // we are using the reverse key, so we need to reverse the direction + direction: match link.direction { + LinkDirection::Forward => LinkDirection::Backward, + LinkDirection::Backward => LinkDirection::Forward, + LinkDirection::Bidirectional => LinkDirection::Bidirectional, + }, + } + } else { + EdgeData::Inactive + }; + + // Update the edge data + if let Some(edge_mut) = graph.g_mut().edge_weight_mut(edge) { + *edge_mut.payload_mut() = new_edge_data; + } + } +} + +/// Render the control panel +fn render_control_panel(ui: &mut egui::Ui, state: &mut VisualizationTabState) { + egui::TopBottomPanel::top("visualization_controls").show(ui.ctx(), |ui| { + ui.vertical(|ui| { + // Row 1: Button + label + ui.horizontal(|ui| { + if !state.visualization_running { + if ui.button("Start Visualization").clicked() { + // Clone config to avoid borrow issues + // Pass the user config if loaded, otherwise None (uses default BN model) + let config = state.config_content.clone(); + let speed = state.speed.clone(); + if let Err(e) = state.start_visualization(config.as_deref(), speed) { + log::error!("Failed to start flow streamer: {}", e); + } + } + + if state.config_content.is_none() { + ui.label( + egui::RichText::new("(Demo mode - load a config for custom network)") + .color(egui::Color32::GRAY), + ); + } + } else { + if ui.button("Stop").clicked() { + state.stop_visualization(); + } + } + }); + + ui.separator(); + + // Row 2: Speed slider + active links + ui.horizontal(|ui| { + ui.label("Speed:"); + // Speed is an Arc, we cannot use it directly with slider, + // we need to read and write its value manually. + let mut speed_value = *state.speed.read().unwrap(); + let response = ui.add( + egui::Slider::new(&mut speed_value, 0.5..=4.0) + .logarithmic(true) + .text("x"), + ); + if response.changed() { + *state.speed.write().unwrap() = speed_value; + } + + ui.separator(); + ui.label(format!("Active links: {}", state.active_links.len())); + }); + + ui.separator(); + + // Row 3: Legend - Node types + ui.horizontal(|ui| { + ui.label("Node Types:"); + legend_item_inline(ui, "Server", COLOR_SERVER); + legend_item_inline(ui, "User", COLOR_USER); + legend_item_inline(ui, "Internet", COLOR_INTERNET); + }); + + // Row 4: Legend - Edge states + ui.horizontal(|ui| { + ui.label("Edge States:"); + legend_item_inline(ui, "Inactive", COLOR_INACTIVE); + legend_item_inline(ui, "HTTP", COLOR_HTTP); + legend_item_inline(ui, "HTTPS", COLOR_HTTPS); + legend_item_inline(ui, "SSH", COLOR_SSH); + legend_item_inline(ui, "DNS", COLOR_DNS); + }); + }); + }); +} + +/// Helper to render a single legend item inline +fn legend_item_inline(ui: &mut egui::Ui, label: &str, color: egui::Color32) { + // Allocate space first + let rect = ui.allocate_space(egui::vec2(12.0, 12.0)).1; + // Then get painter and draw + let painter = ui.painter(); + painter.circle_filled(rect.center(), 6.0, color); + ui.label(label); +} + +/// Render the graph view +fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { egui::CentralPanel::default().show(ui.ctx(), |ui| { - ui.add(&mut egui_graphs::GraphView::< - (), - (), + let mut graph_view = egui_graphs::GraphView::< + NodeData, + EdgeData, petgraph::Undirected, petgraph::stable_graph::DefaultIx, - egui_graphs::DefaultNodeShape, - egui_graphs::DefaultEdgeShape, + NetworkNodeShape, + NetworkEdgeShape, FruchtermanReingoldWithCenterGravityState, LayoutForceDirected, - >::new(&mut visualization_tab_state.graph)); + >::new(&mut state.graph) + .with_styles(&egui_graphs::SettingsStyle::new().with_labels_always(true)); + + // Disable force-directed layout to preserve circle layout + // TODO: handle this properly instead of just deactivating the auto-layout + if !state.layout_initialized { + let layout_state = FruchtermanReingoldWithCenterGravityState { + base: FruchtermanReingoldState { + is_running: false, + ..Default::default() + }, + extras: Default::default(), + }; + set_layout_state(ui, layout_state, None); + state.layout_initialized = true; + } + + ui.add(&mut graph_view); }); } diff --git a/crates/fosr-gui/src/visualization/visualization_utils.rs b/crates/fosr-gui/src/visualization/visualization_utils.rs index c1227fd8..9245656a 100644 --- a/crates/fosr-gui/src/visualization/visualization_utils.rs +++ b/crates/fosr-gui/src/visualization/visualization_utils.rs @@ -1,8 +1,20 @@ use eframe::egui; -pub fn distribute_nodes_circle(graph: &mut egui_graphs::Graph<(), (), petgraph::Undirected>) { + +/// Distributes the graph nodes in a circle layout. +/// Required for proper display on startup. +pub fn distribute_nodes_circle( + graph: &mut egui_graphs::Graph, +) where + N: Clone, + E: Clone, + Ty: petgraph::EdgeType, + Ix: petgraph::graph::IndexType, + Dn: egui_graphs::DisplayNode, + De: egui_graphs::DisplayEdge, +{ let n = graph.node_count().max(1) as f32; - let radius = n.sqrt() * 50.0 + 50.0; + let radius = n.sqrt() * 80.0 + 100.0; let indices: Vec<_> = graph.g().node_indices().collect(); for (i, idx) in indices.into_iter().enumerate() { diff --git a/crates/fosr-lib/src/config.rs b/crates/fosr-lib/src/config.rs index cade66b1..92c23cf7 100644 --- a/crates/fosr-lib/src/config.rs +++ b/crates/fosr-lib/src/config.rs @@ -177,9 +177,9 @@ pub struct Metadata { pub format: Option, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Clone, Copy, PartialEq)] #[serde(rename_all = "lowercase")] -enum HostType { +pub enum HostType { Server, User, } @@ -198,7 +198,8 @@ pub struct Host { client: Option>, // we keep the option here, because there is a difference // between an empty list (no service is used) and nothing // (default services are used) - host_type: HostType, + /// The type of host (server or user) + pub host_type: HostType, /// Its interfaces pub interfaces: Vec, } From b7b8b6da887c21e2fb17a5022843758888927442 Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 29 Jan 2026 18:44:06 +0100 Subject: [PATCH 12/77] Feat: add services modification possible --- .../src/configuration/configuration_tab.rs | 93 +++++++++++++++++-- 1 file changed, 87 insertions(+), 6 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index bc5f56af..5221c518 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -7,6 +7,31 @@ use chrono::NaiveDate; use eframe::egui; use egui_extras::DatePickerButton; +const KNOWN_SERVICES: &[(&str, Option)] = &[ + ("http", Some(80)), + ("https", Some(443)), + ("ssh", Some(22)), + ("ftp", Some(21)), + ("smtp", Some(25)), + ("dns", Some(53)), +]; + +fn parse_service(s: &str) -> (String, Option) { + if let Some((name, port)) = s.split_once(':') { + if let Ok(p) = port.parse::() { + return (name.to_string(), Some(p)); + } + } + (s.to_string(), None) +} + +fn format_service(name: &str, port: Option) -> String { + match port { + Some(p) => format!("{name}:{p}"), + None => name.to_string(), + } +} + /** * Represents the state of the configuration tab. */ @@ -306,12 +331,68 @@ pub fn show_configuration_tab_content( egui::CollapsingHeader::new(format!("Services ({svc_count})")) .default_open(false) .show(ui, |ui| { - if iface.services.is_empty() { - ui.monospace(""); - } else { - for svc in &iface.services { - ui.monospace(format!("- {svc}")); - } + if ui.button("+ Add service").clicked() { + iface.services.push("http".to_string()); + } + + ui.add_space(4.0); + + let mut svc_to_remove: Option = None; + + for (svc_idx, svc_raw) in iface.services.iter_mut().enumerate() { + let (mut svc_name, mut svc_port) = parse_service(svc_raw); + + egui::Frame::group(ui.style()) + .show(ui, |ui| { + ui.horizontal(|ui| { + // --- Service name combo + egui::ComboBox::from_id_salt((if_idx, svc_idx, "service_name")) + .selected_text(&svc_name) + .show_ui(ui, |ui| { + for (name, default_port) in KNOWN_SERVICES { + if ui.selectable_label(&svc_name == name, *name).clicked() { + svc_name = name.to_string(); + svc_port = *default_port; + } + } + }); + + // --- Port editor (optional) + ui.label("Port:"); + let mut port_val = svc_port.unwrap_or(0); + let resp = ui.add( + egui::DragValue::new(&mut port_val) + .speed(1) + .range(0..=65535), + ); + + if resp.changed() { + svc_port = if port_val == 0 { + None + } else { + Some(port_val) + }; + } + + if ui.button("Clear port").clicked() { + svc_port = None; + } + + // --- Remove service + if ui.button("✕").clicked() { + svc_to_remove = Some(svc_idx); + } + }); + }); + + // Write back to YAML-compatible string + *svc_raw = format_service(&svc_name, svc_port); + + ui.add_space(4.0); + } + + if let Some(idx) = svc_to_remove { + iface.services.remove(idx); } }); }); From 64ff5e1912fb949aeb6654c882eb42562097b6c3 Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 29 Jan 2026 22:12:16 +0100 Subject: [PATCH 13/77] Fixing host name and based ip --- .../src/configuration/configuration_tab.rs | 61 ++++++++++++++++--- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 5221c518..149ee55b 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -6,6 +6,34 @@ use crate::shared::ui_utils::{edit_optional_multiline_string, edit_optional_stri use chrono::NaiveDate; use eframe::egui; use egui_extras::DatePickerButton; +use std::collections::HashSet; + +fn next_free_ip(used_ips: &HashSet) -> Option { + for x in 1..=254 { + let candidate = format!("192.168.0.{x}"); + if !used_ips.contains(&candidate) { + return Some(candidate); + } + } + + None +} + +fn host_display_name(host: &Host) -> String { + if let Some(name) = host.hostname.as_deref() { + if !name.trim().is_empty() { + return name.to_string(); + } + } + + if let Some(iface) = host.interfaces.first() { + if !iface.ip_addr.trim().is_empty() { + return iface.ip_addr.clone(); + } + } + + "Unconfigured host".to_string() +} const KNOWN_SERVICES: &[(&str, Option)] = &[ ("http", Some(80)), @@ -150,6 +178,12 @@ pub fn show_configuration_tab_content( if model.hosts.is_empty() { ui.label("No hosts in this configuration."); } else { + let mut used_ips = HashSet::new(); + for h in &model.hosts { + for iface in &h.interfaces { + used_ips.insert(iface.ip_addr.clone()); + } + } let mut host_to_remove: Option = None; for (host_idx, host) in model.hosts.iter_mut().enumerate() { let hostname_for_header = host @@ -167,7 +201,11 @@ pub fn show_configuration_tab_content( // egui::CollapsingHeader::new(header) // .default_open(host_idx == 0) // optionnel: ouvre le premier host par défaut // .show(ui, |ui| { - egui::CollapsingHeader::new(format!("Host #{host_idx}")) + + let host_name = host_display_name(host); + + egui::CollapsingHeader::new(host_name) + .id_salt(("host", host_idx)) .default_open(host_idx == 0) .show(ui, |ui| { ui.horizontal(|ui| { @@ -291,12 +329,18 @@ pub fn show_configuration_tab_content( ui.horizontal(|ui| { ui.label("Interfaces:"); if ui.button("+ Add interface").clicked() { - // IP is mandatory, so give a placeholder that user must edit - host.interfaces.push(Interface { - ip_addr: "192.168.0.1".to_string(), - mac_addr: None, - services: Vec::new(), - }); + if let Some(ip) = next_free_ip(&used_ips) { + host.interfaces.push(Interface { + ip_addr: ip, + mac_addr: None, + services: Vec::new(), + }); + } else { + ui.colored_label( + egui::Color32::RED, + "No free IP available in 192.168.0.0/24", + ); + } } }); @@ -307,7 +351,8 @@ pub fn show_configuration_tab_content( for (if_idx, iface) in host.interfaces.iter_mut().enumerate() { let ip_for_header = iface.ip_addr.clone(); - egui::CollapsingHeader::new(format!("Interface #{if_idx} — {ip_for_header}")) + egui::CollapsingHeader::new(format!("Interface — {ip_for_header}")) + .id_salt(("iface", host_idx, if_idx)) .default_open(if_idx == 0) .show(ui, |ui| { // Remove interface (mark for removal) From f6b12d71de6aaec71029df411dc7fb36cb2bce59 Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 29 Jan 2026 22:35:29 +0100 Subject: [PATCH 14/77] Refacto code --- .../src/configuration/configuration_tab.rs | 836 +++++++++--------- 1 file changed, 419 insertions(+), 417 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 149ee55b..2c781b43 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -1,4 +1,4 @@ -use crate::shared::config_model::{Host, Interface}; +use crate::shared::config_model::{Configuration, Host, Interface}; use crate::shared::configuration_file::{ ConfigurationFileState, configuration_file_picker, load_config_file_contents, }; @@ -8,6 +8,7 @@ use eframe::egui; use egui_extras::DatePickerButton; use std::collections::HashSet; +/// Scans all interfaces to find the next available IP in 192.168.0.x fn next_free_ip(used_ips: &HashSet) -> Option { for x in 1..=254 { let candidate = format!("192.168.0.{x}"); @@ -19,6 +20,7 @@ fn next_free_ip(used_ips: &HashSet) -> Option { None } +/// Determines the best label for a host (Hostname > IP > Default) fn host_display_name(host: &Host) -> String { if let Some(name) = host.hostname.as_deref() { if !name.trim().is_empty() { @@ -44,6 +46,7 @@ const KNOWN_SERVICES: &[(&str, Option)] = &[ ("dns", Some(53)), ]; +/// Splits "name:port" into ("name", Some(port)) fn parse_service(s: &str) -> (String, Option) { if let Some((name, port)) = s.split_once(':') { if let Ok(p) = port.parse::() { @@ -53,6 +56,7 @@ fn parse_service(s: &str) -> (String, Option) { (s.to_string(), None) } +/// Joins name and port into "name:port" or just "name" fn format_service(name: &str, port: Option) -> String { match port { Some(p) => format!("{name}:{p}"), @@ -71,461 +75,459 @@ impl Default for ConfigurationTabState { } } +/// The main tab component pub fn show_configuration_tab_content( ui: &mut egui::Ui, - _configuration_tab_state: &mut ConfigurationTabState, - configuration_file_state: &mut ConfigurationFileState, + _tab_state: &mut ConfigurationTabState, + file_state: &mut ConfigurationFileState, ) { - // Config file picker - configuration_file_picker(ui, configuration_file_state); - + // File Selection + configuration_file_picker(ui, file_state); ui.separator(); - // --- Parsing status --- - if configuration_file_state.picked_config_file.is_some() { - if let Some(err) = &configuration_file_state.parse_error { + ui_parsing_status(ui, file_state); + + // Editor (if model is loaded) + if let Some(model) = file_state.config_model.as_mut() { + ui_metadata(ui, model); + ui.separator(); + ui_hosts_section(ui, model); + ui.separator(); + + // YAML Preview Button + if ui.button("Export YAML (preview)").clicked() { + match serde_yaml::to_string(&*model) { + Ok(yaml) => { + file_state.config_file_content = Some(yaml); + file_state.parse_error = None; + } + Err(e) => { + file_state.parse_error = Some(e.to_string()); + } + } + } + } + + ui_yaml_preview(ui, file_state); +} + +/// Status & Feedback +fn ui_parsing_status(ui: &mut egui::Ui, state: &ConfigurationFileState) { + if state.picked_config_file.is_some() { + if let Some(err) = &state.parse_error { ui.colored_label(egui::Color32::RED, "YAML parsing failed:"); ui.label(err); - } else if configuration_file_state.config_model.is_some() { + } else if state.config_model.is_some() { ui.colored_label(egui::Color32::GREEN, "YAML parsed successfully ✅"); - } else if configuration_file_state.config_file_content.is_some() { - // Content loaded but model not set -> should not happen often, but safe + } else if state.config_file_content.is_some() { ui.colored_label(egui::Color32::YELLOW, "YAML loaded, but not parsed yet."); } ui.separator(); } - if let Some(model) = configuration_file_state.config_model.as_mut() { - ui.heading("Metadata"); +} + +/// Metadata rendering +fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { + ui.heading("Metadata"); + ui.add_space(6.0); + + // Title (Mandatory) + ui.horizontal(|ui| { + ui.label("Title:"); + let title = model.metadata.title.get_or_insert_with(String::new); + ui.text_edit_singleline(title); + }); + + edit_optional_multiline_string( + ui, + "Description (optional):", + &mut model.metadata.desc, + "Optional description", + 3, + ); + + edit_optional_string( + ui, + "Author (optional):", + &mut model.metadata.author, + "Jane Doe", + ); + + // Date Picker + ui.horizontal(|ui| { + ui.label("Date (optional):"); + let mut date_val = model + .metadata + .date + .as_deref() + .and_then(|s| NaiveDate::parse_from_str(s, "%Y/%m/%d").ok()) + .unwrap_or_else(|| NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()); + + if ui.add(DatePickerButton::new(&mut date_val)).changed() { + model.metadata.date = Some(date_val.format("%Y/%m/%d").to_string()); + } + + if ui.button("Clear").clicked() { + model.metadata.date = None; + } + }); + + edit_optional_string( + ui, + "Version (optional):", + &mut model.metadata.version, + "0.1.0", + ); + + // Format (Reserved) + ui.horizontal(|ui| { + ui.label("Format:"); + let current = model.metadata.format.unwrap_or(1); + ui.label(current.to_string()) + .on_hover_text("Reserved for now. Should remain 1."); + + if ui.button("Set to 1").clicked() { + model.metadata.format = Some(1); + } + if ui.button("Clear").clicked() { + model.metadata.format = None; + } + }); +} + +/// Several host rendering +fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { + ui.heading("Hosts"); + ui.add_space(6.0); + + ui.horizontal(|ui| { + if ui.button("+ Add host").clicked() { + model.hosts.push(Host::default()); + } + }); + ui.add_space(6.0); + + if model.hosts.is_empty() { + ui.label("No hosts in this configuration."); + return; + } + + // Pre-calculate used IPs to avoid borrow checker conflicts + let mut used_ips = HashSet::new(); + for h in &model.hosts { + for iface in &h.interfaces { + used_ips.insert(iface.ip_addr.clone()); + } + } + + let mut host_to_remove: Option = None; + + for (idx, host) in model.hosts.iter_mut().enumerate() { + ui_single_host(ui, idx, host, &used_ips, &mut host_to_remove); ui.add_space(6.0); + } + + if let Some(idx) = host_to_remove { + model.hosts.remove(idx); + } +} + +/// Single host rendering +fn ui_single_host( + ui: &mut egui::Ui, + index: usize, + host: &mut Host, + used_ips: &HashSet, + remove_request: &mut Option, +) { + let host_name = host_display_name(host); + let header_name = host + .hostname + .clone() + .unwrap_or_else(|| "".to_string()); + let header_type = host.r#type.clone().unwrap_or_else(|| "".to_string()); + let if_count = host.interfaces.len(); + + egui::CollapsingHeader::new(host_name) + .id_salt(("host", index)) + .default_open(index == 0) + .show(ui, |ui| { + // Header Info & Remove Button + ui.horizontal(|ui| { + ui.strong(format!( + "{header_name} | type: {header_type} | interfaces: {if_count}" + )); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Remove host").clicked() { + *remove_request = Some(index); + } + }); + }); + + // Editable Fields + edit_optional_string(ui, "Hostname (optional):", &mut host.hostname, "host1"); + edit_optional_string(ui, "OS (optional):", &mut host.os, "Linux"); + + // Usage DragValue + ui.horizontal(|ui| { + ui.label("Usage (optional):"); + let mut usage_val = host.usage.unwrap_or(1.0); + if ui + .add(egui::DragValue::new(&mut usage_val).speed(0.1)) + .changed() + { + // Avoid saving 1.0 if it's the default + host.usage = if (usage_val - 1.0).abs() < f32::EPSILON { + None + } else { + Some(usage_val) + }; + } + if ui.button("Clear").clicked() { + host.usage = None; + } + }); + + // Type Selection + ui_host_type_selector(ui, index, host); + + // Client Protocols + ui_host_client_protocols(ui, host); - // --- title (mandatory in spec, but keep Option for editing) --- - ui.horizontal(|ui| { - ui.label("Title:"); - let title = model.metadata.title.get_or_insert_with(String::new); - ui.text_edit_singleline(title); + ui.separator(); + + // Interfaces + ui_interfaces_section(ui, index, host, used_ips); }); +} - edit_optional_multiline_string( - ui, - "Description (optional):", - &mut model.metadata.desc, - "Optional description", - 3, - ); +/// Type of host rendering +fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { + ui.horizontal(|ui| { + ui.label("Type (optional):"); + let selected_text = host.r#type.as_deref().unwrap_or("").to_string(); + + egui::ComboBox::from_id_salt((host_idx, "host_type")) + .selected_text(selected_text) + .show_ui(ui, |ui| { + if ui + .selectable_label(host.r#type.is_none(), "") + .clicked() + { + host.r#type = None; + } + if ui + .selectable_label(host.r#type.as_deref() == Some("server"), "server") + .clicked() + { + host.r#type = Some("server".to_string()); + } + if ui + .selectable_label(host.r#type.as_deref() == Some("user"), "user") + .clicked() + { + host.r#type = Some("user".to_string()); + } + }); - edit_optional_string( - ui, - "Author (optional):", - &mut model.metadata.author, - "Jane Doe", - ); + if ui.button("Clear").clicked() { + host.r#type = None; + } + }); +} + +/// Client protocols rendering +fn ui_host_client_protocols(ui: &mut egui::Ui, host: &mut Host) { + ui.horizontal(|ui| { + ui.label("Client protocols (optional):"); + let mut buf = if host.client.is_empty() { + String::new() + } else { + host.client.join(",") + }; - ui.horizontal(|ui| { - ui.label("Date (optional):"); + let resp = ui.add(egui::TextEdit::singleline(&mut buf).hint_text("ex: http,https,ssh")); - let mut date_val = model - .metadata - .date - .as_deref() - .and_then(|s| NaiveDate::parse_from_str(s, "%Y/%m/%d").ok()) - .unwrap_or_else(|| NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()); + if resp.changed() { + host.client = buf + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } - let resp = ui.add(DatePickerButton::new(&mut date_val)); + if ui.button("Clear").clicked() { + host.client.clear(); + } + }); +} - if resp.changed() { - model.metadata.date = Some(date_val.format("%Y/%m/%d").to_string()); +/// Interface section rendering +fn ui_interfaces_section( + ui: &mut egui::Ui, + host_idx: usize, + host: &mut Host, + used_ips: &HashSet, +) { + ui.horizontal(|ui| { + ui.label("Interfaces:"); + if ui.button("+ Add interface").clicked() { + if let Some(ip) = next_free_ip(used_ips) { + host.interfaces.push(Interface { + ip_addr: ip, + mac_addr: None, + services: Vec::new(), + }); + } else { + ui.colored_label(egui::Color32::RED, "No free IP available in 192.168.0.0/24"); } + } + }); - if ui.button("Clear").clicked() { - model.metadata.date = None; - } - }); + if host.interfaces.is_empty() { + ui.label("No interfaces."); + return; + } - edit_optional_string( - ui, - "Version (optional):", - &mut model.metadata.author, - "0.1.0", - ); + let mut iface_to_remove: Option = None; - // --- format (reserved) --- - ui.horizontal(|ui| { - ui.label("Format:"); - let current = model.metadata.format.unwrap_or(1); - ui.label(current.to_string()) - .on_hover_text("Reserved for now. Should remain 1."); + for (if_idx, iface) in host.interfaces.iter_mut().enumerate() { + let ip_label = iface.ip_addr.clone(); - if ui.button("Set to 1").clicked() { - model.metadata.format = Some(1); - } + egui::CollapsingHeader::new(format!("Interface — {ip_label}")) + .id_salt(("iface", host_idx, if_idx)) + .default_open(if_idx == 0) + .show(ui, |ui| { + if ui.button("Remove interface").clicked() { + iface_to_remove = Some(if_idx); + } + ui.add_space(4.0); + + // IP & MAC + ui.horizontal(|ui| { + ui.label("IP (mandatory):"); + ui.text_edit_singleline(&mut iface.ip_addr); + }); + edit_optional_string( + ui, + "MAC (optional):", + &mut iface.mac_addr, + "00:14:2A:3F:47:D8", + ); + + // Services + ui_services_section(ui, if_idx, iface); + }); + + ui.add_space(6.0); + } + + if let Some(idx) = iface_to_remove { + host.interfaces.remove(idx); + } +} + +/// Service section rendering +fn ui_services_section(ui: &mut egui::Ui, iface_idx: usize, iface: &mut Interface) { + let svc_count = iface.services.len(); - if ui.button("Clear").clicked() { - model.metadata.format = None; + egui::CollapsingHeader::new(format!("Services ({svc_count})")) + .default_open(false) + .show(ui, |ui| { + if ui.button("+ Add service").clicked() { + iface.services.push("http".to_string()); } - }); + ui.add_space(4.0); - ui.separator(); + let mut svc_to_remove: Option = None; - ui.heading("Hosts"); - ui.add_space(6.0); - ui.horizontal(|ui| { - if ui.button("+ Add host").clicked() { - model.hosts.push(Host::default()); + for (svc_idx, svc_raw) in iface.services.iter_mut().enumerate() { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui_single_service(ui, iface_idx, svc_idx, svc_raw, &mut svc_to_remove); + }); + ui.add_space(4.0); + } + + if let Some(idx) = svc_to_remove { + iface.services.remove(idx); } }); - ui.add_space(6.0); +} - if model.hosts.is_empty() { - ui.label("No hosts in this configuration."); - } else { - let mut used_ips = HashSet::new(); - for h in &model.hosts { - for iface in &h.interfaces { - used_ips.insert(iface.ip_addr.clone()); +/// Single service rendering +fn ui_single_service( + ui: &mut egui::Ui, + iface_idx: usize, + svc_idx: usize, + svc_raw: &mut String, + remove_request: &mut Option, +) { + let (mut svc_name, mut svc_port) = parse_service(svc_raw); + + ui.horizontal(|ui| { + // Name ComboBox + egui::ComboBox::from_id_salt((iface_idx, svc_idx, "service_name")) + .selected_text(&svc_name) + .show_ui(ui, |ui| { + for (name, default_port) in KNOWN_SERVICES { + if ui.selectable_label(&svc_name == name, *name).clicked() { + svc_name = name.to_string(); + svc_port = *default_port; + } } - } - let mut host_to_remove: Option = None; - for (host_idx, host) in model.hosts.iter_mut().enumerate() { - let hostname_for_header = host - .hostname - .clone() - .unwrap_or_else(|| "".to_string()); - let host_type_for_header = - host.r#type.clone().unwrap_or_else(|| "".to_string()); - let if_count = host.interfaces.len(); - - // let header = format!( - // "Host #{host_idx}: {hostname} | type: {host_type} | interfaces: {if_count}" - // ); - // - // egui::CollapsingHeader::new(header) - // .default_open(host_idx == 0) // optionnel: ouvre le premier host par défaut - // .show(ui, |ui| { - - let host_name = host_display_name(host); - - egui::CollapsingHeader::new(host_name) - .id_salt(("host", host_idx)) - .default_open(host_idx == 0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.strong(format!( - "{hostname_for_header} | type: {host_type_for_header} | interfaces: {if_count}" - )); - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if ui.button("Remove host").clicked() { - host_to_remove = Some(host_idx); - } - }, - ); - }); - // --- Editable host fields --- - - // Hostname (optional) - edit_optional_string( - ui, - "Hostname (optional):", - &mut host.hostname, - "host1", - ); - - // OS (optional, default is Linux) - edit_optional_string(ui, "OS (optional):", &mut host.os, "Linux"); - - // Usage (optional, default is 1.0) - ui.horizontal(|ui| { - ui.label("Usage (optional):"); - - let mut usage_val = host.usage.unwrap_or(1.0); - let resp = ui.add(egui::DragValue::new(&mut usage_val).speed(0.1)); - - if resp.changed() { - // If equals default, keep None to avoid polluting YAML - if (usage_val - 1.0).abs() < f32::EPSILON { - host.usage = None; - } else { - host.usage = Some(usage_val); - } - } - - if ui.button("Clear").clicked() { - host.usage = None; - } - }); - - // Type (optional, auto if None) - ui.horizontal(|ui| { - ui.label("Type (optional):"); - - let selected_text = - host.r#type.as_deref().unwrap_or("").to_string(); - - egui::ComboBox::from_id_salt((host_idx, "host_type")) - .selected_text(selected_text) - .show_ui(ui, |ui| { - if ui - .selectable_label(host.r#type.is_none(), "") - .clicked() - { - host.r#type = None; - } - if ui - .selectable_label( - host.r#type.as_deref() == Some("server"), - "server", - ) - .clicked() - { - host.r#type = Some("server".to_string()); - } - if ui - .selectable_label( - host.r#type.as_deref() == Some("user"), - "user", - ) - .clicked() - { - host.r#type = Some("user".to_string()); - } - }); - - if ui.button("Clear").clicked() { - host.r#type = None; - } - }); - - // Client protocols (optional list) - // For now: comma-separated editor - ui.horizontal(|ui| { - ui.label("Client protocols (optional):"); - - let mut buf = if host.client.is_empty() { - String::new() - } else { - host.client.join(",") - }; - - let resp = ui.add( - egui::TextEdit::singleline(&mut buf) - .hint_text("ex: http,https,ssh"), - ); - - if resp.changed() { - host.client = buf - .split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); - } - - if ui.button("Clear").clicked() { - host.client.clear(); - } - }); - ui.separator(); - ui.horizontal(|ui| { - ui.label("Interfaces:"); - if ui.button("+ Add interface").clicked() { - if let Some(ip) = next_free_ip(&used_ips) { - host.interfaces.push(Interface { - ip_addr: ip, - mac_addr: None, - services: Vec::new(), - }); - } else { - ui.colored_label( - egui::Color32::RED, - "No free IP available in 192.168.0.0/24", - ); - } - } - }); - - if host.interfaces.is_empty() { - ui.label("No interfaces."); - } else { - let mut iface_to_remove: Option = None; - - for (if_idx, iface) in host.interfaces.iter_mut().enumerate() { - let ip_for_header = iface.ip_addr.clone(); - egui::CollapsingHeader::new(format!("Interface — {ip_for_header}")) - .id_salt(("iface", host_idx, if_idx)) - .default_open(if_idx == 0) - .show(ui, |ui| { - // Remove interface (mark for removal) - if ui.button("Remove interface").clicked() { - iface_to_remove = Some(if_idx); - } - - ui.add_space(4.0); - - // Mandatory IP - ui.horizontal(|ui| { - ui.label("IP (mandatory):"); - ui.text_edit_singleline(&mut iface.ip_addr); - }); - - // Optional MAC - edit_optional_string(ui, "MAC (optional):", &mut iface.mac_addr, "00:14:2A:3F:47:D8"); - - // Services: keep read-only for now (next micro-step) - let svc_count = iface.services.len(); - egui::CollapsingHeader::new(format!("Services ({svc_count})")) - .default_open(false) - .show(ui, |ui| { - if ui.button("+ Add service").clicked() { - iface.services.push("http".to_string()); - } - - ui.add_space(4.0); - - let mut svc_to_remove: Option = None; - - for (svc_idx, svc_raw) in iface.services.iter_mut().enumerate() { - let (mut svc_name, mut svc_port) = parse_service(svc_raw); - - egui::Frame::group(ui.style()) - .show(ui, |ui| { - ui.horizontal(|ui| { - // --- Service name combo - egui::ComboBox::from_id_salt((if_idx, svc_idx, "service_name")) - .selected_text(&svc_name) - .show_ui(ui, |ui| { - for (name, default_port) in KNOWN_SERVICES { - if ui.selectable_label(&svc_name == name, *name).clicked() { - svc_name = name.to_string(); - svc_port = *default_port; - } - } - }); - - // --- Port editor (optional) - ui.label("Port:"); - let mut port_val = svc_port.unwrap_or(0); - let resp = ui.add( - egui::DragValue::new(&mut port_val) - .speed(1) - .range(0..=65535), - ); - - if resp.changed() { - svc_port = if port_val == 0 { - None - } else { - Some(port_val) - }; - } - - if ui.button("Clear port").clicked() { - svc_port = None; - } - - // --- Remove service - if ui.button("✕").clicked() { - svc_to_remove = Some(svc_idx); - } - }); - }); - - // Write back to YAML-compatible string - *svc_raw = format_service(&svc_name, svc_port); - - ui.add_space(4.0); - } - - if let Some(idx) = svc_to_remove { - iface.services.remove(idx); - } - }); - }); - - ui.add_space(6.0); - } - - if let Some(idx) = iface_to_remove { - host.interfaces.remove(idx); - } - } - }); - - ui.add_space(6.0); - } - if let Some(idx) = host_to_remove { - model.hosts.remove(idx); - } + }); + + // Port Editor + ui.label("Port:"); + let mut port_val = svc_port.unwrap_or(0); + if ui + .add( + egui::DragValue::new(&mut port_val) + .speed(1) + .range(0..=65535), + ) + .changed() + { + svc_port = if port_val == 0 { None } else { Some(port_val) }; } - ui.separator(); - if ui.button("Export YAML (preview)").clicked() { - match serde_yaml::to_string(&*model) { - Ok(yaml) => { - configuration_file_state.config_file_content = Some(yaml); - configuration_file_state.parse_error = None; - } - Err(e) => { - configuration_file_state.parse_error = Some(e.to_string()); - } - } + if ui.button("Clear port").clicked() { + svc_port = None; } - } - // Config file editor - if configuration_file_state.picked_config_file.is_none() { + if ui.button("Delete service").clicked() { + *remove_request = Some(svc_idx); + } + }); + + *svc_raw = format_service(&svc_name, svc_port); +} + +/// Read-only YAML Preview +fn ui_yaml_preview(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { + if state.picked_config_file.is_none() { ui.label("No configuration file selected"); + return; + } + + if state.config_file_content.is_none() { + ui.label("Loading configuration file..."); + load_config_file_contents(state); } else { - if configuration_file_state.config_file_content.is_none() { - ui.label("Loading configuration file..."); - load_config_file_contents(configuration_file_state); - } else { - let content = configuration_file_state - .config_file_content - .as_ref() - .unwrap(); - let theme = - egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx(), ui.style()); - let language = "yaml"; - - let mut layout_job = egui_extras::syntax_highlighting::highlight( - ui.ctx(), - ui.style(), - &theme, - content, - language, - ); - layout_job.wrap.max_width = ui.available_width(); - egui::ScrollArea::vertical().show(ui, |ui| { - ui.add( - egui::Label::new(layout_job).selectable(true), // Allows the user to copy the code even if they can't edit it - ); - }); + let content = state.config_file_content.as_ref().unwrap(); + let theme = egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx(), ui.style()); + + let mut layout_job = egui_extras::syntax_highlighting::highlight( + ui.ctx(), + ui.style(), + &theme, + content, + "yaml", + ); - // Use this if you want a code editor instead: - - // let mut layouter = |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| { - // let mut layout_job = egui_extras::syntax_highlighting::highlight( - // ui.ctx(), - // ui.style(), - // &theme, - // buf.as_str(), - // language, - // ); - // layout_job.wrap.max_width = wrap_width; - // ui.fonts_mut(|f| f.layout_job(layout_job)) - // }; - // let code = &mut content.clone(); - // egui::ScrollArea::vertical().show(ui, |ui| { - // ui.add( - // egui::TextEdit::multiline(code) - // .font(egui::TextStyle::Monospace) - // .code_editor() - // .desired_rows(10) - // .lock_focus(true) - // .desired_width(f32::INFINITY) - // .layouter(&mut layouter), - // ); - // }); - } + layout_job.wrap.max_width = ui.available_width(); + + egui::ScrollArea::vertical().show(ui, |ui| { + ui.add(egui::Label::new(layout_job).selectable(true)); + }); } } From b1df7f684edbd84eb21e86224089f809872f9274 Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 29 Jan 2026 22:38:53 +0100 Subject: [PATCH 15/77] Feat: add scroll on the hole tab --- .../src/configuration/configuration_tab.rs | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 2c781b43..910756d5 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -81,34 +81,36 @@ pub fn show_configuration_tab_content( _tab_state: &mut ConfigurationTabState, file_state: &mut ConfigurationFileState, ) { - // File Selection - configuration_file_picker(ui, file_state); - ui.separator(); + egui::ScrollArea::vertical().show(ui, |ui| { + // File Selection + configuration_file_picker(ui, file_state); + ui.separator(); - ui_parsing_status(ui, file_state); + ui_parsing_status(ui, file_state); - // Editor (if model is loaded) - if let Some(model) = file_state.config_model.as_mut() { - ui_metadata(ui, model); - ui.separator(); - ui_hosts_section(ui, model); - ui.separator(); + // Editor (if model is loaded) + if let Some(model) = file_state.config_model.as_mut() { + ui_metadata(ui, model); + ui.separator(); + ui_hosts_section(ui, model); + ui.separator(); - // YAML Preview Button - if ui.button("Export YAML (preview)").clicked() { - match serde_yaml::to_string(&*model) { - Ok(yaml) => { - file_state.config_file_content = Some(yaml); - file_state.parse_error = None; - } - Err(e) => { - file_state.parse_error = Some(e.to_string()); + // YAML Preview Button + if ui.button("Export YAML (preview)").clicked() { + match serde_yaml::to_string(&*model) { + Ok(yaml) => { + file_state.config_file_content = Some(yaml); + file_state.parse_error = None; + } + Err(e) => { + file_state.parse_error = Some(e.to_string()); + } } } } - } - ui_yaml_preview(ui, file_state); + ui_yaml_preview(ui, file_state); + }); } /// Status & Feedback @@ -526,8 +528,6 @@ fn ui_yaml_preview(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { layout_job.wrap.max_width = ui.available_width(); - egui::ScrollArea::vertical().show(ui, |ui| { - ui.add(egui::Label::new(layout_job).selectable(true)); - }); + ui.add(egui::Label::new(layout_job).selectable(true)); } } From 2dee0f851e695b3df794bc7e7fe87518f946ae90 Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 29 Jan 2026 22:49:22 +0100 Subject: [PATCH 16/77] Feat: OS dropdown --- .../src/configuration/configuration_tab.rs | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 910756d5..244d659f 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -268,7 +268,7 @@ fn ui_single_host( // Editable Fields edit_optional_string(ui, "Hostname (optional):", &mut host.hostname, "host1"); - edit_optional_string(ui, "OS (optional):", &mut host.os, "Linux"); + ui_host_os_selector(ui, index, &mut host.os); // Usage DragValue ui.horizontal(|ui| { @@ -303,6 +303,42 @@ fn ui_single_host( }); } +/// Dropdown selector for the Operating System +fn ui_host_os_selector(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option) { + ui.horizontal(|ui| { + ui.label("OS (optional):"); + + let selected_text = host_os.as_deref().unwrap_or(""); + + egui::ComboBox::from_id_salt((host_idx, "host_os_combo")) + .selected_text(selected_text) + .show_ui(ui, |ui| { + if ui.selectable_label(host_os.is_none(), "").clicked() { + *host_os = None; + } + + ui.separator(); + + if ui + .selectable_label(host_os.as_deref() == Some("Linux"), "Linux") + .clicked() + { + *host_os = Some("Linux".to_string()); + } + if ui + .selectable_label(host_os.as_deref() == Some("Windows"), "Windows") + .clicked() + { + *host_os = Some("Windows".to_string()); + } + }); + + if host_os.is_some() && ui.button("Clear OS").clicked() { + *host_os = None; + } + }); +} + /// Type of host rendering fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { ui.horizontal(|ui| { From 9ef671bda6d61dd0b81b607a748d90dbb692341a Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 29 Jan 2026 23:04:19 +0100 Subject: [PATCH 17/77] Feat: custom header for interface and host with remove button in it --- .../src/configuration/configuration_tab.rs | 57 ++++++++----------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 244d659f..eb7d02dd 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -250,27 +250,27 @@ fn ui_single_host( let header_type = host.r#type.clone().unwrap_or_else(|| "".to_string()); let if_count = host.interfaces.len(); - egui::CollapsingHeader::new(host_name) - .id_salt(("host", index)) - .default_open(index == 0) - .show(ui, |ui| { - // Header Info & Remove Button + let id = ui.make_persistent_id(("host", index)); + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, index == 0) + .show_header(ui, |ui| { + ui.label(host_name); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Remove host").clicked() { + *remove_request = Some(index); + } + }); + }) + .body(|ui| { ui.horizontal(|ui| { ui.strong(format!( "{header_name} | type: {header_type} | interfaces: {if_count}" )); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Remove host").clicked() { - *remove_request = Some(index); - } - }); }); - // Editable Fields - edit_optional_string(ui, "Hostname (optional):", &mut host.hostname, "host1"); ui_host_os_selector(ui, index, &mut host.os); + edit_optional_string(ui, "Hostname (optional):", &mut host.hostname, "host1"); - // Usage DragValue ui.horizontal(|ui| { ui.label("Usage (optional):"); let mut usage_val = host.usage.unwrap_or(1.0); @@ -278,7 +278,6 @@ fn ui_single_host( .add(egui::DragValue::new(&mut usage_val).speed(0.1)) .changed() { - // Avoid saving 1.0 if it's the default host.usage = if (usage_val - 1.0).abs() < f32::EPSILON { None } else { @@ -290,15 +289,9 @@ fn ui_single_host( } }); - // Type Selection ui_host_type_selector(ui, index, host); - - // Client Protocols ui_host_client_protocols(ui, host); - ui.separator(); - - // Interfaces ui_interfaces_section(ui, index, host, used_ips); }); } @@ -431,17 +424,20 @@ fn ui_interfaces_section( for (if_idx, iface) in host.interfaces.iter_mut().enumerate() { let ip_label = iface.ip_addr.clone(); + let id = ui.make_persistent_id(("iface", host_idx, if_idx)); - egui::CollapsingHeader::new(format!("Interface — {ip_label}")) - .id_salt(("iface", host_idx, if_idx)) - .default_open(if_idx == 0) - .show(ui, |ui| { - if ui.button("Remove interface").clicked() { - iface_to_remove = Some(if_idx); - } - ui.add_space(4.0); + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, if_idx == 0) + .show_header(ui, |ui| { + ui.label(format!("Interface — {ip_label}")); - // IP & MAC + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Remove interface").clicked() { + iface_to_remove = Some(if_idx); + } + }); + }) + .body(|ui| { + ui.add_space(4.0); ui.horizontal(|ui| { ui.label("IP (mandatory):"); ui.text_edit_singleline(&mut iface.ip_addr); @@ -452,11 +448,8 @@ fn ui_interfaces_section( &mut iface.mac_addr, "00:14:2A:3F:47:D8", ); - - // Services ui_services_section(ui, if_idx, iface); }); - ui.add_space(6.0); } From d63d2c8f0b0161dd18f1dc2304321c75f089a8e2 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:10:49 +0100 Subject: [PATCH 18/77] feat(gui): remove output file name field and use default every time --- crates/fosr-gui/src/generation/generation_tab.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 5d9824c0..be7548e9 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -100,20 +100,6 @@ pub fn show_generation_tab_content( ui.separator(); - // --- Output file name --- - // This is only required for WASM. On desktop, a file dialog is opened instead. - #[cfg(target_arch = "wasm32")] - { - ui.horizontal(|ui| { - ui.label("Output file name:"); - egui::TextEdit::singleline(&mut state.output_file_name) - .desired_width(180.0) - .ui(ui); - }); - - ui.separator(); - } - ui.horizontal(|ui| { ui.label("Duration"); From 656d46f47928098ae16b29650a197323fa5a9974 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:24:21 +0100 Subject: [PATCH 19/77] fix(gui): use gloo_timers and web_time for visualization feature on WASM --- Cargo.lock | 13 +++++++++++++ crates/fosr-gui/Cargo.toml | 1 + .../src/visualization/visualization_stream.rs | 2 +- .../fosr-gui/src/visualization/visualization_tab.rs | 3 ++- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d1aeb8d..c0178c2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1805,6 +1805,7 @@ dependencies = [ "egui_graphs", "env_logger", "fosr-lib", + "gloo-timers", "humantime", "indicatif", "log", @@ -1986,6 +1987,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "glow" version = "0.16.0" diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index 82c0ce4a..c8b71232 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -41,6 +41,7 @@ petgraph = "0.8.3" egui_graphs = "0.29.0" [target.'cfg(target_arch = "wasm32")'.dependencies] +gloo-timers = { version = "0.3", features = ["futures"] } wasm-bindgen-futures = "0.4.55" web-sys = "0.3.82" diff --git a/crates/fosr-gui/src/visualization/visualization_stream.rs b/crates/fosr-gui/src/visualization/visualization_stream.rs index 8da4d6c5..4f7ce49c 100644 --- a/crates/fosr-gui/src/visualization/visualization_stream.rs +++ b/crates/fosr-gui/src/visualization/visualization_stream.rs @@ -379,7 +379,7 @@ impl FlowStreamer { } } - web_time::sleep(check_interval).await; + gloo_timers::future::TimeoutFuture::new(check_interval.as_millis() as u32).await; } } } diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 055b8eac..b82749c0 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -16,7 +16,8 @@ use std::fmt; use std::net::Ipv4Addr; use std::sync::mpsc::Receiver; use std::sync::{Arc, RwLock}; -use std::time::{Duration, Instant}; +use std::time::Duration; +use web_time::Instant; /// Special IP address representing "The Internet" node pub const INTERNET_IP: Ipv4Addr = Ipv4Addr::new(0, 0, 0, 1); From cb29a54b883ab47fd630177474c89d5b0dc538ba Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:30:38 +0100 Subject: [PATCH 20/77] feat(gui): align taint and order checkboxes horizontally --- crates/fosr-gui/src/generation/generation_tab.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index be7548e9..84dd60a9 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -77,7 +77,7 @@ impl Default for GenerationTabState { seed_validation: FieldValidation::default(), timezone_validation: FieldValidation::default(), // Parameters - order_pcap: false, + order_pcap: true, taint: false, duration_str: default_duration, duration_slider_value, @@ -218,9 +218,10 @@ pub fn show_generation_tab_content( ui.add_space(15.0); - ui.checkbox(&mut state.taint, "Taint the packets"); - - ui.checkbox(&mut state.order_pcap, "Order temporally"); + ui.horizontal(|ui| { + ui.checkbox(&mut state.taint, "Taint the packets"); + ui.checkbox(&mut state.order_pcap, "Order temporally"); + }); ui.add_space(20.0); From a78b5cbe715d62eeb80a40d8f758b2b1363bc7ff Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:32:03 +0100 Subject: [PATCH 21/77] feat(gui): only display seed field if a checkbox is checked --- .../fosr-gui/src/generation/generation_tab.rs | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 84dd60a9..73d80daa 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -52,6 +52,7 @@ pub struct GenerationTabState { pub taint: bool, pub duration_str: String, pub duration_slider_value: f32, + pub use_seed: bool, pub seed_input: String, pub timezone_input: String, pub use_local_timezone: bool, @@ -81,6 +82,7 @@ impl Default for GenerationTabState { taint: false, duration_str: default_duration, duration_slider_value, + use_seed: false, seed_input: String::new(), timezone_input: String::new(), use_local_timezone: true, @@ -193,27 +195,30 @@ pub fn show_generation_tab_content( ui.add_space(10.0); ui.horizontal(|ui| { - ui.label("Seed (optional)"); - - let response = ui.add( - egui::TextEdit::singleline(&mut state.seed_input) - .hint_text("leave empty for random") - .desired_width(160.0), - ); - - if response.changed() { - // Convert String to Option - match validate_optional_u64(&state.seed_input) { - Ok(_) => { - state.seed_validation.set_ok(); - } - Err(msg) => { - state.seed_validation.set_err(msg); + ui.checkbox(&mut state.use_seed, "Seed"); + + if state.use_seed { + let response = ui.add( + egui::TextEdit::singleline(&mut state.seed_input) + .hint_text("enter a seed value") + .desired_width(160.0), + ); + + if response.changed() { + match validate_optional_u64(&state.seed_input) { + Ok(_) => { + state.seed_validation.set_ok(); + } + Err(msg) => { + state.seed_validation.set_err(msg); + } } } - } - show_field_error(ui, &state.seed_validation); + show_field_error(ui, &state.seed_validation); + } else { + state.seed_validation.set_ok(); + } }); ui.add_space(15.0); @@ -251,7 +256,11 @@ pub fn show_generation_tab_content( let (pcap_sender, pcap_receiver) = channel(); state.pcap_receiver = Some(pcap_receiver); - let seed = state.seed_input.parse::().ok(); + let seed = if state.use_seed { + state.seed_input.parse::().ok() + } else { + None + }; let order_pcap = state.order_pcap; let start_time = Some(format!( "{}T{}Z", From d23716d1c86473e5376654177964dc4f6084535c Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:22:39 +0100 Subject: [PATCH 22/77] feat(gui): use current time by default (checkbox) --- .../fosr-gui/src/generation/generation_tab.rs | 98 ++++++++++--------- 1 file changed, 54 insertions(+), 44 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 73d80daa..1354bc48 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -55,6 +55,7 @@ pub struct GenerationTabState { pub use_seed: bool, pub seed_input: String, pub timezone_input: String, + pub use_current_time: bool, pub use_local_timezone: bool, pub start_date: NaiveDate, pub start_hour: String, @@ -85,6 +86,7 @@ impl Default for GenerationTabState { use_seed: false, seed_input: String::new(), timezone_input: String::new(), + use_current_time: true, use_local_timezone: true, start_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), start_hour: "00:00:00".to_string(), @@ -143,54 +145,58 @@ pub fn show_generation_tab_content( ui.add_space(10.0); - ui.horizontal(|ui| { - ui.label("Start time"); + ui.checkbox(&mut state.use_current_time, "Use current time for start time"); - ui.add(DatePickerButton::new(&mut state.start_date)); + if !state.use_current_time { + ui.horizontal(|ui| { + ui.label("Start time"); - let response = egui::TextEdit::singleline(&mut state.start_hour) - .hint_text("HH:MM") - .desired_width(50.0) - .ui(ui); + ui.add(DatePickerButton::new(&mut state.start_date)); - if response.changed() { - match validate_start_hour(&state.start_hour) { - Ok(()) => state.start_hour_validation.set_ok(), - Err(msg) => state.start_hour_validation.set_err(msg), + let response = egui::TextEdit::singleline(&mut state.start_hour) + .hint_text("HH:MM") + .desired_width(50.0) + .ui(ui); + + if response.changed() { + match validate_start_hour(&state.start_hour) { + Ok(()) => state.start_hour_validation.set_ok(), + Err(msg) => state.start_hour_validation.set_err(msg), + } } - } - show_field_error(ui, &state.start_hour_validation); - }); + show_field_error(ui, &state.start_hour_validation); + }); - ui.add_space(10.0); + ui.add_space(10.0); - ui.horizontal(|ui| { - if ui - .checkbox(&mut state.use_local_timezone, "Use local timezone") - .clicked() - { - if state.use_local_timezone { - // Reset the timezone - state.timezone_input = String::new(); - state.timezone_validation.set_ok(); - } else { - // Set the default timezone - state.timezone_input = Tz::CET.to_string(); + ui.horizontal(|ui| { + if ui + .checkbox(&mut state.use_local_timezone, "Use local timezone") + .clicked() + { + if state.use_local_timezone { + state.timezone_input = String::new(); + state.timezone_validation.set_ok(); + } else { + state.timezone_input = Tz::CET.to_string(); + } } - } - if !state.use_local_timezone { - // Display the dropdown button - timezone_picker(ui, state); - - let result = validate_timezone(&state.timezone_input); - if result.is_ok() { - state.timezone_validation.set_ok(); - } else { - state.timezone_validation.set_err(result.err().unwrap()); + if !state.use_local_timezone { + timezone_picker(ui, state); + + let result = validate_timezone(&state.timezone_input); + if result.is_ok() { + state.timezone_validation.set_ok(); + } else { + state.timezone_validation.set_err(result.err().unwrap()); + } } - } - }); + }); + } else { + state.start_hour_validation.set_ok(); + state.timezone_validation.set_ok(); + } ui.add_space(10.0); @@ -262,11 +268,15 @@ pub fn show_generation_tab_content( None }; let order_pcap = state.order_pcap; - let start_time = Some(format!( - "{}T{}Z", - state.start_date.format("%Y-%m-%d"), - state.start_hour - )); + let start_time = if state.use_current_time { + None + } else { + Some(format!( + "{}T{}Z", + state.start_date.format("%Y-%m-%d"), + state.start_hour + )) + }; let duration = state.duration_str.clone(); let taint = state.taint; let timezone = if state.timezone_input.is_empty() { From 0628fcf0b68560cf9a0f088cf29b9b46a076bfba Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:24:45 +0100 Subject: [PATCH 23/77] feat(gui): add local timepicker module based on egui_timepicker with theme and UX fixes --- Cargo.lock | 67 +--- crates/fosr-gui/Cargo.toml | 3 +- .../fosr-gui/src/generation/generation_tab.rs | 30 +- .../src/generation/generation_validation.rs | 31 -- crates/fosr-gui/src/lib.rs | 1 + crates/fosr-gui/src/main.rs | 1 + crates/fosr-gui/src/timepicker/button.rs | 134 +++++++ crates/fosr-gui/src/timepicker/mod.rs | 13 + crates/fosr-gui/src/timepicker/popup.rs | 366 ++++++++++++++++++ 9 files changed, 533 insertions(+), 113 deletions(-) create mode 100644 crates/fosr-gui/src/timepicker/button.rs create mode 100644 crates/fosr-gui/src/timepicker/mod.rs create mode 100644 crates/fosr-gui/src/timepicker/popup.rs diff --git a/Cargo.lock b/Cargo.lock index c0178c2f..c922bb17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,28 +286,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "ashpd" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.2", - "raw-window-handle", - "serde", - "serde_repr", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - [[package]] name = "assert_cmd" version = "2.1.1" @@ -367,17 +345,6 @@ dependencies = [ "slab", ] -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - [[package]] name = "async-io" version = "2.6.0" @@ -407,17 +374,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite", -] - [[package]] name = "async-process" version = "2.5.0" @@ -1811,6 +1767,7 @@ dependencies = [ "log", "petgraph", "rfd", + "serde", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -1904,10 +1861,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", - "futures-io", "futures-macro", "futures-task", - "memchr", "pin-project-lite", "pin-utils", "slab", @@ -3825,26 +3780,29 @@ dependencies = [ [[package]] name = "rfd" -version = "0.16.0" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" dependencies = [ - "ashpd", "block2 0.6.2", "dispatch2", "js-sys", + "libc", "log", "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", + "percent-encoding", "pollster", "raw-window-handle", - "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", "web-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4724,12 +4682,6 @@ dependencies = [ "serde", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "usvg" version = "0.45.1" @@ -6130,7 +6082,6 @@ dependencies = [ "endi", "enumflags2", "serde", - "url", "winnow 0.7.14", "zvariant_derive", "zvariant_utils", diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index c8b71232..da1bf62c 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -33,8 +33,9 @@ egui_extras = { version = "0.33.3", features = ["chrono", "all_loaders", "syntec log = "0.4.28" wasm-bindgen = "0.2.106" fosr-lib = { version = "1.0.0-dev", path = "../fosr-lib", features = ["wasm_js"] } -rfd = "0.16.0" +rfd = "0.17.2" humantime = "2.3.0" +serde = { version = "1", features = ["derive"] } indicatif = "0.18.3" web-time = "1.1.0" petgraph = "0.8.3" diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 1354bc48..e4490e76 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -5,20 +5,21 @@ use super::generation_utils::{ }; use super::generation_validation::{ FieldValidation, first_invalid_param, validate_duration, validate_optional_u64, - validate_start_hour, validate_timezone, + validate_timezone, }; use crate::shared::configuration_file::{ConfigurationFileState, configuration_file_picker}; #[cfg(not(target_arch = "wasm32"))] use crate::shared::file_io::{read_file_desktop, save_file_desktop}; #[cfg(target_arch = "wasm32")] use crate::shared::file_io::{read_file_wasm, save_file_wasm}; -use chrono::NaiveDate; +use chrono::{NaiveDate, NaiveTime}; use chrono_tz::Tz; use eframe::egui; use eframe::egui::{SliderClamping, Widget}; use egui_extras::DatePickerButton; use std::sync::mpsc::{Receiver, channel}; use std::time::Duration; +use crate::timepicker::TimePickerButton; // Time interval for the slider. pub const DURATION_MIN: Duration = Duration::from_secs(60); // 1 min @@ -44,7 +45,6 @@ pub struct GenerationTabState { pub status: UiStatus, // Validation states pub duration_validation: FieldValidation, - pub start_hour_validation: FieldValidation, pub seed_validation: FieldValidation, pub timezone_validation: FieldValidation, // Parameters @@ -58,7 +58,7 @@ pub struct GenerationTabState { pub use_current_time: bool, pub use_local_timezone: bool, pub start_date: NaiveDate, - pub start_hour: String, + pub start_hour: NaiveTime, pub output_file_name: String, } @@ -75,7 +75,6 @@ impl Default for GenerationTabState { status: UiStatus::Idle, // Validation states duration_validation: FieldValidation::default(), - start_hour_validation: FieldValidation::default(), seed_validation: FieldValidation::default(), timezone_validation: FieldValidation::default(), // Parameters @@ -89,7 +88,7 @@ impl Default for GenerationTabState { use_current_time: true, use_local_timezone: true, start_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), - start_hour: "00:00:00".to_string(), + start_hour: NaiveTime::from_hms_opt(0, 0, 0).unwrap(), output_file_name: "output.pcap".to_string(), } } @@ -150,22 +149,8 @@ pub fn show_generation_tab_content( if !state.use_current_time { ui.horizontal(|ui| { ui.label("Start time"); - ui.add(DatePickerButton::new(&mut state.start_date)); - - let response = egui::TextEdit::singleline(&mut state.start_hour) - .hint_text("HH:MM") - .desired_width(50.0) - .ui(ui); - - if response.changed() { - match validate_start_hour(&state.start_hour) { - Ok(()) => state.start_hour_validation.set_ok(), - Err(msg) => state.start_hour_validation.set_err(msg), - } - } - - show_field_error(ui, &state.start_hour_validation); + ui.add(TimePickerButton::new(&mut state.start_hour).show_seconds(true).use_dragvalue(true)); }); ui.add_space(10.0); @@ -194,7 +179,6 @@ pub fn show_generation_tab_content( } }); } else { - state.start_hour_validation.set_ok(); state.timezone_validation.set_ok(); } @@ -274,7 +258,7 @@ pub fn show_generation_tab_content( Some(format!( "{}T{}Z", state.start_date.format("%Y-%m-%d"), - state.start_hour + state.start_hour.format("%H:%M:%S") )) }; let duration = state.duration_str.clone(); diff --git a/crates/fosr-gui/src/generation/generation_validation.rs b/crates/fosr-gui/src/generation/generation_validation.rs index f5ad32e5..3d7d4f3b 100644 --- a/crates/fosr-gui/src/generation/generation_validation.rs +++ b/crates/fosr-gui/src/generation/generation_validation.rs @@ -19,7 +19,6 @@ impl FieldValidation { // Spec expected for each parameter const SPEC_DURATION: &str = "a duration between 1 min and 3 days (e.g. 30m, 1h, 2d)"; -const SPEC_START_HOUR: &str = "an hour in HH:MM format"; const SPEC_SEED: &str = "an unsigned integer (u64) or empty for random"; const SPEC_TIMEZONE: &str = "a valid timezone"; @@ -30,9 +29,6 @@ pub fn first_invalid_param( if let Some(err) = &state.duration_validation.error { return Some(("Duration", SPEC_DURATION, err.clone())); } - if let Some(err) = &state.start_hour_validation.error { - return Some(("Start hour", SPEC_START_HOUR, err.clone())); - } if let Some(err) = &state.seed_validation.error { return Some(("Seed", SPEC_SEED, err.clone())); } @@ -55,33 +51,6 @@ pub fn validate_duration(duration_str: &str) -> Result { Ok(d) } -pub fn validate_start_hour(input: &str) -> Result<(), String> { - let s = input.trim(); - if s.is_empty() { - return Err("Invalid value".to_string()); - } - - let parts: Vec<&str> = s.split(':').collect(); - if parts.len() != 3 || parts[0].len() != 2 || parts[1].len() != 2 || parts[2].len() != 2 { - return Err("Invalid value".to_string()); - } - - let hour = parts[0] - .parse::() - .map_err(|_| "Invalid value".to_string())?; - let minute = parts[1] - .parse::() - .map_err(|_| "Invalid value".to_string())?; - let second = parts[2] - .parse::() - .map_err(|_| "Invalid value".to_string())?; - - if hour > 23 || minute > 59 || second > 59 { - return Err("Invalid value".to_string()); - } - - Ok(()) -} pub fn validate_optional_u64(input: &str) -> Result, String> { let s = input.trim(); diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index f274a927..d0366827 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -6,6 +6,7 @@ mod generation; #[cfg(not(target_arch = "wasm32"))] mod injection_tab; mod shared; +mod timepicker; mod visualization; use app::FosrApp; diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index c713c929..a1614712 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -5,6 +5,7 @@ mod generation; #[cfg(not(target_arch = "wasm32"))] mod injection_tab; mod shared; +mod timepicker; mod visualization; // Desktop: native compilation diff --git a/crates/fosr-gui/src/timepicker/button.rs b/crates/fosr-gui/src/timepicker/button.rs new file mode 100644 index 00000000..960273eb --- /dev/null +++ b/crates/fosr-gui/src/timepicker/button.rs @@ -0,0 +1,134 @@ +use chrono::NaiveTime; +use eframe::egui; +use egui::{Area, Button, Frame, InnerResponse, Key, Order, RichText, Widget}; + +use super::popup::TimePickerPopup; + +#[derive(Default, Clone, serde::Deserialize, serde::Serialize)] +pub(crate) struct TimePickerButtonState { + pub picker_visible: bool, +} + +pub struct TimePickerButton<'a> { + selection: &'a mut NaiveTime, + id_salt: Option<&'a str>, + show_icon: bool, + format: String, + show_clockface: bool, + use_12_hour_clock: bool, + show_seconds: bool, + use_dragvalue: bool, +} + +impl<'a> TimePickerButton<'a> { + pub fn new(selection: &'a mut NaiveTime) -> Self { + Self { + selection, + id_salt: None, + show_icon: true, + format: "%H:%M".to_string(), + show_clockface: true, + use_12_hour_clock: false, + show_seconds: false, + use_dragvalue: false, + } + } + + pub fn show_seconds(mut self, show_seconds: bool) -> Self { + self.show_seconds = show_seconds; + self + } + + pub fn use_dragvalue(mut self, use_dragvalue: bool) -> Self { + self.use_dragvalue = use_dragvalue; + self + } +} + +impl Widget for TimePickerButton<'_> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + let id = ui.make_persistent_id(self.id_salt); + let mut button_state = ui + .data_mut(|data| data.get_persisted::(id)) + .unwrap_or_default(); + + let mut text = if self.show_icon { + RichText::new(format!("{} 🕒", self.selection.format(&self.format))) + } else { + RichText::new(self.selection.format(&self.format).to_string()) + }; + + let visuals = ui.visuals().widgets.open; + if button_state.picker_visible { + text = text.color(visuals.text_color()); + } + + let mut button = Button::new(text); + if button_state.picker_visible { + button = button.fill(visuals.weak_bg_fill).stroke(visuals.bg_stroke); + } + + let mut button_response = ui.add(button); + if button_response.clicked() { + button_state.picker_visible = true; + ui.data_mut(|data| data.insert_persisted(id, button_state.clone())); + } + + if button_state.picker_visible { + let width = 250.; + let mut pos = button_response.rect.left_bottom(); + let width_with_padding = width + + ui.style().spacing.item_spacing.x + + ui.style().spacing.window_margin.leftf() + + ui.style().spacing.window_margin.rightf(); + + if pos.x + width_with_padding > ui.clip_rect().right() { + pos.x = button_response.rect.right() - width_with_padding; + } + + pos.x = pos.x.max(ui.style().spacing.window_margin.leftf()); + + let InnerResponse { + inner: saved, + response: area_response, + } = Area::new(ui.make_persistent_id(self.id_salt)) + .kind(egui::UiKind::Picker) + .order(Order::Foreground) + .fixed_pos(pos) + .show(ui.ctx(), |ui| { + let frame = Frame::popup(ui.style()); + frame + .show(ui, |ui| { + ui.set_min_width(width); + ui.set_max_width(width); + + TimePickerPopup { + selection: self.selection, + button_id: id, + show_clockface: self.show_clockface, + show_seconds: self.show_seconds, + use_12_hour_clock: self.use_12_hour_clock, + use_dragvalue: self.use_dragvalue, + } + .draw(ui) + }) + .inner + }); + + if saved { + button_response.mark_changed(); + } + + let any_popup_open = ui.ctx().is_popup_open(); + if !button_response.clicked() + && !any_popup_open + && (ui.input(|i| i.key_pressed(Key::Escape)) || area_response.clicked_elsewhere()) + { + button_state.picker_visible = false; + ui.data_mut(|data| data.insert_persisted(id, button_state)); + } + } + + button_response + } +} diff --git a/crates/fosr-gui/src/timepicker/mod.rs b/crates/fosr-gui/src/timepicker/mod.rs new file mode 100644 index 00000000..8a0e47bd --- /dev/null +++ b/crates/fosr-gui/src/timepicker/mod.rs @@ -0,0 +1,13 @@ +// This module is based on egui_timepicker v0.1.0 (https://crates.io/crates/egui_timepicker). +// +// Changes from the original: +// - Use theme-aware colors instead of hardcoded Color32::WHITE (supports light mode) +// - Auto-advance clock face from Hour → Minute → Second on click/drag release +// - Highlight the active drag value input with a selection-colored border +// - Switch clock face to match whichever drag value is being interacted with +// - Remove unused builder methods (id_salt, show_icon, format, show_clockface, use_12_hour_clock) + +mod button; +mod popup; + +pub use button::TimePickerButton; diff --git a/crates/fosr-gui/src/timepicker/popup.rs b/crates/fosr-gui/src/timepicker/popup.rs new file mode 100644 index 00000000..b475263a --- /dev/null +++ b/crates/fosr-gui/src/timepicker/popup.rs @@ -0,0 +1,366 @@ +use std::fmt::Display; + +use chrono::{NaiveTime, Timelike}; +use eframe::egui; +use egui::{ + Align2, Color32, DragValue, FontId, Id, Layout, Painter, Pos2, Response, RichText, Sense, Ui, + Vec2, +}; + +use super::button::TimePickerButtonState; + +#[derive(Default, Clone, PartialEq, serde::Deserialize, serde::Serialize)] +enum TimeFrame { + #[default] + Hour, + Minute, + Second, +} + +#[derive(Default, Clone, serde::Deserialize, serde::Serialize)] +enum AmPm { + #[default] + Am, + Pm, +} + +impl Display for AmPm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AmPm::Am => write!(f, "AM"), + AmPm::Pm => write!(f, "PM"), + } + } +} + +#[derive(Default, Clone, serde::Deserialize, serde::Serialize)] +pub(crate) struct TimePickerPopupState { + hour: u32, + minute: u32, + second: u32, + setup: bool, + timeframe: TimeFrame, + am_pm: AmPm, +} + +pub(crate) struct TimePickerPopup<'a> { + pub selection: &'a mut NaiveTime, + pub button_id: Id, + pub show_clockface: bool, + pub use_12_hour_clock: bool, + pub show_seconds: bool, + pub use_dragvalue: bool, +} + +impl TimePickerPopup<'_> { + pub fn draw(&mut self, ui: &mut Ui) -> bool { + let id = ui.make_persistent_id("time_picker"); + let mut popup_state = ui + .data_mut(|data| data.get_persisted::(id)) + .unwrap_or_default(); + if !popup_state.setup { + popup_state.hour = self.selection.hour(); + popup_state.minute = self.selection.minute(); + popup_state.second = self.selection.second(); + popup_state.setup = true; + ui.data_mut(|data| data.insert_persisted(id, popup_state.clone())); + } + + let (mut close, mut saved) = (false, false); + + ui.horizontal(|ui| { + let mut hour_rect = None; + let mut minute_rect = None; + let mut second_rect = None; + + if self.use_dragvalue { + let range = if self.use_12_hour_clock { + 0..=11 + } else { + 0..=23 + }; + + let r = ui.add(DragValue::new(&mut popup_state.hour).range(range)); + hour_rect = Some(r.rect); + if r.clicked() || r.changed() { + popup_state.timeframe = TimeFrame::Hour; + } + } else if ui + .button(RichText::new(popup_state.hour.to_string()).size(18.)) + .clicked() + { + popup_state.timeframe = TimeFrame::Hour; + } + + ui.label(RichText::new("h :").monospace()); + + if self.use_dragvalue { + let r = ui.add(DragValue::new(&mut popup_state.minute).range(0..=59)); + minute_rect = Some(r.rect); + if r.clicked() || r.changed() { + popup_state.timeframe = TimeFrame::Minute; + } + } else if ui + .button(RichText::new(popup_state.minute.to_string()).size(18.)) + .clicked() + { + popup_state.timeframe = TimeFrame::Minute; + } + + if self.show_seconds { + ui.label(RichText::new("m :").monospace()); + + if self.use_dragvalue { + let r = ui.add(DragValue::new(&mut popup_state.second).range(0..=59)); + second_rect = Some(r.rect); + if r.clicked() || r.changed() { + popup_state.timeframe = TimeFrame::Second; + } + } else if ui + .button(RichText::new(popup_state.second.to_string()).size(18.)) + .clicked() + { + popup_state.timeframe = TimeFrame::Second; + } + + ui.label(RichText::new("s").monospace()); + } else { + ui.label(RichText::new("m").monospace()); + } + + if self.use_12_hour_clock { + if ui + .button(RichText::new(popup_state.am_pm.to_string()).size(18.)) + .clicked() + { + popup_state.am_pm = match popup_state.am_pm { + AmPm::Am => AmPm::Pm, + AmPm::Pm => AmPm::Am, + }; + } + } + + // Highlight the active drag value with a border + let active_rect = match popup_state.timeframe { + TimeFrame::Hour => hour_rect, + TimeFrame::Minute => minute_rect, + TimeFrame::Second => second_rect, + }; + if let Some(rect) = active_rect { + let stroke = egui::Stroke::new(1.5, ui.visuals().selection.stroke.color); + ui.painter().rect_stroke(rect.expand(1.0), 2.0, stroke, egui::StrokeKind::Outside); + } + }); + + let text_color = ui.visuals().text_color(); + let highlight_color = ui.visuals().selection.bg_fill.linear_multiply(0.5); + + let (rect, response) = + ui.allocate_exact_size(Vec2::new(250., 250.), Sense::click_and_drag()); + let painter = ui.painter_at(rect); + + let center = rect.center(); + let radius = rect.width() / 2.0; + let r_outer = radius * 0.8; + let r_inner = radius * 0.55; + + let time = match popup_state.timeframe { + TimeFrame::Hour => &mut popup_state.hour, + TimeFrame::Minute => &mut popup_state.minute, + TimeFrame::Second => &mut popup_state.second, + }; + + if self.show_clockface { + draw_timepicker( + r_outer, + r_inner, + center, + &painter, + &response, + &popup_state.timeframe, + time, + self.use_12_hour_clock, + text_color, + highlight_color, + ); + + // Auto-advance to next timeframe when the user releases the pointer on the clock + if response.drag_stopped() || response.clicked() { + popup_state.timeframe = match popup_state.timeframe { + TimeFrame::Hour => TimeFrame::Minute, + TimeFrame::Minute if self.show_seconds => TimeFrame::Second, + _ => popup_state.timeframe.clone(), + }; + } + } + + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Save").clicked() { + let mut hour = popup_state.hour; + if self.use_12_hour_clock { + match popup_state.am_pm { + AmPm::Am => {} + AmPm::Pm => hour += 12, + } + } + + *self.selection = + NaiveTime::from_hms_opt(hour, popup_state.minute, popup_state.second) + .expect("Could not create NaiveTime"); + saved = true; + close = true; + } + + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + + ui.data_mut(|data| { + data.insert_persisted(id, popup_state.clone()); + }); + + if close { + popup_state.setup = false; + ui.data_mut(|data| { + data.insert_persisted(id, popup_state); + data.get_persisted_mut_or_default::(self.button_id) + .picker_visible = false; + }); + } + + saved && close + } +} + +fn draw_timepicker( + radius_outer: f32, + radius_inner: f32, + center: Pos2, + painter: &Painter, + response: &Response, + timeframe: &TimeFrame, + time: &mut u32, + use_12_hour_format: bool, + text_color: Color32, + highlight_color: Color32, +) { + for i in 0..12 { + let angle = (-90. + 30. * i as f32).to_radians(); + let x_outer = center.x + radius_outer * angle.cos(); + let y_outer = center.y + radius_outer * angle.sin(); + + let x_inner = center.x + radius_inner * angle.cos(); + let y_inner = center.y + radius_inner * angle.sin(); + + match *timeframe { + TimeFrame::Hour => { + painter.text( + Pos2::new(x_outer, y_outer), + Align2::CENTER_CENTER, + i.to_string(), + FontId::monospace(12.0), + text_color, + ); + + if !use_12_hour_format { + painter.text( + Pos2::new(x_inner, y_inner), + Align2::CENTER_CENTER, + (i + 12).to_string(), + FontId::monospace(12.0), + text_color, + ); + } + + if *time == i { + painter.circle_filled( + Pos2::new(x_outer, y_outer), + 15., + highlight_color, + ); + } + + if *time == (i + 12) { + painter.circle_filled( + Pos2::new(x_inner, y_inner), + 15., + highlight_color, + ); + } + } + TimeFrame::Minute | TimeFrame::Second => { + painter.text( + Pos2::new(x_outer, y_outer), + Align2::CENTER_CENTER, + (i * 5).to_string(), + FontId::monospace(12.0), + text_color, + ); + + if *time % 5 == 0 && *time == i * 5 { + painter.circle_filled( + Pos2::new(x_outer, y_outer), + 15., + highlight_color, + ); + } + } + } + } + + if let Some(pos) = response.interact_pointer_pos() { + let angle = (pos - center).angle(); + let distance = (pos - center).length(); + + match *timeframe { + TimeFrame::Hour => { + let mut h = (angle.to_degrees() + 90. + 15.).rem_euclid(360.) as u32 / 30. as u32; + if distance < radius_outer - 15. && !use_12_hour_format { + h += 12; + } + *time = h; + } + TimeFrame::Minute | TimeFrame::Second => { + let mut t = (angle.to_degrees() + 90. + 3.).rem_euclid(360.) as u32 / 6. as u32; + if t == 60 { + t = 0; + } + *time = t; + } + } + } + + match *timeframe { + TimeFrame::Hour => { + let angle = (*time as f32 * 30. - 90.).to_radians(); + if *time < 12 { + let end = center + Vec2::angled(angle) * (radius_outer - 15.); + painter.line_segment([center, end], (2., text_color)); + } else { + let end = center + Vec2::angled(angle) * (radius_inner - 15.); + painter.line_segment([center, end], (2., text_color)); + } + } + TimeFrame::Minute | TimeFrame::Second => { + let angle = (*time as f32 * 6. - 90.).to_radians(); + + let radius = if *time % 5 == 0 { + radius_outer - 15. + } else { + radius_outer + }; + + let end = center + Vec2::angled(angle) * radius; + painter.line_segment([center, end], (2., text_color)); + + if *time % 5 != 0 { + painter.circle_filled(end, 4., text_color); + } + } + } + + painter.circle_filled(center, 4., text_color); +} From 59c0fadcb57b8dc1fe0c1f9c0a83fec775e186fb Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:31:16 +0100 Subject: [PATCH 24/77] feat(gui): display generation throughput after completion --- .../src/generation/generation_core.rs | 13 +++++++----- .../fosr-gui/src/generation/generation_tab.rs | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_core.rs b/crates/fosr-gui/src/generation/generation_core.rs index 702feb36..0425d627 100644 --- a/crates/fosr-gui/src/generation/generation_core.rs +++ b/crates/fosr-gui/src/generation/generation_core.rs @@ -18,6 +18,7 @@ pub fn generate( timezone: Option, progress_sender: Option>, pcap_sender: Option>>, + throughput_sender: Option>, ) { // Create a closure to send progress updates let send_progress = |progress: f32| { @@ -104,7 +105,7 @@ pub fn generate( let s2 = TadamGenerator::new(automata_library); let s3 = stage3::Stage3::new(taint); log::info!("Run single thread"); - run_single_thread(order_pcap, s0, s1, s2, s3, send_progress, send_pcap); + run_single_thread(order_pcap, s0, s1, s2, s3, send_progress, send_pcap, throughput_sender); } fn run_single_thread( @@ -115,6 +116,7 @@ fn run_single_thread( s3: stage3::Stage3, send_progress: impl Fn(f32), send_pcap: impl Fn(Vec), + throughput_sender: Option>, ) { let start = Instant::now(); @@ -148,10 +150,11 @@ fn run_single_thread( let gen_duration = start.elapsed().as_secs_f64(); let total_size = all_packets.iter().map(|p| p.data.len()).sum::() as u64; - log::info!( - "Generation throughput: {}/s", - HumanBytes(((total_size as f64) / gen_duration) as u64) - ); + let throughput_str = format!("{}/s", HumanBytes(((total_size as f64) / gen_duration) as u64)); + log::info!("Generation throughput: {throughput_str}"); + if let Some(sender) = &throughput_sender { + let _ = sender.send(throughput_str); + } if order_pcap { log::info!("Sorting the packets"); diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index e4490e76..f8fcd024 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -42,6 +42,8 @@ pub struct GenerationTabState { pub progress_receiver: Option>, pub pcap_bytes: Option>, pub pcap_receiver: Option>>, + pub throughput_receiver: Option>, + pub throughput: Option, pub status: UiStatus, // Validation states pub duration_validation: FieldValidation, @@ -72,6 +74,8 @@ impl Default for GenerationTabState { progress_receiver: None, pcap_bytes: None, pcap_receiver: None, + throughput_receiver: None, + throughput: None, status: UiStatus::Idle, // Validation states duration_validation: FieldValidation::default(), @@ -221,6 +225,9 @@ pub fn show_generation_tab_content( ui.add_space(20.0); show_status(ui, &state.status); + if let Some(throughput) = &state.throughput { + ui.label(format!("Throughput: {throughput}")); + } if let Some((name, spec, err)) = first_invalid_param(state) { ui.colored_label( egui::Color32::RED, @@ -246,6 +253,10 @@ pub fn show_generation_tab_content( let (pcap_sender, pcap_receiver) = channel(); state.pcap_receiver = Some(pcap_receiver); + let (throughput_sender, throughput_receiver) = channel(); + state.throughput_receiver = Some(throughput_receiver); + state.throughput = None; + let seed = if state.use_seed { state.seed_input.parse::().ok() } else { @@ -289,6 +300,7 @@ pub fn show_generation_tab_content( timezone, Some(progress_sender), Some(pcap_sender), + Some(throughput_sender), ); ctx.request_repaint(); }); @@ -308,6 +320,7 @@ pub fn show_generation_tab_content( timezone, Some(progress_sender), Some(pcap_sender), + Some(throughput_sender), ); ctx.request_repaint(); }); @@ -330,6 +343,13 @@ pub fn show_generation_tab_content( } } + if let Some(receiver) = &state.throughput_receiver { + if let Ok(throughput) = receiver.try_recv() { + state.throughput = Some(throughput); + state.throughput_receiver = None; + } + } + if state.pcap_bytes.is_some() && state.progress == 1.0 { #[cfg(not(target_arch = "wasm32"))] if !matches!(state.status, UiStatus::Saved(_) | UiStatus::Error(_)) { From 37989186157cc35900520a305d4d4c253203e4e0 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:45:31 +0100 Subject: [PATCH 25/77] feat(gui): change Generate and Save buttons style --- crates/fosr-gui/src/generation/generation_tab.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index f8fcd024..71c626db 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -241,7 +241,13 @@ pub fn show_generation_tab_content( ui.horizontal(|ui| { ui.add_enabled_ui(can_generate, |ui| { - if ui.button("Generate").clicked() { + let accent = ui.visuals().selection.bg_fill; + let generate_button = egui::Button::new( + egui::RichText::new("Generate").size(13.0), + ) + .fill(accent) + .min_size(egui::vec2(75.0, 24.0)); + if ui.add(generate_button).clicked() { state.status = UiStatus::Generating; // Reset the progress value @@ -363,7 +369,11 @@ pub fn show_generation_tab_content( let save_button_label = "Save"; #[cfg(target_arch = "wasm32")] let save_button_label = "Download"; - if ui.button(save_button_label).clicked() { + let save_button = egui::Button::new( + egui::RichText::new(save_button_label).size(13.0), + ) + .min_size(egui::vec2(75.0, 24.0)); + if ui.add(save_button).clicked() { // --- Save file --- let pcap_bytes = state.pcap_bytes.clone(); #[cfg(not(target_arch = "wasm32"))] From f3d25cbc98df0720316e08e9f626be0d5c68dd87 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:01:29 +0100 Subject: [PATCH 26/77] feat(gui): add button and logic to stop generation --- .../src/generation/generation_core.rs | 20 ++- .../fosr-gui/src/generation/generation_tab.rs | 162 ++++++++++-------- 2 files changed, 110 insertions(+), 72 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_core.rs b/crates/fosr-gui/src/generation/generation_core.rs index 0425d627..30370d93 100644 --- a/crates/fosr-gui/src/generation/generation_core.rs +++ b/crates/fosr-gui/src/generation/generation_core.rs @@ -4,7 +4,9 @@ use fosr_lib::{ models, stage0, stage1, stage2, stage2::tadam::TadamGenerator, stage3, stats::Target, }; use indicatif::HumanBytes; -use std::sync::{Arc, mpsc::Sender}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; use std::time::UNIX_EPOCH as STD_UNIX_EPOCH; use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -19,6 +21,7 @@ pub fn generate( progress_sender: Option>, pcap_sender: Option>>, throughput_sender: Option>, + cancelled: Arc, ) { // Create a closure to send progress updates let send_progress = |progress: f32| { @@ -105,7 +108,7 @@ pub fn generate( let s2 = TadamGenerator::new(automata_library); let s3 = stage3::Stage3::new(taint); log::info!("Run single thread"); - run_single_thread(order_pcap, s0, s1, s2, s3, send_progress, send_pcap, throughput_sender); + run_single_thread(order_pcap, s0, s1, s2, s3, send_progress, send_pcap, throughput_sender, cancelled); } fn run_single_thread( @@ -117,19 +120,25 @@ fn run_single_thread( send_progress: impl Fn(f32), send_pcap: impl Fn(Vec), throughput_sender: Option>, + cancelled: Arc, ) { + let is_cancelled = || cancelled.load(Ordering::Relaxed); + let start = Instant::now(); log::info!("Stage 0 generation"); let vec = stage0::run_vec(s0); + if is_cancelled() { log::info!("Generation cancelled after stage 0"); return; } send_progress(0.2); log::info!("Stage 1 generation"); let vec = stage1::run_vec(s1, vec).unwrap(); + if is_cancelled() { log::info!("Generation cancelled after stage 1"); return; } send_progress(0.4); log::info!("Stage 2 generation"); let vec = stage2::run_vec(s2, vec); + if is_cancelled() { log::info!("Generation cancelled after stage 2"); return; } send_progress(0.6); let mut all_packets = vec![]; @@ -138,14 +147,17 @@ fn run_single_thread( |f, p, v, a| s3.generate_udp_packets(f, p, v, a), vec.udp, )); + if is_cancelled() { log::info!("Generation cancelled during stage 3"); return; } all_packets.append(&mut stage3::run_vec( |f, p, v, a| s3.generate_tcp_packets(f, p, v, a), vec.tcp, )); + if is_cancelled() { log::info!("Generation cancelled during stage 3"); return; } all_packets.append(&mut stage3::run_vec( |f, p, v, a| s3.generate_icmp_packets(f, p, v, a), vec.icmp, )); + if is_cancelled() { log::info!("Generation cancelled during stage 3"); return; } send_progress(0.8); let gen_duration = start.elapsed().as_secs_f64(); @@ -156,11 +168,15 @@ fn run_single_thread( let _ = sender.send(throughput_str); } + if is_cancelled() { log::info!("Generation cancelled"); return; } + if order_pcap { log::info!("Sorting the packets"); all_packets.sort_unstable(); } + if is_cancelled() { log::info!("Generation cancelled"); return; } + let pcap_bytes = stage3::to_pcap_vec(&all_packets).expect("Error converting to pcap"); send_pcap(pcap_bytes); send_progress(1.0); diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 71c626db..8da7836e 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -17,6 +17,8 @@ use chrono_tz::Tz; use eframe::egui; use eframe::egui::{SliderClamping, Widget}; use egui_extras::DatePickerButton; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, channel}; use std::time::Duration; use crate::timepicker::TimePickerButton; @@ -44,6 +46,7 @@ pub struct GenerationTabState { pub pcap_receiver: Option>>, pub throughput_receiver: Option>, pub throughput: Option, + pub cancelled: Arc, pub status: UiStatus, // Validation states pub duration_validation: FieldValidation, @@ -76,6 +79,7 @@ impl Default for GenerationTabState { pcap_receiver: None, throughput_receiver: None, throughput: None, + cancelled: Arc::new(AtomicBool::new(false)), status: UiStatus::Idle, // Validation states duration_validation: FieldValidation::default(), @@ -238,9 +242,26 @@ pub fn show_generation_tab_content( ui.add_space(8.0); let can_generate = first_invalid_param(&state).is_none(); + let is_generating = matches!(state.status, UiStatus::Generating); ui.horizontal(|ui| { - ui.add_enabled_ui(can_generate, |ui| { + if is_generating { + let stop_button = egui::Button::new( + egui::RichText::new("Stop").size(13.0), + ) + .fill(egui::Color32::from_rgb(200, 80, 80)) + .min_size(egui::vec2(75.0, 24.0)); + if ui.add(stop_button).clicked() { + state.cancelled.store(true, Ordering::Relaxed); + state.status = UiStatus::Idle; + state.progress = 0.0; + state.progress_receiver = None; + state.pcap_receiver = None; + state.throughput_receiver = None; + } + } + + if !is_generating { ui.add_enabled_ui(can_generate, |ui| { let accent = ui.visuals().selection.bg_fill; let generate_button = egui::Button::new( egui::RichText::new("Generate").size(13.0), @@ -250,8 +271,9 @@ pub fn show_generation_tab_content( if ui.add(generate_button).clicked() { state.status = UiStatus::Generating; - // Reset the progress value + // Reset state state.progress = 0.0; + state.cancelled = Arc::new(AtomicBool::new(false)); let (progress_sender, progress_receiver) = channel(); state.progress_receiver = Some(progress_receiver); @@ -287,6 +309,7 @@ pub fn show_generation_tab_content( }; let ctx = ui.ctx().clone(); let file_handle = configuration_file_state.picked_config_file.clone(); + let cancelled = state.cancelled.clone(); #[cfg(target_arch = "wasm32")] { @@ -307,6 +330,7 @@ pub fn show_generation_tab_content( Some(progress_sender), Some(pcap_sender), Some(throughput_sender), + cancelled, ); ctx.request_repaint(); }); @@ -327,97 +351,95 @@ pub fn show_generation_tab_content( Some(progress_sender), Some(pcap_sender), Some(throughput_sender), + cancelled, ); ctx.request_repaint(); }); } } - - if let Some(receiver) = &state.progress_receiver { - if let Ok(progress) = receiver.try_recv() { - state.progress = progress; - // Remove the progress receiver if the generation is done - if progress >= 1.0 { - state.progress_receiver = None; - } + }); } + + // Poll receivers (must be outside add_enabled_ui to run while generating) + if let Some(receiver) = &state.progress_receiver { + if let Ok(progress) = receiver.try_recv() { + state.progress = progress; + if progress >= 1.0 { + state.progress_receiver = None; } } + } - if let Some(receiver) = &state.pcap_receiver { - if let Ok(pcap_bytes) = receiver.try_recv() { - state.pcap_bytes = Some(pcap_bytes); - } + if let Some(receiver) = &state.pcap_receiver { + if let Ok(pcap_bytes) = receiver.try_recv() { + state.pcap_bytes = Some(pcap_bytes); } + } - if let Some(receiver) = &state.throughput_receiver { - if let Ok(throughput) = receiver.try_recv() { - state.throughput = Some(throughput); - state.throughput_receiver = None; - } + if let Some(receiver) = &state.throughput_receiver { + if let Ok(throughput) = receiver.try_recv() { + state.throughput = Some(throughput); + state.throughput_receiver = None; } + } - if state.pcap_bytes.is_some() && state.progress == 1.0 { + if state.pcap_bytes.is_some() && state.progress == 1.0 { + #[cfg(not(target_arch = "wasm32"))] + if !matches!(state.status, UiStatus::Saved(_) | UiStatus::Error(_)) { + state.status = UiStatus::Generated; + } + #[cfg(target_arch = "wasm32")] + { + state.status = UiStatus::Generated; + } + #[cfg(not(target_arch = "wasm32"))] + let save_button_label = "Save"; + #[cfg(target_arch = "wasm32")] + let save_button_label = "Download"; + let save_button = egui::Button::new( + egui::RichText::new(save_button_label).size(13.0), + ) + .min_size(egui::vec2(75.0, 24.0)); + if ui.add(save_button).clicked() { + let pcap_bytes = state.pcap_bytes.clone(); #[cfg(not(target_arch = "wasm32"))] - if !matches!(state.status, UiStatus::Saved(_) | UiStatus::Error(_)) { - state.status = UiStatus::Generated; - } - #[cfg(target_arch = "wasm32")] { - state.status = UiStatus::Generated; + let data = pcap_bytes.as_ref().unwrap().as_slice(); + match save_file_desktop(data, &state.output_file_name) { + Ok(file_handle) => { + log::info!( + "Successfully wrote to file: {}", + file_handle.path().to_string_lossy() + ); + state.status = UiStatus::Saved(format!( + "Saved to: {}", + file_handle.path().to_string_lossy() + )); + } + Err(e) => { + log::error!("Failed to save file: {:?}", e); + state.status = UiStatus::Error(format!("Failed to save file: {e}")); + } + } } - #[cfg(not(target_arch = "wasm32"))] - let save_button_label = "Save"; + #[cfg(target_arch = "wasm32")] - let save_button_label = "Download"; - let save_button = egui::Button::new( - egui::RichText::new(save_button_label).size(13.0), - ) - .min_size(egui::vec2(75.0, 24.0)); - if ui.add(save_button).clicked() { - // --- Save file --- - let pcap_bytes = state.pcap_bytes.clone(); - #[cfg(not(target_arch = "wasm32"))] - { + { + let file_name = state.output_file_name.clone(); + wasm_bindgen_futures::spawn_local(async move { let data = pcap_bytes.as_ref().unwrap().as_slice(); - match save_file_desktop(data, &state.output_file_name) { - Ok(file_handle) => { - log::info!( - "Successfully wrote to file: {}", - file_handle.path().to_string_lossy() - ); - state.status = UiStatus::Saved(format!( - "Saved to: {}", - file_handle.path().to_string_lossy() - )); + log::info!("Attempting to write file on WASM..."); + match save_file_wasm(data, &file_name).await { + Ok(_) => { + log::info!("File written successfully!"); } Err(e) => { - log::error!("Failed to save file: {:?}", e); - state.status = UiStatus::Error(format!("Failed to save file: {e}")); + log::error!("Failed to write file: {:?}", e); } } - } - - #[cfg(target_arch = "wasm32")] - { - // Spawn a local async task to run the file write operation. - let file_name = state.output_file_name.clone(); - wasm_bindgen_futures::spawn_local(async move { - let data = pcap_bytes.as_ref().unwrap().as_slice(); - log::info!("Attempting to write file on WASM..."); - // Perform the asynchronous write operation. This triggers the browser's saving dialog. - match save_file_wasm(data, &file_name).await { - Ok(_) => { - log::info!("File written successfully!"); - } - Err(e) => { - log::error!("Failed to write file: {:?}", e); - } - } - }); - } + }); } } - }); + } }); ui.add_space(10.0); From 0500829d7fb1ec17d2ad7abd738181bbf97463d7 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:03:00 +0100 Subject: [PATCH 27/77] feat(gui): hide progress bar in idle state --- crates/fosr-gui/src/generation/generation_tab.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 8da7836e..a9db847c 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -444,9 +444,11 @@ pub fn show_generation_tab_content( ui.add_space(10.0); - let progress = egui::ProgressBar::new(state.progress) - .text("") - .fill(egui::Color32::from_rgb(144, 238, 144)); + if !matches!(state.status, UiStatus::Idle) { + let progress = egui::ProgressBar::new(state.progress) + .text("") + .fill(egui::Color32::from_rgb(144, 238, 144)); - ui.add_sized([ui.available_width(), 20.0], progress); + ui.add_sized([ui.available_width(), 20.0], progress); + } } From 1d0cb89d0090dbf27503020d77b7c8ec98fafecb Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:08:22 +0100 Subject: [PATCH 28/77] feat(gui): increase default zoom to 1.4x and enlarge native window --- crates/fosr-gui/src/app.rs | 9 +++++++++ crates/fosr-gui/src/main.rs | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index f6a2120e..7b322e46 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -29,9 +29,12 @@ impl Default for CurrentTab { } } +pub const DEFAULT_ZOOM: f32 = 1.4; + #[derive(Default)] pub struct FosrApp { current_tab: CurrentTab, + zoom_initialized: bool, configuration_file_state: ConfigurationFileState, configuration_tab_state: ConfigurationTabState, visualization_tab_state: VisualizationTabState, @@ -40,6 +43,12 @@ pub struct FosrApp { impl eframe::App for FosrApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Set default zoom once + if !self.zoom_initialized { + ctx.options_mut(|option| option.zoom_factor = DEFAULT_ZOOM); + self.zoom_initialized = true; + } + // Set the image loaders // Required for egui to display images egui_extras::install_image_loaders(ctx); diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index a1614712..3350cac8 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -19,8 +19,8 @@ fn main() -> eframe::Result { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); let shared_viewport = egui::ViewportBuilder::default() - .with_inner_size([500.0, 440.0]) - .with_min_inner_size([400.0, 350.0]) + .with_inner_size([700.0, 600.0]) + .with_min_inner_size([500.0, 450.0]) .with_title("Fos-R"); #[cfg(target_os = "macos")] From f551932ff07dbf262188d26d048a84aa9d6d9e69 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:34:39 +0100 Subject: [PATCH 29/77] feat(gui): place duration slider and text field side by side and reformat --- .../fosr-gui/src/generation/generation_tab.rs | 234 +++++++++--------- 1 file changed, 118 insertions(+), 116 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index a9db847c..341275b9 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -114,12 +114,28 @@ pub fn show_generation_tab_content( ui.horizontal(|ui| { ui.label("Duration"); - let response = egui::TextEdit::singleline(&mut state.duration_str) + // The only way to set the slider width currently is to set it globally. + // If we need another slider at some point, this value should be mutated + // again before adding it. + ui.style_mut().spacing.slider_width = 150.0; + let slider_response = ui.add( + egui::Slider::new(&mut state.duration_slider_value, 0.0..=1.0) + .show_value(false) + .clamping(SliderClamping::Never), + ); + + if slider_response.changed() { + let s = duration_string_from_slider(state.duration_slider_value); + state.duration_str = s; + state.duration_validation.set_ok(); + } + + let text_response = egui::TextEdit::singleline(&mut state.duration_str) .desired_width(100.0) .hint_text("ex: 30m, 1h, 2d") .ui(ui); - if response.changed() { + if text_response.changed() { match validate_duration(&state.duration_str) { Ok(d) => { state.duration_validation.set_ok(); @@ -134,22 +150,6 @@ pub fn show_generation_tab_content( show_field_error(ui, &state.duration_validation); }); - // The only way to set the slider width currently is to set it globally. - // If we need another slider at some point, this value should be mutated - // again before adding it. - ui.style_mut().spacing.slider_width = 250.0; - let response = ui.add( - egui::Slider::new(&mut state.duration_slider_value, 0.0..=1.0) - .show_value(false) - .clamping(SliderClamping::Never), - ); - - if response.changed() { - let s = duration_string_from_slider(state.duration_slider_value); - state.duration_str = s; - state.duration_validation.set_ok(); - } - ui.add_space(10.0); ui.checkbox(&mut state.use_current_time, "Use current time for start time"); @@ -249,8 +249,8 @@ pub fn show_generation_tab_content( let stop_button = egui::Button::new( egui::RichText::new("Stop").size(13.0), ) - .fill(egui::Color32::from_rgb(200, 80, 80)) - .min_size(egui::vec2(75.0, 24.0)); + .fill(egui::Color32::from_rgb(200, 80, 80)) + .min_size(egui::vec2(75.0, 24.0)); if ui.add(stop_button).clicked() { state.cancelled.store(true, Ordering::Relaxed); state.status = UiStatus::Idle; @@ -261,103 +261,105 @@ pub fn show_generation_tab_content( } } - if !is_generating { ui.add_enabled_ui(can_generate, |ui| { - let accent = ui.visuals().selection.bg_fill; - let generate_button = egui::Button::new( - egui::RichText::new("Generate").size(13.0), - ) - .fill(accent) - .min_size(egui::vec2(75.0, 24.0)); - if ui.add(generate_button).clicked() { - state.status = UiStatus::Generating; - - // Reset state - state.progress = 0.0; - state.cancelled = Arc::new(AtomicBool::new(false)); - - let (progress_sender, progress_receiver) = channel(); - state.progress_receiver = Some(progress_receiver); - - let (pcap_sender, pcap_receiver) = channel(); - state.pcap_receiver = Some(pcap_receiver); - - let (throughput_sender, throughput_receiver) = channel(); - state.throughput_receiver = Some(throughput_receiver); - state.throughput = None; - - let seed = if state.use_seed { - state.seed_input.parse::().ok() - } else { - None - }; - let order_pcap = state.order_pcap; - let start_time = if state.use_current_time { - None - } else { - Some(format!( - "{}T{}Z", - state.start_date.format("%Y-%m-%d"), - state.start_hour.format("%H:%M:%S") - )) - }; - let duration = state.duration_str.clone(); - let taint = state.taint; - let timezone = if state.timezone_input.is_empty() { - None - } else { - Some(state.timezone_input.clone()) - }; - let ctx = ui.ctx().clone(); - let file_handle = configuration_file_state.picked_config_file.clone(); - let cancelled = state.cancelled.clone(); - - #[cfg(target_arch = "wasm32")] - { - wasm_bindgen_futures::spawn_local(async move { - let profile = if let Some(file) = file_handle.as_ref() { - Some(read_file_wasm(file).await) - } else { - None - }; - generate( - seed, - profile, - order_pcap, - start_time, - duration, - taint, - timezone, - Some(progress_sender), - Some(pcap_sender), - Some(throughput_sender), - cancelled, - ); - ctx.request_repaint(); - }); - } + if !is_generating { + ui.add_enabled_ui(can_generate, |ui| { + let accent = ui.visuals().selection.bg_fill; + let generate_button = egui::Button::new( + egui::RichText::new("Generate").size(13.0), + ) + .fill(accent) + .min_size(egui::vec2(75.0, 24.0)); + if ui.add(generate_button).clicked() { + state.status = UiStatus::Generating; + + // Reset state + state.progress = 0.0; + state.cancelled = Arc::new(AtomicBool::new(false)); + + let (progress_sender, progress_receiver) = channel(); + state.progress_receiver = Some(progress_receiver); + + let (pcap_sender, pcap_receiver) = channel(); + state.pcap_receiver = Some(pcap_receiver); + + let (throughput_sender, throughput_receiver) = channel(); + state.throughput_receiver = Some(throughput_receiver); + state.throughput = None; + + let seed = if state.use_seed { + state.seed_input.parse::().ok() + } else { + None + }; + let order_pcap = state.order_pcap; + let start_time = if state.use_current_time { + None + } else { + Some(format!( + "{}T{}Z", + state.start_date.format("%Y-%m-%d"), + state.start_hour.format("%H:%M:%S") + )) + }; + let duration = state.duration_str.clone(); + let taint = state.taint; + let timezone = if state.timezone_input.is_empty() { + None + } else { + Some(state.timezone_input.clone()) + }; + let ctx = ui.ctx().clone(); + let file_handle = configuration_file_state.picked_config_file.clone(); + let cancelled = state.cancelled.clone(); + + #[cfg(target_arch = "wasm32")] + { + wasm_bindgen_futures::spawn_local(async move { + let profile = if let Some(file) = file_handle.as_ref() { + Some(read_file_wasm(file).await) + } else { + None + }; + generate( + seed, + profile, + order_pcap, + start_time, + duration, + taint, + timezone, + Some(progress_sender), + Some(pcap_sender), + Some(throughput_sender), + cancelled, + ); + ctx.request_repaint(); + }); + } - #[cfg(not(target_arch = "wasm32"))] - { - std::thread::spawn(move || { - let profile = file_handle.as_ref().map(|file| read_file_desktop(file)); - generate( - seed, - profile, - order_pcap, - start_time, - duration, - taint, - timezone, - Some(progress_sender), - Some(pcap_sender), - Some(throughput_sender), - cancelled, - ); - ctx.request_repaint(); - }); + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::spawn(move || { + let profile = file_handle.as_ref().map(|file| read_file_desktop(file)); + generate( + seed, + profile, + order_pcap, + start_time, + duration, + taint, + timezone, + Some(progress_sender), + Some(pcap_sender), + Some(throughput_sender), + cancelled, + ); + ctx.request_repaint(); + }); + } } - } - }); } + }); + } // Poll receivers (must be outside add_enabled_ui to run while generating) if let Some(receiver) = &state.progress_receiver { @@ -398,7 +400,7 @@ pub fn show_generation_tab_content( let save_button = egui::Button::new( egui::RichText::new(save_button_label).size(13.0), ) - .min_size(egui::vec2(75.0, 24.0)); + .min_size(egui::vec2(75.0, 24.0)); if ui.add(save_button).clicked() { let pcap_bytes = state.pcap_bytes.clone(); #[cfg(not(target_arch = "wasm32"))] From 8956ab86d65deb7006d91899e54ccc6259a269bc Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:54:55 +0100 Subject: [PATCH 30/77] chore(gui): add license comments for timepicker code --- crates/fosr-gui/src/timepicker/button.rs | 6 +++++- crates/fosr-gui/src/timepicker/mod.rs | 24 +++++++++++++++++++++++- crates/fosr-gui/src/timepicker/popup.rs | 4 ++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/fosr-gui/src/timepicker/button.rs b/crates/fosr-gui/src/timepicker/button.rs index 960273eb..d0f21992 100644 --- a/crates/fosr-gui/src/timepicker/button.rs +++ b/crates/fosr-gui/src/timepicker/button.rs @@ -1,3 +1,7 @@ +// This file is derived from egui_timepicker. +// Copyright (c) 2025 Tilen Pintarič +// Licensed under the MIT License (see mod.rs for full text). + use chrono::NaiveTime; use eframe::egui; use egui::{Area, Button, Frame, InnerResponse, Key, Order, RichText, Widget}; @@ -110,7 +114,7 @@ impl Widget for TimePickerButton<'_> { use_12_hour_clock: self.use_12_hour_clock, use_dragvalue: self.use_dragvalue, } - .draw(ui) + .draw(ui) }) .inner }); diff --git a/crates/fosr-gui/src/timepicker/mod.rs b/crates/fosr-gui/src/timepicker/mod.rs index 8a0e47bd..a5857691 100644 --- a/crates/fosr-gui/src/timepicker/mod.rs +++ b/crates/fosr-gui/src/timepicker/mod.rs @@ -1,5 +1,27 @@ -// This module is based on egui_timepicker v0.1.0 (https://crates.io/crates/egui_timepicker). +// Portions of this software are derived from egui_timepicker v0.1.0 (https://crates.io/crates/egui_timepicker). // +// MIT License +// +// Copyright (c) 2025 Tilen Pintarič +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + // Changes from the original: // - Use theme-aware colors instead of hardcoded Color32::WHITE (supports light mode) // - Auto-advance clock face from Hour → Minute → Second on click/drag release diff --git a/crates/fosr-gui/src/timepicker/popup.rs b/crates/fosr-gui/src/timepicker/popup.rs index b475263a..a90d9c3f 100644 --- a/crates/fosr-gui/src/timepicker/popup.rs +++ b/crates/fosr-gui/src/timepicker/popup.rs @@ -1,3 +1,7 @@ +// This file is derived from egui_timepicker. +// Copyright (c) 2025 Tilen Pintarič +// Licensed under the MIT License (see mod.rs for full text). + use std::fmt::Display; use chrono::{NaiveTime, Timelike}; From c21dc23a01468f5ce22a4320833905b1c494aba4 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:17:32 +0100 Subject: [PATCH 31/77] feat(gui): have a single node per host instead of one node per interface --- .../src/visualization/visualization_shapes.rs | 20 +-- .../src/visualization/visualization_tab.rs | 117 +++++++++++------- 2 files changed, 73 insertions(+), 64 deletions(-) diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/visualization_shapes.rs index 7682fe1a..fdea45d7 100644 --- a/crates/fosr-gui/src/visualization/visualization_shapes.rs +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -43,15 +43,7 @@ impl From> for NetworkNodeShape { NodeType::Internet => (COLOR_INTERNET, RADIUS_INTERNET, true), }; - let label = if let Some(ref hostname) = payload.hostname { - if payload.node_type == NodeType::Internet { - hostname.clone() - } else { - format!("{}\n{}", hostname, payload.ip_addr) - } - } else { - format!("{}", payload.ip_addr) - }; + let label = payload.to_string(); Self { radius, @@ -126,15 +118,7 @@ for NetworkNodeShape self.radius = radius; self.is_internet = is_internet; - self.label = if let Some(ref hostname) = payload.hostname { - if payload.node_type == NodeType::Internet { - hostname.clone() - } else { - format!("{}\n{}", hostname, payload.ip_addr) - } - } else { - format!("{}", payload.ip_addr) - }; + self.label = payload.to_string(); self.location = state.location(); } diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index b82749c0..1f53f165 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -42,7 +42,7 @@ impl From for NodeType { /// Node data: host information #[derive(Clone, Debug)] pub struct NodeData { - pub ip_addr: Ipv4Addr, + pub ip_addrs: Vec, pub hostname: Option, pub node_type: NodeType, #[allow(dead_code)] // Kept for possible future use (node styling by OS?) @@ -53,7 +53,7 @@ impl NodeData { /// Create an Internet node pub fn internet() -> Self { Self { - ip_addr: INTERNET_IP, + ip_addrs: vec![INTERNET_IP], hostname: Some("Internet".to_string()), node_type: NodeType::Internet, os: OS::Linux, // Doesn't matter for Internet node @@ -61,17 +61,31 @@ impl NodeData { } } -// Display the IP address, plus the hostname if available +// Display the hostname plus all IP addresses impl fmt::Display for NodeData { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if let Some(ref hostname) = self.hostname { if self.node_type == NodeType::Internet { write!(f, "{}", hostname) } else { - write!(f, "{}\n{}", hostname, self.ip_addr) + // Display hostname followed by all IPs (one per line) + let ips_str = self + .ip_addrs + .iter() + .map(|ip| ip.to_string()) + .collect::>() + .join("\n"); + write!(f, "{}\n{}", hostname, ips_str) } } else { - write!(f, "{}", self.ip_addr) + // No hostname: display all IPs (one per line) + let ips_str = self + .ip_addrs + .iter() + .map(|ip| ip.to_string()) + .collect::>() + .join("\n"); + write!(f, "{}", ips_str) } } } @@ -195,10 +209,10 @@ impl VisualizationTabState { let mut known_ips = HashSet::new(); let mut ip_to_node = HashMap::new(); - // Add demo nodes + // Add demo nodes (one node per IP in demo mode, since demo hosts have single IPs) for (ip, node_type) in &demo_hosts { let node_data = NodeData { - ip_addr: *ip, + ip_addrs: vec![*ip], hostname: None, // No hostname, just show IP node_type: *node_type, os: OS::Linux, // Does not matter @@ -287,19 +301,22 @@ impl VisualizationTabState { let mut known_ips = HashSet::new(); let mut ip_to_node: HashMap = HashMap::new(); - // Add nodes for each host interface - // TODO: it would be better to have one single node per host + // Add one node per host (with all its IPs) for host in &config.hosts { - for interface in &host.interfaces { - let node_data = NodeData { - ip_addr: interface.ip_addr, - hostname: host.hostname.clone(), - node_type: host.host_type.into(), - os: host.os, - }; - let idx = graph.add_node_with_location(node_data, egui::pos2(0.0, 0.0)); - known_ips.insert(interface.ip_addr); - ip_to_node.insert(interface.ip_addr, idx); + let all_ips: Vec = host.interfaces.iter().map(|i| i.ip_addr).collect(); + + let node_data = NodeData { + ip_addrs: all_ips.clone(), + hostname: host.hostname.clone(), + node_type: host.host_type.into(), + os: host.os, + }; + let idx = graph.add_node_with_location(node_data, egui::pos2(0.0, 0.0)); + + // Map all IPs of this host to the same node + for ip in all_ips { + known_ips.insert(ip); + ip_to_node.insert(ip, idx); } } @@ -537,41 +554,49 @@ fn update_graph_edges(state: &mut VisualizationTabState) { let graph = &mut state.graph; // Collect edge info first to avoid borrow issues - let edges_data: Vec<(petgraph::graph::EdgeIndex, Ipv4Addr, Ipv4Addr)> = graph + // Each node can have multiple IPs, so we collect all IP lists for matching + let edges_data: Vec<(petgraph::graph::EdgeIndex, Vec, Vec)> = graph .g() .edge_indices() .map(|edge| { let (source, target) = graph.g().edge_endpoints(edge).unwrap(); - let src_ip = graph.g()[source].payload().ip_addr; - let dst_ip = graph.g()[target].payload().ip_addr; - (edge, src_ip, dst_ip) + let src_ips = graph.g()[source].payload().ip_addrs.clone(); + let dst_ips = graph.g()[target].payload().ip_addrs.clone(); + (edge, src_ips, dst_ips) }) .collect(); - for (edge, src_ip, dst_ip) in edges_data { - let forward_key = (src_ip, dst_ip); - let reverse_key = (dst_ip, src_ip); - - let new_edge_data = if let Some(link) = state.active_links.get(&forward_key) { - EdgeData::Active { - protocol: link.protocol, - start_time: link.start_time, - direction: link.direction.clone(), - } - } else if let Some(link) = state.active_links.get(&reverse_key) { - EdgeData::Active { - protocol: link.protocol, - start_time: link.start_time, - // we are using the reverse key, so we need to reverse the direction - direction: match link.direction { - LinkDirection::Forward => LinkDirection::Backward, - LinkDirection::Backward => LinkDirection::Forward, - LinkDirection::Bidirectional => LinkDirection::Bidirectional, - }, + for (edge, src_ips, dst_ips) in edges_data { + // Check all IP combinations for an active link + let mut new_edge_data = EdgeData::Inactive; + + 'outer: for src_ip in &src_ips { + for dst_ip in &dst_ips { + let forward_key = (*src_ip, *dst_ip); + let reverse_key = (*dst_ip, *src_ip); + + if let Some(link) = state.active_links.get(&forward_key) { + new_edge_data = EdgeData::Active { + protocol: link.protocol, + start_time: link.start_time, + direction: link.direction.clone(), + }; + break 'outer; + } else if let Some(link) = state.active_links.get(&reverse_key) { + new_edge_data = EdgeData::Active { + protocol: link.protocol, + start_time: link.start_time, + // we are using the reverse key, so we need to reverse the direction + direction: match link.direction { + LinkDirection::Forward => LinkDirection::Backward, + LinkDirection::Backward => LinkDirection::Forward, + LinkDirection::Bidirectional => LinkDirection::Bidirectional, + }, + }; + break 'outer; + } } - } else { - EdgeData::Inactive - }; + } // Update the edge data if let Some(edge_mut) = graph.g_mut().edge_weight_mut(edge) { From 98bc053f96071354960e2f78bc7ec0331d5486d6 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:47:42 +0100 Subject: [PATCH 32/77] feat(gui): display host info modal on node click --- Cargo.lock | 42 +++++++ crates/fosr-gui/Cargo.toml | 2 +- .../src/visualization/visualization_tab.rs | 113 +++++++++++++++++- 3 files changed, 155 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c922bb17..bfb66117 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1077,6 +1077,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1086,6 +1099,34 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1377,6 +1418,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0673eb7bb77708b8f59ef30fd90fa6537026f078fe72579ee68720073b6c8423" dependencies = [ + "crossbeam", "egui", "getrandom 0.2.16", "petgraph", diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index da1bf62c..cf4ae794 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -39,7 +39,7 @@ serde = { version = "1", features = ["derive"] } indicatif = "0.18.3" web-time = "1.1.0" petgraph = "0.8.3" -egui_graphs = "0.29.0" +egui_graphs = { version = "0.29.0", features = ["events"] } [target.'cfg(target_arch = "wasm32")'.dependencies] gloo-timers = { version = "0.3", features = ["futures"] } diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 1f53f165..97c7b40e 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -8,12 +8,15 @@ use crate::shared::configuration_file::ConfigurationFileState; use eframe::egui; use egui_graphs::{ FruchtermanReingoldState, FruchtermanReingoldWithCenterGravity, - FruchtermanReingoldWithCenterGravityState, LayoutForceDirected, set_layout_state, + FruchtermanReingoldWithCenterGravityState, LayoutForceDirected, SettingsInteraction, + events::{Event, PayloadNodeClick}, set_layout_state, }; use fosr_lib::{config, config::HostType, L7Proto, OS}; +use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::fmt; use std::net::Ipv4Addr; +use std::rc::Rc; use std::sync::mpsc::Receiver; use std::sync::{Arc, RwLock}; use std::time::Duration; @@ -154,6 +157,12 @@ pub struct VisualizationTabState { visualization_start: Option, /// Speed multiplier (0.5 to 4.0) - shared for runtime updates pub speed: Arc>, + /// Buffer for graph events (clicks, etc.) + events_buffer: Rc>>, + /// Clicked node for info modal display + pub clicked_node: Option, + /// Node info modal open state + pub node_info_modal_open: bool, } impl Default for VisualizationTabState { @@ -271,6 +280,9 @@ impl VisualizationTabState { ip_to_node, visualization_start: None, speed: Arc::new(RwLock::new(1.0)), + events_buffer: Rc::new(RefCell::new(Vec::new())), + clicked_node: None, + node_info_modal_open: false, } } @@ -424,6 +436,10 @@ pub fn show_visualization_tab_content( // Render UI render_control_panel(ui, state); render_graph_view(ui, state); + + // Process node click events and render info modal + process_graph_events(state); + render_node_info_modal(ui.ctx(), state); } /// Handle configuration file changes @@ -679,6 +695,94 @@ fn render_control_panel(ui: &mut egui::Ui, state: &mut VisualizationTabState) { }); } +/// Process graph click events from the event buffer +fn process_graph_events(state: &mut VisualizationTabState) { + let events: Vec = state.events_buffer.borrow_mut().drain(..).collect(); + + for event in events { + if let Event::NodeClick(PayloadNodeClick { id }) = event { + let node_idx = petgraph::graph::NodeIndex::new(id); + state.clicked_node = Some(node_idx); + state.node_info_modal_open = true; + } + } +} + +/// Render the node information modal for the clicked node +fn render_node_info_modal(ctx: &egui::Context, state: &mut VisualizationTabState) { + if !state.node_info_modal_open { + return; + } + + let Some(node_idx) = state.clicked_node else { + return; + }; + + let Some(node) = state.graph.g().node_weight(node_idx) else { + state.node_info_modal_open = false; + state.clicked_node = None; + return; + }; + + let node_data = node.payload().clone(); + + let modal = egui::Modal::new(egui::Id::new("node_info_modal")).show(ctx, |ui| { + ui.set_width(250.0); + ui.heading("Node Information"); + + ui.separator(); + + // Node type with colored indicator + ui.horizontal(|ui| { + let (color, type_str) = match node_data.node_type { + NodeType::Server => (COLOR_SERVER, "Server"), + NodeType::User => (COLOR_USER, "User"), + NodeType::Internet => (COLOR_INTERNET, "Internet"), + }; + let rect = ui.allocate_space(egui::vec2(12.0, 12.0)).1; + ui.painter().circle_filled(rect.center(), 6.0, color); + ui.label(egui::RichText::new(type_str).strong()); + }); + + ui.add_space(4.0); + + if let Some(ref hostname) = node_data.hostname { + ui.horizontal(|ui| { + ui.label("Hostname:"); + ui.label(egui::RichText::new(hostname).monospace()); + }); + } + + ui.label("IP Addresses:"); + for ip in &node_data.ip_addrs { + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new(ip.to_string()).monospace()); + }); + } + + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label("OS:"); + ui.label(egui::RichText::new(format!("{:?}", node_data.os)).monospace()); + }); + + ui.add_space(8.0); + + ui.vertical_centered(|ui| { + if ui.button("Close").clicked() { + ui.close(); + } + }); + }); + + // Close on Escape or click outside + if modal.should_close() { + state.node_info_modal_open = false; + state.clicked_node = None; + } +} + /// Helper to render a single legend item inline fn legend_item_inline(ui: &mut egui::Ui, label: &str, color: egui::Color32) { // Allocate space first @@ -692,6 +796,11 @@ fn legend_item_inline(ui: &mut egui::Ui, label: &str, color: egui::Color32) { /// Render the graph view fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { egui::CentralPanel::default().show(ui.ctx(), |ui| { + // Enable node clicking and dragging + let interactions = SettingsInteraction::new() + .with_node_clicking_enabled(true) + .with_dragging_enabled(true); + let mut graph_view = egui_graphs::GraphView::< NodeData, EdgeData, @@ -702,6 +811,8 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { FruchtermanReingoldWithCenterGravityState, LayoutForceDirected, >::new(&mut state.graph) + .with_interactions(&interactions) + .with_event_sink(&state.events_buffer) .with_styles(&egui_graphs::SettingsStyle::new().with_labels_always(true)); // Disable force-directed layout to preserve circle layout From bc540df1e33c27d27836273c2b603f9228c5652a Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:58:28 +0100 Subject: [PATCH 33/77] fix(gui): add vertical scroll if window is too small + increase min size for native target --- crates/fosr-gui/src/app.rs | 63 +++++++++++++++++++------------------ crates/fosr-gui/src/main.rs | 2 +- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index 7b322e46..9e50158a 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -106,37 +106,40 @@ impl eframe::App for FosrApp { // The Central Panel is the region left after adding the Top, Bottom and Side panels. egui::CentralPanel::default().show(ctx, |ui| { - // Display the tab content depending on the currently select tab - match self.current_tab { - CurrentTab::Generation => { - show_generation_tab_content( - ui, - &mut self.generation_tab_state, - &mut self.configuration_file_state, - ); + // Wrap in ScrollArea for vertical scrolling + egui::ScrollArea::vertical().show(ui, |ui| { + // Display the tab content depending on the currently select tab + match self.current_tab { + CurrentTab::Generation => { + show_generation_tab_content( + ui, + &mut self.generation_tab_state, + &mut self.configuration_file_state, + ); + } + CurrentTab::Configuration => { + show_configuration_tab_content( + ui, + &mut self.configuration_tab_state, + &mut self.configuration_file_state, + ); + } + CurrentTab::Visualization => { + show_visualization_tab_content( + ui, + &mut self.visualization_tab_state, + &self.configuration_file_state, + ); + } + #[cfg(not(target_arch = "wasm32"))] + CurrentTab::Injection => { + show_injection_tab_content(ui); + } + CurrentTab::About => { + show_about_tab_content(ui); + } } - CurrentTab::Configuration => { - show_configuration_tab_content( - ui, - &mut self.configuration_tab_state, - &mut self.configuration_file_state, - ); - } - CurrentTab::Visualization => { - show_visualization_tab_content( - ui, - &mut self.visualization_tab_state, - &self.configuration_file_state, - ); - } - #[cfg(not(target_arch = "wasm32"))] - CurrentTab::Injection => { - show_injection_tab_content(ui); - } - CurrentTab::About => { - show_about_tab_content(ui); - } - } + }); }); } } diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index 3350cac8..a15b88c9 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -20,7 +20,7 @@ fn main() -> eframe::Result { let shared_viewport = egui::ViewportBuilder::default() .with_inner_size([700.0, 600.0]) - .with_min_inner_size([500.0, 450.0]) + .with_min_inner_size([550.0, 500.0]) .with_title("Fos-R"); #[cfg(target_os = "macos")] From d0fa92c4f49b08599430ccbcbded2973e9aee6bc Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:15:25 +0100 Subject: [PATCH 34/77] chore(gui/wasm): reduce max duration to 1 day and increase height --- crates/fosr-gui/src/generation/generation_tab.rs | 3 +++ public/styles.css | 13 ++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 341275b9..3dbef3fb 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -25,7 +25,10 @@ use crate::timepicker::TimePickerButton; // Time interval for the slider. pub const DURATION_MIN: Duration = Duration::from_secs(60); // 1 min +#[cfg(not(target_arch = "wasm32"))] pub const DURATION_MAX: Duration = Duration::from_secs(3 * 24 * 3600); // 3 days +#[cfg(target_arch = "wasm32")] +pub const DURATION_MAX: Duration = Duration::from_secs(24 * 3600); // 1 day (browser tab memory is limited) pub enum UiStatus { Idle, diff --git a/public/styles.css b/public/styles.css index 6e99c23c..1101e146 100644 --- a/public/styles.css +++ b/public/styles.css @@ -94,19 +94,19 @@ blockquote { .scrollable-navbar { max-height: calc(100vh - var(--navbar-height)); overflow-y: auto; - scrollbar-width: none; /* Hide scrollbar in Firefox */ + scrollbar-width: none; /* Hide scrollbar in Firefox */ } /* Hide scrollbar in Chrome, Edge, Safari */ .scrollable-navbar::-webkit-scrollbar { - display: none; + display: none; } } /* Added by PFG */ img { - width: 100%; - height: auto; + width: 100%; + height: auto; } table { @@ -115,14 +115,13 @@ table { } table tr:nth-child(even) { - background: var(--bd-pre-bg); + background: var(--bd-pre-bg); } #gui_wrapper { position: relative; width: 100%; - max-width: 500px; - height: 440px; + height: 550px; background: var(--bs-body-bg, #ffffff); margin: 15px auto; border-radius: 0.5rem; From 97d1d90be480ccf7f4debb4122d490650aa18ff3 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:20:48 +0100 Subject: [PATCH 35/77] chore(gui/wasm): do not display DEBUG logs in browser console --- crates/fosr-gui/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index d0366827..ce39f329 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -16,7 +16,7 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen] pub async fn start(canvas_id: &str) -> Result<(), JsValue> { // Redirect `log` message to `console.log`: - eframe::WebLogger::init(log::LevelFilter::Debug).ok(); + eframe::WebLogger::init(log::LevelFilter::Info).ok(); let web_options = eframe::WebOptions::default(); From d19e9d9a354f640f734d10799cfe0c26a849bda4 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:41:05 +0100 Subject: [PATCH 36/77] feat(gui): auto-start visualization on first tab visit or config change --- .../src/visualization/visualization_tab.rs | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 97c7b40e..9c3b7677 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -163,6 +163,11 @@ pub struct VisualizationTabState { pub clicked_node: Option, /// Node info modal open state pub node_info_modal_open: bool, + /// Frames to wait before auto-starting. + /// Using a countdown instead of a boolean allows to render the UI before starting the visualization. + /// This avoids lag when clicking on the Visualization tab. + /// Note: 2 frames minimum required for all UI elements to display properly on first tab visit. + auto_start_countdown: Option, } impl Default for VisualizationTabState { @@ -283,17 +288,13 @@ impl VisualizationTabState { events_buffer: Rc::new(RefCell::new(Vec::new())), clicked_node: None, node_info_modal_open: false, + auto_start_countdown: Some(2), } } /// Update state from a configuration (preserves some state) + /// Note: caller should stop visualization before calling this if running pub fn update_from_config(&mut self, config: &config::Configuration) { - // Don't update while running - // TODO: provide better handling if configuration changes during generation - if self.visualization_running { - return; - } - let (graph, known_ips, ip_to_node) = Self::build_graph_from_config(config); self.graph = graph; self.known_ips = known_ips; @@ -424,6 +425,20 @@ pub fn show_visualization_tab_content( // Handle config changes handle_config_changes(state, configuration_file_state); + // Auto-start visualization with delay (allows UI to render first) + if let Some(countdown) = state.auto_start_countdown { + if countdown > 0 { + state.auto_start_countdown = Some(countdown - 1); + } else if !state.visualization_running { + let config = state.config_content.clone(); + let speed = state.speed.clone(); + if let Err(e) = state.start_visualization(config.as_deref(), speed) { + log::error!("Failed to auto-start visualization: {}", e); + } + state.auto_start_countdown = None; + } + } + // Process incoming flow events process_flow_events(state); @@ -451,7 +466,11 @@ fn handle_config_changes( let was_config_removed = state.config_content.is_some() && configuration_file_state.config_file_content.is_none(); - if was_config_removed && !state.visualization_running { + if was_config_removed { + // Stop visualization if running, then reset to default + if state.visualization_running { + state.stop_visualization(); + } state.config_content = None; *state = VisualizationTabState::default(); return; @@ -465,18 +484,18 @@ fn handle_config_changes( _ => false, }; - if needs_update && !state.visualization_running { + if needs_update { if let Some(ref config_content) = configuration_file_state.config_file_content { - state.active_links.clear(); - if let Some(streamer) = &state.streamer { - streamer.stop(); + // Stop visualization if running before updating config + let was_running = state.visualization_running; + if was_running { + state.stop_visualization(); } - state.streamer = None; - state.flow_receiver = None; let config = config::import_config(config_content); state.update_from_config(&config); state.config_content = Some(config_content.clone()); + state.auto_start_countdown = Some(2); } } } From 83c4c25935eb0abeacecf97155b174a9c33a8d78 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Feb 2026 14:24:26 +0100 Subject: [PATCH 37/77] feat(gui): dynamic node sizing based on flow activity --- .../src/visualization/visualization_shapes.rs | 57 ++++++++++++------- .../src/visualization/visualization_tab.rs | 47 +++++++++++++++ 2 files changed, 82 insertions(+), 22 deletions(-) diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/visualization_shapes.rs index fdea45d7..d0fac16a 100644 --- a/crates/fosr-gui/src/visualization/visualization_shapes.rs +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -18,8 +18,10 @@ pub const COLOR_HTTPS: Color32 = Color32::from_rgb(46, 204, 113); // Green pub const COLOR_SSH: Color32 = Color32::from_rgb(155, 89, 182); // Purple pub const COLOR_DNS: Color32 = Color32::from_rgb(230, 126, 34); // Orange -const RADIUS_NORMAL: f32 = 20.0; -const RADIUS_INTERNET: f32 = 30.0; +// Node radius constants - all nodes grow with flow count +const RADIUS_MIN: f32 = 5.0; // Starting size for all nodes +const RADIUS_MAX: f32 = 25.0; // Maximum size +const FLOW_SCALE_FACTOR: f32 = 0.3; // Radius increase per flow const EDGE_WIDTH_INACTIVE: f32 = 0.5; const EDGE_WIDTH_ACTIVE: f32 = 3.0; @@ -34,17 +36,37 @@ pub struct NetworkNodeShape { is_internet: bool, } -impl From> for NetworkNodeShape { - fn from(props: NodeProps) -> Self { - let payload = &props.payload; - let (color, radius, is_internet) = match payload.node_type { - NodeType::Server => (COLOR_SERVER, RADIUS_NORMAL, false), - NodeType::User => (COLOR_USER, RADIUS_NORMAL, false), - NodeType::Internet => (COLOR_INTERNET, RADIUS_INTERNET, true), +impl NetworkNodeShape { + /// Compute node style from payload data. + fn style_from_payload(payload: &NodeData) -> (f32, Color32, bool, String) { + // Hybrid linear/proportional radius scaling + let max_linear = RADIUS_MIN + payload.max_flow_count as f32 * FLOW_SCALE_FACTOR; + let radius = if max_linear < RADIUS_MAX { + // Linear phase: everyone grows normally + RADIUS_MIN + payload.flow_count as f32 * FLOW_SCALE_FACTOR + } else { + // Proportional phase: scale by ratio to max + let ratio = if payload.max_flow_count > 0 { + payload.flow_count as f32 / payload.max_flow_count as f32 + } else { + 0.0 + }; + RADIUS_MIN + ratio * (RADIUS_MAX - RADIUS_MIN) }; - let label = payload.to_string(); + let (color, is_internet) = match payload.node_type { + NodeType::Internet => (COLOR_INTERNET, true), + NodeType::Server => (COLOR_SERVER, false), + NodeType::User => (COLOR_USER, false), + }; + (radius, color, is_internet, payload.to_string()) + } +} + +impl From> for NetworkNodeShape { + fn from(props: NodeProps) -> Self { + let (radius, color, is_internet, label) = Self::style_from_payload(&props.payload); Self { radius, color, @@ -106,20 +128,11 @@ for NetworkNodeShape } fn update(&mut self, state: &NodeProps) { - let payload = &state.payload; - - let (color, radius, is_internet) = match payload.node_type { - NodeType::Server => (COLOR_SERVER, RADIUS_NORMAL, false), - NodeType::User => (COLOR_USER, RADIUS_NORMAL, false), - NodeType::Internet => (COLOR_INTERNET, RADIUS_INTERNET, true), - }; - - self.color = color; + let (radius, color, is_internet, label) = Self::style_from_payload(&state.payload); self.radius = radius; + self.color = color; self.is_internet = is_internet; - - self.label = payload.to_string(); - + self.label = label; self.location = state.location(); } diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 9c3b7677..33ddc770 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -50,6 +50,12 @@ pub struct NodeData { pub node_type: NodeType, #[allow(dead_code)] // Kept for possible future use (node styling by OS?) pub os: OS, + /// Number of flows this node has been involved in (as sender or receiver). + /// Used for dynamic node sizing - more active nodes appear larger. + pub flow_count: u32, + /// Maximum flow count among all nodes (for proportional sizing). + /// When the linear formula would exceed RADIUS_MAX, we switch to proportional mode. + pub max_flow_count: u32, } impl NodeData { @@ -60,6 +66,8 @@ impl NodeData { hostname: Some("Internet".to_string()), node_type: NodeType::Internet, os: OS::Linux, // Doesn't matter for Internet node + flow_count: 0, + max_flow_count: 0, } } } @@ -230,6 +238,8 @@ impl VisualizationTabState { hostname: None, // No hostname, just show IP node_type: *node_type, os: OS::Linux, // Does not matter + flow_count: 0, + max_flow_count: 0, }; // Nodes are initially placed at the center. They are manually distributed later. let idx = graph.add_node_with_location(node_data, egui::pos2(0.0, 0.0)); @@ -323,6 +333,8 @@ impl VisualizationTabState { hostname: host.hostname.clone(), node_type: host.host_type.into(), os: host.os, + flow_count: 0, + max_flow_count: 0, }; let idx = graph.add_node_with_location(node_data, egui::pos2(0.0, 0.0)); @@ -568,6 +580,41 @@ fn process_flow_events(state: &mut VisualizationTabState) { direction, }, ); + + // Increment flow counters only if an edge exists between the nodes + if let (Some(&src_idx), Some(&dst_idx)) = ( + state.ip_to_node.get(&display_src), + state.ip_to_node.get(&display_dst), + ) { + // Check if edge exists (undirected graph, so check both directions) + let edge_exists = state.graph.g().find_edge(src_idx, dst_idx).is_some() + || state.graph.g().find_edge(dst_idx, src_idx).is_some(); + + if edge_exists { + if let Some(node) = state.graph.g_mut().node_weight_mut(src_idx) { + node.payload_mut().flow_count += 1; + } + if let Some(node) = state.graph.g_mut().node_weight_mut(dst_idx) { + node.payload_mut().flow_count += 1; + } + } + } + } + + // Update max_flow_count for all nodes (for proportional sizing) + let max_flow = state + .graph + .g() + .node_indices() + .filter_map(|idx| state.graph.g().node_weight(idx)) + .map(|n| n.payload().flow_count) + .max() + .unwrap_or(0); + + for idx in state.graph.g().node_indices().collect::>() { + if let Some(node) = state.graph.g_mut().node_weight_mut(idx) { + node.payload_mut().max_flow_count = max_flow; + } } } From 1d7d4efa6e1a2fdc3353273b5a26135b494082ad Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:37:01 +0100 Subject: [PATCH 38/77] feat(gui): dynamic edge sizing based on flow activity + display total flows + continue/restart choice --- .../src/visualization/visualization_shapes.rs | 33 +++- .../src/visualization/visualization_tab.rs | 151 ++++++++++++++---- 2 files changed, 146 insertions(+), 38 deletions(-) diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/visualization_shapes.rs index d0fac16a..01437b44 100644 --- a/crates/fosr-gui/src/visualization/visualization_shapes.rs +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -1,6 +1,6 @@ //! Custom node and edge shapes for network visualization -use crate::visualization::visualization_tab::{EdgeData, LinkDirection, NodeData, NodeType}; +use crate::visualization::visualization_tab::{EdgeData, EdgeState, LinkDirection, NodeData, NodeType}; use eframe::egui; use egui::{Color32, Pos2, Shape, Vec2}; use egui_graphs::{DisplayEdge, DisplayNode, DrawContext, Node, NodeProps}; @@ -23,8 +23,9 @@ const RADIUS_MIN: f32 = 5.0; // Starting size for all nodes const RADIUS_MAX: f32 = 25.0; // Maximum size const FLOW_SCALE_FACTOR: f32 = 0.3; // Radius increase per flow -const EDGE_WIDTH_INACTIVE: f32 = 0.5; -const EDGE_WIDTH_ACTIVE: f32 = 3.0; +const EDGE_WIDTH_MIN: f32 = 0.2; +const EDGE_WIDTH_MAX: f32 = 3.0; +const EDGE_FLOW_SCALE: f32 = 0.1; // Width increase per flow (linear phase) /// Custom node shape that displays hostname and IP, with color based on node type #[derive(Clone)] @@ -142,11 +143,27 @@ for NetworkNodeShape } } -/// Get edge style based on protocol and direction +/// Get edge style based on protocol, direction, and flow count fn edge_style(edge_data: &EdgeData) -> (Color32, f32, bool, bool) { - match edge_data { - EdgeData::Inactive => (COLOR_INACTIVE, EDGE_WIDTH_INACTIVE, false, false), - EdgeData::Active { protocol, direction, .. } => { + match &edge_data.state { + EdgeState::Inactive => { + // Hybrid linear/proportional width scaling (same approach as nodes) + let max_linear = EDGE_WIDTH_MIN + edge_data.max_flow_count as f32 * EDGE_FLOW_SCALE; + let width = if max_linear < EDGE_WIDTH_MAX { + // Linear phase: all edges grow normally + EDGE_WIDTH_MIN + edge_data.flow_count as f32 * EDGE_FLOW_SCALE + } else { + // Proportional phase: scale by ratio to max + let ratio = if edge_data.max_flow_count > 0 { + edge_data.flow_count as f32 / edge_data.max_flow_count as f32 + } else { + 0.0 + }; + EDGE_WIDTH_MIN + ratio * (EDGE_WIDTH_MAX - EDGE_WIDTH_MIN) + }; + (COLOR_INACTIVE, width, false, false) + } + EdgeState::Active { protocol, direction, .. } => { let color = match protocol { L7Proto::HTTP => Color32::from_rgb(52, 152, 219), // Blue L7Proto::HTTPS => Color32::from_rgb(46, 204, 113), // Green @@ -159,7 +176,7 @@ fn edge_style(edge_data: &EdgeData) -> (Color32, f32, bool, bool) { LinkDirection::Backward => (true, false), LinkDirection::Bidirectional => (true, true), }; - (color, EDGE_WIDTH_ACTIVE, arrow_start, arrow_end) + (color, EDGE_WIDTH_MAX, arrow_start, arrow_end) } } } diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 33ddc770..48ec92a0 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -101,25 +101,45 @@ impl fmt::Display for NodeData { } } -/// Edge data: communication state +/// Edge data: communication state with cumulative flow count for thickness +#[derive(Clone, Debug)] +pub struct EdgeData { + /// Current visual state (active with protocol or inactive) + pub state: EdgeState, + /// Cumulative flow count - persists even when inactive, used for edge thickness + pub flow_count: u32, + /// Maximum flow count among all edges (for proportional sizing) + pub max_flow_count: u32, +} + +impl Default for EdgeData { + fn default() -> Self { + Self { + state: EdgeState::Inactive, + flow_count: 0, + max_flow_count: 0, + } + } +} + +/// Visual state of an edge #[derive(Clone, Debug, Default)] -pub enum EdgeData { +pub enum EdgeState { #[default] Inactive, Active { protocol: L7Proto, #[allow(dead_code)] // Kept for possible future animation effects? start_time: Instant, - #[allow(dead_code)] // Kept for directional arrows direction: LinkDirection, }, } impl fmt::Display for EdgeData { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - EdgeData::Inactive => write!(f, ""), - EdgeData::Active { protocol, .. } => write!(f, "{:?}", protocol), + match &self.state { + EdgeState::Inactive => write!(f, ""), + EdgeState::Active { protocol, .. } => write!(f, "{:?}", protocol), } } } @@ -176,6 +196,8 @@ pub struct VisualizationTabState { /// This avoids lag when clicking on the Visualization tab. /// Note: 2 frames minimum required for all UI elements to display properly on first tab visit. auto_start_countdown: Option, + /// Total number of flows processed since visualization started + total_flows: u32, } impl Default for VisualizationTabState { @@ -270,17 +292,17 @@ impl VisualizationTabState { for (server_ip, _) in &servers { let user_idx = ip_to_node[user_ip]; let server_idx = ip_to_node[server_ip]; - graph.add_edge(user_idx, server_idx, EdgeData::Inactive); + graph.add_edge(user_idx, server_idx, EdgeData::default()); } // Add edge to Internet for each user let user_idx = ip_to_node[user_ip]; - graph.add_edge(user_idx, internet_idx, EdgeData::Inactive); + graph.add_edge(user_idx, internet_idx, EdgeData::default()); } // Add edges from servers to Internet for (server_ip, _) in &servers { let server_idx = ip_to_node[server_ip]; - graph.add_edge(server_idx, internet_idx, EdgeData::Inactive); + graph.add_edge(server_idx, internet_idx, EdgeData::default()); } Self { @@ -299,6 +321,7 @@ impl VisualizationTabState { clicked_node: None, node_info_modal_open: false, auto_start_countdown: Some(2), + total_flows: 0, } } @@ -357,18 +380,18 @@ impl VisualizationTabState { if let Some(&user_idx) = ip_to_node.get(&user_ip) { for &server_ip in &config.servers { if let Some(&server_idx) = ip_to_node.get(&server_ip) { - graph.add_edge(user_idx, server_idx, EdgeData::Inactive); + graph.add_edge(user_idx, server_idx, EdgeData::default()); } } // Add edge to Internet for each user - graph.add_edge(user_idx, internet_idx, EdgeData::Inactive); + graph.add_edge(user_idx, internet_idx, EdgeData::default()); } } // Add edges from servers to Internet for &server_ip in &config.servers { if let Some(&server_idx) = ip_to_node.get(&server_ip) { - graph.add_edge(server_idx, internet_idx, EdgeData::Inactive); + graph.add_edge(server_idx, internet_idx, EdgeData::default()); } } @@ -380,14 +403,39 @@ impl VisualizationTabState { self.known_ips.contains(&ip) } + /// Reset all flow counts on nodes and edges + fn reset_flow_counts(&mut self) { + self.total_flows = 0; + for idx in self.graph.g().node_indices().collect::>() { + if let Some(node) = self.graph.g_mut().node_weight_mut(idx) { + let payload = node.payload_mut(); + payload.flow_count = 0; + payload.max_flow_count = 0; + } + } + for idx in self.graph.g().edge_indices().collect::>() { + if let Some(edge) = self.graph.g_mut().edge_weight_mut(idx) { + let payload = edge.payload_mut(); + payload.flow_count = 0; + payload.max_flow_count = 0; + } + } + } + /// Start visualization /// If config_content is None, the FlowStreamer uses the default BN model (no config applied) /// Speed controls how fast flows are emitted (1.0 = real-time, 2.0 = 2x faster) - can be updated at runtime via slider + /// If reset is true, flow counts are reset to zero before starting pub fn start_visualization( &mut self, config_content: Option<&str>, speed: Arc>, + reset: bool, ) -> Result<(), String> { + if reset { + self.reset_flow_counts(); + } + log::debug!("Starting visualization with {} known IPs:", self.known_ips.len()); for ip in &self.known_ips { log::debug!(" - {}", ip); @@ -444,7 +492,7 @@ pub fn show_visualization_tab_content( } else if !state.visualization_running { let config = state.config_content.clone(); let speed = state.speed.clone(); - if let Err(e) = state.start_visualization(config.as_deref(), speed) { + if let Err(e) = state.start_visualization(config.as_deref(), speed, true) { log::error!("Failed to auto-start visualization: {}", e); } state.auto_start_countdown = None; @@ -544,6 +592,9 @@ fn process_flow_events(state: &mut VisualizationTabState) { continue; } + // Increment total flows counter + state.total_flows += 1; + // Map IPs to display IPs (unknown -> INTERNET_IP) let display_src = if src_known { event.src_ip @@ -581,28 +632,36 @@ fn process_flow_events(state: &mut VisualizationTabState) { }, ); - // Increment flow counters only if an edge exists between the nodes + // Increment flow counters on nodes and edges if let (Some(&src_idx), Some(&dst_idx)) = ( state.ip_to_node.get(&display_src), state.ip_to_node.get(&display_dst), ) { - // Check if edge exists (undirected graph, so check both directions) - let edge_exists = state.graph.g().find_edge(src_idx, dst_idx).is_some() - || state.graph.g().find_edge(dst_idx, src_idx).is_some(); - - if edge_exists { + // Find the edge (undirected graph, so check both directions) + let edge_idx = state + .graph + .g() + .find_edge(src_idx, dst_idx) + .or_else(|| state.graph.g().find_edge(dst_idx, src_idx)); + + if let Some(edge_idx) = edge_idx { + // Increment node flow counters if let Some(node) = state.graph.g_mut().node_weight_mut(src_idx) { node.payload_mut().flow_count += 1; } if let Some(node) = state.graph.g_mut().node_weight_mut(dst_idx) { node.payload_mut().flow_count += 1; } + // Increment edge flow counter (for thickness) + if let Some(edge) = state.graph.g_mut().edge_weight_mut(edge_idx) { + edge.payload_mut().flow_count += 1; + } } } } // Update max_flow_count for all nodes (for proportional sizing) - let max_flow = state + let max_node_flow = state .graph .g() .node_indices() @@ -613,7 +672,23 @@ fn process_flow_events(state: &mut VisualizationTabState) { for idx in state.graph.g().node_indices().collect::>() { if let Some(node) = state.graph.g_mut().node_weight_mut(idx) { - node.payload_mut().max_flow_count = max_flow; + node.payload_mut().max_flow_count = max_node_flow; + } + } + + // Update max_flow_count for all edges (for proportional sizing) + let max_edge_flow = state + .graph + .g() + .edge_indices() + .filter_map(|idx| state.graph.g().edge_weight(idx)) + .map(|e| e.payload().flow_count) + .max() + .unwrap_or(0); + + for idx in state.graph.g().edge_indices().collect::>() { + if let Some(edge) = state.graph.g_mut().edge_weight_mut(idx) { + edge.payload_mut().max_flow_count = max_edge_flow; } } } @@ -650,7 +725,7 @@ fn update_graph_edges(state: &mut VisualizationTabState) { for (edge, src_ips, dst_ips) in edges_data { // Check all IP combinations for an active link - let mut new_edge_data = EdgeData::Inactive; + let mut new_state = EdgeState::Inactive; 'outer: for src_ip in &src_ips { for dst_ip in &dst_ips { @@ -658,14 +733,14 @@ fn update_graph_edges(state: &mut VisualizationTabState) { let reverse_key = (*dst_ip, *src_ip); if let Some(link) = state.active_links.get(&forward_key) { - new_edge_data = EdgeData::Active { + new_state = EdgeState::Active { protocol: link.protocol, start_time: link.start_time, direction: link.direction.clone(), }; break 'outer; } else if let Some(link) = state.active_links.get(&reverse_key) { - new_edge_data = EdgeData::Active { + new_state = EdgeState::Active { protocol: link.protocol, start_time: link.start_time, // we are using the reverse key, so we need to reverse the direction @@ -680,9 +755,9 @@ fn update_graph_edges(state: &mut VisualizationTabState) { } } - // Update the edge data + // Update edge state (flow_count is preserved) if let Some(edge_mut) = graph.g_mut().edge_weight_mut(edge) { - *edge_mut.payload_mut() = new_edge_data; + edge_mut.payload_mut().state = new_state; } } } @@ -691,15 +766,27 @@ fn update_graph_edges(state: &mut VisualizationTabState) { fn render_control_panel(ui: &mut egui::Ui, state: &mut VisualizationTabState) { egui::TopBottomPanel::top("visualization_controls").show(ui.ctx(), |ui| { ui.vertical(|ui| { - // Row 1: Button + label + // Row 1: Buttons + label ui.horizontal(|ui| { if !state.visualization_running { - if ui.button("Start Visualization").clicked() { + // Continue: resume without resetting flow counts + if ui.button("Continue").clicked() { + // Clone config to avoid borrow issues + // Pass the user config if loaded, otherwise None (uses default BN model) + let config = state.config_content.clone(); + let speed = state.speed.clone(); + if let Err(e) = state.start_visualization(config.as_deref(), speed, false) { + log::error!("Failed to start flow streamer: {}", e); + } + } + + // Restart: reset all flow counts and start fresh + if ui.button("Restart").clicked() { // Clone config to avoid borrow issues // Pass the user config if loaded, otherwise None (uses default BN model) let config = state.config_content.clone(); let speed = state.speed.clone(); - if let Err(e) = state.start_visualization(config.as_deref(), speed) { + if let Err(e) = state.start_visualization(config.as_deref(), speed, true) { log::error!("Failed to start flow streamer: {}", e); } } @@ -735,7 +822,11 @@ fn render_control_panel(ui: &mut egui::Ui, state: &mut VisualizationTabState) { } ui.separator(); - ui.label(format!("Active links: {}", state.active_links.len())); + ui.label(format!( + "Active links: {} | Total flows: {}", + state.active_links.len(), + state.total_flows + )); }); ui.separator(); From 3921f357ffc458ca4ec6d85a7d12239850187a88 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:25:37 +0100 Subject: [PATCH 39/77] feat(gui): add info tooltips, fix progress bar updates, improve layout stability --- .../fosr-gui/src/generation/generation_tab.rs | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 3dbef3fb..247d4158 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -105,6 +105,19 @@ impl Default for GenerationTabState { } } +/// Display a small info icon with a tooltip. +fn info_icon(ui: &mut egui::Ui, tooltip: &str) { + // Reduce spacing before the icon + ui.add_space(-4.0); + // Reduce tooltip delay + ui.ctx().style_mut(|s| s.interaction.tooltip_delay = 0.1); + ui.label(egui::RichText::new("ℹ").color(egui::Color32::GRAY).size(14.0)) + .on_hover_ui(|ui| { + ui.set_max_width(300.0); + ui.label(tooltip); + }); +} + pub fn show_generation_tab_content( ui: &mut egui::Ui, state: &mut GenerationTabState, @@ -116,6 +129,7 @@ pub fn show_generation_tab_content( ui.horizontal(|ui| { ui.label("Duration"); + info_icon(ui, "Minimum pcap traffic duration described in human-friendly time, such as \"30m\", \"1h\", \"2d\" or \"15days 30min 5s\"."); // The only way to set the slider width currently is to set it globally. // If we need another slider at some point, this value should be mutated @@ -155,7 +169,10 @@ pub fn show_generation_tab_content( ui.add_space(10.0); - ui.checkbox(&mut state.use_current_time, "Use current time for start time"); + ui.horizontal(|ui| { + ui.checkbox(&mut state.use_current_time, "Use current time for start time"); + info_icon(ui, "Beginning time of the pcap. By default, use the current time. For deterministic generation, you must specify this along with duration, timezone and seed."); + }); if !state.use_current_time { ui.horizontal(|ui| { @@ -178,6 +195,7 @@ pub fn show_generation_tab_content( state.timezone_input = Tz::CET.to_string(); } } + info_icon(ui, "Timezone used for realistic work hours. Use an IANA time zone (like Europe/Paris) or an abbreviation (like CET). The offset is assumed constant during the generation time range."); if !state.use_local_timezone { timezone_picker(ui, state); @@ -197,6 +215,7 @@ pub fn show_generation_tab_content( ui.horizontal(|ui| { ui.checkbox(&mut state.use_seed, "Seed"); + info_icon(ui, "Seed for random number generation. For deterministic generation, you must also specify duration, start time, and timezone."); if state.use_seed { let response = ui.add( @@ -226,24 +245,14 @@ pub fn show_generation_tab_content( ui.horizontal(|ui| { ui.checkbox(&mut state.taint, "Taint the packets"); + info_icon(ui, "Taint the packets with special markers for identification."); + ui.add_space(10.0); ui.checkbox(&mut state.order_pcap, "Order temporally"); + info_icon(ui, "Enable temporal sorting of the generated pcap. Disable to reduce significantly the RAM usage."); }); ui.add_space(20.0); - show_status(ui, &state.status); - if let Some(throughput) = &state.throughput { - ui.label(format!("Throughput: {throughput}")); - } - if let Some((name, spec, err)) = first_invalid_param(state) { - ui.colored_label( - egui::Color32::RED, - format!("Invalid parameter: {name}. Expected: {spec}. ({err})"), - ); - ui.add_space(8.0); - } - ui.add_space(8.0); - let can_generate = first_invalid_param(&state).is_none(); let is_generating = matches!(state.status, UiStatus::Generating); @@ -366,6 +375,8 @@ pub fn show_generation_tab_content( // Poll receivers (must be outside add_enabled_ui to run while generating) if let Some(receiver) = &state.progress_receiver { + // Request repaint to keep polling while generating + ui.ctx().request_repaint(); if let Ok(progress) = receiver.try_recv() { state.progress = progress; if progress >= 1.0 { @@ -456,4 +467,17 @@ pub fn show_generation_tab_content( ui.add_sized([ui.available_width(), 20.0], progress); } + + show_status(ui, &state.status); + + if let Some(throughput) = &state.throughput { + ui.label(format!("Throughput: {throughput}")); + } + + if let Some((name, spec, err)) = first_invalid_param(state) { + ui.colored_label( + egui::Color32::RED, + format!("Invalid parameter: {name}. Expected: {spec}. ({err})"), + ); + } } From 7b524c4cfd5e38e18d264ea6fedf97a7a1961b45 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:37:39 +0100 Subject: [PATCH 40/77] feat(gui): use icons for node types instead of colored circles --- crates/fosr-gui/assets/computer.png | Bin 0 -> 2818 bytes crates/fosr-gui/assets/internet.png | Bin 0 -> 14715 bytes crates/fosr-gui/assets/server.png | Bin 0 -> 256 bytes crates/fosr-gui/src/app.rs | 12 ++- .../src/visualization/visualization_shapes.rs | 89 ++++++++++-------- .../src/visualization/visualization_tab.rs | 51 ++++++---- 6 files changed, 95 insertions(+), 57 deletions(-) create mode 100644 crates/fosr-gui/assets/computer.png create mode 100644 crates/fosr-gui/assets/internet.png create mode 100644 crates/fosr-gui/assets/server.png diff --git a/crates/fosr-gui/assets/computer.png b/crates/fosr-gui/assets/computer.png new file mode 100644 index 0000000000000000000000000000000000000000..c3d5e4590558b7dd8f0484ec3630fcddb250db91 GIT binary patch literal 2818 zcmds1Yfw{X8h+2oMV3P((`q&aA>i7smqu6-xtD|BDx*>r2b~lltWuC$WQjtylpNF* z*zQ(Y2Sl8f=&+H}N&P+;4-j9^Iv`g zg*}C_%C!l|56)gI$d$3)DrlE(izC{r7f<~$)>!pe>@7_h{OI$X=jQC>oRkWgHrI0e z4Z8QQO){;@WObOm#*`e?2|~BY>g4xhDEQd-&a<*Lik8ps zAH02PaR11ltL3}u)&sJn^K616nK7x6p@uG4^!6y93wib9&eX;}a9P5fuF!YIIl;8{ zopWDaC_M-4he{42y?R+VRK3b7m$(y%taJWnIUk%xjL(!ea^&Ks`N!8IC?qZ|qZWTL zQ~00d9I`aC=1w29Jn6Y5;5S8&s|RDo+=#4YcPXq7m}4!%nw!5Po-Y-YXJ@(-(B6ku z^&M#rMTb5u5yHW-yH8M7KL-y=d-#VVpn>@Gz*J z<=tOBc_JGg=$8mO7$Lcr+WZ^($sOMv$n$7E-@jkRoFjM6f^#~rO2t^u!IMH_dAV#? z51R>TL5-{}-^7DB))N7O2Id5>XMQozoc-r?zHUo8Z_awJ<|N?thyDL`euXuM>~nA& zpoZF=y(rCa<$aVWFw>A}>EQIxuVadbN#0>;xxRf3RPD_u1hW1gnFyZoN}4gRR>Qvj zNGLYxcz`fS-5TA66fD@hvA&*z<2N<)GY_#zi>rc#GsL!e9jF5FwKGzY4yc$xE<#3@ zfww*9q=v&*@SjYkBm}6}GJK`SHFX&bf`>D>H@_Yh;Mi=Ag7q>d6*f^Ig4LjugAm-H zJDi|9E82Jq&r-4kfd^`I*UeOS9oMW}#I|Ks>UVu}H;_pN1grA<$D_Y+`Ov1C+$hcZ zsgSd}j6v=i_dqIUHdAzoO_WlOV|3N-a0LsxwZnoAlo%E$ri5gsNRD%*12thuj!1Dx z`k-&eJU>TV2P(D&*cl=k$&1AQ;UMo>gN-9Gl)v`93;DS}bR`Xqu>3{D3e(e)NW(Lp zSY$RNHtIEHkr9@t4BMzYPlGkN6%F{mkCEK2dTiM#QFR4kY>XUjKB3`3bl#)=!(2;m z;Lt7YSLsED#`iH|fyP=H$ZS2p3xrfvJarK^knVLWiWF04?opnMCUzQftY9;hw62Z{ zWAF3Op#EpPbx+;iH>2QKA2|8Fqt^;1Qn9tLzgS zp30BXtce`Eq6i&TRp)p1Fb~IwVS^=H`jqm~U)g*56Cvyb6Nbp+8}T%sM7<=#4e2SO=A~VpCrz_Tmf0I%c26{aMgk zFH&&w%}$FeUo#uvk~kZ{_7#%lBSF?$17WH|@GVbRJ&)R{qE9q2Od_vaYx5x5H07NI zLn(!>Kk9;9aotZ5kJ;i<4Z47sw8(3a;?vm^^Ss8xywiXmVnB-X6SWO`ec5N<3zzHbi#&TCoTLxzaTo=pT#WI)rREp`PTxAuc!9x$J5Qt&R&8u=a*PzcE+(pOt@z-70nvIv7ytkO literal 0 HcmV?d00001 diff --git a/crates/fosr-gui/assets/internet.png b/crates/fosr-gui/assets/internet.png new file mode 100644 index 0000000000000000000000000000000000000000..65546b98b2bd4ccd96d6d19deb0ed3c1f1a303cd GIT binary patch literal 14715 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%0AX@a_>_wIt#F}NPN~0 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 literal 0 HcmV?d00001 diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index 9e50158a..f68f1ae7 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -35,6 +35,7 @@ pub const DEFAULT_ZOOM: f32 = 1.4; pub struct FosrApp { current_tab: CurrentTab, zoom_initialized: bool, + images_preloaded: bool, configuration_file_state: ConfigurationFileState, configuration_tab_state: ConfigurationTabState, visualization_tab_state: VisualizationTabState, @@ -53,6 +54,15 @@ impl eframe::App for FosrApp { // Required for egui to display images egui_extras::install_image_loaders(ctx); + // Preload all images to avoid spinners/fallbacks on first visit + if !self.images_preloaded { + let _ = egui::include_image!("../assets/server.png").load(ctx, Default::default(), Default::default()); + let _ = egui::include_image!("../assets/computer.png").load(ctx, Default::default(), Default::default()); + let _ = egui::include_image!("../assets/internet.png").load(ctx, Default::default(), Default::default()); + let _ = egui::include_image!("../../../public/logo.png").load(ctx, Default::default(), Default::default()); + self.images_preloaded = true; + } + // The Top Panel is logically at the top of the window. egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { // Add a Menu Bar to host the tabs buttons @@ -128,7 +138,7 @@ impl eframe::App for FosrApp { show_visualization_tab_content( ui, &mut self.visualization_tab_state, - &self.configuration_file_state, + &mut self.configuration_file_state, ); } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/visualization_shapes.rs index 01437b44..94ad9a43 100644 --- a/crates/fosr-gui/src/visualization/visualization_shapes.rs +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -2,14 +2,14 @@ use crate::visualization::visualization_tab::{EdgeData, EdgeState, LinkDirection, NodeData, NodeType}; use eframe::egui; -use egui::{Color32, Pos2, Shape, Vec2}; +use egui::{Color32, Pos2, Rect, Shape, TextureOptions, Vec2, load::SizeHint}; use egui_graphs::{DisplayEdge, DisplayNode, DrawContext, Node, NodeProps}; use fosr_lib::L7Proto; -// Color constants for node types -pub const COLOR_SERVER: Color32 = Color32::from_rgb(46, 204, 113); // Green -pub const COLOR_USER: Color32 = Color32::from_rgb(52, 152, 219); // Blue -pub const COLOR_INTERNET: Color32 = Color32::from_rgb(231, 76, 60); // Red +// Embedded node images +const IMG_SERVER: egui::ImageSource = egui::include_image!("../../assets/server.png"); +const IMG_COMPUTER: egui::ImageSource = egui::include_image!("../../assets/computer.png"); +const IMG_INTERNET: egui::ImageSource = egui::include_image!("../../assets/internet.png"); // Color constants for edge states pub const COLOR_INACTIVE: Color32 = Color32::from_rgb(200, 200, 200); // Light gray @@ -17,9 +17,11 @@ pub const COLOR_HTTP: Color32 = Color32::from_rgb(52, 152, 219); // Blue pub const COLOR_HTTPS: Color32 = Color32::from_rgb(46, 204, 113); // Green pub const COLOR_SSH: Color32 = Color32::from_rgb(155, 89, 182); // Purple pub const COLOR_DNS: Color32 = Color32::from_rgb(230, 126, 34); // Orange +pub const COLOR_SMTP: Color32 = Color32::from_rgb(241, 196, 15); // Yellow +pub const COLOR_OTHER: Color32 = Color32::from_rgb(149, 165, 166); // Gray // Node radius constants - all nodes grow with flow count -const RADIUS_MIN: f32 = 5.0; // Starting size for all nodes +const RADIUS_MIN: f32 = 15.0; // Starting size for all nodes const RADIUS_MAX: f32 = 25.0; // Maximum size const FLOW_SCALE_FACTOR: f32 = 0.3; // Radius increase per flow @@ -27,19 +29,18 @@ const EDGE_WIDTH_MIN: f32 = 0.2; const EDGE_WIDTH_MAX: f32 = 3.0; const EDGE_FLOW_SCALE: f32 = 0.1; // Width increase per flow (linear phase) -/// Custom node shape that displays hostname and IP, with color based on node type +/// Custom node shape that displays hostname and IP, with icon based on node type #[derive(Clone)] pub struct NetworkNodeShape { radius: f32, - color: Color32, label: String, location: Pos2, - is_internet: bool, + node_type: NodeType, } impl NetworkNodeShape { /// Compute node style from payload data. - fn style_from_payload(payload: &NodeData) -> (f32, Color32, bool, String) { + fn style_from_payload(payload: &NodeData) -> (f32, NodeType, String) { // Hybrid linear/proportional radius scaling let max_linear = RADIUS_MIN + payload.max_flow_count as f32 * FLOW_SCALE_FACTOR; let radius = if max_linear < RADIUS_MAX { @@ -55,25 +56,27 @@ impl NetworkNodeShape { RADIUS_MIN + ratio * (RADIUS_MAX - RADIUS_MIN) }; - let (color, is_internet) = match payload.node_type { - NodeType::Internet => (COLOR_INTERNET, true), - NodeType::Server => (COLOR_SERVER, false), - NodeType::User => (COLOR_USER, false), - }; + (radius, payload.node_type.clone(), payload.to_string()) + } - (radius, color, is_internet, payload.to_string()) + /// Get the image source for this node type + fn image_for_node_type(node_type: &NodeType) -> egui::ImageSource<'static> { + match node_type { + NodeType::Internet => IMG_INTERNET, + NodeType::Server => IMG_SERVER, + NodeType::User => IMG_COMPUTER, + } } } impl From> for NetworkNodeShape { fn from(props: NodeProps) -> Self { - let (radius, color, is_internet, label) = Self::style_from_payload(&props.payload); + let (radius, node_type, label) = Self::style_from_payload(&props.payload); Self { radius, - color, label, location: props.location(), - is_internet, + node_type, } } } @@ -97,19 +100,21 @@ for NetworkNodeShape let pos = ctx.meta.canvas_to_screen_pos(self.location); let radius = ctx.meta.canvas_to_screen_size(self.radius); - // Draw filled circle - shapes.push(Shape::circle_filled(pos, radius, self.color)); + // Load and draw node icon + let image_source = Self::image_for_node_type(&self.node_type); + let size = radius * 2.0; + let rect = Rect::from_center_size(pos, Vec2::splat(size)); - // Draw circle stroke (thicker for Internet node) - let stroke_width = if self.is_internet { 2.5 } else { 1.5 }; - shapes.push(Shape::circle_stroke( - pos, - radius, - egui::Stroke::new(stroke_width, Color32::DARK_GRAY), - )); + if let Ok(egui::load::TexturePoll::Ready { texture }) = + image_source.load(ctx.ctx, TextureOptions::default(), SizeHint::default()) + { + let uv = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); + shapes.push(Shape::image(texture.id, rect, uv, Color32::WHITE)); + } // Draw text label - let font_size = if self.is_internet { 14.0 } else { 10.0 }; + let is_internet = matches!(self.node_type, NodeType::Internet); + let font_size = if is_internet { 14.0 } else { 10.0 }; let font_id = egui::FontId::proportional(font_size); let job = egui::text::LayoutJob::simple( @@ -129,10 +134,9 @@ for NetworkNodeShape } fn update(&mut self, state: &NodeProps) { - let (radius, color, is_internet, label) = Self::style_from_payload(&state.payload); + let (radius, node_type, label) = Self::style_from_payload(&state.payload); self.radius = radius; - self.color = color; - self.is_internet = is_internet; + self.node_type = node_type; self.label = label; self.location = state.location(); } @@ -165,11 +169,12 @@ fn edge_style(edge_data: &EdgeData) -> (Color32, f32, bool, bool) { } EdgeState::Active { protocol, direction, .. } => { let color = match protocol { - L7Proto::HTTP => Color32::from_rgb(52, 152, 219), // Blue - L7Proto::HTTPS => Color32::from_rgb(46, 204, 113), // Green - L7Proto::SSH => Color32::from_rgb(155, 89, 182), // Purple - L7Proto::DNS => Color32::from_rgb(230, 126, 34), // Orange - _ => Color32::from_rgb(149, 165, 166), // Gray + L7Proto::HTTP => COLOR_HTTP, + L7Proto::HTTPS => COLOR_HTTPS, + L7Proto::SSH => COLOR_SSH, + L7Proto::DNS => COLOR_DNS, + L7Proto::SMTP => COLOR_SMTP, + _ => COLOR_OTHER, }; let (arrow_start, arrow_end) = match direction { LinkDirection::Forward => (false, true), @@ -249,12 +254,18 @@ DisplayEdge< let arrow_size = ctx.meta.canvas_to_screen_size(16.0); let arrow_angle = std::f32::consts::PI / 6.0; + // Extend arrow tip past the line to avoid square appearance due to line width + let arrow_tip_offset = ctx.meta.canvas_to_screen_size(self.width); if self.arrow_end { - shapes.push(arrow_head(start_pos, end_pos, arrow_size, arrow_angle, self.color)); + let dir = (end_pos - start_pos).normalized(); + let extended_end = end_pos + dir * arrow_tip_offset; + shapes.push(arrow_head(start_pos, extended_end, arrow_size, arrow_angle, self.color)); } if self.arrow_start { - shapes.push(arrow_head(end_pos, start_pos, arrow_size, arrow_angle, self.color)); + let dir = (start_pos - end_pos).normalized(); + let extended_start = start_pos + dir * arrow_tip_offset; + shapes.push(arrow_head(end_pos, extended_start, arrow_size, arrow_angle, self.color)); } shapes diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 48ec92a0..d41369b9 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -1,6 +1,6 @@ use super::visualization_shapes::{ NetworkEdgeShape, NetworkNodeShape, COLOR_DNS, COLOR_HTTP, COLOR_HTTPS, COLOR_INACTIVE, - COLOR_INTERNET, COLOR_SERVER, COLOR_SSH, COLOR_USER, + COLOR_OTHER, COLOR_SMTP, COLOR_SSH, }; use super::visualization_stream::{FlowEvent, FlowStreamer}; use super::visualization_utils::distribute_nodes_circle; @@ -194,7 +194,7 @@ pub struct VisualizationTabState { /// Frames to wait before auto-starting. /// Using a countdown instead of a boolean allows to render the UI before starting the visualization. /// This avoids lag when clicking on the Visualization tab. - /// Note: 2 frames minimum required for all UI elements to display properly on first tab visit. + /// Note: 10 frames is an arbitrary value that gives enough time for the UI to render and images to load. auto_start_countdown: Option, /// Total number of flows processed since visualization started total_flows: u32, @@ -320,7 +320,7 @@ impl VisualizationTabState { events_buffer: Rc::new(RefCell::new(Vec::new())), clicked_node: None, node_info_modal_open: false, - auto_start_countdown: Some(2), + auto_start_countdown: Some(10), total_flows: 0, } } @@ -480,8 +480,11 @@ impl VisualizationTabState { pub fn show_visualization_tab_content( ui: &mut egui::Ui, state: &mut VisualizationTabState, - configuration_file_state: &ConfigurationFileState, + configuration_file_state: &mut ConfigurationFileState, ) { + // Load config file contents if a file is selected but content not yet loaded + crate::shared::configuration_file::load_config_file_contents(configuration_file_state); + // Handle config changes handle_config_changes(state, configuration_file_state); @@ -555,7 +558,7 @@ fn handle_config_changes( let config = config::import_config(config_content); state.update_from_config(&config); state.config_content = Some(config_content.clone()); - state.auto_start_countdown = Some(2); + state.auto_start_countdown = Some(10); } } } @@ -834,19 +837,24 @@ fn render_control_panel(ui: &mut egui::Ui, state: &mut VisualizationTabState) { // Row 3: Legend - Node types ui.horizontal(|ui| { ui.label("Node Types:"); - legend_item_inline(ui, "Server", COLOR_SERVER); - legend_item_inline(ui, "User", COLOR_USER); - legend_item_inline(ui, "Internet", COLOR_INTERNET); + legend_item_with_image(ui, "Server", egui::include_image!("../../assets/server.png")); + legend_item_with_image(ui, "User", egui::include_image!("../../assets/computer.png")); + legend_item_with_image(ui, "Internet", egui::include_image!("../../assets/internet.png")); }); - // Row 4: Legend - Edge states + // Row 4-5: Legend - Edge states ui.horizontal(|ui| { ui.label("Edge States:"); legend_item_inline(ui, "Inactive", COLOR_INACTIVE); legend_item_inline(ui, "HTTP", COLOR_HTTP); legend_item_inline(ui, "HTTPS", COLOR_HTTPS); legend_item_inline(ui, "SSH", COLOR_SSH); + }); + ui.horizontal(|ui| { + ui.add_space(80.0); // Align with items above legend_item_inline(ui, "DNS", COLOR_DNS); + legend_item_inline(ui, "SMTP", COLOR_SMTP); + legend_item_inline(ui, "Other", COLOR_OTHER); }); }); }); @@ -889,15 +897,14 @@ fn render_node_info_modal(ctx: &egui::Context, state: &mut VisualizationTabState ui.separator(); - // Node type with colored indicator + // Node type with icon ui.horizontal(|ui| { - let (color, type_str) = match node_data.node_type { - NodeType::Server => (COLOR_SERVER, "Server"), - NodeType::User => (COLOR_USER, "User"), - NodeType::Internet => (COLOR_INTERNET, "Internet"), + let (image, type_str) = match node_data.node_type { + NodeType::Server => (egui::include_image!("../../assets/server.png"), "Server"), + NodeType::User => (egui::include_image!("../../assets/computer.png"), "User"), + NodeType::Internet => (egui::include_image!("../../assets/internet.png"), "Internet"), }; - let rect = ui.allocate_space(egui::vec2(12.0, 12.0)).1; - ui.painter().circle_filled(rect.center(), 6.0, color); + ui.add(egui::Image::new(image).fit_to_exact_size(egui::vec2(20.0, 20.0))); ui.label(egui::RichText::new(type_str).strong()); }); @@ -940,14 +947,24 @@ fn render_node_info_modal(ctx: &egui::Context, state: &mut VisualizationTabState } } -/// Helper to render a single legend item inline +/// Helper to render a single legend item inline (for edges) fn legend_item_inline(ui: &mut egui::Ui, label: &str, color: egui::Color32) { // Allocate space first let rect = ui.allocate_space(egui::vec2(12.0, 12.0)).1; // Then get painter and draw let painter = ui.painter(); painter.circle_filled(rect.center(), 6.0, color); + ui.add_space(-2.0); + ui.label(label); + ui.add_space(6.0); +} + +/// Helper to render a legend item with an image (for nodes) +fn legend_item_with_image(ui: &mut egui::Ui, label: &str, image: egui::ImageSource) { + ui.add(egui::Image::new(image).fit_to_exact_size(egui::vec2(20.0, 20.0))); + ui.add_space(-2.0); ui.label(label); + ui.add_space(6.0); } /// Render the graph view From 5c3d1d12197912fee11c9e80b0ec6434b65c1570 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:49:23 +0100 Subject: [PATCH 41/77] refactor(gui): sync config edits across tabs in real-time --- Cargo.lock | 6 ++--- .../src/configuration/configuration_tab.rs | 23 +++++++++-------- .../fosr-gui/src/generation/generation_tab.rs | 25 ++++++++++--------- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18031c81..164b2bf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1420,7 +1420,7 @@ checksum = "0673eb7bb77708b8f59ef30fd90fa6537026f078fe72579ee68720073b6c8423" dependencies = [ "crossbeam", "egui", - "getrandom 0.2.16", + "getrandom 0.2.17", "petgraph", "rand 0.9.2", "serde", @@ -1519,7 +1519,7 @@ checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1810,6 +1810,7 @@ dependencies = [ "petgraph", "rfd", "serde", + "serde_yaml", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -4714,7 +4715,6 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index f6b05cf5..a02eb57c 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -79,6 +79,9 @@ pub fn show_configuration_tab_content( _tab_state: &mut ConfigurationTabState, file_state: &mut ConfigurationFileState, ) { + // Eagerly load config file contents when a file is selected + load_config_file_contents(file_state); + egui::ScrollArea::vertical().show(ui, |ui| { // File Selection configuration_file_picker(ui, file_state); @@ -92,17 +95,17 @@ pub fn show_configuration_tab_content( ui.separator(); ui_hosts_section(ui, model); ui.separator(); + } - // YAML Preview Button - if ui.button("Export YAML (preview)").clicked() { - match serde_yaml::to_string(&*model) { - Ok(yaml) => { - file_state.config_file_content = Some(yaml); - file_state.parse_error = None; - } - Err(e) => { - file_state.parse_error = Some(e.to_string()); - } + // Auto-sync model edits back to YAML so other tabs see the changes + if let Some(model) = &file_state.config_model { + match serde_yaml::to_string(model) { + Ok(yaml) => { + file_state.config_file_content = Some(yaml); + file_state.parse_error = None; + } + Err(e) => { + file_state.parse_error = Some(e.to_string()); } } } diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 247d4158..ea305c0b 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -7,11 +7,13 @@ use super::generation_validation::{ FieldValidation, first_invalid_param, validate_duration, validate_optional_u64, validate_timezone, }; -use crate::shared::configuration_file::{ConfigurationFileState, configuration_file_picker}; +use crate::shared::configuration_file::{ + ConfigurationFileState, configuration_file_picker, load_config_file_contents, +}; #[cfg(not(target_arch = "wasm32"))] -use crate::shared::file_io::{read_file_desktop, save_file_desktop}; +use crate::shared::file_io::save_file_desktop; #[cfg(target_arch = "wasm32")] -use crate::shared::file_io::{read_file_wasm, save_file_wasm}; +use crate::shared::file_io::save_file_wasm; use chrono::{NaiveDate, NaiveTime}; use chrono_tz::Tz; use eframe::egui; @@ -123,6 +125,9 @@ pub fn show_generation_tab_content( state: &mut GenerationTabState, configuration_file_state: &mut ConfigurationFileState, ) { + // Eagerly load config file contents when a file is selected + load_config_file_contents(configuration_file_state); + configuration_file_picker(ui, configuration_file_state); ui.separator(); @@ -321,20 +326,17 @@ pub fn show_generation_tab_content( Some(state.timezone_input.clone()) }; let ctx = ui.ctx().clone(); - let file_handle = configuration_file_state.picked_config_file.clone(); let cancelled = state.cancelled.clone(); + // Prefer in-memory config content (reflects edits from Configuration tab) + // over re-reading the file from disk + let config_content = configuration_file_state.config_file_content.clone(); #[cfg(target_arch = "wasm32")] { wasm_bindgen_futures::spawn_local(async move { - let profile = if let Some(file) = file_handle.as_ref() { - Some(read_file_wasm(file).await) - } else { - None - }; generate( seed, - profile, + config_content, order_pcap, start_time, duration, @@ -352,10 +354,9 @@ pub fn show_generation_tab_content( #[cfg(not(target_arch = "wasm32"))] { std::thread::spawn(move || { - let profile = file_handle.as_ref().map(|file| read_file_desktop(file)); generate( seed, - profile, + config_content, order_pcap, start_time, duration, From 76d2b71f45f42c79a65b05ae6f097155a3c0965b Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:28:53 +0100 Subject: [PATCH 42/77] feat(gui/visualization): enable zoom + replace computer icon --- crates/fosr-gui/assets/computer.png | Bin 2818 -> 2654 bytes .../src/visualization/visualization_tab.rs | 20 +++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/fosr-gui/assets/computer.png b/crates/fosr-gui/assets/computer.png index c3d5e4590558b7dd8f0484ec3630fcddb250db91..5bad808177fe39d3db56924e57b2922f622a610c 100644 GIT binary patch literal 2654 zcmeHJX;4#H7Ji5;f@nLUfd*W$ZFLAq2oeNT#7Hv%SC+6wvO!2d5|#ua(oqqt zfHYu(N+1PM(O|%q5fu|wQ6!LHs{s=rB80F6GA}h#T{GoOmHoe8)vJ5X_wM(dbI&>T z>RveFgElj%el^lht`Kl)t>WeB# zJxRJd!d{zStm=D-;pb{E5|0jPJgQkCNObt|*BXk5FX`PrwnkDG`pPq7A}$lDsW=01 zDyl7auDYh_em3;Jm#Q3^B|PlTvXa!vdQQEB$9-R;dRnWH_-_Fz?8~NEJY{o${Fr4- zSMbXZ?i>w@T>Fz#jmYC$+d~~(hxr4@=I`R}17&HK$s4y?F2At!Ys?OWe9K^Ms3sIJ?tdkfc~8S8 zvK0jN66hmMaCxmO=Z|Rpx%VrJ{zakK1Ib1lU4OEXX{EC1mPR)A&%{$%_G!-pCPN?DV&Gh`nU84qNWTx!7p%TRf?+nZi09ehQJhG(csXHm(O+V1pplsOpXR84(VX5IcJ%Rqfq7RyMZO)k0){ z_POxe=@?2~`;kC%A@7Ck1?jPcarpy^c9y$I48fvW10&ixy4JLTy05Znukzt{67T{r z0aFi>K zZ35!3w{fUhO6!zB=sGfAbk(!o*Q8!krhVcR@BHcM)k2nEp_{iYR#)3zP#WYfm4y&> z@sbYFhcc}wCVIeuTfz!UiSW>m#?K0g2FN9ELag;msR^L>W++}u&Q9eZ1z?IJg0bu3 z@t6*xBgIc4wwZS#CB~ar-GNBBJL$xn*8`lH$$-hmH4+IPtjJt-!_eoC<3eo~nPw+Y zL=A5C7pdv=?%tw-p2hOiU(#NrY02jds!o&q@yj^`bzx_}DN)k%Xh{uA{%%dm$n{pe z&C{m+g*u)CY05=y)2es(!Eun|fpo{%(^WEU;$jbXwe@W62-|}g+inFOFJ?uO37v>7 z6f~f+%=*reB&@;5ej@eg@`}684OQK}KU<$_w8!pxQN+xAo65{c-IXlAYUmLr6#dZV z{>_u>In>3Rfwd3xd(NBnZ3WBYOxLw@ZgYQAjMVrLILKcE1vdA8z9iE!{?hC&z02gW z!2 zU2%{gaN>R1@ley?$#```iD(1v6F#SRTj9D?!hZZkHT@9dY@g*G5vO#o{*SY2CM4CO!MSkZ}(^Q7PMb{z7lpk6y;+ zfiiuE|M>pFe}F%JY57dGwk~}0y*uGu`^d?N*P?4n)}ChTJ)|Xv=zg}z{B?G33;T5A z9-w#yjRm@ciZif=rV3`aA&DTDUd^r{0k(d=sR#ENso7P4uo9%_YKcKGb5Z{r-rGB) z`S6pzi3?ZsFV&jYGF3Wlp;3jl_qEYQsFe@3t)-W=ns+r}tUX+T@N*`~rvu|EZ*;8c zTb~uZ$J5+S;BNUQHfn0?ME>?@nvy9RWm`jks-OFbDZVlD{$XfxhGlj?b-r4r)>D18 z`n=n>XZ`2P&+yNe^vB+ysW76Xe)?Z+T_W9F__oSm)VEhKB7T@H9Ovd;K7&7c39D)J z+b|-e)r*4QUv(P+fKMM5R&MLQJl*Y{BH{?(0>Gd!bQ8n6-&{}+K8gO4OK|36`e-~CvS@ZIkb7?D&0oq?mqe#t?={uN}s z-!GXU2V&qHk0CM=ad8A-KR)IZVWSrnM_>cbtp~tF) literal 2818 zcmds1Yfw{X8h+2oMV3P((`q&aA>i7smqu6-xtD|BDx*>r2b~lltWuC$WQjtylpNF* z*zQ(Y2Sl8f=&+H}N&P+;4-j9^Iv`g zg*}C_%C!l|56)gI$d$3)DrlE(izC{r7f<~$)>!pe>@7_h{OI$X=jQC>oRkWgHrI0e z4Z8QQO){;@WObOm#*`e?2|~BY>g4xhDEQd-&a<*Lik8ps zAH02PaR11ltL3}u)&sJn^K616nK7x6p@uG4^!6y93wib9&eX;}a9P5fuF!YIIl;8{ zopWDaC_M-4he{42y?R+VRK3b7m$(y%taJWnIUk%xjL(!ea^&Ks`N!8IC?qZ|qZWTL zQ~00d9I`aC=1w29Jn6Y5;5S8&s|RDo+=#4YcPXq7m}4!%nw!5Po-Y-YXJ@(-(B6ku z^&M#rMTb5u5yHW-yH8M7KL-y=d-#VVpn>@Gz*J z<=tOBc_JGg=$8mO7$Lcr+WZ^($sOMv$n$7E-@jkRoFjM6f^#~rO2t^u!IMH_dAV#? z51R>TL5-{}-^7DB))N7O2Id5>XMQozoc-r?zHUo8Z_awJ<|N?thyDL`euXuM>~nA& zpoZF=y(rCa<$aVWFw>A}>EQIxuVadbN#0>;xxRf3RPD_u1hW1gnFyZoN}4gRR>Qvj zNGLYxcz`fS-5TA66fD@hvA&*z<2N<)GY_#zi>rc#GsL!e9jF5FwKGzY4yc$xE<#3@ zfww*9q=v&*@SjYkBm}6}GJK`SHFX&bf`>D>H@_Yh;Mi=Ag7q>d6*f^Ig4LjugAm-H zJDi|9E82Jq&r-4kfd^`I*UeOS9oMW}#I|Ks>UVu}H;_pN1grA<$D_Y+`Ov1C+$hcZ zsgSd}j6v=i_dqIUHdAzoO_WlOV|3N-a0LsxwZnoAlo%E$ri5gsNRD%*12thuj!1Dx z`k-&eJU>TV2P(D&*cl=k$&1AQ;UMo>gN-9Gl)v`93;DS}bR`Xqu>3{D3e(e)NW(Lp zSY$RNHtIEHkr9@t4BMzYPlGkN6%F{mkCEK2dTiM#QFR4kY>XUjKB3`3bl#)=!(2;m z;Lt7YSLsED#`iH|fyP=H$ZS2p3xrfvJarK^knVLWiWF04?opnMCUzQftY9;hw62Z{ zWAF3Op#EpPbx+;iH>2QKA2|8Fqt^;1Qn9tLzgS zp30BXtce`Eq6i&TRp)p1Fb~IwVS^=H`jqm~U)g*56Cvyb6Nbp+8}T%sM7<=#4e2SO=A~VpCrz_Tmf0I%c26{aMgk zFH&&w%}$FeUo#uvk~kZ{_7#%lBSF?$17WH|@GVbRJ&)R{qE9q2Od_vaYx5x5H07NI zLn(!>Kk9;9aotZ5kJ;i<4Z47sw8(3a;?vm^^Ss8xywiXmVnB-X6SWO`ec5N<3zzHbi#&TCoTLxzaTo=pT#WI)rREp`PTxAuc!9x$J5Qt&R&8u=a*PzcE+(pOt@z-70nvIv7ytkO diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index d41369b9..c275654c 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -198,6 +198,8 @@ pub struct VisualizationTabState { auto_start_countdown: Option, /// Total number of flows processed since visualization started total_flows: u32, + /// Flag to request a zoom/pan reset on the next frame + pub reset_view_requested: bool, } impl Default for VisualizationTabState { @@ -322,6 +324,7 @@ impl VisualizationTabState { node_info_modal_open: false, auto_start_countdown: Some(10), total_flows: 0, + reset_view_requested: false, } } @@ -805,6 +808,10 @@ fn render_control_panel(ui: &mut egui::Ui, state: &mut VisualizationTabState) { state.stop_visualization(); } } + + if ui.button("Reset view").clicked() { + state.reset_view_requested = true; + } }); ui.separator(); @@ -975,6 +982,13 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { .with_node_clicking_enabled(true) .with_dragging_enabled(true); + // When reset is requested, enable fit-to-screen for one frame + // so egui_graphs recalculates the proper zoom/pan to center the graph + let fit_to_screen = state.reset_view_requested; + if state.reset_view_requested { + state.reset_view_requested = false; + } + let mut graph_view = egui_graphs::GraphView::< NodeData, EdgeData, @@ -987,7 +1001,11 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { >::new(&mut state.graph) .with_interactions(&interactions) .with_event_sink(&state.events_buffer) - .with_styles(&egui_graphs::SettingsStyle::new().with_labels_always(true)); + .with_styles(&egui_graphs::SettingsStyle::new().with_labels_always(true)) + .with_navigations(&egui_graphs::SettingsNavigation::new() + .with_fit_to_screen_enabled(fit_to_screen) + .with_zoom_and_pan_enabled(true) + ); // Disable force-directed layout to preserve circle layout // TODO: handle this properly instead of just deactivating the auto-layout From 5f44e370eddb328653707ecb31a54b23bccf8f0e Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:06:12 +0100 Subject: [PATCH 43/77] chore(gui): hide unimplemented Injection tab --- crates/fosr-gui/src/app.rs | 33 +++++++++++++++------------- crates/fosr-gui/src/injection_tab.rs | 1 + 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index f68f1ae7..b6c2da2c 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -3,8 +3,8 @@ use crate::configuration::configuration_tab::{ ConfigurationTabState, show_configuration_tab_content, }; use crate::generation::generation_tab::{GenerationTabState, show_generation_tab_content}; -#[cfg(not(target_arch = "wasm32"))] -use crate::injection_tab::show_injection_tab_content; +// #[cfg(not(target_arch = "wasm32"))] +// use crate::injection_tab::show_injection_tab_content; use crate::shared::configuration_file::ConfigurationFileState; use crate::visualization::visualization_tab::{ VisualizationTabState, show_visualization_tab_content, @@ -18,8 +18,9 @@ enum CurrentTab { Configuration, Visualization, Generation, - #[cfg(not(target_arch = "wasm32"))] - Injection, + // To be implemented + // #[cfg(not(target_arch = "wasm32"))] + // Injection, About, } @@ -98,13 +99,14 @@ impl eframe::App for FosrApp { { self.current_tab = CurrentTab::Visualization; } - #[cfg(not(target_arch = "wasm32"))] - if ui - .selectable_label(self.current_tab == CurrentTab::Injection, "Injection") - .clicked() - { - self.current_tab = CurrentTab::Injection; - } + // To be implemented + // #[cfg(not(target_arch = "wasm32"))] + // if ui + // .selectable_label(self.current_tab == CurrentTab::Injection, "Injection") + // .clicked() + // { + // self.current_tab = CurrentTab::Injection; + // } if ui .selectable_label(self.current_tab == CurrentTab::About, "About") .clicked() @@ -141,10 +143,11 @@ impl eframe::App for FosrApp { &mut self.configuration_file_state, ); } - #[cfg(not(target_arch = "wasm32"))] - CurrentTab::Injection => { - show_injection_tab_content(ui); - } + // Still not implemented + // #[cfg(not(target_arch = "wasm32"))] + // CurrentTab::Injection => { + // show_injection_tab_content(ui); + // } CurrentTab::About => { show_about_tab_content(ui); } diff --git a/crates/fosr-gui/src/injection_tab.rs b/crates/fosr-gui/src/injection_tab.rs index 9dfd6668..1e199766 100644 --- a/crates/fosr-gui/src/injection_tab.rs +++ b/crates/fosr-gui/src/injection_tab.rs @@ -1,3 +1,4 @@ +#![allow(dead_code)] #![cfg(not(target_arch = "wasm32"))] use eframe::egui; From 8b26550ac031baee37ff3e183e70a73b2378716b Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:08:20 +0100 Subject: [PATCH 44/77] chore(gui): icons color depends on light/dark mode + fit graph on window size change --- crates/fosr-gui/assets/computer.png | Bin 2654 -> 7110 bytes crates/fosr-gui/assets/internet.png | Bin 14715 -> 21606 bytes crates/fosr-gui/assets/server.png | Bin 256 -> 7055 bytes .../src/visualization/visualization_shapes.rs | 11 ++++++- .../src/visualization/visualization_tab.rs | 31 ++++++++++++++++-- 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/fosr-gui/assets/computer.png b/crates/fosr-gui/assets/computer.png index 5bad808177fe39d3db56924e57b2922f622a610c..4221f9ca2b1c8ad362796858bf57833f21b445cb 100644 GIT binary patch literal 7110 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 D`Q;S7 literal 2654 zcmeHJX;4#H7Ji5;f@nLUfd*W$ZFLAq2oeNT#7Hv%SC+6wvO!2d5|#ua(oqqt zfHYu(N+1PM(O|%q5fu|wQ6!LHs{s=rB80F6GA}h#T{GoOmHoe8)vJ5X_wM(dbI&>T z>RveFgElj%el^lht`Kl)t>WeB# zJxRJd!d{zStm=D-;pb{E5|0jPJgQkCNObt|*BXk5FX`PrwnkDG`pPq7A}$lDsW=01 zDyl7auDYh_em3;Jm#Q3^B|PlTvXa!vdQQEB$9-R;dRnWH_-_Fz?8~NEJY{o${Fr4- zSMbXZ?i>w@T>Fz#jmYC$+d~~(hxr4@=I`R}17&HK$s4y?F2At!Ys?OWe9K^Ms3sIJ?tdkfc~8S8 zvK0jN66hmMaCxmO=Z|Rpx%VrJ{zakK1Ib1lU4OEXX{EC1mPR)A&%{$%_G!-pCPN?DV&Gh`nU84qNWTx!7p%TRf?+nZi09ehQJhG(csXHm(O+V1pplsOpXR84(VX5IcJ%Rqfq7RyMZO)k0){ z_POxe=@?2~`;kC%A@7Ck1?jPcarpy^c9y$I48fvW10&ixy4JLTy05Znukzt{67T{r z0aFi>K zZ35!3w{fUhO6!zB=sGfAbk(!o*Q8!krhVcR@BHcM)k2nEp_{iYR#)3zP#WYfm4y&> z@sbYFhcc}wCVIeuTfz!UiSW>m#?K0g2FN9ELag;msR^L>W++}u&Q9eZ1z?IJg0bu3 z@t6*xBgIc4wwZS#CB~ar-GNBBJL$xn*8`lH$$-hmH4+IPtjJt-!_eoC<3eo~nPw+Y zL=A5C7pdv=?%tw-p2hOiU(#NrY02jds!o&q@yj^`bzx_}DN)k%Xh{uA{%%dm$n{pe z&C{m+g*u)CY05=y)2es(!Eun|fpo{%(^WEU;$jbXwe@W62-|}g+inFOFJ?uO37v>7 z6f~f+%=*reB&@;5ej@eg@`}684OQK}KU<$_w8!pxQN+xAo65{c-IXlAYUmLr6#dZV z{>_u>In>3Rfwd3xd(NBnZ3WBYOxLw@ZgYQAjMVrLILKcE1vdA8z9iE!{?hC&z02gW z!2 zU2%{gaN>R1@ley?$#```iD(1v6F#SRTj9D?!hZZkHT@9dY@g*G5vO#o{*SY2CM4CO!MSkZ}(^Q7PMb{z7lpk6y;+ zfiiuE|M>pFe}F%JY57dGwk~}0y*uGu`^d?N*P?4n)}ChTJ)|Xv=zg}z{B?G33;T5A z9-w#yjRm@ciZif=rV3`aA&DTDUd^r{0k(d=sR#ENso7P4uo9%_YKcKGb5Z{r-rGB) z`S6pzi3?ZsFV&jYGF3Wlp;3jl_qEYQsFe@3t)-W=ns+r}tUX+T@N*`~rvu|EZ*;8c zTb~uZ$J5+S;BNUQHfn0?ME>?@nvy9RWm`jks-OFbDZVlD{$XfxhGlj?b-r4r)>D18 z`n=n>XZ`2P&+yNe^vB+ysW76Xe)?Z+T_W9F__oSm)VEhKB7T@H9Ovd;K7&7c39D)J z+b|-e)r*4QUv(P+fKMM5R&MLQJl*Y{BH{?(0>Gd!bQ8n6-&{}+K8gO4OK|36`e-~CvS@ZIkb7?D&0oq?mqe#t?={uN}s z-!GXU2V&qHk0CM=ad8A-KR)IZVWSrnM_>cbtp~tF) diff --git a/crates/fosr-gui/assets/internet.png b/crates/fosr-gui/assets/internet.png index 65546b98b2bd4ccd96d6d19deb0ed3c1f1a303cd..9312f396d2e1f41e05a88e11ebb4e5b41a616091 100644 GIT binary patch literal 21606 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%0AX@a_>_wIt#F}NPN~0 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 YdNjmd{KB)pdEhEheb%%)m15XNd!TIRq8I0D zm9y)lc}W`$qw0eX@JU*F~N41wWR z2|dvrFEL{wGvpIF267%SE?gi+1HYlv+pt$UA|>1#uIDX_o#Mc{4|>CK#u#*qL6L1b zht=3bl@OtAcibyK9BZ+r_Y@%e_;Gsp)ja!plJU8l58ot4w=lq|YSq^awty5qW`njQ zW6EZ~`Jpw7P;Qbv+_@8amV-FKGwuI<<#vT7)K{CsisjcV%(n-|{8c%% z(B|L&hJ5lQW8m2YrXFuYy`eJDY)s)B&i>4)3N@jAYecLb>&-0@V#%T5TD>dvme=h+ zs{uDzmDaMJa_=cQKGKT`eoD9M6+%x^>MSoqYTVGwI0*K)32Dh{#MkyKze0Z13b4wN zkKE{^i;yPE&?`3$S+&uA7zs8Ry{dIUosE2JymsG6^lH@rX~}nXajMH|^y*UL2jpx2 zagdu8&MaY%7oSh1%^NwZ-?6rTeW+j!F=n~(*|mCOyh;cy?`dnQ7-VG!D`15-Qa1q= zW(^~6RkgJUoeNpnbH$YHmB{KUhT2hi?lh`Q*HkQ)o<5yrBcgVw5tUvR{t4xfr;fRI zm<2zg(kC(0{jL=i=q_I(hwEw1E@2k<;%Xj>0YlJneiNqk6s9_IzQ~v0+Z4-9_b*FG)~~ulDy{Tq=(7DJGU=RDivRNMey!I&~+~HPmHO*eK;@ zDx;D5@)B|@LslaHUETFDT#IW7R1EeIM`@?H^g-oNY05OX)QQi|`aMFllKNa(=^!gD zD_9riPxJI)0UnKNl3IFuk73V&34#K(&J7)``7|-qmDc*1_<=gs@wJPzFc(pa8ERgx z_XfJRvp&%h(YP`ZHIJc2XO^#K?(!vaxSrS#W4r8|WW zDrzIwBNc3OAx>akfCn1WPH9`i2BGo1$6-fCeImeioAO-)zh=FAx{{AkoXof;p*;Cs z>aG`w$-tL~PTLyYID^sFa6X5Dddwu`fTJh8?@;bA^2ANE4cIHnW0A)@{Lv2;Zg-r- zew)VXbLJAo8ifKP}kkOqRK={wH@DaMi@ zzhDN3XE)M-oE4rfjv*Dd-riE=V^H8>Ir#7Y2f`|pCR pl!5mEKO00qniEVruz5^U(e*X{zw!Kyof%I+VxF#kF6*2UngE<`H^u+} diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/visualization_shapes.rs index 94ad9a43..7912817b 100644 --- a/crates/fosr-gui/src/visualization/visualization_shapes.rs +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -11,6 +11,10 @@ const IMG_SERVER: egui::ImageSource = egui::include_image!("../../assets/server. const IMG_COMPUTER: egui::ImageSource = egui::include_image!("../../assets/computer.png"); const IMG_INTERNET: egui::ImageSource = egui::include_image!("../../assets/internet.png"); +// Icon tint: gray instead of pure black/white +pub const ICON_TINT_DARK: Color32 = Color32::from_rgb(180, 180, 180); +pub const ICON_TINT_LIGHT: Color32 = Color32::from_rgb(40, 40, 40); + // Color constants for edge states pub const COLOR_INACTIVE: Color32 = Color32::from_rgb(200, 200, 200); // Light gray pub const COLOR_HTTP: Color32 = Color32::from_rgb(52, 152, 219); // Blue @@ -109,7 +113,12 @@ for NetworkNodeShape image_source.load(ctx.ctx, TextureOptions::default(), SizeHint::default()) { let uv = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); - shapes.push(Shape::image(texture.id, rect, uv, Color32::WHITE)); + let tint = if ctx.ctx.style().visuals.dark_mode { + ICON_TINT_DARK + } else { + ICON_TINT_LIGHT + }; + shapes.push(Shape::image(texture.id, rect, uv, tint)); } // Draw text label diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index c275654c..9800b2ef 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -1,6 +1,6 @@ use super::visualization_shapes::{ NetworkEdgeShape, NetworkNodeShape, COLOR_DNS, COLOR_HTTP, COLOR_HTTPS, COLOR_INACTIVE, - COLOR_OTHER, COLOR_SMTP, COLOR_SSH, + COLOR_OTHER, COLOR_SMTP, COLOR_SSH, ICON_TINT_DARK, ICON_TINT_LIGHT, }; use super::visualization_stream::{FlowEvent, FlowStreamer}; use super::visualization_utils::distribute_nodes_circle; @@ -200,6 +200,8 @@ pub struct VisualizationTabState { total_flows: u32, /// Flag to request a zoom/pan reset on the next frame pub reset_view_requested: bool, + /// Previous screen size (to reset view on window resize) + last_screen_size: Option, } impl Default for VisualizationTabState { @@ -325,6 +327,7 @@ impl VisualizationTabState { auto_start_countdown: Some(10), total_flows: 0, reset_view_requested: false, + last_screen_size: None, } } @@ -562,6 +565,7 @@ fn handle_config_changes( state.update_from_config(&config); state.config_content = Some(config_content.clone()); state.auto_start_countdown = Some(10); + state.reset_view_requested = true; } } } @@ -911,7 +915,12 @@ fn render_node_info_modal(ctx: &egui::Context, state: &mut VisualizationTabState NodeType::User => (egui::include_image!("../../assets/computer.png"), "User"), NodeType::Internet => (egui::include_image!("../../assets/internet.png"), "Internet"), }; - ui.add(egui::Image::new(image).fit_to_exact_size(egui::vec2(20.0, 20.0))); + let tint = if ui.style().visuals.dark_mode { + ICON_TINT_DARK + } else { + ICON_TINT_LIGHT + }; + ui.add(egui::Image::new(image).fit_to_exact_size(egui::vec2(20.0, 20.0)).tint(tint)); ui.label(egui::RichText::new(type_str).strong()); }); @@ -968,7 +977,12 @@ fn legend_item_inline(ui: &mut egui::Ui, label: &str, color: egui::Color32) { /// Helper to render a legend item with an image (for nodes) fn legend_item_with_image(ui: &mut egui::Ui, label: &str, image: egui::ImageSource) { - ui.add(egui::Image::new(image).fit_to_exact_size(egui::vec2(20.0, 20.0))); + let tint = if ui.style().visuals.dark_mode { + ICON_TINT_DARK + } else { + ICON_TINT_LIGHT + }; + ui.add(egui::Image::new(image).fit_to_exact_size(egui::vec2(20.0, 20.0)).tint(tint)); ui.add_space(-2.0); ui.label(label); ui.add_space(6.0); @@ -982,6 +996,17 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { .with_node_clicking_enabled(true) .with_dragging_enabled(true); + // Reset view on window resize + let screen_size = ui.ctx().content_rect().size(); + match state.last_screen_size { + Some(last) if last != screen_size => { + state.last_screen_size = Some(screen_size); + state.reset_view_requested = true; + } + None => state.last_screen_size = Some(screen_size), + _ => {} + } + // When reset is requested, enable fit-to-screen for one frame // so egui_graphs recalculates the proper zoom/pan to center the graph let fit_to_screen = state.reset_view_requested; From 1c6d0a379a248ac3314b70c6ed4fd3227a9172de Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:22:26 +0100 Subject: [PATCH 45/77] fix(gui/config): do not reload config from file on every frame --- crates/fosr-gui/src/shared/configuration_file.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index eb56b2ea..7e29d9c1 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -118,6 +118,11 @@ pub fn configuration_file_picker( } pub fn load_config_file_contents(configuration_file_state: &mut ConfigurationFileState) { + // Already loaded — don't re-read from disk every frame + if configuration_file_state.config_file_content.is_some() { + return; + } + if let Some(file_handle) = &configuration_file_state.picked_config_file { #[cfg(not(target_arch = "wasm32"))] { From 55e77a0039c8e053f72dd870d949360bd5f0cd02 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:21:37 +0100 Subject: [PATCH 46/77] feat(gui/visualization): make host partially editable from info modal --- .../src/visualization/visualization_tab.rs | 164 +++++++++++++++--- 1 file changed, 140 insertions(+), 24 deletions(-) diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 9800b2ef..14acc2db 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -4,6 +4,7 @@ use super::visualization_shapes::{ }; use super::visualization_stream::{FlowEvent, FlowStreamer}; use super::visualization_utils::distribute_nodes_circle; +use crate::shared::config_model::Host; use crate::shared::configuration_file::ConfigurationFileState; use eframe::egui; use egui_graphs::{ @@ -191,6 +192,8 @@ pub struct VisualizationTabState { pub clicked_node: Option, /// Node info modal open state pub node_info_modal_open: bool, + /// Map from graph NodeIndex to config_model.hosts index + node_to_host: HashMap, /// Frames to wait before auto-starting. /// Using a countdown instead of a boolean allows to render the UI before starting the visualization. /// This avoids lag when clicking on the Visualization tab. @@ -202,6 +205,8 @@ pub struct VisualizationTabState { pub reset_view_requested: bool, /// Previous screen size (to reset view on window resize) last_screen_size: Option, + /// Edit buffer for the node info modal (cloned from config on open, applied on Save) + modal_edit_buffer: Option, } impl Default for VisualizationTabState { @@ -324,20 +329,23 @@ impl VisualizationTabState { events_buffer: Rc::new(RefCell::new(Vec::new())), clicked_node: None, node_info_modal_open: false, + node_to_host: HashMap::new(), auto_start_countdown: Some(10), total_flows: 0, reset_view_requested: false, last_screen_size: None, + modal_edit_buffer: None, } } /// Update state from a configuration (preserves some state) /// Note: caller should stop visualization before calling this if running pub fn update_from_config(&mut self, config: &config::Configuration) { - let (graph, known_ips, ip_to_node) = Self::build_graph_from_config(config); + let (graph, known_ips, ip_to_node, node_to_host) = Self::build_graph_from_config(config); self.graph = graph; self.known_ips = known_ips; self.ip_to_node = ip_to_node; + self.node_to_host = node_to_host; self.layout_initialized = false; } @@ -348,13 +356,15 @@ impl VisualizationTabState { VisualizationGraph, HashSet, HashMap, + HashMap, ) { let mut graph = VisualizationGraph::new(petgraph::stable_graph::StableGraph::default()); let mut known_ips = HashSet::new(); let mut ip_to_node: HashMap = HashMap::new(); + let mut node_to_host: HashMap = HashMap::new(); // Add one node per host (with all its IPs) - for host in &config.hosts { + for (host_idx, host) in config.hosts.iter().enumerate() { let all_ips: Vec = host.interfaces.iter().map(|i| i.ip_addr).collect(); let node_data = NodeData { @@ -366,6 +376,7 @@ impl VisualizationTabState { max_flow_count: 0, }; let idx = graph.add_node_with_location(node_data, egui::pos2(0.0, 0.0)); + node_to_host.insert(idx, host_idx); // Map all IPs of this host to the same node for ip in all_ips { @@ -401,7 +412,7 @@ impl VisualizationTabState { } } - (graph, known_ips, ip_to_node) + (graph, known_ips, ip_to_node, node_to_host) } /// Check if an IP is a known (configured) IP @@ -522,8 +533,8 @@ pub fn show_visualization_tab_content( render_graph_view(ui, state); // Process node click events and render info modal - process_graph_events(state); - render_node_info_modal(ui.ctx(), state); + process_graph_events(state, configuration_file_state); + render_node_info_modal(ui.ctx(), state, configuration_file_state); } /// Handle configuration file changes @@ -542,6 +553,7 @@ fn handle_config_changes( } state.config_content = None; *state = VisualizationTabState::default(); + state.reset_view_requested = true; return; } @@ -872,7 +884,10 @@ fn render_control_panel(ui: &mut egui::Ui, state: &mut VisualizationTabState) { } /// Process graph click events from the event buffer -fn process_graph_events(state: &mut VisualizationTabState) { +fn process_graph_events( + state: &mut VisualizationTabState, + configuration_file_state: &ConfigurationFileState, +) { let events: Vec = state.events_buffer.borrow_mut().drain(..).collect(); for event in events { @@ -880,12 +895,25 @@ fn process_graph_events(state: &mut VisualizationTabState) { let node_idx = petgraph::graph::NodeIndex::new(id); state.clicked_node = Some(node_idx); state.node_info_modal_open = true; + + // Clone the host into the edit buffer + let host_idx = state.node_to_host.get(&node_idx).copied(); + state.modal_edit_buffer = host_idx.and_then(|idx| { + configuration_file_state + .config_model + .as_ref() + .and_then(|c| c.hosts.get(idx).cloned()) + }); } } } /// Render the node information modal for the clicked node -fn render_node_info_modal(ctx: &egui::Context, state: &mut VisualizationTabState) { +fn render_node_info_modal( + ctx: &egui::Context, + state: &mut VisualizationTabState, + config_file_state: &mut ConfigurationFileState, +) { if !state.node_info_modal_open { return; } @@ -901,10 +929,17 @@ fn render_node_info_modal(ctx: &egui::Context, state: &mut VisualizationTabState }; let node_data = node.payload().clone(); + let host_idx = state.node_to_host.get(&node_idx).copied(); + let has_edit_buffer = state.modal_edit_buffer.is_some(); + let mut save_clicked = false; let modal = egui::Modal::new(egui::Id::new("node_info_modal")).show(ctx, |ui| { ui.set_width(250.0); - ui.heading("Node Information"); + if has_edit_buffer { + ui.heading("Edit Node Information"); + } else { + ui.heading("Node Information"); + } ui.separator(); @@ -926,40 +961,121 @@ fn render_node_info_modal(ctx: &egui::Context, state: &mut VisualizationTabState ui.add_space(4.0); - if let Some(ref hostname) = node_data.hostname { + // Editable fields if we have an edit buffer (config loaded and host found) + if let Some(ref mut host) = state.modal_edit_buffer { + // Hostname ui.horizontal(|ui| { ui.label("Hostname:"); - ui.label(egui::RichText::new(hostname).monospace()); + let mut buf = host.hostname.clone().unwrap_or_default(); + if ui + .add(egui::TextEdit::singleline(&mut buf).hint_text("hostname")) + .changed() + { + host.hostname = if buf.trim().is_empty() { + None + } else { + Some(buf) + }; + } }); - } - ui.label("IP Addresses:"); - for ip in &node_data.ip_addrs { + // OS ui.horizontal(|ui| { - ui.add_space(16.0); - ui.label(egui::RichText::new(ip.to_string()).monospace()); + ui.label("OS:"); + let selected = host.os.as_deref().unwrap_or(""); + egui::ComboBox::from_id_salt("modal_os") + .selected_text(selected) + .show_ui(ui, |ui| { + if ui.selectable_label(host.os.is_none(), "").clicked() { + host.os = None; + } + if ui + .selectable_label(host.os.as_deref() == Some("Linux"), "Linux") + .clicked() + { + host.os = Some("Linux".to_string()); + } + if ui + .selectable_label(host.os.as_deref() == Some("Windows"), "Windows") + .clicked() + { + host.os = Some("Windows".to_string()); + } + }); }); - } - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.label("OS:"); - ui.label(egui::RichText::new(format!("{:?}", node_data.os)).monospace()); - }); + // IP addresses + ui.label("IP Addresses:"); + for iface in &mut host.interfaces { + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.add(egui::TextEdit::singleline(&mut iface.ip_addr).hint_text("0.0.0.0")); + }); + } + } else { + // Read-only fallback (no config loaded or Internet node) + if let Some(ref hostname) = node_data.hostname { + ui.horizontal(|ui| { + ui.label("Hostname:"); + ui.label(egui::RichText::new(hostname).monospace()); + }); + } + ui.horizontal(|ui| { + ui.label("OS:"); + ui.label(egui::RichText::new(format!("{:?}", node_data.os)).monospace()); + }); + ui.label("IP Addresses:"); + for ip in &node_data.ip_addrs { + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new(ip.to_string()).monospace()); + }); + } + } ui.add_space(8.0); - ui.vertical_centered(|ui| { + if has_edit_buffer { + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + ui.close(); + } + if ui.button("Save").clicked() { + save_clicked = true; + ui.close(); + } + }); + } else { if ui.button("Close").clicked() { ui.close(); } - }); + } }); - // Close on Escape or click outside + // Apply changes to config model on Save + if save_clicked { + if let (Some(idx), Some(buffer)) = (host_idx, state.modal_edit_buffer.take()) { + if let Some(host) = config_file_state + .config_model + .as_mut() + .and_then(|c| c.hosts.get_mut(idx)) + { + *host = buffer; + } + // Sync config model back to YAML so other tabs and handle_config_changes pick it up + if let Some(model) = &config_file_state.config_model { + if let Ok(yaml) = serde_yaml::to_string(model) { + config_file_state.config_file_content = Some(yaml); + } + } + } + } + + // Close on Escape or click outside (discard changes) if modal.should_close() { state.node_info_modal_open = false; state.clicked_node = None; + state.modal_edit_buffer = None; } } From c8ab44c2e74a243b04bb2013b716e5454bb6563a Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:22:29 +0100 Subject: [PATCH 47/77] chore(gui/visualization): only display edge after a first flow --- crates/fosr-gui/src/visualization/visualization_shapes.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/visualization_shapes.rs index 7912817b..1d9fdce7 100644 --- a/crates/fosr-gui/src/visualization/visualization_shapes.rs +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -29,9 +29,9 @@ const RADIUS_MIN: f32 = 15.0; // Starting size for all nodes const RADIUS_MAX: f32 = 25.0; // Maximum size const FLOW_SCALE_FACTOR: f32 = 0.3; // Radius increase per flow -const EDGE_WIDTH_MIN: f32 = 0.2; +const EDGE_WIDTH_MIN: f32 = 0.0; const EDGE_WIDTH_MAX: f32 = 3.0; -const EDGE_FLOW_SCALE: f32 = 0.1; // Width increase per flow (linear phase) +const EDGE_FLOW_SCALE: f32 = 0.2; // Width increase per flow (linear phase) /// Custom node shape that displays hostname and IP, with icon based on node type #[derive(Clone)] From d2dfccc817b25754d918bf3545a52b70beaf60bb Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:27:35 +0100 Subject: [PATCH 48/77] feat(gui/config): add button to save/download current configuration --- .../fosr-gui/src/shared/configuration_file.rs | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 7e29d9c1..ab4ad65c 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -1,8 +1,8 @@ use crate::shared::config_model::Configuration; #[cfg(not(target_arch = "wasm32"))] -use crate::shared::file_io::{read_file_desktop, show_file_picker_desktop}; +use crate::shared::file_io::{read_file_desktop, save_file_desktop, show_file_picker_desktop}; #[cfg(target_arch = "wasm32")] -use crate::shared::file_io::{read_file_wasm, show_file_picker_wasm}; +use crate::shared::file_io::{read_file_wasm, save_file_wasm, show_file_picker_wasm}; use eframe::egui; use rfd::FileHandle; #[cfg(target_arch = "wasm32")] @@ -101,6 +101,40 @@ pub fn configuration_file_picker( reset_loaded_config(configuration_file_state); }; + // Save as button (only when config content is available) + if configuration_file_state.config_file_content.is_some() { + if ui.button("Save as").clicked() { + let content = configuration_file_state.config_file_content.clone().unwrap(); + let default_name = configuration_file_state + .picked_config_file + .as_ref() + .map(|f| f.file_name()) + .unwrap_or_else(|| "config.yaml".to_string()); + + #[cfg(not(target_arch = "wasm32"))] + { + match save_file_desktop(content.as_bytes(), &default_name) { + Ok(handle) => { + log::info!("Config saved to {}", handle.path().display()); + } + Err(e) => { + log::error!("Failed to save config: {}", e); + } + } + } + + #[cfg(target_arch = "wasm32")] + { + wasm_bindgen_futures::spawn_local(async move { + match save_file_wasm(content.as_bytes(), &default_name).await { + Ok(_) => log::info!("Config saved"), + Err(e) => log::error!("Failed to save config: {}", e), + } + }); + } + } + } + // On desktop: filename with its full path on hover, on WASM: just the filename #[cfg(not(target_arch = "wasm32"))] { From 348e2165a2822aabd386657b89aabf85490930b6 Mon Sep 17 00:00:00 2001 From: {} <> Date: Sat, 28 Feb 2026 16:32:53 +0100 Subject: [PATCH 49/77] Feat: add button to add basic config --- .../src/configuration/configuration_tab.rs | 59 ++++++++++++------- .../fosr-gui/src/shared/configuration_file.rs | 26 ++++---- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index a02eb57c..4284a526 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -8,6 +8,17 @@ use eframe::egui; use egui_extras::DatePickerButton; use std::collections::HashSet; +const DEFAULT_CONFIG_YAML: &str = r#"metadata: + title: Sample configuration +hosts: + - interfaces: + - ip_addr: 192.168.0.8 + services: + - https + - ssh:2222 + - interfaces: + - ip_addr: 192.168.0.9"#; + /// Scans all interfaces to find the next available IP in 192.168.0.x fn next_free_ip(used_ips: &HashSet) -> Option { for x in 1..=254 { @@ -87,6 +98,25 @@ pub fn show_configuration_tab_content( configuration_file_picker(ui, file_state); ui.separator(); + if file_state.config_file_content.is_none() { + ui.horizontal(|ui| { + ui.label("No configuration loaded."); + if ui.button("Init basic configuration").clicked() { + file_state.config_file_content = Some(DEFAULT_CONFIG_YAML.to_string()); + match serde_yaml::from_str::(DEFAULT_CONFIG_YAML) { + Ok(model) => { + file_state.config_model = Some(model); + file_state.parse_error = None; + } + Err(e) => { + file_state.parse_error = Some(e.to_string()); + } + } + } + }); + ui.separator(); + } + ui_parsing_status(ui, file_state); // Editor (if model is loaded) @@ -536,28 +566,17 @@ fn ui_single_service( /// Read-only YAML Preview fn ui_yaml_preview(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { - if state.picked_config_file.is_none() { + if state.config_file_content.is_none() { ui.label("No configuration file selected"); return; } + let content = state.config_file_content.as_ref().unwrap(); + let theme = egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx(), ui.style()); - if state.config_file_content.is_none() { - ui.label("Loading configuration file..."); - load_config_file_contents(state); - } else { - let content = state.config_file_content.as_ref().unwrap(); - let theme = egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx(), ui.style()); - - let mut layout_job = egui_extras::syntax_highlighting::highlight( - ui.ctx(), - ui.style(), - &theme, - content, - "yaml", - ); - - layout_job.wrap.max_width = ui.available_width(); - - ui.add(egui::Label::new(layout_job).selectable(true)); - } + let mut layout_job = + egui_extras::syntax_highlighting::highlight(ui.ctx(), ui.style(), &theme, content, "yaml"); + + layout_job.wrap.max_width = ui.available_width(); + + ui.add(egui::Label::new(layout_job).selectable(true)); } diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index ab4ad65c..c7fface3 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -89,14 +89,17 @@ pub fn configuration_file_picker( } } - // Display the filename of the picked file, or a placeholder - let filename = configuration_file_state - .picked_config_file - .as_ref() - .map(|file| file.file_name()) - .unwrap_or("No file selected".to_string()); - - if configuration_file_state.picked_config_file.is_some() && ui.button("Remove").clicked() { + // Diplay the file name on disk or template or a placeholder + let filename = if let Some(file) = &configuration_file_state.picked_config_file { + file.file_name() + } else if configuration_file_state.config_file_content.is_some() { + "basic_config.yaml (unsaved)".to_string() + } else { + "No file selected".to_string() + }; + + // Diplay Remove button if there is content (from disk or template) + if configuration_file_state.config_file_content.is_some() && ui.button("Remove").clicked() { configuration_file_state.picked_config_file = None; reset_loaded_config(configuration_file_state); }; @@ -104,12 +107,15 @@ pub fn configuration_file_picker( // Save as button (only when config content is available) if configuration_file_state.config_file_content.is_some() { if ui.button("Save as").clicked() { - let content = configuration_file_state.config_file_content.clone().unwrap(); + let content = configuration_file_state + .config_file_content + .clone() + .unwrap(); let default_name = configuration_file_state .picked_config_file .as_ref() .map(|f| f.file_name()) - .unwrap_or_else(|| "config.yaml".to_string()); + .unwrap_or_else(|| "basic_config.yaml".to_string()); #[cfg(not(target_arch = "wasm32"))] { From ae691a634deec79d53e45e21c23e289f161b913c Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:07:33 +0100 Subject: [PATCH 50/77] feat(gui/config): rework default config --- .../src/configuration/configuration_tab.rs | 30 ---- crates/fosr-gui/src/default_config.yaml | 145 ++++++++++++++++++ .../fosr-gui/src/shared/configuration_file.rs | 41 +++-- .../src/visualization/visualization_tab.rs | 114 +------------- 4 files changed, 182 insertions(+), 148 deletions(-) create mode 100644 crates/fosr-gui/src/default_config.yaml diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 4284a526..ad398fc4 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -8,17 +8,6 @@ use eframe::egui; use egui_extras::DatePickerButton; use std::collections::HashSet; -const DEFAULT_CONFIG_YAML: &str = r#"metadata: - title: Sample configuration -hosts: - - interfaces: - - ip_addr: 192.168.0.8 - services: - - https - - ssh:2222 - - interfaces: - - ip_addr: 192.168.0.9"#; - /// Scans all interfaces to find the next available IP in 192.168.0.x fn next_free_ip(used_ips: &HashSet) -> Option { for x in 1..=254 { @@ -98,25 +87,6 @@ pub fn show_configuration_tab_content( configuration_file_picker(ui, file_state); ui.separator(); - if file_state.config_file_content.is_none() { - ui.horizontal(|ui| { - ui.label("No configuration loaded."); - if ui.button("Init basic configuration").clicked() { - file_state.config_file_content = Some(DEFAULT_CONFIG_YAML.to_string()); - match serde_yaml::from_str::(DEFAULT_CONFIG_YAML) { - Ok(model) => { - file_state.config_model = Some(model); - file_state.parse_error = None; - } - Err(e) => { - file_state.parse_error = Some(e.to_string()); - } - } - } - }); - ui.separator(); - } - ui_parsing_status(ui, file_state); // Editor (if model is loaded) diff --git a/crates/fosr-gui/src/default_config.yaml b/crates/fosr-gui/src/default_config.yaml new file mode 100644 index 00000000..aecdf6c2 --- /dev/null +++ b/crates/fosr-gui/src/default_config.yaml @@ -0,0 +1,145 @@ +metadata: + title: Default Network Configuration + desc: A multi-subnet demo network with common services + author: FosR + version: 1.0.0 + format: 1 + +hosts: + # ==================== DMZ SUBNET (192.168.10.x) ==================== + + - hostname: dmz-web-gateway + os: Linux + usage: 2.5 + type: server + client: + - https + - ssh + interfaces: + - ip_addr: 192.168.10.10 + mac_addr: 00:14:2A:3F:10:01 + services: + - https:443 + - http:80 + - ip_addr: 10.0.0.10 + mac_addr: 00:14:2A:3F:10:02 + + - hostname: dmz-dns-server + os: Linux + usage: 1.8 + type: server + interfaces: + - ip_addr: 192.168.10.20 + mac_addr: 00:14:2A:3F:10:03 + services: + - dns:53 + + - hostname: dmz-mail-relay + os: Linux + usage: 1.2 + type: server + client: + - smtp + interfaces: + - ip_addr: 192.168.10.30 + mac_addr: 00:14:2A:3F:10:04 + services: + - smtp:587 + + # ==================== APPLICATION SUBNET (192.168.20.x) ==================== + + - hostname: app-api-frontend + os: Linux + usage: 2.0 + type: server + client: + - https + interfaces: + - ip_addr: 192.168.20.10 + mac_addr: 00:14:2A:3F:20:01 + services: + - https:443 + - http:80 + + - hostname: app-api-backend + os: Linux + usage: 1.5 + type: server + client: + - https + interfaces: + - ip_addr: 192.168.20.20 + mac_addr: 00:14:2A:3F:20:03 + services: + - https:8443 + + # ==================== DATABASE SUBNET (192.168.30.x) ==================== + + - hostname: db-primary + os: Linux + usage: 1.8 + type: server + client: + - https + interfaces: + - ip_addr: 192.168.30.10 + mac_addr: 00:14:2A:3F:30:01 + services: + - https:5432 + - ip_addr: 10.0.20.10 + mac_addr: 00:14:2A:3F:30:02 + services: + - ssh:22 + + - hostname: db-replica + os: Linux + usage: 1.0 + type: server + client: + - https + interfaces: + - ip_addr: 192.168.30.20 + mac_addr: 00:14:2A:3F:30:03 + services: + - https:5432 + + # ==================== USER SUBNET (192.168.50.x) ==================== + + - hostname: admin-workstation + os: Linux + usage: 1.2 + type: user + client: + - ssh + - https + - smtp + - dns + interfaces: + - ip_addr: 192.168.50.100 + mac_addr: AA:BB:CC:DD:EE:01 + + - hostname: dev-workstation-01 + os: Linux + usage: 0.9 + type: user + client: + - ssh + - https + - http + - dns + interfaces: + - ip_addr: 192.168.50.101 + mac_addr: AA:BB:CC:DD:EE:02 + + - hostname: dev-workstation-02 + os: Linux + usage: 0.9 + type: user + client: + - ssh + - https + - http + - dns + interfaces: + - ip_addr: 192.168.50.102 + mac_addr: AA:BB:CC:DD:EE:03 diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index c7fface3..65c51a39 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -8,6 +8,8 @@ use rfd::FileHandle; #[cfg(target_arch = "wasm32")] use std::sync::mpsc::{Receiver, channel}; +pub const DEFAULT_CONFIG_YAML: &str = include_str!("../default_config.yaml"); + pub struct ConfigurationFileState { pub picked_config_file: Option, #[cfg(target_arch = "wasm32")] @@ -21,14 +23,15 @@ pub struct ConfigurationFileState { impl Default for ConfigurationFileState { fn default() -> Self { + let config_model = serde_yaml::from_str::(DEFAULT_CONFIG_YAML).ok(); Self { picked_config_file: None, #[cfg(target_arch = "wasm32")] config_file_receiver: None, - config_file_content: None, + config_file_content: Some(DEFAULT_CONFIG_YAML.to_string()), #[cfg(target_arch = "wasm32")] config_file_content_receiver: None, - config_model: None, + config_model, parse_error: None, } } @@ -56,7 +59,7 @@ pub fn configuration_file_picker( let file = show_file_picker_desktop(); if file.is_some() { configuration_file_state.picked_config_file = file; - reset_loaded_config(configuration_file_state); + clear_loaded_config(configuration_file_state); } } @@ -82,24 +85,24 @@ pub fn configuration_file_picker( // Only update if a file was actually selected if file.is_some() { configuration_file_state.picked_config_file = file; - reset_loaded_config(configuration_file_state); + clear_loaded_config(configuration_file_state); } configuration_file_state.config_file_receiver = None; // Dialog finished } } } - // Diplay the file name on disk or template or a placeholder + // Display the file name on disk, or indicate built-in default let filename = if let Some(file) = &configuration_file_state.picked_config_file { file.file_name() - } else if configuration_file_state.config_file_content.is_some() { - "basic_config.yaml (unsaved)".to_string() } else { - "No file selected".to_string() + "default_config.yaml (built-in)".to_string() }; - // Diplay Remove button if there is content (from disk or template) - if configuration_file_state.config_file_content.is_some() && ui.button("Remove").clicked() { + // Display Restore default button when a custom file is loaded + if configuration_file_state.picked_config_file.is_some() + && ui.button("Restore default").clicked() + { configuration_file_state.picked_config_file = None; reset_loaded_config(configuration_file_state); }; @@ -115,7 +118,7 @@ pub fn configuration_file_picker( .picked_config_file .as_ref() .map(|f| f.file_name()) - .unwrap_or_else(|| "basic_config.yaml".to_string()); + .unwrap_or_else(|| "config.yaml".to_string()); #[cfg(not(target_arch = "wasm32"))] { @@ -212,7 +215,8 @@ fn parse_config_yaml(configuration_file_state: &mut ConfigurationFileState) { } } -pub fn reset_loaded_config(configuration_file_state: &mut ConfigurationFileState) { +/// Clear all loaded config state to allow loading a new file. +fn clear_loaded_config(configuration_file_state: &mut ConfigurationFileState) { configuration_file_state.config_file_content = None; configuration_file_state.config_model = None; configuration_file_state.parse_error = None; @@ -222,3 +226,16 @@ pub fn reset_loaded_config(configuration_file_state: &mut ConfigurationFileState configuration_file_state.config_file_content_receiver = None; } } + +/// Restore the built-in default configuration. +pub fn reset_loaded_config(configuration_file_state: &mut ConfigurationFileState) { + configuration_file_state.config_file_content = Some(DEFAULT_CONFIG_YAML.to_string()); + configuration_file_state.config_model = + serde_yaml::from_str::(DEFAULT_CONFIG_YAML).ok(); + configuration_file_state.parse_error = None; + + #[cfg(target_arch = "wasm32")] + { + configuration_file_state.config_file_content_receiver = None; + } +} diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 14acc2db..26695076 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -211,109 +211,9 @@ pub struct VisualizationTabState { impl Default for VisualizationTabState { fn default() -> Self { - Self::create_demo_state() - } -} - -impl VisualizationTabState { - /// Create a demo state with all IPs from the BN models (bn_additional_data.json) - /// TODO: only a subset of them seems to appear in the generated data, prune the unused ones - fn create_demo_state() -> Self { - // All IPs from bn_additional_data.json (excluding 0.0.0.0) - // Servers are x.x.x.2, Users are x.x.x.3+ - let demo_hosts: Vec<(Ipv4Addr, NodeType)> = vec![ - // 192.168.100.x - (Ipv4Addr::new(192, 168, 100, 2), NodeType::Server), - (Ipv4Addr::new(192, 168, 100, 3), NodeType::User), - (Ipv4Addr::new(192, 168, 100, 4), NodeType::User), - (Ipv4Addr::new(192, 168, 100, 5), NodeType::User), - (Ipv4Addr::new(192, 168, 100, 6), NodeType::User), - // 192.168.200.x - (Ipv4Addr::new(192, 168, 200, 2), NodeType::Server), - (Ipv4Addr::new(192, 168, 200, 3), NodeType::User), - (Ipv4Addr::new(192, 168, 200, 4), NodeType::User), - (Ipv4Addr::new(192, 168, 200, 5), NodeType::User), - (Ipv4Addr::new(192, 168, 200, 8), NodeType::User), - (Ipv4Addr::new(192, 168, 200, 9), NodeType::User), - // 192.168.210.x - (Ipv4Addr::new(192, 168, 210, 2), NodeType::Server), - (Ipv4Addr::new(192, 168, 210, 3), NodeType::User), - (Ipv4Addr::new(192, 168, 210, 4), NodeType::User), - (Ipv4Addr::new(192, 168, 210, 5), NodeType::User), - // 192.168.220.x - (Ipv4Addr::new(192, 168, 220, 2), NodeType::Server), - (Ipv4Addr::new(192, 168, 220, 3), NodeType::User), - (Ipv4Addr::new(192, 168, 220, 4), NodeType::User), - (Ipv4Addr::new(192, 168, 220, 5), NodeType::User), - (Ipv4Addr::new(192, 168, 220, 6), NodeType::User), - (Ipv4Addr::new(192, 168, 220, 7), NodeType::User), - (Ipv4Addr::new(192, 168, 220, 8), NodeType::User), - (Ipv4Addr::new(192, 168, 220, 9), NodeType::User), - (Ipv4Addr::new(192, 168, 220, 10), NodeType::User), - (Ipv4Addr::new(192, 168, 220, 11), NodeType::User), - (Ipv4Addr::new(192, 168, 220, 12), NodeType::User), - (Ipv4Addr::new(192, 168, 220, 13), NodeType::User), - (Ipv4Addr::new(192, 168, 220, 14), NodeType::User), - (Ipv4Addr::new(192, 168, 220, 15), NodeType::User), - (Ipv4Addr::new(192, 168, 220, 16), NodeType::User), - ]; - - let mut graph = VisualizationGraph::new(petgraph::stable_graph::StableGraph::default()); - let mut known_ips = HashSet::new(); - let mut ip_to_node = HashMap::new(); - - // Add demo nodes (one node per IP in demo mode, since demo hosts have single IPs) - for (ip, node_type) in &demo_hosts { - let node_data = NodeData { - ip_addrs: vec![*ip], - hostname: None, // No hostname, just show IP - node_type: *node_type, - os: OS::Linux, // Does not matter - flow_count: 0, - max_flow_count: 0, - }; - // Nodes are initially placed at the center. They are manually distributed later. - let idx = graph.add_node_with_location(node_data, egui::pos2(0.0, 0.0)); - known_ips.insert(*ip); - ip_to_node.insert(*ip, idx); - } - - // Distribute nodes before adding the Internet node, so that it stays in the center - distribute_nodes_circle(&mut graph); - - // Add Internet node - let internet_idx = - graph.add_node_with_location(NodeData::internet(), egui::pos2(0.0, 0.0)); - ip_to_node.insert(INTERNET_IP, internet_idx); - - // Add edges between users and servers - // TODO: make sure that all flows occur between a server and a user, never between 2 servers or 2 users or a server and the Internet - let users: Vec<_> = demo_hosts - .iter() - .filter(|(_, t)| *t == NodeType::User) - .collect(); - let servers: Vec<_> = demo_hosts - .iter() - .filter(|(_, t)| *t == NodeType::Server) - .collect(); - - for (user_ip, _) in &users { - for (server_ip, _) in &servers { - let user_idx = ip_to_node[user_ip]; - let server_idx = ip_to_node[server_ip]; - graph.add_edge(user_idx, server_idx, EdgeData::default()); - } - // Add edge to Internet for each user - let user_idx = ip_to_node[user_ip]; - graph.add_edge(user_idx, internet_idx, EdgeData::default()); - } - - // Add edges from servers to Internet - for (server_ip, _) in &servers { - let server_idx = ip_to_node[server_ip]; - graph.add_edge(server_idx, internet_idx, EdgeData::default()); - } - + // Start with an empty graph; the default config from ConfigurationFileState + // will be detected by handle_config_changes() on the first frame. + let graph = VisualizationGraph::new(petgraph::stable_graph::StableGraph::default()); Self { graph, flow_receiver: None, @@ -322,22 +222,24 @@ impl VisualizationTabState { config_content: None, streamer: None, layout_initialized: false, - known_ips, - ip_to_node, + known_ips: HashSet::new(), + ip_to_node: HashMap::new(), visualization_start: None, speed: Arc::new(RwLock::new(1.0)), events_buffer: Rc::new(RefCell::new(Vec::new())), clicked_node: None, node_info_modal_open: false, node_to_host: HashMap::new(), - auto_start_countdown: Some(10), + auto_start_countdown: None, total_flows: 0, reset_view_requested: false, last_screen_size: None, modal_edit_buffer: None, } } +} +impl VisualizationTabState { /// Update state from a configuration (preserves some state) /// Note: caller should stop visualization before calling this if running pub fn update_from_config(&mut self, config: &config::Configuration) { From d83911c9f9a0bf2d7c83793de1ffd204f6200ac2 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:36:47 +0100 Subject: [PATCH 51/77] chore(gui): increase native window size --- crates/fosr-gui/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index a15b88c9..8bd6ccc7 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -19,7 +19,7 @@ fn main() -> eframe::Result { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); let shared_viewport = egui::ViewportBuilder::default() - .with_inner_size([700.0, 600.0]) + .with_inner_size([1200.0, 1000.0]) .with_min_inner_size([550.0, 500.0]) .with_title("Fos-R"); From 3ca1c4c9ef1a2cf9a320f4331732deecd3f578e3 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:36:47 +0100 Subject: [PATCH 52/77] feat(gui): add material icons + rework visualization tab layout --- Cargo.lock | 24 +- crates/fosr-gui/Cargo.toml | 1 + .../src/configuration/configuration_tab.rs | 26 +- .../fosr-gui/src/generation/generation_tab.rs | 34 +-- crates/fosr-gui/src/lib.rs | 5 +- crates/fosr-gui/src/main.rs | 5 +- .../fosr-gui/src/shared/configuration_file.rs | 6 +- crates/fosr-gui/src/shared/ui_utils.rs | 16 +- .../src/visualization/visualization_tab.rs | 260 ++++++++++-------- 9 files changed, 209 insertions(+), 168 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 164b2bf1..24fb49fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,7 +219,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -230,7 +230,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1427,6 +1427,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "egui_material_icons" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff7d3715b5ee29fe75afd19420756b3e0a970149b62712af1b3204a68e2012b" +dependencies = [ + "egui", +] + [[package]] name = "ehttp" version = "0.6.0" @@ -1588,7 +1597,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1801,6 +1810,7 @@ dependencies = [ "eframe", "egui_extras", "egui_graphs", + "egui_material_icons", "env_logger", "fosr-lib", "gloo-timers", @@ -2200,7 +2210,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.61.2", ] [[package]] @@ -3916,7 +3926,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4357,7 +4367,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5240,7 +5250,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index ba61cec6..5c42c4a9 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -41,6 +41,7 @@ web-time = "1.1.0" petgraph = "0.8.3" egui_graphs = { version = "0.29.0", features = ["events"] } serde_yaml = "0.9" +egui_material_icons = "0.5.0" [target.'cfg(target_arch = "wasm32")'.dependencies] gloo-timers = { version = "0.3", features = ["futures"] } diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index ad398fc4..27091309 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -170,7 +170,7 @@ fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { model.metadata.date = Some(date_val.format("%Y/%m/%d").to_string()); } - if ui.button("Clear").clicked() { + if ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear").clicked() { model.metadata.date = None; } }); @@ -192,7 +192,7 @@ fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { if ui.button("Set to 1").clicked() { model.metadata.format = Some(1); } - if ui.button("Clear").clicked() { + if ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear").clicked() { model.metadata.format = None; } }); @@ -204,7 +204,7 @@ fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { ui.add_space(6.0); ui.horizontal(|ui| { - if ui.button("+ Add host").clicked() { + if ui.button(egui_material_icons::icons::ICON_ADD).on_hover_text("Add host").clicked() { model.hosts.push(Host::default()); } }); @@ -257,7 +257,7 @@ fn ui_single_host( ui.label(host_name); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Remove host").clicked() { + if ui.button(egui_material_icons::icons::ICON_DELETE).on_hover_text("Remove host").clicked() { *remove_request = Some(index); } }); @@ -285,7 +285,7 @@ fn ui_single_host( Some(usage_val) }; } - if ui.button("Clear").clicked() { + if ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear").clicked() { host.usage = None; } }); @@ -327,7 +327,7 @@ fn ui_host_os_selector(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option< } }); - if host_os.is_some() && ui.button("Clear OS").clicked() { + if host_os.is_some() && ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear OS").clicked() { *host_os = None; } }); @@ -362,7 +362,7 @@ fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { } }); - if ui.button("Clear").clicked() { + if ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear").clicked() { host.r#type = None; } }); @@ -388,7 +388,7 @@ fn ui_host_client_protocols(ui: &mut egui::Ui, host: &mut Host) { .collect(); } - if ui.button("Clear").clicked() { + if ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear").clicked() { host.client.clear(); } }); @@ -403,7 +403,7 @@ fn ui_interfaces_section( ) { ui.horizontal(|ui| { ui.label("Interfaces:"); - if ui.button("+ Add interface").clicked() { + if ui.button(egui_material_icons::icons::ICON_ADD).on_hover_text("Add interface").clicked() { if let Some(ip) = next_free_ip(used_ips) { host.interfaces.push(Interface { ip_addr: ip, @@ -432,7 +432,7 @@ fn ui_interfaces_section( ui.label(format!("Interface — {ip_label}")); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Remove interface").clicked() { + if ui.button(egui_material_icons::icons::ICON_DELETE).on_hover_text("Remove interface").clicked() { iface_to_remove = Some(if_idx); } }); @@ -466,7 +466,7 @@ fn ui_services_section(ui: &mut egui::Ui, iface_idx: usize, iface: &mut Interfac egui::CollapsingHeader::new(format!("Services ({svc_count})")) .default_open(false) .show(ui, |ui| { - if ui.button("+ Add service").clicked() { + if ui.button(egui_material_icons::icons::ICON_ADD).on_hover_text("Add service").clicked() { iface.services.push("http".to_string()); } ui.add_space(4.0); @@ -522,11 +522,11 @@ fn ui_single_service( { svc_port = if port_val == 0 { None } else { Some(port_val) }; } - if ui.button("Clear port").clicked() { + if ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear port").clicked() { svc_port = None; } - if ui.button("Delete service").clicked() { + if ui.button(egui_material_icons::icons::ICON_DELETE).on_hover_text("Delete service").clicked() { *remove_request = Some(svc_idx); } }); diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index ea305c0b..90248a8b 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -10,6 +10,7 @@ use super::generation_validation::{ use crate::shared::configuration_file::{ ConfigurationFileState, configuration_file_picker, load_config_file_contents, }; +use crate::shared::ui_utils::info_icon; #[cfg(not(target_arch = "wasm32"))] use crate::shared::file_io::save_file_desktop; #[cfg(target_arch = "wasm32")] @@ -107,19 +108,6 @@ impl Default for GenerationTabState { } } -/// Display a small info icon with a tooltip. -fn info_icon(ui: &mut egui::Ui, tooltip: &str) { - // Reduce spacing before the icon - ui.add_space(-4.0); - // Reduce tooltip delay - ui.ctx().style_mut(|s| s.interaction.tooltip_delay = 0.1); - ui.label(egui::RichText::new("ℹ").color(egui::Color32::GRAY).size(14.0)) - .on_hover_ui(|ui| { - ui.set_max_width(300.0); - ui.label(tooltip); - }); -} - pub fn show_generation_tab_content( ui: &mut egui::Ui, state: &mut GenerationTabState, @@ -264,11 +252,11 @@ pub fn show_generation_tab_content( ui.horizontal(|ui| { if is_generating { let stop_button = egui::Button::new( - egui::RichText::new("Stop").size(13.0), + egui::RichText::new(egui_material_icons::icons::ICON_STOP).size(13.0), ) .fill(egui::Color32::from_rgb(200, 80, 80)) .min_size(egui::vec2(75.0, 24.0)); - if ui.add(stop_button).clicked() { + if ui.add(stop_button).on_hover_text("Stop").clicked() { state.cancelled.store(true, Ordering::Relaxed); state.status = UiStatus::Idle; state.progress = 0.0; @@ -282,11 +270,11 @@ pub fn show_generation_tab_content( ui.add_enabled_ui(can_generate, |ui| { let accent = ui.visuals().selection.bg_fill; let generate_button = egui::Button::new( - egui::RichText::new("Generate").size(13.0), + egui::RichText::new(egui_material_icons::icons::ICON_PLAY_ARROW).size(13.0), ) .fill(accent) .min_size(egui::vec2(75.0, 24.0)); - if ui.add(generate_button).clicked() { + if ui.add(generate_button).on_hover_text("Generate").clicked() { state.status = UiStatus::Generating; // Reset state @@ -409,14 +397,18 @@ pub fn show_generation_tab_content( state.status = UiStatus::Generated; } #[cfg(not(target_arch = "wasm32"))] - let save_button_label = "Save"; + let save_button_icon = egui_material_icons::icons::ICON_SAVE; + #[cfg(target_arch = "wasm32")] + let save_button_icon = egui_material_icons::icons::ICON_DOWNLOAD; + #[cfg(not(target_arch = "wasm32"))] + let save_button_tooltip = "Save"; #[cfg(target_arch = "wasm32")] - let save_button_label = "Download"; + let save_button_tooltip = "Download"; let save_button = egui::Button::new( - egui::RichText::new(save_button_label).size(13.0), + egui::RichText::new(save_button_icon).size(13.0), ) .min_size(egui::vec2(75.0, 24.0)); - if ui.add(save_button).clicked() { + if ui.add(save_button).on_hover_text(save_button_tooltip).clicked() { let pcap_bytes = state.pcap_bytes.clone(); #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index ce39f329..52ec90b0 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -37,7 +37,10 @@ pub async fn start(canvas_id: &str) -> Result<(), JsValue> { .start( canvas, web_options, - Box::new(|_cc| Ok(Box::new(FosrApp::default()))), + Box::new(|cc| { + egui_material_icons::initialize(&cc.egui_ctx); + Ok(Box::new(FosrApp::default())) + }), ) .await?; diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index 8bd6ccc7..35889231 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -42,7 +42,10 @@ fn main() -> eframe::Result { eframe::run_native( "Fos-R GUI", native_options, - Box::new(|_cc| Ok(Box::new(FosrApp::default()))), + Box::new(|cc| { + egui_material_icons::initialize(&cc.egui_ctx); + Ok(Box::new(FosrApp::default())) + }), ) } diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 65c51a39..65e55f13 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -45,7 +45,7 @@ pub fn configuration_file_picker( ui.label("Configuration file:"); // File Dialog to pick a config file - if ui.button("Select file").clicked() { + if ui.button(egui_material_icons::icons::ICON_FOLDER_OPEN).on_hover_text("Select file").clicked() { // Clear previous config content when selecting a new file configuration_file_state.config_file_content = None; #[cfg(target_arch = "wasm32")] @@ -101,7 +101,7 @@ pub fn configuration_file_picker( // Display Restore default button when a custom file is loaded if configuration_file_state.picked_config_file.is_some() - && ui.button("Restore default").clicked() + && ui.button(egui_material_icons::icons::ICON_RESTORE).on_hover_text("Restore default").clicked() { configuration_file_state.picked_config_file = None; reset_loaded_config(configuration_file_state); @@ -109,7 +109,7 @@ pub fn configuration_file_picker( // Save as button (only when config content is available) if configuration_file_state.config_file_content.is_some() { - if ui.button("Save as").clicked() { + if ui.button(egui_material_icons::icons::ICON_SAVE_AS).on_hover_text("Save as").clicked() { let content = configuration_file_state .config_file_content .clone() diff --git a/crates/fosr-gui/src/shared/ui_utils.rs b/crates/fosr-gui/src/shared/ui_utils.rs index 3c22c441..a5489f03 100644 --- a/crates/fosr-gui/src/shared/ui_utils.rs +++ b/crates/fosr-gui/src/shared/ui_utils.rs @@ -1,5 +1,17 @@ use eframe::egui; +/// Display a small info icon with a tooltip. +pub fn info_icon(ui: &mut egui::Ui, tooltip: &str) { + ui.add_space(-4.0); + ui.ctx().style_mut(|s| s.interaction.tooltip_delay = 0.1); + ui.label(egui::RichText::new("ℹ").color(egui::Color32::GRAY).size(14.0)) + .on_hover_cursor(egui::CursorIcon::Help) + .on_hover_ui(|ui| { + ui.set_max_width(300.0); + ui.label(tooltip); + }); +} + /// Displays an editor for an `Option` field in an egui UI. /// /// This helper is designed for configuration fields that are **optional**: @@ -50,7 +62,7 @@ pub fn edit_optional_string( } // Explicit clear button - if ui.button("Clear").clicked() { + if ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear").clicked() { *value = None; } }); @@ -89,7 +101,7 @@ pub fn edit_optional_multiline_string( } } - if ui.button("Clear").clicked() { + if ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear").clicked() { *value = None; } } diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 26695076..cc36d768 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -6,6 +6,7 @@ use super::visualization_stream::{FlowEvent, FlowStreamer}; use super::visualization_utils::distribute_nodes_circle; use crate::shared::config_model::Host; use crate::shared::configuration_file::ConfigurationFileState; +use crate::shared::ui_utils::info_icon; use eframe::egui; use egui_graphs::{ FruchtermanReingoldState, FruchtermanReingoldWithCenterGravity, @@ -431,7 +432,6 @@ pub fn show_visualization_tab_content( update_graph_edges(state); // Render UI - render_control_panel(ui, state); render_graph_view(ui, state); // Process node click events and render info modal @@ -686,105 +686,6 @@ fn update_graph_edges(state: &mut VisualizationTabState) { } } -/// Render the control panel -fn render_control_panel(ui: &mut egui::Ui, state: &mut VisualizationTabState) { - egui::TopBottomPanel::top("visualization_controls").show(ui.ctx(), |ui| { - ui.vertical(|ui| { - // Row 1: Buttons + label - ui.horizontal(|ui| { - if !state.visualization_running { - // Continue: resume without resetting flow counts - if ui.button("Continue").clicked() { - // Clone config to avoid borrow issues - // Pass the user config if loaded, otherwise None (uses default BN model) - let config = state.config_content.clone(); - let speed = state.speed.clone(); - if let Err(e) = state.start_visualization(config.as_deref(), speed, false) { - log::error!("Failed to start flow streamer: {}", e); - } - } - - // Restart: reset all flow counts and start fresh - if ui.button("Restart").clicked() { - // Clone config to avoid borrow issues - // Pass the user config if loaded, otherwise None (uses default BN model) - let config = state.config_content.clone(); - let speed = state.speed.clone(); - if let Err(e) = state.start_visualization(config.as_deref(), speed, true) { - log::error!("Failed to start flow streamer: {}", e); - } - } - - if state.config_content.is_none() { - ui.label( - egui::RichText::new("(Demo mode - load a config for custom network)") - .color(egui::Color32::GRAY), - ); - } - } else { - if ui.button("Stop").clicked() { - state.stop_visualization(); - } - } - - if ui.button("Reset view").clicked() { - state.reset_view_requested = true; - } - }); - - ui.separator(); - - // Row 2: Speed slider + active links - ui.horizontal(|ui| { - ui.label("Speed:"); - // Speed is an Arc, we cannot use it directly with slider, - // we need to read and write its value manually. - let mut speed_value = *state.speed.read().unwrap(); - let response = ui.add( - egui::Slider::new(&mut speed_value, 0.5..=4.0) - .logarithmic(true) - .text("x"), - ); - if response.changed() { - *state.speed.write().unwrap() = speed_value; - } - - ui.separator(); - ui.label(format!( - "Active links: {} | Total flows: {}", - state.active_links.len(), - state.total_flows - )); - }); - - ui.separator(); - - // Row 3: Legend - Node types - ui.horizontal(|ui| { - ui.label("Node Types:"); - legend_item_with_image(ui, "Server", egui::include_image!("../../assets/server.png")); - legend_item_with_image(ui, "User", egui::include_image!("../../assets/computer.png")); - legend_item_with_image(ui, "Internet", egui::include_image!("../../assets/internet.png")); - }); - - // Row 4-5: Legend - Edge states - ui.horizontal(|ui| { - ui.label("Edge States:"); - legend_item_inline(ui, "Inactive", COLOR_INACTIVE); - legend_item_inline(ui, "HTTP", COLOR_HTTP); - legend_item_inline(ui, "HTTPS", COLOR_HTTPS); - legend_item_inline(ui, "SSH", COLOR_SSH); - }); - ui.horizontal(|ui| { - ui.add_space(80.0); // Align with items above - legend_item_inline(ui, "DNS", COLOR_DNS); - legend_item_inline(ui, "SMTP", COLOR_SMTP); - legend_item_inline(ui, "Other", COLOR_OTHER); - }); - }); - }); -} - /// Process graph click events from the event buffer fn process_graph_events( state: &mut VisualizationTabState, @@ -939,16 +840,16 @@ fn render_node_info_modal( if has_edit_buffer { ui.horizontal(|ui| { - if ui.button("Cancel").clicked() { + if ui.button(egui_material_icons::icons::ICON_CLOSE).on_hover_text("Cancel").clicked() { ui.close(); } - if ui.button("Save").clicked() { + if ui.button(egui_material_icons::icons::ICON_SAVE).on_hover_text("Save").clicked() { save_clicked = true; ui.close(); } }); } else { - if ui.button("Close").clicked() { + if ui.button(egui_material_icons::icons::ICON_CLOSE).on_hover_text("Close").clicked() { ui.close(); } } @@ -983,27 +884,27 @@ fn render_node_info_modal( /// Helper to render a single legend item inline (for edges) fn legend_item_inline(ui: &mut egui::Ui, label: &str, color: egui::Color32) { - // Allocate space first - let rect = ui.allocate_space(egui::vec2(12.0, 12.0)).1; - // Then get painter and draw - let painter = ui.painter(); - painter.circle_filled(rect.center(), 6.0, color); - ui.add_space(-2.0); - ui.label(label); - ui.add_space(6.0); + ui.horizontal(|ui| { + let rect = ui.allocate_space(egui::vec2(12.0, 12.0)).1; + let painter = ui.painter(); + painter.circle_filled(rect.center(), 6.0, color); + ui.add_space(-2.0); + ui.label(label); + }); } /// Helper to render a legend item with an image (for nodes) fn legend_item_with_image(ui: &mut egui::Ui, label: &str, image: egui::ImageSource) { - let tint = if ui.style().visuals.dark_mode { - ICON_TINT_DARK - } else { - ICON_TINT_LIGHT - }; - ui.add(egui::Image::new(image).fit_to_exact_size(egui::vec2(20.0, 20.0)).tint(tint)); - ui.add_space(-2.0); - ui.label(label); - ui.add_space(6.0); + ui.horizontal(|ui| { + let tint = if ui.style().visuals.dark_mode { + ICON_TINT_DARK + } else { + ICON_TINT_LIGHT + }; + ui.add(egui::Image::new(image).fit_to_exact_size(egui::vec2(20.0, 20.0)).tint(tint)); + ui.add_space(-2.0); + ui.label(label); + }); } /// Render the graph view @@ -1065,5 +966,124 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { } ui.add(&mut graph_view); + + // Overlay control buttons in the top-left corner of the graph + let panel_rect = ui.min_rect(); + egui::Area::new(egui::Id::new("viz_overlay_buttons")) + .fixed_pos(panel_rect.left_top() + egui::vec2(4.0, 4.0)) + .order(egui::Order::Foreground) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).shadow(egui::epaint::Shadow::NONE).show(ui, |ui| { + ui.horizontal(|ui| { + if !state.visualization_running { + if ui.button(egui_material_icons::icons::ICON_PLAY_ARROW).on_hover_text("Continue").clicked() { + let config = state.config_content.clone(); + let speed = state.speed.clone(); + if let Err(e) = state.start_visualization(config.as_deref(), speed, false) { + log::error!("Failed to start flow streamer: {}", e); + } + } + if ui.button(egui_material_icons::icons::ICON_RESTART_ALT).on_hover_text("Restart").clicked() { + let config = state.config_content.clone(); + let speed = state.speed.clone(); + if let Err(e) = state.start_visualization(config.as_deref(), speed, true) { + log::error!("Failed to start flow streamer: {}", e); + } + } + } else { + if ui.button(egui_material_icons::icons::ICON_STOP).on_hover_text("Stop").clicked() { + state.stop_visualization(); + } + } + if ui.button(egui_material_icons::icons::ICON_FIT_SCREEN).on_hover_text("Fit to screen").clicked() { + state.reset_view_requested = true; + } + + ui.separator(); + + // Playback speed: −/+ buttons with discrete steps + let speed_steps: &[f32] = &[0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0]; + let mut speed_value = *state.speed.read().unwrap(); + let current_idx = speed_steps.iter().position(|&s| (s - speed_value).abs() < 0.01); + + if ui.button(egui_material_icons::icons::ICON_REMOVE) + .on_hover_text("Slow down") + .clicked() + { + if let Some(idx) = current_idx { + if idx > 0 { + speed_value = speed_steps[idx - 1]; + *state.speed.write().unwrap() = speed_value; + } + } + } + ui.label(format!("{:.1}x", speed_value)) + .on_hover_text("Playback speed — controls how fast network flows are simulated"); + if ui.button(egui_material_icons::icons::ICON_ADD) + .on_hover_text("Speed up") + .clicked() + { + if let Some(idx) = current_idx { + if idx < speed_steps.len() - 1 { + speed_value = speed_steps[idx + 1]; + *state.speed.write().unwrap() = speed_value; + } + } + } + }); + + }); + }); + + // Overlay stats (bottom-left of graph) + egui::Area::new(egui::Id::new("viz_overlay_stats")) + .fixed_pos(panel_rect.left_bottom() + egui::vec2(4.0, 0.0)) + .pivot(egui::Align2::LEFT_BOTTOM) + .order(egui::Order::Foreground) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).shadow(egui::epaint::Shadow::NONE).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(format!( + "Active: {} | Flows: {}", + state.active_links.len(), + state.total_flows + )); + info_icon( + ui, + "This is a simulation of network traffic based on the current configuration. No real traffic is generated.", + ); + }); + }); + }); + + // Overlay legend: node types (top-right of graph) + egui::Area::new(egui::Id::new("viz_overlay_node_legend")) + .pivot(egui::Align2::RIGHT_TOP) + .fixed_pos(panel_rect.right_top() + egui::vec2(-4.0, 4.0)) + .order(egui::Order::Foreground) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).shadow(egui::epaint::Shadow::NONE).show(ui, |ui| { + legend_item_with_image(ui, "Server", egui::include_image!("../../assets/server.png")); + legend_item_with_image(ui, "User", egui::include_image!("../../assets/computer.png")); + legend_item_with_image(ui, "Internet", egui::include_image!("../../assets/internet.png")); + }); + }); + + // Overlay legend: edge states (bottom-right of graph) + egui::Area::new(egui::Id::new("viz_overlay_edge_legend")) + .pivot(egui::Align2::RIGHT_BOTTOM) + .fixed_pos(panel_rect.right_bottom() + egui::vec2(-4.0, -4.0)) + .order(egui::Order::Foreground) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).shadow(egui::epaint::Shadow::NONE).show(ui, |ui| { + legend_item_inline(ui, "Inactive", COLOR_INACTIVE); + legend_item_inline(ui, "HTTP", COLOR_HTTP); + legend_item_inline(ui, "HTTPS", COLOR_HTTPS); + legend_item_inline(ui, "SSH", COLOR_SSH); + legend_item_inline(ui, "DNS", COLOR_DNS); + legend_item_inline(ui, "SMTP", COLOR_SMTP); + legend_item_inline(ui, "Other", COLOR_OTHER); + }); + }); }); } From 34e86f77e0aed8600cc8972ac084b1f3bfdc2423 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:36:47 +0100 Subject: [PATCH 53/77] feat(gui/config): add startup modal to choose configuration source --- crates/fosr-gui/src/app.rs | 88 ++++++++++++- .../fosr-gui/src/generation/generation_tab.rs | 6 +- .../fosr-gui/src/shared/configuration_file.rs | 116 ++++++++++-------- 3 files changed, 154 insertions(+), 56 deletions(-) diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index b6c2da2c..6c27c1b7 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -5,7 +5,11 @@ use crate::configuration::configuration_tab::{ use crate::generation::generation_tab::{GenerationTabState, show_generation_tab_content}; // #[cfg(not(target_arch = "wasm32"))] // use crate::injection_tab::show_injection_tab_content; -use crate::shared::configuration_file::ConfigurationFileState; +use crate::shared::configuration_file::{ + ConfigurationFileState, load_default_config, trigger_file_import, +}; +#[cfg(target_arch = "wasm32")] +use crate::shared::configuration_file::poll_file_import; use crate::visualization::visualization_tab::{ VisualizationTabState, show_visualization_tab_content, }; @@ -116,6 +120,11 @@ impl eframe::App for FosrApp { }); }); + // Startup modal: choose configuration source + if !self.configuration_file_state.config_chosen { + render_startup_modal(ctx, &mut self.configuration_file_state); + } + // The Central Panel is the region left after adding the Top, Bottom and Side panels. egui::CentralPanel::default().show(ctx, |ui| { // Wrap in ScrollArea for vertical scrolling @@ -156,3 +165,80 @@ impl eframe::App for FosrApp { }); } } + +/// A clickable card with icon, title and description. Returns true if clicked. +fn startup_card(ui: &mut egui::Ui, icon: &str, title: &str, description: &str) -> bool { + // Reserve the full width and detect hover/click on the whole area + let desired_size = egui::vec2(ui.available_width(), 120.0); + let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); + + // Choose fill color based on hover + let fill = if response.hovered() { + ui.style().visuals.widgets.hovered.bg_fill + } else { + ui.style().visuals.widgets.inactive.bg_fill + }; + + let frame = egui::Frame::group(ui.style()).fill(fill); + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + frame.show(ui, |ui| { + // Disable interactions on child widgets so the whole card behaves as one + ui.style_mut().interaction.selectable_labels = false; + ui.set_width(ui.available_width()); + ui.vertical_centered(|ui| { + ui.add_space(8.0); + ui.label(egui::RichText::new(icon).size(28.0)); + ui.add_space(4.0); + ui.strong(egui::RichText::new(title).size(16.0)); + ui.add_space(2.0); + ui.label( + egui::RichText::new(description) + .size(12.0) + .color(egui::Color32::GRAY), + ); + ui.add_space(8.0); + }); + }); + }); + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + response.clicked() +} + +fn render_startup_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { + egui::Modal::new(egui::Id::new("startup_config_modal")).show(ctx, |ui| { + ui.set_width(400.0); + ui.heading("Welcome to Fos-R"); + ui.add_space(4.0); + ui.label("Choose a configuration to get started:"); + ui.add_space(12.0); + + ui.columns(2, |cols| { + // Left: default config + if startup_card( + &mut cols[0], + egui_material_icons::icons::ICON_LAN, + "Default configuration", + "A sample enterprise network\nwith servers and workstations", + ) { + load_default_config(state); + } + + // Right: import file + if startup_card( + &mut cols[1], + egui_material_icons::icons::ICON_UPLOAD_FILE, + "Import YAML file", + "Load your own network\nconfiguration from a file", + ) { + trigger_file_import(state, cols[1].ctx()); + } + }); + + #[cfg(target_arch = "wasm32")] + poll_file_import(state); + }); +} diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 90248a8b..55853287 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -8,7 +8,7 @@ use super::generation_validation::{ validate_timezone, }; use crate::shared::configuration_file::{ - ConfigurationFileState, configuration_file_picker, load_config_file_contents, + ConfigurationFileState, load_config_file_contents, }; use crate::shared::ui_utils::info_icon; #[cfg(not(target_arch = "wasm32"))] @@ -116,10 +116,6 @@ pub fn show_generation_tab_content( // Eagerly load config file contents when a file is selected load_config_file_contents(configuration_file_state); - configuration_file_picker(ui, configuration_file_state); - - ui.separator(); - ui.horizontal(|ui| { ui.label("Duration"); info_icon(ui, "Minimum pcap traffic duration described in human-friendly time, such as \"30m\", \"1h\", \"2d\" or \"15days 30min 5s\"."); diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 65e55f13..85ad7b3a 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -19,20 +19,80 @@ pub struct ConfigurationFileState { pub config_file_content_receiver: Option>>, pub config_model: Option, pub parse_error: Option, + /// Whether the user has chosen a configuration (default or imported). + /// When false, the startup modal is shown. + pub config_chosen: bool, } impl Default for ConfigurationFileState { fn default() -> Self { - let config_model = serde_yaml::from_str::(DEFAULT_CONFIG_YAML).ok(); Self { picked_config_file: None, #[cfg(target_arch = "wasm32")] config_file_receiver: None, - config_file_content: Some(DEFAULT_CONFIG_YAML.to_string()), + config_file_content: None, #[cfg(target_arch = "wasm32")] config_file_content_receiver: None, - config_model, + config_model: None, parse_error: None, + config_chosen: false, + } + } +} + +/// Load the built-in default configuration into the state. +pub fn load_default_config(state: &mut ConfigurationFileState) { + state.picked_config_file = None; + state.config_file_content = Some(DEFAULT_CONFIG_YAML.to_string()); + state.config_model = serde_yaml::from_str::(DEFAULT_CONFIG_YAML).ok(); + state.parse_error = None; + state.config_chosen = true; +} + +/// Trigger a file import dialog (works on both desktop and WASM). +/// On desktop this is synchronous; on WASM the result arrives via `config_file_receiver`. +pub fn trigger_file_import(state: &mut ConfigurationFileState, ctx: &egui::Context) { + state.config_file_content = None; + #[cfg(target_arch = "wasm32")] + { + state.config_file_content_receiver = None; + } + + #[cfg(not(target_arch = "wasm32"))] + { + let _ = ctx; + let file = show_file_picker_desktop(); + if file.is_some() { + state.picked_config_file = file; + state.config_chosen = true; + clear_loaded_config(state); + } + } + + #[cfg(target_arch = "wasm32")] + { + let (sender, receiver) = channel(); + state.config_file_receiver = Some(receiver); + let ctx = ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let file = show_file_picker_wasm().await; + let _ = sender.send(file); + ctx.request_repaint(); + }); + } +} + +/// Poll the WASM async file picker and apply the result if ready. +#[cfg(target_arch = "wasm32")] +pub fn poll_file_import(state: &mut ConfigurationFileState) { + if let Some(receiver) = &state.config_file_receiver { + if let Ok(file) = receiver.try_recv() { + if file.is_some() { + state.picked_config_file = file; + state.config_chosen = true; + clear_loaded_config(state); + } + state.config_file_receiver = None; } } } @@ -44,53 +104,12 @@ pub fn configuration_file_picker( ui.horizontal(|ui| { ui.label("Configuration file:"); - // File Dialog to pick a config file if ui.button(egui_material_icons::icons::ICON_FOLDER_OPEN).on_hover_text("Select file").clicked() { - // Clear previous config content when selecting a new file - configuration_file_state.config_file_content = None; - #[cfg(target_arch = "wasm32")] - { - configuration_file_state.config_file_content_receiver = None; - } - - #[cfg(not(target_arch = "wasm32"))] - { - // Only update if a file was actually selected - let file = show_file_picker_desktop(); - if file.is_some() { - configuration_file_state.picked_config_file = file; - clear_loaded_config(configuration_file_state); - } - } - - #[cfg(target_arch = "wasm32")] - { - let (sender, receiver) = channel(); - configuration_file_state.config_file_receiver = Some(receiver); - - let ctx = ui.ctx().clone(); - wasm_bindgen_futures::spawn_local(async move { - let file = show_file_picker_wasm().await; - let _ = sender.send(file); - ctx.request_repaint(); - }); - } + trigger_file_import(configuration_file_state, ui.ctx()); } #[cfg(target_arch = "wasm32")] - // Check if we received a file from the async task - { - if let Some(receiver) = &configuration_file_state.config_file_receiver { - if let Ok(file) = receiver.try_recv() { - // Only update if a file was actually selected - if file.is_some() { - configuration_file_state.picked_config_file = file; - clear_loaded_config(configuration_file_state); - } - configuration_file_state.config_file_receiver = None; // Dialog finished - } - } - } + poll_file_import(configuration_file_state); // Display the file name on disk, or indicate built-in default let filename = if let Some(file) = &configuration_file_state.picked_config_file { @@ -229,10 +248,7 @@ fn clear_loaded_config(configuration_file_state: &mut ConfigurationFileState) { /// Restore the built-in default configuration. pub fn reset_loaded_config(configuration_file_state: &mut ConfigurationFileState) { - configuration_file_state.config_file_content = Some(DEFAULT_CONFIG_YAML.to_string()); - configuration_file_state.config_model = - serde_yaml::from_str::(DEFAULT_CONFIG_YAML).ok(); - configuration_file_state.parse_error = None; + load_default_config(configuration_file_state); #[cfg(target_arch = "wasm32")] { From 2319b4078000e0e134bfc2b19e90ad37c2077319 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:28:47 +0100 Subject: [PATCH 54/77] feat(gui/wasm): implement a fullscreen button for web target --- crates/fosr-gui/Cargo.toml | 2 +- crates/fosr-gui/src/app.rs | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index 5c42c4a9..d1936899 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -46,7 +46,7 @@ egui_material_icons = "0.5.0" [target.'cfg(target_arch = "wasm32")'.dependencies] gloo-timers = { version = "0.3", features = ["futures"] } wasm-bindgen-futures = "0.4.58" -web-sys = "0.3.85" +web-sys = { version = "0.3.85", features = ["Element", "Document", "Window"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] env_logger = { version = "0.11.8", default-features = false, features = ["auto-color", "humantime"] } diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index 6c27c1b7..d9af1f89 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -72,9 +72,6 @@ impl eframe::App for FosrApp { egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { // Add a Menu Bar to host the tabs buttons egui::MenuBar::new().ui(ui, |ui| { - // On native, show the theme switch (using system theme by default) - #[cfg(not(target_arch = "wasm32"))] - global_theme_preference_switch(ui); // On web, use dark theme to match with the Fos-R website's theme #[cfg(target_arch = "wasm32")] ctx.set_theme(egui::Theme::Dark); @@ -117,6 +114,40 @@ impl eframe::App for FosrApp { { self.current_tab = CurrentTab::About; } + + // Right-align utility buttons so they sit on the opposite side of the tabs + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // On native, show the theme switch (using system theme by default) + #[cfg(not(target_arch = "wasm32"))] + global_theme_preference_switch(ui); + + // On web, show a fullscreen toggle button + #[cfg(target_arch = "wasm32")] + { + let is_fullscreen = web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.fullscreen_element()) + .is_some(); + + let (icon, tooltip) = if is_fullscreen { + (egui_material_icons::icons::ICON_FULLSCREEN_EXIT, "Exit fullscreen") + } else { + (egui_material_icons::icons::ICON_FULLSCREEN, "Fullscreen") + }; + + if ui.button(icon).on_hover_text(tooltip).clicked() { + if let Some(window) = web_sys::window() { + if let Some(document) = window.document() { + if is_fullscreen { + document.exit_fullscreen(); + } else if let Some(canvas) = document.get_element_by_id("fosr_gui_canvas") { + let _ = canvas.request_fullscreen(); + } + } + } + } + } + }); }); }); From 22a9a3adc94f1abfe3fc5d0f7c14d2e9aef4808d Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:29:34 +0100 Subject: [PATCH 55/77] fix(gui/generation): specify a reasonable min year for the date picker --- crates/fosr-gui/src/generation/generation_tab.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 55853287..a6426322 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -166,7 +166,7 @@ pub fn show_generation_tab_content( if !state.use_current_time { ui.horizontal(|ui| { ui.label("Start time"); - ui.add(DatePickerButton::new(&mut state.start_date)); + ui.add(DatePickerButton::new(&mut state.start_date).start_end_years(2020..=2100)); ui.add(TimePickerButton::new(&mut state.start_hour).show_seconds(true).use_dragvalue(true)); }); From 305a45e8d7f29ec9319806982dd332a1fbcbe47a Mon Sep 17 00:00:00 2001 From: {} <> Date: Fri, 6 Mar 2026 13:02:03 +0100 Subject: [PATCH 56/77] Feat: metadata at the end, random mac addresses, ip conflicts and basic red star for mandatory fields --- crates/fosr-gui/Cargo.toml | 1 + .../src/configuration/configuration_tab.rs | 146 ++++++++++++------ 2 files changed, 104 insertions(+), 43 deletions(-) diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index d1936899..887417ae 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -42,6 +42,7 @@ petgraph = "0.8.3" egui_graphs = { version = "0.29.0", features = ["events"] } serde_yaml = "0.9" egui_material_icons = "0.5.0" +rand = "0.10.0" [target.'cfg(target_arch = "wasm32")'.dependencies] gloo-timers = { version = "0.3", features = ["futures"] } diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 27091309..4afefb59 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -5,18 +5,75 @@ use crate::shared::configuration_file::{ use crate::shared::ui_utils::{edit_optional_multiline_string, edit_optional_string}; use chrono::NaiveDate; use eframe::egui; +use egui::{TextFormat, text::LayoutJob}; use egui_extras::DatePickerButton; -use std::collections::HashSet; +use std::collections::HashMap; + +// Helper for required label with red * +fn required_label(ui: &mut egui::Ui, text: &str) { + let mut job = LayoutJob::default(); + + job.append( + text, + 0.0, + TextFormat { + color: ui.visuals().text_color(), + ..Default::default() + }, + ); + + job.append( + "*", + 0.0, + TextFormat { + color: egui::Color32::RED, + ..Default::default() + }, + ); + + job.append( + ":", + 0.0, + TextFormat { + color: ui.visuals().text_color(), + ..Default::default() + }, + ); + + ui.label(job); +} + +/// Generate a random mac address +fn random_mac() -> String { + let mut bytes: [u8; 6] = rand::random(); + + // Forcing local MAC + bytes[0] = (bytes[0] | 0x02) & 0xFE; + + format!( + "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5] + ) +} + +fn generate_mac_until_unique(mac_counts: &HashMap) -> String { + loop { + let mac = random_mac(); + + if !mac_counts.contains_key(&mac) { + return mac; + } + } +} /// Scans all interfaces to find the next available IP in 192.168.0.x -fn next_free_ip(used_ips: &HashSet) -> Option { +fn next_free_ip(ip_counts: &HashMap) -> Option { for x in 1..=254 { let candidate = format!("192.168.0.{x}"); - if !used_ips.contains(&candidate) { + if !ip_counts.contains_key(&candidate) { return Some(candidate); } } - None } @@ -91,10 +148,10 @@ pub fn show_configuration_tab_content( // Editor (if model is loaded) if let Some(model) = file_state.config_model.as_mut() { - ui_metadata(ui, model); - ui.separator(); ui_hosts_section(ui, model); ui.separator(); + ui_metadata(ui, model); + ui.separator(); } // Auto-sync model edits back to YAML so other tabs see the changes @@ -134,31 +191,26 @@ fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { ui.heading("Metadata"); ui.add_space(6.0); - // Title (Mandatory) + // Title ui.horizontal(|ui| { - ui.label("Title:"); + required_label(ui, "Title"); let title = model.metadata.title.get_or_insert_with(String::new); ui.text_edit_singleline(title); }); edit_optional_multiline_string( ui, - "Description (optional):", + "Description:", &mut model.metadata.desc, "Optional description", 3, ); - edit_optional_string( - ui, - "Author (optional):", - &mut model.metadata.author, - "Jane Doe", - ); + edit_optional_string(ui, "Author:", &mut model.metadata.author, "Jane Doe"); // Date Picker ui.horizontal(|ui| { - ui.label("Date (optional):"); + ui.label("Date:"); let mut date_val = model .metadata .date @@ -175,12 +227,7 @@ fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { } }); - edit_optional_string( - ui, - "Version (optional):", - &mut model.metadata.version, - "0.1.0", - ); + edit_optional_string(ui, "Version:", &mut model.metadata.version, "0.1.0"); // Format (Reserved) ui.horizontal(|ui| { @@ -215,18 +262,26 @@ fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { return; } - // Pre-calculate used IPs to avoid borrow checker conflicts - let mut used_ips = HashSet::new(); + // Keep track of used IP and MAC addresses + let mut ip_counts: HashMap = HashMap::new(); for h in &model.hosts { for iface in &h.interfaces { - used_ips.insert(iface.ip_addr.clone()); + *ip_counts.entry(iface.ip_addr.clone()).or_insert(0) += 1; + } + } + let mut mac_counts: HashMap = HashMap::new(); + for h in &model.hosts { + for iface in &h.interfaces { + if let Some(mac) = &iface.mac_addr { + *mac_counts.entry(mac.clone()).or_insert(0) += 1; + } } } let mut host_to_remove: Option = None; for (idx, host) in model.hosts.iter_mut().enumerate() { - ui_single_host(ui, idx, host, &used_ips, &mut host_to_remove); + ui_single_host(ui, idx, host, &ip_counts, &mac_counts, &mut host_to_remove); ui.add_space(6.0); } @@ -240,7 +295,8 @@ fn ui_single_host( ui: &mut egui::Ui, index: usize, host: &mut Host, - used_ips: &HashSet, + ip_counts: &HashMap, + mac_counts: &HashMap, remove_request: &mut Option, ) { let host_name = host_display_name(host); @@ -270,10 +326,10 @@ fn ui_single_host( }); ui_host_os_selector(ui, index, &mut host.os); - edit_optional_string(ui, "Hostname (optional):", &mut host.hostname, "host1"); + edit_optional_string(ui, "Hostname:", &mut host.hostname, "host1"); ui.horizontal(|ui| { - ui.label("Usage (optional):"); + ui.label("Usage:"); let mut usage_val = host.usage.unwrap_or(1.0); if ui .add(egui::DragValue::new(&mut usage_val).speed(0.1)) @@ -293,14 +349,14 @@ fn ui_single_host( ui_host_type_selector(ui, index, host); ui_host_client_protocols(ui, host); ui.separator(); - ui_interfaces_section(ui, index, host, used_ips); + ui_interfaces_section(ui, index, host, ip_counts, mac_counts); }); } /// Dropdown selector for the Operating System fn ui_host_os_selector(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option) { ui.horizontal(|ui| { - ui.label("OS (optional):"); + ui.label("OS:"); let selected_text = host_os.as_deref().unwrap_or(""); @@ -336,7 +392,7 @@ fn ui_host_os_selector(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option< /// Type of host rendering fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { ui.horizontal(|ui| { - ui.label("Type (optional):"); + ui.label("Type:"); let selected_text = host.r#type.as_deref().unwrap_or("").to_string(); egui::ComboBox::from_id_salt((host_idx, "host_type")) @@ -371,7 +427,7 @@ fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { /// Client protocols rendering fn ui_host_client_protocols(ui: &mut egui::Ui, host: &mut Host) { ui.horizontal(|ui| { - ui.label("Client protocols (optional):"); + ui.label("Client protocols:"); let mut buf = if host.client.is_empty() { String::new() } else { @@ -399,15 +455,16 @@ fn ui_interfaces_section( ui: &mut egui::Ui, host_idx: usize, host: &mut Host, - used_ips: &HashSet, + ip_counts: &HashMap, + mac_counts: &HashMap, ) { ui.horizontal(|ui| { ui.label("Interfaces:"); if ui.button(egui_material_icons::icons::ICON_ADD).on_hover_text("Add interface").clicked() { - if let Some(ip) = next_free_ip(used_ips) { + if let Some(ip) = next_free_ip(ip_counts) { host.interfaces.push(Interface { ip_addr: ip, - mac_addr: None, + mac_addr: Some(generate_mac_until_unique(mac_counts)), services: Vec::new(), }); } else { @@ -440,15 +497,18 @@ fn ui_interfaces_section( .body(|ui| { ui.add_space(4.0); ui.horizontal(|ui| { - ui.label("IP (mandatory):"); + required_label(ui, "IP"); ui.text_edit_singleline(&mut iface.ip_addr); + if ip_counts.get(&iface.ip_addr).copied().unwrap_or(0) > 1 { + ui.colored_label(egui::Color32::RED, "IP already in used"); + } }); - edit_optional_string( - ui, - "MAC (optional):", - &mut iface.mac_addr, - "00:14:2A:3F:47:D8", - ); + edit_optional_string(ui, "MAC:", &mut iface.mac_addr, "00:14:2A:3F:47:D8"); + if let Some(mac) = &iface.mac_addr { + if mac_counts.get(mac).copied().unwrap_or(0) > 1 { + ui.colored_label(egui::Color32::RED, "MAC already in use"); + } + } ui_services_section(ui, if_idx, iface); }); ui.add_space(6.0); From c3103a132d718b94ae74a305461df09dc5fc7536 Mon Sep 17 00:00:00 2001 From: {} <> Date: Fri, 6 Mar 2026 13:17:01 +0100 Subject: [PATCH 57/77] Feat: remove date and format from ui metada section and enforce format to 1 and current date --- .../src/configuration/configuration_tab.rs | 55 +++++-------------- .../fosr-gui/src/shared/configuration_file.rs | 21 ++++++- 2 files changed, 33 insertions(+), 43 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 4afefb59..3f622791 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -3,10 +3,8 @@ use crate::shared::configuration_file::{ ConfigurationFileState, configuration_file_picker, load_config_file_contents, }; use crate::shared::ui_utils::{edit_optional_multiline_string, edit_optional_string}; -use chrono::NaiveDate; use eframe::egui; use egui::{TextFormat, text::LayoutJob}; -use egui_extras::DatePickerButton; use std::collections::HashMap; // Helper for required label with red * @@ -208,41 +206,7 @@ fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { edit_optional_string(ui, "Author:", &mut model.metadata.author, "Jane Doe"); - // Date Picker - ui.horizontal(|ui| { - ui.label("Date:"); - let mut date_val = model - .metadata - .date - .as_deref() - .and_then(|s| NaiveDate::parse_from_str(s, "%Y/%m/%d").ok()) - .unwrap_or_else(|| NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()); - - if ui.add(DatePickerButton::new(&mut date_val)).changed() { - model.metadata.date = Some(date_val.format("%Y/%m/%d").to_string()); - } - - if ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear").clicked() { - model.metadata.date = None; - } - }); - edit_optional_string(ui, "Version:", &mut model.metadata.version, "0.1.0"); - - // Format (Reserved) - ui.horizontal(|ui| { - ui.label("Format:"); - let current = model.metadata.format.unwrap_or(1); - ui.label(current.to_string()) - .on_hover_text("Reserved for now. Should remain 1."); - - if ui.button("Set to 1").clicked() { - model.metadata.format = Some(1); - } - if ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear").clicked() { - model.metadata.format = None; - } - }); } /// Several host rendering @@ -509,7 +473,7 @@ fn ui_interfaces_section( ui.colored_label(egui::Color32::RED, "MAC already in use"); } } - ui_services_section(ui, if_idx, iface); + ui_services_section(ui, if_idx, host_idx, iface); }); ui.add_space(6.0); } @@ -520,12 +484,21 @@ fn ui_interfaces_section( } /// Service section rendering -fn ui_services_section(ui: &mut egui::Ui, iface_idx: usize, iface: &mut Interface) { +fn ui_services_section( + ui: &mut egui::Ui, + host_idx: usize, + iface_idx: usize, + iface: &mut Interface, +) { let svc_count = iface.services.len(); + // let id = ui.make_persistent_id(("services", iface_idx)); + let id = ui.make_persistent_id(("services", host_idx, iface_idx)); - egui::CollapsingHeader::new(format!("Services ({svc_count})")) - .default_open(false) - .show(ui, |ui| { + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false) + .show_header(ui, |ui| { + ui.label(format!("Services ({svc_count})")); + }) + .body(|ui| { if ui.button(egui_material_icons::icons::ICON_ADD).on_hover_text("Add service").clicked() { iface.services.push("http".to_string()); } diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 85ad7b3a..974f1823 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -3,6 +3,7 @@ use crate::shared::config_model::Configuration; use crate::shared::file_io::{read_file_desktop, save_file_desktop, show_file_picker_desktop}; #[cfg(target_arch = "wasm32")] use crate::shared::file_io::{read_file_wasm, save_file_wasm, show_file_picker_wasm}; +use chrono::{DateTime, Local}; use eframe::egui; use rfd::FileHandle; #[cfg(target_arch = "wasm32")] @@ -44,7 +45,12 @@ impl Default for ConfigurationFileState { pub fn load_default_config(state: &mut ConfigurationFileState) { state.picked_config_file = None; state.config_file_content = Some(DEFAULT_CONFIG_YAML.to_string()); - state.config_model = serde_yaml::from_str::(DEFAULT_CONFIG_YAML).ok(); + state.config_model = serde_yaml::from_str::(DEFAULT_CONFIG_YAML) + .ok() + .map(|mut c| { + enforce_metadata_defaults(&mut c); + c + }); state.parse_error = None; state.config_chosen = true; } @@ -229,7 +235,10 @@ fn parse_config_yaml(configuration_file_state: &mut ConfigurationFileState) { }; match serde_yaml::from_str::(yaml) { - Ok(model) => configuration_file_state.config_model = Some(model), + Ok(mut model) => { + enforce_metadata_defaults(&mut model); + configuration_file_state.config_model = Some(model); + } Err(e) => configuration_file_state.parse_error = Some(e.to_string()), } } @@ -255,3 +264,11 @@ pub fn reset_loaded_config(configuration_file_state: &mut ConfigurationFileState configuration_file_state.config_file_content_receiver = None; } } + +/// Enforce date and format in metadata +fn enforce_metadata_defaults(config: &mut Configuration) { + let now: DateTime = Local::now(); + + config.metadata.date = Some(now.format("%Y/%m/%d").to_string()); + config.metadata.format = Some(1); +} From 6f3121ef8f50bd6be81d172f394ac6394383e956 Mon Sep 17 00:00:00 2001 From: {} <> Date: Fri, 6 Mar 2026 13:27:15 +0100 Subject: [PATCH 58/77] Feat: add tooltips for basic configuration labels --- Cargo.lock | 217 +++++++++++++++++- .../src/configuration/configuration_tab.rs | 5 +- .../fosr-gui/src/generation/generation_tab.rs | 9 +- crates/fosr-gui/src/shared/ui_utils.rs | 1 - 4 files changed, 225 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 24fb49fd..8c09ddc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -862,6 +862,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" version = "0.4.43" @@ -1068,6 +1079,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1818,6 +1838,7 @@ dependencies = [ "indicatif", "log", "petgraph", + "rand 0.10.0", "rfd", "serde", "serde_yaml", @@ -1963,11 +1984,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + [[package]] name = "gif" version = "0.14.1" @@ -2303,6 +2338,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -2386,6 +2427,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -2537,6 +2580,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.180" @@ -3585,6 +3634,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3671,6 +3730,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -3690,6 +3755,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -3715,6 +3791,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "rand_distr" version = "0.5.1" @@ -4117,7 +4199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4681,6 +4763,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unit-prefix" version = "0.5.2" @@ -4812,6 +4900,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -4871,6 +4968,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.12" @@ -5752,6 +5883,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 3f622791..3a4f6af2 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -2,7 +2,7 @@ use crate::shared::config_model::{Configuration, Host, Interface}; use crate::shared::configuration_file::{ ConfigurationFileState, configuration_file_picker, load_config_file_contents, }; -use crate::shared::ui_utils::{edit_optional_multiline_string, edit_optional_string}; +use crate::shared::ui_utils::{edit_optional_multiline_string, edit_optional_string, info_icon}; use eframe::egui; use egui::{TextFormat, text::LayoutJob}; use std::collections::HashMap; @@ -294,6 +294,7 @@ fn ui_single_host( ui.horizontal(|ui| { ui.label("Usage:"); + info_icon(ui, "How much is the host active"); let mut usage_val = host.usage.unwrap_or(1.0); if ui .add(egui::DragValue::new(&mut usage_val).speed(0.1)) @@ -357,6 +358,7 @@ fn ui_host_os_selector(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option< fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { ui.horizontal(|ui| { ui.label("Type:"); + info_icon(ui, "If the host is a server then it will provide services, if it is a client it will use a service. If the host is a server it needs services, if it is a client it needs clients protocols."); let selected_text = host.r#type.as_deref().unwrap_or("").to_string(); egui::ComboBox::from_id_salt((host_idx, "host_type")) @@ -392,6 +394,7 @@ fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { fn ui_host_client_protocols(ui: &mut egui::Ui, host: &mut Host) { ui.horizontal(|ui| { ui.label("Client protocols:"); + info_icon(ui, "Protocols the host is going to use from other servers"); let mut buf = if host.client.is_empty() { String::new() } else { diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index a6426322..87c437e0 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -15,6 +15,7 @@ use crate::shared::ui_utils::info_icon; use crate::shared::file_io::save_file_desktop; #[cfg(target_arch = "wasm32")] use crate::shared::file_io::save_file_wasm; +use crate::timepicker::TimePickerButton; use chrono::{NaiveDate, NaiveTime}; use chrono_tz::Tz; use eframe::egui; @@ -24,7 +25,6 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, channel}; use std::time::Duration; -use crate::timepicker::TimePickerButton; // Time interval for the slider. pub const DURATION_MIN: Duration = Duration::from_secs(60); // 1 min @@ -43,7 +43,6 @@ pub enum UiStatus { Error(String), } - /// Represents the state of the generation tab. pub struct GenerationTabState { pub progress: f32, @@ -167,7 +166,11 @@ pub fn show_generation_tab_content( ui.horizontal(|ui| { ui.label("Start time"); ui.add(DatePickerButton::new(&mut state.start_date).start_end_years(2020..=2100)); - ui.add(TimePickerButton::new(&mut state.start_hour).show_seconds(true).use_dragvalue(true)); + ui.add( + TimePickerButton::new(&mut state.start_hour) + .show_seconds(true) + .use_dragvalue(true), + ); }); ui.add_space(10.0); diff --git a/crates/fosr-gui/src/shared/ui_utils.rs b/crates/fosr-gui/src/shared/ui_utils.rs index a5489f03..90015ea6 100644 --- a/crates/fosr-gui/src/shared/ui_utils.rs +++ b/crates/fosr-gui/src/shared/ui_utils.rs @@ -68,7 +68,6 @@ pub fn edit_optional_string( }); } - /// Displays a multiline editor for an `Option`. /// /// - `None` is represented as an empty text box. From 2e3eb5e0185e9dca8e8593452b286f373cd04dab Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:29:58 +0100 Subject: [PATCH 59/77] chore(gui): edit some code comments --- crates/fosr-gui/src/app.rs | 3 ++- crates/fosr-gui/src/visualization/visualization_tab.rs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index d9af1f89..ad8a44f5 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -213,7 +213,8 @@ fn startup_card(ui: &mut egui::Ui, icon: &str, title: &str, description: &str) - let frame = egui::Frame::group(ui.style()).fill(fill); ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { frame.show(ui, |ui| { - // Disable interactions on child widgets so the whole card behaves as one + // Prevent child labels/icons from showing their own hover cursor and + // highlight, so the entire card acts as a single clickable area. ui.style_mut().interaction.selectable_labels = false; ui.set_width(ui.available_width()); ui.vertical_centered(|ui| { diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index cc36d768..3ed500dc 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -976,13 +976,16 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { egui::Frame::popup(ui.style()).shadow(egui::epaint::Shadow::NONE).show(ui, |ui| { ui.horizontal(|ui| { if !state.visualization_running { + // Continue: resume without resetting flow counts if ui.button(egui_material_icons::icons::ICON_PLAY_ARROW).on_hover_text("Continue").clicked() { + // Pass the user config if loaded, otherwise None (uses default BN model) let config = state.config_content.clone(); let speed = state.speed.clone(); if let Err(e) = state.start_visualization(config.as_deref(), speed, false) { log::error!("Failed to start flow streamer: {}", e); } } + // Restart: reset all flow counts and start fresh if ui.button(egui_material_icons::icons::ICON_RESTART_ALT).on_hover_text("Restart").clicked() { let config = state.config_content.clone(); let speed = state.speed.clone(); @@ -1003,6 +1006,8 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { // Playback speed: −/+ buttons with discrete steps let speed_steps: &[f32] = &[0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0]; + // Speed is an Arc, we cannot use it directly with the buttons, + // we need to read and write its value manually. let mut speed_value = *state.speed.read().unwrap(); let current_idx = speed_steps.iter().position(|&s| (s - speed_value).abs() < 0.01); From b78dcaaa78756c92553da970d956a78ad7604ca2 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:05:47 +0100 Subject: [PATCH 60/77] feat(gui): rework tab navigation + add tooltips --- crates/fosr-gui/src/app.rs | 32 ++++++++++++------- crates/fosr-gui/src/shared/ui_utils.rs | 1 - .../src/visualization/visualization_shapes.rs | 3 +- .../src/visualization/visualization_tab.rs | 19 ++++------- 4 files changed, 29 insertions(+), 26 deletions(-) diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index ad8a44f5..bb9c825f 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -30,7 +30,7 @@ enum CurrentTab { impl Default for CurrentTab { fn default() -> Self { - CurrentTab::Generation + CurrentTab::Visualization } } @@ -39,7 +39,7 @@ pub const DEFAULT_ZOOM: f32 = 1.4; #[derive(Default)] pub struct FosrApp { current_tab: CurrentTab, - zoom_initialized: bool, + style_initialized: bool, images_preloaded: bool, configuration_file_state: ConfigurationFileState, configuration_tab_state: ConfigurationTabState, @@ -50,9 +50,10 @@ pub struct FosrApp { impl eframe::App for FosrApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Set default zoom once - if !self.zoom_initialized { + if !self.style_initialized { ctx.options_mut(|option| option.zoom_factor = DEFAULT_ZOOM); - self.zoom_initialized = true; + ctx.style_mut(|s| s.interaction.tooltip_delay = 0.1); + self.style_initialized = true; } // Set the image loaders @@ -77,28 +78,31 @@ impl eframe::App for FosrApp { ctx.set_theme(egui::Theme::Dark); if ui - .selectable_label(self.current_tab == CurrentTab::Generation, "Generation") + .selectable_label( + self.current_tab == CurrentTab::Visualization, + "Live Preview", + ) + .on_hover_text("Simulation of network traffic based on the current configuration. No real traffic is generated.") .clicked() { - self.current_tab = CurrentTab::Generation; + self.current_tab = CurrentTab::Visualization; } if ui .selectable_label( self.current_tab == CurrentTab::Configuration, "Configuration", ) + .on_hover_text("Edit the network configuration: hosts, interfaces, and services.") .clicked() { self.current_tab = CurrentTab::Configuration; } if ui - .selectable_label( - self.current_tab == CurrentTab::Visualization, - "Visualization", - ) + .selectable_label(self.current_tab == CurrentTab::Generation, "Generation") + .on_hover_text("Generate a PCAP file from the current network configuration.") .clicked() { - self.current_tab = CurrentTab::Visualization; + self.current_tab = CurrentTab::Generation; } // To be implemented // #[cfg(not(target_arch = "wasm32"))] @@ -110,6 +114,7 @@ impl eframe::App for FosrApp { // } if ui .selectable_label(self.current_tab == CurrentTab::About, "About") + .on_hover_text("About Fos-R and its authors.") .clicked() { self.current_tab = CurrentTab::About; @@ -157,7 +162,12 @@ impl eframe::App for FosrApp { } // The Central Panel is the region left after adding the Top, Bottom and Side panels. + // Don't render tab content while the startup modal is open, otherwise + // foreground overlays (e.g. visualization controls) would appear above the modal. egui::CentralPanel::default().show(ctx, |ui| { + if !self.configuration_file_state.config_chosen { + return; + } // Wrap in ScrollArea for vertical scrolling egui::ScrollArea::vertical().show(ui, |ui| { // Display the tab content depending on the currently select tab diff --git a/crates/fosr-gui/src/shared/ui_utils.rs b/crates/fosr-gui/src/shared/ui_utils.rs index 90015ea6..902de2e5 100644 --- a/crates/fosr-gui/src/shared/ui_utils.rs +++ b/crates/fosr-gui/src/shared/ui_utils.rs @@ -3,7 +3,6 @@ use eframe::egui; /// Display a small info icon with a tooltip. pub fn info_icon(ui: &mut egui::Ui, tooltip: &str) { ui.add_space(-4.0); - ui.ctx().style_mut(|s| s.interaction.tooltip_delay = 0.1); ui.label(egui::RichText::new("ℹ").color(egui::Color32::GRAY).size(14.0)) .on_hover_cursor(egui::CursorIcon::Help) .on_hover_ui(|ui| { diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/visualization_shapes.rs index 1d9fdce7..ebb2bd36 100644 --- a/crates/fosr-gui/src/visualization/visualization_shapes.rs +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -122,8 +122,7 @@ for NetworkNodeShape } // Draw text label - let is_internet = matches!(self.node_type, NodeType::Internet); - let font_size = if is_internet { 14.0 } else { 10.0 }; + let font_size = 14.0; let font_id = egui::FontId::proportional(font_size); let job = egui::text::LayoutJob::simple( diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 3ed500dc..328da70e 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -6,7 +6,6 @@ use super::visualization_stream::{FlowEvent, FlowStreamer}; use super::visualization_utils::distribute_nodes_circle; use crate::shared::config_model::Host; use crate::shared::configuration_file::ConfigurationFileState; -use crate::shared::ui_utils::info_icon; use eframe::egui; use egui_graphs::{ FruchtermanReingoldState, FruchtermanReingoldWithCenterGravity, @@ -1048,15 +1047,11 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { .show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).shadow(egui::epaint::Shadow::NONE).show(ui, |ui| { ui.horizontal(|ui| { - ui.label(format!( - "Active: {} | Flows: {}", - state.active_links.len(), - state.total_flows - )); - info_icon( - ui, - "This is a simulation of network traffic based on the current configuration. No real traffic is generated.", - ); + ui.label(format!("Active: {}", state.active_links.len())) + .on_hover_text("Number of network links currently transmitting data."); + ui.separator(); + ui.label(format!("Total flows: {}", state.total_flows)) + .on_hover_text("Cumulative number of flows generated since the simulation started."); }); }); }); @@ -1071,7 +1066,7 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { legend_item_with_image(ui, "Server", egui::include_image!("../../assets/server.png")); legend_item_with_image(ui, "User", egui::include_image!("../../assets/computer.png")); legend_item_with_image(ui, "Internet", egui::include_image!("../../assets/internet.png")); - }); + }).response.on_hover_text("Node types"); }); // Overlay legend: edge states (bottom-right of graph) @@ -1088,7 +1083,7 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { legend_item_inline(ui, "DNS", COLOR_DNS); legend_item_inline(ui, "SMTP", COLOR_SMTP); legend_item_inline(ui, "Other", COLOR_OTHER); - }); + }).response.on_hover_text("Link protocols"); }); }); } From 4d8dfa33e4b6eaa873b8385c3a260a7b300403de Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:33:09 +0100 Subject: [PATCH 61/77] feat(gui/generation): display start time as UTC --- .../fosr-gui/src/generation/generation_tab.rs | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 87c437e0..faa9ad1e 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -16,7 +16,7 @@ use crate::shared::file_io::save_file_desktop; #[cfg(target_arch = "wasm32")] use crate::shared::file_io::save_file_wasm; use crate::timepicker::TimePickerButton; -use chrono::{NaiveDate, NaiveTime}; +use chrono::{Local, NaiveDate, NaiveTime, TimeZone}; use chrono_tz::Tz; use eframe::egui; use eframe::egui::{SliderClamping, Widget}; @@ -100,8 +100,8 @@ impl Default for GenerationTabState { timezone_input: String::new(), use_current_time: true, use_local_timezone: true, - start_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), - start_hour: NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + start_date: Local::now().date_naive(), + start_hour: Local::now().time(), output_file_name: "output.pcap".to_string(), } } @@ -199,10 +199,35 @@ pub fn show_generation_tab_content( } } }); + } else { state.timezone_validation.set_ok(); } + // Show the equivalent UTC start time + let utc_label = if state.use_current_time { + Some(chrono::Utc::now()) + } else { + let local_dt = state.start_date.and_time(state.start_hour); + if state.use_local_timezone { + Local::now() + .timezone() + .from_local_datetime(&local_dt) + .earliest() + .map(|dt| dt.with_timezone(&chrono::Utc)) + } else { + state.timezone_input.parse::().ok() + .and_then(|tz| local_dt.and_local_timezone(tz).earliest()) + .map(|dt| dt.with_timezone(&chrono::Utc)) + } + }; + if let Some(utc) = utc_label { + ui.label( + egui::RichText::new(format!("Start time (UTC): {}", utc.format("%Y-%m-%d %H:%M:%S"))) + .color(egui::Color32::GRAY), + ); + } + ui.add_space(10.0); ui.horizontal(|ui| { From 5b332605e567d4633c446f51d0302f1570d6255d Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:11:21 +0100 Subject: [PATCH 62/77] feat(gui/generation): custom timezone picker + dynamic date picker year range --- .../fosr-gui/src/generation/generation_tab.rs | 5 +- .../generation/generation_ui_components.rs | 141 ++++++++++++++---- 2 files changed, 118 insertions(+), 28 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index faa9ad1e..656f3084 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -16,7 +16,7 @@ use crate::shared::file_io::save_file_desktop; #[cfg(target_arch = "wasm32")] use crate::shared::file_io::save_file_wasm; use crate::timepicker::TimePickerButton; -use chrono::{Local, NaiveDate, NaiveTime, TimeZone}; +use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone}; use chrono_tz::Tz; use eframe::egui; use eframe::egui::{SliderClamping, Widget}; @@ -165,7 +165,8 @@ pub fn show_generation_tab_content( if !state.use_current_time { ui.horizontal(|ui| { ui.label("Start time"); - ui.add(DatePickerButton::new(&mut state.start_date).start_end_years(2020..=2100)); + let current_year = Local::now().date_naive().year(); + ui.add(DatePickerButton::new(&mut state.start_date).start_end_years((current_year - 5)..=(current_year + 30))); ui.add( TimePickerButton::new(&mut state.start_hour) .show_seconds(true) diff --git a/crates/fosr-gui/src/generation/generation_ui_components.rs b/crates/fosr-gui/src/generation/generation_ui_components.rs index 62d2c384..5b1df0c1 100644 --- a/crates/fosr-gui/src/generation/generation_ui_components.rs +++ b/crates/fosr-gui/src/generation/generation_ui_components.rs @@ -1,7 +1,33 @@ +// The `timezone_picker` function is inspired by egui's ComboBox (combo_box.rs). +// +// egui is licensed under MIT OR Apache-2.0. +// +// MIT License +// +// Copyright (c) 2018-2021 Emil Ernerfeldt +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + use super::generation_tab::{GenerationTabState, UiStatus}; use super::generation_validation::FieldValidation; use chrono_tz::TZ_VARIANTS; -use eframe::{egui, egui::PopupCloseBehavior}; +use eframe::egui::{self, epaint}; /// Display the error in red pub fn show_field_error(ui: &mut egui::Ui, validation: &FieldValidation) { @@ -32,44 +58,107 @@ pub fn show_status(ui: &mut egui::Ui, status: &UiStatus) { } pub fn timezone_picker(ui: &mut egui::Ui, state: &mut GenerationTabState) { - egui::ComboBox::from_id_salt("timezone") - .selected_text(&state.timezone_input) - .width(200.0) - .close_behavior(PopupCloseBehavior::CloseOnClickOutside) - .show_ui(ui, |ui| { - ui.set_max_width(240.0); + let popup_id = ui.make_persistent_id("tz_popup"); + let is_open = egui::Popup::is_id_open(ui.ctx(), popup_id); + + // --- ComboBox-style button (inspired by egui's combo_box.rs button_frame) --- + let width = 160.0_f32; + let margin = ui.spacing().button_padding; + let icon_spacing = ui.spacing().icon_spacing; + let icon_size = egui::Vec2::splat(ui.spacing().icon_width); + let desired_size = egui::vec2(width, ui.spacing().interact_size.y); + + let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); + + if ui.is_rect_visible(rect) { + let visuals = if is_open { + &ui.visuals().widgets.open + } else { + ui.style().interact(&response) + }; + + // Button background + ui.painter().add(epaint::RectShape::new( + rect.expand(visuals.expansion), + visuals.corner_radius, + visuals.weak_bg_fill, + visuals.bg_stroke, + epaint::StrokeKind::Inside, + )); + + let inner = rect.shrink2(margin); - // Define a unique ID for focus and state tracking - let edit_id = ui.make_persistent_id("timezone_search_input"); + // Triangle icon on the right + let icon_rect = egui::Align2::RIGHT_CENTER.align_size_within_rect(icon_size, inner); + let tri = egui::Rect::from_center_size( + icon_rect.center(), + egui::vec2(icon_rect.width() * 0.7, icon_rect.height() * 0.45), + ); + ui.painter().add(egui::Shape::convex_polygon( + vec![tri.left_top(), tri.right_top(), tri.center_bottom()], + visuals.fg_stroke.color, + egui::Stroke::NONE, + )); - // Add the text edit widget + // Selected text on the left + let text_rect = inner.with_max_x(icon_rect.left() - icon_spacing); + let galley = ui.painter().layout_no_wrap( + state.timezone_input.clone(), + egui::TextStyle::Button.resolve(ui.style()), + visuals.text_color(), + ); + let text_pos = egui::Align2::LEFT_CENTER + .align_size_within_rect(galley.size(), text_rect) + .min; + ui.painter() + .with_clip_rect(text_rect) + .galley(text_pos, galley, visuals.text_color()); + } + + let response = response.on_hover_text(&state.timezone_input); + + // --- Popup (inspired by combo_box_dyn) --- + egui::Popup::menu(&response) + .id(popup_id) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .width(response.rect.width()) + .show(|ui| { + ui.set_min_width(response.rect.width()); + // Override cached Area height constraint + // (workaround for https://github.com/emilk/egui/issues/5225) + ui.set_max_height(450.0); + + // Search input with auto-focus on open + let edit_id = ui.make_persistent_id("tz_search"); ui.add( egui::TextEdit::singleline(&mut state.timezone_input) .hint_text("Search...") .id(edit_id), ); - - // Handle Auto-focus & Auto-select on initial open if ui.memory(|m| m.focused().is_none()) { ui.memory_mut(|m| m.request_focus(edit_id)); } ui.separator(); - // List with filtering - let filter = state.timezone_input.to_lowercase(); - for tz in TZ_VARIANTS { - let tz_str = tz.to_string(); - if filter.is_empty() || tz_str.to_lowercase().contains(&filter) { - if ui - .selectable_label(state.timezone_input == tz_str, &tz_str) - .clicked() - { - state.timezone_input = tz_str; - // Manually close the popup - ui.close(); + // Filtered timezone list + egui::ScrollArea::vertical() + .max_height(400.0) + .show(ui, |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + let filter = state.timezone_input.to_lowercase(); + for tz in TZ_VARIANTS { + let tz_str = tz.to_string(); + if filter.is_empty() || tz_str.to_lowercase().contains(&filter) { + if ui + .selectable_label(state.timezone_input == tz_str, &tz_str) + .clicked() + { + state.timezone_input = tz_str; + ui.close(); + } + } } - } - } + }); }); } From 12492e3342090209b016c7af5d4720a65400fdaa Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:23:19 +0100 Subject: [PATCH 63/77] feat(gui/generation): replace duration slider with preset buttons --- .../fosr-gui/src/generation/generation_tab.rs | 37 ++++--------- .../src/generation/generation_utils.rs | 52 ------------------- crates/fosr-gui/src/generation/mod.rs | 1 - 3 files changed, 10 insertions(+), 80 deletions(-) delete mode 100644 crates/fosr-gui/src/generation/generation_utils.rs diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 656f3084..043cc57d 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -1,8 +1,5 @@ use super::generation_core::generate; use super::generation_ui_components::{show_field_error, show_status, timezone_picker}; -use super::generation_utils::{ - duration_string_from_slider, duration_to_slider, slider_from_duration_string, -}; use super::generation_validation::{ FieldValidation, first_invalid_param, validate_duration, validate_optional_u64, validate_timezone, @@ -19,7 +16,7 @@ use crate::timepicker::TimePickerButton; use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone}; use chrono_tz::Tz; use eframe::egui; -use eframe::egui::{SliderClamping, Widget}; +use eframe::egui::Widget; use egui_extras::DatePickerButton; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -61,7 +58,6 @@ pub struct GenerationTabState { pub order_pcap: bool, pub taint: bool, pub duration_str: String, - pub duration_slider_value: f32, pub use_seed: bool, pub seed_input: String, pub timezone_input: String, @@ -74,9 +70,6 @@ pub struct GenerationTabState { impl Default for GenerationTabState { fn default() -> Self { - let default_duration = "1h".to_string(); - let duration_slider_value = slider_from_duration_string(default_duration.clone()).unwrap(); - Self { progress: 0.0, progress_receiver: None, @@ -93,8 +86,7 @@ impl Default for GenerationTabState { // Parameters order_pcap: true, taint: false, - duration_str: default_duration, - duration_slider_value, + duration_str: "1h".to_string(), use_seed: false, seed_input: String::new(), timezone_input: String::new(), @@ -117,22 +109,14 @@ pub fn show_generation_tab_content( ui.horizontal(|ui| { ui.label("Duration"); - info_icon(ui, "Minimum pcap traffic duration described in human-friendly time, such as \"30m\", \"1h\", \"2d\" or \"15days 30min 5s\"."); - - // The only way to set the slider width currently is to set it globally. - // If we need another slider at some point, this value should be mutated - // again before adding it. - ui.style_mut().spacing.slider_width = 150.0; - let slider_response = ui.add( - egui::Slider::new(&mut state.duration_slider_value, 0.0..=1.0) - .show_value(false) - .clamping(SliderClamping::Never), - ); + info_icon(ui, "Minimum pcap traffic duration described in human-friendly time, such as \"30m\", \"1h\", \"2d\" or \"2days 30min 5s\"."); - if slider_response.changed() { - let s = duration_string_from_slider(state.duration_slider_value); - state.duration_str = s; - state.duration_validation.set_ok(); + // Preset buttons + for preset in ["5min", "1h", "24h"] { + if ui.small_button(preset).clicked() { + state.duration_str = preset.to_string(); + state.duration_validation.set_ok(); + } } let text_response = egui::TextEdit::singleline(&mut state.duration_str) @@ -142,9 +126,8 @@ pub fn show_generation_tab_content( if text_response.changed() { match validate_duration(&state.duration_str) { - Ok(d) => { + Ok(_) => { state.duration_validation.set_ok(); - state.duration_slider_value = duration_to_slider(d); } Err(msg) => { state.duration_validation.set_err(msg); diff --git a/crates/fosr-gui/src/generation/generation_utils.rs b/crates/fosr-gui/src/generation/generation_utils.rs deleted file mode 100644 index f15d7efe..00000000 --- a/crates/fosr-gui/src/generation/generation_utils.rs +++ /dev/null @@ -1,52 +0,0 @@ -use super::generation_tab::{DURATION_MAX, DURATION_MIN}; -use std::time::Duration; - -/// Returns the minimum and maximum durations, expressed in seconds (f64), -/// used as bounds when converting between a duration and the slider position. -fn duration_range_secs() -> (f64, f64) { - (DURATION_MIN.as_secs_f64(), DURATION_MAX.as_secs_f64()) -} - -/// Converts the slider position (between 0.0 and 1.0) into an actual Duration, -/// by logarithmically interpolating between DURATION_MIN and DURATION_MAX. -fn slider_to_duration(value: f32) -> Duration { - let (min, max) = duration_range_secs(); - let v = value.clamp(0.0, 1.0) as f64; - - let log_secs = min.ln() + (max.ln() - min.ln()) * v; - let secs = log_secs.exp(); - let rounded_secs = (secs / 60.0).round() * 60.0; - - Duration::from_secs_f64(rounded_secs.clamp(min, max).round()) -} - -/// Converts a real Duration into a slider position (between 0.0 and 1.0), -/// based on its proportion within the [DURATION_MIN, DURATION_MAX] interval. -pub fn duration_to_slider(d: Duration) -> f32 { - let (min, max) = duration_range_secs(); - let secs = d.as_secs_f64().clamp(min, max); - - let numerator = secs.ln() - min.ln(); - let denominator = max.ln() - min.ln(); - let v = if denominator == 0.0 { - 0.0 - } else { - numerator / denominator - }; - v.clamp(0.0, 1.0) as f32 -} - -/// Produces a human-readable duration string from the given slider position. -pub fn duration_string_from_slider(value: f32) -> String { - let duration = slider_to_duration(value); - humantime::format_duration(duration).to_string() -} - -/// Converts a human-readable duration string into a slider position. -pub fn slider_from_duration_string(duration_str: String) -> Option { - let result = humantime::parse_duration(&duration_str); - match result { - Ok(duration) => Some(duration_to_slider(duration)), - Err(_) => None, - } -} diff --git a/crates/fosr-gui/src/generation/mod.rs b/crates/fosr-gui/src/generation/mod.rs index f8bb99fc..9c271fb2 100644 --- a/crates/fosr-gui/src/generation/mod.rs +++ b/crates/fosr-gui/src/generation/mod.rs @@ -1,5 +1,4 @@ mod generation_core; pub mod generation_tab; mod generation_ui_components; -mod generation_utils; mod generation_validation; From 74d4e126ac2d06413e99e3cfa9386d7a7870039b Mon Sep 17 00:00:00 2001 From: {} <> Date: Fri, 6 Mar 2026 18:11:32 +0100 Subject: [PATCH 64/77] Feat: add code mode and visual mode --- .../src/configuration/configuration_tab.rs | 155 +++++++++++++----- .../fosr-gui/src/shared/configuration_file.rs | 19 ++- 2 files changed, 131 insertions(+), 43 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 3a4f6af2..46e9e24d 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -1,6 +1,6 @@ use crate::shared::config_model::{Configuration, Host, Interface}; use crate::shared::configuration_file::{ - ConfigurationFileState, configuration_file_picker, load_config_file_contents, + ConfigurationFileState, configuration_file_picker, load_config_file_contents, parse_config_yaml, }; use crate::shared::ui_utils::{edit_optional_multiline_string, edit_optional_string, info_icon}; use eframe::egui; @@ -120,18 +120,22 @@ fn format_service(name: &str, port: Option) -> String { } /// Represents the state of the configuration tab. -pub struct ConfigurationTabState {} +pub struct ConfigurationTabState { + pub is_code_mode: bool, +} impl Default for ConfigurationTabState { fn default() -> Self { - Self {} + Self { + is_code_mode: false, + } } } /// The main tab component pub fn show_configuration_tab_content( ui: &mut egui::Ui, - _tab_state: &mut ConfigurationTabState, + tab_state: &mut ConfigurationTabState, file_state: &mut ConfigurationFileState, ) { // Eagerly load config file contents when a file is selected @@ -144,28 +148,38 @@ pub fn show_configuration_tab_content( ui_parsing_status(ui, file_state); - // Editor (if model is loaded) - if let Some(model) = file_state.config_model.as_mut() { - ui_hosts_section(ui, model); - ui.separator(); - ui_metadata(ui, model); + if file_state.config_chosen { + ui.horizontal(|ui| { + ui.selectable_value(&mut tab_state.is_code_mode, false, "Visual mode"); + ui.selectable_value(&mut tab_state.is_code_mode, true, "Code mode"); + }); ui.separator(); - } - // Auto-sync model edits back to YAML so other tabs see the changes - if let Some(model) = &file_state.config_model { - match serde_yaml::to_string(model) { - Ok(yaml) => { - file_state.config_file_content = Some(yaml); - file_state.parse_error = None; + if !tab_state.is_code_mode { + // Visual mode + if let Some(model) = file_state.config_model.as_mut() { + ui_hosts_section(ui, model); + ui.separator(); + ui_metadata(ui, model); + ui.separator(); } - Err(e) => { - file_state.parse_error = Some(e.to_string()); + + // Auto-sync model edits back to YAML so other tabs see the changes + if let Some(model) = &file_state.config_model { + match serde_yaml::to_string(model) { + Ok(yaml) => { + file_state.config_file_content = Some(yaml); + file_state.parse_error = None; + } + Err(e) => { + file_state.parse_error = Some(e.to_string()); + } + } } + } else { + ui_yaml_editor(ui, file_state); } } - - ui_yaml_preview(ui, file_state); }); } @@ -215,7 +229,11 @@ fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { ui.add_space(6.0); ui.horizontal(|ui| { - if ui.button(egui_material_icons::icons::ICON_ADD).on_hover_text("Add host").clicked() { + if ui + .button(egui_material_icons::icons::ICON_ADD) + .on_hover_text("Add host") + .clicked() + { model.hosts.push(Host::default()); } }); @@ -277,7 +295,11 @@ fn ui_single_host( ui.label(host_name); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(egui_material_icons::icons::ICON_DELETE).on_hover_text("Remove host").clicked() { + if ui + .button(egui_material_icons::icons::ICON_DELETE) + .on_hover_text("Remove host") + .clicked() + { *remove_request = Some(index); } }); @@ -306,7 +328,11 @@ fn ui_single_host( Some(usage_val) }; } - if ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear").clicked() { + if ui + .button(egui_material_icons::icons::ICON_CLEAR) + .on_hover_text("Clear") + .clicked() + { host.usage = None; } }); @@ -348,7 +374,12 @@ fn ui_host_os_selector(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option< } }); - if host_os.is_some() && ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear OS").clicked() { + if host_os.is_some() + && ui + .button(egui_material_icons::icons::ICON_CLEAR) + .on_hover_text("Clear OS") + .clicked() + { *host_os = None; } }); @@ -411,7 +442,11 @@ fn ui_host_client_protocols(ui: &mut egui::Ui, host: &mut Host) { .collect(); } - if ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear").clicked() { + if ui + .button(egui_material_icons::icons::ICON_CLEAR) + .on_hover_text("Clear") + .clicked() + { host.client.clear(); } }); @@ -427,7 +462,11 @@ fn ui_interfaces_section( ) { ui.horizontal(|ui| { ui.label("Interfaces:"); - if ui.button(egui_material_icons::icons::ICON_ADD).on_hover_text("Add interface").clicked() { + if ui + .button(egui_material_icons::icons::ICON_ADD) + .on_hover_text("Add interface") + .clicked() + { if let Some(ip) = next_free_ip(ip_counts) { host.interfaces.push(Interface { ip_addr: ip, @@ -456,7 +495,11 @@ fn ui_interfaces_section( ui.label(format!("Interface — {ip_label}")); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(egui_material_icons::icons::ICON_DELETE).on_hover_text("Remove interface").clicked() { + if ui + .button(egui_material_icons::icons::ICON_DELETE) + .on_hover_text("Remove interface") + .clicked() + { iface_to_remove = Some(if_idx); } }); @@ -502,7 +545,11 @@ fn ui_services_section( ui.label(format!("Services ({svc_count})")); }) .body(|ui| { - if ui.button(egui_material_icons::icons::ICON_ADD).on_hover_text("Add service").clicked() { + if ui + .button(egui_material_icons::icons::ICON_ADD) + .on_hover_text("Add service") + .clicked() + { iface.services.push("http".to_string()); } ui.add_space(4.0); @@ -558,11 +605,19 @@ fn ui_single_service( { svc_port = if port_val == 0 { None } else { Some(port_val) }; } - if ui.button(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear port").clicked() { + if ui + .button(egui_material_icons::icons::ICON_CLEAR) + .on_hover_text("Clear port") + .clicked() + { svc_port = None; } - if ui.button(egui_material_icons::icons::ICON_DELETE).on_hover_text("Delete service").clicked() { + if ui + .button(egui_material_icons::icons::ICON_DELETE) + .on_hover_text("Delete service") + .clicked() + { *remove_request = Some(svc_idx); } }); @@ -570,19 +625,41 @@ fn ui_single_service( *svc_raw = format_service(&svc_name, svc_port); } -/// Read-only YAML Preview -fn ui_yaml_preview(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { +/// Editable YAML Editor +fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { if state.config_file_content.is_none() { - ui.label("No configuration file selected"); + ui.label("No configuration file selected."); return; } - let content = state.config_file_content.as_ref().unwrap(); - let theme = egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx(), ui.style()); - let mut layout_job = - egui_extras::syntax_highlighting::highlight(ui.ctx(), ui.style(), &theme, content, "yaml"); + let mut content = state.config_file_content.clone().unwrap(); + let theme = egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx(), ui.style()); - layout_job.wrap.max_width = ui.available_width(); + let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { + let mut layout_job = egui_extras::syntax_highlighting::highlight( + ui.ctx(), + ui.style(), + &theme, + text.as_str(), + "yaml", + ); + layout_job.wrap.max_width = wrap_width; + + ui.fonts_mut(|f| f.layout_job(layout_job)) + }; + + let response = ui.add( + egui::TextEdit::multiline(&mut content) + .font(egui::TextStyle::Monospace) + .code_editor() + .desired_rows(20) + .lock_focus(true) + .desired_width(f32::INFINITY) + .layouter(&mut layouter), + ); - ui.add(egui::Label::new(layout_job).selectable(true)); + if response.changed() { + state.config_file_content = Some(content); + parse_config_yaml(state); + } } diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 974f1823..44c7afbc 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -110,7 +110,11 @@ pub fn configuration_file_picker( ui.horizontal(|ui| { ui.label("Configuration file:"); - if ui.button(egui_material_icons::icons::ICON_FOLDER_OPEN).on_hover_text("Select file").clicked() { + if ui + .button(egui_material_icons::icons::ICON_FOLDER_OPEN) + .on_hover_text("Select file") + .clicked() + { trigger_file_import(configuration_file_state, ui.ctx()); } @@ -126,7 +130,10 @@ pub fn configuration_file_picker( // Display Restore default button when a custom file is loaded if configuration_file_state.picked_config_file.is_some() - && ui.button(egui_material_icons::icons::ICON_RESTORE).on_hover_text("Restore default").clicked() + && ui + .button(egui_material_icons::icons::ICON_RESTORE) + .on_hover_text("Restore default") + .clicked() { configuration_file_state.picked_config_file = None; reset_loaded_config(configuration_file_state); @@ -134,7 +141,11 @@ pub fn configuration_file_picker( // Save as button (only when config content is available) if configuration_file_state.config_file_content.is_some() { - if ui.button(egui_material_icons::icons::ICON_SAVE_AS).on_hover_text("Save as").clicked() { + if ui + .button(egui_material_icons::icons::ICON_SAVE_AS) + .on_hover_text("Save as") + .clicked() + { let content = configuration_file_state .config_file_content .clone() @@ -226,7 +237,7 @@ pub fn load_config_file_contents(configuration_file_state: &mut ConfigurationFil } } -fn parse_config_yaml(configuration_file_state: &mut ConfigurationFileState) { +pub fn parse_config_yaml(configuration_file_state: &mut ConfigurationFileState) { configuration_file_state.config_model = None; configuration_file_state.parse_error = None; From 1e30bead30cc77d26d0a5e0f79a8d737991b3aaf Mon Sep 17 00:00:00 2001 From: {} <> Date: Fri, 6 Mar 2026 18:35:10 +0100 Subject: [PATCH 65/77] Feat: add visual and code mode --- .../src/configuration/configuration_tab.rs | 13 +++---- .../fosr-gui/src/shared/configuration_file.rs | 35 ++++++++++++++++++- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 46e9e24d..fe2171c2 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -143,16 +143,17 @@ pub fn show_configuration_tab_content( egui::ScrollArea::vertical().show(ui, |ui| { // File Selection - configuration_file_picker(ui, file_state); - ui.separator(); + configuration_file_picker(ui, tab_state, file_state); ui_parsing_status(ui, file_state); if file_state.config_chosen { - ui.horizontal(|ui| { - ui.selectable_value(&mut tab_state.is_code_mode, false, "Visual mode"); - ui.selectable_value(&mut tab_state.is_code_mode, true, "Code mode"); - }); + // ui.horizontal(|ui| { + // ui.selectable_value(&mut tab_state.is_code_mode, false, "Visual mode") + // .on_hover_text("Modify the config using the config editor"); + // ui.selectable_value(&mut tab_state.is_code_mode, true, "Code mode") + // .on_hover_text("Modify yaml directly"); + // }); ui.separator(); if !tab_state.is_code_mode { diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 44c7afbc..980d3739 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -1,8 +1,10 @@ -use crate::shared::config_model::Configuration; #[cfg(not(target_arch = "wasm32"))] use crate::shared::file_io::{read_file_desktop, save_file_desktop, show_file_picker_desktop}; #[cfg(target_arch = "wasm32")] use crate::shared::file_io::{read_file_wasm, save_file_wasm, show_file_picker_wasm}; +use crate::{ + configuration::configuration_tab::ConfigurationTabState, shared::config_model::Configuration, +}; use chrono::{DateTime, Local}; use eframe::egui; use rfd::FileHandle; @@ -105,6 +107,7 @@ pub fn poll_file_import(state: &mut ConfigurationFileState) { pub fn configuration_file_picker( ui: &mut egui::Ui, + tab_state: &mut ConfigurationTabState, configuration_file_state: &mut ConfigurationFileState, ) { ui.horizontal(|ui| { @@ -193,6 +196,36 @@ pub fn configuration_file_picker( #[cfg(target_arch = "wasm32")] ui.label(&filename); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(8.0); + ui.group(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + + if ui + .selectable_label( + tab_state.is_code_mode, + egui_material_icons::icons::ICON_CODE, + ) + .on_hover_text("Code Mode") + .clicked() + { + tab_state.is_code_mode = true; + } + + if ui + .selectable_label( + !tab_state.is_code_mode, + egui_material_icons::icons::ICON_EDIT, + ) + .on_hover_text("Visual Mode") + .clicked() + { + tab_state.is_code_mode = false; + } + }); + ui.add_space(8.0); + }); }); } From f26a46f82cee6ae436e88f466d5071d8ee3dc684 Mon Sep 17 00:00:00 2001 From: {} <> Date: Sat, 7 Mar 2026 17:46:51 +0100 Subject: [PATCH 66/77] Feat: host summary on hover --- .../src/configuration/configuration_tab.rs | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index fe2171c2..1ad59a86 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -7,6 +7,39 @@ use eframe::egui; use egui::{TextFormat, text::LayoutJob}; use std::collections::HashMap; +/// Host summary +fn ui_host_summary_tooltip(ui: &mut egui::Ui, host: &Host) { + let host_type = host.r#type.as_deref().unwrap_or(""); + ui.horizontal(|ui| { + ui.label("Type :"); + ui.strong(host_type); + }); + + if !host.client.is_empty() { + ui.horizontal(|ui| { + ui.label("Client protocols :"); + ui.strong(host.client.join(", ")); + }); + } + + ui.add_space(4.0); + + ui.label("Interfaces :"); + if host.interfaces.is_empty() { + ui.label(" No interfaces configured."); + } else { + for iface in &host.interfaces { + let services_str = if iface.services.is_empty() { + "no services".to_string() + } else { + iface.services.join(", ") + }; + + ui.label(format!(" • {} ({})", iface.ip_addr, services_str)); + } + } +} + // Helper for required label with red * fn required_label(ui: &mut egui::Ui, text: &str) { let mut job = LayoutJob::default(); @@ -148,12 +181,6 @@ pub fn show_configuration_tab_content( ui_parsing_status(ui, file_state); if file_state.config_chosen { - // ui.horizontal(|ui| { - // ui.selectable_value(&mut tab_state.is_code_mode, false, "Visual mode") - // .on_hover_text("Modify the config using the config editor"); - // ui.selectable_value(&mut tab_state.is_code_mode, true, "Code mode") - // .on_hover_text("Modify yaml directly"); - // }); ui.separator(); if !tab_state.is_code_mode { @@ -293,7 +320,9 @@ fn ui_single_host( let id = ui.make_persistent_id(("host", index)); egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, index == 0) .show_header(ui, |ui| { - ui.label(host_name); + ui.label(host_name).on_hover_ui(|ui| { + ui_host_summary_tooltip(ui, host); + }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui From 8eef9f36964630785b69d4730e36a20ee18bf2de Mon Sep 17 00:00:00 2001 From: {} <> Date: Sat, 7 Mar 2026 18:22:13 +0100 Subject: [PATCH 67/77] Feat: add checking for errors and madatory tooltip --- .../src/configuration/configuration_tab.rs | 88 +++++++++++++++---- 1 file changed, 73 insertions(+), 15 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 1ad59a86..f37fbc59 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -71,7 +71,7 @@ fn required_label(ui: &mut egui::Ui, text: &str) { }, ); - ui.label(job); + ui.label(job).on_hover_text("Mandatory"); } /// Generate a random mac address @@ -97,6 +97,17 @@ fn generate_mac_until_unique(mac_counts: &HashMap) -> String { } } +/// Check MAC format (ex: 00:14:2A:3F:47:D8) +fn is_valid_mac(mac: &str) -> bool { + let parts: Vec<&str> = mac.split(':').collect(); + if parts.len() != 6 { + return false; + } + parts + .iter() + .all(|p| p.len() == 2 && u8::from_str_radix(p, 16).is_ok()) +} + /// Scans all interfaces to find the next available IP in 192.168.0.x fn next_free_ip(ip_counts: &HashMap) -> Option { for x in 1..=254 { @@ -108,6 +119,51 @@ fn next_free_ip(ip_counts: &HashMap) -> Option { None } +/// Function to validate if a host is correct +fn validate_host( + host: &Host, + ip_counts: &HashMap, + mac_counts: &HashMap, +) -> Vec { + let mut errors = Vec::new(); + + if host.interfaces.is_empty() { + errors.push("Missing interface".to_string()); + } + + for iface in &host.interfaces { + if iface.ip_addr.parse::().is_err() { + errors.push("Invalid IP format".to_string()); + } else if ip_counts.get(&iface.ip_addr).copied().unwrap_or(0) > 1 { + errors.push("IP conflict".to_string()); + } + + if let Some(mac) = &iface.mac_addr { + if !is_valid_mac(mac) { + errors.push("Invalid MAC format".to_string()); + } else if mac_counts.get(mac).copied().unwrap_or(0) > 1 { + errors.push("MAC conflict".to_string()); + } + } + } + + if host.r#type.as_deref() == Some("server") { + let has_service = host.interfaces.iter().any(|i| !i.services.is_empty()); + if !has_service { + errors.push("Server missing service".to_string()); + } + } + + if host.r#type.as_deref() == Some("user") { + if host.client.is_empty() { + errors.push("Client missing protocols".to_string()); + } + } + + errors.dedup(); + errors +} + /// Determines the best label for a host (Hostname > IP > Default) fn host_display_name(host: &Host) -> String { if let Some(name) = host.hostname.as_deref() { @@ -310,18 +366,26 @@ fn ui_single_host( remove_request: &mut Option, ) { let host_name = host_display_name(host); - let header_name = host - .hostname - .clone() - .unwrap_or_else(|| "".to_string()); - let header_type = host.r#type.clone().unwrap_or_else(|| "".to_string()); - let if_count = host.interfaces.len(); + let errors = validate_host(host, ip_counts, mac_counts); let id = ui.make_persistent_id(("host", index)); egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, index == 0) .show_header(ui, |ui| { - ui.label(host_name).on_hover_ui(|ui| { - ui_host_summary_tooltip(ui, host); + ui.horizontal(|ui| { + if errors.is_empty() { + ui.label(host_name).on_hover_ui(|ui| { + ui_host_summary_tooltip(ui, host); + }); + } else { + let warning_icon = egui_material_icons::icons::ICON_WARNING; + let error_text = errors.join(", "); + + let label_text = format!("{} {} - {}", warning_icon, host_name, error_text); + ui.colored_label(egui::Color32::RED, label_text) + .on_hover_ui(|ui| { + ui_host_summary_tooltip(ui, host); + }); + } }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -335,12 +399,6 @@ fn ui_single_host( }); }) .body(|ui| { - ui.horizontal(|ui| { - ui.strong(format!( - "{header_name} | type: {header_type} | interfaces: {if_count}" - )); - }); - ui_host_os_selector(ui, index, &mut host.os); edit_optional_string(ui, "Hostname:", &mut host.hostname, "host1"); From a4bba910b53d1a9306279bb3906914b472b54b8e Mon Sep 17 00:00:00 2001 From: {} <> Date: Sun, 8 Mar 2026 13:02:35 +0100 Subject: [PATCH 68/77] Feat: better client protocols rendering --- .../src/configuration/configuration_tab.rs | 97 ++++++++++++++----- 1 file changed, 74 insertions(+), 23 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index f37fbc59..4fdfe2a4 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -426,7 +426,7 @@ fn ui_single_host( }); ui_host_type_selector(ui, index, host); - ui_host_client_protocols(ui, host); + ui_host_client_protocols(ui, index, host); ui.separator(); ui_interfaces_section(ui, index, host, ip_counts, mac_counts); }); @@ -510,33 +510,85 @@ fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { } /// Client protocols rendering -fn ui_host_client_protocols(ui: &mut egui::Ui, host: &mut Host) { +fn ui_host_client_protocols(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { ui.horizontal(|ui| { ui.label("Client protocols:"); info_icon(ui, "Protocols the host is going to use from other servers"); - let mut buf = if host.client.is_empty() { - String::new() - } else { - host.client.join(",") - }; - - let resp = ui.add(egui::TextEdit::singleline(&mut buf).hint_text("ex: http,https,ssh")); - - if resp.changed() { - host.client = buf - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); + }); + + ui.horizontal_wrapped(|ui| { + let mut proto_to_remove: Option = None; + + for (p_idx, proto) in host.client.iter().enumerate() { + let btn_text = format!("{} {}", proto, egui_material_icons::icons::ICON_CLEAR); + if ui + .button(btn_text) + .on_hover_text("Remove protocol") + .clicked() + { + proto_to_remove = Some(p_idx); + } } - if ui - .button(egui_material_icons::icons::ICON_CLEAR) - .on_hover_text("Clear") - .clicked() - { - host.client.clear(); + if let Some(idx) = proto_to_remove { + host.client.remove(idx); } + + let popup_id = ui.make_persistent_id(("client_proto_popup", host_idx)); + let add_btn_resp = ui.button(format!("{} Add", egui_material_icons::icons::ICON_ADD)); + + egui::Popup::from_toggle_button_response(&add_btn_resp) + .id(popup_id) + .show(|ui| { + ui.set_min_width(180.0); + + let search_id = ui.make_persistent_id(("proto_search", host_idx)); + let mut search_text = + ui.data_mut(|d| d.get_temp::(search_id).unwrap_or_default()); + + let search_resp = + ui.add(egui::TextEdit::singleline(&mut search_text).hint_text("Search...")); + + if ui.memory(|m| m.focused().is_none()) { + ui.memory_mut(|m| m.request_focus(search_resp.id)); + } + + ui.data_mut(|d| d.insert_temp(search_id, search_text.clone())); + + ui.separator(); + + egui::ScrollArea::vertical() + .max_height(200.0) + .auto_shrink([true; 2]) + .show(ui, |ui| { + let desired_content_width = 250.0; + ui.set_width(desired_content_width); + + let filter = search_text.to_lowercase(); + let mut any_shown = false; + + for (name, _) in KNOWN_SERVICES { + if (filter.is_empty() || name.to_lowercase().contains(&filter)) + && !host.client.contains(&name.to_string()) + { + any_shown = true; + if ui.selectable_label(false, *name).clicked() { + host.client.push(name.to_string()); + ui.data_mut(|d| d.insert_temp(search_id, String::new())); + egui::Popup::close_id(ui.ctx(), popup_id); + } + } + } + + if !any_shown { + ui.label( + egui::RichText::new("No available protocols") + .italics() + .weak(), + ); + } + }); + }); }); } @@ -625,7 +677,6 @@ fn ui_services_section( iface: &mut Interface, ) { let svc_count = iface.services.len(); - // let id = ui.make_persistent_id(("services", iface_idx)); let id = ui.make_persistent_id(("services", host_idx, iface_idx)); egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false) From dcdda056309b1808e3a1a18191e803acdfb70fc7 Mon Sep 17 00:00:00 2001 From: {} <> Date: Sun, 8 Mar 2026 13:40:56 +0100 Subject: [PATCH 69/77] Feat: add yaml editor error highlight --- .../src/configuration/configuration_tab.rs | 144 +++++++++++++++--- 1 file changed, 124 insertions(+), 20 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 4fdfe2a4..1d256cca 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -764,6 +764,22 @@ fn ui_single_service( *svc_raw = format_service(&svc_name, svc_port); } +fn parse_error_lines(err: &str) -> Vec { + let mut found = Vec::new(); + let mut search = err; + while let Some(pos) = search.find("line ") { + let rest = &search[pos + 5..]; + let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(n) = num.parse::() { + if !found.contains(&n) { + found.push(n); + } + } + search = &search[pos + 5..]; + } + found +} + /// Editable YAML Editor fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { if state.config_file_content.is_none() { @@ -771,33 +787,121 @@ fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { return; } + let error_lines: Vec = if let Some(err) = &state.parse_error { + ui.colored_label( + egui::Color32::RED, + format!( + "{} YAML parsing failed", + egui_material_icons::icons::ICON_WARNING + ), + ); + ui.colored_label(egui::Color32::RED, err); + ui.separator(); + parse_error_lines(err) + } else { + Vec::new() + }; + let mut content = state.config_file_content.clone().unwrap(); + let line_count = content.lines().count().max(1); + let theme = egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx(), ui.style()); + let font_id = egui::TextStyle::Monospace.resolve(ui.style()); + let line_height = ui.fonts_mut(|f| f.row_height(&font_id)); + let digit_width = ui.fonts_mut(|f| f.glyph_width(&font_id, '0')); + let digits = line_count.to_string().len(); + let icon_col_width = 20.0; + let gutter_width = digit_width * digits as f32 + 6.0 + icon_col_width; + + let gutter_color = ui.visuals().weak_text_color(); + let error_color = egui::Color32::from_rgb(220, 50, 50); + let gutter_bg = ui.visuals().extreme_bg_color; + + let scroll_offset_id = ui.make_persistent_id("yaml_editor_scroll_y"); + let scroll_y: f32 = ui.data(|d| d.get_temp(scroll_offset_id).unwrap_or(0.0)); + + let mut editor_changed = false; + let mut new_scroll_y = scroll_y; + + ui.horizontal_top(|ui| { + let available_height = ui.available_height(); - let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { - let mut layout_job = egui_extras::syntax_highlighting::highlight( - ui.ctx(), - ui.style(), - &theme, - text.as_str(), - "yaml", + let (gutter_rect, _) = ui.allocate_exact_size( + egui::vec2(gutter_width, available_height), + egui::Sense::hover(), ); - layout_job.wrap.max_width = wrap_width; - ui.fonts_mut(|f| f.layout_job(layout_job)) - }; + ui.painter().rect_filled(gutter_rect, 0.0, gutter_bg); - let response = ui.add( - egui::TextEdit::multiline(&mut content) - .font(egui::TextStyle::Monospace) - .code_editor() - .desired_rows(20) - .lock_focus(true) - .desired_width(f32::INFINITY) - .layouter(&mut layouter), - ); + let first_visible = (scroll_y / line_height).floor() as usize; + let visible_count = (available_height / line_height).ceil() as usize + 2; + + for i in first_visible..(first_visible + visible_count).min(line_count) { + let line_num = i + 1; + let y_offset = i as f32 * line_height - scroll_y; + let y = gutter_rect.top() + y_offset; + + if y > gutter_rect.bottom() { + break; + } + + let is_error = error_lines.contains(&line_num); + let num_color = if is_error { error_color } else { gutter_color }; + + let num_str = format!("{:>width$}", line_num, width = digits); + let num_col_right = gutter_rect.left() + digit_width * digits as f32 + 4.0; + ui.painter().text( + egui::pos2(gutter_rect.left() + 2.0, y), + egui::Align2::LEFT_TOP, + num_str, + font_id.clone(), + num_color, + ); + + if is_error { + let icon_rect = egui::Rect::from_min_size( + egui::pos2(num_col_right, y), + egui::vec2(icon_col_width, line_height), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(icon_rect), |ui| { + ui.colored_label(error_color, egui_material_icons::icons::ICON_WARNING); + }); + } + } + + let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { + let mut layout_job = egui_extras::syntax_highlighting::highlight( + ui.ctx(), + ui.style(), + &theme, + text.as_str(), + "yaml", + ); + layout_job.wrap.max_width = wrap_width; + ui.fonts_mut(|f| f.layout_job(layout_job)) + }; + + let scroll_out = egui::ScrollArea::vertical() + .id_salt("yaml_scroll_area") + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut content) + .font(egui::TextStyle::Monospace) + .code_editor() + .desired_rows(20) + .lock_focus(true) + .desired_width(f32::INFINITY) + .layouter(&mut layouter), + ) + }); + + new_scroll_y = scroll_out.state.offset.y; + editor_changed = scroll_out.inner.changed(); + }); + + ui.data_mut(|d| d.insert_temp(scroll_offset_id, new_scroll_y)); - if response.changed() { + if editor_changed { state.config_file_content = Some(content); parse_config_yaml(state); } From 7ecffd732ebb003af40ca9eb9b22937cb0806c63 Mon Sep 17 00:00:00 2001 From: {} <> Date: Sun, 8 Mar 2026 14:09:40 +0100 Subject: [PATCH 70/77] Feat: better tooltip and guidance for users --- .../src/configuration/configuration_tab.rs | 49 ++++++++----------- .../fosr-gui/src/shared/configuration_file.rs | 10 ++-- 2 files changed, 26 insertions(+), 33 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 1d256cca..f7bb83ed 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -61,16 +61,6 @@ fn required_label(ui: &mut egui::Ui, text: &str) { ..Default::default() }, ); - - job.append( - ":", - 0.0, - TextFormat { - color: ui.visuals().text_color(), - ..Default::default() - }, - ); - ui.label(job).on_hover_text("Mandatory"); } @@ -274,7 +264,7 @@ fn ui_parsing_status(ui: &mut egui::Ui, state: &ConfigurationFileState) { ui.colored_label(egui::Color32::RED, "YAML parsing failed:"); ui.label(err); } else if state.config_model.is_some() { - ui.colored_label(egui::Color32::GREEN, "YAML parsed successfully ✅"); + ui.colored_label(egui::Color32::GREEN, "YAML parsed successfully"); } else if state.config_file_content.is_some() { ui.colored_label(egui::Color32::YELLOW, "YAML loaded, but not parsed yet."); } @@ -296,23 +286,21 @@ fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { edit_optional_multiline_string( ui, - "Description:", + "Description", &mut model.metadata.desc, "Optional description", 3, ); - edit_optional_string(ui, "Author:", &mut model.metadata.author, "Jane Doe"); + edit_optional_string(ui, "Author", &mut model.metadata.author, "Jane Doe"); - edit_optional_string(ui, "Version:", &mut model.metadata.version, "0.1.0"); + edit_optional_string(ui, "Version", &mut model.metadata.version, "0.1.0"); } /// Several host rendering fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { - ui.heading("Hosts"); - ui.add_space(6.0); - ui.horizontal(|ui| { + ui.heading("Hosts"); if ui .button(egui_material_icons::icons::ICON_ADD) .on_hover_text("Add host") @@ -400,11 +388,11 @@ fn ui_single_host( }) .body(|ui| { ui_host_os_selector(ui, index, &mut host.os); - edit_optional_string(ui, "Hostname:", &mut host.hostname, "host1"); + edit_optional_string(ui, "Hostname", &mut host.hostname, "host1"); ui.horizontal(|ui| { - ui.label("Usage:"); - info_icon(ui, "How much is the host active"); + ui.label("Usage"); + info_icon(ui, "Optional (default value: 1.0). The usage intensity of the host. 1 is the baseline, < 1 means less usage than usual, and > 1 means higher usage"); let mut usage_val = host.usage.unwrap_or(1.0); if ui .add(egui::DragValue::new(&mut usage_val).speed(0.1)) @@ -435,7 +423,7 @@ fn ui_single_host( /// Dropdown selector for the Operating System fn ui_host_os_selector(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option) { ui.horizontal(|ui| { - ui.label("OS:"); + ui.label("OS"); let selected_text = host_os.as_deref().unwrap_or(""); @@ -476,8 +464,8 @@ fn ui_host_os_selector(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option< /// Type of host rendering fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { ui.horizontal(|ui| { - ui.label("Type:"); - info_icon(ui, "If the host is a server then it will provide services, if it is a client it will use a service. If the host is a server it needs services, if it is a client it needs clients protocols."); + ui.label("Type"); + info_icon(ui, "Defines the role of the host. A server provides services, while a user (client) consumes services. If the host is a server, it must define services. If it is a user, it must define client protocols. If set to , the role is determined automatically: server if at least one service is defined, otherwise user."); let selected_text = host.r#type.as_deref().unwrap_or("").to_string(); egui::ComboBox::from_id_salt((host_idx, "host_type")) @@ -512,8 +500,8 @@ fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { /// Client protocols rendering fn ui_host_client_protocols(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { ui.horizontal(|ui| { - ui.label("Client protocols:"); - info_icon(ui, "Protocols the host is going to use from other servers"); + ui.label("Client protocols"); + info_icon(ui, "Specify what services the host is a client of."); }); ui.horizontal_wrapped(|ui| { @@ -535,7 +523,9 @@ fn ui_host_client_protocols(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) } let popup_id = ui.make_persistent_id(("client_proto_popup", host_idx)); - let add_btn_resp = ui.button(format!("{} Add", egui_material_icons::icons::ICON_ADD)); + let add_btn_resp = ui + .button(format!("{} Add", egui_material_icons::icons::ICON_ADD)) + .on_hover_text("Add protocol"); egui::Popup::from_toggle_button_response(&add_btn_resp) .id(popup_id) @@ -601,7 +591,7 @@ fn ui_interfaces_section( mac_counts: &HashMap, ) { ui.horizontal(|ui| { - ui.label("Interfaces:"); + ui.label("Interfaces"); if ui .button(egui_material_icons::icons::ICON_ADD) .on_hover_text("Add interface") @@ -653,7 +643,7 @@ fn ui_interfaces_section( ui.colored_label(egui::Color32::RED, "IP already in used"); } }); - edit_optional_string(ui, "MAC:", &mut iface.mac_addr, "00:14:2A:3F:47:D8"); + edit_optional_string(ui, "MAC", &mut iface.mac_addr, "00:14:2A:3F:47:D8"); if let Some(mac) = &iface.mac_addr { if mac_counts.get(mac).copied().unwrap_or(0) > 1 { ui.colored_label(egui::Color32::RED, "MAC already in use"); @@ -682,6 +672,7 @@ fn ui_services_section( egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false) .show_header(ui, |ui| { ui.label(format!("Services ({svc_count})")); + info_icon(ui, "The list of available services provided by the host."); }) .body(|ui| { if ui @@ -732,7 +723,7 @@ fn ui_single_service( }); // Port Editor - ui.label("Port:"); + ui.label("Port"); let mut port_val = svc_port.unwrap_or(0); if ui .add( diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 980d3739..43f2085f 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -115,7 +115,7 @@ pub fn configuration_file_picker( if ui .button(egui_material_icons::icons::ICON_FOLDER_OPEN) - .on_hover_text("Select file") + .on_hover_text("Select a configuration file") .clicked() { trigger_file_import(configuration_file_state, ui.ctx()); @@ -190,7 +190,7 @@ pub fn configuration_file_picker( .picked_config_file .as_ref() .map(|file| file.path().to_string_lossy().to_string()) - .unwrap_or("Select a configuration file".to_string()); + .unwrap_or("Default config selected".to_string()); ui.label(&filename).on_hover_text(path_text); } @@ -207,7 +207,7 @@ pub fn configuration_file_picker( tab_state.is_code_mode, egui_material_icons::icons::ICON_CODE, ) - .on_hover_text("Code Mode") + .on_hover_text("Code Mode: edit the configuration directly as raw YAML.") .clicked() { tab_state.is_code_mode = true; @@ -218,7 +218,9 @@ pub fn configuration_file_picker( !tab_state.is_code_mode, egui_material_icons::icons::ICON_EDIT, ) - .on_hover_text("Visual Mode") + .on_hover_text( + "Visual Mode: edit the configuration using the graphical interface. Fields and options are presented as forms instead of raw YAML.", + ) .clicked() { tab_state.is_code_mode = false; From 88d9453f01a95c5d8ea538e440b8b7c65fee391c Mon Sep 17 00:00:00 2001 From: {} <> Date: Sun, 8 Mar 2026 15:09:26 +0100 Subject: [PATCH 71/77] Feat: better rendering for host services --- .../src/configuration/configuration_tab.rs | 165 +++++++++++++----- 1 file changed, 121 insertions(+), 44 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index f7bb83ed..0be59fbb 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -675,22 +675,81 @@ fn ui_services_section( info_icon(ui, "The list of available services provided by the host."); }) .body(|ui| { - if ui - .button(egui_material_icons::icons::ICON_ADD) - .on_hover_text("Add service") - .clicked() - { - iface.services.push("http".to_string()); - } + let popup_id = ui.make_persistent_id(("svc_popup", host_idx, iface_idx)); + let add_btn_resp = ui + .button(format!("{} Add", egui_material_icons::icons::ICON_ADD)) + .on_hover_text("Add service"); + + egui::Popup::from_toggle_button_response(&add_btn_resp) + .id(popup_id) + .show(|ui| { + ui.set_min_width(180.0); + + let search_id = ui.make_persistent_id(("svc_search", host_idx, iface_idx)); + let mut search_text = + ui.data_mut(|d| d.get_temp::(search_id).unwrap_or_default()); + + let search_resp = + ui.add(egui::TextEdit::singleline(&mut search_text).hint_text("Search...")); + + if ui.memory(|m| m.focused().is_none()) { + ui.memory_mut(|m| m.request_focus(search_resp.id)); + } + + ui.data_mut(|d| d.insert_temp(search_id, search_text.clone())); + ui.separator(); + + egui::ScrollArea::vertical() + .max_height(200.0) + .auto_shrink([true; 2]) + .show(ui, |ui| { + ui.set_width(250.0); + + let filter = search_text.to_lowercase(); + let mut any_shown = false; + + for (name, default_port) in KNOWN_SERVICES { + let already_present = iface.services.iter().any(|s| { + let (sname, _) = parse_service(s); + &sname == name + }); + + if (filter.is_empty() || name.to_lowercase().contains(&filter)) + && !already_present + { + any_shown = true; + if ui.selectable_label(false, *name).clicked() { + iface.services.push(format_service(name, *default_port)); + ui.data_mut(|d| d.insert_temp(search_id, String::new())); + egui::Popup::close_id(ui.ctx(), popup_id); + } + } + } + + if !any_shown { + ui.label( + egui::RichText::new("No available services") + .italics() + .weak(), + ); + } + }); + }); + ui.add_space(4.0); let mut svc_to_remove: Option = None; for (svc_idx, svc_raw) in iface.services.iter_mut().enumerate() { - egui::Frame::group(ui.style()).show(ui, |ui| { - ui_single_service(ui, iface_idx, svc_idx, svc_raw, &mut svc_to_remove); - }); - ui.add_space(4.0); + ui_single_service( + ui, + host_idx, + iface_idx, + svc_idx, + svc_raw, + &mut svc_to_remove, + ); + ui.add_space(2.0); } if let Some(idx) = svc_to_remove { @@ -702,56 +761,74 @@ fn ui_services_section( /// Single service rendering fn ui_single_service( ui: &mut egui::Ui, + host_idx: usize, iface_idx: usize, svc_idx: usize, svc_raw: &mut String, remove_request: &mut Option, ) { - let (mut svc_name, mut svc_port) = parse_service(svc_raw); + let (svc_name, mut svc_port) = parse_service(svc_raw); - ui.horizontal(|ui| { - // Name ComboBox - egui::ComboBox::from_id_salt((iface_idx, svc_idx, "service_name")) - .selected_text(&svc_name) - .show_ui(ui, |ui| { - for (name, default_port) in KNOWN_SERVICES { - if ui.selectable_label(&svc_name == name, *name).clicked() { - svc_name = name.to_string(); - svc_port = *default_port; - } - } - }); + let default_port = KNOWN_SERVICES + .iter() + .find(|(n, _)| *n == svc_name) + .and_then(|(_, p)| *p) + .unwrap_or(0); - // Port Editor - ui.label("Port"); - let mut port_val = svc_port.unwrap_or(0); - if ui - .add( - egui::DragValue::new(&mut port_val) - .speed(1) - .range(0..=65535), - ) - .changed() - { - svc_port = if port_val == 0 { None } else { Some(port_val) }; - } + let custom_port_id = ui.make_persistent_id(("custom_port", host_idx, iface_idx, svc_idx)); + let mut custom_port_enabled: bool = + ui.data_mut(|d| d.get_temp(custom_port_id).unwrap_or(false)); + + ui.horizontal(|ui| { + let btn_text = format!("{} {}", svc_name, egui_material_icons::icons::ICON_CLEAR); if ui - .button(egui_material_icons::icons::ICON_CLEAR) - .on_hover_text("Clear port") + .button(btn_text) + .on_hover_text("Remove service") .clicked() { - svc_port = None; + *remove_request = Some(svc_idx); } if ui - .button(egui_material_icons::icons::ICON_DELETE) - .on_hover_text("Delete service") - .clicked() + .checkbox(&mut custom_port_enabled, "Custom port") + .changed() { - *remove_request = Some(svc_idx); + if !custom_port_enabled { + svc_port = if default_port == 0 { + None + } else { + Some(default_port) + }; + } + } + + if custom_port_enabled { + let mut port_val = svc_port.unwrap_or(default_port); + if ui + .add( + egui::DragValue::new(&mut port_val) + .speed(1) + .range(1..=65535), + ) + .changed() + { + svc_port = Some(port_val); + } + } else { + ui.add_enabled( + false, + egui::Label::new(egui::RichText::new(format!("(default: {default_port})")).weak()), + ); + svc_port = if default_port == 0 { + None + } else { + Some(default_port) + }; } }); + ui.data_mut(|d| d.insert_temp(custom_port_id, custom_port_enabled)); + *svc_raw = format_service(&svc_name, svc_port); } From 1d28298772e759be1e729a54b8d8a539eee3f490 Mon Sep 17 00:00:00 2001 From: {} <> Date: Sun, 8 Mar 2026 15:13:27 +0100 Subject: [PATCH 72/77] Feat: new host on top of the list and check if user don't have services --- crates/fosr-gui/src/configuration/configuration_tab.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 0be59fbb..28e70066 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -148,6 +148,10 @@ fn validate_host( if host.client.is_empty() { errors.push("Client missing protocols".to_string()); } + let has_service = host.interfaces.iter().any(|i| !i.services.is_empty()); + if has_service { + errors.push("User host should not have services (use type: server)".to_string()); + } } errors.dedup(); @@ -306,7 +310,7 @@ fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { .on_hover_text("Add host") .clicked() { - model.hosts.push(Host::default()); + model.hosts.insert(0, Host::default()); } }); ui.add_space(6.0); From 85d120fde2bcf98a498ac3b8ddebb75eb87ec2ae Mon Sep 17 00:00:00 2001 From: {} <> Date: Sun, 8 Mar 2026 15:20:04 +0100 Subject: [PATCH 73/77] Fix: remove space in code mode between line numbers and yaml --- crates/fosr-gui/src/configuration/configuration_tab.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 28e70066..6090c895 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -895,6 +895,7 @@ fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { let mut editor_changed = false; let mut new_scroll_y = scroll_y; + ui.spacing_mut().item_spacing.x = 0.0; ui.horizontal_top(|ui| { let available_height = ui.available_height(); From e21e318c0840bfe6fdf1123f7c6221a794cd5995 Mon Sep 17 00:00:00 2001 From: {} <> Date: Sun, 8 Mar 2026 15:42:10 +0100 Subject: [PATCH 74/77] Feat: alert user on unsaved changes --- .../src/configuration/configuration_tab.rs | 7 ++- .../fosr-gui/src/shared/configuration_file.rs | 55 ++++++++++++++----- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 6090c895..9f7e4b81 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -242,7 +242,6 @@ pub fn show_configuration_tab_content( ui.separator(); } - // Auto-sync model edits back to YAML so other tabs see the changes if let Some(model) = &file_state.config_model { match serde_yaml::to_string(model) { Ok(yaml) => { @@ -253,6 +252,12 @@ pub fn show_configuration_tab_content( file_state.parse_error = Some(e.to_string()); } } + + if let Some(snapshot) = &file_state.clean_snapshot { + let model_yaml = serde_yaml::to_string(model).unwrap_or_default(); + let snap_yaml = serde_yaml::to_string(snapshot).unwrap_or_default(); + file_state.is_dirty = model_yaml != snap_yaml; + } } } else { ui_yaml_editor(ui, file_state); diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 43f2085f..5bb75c7c 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -25,6 +25,8 @@ pub struct ConfigurationFileState { /// Whether the user has chosen a configuration (default or imported). /// When false, the startup modal is shown. pub config_chosen: bool, + pub is_dirty: bool, + pub clean_snapshot: Option, } impl Default for ConfigurationFileState { @@ -39,6 +41,8 @@ impl Default for ConfigurationFileState { config_model: None, parse_error: None, config_chosen: false, + is_dirty: false, + clean_snapshot: None, } } } @@ -47,14 +51,11 @@ impl Default for ConfigurationFileState { pub fn load_default_config(state: &mut ConfigurationFileState) { state.picked_config_file = None; state.config_file_content = Some(DEFAULT_CONFIG_YAML.to_string()); - state.config_model = serde_yaml::from_str::(DEFAULT_CONFIG_YAML) - .ok() - .map(|mut c| { - enforce_metadata_defaults(&mut c); - c - }); + state.config_model = serde_yaml::from_str::(DEFAULT_CONFIG_YAML).ok(); state.parse_error = None; state.config_chosen = true; + state.is_dirty = false; + state.clean_snapshot = state.config_model.clone(); } /// Trigger a file import dialog (works on both desktop and WASM). @@ -149,10 +150,15 @@ pub fn configuration_file_picker( .on_hover_text("Save as") .clicked() { - let content = configuration_file_state - .config_file_content - .clone() - .unwrap(); + if let Some(model) = configuration_file_state.config_model.as_mut() { + enforce_metadata_defaults(model); + } + let content = match &configuration_file_state.config_model { + Some(model) => serde_yaml::to_string(model).unwrap_or_default(), + None => configuration_file_state.config_file_content.clone().unwrap_or_default(), + }; + configuration_file_state.is_dirty = false; + configuration_file_state.clean_snapshot = configuration_file_state.config_model.clone(); let default_name = configuration_file_state .picked_config_file .as_ref() @@ -191,11 +197,27 @@ pub fn configuration_file_picker( .as_ref() .map(|file| file.path().to_string_lossy().to_string()) .unwrap_or("Default config selected".to_string()); - ui.label(&filename).on_hover_text(path_text); + + if configuration_file_state.is_dirty { + ui.colored_label(egui::Color32::YELLOW, egui_material_icons::icons::ICON_WARNING) + .on_hover_text("Unsaved changes detected — download the file to avoid losing them."); + ui.colored_label(egui::Color32::YELLOW, &filename) + .on_hover_text(path_text); + } else { + ui.label(&filename).on_hover_text(path_text); + } } #[cfg(target_arch = "wasm32")] - ui.label(&filename); + { + if configuration_file_state.is_dirty { + ui.colored_label(egui::Color32::YELLOW, egui_material_icons::icons::ICON_WARNING) + .on_hover_text("Unsaved changes detected — download the file to avoid losing them."); + ui.colored_label(egui::Color32::YELLOW, &filename); + } else { + ui.label(&filename); + } + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); @@ -281,9 +303,12 @@ pub fn parse_config_yaml(configuration_file_state: &mut ConfigurationFileState) }; match serde_yaml::from_str::(yaml) { - Ok(mut model) => { - enforce_metadata_defaults(&mut model); + Ok(model) => { + if configuration_file_state.clean_snapshot.is_none() { + configuration_file_state.clean_snapshot = Some(model.clone()); + } configuration_file_state.config_model = Some(model); + configuration_file_state.is_dirty = true; } Err(e) => configuration_file_state.parse_error = Some(e.to_string()), } @@ -294,6 +319,8 @@ fn clear_loaded_config(configuration_file_state: &mut ConfigurationFileState) { configuration_file_state.config_file_content = None; configuration_file_state.config_model = None; configuration_file_state.parse_error = None; + configuration_file_state.is_dirty = false; + configuration_file_state.clean_snapshot = None; #[cfg(target_arch = "wasm32")] { From 0a1f455e9c5799be5e1caf27c6e110cb659717ce Mon Sep 17 00:00:00 2001 From: {} <> Date: Sun, 8 Mar 2026 15:56:10 +0100 Subject: [PATCH 75/77] UI upgrade --- .../src/configuration/configuration_tab.rs | 18 ++++++++++++------ .../fosr-gui/src/shared/configuration_file.rs | 4 ++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 9f7e4b81..cbccfb8a 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -238,8 +238,18 @@ pub fn show_configuration_tab_content( if let Some(model) = file_state.config_model.as_mut() { ui_hosts_section(ui, model); ui.separator(); - ui_metadata(ui, model); - ui.separator(); + let meta_id = ui.make_persistent_id("metadata_section"); + egui::collapsing_header::CollapsingState::load_with_default_open( + ui.ctx(), + meta_id, + false, + ) + .show_header(ui, |ui| { + ui.heading("Metadata"); + }) + .body(|ui| { + ui_metadata(ui, model); + }); } if let Some(model) = &file_state.config_model { @@ -283,7 +293,6 @@ fn ui_parsing_status(ui: &mut egui::Ui, state: &ConfigurationFileState) { /// Metadata rendering fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { - ui.heading("Metadata"); ui.add_space(6.0); // Title @@ -511,9 +520,6 @@ fn ui_host_client_protocols(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) ui.horizontal(|ui| { ui.label("Client protocols"); info_icon(ui, "Specify what services the host is a client of."); - }); - - ui.horizontal_wrapped(|ui| { let mut proto_to_remove: Option = None; for (p_idx, proto) in host.client.iter().enumerate() { diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 5bb75c7c..890068a9 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -221,7 +221,7 @@ pub fn configuration_file_picker( ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); - ui.group(|ui| { + { ui.spacing_mut().item_spacing.x = 0.0; if ui @@ -247,7 +247,7 @@ pub fn configuration_file_picker( { tab_state.is_code_mode = false; } - }); + } ui.add_space(8.0); }); }); From 499e7dd11d78a0a6b00b9be7cb6ebf241ee4566d Mon Sep 17 00:00:00 2001 From: {} <> Date: Sun, 8 Mar 2026 16:04:44 +0100 Subject: [PATCH 76/77] Fix: error in service with custom port fix --- crates/fosr-gui/src/configuration/configuration_tab.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index cbccfb8a..23c14f5a 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -791,8 +791,9 @@ fn ui_single_service( .unwrap_or(0); let custom_port_id = ui.make_persistent_id(("custom_port", host_idx, iface_idx, svc_idx)); + let is_custom_by_default = svc_port.map_or(false, |p| p != default_port); let mut custom_port_enabled: bool = - ui.data_mut(|d| d.get_temp(custom_port_id).unwrap_or(false)); + ui.data_mut(|d| d.get_temp(custom_port_id).unwrap_or(is_custom_by_default)); ui.horizontal(|ui| { let btn_text = format!("{} {}", svc_name, egui_material_icons::icons::ICON_CLEAR); From ba93bb9235247b99879f894c96e3f5c759868d40 Mon Sep 17 00:00:00 2001 From: {} <> Date: Sun, 8 Mar 2026 16:11:04 +0100 Subject: [PATCH 77/77] better add for client protocols --- .../src/configuration/configuration_tab.rs | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 23c14f5a..463077c9 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -520,23 +520,6 @@ fn ui_host_client_protocols(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) ui.horizontal(|ui| { ui.label("Client protocols"); info_icon(ui, "Specify what services the host is a client of."); - let mut proto_to_remove: Option = None; - - for (p_idx, proto) in host.client.iter().enumerate() { - let btn_text = format!("{} {}", proto, egui_material_icons::icons::ICON_CLEAR); - if ui - .button(btn_text) - .on_hover_text("Remove protocol") - .clicked() - { - proto_to_remove = Some(p_idx); - } - } - - if let Some(idx) = proto_to_remove { - host.client.remove(idx); - } - let popup_id = ui.make_persistent_id(("client_proto_popup", host_idx)); let add_btn_resp = ui .button(format!("{} Add", egui_material_icons::icons::ICON_ADD)) @@ -594,6 +577,21 @@ fn ui_host_client_protocols(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) } }); }); + + let mut proto_to_remove: Option = None; + for (p_idx, proto) in host.client.iter().enumerate() { + let btn_text = format!("{} {}", proto, egui_material_icons::icons::ICON_CLEAR); + if ui + .button(btn_text) + .on_hover_text("Remove protocol") + .clicked() + { + proto_to_remove = Some(p_idx); + } + } + if let Some(idx) = proto_to_remove { + host.client.remove(idx); + } }); }