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 001/127] 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 002/127] 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 003/127] 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 004/127] 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 005/127] 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 006/127] 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 007/127] 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 008/127] 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 009/127] 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 010/127] 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 011/127] 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 012/127] 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 013/127] 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 014/127] 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 015/127] 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 016/127] 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 017/127] 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 018/127] 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 019/127] 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 020/127] 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 021/127] 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 022/127] 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 023/127] 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 024/127] 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 025/127] 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 026/127] 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 027/127] 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 028/127] 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 029/127] 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 030/127] 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 031/127] 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 032/127] 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 033/127] 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 034/127] 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 035/127] 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 036/127] 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 037/127] 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 038/127] 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 039/127] 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 040/127] 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 zcmbt)cRZDE`2T&5gJWeRdmO8ft?Y5^nVpp) zA_1BVzU>5jKY(u(-nuq-0iZg2`a^)Hxhw!s8sE~;FfnoS^7Fdu=H-pi)6l?p`+DJS zdAI-&Fp+2OYGMAHNqKK(N8KP6N;mK_JBP-YsmDW%Dd&ZGDCqQKc?!qRnYPhuX`$G= z3t~wgKZX*{nTk-yQI3)S^6_kQ)Cyc<jS$cra$0f>NaRA_*|l7pAv zh#}H0t7qFGpoa*&Uy7Uo8e#<42anZD09G3T(uLmvTw1Bk)V8j`Gk_Tg=Ktj(7rwgj;0e*mv>McN1 z0}0dEC?S9f0uJ4Ld;#ELHel4=vQqkUv4U<<7}iwwTct*Rd9CnkXi*<>D=RTB!2vzy zi;OZikvE@W#o7b2S*5}+(Gs@%04PW~2ak69IAGvR#lV2fGpS|D@Dl2W_+;;GqmX`u!E2QM7ugl-D#)6{LeApK>d#V{Wmv-cEuOW@ zLPAz@C2KL8Hrwl}wQ!wq@VS)Flo|RN=QG%a#VyLpOUh@;sjhBc?X^fMyI~$up?y`_ ze7=n0`;%8!%kxZD%-PLd%M?pv%3VvnEL4j^ZH^z2*?ctoXsl6WT2O62-`t6za$>Cs z5aGE^^D6pY_~lRMtNX`;?+Z6&9dkCtE0>oQ%4+0t_H+G=C>8}@ zW^RYZ%`k2V{Saa=xn$TV>0)=g{7L2$>sPw3Oke4&e2}SFdzn}AL95B;F#L>Km;MF2 z3p%bz1EhJ2=HuqmFDYL#T~Suptq?1}UCdtCr0L&!=jD1psGD889-Gjr}?O5<0h7sJ|# z+O!F`I_>89=RG?;J71h9JI{08;L7rq^`~Z68cT#q6b%&(yZZb3pZAYsU(QjM9FiOimLse&!XY zEobQ6T3<#5ONE)I1G(#}AsstUev7+W57G^eNzd1v?G^fTE6+UVn~p29YvbL>?b!H7 z1`)ktnJQvA?48ryAAail?fP7wdoZ+{ms69|zE(O}xh}JQYdvP_S>L5+Lisk*cfWk> z)*zTJd{w1-mLYL__I0SShH<#-yG1^6C-D@kH=|Yc#s1}cH#eA7{;K#sn^&1vlAV#7 zv9a%y#n*S&_Zoln%D0c2)d=GwIujodzk-*L0K%2zQMg+8HRR#`gJ7Q4$EpgGE`Q&S zuc;*zG^(9bONw|CvC#I0$E|xc`3=wBV%9*)z^yY;CZS6sU&wF%x*7j5l=?zcOq5l$ z>SOO%YqqbFnV0>)PHEho)jZIAqB)b}DrqcVDXM>2SfyOdU1>u0{iXM+onknfzG3S; zYdsGG4tX)*3Yj||Ewe{!+c7`#eo)N^F=vT)U-u3y9BAK(ddXBK#vr2b1Zx;;B$vLa zMkid#lpwHm)?eT;WnepJyDP8wSDCM|V;XkCTt-{E!dD)ouBJ3!4&?8UE*4p6e0ZTO zx!I7$a5_gJpXup+jHi^c!UxI7)Rbf&^>UsoX?3p4uD@{idtbXmy*=r;6JIaRl|kNf zyC(THsf{RQoqEZVg{|PIF!@_o7b{$A|D(_HPhRC;>|g7v`8=5{o4fIhxW>@VFqc|a z9Bp7F%4z-U!)tf>w@N;Nla%x4NH0cPlD$@Hh^rI!95#NnW|dhH`r-V8E4RaMw0(|f zdxyBaLUomLAu42LYIN(Ru7D=N`pXFitXOo(mfjoC;WAkMa84nmv_X zTlVTxqw^qc2>1KfjA;I~Gf&Dhe`TJzLo+^6OEYU(ySDpRYf?P3UvRQ1vQDG%MXORn zv&+k#fqq3*iz161ivtUp^`Yr^H%IsHd|hV`YW{m^b$Iex^WL#aPs`+0#MLXqxM7z= zy~CP4)rv>uzxSUUdR~8HPjmxY>RO zyQVg-FkdhKLtY-1|S9q(VT{dG!bk1Q&3@FZn7iB^L&DLbh*nkITBUrt3{{)ejX z@$aLT8iUS*=ku9Qo}ctqFf)mV?hmhw4t&k}T9~<&nbztVTztHYUtz7B>}>DkYUv4( zmb|fJdAQc*KjqK4@nV+N_-8OCOpxgIuX@+~%ge(w*0eMtiX?0M$Vpms681MSPP%4S z0C*q(02B$pKO%hp4ZuAK0G1s9P{;y+*(=q)LmL3G5YiaLw~+4K7q0B>HA57 z`Ln;LGF#8|r^G|=Xoq*`jihALt)+)qP%0%p#}^v|dw5C8=*&dIZ+2b1DRm0&T{ zV2~rkfdLQz4HP@Y*&){kXAI(s&obR3o2xt+j77k%H^2)r-h;%KSBr~O#n%b>L3 zLNx+ALrCytp|6F1tSLQ0Ysc`vrr7$L!=b*JVPc;U!|3bV^!wjbM7c5Glt>uz#Piq~ zxA&(86|Z1GxPhhvSOA`;jfS6)BUM%@+PMGTs^K~1hovjor4Dm1+L^m_TRP~qG%5)V zU>Ya5e}`(^1T(IT8j}ACCf^;Ed72-Rb|93iq-v7R*ZPCg@tK`DP-QC%VadOj$GI<8 zLjB+9)VMKftS~l~!kS|Eq#d8N!c_Fwy4ocm;A z@Hs6+_`bvBxBr#kS@#M=e39}aX6&_RlNBNIUX>BM ziNi_b4#6`Q(wR}4aGKL`X}+jhi*AW~4&*+^*pgYEj)9O@p1+pKNQ!l0`uiL8p9&jz zYS!MLRBAuvCa{g*{^oGX>&Kol(GL&9Of`m+<^5ipyeZ&Li zaN2j&ujv6`k=~rm8E2=Z9V85XsQyF-P@!C0ngBrf!H<#fywU9k2GAj(!GGd$z$iI; zJS=BC@{g7Z5NE9W5H=q+_E)`-sFQ-{ks}3NNMfJd068HqkyB0ujU@wTOtZ&{)oUp& zVI~});$MuX*W8gk?r;6&W=EE8fVEA>-FGcIK=9T`)!2Ik4NG7^vRtF5EKLSHMk%Ec z-l`J8p(p{!ucvPnW=p($pPHnolUTc^@{HZ-1qN$of&1&uk-2rE^yiEcr&497>w84I zxr9SZ;IDgmkB_-5TdD3bXb4h8>p|Aeb<>0{46{?6<@)fO2$1DzoS1wy?#~o!R~SNF z;&Kf$**m(yp_ZA-LSA6mI+^-_@=x=3CvqUiU%y5ZN{#cW@K?Tc(VPLgUiawsi+Ls5 z&hs~cRiAEG^aI?5W!o#@eqUL=Nw1M~^i^iw~4fO ziV+KH;64N4iiN*{gny?V+gk+aJ18c9ah$}ua3+#?OzPPd4^|ue$11iyEF>rv9lGBl zg{ZQnK*&AATWpwx;INxPo96+S0O3n&-*-9OJ{NhBE z4q{ByKJaY6)3I+SL)-YFFd(Q?l4TL8#ayb!UGBL|ZVJPK-}kcmng3jKh{(4@`cVeI z+s9()G2?AHCt6^^SxY;Jq5!S+at*`K{R19V0 z#8PDP^Qq$VNIMQx&s`ltYe3bG@2B$RMjEYiz?flk$Y;_>5B2LILN~E*$a-T#VRK{C z?EWzD6nYB+QlS{G&R7dgC5zGdc&z8WlS6v3om7)|3)jkgJM8YPK!EjLD|ex1F+aG$Pq+#BX4$TK(M-|H;1z8M zwbOUr)} zlnZQ5e?_7A$Ppf8Tb>0E&~ZX1*FFhkK3ExIi?t z(ITVWnoZ6bN!dhCT8H7D7qjs`6{ z3J^hjq;}!##nIGkHslv4oZdf`M`l&cydECf@IbttvL7qae2I#zdjxWyjSEwi@S1P8 z@u5-n$ir+H0bCQWl2D%FS7yZFP_ITtAnK*Ds<6mr9d4X#} zc(QQ*X#0&T#3v%dG`3tL$MjHdME0`px3qxqtG|Nu9B@FCy?M0r#PQ(v=;wuNa<^Zh zu?eru7YbHya3sZNAW*a?#%FMq*_$4Y>s23k!CDrT>zdNe^+9HG9t54t{;a%^({ORd zg_oX82)DtrA^XBg8&p^%{`vz6{TG&^^H$%cW21ndu~=@DaF&Q^r$n&BRzuIiu#J;t zUf>nZ`s*l+CD=}XB+M!sP8iH+$tRy8UtD8GG;j>R2sd|~f7x}2RB}Bo;FEfpX!WOQ zXYupDjtC49SSzbeI6)OZ|Hm=D(aq)wVnPz>%?Zz!yubAmE?IIhil|#Z zs;g(@MHcP+p4?+D;^YIPFB%vLm$laZATQscGNevQtW~c~y;98!j$KL42@KzMD9^G( zB&HGU*~-FDcygGSt;B%Tfch%LxZ4o-qq;T5!IKTKbf%*iy0AF^rv9vDl$tK3YvR|S zO>wQ>OB#R<;&XoPq}kKZJ44k_;Uu0&Q00&7Iw?U+${@dpW;itMwwO=npuClmzjL*z zj$sZMYd!hE^os_+t2_vibLv4#Yy0+Oda7mpCwwn=>7ex<(yr)Cuwa{2X-qjt@vKi% zPw}0TwvIMHN?f4&LY4i`_HiDAww^rwQI7wecT!;#GXwOHpOlSwcqfH813o6w%>fnG zRa3%%zyh%%{+tdLPY+7JE)tsf@z&z%6mo>_ZIM748C<~lS$qlhLLW!*lP&Cm%FAGS z2>Kb}s7Pzk67|ofJdggyF8kRP{k{|P3D~L>3Dw(F`Ao*67|BpP5v@DmU#dxy4v1#G z_T6mwD{3?zrFoESEtsD`ND3(S_ajHJM8}B$28UJhWM}Pz*zwkmmOr!zht!mIDXUwn zvh&^$-qe!|9G*; z(eR;F)gO=DP}odEkE2JSt5=Ud6Vkh$nVP6>F(0qHy<6>7nrneT!pEtxBZVUGG6Q^C z8>WwA%pn}QIg1810BPdNc;;=3bo<@bpJ&+AcHot=@(c}-1#+x6SLY7-`jjZNKRmA$ zT8t742MGy7H?kP-7g=?hfjZIsDFG95`SvK-;Tl2+MX{D1^gyiB&kyl0au19<4u18W z&;t}!fl9)-s4t_YO@x%+V!UjMEaQ<*ew0{AbW0xU zfNmtFE7F|B08!X;MRC_&5^uVW2C)=dslKj47$j|bM$QoD7Xg?AKzIxExTr5wGz)Ep z-|=BJpVPm7-jZ3LGhtj6S@1cR;J0q9-Fg9iLTR1Ytn?{F9+n zmnUO##qaGznoNUvZL?b`$;Y4ddE}XNnLYJsm93%iA=4cH?wQ`AsI>CH!!jey{x?^@ z;6rnHe~OV9{qxb|s;uBGSk07=*$Ras)_u5!rHNl#H~nMFs!JZhI?0bT$u5A(*2xKX zATptg1BWXGew2JKVdh5N^oyoK4{N}=X${qTol$3~9bt{rY30|Tdc)2uW%n&{*-JCO zsj?XK5x!TDx*iVXUN|GA70P>i?q(q0(?9j}ZKDF92=9O1`>Ejq3r6S$0J@})&bm;t zLPKMm6ad`%BQAZdNDLUv2lK-%J3jZdaBq`#-awY3PXI{9L?s4`c=u6bEeB8TP9Ol_ z@1Ce*N9xSAY9GxJJ%a^c5<>#7F8qj3mjc-oVLb4=14iZ^lY{h*z0wF5#M;IU0A7my z_uCRlDbVQin-+czWg-Nz@B0=={smL4ibR;~{i7EHs3UrL68tLMo*)r z2LSrx^fQ?i;D`VKUK@x<8uXpOT{ImRO#Zi2-3BU@`k&H=j=$*sx6~1C`@f|Vrh$XN zxijySgy*>R;4FGckwk)Tp9BEAOX2x}{XpW796rT~QEeJ0u|h(pLKDg#`7KD2Y&uTD zxe@@pS#M9SlX|BivKWqF?iJ@Noyyq}``HRGjW3C1AtDGyTwPHM+*0Aic6|6j_y3Sw zdNLqByrMt?pA;_cqTr@N>?`|@1r^djdKo@V6x@|s+1GU#Wd)B<$F%ctLP3dP%!SNB ze-oBxDRJt70z-f(E$;96t-f0qbl8*Bk1=u6lJ~D8L!?Q6ssC9E?+iK$yj+1%=`<_| zmE(;A_?tIS%Qes0&ZG`kYY7b8<8{9-iV(c~RA=SS_M?Hua{)1=!@%2bl`V(6G&<5l zb6H37mwwhoq1W4jEFOB7lMzs);T66}%9x(0A5&C`e^(asWgetQw2{KwXz3uCvD#&h zg&Tr1*OjxE9VxIvE;}iI0~w>T_afYZTo0d9)Fpk%UtQIt80oxsv80G!{FE!iK?@Qn zn7gr<&F)in+4aV`kfAmz2b?od>c0cNlTbU6+CU{|H%*;3hsNmU!Xp$Yse-_d!m2Wb zmux>*miqhhJ3oV;B{en=$BBUm2OJ5O3EmT4XPMvNyFH4=Ta-$LzEsYCxElskt7x&$ zXw^5Q^hGT7!o7C}b*?X1Xoy##=#r+a-)=*nQ}AtGR-z(bY`O`~)aT)E397Z$gDU<1y^E7(IykDrO6g^6Pi1YrYmDgjRlfFk*a=9b{AKQ zx8I)7db5T}MK=(tAL}`#)h7;mk`^!uZPL~@YB!$^h&eY52r?v_kKNrw5MnjJ)1>i` z`=V#(7(1{piW*MoUJ!%!1I>`%yOkt90sdj%>)Bi~%Ut z<*E5s*_iT}W`zh&4o%c9U;1JCQ<<%6Sq8Zo*`0KVRlNJ{mK7}Lp~^$B|1|e6!>hET zrEn+4_1oR0(K9^C963`Q`QpYb87`2KG%%-*r^XtLl=xgC!%N?0C$M2T!*JH_vFLDct6Tr7wJYGFyF2%wO$m-ZjyOP~JH|rFizsdX#NZX37aiL^=@q9THqRdQ0N^)hOW^EA z?#J6KTt<2usJ-WNszRb;A}sj8iR0*-J!LY4utz%B@SCz$v9zEuutU|KVMV(H#RZ6IVc2>9t^?cKfC|3#~W<-lQ_WO`a!kYKx8GE7OuoUF$!fz&tMJ*A=0 zDb|Ch?qDe@(N_X7PK^yUoH^f~(JWPw_Mzyx`Lkj`XV{G_t&*AZk~e(lno2eCSP|g> ztD`CDoJDxAwPh_mh@yh5iC%+aU4C%PXDlS)1*GCM{-&N9A9sIVj0Bs9__XdMf0a&= zBzbawMd>y!ZTjjKbadq$c)0F_h6V$kaF#bcT3QWL%V}&#Et3VfG&IC?v4i4yIq=R- zXdQ25eJKOX0EJspy9%U@ET}>xXBWZ6aSO;~c<<>3mtxn-Spo7Tyq5`!whCrtSo&;$OC*#!AJ;|WK5nf8>}^Cp71pg@fTXzKi$*m=~wI-8LG z#I;lrx@lvAO2{0=Az+)_$6NW@5J2IAf=FVukq`;=b*KP=Cuh~7yFda6AJj;SP-oAd zH_USZcW(7%$pPSG%pkeJ$qKPBG9rM)U+lHONeZmWR$n=98yA9bVEUbzzzeHtWsz~j>y!*3%5&vy!7y*V$&0nD@w ze}jM%1*ll?iUSUe`=N36OqAk=YR?5U76Wug74S}4Ezs^2VX_J zK)wUMCo_&GW`8g}13&(Tfer8lTU-GCV@!AYggyQH|16{fasLk@Xg+W|4(5gtf&-T@ zqX!<=NcgTx_aF09F(8Wu>6&`_XBWx|!ui@2twL8NguVDwCAiKx$vW+w8w_^Dg_+ZO zlaguy3Ce%nVZ>6ea_h%fFsHKtJjRP)L=&Z zUA7C1Shed73+$&lZz)DknF%*PY-fO2dqKY14Cv%}i6&6W2pa%2e&Mws60n4mY7uN_ zEJYBgD!J1UA>4Y6z{q!UJ6&yhP{giD;O(4Id}qc0U7Lu!MhUk-0IT_5ID3E>hi4hE z1QE;-0bKuF^q1$~$H6F!RiBSzk{iZS;LAC92>2ojwv@8wZX;lM$u0Iv0RkFVJzG*E z@|P4_C`G|*WqBUpv9KIc;OlF2sUo0FXGF}q6j6gcTc=t8z6QR0f-vbu{;Z9U4=h20 zRCoDQRR0|PiLI0xJe%;L%#{cWaRt%nqV@s7-!^u(nDi>) zeu9z^2s737=3&)>mjUPGiGl#2#O*!4@B3lg6yA|on=0R&%J8#?8dC2_MKmO|!j=W@ z9S%9QK(z$yC-{A$6tUxCH-)g!u07xrfD&7VX=>SP5)(|%5Y@R~=`IVbRtNMOkvcsARks$VsW zvZp*CfwID#H7?gO$wWQ+`aBVMBzX0uAuv>+;9>hiH?_+R@i$kTe#p>QE^)i5zF7`t$3!2rPA zn6))0eIuyZ`EghPStXU2KA3+0dt57uO&NSzxyFImkv*KX69&RIV4cJ*Mm>)Q0sb6( zk8t;9ZZaAfmq(a1(8W??gXY}!>d)mF}8yrNS6A@fr- zo*Svkf1woKNfkMy=Gj~!v98wY!?PE_M31f5CYS5pW?&D<%0AX@a_>_wIt#F}NPN~0 z@(k;HbpvqkiG49h7G@_WxKn(2_)At9Uh(zQ@8d;tVXLOj+S%Xgru-}`P-L-RGiA_F zliWMEE%D3eAlH=kyrGu&o>l-T zD%JNtoG@YL6zlO=y?E}wx(_f|P}^6C4h?+%c)B3IXa$Sny-c3lz=|~Edl#Mfk;oR;8%Muu2{yuF-8MSQE1!hI~gR+&3N{4P7)$r6Esm6|Ne6s849qa@U4k8$S!jC zDdCJt9io(k=wLn5e-CHs|U6SZ1F} zoUeo5%L{Uo3v~ zIdMknJR2@6(vV{a$ZgjZ)qW@dmcJcm`|6Z(9H+e}S=1^u@ts&Qy>Y7+MUBETMfEVI zhUVG{4%~ZN_F%}gEts*q$o|$#9UFA>M_LOKyc**Wk*d5+%fmFT(G0iaZb$8xkS*Wx zjKiO=-w`4EZ93bAE@?3FpeMUA6u0TaHA4gA%^e6L8@x?t9;EBCa$AOTu<) zKQ8Q=j?Si+LTEKJH3I+z%y1g;x)rfnuS+|Im#-v=_obZnU$MRxqeG1DKK*31KfoN_I)_xGflVH1cd05|KjPsLv{T7^ zUb$5cMk2y_?-!#i558^prjf!g4IT`~dG~_e*ao_4hG0pm+L?a0RtRVQcASGR{qKBed;aVT4JZ}v~1 z)R!b4I2r^nQit49@F5z0ESOuJ?Gw=_gGAsLsCzYRi5d>|!H;A$M7PtZ6?`FUh;ZZv zwi^JbxH{ox!-T+BDbOSW>?vV;H?|eJG)}Z;1=20_aEq#(WA5f{RYx+1y~uxN7yt)B zHvSESy`#p9QB~Eh<-<*&@7AL=#7ND4unI@TUwaW z8Ay^;%`^WObLw2GHdX^nyCkrcLncxB7~iD8C-jD&8d8;}tv!~IF4v%-pq1TQZBu`R8FXY@Pc=dTC+BtK(Lx%(u%97Gi_`cT+6}1s+9^WrqC^-$orNCi0&H3(Yob!HCJC4YL+&Y`{ zZbRl|QgCR|t^Y%Hung{(uMnF?zODZ{iWECOv(CWLcSEUUfDWJ>K7pdQ>DfOz-39&~ zY~9M!T31vtMqTuF!L`QARM;qAGpGT;f8op_NWKjKW=&G&}z8SY@5u-IbdT32Un5a>7Q$jL`>J&*fZ~<2nV%K zeMy=9F2U$n0Ebxvy(#C$zVwj#qVZ2I%;{TB?0mbBZ7mZm0>@c6xbxGkdFNA7{jP`n z;zn#XF>a>ozRXD@#lqXs?{T71ij-A8|zbhWtd{(@~k+96aeNk_l*_@NqW#c;+xQ#NwU-qsXA0_WT z5uj7hIS8sy5*&@zIZ43`zXsa&XIA)=ltI6l&vIfB<%HU+bOk;n1kgqbEZ2~;$as+Z z())|a^ZVcXcJBXr0#D4Bv&FyjXa$=dhbnbJnEg}dLrP4IJ+B)T zoLHST*yQ%}Py-XPytKbxxG@w6+HY${oJA(*6SQ-VgD6fS#pmHHS+;yCl&hAyr3H$5 zQm#d`(lo3m0lUG-G_rHQKRa}Y;5Cp4%Kw{oX_jMsrNe4K2lcX#3?)O~Yk&|3l_ofN z!RRq)iX$d`MT4?p2Zn+y(f|ssB0A_KKvJf@gzHFWx-tsRSuPZ%Xr+PJhY9j>-Ys2aSLVvnVVE`2ki=O;v|F(R-x6nm)|j?UUW)&fJG4`&1l7)8H{ zSmV{f`#eFx$Mgbis{Mx=bkM$<&|8#XiP?*38VLBs{4gtAEPgd)vzdA5z>ZATjuUrZ zeorSl2%qRda9cX`N$%okBRzDZ;f2qcPGtiGJWOJIeozF_VKC2mQ5&RE8j7+1X7wer zLP6idq-Ni4$~C5PQqe)7Z^HDPI3LRYPnXS1tH4|fy=PTekPM;g$#5lsA`q z9@0TRf0{>%LvNXg+1<&1OvVfP1L*E}HCA1cgr9pTu_y{_N?M7#I2tq_^gWy$x3m6| zslBNK7?>e6!>0Rt*XfMWb5tn&2f$eJ!Coh&Y&$(H%PRx}bg`pd2~yR*q8;Q*h9L=9 zU{q3-L0ZEoQrQIN?b2yOc;F@sR_Keg3d`)ZbkMvYw~e{twmnMsUkv35ti>;jsdz(= zUxm58xqPtR9gJ{glJJTqdbe>Su*m4KwF)V^D;>S=aiD=7?A8^f9t`&Fb*L0Zsv;p& zQJvbRT5~RCha?iQFP!m4Dd(!ZqXLc83N8B)JqTHk@WW(yKnG^fkG+rd(BNd!mcrjx zy4*qNOLzKt$yrd?XTm0~X5D0^FTIAvDBxs&Vg7*S(zVL)&R4<@yw%_Z!dQb??!Bn5 z2s7s%Z;+fcthEVa_)j#melq70;v9Vj0*&~x5#+RfkR-EWe10DeT?)c@gbVD8!gE7v z2QO%>%P1i7 zLN0Z(muH?}c|$}f;9alcIpvg%|4Y&X!$6!7S#zNPMgXx%cXT*&*37zj`nZQ{TLTO| zr_QaIq=f>LBDqPdpCSob1&65wrA=SQAw?K8P%9|1{tGX@N4_I@_9+jdhXTqZsICZY zU#3;L!T;eKH$pe5@5l9i`66_HP!kV7<~@MXAGFE|^xG(@X!mCVrzzUD27`jTdpyYO zf8$`d9;L|y#M_9)^L2Up1n!mR{ILI!T&Q1K<1d7adf|JK9vU$eeRFT+JF>MsQ|B}* z8qxk^gK9|wCG2$9ToVSw_>bZO%&3lFn*@`#v+WZ@N>gqfiykQmEct_hflC+D-&5SQJL77h~P?`+DxQ#dzbh2&BapUajqI5KT_;aLr7}8h% znF?oqT^I64#9F%sPUGwuHkyjyb-d)JhuLi_`n`qE zD;7ZHlj5&!s_un9P5*XuT8k=J6hM$=8k6*`YFgqaZ^hl_$G-vry&lTBOPSKBV`;q$ z3rVnXR+fC{CyVA{&w5Etwov>rT>2bh^ZeUh5;afv-<*>Rj@+2x_w9U$+u^geYH5<33$%(7D{*3uq z6V{cFUvbB?qfRuMum?Q%t7L>hezg`&2H-I2+xdsj6T zaXHetos2*R(dp_w8gh%)&pLa&b#42dRq+)_@wZ5+@E(k>93kqOCR_v|?Of2l65Gms zQ$dd)p?(XCOPMGl-O-ICR#cc7H5}Zd!q{OLZ`(ud4EWpw^xZ;QWTr_ljskNe0X{avA;+_*Ff4HBYOhlaYn)}rlq?aQr% zs!$-N+oMU9;0hxdAy*mP-x=#ge~KW0RYQ#lqOk;Wcw%*vo1Z{11T=Xu($jyhQiu{o zU|dB&=ivc|?&tYfvS8&m!(3PAPeG&zfK&xY5DP#!H$k23n?&-d1cr^r$+L3jF6K}zUFa48AwGq)z37Usi4F(&9Z1p>TB&aW_ga^jU7 zqRxNN*3Y#};X+yniY+%B_nP5T?o-e$Z`$PjThMDzBr!>}9{f8xdeU`@@5R_!63Cq@ z!KWV@ocqkzPv8v!F67M)QBX56;UFYugiQ3b{+<`d!cxP?+#soBa}G@31M#KG!;{|s z2nbLf*80Ht@QebY+ax2=y}Om=lwUH!88A>B{D%X!@5O(wV@FfPkvwp4c_y>0zmMz(xL`5=!0$2ZPlEPBu@QpM7Ra`neW#k=kk8|hQS{&7bQ%-c?OW&FFy6eJ% ziGxfp2TJm%dk3~o#RUh2rCJ0g9$jIAYR(s1l~X7$^q8*s6*1eKzrqH_FAt&-|0%&i z?yW5c@C{ot}A>03GO3Bs+A|YKceC( z^KixHd)u-2VS4AR3GCBg0O0!MkSWkGec0Nzs5S$GuW??M#s&Y)F8U(*p}O}qqIA6+ zJKgACA!VW+@$Nh3C5o;8I(kES{K5Ygz0No@95BWlWA6loA8i~AHbEx~&tL$!+6fVK YdNjmd{KB)pdEhEheb%%)m15XNd!T)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+} 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 041/127] 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 042/127] 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 043/127] 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 044/127] 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 zcmeHMX;{+f+6Tg1bIm%YrdeufMw7VZ#$ZZ|XeUd@O3@Z1D%TPf6_{LVOufue5}S~z z)Y9B@V-%(`H!wVoChl2jrKE;C3hzVbz0SAu>3n+6+Xt`9=YH<{f8YP#|K7QE+zYcw zWt$2F0@-xr@SzhB2$UiJQ`!h9wi}brLm>L;M-KgXGA2+oOnYX~l0yyNv%zsg7W9I# zd(h1w_l=rR6IhMbPx?@M*oX)907~z!TFt4;?TR-dhGTmYrhFHcy1Ij#EU+X>9fm_h zhRx64Lcrikn#SS3b9;1*2HkXwQB(-c9R1Gh+dn=ENfRdnHN2=0C=C5>`*+%Z9R~Xh z8A~&xu3?Q7qN|JZDd7#^0b}jisVTFaJw@K5ft%i)U|kB1?c2B(E19D&KES-%3WdR| z6cR5fPIs@Ik<7WNddo)7Py$SlMldlp|9$7vqEF*Wnm=I+FcX}?%r}mTAya}H?mWFE zE~xpm=u1>HT#O}E%|R80~cypm$E)lX1*p!=B~8<*_${=Avh%In zdQ5t7CX%V#I#!wqDu_fQ8J(-<=Wnc4O>di)8eB_&@7p239VJ(GI{sCwf=%6-SySJujG(*aj<``+-9(`YL92~5KZiSTt^ zF+_{2-jXfFPe=t~GnBDK#dH}yRWNS{Sw7DD_>^b}g_WqxXFgtuYstnPL>e_qIH})2 zjNbZ@xwbZ`i-=qh%)j0*33ZQ|^aRm|QJSAA@&BT;!B+z-o8U>YjAnU+D$vUP*2eg~ zZat${7pGN7^vkOw?Xo!2&Ex2V`N?IaMNkRcoJHt zuIdizB9wLHeum)XpUGyS7fop$;Nv?PvTof_#SV2Gh2;PziaiJ(wv|zzlLNN^-thHW zl9~EGWQ3)q^2sfljQ5|Eum)!d@8R@4fqYMy-;2b*D~p-x{E}tX(_N@B6piG~bV-Q%>s=E? z*6d2>IDDSlm>EUbmEE;+OYriM#wP4qg|D`;9Lrar{#JIk>VB)S+>7ueyc3b7r>~41 zYC8&}>u56O+LG~Cb|E8nS}LFQ0{lC-GAgqKFVjnRg*!l5dYNYluRPE?c2#${ALPQ3 z|24w%5iNs@lr?Q!s3{b-RV${itXkH#92c`X|0(VE2E$l2mcxmj^IBjGItyhvyjfbx z9H}jxsm}Zq0|w>0yFt6WjLx?D0#2Tw?U{xuVQcH2c1%RfRZSn} zo$%ghevHa>kQ02>0Kvho3}cE4vZ7s#wmFvkDir@TgjQ{e8apW`lq65I+SX1MWpv30 z#vntQX|zpeQlSVR7ZgW`2jEe;uJGLc-qOw6*m~Yp^3f`=V66;)OVH1c36+DZq0liy z^i@tDz8#_mrWEN<1@mQv@q}r-QyrLg_HW+ZRN}t_SpI^{g8^};J?xS_hVq9Qf>V-0 z#~0$0)Iymx%Mq`#uBI13Sth|ff^DK1wz(jxNq^KWsI+F>@+Dqc!=S%7htu)HxxQBt$)-h?|VFmF%AUQ0+Lw%H#aO zouPeVGxS)M`FXK)MB;QB=gMM4T%gMtnallkNzi9=$D))%RJ#b@NBKE${bF^d&iq&5 z^QB1IsHzXiM##zwh@jY2&t_LM8|i$h0}8=>Nz_8 zyGeP;YPzzB_$AQAalDYR{5QXW-U}&(n8H?kAAOj&cjZRL)G(fN11R`$sjoD(p~F#d z0)5`jddhZWsy@(GGVFw_BOOm{dh=`fbsIln?*$Ne6Q^G@UzGQC;$^utltrO=>UlEv zB+YV~=%tZ9?D^T5E&5Z%Nv~uevG1I?_H(;Kc_mjj+D87qdZU+p)!+l!b2C@lnW)&6 zE3M=LuF*rU1jT{J;95AhCyRm=xvc(SxFA{EiHJCaU|Ri9D2u8tQCfyP9HGnus|4pzubqKo+=9_4Pw5`XXP`(N(;PUoi_9Z9g=^@I~N z8NknDtu&-B3SZ8xHs{@1#uyHC;&&WDFq7|oMp#sod^(&;q4ezmRy?M-l}Dzy-;c2j z2Novp+0<3pRyHw9Nmwog?t3M?$FCla$WJaB`%u0AlqlI<8h-sFxEE~h=P#=j54?Ub zD~uKH_3Ygmw=!*3)|f^b>bU{Hv<;zocOd2lyCO4GLc!BN-S|weT{gvqCcxW|3 zqY1*reJX>t~9ojJ}3R zJ)82~eBPr)__8H~s1~>qlBNrED1#{U{hAMJC&&RY29 zt+J*usz5nO*2FmDCVt|)%=0GL27pYhS-fNRssv+3)=9zDrQjDi_Z|K)Rs|UzW$bRe zW@YwP5F^}sFr;8liX<7ts1TM_XQWP-E8zyW^ctG@2q^YZb{ZaVS@Bc{QYK#SmDr zKQx|Z=_#+o%I#X-31vlu0XwMN$FS7YiBpaNVH@qQz7R!eD5Vrk%wDYSit-_>A^;XmMA@9} z=+tlEXgvBgh6_sPqrfdw=;!|d 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 zcmcG$hgVb2^FDqP5(pp?dQ%9!cL4=~(2F2QFH!|TKtQDl2%$Gox*}DS-aAMOic%Gj z-XkJSs`Q%Q<)!b>_b>RJlXJ+qnVp$^cJJ)Yv$I6$-c}P9=7FO9&U*`ak6CpiHo1LUMfSv|k~DGstIn7PT$fA-7wx^WZrhqgxs;};uZH0C&jm78)l~nSlxr&D>%@X2are;3Q#N>_hfYG2_9z82c2mbYze9He1o$!Agm;K$PbSfynRO;&UwL{c z`DhlNtJYnlkZbcY4AlszIR=Psp9F70o=LIqM#*8Ya9HLV-jA!DKnUu=!3aU1YhYfd zXH`@3%G;at#2;GqVV6x|SFYFS!y`8&VJI`G%t^rZdSJh6phIwe(-_jd7}H78V2Ck+ zc-V`BE~F{6Y?3GM%91ZPO-WJiGQ-N)ws3ZsYWP5cAlQAEpg%9#Y)IRL4_z1AW<)P} zT!k&NZJ9&@8$pd-cUmtTkT9SwQNi(qZK@SN(Kmnj7zIGfkv>)Uoy4=lMrZ?(Zd@5S zMLv9N+j;Ios|2uN0bEPiAqvS7w_h z=?I^6p#L&B0oKr6uTLTi9-#*}AiN7OudjB|) zqks_72Ccr<-)#)d#eU0~n|=PP{3)Rd02L{) zzU9#-eA+7hv->4hDK*Y&ttSbAW`(#y+*_J@$#2Mtz`AJZO~_CH0m&FG{NbQkna*jUv5A#%;-eY|g zMX+oWf@ut+j~~j znQ1(Ml`K~3)wy{`9jo!@i7|nucR%$*3K$eQX-33huFgF%Nl(4n$NRI^Izt|~DNp8p zzhp~bz%e*}8G&YZj~^M|%Tk%+en(_=IOAd|ay)V_qmNn$shy!qU*!&g;~KUiRG^)fetnFbfn~?|*OX{O-_^rGTr{St|Tsj2(a!bZX{s5S2AkhUD`e<=}a( z8rLMNQ_~%U&tE7@LUkw8V`0sYyOSO*+hMLv$%il4VT(Fon(US+0HU6Qw7Ey_4>%ao*!kJ#_dfj-0*wzVGo$xBSE@F^bTeh9i47dUs~+cGn-=>uqSR#&1Fz zq)er&ZDlN8%08^^39Gf0@$rO%@s=bmCx&|H-HFbDT>Ig|eA8LyEiny8TUX1|Ud1uyAJ3Mq1>gWtIQ3Y%clk3X_M2ViKE>-NF@b^5EZ z1v*^J15!`zAAE${56)+007?4}?2tq;{%!7AVQvDc79!=G1B;tjzB?@8u6!`GL9@oY zJinZyWzxXX2hAoLHmQm}syOT+MYN9LU#VaX%?v7mM(} ziU3M3xv&536&fYT*-tWBAk9(R(K$X4{_&b;x%^~ao)N-x_K59O80=cEYf8ZJ{N}c- zpG+&$EA)C!g~AO4mJp@$EN9K%f4jdTCS&#X*Eu@jQ+vvm>N$p-E@RVEH4cDS18b*? ztA*T&#Lq@zI(4qQ8a|?ypdzuK1Or6PkI_;ivAL!10p6yEFKMGLDFhY^p1-5S$R;#h zrG&81-G)xHVW~m^7z%MH*4H-TT)sSj(zEiUR?ow`v1^^OtON80WL1O0pAcf7#( zIVVm)tM*|t4&gK1f0mTFbKnWuDGB*KQoqqPH|P079^-WQG+a5{B+8HuA1?@~Rkc#lq))C?NNxsXF?A z=YX9JmrB4c&rFUh40hH2F@XTLTB_(*QEQ_CFEi)x*3Votm(nOzay|t}nw}mg0txY$ zRi)s(-Lu0@wUv8N&j ze}}lAsDuhLlF1Q(Uu@no!tE;qSKe`OWINx)!r5@5B68)^HLEXtTC}5TuG1DvkV7jN zYx)zD)rw#GhHX}71;!v|Ir7=dn&*+$ zf1bZSnHO>?$SC)90TE>Rl1swX-_w?E_c!ESO@haxzIr`<1*Z)UeJys70T>=Ka zX1fM$RZ4n)@6%<54&2cU5jt<3@(x_oS1aJMGV~IcYvh+Ne((QvWh;{yTr^sL8;((V zlRvyK!QU`F?k11!-e&j}1oKGYfMru<6XYF*WsGES{Blb~mGkaR%RqQyW-LtC@^I0| zO^Hh*dIaU3J-eT(;o#ag-NEy+W1I=1g+LSWUT!#yVE&ql8o9-#LJZyN1XLY_bnL=( zUwVyP=g9sv35S><=B;A_$II}j@Tz;;LzISDaUN~AtkJ-EtXA+Y_NxV?(^ouO0-&sL zPYDGC01<__$@JNN3@SoZqd*%t_tiJx zPgClQRjv=Qo}T2HTV}5X=`|ScrXDAq!=_=`SORaGua*4E)}PIFPnq9re2H_O}$A<(o515clebp0iQ-rtROU%MID{H zivnoSWEF?Ku;Z(rGOU9y%dfa;%2on*)e6>JGGIJ%I;vmV44lVN5YQ6I=U_k!a}7H$ z>=z&2kR_?&O98X?P2rbwZA&rVj(;vJ+UI&nfE?MkMu~xGIo^cfNYDfV=T!t~e+RDh z1Nq7v9JIh>RC_K-U+c^TlOOHsV)ou-IsN*~!{Z;VEQuKeCtlU-50MzshA5&uZH+1I z^VX1OuleZryng_KS&{Z^HTer6aBGsc^5;SuuD&&u$DS5Cb;9=JHz4k8aT2#sVG?eB z-2wXyFx;h8l^?Qfl0ScvzFsQnRX)lJ=6O-xJcp;(L7>l&H1*ONEY9W?PxyPZW#{!c zbGKs^Nuch*&4=^Xa=BmHZ@-HABVT>U&vL-O^0I}?1A!fm^EvYV(U7=xFE~F=z$mGY zh)4l?JM_0$h3C^iDbEI_ z^{k8jr!BK4ZXk*-u#x>)`Ge}M&@`@Jn^#(qnEe|D*Cj;ASOs?+zOaW5nVn5aoO^z? zpu$iN&o_N{iLf}F-|UR+Aw0QS{KlOm#UD2^Y#jk;v3eYBstD5vzkO_MkY`{}4~V?1 zE4DTHIzx%+hza})F7QH9zcLzVoAg(7yNEA>JJ3Mth z-@E-qmJ0K{=M9_Yq1{2?J$xy>d-CO5cY)tpMLA`dBrc~P0$I*IiLUx1&KzqA0Egjs z>iF~g=m#eJl%)n-qs}2$NwI$lb;oJq9@|j9IB3ihP;x;mz6hV8z`XBC_1ta~Y;AA! zZS*?Y&BGI*kr=rmwlxy<)T%Exr__YU0(!yJNyGe3i{E*AiGJ=;#>5$a-dXO8WBCwK zB8cZ3P8AEtgYVG(vGl^vBkMdhTRynB;C1R#)l{%uS`i^js3_b28u0&Jqx zNFp4Y@Pim#6&dJ|*?Q~Qs(k6g1J(AKz@DTc#wZ2AN%xKM7ZK0iWb=xoOzt$gRs*Mj z={jo4xE0MG9uA^?$=w4m^l-YjhN{x*j7vM4r6KbDh8@Yhi;gmVJjTDRW`oCk0eOb!;Uc;6mNAw{5fY^6=_3?%$YX}_ih zvw~h{AXeW@XvtevqQuydNOsIyjknB~@jis@Es*SWzKFhtWz1&miOeG-xA|)O!d176 zrs%mQPz5`StvTf1IFBG-2m(q(_?cVuwp=hadZwhrTlYS?jV9Z^swcnk>XI6u{A^(9 zntK8EnnKo!?xCe=VH=BQYcriLHS!wx1 zx8mRGd!vBg!pHemNG)DwE?OMMlZ{eD>n0WnL&eVp^6cDU1rGMjd98fAsPIC9Pr($D zogln+lItE1KRQ*%b=^ChbSgZONL>e@JUSaD^GgWmbR+>H4}R`i z)*&ppdVW8PqNeWds^2l0g%cFDnkJJk@JaAItaNU|Ems`R;-Yx!TAiwiQv!|91pin+ z-taB|BU0a?G2bnhA4fi}EGf@zqaX%km{2UGpXAjpux7qNv zILx5q>yI-6h@^;#)tNlf6M#tOZ;zxI-nV}8bGlD8bw*A{AsdGOv55rVh~s1`tsBgv zB;fueYki)Guu$&7QdS8JEy#E=E_NqG`g~d>C`RsqLXfF?Q zKT0pxg6iF8$z=zK_0Ge4_vACIK7W7vy=s&BH!UrOnde(zL^ciT7t|oeKS3P&J(n<# z4bafSujnh4k!iLY6xf_)NhGpg#7@s1gsbXnzflU$b@QPa`Gi=YAniQD>Ul@-z7%gkPtLq1VgXamEH?Wuz%w0*fL&rpN{wTlLZ9ib;#(k z{pT++{zx;Os7Uiz&i<%)c3?g}BSCTPM&vgU=VY9`7v2Fz1~Jh;S!y%yBdKWeVc1p* zUU=bT!~qoD0ACep`u*@W(nb5+>(6HnuPq74ATc)_AL<*qP)urzWZ$Ysij4RGjn$C% z2#mXCr|X}ym-*_65qh*=w$#$=1ay!X+Tqbsc3P=wnRx+Ml@t=&6js1CuX>2V*0{Ci z<9oNYdd;o54X(;bPLhjMr0!Qo*N$s2gj`SZw5>kSPat)Y{a23x8*so?$TITYem(x% z&%30;m5>^K@x?2tiojxat%Ye~Z|O7bUu(~29$NQssg7g_Q1uBY03BNQr@m796T?8R z9c}#*q26%K5P{*-pL%Ee-2n+ekU)?pMO=kI1Qj5vKkv;ma40Tc z>RFg720&bVurH7Zc|Ex#0Fi&Tu1>VV_uo@A68iQ*kPQrd+Kfk8;Hv*>kWobK)_hTi zy1OYFX`M{PZwwm#wPy|c_#LW*z#P7nZ~uT@QUDzTU|yl<@gL|rFw6}8MNL{+@PGF1 zs-QYZDnNd2fF&H{m;Za#jK-D%0p_@&lm=xarp=USvu=%J4hnYU;`0b5*|uY+55;a+ z-92+;{_7qGG>w^}Sps5xz*1=D&-5P@Xoeu|QXq{2@WS^?1HhD#ZiYgyU20SF&a>?Jm0zo@mG*4NV^0mrUC`uOsW+B6?{)& zGDA`UztC8E@7m?Rg2!8RPXmep+BS6tSe1+{N)Tf+Bqq=B7Zl~+&qIX20|)MH>FS=DGbLtj zZAi-b+8gh`fMT$>kv?M3tzV4KEW&`-M|dXZwYf1zkxkfN{DLxAw@TwN2Q4Sfw2;FH ziQPKydrw*ZA-{$UQey|K4mP~Kq1H|K@{;(reOI1Mf`3h~vJaeDXGlml{Nk>@?7$sxEXY;&FxQPJtg19`hnzH>w1vMt9JhP z%`Y?#&{522{^m~$yDhapmvD9dBEOz7O^4gakwfTJld@6;XzmsiUzJHS)eH8X@|aNG zV*@nT3jB(c?|+tcZQ`vAPF~EQz_6&cU;Ds<#5fY~p0sv1z^sCqd0~oRNpk%sG1MF4HOr-J<_Li>?se>x$1trk2ER-Rk>3 z!%6Qh3PIkP46&>{X%qRVeB06P@ybmCML?Q>@$`e^)0B-iiG*`jdXnz`7*hzXTb?fx z;H>VlMRU}aDZNuZ#lNPL>H2d>Ge}CPfO!Vzo6r0G#(9?R?Rb5vziTNxU@Z1ywa=%f zNFXxfw7$olm_%=5kdP~dnP;_y0GiF6$SD|HAgth)_JNB%xp#w>s9Bg3@Fr|D2dJ5I zjLX#(I`BIFs!Q=ukY{k*`t3dfC(WMU;d*B?+L8;yx}EJz;d8QtAQXccCcjVE5=q3& z6tGAPm|OVG@=m92gT1ULo0>$jY|o2=joIHO43cm zAr6)1%AdV`x>odFNgL_?lEe*Rd&TL~%5oxtkNdlIooze?Wa771QylQ2w9 z@-FmWn-Ts{o;W_-Q`lw~K56;B2C)auS4IfY_GVZPeb@cUY=RlbHO`@b~O@(~Z$a7+Qff zP3tO+*p;$K-rV4(@T(osZ$RFLVj5>0}R_M~TmmoSdQ^`!Vu-@t=UkE`+#(@Q#4nlwr1~pp6bbaYHH@AOQ$_bFH z*v<0x;rF8HA66DhDj9l3**bun?DKM3uAhzv{1Z2mowybV3#ulEDS@ydya;}b|3@s1 zXR3-E3~)c2f!eVK^nP2e?GOx$h28@k%?$bIw7zbKi|sz1TQhtY>6@*~mRjr>7(wQ08Y|#4|)2CkY>S-OmYk=qaha4$G!#yt@ah@}(aV zH~6yf%jijcrkY%0dzvvZCXV+VUoI=TEOHnY*%qc2SDqH7M+)4p9F zEL%eLmdZbT%ST{3)&qHF1wNnjXg0x7e}XC=hJyI8XNSn&d%Qnm=OHj^r)w&rrI+=R zISu~2*kMVF0)XS?j~2v-%{XjdW4ALK>+akIC$uv*EYsl4wAEN_zI7$K3f8q&ie-;PM=*6Z4}uIt~w z6|DrMWWOpdLvvy6%uI!p$J|QQrE#A=buQd%UY~N zkvI{1CUb0C7vsFb3Ehev*`mgFMOas*F7sDtdm+Tb>ME|otXVPLMQXiMxQ`#D^c|Gv zfp0zQl#qscx$wM%iG#*7x7Hk$-q`G~sbc14H+>Z_TY|~B=3{6Kv8ddR^5PPNr(-B1 zd5RN?Gwz=Kw&t)oFY+}tl7xrx&PQ?kx*AYFZ*b!(sofFswXD89wLtagLiLj!PAKxK z#s)GGFK;!N6UAhdcA@icf&I=mw|;-e5&+)l00`()GOd?($sfwICBw; zMdfJVlIV1`_{bNqMb!pB>G}=uEmHGxG*w^ZA1^h0G!Z7dcBw7`JOYqi9JG3#(zwTR zYfg(3$?94@FZGQA<0${fL4ZTNwl|to`ORakW&cXSGKv;@5hJ=7_kB{B?K7cFLHQF z6zu_^7IP(kMc;q5MslH>PB!KkwtFCFs zxT)`a|3Okr4(Z~qRs^WUYAsFNZWE(-g%SX)>dXsE#BJ?cu`}6|TCkj7w=ZF}f;@T3 zkau28Pr(>uElWp)ZEG*26~tf(wJXVKuX60woOIu z#kHRVA$R&?v@kj_GzS5O3Sb)kXy<)zL~%3yKC2`0ZUpGdWfYfRSl8XJHfS>6dsX`G z)qy>*>s&znXx2c8-P#Fgh1alQ4Lk)1{;dm#4nR6W%Og{o6v983E4abtrJ6^w?7X!A z>KA;_gD6V`DnR!Ab^KK>@A|{WU&5<4Wv*z;_GkCLo+mv3TWqY8 z5AtG!FY`KLyDpKlr0o6DiLA>HWw?OB!KgL1DH^^>-%oY}JWaVHIowotfm@v8 zMso|y&;D@VYt{B3QG*7WIlm-EdoVIE08eS6yR+|j-6~c9QrKQs%Nez$Ki)A)TRV4c zYmHiK4{DI70^(~ZY2#I6ks?HoNvuJjjlh#Y6%mJL2=wj7icKvG=c2tc5bTkTmikzAejQw0QEGIM85G^jsMGposA=sbn^NV_wy zT{i0m1t2(y+ibrB=s;LvfaVY8a)eR-Ry1)LA^X8V*K3YHQR1zi2V*d-Som!PC#0Ys z0{t9ELrY;d$K}WQ>ZBbCmZl`;Dhtv=h($xMK_?=xLfd!jgjvJ$RF!+bF`iv{0y1=i z4ggNaNsm1ixa|dv{rFo~(3eeN|7z&?VYK6fdG(EkQVXv^=Zzgx0EtnB6d8P?E?|p# z?WHUXp0?9Bo*2Kg58Pt!WQ{xMg$7Oar)>~GBIS(VP-9gsXi)%*R{X4_WR?>tMWiKI zTSE$O$o|kR=R3=FSbrygt7$nTJ#%UOq6G{A9Vk<;Htimte(bP@mm01u!^W3hD}=L zcMhE+KptT^mq2l>FR<~Jy$BxHi zBQOM3CUJ(miB{2ii4_RK5d}`#ZOW+6X{gGAN)}W`XUaJdK#+2*nH?w#B%+%RX~)1L zw{VelQu>nzDhJbB%azXBK3u@ul>ALL(X}9;Mya1;;Kd9=;vF}|7uR%J(fywlo7=xY zv1fFT zD6kbrbF29LJsLo;nWI#pL9`etv>^fuD7t`*JH$s$yR7o4shmsH+nhbeI}V9~D=DC( zx^O0_wp>G2u)L|ye9kV=q4cE|=e-TfY&qzYgK9YHSZf}R)T8;AucEUdL23vzfs+AP zEA$_2vH%tuC6LA~hWtlMV9=!C=+h1tfHi*qW#ZiwV21~#G&wu}pNKR}?m8s0m00Ae z(>3~kimSkikuD*HN%DW~$UwD>=yxRll3qJC02*fzT*LBDoDtjsUc;!;{G~gPT`#~R z{wCN1`G4Zqz#t{?~Z`dV}H+e9AJqKb5|o4@SlRgZQQ* zz^MCw7;{`;d>3==e;AvA7}sR0Ui}A}`U|W$1f6s15d*6+%t!>FIZF#cL>aG9%Fc6BGBPv|0P|fa0F~)Ij{egMwmE~(rn_yD5yuAtf&pyH8p(!1_&f>QCk=~+kP;EVb?VUlKLv`F=fJNSy&kG*4r zWfMi;n1evNc`=-pTh7?&()ML3^{Fdx-b9vPTN0vG$vsN%G>cS{dJ z3&-qnt!FpgSOmZ~6j~MIV*K8b6f2(S;vzRf3zoT>Agt$C2dku(Q5T}tEl(|*mWfUqmNR=h7QvwXO|{-{X6I{O5kO6^ZeAWn?9mIQI7#JR=09}oK0)IwcLQT{ zj4LKHDKX0iViA;qdQFO9vxtZ_M)=rUu)_qA#8#|mpy5;Q9XFWW!GjiA6To#Hubh|g zG~_yIw#D0=kn5~&u~p_~WEY#V^gTE&1+<>YPz|@1Vhlswj9ar#aP05^qdHVGKtzA5 z$%2%%Uq{AGg(WDKEw=0gRq#v{%-&RMz+U9B#@_f7)r|HyK4|JS8Ad?2XF` zjP$auQIJTAW-J>>08qDdf75aGtoIv#LSNqr!)_96`9X4+sf#F{26cn+=3<9&QAFBj zYK22FeDQy`if|d1E3NH>p(bBy89ZoGw7KlkA>=1<5vuBHt*j})1tLo5bnUY9{tg2$ zdwLIwY>D*oyKa)BPzVzMOxze3M&FTK4F1t9h(+Yd16`)XsaV4u_H_0vorU$+fWMpI z@r^3O{f9Z-OfVFZ;?(Z>pWg61eS20gM1=udrLK>cm@%PMRIiJ#)^57A23)TNrYG@{ zx8WTn;7JvUrizG32u|7{2=D+zfAHu0`gXrm`vI(^c6LjLY5g84Mv)Jd3Xu8cS{nre z*AQh&$;)9tqc?KC&-is8&El%C_P&mITIT92zntz#pYoT-&WKx!aG$~rtq%zCmq_T}^3I)ybZ zDS3xZK_I?ghN)Wq3Zpx?CtoLz@4V&PB&Wionv=9X1%+XNZ$NGk0K+h!Y9P=R0HeIy zcNF24X2DU$a713{FEd=|FNoU*5y}`PFUtPQpkALu=e7h zTDO>*R6z#z%6e%4MX{$QK%H|-RJfLRti>>JM;E}X z1vk?wiH6bv`1DiosCbok=-)@gXn7W3~ftW$IysDvF&==#~`%Bq3k*ZMv0 zL~iemA#u-X>a{OL%2}dhkJ5PSb3ebMBLE8wiN6&KSH-y_25we19$Eb;I5Q5KTtZ;x z^w$));>o$8lG!XR^cCWc$jOg`Q)_EW#1p+ijXss=h&`2|iNKY^ib zxVwLe?RfSj3mgX{^&m_Ob??EPhuHUMmE=7jCM8u6i=AF84X|%~ssc18q2szIzDVL) zi}|2;^4=3YEO~^)#6rl=)6skk6LR97=nh#wN%a%(C>a6sw=a7gb+QqimZOrj(6vxe zIjR6i65c#~0_9*iSkt>Bt(o0X9!Oo8z$MqWj9xjw!~rpb12TB(A9moK0%JBH@j zP5X-*s#@P$6qMtyzXe0HBDX@|0?ipwdu1_cIbprAz}tt0NN9!N?4VUlO8p*7?;4n4 zq8`r9S3ho22B>etc2(Q1*E(`-H1U@qSzWRdNgCcRst6;Ht?QwJVy%t)e&Jmzr+B)t z@6|BuE&l2hhQrOGf^My#hDxPz-QOsHDfsFsuYi9$)bX%65|7Vu-Un%m%REmSh_9y> zdO#Vj93mWgTr|K+Z_3@rBQ)xeg@(;kKK6NhTf+-Wc7TMNj>16F>PL?F^#&UEyuK7_ za6}Y@jV%&AcLgzaM|}ni6t|8)H!+n_V9HC%+rF)BGJd|{^_Z{-`aBW$mEn(>8TCbQ zyt$`6D#votd}Yr%J|n2P`<6Xf3;iq4c)&*<-q$^QX#@T`-{ZjO=C!ayYy|_<+yrce z#43-xvwx`jV>a!rFqbf#|JbnpD>Woi^2Uwn*GWsfGo>`%Qs@OGF0AS@NKD1GJcJ2W zrEw5ghd03QN+$kZqK4>Ni)4v*4?AacDJyWHTe@hI!Oe28u(o}?WIbdaDFc?=Z}M6RZA@IZpe|#CzW11D3aLcgKW`7 zc0k+(wr>84xlGGu<`dqWLJd+-3c@-TiBCMd$nW`ve?Y+Q`8jhBNjf7iidTRY%ubT9 z+5N<5sdqBeEy3U#$+F5?LBS3>TNL#kuYEzwO`Eoz>1HoSV5>BVd0vy27GC*zm$#hr z^93>6duPnf|DNEPU_))g9v>V<8S$&M#OG zuTOCi&lmGl5TdK`0B3;pReAn`k;}bW&*)Y(n8RkjdRK;hk#Gs4X-H8y>f>Cjw^WQk zKO*oi>9GE4Yj{6af+_CT3|jyX6k=>dkHGc-L~WKG8OF6~m%=sYcc6Cb3=w9%(^sI# z3^OmJNNWL!p_EEplc7LOjtq4pxRbVa8A0CAy)X90--UO0?{?Rz)p|gN5-TWk^FMo!U~4@6wi_G0!`? zD9bA)#D|yp?MKW6%)Zp-eHlGpZ^`VIl1|B=On+V?$7k2uNMBUCFapaK;2BiwWA?bD zY9=}f<08+A;9sbDkGKdz$DZ$gkw%pdQBcGbOL-ZkPv3>29?4{TCS)g(v`~v`D2(#i zjhJ!x24SHCe2pu@FcM_xx=Gh%Lrekm!;N^dp6ry{Brb2qeW}4UbgTo+cXsvsl-tB{E zN+OW$i6OO?wJ;cWVV!$$^d{XvZ^5C-*$jb+X2NoQQV<_nyLK~r{LS@V0SVa!9tecq zoXF))VeGp*9C|>MBK1Jh}2i&Rtf3AHsBM2FJH4U}E-;r+u9Rz37TRicg)+ zX#veSk-@(D;}(n7g&mL*5p6VJ1V|hvvz-U3pX@dlC7j6r-pcyvDq#wcKyi#`1lcAR3XDSDgo#C#zS~;=pu(F7TFK6jf~Z%{-}+aOXuxIE*RQWdS{AOu>bpfUizP-maoQ29O&w)aBoovj}eeO6xV#? zFjd$bLOl;ueD_E{$f3I=6tlvu6|XO|jkn5_cU#lpD-5&A+HQy+f z@73R0b)Diebti@i1Bc<9D}@_Pf#m+iJ|9dzG$Aqbgu3Gv%&?)btGw!s4ou1~=s#-@ z-6=p~URV>hMprT*0zT(!%V`>IxEj65grRxqD-_>T|Bs6BKzKCeRx(}mE7uz9p_kkK zFV}U42MM(YMjRY)0h;$I{}n0#Fjd9u-!@Jy#F+2h7 zaRk;d*oXXvas`0eHIqqUd>8UBeZP$G&^VI`uLeuHK{R_Qb^n(xcfG*!y_zXi0n~~6 z%3S{u7if5pYhhrqIS@^QNkY?%yuJF@LsN?7H^=ofkcMjVr)bpwPWmxuCTQ{1{^n@X zY!l!nr|!zyg5+``9QwD7D5Aty=BE=ZI}YP_Qu_Fo_4C8SzlA6OqK36%wMEjI3Y@eL z74vETmK3y7n4I08G9*SqKr!xdz@h@$$o?N+v*iXm9d4_4n{9qNNtyJu3V0#2xBauz@5F5bF|POBpY%Vs z(KKMa$(hX9HV+hg6;kA+-t^a(I%VrUalEi6S190fuL9~)X!XAa)Io7GZTBcN0PuEO z@!51FN!b0MzbqH_4dQ0%9KKgV-hF+>2E5({*;uQeqwV@1>*c3}lVJzQ?_^x&1TSSrYQRB0qV!0O1M#mO2Y^Yc;+)xmmrQ{>pO-#fjMa6p zWE;HD_ZQ7~>|3+6c(->lg^GadrO3IQcHGI|Ur-egu)gQWuTY-yKY!Ni=YOCnwH^|L z`EL{78akal@9Z}ZJotFZyU2*g#DCqiWWqxTm+RY_E)k`If16;E?I^18pKASmT zl42`8`G~-g?l=BcekoK~37qS=Zvp&y@DBt2bjLPQ>|N4MA?gn(6Gc)B zyIG*wT=7efp4=@DMm^&y`@DQ za-PkBt(~&{vz76D(=msIcgXY_y;^80JwRh~DjLY!D5j*78iS0XKavi^x}79evGr11 zTrNB83mKr!a?_67q+zO4P3zcji6hQ5CHS4>IlO?7HKFEqWm=^Qb+V@LL9rY-%% zB;jJUNx&Z&y#4sneOm9+=RT*V$q}t?2B=8bfL9>h)Y&H2eO0g#36HZx%Ef%*QF)v7 zjl+-8x_H%Ceg*H^e&4EO?)SKG(ot7wf-9$y|H9+*{-C5Vpdl{V0>T^G z;*k^yzhMIx2~q@7oc8`4+cx1X-uEyr_M|C39l|U8)<<6WJ7I?c#|cr$HKFbfTuA=N`IrF^BYy>qzY409TW}tIvq57GweGVbq_9O znkU9-O8>cGuW|2K=K(~0o`n9Koe-i80Ms-W{}*5pEsCEi(;*54`pj$ml4&nRYuE0i z^G8r_Z^pFEYXnAYyca#o=L9tFLX_sNIELI=Px@j3lgmiE#tJ-nl_2-)Gt{<4{}c16 zepkvWSs+oErUq885xA`W%KF-`62NVBAmUNvk({?P*oR%yfu&{@_XBwkf#*L;JrEO| zLv2J>JsErQxnsN+L8D+@vEN44MfZiZ$Ty)<@PMX}w+uf=_M~=7Ga@#Gf>gToy;haO zna+bivkQP&TAoUcbmQ8{)0lbg!`(&6v!I?uy-3VMwR&s5wTh-dg&c`#E@7}3x>fSf z#5CM)IoJqeBPjGL1-!(!2tz*{YM-tuCAku~-hIx#$6HbOpAWgY7J8jO2#d0Xhbvhm z=~vTj6@a?5GqT_2CvWr^C*wNLNn8blcukLMdUwHP45;;=wxL;kPJBFjKKF7;`%|Ko zCPS>uL@_BG1(bGuE$2=-U%g5%w4oAbXQ7wm!5g?FEe3<|aG=kU&~A(F4zNfw$alhD zeHJMw;0c8@zi^LaFZF^UuZ1HV{1T}eIM>4maTuX z=#)>4Knv=v|03x&Y& zcS!!@faMMN)@*s7tP{ZtQf=x%@29H?#H@8-)TcfIh2rrfj0m{j*Cb5K_Y@nc%Mo(qIsgf=F6lyJ z9)3mpc8(E-RR<9Y>{@AF(*LaVS#R?^QRSpbkBnj0TiR@25fFpJKRj1OL86T?!->m{ zxtE~>uYkyeG4lH~{!{h&Q!Ro&=H)|W>Qp~Ap@7dMalAYtvc$;PY-X;oRgnf(Kw{{w z1;Zf9$jyIJD&uoyjD_iPUS6>h_L@8`tJWV?dI^Sqis{Pu^WOq;tPM!%dmU5*1q=J4LV zehuqP2}0+amwvOFM4xP(T6tO81X9bMo*> zt&X3>el9viHd>nvS-lz;hjgVdxW`#a+tbJzeBRdDBms|awEXa8>@LSTuR>f+{=W{+ zJRYjytrORqy^6Oo!i+T}9V_!oi$efk=u2 z_wooQr(#FyBmw`m&&}xkgEQ!a4cpiCg?#cM^ZK9YlCaVy>P3vc^|AqV z=IL}UGUg@k2K2+=tEYwVGvXq$YZQ@B`uVp}0$BFYp(xYa%4HJ&B>7ZS#yg6eAECsm zuX~9=!y%3rTy8z&Wri;BO&6Msie;!MT)TS&eA1Jg1CXKdwGLVpkbO(C4Tc!-O9mln?-Esye-ZCxkW0SZ%&2W%6h?_Pp;_-0WKLQ#Ce>S(iooJYZ`(g z5`?Iwn9q!nR2hbs)=j?->)lLxnazY7&k<;?lg{aqb(%Q3#42XLVqc?(u>peY@wG#f z)7S~q9({OfvWC*#_p4nXUur+l8i16%5080d29`aq5hUMK2e7n>-MY4|_b(XfQfo-v z9bL^!uZch;^rn^_@wrH_piNeXx-P^ND3&1Hf^q`+MQ;{=B(~t6)Jrhp>?%7+ifSRp>>DiH-VvLx4ZLE=Zj*l-cmMV?J=F~Mz zWi(K;+27{uHk~IYJ#|cjUkX5Q&gYYb`ub;}cb}6QN@9!FOq0z(X0T}m-uAZ%a zc~xqz&FJvSVK(T2j|_Repw>B}B>n+c>3||OWSQI!OL8qQw=sawnSlP(EkX`6zKv3l zLo^X4DyDacE{p}3rP=j?B$V}J`{k4ZoMUA3(FqYyYB>2wR*KS_AyyQZMonf5WyWvc zuU&Id!U*xZ5>6M8!IMx#IK+?DlIy?5eW=<-nT-CYh9&T=(PQRg(&>Y4;?UYU1HJ*2 zfz@wAG#NkhU_#GH%k~WMVjU7W34_?YWkzEjwd2~)@LFwMS@hLa$>y|W75@91|0 zKE9e@VH+`_S|g<7ll{UZ`yn3|$JmRR5PuEtbe@{0$+!&W(QG~|E?fA2Zz94z&z^*K z=a_gLIeTN>L1k1>j{pPx+6-!? z0Q4T>{>U3F(qN$v(i!PhhI0oH#4N2=)%&G0aq`RQi^*(ein(Ki-?hgtDH7o;=c7fm zL?AZrnEFMVVF|6;LG*igOmDbQXvgCGjEP;qy0_`hBT(uA*OQ;Jm^^B#4DIMM^r)p( z;O%st86oNE4$^UeK0n0fkieo2>*l$%%B_v@Rn`=KJ+{YW%D8Z}W#Hm(3DIM4ti5dN zk37r%Nziv)CJIr8=Q_$c96pFN`V*AB!L)HipG$fZ0LdL(FjRCt`3cgy+J!$$ ziJzye8T|XbV}a?lyV{)`eHr$GAeC6u49_tAHQS}Rt$X{0Q)fz`HnJxaBIz95-AA}& zv?jpq&sa9F=PY!l0`~-Z3f!X_Lj@-{0~egm7#wa2M(bDA7gg8Zg9j=BNC|GG#3*g1 zk8t*z_Kq^nOBO)hakU}x9e5bK%~-v?J5H7&GEs)Pp~gN-WI; zJxYF7uRU?`5777Ll&*0+9nfVWMu(YBTE9!k_7;~5e-g_yYwH1tM`pm8;*T! z!}w$4R>m-AUWpLJzN#?0!$uJYh@m^p0ajau0ZPvwkug(*SZc|}OudI)utS9h0y|Rm z_p(nlo7Ibt|A+8qiFuB${%Kj+i%B^iqULZW8I0S$_w*OE(I9X36M}BGkA67-Msn?f zZMs({Rh3_b{*JEi&lLmjG3P%Noj!V%EStoN)_-+Q&{Y}ezE-?6QZb(wl5((a%?~pA z!rcrg&GMUX!NI_I_jW#dD%N7l+)4mzyE*;E;Zfbw`u>Tjt+3?HV82A*8t+z=Oy`kW zsqv!;z3eK+a(np|NNv>ZI1_Y=G6qW9-TXqowhu(&e}{v`Gc2EuNa{qXktlW-GSShXQ~Ic)BFsO-_`c+#OubmGloor4*rR(bj+<2fvA*y6}s>_ zlG7>ur=mQw=3+ZNcY>1Sn*;9aibQoJge1ltxMGMwH z0gkVCwD)aj!|O85_R(sH(UkVyDTrBTiK=X^6x4zdmfApjBn#A+PQANe1)s^I{eoD8 zhY6VY!4lPl!@2F&3{2>bb9*@mrNg>tT&Ox;yFeerh@F4- z&G`s~SS6YJro&K<3Q2TE#i)SvCubuI%B*kibgDJ=XUH&vut_AI1V`Cpc&Ii4kr&x%PL&M= z^qjF|iCuH7%+EM}k!ON#U>9EbmuAUBvmll)X%!A9cUy$Fnxs;I2WrzkTUA+*Xweuz zP8-&e!wD|hnr*YDV?0--joJPF%jQ=>f*el3WA5l(vU4AqR3%)fjZ(TVho)7qxf<#1 z8@so${*qu9yYA&=c$<}!9g^-L3)&=-#o_VU3`;KQ*idru15g?1ABp_hA%PHEBUiH> zqs5tFn5T~~bs(5}Et%V08iWp`+G&vV7Nr(zw2sJ|geXHp<%lTIUz5tiCAHHRzsUGe z|Il^7uD*rAGf)QdaIPETgCJ0DMSV%5b5nUR4aY9C`1hwA$al|3#u{c1sl$prsDDAZ z52vv%AKP0PEAf=sy)erB9(NJU));`BC(y7@ip(>BDnql9o=o04iEo}H)OOEB@9ldy z^TF0mB$i5oEWHvOe<5n-+aTfFpu>(nS>2jC>^q;n$NiScotB7;+WvB5NzfRSF4jiy zl5O7k__5NN@Kwqt1P9xD@Wj&to1;CLCk1I;q2WMM-CCHOfM z>%A)Gh9Hfxq#T#eJ$5LD`yYCDIi+|u9#e8#6wwE2_q6!bKPQ2k({bH-R7}jVJ4~7J zLKnNvryu8I zA_1BVzU>5jKY(u(-nuq-0iZg2`a^)Hxhw!s8sE~;FfnoS^7Fdu=H-pi)6l?p`+DJS zdAI-&Fp+2OYGMAHNqKK(N8KP6N;mK_JBP-YsmDW%Dd&ZGDCqQKc?!qRnYPhuX`$G= z3t~wgKZX*{nTk-yQI3)S^6_kQ)Cyc<jS$cra$0f>NaRA_*|l7pAv zh#}H0t7qFGpoa*&Uy7Uo8e#<42anZD09G3T(uLmvTw1Bk)V8j`Gk_Tg=Ktj(7rwgj;0e*mv>McN1 z0}0dEC?S9f0uJ4Ld;#ELHel4=vQqkUv4U<<7}iwwTct*Rd9CnkXi*<>D=RTB!2vzy zi;OZikvE@W#o7b2S*5}+(Gs@%04PW~2ak69IAGvR#lV2fGpS|D@Dl2W_+;;GqmX`u!E2QM7ugl-D#)6{LeApK>d#V{Wmv-cEuOW@ zLPAz@C2KL8Hrwl}wQ!wq@VS)Flo|RN=QG%a#VyLpOUh@;sjhBc?X^fMyI~$up?y`_ ze7=n0`;%8!%kxZD%-PLd%M?pv%3VvnEL4j^ZH^z2*?ctoXsl6WT2O62-`t6za$>Cs z5aGE^^D6pY_~lRMtNX`;?+Z6&9dkCtE0>oQ%4+0t_H+G=C>8}@ zW^RYZ%`k2V{Saa=xn$TV>0)=g{7L2$>sPw3Oke4&e2}SFdzn}AL95B;F#L>Km;MF2 z3p%bz1EhJ2=HuqmFDYL#T~Suptq?1}UCdtCr0L&!=jD1psGD889-Gjr}?O5<0h7sJ|# z+O!F`I_>89=RG?;J71h9JI{08;L7rq^`~Z68cT#q6b%&(yZZb3pZAYsU(QjM9FiOimLse&!XY zEobQ6T3<#5ONE)I1G(#}AsstUev7+W57G^eNzd1v?G^fTE6+UVn~p29YvbL>?b!H7 z1`)ktnJQvA?48ryAAail?fP7wdoZ+{ms69|zE(O}xh}JQYdvP_S>L5+Lisk*cfWk> z)*zTJd{w1-mLYL__I0SShH<#-yG1^6C-D@kH=|Yc#s1}cH#eA7{;K#sn^&1vlAV#7 zv9a%y#n*S&_Zoln%D0c2)d=GwIujodzk-*L0K%2zQMg+8HRR#`gJ7Q4$EpgGE`Q&S zuc;*zG^(9bONw|CvC#I0$E|xc`3=wBV%9*)z^yY;CZS6sU&wF%x*7j5l=?zcOq5l$ z>SOO%YqqbFnV0>)PHEho)jZIAqB)b}DrqcVDXM>2SfyOdU1>u0{iXM+onknfzG3S; zYdsGG4tX)*3Yj||Ewe{!+c7`#eo)N^F=vT)U-u3y9BAK(ddXBK#vr2b1Zx;;B$vLa zMkid#lpwHm)?eT;WnepJyDP8wSDCM|V;XkCTt-{E!dD)ouBJ3!4&?8UE*4p6e0ZTO zx!I7$a5_gJpXup+jHi^c!UxI7)Rbf&^>UsoX?3p4uD@{idtbXmy*=r;6JIaRl|kNf zyC(THsf{RQoqEZVg{|PIF!@_o7b{$A|D(_HPhRC;>|g7v`8=5{o4fIhxW>@VFqc|a z9Bp7F%4z-U!)tf>w@N;Nla%x4NH0cPlD$@Hh^rI!95#NnW|dhH`r-V8E4RaMw0(|f zdxyBaLUomLAu42LYIN(Ru7D=N`pXFitXOo(mfjoC;WAkMa84nmv_X zTlVTxqw^qc2>1KfjA;I~Gf&Dhe`TJzLo+^6OEYU(ySDpRYf?P3UvRQ1vQDG%MXORn zv&+k#fqq3*iz161ivtUp^`Yr^H%IsHd|hV`YW{m^b$Iex^WL#aPs`+0#MLXqxM7z= zy~CP4)rv>uzxSUUdR~8HPjmxY>RO zyQVg-FkdhKLtY-1|S9q(VT{dG!bk1Q&3@FZn7iB^L&DLbh*nkITBUrt3{{)ejX z@$aLT8iUS*=ku9Qo}ctqFf)mV?hmhw4t&k}T9~<&nbztVTztHYUtz7B>}>DkYUv4( zmb|fJdAQc*KjqK4@nV+N_-8OCOpxgIuX@+~%ge(w*0eMtiX?0M$Vpms681MSPP%4S z0C*q(02B$pKO%hp4ZuAK0G1s9P{;y+*(=q)LmL3G5YiaLw~+4K7q0B>HA57 z`Ln;LGF#8|r^G|=Xoq*`jihALt)+)qP%0%p#}^v|dw5C8=*&dIZ+2b1DRm0&T{ zV2~rkfdLQz4HP@Y*&){kXAI(s&obR3o2xt+j77k%H^2)r-h;%KSBr~O#n%b>L3 zLNx+ALrCytp|6F1tSLQ0Ysc`vrr7$L!=b*JVPc;U!|3bV^!wjbM7c5Glt>uz#Piq~ zxA&(86|Z1GxPhhvSOA`;jfS6)BUM%@+PMGTs^K~1hovjor4Dm1+L^m_TRP~qG%5)V zU>Ya5e}`(^1T(IT8j}ACCf^;Ed72-Rb|93iq-v7R*ZPCg@tK`DP-QC%VadOj$GI<8 zLjB+9)VMKftS~l~!kS|Eq#d8N!c_Fwy4ocm;A z@Hs6+_`bvBxBr#kS@#M=e39}aX6&_RlNBNIUX>BM ziNi_b4#6`Q(wR}4aGKL`X}+jhi*AW~4&*+^*pgYEj)9O@p1+pKNQ!l0`uiL8p9&jz zYS!MLRBAuvCa{g*{^oGX>&Kol(GL&9Of`m+<^5ipyeZ&Li zaN2j&ujv6`k=~rm8E2=Z9V85XsQyF-P@!C0ngBrf!H<#fywU9k2GAj(!GGd$z$iI; zJS=BC@{g7Z5NE9W5H=q+_E)`-sFQ-{ks}3NNMfJd068HqkyB0ujU@wTOtZ&{)oUp& zVI~});$MuX*W8gk?r;6&W=EE8fVEA>-FGcIK=9T`)!2Ik4NG7^vRtF5EKLSHMk%Ec z-l`J8p(p{!ucvPnW=p($pPHnolUTc^@{HZ-1qN$of&1&uk-2rE^yiEcr&497>w84I zxr9SZ;IDgmkB_-5TdD3bXb4h8>p|Aeb<>0{46{?6<@)fO2$1DzoS1wy?#~o!R~SNF z;&Kf$**m(yp_ZA-LSA6mI+^-_@=x=3CvqUiU%y5ZN{#cW@K?Tc(VPLgUiawsi+Ls5 z&hs~cRiAEG^aI?5W!o#@eqUL=Nw1M~^i^iw~4fO ziV+KH;64N4iiN*{gny?V+gk+aJ18c9ah$}ua3+#?OzPPd4^|ue$11iyEF>rv9lGBl zg{ZQnK*&AATWpwx;INxPo96+S0O3n&-*-9OJ{NhBE z4q{ByKJaY6)3I+SL)-YFFd(Q?l4TL8#ayb!UGBL|ZVJPK-}kcmng3jKh{(4@`cVeI z+s9()G2?AHCt6^^SxY;Jq5!S+at*`K{R19V0 z#8PDP^Qq$VNIMQx&s`ltYe3bG@2B$RMjEYiz?flk$Y;_>5B2LILN~E*$a-T#VRK{C z?EWzD6nYB+QlS{G&R7dgC5zGdc&z8WlS6v3om7)|3)jkgJM8YPK!EjLD|ex1F+aG$Pq+#BX4$TK(M-|H;1z8M zwbOUr)} zlnZQ5e?_7A$Ppf8Tb>0E&~ZX1*FFhkK3ExIi?t z(ITVWnoZ6bN!dhCT8H7D7qjs`6{ z3J^hjq;}!##nIGkHslv4oZdf`M`l&cydECf@IbttvL7qae2I#zdjxWyjSEwi@S1P8 z@u5-n$ir+H0bCQWl2D%FS7yZFP_ITtAnK*Ds<6mr9d4X#} zc(QQ*X#0&T#3v%dG`3tL$MjHdME0`px3qxqtG|Nu9B@FCy?M0r#PQ(v=;wuNa<^Zh zu?eru7YbHya3sZNAW*a?#%FMq*_$4Y>s23k!CDrT>zdNe^+9HG9t54t{;a%^({ORd zg_oX82)DtrA^XBg8&p^%{`vz6{TG&^^H$%cW21ndu~=@DaF&Q^r$n&BRzuIiu#J;t zUf>nZ`s*l+CD=}XB+M!sP8iH+$tRy8UtD8GG;j>R2sd|~f7x}2RB}Bo;FEfpX!WOQ zXYupDjtC49SSzbeI6)OZ|Hm=D(aq)wVnPz>%?Zz!yubAmE?IIhil|#Z zs;g(@MHcP+p4?+D;^YIPFB%vLm$laZATQscGNevQtW~c~y;98!j$KL42@KzMD9^G( zB&HGU*~-FDcygGSt;B%Tfch%LxZ4o-qq;T5!IKTKbf%*iy0AF^rv9vDl$tK3YvR|S zO>wQ>OB#R<;&XoPq}kKZJ44k_;Uu0&Q00&7Iw?U+${@dpW;itMwwO=npuClmzjL*z zj$sZMYd!hE^os_+t2_vibLv4#Yy0+Oda7mpCwwn=>7ex<(yr)Cuwa{2X-qjt@vKi% zPw}0TwvIMHN?f4&LY4i`_HiDAww^rwQI7wecT!;#GXwOHpOlSwcqfH813o6w%>fnG zRa3%%zyh%%{+tdLPY+7JE)tsf@z&z%6mo>_ZIM748C<~lS$qlhLLW!*lP&Cm%FAGS z2>Kb}s7Pzk67|ofJdggyF8kRP{k{|P3D~L>3Dw(F`Ao*67|BpP5v@DmU#dxy4v1#G z_T6mwD{3?zrFoESEtsD`ND3(S_ajHJM8}B$28UJhWM}Pz*zwkmmOr!zht!mIDXUwn zvh&^$-qe!|9G*; z(eR;F)gO=DP}odEkE2JSt5=Ud6Vkh$nVP6>F(0qHy<6>7nrneT!pEtxBZVUGG6Q^C z8>WwA%pn}QIg1810BPdNc;;=3bo<@bpJ&+AcHot=@(c}-1#+x6SLY7-`jjZNKRmA$ zT8t742MGy7H?kP-7g=?hfjZIsDFG95`SvK-;Tl2+MX{D1^gyiB&kyl0au19<4u18W z&;t}!fl9)-s4t_YO@x%+V!UjMEaQ<*ew0{AbW0xU zfNmtFE7F|B08!X;MRC_&5^uVW2C)=dslKj47$j|bM$QoD7Xg?AKzIxExTr5wGz)Ep z-|=BJpVPm7-jZ3LGhtj6S@1cR;J0q9-Fg9iLTR1Ytn?{F9+n zmnUO##qaGznoNUvZL?b`$;Y4ddE}XNnLYJsm93%iA=4cH?wQ`AsI>CH!!jey{x?^@ z;6rnHe~OV9{qxb|s;uBGSk07=*$Ras)_u5!rHNl#H~nMFs!JZhI?0bT$u5A(*2xKX zATptg1BWXGew2JKVdh5N^oyoK4{N}=X${qTol$3~9bt{rY30|Tdc)2uW%n&{*-JCO zsj?XK5x!TDx*iVXUN|GA70P>i?q(q0(?9j}ZKDF92=9O1`>Ejq3r6S$0J@})&bm;t zLPKMm6ad`%BQAZdNDLUv2lK-%J3jZdaBq`#-awY3PXI{9L?s4`c=u6bEeB8TP9Ol_ z@1Ce*N9xSAY9GxJJ%a^c5<>#7F8qj3mjc-oVLb4=14iZ^lY{h*z0wF5#M;IU0A7my z_uCRlDbVQin-+czWg-Nz@B0=={smL4ibR;~{i7EHs3UrL68tLMo*)r z2LSrx^fQ?i;D`VKUK@x<8uXpOT{ImRO#Zi2-3BU@`k&H=j=$*sx6~1C`@f|Vrh$XN zxijySgy*>R;4FGckwk)Tp9BEAOX2x}{XpW796rT~QEeJ0u|h(pLKDg#`7KD2Y&uTD zxe@@pS#M9SlX|BivKWqF?iJ@Noyyq}``HRGjW3C1AtDGyTwPHM+*0Aic6|6j_y3Sw zdNLqByrMt?pA;_cqTr@N>?`|@1r^djdKo@V6x@|s+1GU#Wd)B<$F%ctLP3dP%!SNB ze-oBxDRJt70z-f(E$;96t-f0qbl8*Bk1=u6lJ~D8L!?Q6ssC9E?+iK$yj+1%=`<_| zmE(;A_?tIS%Qes0&ZG`kYY7b8<8{9-iV(c~RA=SS_M?Hua{)1=!@%2bl`V(6G&<5l zb6H37mwwhoq1W4jEFOB7lMzs);T66}%9x(0A5&C`e^(asWgetQw2{KwXz3uCvD#&h zg&Tr1*OjxE9VxIvE;}iI0~w>T_afYZTo0d9)Fpk%UtQIt80oxsv80G!{FE!iK?@Qn zn7gr<&F)in+4aV`kfAmz2b?od>c0cNlTbU6+CU{|H%*;3hsNmU!Xp$Yse-_d!m2Wb zmux>*miqhhJ3oV;B{en=$BBUm2OJ5O3EmT4XPMvNyFH4=Ta-$LzEsYCxElskt7x&$ zXw^5Q^hGT7!o7C}b*?X1Xoy##=#r+a-)=*nQ}AtGR-z(bY`O`~)aT)E397Z$gDU<1y^E7(IykDrO6g^6Pi1YrYmDgjRlfFk*a=9b{AKQ zx8I)7db5T}MK=(tAL}`#)h7;mk`^!uZPL~@YB!$^h&eY52r?v_kKNrw5MnjJ)1>i` z`=V#(7(1{piW*MoUJ!%!1I>`%yOkt90sdj%>)Bi~%Ut z<*E5s*_iT}W`zh&4o%c9U;1JCQ<<%6Sq8Zo*`0KVRlNJ{mK7}Lp~^$B|1|e6!>hET zrEn+4_1oR0(K9^C963`Q`QpYb87`2KG%%-*r^XtLl=xgC!%N?0C$M2T!*JH_vFLDct6Tr7wJYGFyF2%wO$m-ZjyOP~JH|rFizsdX#NZX37aiL^=@q9THqRdQ0N^)hOW^EA z?#J6KTt<2usJ-WNszRb;A}sj8iR0*-J!LY4utz%B@SCz$v9zEuutU|KVMV(H#RZ6IVc2>9t^?cKfC|3#~W<-lQ_WO`a!kYKx8GE7OuoUF$!fz&tMJ*A=0 zDb|Ch?qDe@(N_X7PK^yUoH^f~(JWPw_Mzyx`Lkj`XV{G_t&*AZk~e(lno2eCSP|g> ztD`CDoJDxAwPh_mh@yh5iC%+aU4C%PXDlS)1*GCM{-&N9A9sIVj0Bs9__XdMf0a&= zBzbawMd>y!ZTjjKbadq$c)0F_h6V$kaF#bcT3QWL%V}&#Et3VfG&IC?v4i4yIq=R- zXdQ25eJKOX0EJspy9%U@ET}>xXBWZ6aSO;~c<<>3mtxn-Spo7Tyq5`!whCrtSo&;$OC*#!AJ;|WK5nf8>}^Cp71pg@fTXzKi$*m=~wI-8LG z#I;lrx@lvAO2{0=Az+)_$6NW@5J2IAf=FVukq`;=b*KP=Cuh~7yFda6AJj;SP-oAd zH_USZcW(7%$pPSG%pkeJ$qKPBG9rM)U+lHONeZmWR$n=98yA9bVEUbzzzeHtWsz~j>y!*3%5&vy!7y*V$&0nD@w ze}jM%1*ll?iUSUe`=N36OqAk=YR?5U76Wug74S}4Ezs^2VX_J zK)wUMCo_&GW`8g}13&(Tfer8lTU-GCV@!AYggyQH|16{fasLk@Xg+W|4(5gtf&-T@ zqX!<=NcgTx_aF09F(8Wu>6&`_XBWx|!ui@2twL8NguVDwCAiKx$vW+w8w_^Dg_+ZO zlaguy3Ce%nVZ>6ea_h%fFsHKtJjRP)L=&Z zUA7C1Shed73+$&lZz)DknF%*PY-fO2dqKY14Cv%}i6&6W2pa%2e&Mws60n4mY7uN_ zEJYBgD!J1UA>4Y6z{q!UJ6&yhP{giD;O(4Id}qc0U7Lu!MhUk-0IT_5ID3E>hi4hE z1QE;-0bKuF^q1$~$H6F!RiBSzk{iZS;LAC92>2ojwv@8wZX;lM$u0Iv0RkFVJzG*E z@|P4_C`G|*WqBUpv9KIc;OlF2sUo0FXGF}q6j6gcTc=t8z6QR0f-vbu{;Z9U4=h20 zRCoDQRR0|PiLI0xJe%;L%#{cWaRt%nqV@s7-!^u(nDi>) zeu9z^2s737=3&)>mjUPGiGl#2#O*!4@B3lg6yA|on=0R&%J8#?8dC2_MKmO|!j=W@ z9S%9QK(z$yC-{A$6tUxCH-)g!u07xrfD&7VX=>SP5)(|%5Y@R~=`IVbRtNMOkvcsARks$VsW zvZp*CfwID#H7?gO$wWQ+`aBVMBzX0uAuv>+;9>hiH?_+R@i$kTe#p>QE^)i5zF7`t$3!2rPA zn6))0eIuyZ`EghPStXU2KA3+0dt57uO&NSzxyFImkv*KX69&RIV4cJ*Mm>)Q0sb6( zk8t;9ZZaAfmq(a1(8W??gXY}!>d)mF}8yrNS6A@fr- zo*Svkf1woKNfkMy=Gj~!v98wY!?PE_M31f5CYS5pW?&D<%0AX@a_>_wIt#F}NPN~0 z@(k;HbpvqkiG49h7G@_WxKn(2_)At9Uh(zQ@8d;tVXLOj+S%Xgru-}`P-L-RGiA_F zliWMEE%D3eAlH=kyrGu&o>l-T zD%JNtoG@YL6zlO=y?E}wx(_f|P}^6C4h?+%c)B3IXa$Sny-c3lz=|~Edl#Mfk;oR;8%Muu2{yuF-8MSQE1!hI~gR+&3N{4P7)$r6Esm6|Ne6s849qa@U4k8$S!jC zDdCJt9io(k=wLn5e-CHs|U6SZ1F} zoUeo5%L{Uo3v~ zIdMknJR2@6(vV{a$ZgjZ)qW@dmcJcm`|6Z(9H+e}S=1^u@ts&Qy>Y7+MUBETMfEVI zhUVG{4%~ZN_F%}gEts*q$o|$#9UFA>M_LOKyc**Wk*d5+%fmFT(G0iaZb$8xkS*Wx zjKiO=-w`4EZ93bAE@?3FpeMUA6u0TaHA4gA%^e6L8@x?t9;EBCa$AOTu<) zKQ8Q=j?Si+LTEKJH3I+z%y1g;x)rfnuS+|Im#-v=_obZnU$MRxqeG1DKK*31KfoN_I)_xGflVH1cd05|KjPsLv{T7^ zUb$5cMk2y_?-!#i558^prjf!g4IT`~dG~_e*ao_4hG0pm+L?a0RtRVQcASGR{qKBed;aVT4JZ}v~1 z)R!b4I2r^nQit49@F5z0ESOuJ?Gw=_gGAsLsCzYRi5d>|!H;A$M7PtZ6?`FUh;ZZv zwi^JbxH{ox!-T+BDbOSW>?vV;H?|eJG)}Z;1=20_aEq#(WA5f{RYx+1y~uxN7yt)B zHvSESy`#p9QB~Eh<-<*&@7AL=#7ND4unI@TUwaW z8Ay^;%`^WObLw2GHdX^nyCkrcLncxB7~iD8C-jD&8d8;}tv!~IF4v%-pq1TQZBu`R8FXY@Pc=dTC+BtK(Lx%(u%97Gi_`cT+6}1s+9^WrqC^-$orNCi0&H3(Yob!HCJC4YL+&Y`{ zZbRl|QgCR|t^Y%Hung{(uMnF?zODZ{iWECOv(CWLcSEUUfDWJ>K7pdQ>DfOz-39&~ zY~9M!T31vtMqTuF!L`QARM;qAGpGT;f8op_NWKjKW=&G&}z8SY@5u-IbdT32Un5a>7Q$jL`>J&*fZ~<2nV%K zeMy=9F2U$n0Ebxvy(#C$zVwj#qVZ2I%;{TB?0mbBZ7mZm0>@c6xbxGkdFNA7{jP`n z;zn#XF>a>ozRXD@#lqXs?{T71ij-A8|zbhWtd{(@~k+96aeNk_l*_@NqW#c;+xQ#NwU-qsXA0_WT z5uj7hIS8sy5*&@zIZ43`zXsa&XIA)=ltI6l&vIfB<%HU+bOk;n1kgqbEZ2~;$as+Z z())|a^ZVcXcJBXr0#D4Bv&FyjXa$=dhbnbJnEg}dLrP4IJ+B)T zoLHST*yQ%}Py-XPytKbxxG@w6+HY${oJA(*6SQ-VgD6fS#pmHHS+;yCl&hAyr3H$5 zQm#d`(lo3m0lUG-G_rHQKRa}Y;5Cp4%Kw{oX_jMsrNe4K2lcX#3?)O~Yk&|3l_ofN z!RRq)iX$d`MT4?p2Zn+y(f|ssB0A_KKvJf@gzHFWx-tsRSuPZ%Xr+PJhY9j>-Ys2aSLVvnVVE`2ki=O;v|F(R-x6nm)|j?UUW)&fJG4`&1l7)8H{ zSmV{f`#eFx$Mgbis{Mx=bkM$<&|8#XiP?*38VLBs{4gtAEPgd)vzdA5z>ZATjuUrZ zeorSl2%qRda9cX`N$%okBRzDZ;f2qcPGtiGJWOJIeozF_VKC2mQ5&RE8j7+1X7wer zLP6idq-Ni4$~C5PQqe)7Z^HDPI3LRYPnXS1tH4|fy=PTekPM;g$#5lsA`q z9@0TRf0{>%LvNXg+1<&1OvVfP1L*E}HCA1cgr9pTu_y{_N?M7#I2tq_^gWy$x3m6| zslBNK7?>e6!>0Rt*XfMWb5tn&2f$eJ!Coh&Y&$(H%PRx}bg`pd2~yR*q8;Q*h9L=9 zU{q3-L0ZEoQrQIN?b2yOc;F@sR_Keg3d`)ZbkMvYw~e{twmnMsUkv35ti>;jsdz(= zUxm58xqPtR9gJ{glJJTqdbe>Su*m4KwF)V^D;>S=aiD=7?A8^f9t`&Fb*L0Zsv;p& zQJvbRT5~RCha?iQFP!m4Dd(!ZqXLc83N8B)JqTHk@WW(yKnG^fkG+rd(BNd!mcrjx zy4*qNOLzKt$yrd?XTm0~X5D0^FTIAvDBxs&Vg7*S(zVL)&R4<@yw%_Z!dQb??!Bn5 z2s7s%Z;+fcthEVa_)j#melq70;v9Vj0*&~x5#+RfkR-EWe10DeT?)c@gbVD8!gE7v z2QO%>%P1i7 zLN0Z(muH?}c|$}f;9alcIpvg%|4Y&X!$6!7S#zNPMgXx%cXT*&*37zj`nZQ{TLTO| zr_QaIq=f>LBDqPdpCSob1&65wrA=SQAw?K8P%9|1{tGX@N4_I@_9+jdhXTqZsICZY zU#3;L!T;eKH$pe5@5l9i`66_HP!kV7<~@MXAGFE|^xG(@X!mCVrzzUD27`jTdpyYO zf8$`d9;L|y#M_9)^L2Up1n!mR{ILI!T&Q1K<1d7adf|JK9vU$eeRFT+JF>MsQ|B}* z8qxk^gK9|wCG2$9ToVSw_>bZO%&3lFn*@`#v+WZ@N>gqfiykQmEct_hflC+D-&5SQJL77h~P?`+DxQ#dzbh2&BapUajqI5KT_;aLr7}8h% znF?oqT^I64#9F%sPUGwuHkyjyb-d)JhuLi_`n`qE zD;7ZHlj5&!s_un9P5*XuT8k=J6hM$=8k6*`YFgqaZ^hl_$G-vry&lTBOPSKBV`;q$ z3rVnXR+fC{CyVA{&w5Etwov>rT>2bh^ZeUh5;afv-<*>Rj@+2x_w9U$+u^geYH5<33$%(7D{*3uq z6V{cFUvbB?qfRuMum?Q%t7L>hezg`&2H-I2+xdsj6T zaXHetos2*R(dp_w8gh%)&pLa&b#42dRq+)_@wZ5+@E(k>93kqOCR_v|?Of2l65Gms zQ$dd)p?(XCOPMGl-O-ICR#cc7H5}Zd!q{OLZ`(ud4EWpw^xZ;QWTr_ljskNe0X{avA;+_*Ff4HBYOhlaYn)}rlq?aQr% zs!$-N+oMU9;0hxdAy*mP-x=#ge~KW0RYQ#lqOk;Wcw%*vo1Z{11T=Xu($jyhQiu{o zU|dB&=ivc|?&tYfvS8&m!(3PAPeG&zfK&xY5DP#!H$k23n?&-d1cr^r$+L3jF6K}zUFa48AwGq)z37Usi4F(&9Z1p>TB&aW_ga^jU7 zqRxNN*3Y#};X+yniY+%B_nP5T?o-e$Z`$PjThMDzBr!>}9{f8xdeU`@@5R_!63Cq@ z!KWV@ocqkzPv8v!F67M)QBX56;UFYugiQ3b{+<`d!cxP?+#soBa}G@31M#KG!;{|s z2nbLf*80Ht@QebY+ax2=y}Om=lwUH!88A>B{D%X!@5O(wV@FfPkvwp4c_y>0zmMz(xL`5=!0$2ZPlEPBu@QpM7Ra`neW#k=kk8|hQS{&7bQ%-c?OW&FFy6eJ% ziGxfp2TJm%dk3~o#RUh2rCJ0g9$jIAYR(s1l~X7$^q8*s6*1eKzrqH_FAt&-|0%&i z?yW5c@C{ot}A>03GO3Bs+A|YKceC( z^KixHd)u-2VS4AR3GCBg0O0!MkSWkGec0Nzs5S$GuW??M#s&Y)F8U(*p}O}qqIA6+ zJKgACA!VW+@$Nh3C5o;8I(kES{K5Ygz0No@95BWlWA6loA8i~AHbEx~&tL$!+6fVK 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 045/127] 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 046/127] 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 047/127] 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 048/127] 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 049/127] 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 050/127] 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 051/127] 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 052/127] 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 053/127] 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 054/127] 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 055/127] 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 056/127] 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 057/127] 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 058/127] 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 059/127] 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 060/127] 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 061/127] 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 062/127] 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 063/127] 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 6b7d3432c9357d73b3e6802a495a124eb161a7f6 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:43:32 +0100 Subject: [PATCH 064/127] fix(gui): enable wasm_js feature for getrandom on wasm32 --- Cargo.lock | 3 +++ crates/fosr-gui/Cargo.toml | 1 + 2 files changed, 4 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 8c09ddc7..3dfb0abb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1833,6 +1833,7 @@ dependencies = [ "egui_material_icons", "env_logger", "fosr-lib", + "getrandom 0.4.2", "gloo-timers", "humantime", "indicatif", @@ -1996,11 +1997,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.0", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index 887417ae..2c78e21d 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -48,6 +48,7 @@ rand = "0.10.0" gloo-timers = { version = "0.3", features = ["futures"] } wasm-bindgen-futures = "0.4.58" web-sys = { version = "0.3.85", features = ["Element", "Document", "Window"] } +getrandom = { version = "0.4", features = ["wasm_js"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] env_logger = { version = "0.11.8", default-features = false, features = ["auto-color", "humantime"] } From 43e2ee2593fda02a7ac362936b873ac97142cd83 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:59:00 +0100 Subject: [PATCH 065/127] feat(gui/visualization): style hostname in italic to distinguish from IPs --- .../src/visualization/visualization_shapes.rs | 59 +++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/visualization_shapes.rs index ebb2bd36..8caaebe7 100644 --- a/crates/fosr-gui/src/visualization/visualization_shapes.rs +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -37,14 +37,15 @@ const EDGE_FLOW_SCALE: f32 = 0.2; // Width increase per flow (linear phase) #[derive(Clone)] pub struct NetworkNodeShape { radius: f32, - label: String, + hostname: Option, + ips: Vec, location: Pos2, node_type: NodeType, } impl NetworkNodeShape { /// Compute node style from payload data. - fn style_from_payload(payload: &NodeData) -> (f32, NodeType, String) { + fn style_from_payload(payload: &NodeData) -> (f32, NodeType, Option, Vec) { // 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 { @@ -60,7 +61,8 @@ impl NetworkNodeShape { RADIUS_MIN + ratio * (RADIUS_MAX - RADIUS_MIN) }; - (radius, payload.node_type.clone(), payload.to_string()) + let ips: Vec = payload.ip_addrs.iter().map(|ip| ip.to_string()).collect(); + (radius, payload.node_type.clone(), payload.hostname.clone(), ips) } /// Get the image source for this node type @@ -75,10 +77,11 @@ impl NetworkNodeShape { impl From> for NetworkNodeShape { fn from(props: NodeProps) -> Self { - let (radius, node_type, label) = Self::style_from_payload(&props.payload); + let (radius, node_type, hostname, ips) = Self::style_from_payload(&props.payload); Self { radius, - label, + hostname, + ips, location: props.location(), node_type, } @@ -121,31 +124,51 @@ for NetworkNodeShape shapes.push(Shape::image(texture.id, rect, uv, tint)); } - // Draw text label + // Draw text label: hostname (italic) and IPs (normal) let font_size = 14.0; let font_id = egui::FontId::proportional(font_size); - - let job = egui::text::LayoutJob::simple( - self.label.clone(), - font_id, - Color32::GRAY, - f32::INFINITY, - ); + let mut current_y = pos.y + radius + 2.0; 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)); + // Draw hostname in italic + if let Some(ref hostname) = self.hostname { + let mut job = egui::text::LayoutJob::default(); + job.append(hostname, 0.0, egui::TextFormat { + font_id: font_id.clone(), + color: Color32::GRAY, + italics: true, + ..Default::default() + }); + let galley = f.layout_job(job); + let label_pos = Pos2::new(pos.x - galley.size().x / 2.0, current_y); + shapes.push(Shape::galley(label_pos, galley, Color32::GRAY)); + current_y += font_size + 2.0; + } + + // Draw IPs (normal) + for ip in &self.ips { + let mut job = egui::text::LayoutJob::default(); + job.append(ip, 0.0, egui::TextFormat { + font_id: font_id.clone(), + color: Color32::GRAY, + ..Default::default() + }); + let galley = f.layout_job(job); + let label_pos = Pos2::new(pos.x - galley.size().x / 2.0, current_y); + shapes.push(Shape::galley(label_pos, galley, Color32::GRAY)); + current_y += font_size + 2.0; + } }); shapes } fn update(&mut self, state: &NodeProps) { - let (radius, node_type, label) = Self::style_from_payload(&state.payload); + let (radius, node_type, hostname, ips) = Self::style_from_payload(&state.payload); self.radius = radius; self.node_type = node_type; - self.label = label; + self.hostname = hostname; + self.ips = ips; self.location = state.location(); } From ffed909ecabd3c63388b75746b20ee9d94a36c2e Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:05:23 +0100 Subject: [PATCH 066/127] chore(gui/about): limit logo size and center it --- crates/fosr-gui/src/about_tab.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/fosr-gui/src/about_tab.rs b/crates/fosr-gui/src/about_tab.rs index fb5b2a8d..66972f00 100644 --- a/crates/fosr-gui/src/about_tab.rs +++ b/crates/fosr-gui/src/about_tab.rs @@ -1,7 +1,12 @@ use eframe::egui; pub fn show_about_tab_content(ui: &mut egui::Ui) { - ui.image(egui::include_image!("../../../public/logo.png")); + ui.vertical_centered(|ui| { + ui.add( + egui::Image::new(egui::include_image!("../../../public/logo.png")) + .max_width(450.0), + ); + }); ui.separator(); ui.add_space(10.0); From 74d4e126ac2d06413e99e3cfa9386d7a7870039b Mon Sep 17 00:00:00 2001 From: {} <> Date: Fri, 6 Mar 2026 18:11:32 +0100 Subject: [PATCH 067/127] 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 068/127] 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 069/127] 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 070/127] 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 071/127] 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 072/127] 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 073/127] 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 074/127] 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 075/127] 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 076/127] 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 077/127] 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 078/127] 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 079/127] 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 080/127] 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); + } }); } From bc01f9e56b3abd07ad7f273079bcfe6409bfecbd Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:51:46 +0100 Subject: [PATCH 081/127] feat(gui/config): show config errors in navbar + fix warning color readability --- crates/fosr-gui/src/app.rs | 23 ++++++++++++------- .../src/configuration/configuration_tab.rs | 21 ++++++++++++++++- .../fosr-gui/src/shared/configuration_file.rs | 15 ++++++++---- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index bb9c825f..870b4fbc 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -87,15 +87,22 @@ impl eframe::App for FosrApp { { 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; + let label_text = if self.configuration_file_state.has_errors { + egui::RichText::new("⚠ Configuration").color(egui::Color32::RED) + } else { + egui::RichText::new("Configuration") + }; + if ui + .selectable_label( + self.current_tab == CurrentTab::Configuration, + label_text, + ) + .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::Generation, "Generation") diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 463077c9..066ea64d 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -272,6 +272,10 @@ pub fn show_configuration_tab_content( } else { ui_yaml_editor(ui, file_state); } + + // Update error flag (parse errors + host validation errors) + file_state.has_errors = file_state.parse_error.is_some() + || file_state.config_model.as_ref().is_some_and(has_model_errors); } }); } @@ -315,7 +319,22 @@ fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { edit_optional_string(ui, "Version", &mut model.metadata.version, "0.1.0"); } -/// Several host rendering +/// Returns true if any host in the model has validation errors. +pub fn has_model_errors(model: &Configuration) -> bool { + let mut ip_counts: HashMap = HashMap::new(); + let mut mac_counts: HashMap = HashMap::new(); + for h in &model.hosts { + for iface in &h.interfaces { + *ip_counts.entry(iface.ip_addr.clone()).or_insert(0) += 1; + if let Some(mac) = &iface.mac_addr { + *mac_counts.entry(mac.clone()).or_insert(0) += 1; + } + } + } + model.hosts.iter().any(|host| !validate_host(host, &ip_counts, &mac_counts).is_empty()) +} + +/// Several host rendering. fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { ui.horizontal(|ui| { ui.heading("Hosts"); diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 890068a9..fee5cc22 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -13,6 +13,9 @@ use std::sync::mpsc::{Receiver, channel}; pub const DEFAULT_CONFIG_YAML: &str = include_str!("../default_config.yaml"); +/// Warning color (amber/orange). +const COLOR_WARNING: egui::Color32 = egui::Color32::from_rgb(230, 160, 0); + pub struct ConfigurationFileState { pub picked_config_file: Option, #[cfg(target_arch = "wasm32")] @@ -27,6 +30,9 @@ pub struct ConfigurationFileState { pub config_chosen: bool, pub is_dirty: bool, pub clean_snapshot: Option, + /// Whether the configuration has any errors (parse errors or validation errors). + /// Updated by the configuration tab rendering each frame. + pub has_errors: bool, } impl Default for ConfigurationFileState { @@ -43,6 +49,7 @@ impl Default for ConfigurationFileState { config_chosen: false, is_dirty: false, clean_snapshot: None, + has_errors: false, } } } @@ -199,9 +206,9 @@ pub fn configuration_file_picker( .unwrap_or("Default config selected".to_string()); if configuration_file_state.is_dirty { - ui.colored_label(egui::Color32::YELLOW, egui_material_icons::icons::ICON_WARNING) + ui.colored_label(COLOR_WARNING, 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) + ui.colored_label(COLOR_WARNING, &filename) .on_hover_text(path_text); } else { ui.label(&filename).on_hover_text(path_text); @@ -211,9 +218,9 @@ pub fn configuration_file_picker( #[cfg(target_arch = "wasm32")] { if configuration_file_state.is_dirty { - ui.colored_label(egui::Color32::YELLOW, egui_material_icons::icons::ICON_WARNING) + ui.colored_label(COLOR_WARNING, 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); + ui.colored_label(COLOR_WARNING, &filename); } else { ui.label(&filename); } From 549148a3d2d4e8dc496f8547c490b7a92eabf506 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:23:47 +0100 Subject: [PATCH 082/127] feat(gui/config): replace code/visual selector with labeled toggle button --- .../fosr-gui/src/shared/configuration_file.rs | 40 +++++-------------- crates/fosr-gui/src/shared/ui_utils.rs | 35 ++++++++++++++++ 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index fee5cc22..39bb5fc2 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -3,7 +3,9 @@ use crate::shared::file_io::{read_file_desktop, save_file_desktop, show_file_pic #[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, + configuration::configuration_tab::ConfigurationTabState, + shared::config_model::Configuration, + shared::ui_utils::labeled_toggle, }; use chrono::{DateTime, Local}; use eframe::egui; @@ -228,34 +230,14 @@ pub fn configuration_file_picker( ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); - { - 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: edit the configuration directly as raw YAML.") - .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: 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; - } - } - ui.add_space(8.0); + labeled_toggle( + ui, + &mut tab_state.is_code_mode, + &format!("{} Visual", egui_material_icons::icons::ICON_EDIT), + &format!("{} Code", egui_material_icons::icons::ICON_CODE), + "Visual Mode: edit using the graphical interface.", + "Code Mode: edit as raw YAML.", + ); }); }); } diff --git a/crates/fosr-gui/src/shared/ui_utils.rs b/crates/fosr-gui/src/shared/ui_utils.rs index 902de2e5..9bfdef6e 100644 --- a/crates/fosr-gui/src/shared/ui_utils.rs +++ b/crates/fosr-gui/src/shared/ui_utils.rs @@ -67,6 +67,41 @@ pub fn edit_optional_string( }); } +/// Segmented toggle button between two options. +/// Displays two buttons side by side in a grouped frame. +/// `on` is false → left is selected, true → right is selected. +pub fn labeled_toggle( + ui: &mut egui::Ui, + on: &mut bool, + left_label: &str, + right_label: &str, + tooltip_left: &str, + tooltip_right: &str, +) { + // Use a group frame with tight padding to auto-size around the content + let resp = egui::Frame::group(ui.style()) + .inner_margin(3.0) + .show(ui, |ui| { + // Remove the hover stroke on selectable labels inside this toggle + ui.style_mut().visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE; + ui.spacing_mut().item_spacing = egui::vec2(3.0, 0.0); + ui.horizontal(|ui| { + let left = ui.selectable_label(!*on, left_label); + if left.clicked() { + *on = false; + } + left.on_hover_text(tooltip_left); + + let right = ui.selectable_label(*on, right_label); + if right.clicked() { + *on = true; + } + right.on_hover_text(tooltip_right); + }); + }); + let _ = resp; +} + /// Displays a multiline editor for an `Option`. /// /// - `None` is represented as an empty text box. From 8473615fb26aea77aede6ecbee349c3f87d534e6 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:33:12 +0100 Subject: [PATCH 083/127] feat(gui/visualization): defer auto-start until first user play --- .../src/visualization/visualization_tab.rs | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 328da70e..3ae4a738 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -205,6 +205,9 @@ pub struct VisualizationTabState { pub reset_view_requested: bool, /// Previous screen size (to reset view on window resize) last_screen_size: Option, + /// Whether the user has manually started the visualization at least once. + /// Auto-restart on config change is only enabled after this. + user_has_started: bool, /// Edit buffer for the node info modal (cloned from config on open, applied on Save) modal_edit_buffer: Option, } @@ -232,6 +235,7 @@ impl Default for VisualizationTabState { node_to_host: HashMap::new(), auto_start_countdown: None, total_flows: 0, + user_has_started: false, reset_view_requested: false, last_screen_size: None, modal_edit_buffer: None, @@ -477,7 +481,10 @@ 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(10); + // Only auto-restart if the user has started the visualization at least once + if state.user_has_started { + state.auto_start_countdown = Some(10); + } state.reset_view_requested = true; } } @@ -975,8 +982,10 @@ 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() { + // Play / Continue: resume without resetting flow counts + let play_tooltip = if state.user_has_started { "Continue" } else { "Start" }; + if ui.button(egui_material_icons::icons::ICON_PLAY_ARROW).on_hover_text(play_tooltip).clicked() { + state.user_has_started = true; // Pass the user config if loaded, otherwise None (uses default BN model) let config = state.config_content.clone(); let speed = state.speed.clone(); @@ -985,11 +994,14 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { } } // 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(); - if let Err(e) = state.start_visualization(config.as_deref(), speed, true) { - log::error!("Failed to start flow streamer: {}", e); + // Only visible after the user has started at least once + if state.user_has_started { + 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 { From 80807cde294be362a1525860306ffe3773c52305 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:42:01 +0100 Subject: [PATCH 084/127] feat(gui/visualization): improve legend tooltips with visual behavior descriptions --- crates/fosr-gui/src/visualization/visualization_tab.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 3ae4a738..55bf336e 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -1078,7 +1078,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"); + }).response.on_hover_text("Node types. Size reflects relative traffic activity."); }); // Overlay legend: edge states (bottom-right of graph) @@ -1095,7 +1095,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"); + }).response.on_hover_text("Link protocols. Color shows protocol, thickness reflects relative traffic volume."); }); }); } From 89665b3816fb9c0ee1f6aa50941b5f1aa8344726 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:07:17 +0100 Subject: [PATCH 085/127] fix(gui/config): fix visual/code toggle button order in RTL layout --- .../fosr-gui/src/shared/configuration_file.rs | 6 ++-- crates/fosr-gui/src/shared/ui_utils.rs | 28 +++++++++---------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 39bb5fc2..e5fbde61 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -230,13 +230,15 @@ pub fn configuration_file_picker( ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); + // In RTL layout, rendering order is reversed, + // so Code is passed first to appear visually on the right. labeled_toggle( ui, &mut tab_state.is_code_mode, - &format!("{} Visual", egui_material_icons::icons::ICON_EDIT), &format!("{} Code", egui_material_icons::icons::ICON_CODE), - "Visual Mode: edit using the graphical interface.", + &format!("{} Visual", egui_material_icons::icons::ICON_EDIT), "Code Mode: edit as raw YAML.", + "Visual Mode: edit using the graphical interface.", ); }); }); diff --git a/crates/fosr-gui/src/shared/ui_utils.rs b/crates/fosr-gui/src/shared/ui_utils.rs index 9bfdef6e..391f926d 100644 --- a/crates/fosr-gui/src/shared/ui_utils.rs +++ b/crates/fosr-gui/src/shared/ui_utils.rs @@ -69,14 +69,14 @@ pub fn edit_optional_string( /// Segmented toggle button between two options. /// Displays two buttons side by side in a grouped frame. -/// `on` is false → left is selected, true → right is selected. +/// Rendering order follows the parent layout direction. pub fn labeled_toggle( ui: &mut egui::Ui, - on: &mut bool, - left_label: &str, - right_label: &str, - tooltip_left: &str, - tooltip_right: &str, + is_first_selected: &mut bool, + first_label: &str, + second_label: &str, + tooltip_first: &str, + tooltip_second: &str, ) { // Use a group frame with tight padding to auto-size around the content let resp = egui::Frame::group(ui.style()) @@ -86,17 +86,17 @@ pub fn labeled_toggle( ui.style_mut().visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE; ui.spacing_mut().item_spacing = egui::vec2(3.0, 0.0); ui.horizontal(|ui| { - let left = ui.selectable_label(!*on, left_label); - if left.clicked() { - *on = false; + let first = ui.selectable_label(*is_first_selected, first_label); + if first.clicked() { + *is_first_selected = true; } - left.on_hover_text(tooltip_left); + first.on_hover_text(tooltip_first); - let right = ui.selectable_label(*on, right_label); - if right.clicked() { - *on = true; + let second = ui.selectable_label(!*is_first_selected, second_label); + if second.clicked() { + *is_first_selected = false; } - right.on_hover_text(tooltip_right); + second.on_hover_text(tooltip_second); }); }); let _ = resp; From f5ee1c98f4eab7e5cce3ac423dab9751c7b6aed2 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:12:17 +0100 Subject: [PATCH 086/127] chore(gui): run cargo fmt --- crates/fosr-gui/src/about_tab.rs | 5 +- crates/fosr-gui/src/app.rs | 28 ++++-- .../src/configuration/configuration_tab.rs | 10 ++- .../src/generation/generation_core.rs | 57 +++++++++--- .../fosr-gui/src/generation/generation_tab.rs | 42 +++++---- .../src/generation/generation_validation.rs | 1 - .../fosr-gui/src/shared/configuration_file.rs | 19 ++-- crates/fosr-gui/src/shared/ui_utils.rs | 58 +++++++----- crates/fosr-gui/src/timepicker/button.rs | 2 +- crates/fosr-gui/src/timepicker/popup.rs | 21 ++--- crates/fosr-gui/src/visualization/mod.rs | 2 +- .../src/visualization/visualization_shapes.rs | 90 +++++++++++++------ .../src/visualization/visualization_stream.rs | 7 +- .../src/visualization/visualization_tab.rs | 68 +++++++++----- .../src/visualization/visualization_utils.rs | 1 - 15 files changed, 268 insertions(+), 143 deletions(-) diff --git a/crates/fosr-gui/src/about_tab.rs b/crates/fosr-gui/src/about_tab.rs index 66972f00..9030f7a4 100644 --- a/crates/fosr-gui/src/about_tab.rs +++ b/crates/fosr-gui/src/about_tab.rs @@ -2,10 +2,7 @@ use eframe::egui; pub fn show_about_tab_content(ui: &mut egui::Ui) { ui.vertical_centered(|ui| { - ui.add( - egui::Image::new(egui::include_image!("../../../public/logo.png")) - .max_width(450.0), - ); + ui.add(egui::Image::new(egui::include_image!("../../../public/logo.png")).max_width(450.0)); }); ui.separator(); ui.add_space(10.0); diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index 870b4fbc..5262e38a 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -5,11 +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; +#[cfg(target_arch = "wasm32")] +use crate::shared::configuration_file::poll_file_import; 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, }; @@ -62,10 +62,26 @@ impl eframe::App for FosrApp { // 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()); + 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; } diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 066ea64d..503787c0 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -275,7 +275,10 @@ pub fn show_configuration_tab_content( // Update error flag (parse errors + host validation errors) file_state.has_errors = file_state.parse_error.is_some() - || file_state.config_model.as_ref().is_some_and(has_model_errors); + || file_state + .config_model + .as_ref() + .is_some_and(has_model_errors); } }); } @@ -331,7 +334,10 @@ pub fn has_model_errors(model: &Configuration) -> bool { } } } - model.hosts.iter().any(|host| !validate_host(host, &ip_counts, &mac_counts).is_empty()) + model + .hosts + .iter() + .any(|host| !validate_host(host, &ip_counts, &mac_counts).is_empty()) } /// Several host rendering. diff --git a/crates/fosr-gui/src/generation/generation_core.rs b/crates/fosr-gui/src/generation/generation_core.rs index 30370d93..7bbee302 100644 --- a/crates/fosr-gui/src/generation/generation_core.rs +++ b/crates/fosr-gui/src/generation/generation_core.rs @@ -108,7 +108,17 @@ 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, cancelled); + run_single_thread( + order_pcap, + s0, + s1, + s2, + s3, + send_progress, + send_pcap, + throughput_sender, + cancelled, + ); } fn run_single_thread( @@ -128,17 +138,26 @@ fn run_single_thread( log::info!("Stage 0 generation"); let vec = stage0::run_vec(s0); - if is_cancelled() { log::info!("Generation cancelled after stage 0"); return; } + 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; } + 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; } + if is_cancelled() { + log::info!("Generation cancelled after stage 2"); + return; + } send_progress(0.6); let mut all_packets = vec![]; @@ -147,35 +166,53 @@ 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; } + 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; } + 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; } + if is_cancelled() { + log::info!("Generation cancelled during stage 3"); + return; + } send_progress(0.8); let gen_duration = start.elapsed().as_secs_f64(); let total_size = all_packets.iter().map(|p| p.data.len()).sum::() as u64; - let throughput_str = format!("{}/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 is_cancelled() { log::info!("Generation cancelled"); return; } + 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; } + 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); diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 043cc57d..01cc9f55 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -4,14 +4,12 @@ use super::generation_validation::{ FieldValidation, first_invalid_param, validate_duration, validate_optional_u64, validate_timezone, }; -use crate::shared::configuration_file::{ - ConfigurationFileState, load_config_file_contents, -}; -use crate::shared::ui_utils::info_icon; +use crate::shared::configuration_file::{ConfigurationFileState, load_config_file_contents}; #[cfg(not(target_arch = "wasm32"))] use crate::shared::file_io::save_file_desktop; #[cfg(target_arch = "wasm32")] use crate::shared::file_io::save_file_wasm; +use crate::shared::ui_utils::info_icon; use crate::timepicker::TimePickerButton; use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone}; use chrono_tz::Tz; @@ -149,7 +147,10 @@ pub fn show_generation_tab_content( ui.horizontal(|ui| { ui.label("Start time"); 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( + 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) @@ -183,7 +184,6 @@ pub fn show_generation_tab_content( } } }); - } else { state.timezone_validation.set_ok(); } @@ -200,15 +200,21 @@ pub fn show_generation_tab_content( .earliest() .map(|dt| dt.with_timezone(&chrono::Utc)) } else { - state.timezone_input.parse::().ok() + 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), + egui::RichText::new(format!( + "Start time (UTC): {}", + utc.format("%Y-%m-%d %H:%M:%S") + )) + .color(egui::Color32::GRAY), ); } @@ -262,8 +268,8 @@ pub fn show_generation_tab_content( let stop_button = egui::Button::new( 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)); + .fill(egui::Color32::from_rgb(200, 80, 80)) + .min_size(egui::vec2(75.0, 24.0)); if ui.add(stop_button).on_hover_text("Stop").clicked() { state.cancelled.store(true, Ordering::Relaxed); state.status = UiStatus::Idle; @@ -280,8 +286,8 @@ pub fn show_generation_tab_content( let generate_button = egui::Button::new( egui::RichText::new(egui_material_icons::icons::ICON_PLAY_ARROW).size(13.0), ) - .fill(accent) - .min_size(egui::vec2(75.0, 24.0)); + .fill(accent) + .min_size(egui::vec2(75.0, 24.0)); if ui.add(generate_button).on_hover_text("Generate").clicked() { state.status = UiStatus::Generating; @@ -412,11 +418,13 @@ pub fn show_generation_tab_content( let save_button_tooltip = "Save"; #[cfg(target_arch = "wasm32")] let save_button_tooltip = "Download"; - let save_button = egui::Button::new( - egui::RichText::new(save_button_icon).size(13.0), - ) + let save_button = egui::Button::new(egui::RichText::new(save_button_icon).size(13.0)) .min_size(egui::vec2(75.0, 24.0)); - if ui.add(save_button).on_hover_text(save_button_tooltip).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/generation/generation_validation.rs b/crates/fosr-gui/src/generation/generation_validation.rs index 3d7d4f3b..59de92d1 100644 --- a/crates/fosr-gui/src/generation/generation_validation.rs +++ b/crates/fosr-gui/src/generation/generation_validation.rs @@ -51,7 +51,6 @@ pub fn validate_duration(duration_str: &str) -> Result { Ok(d) } - pub fn validate_optional_u64(input: &str) -> Result, String> { let s = input.trim(); if s.is_empty() { diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index e5fbde61..8ef3029b 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -3,8 +3,7 @@ use crate::shared::file_io::{read_file_desktop, save_file_desktop, show_file_pic #[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, + configuration::configuration_tab::ConfigurationTabState, shared::config_model::Configuration, shared::ui_utils::labeled_toggle, }; use chrono::{DateTime, Local}; @@ -164,10 +163,14 @@ pub fn configuration_file_picker( } 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(), + 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(); + configuration_file_state.clean_snapshot = + configuration_file_state.config_model.clone(); let default_name = configuration_file_state .picked_config_file .as_ref() @@ -209,7 +212,9 @@ pub fn configuration_file_picker( if configuration_file_state.is_dirty { ui.colored_label(COLOR_WARNING, egui_material_icons::icons::ICON_WARNING) - .on_hover_text("Unsaved changes detected — download the file to avoid losing them."); + .on_hover_text( + "Unsaved changes detected — download the file to avoid losing them.", + ); ui.colored_label(COLOR_WARNING, &filename) .on_hover_text(path_text); } else { @@ -221,7 +226,9 @@ pub fn configuration_file_picker( { if configuration_file_state.is_dirty { ui.colored_label(COLOR_WARNING, egui_material_icons::icons::ICON_WARNING) - .on_hover_text("Unsaved changes detected — download the file to avoid losing them."); + .on_hover_text( + "Unsaved changes detected — download the file to avoid losing them.", + ); ui.colored_label(COLOR_WARNING, &filename); } else { ui.label(&filename); diff --git a/crates/fosr-gui/src/shared/ui_utils.rs b/crates/fosr-gui/src/shared/ui_utils.rs index 391f926d..896d9de9 100644 --- a/crates/fosr-gui/src/shared/ui_utils.rs +++ b/crates/fosr-gui/src/shared/ui_utils.rs @@ -3,12 +3,16 @@ 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.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); - }); + 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. @@ -61,7 +65,11 @@ pub fn edit_optional_string( } // Explicit clear button - 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() + { *value = None; } }); @@ -82,23 +90,23 @@ pub fn labeled_toggle( let resp = egui::Frame::group(ui.style()) .inner_margin(3.0) .show(ui, |ui| { - // Remove the hover stroke on selectable labels inside this toggle - ui.style_mut().visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE; - ui.spacing_mut().item_spacing = egui::vec2(3.0, 0.0); - ui.horizontal(|ui| { - let first = ui.selectable_label(*is_first_selected, first_label); - if first.clicked() { - *is_first_selected = true; - } - first.on_hover_text(tooltip_first); + // Remove the hover stroke on selectable labels inside this toggle + ui.style_mut().visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE; + ui.spacing_mut().item_spacing = egui::vec2(3.0, 0.0); + ui.horizontal(|ui| { + let first = ui.selectable_label(*is_first_selected, first_label); + if first.clicked() { + *is_first_selected = true; + } + first.on_hover_text(tooltip_first); - let second = ui.selectable_label(!*is_first_selected, second_label); - if second.clicked() { - *is_first_selected = false; - } - second.on_hover_text(tooltip_second); + let second = ui.selectable_label(!*is_first_selected, second_label); + if second.clicked() { + *is_first_selected = false; + } + second.on_hover_text(tooltip_second); + }); }); - }); let _ = resp; } @@ -134,7 +142,11 @@ pub fn edit_optional_multiline_string( } } - 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() + { *value = None; } } diff --git a/crates/fosr-gui/src/timepicker/button.rs b/crates/fosr-gui/src/timepicker/button.rs index d0f21992..a91bebe8 100644 --- a/crates/fosr-gui/src/timepicker/button.rs +++ b/crates/fosr-gui/src/timepicker/button.rs @@ -114,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/popup.rs b/crates/fosr-gui/src/timepicker/popup.rs index a90d9c3f..3ac0981d 100644 --- a/crates/fosr-gui/src/timepicker/popup.rs +++ b/crates/fosr-gui/src/timepicker/popup.rs @@ -152,7 +152,8 @@ impl TimePickerPopup<'_> { }; 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); + ui.painter() + .rect_stroke(rect.expand(1.0), 2.0, stroke, egui::StrokeKind::Outside); } }); @@ -280,19 +281,11 @@ fn draw_timepicker( } if *time == i { - painter.circle_filled( - Pos2::new(x_outer, y_outer), - 15., - highlight_color, - ); + 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, - ); + painter.circle_filled(Pos2::new(x_inner, y_inner), 15., highlight_color); } } TimeFrame::Minute | TimeFrame::Second => { @@ -305,11 +298,7 @@ fn draw_timepicker( ); if *time % 5 == 0 && *time == i * 5 { - painter.circle_filled( - Pos2::new(x_outer, y_outer), - 15., - highlight_color, - ); + painter.circle_filled(Pos2::new(x_outer, y_outer), 15., highlight_color); } } } diff --git a/crates/fosr-gui/src/visualization/mod.rs b/crates/fosr-gui/src/visualization/mod.rs index b04ef22d..89493299 100644 --- a/crates/fosr-gui/src/visualization/mod.rs +++ b/crates/fosr-gui/src/visualization/mod.rs @@ -1,4 +1,4 @@ -pub mod visualization_tab; mod visualization_shapes; mod visualization_stream; +pub mod visualization_tab; mod visualization_utils; diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/visualization_shapes.rs index 8caaebe7..a891cb93 100644 --- a/crates/fosr-gui/src/visualization/visualization_shapes.rs +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -1,6 +1,8 @@ //! Custom node and edge shapes for network visualization -use crate::visualization::visualization_tab::{EdgeData, EdgeState, LinkDirection, NodeData, NodeType}; +use crate::visualization::visualization_tab::{ + EdgeData, EdgeState, LinkDirection, NodeData, NodeType, +}; use eframe::egui; use egui::{Color32, Pos2, Rect, Shape, TextureOptions, Vec2, load::SizeHint}; use egui_graphs::{DisplayEdge, DisplayNode, DrawContext, Node, NodeProps}; @@ -25,8 +27,8 @@ 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 = 15.0; // Starting size for all nodes -const RADIUS_MAX: f32 = 25.0; // Maximum size +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.0; @@ -62,7 +64,12 @@ impl NetworkNodeShape { }; let ips: Vec = payload.ip_addrs.iter().map(|ip| ip.to_string()).collect(); - (radius, payload.node_type.clone(), payload.hostname.clone(), ips) + ( + radius, + payload.node_type.clone(), + payload.hostname.clone(), + ips, + ) } /// Get the image source for this node type @@ -89,7 +96,7 @@ impl From> for NetworkNodeShape { } impl DisplayNode -for NetworkNodeShape + for NetworkNodeShape { /// Determines where edges should connect to the node shape fn closest_boundary_point(&self, dir: Vec2) -> Pos2 { @@ -133,12 +140,16 @@ for NetworkNodeShape // Draw hostname in italic if let Some(ref hostname) = self.hostname { let mut job = egui::text::LayoutJob::default(); - job.append(hostname, 0.0, egui::TextFormat { - font_id: font_id.clone(), - color: Color32::GRAY, - italics: true, - ..Default::default() - }); + job.append( + hostname, + 0.0, + egui::TextFormat { + font_id: font_id.clone(), + color: Color32::GRAY, + italics: true, + ..Default::default() + }, + ); let galley = f.layout_job(job); let label_pos = Pos2::new(pos.x - galley.size().x / 2.0, current_y); shapes.push(Shape::galley(label_pos, galley, Color32::GRAY)); @@ -148,11 +159,15 @@ for NetworkNodeShape // Draw IPs (normal) for ip in &self.ips { let mut job = egui::text::LayoutJob::default(); - job.append(ip, 0.0, egui::TextFormat { - font_id: font_id.clone(), - color: Color32::GRAY, - ..Default::default() - }); + job.append( + ip, + 0.0, + egui::TextFormat { + font_id: font_id.clone(), + color: Color32::GRAY, + ..Default::default() + }, + ); let galley = f.layout_job(job); let label_pos = Pos2::new(pos.x - galley.size().x / 2.0, current_y); shapes.push(Shape::galley(label_pos, galley, Color32::GRAY)); @@ -198,7 +213,11 @@ fn edge_style(edge_data: &EdgeData) -> (Color32, f32, bool, bool) { }; (COLOR_INACTIVE, width, false, false) } - EdgeState::Active { protocol, direction, .. } => { + EdgeState::Active { + protocol, + direction, + .. + } => { let color = match protocol { L7Proto::HTTP => COLOR_HTTP, L7Proto::HTTPS => COLOR_HTTPS, @@ -229,7 +248,12 @@ pub struct NetworkEdgeShape { 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 } + Self { + color, + width, + arrow_start, + arrow_end, + } } } @@ -242,13 +266,13 @@ fn arrow_head(from: Pos2, to: Pos2, size: f32, angle: f32, color: Color32) -> Sh } impl -DisplayEdge< - NodeData, - EdgeData, - petgraph::Undirected, - petgraph::stable_graph::DefaultIx, - NetworkNodeShape, -> for NetworkEdgeShape + DisplayEdge< + NodeData, + EdgeData, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, + > for NetworkEdgeShape { fn shapes( &mut self, @@ -291,12 +315,24 @@ DisplayEdge< if self.arrow_end { 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)); + shapes.push(arrow_head( + start_pos, + extended_end, + arrow_size, + arrow_angle, + self.color, + )); } if self.arrow_start { 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.push(arrow_head( + end_pos, + extended_start, + arrow_size, + arrow_angle, + self.color, + )); } shapes diff --git a/crates/fosr-gui/src/visualization/visualization_stream.rs b/crates/fosr-gui/src/visualization/visualization_stream.rs index 4f7ce49c..b0c89e5b 100644 --- a/crates/fosr-gui/src/visualization/visualization_stream.rs +++ b/crates/fosr-gui/src/visualization/visualization_stream.rs @@ -7,7 +7,7 @@ //! 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 fosr_lib::{L7Proto, models, stage0, stage1::Stage1, stage1::bayesian_networks::BNGenerator}; use std::collections::BinaryHeap; use std::net::Ipv4Addr; use std::sync::atomic::{AtomicBool, Ordering}; @@ -40,7 +40,6 @@ impl PartialEq for ScheduledFlow { } } - impl PartialOrd for ScheduledFlow { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) @@ -221,7 +220,8 @@ impl FlowStreamer { .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() + if last_generation.elapsed() < Duration::from_millis(100) + && !pending_flows.is_empty() { break; } @@ -267,7 +267,6 @@ impl FlowStreamer { } } - // Emit flows whose scheduled time has passed (in virtual time) while let Some(scheduled) = pending_flows.peek() { if scheduled.scheduled_time <= virtual_elapsed { diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 55bf336e..2c0f26d9 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, ICON_TINT_DARK, ICON_TINT_LIGHT, + COLOR_DNS, COLOR_HTTP, COLOR_HTTPS, COLOR_INACTIVE, COLOR_OTHER, COLOR_SMTP, COLOR_SSH, + ICON_TINT_DARK, ICON_TINT_LIGHT, NetworkEdgeShape, NetworkNodeShape, }; use super::visualization_stream::{FlowEvent, FlowStreamer}; use super::visualization_utils::distribute_nodes_circle; @@ -10,9 +10,10 @@ use eframe::egui; use egui_graphs::{ FruchtermanReingoldState, FruchtermanReingoldWithCenterGravity, FruchtermanReingoldWithCenterGravityState, LayoutForceDirected, SettingsInteraction, - events::{Event, PayloadNodeClick}, set_layout_state, + events::{Event, PayloadNodeClick}, + set_layout_state, }; -use fosr_lib::{config, config::HostType, L7Proto, OS}; +use fosr_lib::{L7Proto, OS, config, config::HostType}; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::fmt; @@ -152,7 +153,6 @@ pub enum LinkDirection { Bidirectional, } - /// An active link being displayed pub struct ActiveLink { pub protocol: L7Proto, @@ -359,7 +359,10 @@ impl VisualizationTabState { self.reset_flow_counts(); } - log::debug!("Starting visualization with {} known IPs:", self.known_ips.len()); + log::debug!( + "Starting visualization with {} known IPs:", + self.known_ips.len() + ); for ip in &self.known_ips { log::debug!(" - {}", ip); } @@ -463,8 +466,10 @@ fn handle_config_changes( } // Check if config content has changed - let needs_update = match (&state.config_content, &configuration_file_state.config_file_content) - { + let needs_update = match ( + &state.config_content, + &configuration_file_state.config_file_content, + ) { (Some(current), Some(new)) => current != new, (None, Some(_)) => true, _ => false, @@ -526,16 +531,8 @@ fn process_flow_events(state: &mut VisualizationTabState) { state.total_flows += 1; // 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 - }; + 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: {} -> {} ({:?})", @@ -757,14 +754,21 @@ fn render_node_info_modal( 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"), + NodeType::Internet => ( + egui::include_image!("../../assets/internet.png"), + "Internet", + ), }; 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( + egui::Image::new(image) + .fit_to_exact_size(egui::vec2(20.0, 20.0)) + .tint(tint), + ); ui.label(egui::RichText::new(type_str).strong()); }); @@ -846,16 +850,28 @@ fn render_node_info_modal( if has_edit_buffer { ui.horizontal(|ui| { - if ui.button(egui_material_icons::icons::ICON_CLOSE).on_hover_text("Cancel").clicked() { + if ui + .button(egui_material_icons::icons::ICON_CLOSE) + .on_hover_text("Cancel") + .clicked() + { ui.close(); } - if ui.button(egui_material_icons::icons::ICON_SAVE).on_hover_text("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(egui_material_icons::icons::ICON_CLOSE).on_hover_text("Close").clicked() { + if ui + .button(egui_material_icons::icons::ICON_CLOSE) + .on_hover_text("Close") + .clicked() + { ui.close(); } } @@ -907,7 +923,11 @@ fn legend_item_with_image(ui: &mut egui::Ui, label: &str, image: egui::ImageSour } else { ICON_TINT_LIGHT }; - ui.add(egui::Image::new(image).fit_to_exact_size(egui::vec2(20.0, 20.0)).tint(tint)); + 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); }); diff --git a/crates/fosr-gui/src/visualization/visualization_utils.rs b/crates/fosr-gui/src/visualization/visualization_utils.rs index 9245656a..af6603fd 100644 --- a/crates/fosr-gui/src/visualization/visualization_utils.rs +++ b/crates/fosr-gui/src/visualization/visualization_utils.rs @@ -1,6 +1,5 @@ use eframe::egui; - /// Distributes the graph nodes in a circle layout. /// Required for proper display on startup. pub fn distribute_nodes_circle( From 4334313a3f38a76b576a99630d41018a520359c4 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:24:41 +0100 Subject: [PATCH 087/127] chore(gui): add a script to build and launch web version --- crates/fosr-gui/build-web.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100755 crates/fosr-gui/build-web.sh diff --git a/crates/fosr-gui/build-web.sh b/crates/fosr-gui/build-web.sh new file mode 100755 index 00000000..7a18265a --- /dev/null +++ b/crates/fosr-gui/build-web.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Build and serve the WASM version of Fos-R GUI + +set -e + +# Go to project root +cd "$(dirname "$0")/../.." + +echo "Building WASM..." +cargo build -p fosr-gui -r --target wasm32-unknown-unknown --no-default-features + +echo "Generating JS glue with wasm-bindgen..." +wasm-bindgen --out-dir public --target web target/wasm32-unknown-unknown/release/fosr_gui.wasm --no-typescript + +echo "Starting HTTP server on port 8080..." +http-server ./public -p 8080 From ae85f0575858c7cb6a9dc2837ef55b35b236162c Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:26:56 +0100 Subject: [PATCH 088/127] feat(gui): hide navigation bar until config is chosen --- crates/fosr-gui/src/app.rs | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index 5262e38a..a024e289 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -85,14 +85,22 @@ impl eframe::App for FosrApp { self.images_preloaded = true; } + // On web, use dark theme to match with the Fos-R website's theme + #[cfg(target_arch = "wasm32")] + ctx.set_theme(egui::Theme::Dark); + + // Startup modal: choose configuration source + if !self.configuration_file_state.config_chosen { + // Render empty CentralPanel for background, then modal on top + egui::CentralPanel::default().show(ctx, |_ui| {}); + render_startup_modal(ctx, &mut self.configuration_file_state); + return; + } + // 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 egui::MenuBar::new().ui(ui, |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); - if ui .selectable_label( self.current_tab == CurrentTab::Visualization, @@ -179,18 +187,8 @@ 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. - // 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 From 167cdf3c7401ffb85bf990afc8842e939012c269 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:34:41 +0100 Subject: [PATCH 089/127] fix(gui/visualization): don't display IP address for Internet node --- .../src/visualization/visualization_shapes.rs | 34 ++++++++++--------- .../src/visualization/visualization_tab.rs | 19 ++++++----- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/visualization_shapes.rs index a891cb93..fbe85b56 100644 --- a/crates/fosr-gui/src/visualization/visualization_shapes.rs +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -156,22 +156,24 @@ impl DisplayNode Date: Mon, 9 Mar 2026 16:49:07 +0100 Subject: [PATCH 090/127] chore(gui): remove unimplemented Injection tab code --- crates/fosr-gui/src/app.rs | 18 ------------------ crates/fosr-gui/src/injection_tab.rs | 7 ------- crates/fosr-gui/src/lib.rs | 2 -- crates/fosr-gui/src/main.rs | 2 -- 4 files changed, 29 deletions(-) delete mode 100644 crates/fosr-gui/src/injection_tab.rs diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index a024e289..7f5469b8 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -3,8 +3,6 @@ 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(target_arch = "wasm32")] use crate::shared::configuration_file::poll_file_import; use crate::shared::configuration_file::{ @@ -22,9 +20,6 @@ enum CurrentTab { Configuration, Visualization, Generation, - // To be implemented - // #[cfg(not(target_arch = "wasm32"))] - // Injection, About, } @@ -135,14 +130,6 @@ impl eframe::App for FosrApp { { self.current_tab = CurrentTab::Generation; } - // 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") .on_hover_text("About Fos-R and its authors.") @@ -214,11 +201,6 @@ impl eframe::App for FosrApp { &mut self.configuration_file_state, ); } - // 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 deleted file mode 100644 index 1e199766..00000000 --- a/crates/fosr-gui/src/injection_tab.rs +++ /dev/null @@ -1,7 +0,0 @@ -#![allow(dead_code)] -#![cfg(not(target_arch = "wasm32"))] -use eframe::egui; - -pub fn show_injection_tab_content(ui: &mut egui::Ui) { - ui.heading("COMING SOON"); -} diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index 52ec90b0..8933bee0 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -3,8 +3,6 @@ mod about_tab; mod app; mod configuration; mod generation; -#[cfg(not(target_arch = "wasm32"))] -mod injection_tab; mod shared; mod timepicker; mod visualization; diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index 35889231..0606c8e9 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -2,8 +2,6 @@ mod about_tab; mod app; mod configuration; mod generation; -#[cfg(not(target_arch = "wasm32"))] -mod injection_tab; mod shared; mod timepicker; mod visualization; From 9fb986a9a8f17d6b6cbd5895392fc75156a15a26 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:28:26 +0100 Subject: [PATCH 091/127] feat(gui/generation): add Open button to view PCAP in default app --- Cargo.lock | 88 +++++++++++++----- crates/fosr-gui/Cargo.toml | 2 + .../fosr-gui/src/generation/generation_tab.rs | 92 +++++++++++++++++-- 3 files changed, 147 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3dfb0abb..b14cd81c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -358,7 +358,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.3", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -389,7 +389,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.3", + "rustix 1.1.4", ] [[package]] @@ -415,7 +415,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.3", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -761,7 +761,7 @@ checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" dependencies = [ "bitflags 2.10.0", "polling", - "rustix 1.1.3", + "rustix 1.1.4", "slab", "tracing", ] @@ -785,7 +785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.3", - "rustix 1.1.3", + "rustix 1.1.4", "wayland-backend", "wayland-client", ] @@ -1838,11 +1838,13 @@ dependencies = [ "humantime", "indicatif", "log", + "open", "petgraph", "rand 0.10.0", "rfd", "serde", "serde_yaml", + "tempfile", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -1959,7 +1961,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -2456,6 +2458,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2591,9 +2612,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libloading" @@ -2636,9 +2657,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -3195,6 +3216,17 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "orbclient" version = "0.3.50" @@ -3268,6 +3300,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pcap-file" version = "2.0.0" @@ -3555,7 +3593,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4003,14 +4041,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -4302,7 +4340,7 @@ dependencies = [ "libc", "log", "memmap2", - "rustix 1.1.3", + "rustix 1.1.4", "thiserror 2.0.17", "wayland-backend", "wayland-client", @@ -4444,14 +4482,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5013,7 +5051,7 @@ checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.3", + "rustix 1.1.4", "scoped-tls", "smallvec", "wayland-sys", @@ -5026,7 +5064,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ "bitflags 2.10.0", - "rustix 1.1.3", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -5048,7 +5086,7 @@ version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "wayland-client", "xcursor", ] @@ -5358,7 +5396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" dependencies = [ "env_home", - "rustix 1.1.3", + "rustix 1.1.4", "winsafe", ] @@ -5997,7 +6035,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "x11rb-protocol", ] @@ -6104,7 +6142,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.3", + "rustix 1.1.4", "serde", "serde_repr", "tracing", diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index 2c78e21d..75b3250f 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -52,4 +52,6 @@ getrandom = { version = "0.4", features = ["wasm_js"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] env_logger = { version = "0.11.8", default-features = false, features = ["auto-color", "humantime"] } +tempfile = "3.26.0" +open = "5.3.3" diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 01cc9f55..e4ba17f8 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -64,6 +64,13 @@ pub struct GenerationTabState { pub start_date: NaiveDate, pub start_hour: NaiveTime, pub output_file_name: String, + /// Holds the temporary PCAP file opened in Wireshark. + /// + /// `NamedTempFile` automatically deletes the file when dropped. By storing it here, + /// the file stays alive until: (1) the app closes, or (2) a new file is opened + /// (which replaces this value, dropping the previous file). + #[cfg(not(target_arch = "wasm32"))] + pub temp_pcap_file: Option, } impl Default for GenerationTabState { @@ -93,6 +100,8 @@ impl Default for GenerationTabState { start_date: Local::now().date_naive(), start_hour: Local::now().time(), output_file_name: "output.pcap".to_string(), + #[cfg(not(target_arch = "wasm32"))] + temp_pcap_file: None, } } } @@ -266,11 +275,11 @@ pub fn show_generation_tab_content( ui.horizontal(|ui| { if is_generating { let stop_button = egui::Button::new( - egui::RichText::new(egui_material_icons::icons::ICON_STOP).size(13.0), + egui::RichText::new(format!("{} Stop", 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).on_hover_text("Stop").clicked() { + if ui.add(stop_button).on_hover_text("Cancel generation").clicked() { state.cancelled.store(true, Ordering::Relaxed); state.status = UiStatus::Idle; state.progress = 0.0; @@ -284,11 +293,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(egui_material_icons::icons::ICON_PLAY_ARROW).size(13.0), + egui::RichText::new(format!("{} Generate", 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).on_hover_text("Generate").clicked() { + .min_size(egui::vec2(85.0, 24.0)); + if ui.add(generate_button).on_hover_text("Generate PCAP from configuration").clicked() { state.status = UiStatus::Generating; // Reset state @@ -411,14 +420,14 @@ pub fn show_generation_tab_content( state.status = UiStatus::Generated; } #[cfg(not(target_arch = "wasm32"))] - let save_button_icon = egui_material_icons::icons::ICON_SAVE; + let save_button_text = format!("{} Save", egui_material_icons::icons::ICON_SAVE); #[cfg(target_arch = "wasm32")] - let save_button_icon = egui_material_icons::icons::ICON_DOWNLOAD; + let save_button_text = format!("{} Download", egui_material_icons::icons::ICON_DOWNLOAD); #[cfg(not(target_arch = "wasm32"))] - let save_button_tooltip = "Save"; + let save_button_tooltip = "Save PCAP file"; #[cfg(target_arch = "wasm32")] - let save_button_tooltip = "Download"; - let save_button = egui::Button::new(egui::RichText::new(save_button_icon).size(13.0)) + let save_button_tooltip = "Download PCAP file"; + let save_button = egui::Button::new(egui::RichText::new(save_button_text).size(13.0)) .min_size(egui::vec2(75.0, 24.0)); if ui .add(save_button) @@ -464,6 +473,32 @@ pub fn show_generation_tab_content( }); } } + + // Open in Wireshark button (native only) + #[cfg(not(target_arch = "wasm32"))] + { + let open_button = egui::Button::new( + egui::RichText::new(format!("{} Open", egui_material_icons::icons::ICON_LAN)).size(13.0), + ) + .min_size(egui::vec2(75.0, 24.0)); + if ui + .add(open_button) + .on_hover_text("Open with default application (e.g. Wireshark)") + .clicked() + { + if let Some(ref pcap_bytes) = state.pcap_bytes { + match open_in_wireshark(pcap_bytes, &mut state.temp_pcap_file) { + Ok(_) => { + log::info!("Opened PCAP in Wireshark"); + } + Err(e) => { + log::error!("Failed to open in Wireshark: {:?}", e); + state.status = UiStatus::Error(format!("Failed to open in Wireshark: {e}")); + } + } + } + } + } } }); @@ -490,3 +525,40 @@ pub fn show_generation_tab_content( ); } } + +/// Opens the PCAP data in the system's default application (e.g., Wireshark). +/// +/// This creates a temporary file with `.pcap` extension and opens it using `open::that()`. +/// The `NamedTempFile` handle is stored in `temp_file_storage` to keep the file alive. +/// When the handle is dropped (app closes or a new file is opened), the temp file is deleted. +#[cfg(not(target_arch = "wasm32"))] +fn open_in_wireshark( + pcap_bytes: &[u8], + temp_file_storage: &mut Option, +) -> Result<(), String> { + use std::io::Write; + + // Create a temporary file with .pcap extension + let mut temp_file = tempfile::Builder::new() + .suffix(".pcap") + .tempfile() + .map_err(|e| format!("Failed to create temp file: {e}"))?; + + // Write the PCAP data + temp_file + .write_all(pcap_bytes) + .map_err(|e| format!("Failed to write PCAP data: {e}"))?; + + // Get the path + let path = temp_file.path().to_path_buf(); + + log::info!("Opening PCAP file at: {}", path.display()); + + // Store the temp file to keep it alive until app closes + *temp_file_storage = Some(temp_file); + + // Open with system default application (Wireshark for .pcap files) + open::that(&path).map_err(|e| format!("Failed to open file: {e}"))?; + + Ok(()) +} From 23d7638e722f4ac1485db2a6095fbeafe82f8372 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:09:18 +0100 Subject: [PATCH 092/127] feat(gui/visualization): implement graph export as PNG screenshot --- Cargo.lock | 1 + crates/fosr-gui/Cargo.toml | 1 + .../src/visualization/visualization_tab.rs | 122 +++++++++++++++++- 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b14cd81c..69d3c38d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1836,6 +1836,7 @@ dependencies = [ "getrandom 0.4.2", "gloo-timers", "humantime", + "image", "indicatif", "log", "open", diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index 75b3250f..ca2a0d72 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -43,6 +43,7 @@ egui_graphs = { version = "0.29.0", features = ["events"] } serde_yaml = "0.9" egui_material_icons = "0.5.0" rand = "0.10.0" +image = { version = "0.25", default-features = false, features = ["png"] } [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 7e35e869..7e12d1a2 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -169,6 +169,17 @@ type VisualizationGraph = egui_graphs::Graph< NetworkEdgeShape, >; +/// State machine for screenshot export +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExportState { + #[default] + Idle, + /// Hide overlays on next frame before taking screenshot + HidingOverlays, + /// Screenshot requested, waiting for result + WaitingForScreenshot, +} + /// Represents the state of the visualization tab. pub struct VisualizationTabState { pub graph: VisualizationGraph, @@ -210,6 +221,10 @@ pub struct VisualizationTabState { user_has_started: bool, /// Edit buffer for the node info modal (cloned from config on open, applied on Save) modal_edit_buffer: Option, + /// The rect of the graph panel (updated each frame, used for screenshot region) + pub graph_rect: Option, + /// Screenshot export state machine + pub export_state: ExportState, } impl Default for VisualizationTabState { @@ -239,6 +254,8 @@ impl Default for VisualizationTabState { reset_view_requested: false, last_screen_size: None, modal_edit_buffer: None, + graph_rect: None, + export_state: ExportState::Idle, } } } @@ -938,7 +955,7 @@ fn legend_item_with_image(ui: &mut egui::Ui, label: &str, image: egui::ImageSour /// Render the graph view fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { - egui::CentralPanel::default().show(ui.ctx(), |ui| { + let inner_response = egui::CentralPanel::default().show(ui.ctx(), |ui| { // Enable node clicking and dragging let interactions = SettingsInteraction::new() .with_node_clicking_enabled(true) @@ -996,10 +1013,20 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { ui.add(&mut graph_view); + // Handle screenshot export state machine + handle_screenshot_export(ui, state); + + // Hide overlays during export to get clean screenshot + if state.export_state != ExportState::Idle { + return; + } + + // Get content rect for overlay positioning + let local_rect = ui.max_rect(); + // 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)) + .fixed_pos(local_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| { @@ -1035,6 +1062,9 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { if ui.button(egui_material_icons::icons::ICON_FIT_SCREEN).on_hover_text("Fit to screen").clicked() { state.reset_view_requested = true; } + if ui.button(egui_material_icons::icons::ICON_IMAGE).on_hover_text("Export as PNG").clicked() { + state.export_state = ExportState::HidingOverlays; + } ui.separator(); @@ -1076,7 +1106,7 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { // 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)) + .fixed_pos(local_rect.left_bottom() + egui::vec2(4.0, 0.0)) .pivot(egui::Align2::LEFT_BOTTOM) .order(egui::Order::Foreground) .show(ui.ctx(), |ui| { @@ -1094,7 +1124,7 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { // 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)) + .fixed_pos(local_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| { @@ -1107,7 +1137,7 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { // 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)) + .fixed_pos(local_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| { @@ -1121,4 +1151,84 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { }).response.on_hover_text("Link protocols. Color shows protocol, thickness reflects relative traffic volume."); }); }); + + // Use panel rect directly - it's already in screen coordinates + // and represents the full panel area (ui.max_rect() excludes internal padding) + let panel_rect = inner_response.response.rect; + state.graph_rect = Some(panel_rect); +} + +/// Handle screenshot export state machine. +/// Uses a 2-frame approach: +/// - Frame N: user clicks export → HidingOverlays +/// - Frame N+1: overlays hidden → request screenshot → WaitingForScreenshot +/// - Frame N+2: screenshot received → extract graph region → save → Idle +fn handle_screenshot_export(ui: &mut egui::Ui, state: &mut VisualizationTabState) { + // Transition: HidingOverlays → WaitingForScreenshot (request screenshot) + if state.export_state == ExportState::HidingOverlays { + state.export_state = ExportState::WaitingForScreenshot; + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + } + + // Handle screenshot result + ui.input(|i| { + for event in &i.raw.events { + if let egui::Event::Screenshot { image, .. } = event { + if state.export_state == ExportState::WaitingForScreenshot { + if let Some(graph_rect) = state.graph_rect { + let graph_image = image.region(&graph_rect, Some(i.pixels_per_point())); + save_graph_png(&graph_image); + } else { + log::error!("No graph rect stored for screenshot export"); + } + state.export_state = ExportState::Idle; + } + } + } + }); +} + +/// Save the graph screenshot as a PNG file. +fn save_graph_png(image: &egui::ColorImage) { + let width = image.width() as u32; + let height = image.height() as u32; + let pixels = image.as_raw(); + + // Convert RGBA to ImageBuffer + let img_buffer = image::RgbaImage::from_raw(width, height, pixels.to_vec()) + .expect("Failed to create image buffer"); + + // Generate filename with timestamp + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); + let filename = format!("fosr_graph_{}.png", timestamp); + + // Convert to PNG bytes + let mut buffer = Vec::new(); + match img_buffer.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png) { + Ok(_) => { + #[cfg(not(target_arch = "wasm32"))] + { + match crate::shared::file_io::save_file_desktop(&buffer, &filename) { + Ok(file_handle) => { + log::info!("Exported graph to {}", file_handle.path().to_string_lossy()); + } + Err(e) => { + log::error!("Failed to save graph PNG: {:?}", e); + } + } + } + + #[cfg(target_arch = "wasm32")] + { + let filename_clone = filename.clone(); + wasm_bindgen_futures::spawn_local(async move { + match crate::shared::file_io::save_file_wasm(&buffer, &filename_clone).await { + Ok(_) => log::info!("Exported graph to {}", filename_clone), + Err(e) => log::error!("Failed to save PNG on WASM: {:?}", e), + } + }); + } + } + Err(e) => log::error!("Failed to write PNG to buffer: {}", e), + } } From 0eacb6cea5a0755dcec8e0b11bcbc10177e58b5b Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:07:00 +0100 Subject: [PATCH 093/127] feat(gui): add template selection to replace single default config --- crates/fosr-gui/src/app.rs | 44 +++- crates/fosr-gui/src/lib.rs | 1 + crates/fosr-gui/src/main.rs | 1 + .../fosr-gui/src/shared/configuration_file.rs | 71 +++---- crates/fosr-gui/src/templates/datacenter.yaml | 199 ++++++++++++++++++ .../enterprise.yaml} | 0 crates/fosr-gui/src/templates/home.yaml | 48 +++++ crates/fosr-gui/src/templates/mod.rs | 70 ++++++ 8 files changed, 395 insertions(+), 39 deletions(-) create mode 100644 crates/fosr-gui/src/templates/datacenter.yaml rename crates/fosr-gui/src/{default_config.yaml => templates/enterprise.yaml} (100%) create mode 100644 crates/fosr-gui/src/templates/home.yaml create mode 100644 crates/fosr-gui/src/templates/mod.rs diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index 7f5469b8..e975dbbb 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -6,8 +6,9 @@ use crate::generation::generation_tab::{GenerationTabState, show_generation_tab_ #[cfg(target_arch = "wasm32")] use crate::shared::configuration_file::poll_file_import; use crate::shared::configuration_file::{ - ConfigurationFileState, load_default_config, trigger_file_import, + ConfigurationFileState, StartupModalState, trigger_file_import, }; +use crate::templates::{all_templates, load_template_by_id}; use crate::visualization::visualization_tab::{ VisualizationTabState, show_visualization_tab_content, }; @@ -254,6 +255,13 @@ fn startup_card(ui: &mut egui::Ui, icon: &str, title: &str, description: &str) - } fn render_startup_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { + match state.modal_state { + StartupModalState::Initial => render_initial_modal(ctx, state), + StartupModalState::TemplateSelection => render_template_selection_modal(ctx, state), + } +} + +fn render_initial_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"); @@ -267,9 +275,9 @@ fn render_startup_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) &mut cols[0], egui_material_icons::icons::ICON_LAN, "Default configuration", - "A sample enterprise network\nwith servers and workstations", + "Choose from preset templates\nfor different network types", ) { - load_default_config(state); + state.modal_state = StartupModalState::TemplateSelection; } // Right: import file @@ -287,3 +295,33 @@ fn render_startup_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) poll_file_import(state); }); } + +fn render_template_selection_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { + egui::Modal::new(egui::Id::new("template_selection_modal")).show(ctx, |ui| { + ui.set_width(400.0); + + // Header with back button + ui.horizontal(|ui| { + if ui + .button(egui_material_icons::icons::ICON_ARROW_BACK) + .on_hover_text("Back") + .clicked() + { + state.modal_state = StartupModalState::Initial; + } + ui.heading("Choose a template"); + }); + + ui.add_space(12.0); + + // Grid of template cards + let templates = all_templates(); + ui.columns(3, |cols| { + for (i, template) in templates.iter().enumerate() { + if startup_card(&mut cols[i % 3], template.icon, template.title, template.description) { + load_template_by_id(state, template.id); + } + } + }); + }); +} diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index 8933bee0..e67f90dd 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -4,6 +4,7 @@ mod app; mod configuration; mod generation; mod shared; +mod templates; mod timepicker; mod visualization; diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index 0606c8e9..b79ac84f 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -3,6 +3,7 @@ mod app; mod configuration; mod generation; mod shared; +mod templates; mod timepicker; mod visualization; diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 8ef3029b..85344eb8 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -3,8 +3,10 @@ use crate::shared::file_io::{read_file_desktop, save_file_desktop, show_file_pic #[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, + configuration::configuration_tab::ConfigurationTabState, + shared::config_model::Configuration, shared::ui_utils::labeled_toggle, + templates::load_template_by_id, }; use chrono::{DateTime, Local}; use eframe::egui; @@ -12,7 +14,13 @@ use rfd::FileHandle; #[cfg(target_arch = "wasm32")] use std::sync::mpsc::{Receiver, channel}; -pub const DEFAULT_CONFIG_YAML: &str = include_str!("../default_config.yaml"); +/// State for the startup modal flow. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +pub enum StartupModalState { + #[default] + Initial, + TemplateSelection, +} /// Warning color (amber/orange). const COLOR_WARNING: egui::Color32 = egui::Color32::from_rgb(230, 160, 0); @@ -34,6 +42,10 @@ pub struct ConfigurationFileState { /// Whether the configuration has any errors (parse errors or validation errors). /// Updated by the configuration tab rendering each frame. pub has_errors: bool, + /// Current state of the startup modal. + pub modal_state: StartupModalState, + /// The ID of the currently loaded template, if any. + pub loaded_template_id: Option, } impl Default for ConfigurationFileState { @@ -51,21 +63,12 @@ impl Default for ConfigurationFileState { is_dirty: false, clean_snapshot: None, has_errors: false, + modal_state: StartupModalState::Initial, + loaded_template_id: None, } } } -/// 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; - state.is_dirty = false; - state.clean_snapshot = state.config_model.clone(); -} - /// 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) { @@ -133,22 +136,27 @@ pub fn configuration_file_picker( #[cfg(target_arch = "wasm32")] poll_file_import(configuration_file_state); - // Display the file name on disk, or indicate built-in default + // Template dropdown menu (always visible) + let template_menu = ui.menu_button(egui_material_icons::icons::ICON_DESCRIPTION, |menu_ui| { + for template in crate::templates::all_templates() { + if menu_ui + .button(format!("{} {}", template.icon, template.title)) + .clicked() + { + menu_ui.close(); + load_template_by_id(configuration_file_state, template.id); + } + } + }); + template_menu.response.on_hover_text("Open template"); + + // Display the file name on disk, or indicate built-in template let filename = if let Some(file) = &configuration_file_state.picked_config_file { file.file_name() + } else if let Some(template_id) = &configuration_file_state.loaded_template_id { + format!("{}.yaml (built-in)", template_id) } else { - "default_config.yaml (built-in)".to_string() - }; - - // 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() - { - configuration_file_state.picked_config_file = None; - reset_loaded_config(configuration_file_state); + "No file selected".to_string() }; // Save as button (only when config content is available) @@ -319,16 +327,7 @@ fn clear_loaded_config(configuration_file_state: &mut ConfigurationFileState) { configuration_file_state.parse_error = None; configuration_file_state.is_dirty = false; configuration_file_state.clean_snapshot = None; - - #[cfg(target_arch = "wasm32")] - { - configuration_file_state.config_file_content_receiver = None; - } -} - -/// Restore the built-in default configuration. -pub fn reset_loaded_config(configuration_file_state: &mut ConfigurationFileState) { - load_default_config(configuration_file_state); + configuration_file_state.loaded_template_id = None; #[cfg(target_arch = "wasm32")] { diff --git a/crates/fosr-gui/src/templates/datacenter.yaml b/crates/fosr-gui/src/templates/datacenter.yaml new file mode 100644 index 00000000..8452e586 --- /dev/null +++ b/crates/fosr-gui/src/templates/datacenter.yaml @@ -0,0 +1,199 @@ +metadata: + title: Datacenter Network Configuration + desc: A server-heavy setup with multiple server types + author: FosR + version: 1.0.0 + format: 1 + +hosts: + # ==================== LOAD BALANCER TIER ==================== + + - hostname: lb-primary + os: Linux + usage: 1.5 + type: server + client: + - https + - http + - dns + interfaces: + - ip_addr: 10.0.1.10 + mac_addr: 00:14:2A:3F:01:01 + services: + - https:443 + - http:80 + + - hostname: lb-secondary + os: Linux + usage: 1.0 + type: server + client: + - https + - http + - dns + interfaces: + - ip_addr: 10.0.1.11 + mac_addr: 00:14:2A:3F:01:02 + services: + - https:443 + - http:80 + + # ==================== WEB SERVER TIER ==================== + + - hostname: web-server-01 + os: Linux + usage: 2.0 + type: server + client: + - https + - http + interfaces: + - ip_addr: 10.0.2.10 + mac_addr: 00:14:2A:3F:02:01 + services: + - https:443 + - http:80 + + - hostname: web-server-02 + os: Linux + usage: 2.0 + type: server + client: + - https + - http + interfaces: + - ip_addr: 10.0.2.11 + mac_addr: 00:14:2A:3F:02:02 + services: + - https:443 + - http:80 + + - hostname: web-server-03 + os: Linux + usage: 2.0 + type: server + client: + - https + - http + interfaces: + - ip_addr: 10.0.2.12 + mac_addr: 00:14:2A:3F:02:03 + services: + - https:443 + - http:80 + + # ==================== APPLICATION TIER ==================== + + - hostname: app-server-01 + os: Linux + usage: 1.8 + type: server + client: + - https + - ssh + interfaces: + - ip_addr: 10.0.3.10 + mac_addr: 00:14:2A:3F:03:01 + services: + - https:8443 + - ssh:22 + + - hostname: app-server-02 + os: Linux + usage: 1.8 + type: server + client: + - https + - ssh + interfaces: + - ip_addr: 10.0.3.11 + mac_addr: 00:14:2A:3F:03:02 + services: + - https:8443 + - ssh:22 + + # ==================== DATABASE TIER ==================== + + - hostname: db-primary + os: Linux + usage: 2.5 + type: server + client: + - https + - ssh + interfaces: + - ip_addr: 10.0.4.10 + mac_addr: 00:14:2A:3F:04:01 + services: + - https:5432 + - ssh:22 + + - hostname: db-replica-01 + os: Linux + usage: 1.5 + type: server + client: + - https + interfaces: + - ip_addr: 10.0.4.11 + mac_addr: 00:14:2A:3F:04:02 + services: + - https:5432 + + - hostname: db-replica-02 + os: Linux + usage: 1.5 + type: server + client: + - https + interfaces: + - ip_addr: 10.0.4.12 + mac_addr: 00:14:2A:3F:04:03 + services: + - https:5432 + + # ==================== CACHE TIER ==================== + + - hostname: cache-server + os: Linux + usage: 1.0 + type: server + client: + - https + interfaces: + - ip_addr: 10.0.5.10 + mac_addr: 00:14:2A:3F:05:01 + services: + - https:6379 + + # ==================== MONITORING ==================== + + - hostname: monitoring-server + os: Linux + usage: 0.8 + type: server + client: + - https + - ssh + - dns + interfaces: + - ip_addr: 10.0.6.10 + mac_addr: 00:14:2A:3F:06:01 + services: + - https:9090 + - ssh:22 + + # ==================== DNS ==================== + + - hostname: dns-server + os: Linux + usage: 0.5 + type: server + client: + - https + - dns + interfaces: + - ip_addr: 10.0.7.10 + mac_addr: 00:14:2A:3F:07:01 + services: + - dns:53 diff --git a/crates/fosr-gui/src/default_config.yaml b/crates/fosr-gui/src/templates/enterprise.yaml similarity index 100% rename from crates/fosr-gui/src/default_config.yaml rename to crates/fosr-gui/src/templates/enterprise.yaml diff --git a/crates/fosr-gui/src/templates/home.yaml b/crates/fosr-gui/src/templates/home.yaml new file mode 100644 index 00000000..fd481de4 --- /dev/null +++ b/crates/fosr-gui/src/templates/home.yaml @@ -0,0 +1,48 @@ +metadata: + title: Home Network Configuration + desc: A simple home network with a router and workstations + author: FosR + version: 1.0.0 + format: 1 + +hosts: + # ==================== HOME NETWORK ==================== + + - hostname: home-router + os: Linux + usage: 0.5 + type: server + client: + - dns + - https + interfaces: + - ip_addr: 192.168.1.1 + mac_addr: 00:14:2A:3F:01:01 + services: + - dns:53 + - https:443 + + - hostname: workstation + os: Windows + usage: 0.8 + type: user + client: + - https + - dns + - ssh + interfaces: + - ip_addr: 192.168.1.10 + mac_addr: AA:BB:CC:DD:EE:01 + + - hostname: nas + os: Linux + usage: 0.3 + type: server + client: + - https + interfaces: + - ip_addr: 192.168.1.20 + mac_addr: 00:14:2A:3F:01:02 + services: + - https:443 + - ssh:22 diff --git a/crates/fosr-gui/src/templates/mod.rs b/crates/fosr-gui/src/templates/mod.rs new file mode 100644 index 00000000..57ed9cf4 --- /dev/null +++ b/crates/fosr-gui/src/templates/mod.rs @@ -0,0 +1,70 @@ +use crate::shared::config_model::Configuration; +use crate::shared::configuration_file::ConfigurationFileState; +use egui_material_icons::icons; + +/// A predefined configuration template. +pub struct Template { + pub id: &'static str, + pub title: &'static str, + pub description: &'static str, + pub icon: &'static str, + pub yaml: &'static str, +} + +/// All available templates. +pub static TEMPLATES: &[Template] = &[ + Template { + id: "home", + title: "Home Network", + description: "2-3 hosts, simple setup", + icon: icons::ICON_HOME, + yaml: include_str!("home.yaml"), + }, + Template { + id: "enterprise", + title: "Enterprise", + description: "DMZ + App + DB + Users", + icon: icons::ICON_BUSINESS, + yaml: include_str!("enterprise.yaml"), + }, + Template { + id: "datacenter", + title: "Datacenter", + description: "Many servers, no users", + icon: icons::ICON_DNS, + yaml: include_str!("datacenter.yaml"), + }, +]; + +/// Load a template by its ID into the configuration file state. +pub fn load_template_by_id(state: &mut ConfigurationFileState, id: &str) { + if let Some(template) = TEMPLATES.iter().find(|t| t.id == id) { + load_template(state, template); + } +} + +/// Load a template into the configuration file state. +fn load_template(state: &mut ConfigurationFileState, template: &Template) { + state.picked_config_file = None; + state.config_file_content = Some(template.yaml.to_string()); + state.config_model = None; + state.parse_error = None; + + match serde_yaml::from_str::(template.yaml) { + Ok(model) => { + state.config_model = Some(model); + state.clean_snapshot = state.config_model.clone(); + state.config_chosen = true; + state.is_dirty = false; + state.loaded_template_id = Some(template.id.to_string()); + } + Err(e) => { + state.parse_error = Some(e.to_string()); + } + } +} + +/// Get all available templates. +pub fn all_templates() -> &'static [Template] { + TEMPLATES +} From df95d8f095c2bb2b13014a79e87c3f1a7a08a4f8 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:48:53 +0100 Subject: [PATCH 094/127] chore(gui): add a usage guide + reformat code --- crates/fosr-gui/src/about_tab.rs | 18 +++++++++++ crates/fosr-gui/src/app.rs | 7 ++++- .../fosr-gui/src/generation/generation_tab.rs | 30 ++++++++++++++----- .../fosr-gui/src/shared/configuration_file.rs | 27 ++++++++--------- .../src/visualization/visualization_tab.rs | 8 +++-- 5 files changed, 66 insertions(+), 24 deletions(-) diff --git a/crates/fosr-gui/src/about_tab.rs b/crates/fosr-gui/src/about_tab.rs index 9030f7a4..f2c67310 100644 --- a/crates/fosr-gui/src/about_tab.rs +++ b/crates/fosr-gui/src/about_tab.rs @@ -15,6 +15,24 @@ pub fn show_about_tab_content(ui: &mut egui::Ui) { ); ui.add_space(15.0); + ui.heading("Usage Guide"); + ui.add_space(5.0); + + ui.label("This GUI helps you design a Fos-R network configuration visually and generate synthetic network traffic as PCAP files. The Live Preview gives you immediate feedback on your topology before generating. Use the CLI to inject this traffic into a real network."); + ui.add_space(10.0); + + ui.label(egui::RichText::new("Live Preview").strong()); + ui.label("Visualize what Fos-R would generate based on your configuration. This is a real-time simulation, not the actual generation. Use it to quickly verify your network topology. Click on a node to adjust some properties."); + ui.add_space(8.0); + + ui.label(egui::RichText::new("Configuration").strong()); + ui.label("Define your network: hosts, their interfaces, and the services they provide. Create a configuration from a template or import an existing YAML file. Switch between visual editing and raw YAML at any time."); + ui.add_space(8.0); + + ui.label(egui::RichText::new("Generation").strong()); + ui.label("Generate the PCAP file from your configuration. Set parameters like duration and start time. For reproducible results, use a fixed seed. On desktop, open the result in Wireshark to take a quick look."); + ui.add_space(15.0); + ui.heading("Repository Information"); ui.add_space(5.0); ui.horizontal(|ui| { diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index e975dbbb..f1f9a2d1 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -318,7 +318,12 @@ fn render_template_selection_modal(ctx: &egui::Context, state: &mut Configuratio let templates = all_templates(); ui.columns(3, |cols| { for (i, template) in templates.iter().enumerate() { - if startup_card(&mut cols[i % 3], template.icon, template.title, template.description) { + if startup_card( + &mut cols[i % 3], + template.icon, + template.title, + template.description, + ) { load_template_by_id(state, template.id); } } diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index e4ba17f8..5c78bb9e 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -275,11 +275,16 @@ pub fn show_generation_tab_content( ui.horizontal(|ui| { if is_generating { let stop_button = egui::Button::new( - egui::RichText::new(format!("{} Stop", egui_material_icons::icons::ICON_STOP)).size(13.0), + egui::RichText::new(format!("{} Stop", 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).on_hover_text("Cancel generation").clicked() { + if ui + .add(stop_button) + .on_hover_text("Cancel generation") + .clicked() + { state.cancelled.store(true, Ordering::Relaxed); state.status = UiStatus::Idle; state.progress = 0.0; @@ -293,11 +298,19 @@ 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(format!("{} Generate", egui_material_icons::icons::ICON_PLAY_ARROW)).size(13.0), + egui::RichText::new(format!( + "{} Generate", + egui_material_icons::icons::ICON_PLAY_ARROW + )) + .size(13.0), ) .fill(accent) .min_size(egui::vec2(85.0, 24.0)); - if ui.add(generate_button).on_hover_text("Generate PCAP from configuration").clicked() { + if ui + .add(generate_button) + .on_hover_text("Generate PCAP from configuration") + .clicked() + { state.status = UiStatus::Generating; // Reset state @@ -422,7 +435,8 @@ pub fn show_generation_tab_content( #[cfg(not(target_arch = "wasm32"))] let save_button_text = format!("{} Save", egui_material_icons::icons::ICON_SAVE); #[cfg(target_arch = "wasm32")] - let save_button_text = format!("{} Download", egui_material_icons::icons::ICON_DOWNLOAD); + let save_button_text = + format!("{} Download", egui_material_icons::icons::ICON_DOWNLOAD); #[cfg(not(target_arch = "wasm32"))] let save_button_tooltip = "Save PCAP file"; #[cfg(target_arch = "wasm32")] @@ -478,7 +492,8 @@ pub fn show_generation_tab_content( #[cfg(not(target_arch = "wasm32"))] { let open_button = egui::Button::new( - egui::RichText::new(format!("{} Open", egui_material_icons::icons::ICON_LAN)).size(13.0), + egui::RichText::new(format!("{} Open", egui_material_icons::icons::ICON_LAN)) + .size(13.0), ) .min_size(egui::vec2(75.0, 24.0)); if ui @@ -493,7 +508,8 @@ pub fn show_generation_tab_content( } Err(e) => { log::error!("Failed to open in Wireshark: {:?}", e); - state.status = UiStatus::Error(format!("Failed to open in Wireshark: {e}")); + state.status = + UiStatus::Error(format!("Failed to open in Wireshark: {e}")); } } } diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 85344eb8..efadf856 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -3,10 +3,8 @@ use crate::shared::file_io::{read_file_desktop, save_file_desktop, show_file_pic #[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, - shared::ui_utils::labeled_toggle, - templates::load_template_by_id, + configuration::configuration_tab::ConfigurationTabState, shared::config_model::Configuration, + shared::ui_utils::labeled_toggle, templates::load_template_by_id, }; use chrono::{DateTime, Local}; use eframe::egui; @@ -137,17 +135,18 @@ pub fn configuration_file_picker( poll_file_import(configuration_file_state); // Template dropdown menu (always visible) - let template_menu = ui.menu_button(egui_material_icons::icons::ICON_DESCRIPTION, |menu_ui| { - for template in crate::templates::all_templates() { - if menu_ui - .button(format!("{} {}", template.icon, template.title)) - .clicked() - { - menu_ui.close(); - load_template_by_id(configuration_file_state, template.id); + let template_menu = + ui.menu_button(egui_material_icons::icons::ICON_DESCRIPTION, |menu_ui| { + for template in crate::templates::all_templates() { + if menu_ui + .button(format!("{} {}", template.icon, template.title)) + .clicked() + { + menu_ui.close(); + load_template_by_id(configuration_file_state, template.id); + } } - } - }); + }); template_menu.response.on_hover_text("Open template"); // Display the file name on disk, or indicate built-in template diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 7e12d1a2..cd3564ff 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -1167,7 +1167,8 @@ fn handle_screenshot_export(ui: &mut egui::Ui, state: &mut VisualizationTabState // Transition: HidingOverlays → WaitingForScreenshot (request screenshot) if state.export_state == ExportState::HidingOverlays { state.export_state = ExportState::WaitingForScreenshot; - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); } // Handle screenshot result @@ -1204,7 +1205,10 @@ fn save_graph_png(image: &egui::ColorImage) { // Convert to PNG bytes let mut buffer = Vec::new(); - match img_buffer.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png) { + match img_buffer.write_to( + &mut std::io::Cursor::new(&mut buffer), + image::ImageFormat::Png, + ) { Ok(_) => { #[cfg(not(target_arch = "wasm32"))] { From 411767d9c93edd0e6c3ef944e354251363f4d5d2 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:33:17 +0100 Subject: [PATCH 095/127] fix(gui): prevent startup modal flicker and handle invalid config gracefully --- crates/fosr-gui/src/app.rs | 6 ++- .../src/visualization/visualization_tab.rs | 50 +++++++++++++++---- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index f1f9a2d1..d4037174 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -262,7 +262,8 @@ fn render_startup_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) } fn render_initial_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { - egui::Modal::new(egui::Id::new("startup_config_modal")).show(ctx, |ui| { + // Use the same modal ID as template selection to avoid flicker when transitioning + egui::Modal::new(egui::Id::new("startup_modal")).show(ctx, |ui| { ui.set_width(400.0); ui.heading("Welcome to Fos-R"); ui.add_space(4.0); @@ -297,7 +298,8 @@ fn render_initial_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) } fn render_template_selection_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { - egui::Modal::new(egui::Id::new("template_selection_modal")).show(ctx, |ui| { + // Use the same modal ID as initial modal to avoid flicker when transitioning + egui::Modal::new(egui::Id::new("startup_modal")).show(ctx, |ui| { ui.set_width(400.0); // Header with back button diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index cd3564ff..2942f2f9 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -467,11 +467,20 @@ fn handle_config_changes( state: &mut VisualizationTabState, configuration_file_state: &ConfigurationFileState, ) { - // Check if config was removed + // Check if config was removed or is empty + let config_is_empty = configuration_file_state + .config_file_content + .as_ref() + .map(|c| c.trim().is_empty()) + .unwrap_or(true); + let was_config_removed = state.config_content.is_some() && configuration_file_state.config_file_content.is_none(); - if was_config_removed { + // Only reset if we previously had a config (avoid resetting every frame when starting empty) + let should_reset = was_config_removed || (config_is_empty && state.config_content.is_some()); + + if should_reset { // Stop visualization if running, then reset to default if state.visualization_running { state.stop_visualization(); @@ -479,6 +488,12 @@ fn handle_config_changes( state.config_content = None; *state = VisualizationTabState::default(); state.reset_view_requested = true; + log::warn!("Config removed or empty, visualization reset to default"); + return; + } + + // If config is empty and we have no config loaded, nothing to do + if config_is_empty && state.config_content.is_none() { return; } @@ -500,14 +515,31 @@ fn handle_config_changes( state.stop_visualization(); } - let config = config::import_config(config_content); - state.update_from_config(&config); - state.config_content = Some(config_content.clone()); - // Only auto-restart if the user has started the visualization at least once - if state.user_has_started { - state.auto_start_countdown = Some(10); + // Try to parse the config, handle errors gracefully + // Use catch_unwind because import_config uses .expect() internally + let config_result = std::panic::catch_unwind(|| { + config::import_config(config_content) + }); + + match config_result { + Ok(config) => { + state.update_from_config(&config); + state.config_content = Some(config_content.clone()); + // Only auto-restart if the user has started the visualization at least once + if state.user_has_started { + state.auto_start_countdown = Some(10); + } + state.reset_view_requested = true; + } + Err(e) => { + // Log the error once and reset to default state instead of crashing + // Store the config content so we don't retry parsing every frame + log::error!("Failed to parse configuration: {:?}", e); + *state = VisualizationTabState::default(); + state.config_content = Some(config_content.clone()); + state.reset_view_requested = true; + } } - state.reset_view_requested = true; } } } From 8451434b9d5c0847909f5a1daa557673c8d5bbd5 Mon Sep 17 00:00:00 2001 From: {} <> Date: Thu, 19 Mar 2026 19:00:21 +0100 Subject: [PATCH 096/127] Refacto: configuration tab --- .../src/configuration/configuration_tab.rs | 905 +----------------- crates/fosr-gui/src/configuration/host_ui.rs | 335 +++++++ .../src/configuration/host_validation.rs | 80 ++ .../src/configuration/interface_ui.rs | 116 +++ crates/fosr-gui/src/configuration/mod.rs | 5 + .../fosr-gui/src/configuration/service_ui.rs | 204 ++++ .../fosr-gui/src/configuration/yaml_editor.rs | 146 +++ crates/fosr-gui/src/shared/ui_utils.rs | 26 + 8 files changed, 921 insertions(+), 896 deletions(-) create mode 100644 crates/fosr-gui/src/configuration/host_ui.rs create mode 100644 crates/fosr-gui/src/configuration/host_validation.rs create mode 100644 crates/fosr-gui/src/configuration/interface_ui.rs create mode 100644 crates/fosr-gui/src/configuration/service_ui.rs create mode 100644 crates/fosr-gui/src/configuration/yaml_editor.rs diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 503787c0..e39b2880 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -1,206 +1,12 @@ -use crate::shared::config_model::{Configuration, Host, Interface}; +use crate::configuration::{host_ui, host_validation, yaml_editor}; +use crate::shared::config_model::Configuration; use crate::shared::configuration_file::{ - ConfigurationFileState, configuration_file_picker, load_config_file_contents, parse_config_yaml, + ConfigurationFileState, configuration_file_picker, load_config_file_contents, +}; +use crate::shared::ui_utils::{ + edit_optional_multiline_string, edit_optional_string, required_label, }; -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; - -/// 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(); - - job.append( - text, - 0.0, - TextFormat { - color: ui.visuals().text_color(), - ..Default::default() - }, - ); - - job.append( - "*", - 0.0, - TextFormat { - color: egui::Color32::RED, - ..Default::default() - }, - ); - ui.label(job).on_hover_text("Mandatory"); -} - -/// 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; - } - } -} - -/// 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 { - let candidate = format!("192.168.0.{x}"); - if !ip_counts.contains_key(&candidate) { - return Some(candidate); - } - } - 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()); - } - 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(); - 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() { - 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)), - ("https", Some(443)), - ("ssh", Some(22)), - ("ftp", Some(21)), - ("smtp", Some(25)), - ("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::() { - return (name.to_string(), Some(p)); - } - } - (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}"), - None => name.to_string(), - } -} /// Represents the state of the configuration tab. pub struct ConfigurationTabState { @@ -236,7 +42,7 @@ pub fn show_configuration_tab_content( if !tab_state.is_code_mode { // Visual mode if let Some(model) = file_state.config_model.as_mut() { - ui_hosts_section(ui, model); + host_ui::ui_hosts_section(ui, model); ui.separator(); let meta_id = ui.make_persistent_id("metadata_section"); egui::collapsing_header::CollapsingState::load_with_default_open( @@ -270,7 +76,7 @@ pub fn show_configuration_tab_content( } } } else { - ui_yaml_editor(ui, file_state); + yaml_editor::ui_yaml_editor(ui, file_state); } // Update error flag (parse errors + host validation errors) @@ -278,7 +84,7 @@ pub fn show_configuration_tab_content( || file_state .config_model .as_ref() - .is_some_and(has_model_errors); + .is_some_and(host_validation::has_model_errors); } }); } @@ -321,696 +127,3 @@ fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { edit_optional_string(ui, "Version", &mut model.metadata.version, "0.1.0"); } - -/// Returns true if any host in the model has validation errors. -pub fn has_model_errors(model: &Configuration) -> bool { - let mut ip_counts: HashMap = HashMap::new(); - let mut mac_counts: HashMap = HashMap::new(); - for h in &model.hosts { - for iface in &h.interfaces { - *ip_counts.entry(iface.ip_addr.clone()).or_insert(0) += 1; - if let Some(mac) = &iface.mac_addr { - *mac_counts.entry(mac.clone()).or_insert(0) += 1; - } - } - } - model - .hosts - .iter() - .any(|host| !validate_host(host, &ip_counts, &mac_counts).is_empty()) -} - -/// Several host rendering. -fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { - ui.horizontal(|ui| { - ui.heading("Hosts"); - if ui - .button(egui_material_icons::icons::ICON_ADD) - .on_hover_text("Add host") - .clicked() - { - model.hosts.insert(0, Host::default()); - } - }); - ui.add_space(6.0); - - if model.hosts.is_empty() { - ui.label("No hosts in this configuration."); - return; - } - - // 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 { - *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, &ip_counts, &mac_counts, &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, - ip_counts: &HashMap, - mac_counts: &HashMap, - remove_request: &mut Option, -) { - let host_name = host_display_name(host); - 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.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| { - if ui - .button(egui_material_icons::icons::ICON_DELETE) - .on_hover_text("Remove host") - .clicked() - { - *remove_request = Some(index); - } - }); - }) - .body(|ui| { - ui_host_os_selector(ui, index, &mut host.os); - edit_optional_string(ui, "Hostname", &mut host.hostname, "host1"); - - ui.horizontal(|ui| { - 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)) - .changed() - { - host.usage = if (usage_val - 1.0).abs() < f32::EPSILON { - None - } else { - Some(usage_val) - }; - } - if ui - .button(egui_material_icons::icons::ICON_CLEAR) - .on_hover_text("Clear") - .clicked() - { - host.usage = None; - } - }); - - ui_host_type_selector(ui, index, host); - ui_host_client_protocols(ui, index, host); - ui.separator(); - 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"); - - 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(egui_material_icons::icons::ICON_CLEAR) - .on_hover_text("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| { - 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")) - .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(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear").clicked() { - host.r#type = None; - } - }); -} - -/// 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, "Specify what services the host is a client of."); - 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)) - .on_hover_text("Add protocol"); - - 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(), - ); - } - }); - }); - - 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); - } - }); -} - -/// Interface section rendering -fn ui_interfaces_section( - ui: &mut egui::Ui, - host_idx: usize, - host: &mut Host, - 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(ip_counts) { - host.interfaces.push(Interface { - ip_addr: ip, - mac_addr: Some(generate_mac_until_unique(mac_counts)), - 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."); - return; - } - - let mut iface_to_remove: Option = None; - - 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::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, if_idx == 0) - .show_header(ui, |ui| { - 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() - { - iface_to_remove = Some(if_idx); - } - }); - }) - .body(|ui| { - ui.add_space(4.0); - ui.horizontal(|ui| { - 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", &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, host_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, - host_idx: usize, - iface_idx: usize, - iface: &mut Interface, -) { - let svc_count = iface.services.len(); - let id = ui.make_persistent_id(("services", host_idx, iface_idx)); - - 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| { - 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() { - 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 { - iface.services.remove(idx); - } - }); -} - -/// 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 (svc_name, mut svc_port) = parse_service(svc_raw); - - let default_port = KNOWN_SERVICES - .iter() - .find(|(n, _)| *n == svc_name) - .and_then(|(_, p)| *p) - .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(is_custom_by_default)); - - ui.horizontal(|ui| { - let btn_text = format!("{} {}", svc_name, egui_material_icons::icons::ICON_CLEAR); - if ui - .button(btn_text) - .on_hover_text("Remove service") - .clicked() - { - *remove_request = Some(svc_idx); - } - - if ui - .checkbox(&mut custom_port_enabled, "Custom port") - .changed() - { - 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); -} - -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() { - ui.label("No configuration file selected."); - 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.spacing_mut().item_spacing.x = 0.0; - ui.horizontal_top(|ui| { - let available_height = ui.available_height(); - - let (gutter_rect, _) = ui.allocate_exact_size( - egui::vec2(gutter_width, available_height), - egui::Sense::hover(), - ); - - ui.painter().rect_filled(gutter_rect, 0.0, gutter_bg); - - 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 editor_changed { - state.config_file_content = Some(content); - parse_config_yaml(state); - } -} diff --git a/crates/fosr-gui/src/configuration/host_ui.rs b/crates/fosr-gui/src/configuration/host_ui.rs new file mode 100644 index 00000000..41074d14 --- /dev/null +++ b/crates/fosr-gui/src/configuration/host_ui.rs @@ -0,0 +1,335 @@ +use crate::configuration::{host_validation, interface_ui, service_ui}; +use crate::shared::config_model::{Configuration, Host}; +use crate::shared::ui_utils::{edit_optional_string, info_icon}; +use eframe::egui; +use std::collections::HashMap; + +/// Several host rendering. +pub fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { + ui.horizontal(|ui| { + ui.heading("Hosts"); + if ui + .button(egui_material_icons::icons::ICON_ADD) + .on_hover_text("Add host") + .clicked() + { + model.hosts.insert(0, Host::default()); + } + }); + ui.add_space(6.0); + + if model.hosts.is_empty() { + ui.label("No hosts in this configuration."); + return; + } + + // 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 { + *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, &ip_counts, &mac_counts, &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, + ip_counts: &HashMap, + mac_counts: &HashMap, + remove_request: &mut Option, +) { + let host_name = host_display_name(host); + let errors = host_validation::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.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| { + if ui + .button(egui_material_icons::icons::ICON_DELETE) + .on_hover_text("Remove host") + .clicked() + { + *remove_request = Some(index); + } + }); + }) + .body(|ui| { + ui_host_os_selector(ui, index, &mut host.os); + edit_optional_string(ui, "Hostname", &mut host.hostname, "host1"); + + ui.horizontal(|ui| { + 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)) + .changed() + { + host.usage = if (usage_val - 1.0).abs() < f32::EPSILON { + None + } else { + Some(usage_val) + }; + } + if ui + .button(egui_material_icons::icons::ICON_CLEAR) + .on_hover_text("Clear") + .clicked() + { + host.usage = None; + } + }); + + ui_host_type_selector(ui, index, host); + ui_host_client_protocols(ui, index, host); + ui.separator(); + interface_ui::ui_interfaces_section(ui, index, host, ip_counts, mac_counts); + }); +} + +/// 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)); + } + } +} + +/// 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"); + + 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(egui_material_icons::icons::ICON_CLEAR) + .on_hover_text("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| { + 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")) + .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(egui_material_icons::icons::ICON_CLEAR).on_hover_text("Clear").clicked() { + host.r#type = None; + } + }); +} + +/// 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, "Specify what services the host is a client of."); + 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)) + .on_hover_text("Add protocol"); + + 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 service_ui::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(), + ); + } + }); + }); + + 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); + } + }); +} + +/// 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() { + 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() +} diff --git a/crates/fosr-gui/src/configuration/host_validation.rs b/crates/fosr-gui/src/configuration/host_validation.rs new file mode 100644 index 00000000..9593c8f8 --- /dev/null +++ b/crates/fosr-gui/src/configuration/host_validation.rs @@ -0,0 +1,80 @@ +use crate::shared::config_model::{Configuration, Host}; +use std::collections::HashMap; + +/// Function to validate if a host is correct +pub 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()); + } + 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(); + errors +} + +/// Returns true if any host in the model has validation errors. +pub fn has_model_errors(model: &Configuration) -> bool { + let mut ip_counts: HashMap = HashMap::new(); + let mut mac_counts: HashMap = HashMap::new(); + for h in &model.hosts { + for iface in &h.interfaces { + *ip_counts.entry(iface.ip_addr.clone()).or_insert(0) += 1; + if let Some(mac) = &iface.mac_addr { + *mac_counts.entry(mac.clone()).or_insert(0) += 1; + } + } + } + model + .hosts + .iter() + .any(|host| !validate_host(host, &ip_counts, &mac_counts).is_empty()) +} + +/// 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()) +} diff --git a/crates/fosr-gui/src/configuration/interface_ui.rs b/crates/fosr-gui/src/configuration/interface_ui.rs new file mode 100644 index 00000000..013bc037 --- /dev/null +++ b/crates/fosr-gui/src/configuration/interface_ui.rs @@ -0,0 +1,116 @@ +use crate::configuration::service_ui; +use crate::shared::config_model::{Host, Interface}; +use crate::shared::ui_utils::{edit_optional_string, required_label}; +use eframe::egui; +use std::collections::HashMap; + +/// 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 { + let candidate = format!("192.168.0.{x}"); + if !ip_counts.contains_key(&candidate) { + return Some(candidate); + } + } + None +} + +/// Interface section rendering +pub fn ui_interfaces_section( + ui: &mut egui::Ui, + host_idx: usize, + host: &mut Host, + 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(ip_counts) { + host.interfaces.push(Interface { + ip_addr: ip, + mac_addr: Some(generate_mac_until_unique(mac_counts)), + 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."); + return; + } + + let mut iface_to_remove: Option = None; + + 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::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, if_idx == 0) + .show_header(ui, |ui| { + 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() + { + iface_to_remove = Some(if_idx); + } + }); + }) + .body(|ui| { + ui.add_space(4.0); + ui.horizontal(|ui| { + 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", &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"); + } + } + service_ui::ui_services_section(ui, if_idx, host_idx, iface); + }); + ui.add_space(6.0); + } + + if let Some(idx) = iface_to_remove { + host.interfaces.remove(idx); + } +} + +/// 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; + } + } +} diff --git a/crates/fosr-gui/src/configuration/mod.rs b/crates/fosr-gui/src/configuration/mod.rs index e039e2c1..80f209ca 100644 --- a/crates/fosr-gui/src/configuration/mod.rs +++ b/crates/fosr-gui/src/configuration/mod.rs @@ -1 +1,6 @@ pub mod configuration_tab; +pub mod host_ui; +pub mod host_validation; +pub mod interface_ui; +pub mod service_ui; +pub mod yaml_editor; diff --git a/crates/fosr-gui/src/configuration/service_ui.rs b/crates/fosr-gui/src/configuration/service_ui.rs new file mode 100644 index 00000000..ada2a698 --- /dev/null +++ b/crates/fosr-gui/src/configuration/service_ui.rs @@ -0,0 +1,204 @@ +use crate::shared::config_model::Interface; +use crate::shared::ui_utils::info_icon; +use eframe::egui; + +pub const KNOWN_SERVICES: &[(&str, Option)] = &[ + ("http", Some(80)), + ("https", Some(443)), + ("ssh", Some(22)), + ("ftp", Some(21)), + ("smtp", Some(25)), + ("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::() { + return (name.to_string(), Some(p)); + } + } + (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}"), + None => name.to_string(), + } +} + +/// Service section rendering +pub 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", host_idx, iface_idx)); + + 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| { + 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() { + 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 { + iface.services.remove(idx); + } + }); +} + +/// 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 (svc_name, mut svc_port) = parse_service(svc_raw); + + let default_port = KNOWN_SERVICES + .iter() + .find(|(n, _)| *n == svc_name) + .and_then(|(_, p)| *p) + .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(is_custom_by_default)); + + ui.horizontal(|ui| { + let btn_text = format!("{} {}", svc_name, egui_material_icons::icons::ICON_CLEAR); + if ui + .button(btn_text) + .on_hover_text("Remove service") + .clicked() + { + *remove_request = Some(svc_idx); + } + + if ui + .checkbox(&mut custom_port_enabled, "Custom port") + .changed() + { + 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); +} diff --git a/crates/fosr-gui/src/configuration/yaml_editor.rs b/crates/fosr-gui/src/configuration/yaml_editor.rs new file mode 100644 index 00000000..870ff9f5 --- /dev/null +++ b/crates/fosr-gui/src/configuration/yaml_editor.rs @@ -0,0 +1,146 @@ +use crate::shared::configuration_file::{ConfigurationFileState, parse_config_yaml}; +use eframe::egui; + +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 +pub fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { + if state.config_file_content.is_none() { + ui.label("No configuration file selected."); + 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.spacing_mut().item_spacing.x = 0.0; + ui.horizontal_top(|ui| { + let available_height = ui.available_height(); + + let (gutter_rect, _) = ui.allocate_exact_size( + egui::vec2(gutter_width, available_height), + egui::Sense::hover(), + ); + + ui.painter().rect_filled(gutter_rect, 0.0, gutter_bg); + + 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 editor_changed { + state.config_file_content = Some(content); + parse_config_yaml(state); + } +} diff --git a/crates/fosr-gui/src/shared/ui_utils.rs b/crates/fosr-gui/src/shared/ui_utils.rs index 896d9de9..18cca512 100644 --- a/crates/fosr-gui/src/shared/ui_utils.rs +++ b/crates/fosr-gui/src/shared/ui_utils.rs @@ -1,3 +1,5 @@ +use crate::shared::ui_utils::egui::TextFormat; +use crate::shared::ui_utils::egui::text::LayoutJob; use eframe::egui; /// Display a small info icon with a tooltip. @@ -150,3 +152,27 @@ pub fn edit_optional_multiline_string( *value = None; } } + +// Helper for required label with red * +pub 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() + }, + ); + ui.label(job).on_hover_text("Mandatory"); +} From 063df96ba04f02715d670f3024db2240438fbb10 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:58:06 +0100 Subject: [PATCH 097/127] fix(gui): proper egui imports --- crates/fosr-gui/src/generation/generation_tab.rs | 3 +-- crates/fosr-gui/src/shared/ui_utils.rs | 4 +--- crates/fosr-gui/src/timepicker/button.rs | 3 +-- crates/fosr-gui/src/timepicker/popup.rs | 7 +++---- crates/fosr-gui/src/visualization/visualization_shapes.rs | 3 +-- 5 files changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index 5c78bb9e..e9f908a3 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -13,8 +13,7 @@ use crate::shared::ui_utils::info_icon; use crate::timepicker::TimePickerButton; use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone}; use chrono_tz::Tz; -use eframe::egui; -use eframe::egui::Widget; +use eframe::egui::{self, Widget}; use egui_extras::DatePickerButton; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/fosr-gui/src/shared/ui_utils.rs b/crates/fosr-gui/src/shared/ui_utils.rs index 18cca512..fcdeef80 100644 --- a/crates/fosr-gui/src/shared/ui_utils.rs +++ b/crates/fosr-gui/src/shared/ui_utils.rs @@ -1,6 +1,4 @@ -use crate::shared::ui_utils::egui::TextFormat; -use crate::shared::ui_utils::egui::text::LayoutJob; -use eframe::egui; +use eframe::egui::{self, TextFormat, text::LayoutJob}; /// Display a small info icon with a tooltip. pub fn info_icon(ui: &mut egui::Ui, tooltip: &str) { diff --git a/crates/fosr-gui/src/timepicker/button.rs b/crates/fosr-gui/src/timepicker/button.rs index a91bebe8..dba39cb1 100644 --- a/crates/fosr-gui/src/timepicker/button.rs +++ b/crates/fosr-gui/src/timepicker/button.rs @@ -3,8 +3,7 @@ // 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}; +use eframe::egui::{self, Area, Button, Frame, InnerResponse, Key, Order, RichText, Widget}; use super::popup::TimePickerPopup; diff --git a/crates/fosr-gui/src/timepicker/popup.rs b/crates/fosr-gui/src/timepicker/popup.rs index 3ac0981d..bc873318 100644 --- a/crates/fosr-gui/src/timepicker/popup.rs +++ b/crates/fosr-gui/src/timepicker/popup.rs @@ -5,10 +5,9 @@ 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 eframe::egui::{ + self, Align2, Color32, DragValue, FontId, Id, Layout, Painter, Pos2, Response, RichText, Sense, + Ui, Vec2, }; use super::button::TimePickerButtonState; diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/visualization_shapes.rs index fbe85b56..e1d22f01 100644 --- a/crates/fosr-gui/src/visualization/visualization_shapes.rs +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -3,8 +3,7 @@ use crate::visualization::visualization_tab::{ EdgeData, EdgeState, LinkDirection, NodeData, NodeType, }; -use eframe::egui; -use egui::{Color32, Pos2, Rect, Shape, TextureOptions, Vec2, load::SizeHint}; +use eframe::egui::{self, Color32, Pos2, Rect, Shape, TextureOptions, Vec2, load::SizeHint}; use egui_graphs::{DisplayEdge, DisplayNode, DrawContext, Node, NodeProps}; use fosr_lib::L7Proto; From 706b722020d1215fe18ddba34fedaa7920a5f952 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:02:06 +0100 Subject: [PATCH 098/127] feat(gui): improve Wireshark integration and navbar UX --- Cargo.lock | 21 +- crates/fosr-gui/Cargo.toml | 1 + crates/fosr-gui/src/app.rs | 187 ++++++++++++------ .../fosr-gui/src/generation/generation_tab.rs | 80 +++++--- .../src/visualization/visualization_tab.rs | 4 +- 5 files changed, 189 insertions(+), 104 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69d3c38d..72c7ecb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1560,12 +1560,6 @@ dependencies = [ "log", ] -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - [[package]] name = "env_logger" version = "0.11.8" @@ -1850,6 +1844,7 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "web-time", + "which", ] [[package]] @@ -5392,13 +5387,11 @@ dependencies = [ [[package]] name = "which" -version = "8.0.0" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" dependencies = [ - "env_home", - "rustix 1.1.4", - "winsafe", + "libc", ] [[package]] @@ -5914,12 +5907,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index ca2a0d72..6b8b096e 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -55,4 +55,5 @@ getrandom = { version = "0.4", features = ["wasm_js"] } env_logger = { version = "0.11.8", default-features = false, features = ["auto-color", "humantime"] } tempfile = "3.26.0" open = "5.3.3" +which = "8.0.2" diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index d4037174..a881c0b6 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -41,6 +41,10 @@ pub struct FosrApp { configuration_tab_state: ConfigurationTabState, visualization_tab_state: VisualizationTabState, generation_tab_state: GenerationTabState, + /// Whether to show the close confirmation dialog + show_close_confirmation: bool, + /// Whether the user has confirmed they want to close + allowed_to_close: bool, } impl eframe::App for FosrApp { @@ -85,6 +89,25 @@ impl eframe::App for FosrApp { #[cfg(target_arch = "wasm32")] ctx.set_theme(egui::Theme::Dark); + // Handle close confirmation if there are active Wireshark sessions + #[cfg(not(target_arch = "wasm32"))] + { + let has_active_sessions = self + .generation_tab_state + .temp_pcap_files + .iter() + .any(|(handle, _)| !handle.is_finished()); + + if ctx.input(|i| i.viewport().close_requested()) { + if self.allowed_to_close { + // Do nothing, let the app close + } else if has_active_sessions { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.show_close_confirmation = true; + } + } + } + // Startup modal: choose configuration source if !self.configuration_file_state.config_chosen { // Render empty CentralPanel for background, then modal on top @@ -93,87 +116,127 @@ impl eframe::App for FosrApp { return; } + // Close confirmation dialog + #[cfg(not(target_arch = "wasm32"))] + if self.show_close_confirmation { + egui::Modal::new(egui::Id::new("close_confirmation_modal")).show(ctx, |ui| { + ui.set_width(370.0); + ui.heading("Confirm Exit"); + ui.add_space(8.0); + ui.label("You have Wireshark session(s) open with temporary PCAP files."); + ui.label("Closing will delete these files."); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + self.show_close_confirmation = false; + } + if ui.button("Exit").clicked() { + self.show_close_confirmation = false; + self.allowed_to_close = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + }); + } + // 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 - egui::MenuBar::new().ui(ui, |ui| { - if ui - .selectable_label( - self.current_tab == CurrentTab::Visualization, - "Live Preview", + egui::TopBottomPanel::top("top_panel") + .frame(egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin::symmetric(4, 3))) + .show(ctx, |ui| { + // Add a Menu Bar to host the tabs buttons + egui::MenuBar::new().ui(ui, |ui| { + ui.spacing_mut().button_padding = egui::vec2(5.0, 2.0); + let tab_text_size = 14.0; + let live_preview_button = egui::Button::new( + egui::RichText::new("Live Preview").size(tab_text_size), ) - .on_hover_text("Simulation of network traffic based on the current configuration. No real traffic is generated.") - .clicked() - { - self.current_tab = CurrentTab::Visualization; - } - { + .selected(self.current_tab == CurrentTab::Visualization); + + let has_errors = self.configuration_file_state.has_errors; + + let response = ui.add_enabled(!has_errors, live_preview_button); + + let response = if has_errors { + response.on_disabled_hover_text("Configuration is invalid. Fix errors in the Configuration tab to enable Live Preview.") + } else { + response.on_hover_text("Simulation of network traffic based on the current configuration. No real traffic is generated.") + }; + + if !has_errors && response.clicked() { + self.current_tab = CurrentTab::Visualization; + } + let label_text = if self.configuration_file_state.has_errors { - egui::RichText::new("⚠ Configuration").color(egui::Color32::RED) + egui::RichText::new("⚠ Configuration").color(egui::Color32::RED).size(tab_text_size) } else { - egui::RichText::new("Configuration") + egui::RichText::new("Configuration").size(tab_text_size) }; if ui - .selectable_label( - self.current_tab == CurrentTab::Configuration, - label_text, - ) + .add(egui::Button::new(label_text) + .selected(self.current_tab == CurrentTab::Configuration)) .on_hover_text("Edit the network configuration: hosts, interfaces, and services.") .clicked() { self.current_tab = CurrentTab::Configuration; + }; + + let generation_button = egui::Button::new( + egui::RichText::new("Generation").size(tab_text_size), + ).selected(self.current_tab == CurrentTab::Generation); + let response = ui.add_enabled(!has_errors, generation_button); + let response = if has_errors { + response.on_disabled_hover_text("Configuration is invalid. Fix errors in the Configuration tab to enable Generation.") + } else { + response.on_hover_text("Generate a PCAP file from the current network configuration.") + }; + if !has_errors && response.clicked() { + self.current_tab = CurrentTab::Generation; + } + if ui + .add(egui::Button::new( + egui::RichText::new("About").size(tab_text_size), + ).selected(self.current_tab == CurrentTab::About)) + .on_hover_text("About Fos-R and its authors.") + .clicked() + { + self.current_tab = CurrentTab::About; } - } - if ui - .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::Generation; - } - 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; - } - // 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); + // 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(); + // 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(); + } } } } } - } + }); }); }); - }); // The Central Panel is the region left after adding the Top, Bottom and Side panels. egui::CentralPanel::default().show(ctx, |ui| { diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs index e9f908a3..a463802b 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -63,13 +63,22 @@ pub struct GenerationTabState { pub start_date: NaiveDate, pub start_hour: NaiveTime, pub output_file_name: String, - /// Holds the temporary PCAP file opened in Wireshark. + /// Holds temporary PCAP files opened in Wireshark along with their background thread handles. /// - /// `NamedTempFile` automatically deletes the file when dropped. By storing it here, - /// the file stays alive until: (1) the app closes, or (2) a new file is opened - /// (which replaces this value, dropping the previous file). + /// `NamedTempFile` automatically deletes the file when dropped. By storing them here, + /// the files stay alive until the app closes. Multiple files can be open simultaneously + /// for comparison purposes. + /// + /// The `JoinHandle` comes from `open::with_in_background()`. The thread stays alive while + /// Wireshark is running, so `is_finished()` can detect active sessions. + #[cfg(not(target_arch = "wasm32"))] + pub temp_pcap_files: Vec<( + std::thread::JoinHandle>, + tempfile::NamedTempFile, + )>, + /// Whether Wireshark is available on the system #[cfg(not(target_arch = "wasm32"))] - pub temp_pcap_file: Option, + pub wireshark_available: bool, } impl Default for GenerationTabState { @@ -100,7 +109,9 @@ impl Default for GenerationTabState { start_hour: Local::now().time(), output_file_name: "output.pcap".to_string(), #[cfg(not(target_arch = "wasm32"))] - temp_pcap_file: None, + temp_pcap_files: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + wireshark_available: which::which("wireshark").is_ok(), } } } @@ -495,13 +506,15 @@ pub fn show_generation_tab_content( .size(13.0), ) .min_size(egui::vec2(75.0, 24.0)); - if ui - .add(open_button) - .on_hover_text("Open with default application (e.g. Wireshark)") - .clicked() - { + let response = ui.add_enabled(state.wireshark_available, open_button); + let response = if state.wireshark_available { + response.on_hover_text("Open in Wireshark") + } else { + response.on_disabled_hover_text("Wireshark not found in PATH") + }; + if response.clicked() { if let Some(ref pcap_bytes) = state.pcap_bytes { - match open_in_wireshark(pcap_bytes, &mut state.temp_pcap_file) { + match open_in_wireshark(pcap_bytes, &mut state.temp_pcap_files) { Ok(_) => { log::info!("Opened PCAP in Wireshark"); } @@ -541,15 +554,23 @@ pub fn show_generation_tab_content( } } -/// Opens the PCAP data in the system's default application (e.g., Wireshark). +/// Opens the PCAP data in Wireshark. /// -/// This creates a temporary file with `.pcap` extension and opens it using `open::that()`. -/// The `NamedTempFile` handle is stored in `temp_file_storage` to keep the file alive. -/// When the handle is dropped (app closes or a new file is opened), the temp file is deleted. +/// This creates a temporary file with `.pcap` extension and opens it in Wireshark. +/// The `NamedTempFile` handle and `JoinHandle` are stored to keep the file alive. +/// When the handle is dropped (app closed), the temp file is deleted. +/// +/// Platform-specific behavior: +/// - **Linux**: Uses `open::with_in_background()` which spawns Wireshark directly. +/// The thread stays alive while Wireshark is running. +/// - **macOS**: Uses `open -n -W -a Wireshark` which waits for the app to close. #[cfg(not(target_arch = "wasm32"))] fn open_in_wireshark( pcap_bytes: &[u8], - temp_file_storage: &mut Option, + temp_files: &mut Vec<( + std::thread::JoinHandle>, + tempfile::NamedTempFile, + )>, ) -> Result<(), String> { use std::io::Write; @@ -567,13 +588,28 @@ fn open_in_wireshark( // Get the path let path = temp_file.path().to_path_buf(); - log::info!("Opening PCAP file at: {}", path.display()); + log::info!("Opening PCAP file in Wireshark: {}", path.display()); + + // Platform-specific launch + #[cfg(target_os = "macos")] + let handle = { + // On macOS, use `open -n -W -a Wireshark`: + // - `-n` opens a new instance even if one is already running + // - `-W` waits for the app to close + std::thread::spawn(move || { + std::process::Command::new("open") + .args(["-n", "-W", "-a", "Wireshark"]) + .arg(&path) + .status() + .map(|_| ()) + }) + }; - // Store the temp file to keep it alive until app closes - *temp_file_storage = Some(temp_file); + #[cfg(not(target_os = "macos"))] + let handle = open::with_in_background(&path, "wireshark"); - // Open with system default application (Wireshark for .pcap files) - open::that(&path).map_err(|e| format!("Failed to open file: {e}"))?; + // Store the handle and temp file to keep them alive until app closes + temp_files.push((handle, temp_file)); Ok(()) } diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 2942f2f9..8359c761 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -517,9 +517,7 @@ fn handle_config_changes( // Try to parse the config, handle errors gracefully // Use catch_unwind because import_config uses .expect() internally - let config_result = std::panic::catch_unwind(|| { - config::import_config(config_content) - }); + let config_result = std::panic::catch_unwind(|| config::import_config(config_content)); match config_result { Ok(config) => { From b1abc67dab0f6a561191244a5905cd1e1ad83ce8 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:13:36 +0100 Subject: [PATCH 099/127] feat(gui/visualization): add text and color to start/continue/stop buttons --- .../src/visualization/visualization_tab.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 8359c761..2dc1f751 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -27,6 +27,9 @@ use web_time::Instant; /// Special IP address representing "The Internet" node pub const INTERNET_IP: Ipv4Addr = Ipv4Addr::new(0, 0, 0, 1); +/// Color for stop/danger buttons +const STOP_BUTTON_COLOR: egui::Color32 = egui::Color32::from_rgb(200, 80, 80); + /// Node type for visualization (extends HostType with Internet) #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NodeType { @@ -1063,8 +1066,13 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { ui.horizontal(|ui| { if !state.visualization_running { // Play / Continue: resume without resetting flow counts - let play_tooltip = if state.user_has_started { "Continue" } else { "Start" }; - if ui.button(egui_material_icons::icons::ICON_PLAY_ARROW).on_hover_text(play_tooltip).clicked() { + let play_text = if state.user_has_started { "Continue" } else { "Start" }; + let accent = ui.visuals().selection.bg_fill; + let play_button = egui::Button::new( + egui::RichText::new(format!("{} {}", egui_material_icons::icons::ICON_PLAY_ARROW, play_text)), + ) + .fill(accent); + if ui.add(play_button).clicked() { state.user_has_started = true; // Pass the user config if loaded, otherwise None (uses default BN model) let config = state.config_content.clone(); @@ -1085,7 +1093,11 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { } } } else { - if ui.button(egui_material_icons::icons::ICON_STOP).on_hover_text("Stop").clicked() { + let stop_button = egui::Button::new( + egui::RichText::new(format!("{} Stop", egui_material_icons::icons::ICON_STOP)), + ) + .fill(STOP_BUTTON_COLOR); + if ui.add(stop_button).clicked() { state.stop_visualization(); } } From bb1aad4bdf8a48e93671cd08146464be61391bf4 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:14:37 +0100 Subject: [PATCH 100/127] fix(gui/visualization): only auto-restart if preview was already running --- crates/fosr-gui/src/visualization/visualization_tab.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs index 2dc1f751..ad2a7716 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -526,8 +526,8 @@ fn handle_config_changes( Ok(config) => { state.update_from_config(&config); state.config_content = Some(config_content.clone()); - // Only auto-restart if the user has started the visualization at least once - if state.user_has_started { + // Only auto-restart if visualization was running before config change + if was_running { state.auto_start_countdown = Some(10); } state.reset_view_requested = true; From 7b5ec32e982de556ec54bf7c7f77e1f770d32dea Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:34:37 +0100 Subject: [PATCH 101/127] feat(gui): merge generation and live preview tabs + split code --- .gitlab-ci.yml | 2 +- Cargo.lock | 28 +- crates/fosr-gui/Cargo.toml | 2 +- crates/fosr-gui/src/about_tab.rs | 11 +- crates/fosr-gui/src/app.rs | 143 +- .../src/generation/generation_state.rs | 105 ++ .../fosr-gui/src/generation/generation_tab.rs | 615 -------- .../generation/generation_ui_components.rs | 22 +- .../src/generation/generation_validation.rs | 2 +- .../src/generation/generation_wireshark.rs | 59 + crates/fosr-gui/src/generation/mod.rs | 9 +- crates/fosr-gui/src/lib.rs | 1 + crates/fosr-gui/src/main.rs | 1 + crates/fosr-gui/src/run/mod.rs | 5 + crates/fosr-gui/src/run/run_state.rs | 21 + crates/fosr-gui/src/run/run_tab.rs | 972 +++++++++++++ crates/fosr-gui/src/visualization/mod.rs | 11 +- .../src/visualization/visualization_modal.rs | 223 +++ .../visualization/visualization_overlays.rs | 245 ++++ .../visualization/visualization_screenshot.rs | 82 ++ .../src/visualization/visualization_shapes.rs | 4 +- .../src/visualization/visualization_state.rs | 417 ++++++ .../src/visualization/visualization_tab.rs | 1280 ----------------- public/styles.css | 2 +- 24 files changed, 2247 insertions(+), 2015 deletions(-) create mode 100644 crates/fosr-gui/src/generation/generation_state.rs delete mode 100644 crates/fosr-gui/src/generation/generation_tab.rs create mode 100644 crates/fosr-gui/src/generation/generation_wireshark.rs create mode 100644 crates/fosr-gui/src/run/mod.rs create mode 100644 crates/fosr-gui/src/run/run_state.rs create mode 100644 crates/fosr-gui/src/run/run_tab.rs create mode 100644 crates/fosr-gui/src/visualization/visualization_modal.rs create mode 100644 crates/fosr-gui/src/visualization/visualization_overlays.rs create mode 100644 crates/fosr-gui/src/visualization/visualization_screenshot.rs create mode 100644 crates/fosr-gui/src/visualization/visualization_state.rs delete mode 100644 crates/fosr-gui/src/visualization/visualization_tab.rs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7e2e33cf..ffee2d80 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -162,7 +162,7 @@ build-wasm32: image: rust script: - rustup target add wasm32-unknown-unknown - - cargo install wasm-bindgen-cli --version 0.2.108 + - cargo install wasm-bindgen-cli --version 0.2.114 - cargo build -p fosr-gui -r --target wasm32-unknown-unknown --no-default-features - wasm-bindgen --out-dir public --target web target/wasm32-unknown-unknown/release/fosr_gui.wasm --no-typescript artifacts: diff --git a/Cargo.lock b/Cargo.lock index 72c7ecb5..fad79bd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2552,9 +2552,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -4948,9 +4948,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -4961,9 +4961,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", "futures-util", @@ -4975,9 +4975,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4985,9 +4985,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -4998,9 +4998,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -5176,9 +5176,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index 6b8b096e..8c7f9436 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -31,7 +31,6 @@ chrono-tz = "0.10.4" eframe = "0.33.3" egui_extras = { version = "0.33.3", features = ["chrono", "all_loaders", "syntect", "serde"] } log = "0.4.29" -wasm-bindgen = "0.2.108" fosr-lib = { version = "1.0.0-dev", path = "../fosr-lib", features = ["wasm_js"] } rfd = "0.17.2" humantime = "2.3.0" @@ -46,6 +45,7 @@ rand = "0.10.0" image = { version = "0.25", default-features = false, features = ["png"] } [target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2.114" gloo-timers = { version = "0.3", features = ["futures"] } wasm-bindgen-futures = "0.4.58" web-sys = { version = "0.3.85", features = ["Element", "Document", "Window"] } diff --git a/crates/fosr-gui/src/about_tab.rs b/crates/fosr-gui/src/about_tab.rs index f2c67310..476bc30a 100644 --- a/crates/fosr-gui/src/about_tab.rs +++ b/crates/fosr-gui/src/about_tab.rs @@ -18,19 +18,16 @@ pub fn show_about_tab_content(ui: &mut egui::Ui) { ui.heading("Usage Guide"); ui.add_space(5.0); - ui.label("This GUI helps you design a Fos-R network configuration visually and generate synthetic network traffic as PCAP files. The Live Preview gives you immediate feedback on your topology before generating. Use the CLI to inject this traffic into a real network."); + ui.label("This GUI helps you design a Fos-R network configuration visually and generate synthetic network traffic as PCAP files. Export your configuration to use it with the CLI, which can generate and inject live traffic directly on a network."); ui.add_space(10.0); - ui.label(egui::RichText::new("Live Preview").strong()); - ui.label("Visualize what Fos-R would generate based on your configuration. This is a real-time simulation, not the actual generation. Use it to quickly verify your network topology. Click on a node to adjust some properties."); + ui.label(egui::RichText::new("Run").strong()); + ui.label("• Live Preview: visualize what Fos-R would generate based on your configuration. This is a real-time simulation, not the actual generation. Use it to quickly verify your network topology. Click on a node to adjust some properties."); + ui.label("• Generation: generate the PCAP file from your configuration. Set parameters like duration and start time. For reproducible results, use a fixed seed. On desktop, open the result in Wireshark to take a quick look."); ui.add_space(8.0); ui.label(egui::RichText::new("Configuration").strong()); ui.label("Define your network: hosts, their interfaces, and the services they provide. Create a configuration from a template or import an existing YAML file. Switch between visual editing and raw YAML at any time."); - ui.add_space(8.0); - - ui.label(egui::RichText::new("Generation").strong()); - ui.label("Generate the PCAP file from your configuration. Set parameters like duration and start time. For reproducible results, use a fixed seed. On desktop, open the result in Wireshark to take a quick look."); ui.add_space(15.0); ui.heading("Repository Information"); diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index a881c0b6..0cc55b3d 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -2,34 +2,32 @@ use crate::about_tab::show_about_tab_content; use crate::configuration::configuration_tab::{ ConfigurationTabState, show_configuration_tab_content, }; -use crate::generation::generation_tab::{GenerationTabState, show_generation_tab_content}; +use crate::run::{RunState, show_run_tab_content}; #[cfg(target_arch = "wasm32")] use crate::shared::configuration_file::poll_file_import; use crate::shared::configuration_file::{ ConfigurationFileState, StartupModalState, trigger_file_import, }; use crate::templates::{all_templates, load_template_by_id}; -use crate::visualization::visualization_tab::{ - VisualizationTabState, show_visualization_tab_content, -}; use eframe::egui; -#[cfg(not(target_arch = "wasm32"))] use eframe::egui::global_theme_preference_switch; #[derive(Debug, PartialEq, Eq, Clone, Copy)] enum CurrentTab { Configuration, - Visualization, - Generation, + Run, About, } impl Default for CurrentTab { fn default() -> Self { - CurrentTab::Visualization + CurrentTab::Run } } +#[cfg(target_arch = "wasm32")] +pub const DEFAULT_ZOOM: f32 = 1.2; +#[cfg(not(target_arch = "wasm32"))] pub const DEFAULT_ZOOM: f32 = 1.4; #[derive(Default)] @@ -37,13 +35,15 @@ pub struct FosrApp { current_tab: CurrentTab, style_initialized: bool, images_preloaded: bool, + zoom_factor: f32, configuration_file_state: ConfigurationFileState, configuration_tab_state: ConfigurationTabState, - visualization_tab_state: VisualizationTabState, - generation_tab_state: GenerationTabState, + run_state: RunState, /// Whether to show the close confirmation dialog + #[cfg(not(target_arch = "wasm32"))] show_close_confirmation: bool, /// Whether the user has confirmed they want to close + #[cfg(not(target_arch = "wasm32"))] allowed_to_close: bool, } @@ -51,8 +51,14 @@ impl eframe::App for FosrApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Set default zoom once if !self.style_initialized { - ctx.options_mut(|option| option.zoom_factor = DEFAULT_ZOOM); + self.zoom_factor = DEFAULT_ZOOM; + ctx.options_mut(|option| option.zoom_factor = self.zoom_factor); ctx.style_mut(|s| s.interaction.tooltip_delay = 0.1); + + // On web, use dark theme to match with the Fos-R website's theme + #[cfg(target_arch = "wasm32")] + ctx.set_theme(egui::Theme::Dark); + self.style_initialized = true; } @@ -85,15 +91,12 @@ impl eframe::App for FosrApp { self.images_preloaded = true; } - // On web, use dark theme to match with the Fos-R website's theme - #[cfg(target_arch = "wasm32")] - ctx.set_theme(egui::Theme::Dark); - // Handle close confirmation if there are active Wireshark sessions #[cfg(not(target_arch = "wasm32"))] { let has_active_sessions = self - .generation_tab_state + .run_state + .generation .temp_pcap_files .iter() .any(|(handle, _)| !handle.is_finished()); @@ -147,25 +150,28 @@ impl eframe::App for FosrApp { egui::MenuBar::new().ui(ui, |ui| { ui.spacing_mut().button_padding = egui::vec2(5.0, 2.0); let tab_text_size = 14.0; - let live_preview_button = egui::Button::new( - egui::RichText::new("Live Preview").size(tab_text_size), - ) - .selected(self.current_tab == CurrentTab::Visualization); let has_errors = self.configuration_file_state.has_errors; - let response = ui.add_enabled(!has_errors, live_preview_button); + // Run tab (combines Live Preview + Generation) + let run_button = egui::Button::new( + egui::RichText::new("Run").size(tab_text_size), + ) + .selected(self.current_tab == CurrentTab::Run); + + let response = ui.add_enabled(!has_errors, run_button); let response = if has_errors { - response.on_disabled_hover_text("Configuration is invalid. Fix errors in the Configuration tab to enable Live Preview.") + response.on_disabled_hover_text("Configuration is invalid. Fix errors in the Configuration tab to enable Run.") } else { - response.on_hover_text("Simulation of network traffic based on the current configuration. No real traffic is generated.") + response.on_hover_text("Live preview and PCAP generation from the current configuration.") }; if !has_errors && response.clicked() { - self.current_tab = CurrentTab::Visualization; + self.current_tab = CurrentTab::Run; } + // Configuration tab let label_text = if self.configuration_file_state.has_errors { egui::RichText::new("⚠ Configuration").color(egui::Color32::RED).size(tab_text_size) } else { @@ -180,18 +186,7 @@ impl eframe::App for FosrApp { self.current_tab = CurrentTab::Configuration; }; - let generation_button = egui::Button::new( - egui::RichText::new("Generation").size(tab_text_size), - ).selected(self.current_tab == CurrentTab::Generation); - let response = ui.add_enabled(!has_errors, generation_button); - let response = if has_errors { - response.on_disabled_hover_text("Configuration is invalid. Fix errors in the Configuration tab to enable Generation.") - } else { - response.on_hover_text("Generate a PCAP file from the current network configuration.") - }; - if !has_errors && response.clicked() { - self.current_tab = CurrentTab::Generation; - } + // About tab if ui .add(egui::Button::new( egui::RichText::new("About").size(tab_text_size), @@ -204,10 +199,6 @@ impl eframe::App for FosrApp { // 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")] { @@ -234,42 +225,68 @@ impl eframe::App for FosrApp { } } } + + #[cfg(not(target_arch = "wasm32"))] + ui.add_space(4.0); + + // Show the theme switch + global_theme_preference_switch(ui); + + // Zoom controls + if ui + .button(egui_material_icons::icons::ICON_ADD) + .on_hover_text("Zoom in") + .clicked() + { + let current_zoom = ctx.zoom_factor(); + let new_zoom = (current_zoom + 0.1).min(3.0); + ctx.set_zoom_factor(new_zoom); + self.zoom_factor = new_zoom; + } + ui.label(format!("{:.0}%", ctx.zoom_factor() * 100.0)); + if ui + .button(egui_material_icons::icons::ICON_REMOVE) + .on_hover_text("Zoom out") + .clicked() + { + let current_zoom = ctx.zoom_factor(); + let new_zoom = (current_zoom - 0.1).max(0.5); + ctx.set_zoom_factor(new_zoom); + self.zoom_factor = new_zoom; + } }); }); }); // 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 - 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 => { + // Display the tab content depending on the currently select tab + // Note: Run tab doesn't use ScrollArea as it has its own layout + match self.current_tab { + CurrentTab::Run => { + show_run_tab_content( + ui, + &mut self.run_state, + &mut self.configuration_file_state, + ); + } + CurrentTab::Configuration => { + // Wrap in ScrollArea for vertical scrolling + egui::ScrollArea::vertical().show(ui, |ui| { 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, - &mut self.configuration_file_state, - ); - } - CurrentTab::About => { + }); + } + CurrentTab::About => { + // Wrap in ScrollArea for vertical scrolling + egui::ScrollArea::vertical().show(ui, |ui| { show_about_tab_content(ui); - } + }); } - }); + } }); } } diff --git a/crates/fosr-gui/src/generation/generation_state.rs b/crates/fosr-gui/src/generation/generation_state.rs new file mode 100644 index 00000000..e913e3df --- /dev/null +++ b/crates/fosr-gui/src/generation/generation_state.rs @@ -0,0 +1,105 @@ +use super::generation_validation::FieldValidation; +use chrono::{Local, NaiveDate, NaiveTime}; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::mpsc::Receiver; +use std::time::Duration; + +// 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) + +/// Represents the state of the generation tab. +pub struct GenerationTabState { + pub progress: f32, + pub progress_receiver: Option>, + pub pcap_bytes: Option>, + pub pcap_receiver: Option>>, + pub throughput_receiver: Option>, + pub throughput: Option, + pub cancelled: Arc, + /// Error message to display, if any + pub error: Option, + // Validation states + pub duration_validation: FieldValidation, + pub seed_validation: FieldValidation, + pub timezone_validation: FieldValidation, + // Parameters + pub order_pcap: bool, + pub taint: bool, + pub duration_str: String, + 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: NaiveTime, + pub output_file_name: String, + /// Holds temporary PCAP files opened in Wireshark along with their background thread handles. + /// + /// `NamedTempFile` automatically deletes the file when dropped. By storing them here, + /// the files stay alive until the app closes. Multiple files can be open simultaneously + /// for comparison purposes. + /// + /// The `JoinHandle` comes from `open::with_in_background()`. The thread stays alive while + /// Wireshark is running, so `is_finished()` can detect active sessions. + #[cfg(not(target_arch = "wasm32"))] + pub temp_pcap_files: Vec<( + std::thread::JoinHandle>, + tempfile::NamedTempFile, + )>, + /// Whether Wireshark is available on the system + #[cfg(not(target_arch = "wasm32"))] + pub wireshark_available: bool, +} + +impl GenerationTabState { + /// Returns true if generation is currently in progress + pub fn is_generating(&self) -> bool { + self.progress_receiver.is_some() + } + + /// Returns true if generation is complete (PCAP ready) + pub fn is_complete(&self) -> bool { + self.progress == 1.0 + } +} + +impl Default for GenerationTabState { + fn default() -> Self { + Self { + progress: 0.0, + progress_receiver: None, + pcap_bytes: None, + pcap_receiver: None, + throughput_receiver: None, + throughput: None, + cancelled: Arc::new(AtomicBool::new(false)), + error: None, + // Validation states + duration_validation: FieldValidation::default(), + seed_validation: FieldValidation::default(), + timezone_validation: FieldValidation::default(), + // Parameters + order_pcap: true, + taint: false, + duration_str: "1h".to_string(), + use_seed: false, + seed_input: String::new(), + timezone_input: String::new(), + use_current_time: true, + use_local_timezone: true, + start_date: Local::now().date_naive(), + start_hour: Local::now().time(), + output_file_name: "output.pcap".to_string(), + #[cfg(not(target_arch = "wasm32"))] + temp_pcap_files: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + wireshark_available: which::which("wireshark").is_ok(), + } + } +} diff --git a/crates/fosr-gui/src/generation/generation_tab.rs b/crates/fosr-gui/src/generation/generation_tab.rs deleted file mode 100644 index a463802b..00000000 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ /dev/null @@ -1,615 +0,0 @@ -use super::generation_core::generate; -use super::generation_ui_components::{show_field_error, show_status, timezone_picker}; -use super::generation_validation::{ - FieldValidation, first_invalid_param, validate_duration, validate_optional_u64, - validate_timezone, -}; -use crate::shared::configuration_file::{ConfigurationFileState, load_config_file_contents}; -#[cfg(not(target_arch = "wasm32"))] -use crate::shared::file_io::save_file_desktop; -#[cfg(target_arch = "wasm32")] -use crate::shared::file_io::save_file_wasm; -use crate::shared::ui_utils::info_icon; -use crate::timepicker::TimePickerButton; -use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone}; -use chrono_tz::Tz; -use eframe::egui::{self, 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; - -// 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, - Generating, - Generated, - #[cfg(not(target_arch = "wasm32"))] - Saved(String), - #[cfg(not(target_arch = "wasm32"))] - Error(String), -} - -/// Represents the state of the generation tab. -pub struct GenerationTabState { - pub progress: f32, - pub progress_receiver: Option>, - pub pcap_bytes: Option>, - pub pcap_receiver: Option>>, - pub throughput_receiver: Option>, - pub throughput: Option, - pub cancelled: Arc, - pub status: UiStatus, - // Validation states - pub duration_validation: FieldValidation, - pub seed_validation: FieldValidation, - pub timezone_validation: FieldValidation, - // Parameters - pub order_pcap: bool, - pub taint: bool, - pub duration_str: String, - 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: NaiveTime, - pub output_file_name: String, - /// Holds temporary PCAP files opened in Wireshark along with their background thread handles. - /// - /// `NamedTempFile` automatically deletes the file when dropped. By storing them here, - /// the files stay alive until the app closes. Multiple files can be open simultaneously - /// for comparison purposes. - /// - /// The `JoinHandle` comes from `open::with_in_background()`. The thread stays alive while - /// Wireshark is running, so `is_finished()` can detect active sessions. - #[cfg(not(target_arch = "wasm32"))] - pub temp_pcap_files: Vec<( - std::thread::JoinHandle>, - tempfile::NamedTempFile, - )>, - /// Whether Wireshark is available on the system - #[cfg(not(target_arch = "wasm32"))] - pub wireshark_available: bool, -} - -impl Default for GenerationTabState { - fn default() -> Self { - Self { - progress: 0.0, - progress_receiver: None, - pcap_bytes: None, - pcap_receiver: None, - throughput_receiver: None, - throughput: None, - cancelled: Arc::new(AtomicBool::new(false)), - status: UiStatus::Idle, - // Validation states - duration_validation: FieldValidation::default(), - seed_validation: FieldValidation::default(), - timezone_validation: FieldValidation::default(), - // Parameters - order_pcap: true, - taint: false, - duration_str: "1h".to_string(), - use_seed: false, - seed_input: String::new(), - timezone_input: String::new(), - use_current_time: true, - use_local_timezone: true, - start_date: Local::now().date_naive(), - start_hour: Local::now().time(), - output_file_name: "output.pcap".to_string(), - #[cfg(not(target_arch = "wasm32"))] - temp_pcap_files: Vec::new(), - #[cfg(not(target_arch = "wasm32"))] - wireshark_available: which::which("wireshark").is_ok(), - } - } -} - -pub fn show_generation_tab_content( - ui: &mut egui::Ui, - 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); - - ui.horizontal(|ui| { - ui.label("Duration"); - info_icon(ui, "Minimum pcap traffic duration described in human-friendly time, such as \"30m\", \"1h\", \"2d\" or \"2days 30min 5s\"."); - - // 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) - .desired_width(100.0) - .hint_text("ex: 30m, 1h, 2d") - .ui(ui); - - if text_response.changed() { - match validate_duration(&state.duration_str) { - Ok(_) => { - state.duration_validation.set_ok(); - } - Err(msg) => { - state.duration_validation.set_err(msg); - } - } - } - - show_field_error(ui, &state.duration_validation); - }); - - ui.add_space(10.0); - - 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| { - ui.label("Start time"); - 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) - .use_dragvalue(true), - ); - }); - - 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 { - state.timezone_input = String::new(); - state.timezone_validation.set_ok(); - } else { - 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); - - 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.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| { - 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( - 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); - } else { - state.seed_validation.set_ok(); - } - }); - - ui.add_space(15.0); - - 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); - - let can_generate = first_invalid_param(&state).is_none(); - let is_generating = matches!(state.status, UiStatus::Generating); - - ui.horizontal(|ui| { - if is_generating { - let stop_button = egui::Button::new( - egui::RichText::new(format!("{} Stop", 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) - .on_hover_text("Cancel generation") - .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(format!( - "{} Generate", - egui_material_icons::icons::ICON_PLAY_ARROW - )) - .size(13.0), - ) - .fill(accent) - .min_size(egui::vec2(85.0, 24.0)); - if ui - .add(generate_button) - .on_hover_text("Generate PCAP from configuration") - .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 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 { - generate( - seed, - config_content, - 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 || { - generate( - seed, - config_content, - 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 { - // 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 { - 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.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(_)) { - state.status = UiStatus::Generated; - } - #[cfg(target_arch = "wasm32")] - { - state.status = UiStatus::Generated; - } - #[cfg(not(target_arch = "wasm32"))] - let save_button_text = format!("{} Save", egui_material_icons::icons::ICON_SAVE); - #[cfg(target_arch = "wasm32")] - let save_button_text = - format!("{} Download", egui_material_icons::icons::ICON_DOWNLOAD); - #[cfg(not(target_arch = "wasm32"))] - let save_button_tooltip = "Save PCAP file"; - #[cfg(target_arch = "wasm32")] - let save_button_tooltip = "Download PCAP file"; - let save_button = egui::Button::new(egui::RichText::new(save_button_text).size(13.0)) - .min_size(egui::vec2(75.0, 24.0)); - if ui - .add(save_button) - .on_hover_text(save_button_tooltip) - .clicked() - { - let pcap_bytes = state.pcap_bytes.clone(); - #[cfg(not(target_arch = "wasm32"))] - { - 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(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(); - 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 write file: {:?}", e); - } - } - }); - } - } - - // Open in Wireshark button (native only) - #[cfg(not(target_arch = "wasm32"))] - { - let open_button = egui::Button::new( - egui::RichText::new(format!("{} Open", egui_material_icons::icons::ICON_LAN)) - .size(13.0), - ) - .min_size(egui::vec2(75.0, 24.0)); - let response = ui.add_enabled(state.wireshark_available, open_button); - let response = if state.wireshark_available { - response.on_hover_text("Open in Wireshark") - } else { - response.on_disabled_hover_text("Wireshark not found in PATH") - }; - if response.clicked() { - if let Some(ref pcap_bytes) = state.pcap_bytes { - match open_in_wireshark(pcap_bytes, &mut state.temp_pcap_files) { - Ok(_) => { - log::info!("Opened PCAP in Wireshark"); - } - Err(e) => { - log::error!("Failed to open in Wireshark: {:?}", e); - state.status = - UiStatus::Error(format!("Failed to open in Wireshark: {e}")); - } - } - } - } - } - } - }); - - ui.add_space(10.0); - - 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); - } - - 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})"), - ); - } -} - -/// Opens the PCAP data in Wireshark. -/// -/// This creates a temporary file with `.pcap` extension and opens it in Wireshark. -/// The `NamedTempFile` handle and `JoinHandle` are stored to keep the file alive. -/// When the handle is dropped (app closed), the temp file is deleted. -/// -/// Platform-specific behavior: -/// - **Linux**: Uses `open::with_in_background()` which spawns Wireshark directly. -/// The thread stays alive while Wireshark is running. -/// - **macOS**: Uses `open -n -W -a Wireshark` which waits for the app to close. -#[cfg(not(target_arch = "wasm32"))] -fn open_in_wireshark( - pcap_bytes: &[u8], - temp_files: &mut Vec<( - std::thread::JoinHandle>, - tempfile::NamedTempFile, - )>, -) -> Result<(), String> { - use std::io::Write; - - // Create a temporary file with .pcap extension - let mut temp_file = tempfile::Builder::new() - .suffix(".pcap") - .tempfile() - .map_err(|e| format!("Failed to create temp file: {e}"))?; - - // Write the PCAP data - temp_file - .write_all(pcap_bytes) - .map_err(|e| format!("Failed to write PCAP data: {e}"))?; - - // Get the path - let path = temp_file.path().to_path_buf(); - - log::info!("Opening PCAP file in Wireshark: {}", path.display()); - - // Platform-specific launch - #[cfg(target_os = "macos")] - let handle = { - // On macOS, use `open -n -W -a Wireshark`: - // - `-n` opens a new instance even if one is already running - // - `-W` waits for the app to close - std::thread::spawn(move || { - std::process::Command::new("open") - .args(["-n", "-W", "-a", "Wireshark"]) - .arg(&path) - .status() - .map(|_| ()) - }) - }; - - #[cfg(not(target_os = "macos"))] - let handle = open::with_in_background(&path, "wireshark"); - - // Store the handle and temp file to keep them alive until app closes - temp_files.push((handle, temp_file)); - - Ok(()) -} diff --git a/crates/fosr-gui/src/generation/generation_ui_components.rs b/crates/fosr-gui/src/generation/generation_ui_components.rs index 5b1df0c1..d9b91c19 100644 --- a/crates/fosr-gui/src/generation/generation_ui_components.rs +++ b/crates/fosr-gui/src/generation/generation_ui_components.rs @@ -24,7 +24,7 @@ // 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_state::GenerationTabState; use super::generation_validation::FieldValidation; use chrono_tz::TZ_VARIANTS; use eframe::egui::{self, epaint}; @@ -37,26 +37,6 @@ pub fn show_field_error(ui: &mut egui::Ui, validation: &FieldValidation) { } } -pub fn show_status(ui: &mut egui::Ui, status: &UiStatus) { - match status { - UiStatus::Idle => {} - UiStatus::Generating => { - ui.label("Generating file…"); - } - UiStatus::Generated => { - ui.label("File generated. You can save it."); - } - #[cfg(not(target_arch = "wasm32"))] - UiStatus::Saved(msg) => { - ui.label(format!("File saved. {}", msg)); - } - #[cfg(not(target_arch = "wasm32"))] - UiStatus::Error(msg) => { - ui.colored_label(egui::Color32::RED, format!("Error: {msg}")); - } - } -} - pub fn timezone_picker(ui: &mut egui::Ui, state: &mut GenerationTabState) { let popup_id = ui.make_persistent_id("tz_popup"); let is_open = egui::Popup::is_id_open(ui.ctx(), popup_id); diff --git a/crates/fosr-gui/src/generation/generation_validation.rs b/crates/fosr-gui/src/generation/generation_validation.rs index 59de92d1..032b034e 100644 --- a/crates/fosr-gui/src/generation/generation_validation.rs +++ b/crates/fosr-gui/src/generation/generation_validation.rs @@ -1,4 +1,4 @@ -use super::generation_tab::{DURATION_MAX, DURATION_MIN, GenerationTabState}; +use super::generation_state::{DURATION_MAX, DURATION_MIN, GenerationTabState}; use chrono_tz::Tz; use std::time::Duration; diff --git a/crates/fosr-gui/src/generation/generation_wireshark.rs b/crates/fosr-gui/src/generation/generation_wireshark.rs new file mode 100644 index 00000000..e98fdda3 --- /dev/null +++ b/crates/fosr-gui/src/generation/generation_wireshark.rs @@ -0,0 +1,59 @@ +/// Opens the PCAP data in Wireshark. +/// +/// This creates a temporary file with `.pcap` extension and opens it in Wireshark. +/// The `NamedTempFile` handle and `JoinHandle` are stored to keep the file alive. +/// When the handle is dropped (app closed), the temp file is deleted. +/// +/// Platform-specific behavior: +/// - **Linux**: Uses `open::with_in_background()` which spawns Wireshark directly. +/// The thread stays alive while Wireshark is running. +/// - **macOS**: Uses `open -n -W -a Wireshark` which waits for the app to close. +#[cfg(not(target_arch = "wasm32"))] +pub fn open_in_wireshark( + pcap_bytes: &[u8], + temp_files: &mut Vec<( + std::thread::JoinHandle>, + tempfile::NamedTempFile, + )>, +) -> Result<(), String> { + use std::io::Write; + + // Create a temporary file with .pcap extension + let mut temp_file = tempfile::Builder::new() + .suffix(".pcap") + .tempfile() + .map_err(|e| format!("Failed to create temp file: {e}"))?; + + // Write the PCAP data + temp_file + .write_all(pcap_bytes) + .map_err(|e| format!("Failed to write PCAP data: {e}"))?; + + // Get the path + let path = temp_file.path().to_path_buf(); + + log::info!("Opening PCAP file in Wireshark: {}", path.display()); + + // Platform-specific launch + #[cfg(target_os = "macos")] + let handle = { + // On macOS, use `open -n -W -a Wireshark`: + // - `-n` opens a new instance even if one is already running + // - `-W` waits for the app to close + std::thread::spawn(move || { + std::process::Command::new("open") + .args(["-n", "-W", "-a", "Wireshark"]) + .arg(&path) + .status() + .map(|_| ()) + }) + }; + + #[cfg(not(target_os = "macos"))] + let handle = open::with_in_background(&path, "wireshark"); + + // Store the handle and temp file to keep them alive until app closes + temp_files.push((handle, temp_file)); + + Ok(()) +} diff --git a/crates/fosr-gui/src/generation/mod.rs b/crates/fosr-gui/src/generation/mod.rs index 9c271fb2..3f79496f 100644 --- a/crates/fosr-gui/src/generation/mod.rs +++ b/crates/fosr-gui/src/generation/mod.rs @@ -1,4 +1,5 @@ -mod generation_core; -pub mod generation_tab; -mod generation_ui_components; -mod generation_validation; +pub mod generation_core; +pub mod generation_state; +pub mod generation_ui_components; +pub mod generation_validation; +pub mod generation_wireshark; diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index e67f90dd..66e8a604 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -3,6 +3,7 @@ mod about_tab; mod app; mod configuration; mod generation; +mod run; mod shared; mod templates; mod timepicker; diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index b79ac84f..5f528db6 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -2,6 +2,7 @@ mod about_tab; mod app; mod configuration; mod generation; +mod run; mod shared; mod templates; mod timepicker; diff --git a/crates/fosr-gui/src/run/mod.rs b/crates/fosr-gui/src/run/mod.rs new file mode 100644 index 00000000..a7f4bc57 --- /dev/null +++ b/crates/fosr-gui/src/run/mod.rs @@ -0,0 +1,5 @@ +mod run_state; +mod run_tab; + +pub use run_state::RunState; +pub use run_tab::show_run_tab_content; diff --git a/crates/fosr-gui/src/run/run_state.rs b/crates/fosr-gui/src/run/run_state.rs new file mode 100644 index 00000000..94b2fe89 --- /dev/null +++ b/crates/fosr-gui/src/run/run_state.rs @@ -0,0 +1,21 @@ +use crate::generation::generation_state::GenerationTabState; +use crate::visualization::visualization_state::VisualizationTabState; + +/// State for the unified Run tab. +/// Combines visualization (live preview) and generation state. +pub struct RunState { + pub visualization: VisualizationTabState, + pub generation: GenerationTabState, + /// Whether the side panel (generation options) is open + pub panel_open: bool, +} + +impl Default for RunState { + fn default() -> Self { + Self { + visualization: VisualizationTabState::default(), + generation: GenerationTabState::default(), + panel_open: true, + } + } +} diff --git a/crates/fosr-gui/src/run/run_tab.rs b/crates/fosr-gui/src/run/run_tab.rs new file mode 100644 index 00000000..568b1c17 --- /dev/null +++ b/crates/fosr-gui/src/run/run_tab.rs @@ -0,0 +1,972 @@ +use super::run_state::RunState; +use crate::generation::generation_core::generate; +use crate::generation::generation_ui_components::{show_field_error, timezone_picker}; +use crate::generation::generation_validation::{ + first_invalid_param, validate_duration, validate_optional_u64, validate_timezone, +}; +#[cfg(not(target_arch = "wasm32"))] +use crate::generation::generation_wireshark::open_in_wireshark; +use crate::shared::configuration_file::{ConfigurationFileState, load_config_file_contents}; +#[cfg(not(target_arch = "wasm32"))] +use crate::shared::file_io::save_file_desktop; +#[cfg(target_arch = "wasm32")] +use crate::shared::file_io::save_file_wasm; +use crate::shared::ui_utils::info_icon; +use crate::timepicker::TimePickerButton; +use crate::visualization::visualization_modal::{process_graph_events, render_node_info_modal}; +use crate::visualization::visualization_overlays::{ + render_overlay_buttons, render_overlay_edge_legend, render_overlay_node_legend, + render_overlay_stats, +}; +use crate::visualization::visualization_screenshot::handle_screenshot_export; +use crate::visualization::visualization_shapes::{NetworkEdgeShape, NetworkNodeShape}; +use crate::visualization::visualization_state::{ + ActiveLink, EdgeData, EdgeState, ExportState, INTERNET_IP, LinkDirection, NodeData, + VisualizationTabState, +}; +use crate::visualization::visualization_stream::FlowEvent; +use chrono::{Datelike, Local, TimeZone}; +use chrono_tz::Tz; +use eframe::egui::{self, Widget}; +use egui_extras::DatePickerButton; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::channel; + +pub fn show_run_tab_content( + ui: &mut egui::Ui, + state: &mut RunState, + configuration_file_state: &mut ConfigurationFileState, +) { + // Load config file contents if a file is selected but content not yet loaded + load_config_file_contents(configuration_file_state); + + // Handle config changes for visualization + handle_config_changes(&mut state.visualization, configuration_file_state); + + // Auto-start visualization with delay (allows UI to render first) + if let Some(countdown) = state.visualization.auto_start_countdown { + if countdown > 0 { + state.visualization.auto_start_countdown = Some(countdown - 1); + } else if !state.visualization.visualization_running { + let config = state.visualization.config_content.clone(); + let speed = state.visualization.speed.clone(); + if let Err(e) = state + .visualization + .start_visualization(config.as_deref(), speed, true) + { + log::error!("Failed to auto-start visualization: {}", e); + } + state.visualization.auto_start_countdown = None; + } + } + + // Handle delayed fit-to-screen (after panel toggle or on initial load) + if let Some(countdown) = state.visualization.delayed_fit_countdown { + if countdown > 0 { + state.visualization.delayed_fit_countdown = Some(countdown - 1); + } else { + state.visualization.reset_view_requested = true; + state.visualization.delayed_fit_countdown = None; + } + } + + // Process incoming flow events + process_flow_events(&mut state.visualization); + + // Update active links (remove expired ones) + update_active_links(&mut state.visualization); + + // Update graph edges based on active links + update_graph_edges(&mut state.visualization); + + // Poll generation receivers (must be done before rendering UI) + poll_generation_receivers(ui.ctx(), state); + + // Bottom panel (action bar + expandable options) + show_bottom_panel(ui.ctx(), state, configuration_file_state); + + // Render the graph view + render_graph_view(ui, state); + + // Process node click events and render info modal + process_graph_events(&mut state.visualization, configuration_file_state); + render_node_info_modal(ui.ctx(), &mut state.visualization, configuration_file_state); +} + +/// Handle configuration file changes +fn handle_config_changes( + state: &mut VisualizationTabState, + configuration_file_state: &ConfigurationFileState, +) { + // Check if config was removed or is empty + let config_is_empty = configuration_file_state + .config_file_content + .as_ref() + .map(|c| c.trim().is_empty()) + .unwrap_or(true); + + let was_config_removed = + state.config_content.is_some() && configuration_file_state.config_file_content.is_none(); + + // Only reset if we previously had a config (avoid resetting every frame when starting empty) + let should_reset = was_config_removed || (config_is_empty && state.config_content.is_some()); + + if should_reset { + // Stop visualization if running, then reset to default + if state.visualization_running { + state.stop_visualization(); + } + state.config_content = None; + *state = VisualizationTabState::default(); + state.reset_view_requested = true; + log::warn!("Config removed or empty, visualization reset to default"); + return; + } + + // If config is empty and we have no config loaded, nothing to do + if config_is_empty && state.config_content.is_none() { + 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 { + if let Some(config_content) = &configuration_file_state.config_file_content { + // Stop visualization if running before updating config + let was_running = state.visualization_running; + if was_running { + state.stop_visualization(); + } + + // Try to parse the config, handle errors gracefully + // Use catch_unwind because import_config uses .expect() internally + let config_result = + std::panic::catch_unwind(|| fosr_lib::config::import_config(config_content)); + + match config_result { + Ok(config) => { + state.update_from_config(&config); + state.config_content = Some(config_content.clone()); + // Only auto-restart if visualization was running before config change + if was_running { + state.auto_start_countdown = Some(10); + } + state.reset_view_requested = true; + } + Err(e) => { + // Log the error once and reset to default state instead of crashing + // Store the config content so we don't retry parsing every frame + log::error!("Failed to parse configuration: {:?}", e); + *state = VisualizationTabState::default(); + state.config_content = Some(config_content.clone()); + state.reset_view_requested = true; + } + } + } + } +} + +/// 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 = web_time::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; + } + + // 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 } 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, + }, + ); + + // 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), + ) { + // 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_node_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_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; + } + } +} + +/// Update active links (remove expired ones) +fn update_active_links(state: &mut VisualizationTabState) { + let now = web_time::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 = std::time::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 + // 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_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_ips, dst_ips) in edges_data { + // Check all IP combinations for an active link + let mut new_state = EdgeState::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_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_state = EdgeState::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; + } + } + } + + // Update edge state (flow_count is preserved) + if let Some(edge_mut) = graph.g_mut().edge_weight_mut(edge) { + edge_mut.payload_mut().state = new_state; + } + } +} + +/// Show the generation options +fn show_generation_options(ui: &mut egui::Ui, state: &mut RunState) { + ui.columns(2, |cols| { + // --- Column 1: Duration & Time --- + let col1 = &mut cols[0]; + col1.set_min_width(280.0); + + // Duration + col1.horizontal(|ui| { + ui.label("Duration"); + info_icon(ui, "Minimum pcap traffic duration described in human-friendly time, such as \"30m\", \"1h\", \"2d\" or \"2days 30min 5s\"."); + + // Preset buttons + for preset in ["5min", "1h", "24h"] { + if ui.small_button(preset).clicked() { + state.generation.duration_str = preset.to_string(); + state.generation.duration_validation.set_ok(); + } + } + + let text_response = egui::TextEdit::singleline(&mut state.generation.duration_str) + .desired_width(80.0) + .hint_text("ex: 30m, 1h, 2d") + .ui(ui); + + if text_response.changed() { + match validate_duration(&state.generation.duration_str) { + Ok(_) => { + state.generation.duration_validation.set_ok(); + } + Err(msg) => { + state.generation.duration_validation.set_err(msg); + } + } + } + + show_field_error(ui, &state.generation.duration_validation); + }); + + col1.add_space(8.0); + + // Use current time + col1.horizontal(|ui| { + ui.checkbox(&mut state.generation.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.generation.use_current_time { + col1.horizontal(|ui| { + ui.label("Start time"); + let current_year = Local::now().date_naive().year(); + ui.add( + DatePickerButton::new(&mut state.generation.start_date) + .start_end_years((current_year - 5)..=(current_year + 30)), + ); + ui.add( + TimePickerButton::new(&mut state.generation.start_hour) + .show_seconds(true) + .use_dragvalue(true), + ); + }); + + col1.add_space(8.0); + + col1.horizontal(|ui| { + if ui + .checkbox(&mut state.generation.use_local_timezone, "Use local timezone") + .clicked() + { + if state.generation.use_local_timezone { + state.generation.timezone_input = String::new(); + state.generation.timezone_validation.set_ok(); + } else { + state.generation.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.generation.use_local_timezone { + timezone_picker(ui, &mut state.generation); + + let result = validate_timezone(&state.generation.timezone_input); + if result.is_ok() { + state.generation.timezone_validation.set_ok(); + } else { + state.generation.timezone_validation.set_err(result.err().unwrap()); + } + } + }); + } else { + state.generation.timezone_validation.set_ok(); + } + + // Show the equivalent UTC start time (or error if timezone is invalid) + let utc_text: Option = if state.generation.use_current_time { + Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()) + } else { + let local_dt = state + .generation + .start_date + .and_time(state.generation.start_hour); + + let utc = if state.generation.use_local_timezone { + Local::now() + .timezone() + .from_local_datetime(&local_dt) + .earliest() + .map(|dt| dt.with_timezone(&chrono::Utc)) + } else { + state + .generation + .timezone_input + .parse::() + .ok() + .and_then(|tz| local_dt.and_local_timezone(tz).earliest()) + .map(|dt| dt.with_timezone(&chrono::Utc)) + }; + + utc.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .or_else(|| { + if !state.generation.use_local_timezone { + Some("select a valid timezone".to_string()) + } else { + None + } + }) + }; + + if let Some(text) = utc_text { + col1.label( + egui::RichText::new(format!("Start time (UTC): {}", text)) + .color(egui::Color32::GRAY), + ); + } + + // --- Column 2: Seed & Advanced --- + let col2 = &mut cols[1]; + col2.set_min_width(200.0); + + // Seed + col2.horizontal(|ui| { + ui.checkbox(&mut state.generation.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.generation.use_seed { + let response = ui.add( + egui::TextEdit::singleline(&mut state.generation.seed_input) + .hint_text("enter a seed value") + .desired_width(120.0), + ); + + if response.changed() { + match validate_optional_u64(&state.generation.seed_input) { + Ok(_) => { + state.generation.seed_validation.set_ok(); + } + Err(msg) => { + state.generation.seed_validation.set_err(msg); + } + } + } + + show_field_error(ui, &state.generation.seed_validation); + } else { + state.generation.seed_validation.set_ok(); + } + }); + + col2.add_space(8.0); + + // Advanced options + col2.horizontal(|ui| { + ui.checkbox(&mut state.generation.taint, "Taint the packets"); + info_icon(ui, "Taint the packets with special markers for identification."); + }); + col2.horizontal(|ui| { + ui.checkbox(&mut state.generation.order_pcap, "Order temporally"); + info_icon(ui, "Enable temporal sorting of the generated pcap. Disable to reduce significantly the RAM usage."); + }); + + col2.add_space(8.0); + + // Validation errors + if let Some((name, spec, err)) = first_invalid_param(&state.generation) { + col2.colored_label( + egui::Color32::RED, + format!("Invalid parameter: {name}. Expected: {spec}. ({err})"), + ); + } + }); +} + +/// Start the generation process +fn start_generation( + state: &mut RunState, + configuration_file_state: &ConfigurationFileState, + ctx: &egui::Context, +) { + // Reset state + state.generation.progress = 0.0; + state.generation.error = None; + state.generation.cancelled = Arc::new(AtomicBool::new(false)); + + let (progress_sender, progress_receiver) = channel(); + state.generation.progress_receiver = Some(progress_receiver); + + let (pcap_sender, pcap_receiver) = channel(); + state.generation.pcap_receiver = Some(pcap_receiver); + + let (throughput_sender, throughput_receiver) = channel(); + state.generation.throughput_receiver = Some(throughput_receiver); + state.generation.throughput = None; + + let seed = if state.generation.use_seed { + state.generation.seed_input.parse::().ok() + } else { + None + }; + let order_pcap = state.generation.order_pcap; + let start_time = if state.generation.use_current_time { + None + } else { + Some(format!( + "{}T{}Z", + state.generation.start_date.format("%Y-%m-%d"), + state.generation.start_hour.format("%H:%M:%S") + )) + }; + let duration = state.generation.duration_str.clone(); + let taint = state.generation.taint; + let timezone = if state.generation.timezone_input.is_empty() { + None + } else { + Some(state.generation.timezone_input.clone()) + }; + let ctx = ctx.clone(); + let cancelled = state.generation.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 { + generate( + seed, + config_content, + 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 || { + generate( + seed, + config_content, + order_pcap, + start_time, + duration, + taint, + timezone, + Some(progress_sender), + Some(pcap_sender), + Some(throughput_sender), + cancelled, + ); + ctx.request_repaint(); + }); + } +} + +/// Poll generation receivers for updates +fn poll_generation_receivers(ctx: &egui::Context, state: &mut RunState) { + // Poll progress receiver + if let Some(receiver) = &state.generation.progress_receiver { + // Request repaint to keep polling while generating + ctx.request_repaint(); + if let Ok(progress) = receiver.try_recv() { + state.generation.progress = progress; + if progress == 1.0 { + state.generation.progress_receiver = None; + } + } + } + + // Poll pcap receiver + if let Some(receiver) = &state.generation.pcap_receiver { + if let Ok(pcap_bytes) = receiver.try_recv() { + state.generation.pcap_bytes = Some(pcap_bytes); + } + } + + // Poll throughput receiver + if let Some(receiver) = &state.generation.throughput_receiver { + if let Ok(throughput) = receiver.try_recv() { + state.generation.throughput = Some(throughput); + state.generation.throughput_receiver = None; + } + } +} + +/// Show the bottom panel with action bar and expandable options +fn show_bottom_panel( + ctx: &egui::Context, + state: &mut RunState, + configuration_file_state: &ConfigurationFileState, +) { + // Options panel (shown above action bar when expanded) + if state.panel_open { + egui::TopBottomPanel::bottom("run_options_panel") + .frame( + egui::Frame::side_top_panel(&ctx.style()) + .inner_margin(egui::Margin::symmetric(8, 8)) + .fill(ctx.style().visuals.panel_fill), + ) + .resizable(false) + .show(ctx, |ui| { + show_generation_options(ui, state); + }); + } + + // Action bar (always visible) + egui::TopBottomPanel::bottom("run_bottom_bar") + .frame( + egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin::symmetric(8, 4)), + ) + .show(ctx, |ui| { + let is_generating = state.generation.is_generating(); + let is_complete = state.generation.is_complete(); + let can_generate = first_invalid_param(&state.generation).is_none(); + + ui.horizontal(|ui| { + // Generate button (always visible, same style/position) + 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(format!( + "{} Generate", + egui_material_icons::icons::ICON_PLAY_ARROW + )) + .size(13.0), + ) + .fill(accent) + .min_size(egui::vec2(85.0, 24.0)); + if ui + .add(generate_button) + .on_hover_text("Generate PCAP from configuration") + .clicked() + { + start_generation(state, configuration_file_state, ctx); + } + }); + } + + // Stop button (when generating) + if is_generating { + let stop_button = egui::Button::new( + egui::RichText::new(format!( + "{} Stop", + 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) + .on_hover_text("Cancel generation") + .clicked() + { + state.generation.cancelled.store(true, Ordering::Relaxed); + state.generation.progress = 0.0; + state.generation.progress_receiver = None; + state.generation.pcap_receiver = None; + state.generation.throughput_receiver = None; + } + } + + // Save/Open buttons (when complete) + if is_complete { + #[cfg(not(target_arch = "wasm32"))] + let save_text = format!("{} Save", egui_material_icons::icons::ICON_SAVE); + #[cfg(target_arch = "wasm32")] + let save_text = + format!("{} Download", egui_material_icons::icons::ICON_DOWNLOAD); + + let save_button = egui::Button::new(egui::RichText::new(save_text).size(13.0)) + .min_size(egui::vec2(75.0, 24.0)); + if ui.add(save_button).clicked() { + let pcap_bytes = state.generation.pcap_bytes.clone(); + #[cfg(not(target_arch = "wasm32"))] + { + let data = pcap_bytes.as_ref().unwrap().as_slice(); + match save_file_desktop(data, &state.generation.output_file_name) { + Ok(file_handle) => { + log::info!( + "Successfully wrote to file: {}", + file_handle.path().to_string_lossy() + ); + } + Err(e) => { + log::error!("Failed to save file: {:?}", e); + state.generation.error = + Some(format!("Failed to save file: {e}")); + } + } + } + + #[cfg(target_arch = "wasm32")] + { + let file_name = state.generation.output_file_name.clone(); + wasm_bindgen_futures::spawn_local(async move { + let data = pcap_bytes.as_ref().unwrap().as_slice(); + match save_file_wasm(data, &file_name).await { + Ok(_) => log::info!("File written successfully!"), + Err(e) => log::error!("Failed to write file on WASM: {:?}", e), + } + }); + } + } + + // Open in Wireshark button (native only) + #[cfg(not(target_arch = "wasm32"))] + { + let open_button = egui::Button::new( + egui::RichText::new(format!( + "{} Open", + egui_material_icons::icons::ICON_LAN + )) + .size(13.0), + ) + .min_size(egui::vec2(75.0, 24.0)); + let response = + ui.add_enabled(state.generation.wireshark_available, open_button); + let response = if state.generation.wireshark_available { + response.on_hover_text("Open in Wireshark") + } else { + response.on_disabled_hover_text("Wireshark not found in PATH") + }; + if response.clicked() { + if let Some(ref pcap_bytes) = state.generation.pcap_bytes { + match open_in_wireshark( + pcap_bytes, + &mut state.generation.temp_pcap_files, + ) { + Ok(_) => log::info!("Opened PCAP in Wireshark"), + Err(e) => { + log::error!("Failed to open in Wireshark: {:?}", e); + state.generation.error = + Some(format!("Failed to open in Wireshark: {e}")); + } + } + } + } + } + } + + // Error display (when there's an error) + if let Some(error) = &state.generation.error { + ui.colored_label(egui::Color32::RED, error); + } + + // Options toggle button (right-aligned) with progress bar and throughput + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Options button (rightmost) + let icon = if state.panel_open { + egui_material_icons::icons::ICON_KEYBOARD_ARROW_DOWN + } else { + egui_material_icons::icons::ICON_KEYBOARD_ARROW_UP + }; + let tooltip = if state.panel_open { + "Hide options" + } else { + "Show options" + }; + if ui + .button(format!("{} Options", icon)) + .on_hover_text(tooltip) + .clicked() + { + state.panel_open = !state.panel_open; + state.visualization.delayed_fit_countdown = Some(2); // Delay by 2 frames + } + + // Throughput (when complete) - left of Options + if is_complete { + if let Some(throughput) = &state.generation.throughput { + ui.label(format!("Throughput: {throughput}")); + } + } + + // Progress bar (when generating) - left of Throughput/Options + if is_generating { + let progress = egui::ProgressBar::new(state.generation.progress) + .text("") + .fill(egui::Color32::from_rgb(144, 238, 144)); + ui.add(progress); + } + }); + }); + }); +} + +/// Render the graph view with overlays +fn render_graph_view(ui: &mut egui::Ui, state: &mut RunState) { + let inner_response = egui::CentralPanel::default().show(ui.ctx(), |ui| { + // Enable node clicking and dragging + let interactions = egui_graphs::SettingsInteraction::new() + .with_node_clicking_enabled(true) + .with_dragging_enabled(true); + + // Reset view on window resize + let screen_size = ui.ctx().content_rect().size(); + match state.visualization.last_screen_size { + Some(last) if last != screen_size => { + state.visualization.last_screen_size = Some(screen_size); + state.visualization.reset_view_requested = true; + } + None => state.visualization.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.visualization.reset_view_requested; + if state.visualization.reset_view_requested { + state.visualization.reset_view_requested = false; + } + + let mut graph_view = egui_graphs::GraphView::< + NodeData, + EdgeData, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, + NetworkEdgeShape, + egui_graphs::FruchtermanReingoldWithCenterGravityState, + egui_graphs::LayoutForceDirected, + >::new(&mut state.visualization.graph) + .with_interactions(&interactions) + .with_event_sink(&state.visualization.events_buffer) + .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_fit_to_screen_padding(0.15) // padding to avoid cropping with labels and overlays + .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 + if !state.visualization.layout_initialized { + let layout_state = egui_graphs::FruchtermanReingoldWithCenterGravityState { + base: egui_graphs::FruchtermanReingoldState { + is_running: false, + ..Default::default() + }, + extras: Default::default(), + }; + egui_graphs::set_layout_state(ui, layout_state, None); + state.visualization.layout_initialized = true; + } + + ui.add(&mut graph_view); + + // Handle screenshot export state machine + handle_screenshot_export(ui, &mut state.visualization); + + // Hide overlays during export to get clean screenshot + if state.visualization.export_state != ExportState::Idle { + return; + } + + // Render overlays + render_overlay_buttons(ui, &mut state.visualization); + render_overlay_stats(ui, &state.visualization); + render_overlay_node_legend(ui); + render_overlay_edge_legend(ui); + }); + + // Use panel rect directly - it's already in screen coordinates + // and represents the full panel area (ui.max_rect() excludes internal padding) + let panel_rect = inner_response.response.rect; + state.visualization.graph_rect = Some(panel_rect); +} diff --git a/crates/fosr-gui/src/visualization/mod.rs b/crates/fosr-gui/src/visualization/mod.rs index 89493299..bb85843a 100644 --- a/crates/fosr-gui/src/visualization/mod.rs +++ b/crates/fosr-gui/src/visualization/mod.rs @@ -1,4 +1,7 @@ -mod visualization_shapes; -mod visualization_stream; -pub mod visualization_tab; -mod visualization_utils; +pub mod visualization_modal; +pub mod visualization_overlays; +pub mod visualization_screenshot; +pub mod visualization_shapes; +pub mod visualization_state; +pub mod visualization_stream; +pub mod visualization_utils; diff --git a/crates/fosr-gui/src/visualization/visualization_modal.rs b/crates/fosr-gui/src/visualization/visualization_modal.rs new file mode 100644 index 00000000..963cd7bb --- /dev/null +++ b/crates/fosr-gui/src/visualization/visualization_modal.rs @@ -0,0 +1,223 @@ +use super::visualization_shapes::{ICON_TINT_DARK, ICON_TINT_LIGHT}; +use super::visualization_state::{NodeType, VisualizationTabState}; +use crate::shared::configuration_file::ConfigurationFileState; +use eframe::egui; +use egui_graphs::events::{Event, PayloadNodeClick}; + +/// Process graph click events from the event buffer +pub 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 { + 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; + + // 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 +pub fn render_node_info_modal( + ctx: &egui::Context, + state: &mut VisualizationTabState, + config_file_state: &mut ConfigurationFileState, +) { + 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 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); + if has_edit_buffer { + ui.heading("Edit Node Information"); + } else { + ui.heading("Node Information"); + } + + ui.separator(); + + // Node type with icon + ui.horizontal(|ui| { + 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 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()); + }); + + ui.add_space(4.0); + + // 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:"); + 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) + }; + } + }); + + // OS + ui.horizontal(|ui| { + 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()); + } + }); + }); + + // 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()); + }); + } + // Don't show OS or IP for Internet node + if node_data.node_type != NodeType::Internet { + 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); + + if has_edit_buffer { + ui.horizontal(|ui| { + if ui + .button(egui_material_icons::icons::ICON_CLOSE) + .on_hover_text("Cancel") + .clicked() + { + ui.close(); + } + if ui + .button(egui_material_icons::icons::ICON_SAVE) + .on_hover_text("Save") + .clicked() + { + save_clicked = true; + ui.close(); + } + }); + } else { + if ui + .button(egui_material_icons::icons::ICON_CLOSE) + .on_hover_text("Close") + .clicked() + { + ui.close(); + } + } + }); + + // 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; + } +} diff --git a/crates/fosr-gui/src/visualization/visualization_overlays.rs b/crates/fosr-gui/src/visualization/visualization_overlays.rs new file mode 100644 index 00000000..15699b13 --- /dev/null +++ b/crates/fosr-gui/src/visualization/visualization_overlays.rs @@ -0,0 +1,245 @@ +use super::visualization_shapes::{ + COLOR_DNS, COLOR_HTTP, COLOR_HTTPS, COLOR_INACTIVE, COLOR_OTHER, COLOR_SMTP, COLOR_SSH, + ICON_TINT_DARK, ICON_TINT_LIGHT, +}; +use super::visualization_state::{STOP_BUTTON_COLOR, VisualizationTabState}; +use eframe::egui; + +/// Helper to render a single legend item inline (for edges) +fn legend_item_inline(ui: &mut egui::Ui, label: &str, color: egui::Color32) { + 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) { + 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 overlay buttons in the top-left corner of the graph +pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationTabState) { + let local_rect = ui.max_rect(); + + egui::Area::new(egui::Id::new("viz_overlay_buttons")) + .fixed_pos(local_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 { + // Play / Continue: resume without resetting flow counts + let play_text = if state.user_has_started { + "Continue" + } else { + "Start" + }; + let accent = ui.visuals().selection.bg_fill; + let play_button = egui::Button::new(egui::RichText::new(format!( + "{} {}", + egui_material_icons::icons::ICON_PLAY_ARROW, + play_text + ))) + .fill(accent); + if ui.add(play_button).clicked() { + state.user_has_started = true; + // 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 + // Only visible after the user has started at least once + if state.user_has_started { + 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 { + let stop_button = egui::Button::new(egui::RichText::new(format!( + "{} Stop", + egui_material_icons::icons::ICON_STOP + ))) + .fill(STOP_BUTTON_COLOR); + if ui.add(stop_button).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; + } + if ui + .button(egui_material_icons::icons::ICON_IMAGE) + .on_hover_text("Export as PNG") + .clicked() + { + state.export_state = + super::visualization_state::ExportState::HidingOverlays; + } + + 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]; + // 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); + + 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; + } + } + } + }); + }); + }); +} + +/// Render overlay stats in the bottom-left corner of the graph +pub fn render_overlay_stats(ui: &mut egui::Ui, state: &VisualizationTabState) { + let local_rect = ui.max_rect(); + + egui::Area::new(egui::Id::new("viz_overlay_stats")) + .fixed_pos(local_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: {}", 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.", + ); + }); + }); + }); +} + +/// Render node legend in the top-right corner of the graph +pub fn render_overlay_node_legend(ui: &mut egui::Ui) { + let local_rect = ui.max_rect(); + + egui::Area::new(egui::Id::new("viz_overlay_node_legend")) + .pivot(egui::Align2::RIGHT_TOP) + .fixed_pos(local_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"), + ); + }) + .response + .on_hover_text("Node types. Size reflects relative traffic activity."); + }); +} + +/// Render edge legend in the bottom-right corner of the graph +pub fn render_overlay_edge_legend(ui: &mut egui::Ui) { + let local_rect = ui.max_rect(); + + egui::Area::new(egui::Id::new("viz_overlay_edge_legend")) + .pivot(egui::Align2::RIGHT_BOTTOM) + .fixed_pos(local_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); + }) + .response + .on_hover_text( + "Link protocols. Color shows protocol, thickness reflects relative traffic volume.", + ); + }); +} diff --git a/crates/fosr-gui/src/visualization/visualization_screenshot.rs b/crates/fosr-gui/src/visualization/visualization_screenshot.rs new file mode 100644 index 00000000..da3e3cd4 --- /dev/null +++ b/crates/fosr-gui/src/visualization/visualization_screenshot.rs @@ -0,0 +1,82 @@ +use super::visualization_state::ExportState; +use super::visualization_state::VisualizationTabState; +use eframe::egui; + +/// Handle screenshot export state machine. +/// Uses a 2-frame approach: +/// - Frame N: user clicks export → HidingOverlays +/// - Frame N+1: overlays hidden → request screenshot → WaitingForScreenshot +/// - Frame N+2: screenshot received → extract graph region → save → Idle +pub fn handle_screenshot_export(ui: &mut egui::Ui, state: &mut VisualizationTabState) { + // Transition: HidingOverlays → WaitingForScreenshot (request screenshot) + if state.export_state == ExportState::HidingOverlays { + state.export_state = ExportState::WaitingForScreenshot; + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + } + + // Handle screenshot result + ui.input(|i| { + for event in &i.raw.events { + if let egui::Event::Screenshot { image, .. } = event { + if state.export_state == ExportState::WaitingForScreenshot { + if let Some(graph_rect) = state.graph_rect { + let graph_image = image.region(&graph_rect, Some(i.pixels_per_point())); + save_graph_png(&graph_image); + } else { + log::error!("No graph rect stored for screenshot export"); + } + state.export_state = ExportState::Idle; + } + } + } + }); +} + +/// Save the graph screenshot as a PNG file. +fn save_graph_png(image: &egui::ColorImage) { + let width = image.width() as u32; + let height = image.height() as u32; + let pixels = image.as_raw(); + + // Convert RGBA to ImageBuffer + let img_buffer = image::RgbaImage::from_raw(width, height, pixels.to_vec()) + .expect("Failed to create image buffer"); + + // Generate filename with timestamp + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); + let filename = format!("fosr_graph_{}.png", timestamp); + + // Convert to PNG bytes + let mut buffer = Vec::new(); + match img_buffer.write_to( + &mut std::io::Cursor::new(&mut buffer), + image::ImageFormat::Png, + ) { + Ok(_) => { + #[cfg(not(target_arch = "wasm32"))] + { + match crate::shared::file_io::save_file_desktop(&buffer, &filename) { + Ok(file_handle) => { + log::info!("Exported graph to {}", file_handle.path().to_string_lossy()); + } + Err(e) => { + log::error!("Failed to save graph PNG: {:?}", e); + } + } + } + + #[cfg(target_arch = "wasm32")] + { + let filename_clone = filename.clone(); + wasm_bindgen_futures::spawn_local(async move { + match crate::shared::file_io::save_file_wasm(&buffer, &filename_clone).await { + Ok(_) => log::info!("Exported graph to {}", filename_clone), + Err(e) => log::error!("Failed to save PNG on WASM: {:?}", e), + } + }); + } + } + Err(e) => log::error!("Failed to write PNG to buffer: {}", e), + } +} diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/visualization_shapes.rs index e1d22f01..0c0e950a 100644 --- a/crates/fosr-gui/src/visualization/visualization_shapes.rs +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -1,8 +1,6 @@ //! Custom node and edge shapes for network visualization -use crate::visualization::visualization_tab::{ - EdgeData, EdgeState, LinkDirection, NodeData, NodeType, -}; +use super::visualization_state::{EdgeData, EdgeState, LinkDirection, NodeData, NodeType}; use eframe::egui::{self, Color32, Pos2, Rect, Shape, TextureOptions, Vec2, load::SizeHint}; use egui_graphs::{DisplayEdge, DisplayNode, DrawContext, Node, NodeProps}; use fosr_lib::L7Proto; diff --git a/crates/fosr-gui/src/visualization/visualization_state.rs b/crates/fosr-gui/src/visualization/visualization_state.rs new file mode 100644 index 00000000..d5884747 --- /dev/null +++ b/crates/fosr-gui/src/visualization/visualization_state.rs @@ -0,0 +1,417 @@ +use super::visualization_shapes::{NetworkEdgeShape, NetworkNodeShape}; +use super::visualization_stream::{FlowEvent, FlowStreamer}; +use super::visualization_utils::distribute_nodes_circle; +use crate::shared::config_model::Host; +use eframe::egui; +use egui_graphs::events::Event; +use fosr_lib::{L7Proto, OS, config, config::HostType}; +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 web_time::Instant; + +/// Special IP address representing "The Internet" node +pub const INTERNET_IP: Ipv4Addr = Ipv4Addr::new(0, 0, 0, 1); + +/// Color for stop/danger buttons +pub const STOP_BUTTON_COLOR: egui::Color32 = egui::Color32::from_rgb(200, 80, 80); + +/// 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, + } + } +} + +/// Node data: host information +#[derive(Clone, Debug)] +pub struct NodeData { + pub ip_addrs: Vec, + pub hostname: Option, + 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 { + /// Create an Internet node + pub fn internet() -> Self { + Self { + ip_addrs: vec![INTERNET_IP], + 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, + } + } +} + +// 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 { + // 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 { + // 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) + } + } +} + +/// 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 EdgeState { + #[default] + Inactive, + Active { + protocol: L7Proto, + #[allow(dead_code)] // Kept for possible future animation effects? + start_time: Instant, + direction: LinkDirection, + }, +} + +impl fmt::Display for EdgeData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.state { + EdgeState::Inactive => write!(f, ""), + EdgeState::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, +} + +pub type VisualizationGraph = egui_graphs::Graph< + NodeData, + EdgeData, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, + NetworkEdgeShape, +>; + +/// State machine for screenshot export +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExportState { + #[default] + Idle, + /// Hide overlays on next frame before taking screenshot + HidingOverlays, + /// Screenshot requested, waiting for result + WaitingForScreenshot, +} + +/// Represents the state of the visualization tab. +pub struct VisualizationTabState { + pub graph: VisualizationGraph, + pub flow_receiver: Option>, + pub active_links: HashMap<(Ipv4Addr, Ipv4Addr), ActiveLink>, + pub visualization_running: bool, + pub config_content: Option, + pub streamer: Option, + pub layout_initialized: bool, + /// Set of known IPs from the configuration (for filtering Internet flows) + pub known_ips: HashSet, + /// Map from IP to node index for quick lookup + pub ip_to_node: HashMap, + /// Visualization start time (for timestamp-based flow display) + pub visualization_start: Option, + /// Speed multiplier (0.5 to 4.0) - shared for runtime updates + pub speed: Arc>, + /// Buffer for graph events (clicks, etc.) + pub events_buffer: Rc>>, + /// Clicked node for info modal display + pub clicked_node: Option, + /// Node info modal open state + pub node_info_modal_open: bool, + /// Map from graph NodeIndex to config_model.hosts index + pub 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. + /// Note: 10 frames is an arbitrary value that gives enough time for the UI to render and images to load. + pub auto_start_countdown: Option, + /// Total number of flows processed since visualization started + pub total_flows: u32, + /// Flag to request a zoom/pan reset on the next frame + pub reset_view_requested: bool, + /// Countdown to delay fit-to-screen (waiting for layout to settle, e.g., after panel toggle or on initial load) + pub delayed_fit_countdown: Option, + /// Previous screen size (to reset view on window resize) + pub last_screen_size: Option, + /// Whether the user has manually started the visualization at least once. + /// Auto-restart on config change is only enabled after this. + pub user_has_started: bool, + /// Edit buffer for the node info modal (cloned from config on open, applied on Save) + pub modal_edit_buffer: Option, + /// The rect of the graph panel (updated each frame, used for screenshot region) + pub graph_rect: Option, + /// Screenshot export state machine + pub export_state: ExportState, +} + +impl Default for VisualizationTabState { + fn default() -> Self { + // 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, + active_links: HashMap::new(), + visualization_running: false, + config_content: None, + streamer: None, + layout_initialized: false, + 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: None, + total_flows: 0, + user_has_started: false, + reset_view_requested: false, + delayed_fit_countdown: Some(2), // Delay initial fit for bottom panel to be laid out + last_screen_size: None, + modal_edit_buffer: None, + graph_rect: None, + export_state: ExportState::Idle, + } + } +} + +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) { + 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; + } + + /// Build graph from configuration (shared logic) + fn build_graph_from_config( + config: &config::Configuration, + ) -> ( + 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_idx, host) in config.hosts.iter().enumerate() { + 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, + flow_count: 0, + 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 { + 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 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::default()); + } + } + // Add edge to Internet for each user + 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::default()); + } + } + + (graph, known_ips, ip_to_node, node_to_host) + } + + /// Check if an IP is a known (configured) IP + pub fn is_known_ip(&self, ip: Ipv4Addr) -> bool { + 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); + } + + 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"); + } +} diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs deleted file mode 100644 index ad2a7716..00000000 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ /dev/null @@ -1,1280 +0,0 @@ -use super::visualization_shapes::{ - COLOR_DNS, COLOR_HTTP, COLOR_HTTPS, COLOR_INACTIVE, COLOR_OTHER, COLOR_SMTP, COLOR_SSH, - ICON_TINT_DARK, ICON_TINT_LIGHT, NetworkEdgeShape, NetworkNodeShape, -}; -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::{ - FruchtermanReingoldState, FruchtermanReingoldWithCenterGravity, - FruchtermanReingoldWithCenterGravityState, LayoutForceDirected, SettingsInteraction, - events::{Event, PayloadNodeClick}, - set_layout_state, -}; -use fosr_lib::{L7Proto, OS, config, config::HostType}; -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; -use web_time::Instant; - -/// Special IP address representing "The Internet" node -pub const INTERNET_IP: Ipv4Addr = Ipv4Addr::new(0, 0, 0, 1); - -/// Color for stop/danger buttons -const STOP_BUTTON_COLOR: egui::Color32 = egui::Color32::from_rgb(200, 80, 80); - -/// 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, - } - } -} - -/// Node data: host information -#[derive(Clone, Debug)] -pub struct NodeData { - pub ip_addrs: Vec, - pub hostname: Option, - 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 { - /// Create an Internet node - pub fn internet() -> Self { - Self { - ip_addrs: vec![INTERNET_IP], - 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, - } - } -} - -// 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 { - // 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 { - // 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) - } - } -} - -/// 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 EdgeState { - #[default] - Inactive, - Active { - protocol: L7Proto, - #[allow(dead_code)] // Kept for possible future animation effects? - start_time: Instant, - direction: LinkDirection, - }, -} - -impl fmt::Display for EdgeData { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match &self.state { - EdgeState::Inactive => write!(f, ""), - EdgeState::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, ->; - -/// State machine for screenshot export -#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] -pub enum ExportState { - #[default] - Idle, - /// Hide overlays on next frame before taking screenshot - HidingOverlays, - /// Screenshot requested, waiting for result - WaitingForScreenshot, -} - -/// Represents the state of the visualization tab. -pub struct VisualizationTabState { - 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>, - /// 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, - /// 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. - /// 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, - /// 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, - /// Whether the user has manually started the visualization at least once. - /// Auto-restart on config change is only enabled after this. - user_has_started: bool, - /// Edit buffer for the node info modal (cloned from config on open, applied on Save) - modal_edit_buffer: Option, - /// The rect of the graph panel (updated each frame, used for screenshot region) - pub graph_rect: Option, - /// Screenshot export state machine - pub export_state: ExportState, -} - -impl Default for VisualizationTabState { - fn default() -> Self { - // 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, - active_links: HashMap::new(), - visualization_running: false, - config_content: None, - streamer: None, - layout_initialized: false, - 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: None, - total_flows: 0, - user_has_started: false, - reset_view_requested: false, - last_screen_size: None, - modal_edit_buffer: None, - graph_rect: None, - export_state: ExportState::Idle, - } - } -} - -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) { - 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; - } - - /// Build graph from configuration (shared logic) - fn build_graph_from_config( - config: &config::Configuration, - ) -> ( - 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_idx, host) in config.hosts.iter().enumerate() { - 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, - flow_count: 0, - 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 { - 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 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::default()); - } - } - // Add edge to Internet for each user - 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::default()); - } - } - - (graph, known_ips, ip_to_node, node_to_host) - } - - /// Check if an IP is a known (configured) IP - fn is_known_ip(&self, ip: Ipv4Addr) -> bool { - 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); - } - - 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, - state: &mut VisualizationTabState, - 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); - - // 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, true) { - log::error!("Failed to auto-start visualization: {}", e); - } - state.auto_start_countdown = None; - } - } - - // 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_graph_view(ui, state); - - // Process node click events and render info modal - process_graph_events(state, configuration_file_state); - render_node_info_modal(ui.ctx(), state, configuration_file_state); -} - -/// Handle configuration file changes -fn handle_config_changes( - state: &mut VisualizationTabState, - configuration_file_state: &ConfigurationFileState, -) { - // Check if config was removed or is empty - let config_is_empty = configuration_file_state - .config_file_content - .as_ref() - .map(|c| c.trim().is_empty()) - .unwrap_or(true); - - let was_config_removed = - state.config_content.is_some() && configuration_file_state.config_file_content.is_none(); - - // Only reset if we previously had a config (avoid resetting every frame when starting empty) - let should_reset = was_config_removed || (config_is_empty && state.config_content.is_some()); - - if should_reset { - // Stop visualization if running, then reset to default - if state.visualization_running { - state.stop_visualization(); - } - state.config_content = None; - *state = VisualizationTabState::default(); - state.reset_view_requested = true; - log::warn!("Config removed or empty, visualization reset to default"); - return; - } - - // If config is empty and we have no config loaded, nothing to do - if config_is_empty && state.config_content.is_none() { - 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 { - if let Some(ref config_content) = configuration_file_state.config_file_content { - // Stop visualization if running before updating config - let was_running = state.visualization_running; - if was_running { - state.stop_visualization(); - } - - // Try to parse the config, handle errors gracefully - // Use catch_unwind because import_config uses .expect() internally - let config_result = std::panic::catch_unwind(|| config::import_config(config_content)); - - match config_result { - Ok(config) => { - state.update_from_config(&config); - state.config_content = Some(config_content.clone()); - // Only auto-restart if visualization was running before config change - if was_running { - state.auto_start_countdown = Some(10); - } - state.reset_view_requested = true; - } - Err(e) => { - // Log the error once and reset to default state instead of crashing - // Store the config content so we don't retry parsing every frame - log::error!("Failed to parse configuration: {:?}", e); - *state = VisualizationTabState::default(); - state.config_content = Some(config_content.clone()); - state.reset_view_requested = true; - } - } - } - } -} - -/// 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; - } - - // 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 } 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, - }, - ); - - // 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), - ) { - // 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_node_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_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; - } - } -} - -/// 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 - // 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_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_ips, dst_ips) in edges_data { - // Check all IP combinations for an active link - let mut new_state = EdgeState::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_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_state = EdgeState::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; - } - } - } - - // Update edge state (flow_count is preserved) - if let Some(edge_mut) = graph.g_mut().edge_weight_mut(edge) { - edge_mut.payload_mut().state = new_state; - } - } -} - -/// Process graph click events from the event buffer -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 { - 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; - - // 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, - config_file_state: &mut ConfigurationFileState, -) { - 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 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); - if has_edit_buffer { - ui.heading("Edit Node Information"); - } else { - ui.heading("Node Information"); - } - - ui.separator(); - - // Node type with icon - ui.horizontal(|ui| { - 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 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()); - }); - - ui.add_space(4.0); - - // 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:"); - 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) - }; - } - }); - - // OS - ui.horizontal(|ui| { - 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()); - } - }); - }); - - // 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()); - }); - } - // Don't show OS or IP for Internet node - if node_data.node_type != NodeType::Internet { - 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); - - if has_edit_buffer { - ui.horizontal(|ui| { - if ui - .button(egui_material_icons::icons::ICON_CLOSE) - .on_hover_text("Cancel") - .clicked() - { - ui.close(); - } - if ui - .button(egui_material_icons::icons::ICON_SAVE) - .on_hover_text("Save") - .clicked() - { - save_clicked = true; - ui.close(); - } - }); - } else { - if ui - .button(egui_material_icons::icons::ICON_CLOSE) - .on_hover_text("Close") - .clicked() - { - ui.close(); - } - } - }); - - // 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; - } -} - -/// Helper to render a single legend item inline (for edges) -fn legend_item_inline(ui: &mut egui::Ui, label: &str, color: egui::Color32) { - 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) { - 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 -fn render_graph_view(ui: &mut egui::Ui, state: &mut VisualizationTabState) { - let inner_response = 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); - - // 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; - if state.reset_view_requested { - state.reset_view_requested = false; - } - - let mut graph_view = egui_graphs::GraphView::< - NodeData, - EdgeData, - petgraph::Undirected, - petgraph::stable_graph::DefaultIx, - NetworkNodeShape, - NetworkEdgeShape, - 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)) - .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 - 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); - - // Handle screenshot export state machine - handle_screenshot_export(ui, state); - - // Hide overlays during export to get clean screenshot - if state.export_state != ExportState::Idle { - return; - } - - // Get content rect for overlay positioning - let local_rect = ui.max_rect(); - - // Overlay control buttons in the top-left corner of the graph - egui::Area::new(egui::Id::new("viz_overlay_buttons")) - .fixed_pos(local_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 { - // Play / Continue: resume without resetting flow counts - let play_text = if state.user_has_started { "Continue" } else { "Start" }; - let accent = ui.visuals().selection.bg_fill; - let play_button = egui::Button::new( - egui::RichText::new(format!("{} {}", egui_material_icons::icons::ICON_PLAY_ARROW, play_text)), - ) - .fill(accent); - if ui.add(play_button).clicked() { - state.user_has_started = true; - // 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 - // Only visible after the user has started at least once - if state.user_has_started { - 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 { - let stop_button = egui::Button::new( - egui::RichText::new(format!("{} Stop", egui_material_icons::icons::ICON_STOP)), - ) - .fill(STOP_BUTTON_COLOR); - if ui.add(stop_button).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; - } - if ui.button(egui_material_icons::icons::ICON_IMAGE).on_hover_text("Export as PNG").clicked() { - state.export_state = ExportState::HidingOverlays; - } - - 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]; - // 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); - - 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(local_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: {}", 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."); - }); - }); - }); - - // 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(local_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")); - }).response.on_hover_text("Node types. Size reflects relative traffic activity."); - }); - - // 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(local_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); - }).response.on_hover_text("Link protocols. Color shows protocol, thickness reflects relative traffic volume."); - }); - }); - - // Use panel rect directly - it's already in screen coordinates - // and represents the full panel area (ui.max_rect() excludes internal padding) - let panel_rect = inner_response.response.rect; - state.graph_rect = Some(panel_rect); -} - -/// Handle screenshot export state machine. -/// Uses a 2-frame approach: -/// - Frame N: user clicks export → HidingOverlays -/// - Frame N+1: overlays hidden → request screenshot → WaitingForScreenshot -/// - Frame N+2: screenshot received → extract graph region → save → Idle -fn handle_screenshot_export(ui: &mut egui::Ui, state: &mut VisualizationTabState) { - // Transition: HidingOverlays → WaitingForScreenshot (request screenshot) - if state.export_state == ExportState::HidingOverlays { - state.export_state = ExportState::WaitingForScreenshot; - ui.ctx() - .send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); - } - - // Handle screenshot result - ui.input(|i| { - for event in &i.raw.events { - if let egui::Event::Screenshot { image, .. } = event { - if state.export_state == ExportState::WaitingForScreenshot { - if let Some(graph_rect) = state.graph_rect { - let graph_image = image.region(&graph_rect, Some(i.pixels_per_point())); - save_graph_png(&graph_image); - } else { - log::error!("No graph rect stored for screenshot export"); - } - state.export_state = ExportState::Idle; - } - } - } - }); -} - -/// Save the graph screenshot as a PNG file. -fn save_graph_png(image: &egui::ColorImage) { - let width = image.width() as u32; - let height = image.height() as u32; - let pixels = image.as_raw(); - - // Convert RGBA to ImageBuffer - let img_buffer = image::RgbaImage::from_raw(width, height, pixels.to_vec()) - .expect("Failed to create image buffer"); - - // Generate filename with timestamp - let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); - let filename = format!("fosr_graph_{}.png", timestamp); - - // Convert to PNG bytes - let mut buffer = Vec::new(); - match img_buffer.write_to( - &mut std::io::Cursor::new(&mut buffer), - image::ImageFormat::Png, - ) { - Ok(_) => { - #[cfg(not(target_arch = "wasm32"))] - { - match crate::shared::file_io::save_file_desktop(&buffer, &filename) { - Ok(file_handle) => { - log::info!("Exported graph to {}", file_handle.path().to_string_lossy()); - } - Err(e) => { - log::error!("Failed to save graph PNG: {:?}", e); - } - } - } - - #[cfg(target_arch = "wasm32")] - { - let filename_clone = filename.clone(); - wasm_bindgen_futures::spawn_local(async move { - match crate::shared::file_io::save_file_wasm(&buffer, &filename_clone).await { - Ok(_) => log::info!("Exported graph to {}", filename_clone), - Err(e) => log::error!("Failed to save PNG on WASM: {:?}", e), - } - }); - } - } - Err(e) => log::error!("Failed to write PNG to buffer: {}", e), - } -} diff --git a/public/styles.css b/public/styles.css index 1101e146..34e757e5 100644 --- a/public/styles.css +++ b/public/styles.css @@ -121,7 +121,7 @@ table tr:nth-child(even) { #gui_wrapper { position: relative; width: 100%; - height: 550px; + height: 800px; background: var(--bs-body-bg, #ffffff); margin: 15px auto; border-radius: 0.5rem; From f2ca1b3f780b1ac450fe64b437a05b94f56f38a7 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:04:08 +0100 Subject: [PATCH 102/127] refactor(gui): rename files + add top-level file comments --- crates/fosr-gui/src/about_tab.rs | 2 + crates/fosr-gui/src/app.rs | 8 ++-- .../datacenter.yaml | 2 + .../enterprise.yaml | 2 + .../{templates => config_templates}/home.yaml | 2 + .../{templates => config_templates}/mod.rs | 2 + .../src/configuration/{host_ui.rs => host.rs} | 8 ++-- .../{interface_ui.rs => host_interfaces.rs} | 6 ++- .../{service_ui.rs => host_services.rs} | 2 + .../src/configuration/host_validation.rs | 2 + crates/fosr-gui/src/configuration/mod.rs | 10 +++-- .../{configuration_tab.rs => tab.rs} | 6 ++- .../fosr-gui/src/configuration/yaml_editor.rs | 2 + .../{generation_core.rs => core.rs} | 2 + crates/fosr-gui/src/generation/mod.rs | 12 +++--- .../{generation_state.rs => state.rs} | 10 +++-- ...tion_ui_components.rs => ui_components.rs} | 8 ++-- ...generation_validation.rs => validation.rs} | 6 ++- .../{generation_wireshark.rs => wireshark.rs} | 2 + crates/fosr-gui/src/lib.rs | 5 ++- crates/fosr-gui/src/main.rs | 5 ++- crates/fosr-gui/src/run/mod.rs | 10 +++-- crates/fosr-gui/src/run/run_state.rs | 21 ---------- crates/fosr-gui/src/run/state.rs | 23 +++++++++++ .../fosr-gui/src/run/{run_tab.rs => tab.rs} | 38 ++++++++++--------- crates/fosr-gui/src/shared/config_model.rs | 2 + .../fosr-gui/src/shared/configuration_file.rs | 8 ++-- crates/fosr-gui/src/shared/file_io.rs | 2 + crates/fosr-gui/src/shared/mod.rs | 2 + crates/fosr-gui/src/shared/ui_utils.rs | 2 + crates/fosr-gui/src/timepicker/button.rs | 3 ++ crates/fosr-gui/src/timepicker/mod.rs | 2 + crates/fosr-gui/src/timepicker/popup.rs | 3 ++ crates/fosr-gui/src/visualization/mod.rs | 16 ++++---- .../{visualization_modal.rs => node_modal.rs} | 10 +++-- ...{visualization_overlays.rs => overlays.rs} | 13 ++++--- ...ualization_screenshot.rs => screenshot.rs} | 8 ++-- .../{visualization_shapes.rs => shapes.rs} | 4 +- .../{visualization_state.rs => state.rs} | 14 ++++--- .../{visualization_stream.rs => stream.rs} | 0 .../{visualization_utils.rs => utils.rs} | 2 + 41 files changed, 182 insertions(+), 105 deletions(-) rename crates/fosr-gui/src/{templates => config_templates}/datacenter.yaml (96%) rename crates/fosr-gui/src/{templates => config_templates}/enterprise.yaml (94%) rename crates/fosr-gui/src/{templates => config_templates}/home.yaml (85%) rename crates/fosr-gui/src/{templates => config_templates}/mod.rs (96%) rename crates/fosr-gui/src/configuration/{host_ui.rs => host.rs} (97%) rename crates/fosr-gui/src/configuration/{interface_ui.rs => host_interfaces.rs} (94%) rename crates/fosr-gui/src/configuration/{service_ui.rs => host_services.rs} (98%) rename crates/fosr-gui/src/configuration/{configuration_tab.rs => tab.rs} (95%) rename crates/fosr-gui/src/generation/{generation_core.rs => core.rs} (98%) rename crates/fosr-gui/src/generation/{generation_state.rs => state.rs} (94%) rename crates/fosr-gui/src/generation/{generation_ui_components.rs => ui_components.rs} (97%) rename crates/fosr-gui/src/generation/{generation_validation.rs => validation.rs} (92%) rename crates/fosr-gui/src/generation/{generation_wireshark.rs => wireshark.rs} (96%) delete mode 100644 crates/fosr-gui/src/run/run_state.rs create mode 100644 crates/fosr-gui/src/run/state.rs rename crates/fosr-gui/src/run/{run_tab.rs => tab.rs} (97%) rename crates/fosr-gui/src/visualization/{visualization_modal.rs => node_modal.rs} (96%) rename crates/fosr-gui/src/visualization/{visualization_overlays.rs => overlays.rs} (97%) rename crates/fosr-gui/src/visualization/{visualization_screenshot.rs => screenshot.rs} (94%) rename crates/fosr-gui/src/visualization/{visualization_shapes.rs => shapes.rs} (98%) rename crates/fosr-gui/src/visualization/{visualization_state.rs => state.rs} (97%) rename crates/fosr-gui/src/visualization/{visualization_stream.rs => stream.rs} (100%) rename crates/fosr-gui/src/visualization/{visualization_utils.rs => utils.rs} (91%) diff --git a/crates/fosr-gui/src/about_tab.rs b/crates/fosr-gui/src/about_tab.rs index 476bc30a..637a066c 100644 --- a/crates/fosr-gui/src/about_tab.rs +++ b/crates/fosr-gui/src/about_tab.rs @@ -1,3 +1,5 @@ +//! About tab: Fos-R information, usage guide, and repository links. + use eframe::egui; pub fn show_about_tab_content(ui: &mut egui::Ui) { diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index 0cc55b3d..4742f518 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -1,14 +1,14 @@ +//! Main Fos-R application: tab navigation, startup modal, and app state. + use crate::about_tab::show_about_tab_content; -use crate::configuration::configuration_tab::{ - ConfigurationTabState, show_configuration_tab_content, -}; +use crate::config_templates::{all_templates, load_template_by_id}; +use crate::configuration::tab::{ConfigurationTabState, show_configuration_tab_content}; use crate::run::{RunState, show_run_tab_content}; #[cfg(target_arch = "wasm32")] use crate::shared::configuration_file::poll_file_import; use crate::shared::configuration_file::{ ConfigurationFileState, StartupModalState, trigger_file_import, }; -use crate::templates::{all_templates, load_template_by_id}; use eframe::egui; use eframe::egui::global_theme_preference_switch; diff --git a/crates/fosr-gui/src/templates/datacenter.yaml b/crates/fosr-gui/src/config_templates/datacenter.yaml similarity index 96% rename from crates/fosr-gui/src/templates/datacenter.yaml rename to crates/fosr-gui/src/config_templates/datacenter.yaml index 8452e586..ea3ab435 100644 --- a/crates/fosr-gui/src/templates/datacenter.yaml +++ b/crates/fosr-gui/src/config_templates/datacenter.yaml @@ -1,3 +1,5 @@ +# This configuration file was generated by AI, it might contain errors or inaccuracies. +# To be corrected when the new configuration format is finalized. metadata: title: Datacenter Network Configuration desc: A server-heavy setup with multiple server types diff --git a/crates/fosr-gui/src/templates/enterprise.yaml b/crates/fosr-gui/src/config_templates/enterprise.yaml similarity index 94% rename from crates/fosr-gui/src/templates/enterprise.yaml rename to crates/fosr-gui/src/config_templates/enterprise.yaml index aecdf6c2..a8ecf8cf 100644 --- a/crates/fosr-gui/src/templates/enterprise.yaml +++ b/crates/fosr-gui/src/config_templates/enterprise.yaml @@ -1,3 +1,5 @@ +# This configuration file was generated by AI, it might contain errors or inaccuracies. +# To be corrected when the new configuration format is finalized. metadata: title: Default Network Configuration desc: A multi-subnet demo network with common services diff --git a/crates/fosr-gui/src/templates/home.yaml b/crates/fosr-gui/src/config_templates/home.yaml similarity index 85% rename from crates/fosr-gui/src/templates/home.yaml rename to crates/fosr-gui/src/config_templates/home.yaml index fd481de4..9a8fd72b 100644 --- a/crates/fosr-gui/src/templates/home.yaml +++ b/crates/fosr-gui/src/config_templates/home.yaml @@ -1,3 +1,5 @@ +# This configuration file was generated by AI, it might contain errors or inaccuracies. +# To be corrected when the new configuration format is finalized. metadata: title: Home Network Configuration desc: A simple home network with a router and workstations diff --git a/crates/fosr-gui/src/templates/mod.rs b/crates/fosr-gui/src/config_templates/mod.rs similarity index 96% rename from crates/fosr-gui/src/templates/mod.rs rename to crates/fosr-gui/src/config_templates/mod.rs index 57ed9cf4..bbc770f7 100644 --- a/crates/fosr-gui/src/templates/mod.rs +++ b/crates/fosr-gui/src/config_templates/mod.rs @@ -1,3 +1,5 @@ +//! Predefined configuration templates: Home, Enterprise, Datacenter. + use crate::shared::config_model::Configuration; use crate::shared::configuration_file::ConfigurationFileState; use egui_material_icons::icons; diff --git a/crates/fosr-gui/src/configuration/host_ui.rs b/crates/fosr-gui/src/configuration/host.rs similarity index 97% rename from crates/fosr-gui/src/configuration/host_ui.rs rename to crates/fosr-gui/src/configuration/host.rs index 41074d14..2a99e0ac 100644 --- a/crates/fosr-gui/src/configuration/host_ui.rs +++ b/crates/fosr-gui/src/configuration/host.rs @@ -1,4 +1,6 @@ -use crate::configuration::{host_validation, interface_ui, service_ui}; +//! Host editing UI: hostname, OS, type, and client protocols. + +use crate::configuration::{host_interfaces, host_services, host_validation}; use crate::shared::config_model::{Configuration, Host}; use crate::shared::ui_utils::{edit_optional_string, info_icon}; use eframe::egui; @@ -123,7 +125,7 @@ fn ui_single_host( ui_host_type_selector(ui, index, host); ui_host_client_protocols(ui, index, host); ui.separator(); - interface_ui::ui_interfaces_section(ui, index, host, ip_counts, mac_counts); + host_interfaces::ui_interfaces_section(ui, index, host, ip_counts, mac_counts); }); } @@ -277,7 +279,7 @@ fn ui_host_client_protocols(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) let filter = search_text.to_lowercase(); let mut any_shown = false; - for (name, _) in service_ui::KNOWN_SERVICES { + for (name, _) in host_services::KNOWN_SERVICES { if (filter.is_empty() || name.to_lowercase().contains(&filter)) && !host.client.contains(&name.to_string()) { diff --git a/crates/fosr-gui/src/configuration/interface_ui.rs b/crates/fosr-gui/src/configuration/host_interfaces.rs similarity index 94% rename from crates/fosr-gui/src/configuration/interface_ui.rs rename to crates/fosr-gui/src/configuration/host_interfaces.rs index 013bc037..b172cb74 100644 --- a/crates/fosr-gui/src/configuration/interface_ui.rs +++ b/crates/fosr-gui/src/configuration/host_interfaces.rs @@ -1,4 +1,6 @@ -use crate::configuration::service_ui; +//! Network interface editing UI: IP, MAC address, and associated services. + +use crate::configuration::host_services; use crate::shared::config_model::{Host, Interface}; use crate::shared::ui_utils::{edit_optional_string, required_label}; use eframe::egui; @@ -82,7 +84,7 @@ pub fn ui_interfaces_section( ui.colored_label(egui::Color32::RED, "MAC already in use"); } } - service_ui::ui_services_section(ui, if_idx, host_idx, iface); + host_services::ui_services_section(ui, if_idx, host_idx, iface); }); ui.add_space(6.0); } diff --git a/crates/fosr-gui/src/configuration/service_ui.rs b/crates/fosr-gui/src/configuration/host_services.rs similarity index 98% rename from crates/fosr-gui/src/configuration/service_ui.rs rename to crates/fosr-gui/src/configuration/host_services.rs index ada2a698..8a5fe274 100644 --- a/crates/fosr-gui/src/configuration/service_ui.rs +++ b/crates/fosr-gui/src/configuration/host_services.rs @@ -1,3 +1,5 @@ +//! Service editing UI: HTTP, SSH, DNS, etc. with custom port support. + use crate::shared::config_model::Interface; use crate::shared::ui_utils::info_icon; use eframe::egui; diff --git a/crates/fosr-gui/src/configuration/host_validation.rs b/crates/fosr-gui/src/configuration/host_validation.rs index 9593c8f8..3f6b9e9c 100644 --- a/crates/fosr-gui/src/configuration/host_validation.rs +++ b/crates/fosr-gui/src/configuration/host_validation.rs @@ -1,3 +1,5 @@ +//! Host validation: IP/MAC format, conflicts, and type/service consistency. + use crate::shared::config_model::{Configuration, Host}; use std::collections::HashMap; diff --git a/crates/fosr-gui/src/configuration/mod.rs b/crates/fosr-gui/src/configuration/mod.rs index 80f209ca..84b2bd2c 100644 --- a/crates/fosr-gui/src/configuration/mod.rs +++ b/crates/fosr-gui/src/configuration/mod.rs @@ -1,6 +1,8 @@ -pub mod configuration_tab; -pub mod host_ui; +//! Configuration module: visual and YAML editing of network configuration. + +pub mod host; +pub mod host_interfaces; +pub mod host_services; pub mod host_validation; -pub mod interface_ui; -pub mod service_ui; +pub mod tab; pub mod yaml_editor; diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/tab.rs similarity index 95% rename from crates/fosr-gui/src/configuration/configuration_tab.rs rename to crates/fosr-gui/src/configuration/tab.rs index e39b2880..fb891ec9 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/tab.rs @@ -1,4 +1,6 @@ -use crate::configuration::{host_ui, host_validation, yaml_editor}; +//! Configuration tab: toggles between visual mode and YAML editor. + +use crate::configuration::{host, host_validation, yaml_editor}; use crate::shared::config_model::Configuration; use crate::shared::configuration_file::{ ConfigurationFileState, configuration_file_picker, load_config_file_contents, @@ -42,7 +44,7 @@ pub fn show_configuration_tab_content( if !tab_state.is_code_mode { // Visual mode if let Some(model) = file_state.config_model.as_mut() { - host_ui::ui_hosts_section(ui, model); + host::ui_hosts_section(ui, model); ui.separator(); let meta_id = ui.make_persistent_id("metadata_section"); egui::collapsing_header::CollapsingState::load_with_default_open( diff --git a/crates/fosr-gui/src/configuration/yaml_editor.rs b/crates/fosr-gui/src/configuration/yaml_editor.rs index 870ff9f5..0c88b806 100644 --- a/crates/fosr-gui/src/configuration/yaml_editor.rs +++ b/crates/fosr-gui/src/configuration/yaml_editor.rs @@ -1,3 +1,5 @@ +//! YAML editor with syntax highlighting and error line markers. + use crate::shared::configuration_file::{ConfigurationFileState, parse_config_yaml}; use eframe::egui; diff --git a/crates/fosr-gui/src/generation/generation_core.rs b/crates/fosr-gui/src/generation/core.rs similarity index 98% rename from crates/fosr-gui/src/generation/generation_core.rs rename to crates/fosr-gui/src/generation/core.rs index 7bbee302..74a9ba46 100644 --- a/crates/fosr-gui/src/generation/generation_core.rs +++ b/crates/fosr-gui/src/generation/core.rs @@ -1,3 +1,5 @@ +//! Core PCAP generation: runs the 4-stage Fos-R pipeline (S0→S1→S2→S3). + use chrono::{DateTime, Offset, TimeZone}; use chrono_tz::Tz; use fosr_lib::{ diff --git a/crates/fosr-gui/src/generation/mod.rs b/crates/fosr-gui/src/generation/mod.rs index 3f79496f..64002dd3 100644 --- a/crates/fosr-gui/src/generation/mod.rs +++ b/crates/fosr-gui/src/generation/mod.rs @@ -1,5 +1,7 @@ -pub mod generation_core; -pub mod generation_state; -pub mod generation_ui_components; -pub mod generation_validation; -pub mod generation_wireshark; +//! PCAP generation module: core logic, state, validation, and Wireshark integration. + +pub mod core; +pub mod state; +pub mod ui_components; +pub mod validation; +pub mod wireshark; diff --git a/crates/fosr-gui/src/generation/generation_state.rs b/crates/fosr-gui/src/generation/state.rs similarity index 94% rename from crates/fosr-gui/src/generation/generation_state.rs rename to crates/fosr-gui/src/generation/state.rs index e913e3df..54eb471c 100644 --- a/crates/fosr-gui/src/generation/generation_state.rs +++ b/crates/fosr-gui/src/generation/state.rs @@ -1,4 +1,6 @@ -use super::generation_validation::FieldValidation; +//! Generation state: parameters, progress tracking, and validation states. + +use super::validation::FieldValidation; use chrono::{Local, NaiveDate, NaiveTime}; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -13,7 +15,7 @@ pub const DURATION_MAX: Duration = Duration::from_secs(3 * 24 * 3600); // 3 days pub const DURATION_MAX: Duration = Duration::from_secs(24 * 3600); // 1 day (browser tab memory is limited) /// Represents the state of the generation tab. -pub struct GenerationTabState { +pub struct GenerationState { pub progress: f32, pub progress_receiver: Option>, pub pcap_bytes: Option>, @@ -57,7 +59,7 @@ pub struct GenerationTabState { pub wireshark_available: bool, } -impl GenerationTabState { +impl GenerationState { /// Returns true if generation is currently in progress pub fn is_generating(&self) -> bool { self.progress_receiver.is_some() @@ -69,7 +71,7 @@ impl GenerationTabState { } } -impl Default for GenerationTabState { +impl Default for GenerationState { fn default() -> Self { Self { progress: 0.0, diff --git a/crates/fosr-gui/src/generation/generation_ui_components.rs b/crates/fosr-gui/src/generation/ui_components.rs similarity index 97% rename from crates/fosr-gui/src/generation/generation_ui_components.rs rename to crates/fosr-gui/src/generation/ui_components.rs index d9b91c19..c5087917 100644 --- a/crates/fosr-gui/src/generation/generation_ui_components.rs +++ b/crates/fosr-gui/src/generation/ui_components.rs @@ -1,3 +1,5 @@ +//! UI components for generation: timezone picker and field error display. + // The `timezone_picker` function is inspired by egui's ComboBox (combo_box.rs). // // egui is licensed under MIT OR Apache-2.0. @@ -24,8 +26,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -use super::generation_state::GenerationTabState; -use super::generation_validation::FieldValidation; +use super::state::GenerationState; +use super::validation::FieldValidation; use chrono_tz::TZ_VARIANTS; use eframe::egui::{self, epaint}; @@ -37,7 +39,7 @@ pub fn show_field_error(ui: &mut egui::Ui, validation: &FieldValidation) { } } -pub fn timezone_picker(ui: &mut egui::Ui, state: &mut GenerationTabState) { +pub fn timezone_picker(ui: &mut egui::Ui, state: &mut GenerationState) { let popup_id = ui.make_persistent_id("tz_popup"); let is_open = egui::Popup::is_id_open(ui.ctx(), popup_id); diff --git a/crates/fosr-gui/src/generation/generation_validation.rs b/crates/fosr-gui/src/generation/validation.rs similarity index 92% rename from crates/fosr-gui/src/generation/generation_validation.rs rename to crates/fosr-gui/src/generation/validation.rs index 032b034e..1b71b922 100644 --- a/crates/fosr-gui/src/generation/generation_validation.rs +++ b/crates/fosr-gui/src/generation/validation.rs @@ -1,4 +1,6 @@ -use super::generation_state::{DURATION_MAX, DURATION_MIN, GenerationTabState}; +//! Input validation helpers for duration, seed, and timezone fields. + +use super::state::{DURATION_MAX, DURATION_MIN, GenerationState}; use chrono_tz::Tz; use std::time::Duration; @@ -24,7 +26,7 @@ const SPEC_TIMEZONE: &str = "a valid timezone"; // return the first invalid parameter pub fn first_invalid_param( - state: &GenerationTabState, + state: &GenerationState, ) -> Option<(&'static str, &'static str, String)> { if let Some(err) = &state.duration_validation.error { return Some(("Duration", SPEC_DURATION, err.clone())); diff --git a/crates/fosr-gui/src/generation/generation_wireshark.rs b/crates/fosr-gui/src/generation/wireshark.rs similarity index 96% rename from crates/fosr-gui/src/generation/generation_wireshark.rs rename to crates/fosr-gui/src/generation/wireshark.rs index e98fdda3..070700ef 100644 --- a/crates/fosr-gui/src/generation/generation_wireshark.rs +++ b/crates/fosr-gui/src/generation/wireshark.rs @@ -1,3 +1,5 @@ +//! Wireshark integration: opens PCAP files in a temporary file for analysis. + /// Opens the PCAP data in Wireshark. /// /// This creates a temporary file with `.pcap` extension and opens it in Wireshark. diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index 66e8a604..f074f749 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -1,11 +1,14 @@ +//! WASM entry point for the Fos-R GUI web application. +//! Initializes eframe and starts the app in the browser canvas. + #![cfg(target_arch = "wasm32")] mod about_tab; mod app; +mod config_templates; mod configuration; mod generation; mod run; mod shared; -mod templates; mod timepicker; mod visualization; diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index 5f528db6..3a3e7940 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -1,10 +1,13 @@ +//! Entry point for the Fos-R GUI application. +//! Compiles natively for desktop; delegates to lib.rs for WASM builds. + mod about_tab; mod app; +mod config_templates; mod configuration; mod generation; mod run; mod shared; -mod templates; mod timepicker; mod visualization; diff --git a/crates/fosr-gui/src/run/mod.rs b/crates/fosr-gui/src/run/mod.rs index a7f4bc57..33fbfa47 100644 --- a/crates/fosr-gui/src/run/mod.rs +++ b/crates/fosr-gui/src/run/mod.rs @@ -1,5 +1,7 @@ -mod run_state; -mod run_tab; +//! Run module: combines live visualization and PCAP generation in a single tab. -pub use run_state::RunState; -pub use run_tab::show_run_tab_content; +mod state; +mod tab; + +pub use state::RunState; +pub use tab::show_run_tab_content; diff --git a/crates/fosr-gui/src/run/run_state.rs b/crates/fosr-gui/src/run/run_state.rs deleted file mode 100644 index 94b2fe89..00000000 --- a/crates/fosr-gui/src/run/run_state.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::generation::generation_state::GenerationTabState; -use crate::visualization::visualization_state::VisualizationTabState; - -/// State for the unified Run tab. -/// Combines visualization (live preview) and generation state. -pub struct RunState { - pub visualization: VisualizationTabState, - pub generation: GenerationTabState, - /// Whether the side panel (generation options) is open - pub panel_open: bool, -} - -impl Default for RunState { - fn default() -> Self { - Self { - visualization: VisualizationTabState::default(), - generation: GenerationTabState::default(), - panel_open: true, - } - } -} diff --git a/crates/fosr-gui/src/run/state.rs b/crates/fosr-gui/src/run/state.rs new file mode 100644 index 00000000..e0d18f9e --- /dev/null +++ b/crates/fosr-gui/src/run/state.rs @@ -0,0 +1,23 @@ +//! Combined state for the Run tab: visualization and generation with panel toggle. + +use crate::generation::state::GenerationState; +use crate::visualization::state::VisualizationState; + +/// State for the unified Run tab. +/// Combines visualization (live preview) and generation state. +pub struct RunState { + pub visualization: VisualizationState, + pub generation: GenerationState, + /// Whether the side panel (generation options) is open + pub panel_open: bool, +} + +impl Default for RunState { + fn default() -> Self { + Self { + visualization: VisualizationState::default(), + generation: GenerationState::default(), + panel_open: true, + } + } +} diff --git a/crates/fosr-gui/src/run/run_tab.rs b/crates/fosr-gui/src/run/tab.rs similarity index 97% rename from crates/fosr-gui/src/run/run_tab.rs rename to crates/fosr-gui/src/run/tab.rs index 568b1c17..bfda94fd 100644 --- a/crates/fosr-gui/src/run/run_tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -1,11 +1,13 @@ -use super::run_state::RunState; -use crate::generation::generation_core::generate; -use crate::generation::generation_ui_components::{show_field_error, timezone_picker}; -use crate::generation::generation_validation::{ +//! Run tab: live network visualization combined with PCAP generation controls. + +use super::state::RunState; +use crate::generation::core::generate; +use crate::generation::ui_components::{show_field_error, timezone_picker}; +use crate::generation::validation::{ first_invalid_param, validate_duration, validate_optional_u64, validate_timezone, }; #[cfg(not(target_arch = "wasm32"))] -use crate::generation::generation_wireshark::open_in_wireshark; +use crate::generation::wireshark::open_in_wireshark; use crate::shared::configuration_file::{ConfigurationFileState, load_config_file_contents}; #[cfg(not(target_arch = "wasm32"))] use crate::shared::file_io::save_file_desktop; @@ -13,18 +15,18 @@ use crate::shared::file_io::save_file_desktop; use crate::shared::file_io::save_file_wasm; use crate::shared::ui_utils::info_icon; use crate::timepicker::TimePickerButton; -use crate::visualization::visualization_modal::{process_graph_events, render_node_info_modal}; -use crate::visualization::visualization_overlays::{ +use crate::visualization::node_modal::{process_graph_events, render_node_info_modal}; +use crate::visualization::overlays::{ render_overlay_buttons, render_overlay_edge_legend, render_overlay_node_legend, render_overlay_stats, }; -use crate::visualization::visualization_screenshot::handle_screenshot_export; -use crate::visualization::visualization_shapes::{NetworkEdgeShape, NetworkNodeShape}; -use crate::visualization::visualization_state::{ +use crate::visualization::screenshot::handle_screenshot_export; +use crate::visualization::shapes::{NetworkEdgeShape, NetworkNodeShape}; +use crate::visualization::state::{ ActiveLink, EdgeData, EdgeState, ExportState, INTERNET_IP, LinkDirection, NodeData, - VisualizationTabState, + VisualizationState, }; -use crate::visualization::visualization_stream::FlowEvent; +use crate::visualization::stream::FlowEvent; use chrono::{Datelike, Local, TimeZone}; use chrono_tz::Tz; use eframe::egui::{self, Widget}; @@ -96,7 +98,7 @@ pub fn show_run_tab_content( /// Handle configuration file changes fn handle_config_changes( - state: &mut VisualizationTabState, + state: &mut VisualizationState, configuration_file_state: &ConfigurationFileState, ) { // Check if config was removed or is empty @@ -118,7 +120,7 @@ fn handle_config_changes( state.stop_visualization(); } state.config_content = None; - *state = VisualizationTabState::default(); + *state = VisualizationState::default(); state.reset_view_requested = true; log::warn!("Config removed or empty, visualization reset to default"); return; @@ -166,7 +168,7 @@ fn handle_config_changes( // Log the error once and reset to default state instead of crashing // Store the config content so we don't retry parsing every frame log::error!("Failed to parse configuration: {:?}", e); - *state = VisualizationTabState::default(); + *state = VisualizationState::default(); state.config_content = Some(config_content.clone()); state.reset_view_requested = true; } @@ -176,7 +178,7 @@ fn handle_config_changes( } /// Process incoming flow events from the streamer -fn process_flow_events(state: &mut VisualizationTabState) { +fn process_flow_events(state: &mut VisualizationState) { let events: Vec = if let Some(ref receiver) = state.flow_receiver { receiver.try_iter().collect() } else { @@ -301,7 +303,7 @@ fn process_flow_events(state: &mut VisualizationTabState) { } /// Update active links (remove expired ones) -fn update_active_links(state: &mut VisualizationTabState) { +fn update_active_links(state: &mut VisualizationState) { let now = web_time::Instant::now(); // Base display time is 0.5s, adjusted by speed (faster = shorter display) let base_timeout_ms = 500.0; @@ -314,7 +316,7 @@ fn update_active_links(state: &mut VisualizationTabState) { } /// Update graph edges based on active links -fn update_graph_edges(state: &mut VisualizationTabState) { +fn update_graph_edges(state: &mut VisualizationState) { let graph = &mut state.graph; // Collect edge info first to avoid borrow issues diff --git a/crates/fosr-gui/src/shared/config_model.rs b/crates/fosr-gui/src/shared/config_model.rs index cfe961f1..7cea4855 100644 --- a/crates/fosr-gui/src/shared/config_model.rs +++ b/crates/fosr-gui/src/shared/config_model.rs @@ -1,3 +1,5 @@ +//! Configuration data model: Metadata, Host, Interface structs for YAML serde. + use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index efadf856..a4c2726c 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -1,10 +1,12 @@ +//! Configuration file state management: loading, parsing, and dirty tracking. + #[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, - shared::ui_utils::labeled_toggle, templates::load_template_by_id, + config_templates::load_template_by_id, configuration::tab::ConfigurationTabState, + shared::config_model::Configuration, shared::ui_utils::labeled_toggle, }; use chrono::{DateTime, Local}; use eframe::egui; @@ -137,7 +139,7 @@ pub fn configuration_file_picker( // Template dropdown menu (always visible) let template_menu = ui.menu_button(egui_material_icons::icons::ICON_DESCRIPTION, |menu_ui| { - for template in crate::templates::all_templates() { + for template in crate::config_templates::all_templates() { if menu_ui .button(format!("{} {}", template.icon, template.title)) .clicked() diff --git a/crates/fosr-gui/src/shared/file_io.rs b/crates/fosr-gui/src/shared/file_io.rs index ac470a85..f5e7dea1 100644 --- a/crates/fosr-gui/src/shared/file_io.rs +++ b/crates/fosr-gui/src/shared/file_io.rs @@ -1,3 +1,5 @@ +//! Cross-platform file I/O: desktop (native) and WASM (async) implementations. + use rfd::FileHandle; use std::io::Error; diff --git a/crates/fosr-gui/src/shared/mod.rs b/crates/fosr-gui/src/shared/mod.rs index 69e0f1f5..2e3270b1 100644 --- a/crates/fosr-gui/src/shared/mod.rs +++ b/crates/fosr-gui/src/shared/mod.rs @@ -1,3 +1,5 @@ +//! Shared utilities: config model, file I/O, and reusable UI components. + pub mod config_model; pub mod configuration_file; pub mod file_io; diff --git a/crates/fosr-gui/src/shared/ui_utils.rs b/crates/fosr-gui/src/shared/ui_utils.rs index fcdeef80..5c3be1f0 100644 --- a/crates/fosr-gui/src/shared/ui_utils.rs +++ b/crates/fosr-gui/src/shared/ui_utils.rs @@ -1,3 +1,5 @@ +//! Reusable UI widgets: info icons, optional string editors, toggles, labels. + use eframe::egui::{self, TextFormat, text::LayoutJob}; /// Display a small info icon with a tooltip. diff --git a/crates/fosr-gui/src/timepicker/button.rs b/crates/fosr-gui/src/timepicker/button.rs index dba39cb1..71101b16 100644 --- a/crates/fosr-gui/src/timepicker/button.rs +++ b/crates/fosr-gui/src/timepicker/button.rs @@ -1,3 +1,6 @@ +//! Time picker button widget that toggles a popup on click. +//! Derived from egui_timepicker (MIT License). + // This file is derived from egui_timepicker. // Copyright (c) 2025 Tilen Pintarič // Licensed under the MIT License (see mod.rs for full text). diff --git a/crates/fosr-gui/src/timepicker/mod.rs b/crates/fosr-gui/src/timepicker/mod.rs index a5857691..43ca49b9 100644 --- a/crates/fosr-gui/src/timepicker/mod.rs +++ b/crates/fosr-gui/src/timepicker/mod.rs @@ -1,3 +1,5 @@ +//! Time picker widget (derived from egui_timepicker) with theme support. + // Portions of this software are derived from egui_timepicker v0.1.0 (https://crates.io/crates/egui_timepicker). // // MIT License diff --git a/crates/fosr-gui/src/timepicker/popup.rs b/crates/fosr-gui/src/timepicker/popup.rs index bc873318..aa1b1fa1 100644 --- a/crates/fosr-gui/src/timepicker/popup.rs +++ b/crates/fosr-gui/src/timepicker/popup.rs @@ -1,3 +1,6 @@ +//! Time picker popup with clock face and drag-value inputs. +//! Derived from egui_timepicker (MIT License). + // This file is derived from egui_timepicker. // Copyright (c) 2025 Tilen Pintarič // Licensed under the MIT License (see mod.rs for full text). diff --git a/crates/fosr-gui/src/visualization/mod.rs b/crates/fosr-gui/src/visualization/mod.rs index bb85843a..a85c6a1b 100644 --- a/crates/fosr-gui/src/visualization/mod.rs +++ b/crates/fosr-gui/src/visualization/mod.rs @@ -1,7 +1,9 @@ -pub mod visualization_modal; -pub mod visualization_overlays; -pub mod visualization_screenshot; -pub mod visualization_shapes; -pub mod visualization_state; -pub mod visualization_stream; -pub mod visualization_utils; +//! Network visualization module: real-time graph display with custom nodes and edges. + +pub mod node_modal; +pub mod overlays; +pub mod screenshot; +pub mod shapes; +pub mod state; +pub mod stream; +pub mod utils; diff --git a/crates/fosr-gui/src/visualization/visualization_modal.rs b/crates/fosr-gui/src/visualization/node_modal.rs similarity index 96% rename from crates/fosr-gui/src/visualization/visualization_modal.rs rename to crates/fosr-gui/src/visualization/node_modal.rs index 963cd7bb..593d7632 100644 --- a/crates/fosr-gui/src/visualization/visualization_modal.rs +++ b/crates/fosr-gui/src/visualization/node_modal.rs @@ -1,12 +1,14 @@ -use super::visualization_shapes::{ICON_TINT_DARK, ICON_TINT_LIGHT}; -use super::visualization_state::{NodeType, VisualizationTabState}; +//! Node click handling and info/edit modal for the visualization graph. + +use super::shapes::{ICON_TINT_DARK, ICON_TINT_LIGHT}; +use super::state::{NodeType, VisualizationState}; use crate::shared::configuration_file::ConfigurationFileState; use eframe::egui; use egui_graphs::events::{Event, PayloadNodeClick}; /// Process graph click events from the event buffer pub fn process_graph_events( - state: &mut VisualizationTabState, + state: &mut VisualizationState, configuration_file_state: &ConfigurationFileState, ) { let events: Vec = state.events_buffer.borrow_mut().drain(..).collect(); @@ -32,7 +34,7 @@ pub fn process_graph_events( /// Render the node information modal for the clicked node pub fn render_node_info_modal( ctx: &egui::Context, - state: &mut VisualizationTabState, + state: &mut VisualizationState, config_file_state: &mut ConfigurationFileState, ) { if !state.node_info_modal_open { diff --git a/crates/fosr-gui/src/visualization/visualization_overlays.rs b/crates/fosr-gui/src/visualization/overlays.rs similarity index 97% rename from crates/fosr-gui/src/visualization/visualization_overlays.rs rename to crates/fosr-gui/src/visualization/overlays.rs index 15699b13..ff01b6b2 100644 --- a/crates/fosr-gui/src/visualization/visualization_overlays.rs +++ b/crates/fosr-gui/src/visualization/overlays.rs @@ -1,8 +1,10 @@ -use super::visualization_shapes::{ +//! Graph overlay UI: control buttons, stats display, and legends for nodes/edges. + +use super::shapes::{ COLOR_DNS, COLOR_HTTP, COLOR_HTTPS, COLOR_INACTIVE, COLOR_OTHER, COLOR_SMTP, COLOR_SSH, ICON_TINT_DARK, ICON_TINT_LIGHT, }; -use super::visualization_state::{STOP_BUTTON_COLOR, VisualizationTabState}; +use super::state::{STOP_BUTTON_COLOR, VisualizationState}; use eframe::egui; /// Helper to render a single legend item inline (for edges) @@ -35,7 +37,7 @@ fn legend_item_with_image(ui: &mut egui::Ui, label: &str, image: egui::ImageSour } /// Render overlay buttons in the top-left corner of the graph -pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationTabState) { +pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) { let local_rect = ui.max_rect(); egui::Area::new(egui::Id::new("viz_overlay_buttons")) @@ -110,8 +112,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationTabSta .on_hover_text("Export as PNG") .clicked() { - state.export_state = - super::visualization_state::ExportState::HidingOverlays; + state.export_state = super::state::ExportState::HidingOverlays; } ui.separator(); @@ -158,7 +159,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationTabSta } /// Render overlay stats in the bottom-left corner of the graph -pub fn render_overlay_stats(ui: &mut egui::Ui, state: &VisualizationTabState) { +pub fn render_overlay_stats(ui: &mut egui::Ui, state: &VisualizationState) { let local_rect = ui.max_rect(); egui::Area::new(egui::Id::new("viz_overlay_stats")) diff --git a/crates/fosr-gui/src/visualization/visualization_screenshot.rs b/crates/fosr-gui/src/visualization/screenshot.rs similarity index 94% rename from crates/fosr-gui/src/visualization/visualization_screenshot.rs rename to crates/fosr-gui/src/visualization/screenshot.rs index da3e3cd4..5ff18470 100644 --- a/crates/fosr-gui/src/visualization/visualization_screenshot.rs +++ b/crates/fosr-gui/src/visualization/screenshot.rs @@ -1,5 +1,7 @@ -use super::visualization_state::ExportState; -use super::visualization_state::VisualizationTabState; +//! Graph screenshot export with a 2-frame state machine for clean PNG output. + +use super::state::ExportState; +use super::state::VisualizationState; use eframe::egui; /// Handle screenshot export state machine. @@ -7,7 +9,7 @@ use eframe::egui; /// - Frame N: user clicks export → HidingOverlays /// - Frame N+1: overlays hidden → request screenshot → WaitingForScreenshot /// - Frame N+2: screenshot received → extract graph region → save → Idle -pub fn handle_screenshot_export(ui: &mut egui::Ui, state: &mut VisualizationTabState) { +pub fn handle_screenshot_export(ui: &mut egui::Ui, state: &mut VisualizationState) { // Transition: HidingOverlays → WaitingForScreenshot (request screenshot) if state.export_state == ExportState::HidingOverlays { state.export_state = ExportState::WaitingForScreenshot; diff --git a/crates/fosr-gui/src/visualization/visualization_shapes.rs b/crates/fosr-gui/src/visualization/shapes.rs similarity index 98% rename from crates/fosr-gui/src/visualization/visualization_shapes.rs rename to crates/fosr-gui/src/visualization/shapes.rs index 0c0e950a..9cffa6e5 100644 --- a/crates/fosr-gui/src/visualization/visualization_shapes.rs +++ b/crates/fosr-gui/src/visualization/shapes.rs @@ -1,6 +1,6 @@ -//! Custom node and edge shapes for network visualization +//! Custom node and edge shapes with protocol colors, icons, and dynamic sizing. -use super::visualization_state::{EdgeData, EdgeState, LinkDirection, NodeData, NodeType}; +use super::state::{EdgeData, EdgeState, LinkDirection, NodeData, NodeType}; use eframe::egui::{self, Color32, Pos2, Rect, Shape, TextureOptions, Vec2, load::SizeHint}; use egui_graphs::{DisplayEdge, DisplayNode, DrawContext, Node, NodeProps}; use fosr_lib::L7Proto; diff --git a/crates/fosr-gui/src/visualization/visualization_state.rs b/crates/fosr-gui/src/visualization/state.rs similarity index 97% rename from crates/fosr-gui/src/visualization/visualization_state.rs rename to crates/fosr-gui/src/visualization/state.rs index d5884747..99dcc53e 100644 --- a/crates/fosr-gui/src/visualization/visualization_state.rs +++ b/crates/fosr-gui/src/visualization/state.rs @@ -1,6 +1,8 @@ -use super::visualization_shapes::{NetworkEdgeShape, NetworkNodeShape}; -use super::visualization_stream::{FlowEvent, FlowStreamer}; -use super::visualization_utils::distribute_nodes_circle; +//! Visualization state: graph data, active links, flow processing, and export. + +use super::shapes::{NetworkEdgeShape, NetworkNodeShape}; +use super::stream::{FlowEvent, FlowStreamer}; +use super::utils::distribute_nodes_circle; use crate::shared::config_model::Host; use eframe::egui; use egui_graphs::events::Event; @@ -174,7 +176,7 @@ pub enum ExportState { } /// Represents the state of the visualization tab. -pub struct VisualizationTabState { +pub struct VisualizationState { pub graph: VisualizationGraph, pub flow_receiver: Option>, pub active_links: HashMap<(Ipv4Addr, Ipv4Addr), ActiveLink>, @@ -222,7 +224,7 @@ pub struct VisualizationTabState { pub export_state: ExportState, } -impl Default for VisualizationTabState { +impl Default for VisualizationState { fn default() -> Self { // Start with an empty graph; the default config from ConfigurationFileState // will be detected by handle_config_changes() on the first frame. @@ -256,7 +258,7 @@ impl Default for VisualizationTabState { } } -impl VisualizationTabState { +impl VisualizationState { /// 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) { diff --git a/crates/fosr-gui/src/visualization/visualization_stream.rs b/crates/fosr-gui/src/visualization/stream.rs similarity index 100% rename from crates/fosr-gui/src/visualization/visualization_stream.rs rename to crates/fosr-gui/src/visualization/stream.rs diff --git a/crates/fosr-gui/src/visualization/visualization_utils.rs b/crates/fosr-gui/src/visualization/utils.rs similarity index 91% rename from crates/fosr-gui/src/visualization/visualization_utils.rs rename to crates/fosr-gui/src/visualization/utils.rs index af6603fd..8c12e274 100644 --- a/crates/fosr-gui/src/visualization/visualization_utils.rs +++ b/crates/fosr-gui/src/visualization/utils.rs @@ -1,3 +1,5 @@ +//! Graph layout utilities: circular node distribution for initial display. + use eframe::egui; /// Distributes the graph nodes in a circle layout. From 061795f0a3382b9ec2b23fd2ff67d0218e29061b Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:17:11 +0100 Subject: [PATCH 103/127] refactor(gui): extract constants in dedicated files + clamp max/min zoom --- crates/fosr-gui/src/about_tab.rs | 30 +- crates/fosr-gui/src/app.rs | 80 +++-- crates/fosr-gui/src/configuration/host.rs | 26 +- .../src/configuration/host_interfaces.rs | 21 +- .../src/configuration/host_services.rs | 18 +- .../src/configuration/host_validation.rs | 5 +- crates/fosr-gui/src/configuration/tab.rs | 12 +- .../fosr-gui/src/configuration/yaml_editor.rs | 15 +- .../fosr-gui/src/generation/ui_components.rs | 15 +- crates/fosr-gui/src/main.rs | 7 +- crates/fosr-gui/src/run/tab.rs | 71 ++-- crates/fosr-gui/src/shared/colors.rs | 70 ++++ .../fosr-gui/src/shared/configuration_file.rs | 7 +- crates/fosr-gui/src/shared/mod.rs | 3 + .../fosr-gui/src/shared/network_constants.rs | 74 +++++ crates/fosr-gui/src/shared/ui_constants.rs | 313 ++++++++++++++++++ crates/fosr-gui/src/shared/ui_utils.rs | 19 +- .../fosr-gui/src/visualization/node_modal.rs | 21 +- crates/fosr-gui/src/visualization/overlays.rs | 64 ++-- crates/fosr-gui/src/visualization/shapes.rs | 77 ++--- crates/fosr-gui/src/visualization/state.rs | 3 - crates/fosr-gui/src/visualization/stream.rs | 17 +- crates/fosr-gui/src/visualization/utils.rs | 3 +- 23 files changed, 744 insertions(+), 227 deletions(-) create mode 100644 crates/fosr-gui/src/shared/colors.rs create mode 100644 crates/fosr-gui/src/shared/network_constants.rs create mode 100644 crates/fosr-gui/src/shared/ui_constants.rs diff --git a/crates/fosr-gui/src/about_tab.rs b/crates/fosr-gui/src/about_tab.rs index 637a066c..df8bb22c 100644 --- a/crates/fosr-gui/src/about_tab.rs +++ b/crates/fosr-gui/src/about_tab.rs @@ -1,57 +1,61 @@ //! About tab: Fos-R information, usage guide, and repository links. +use crate::shared::ui_constants::{LOGO_MAX_WIDTH, SPACING_LG, SPACING_SM, SPACING_XXL}; use eframe::egui; pub fn show_about_tab_content(ui: &mut egui::Ui) { ui.vertical_centered(|ui| { - ui.add(egui::Image::new(egui::include_image!("../../../public/logo.png")).max_width(450.0)); + ui.add( + egui::Image::new(egui::include_image!("../../../public/logo.png")) + .max_width(LOGO_MAX_WIDTH), + ); }); ui.separator(); - ui.add_space(10.0); + ui.add_space(SPACING_LG); ui.heading("Fos-R - Synthetic Network Traffic Generator"); - ui.add_space(5.0); + ui.add_space(SPACING_SM); ui.label( "Fos-R is a high-quality and high-throughput network traffic generator based on AI models.", ); - ui.add_space(15.0); + ui.add_space(SPACING_XXL); ui.heading("Usage Guide"); - ui.add_space(5.0); + ui.add_space(SPACING_SM); ui.label("This GUI helps you design a Fos-R network configuration visually and generate synthetic network traffic as PCAP files. Export your configuration to use it with the CLI, which can generate and inject live traffic directly on a network."); - ui.add_space(10.0); + ui.add_space(SPACING_LG); ui.label(egui::RichText::new("Run").strong()); ui.label("• Live Preview: visualize what Fos-R would generate based on your configuration. This is a real-time simulation, not the actual generation. Use it to quickly verify your network topology. Click on a node to adjust some properties."); ui.label("• Generation: generate the PCAP file from your configuration. Set parameters like duration and start time. For reproducible results, use a fixed seed. On desktop, open the result in Wireshark to take a quick look."); - ui.add_space(8.0); + ui.add_space(SPACING_LG); ui.label(egui::RichText::new("Configuration").strong()); ui.label("Define your network: hosts, their interfaces, and the services they provide. Create a configuration from a template or import an existing YAML file. Switch between visual editing and raw YAML at any time."); - ui.add_space(15.0); + ui.add_space(SPACING_XXL); ui.heading("Repository Information"); - ui.add_space(5.0); + ui.add_space(SPACING_SM); ui.horizontal(|ui| { ui.label("GitLab - Main repository:"); ui.hyperlink("https://gitlab.inria.fr/pirat-public/Fos-R"); }); - ui.add_space(5.0); + ui.add_space(SPACING_SM); ui.horizontal(|ui| { ui.label("GitHub mirror:"); ui.hyperlink("https://github.com/Fos-R/Fos-R"); }); - ui.add_space(5.0); + ui.add_space(SPACING_SM); ui.horizontal(|ui| { ui.label("Website:"); ui.hyperlink("https://fosr.inria.fr"); }); - ui.add_space(15.0); + ui.add_space(SPACING_XXL); ui.heading("Contact"); - ui.add_space(5.0); + ui.add_space(SPACING_SM); ui.horizontal(|ui| { ui.label("Maintainer:"); ui.hyperlink_to( diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index 4742f518..6250501f 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -4,11 +4,20 @@ use crate::about_tab::show_about_tab_content; use crate::config_templates::{all_templates, load_template_by_id}; use crate::configuration::tab::{ConfigurationTabState, show_configuration_tab_content}; use crate::run::{RunState, show_run_tab_content}; +use crate::shared::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; #[cfg(target_arch = "wasm32")] use crate::shared::configuration_file::poll_file_import; use crate::shared::configuration_file::{ ConfigurationFileState, StartupModalState, trigger_file_import, }; +#[cfg(not(target_arch = "wasm32"))] +use crate::shared::ui_constants::MODAL_WIDTH_SM; +use crate::shared::ui_constants::{ + BUTTON_PADDING, ICON_SIZE_LG, MODAL_WIDTH_MD, PANEL_INNER_MARGIN, SPACING_LG, SPACING_SM, + SPACING_XL, SPACING_XS, STARTUP_CARD_HEIGHT, STARTUP_COLUMNS_INITIAL, + STARTUP_COLUMNS_TEMPLATES, TEXT_SIZE_DEFAULT, TEXT_SIZE_LG, TEXT_SIZE_SM, TOOLTIP_DELAY, + ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP, +}; use eframe::egui; use eframe::egui::global_theme_preference_switch; @@ -25,11 +34,6 @@ impl Default for CurrentTab { } } -#[cfg(target_arch = "wasm32")] -pub const DEFAULT_ZOOM: f32 = 1.2; -#[cfg(not(target_arch = "wasm32"))] -pub const DEFAULT_ZOOM: f32 = 1.4; - #[derive(Default)] pub struct FosrApp { current_tab: CurrentTab, @@ -51,9 +55,9 @@ impl eframe::App for FosrApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Set default zoom once if !self.style_initialized { - self.zoom_factor = DEFAULT_ZOOM; + self.zoom_factor = ZOOM_DEFAULT; ctx.options_mut(|option| option.zoom_factor = self.zoom_factor); - ctx.style_mut(|s| s.interaction.tooltip_delay = 0.1); + ctx.style_mut(|s| s.interaction.tooltip_delay = TOOLTIP_DELAY); // On web, use dark theme to match with the Fos-R website's theme #[cfg(target_arch = "wasm32")] @@ -62,6 +66,14 @@ impl eframe::App for FosrApp { self.style_initialized = true; } + // Clamp zoom to min/max (prevents Ctrl+/- from exceeding limits) + let current_zoom = ctx.zoom_factor(); + if current_zoom < ZOOM_MIN || current_zoom > ZOOM_MAX { + let clamped_zoom = current_zoom.clamp(ZOOM_MIN, ZOOM_MAX); + ctx.set_zoom_factor(clamped_zoom); + self.zoom_factor = clamped_zoom; + } + // Set the image loaders // Required for egui to display images egui_extras::install_image_loaders(ctx); @@ -123,12 +135,12 @@ impl eframe::App for FosrApp { #[cfg(not(target_arch = "wasm32"))] if self.show_close_confirmation { egui::Modal::new(egui::Id::new("close_confirmation_modal")).show(ctx, |ui| { - ui.set_width(370.0); + ui.set_width(MODAL_WIDTH_SM); ui.heading("Confirm Exit"); - ui.add_space(8.0); + ui.add_space(SPACING_LG); ui.label("You have Wireshark session(s) open with temporary PCAP files."); ui.label("Closing will delete these files."); - ui.add_space(12.0); + ui.add_space(SPACING_XL); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { self.show_close_confirmation = false; @@ -144,12 +156,12 @@ impl eframe::App for FosrApp { // The Top Panel is logically at the top of the window. egui::TopBottomPanel::top("top_panel") - .frame(egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin::symmetric(4, 3))) + .frame(egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin::symmetric(PANEL_INNER_MARGIN.0, PANEL_INNER_MARGIN.1))) .show(ctx, |ui| { // Add a Menu Bar to host the tabs buttons egui::MenuBar::new().ui(ui, |ui| { - ui.spacing_mut().button_padding = egui::vec2(5.0, 2.0); - let tab_text_size = 14.0; + ui.spacing_mut().button_padding = egui::vec2(BUTTON_PADDING.0, BUTTON_PADDING.1); + let tab_text_size = TEXT_SIZE_DEFAULT; let has_errors = self.configuration_file_state.has_errors; @@ -173,7 +185,7 @@ impl eframe::App for FosrApp { // Configuration tab let label_text = if self.configuration_file_state.has_errors { - egui::RichText::new("⚠ Configuration").color(egui::Color32::RED).size(tab_text_size) + egui::RichText::new("⚠ Configuration").color(COLOR_ERROR).size(tab_text_size) } else { egui::RichText::new("Configuration").size(tab_text_size) }; @@ -227,7 +239,7 @@ impl eframe::App for FosrApp { } #[cfg(not(target_arch = "wasm32"))] - ui.add_space(4.0); + ui.add_space(SPACING_SM); // Show the theme switch global_theme_preference_switch(ui); @@ -239,7 +251,7 @@ impl eframe::App for FosrApp { .clicked() { let current_zoom = ctx.zoom_factor(); - let new_zoom = (current_zoom + 0.1).min(3.0); + let new_zoom = (current_zoom + ZOOM_STEP).min(ZOOM_MAX); ctx.set_zoom_factor(new_zoom); self.zoom_factor = new_zoom; } @@ -250,7 +262,7 @@ impl eframe::App for FosrApp { .clicked() { let current_zoom = ctx.zoom_factor(); - let new_zoom = (current_zoom - 0.1).max(0.5); + let new_zoom = (current_zoom - ZOOM_STEP).max(ZOOM_MIN); ctx.set_zoom_factor(new_zoom); self.zoom_factor = new_zoom; } @@ -294,7 +306,7 @@ 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 desired_size = egui::vec2(ui.available_width(), STARTUP_CARD_HEIGHT); let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); // Choose fill color based on hover @@ -312,17 +324,17 @@ fn startup_card(ui: &mut egui::Ui, icon: &str, title: &str, description: &str) - 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.add_space(SPACING_LG); + ui.label(egui::RichText::new(icon).size(ICON_SIZE_LG)); + ui.add_space(SPACING_SM); + ui.strong(egui::RichText::new(title).size(TEXT_SIZE_LG)); + ui.add_space(SPACING_XS); ui.label( egui::RichText::new(description) - .size(12.0) - .color(egui::Color32::GRAY), + .size(TEXT_SIZE_SM) + .color(COLOR_TEXT_MUTED), ); - ui.add_space(8.0); + ui.add_space(SPACING_LG); }); }); }); @@ -344,13 +356,13 @@ fn render_startup_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) fn render_initial_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { // Use the same modal ID as template selection to avoid flicker when transitioning egui::Modal::new(egui::Id::new("startup_modal")).show(ctx, |ui| { - ui.set_width(400.0); + ui.set_width(MODAL_WIDTH_MD); ui.heading("Welcome to Fos-R"); - ui.add_space(4.0); + ui.add_space(SPACING_SM); ui.label("Choose a configuration to get started:"); - ui.add_space(12.0); + ui.add_space(SPACING_XL); - ui.columns(2, |cols| { + ui.columns(STARTUP_COLUMNS_INITIAL, |cols| { // Left: default config if startup_card( &mut cols[0], @@ -380,7 +392,7 @@ fn render_initial_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) fn render_template_selection_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { // Use the same modal ID as initial modal to avoid flicker when transitioning egui::Modal::new(egui::Id::new("startup_modal")).show(ctx, |ui| { - ui.set_width(400.0); + ui.set_width(MODAL_WIDTH_MD); // Header with back button ui.horizontal(|ui| { @@ -394,14 +406,14 @@ fn render_template_selection_modal(ctx: &egui::Context, state: &mut Configuratio ui.heading("Choose a template"); }); - ui.add_space(12.0); + ui.add_space(SPACING_XL); // Grid of template cards let templates = all_templates(); - ui.columns(3, |cols| { + ui.columns(STARTUP_COLUMNS_TEMPLATES, |cols| { for (i, template) in templates.iter().enumerate() { if startup_card( - &mut cols[i % 3], + &mut cols[i % STARTUP_COLUMNS_TEMPLATES], template.icon, template.title, template.description, diff --git a/crates/fosr-gui/src/configuration/host.rs b/crates/fosr-gui/src/configuration/host.rs index 2a99e0ac..d20ccbf8 100644 --- a/crates/fosr-gui/src/configuration/host.rs +++ b/crates/fosr-gui/src/configuration/host.rs @@ -1,7 +1,12 @@ //! Host editing UI: hostname, OS, type, and client protocols. use crate::configuration::{host_interfaces, host_services, host_validation}; +use crate::shared::colors::COLOR_ERROR; use crate::shared::config_model::{Configuration, Host}; +use crate::shared::network_constants::HOST_USAGE_DEFAULT; +use crate::shared::ui_constants::{ + PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_MD, SPACING_SM, +}; use crate::shared::ui_utils::{edit_optional_string, info_icon}; use eframe::egui; use std::collections::HashMap; @@ -18,7 +23,7 @@ pub fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { model.hosts.insert(0, Host::default()); } }); - ui.add_space(6.0); + ui.add_space(SPACING_MD); if model.hosts.is_empty() { ui.label("No hosts in this configuration."); @@ -45,7 +50,7 @@ pub fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { for (idx, host) in model.hosts.iter_mut().enumerate() { ui_single_host(ui, idx, host, &ip_counts, &mac_counts, &mut host_to_remove); - ui.add_space(6.0); + ui.add_space(SPACING_MD); } if let Some(idx) = host_to_remove { @@ -78,7 +83,7 @@ fn ui_single_host( let error_text = errors.join(", "); let label_text = format!("{} {} - {}", warning_icon, host_name, error_text); - ui.colored_label(egui::Color32::RED, label_text) + ui.colored_label(COLOR_ERROR, label_text) .on_hover_ui(|ui| { ui_host_summary_tooltip(ui, host); }); @@ -101,13 +106,13 @@ fn ui_single_host( ui.horizontal(|ui| { 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); + info_icon(ui, &format!("Optional (default value: {0}). The usage intensity of the host. {0} is the baseline, < {0} means less usage than usual, and > {0} means higher usage", HOST_USAGE_DEFAULT)); + let mut usage_val = host.usage.unwrap_or(HOST_USAGE_DEFAULT); if ui .add(egui::DragValue::new(&mut usage_val).speed(0.1)) .changed() { - host.usage = if (usage_val - 1.0).abs() < f32::EPSILON { + host.usage = if (usage_val - HOST_USAGE_DEFAULT).abs() < f32::EPSILON { None } else { Some(usage_val) @@ -144,7 +149,7 @@ fn ui_host_summary_tooltip(ui: &mut egui::Ui, host: &Host) { }); } - ui.add_space(4.0); + ui.add_space(SPACING_SM); ui.label("Interfaces :"); if host.interfaces.is_empty() { @@ -252,7 +257,7 @@ fn ui_host_client_protocols(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) egui::Popup::from_toggle_button_response(&add_btn_resp) .id(popup_id) .show(|ui| { - ui.set_min_width(180.0); + ui.set_min_width(POPUP_MIN_WIDTH); let search_id = ui.make_persistent_id(("proto_search", host_idx)); let mut search_text = @@ -270,11 +275,10 @@ fn ui_host_client_protocols(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) ui.separator(); egui::ScrollArea::vertical() - .max_height(200.0) + .max_height(POPUP_MAX_HEIGHT) .auto_shrink([true; 2]) .show(ui, |ui| { - let desired_content_width = 250.0; - ui.set_width(desired_content_width); + ui.set_width(PANEL_MIN_WIDTH); let filter = search_text.to_lowercase(); let mut any_shown = false; diff --git a/crates/fosr-gui/src/configuration/host_interfaces.rs b/crates/fosr-gui/src/configuration/host_interfaces.rs index b172cb74..154bea16 100644 --- a/crates/fosr-gui/src/configuration/host_interfaces.rs +++ b/crates/fosr-gui/src/configuration/host_interfaces.rs @@ -1,14 +1,19 @@ //! Network interface editing UI: IP, MAC address, and associated services. use crate::configuration::host_services; +use crate::shared::colors::COLOR_ERROR; use crate::shared::config_model::{Host, Interface}; +use crate::shared::network_constants::{ + IP_LOCAL_MAX, IP_LOCAL_MIN, MAC_ADDRESS_BYTES, MAC_LOCAL_BIT, MAC_LOCAL_MASK, +}; +use crate::shared::ui_constants::{SPACING_MD, SPACING_SM}; use crate::shared::ui_utils::{edit_optional_string, required_label}; use eframe::egui; use std::collections::HashMap; /// 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 { + for x in IP_LOCAL_MIN..=IP_LOCAL_MAX { let candidate = format!("192.168.0.{x}"); if !ip_counts.contains_key(&candidate) { return Some(candidate); @@ -39,7 +44,7 @@ pub fn ui_interfaces_section( services: Vec::new(), }); } else { - ui.colored_label(egui::Color32::RED, "No free IP available in 192.168.0.0/24"); + ui.colored_label(COLOR_ERROR, "No free IP available in 192.168.0.0/24"); } } }); @@ -70,23 +75,23 @@ pub fn ui_interfaces_section( }); }) .body(|ui| { - ui.add_space(4.0); + ui.add_space(SPACING_SM); ui.horizontal(|ui| { 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"); + ui.colored_label(COLOR_ERROR, "IP already in used"); } }); 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.colored_label(COLOR_ERROR, "MAC already in use"); } } host_services::ui_services_section(ui, if_idx, host_idx, iface); }); - ui.add_space(6.0); + ui.add_space(SPACING_MD); } if let Some(idx) = iface_to_remove { @@ -96,10 +101,10 @@ pub fn ui_interfaces_section( /// Generate a random mac address fn random_mac() -> String { - let mut bytes: [u8; 6] = rand::random(); + let mut bytes: [u8; MAC_ADDRESS_BYTES] = rand::random(); // Forcing local MAC - bytes[0] = (bytes[0] | 0x02) & 0xFE; + bytes[0] = (bytes[0] | MAC_LOCAL_BIT) & MAC_LOCAL_MASK; format!( "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", diff --git a/crates/fosr-gui/src/configuration/host_services.rs b/crates/fosr-gui/src/configuration/host_services.rs index 8a5fe274..dff368ed 100644 --- a/crates/fosr-gui/src/configuration/host_services.rs +++ b/crates/fosr-gui/src/configuration/host_services.rs @@ -1,6 +1,10 @@ //! Service editing UI: HTTP, SSH, DNS, etc. with custom port support. use crate::shared::config_model::Interface; +use crate::shared::network_constants::{PORT_DEFAULT_UNKNOWN, PORT_MAX, PORT_MIN}; +use crate::shared::ui_constants::{ + PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_SM, SPACING_XS, +}; use crate::shared::ui_utils::info_icon; use eframe::egui; @@ -55,7 +59,7 @@ pub fn ui_services_section( egui::Popup::from_toggle_button_response(&add_btn_resp) .id(popup_id) .show(|ui| { - ui.set_min_width(180.0); + ui.set_min_width(POPUP_MIN_WIDTH); let search_id = ui.make_persistent_id(("svc_search", host_idx, iface_idx)); let mut search_text = @@ -72,10 +76,10 @@ pub fn ui_services_section( ui.separator(); egui::ScrollArea::vertical() - .max_height(200.0) + .max_height(POPUP_MAX_HEIGHT) .auto_shrink([true; 2]) .show(ui, |ui| { - ui.set_width(250.0); + ui.set_width(PANEL_MIN_WIDTH); let filter = search_text.to_lowercase(); let mut any_shown = false; @@ -108,7 +112,7 @@ pub fn ui_services_section( }); }); - ui.add_space(4.0); + ui.add_space(SPACING_SM); let mut svc_to_remove: Option = None; @@ -121,7 +125,7 @@ pub fn ui_services_section( svc_raw, &mut svc_to_remove, ); - ui.add_space(2.0); + ui.add_space(SPACING_XS); } if let Some(idx) = svc_to_remove { @@ -145,7 +149,7 @@ fn ui_single_service( .iter() .find(|(n, _)| *n == svc_name) .and_then(|(_, p)| *p) - .unwrap_or(0); + .unwrap_or(PORT_DEFAULT_UNKNOWN); 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); @@ -181,7 +185,7 @@ fn ui_single_service( .add( egui::DragValue::new(&mut port_val) .speed(1) - .range(1..=65535), + .range(PORT_MIN..=PORT_MAX), ) .changed() { diff --git a/crates/fosr-gui/src/configuration/host_validation.rs b/crates/fosr-gui/src/configuration/host_validation.rs index 3f6b9e9c..20eb52cd 100644 --- a/crates/fosr-gui/src/configuration/host_validation.rs +++ b/crates/fosr-gui/src/configuration/host_validation.rs @@ -1,6 +1,7 @@ //! Host validation: IP/MAC format, conflicts, and type/service consistency. use crate::shared::config_model::{Configuration, Host}; +use crate::shared::network_constants::{MAC_ADDRESS_PARTS, MAC_PART_LENGTH}; use std::collections::HashMap; /// Function to validate if a host is correct @@ -73,10 +74,10 @@ pub fn has_model_errors(model: &Configuration) -> bool { /// 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 { + if parts.len() != MAC_ADDRESS_PARTS { return false; } parts .iter() - .all(|p| p.len() == 2 && u8::from_str_radix(p, 16).is_ok()) + .all(|p| p.len() == MAC_PART_LENGTH && u8::from_str_radix(p, 16).is_ok()) } diff --git a/crates/fosr-gui/src/configuration/tab.rs b/crates/fosr-gui/src/configuration/tab.rs index fb891ec9..79c7f332 100644 --- a/crates/fosr-gui/src/configuration/tab.rs +++ b/crates/fosr-gui/src/configuration/tab.rs @@ -1,10 +1,12 @@ //! Configuration tab: toggles between visual mode and YAML editor. use crate::configuration::{host, host_validation, yaml_editor}; +use crate::shared::colors::{COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING}; use crate::shared::config_model::Configuration; use crate::shared::configuration_file::{ ConfigurationFileState, configuration_file_picker, load_config_file_contents, }; +use crate::shared::ui_constants::{SPACING_MD, TEXT_EDIT_DEFAULT_ROWS}; use crate::shared::ui_utils::{ edit_optional_multiline_string, edit_optional_string, required_label, }; @@ -95,12 +97,12 @@ pub fn show_configuration_tab_content( 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.colored_label(COLOR_ERROR, "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(COLOR_SUCCESS, "YAML parsed successfully"); } else if state.config_file_content.is_some() { - ui.colored_label(egui::Color32::YELLOW, "YAML loaded, but not parsed yet."); + ui.colored_label(COLOR_WARNING, "YAML loaded, but not parsed yet."); } ui.separator(); } @@ -108,7 +110,7 @@ fn ui_parsing_status(ui: &mut egui::Ui, state: &ConfigurationFileState) { /// Metadata rendering fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { - ui.add_space(6.0); + ui.add_space(SPACING_MD); // Title ui.horizontal(|ui| { @@ -122,7 +124,7 @@ fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { "Description", &mut model.metadata.desc, "Optional description", - 3, + TEXT_EDIT_DEFAULT_ROWS, ); edit_optional_string(ui, "Author", &mut model.metadata.author, "Jane Doe"); diff --git a/crates/fosr-gui/src/configuration/yaml_editor.rs b/crates/fosr-gui/src/configuration/yaml_editor.rs index 0c88b806..c0327f40 100644 --- a/crates/fosr-gui/src/configuration/yaml_editor.rs +++ b/crates/fosr-gui/src/configuration/yaml_editor.rs @@ -1,6 +1,8 @@ //! YAML editor with syntax highlighting and error line markers. +use crate::shared::colors::COLOR_ERROR; use crate::shared::configuration_file::{ConfigurationFileState, parse_config_yaml}; +use crate::shared::ui_constants::{YAML_EDITOR_ROWS, YAML_GUTTER_PADDING, YAML_ICON_COL_WIDTH}; use eframe::egui; fn parse_error_lines(err: &str) -> Vec { @@ -28,13 +30,13 @@ pub fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { let error_lines: Vec = if let Some(err) = &state.parse_error { ui.colored_label( - egui::Color32::RED, + COLOR_ERROR, format!( "{} YAML parsing failed", egui_material_icons::icons::ICON_WARNING ), ); - ui.colored_label(egui::Color32::RED, err); + ui.colored_label(COLOR_ERROR, err); ui.separator(); parse_error_lines(err) } else { @@ -49,11 +51,10 @@ pub fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { 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_width = digit_width * digits as f32 + YAML_GUTTER_PADDING + YAML_ICON_COL_WIDTH; let gutter_color = ui.visuals().weak_text_color(); - let error_color = egui::Color32::from_rgb(220, 50, 50); + let error_color = COLOR_ERROR; let gutter_bg = ui.visuals().extreme_bg_color; let scroll_offset_id = ui.make_persistent_id("yaml_editor_scroll_y"); @@ -101,7 +102,7 @@ pub fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { if is_error { let icon_rect = egui::Rect::from_min_size( egui::pos2(num_col_right, y), - egui::vec2(icon_col_width, line_height), + egui::vec2(YAML_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); @@ -128,7 +129,7 @@ pub fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { egui::TextEdit::multiline(&mut content) .font(egui::TextStyle::Monospace) .code_editor() - .desired_rows(20) + .desired_rows(YAML_EDITOR_ROWS) .lock_focus(true) .desired_width(f32::INFINITY) .layouter(&mut layouter), diff --git a/crates/fosr-gui/src/generation/ui_components.rs b/crates/fosr-gui/src/generation/ui_components.rs index c5087917..a57270a8 100644 --- a/crates/fosr-gui/src/generation/ui_components.rs +++ b/crates/fosr-gui/src/generation/ui_components.rs @@ -28,14 +28,18 @@ use super::state::GenerationState; use super::validation::FieldValidation; +use crate::shared::colors::COLOR_ERROR; +use crate::shared::ui_constants::{ + SPACING_MD, TIMEZONE_LIST_MAX_HEIGHT, TIMEZONE_PICKER_WIDTH, TIMEZONE_POPUP_MAX_HEIGHT, +}; use chrono_tz::TZ_VARIANTS; use eframe::egui::{self, epaint}; /// 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); - ui.colored_label(egui::Color32::RED, msg); + ui.add_space(SPACING_MD); + ui.colored_label(COLOR_ERROR, msg); } } @@ -44,11 +48,10 @@ pub fn timezone_picker(ui: &mut egui::Ui, state: &mut GenerationState) { 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 desired_size = egui::vec2(TIMEZONE_PICKER_WIDTH, ui.spacing().interact_size.y); let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); @@ -108,7 +111,7 @@ pub fn timezone_picker(ui: &mut egui::Ui, state: &mut GenerationState) { 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); + ui.set_max_height(TIMEZONE_POPUP_MAX_HEIGHT); // Search input with auto-focus on open let edit_id = ui.make_persistent_id("tz_search"); @@ -125,7 +128,7 @@ pub fn timezone_picker(ui: &mut egui::Ui, state: &mut GenerationState) { // Filtered timezone list egui::ScrollArea::vertical() - .max_height(400.0) + .max_height(TIMEZONE_LIST_MAX_HEIGHT) .show(ui, |ui| { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); let filter = state.timezone_input.to_lowercase(); diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index 3a3e7940..ef2c88a5 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -15,6 +15,9 @@ mod visualization; #[cfg(not(target_arch = "wasm32"))] fn main() -> eframe::Result { use crate::app::FosrApp; + use crate::shared::ui_constants::{ + WINDOW_DEFAULT_HEIGHT, WINDOW_DEFAULT_WIDTH, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, + }; use eframe::egui; use env_logger; @@ -22,8 +25,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([1200.0, 1000.0]) - .with_min_inner_size([550.0, 500.0]) + .with_inner_size([WINDOW_DEFAULT_WIDTH, WINDOW_DEFAULT_HEIGHT]) + .with_min_inner_size([WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT]) .with_title("Fos-R"); #[cfg(target_os = "macos")] diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs index bfda94fd..0986019d 100644 --- a/crates/fosr-gui/src/run/tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -8,11 +8,19 @@ use crate::generation::validation::{ }; #[cfg(not(target_arch = "wasm32"))] use crate::generation::wireshark::open_in_wireshark; +use crate::shared::colors::{COLOR_ERROR, COLOR_STOP, COLOR_SUCCESS, COLOR_TEXT_MUTED}; use crate::shared::configuration_file::{ConfigurationFileState, load_config_file_contents}; #[cfg(not(target_arch = "wasm32"))] use crate::shared::file_io::save_file_desktop; #[cfg(target_arch = "wasm32")] use crate::shared::file_io::save_file_wasm; +use crate::shared::ui_constants::{ + ACTIVE_LINK_BASE_TIMEOUT_MS, BOTTOM_BAR_INNER_MARGIN, BUTTON_HEIGHT, BUTTON_MIN_WIDTH_LG, + BUTTON_MIN_WIDTH_SM, DELAY_FRAMES_NORMAL, DELAY_FRAMES_QUICK, DURATION_TEXT_WIDTH, + FIT_TO_SCREEN_PADDING, GENERATION_COL1_MIN_WIDTH, GENERATION_COL2_MIN_WIDTH, + GENERATION_OPTIONS_COLUMNS, OPTIONS_PANEL_INNER_MARGIN, SEED_INPUT_WIDTH, SPACING_LG, + TEXT_SIZE_MD, +}; use crate::shared::ui_utils::info_icon; use crate::timepicker::TimePickerButton; use crate::visualization::node_modal::{process_graph_events, render_node_info_modal}; @@ -160,7 +168,7 @@ fn handle_config_changes( state.config_content = Some(config_content.clone()); // Only auto-restart if visualization was running before config change if was_running { - state.auto_start_countdown = Some(10); + state.auto_start_countdown = Some(DELAY_FRAMES_NORMAL); } state.reset_view_requested = true; } @@ -306,7 +314,7 @@ fn process_flow_events(state: &mut VisualizationState) { fn update_active_links(state: &mut VisualizationState) { let now = web_time::Instant::now(); // Base display time is 0.5s, adjusted by speed (faster = shorter display) - let base_timeout_ms = 500.0; + let base_timeout_ms = ACTIVE_LINK_BASE_TIMEOUT_MS; let speed = *state.speed.read().unwrap(); let timeout = std::time::Duration::from_millis((base_timeout_ms / speed) as u64); @@ -377,10 +385,10 @@ fn update_graph_edges(state: &mut VisualizationState) { /// Show the generation options fn show_generation_options(ui: &mut egui::Ui, state: &mut RunState) { - ui.columns(2, |cols| { + ui.columns(GENERATION_OPTIONS_COLUMNS, |cols| { // --- Column 1: Duration & Time --- let col1 = &mut cols[0]; - col1.set_min_width(280.0); + col1.set_min_width(GENERATION_COL1_MIN_WIDTH); // Duration col1.horizontal(|ui| { @@ -396,7 +404,7 @@ fn show_generation_options(ui: &mut egui::Ui, state: &mut RunState) { } let text_response = egui::TextEdit::singleline(&mut state.generation.duration_str) - .desired_width(80.0) + .desired_width(DURATION_TEXT_WIDTH) .hint_text("ex: 30m, 1h, 2d") .ui(ui); @@ -414,7 +422,7 @@ fn show_generation_options(ui: &mut egui::Ui, state: &mut RunState) { show_field_error(ui, &state.generation.duration_validation); }); - col1.add_space(8.0); + col1.add_space(SPACING_LG); // Use current time col1.horizontal(|ui| { @@ -437,7 +445,7 @@ fn show_generation_options(ui: &mut egui::Ui, state: &mut RunState) { ); }); - col1.add_space(8.0); + col1.add_space(SPACING_LG); col1.horizontal(|ui| { if ui @@ -506,13 +514,13 @@ fn show_generation_options(ui: &mut egui::Ui, state: &mut RunState) { if let Some(text) = utc_text { col1.label( egui::RichText::new(format!("Start time (UTC): {}", text)) - .color(egui::Color32::GRAY), + .color(COLOR_TEXT_MUTED), ); } // --- Column 2: Seed & Advanced --- let col2 = &mut cols[1]; - col2.set_min_width(200.0); + col2.set_min_width(GENERATION_COL2_MIN_WIDTH); // Seed col2.horizontal(|ui| { @@ -523,7 +531,7 @@ fn show_generation_options(ui: &mut egui::Ui, state: &mut RunState) { let response = ui.add( egui::TextEdit::singleline(&mut state.generation.seed_input) .hint_text("enter a seed value") - .desired_width(120.0), + .desired_width(SEED_INPUT_WIDTH), ); if response.changed() { @@ -543,7 +551,7 @@ fn show_generation_options(ui: &mut egui::Ui, state: &mut RunState) { } }); - col2.add_space(8.0); + col2.add_space(SPACING_LG); // Advanced options col2.horizontal(|ui| { @@ -555,12 +563,12 @@ fn show_generation_options(ui: &mut egui::Ui, state: &mut RunState) { info_icon(ui, "Enable temporal sorting of the generated pcap. Disable to reduce significantly the RAM usage."); }); - col2.add_space(8.0); + col2.add_space(SPACING_LG); // Validation errors if let Some((name, spec, err)) = first_invalid_param(&state.generation) { col2.colored_label( - egui::Color32::RED, + COLOR_ERROR, format!("Invalid parameter: {name}. Expected: {spec}. ({err})"), ); } @@ -698,7 +706,10 @@ fn show_bottom_panel( egui::TopBottomPanel::bottom("run_options_panel") .frame( egui::Frame::side_top_panel(&ctx.style()) - .inner_margin(egui::Margin::symmetric(8, 8)) + .inner_margin(egui::Margin::symmetric( + OPTIONS_PANEL_INNER_MARGIN.0, + OPTIONS_PANEL_INNER_MARGIN.1, + )) .fill(ctx.style().visuals.panel_fill), ) .resizable(false) @@ -710,7 +721,10 @@ fn show_bottom_panel( // Action bar (always visible) egui::TopBottomPanel::bottom("run_bottom_bar") .frame( - egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin::symmetric(8, 4)), + egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin::symmetric( + BOTTOM_BAR_INNER_MARGIN.0, + BOTTOM_BAR_INNER_MARGIN.1, + )), ) .show(ctx, |ui| { let is_generating = state.generation.is_generating(); @@ -727,10 +741,10 @@ fn show_bottom_panel( "{} Generate", egui_material_icons::icons::ICON_PLAY_ARROW )) - .size(13.0), + .size(TEXT_SIZE_MD), ) .fill(accent) - .min_size(egui::vec2(85.0, 24.0)); + .min_size(egui::vec2(BUTTON_MIN_WIDTH_LG, BUTTON_HEIGHT)); if ui .add(generate_button) .on_hover_text("Generate PCAP from configuration") @@ -748,10 +762,10 @@ fn show_bottom_panel( "{} Stop", egui_material_icons::icons::ICON_STOP )) - .size(13.0), + .size(TEXT_SIZE_MD), ) - .fill(egui::Color32::from_rgb(200, 80, 80)) - .min_size(egui::vec2(75.0, 24.0)); + .fill(COLOR_STOP) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); if ui .add(stop_button) .on_hover_text("Cancel generation") @@ -773,8 +787,9 @@ fn show_bottom_panel( let save_text = format!("{} Download", egui_material_icons::icons::ICON_DOWNLOAD); - let save_button = egui::Button::new(egui::RichText::new(save_text).size(13.0)) - .min_size(egui::vec2(75.0, 24.0)); + let save_button = + egui::Button::new(egui::RichText::new(save_text).size(TEXT_SIZE_MD)) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); if ui.add(save_button).clicked() { let pcap_bytes = state.generation.pcap_bytes.clone(); #[cfg(not(target_arch = "wasm32"))] @@ -816,9 +831,9 @@ fn show_bottom_panel( "{} Open", egui_material_icons::icons::ICON_LAN )) - .size(13.0), + .size(TEXT_SIZE_MD), ) - .min_size(egui::vec2(75.0, 24.0)); + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); let response = ui.add_enabled(state.generation.wireshark_available, open_button); let response = if state.generation.wireshark_available { @@ -846,7 +861,7 @@ fn show_bottom_panel( // Error display (when there's an error) if let Some(error) = &state.generation.error { - ui.colored_label(egui::Color32::RED, error); + ui.colored_label(COLOR_ERROR, error); } // Options toggle button (right-aligned) with progress bar and throughput @@ -868,7 +883,7 @@ fn show_bottom_panel( .clicked() { state.panel_open = !state.panel_open; - state.visualization.delayed_fit_countdown = Some(2); // Delay by 2 frames + state.visualization.delayed_fit_countdown = Some(DELAY_FRAMES_QUICK); } // Throughput (when complete) - left of Options @@ -882,7 +897,7 @@ fn show_bottom_panel( if is_generating { let progress = egui::ProgressBar::new(state.generation.progress) .text("") - .fill(egui::Color32::from_rgb(144, 238, 144)); + .fill(COLOR_SUCCESS); ui.add(progress); } }); @@ -932,7 +947,7 @@ fn render_graph_view(ui: &mut egui::Ui, state: &mut RunState) { .with_navigations( &egui_graphs::SettingsNavigation::new() .with_fit_to_screen_enabled(fit_to_screen) - .with_fit_to_screen_padding(0.15) // padding to avoid cropping with labels and overlays + .with_fit_to_screen_padding(FIT_TO_SCREEN_PADDING) // padding to avoid cropping with labels and overlays .with_zoom_and_pan_enabled(true), ); diff --git a/crates/fosr-gui/src/shared/colors.rs b/crates/fosr-gui/src/shared/colors.rs new file mode 100644 index 00000000..1b81803c --- /dev/null +++ b/crates/fosr-gui/src/shared/colors.rs @@ -0,0 +1,70 @@ +//! Centralized color constants for consistent styling across the application. +//! +//! This module centralizes all color constants to ensure: +//! - Consistent colors across the app +//! - Easy theme adjustments in one place +//! - Self-documenting code with named colors + +use eframe::egui::Color32; + +// ============================================================================ +// SEMANTIC COLORS (UI state) +// ============================================================================ + +/// Error color (red) - for validation errors and error messages +pub const COLOR_ERROR: Color32 = Color32::from_rgb(220, 50, 50); + +/// Warning color (amber/orange) - for warnings and unsaved changes +pub const COLOR_WARNING: Color32 = Color32::from_rgb(230, 160, 0); + +/// Success color (light green) - for success states and progress bars +pub const COLOR_SUCCESS: Color32 = Color32::from_rgb(144, 238, 144); + +/// Stop/Danger color (red) - for stop buttons and dangerous actions +pub const COLOR_STOP: Color32 = Color32::from_rgb(200, 80, 80); + +// ============================================================================ +// TEXT COLORS +// ============================================================================ + +/// Muted/secondary text color (gray) - for labels and secondary info +pub const COLOR_TEXT_MUTED: Color32 = Color32::GRAY; + +// ============================================================================ +// VISUALIZATION: NODE ICONS +// ============================================================================ + +/// Icon tint for dark mode - gray instead of pure white +pub const COLOR_ICON_TINT_DARK: Color32 = Color32::from_rgb(180, 180, 180); + +/// Icon tint for light mode - gray instead of pure black +pub const COLOR_ICON_TINT_LIGHT: Color32 = Color32::from_rgb(40, 40, 40); + +// ============================================================================ +// VISUALIZATION: EDGE STATES +// ============================================================================ + +/// Inactive edge color (light gray) +pub const COLOR_EDGE_INACTIVE: Color32 = Color32::from_rgb(200, 200, 200); + +// ============================================================================ +// VISUALIZATION: PROTOCOL COLORS +// ============================================================================ + +/// HTTP protocol color (blue) +pub const COLOR_PROTOCOL_HTTP: Color32 = Color32::from_rgb(52, 152, 219); + +/// HTTPS protocol color (green) +pub const COLOR_PROTOCOL_HTTPS: Color32 = Color32::from_rgb(46, 204, 113); + +/// SSH protocol color (purple) +pub const COLOR_PROTOCOL_SSH: Color32 = Color32::from_rgb(155, 89, 182); + +/// DNS protocol color (orange) +pub const COLOR_PROTOCOL_DNS: Color32 = Color32::from_rgb(230, 126, 34); + +/// SMTP protocol color (yellow) +pub const COLOR_PROTOCOL_SMTP: Color32 = Color32::from_rgb(241, 196, 15); + +/// Other/unknown protocol color (gray) +pub const COLOR_PROTOCOL_OTHER: Color32 = Color32::from_rgb(149, 165, 166); diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index a4c2726c..2bb957fd 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -1,9 +1,11 @@ //! Configuration file state management: loading, parsing, and dirty tracking. +use crate::shared::colors::COLOR_WARNING; #[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::shared::ui_constants::SPACING_LG; use crate::{ config_templates::load_template_by_id, configuration::tab::ConfigurationTabState, shared::config_model::Configuration, shared::ui_utils::labeled_toggle, @@ -22,9 +24,6 @@ pub enum StartupModalState { TemplateSelection, } -/// Warning color (amber/orange). -const COLOR_WARNING: egui::Color32 = egui::Color32::from_rgb(230, 160, 0); - pub struct ConfigurationFileState { pub picked_config_file: Option, #[cfg(target_arch = "wasm32")] @@ -245,7 +244,7 @@ pub fn configuration_file_picker( } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_space(8.0); + ui.add_space(SPACING_LG); // In RTL layout, rendering order is reversed, // so Code is passed first to appear visually on the right. labeled_toggle( diff --git a/crates/fosr-gui/src/shared/mod.rs b/crates/fosr-gui/src/shared/mod.rs index 2e3270b1..9980b9b8 100644 --- a/crates/fosr-gui/src/shared/mod.rs +++ b/crates/fosr-gui/src/shared/mod.rs @@ -1,6 +1,9 @@ //! Shared utilities: config model, file I/O, and reusable UI components. +pub mod colors; pub mod config_model; pub mod configuration_file; pub mod file_io; +pub mod network_constants; +pub mod ui_constants; pub mod ui_utils; diff --git a/crates/fosr-gui/src/shared/network_constants.rs b/crates/fosr-gui/src/shared/network_constants.rs new file mode 100644 index 00000000..afad6163 --- /dev/null +++ b/crates/fosr-gui/src/shared/network_constants.rs @@ -0,0 +1,74 @@ +//! Network-related constants for configuration and visualization +//! +//! This module centralizes network-related constants including: +//! - Port ranges and defaults +//! - IP address ranges +//! - Stream timing parameters +//! - Generation progress stages + +// ============================================================================ +// PORTS +// ============================================================================ + +/// Minimum valid port number +pub const PORT_MIN: u16 = 1; + +/// Maximum valid port number +pub const PORT_MAX: u16 = 65535; + +/// Default port when service is unknown +pub const PORT_DEFAULT_UNKNOWN: u16 = 0; + +// ============================================================================ +// IP ADDRESS RANGES +// ============================================================================ + +/// Minimum IP octet value for local network (192.168.0.x) +pub const IP_LOCAL_MIN: u8 = 1; + +/// Maximum IP octet value for local network (192.168.0.x) +pub const IP_LOCAL_MAX: u8 = 254; + +// ============================================================================ +// MAC ADDRESS +// ============================================================================ + +/// Number of bytes in a MAC address +pub const MAC_ADDRESS_BYTES: usize = 6; + +/// Number of parts when parsing MAC address (XX:XX:XX:XX:XX:XX) +pub const MAC_ADDRESS_PARTS: usize = 6; + +/// Expected length of each MAC address part (2 hex chars) +pub const MAC_PART_LENGTH: usize = 2; + +/// MAC address local bit (second character of first octet) +pub const MAC_LOCAL_BIT: u8 = 0x02; + +/// MAC address mask for local bit manipulation +pub const MAC_LOCAL_MASK: u8 = 0xFE; + +// ============================================================================ +// HOST CONFIGURATION DEFAULTS +// ============================================================================ + +/// Default usage intensity for hosts (1.0 = baseline, <1 = less active, >1 = more active) +pub const HOST_USAGE_DEFAULT: f32 = 1.0; + +// ============================================================================ +// STREAM TIMING (for visualization) +// ============================================================================ + +/// Buffer ahead duration in seconds - how far ahead to generate flows +pub const STREAM_BUFFER_AHEAD_SECS: u64 = 5; + +/// Check interval in milliseconds - how often to check for flows to emit +pub const STREAM_CHECK_INTERVAL_MS: u64 = 50; + +/// Rate limit for generation in milliseconds - prevents CPU spinning +#[cfg(not(target_arch = "wasm32"))] +pub const STREAM_RATE_LIMIT_MS: u64 = 100; + +/// Maximum flows generated per cycle (WASM only) +#[cfg(target_arch = "wasm32")] +pub const STREAM_MAX_PER_CYCLE_WASM: usize = 10; diff --git a/crates/fosr-gui/src/shared/ui_constants.rs b/crates/fosr-gui/src/shared/ui_constants.rs new file mode 100644 index 00000000..ccc62900 --- /dev/null +++ b/crates/fosr-gui/src/shared/ui_constants.rs @@ -0,0 +1,313 @@ +//! UI constants for consistent styling across the application +//! +//! This module centralizes all UI-related constants to ensure: +//! - Consistent spacing, sizing, and colors across the app +//! - Easy theme adjustments in one place +//! - Self-documenting code with named constants + +// ============================================================================ +// SPACING +// ============================================================================ + +/// Extra small spacing (px) - minimal gaps +pub const SPACING_XS: f32 = 2.0; + +/// Small spacing (px) - tight gaps between related items +pub const SPACING_SM: f32 = 4.0; + +/// Medium spacing (px) - default gap between items +pub const SPACING_MD: f32 = 6.0; + +/// Large spacing (px) - gaps between sections +pub const SPACING_LG: f32 = 8.0; + +/// Extra large spacing (px) - major section separators +pub const SPACING_XL: f32 = 12.0; + +/// Extra extra large spacing (px) - large section gaps +pub const SPACING_XXL: f32 = 15.0; + +/// Negative extra small spacing (px) - for pulling elements closer together +pub const SPACING_NEGATIVE_XS: f32 = -2.0; + +// ============================================================================ +// TYPOGRAPHY +// ============================================================================ + +/// Small text size (px) - captions, hints +pub const TEXT_SIZE_SM: f32 = 12.0; + +/// Medium text size (px) - button text +pub const TEXT_SIZE_MD: f32 = 13.0; + +/// Default text size (px) - body text, tabs +pub const TEXT_SIZE_DEFAULT: f32 = 14.0; + +/// Large text size (px) - headings, emphasis +pub const TEXT_SIZE_LG: f32 = 16.0; + +/// Icon size for startup cards and major UI elements (px) +pub const ICON_SIZE_LG: f32 = 28.0; + +// ============================================================================ +// BUTTONS +// ============================================================================ + +/// Standard button height (px) +pub const BUTTON_HEIGHT: f32 = 24.0; + +/// Small button minimum width (px) - Stop, Save, Open +pub const BUTTON_MIN_WIDTH_SM: f32 = 75.0; + +/// Large button minimum width (px) - Generate +pub const BUTTON_MIN_WIDTH_LG: f32 = 85.0; + +/// Button padding (horizontal, vertical) +pub const BUTTON_PADDING: (f32, f32) = (5.0, 2.0); + +// ============================================================================ +// PANELS & MODALS +// ============================================================================ + +/// Small modal width (px) - close confirmation +#[cfg(not(target_arch = "wasm32"))] +pub const MODAL_WIDTH_SM: f32 = 370.0; + +/// Default modal width (px) - startup, template selection +pub const MODAL_WIDTH_MD: f32 = 400.0; + +/// Panel minimum width for popup content (px) +pub const PANEL_MIN_WIDTH: f32 = 250.0; + +/// Panel popup minimum width (px) +pub const POPUP_MIN_WIDTH: f32 = 180.0; + +/// Panel popup maximum height (px) +pub const POPUP_MAX_HEIGHT: f32 = 200.0; + +/// Node info modal width (px) +pub const NODE_MODAL_WIDTH: f32 = 250.0; + +/// Panel inner margin (horizontal, vertical) for top panels +pub const PANEL_INNER_MARGIN: (i8, i8) = (4, 3); + +/// Bottom bar inner margin (horizontal, vertical) +pub const BOTTOM_BAR_INNER_MARGIN: (i8, i8) = (8, 4); + +/// Options panel inner margin (horizontal, vertical) +pub const OPTIONS_PANEL_INNER_MARGIN: (i8, i8) = (8, 8); + +// ============================================================================ +// WINDOW DIMENSIONS +// ============================================================================ + +/// Default window width (px) +#[cfg(not(target_arch = "wasm32"))] +pub const WINDOW_DEFAULT_WIDTH: f32 = 1200.0; + +/// Default window height (px) +#[cfg(not(target_arch = "wasm32"))] +pub const WINDOW_DEFAULT_HEIGHT: f32 = 1000.0; + +/// Minimum window width (px) +#[cfg(not(target_arch = "wasm32"))] +pub const WINDOW_MIN_WIDTH: f32 = 550.0; + +/// Minimum window height (px) +#[cfg(not(target_arch = "wasm32"))] +pub const WINDOW_MIN_HEIGHT: f32 = 500.0; + +// ============================================================================ +// ZOOM +// ============================================================================ + +/// Minimum zoom level (fraction) +pub const ZOOM_MIN: f32 = 0.5; + +/// Maximum zoom level (fraction) +pub const ZOOM_MAX: f32 = 3.0; + +/// Zoom step increment (fraction) +pub const ZOOM_STEP: f32 = 0.1; + +/// Default zoom for native builds (fraction) +#[cfg(not(target_arch = "wasm32"))] +pub const ZOOM_DEFAULT: f32 = 1.4; + +/// Default zoom for WASM builds (fraction) +#[cfg(target_arch = "wasm32")] +pub const ZOOM_DEFAULT: f32 = 1.2; + +/// Tooltip delay in seconds +pub const TOOLTIP_DELAY: f32 = 0.1; + +// ============================================================================ +// STARTUP CARDS +// ============================================================================ + +/// Startup card height (px) +pub const STARTUP_CARD_HEIGHT: f32 = 120.0; + +/// Number of columns in initial startup modal +pub const STARTUP_COLUMNS_INITIAL: usize = 2; + +/// Number of columns in template selection modal +pub const STARTUP_COLUMNS_TEMPLATES: usize = 3; + +// ============================================================================ +// ABOUT TAB +// ============================================================================ + +/// Logo max width in about tab (px) +pub const LOGO_MAX_WIDTH: f32 = 450.0; + +// ============================================================================ +// GENERATION OPTIONS +// ============================================================================ + +/// Number of columns in generation options +pub const GENERATION_OPTIONS_COLUMNS: usize = 2; + +/// Column 1 minimum width (duration & time options) +pub const GENERATION_COL1_MIN_WIDTH: f32 = 280.0; + +/// Column 2 minimum width (seed & advanced options) +pub const GENERATION_COL2_MIN_WIDTH: f32 = 200.0; + +/// Duration text field width +pub const DURATION_TEXT_WIDTH: f32 = 80.0; + +/// Seed input field width +pub const SEED_INPUT_WIDTH: f32 = 120.0; + +/// Timezone picker width +pub const TIMEZONE_PICKER_WIDTH: f32 = 160.0; + +/// Timezone popup maximum height +pub const TIMEZONE_POPUP_MAX_HEIGHT: f32 = 450.0; + +/// Timezone list maximum height +pub const TIMEZONE_LIST_MAX_HEIGHT: f32 = 400.0; + +// ============================================================================ +// VISUALIZATION: NODE SIZING +// ============================================================================ + +/// Minimum radius for graph nodes (px) - starting size for all nodes +pub const NODE_RADIUS_MIN: f32 = 15.0; + +/// Maximum radius for graph nodes (px) - cap to prevent oversized nodes +pub const NODE_RADIUS_MAX: f32 = 25.0; + +/// Radius increase per flow (px) - controls how fast nodes grow with traffic +pub const NODE_FLOW_SCALE_FACTOR: f32 = 0.3; + +// ============================================================================ +// VISUALIZATION: EDGE SIZING +// ============================================================================ + +/// Minimum width for graph edges (px) - inactive edges are invisible +pub const EDGE_WIDTH_MIN: f32 = 0.0; + +/// Maximum width for graph edges (px) - cap to prevent oversized edges +pub const EDGE_WIDTH_MAX: f32 = 3.0; + +/// Width increase per flow (px) - controls how fast edges grow with traffic +pub const EDGE_FLOW_SCALE: f32 = 0.2; + +/// Arrow size at edge endpoints (px) - indicates direction of traffic +pub const EDGE_ARROW_SIZE: f32 = 16.0; + +/// Arrow angle in radians (30 degrees) - PI/6 +pub const EDGE_ARROW_ANGLE_RAD: f32 = std::f32::consts::PI / 6.0; + +// ============================================================================ +// VISUALIZATION: PLAYBACK SPEED +// ============================================================================ + +/// Available playback speed steps for visualization +pub const PLAYBACK_SPEED_STEPS: &[f32] = &[0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0]; + +/// Epsilon for comparing playback speed values (to handle floating point precision) +pub const PLAYBACK_SPEED_EPSILON: f32 = 0.01; + +// ============================================================================ +// VISUALIZATION: LAYOUT & DISPLAY +// ============================================================================ + +/// Graph layout radius multiplier (scales with node count) +pub const GRAPH_LAYOUT_RADIUS_MULTIPLIER: f32 = 80.0; + +/// Graph layout radius base offset +pub const GRAPH_LAYOUT_RADIUS_BASE: f32 = 100.0; + +/// Fit to screen padding (fraction of screen) +pub const FIT_TO_SCREEN_PADDING: f32 = 0.15; + +/// Base timeout for active links display (ms), adjusted by speed +pub const ACTIVE_LINK_BASE_TIMEOUT_MS: f32 = 500.0; + +/// Overlay margin from screen edges (px) +pub const OVERLAY_MARGIN: f32 = 4.0; + +/// Legend icon size (px) - for node/edge legend icons +pub const LEGEND_ICON_SIZE: f32 = 20.0; + +/// Legend marker radius (px) - for edge protocol color dots +pub const LEGEND_MARKER_RADIUS: f32 = 6.0; + +// ============================================================================ +// TIMING (frames) +// ============================================================================ + +/// Quick delay for UI state transitions (frames) +pub const DELAY_FRAMES_QUICK: u8 = 2; + +/// Normal delay for UI state transitions (frames) +pub const DELAY_FRAMES_NORMAL: u8 = 10; + +// ============================================================================ +// INFO ICON & TOOLTIPS +// ============================================================================ + +/// Info icon spacing adjustment (negative to pull closer) +pub const INFO_ICON_SPACING: f32 = -4.0; + +/// Info icon size +pub const INFO_ICON_SIZE: f32 = 14.0; + +/// Info tooltip maximum width +pub const INFO_TOOLTIP_MAX_WIDTH: f32 = 300.0; + +// ============================================================================ +// TEXT EDITOR +// ============================================================================ + +/// Default number of rows for multiline text editors +pub const TEXT_EDIT_DEFAULT_ROWS: usize = 3; + +/// Number of rows for YAML editor +pub const YAML_EDITOR_ROWS: usize = 20; + +/// Icon column width in YAML editor +pub const YAML_ICON_COL_WIDTH: f32 = 20.0; + +/// Gutter padding in YAML editor +pub const YAML_GUTTER_PADDING: f32 = 6.0; + +// ============================================================================ +// TOGGLE COMPONENT +// ============================================================================ + +/// Toggle frame inner margin +pub const TOGGLE_INNER_MARGIN: f32 = 3.0; + +/// Toggle item spacing +pub const TOGGLE_ITEM_SPACING: f32 = 3.0; + +// ============================================================================ +// INDENTATION +// ============================================================================ + +/// Standard indentation for nested content (px) +pub const INDENT_STANDARD: f32 = 16.0; diff --git a/crates/fosr-gui/src/shared/ui_utils.rs b/crates/fosr-gui/src/shared/ui_utils.rs index 5c3be1f0..b3eaa2e8 100644 --- a/crates/fosr-gui/src/shared/ui_utils.rs +++ b/crates/fosr-gui/src/shared/ui_utils.rs @@ -1,18 +1,23 @@ //! Reusable UI widgets: info icons, optional string editors, toggles, labels. +use crate::shared::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; +use crate::shared::ui_constants::{ + INFO_ICON_SIZE, INFO_ICON_SPACING, INFO_TOOLTIP_MAX_WIDTH, TOGGLE_INNER_MARGIN, + TOGGLE_ITEM_SPACING, +}; use eframe::egui::{self, TextFormat, text::LayoutJob}; /// Display a small info icon with a tooltip. pub fn info_icon(ui: &mut egui::Ui, tooltip: &str) { - ui.add_space(-4.0); + ui.add_space(INFO_ICON_SPACING); ui.label( egui::RichText::new("ℹ") - .color(egui::Color32::GRAY) - .size(14.0), + .color(COLOR_TEXT_MUTED) + .size(INFO_ICON_SIZE), ) .on_hover_cursor(egui::CursorIcon::Help) .on_hover_ui(|ui| { - ui.set_max_width(300.0); + ui.set_max_width(INFO_TOOLTIP_MAX_WIDTH); ui.label(tooltip); }); } @@ -90,11 +95,11 @@ pub fn labeled_toggle( ) { // Use a group frame with tight padding to auto-size around the content let resp = egui::Frame::group(ui.style()) - .inner_margin(3.0) + .inner_margin(TOGGLE_INNER_MARGIN) .show(ui, |ui| { // Remove the hover stroke on selectable labels inside this toggle ui.style_mut().visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE; - ui.spacing_mut().item_spacing = egui::vec2(3.0, 0.0); + ui.spacing_mut().item_spacing = egui::vec2(TOGGLE_ITEM_SPACING, 0.0); ui.horizontal(|ui| { let first = ui.selectable_label(*is_first_selected, first_label); if first.clicked() { @@ -170,7 +175,7 @@ pub fn required_label(ui: &mut egui::Ui, text: &str) { "*", 0.0, TextFormat { - color: egui::Color32::RED, + color: COLOR_ERROR, ..Default::default() }, ); diff --git a/crates/fosr-gui/src/visualization/node_modal.rs b/crates/fosr-gui/src/visualization/node_modal.rs index 593d7632..2cdd3fbc 100644 --- a/crates/fosr-gui/src/visualization/node_modal.rs +++ b/crates/fosr-gui/src/visualization/node_modal.rs @@ -1,8 +1,11 @@ //! Node click handling and info/edit modal for the visualization graph. -use super::shapes::{ICON_TINT_DARK, ICON_TINT_LIGHT}; use super::state::{NodeType, VisualizationState}; +use crate::shared::colors::{COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT}; use crate::shared::configuration_file::ConfigurationFileState; +use crate::shared::ui_constants::{ + INDENT_STANDARD, LEGEND_ICON_SIZE, NODE_MODAL_WIDTH, SPACING_LG, SPACING_SM, +}; use eframe::egui; use egui_graphs::events::{Event, PayloadNodeClick}; @@ -57,7 +60,7 @@ pub fn render_node_info_modal( 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.set_width(NODE_MODAL_WIDTH); if has_edit_buffer { ui.heading("Edit Node Information"); } else { @@ -77,19 +80,19 @@ pub fn render_node_info_modal( ), }; let tint = if ui.style().visuals.dark_mode { - ICON_TINT_DARK + COLOR_ICON_TINT_DARK } else { - ICON_TINT_LIGHT + COLOR_ICON_TINT_LIGHT }; ui.add( egui::Image::new(image) - .fit_to_exact_size(egui::vec2(20.0, 20.0)) + .fit_to_exact_size(egui::vec2(LEGEND_ICON_SIZE, LEGEND_ICON_SIZE)) .tint(tint), ); ui.label(egui::RichText::new(type_str).strong()); }); - ui.add_space(4.0); + ui.add_space(SPACING_SM); // Editable fields if we have an edit buffer (config loaded and host found) if let Some(ref mut host) = state.modal_edit_buffer { @@ -138,7 +141,7 @@ pub fn render_node_info_modal( ui.label("IP Addresses:"); for iface in &mut host.interfaces { ui.horizontal(|ui| { - ui.add_space(16.0); + ui.add_space(INDENT_STANDARD); ui.add(egui::TextEdit::singleline(&mut iface.ip_addr).hint_text("0.0.0.0")); }); } @@ -159,14 +162,14 @@ pub fn render_node_info_modal( ui.label("IP Addresses:"); for ip in &node_data.ip_addrs { ui.horizontal(|ui| { - ui.add_space(16.0); + ui.add_space(INDENT_STANDARD); ui.label(egui::RichText::new(ip.to_string()).monospace()); }); } } } - ui.add_space(8.0); + ui.add_space(SPACING_LG); if has_edit_buffer { ui.horizontal(|ui| { diff --git a/crates/fosr-gui/src/visualization/overlays.rs b/crates/fosr-gui/src/visualization/overlays.rs index ff01b6b2..027c25f7 100644 --- a/crates/fosr-gui/src/visualization/overlays.rs +++ b/crates/fosr-gui/src/visualization/overlays.rs @@ -1,19 +1,26 @@ //! Graph overlay UI: control buttons, stats display, and legends for nodes/edges. -use super::shapes::{ - COLOR_DNS, COLOR_HTTP, COLOR_HTTPS, COLOR_INACTIVE, COLOR_OTHER, COLOR_SMTP, COLOR_SSH, - ICON_TINT_DARK, ICON_TINT_LIGHT, +use super::state::VisualizationState; +use crate::shared::colors::{ + COLOR_EDGE_INACTIVE, COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT, COLOR_PROTOCOL_DNS, + COLOR_PROTOCOL_HTTP, COLOR_PROTOCOL_HTTPS, COLOR_PROTOCOL_OTHER, COLOR_PROTOCOL_SMTP, + COLOR_PROTOCOL_SSH, COLOR_STOP, +}; +use crate::shared::ui_constants::{ + LEGEND_ICON_SIZE, LEGEND_MARKER_RADIUS, OVERLAY_MARGIN, PLAYBACK_SPEED_EPSILON, + PLAYBACK_SPEED_STEPS, SPACING_NEGATIVE_XS, }; -use super::state::{STOP_BUTTON_COLOR, VisualizationState}; use eframe::egui; /// Helper to render a single legend item inline (for edges) fn legend_item_inline(ui: &mut egui::Ui, label: &str, color: egui::Color32) { ui.horizontal(|ui| { - let rect = ui.allocate_space(egui::vec2(12.0, 12.0)).1; + let rect = ui + .allocate_space(egui::vec2(LEGEND_ICON_SIZE, LEGEND_ICON_SIZE)) + .1; let painter = ui.painter(); - painter.circle_filled(rect.center(), 6.0, color); - ui.add_space(-2.0); + painter.circle_filled(rect.center(), LEGEND_MARKER_RADIUS, color); + ui.add_space(SPACING_NEGATIVE_XS); ui.label(label); }); } @@ -22,16 +29,16 @@ fn legend_item_inline(ui: &mut egui::Ui, label: &str, color: egui::Color32) { fn legend_item_with_image(ui: &mut egui::Ui, label: &str, image: egui::ImageSource) { ui.horizontal(|ui| { let tint = if ui.style().visuals.dark_mode { - ICON_TINT_DARK + COLOR_ICON_TINT_DARK } else { - ICON_TINT_LIGHT + COLOR_ICON_TINT_LIGHT }; ui.add( egui::Image::new(image) - .fit_to_exact_size(egui::vec2(20.0, 20.0)) + .fit_to_exact_size(egui::vec2(LEGEND_ICON_SIZE, LEGEND_ICON_SIZE)) .tint(tint), ); - ui.add_space(-2.0); + ui.add_space(SPACING_NEGATIVE_XS); ui.label(label); }); } @@ -41,7 +48,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) let local_rect = ui.max_rect(); egui::Area::new(egui::Id::new("viz_overlay_buttons")) - .fixed_pos(local_rect.left_top() + egui::vec2(4.0, 4.0)) + .fixed_pos(local_rect.left_top() + egui::vec2(OVERLAY_MARGIN, OVERLAY_MARGIN)) .order(egui::Order::Foreground) .show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()) @@ -95,7 +102,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) "{} Stop", egui_material_icons::icons::ICON_STOP ))) - .fill(STOP_BUTTON_COLOR); + .fill(COLOR_STOP); if ui.add(stop_button).clicked() { state.stop_visualization(); } @@ -118,13 +125,12 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) 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]; // 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 + let current_idx = PLAYBACK_SPEED_STEPS .iter() - .position(|&s| (s - speed_value).abs() < 0.01); + .position(|&s| (s - speed_value).abs() < PLAYBACK_SPEED_EPSILON); if ui .button(egui_material_icons::icons::ICON_REMOVE) @@ -133,7 +139,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) { if let Some(idx) = current_idx { if idx > 0 { - speed_value = speed_steps[idx - 1]; + speed_value = PLAYBACK_SPEED_STEPS[idx - 1]; *state.speed.write().unwrap() = speed_value; } } @@ -147,8 +153,8 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) .clicked() { if let Some(idx) = current_idx { - if idx < speed_steps.len() - 1 { - speed_value = speed_steps[idx + 1]; + if idx < PLAYBACK_SPEED_STEPS.len() - 1 { + speed_value = PLAYBACK_SPEED_STEPS[idx + 1]; *state.speed.write().unwrap() = speed_value; } } @@ -163,7 +169,7 @@ pub fn render_overlay_stats(ui: &mut egui::Ui, state: &VisualizationState) { let local_rect = ui.max_rect(); egui::Area::new(egui::Id::new("viz_overlay_stats")) - .fixed_pos(local_rect.left_bottom() + egui::vec2(4.0, 0.0)) + .fixed_pos(local_rect.left_bottom() + egui::vec2(OVERLAY_MARGIN, 0.0)) .pivot(egui::Align2::LEFT_BOTTOM) .order(egui::Order::Foreground) .show(ui.ctx(), |ui| { @@ -191,7 +197,7 @@ pub fn render_overlay_node_legend(ui: &mut egui::Ui) { egui::Area::new(egui::Id::new("viz_overlay_node_legend")) .pivot(egui::Align2::RIGHT_TOP) - .fixed_pos(local_rect.right_top() + egui::vec2(-4.0, 4.0)) + .fixed_pos(local_rect.right_top() + egui::vec2(-OVERLAY_MARGIN, OVERLAY_MARGIN)) .order(egui::Order::Foreground) .show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()) @@ -224,19 +230,19 @@ pub fn render_overlay_edge_legend(ui: &mut egui::Ui) { egui::Area::new(egui::Id::new("viz_overlay_edge_legend")) .pivot(egui::Align2::RIGHT_BOTTOM) - .fixed_pos(local_rect.right_bottom() + egui::vec2(-4.0, -4.0)) + .fixed_pos(local_rect.right_bottom() + egui::vec2(-OVERLAY_MARGIN, -OVERLAY_MARGIN)) .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); + legend_item_inline(ui, "Inactive", COLOR_EDGE_INACTIVE); + legend_item_inline(ui, "HTTP", COLOR_PROTOCOL_HTTP); + legend_item_inline(ui, "HTTPS", COLOR_PROTOCOL_HTTPS); + legend_item_inline(ui, "SSH", COLOR_PROTOCOL_SSH); + legend_item_inline(ui, "DNS", COLOR_PROTOCOL_DNS); + legend_item_inline(ui, "SMTP", COLOR_PROTOCOL_SMTP); + legend_item_inline(ui, "Other", COLOR_PROTOCOL_OTHER); }) .response .on_hover_text( diff --git a/crates/fosr-gui/src/visualization/shapes.rs b/crates/fosr-gui/src/visualization/shapes.rs index 9cffa6e5..12522d21 100644 --- a/crates/fosr-gui/src/visualization/shapes.rs +++ b/crates/fosr-gui/src/visualization/shapes.rs @@ -1,6 +1,15 @@ //! Custom node and edge shapes with protocol colors, icons, and dynamic sizing. use super::state::{EdgeData, EdgeState, LinkDirection, NodeData, NodeType}; +use crate::shared::colors::{ + COLOR_EDGE_INACTIVE, COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT, COLOR_PROTOCOL_DNS, + COLOR_PROTOCOL_HTTP, COLOR_PROTOCOL_HTTPS, COLOR_PROTOCOL_OTHER, COLOR_PROTOCOL_SMTP, + COLOR_PROTOCOL_SSH, COLOR_TEXT_MUTED, +}; +use crate::shared::ui_constants::{ + EDGE_ARROW_ANGLE_RAD, EDGE_ARROW_SIZE, EDGE_FLOW_SCALE, EDGE_WIDTH_MAX, EDGE_WIDTH_MIN, + NODE_FLOW_SCALE_FACTOR, NODE_RADIUS_MAX, NODE_RADIUS_MIN, SPACING_XS, TEXT_SIZE_DEFAULT, +}; use eframe::egui::{self, Color32, Pos2, Rect, Shape, TextureOptions, Vec2, load::SizeHint}; use egui_graphs::{DisplayEdge, DisplayNode, DrawContext, Node, NodeProps}; use fosr_lib::L7Proto; @@ -10,28 +19,6 @@ 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 -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 = 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.0; -const EDGE_WIDTH_MAX: f32 = 3.0; -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)] pub struct NetworkNodeShape { @@ -46,10 +33,10 @@ impl NetworkNodeShape { /// Compute node style from payload data. fn style_from_payload(payload: &NodeData) -> (f32, NodeType, Option, Vec) { // 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 { + let max_linear = NODE_RADIUS_MIN + payload.max_flow_count as f32 * NODE_FLOW_SCALE_FACTOR; + let radius = if max_linear < NODE_RADIUS_MAX { // Linear phase: everyone grows normally - RADIUS_MIN + payload.flow_count as f32 * FLOW_SCALE_FACTOR + NODE_RADIUS_MIN + payload.flow_count as f32 * NODE_FLOW_SCALE_FACTOR } else { // Proportional phase: scale by ratio to max let ratio = if payload.max_flow_count > 0 { @@ -57,7 +44,7 @@ impl NetworkNodeShape { } else { 0.0 }; - RADIUS_MIN + ratio * (RADIUS_MAX - RADIUS_MIN) + NODE_RADIUS_MIN + ratio * (NODE_RADIUS_MAX - NODE_RADIUS_MIN) }; let ips: Vec = payload.ip_addrs.iter().map(|ip| ip.to_string()).collect(); @@ -121,17 +108,17 @@ impl DisplayNode (Color32, f32, bool, bool) { }; EDGE_WIDTH_MIN + ratio * (EDGE_WIDTH_MAX - EDGE_WIDTH_MIN) }; - (COLOR_INACTIVE, width, false, false) + (COLOR_EDGE_INACTIVE, width, false, false) } EdgeState::Active { protocol, @@ -218,12 +205,12 @@ fn edge_style(edge_data: &EdgeData) -> (Color32, f32, bool, bool) { .. } => { let color = match protocol { - L7Proto::HTTP => COLOR_HTTP, - L7Proto::HTTPS => COLOR_HTTPS, - L7Proto::SSH => COLOR_SSH, - L7Proto::DNS => COLOR_DNS, - L7Proto::SMTP => COLOR_SMTP, - _ => COLOR_OTHER, + L7Proto::HTTP => COLOR_PROTOCOL_HTTP, + L7Proto::HTTPS => COLOR_PROTOCOL_HTTPS, + L7Proto::SSH => COLOR_PROTOCOL_SSH, + L7Proto::DNS => COLOR_PROTOCOL_DNS, + L7Proto::SMTP => COLOR_PROTOCOL_SMTP, + _ => COLOR_PROTOCOL_OTHER, }; let (arrow_start, arrow_end) = match direction { LinkDirection::Forward => (false, true), @@ -306,8 +293,8 @@ impl 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; + let arrow_size = ctx.meta.canvas_to_screen_size(EDGE_ARROW_SIZE); + let arrow_angle = EDGE_ARROW_ANGLE_RAD; // 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); diff --git a/crates/fosr-gui/src/visualization/state.rs b/crates/fosr-gui/src/visualization/state.rs index 99dcc53e..b6a153ec 100644 --- a/crates/fosr-gui/src/visualization/state.rs +++ b/crates/fosr-gui/src/visualization/state.rs @@ -19,9 +19,6 @@ use web_time::Instant; /// Special IP address representing "The Internet" node pub const INTERNET_IP: Ipv4Addr = Ipv4Addr::new(0, 0, 0, 1); -/// Color for stop/danger buttons -pub const STOP_BUTTON_COLOR: egui::Color32 = egui::Color32::from_rgb(200, 80, 80); - /// Node type for visualization (extends HostType with Internet) #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NodeType { diff --git a/crates/fosr-gui/src/visualization/stream.rs b/crates/fosr-gui/src/visualization/stream.rs index b0c89e5b..a2997f34 100644 --- a/crates/fosr-gui/src/visualization/stream.rs +++ b/crates/fosr-gui/src/visualization/stream.rs @@ -6,6 +6,11 @@ //! Flows are emitted based on their timestamps relative to visualization start, //! allowing multiple flows to be displayed in parallel. +#[cfg(target_arch = "wasm32")] +use crate::shared::network_constants::STREAM_MAX_PER_CYCLE_WASM; +#[cfg(not(target_arch = "wasm32"))] +use crate::shared::network_constants::STREAM_RATE_LIMIT_MS; +use crate::shared::network_constants::{STREAM_BUFFER_AHEAD_SECS, STREAM_CHECK_INTERVAL_MS}; use chrono::{DateTime, Offset, TimeZone}; use fosr_lib::{L7Proto, models, stage0, stage1::Stage1, stage1::bayesian_networks::BNGenerator}; use std::collections::BinaryHeap; @@ -181,9 +186,9 @@ impl FlowStreamer { // 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); + let buffer_ahead = Duration::from_secs(STREAM_BUFFER_AHEAD_SECS); // How often to check for flows to emit - let check_interval = Duration::from_millis(50); + let check_interval = Duration::from_millis(STREAM_CHECK_INTERVAL_MS); // Track virtual time by integrating speed changes // @@ -220,7 +225,7 @@ impl FlowStreamer { .map_or(true, |f| f.scheduled_time < buffer_target) { // Limit generation rate to avoid CPU spinning - if last_generation.elapsed() < Duration::from_millis(100) + if last_generation.elapsed() < Duration::from_millis(STREAM_RATE_LIMIT_MS) && !pending_flows.is_empty() { break; @@ -311,8 +316,8 @@ impl FlowStreamer { speed: Arc>, ) { let mut pending_flows: BinaryHeap = BinaryHeap::new(); - let buffer_ahead = Duration::from_secs(5); - let check_interval = Duration::from_millis(50); + let buffer_ahead = Duration::from_secs(STREAM_BUFFER_AHEAD_SECS); + let check_interval = Duration::from_millis(STREAM_CHECK_INTERVAL_MS); // Track virtual time by integrating speed changes let mut virtual_elapsed = Duration::ZERO; @@ -335,7 +340,7 @@ impl FlowStreamer { while pending_flows .peek() .map_or(true, |f| f.scheduled_time < buffer_target) - && generated_this_cycle < 10 + && generated_this_cycle < STREAM_MAX_PER_CYCLE_WASM { if let Some(timestamp) = s0.next() { if let Ok(flows) = s1.generate_flows(timestamp) { diff --git a/crates/fosr-gui/src/visualization/utils.rs b/crates/fosr-gui/src/visualization/utils.rs index 8c12e274..08cab755 100644 --- a/crates/fosr-gui/src/visualization/utils.rs +++ b/crates/fosr-gui/src/visualization/utils.rs @@ -1,5 +1,6 @@ //! Graph layout utilities: circular node distribution for initial display. +use crate::shared::ui_constants::{GRAPH_LAYOUT_RADIUS_BASE, GRAPH_LAYOUT_RADIUS_MULTIPLIER}; use eframe::egui; /// Distributes the graph nodes in a circle layout. @@ -15,7 +16,7 @@ pub fn distribute_nodes_circle( De: egui_graphs::DisplayEdge, { let n = graph.node_count().max(1) as f32; - let radius = n.sqrt() * 80.0 + 100.0; + let radius = n.sqrt() * GRAPH_LAYOUT_RADIUS_MULTIPLIER + GRAPH_LAYOUT_RADIUS_BASE; let indices: Vec<_> = graph.g().node_indices().collect(); for (i, idx) in indices.into_iter().enumerate() { From 476fdc7071be9b3933fd497d32e7bb70b991315f Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:43:21 +0100 Subject: [PATCH 104/127] refactor(gui/run): extract config_handling module from tab.rs --- crates/fosr-gui/src/run/config_handling.rs | 96 ++++++++++++++++++++++ crates/fosr-gui/src/run/mod.rs | 1 + crates/fosr-gui/src/run/tab.rs | 89 +------------------- 3 files changed, 101 insertions(+), 85 deletions(-) create mode 100644 crates/fosr-gui/src/run/config_handling.rs diff --git a/crates/fosr-gui/src/run/config_handling.rs b/crates/fosr-gui/src/run/config_handling.rs new file mode 100644 index 00000000..28882e73 --- /dev/null +++ b/crates/fosr-gui/src/run/config_handling.rs @@ -0,0 +1,96 @@ +//! Configuration change detection and handling for visualization. +//! +//! This module monitors the configuration file state and triggers +//! visualization updates when the configuration changes. + +use crate::shared::configuration_file::ConfigurationFileState; +use crate::shared::ui_constants::DELAY_FRAMES_NORMAL; +use crate::visualization::state::VisualizationState; + +/// Handle configuration file changes and update visualization state. +/// +/// This function detects when the configuration has been removed, modified, +/// or replaced, and updates the visualization accordingly: +/// +/// - If config is removed: stops visualization and resets to default state +/// - If config changes: parses new config and updates graph nodes/edges +/// - If parsing fails: logs error and resets to default state +pub fn handle_config_changes( + state: &mut VisualizationState, + configuration_file_state: &ConfigurationFileState, +) { + // Check if config was removed or is empty + let config_is_empty = configuration_file_state + .config_file_content + .as_ref() + .map(|c| c.trim().is_empty()) + .unwrap_or(true); + + let was_config_removed = + state.config_content.is_some() && configuration_file_state.config_file_content.is_none(); + + // Only reset if we previously had a config (avoid resetting every frame when starting empty) + let should_reset = was_config_removed || (config_is_empty && state.config_content.is_some()); + + if should_reset { + // Stop visualization if running, then reset to default + if state.visualization_running { + state.stop_visualization(); + } + state.config_content = None; + *state = VisualizationState::default(); + state.reset_view_requested = true; + log::warn!("Config removed or empty, visualization reset to default"); + return; + } + + // If config is empty and we have no config loaded, nothing to do + if config_is_empty && state.config_content.is_none() { + 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 { + if let Some(config_content) = &configuration_file_state.config_file_content { + // Stop visualization if running before updating config + let was_running = state.visualization_running; + if was_running { + state.stop_visualization(); + } + + // Try to parse the config, handle errors gracefully + // Use catch_unwind because import_config uses .expect() internally + let config_result = + std::panic::catch_unwind(|| fosr_lib::config::import_config(config_content)); + + match config_result { + Ok(config) => { + state.update_from_config(&config); + state.config_content = Some(config_content.clone()); + // Only auto-restart if visualization was running before config change + if was_running { + state.auto_start_countdown = Some(DELAY_FRAMES_NORMAL); + } + state.reset_view_requested = true; + } + Err(e) => { + // Log the error once and reset to default state instead of crashing + // Store the config content so we don't retry parsing every frame + log::error!("Failed to parse configuration: {:?}", e); + *state = VisualizationState::default(); + state.config_content = Some(config_content.clone()); + state.reset_view_requested = true; + } + } + } + } +} diff --git a/crates/fosr-gui/src/run/mod.rs b/crates/fosr-gui/src/run/mod.rs index 33fbfa47..6d683a8a 100644 --- a/crates/fosr-gui/src/run/mod.rs +++ b/crates/fosr-gui/src/run/mod.rs @@ -1,5 +1,6 @@ //! Run module: combines live visualization and PCAP generation in a single tab. +mod config_handling; mod state; mod tab; diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs index 0986019d..6de91fbf 100644 --- a/crates/fosr-gui/src/run/tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -1,5 +1,6 @@ //! Run tab: live network visualization combined with PCAP generation controls. +use super::config_handling::handle_config_changes; use super::state::RunState; use crate::generation::core::generate; use crate::generation::ui_components::{show_field_error, timezone_picker}; @@ -16,10 +17,9 @@ use crate::shared::file_io::save_file_desktop; use crate::shared::file_io::save_file_wasm; use crate::shared::ui_constants::{ ACTIVE_LINK_BASE_TIMEOUT_MS, BOTTOM_BAR_INNER_MARGIN, BUTTON_HEIGHT, BUTTON_MIN_WIDTH_LG, - BUTTON_MIN_WIDTH_SM, DELAY_FRAMES_NORMAL, DELAY_FRAMES_QUICK, DURATION_TEXT_WIDTH, - FIT_TO_SCREEN_PADDING, GENERATION_COL1_MIN_WIDTH, GENERATION_COL2_MIN_WIDTH, - GENERATION_OPTIONS_COLUMNS, OPTIONS_PANEL_INNER_MARGIN, SEED_INPUT_WIDTH, SPACING_LG, - TEXT_SIZE_MD, + BUTTON_MIN_WIDTH_SM, DELAY_FRAMES_QUICK, DURATION_TEXT_WIDTH, FIT_TO_SCREEN_PADDING, + GENERATION_COL1_MIN_WIDTH, GENERATION_COL2_MIN_WIDTH, GENERATION_OPTIONS_COLUMNS, + OPTIONS_PANEL_INNER_MARGIN, SEED_INPUT_WIDTH, SPACING_LG, TEXT_SIZE_MD, }; use crate::shared::ui_utils::info_icon; use crate::timepicker::TimePickerButton; @@ -104,87 +104,6 @@ pub fn show_run_tab_content( render_node_info_modal(ui.ctx(), &mut state.visualization, configuration_file_state); } -/// Handle configuration file changes -fn handle_config_changes( - state: &mut VisualizationState, - configuration_file_state: &ConfigurationFileState, -) { - // Check if config was removed or is empty - let config_is_empty = configuration_file_state - .config_file_content - .as_ref() - .map(|c| c.trim().is_empty()) - .unwrap_or(true); - - let was_config_removed = - state.config_content.is_some() && configuration_file_state.config_file_content.is_none(); - - // Only reset if we previously had a config (avoid resetting every frame when starting empty) - let should_reset = was_config_removed || (config_is_empty && state.config_content.is_some()); - - if should_reset { - // Stop visualization if running, then reset to default - if state.visualization_running { - state.stop_visualization(); - } - state.config_content = None; - *state = VisualizationState::default(); - state.reset_view_requested = true; - log::warn!("Config removed or empty, visualization reset to default"); - return; - } - - // If config is empty and we have no config loaded, nothing to do - if config_is_empty && state.config_content.is_none() { - 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 { - if let Some(config_content) = &configuration_file_state.config_file_content { - // Stop visualization if running before updating config - let was_running = state.visualization_running; - if was_running { - state.stop_visualization(); - } - - // Try to parse the config, handle errors gracefully - // Use catch_unwind because import_config uses .expect() internally - let config_result = - std::panic::catch_unwind(|| fosr_lib::config::import_config(config_content)); - - match config_result { - Ok(config) => { - state.update_from_config(&config); - state.config_content = Some(config_content.clone()); - // Only auto-restart if visualization was running before config change - if was_running { - state.auto_start_countdown = Some(DELAY_FRAMES_NORMAL); - } - state.reset_view_requested = true; - } - Err(e) => { - // Log the error once and reset to default state instead of crashing - // Store the config content so we don't retry parsing every frame - log::error!("Failed to parse configuration: {:?}", e); - *state = VisualizationState::default(); - state.config_content = Some(config_content.clone()); - state.reset_view_requested = true; - } - } - } - } -} - /// Process incoming flow events from the streamer fn process_flow_events(state: &mut VisualizationState) { let events: Vec = if let Some(ref receiver) = state.flow_receiver { From a583cb5685f74d6fbd88770f63619d3cf56e309f Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:59:17 +0100 Subject: [PATCH 105/127] refactor(gui/run): extract flow_processing module from tab.rs --- crates/fosr-gui/src/run/flow_processing.rs | 220 +++++++++++++++++++++ crates/fosr-gui/src/run/mod.rs | 1 + crates/fosr-gui/src/run/tab.rs | 213 +------------------- 3 files changed, 227 insertions(+), 207 deletions(-) create mode 100644 crates/fosr-gui/src/run/flow_processing.rs diff --git a/crates/fosr-gui/src/run/flow_processing.rs b/crates/fosr-gui/src/run/flow_processing.rs new file mode 100644 index 00000000..b8b62c81 --- /dev/null +++ b/crates/fosr-gui/src/run/flow_processing.rs @@ -0,0 +1,220 @@ +//! Flow event processing for network visualization. +//! +//! This module handles the processing of flow events from the streamer, +//! updating active links, and synchronizing graph edge states. + +use crate::shared::ui_constants::ACTIVE_LINK_BASE_TIMEOUT_MS; +use crate::visualization::state::{ + ActiveLink, EdgeState, INTERNET_IP, LinkDirection, VisualizationState, +}; +use crate::visualization::stream::FlowEvent; + +/// Process incoming flow events from the streamer. +/// +/// Reads all pending events from the flow receiver and updates: +/// - Active links (for visual edge highlighting) +/// - Node flow counters (for proportional node sizing) +/// - Edge flow counters (for edge thickness) +/// +/// Flows between two unknown IPs (Internet<->Internet) are filtered out. +pub fn process_flow_events(state: &mut VisualizationState) { + let events: Vec = if let Some(ref receiver) = state.flow_receiver { + receiver.try_iter().collect() + } else { + return; + }; + + let now = web_time::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; + } + + // 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 } 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, + }, + ); + + // 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), + ) { + // 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_node_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_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; + } + } +} + +/// Update active links by removing expired ones. +/// +/// Links expire after a timeout period adjusted by the current speed setting. +pub fn update_active_links(state: &mut VisualizationState) { + let now = web_time::Instant::now(); + // Base display time is 0.5s, adjusted by speed (faster = shorter display) + let base_timeout_ms = ACTIVE_LINK_BASE_TIMEOUT_MS; + let speed = *state.speed.read().unwrap(); + let timeout = std::time::Duration::from_millis((base_timeout_ms / speed) as u64); + + state + .active_links + .retain(|_, link| now.duration_since(link.start_time) < timeout); +} + +/// Update graph edge states based on active links. +/// +/// For each edge, checks if any IP combination has an active link +/// and updates the edge's visual state accordingly. +pub fn update_graph_edges(state: &mut VisualizationState) { + let graph = &mut state.graph; + + // Collect edge info first to avoid borrow issues + // 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_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_ips, dst_ips) in edges_data { + // Check all IP combinations for an active link + let mut new_state = EdgeState::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_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_state = EdgeState::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; + } + } + } + + // Update edge state (flow_count is preserved) + if let Some(edge_mut) = graph.g_mut().edge_weight_mut(edge) { + edge_mut.payload_mut().state = new_state; + } + } +} diff --git a/crates/fosr-gui/src/run/mod.rs b/crates/fosr-gui/src/run/mod.rs index 6d683a8a..8aeee18f 100644 --- a/crates/fosr-gui/src/run/mod.rs +++ b/crates/fosr-gui/src/run/mod.rs @@ -1,6 +1,7 @@ //! Run module: combines live visualization and PCAP generation in a single tab. mod config_handling; +mod flow_processing; mod state; mod tab; diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs index 6de91fbf..782238e4 100644 --- a/crates/fosr-gui/src/run/tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -1,6 +1,7 @@ //! Run tab: live network visualization combined with PCAP generation controls. use super::config_handling::handle_config_changes; +use super::flow_processing::{process_flow_events, update_active_links, update_graph_edges}; use super::state::RunState; use crate::generation::core::generate; use crate::generation::ui_components::{show_field_error, timezone_picker}; @@ -16,10 +17,10 @@ use crate::shared::file_io::save_file_desktop; #[cfg(target_arch = "wasm32")] use crate::shared::file_io::save_file_wasm; use crate::shared::ui_constants::{ - ACTIVE_LINK_BASE_TIMEOUT_MS, BOTTOM_BAR_INNER_MARGIN, BUTTON_HEIGHT, BUTTON_MIN_WIDTH_LG, - BUTTON_MIN_WIDTH_SM, DELAY_FRAMES_QUICK, DURATION_TEXT_WIDTH, FIT_TO_SCREEN_PADDING, - GENERATION_COL1_MIN_WIDTH, GENERATION_COL2_MIN_WIDTH, GENERATION_OPTIONS_COLUMNS, - OPTIONS_PANEL_INNER_MARGIN, SEED_INPUT_WIDTH, SPACING_LG, TEXT_SIZE_MD, + BOTTOM_BAR_INNER_MARGIN, BUTTON_HEIGHT, BUTTON_MIN_WIDTH_LG, BUTTON_MIN_WIDTH_SM, + DELAY_FRAMES_QUICK, DURATION_TEXT_WIDTH, FIT_TO_SCREEN_PADDING, GENERATION_COL1_MIN_WIDTH, + GENERATION_COL2_MIN_WIDTH, GENERATION_OPTIONS_COLUMNS, OPTIONS_PANEL_INNER_MARGIN, + SEED_INPUT_WIDTH, SPACING_LG, TEXT_SIZE_MD, }; use crate::shared::ui_utils::info_icon; use crate::timepicker::TimePickerButton; @@ -30,11 +31,7 @@ use crate::visualization::overlays::{ }; use crate::visualization::screenshot::handle_screenshot_export; use crate::visualization::shapes::{NetworkEdgeShape, NetworkNodeShape}; -use crate::visualization::state::{ - ActiveLink, EdgeData, EdgeState, ExportState, INTERNET_IP, LinkDirection, NodeData, - VisualizationState, -}; -use crate::visualization::stream::FlowEvent; +use crate::visualization::state::{EdgeData, ExportState, NodeData}; use chrono::{Datelike, Local, TimeZone}; use chrono_tz::Tz; use eframe::egui::{self, Widget}; @@ -104,204 +101,6 @@ pub fn show_run_tab_content( render_node_info_modal(ui.ctx(), &mut state.visualization, configuration_file_state); } -/// Process incoming flow events from the streamer -fn process_flow_events(state: &mut VisualizationState) { - let events: Vec = if let Some(ref receiver) = state.flow_receiver { - receiver.try_iter().collect() - } else { - return; - }; - - let now = web_time::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; - } - - // 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 } 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, - }, - ); - - // 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), - ) { - // 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_node_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_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; - } - } -} - -/// Update active links (remove expired ones) -fn update_active_links(state: &mut VisualizationState) { - let now = web_time::Instant::now(); - // Base display time is 0.5s, adjusted by speed (faster = shorter display) - let base_timeout_ms = ACTIVE_LINK_BASE_TIMEOUT_MS; - let speed = *state.speed.read().unwrap(); - let timeout = std::time::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 VisualizationState) { - let graph = &mut state.graph; - - // Collect edge info first to avoid borrow issues - // 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_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_ips, dst_ips) in edges_data { - // Check all IP combinations for an active link - let mut new_state = EdgeState::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_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_state = EdgeState::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; - } - } - } - - // Update edge state (flow_count is preserved) - if let Some(edge_mut) = graph.g_mut().edge_weight_mut(edge) { - edge_mut.payload_mut().state = new_state; - } - } -} - /// Show the generation options fn show_generation_options(ui: &mut egui::Ui, state: &mut RunState) { ui.columns(GENERATION_OPTIONS_COLUMNS, |cols| { From c9d2af27f1ba2173999af7af13c488d1e20fa375 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:05:05 +0100 Subject: [PATCH 106/127] refactor(gui/run): extract generation_options module from tab.rs --- crates/fosr-gui/src/run/generation_options.rs | 223 ++++++++++++++++++ crates/fosr-gui/src/run/mod.rs | 1 + crates/fosr-gui/src/run/tab.rs | 211 +---------------- 3 files changed, 229 insertions(+), 206 deletions(-) create mode 100644 crates/fosr-gui/src/run/generation_options.rs diff --git a/crates/fosr-gui/src/run/generation_options.rs b/crates/fosr-gui/src/run/generation_options.rs new file mode 100644 index 00000000..7379d7ce --- /dev/null +++ b/crates/fosr-gui/src/run/generation_options.rs @@ -0,0 +1,223 @@ +//! Generation options UI components. +//! +//! This module provides the UI for configuring PCAP generation parameters: +//! duration, start time, timezone, seed, and advanced options. + +use super::state::RunState; +use crate::generation::ui_components::{show_field_error, timezone_picker}; +use crate::generation::validation::{ + first_invalid_param, validate_duration, validate_optional_u64, validate_timezone, +}; +use crate::shared::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; +use crate::shared::ui_constants::{ + DURATION_TEXT_WIDTH, GENERATION_COL1_MIN_WIDTH, GENERATION_COL2_MIN_WIDTH, + GENERATION_OPTIONS_COLUMNS, SEED_INPUT_WIDTH, SPACING_LG, +}; +use crate::shared::ui_utils::info_icon; +use crate::timepicker::TimePickerButton; +use chrono::{Datelike, Local, TimeZone}; +use chrono_tz::Tz; +use eframe::egui::{self, Widget}; +use egui_extras::DatePickerButton; + +/// Show the generation options panel with two columns. +/// +/// **Column 1:** Duration and time settings +/// - Duration input with preset buttons (5min, 1h, 24h) +/// - Start time toggle (current time vs custom) +/// - Timezone selection +/// +/// **Column 2:** Seed and advanced options +/// - Optional seed for deterministic generation +/// - Taint packets option +/// - Temporal ordering option +pub fn show_generation_options(ui: &mut egui::Ui, state: &mut RunState) { + ui.columns(GENERATION_OPTIONS_COLUMNS, |cols| { + // --- Column 1: Duration & Time --- + let col1 = &mut cols[0]; + col1.set_min_width(GENERATION_COL1_MIN_WIDTH); + + // Duration + col1.horizontal(|ui| { + ui.label("Duration"); + info_icon(ui, "Minimum pcap traffic duration described in human-friendly time, such as \"30m\", \"1h\", \"2d\" or \"2days 30min 5s\"."); + + // Preset buttons + for preset in ["5min", "1h", "24h"] { + if ui.small_button(preset).clicked() { + state.generation.duration_str = preset.to_string(); + state.generation.duration_validation.set_ok(); + } + } + + let text_response = egui::TextEdit::singleline(&mut state.generation.duration_str) + .desired_width(DURATION_TEXT_WIDTH) + .hint_text("ex: 30m, 1h, 2d") + .ui(ui); + + if text_response.changed() { + match validate_duration(&state.generation.duration_str) { + Ok(_) => { + state.generation.duration_validation.set_ok(); + } + Err(msg) => { + state.generation.duration_validation.set_err(msg); + } + } + } + + show_field_error(ui, &state.generation.duration_validation); + }); + + col1.add_space(SPACING_LG); + + // Use current time + col1.horizontal(|ui| { + ui.checkbox(&mut state.generation.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.generation.use_current_time { + col1.horizontal(|ui| { + ui.label("Start time"); + let current_year = Local::now().date_naive().year(); + ui.add( + DatePickerButton::new(&mut state.generation.start_date) + .start_end_years((current_year - 5)..=(current_year + 30)), + ); + ui.add( + TimePickerButton::new(&mut state.generation.start_hour) + .show_seconds(true) + .use_dragvalue(true), + ); + }); + + col1.add_space(SPACING_LG); + + col1.horizontal(|ui| { + if ui + .checkbox(&mut state.generation.use_local_timezone, "Use local timezone") + .clicked() + { + if state.generation.use_local_timezone { + state.generation.timezone_input = String::new(); + state.generation.timezone_validation.set_ok(); + } else { + state.generation.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.generation.use_local_timezone { + timezone_picker(ui, &mut state.generation); + + let result = validate_timezone(&state.generation.timezone_input); + if result.is_ok() { + state.generation.timezone_validation.set_ok(); + } else { + state.generation.timezone_validation.set_err(result.err().unwrap()); + } + } + }); + } else { + state.generation.timezone_validation.set_ok(); + } + + // Show the equivalent UTC start time (or error if timezone is invalid) + let utc_text: Option = if state.generation.use_current_time { + Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()) + } else { + let local_dt = state + .generation + .start_date + .and_time(state.generation.start_hour); + + let utc = if state.generation.use_local_timezone { + Local::now() + .timezone() + .from_local_datetime(&local_dt) + .earliest() + .map(|dt| dt.with_timezone(&chrono::Utc)) + } else { + state + .generation + .timezone_input + .parse::() + .ok() + .and_then(|tz| local_dt.and_local_timezone(tz).earliest()) + .map(|dt| dt.with_timezone(&chrono::Utc)) + }; + + utc.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .or_else(|| { + if !state.generation.use_local_timezone { + Some("select a valid timezone".to_string()) + } else { + None + } + }) + }; + + if let Some(text) = utc_text { + col1.label( + egui::RichText::new(format!("Start time (UTC): {}", text)) + .color(COLOR_TEXT_MUTED), + ); + } + + // --- Column 2: Seed & Advanced --- + let col2 = &mut cols[1]; + col2.set_min_width(GENERATION_COL2_MIN_WIDTH); + + // Seed + col2.horizontal(|ui| { + ui.checkbox(&mut state.generation.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.generation.use_seed { + let response = ui.add( + egui::TextEdit::singleline(&mut state.generation.seed_input) + .hint_text("enter a seed value") + .desired_width(SEED_INPUT_WIDTH), + ); + + if response.changed() { + match validate_optional_u64(&state.generation.seed_input) { + Ok(_) => { + state.generation.seed_validation.set_ok(); + } + Err(msg) => { + state.generation.seed_validation.set_err(msg); + } + } + } + + show_field_error(ui, &state.generation.seed_validation); + } else { + state.generation.seed_validation.set_ok(); + } + }); + + col2.add_space(SPACING_LG); + + // Advanced options + col2.horizontal(|ui| { + ui.checkbox(&mut state.generation.taint, "Taint the packets"); + info_icon(ui, "Taint the packets with special markers for identification."); + }); + col2.horizontal(|ui| { + ui.checkbox(&mut state.generation.order_pcap, "Order temporally"); + info_icon(ui, "Enable temporal sorting of the generated pcap. Disable to reduce significantly the RAM usage."); + }); + + col2.add_space(SPACING_LG); + + // Validation errors + if let Some((name, spec, err)) = first_invalid_param(&state.generation) { + col2.colored_label( + COLOR_ERROR, + format!("Invalid parameter: {name}. Expected: {spec}. ({err})"), + ); + } + }); +} diff --git a/crates/fosr-gui/src/run/mod.rs b/crates/fosr-gui/src/run/mod.rs index 8aeee18f..8c938e27 100644 --- a/crates/fosr-gui/src/run/mod.rs +++ b/crates/fosr-gui/src/run/mod.rs @@ -2,6 +2,7 @@ mod config_handling; mod flow_processing; +mod generation_options; mod state; mod tab; diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs index 782238e4..07ceea7a 100644 --- a/crates/fosr-gui/src/run/tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -2,15 +2,13 @@ use super::config_handling::handle_config_changes; use super::flow_processing::{process_flow_events, update_active_links, update_graph_edges}; +use super::generation_options::show_generation_options; use super::state::RunState; use crate::generation::core::generate; -use crate::generation::ui_components::{show_field_error, timezone_picker}; -use crate::generation::validation::{ - first_invalid_param, validate_duration, validate_optional_u64, validate_timezone, -}; +use crate::generation::validation::first_invalid_param; #[cfg(not(target_arch = "wasm32"))] use crate::generation::wireshark::open_in_wireshark; -use crate::shared::colors::{COLOR_ERROR, COLOR_STOP, COLOR_SUCCESS, COLOR_TEXT_MUTED}; +use crate::shared::colors::{COLOR_ERROR, COLOR_STOP, COLOR_SUCCESS}; use crate::shared::configuration_file::{ConfigurationFileState, load_config_file_contents}; #[cfg(not(target_arch = "wasm32"))] use crate::shared::file_io::save_file_desktop; @@ -18,12 +16,8 @@ use crate::shared::file_io::save_file_desktop; use crate::shared::file_io::save_file_wasm; use crate::shared::ui_constants::{ BOTTOM_BAR_INNER_MARGIN, BUTTON_HEIGHT, BUTTON_MIN_WIDTH_LG, BUTTON_MIN_WIDTH_SM, - DELAY_FRAMES_QUICK, DURATION_TEXT_WIDTH, FIT_TO_SCREEN_PADDING, GENERATION_COL1_MIN_WIDTH, - GENERATION_COL2_MIN_WIDTH, GENERATION_OPTIONS_COLUMNS, OPTIONS_PANEL_INNER_MARGIN, - SEED_INPUT_WIDTH, SPACING_LG, TEXT_SIZE_MD, + DELAY_FRAMES_QUICK, FIT_TO_SCREEN_PADDING, OPTIONS_PANEL_INNER_MARGIN, TEXT_SIZE_MD, }; -use crate::shared::ui_utils::info_icon; -use crate::timepicker::TimePickerButton; use crate::visualization::node_modal::{process_graph_events, render_node_info_modal}; use crate::visualization::overlays::{ render_overlay_buttons, render_overlay_edge_legend, render_overlay_node_legend, @@ -32,10 +26,7 @@ use crate::visualization::overlays::{ use crate::visualization::screenshot::handle_screenshot_export; use crate::visualization::shapes::{NetworkEdgeShape, NetworkNodeShape}; use crate::visualization::state::{EdgeData, ExportState, NodeData}; -use chrono::{Datelike, Local, TimeZone}; -use chrono_tz::Tz; -use eframe::egui::{self, Widget}; -use egui_extras::DatePickerButton; +use eframe::egui; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::channel; @@ -101,198 +92,6 @@ pub fn show_run_tab_content( render_node_info_modal(ui.ctx(), &mut state.visualization, configuration_file_state); } -/// Show the generation options -fn show_generation_options(ui: &mut egui::Ui, state: &mut RunState) { - ui.columns(GENERATION_OPTIONS_COLUMNS, |cols| { - // --- Column 1: Duration & Time --- - let col1 = &mut cols[0]; - col1.set_min_width(GENERATION_COL1_MIN_WIDTH); - - // Duration - col1.horizontal(|ui| { - ui.label("Duration"); - info_icon(ui, "Minimum pcap traffic duration described in human-friendly time, such as \"30m\", \"1h\", \"2d\" or \"2days 30min 5s\"."); - - // Preset buttons - for preset in ["5min", "1h", "24h"] { - if ui.small_button(preset).clicked() { - state.generation.duration_str = preset.to_string(); - state.generation.duration_validation.set_ok(); - } - } - - let text_response = egui::TextEdit::singleline(&mut state.generation.duration_str) - .desired_width(DURATION_TEXT_WIDTH) - .hint_text("ex: 30m, 1h, 2d") - .ui(ui); - - if text_response.changed() { - match validate_duration(&state.generation.duration_str) { - Ok(_) => { - state.generation.duration_validation.set_ok(); - } - Err(msg) => { - state.generation.duration_validation.set_err(msg); - } - } - } - - show_field_error(ui, &state.generation.duration_validation); - }); - - col1.add_space(SPACING_LG); - - // Use current time - col1.horizontal(|ui| { - ui.checkbox(&mut state.generation.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.generation.use_current_time { - col1.horizontal(|ui| { - ui.label("Start time"); - let current_year = Local::now().date_naive().year(); - ui.add( - DatePickerButton::new(&mut state.generation.start_date) - .start_end_years((current_year - 5)..=(current_year + 30)), - ); - ui.add( - TimePickerButton::new(&mut state.generation.start_hour) - .show_seconds(true) - .use_dragvalue(true), - ); - }); - - col1.add_space(SPACING_LG); - - col1.horizontal(|ui| { - if ui - .checkbox(&mut state.generation.use_local_timezone, "Use local timezone") - .clicked() - { - if state.generation.use_local_timezone { - state.generation.timezone_input = String::new(); - state.generation.timezone_validation.set_ok(); - } else { - state.generation.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.generation.use_local_timezone { - timezone_picker(ui, &mut state.generation); - - let result = validate_timezone(&state.generation.timezone_input); - if result.is_ok() { - state.generation.timezone_validation.set_ok(); - } else { - state.generation.timezone_validation.set_err(result.err().unwrap()); - } - } - }); - } else { - state.generation.timezone_validation.set_ok(); - } - - // Show the equivalent UTC start time (or error if timezone is invalid) - let utc_text: Option = if state.generation.use_current_time { - Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()) - } else { - let local_dt = state - .generation - .start_date - .and_time(state.generation.start_hour); - - let utc = if state.generation.use_local_timezone { - Local::now() - .timezone() - .from_local_datetime(&local_dt) - .earliest() - .map(|dt| dt.with_timezone(&chrono::Utc)) - } else { - state - .generation - .timezone_input - .parse::() - .ok() - .and_then(|tz| local_dt.and_local_timezone(tz).earliest()) - .map(|dt| dt.with_timezone(&chrono::Utc)) - }; - - utc.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) - .or_else(|| { - if !state.generation.use_local_timezone { - Some("select a valid timezone".to_string()) - } else { - None - } - }) - }; - - if let Some(text) = utc_text { - col1.label( - egui::RichText::new(format!("Start time (UTC): {}", text)) - .color(COLOR_TEXT_MUTED), - ); - } - - // --- Column 2: Seed & Advanced --- - let col2 = &mut cols[1]; - col2.set_min_width(GENERATION_COL2_MIN_WIDTH); - - // Seed - col2.horizontal(|ui| { - ui.checkbox(&mut state.generation.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.generation.use_seed { - let response = ui.add( - egui::TextEdit::singleline(&mut state.generation.seed_input) - .hint_text("enter a seed value") - .desired_width(SEED_INPUT_WIDTH), - ); - - if response.changed() { - match validate_optional_u64(&state.generation.seed_input) { - Ok(_) => { - state.generation.seed_validation.set_ok(); - } - Err(msg) => { - state.generation.seed_validation.set_err(msg); - } - } - } - - show_field_error(ui, &state.generation.seed_validation); - } else { - state.generation.seed_validation.set_ok(); - } - }); - - col2.add_space(SPACING_LG); - - // Advanced options - col2.horizontal(|ui| { - ui.checkbox(&mut state.generation.taint, "Taint the packets"); - info_icon(ui, "Taint the packets with special markers for identification."); - }); - col2.horizontal(|ui| { - ui.checkbox(&mut state.generation.order_pcap, "Order temporally"); - info_icon(ui, "Enable temporal sorting of the generated pcap. Disable to reduce significantly the RAM usage."); - }); - - col2.add_space(SPACING_LG); - - // Validation errors - if let Some((name, spec, err)) = first_invalid_param(&state.generation) { - col2.colored_label( - COLOR_ERROR, - format!("Invalid parameter: {name}. Expected: {spec}. ({err})"), - ); - } - }); -} - /// Start the generation process fn start_generation( state: &mut RunState, From c453a8edd07ca0f1656b76de3a04ce08d7f55738 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:10:14 +0100 Subject: [PATCH 107/127] refactor(gui/run): extract generation_process module from tab.rs --- crates/fosr-gui/src/run/generation_process.rs | 136 ++++++++++++++++++ crates/fosr-gui/src/run/mod.rs | 1 + crates/fosr-gui/src/run/tab.rs | 126 +--------------- 3 files changed, 139 insertions(+), 124 deletions(-) create mode 100644 crates/fosr-gui/src/run/generation_process.rs diff --git a/crates/fosr-gui/src/run/generation_process.rs b/crates/fosr-gui/src/run/generation_process.rs new file mode 100644 index 00000000..18142c7a --- /dev/null +++ b/crates/fosr-gui/src/run/generation_process.rs @@ -0,0 +1,136 @@ +//! PCAP generation process management. +//! +//! This module handles starting generation threads and polling for updates. + +use super::state::RunState; +use crate::generation::core::generate; +use crate::shared::configuration_file::ConfigurationFileState; +use eframe::egui; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::mpsc::channel; + +/// Start the PCAP generation process in a background thread. +/// +/// Creates channels for progress updates, PCAP data, and throughput metrics. +/// The generation runs asynchronously (native thread or WASM future). +pub fn start_generation( + state: &mut RunState, + configuration_file_state: &ConfigurationFileState, + ctx: &egui::Context, +) { + // Reset state + state.generation.progress = 0.0; + state.generation.error = None; + state.generation.cancelled = Arc::new(AtomicBool::new(false)); + + let (progress_sender, progress_receiver) = channel(); + state.generation.progress_receiver = Some(progress_receiver); + + let (pcap_sender, pcap_receiver) = channel(); + state.generation.pcap_receiver = Some(pcap_receiver); + + let (throughput_sender, throughput_receiver) = channel(); + state.generation.throughput_receiver = Some(throughput_receiver); + state.generation.throughput = None; + + let seed = if state.generation.use_seed { + state.generation.seed_input.parse::().ok() + } else { + None + }; + let order_pcap = state.generation.order_pcap; + let start_time = if state.generation.use_current_time { + None + } else { + Some(format!( + "{}T{}Z", + state.generation.start_date.format("%Y-%m-%d"), + state.generation.start_hour.format("%H:%M:%S") + )) + }; + let duration = state.generation.duration_str.clone(); + let taint = state.generation.taint; + let timezone = if state.generation.timezone_input.is_empty() { + None + } else { + Some(state.generation.timezone_input.clone()) + }; + let ctx = ctx.clone(); + let cancelled = state.generation.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 { + generate( + seed, + config_content, + 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 || { + generate( + seed, + config_content, + order_pcap, + start_time, + duration, + taint, + timezone, + Some(progress_sender), + Some(pcap_sender), + Some(throughput_sender), + cancelled, + ); + ctx.request_repaint(); + }); + } +} + +/// Poll generation receivers for progress, PCAP data, and throughput. +/// +/// Should be called every frame to update the UI with generation status. +pub fn poll_generation_receivers(ctx: &egui::Context, state: &mut RunState) { + // Poll progress receiver + if let Some(receiver) = &state.generation.progress_receiver { + // Request repaint to keep polling while generating + ctx.request_repaint(); + if let Ok(progress) = receiver.try_recv() { + state.generation.progress = progress; + if progress == 1.0 { + state.generation.progress_receiver = None; + } + } + } + + // Poll pcap receiver + if let Some(receiver) = &state.generation.pcap_receiver { + if let Ok(pcap_bytes) = receiver.try_recv() { + state.generation.pcap_bytes = Some(pcap_bytes); + } + } + + // Poll throughput receiver + if let Some(receiver) = &state.generation.throughput_receiver { + if let Ok(throughput) = receiver.try_recv() { + state.generation.throughput = Some(throughput); + state.generation.throughput_receiver = None; + } + } +} diff --git a/crates/fosr-gui/src/run/mod.rs b/crates/fosr-gui/src/run/mod.rs index 8c938e27..f5b9e848 100644 --- a/crates/fosr-gui/src/run/mod.rs +++ b/crates/fosr-gui/src/run/mod.rs @@ -3,6 +3,7 @@ mod config_handling; mod flow_processing; mod generation_options; +mod generation_process; mod state; mod tab; diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs index 07ceea7a..1335c234 100644 --- a/crates/fosr-gui/src/run/tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -3,8 +3,8 @@ use super::config_handling::handle_config_changes; use super::flow_processing::{process_flow_events, update_active_links, update_graph_edges}; use super::generation_options::show_generation_options; +use super::generation_process::{poll_generation_receivers, start_generation}; use super::state::RunState; -use crate::generation::core::generate; use crate::generation::validation::first_invalid_param; #[cfg(not(target_arch = "wasm32"))] use crate::generation::wireshark::open_in_wireshark; @@ -27,9 +27,7 @@ use crate::visualization::screenshot::handle_screenshot_export; use crate::visualization::shapes::{NetworkEdgeShape, NetworkNodeShape}; use crate::visualization::state::{EdgeData, ExportState, NodeData}; use eframe::egui; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::channel; +use std::sync::atomic::Ordering; pub fn show_run_tab_content( ui: &mut egui::Ui, @@ -92,126 +90,6 @@ pub fn show_run_tab_content( render_node_info_modal(ui.ctx(), &mut state.visualization, configuration_file_state); } -/// Start the generation process -fn start_generation( - state: &mut RunState, - configuration_file_state: &ConfigurationFileState, - ctx: &egui::Context, -) { - // Reset state - state.generation.progress = 0.0; - state.generation.error = None; - state.generation.cancelled = Arc::new(AtomicBool::new(false)); - - let (progress_sender, progress_receiver) = channel(); - state.generation.progress_receiver = Some(progress_receiver); - - let (pcap_sender, pcap_receiver) = channel(); - state.generation.pcap_receiver = Some(pcap_receiver); - - let (throughput_sender, throughput_receiver) = channel(); - state.generation.throughput_receiver = Some(throughput_receiver); - state.generation.throughput = None; - - let seed = if state.generation.use_seed { - state.generation.seed_input.parse::().ok() - } else { - None - }; - let order_pcap = state.generation.order_pcap; - let start_time = if state.generation.use_current_time { - None - } else { - Some(format!( - "{}T{}Z", - state.generation.start_date.format("%Y-%m-%d"), - state.generation.start_hour.format("%H:%M:%S") - )) - }; - let duration = state.generation.duration_str.clone(); - let taint = state.generation.taint; - let timezone = if state.generation.timezone_input.is_empty() { - None - } else { - Some(state.generation.timezone_input.clone()) - }; - let ctx = ctx.clone(); - let cancelled = state.generation.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 { - generate( - seed, - config_content, - 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 || { - generate( - seed, - config_content, - order_pcap, - start_time, - duration, - taint, - timezone, - Some(progress_sender), - Some(pcap_sender), - Some(throughput_sender), - cancelled, - ); - ctx.request_repaint(); - }); - } -} - -/// Poll generation receivers for updates -fn poll_generation_receivers(ctx: &egui::Context, state: &mut RunState) { - // Poll progress receiver - if let Some(receiver) = &state.generation.progress_receiver { - // Request repaint to keep polling while generating - ctx.request_repaint(); - if let Ok(progress) = receiver.try_recv() { - state.generation.progress = progress; - if progress == 1.0 { - state.generation.progress_receiver = None; - } - } - } - - // Poll pcap receiver - if let Some(receiver) = &state.generation.pcap_receiver { - if let Ok(pcap_bytes) = receiver.try_recv() { - state.generation.pcap_bytes = Some(pcap_bytes); - } - } - - // Poll throughput receiver - if let Some(receiver) = &state.generation.throughput_receiver { - if let Ok(throughput) = receiver.try_recv() { - state.generation.throughput = Some(throughput); - state.generation.throughput_receiver = None; - } - } -} - /// Show the bottom panel with action bar and expandable options fn show_bottom_panel( ctx: &egui::Context, From d8b8d9495e4bd203130fe10afb00b59ece1b3c6d Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:15:03 +0100 Subject: [PATCH 108/127] refactor(gui/run): extract bottom_panel module from tab.rs --- crates/fosr-gui/src/run/bottom_panel.rs | 237 ++++++++++++++++++++++++ crates/fosr-gui/src/run/mod.rs | 1 + crates/fosr-gui/src/run/tab.rs | 228 +---------------------- 3 files changed, 241 insertions(+), 225 deletions(-) create mode 100644 crates/fosr-gui/src/run/bottom_panel.rs diff --git a/crates/fosr-gui/src/run/bottom_panel.rs b/crates/fosr-gui/src/run/bottom_panel.rs new file mode 100644 index 00000000..77553904 --- /dev/null +++ b/crates/fosr-gui/src/run/bottom_panel.rs @@ -0,0 +1,237 @@ +//! Bottom panel UI for the Run tab. +//! +//! Contains the action bar with Generate/Stop/Save buttons and +//! the expandable generation options panel. + +use super::generation_options::show_generation_options; +use super::generation_process::start_generation; +use super::state::RunState; +use crate::generation::validation::first_invalid_param; +#[cfg(not(target_arch = "wasm32"))] +use crate::generation::wireshark::open_in_wireshark; +use crate::shared::colors::{COLOR_ERROR, COLOR_STOP, COLOR_SUCCESS}; +use crate::shared::configuration_file::ConfigurationFileState; +#[cfg(not(target_arch = "wasm32"))] +use crate::shared::file_io::save_file_desktop; +#[cfg(target_arch = "wasm32")] +use crate::shared::file_io::save_file_wasm; +use crate::shared::ui_constants::{ + BOTTOM_BAR_INNER_MARGIN, BUTTON_HEIGHT, BUTTON_MIN_WIDTH_LG, BUTTON_MIN_WIDTH_SM, + DELAY_FRAMES_QUICK, OPTIONS_PANEL_INNER_MARGIN, TEXT_SIZE_MD, +}; +use eframe::egui; +use std::sync::atomic::Ordering; + +/// Show the bottom panel with action bar and expandable options. +/// +/// The panel consists of: +/// - Options panel (shown when expanded): generation parameters +/// - Action bar (always visible): Generate/Stop/Save buttons, progress bar +pub fn show_bottom_panel( + ctx: &egui::Context, + state: &mut RunState, + configuration_file_state: &ConfigurationFileState, +) { + // Options panel (shown above action bar when expanded) + if state.panel_open { + egui::TopBottomPanel::bottom("run_options_panel") + .frame( + egui::Frame::side_top_panel(&ctx.style()) + .inner_margin(egui::Margin::symmetric( + OPTIONS_PANEL_INNER_MARGIN.0, + OPTIONS_PANEL_INNER_MARGIN.1, + )) + .fill(ctx.style().visuals.panel_fill), + ) + .resizable(false) + .show(ctx, |ui| { + show_generation_options(ui, state); + }); + } + + // Action bar (always visible) + egui::TopBottomPanel::bottom("run_bottom_bar") + .frame( + egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin::symmetric( + BOTTOM_BAR_INNER_MARGIN.0, + BOTTOM_BAR_INNER_MARGIN.1, + )), + ) + .show(ctx, |ui| { + let is_generating = state.generation.is_generating(); + let is_complete = state.generation.is_complete(); + let can_generate = first_invalid_param(&state.generation).is_none(); + + ui.horizontal(|ui| { + // Generate button (always visible, same style/position) + 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(format!( + "{} Generate", + egui_material_icons::icons::ICON_PLAY_ARROW + )) + .size(TEXT_SIZE_MD), + ) + .fill(accent) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_LG, BUTTON_HEIGHT)); + if ui + .add(generate_button) + .on_hover_text("Generate PCAP from configuration") + .clicked() + { + start_generation(state, configuration_file_state, ctx); + } + }); + } + + // Stop button (when generating) + if is_generating { + let stop_button = egui::Button::new( + egui::RichText::new(format!( + "{} Stop", + egui_material_icons::icons::ICON_STOP + )) + .size(TEXT_SIZE_MD), + ) + .fill(COLOR_STOP) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); + if ui + .add(stop_button) + .on_hover_text("Cancel generation") + .clicked() + { + state.generation.cancelled.store(true, Ordering::Relaxed); + state.generation.progress = 0.0; + state.generation.progress_receiver = None; + state.generation.pcap_receiver = None; + state.generation.throughput_receiver = None; + } + } + + // Save/Open buttons (when complete) + if is_complete { + #[cfg(not(target_arch = "wasm32"))] + let save_text = format!("{} Save", egui_material_icons::icons::ICON_SAVE); + #[cfg(target_arch = "wasm32")] + let save_text = + format!("{} Download", egui_material_icons::icons::ICON_DOWNLOAD); + + let save_button = + egui::Button::new(egui::RichText::new(save_text).size(TEXT_SIZE_MD)) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); + if ui.add(save_button).clicked() { + let pcap_bytes = state.generation.pcap_bytes.clone(); + #[cfg(not(target_arch = "wasm32"))] + { + let data = pcap_bytes.as_ref().unwrap().as_slice(); + match save_file_desktop(data, &state.generation.output_file_name) { + Ok(file_handle) => { + log::info!( + "Successfully wrote to file: {}", + file_handle.path().to_string_lossy() + ); + } + Err(e) => { + log::error!("Failed to save file: {:?}", e); + state.generation.error = + Some(format!("Failed to save file: {e}")); + } + } + } + + #[cfg(target_arch = "wasm32")] + { + let file_name = state.generation.output_file_name.clone(); + wasm_bindgen_futures::spawn_local(async move { + let data = pcap_bytes.as_ref().unwrap().as_slice(); + match save_file_wasm(data, &file_name).await { + Ok(_) => log::info!("File written successfully!"), + Err(e) => log::error!("Failed to write file on WASM: {:?}", e), + } + }); + } + } + + // Open in Wireshark button (native only) + #[cfg(not(target_arch = "wasm32"))] + { + let open_button = egui::Button::new( + egui::RichText::new(format!( + "{} Open", + egui_material_icons::icons::ICON_LAN + )) + .size(TEXT_SIZE_MD), + ) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); + let response = + ui.add_enabled(state.generation.wireshark_available, open_button); + let response = if state.generation.wireshark_available { + response.on_hover_text("Open in Wireshark") + } else { + response.on_disabled_hover_text("Wireshark not found in PATH") + }; + if response.clicked() { + if let Some(ref pcap_bytes) = state.generation.pcap_bytes { + match open_in_wireshark( + pcap_bytes, + &mut state.generation.temp_pcap_files, + ) { + Ok(_) => log::info!("Opened PCAP in Wireshark"), + Err(e) => { + log::error!("Failed to open in Wireshark: {:?}", e); + state.generation.error = + Some(format!("Failed to open in Wireshark: {e}")); + } + } + } + } + } + } + + // Error display (when there's an error) + if let Some(error) = &state.generation.error { + ui.colored_label(COLOR_ERROR, error); + } + + // Options toggle button (right-aligned) with progress bar and throughput + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Options button (rightmost) + let icon = if state.panel_open { + egui_material_icons::icons::ICON_KEYBOARD_ARROW_DOWN + } else { + egui_material_icons::icons::ICON_KEYBOARD_ARROW_UP + }; + let tooltip = if state.panel_open { + "Hide options" + } else { + "Show options" + }; + if ui + .button(format!("{} Options", icon)) + .on_hover_text(tooltip) + .clicked() + { + state.panel_open = !state.panel_open; + state.visualization.delayed_fit_countdown = Some(DELAY_FRAMES_QUICK); + } + + // Throughput (when complete) - left of Options + if is_complete { + if let Some(throughput) = &state.generation.throughput { + ui.label(format!("Throughput: {throughput}")); + } + } + + // Progress bar (when generating) - left of Throughput/Options + if is_generating { + let progress = egui::ProgressBar::new(state.generation.progress) + .text("") + .fill(COLOR_SUCCESS); + ui.add(progress); + } + }); + }); + }); +} diff --git a/crates/fosr-gui/src/run/mod.rs b/crates/fosr-gui/src/run/mod.rs index f5b9e848..7047a8eb 100644 --- a/crates/fosr-gui/src/run/mod.rs +++ b/crates/fosr-gui/src/run/mod.rs @@ -1,5 +1,6 @@ //! Run module: combines live visualization and PCAP generation in a single tab. +mod bottom_panel; mod config_handling; mod flow_processing; mod generation_options; diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs index 1335c234..cd8a1da4 100644 --- a/crates/fosr-gui/src/run/tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -1,23 +1,12 @@ //! Run tab: live network visualization combined with PCAP generation controls. +use super::bottom_panel::show_bottom_panel; use super::config_handling::handle_config_changes; use super::flow_processing::{process_flow_events, update_active_links, update_graph_edges}; -use super::generation_options::show_generation_options; -use super::generation_process::{poll_generation_receivers, start_generation}; +use super::generation_process::poll_generation_receivers; use super::state::RunState; -use crate::generation::validation::first_invalid_param; -#[cfg(not(target_arch = "wasm32"))] -use crate::generation::wireshark::open_in_wireshark; -use crate::shared::colors::{COLOR_ERROR, COLOR_STOP, COLOR_SUCCESS}; use crate::shared::configuration_file::{ConfigurationFileState, load_config_file_contents}; -#[cfg(not(target_arch = "wasm32"))] -use crate::shared::file_io::save_file_desktop; -#[cfg(target_arch = "wasm32")] -use crate::shared::file_io::save_file_wasm; -use crate::shared::ui_constants::{ - BOTTOM_BAR_INNER_MARGIN, BUTTON_HEIGHT, BUTTON_MIN_WIDTH_LG, BUTTON_MIN_WIDTH_SM, - DELAY_FRAMES_QUICK, FIT_TO_SCREEN_PADDING, OPTIONS_PANEL_INNER_MARGIN, TEXT_SIZE_MD, -}; +use crate::shared::ui_constants::FIT_TO_SCREEN_PADDING; use crate::visualization::node_modal::{process_graph_events, render_node_info_modal}; use crate::visualization::overlays::{ render_overlay_buttons, render_overlay_edge_legend, render_overlay_node_legend, @@ -27,7 +16,6 @@ use crate::visualization::screenshot::handle_screenshot_export; use crate::visualization::shapes::{NetworkEdgeShape, NetworkNodeShape}; use crate::visualization::state::{EdgeData, ExportState, NodeData}; use eframe::egui; -use std::sync::atomic::Ordering; pub fn show_run_tab_content( ui: &mut egui::Ui, @@ -90,216 +78,6 @@ pub fn show_run_tab_content( render_node_info_modal(ui.ctx(), &mut state.visualization, configuration_file_state); } -/// Show the bottom panel with action bar and expandable options -fn show_bottom_panel( - ctx: &egui::Context, - state: &mut RunState, - configuration_file_state: &ConfigurationFileState, -) { - // Options panel (shown above action bar when expanded) - if state.panel_open { - egui::TopBottomPanel::bottom("run_options_panel") - .frame( - egui::Frame::side_top_panel(&ctx.style()) - .inner_margin(egui::Margin::symmetric( - OPTIONS_PANEL_INNER_MARGIN.0, - OPTIONS_PANEL_INNER_MARGIN.1, - )) - .fill(ctx.style().visuals.panel_fill), - ) - .resizable(false) - .show(ctx, |ui| { - show_generation_options(ui, state); - }); - } - - // Action bar (always visible) - egui::TopBottomPanel::bottom("run_bottom_bar") - .frame( - egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin::symmetric( - BOTTOM_BAR_INNER_MARGIN.0, - BOTTOM_BAR_INNER_MARGIN.1, - )), - ) - .show(ctx, |ui| { - let is_generating = state.generation.is_generating(); - let is_complete = state.generation.is_complete(); - let can_generate = first_invalid_param(&state.generation).is_none(); - - ui.horizontal(|ui| { - // Generate button (always visible, same style/position) - 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(format!( - "{} Generate", - egui_material_icons::icons::ICON_PLAY_ARROW - )) - .size(TEXT_SIZE_MD), - ) - .fill(accent) - .min_size(egui::vec2(BUTTON_MIN_WIDTH_LG, BUTTON_HEIGHT)); - if ui - .add(generate_button) - .on_hover_text("Generate PCAP from configuration") - .clicked() - { - start_generation(state, configuration_file_state, ctx); - } - }); - } - - // Stop button (when generating) - if is_generating { - let stop_button = egui::Button::new( - egui::RichText::new(format!( - "{} Stop", - egui_material_icons::icons::ICON_STOP - )) - .size(TEXT_SIZE_MD), - ) - .fill(COLOR_STOP) - .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); - if ui - .add(stop_button) - .on_hover_text("Cancel generation") - .clicked() - { - state.generation.cancelled.store(true, Ordering::Relaxed); - state.generation.progress = 0.0; - state.generation.progress_receiver = None; - state.generation.pcap_receiver = None; - state.generation.throughput_receiver = None; - } - } - - // Save/Open buttons (when complete) - if is_complete { - #[cfg(not(target_arch = "wasm32"))] - let save_text = format!("{} Save", egui_material_icons::icons::ICON_SAVE); - #[cfg(target_arch = "wasm32")] - let save_text = - format!("{} Download", egui_material_icons::icons::ICON_DOWNLOAD); - - let save_button = - egui::Button::new(egui::RichText::new(save_text).size(TEXT_SIZE_MD)) - .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); - if ui.add(save_button).clicked() { - let pcap_bytes = state.generation.pcap_bytes.clone(); - #[cfg(not(target_arch = "wasm32"))] - { - let data = pcap_bytes.as_ref().unwrap().as_slice(); - match save_file_desktop(data, &state.generation.output_file_name) { - Ok(file_handle) => { - log::info!( - "Successfully wrote to file: {}", - file_handle.path().to_string_lossy() - ); - } - Err(e) => { - log::error!("Failed to save file: {:?}", e); - state.generation.error = - Some(format!("Failed to save file: {e}")); - } - } - } - - #[cfg(target_arch = "wasm32")] - { - let file_name = state.generation.output_file_name.clone(); - wasm_bindgen_futures::spawn_local(async move { - let data = pcap_bytes.as_ref().unwrap().as_slice(); - match save_file_wasm(data, &file_name).await { - Ok(_) => log::info!("File written successfully!"), - Err(e) => log::error!("Failed to write file on WASM: {:?}", e), - } - }); - } - } - - // Open in Wireshark button (native only) - #[cfg(not(target_arch = "wasm32"))] - { - let open_button = egui::Button::new( - egui::RichText::new(format!( - "{} Open", - egui_material_icons::icons::ICON_LAN - )) - .size(TEXT_SIZE_MD), - ) - .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); - let response = - ui.add_enabled(state.generation.wireshark_available, open_button); - let response = if state.generation.wireshark_available { - response.on_hover_text("Open in Wireshark") - } else { - response.on_disabled_hover_text("Wireshark not found in PATH") - }; - if response.clicked() { - if let Some(ref pcap_bytes) = state.generation.pcap_bytes { - match open_in_wireshark( - pcap_bytes, - &mut state.generation.temp_pcap_files, - ) { - Ok(_) => log::info!("Opened PCAP in Wireshark"), - Err(e) => { - log::error!("Failed to open in Wireshark: {:?}", e); - state.generation.error = - Some(format!("Failed to open in Wireshark: {e}")); - } - } - } - } - } - } - - // Error display (when there's an error) - if let Some(error) = &state.generation.error { - ui.colored_label(COLOR_ERROR, error); - } - - // Options toggle button (right-aligned) with progress bar and throughput - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Options button (rightmost) - let icon = if state.panel_open { - egui_material_icons::icons::ICON_KEYBOARD_ARROW_DOWN - } else { - egui_material_icons::icons::ICON_KEYBOARD_ARROW_UP - }; - let tooltip = if state.panel_open { - "Hide options" - } else { - "Show options" - }; - if ui - .button(format!("{} Options", icon)) - .on_hover_text(tooltip) - .clicked() - { - state.panel_open = !state.panel_open; - state.visualization.delayed_fit_countdown = Some(DELAY_FRAMES_QUICK); - } - - // Throughput (when complete) - left of Options - if is_complete { - if let Some(throughput) = &state.generation.throughput { - ui.label(format!("Throughput: {throughput}")); - } - } - - // Progress bar (when generating) - left of Throughput/Options - if is_generating { - let progress = egui::ProgressBar::new(state.generation.progress) - .text("") - .fill(COLOR_SUCCESS); - ui.add(progress); - } - }); - }); - }); -} - /// Render the graph view with overlays fn render_graph_view(ui: &mut egui::Ui, state: &mut RunState) { let inner_response = egui::CentralPanel::default().show(ui.ctx(), |ui| { From 7d94af99ea6853b5c8896be69090b09347b69887 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:16:27 +0100 Subject: [PATCH 109/127] refactor(gui/run): extract graph_view module from tab.rs --- crates/fosr-gui/src/run/graph_view.rs | 105 ++++++++++++++++++++++++++ crates/fosr-gui/src/run/mod.rs | 1 + crates/fosr-gui/src/run/tab.rs | 92 +--------------------- 3 files changed, 107 insertions(+), 91 deletions(-) create mode 100644 crates/fosr-gui/src/run/graph_view.rs diff --git a/crates/fosr-gui/src/run/graph_view.rs b/crates/fosr-gui/src/run/graph_view.rs new file mode 100644 index 00000000..7ca6a64a --- /dev/null +++ b/crates/fosr-gui/src/run/graph_view.rs @@ -0,0 +1,105 @@ +//! Graph view rendering for the Run tab. +//! +//! Renders the network visualization with custom node/edge shapes, +//! overlays, and screenshot export handling. + +use super::state::RunState; +use crate::shared::ui_constants::FIT_TO_SCREEN_PADDING; +use crate::visualization::overlays::{ + render_overlay_buttons, render_overlay_edge_legend, render_overlay_node_legend, + render_overlay_stats, +}; +use crate::visualization::screenshot::handle_screenshot_export; +use crate::visualization::shapes::{NetworkEdgeShape, NetworkNodeShape}; +use crate::visualization::state::{EdgeData, ExportState, NodeData}; +use eframe::egui; + +/// Render the graph view with overlays. +/// +/// This function: +/// 1. Sets up the egui_graphs GraphView with custom shapes +/// 2. Handles window resize (triggers fit-to-screen) +/// 3. Disables force-directed layout (uses circle layout) +/// 4. Handles screenshot export state machine +/// 5. Renders UI overlays (buttons, stats, legends) +pub fn render_graph_view(ui: &mut egui::Ui, state: &mut RunState) { + let inner_response = egui::CentralPanel::default().show(ui.ctx(), |ui| { + // Enable node clicking and dragging + let interactions = egui_graphs::SettingsInteraction::new() + .with_node_clicking_enabled(true) + .with_dragging_enabled(true); + + // Reset view on window resize + let screen_size = ui.ctx().content_rect().size(); + match state.visualization.last_screen_size { + Some(last) if last != screen_size => { + state.visualization.last_screen_size = Some(screen_size); + state.visualization.reset_view_requested = true; + } + None => state.visualization.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.visualization.reset_view_requested; + if state.visualization.reset_view_requested { + state.visualization.reset_view_requested = false; + } + + let mut graph_view = egui_graphs::GraphView::< + NodeData, + EdgeData, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, + NetworkEdgeShape, + egui_graphs::FruchtermanReingoldWithCenterGravityState, + egui_graphs::LayoutForceDirected, + >::new(&mut state.visualization.graph) + .with_interactions(&interactions) + .with_event_sink(&state.visualization.events_buffer) + .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_fit_to_screen_padding(FIT_TO_SCREEN_PADDING) // padding to avoid cropping with labels and overlays + .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 + if !state.visualization.layout_initialized { + let layout_state = egui_graphs::FruchtermanReingoldWithCenterGravityState { + base: egui_graphs::FruchtermanReingoldState { + is_running: false, + ..Default::default() + }, + extras: Default::default(), + }; + egui_graphs::set_layout_state(ui, layout_state, None); + state.visualization.layout_initialized = true; + } + + ui.add(&mut graph_view); + + // Handle screenshot export state machine + handle_screenshot_export(ui, &mut state.visualization); + + // Hide overlays during export to get clean screenshot + if state.visualization.export_state != ExportState::Idle { + return; + } + + // Render overlays + render_overlay_buttons(ui, &mut state.visualization); + render_overlay_stats(ui, &state.visualization); + render_overlay_node_legend(ui); + render_overlay_edge_legend(ui); + }); + + // Use panel rect directly - it's already in screen coordinates + // and represents the full panel area (ui.max_rect() excludes internal padding) + let panel_rect = inner_response.response.rect; + state.visualization.graph_rect = Some(panel_rect); +} diff --git a/crates/fosr-gui/src/run/mod.rs b/crates/fosr-gui/src/run/mod.rs index 7047a8eb..fd3c3872 100644 --- a/crates/fosr-gui/src/run/mod.rs +++ b/crates/fosr-gui/src/run/mod.rs @@ -5,6 +5,7 @@ mod config_handling; mod flow_processing; mod generation_options; mod generation_process; +mod graph_view; mod state; mod tab; diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs index cd8a1da4..2e3fe9ca 100644 --- a/crates/fosr-gui/src/run/tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -4,17 +4,10 @@ use super::bottom_panel::show_bottom_panel; use super::config_handling::handle_config_changes; use super::flow_processing::{process_flow_events, update_active_links, update_graph_edges}; use super::generation_process::poll_generation_receivers; +use super::graph_view::render_graph_view; use super::state::RunState; use crate::shared::configuration_file::{ConfigurationFileState, load_config_file_contents}; -use crate::shared::ui_constants::FIT_TO_SCREEN_PADDING; use crate::visualization::node_modal::{process_graph_events, render_node_info_modal}; -use crate::visualization::overlays::{ - render_overlay_buttons, render_overlay_edge_legend, render_overlay_node_legend, - render_overlay_stats, -}; -use crate::visualization::screenshot::handle_screenshot_export; -use crate::visualization::shapes::{NetworkEdgeShape, NetworkNodeShape}; -use crate::visualization::state::{EdgeData, ExportState, NodeData}; use eframe::egui; pub fn show_run_tab_content( @@ -77,86 +70,3 @@ pub fn show_run_tab_content( process_graph_events(&mut state.visualization, configuration_file_state); render_node_info_modal(ui.ctx(), &mut state.visualization, configuration_file_state); } - -/// Render the graph view with overlays -fn render_graph_view(ui: &mut egui::Ui, state: &mut RunState) { - let inner_response = egui::CentralPanel::default().show(ui.ctx(), |ui| { - // Enable node clicking and dragging - let interactions = egui_graphs::SettingsInteraction::new() - .with_node_clicking_enabled(true) - .with_dragging_enabled(true); - - // Reset view on window resize - let screen_size = ui.ctx().content_rect().size(); - match state.visualization.last_screen_size { - Some(last) if last != screen_size => { - state.visualization.last_screen_size = Some(screen_size); - state.visualization.reset_view_requested = true; - } - None => state.visualization.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.visualization.reset_view_requested; - if state.visualization.reset_view_requested { - state.visualization.reset_view_requested = false; - } - - let mut graph_view = egui_graphs::GraphView::< - NodeData, - EdgeData, - petgraph::Undirected, - petgraph::stable_graph::DefaultIx, - NetworkNodeShape, - NetworkEdgeShape, - egui_graphs::FruchtermanReingoldWithCenterGravityState, - egui_graphs::LayoutForceDirected, - >::new(&mut state.visualization.graph) - .with_interactions(&interactions) - .with_event_sink(&state.visualization.events_buffer) - .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_fit_to_screen_padding(FIT_TO_SCREEN_PADDING) // padding to avoid cropping with labels and overlays - .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 - if !state.visualization.layout_initialized { - let layout_state = egui_graphs::FruchtermanReingoldWithCenterGravityState { - base: egui_graphs::FruchtermanReingoldState { - is_running: false, - ..Default::default() - }, - extras: Default::default(), - }; - egui_graphs::set_layout_state(ui, layout_state, None); - state.visualization.layout_initialized = true; - } - - ui.add(&mut graph_view); - - // Handle screenshot export state machine - handle_screenshot_export(ui, &mut state.visualization); - - // Hide overlays during export to get clean screenshot - if state.visualization.export_state != ExportState::Idle { - return; - } - - // Render overlays - render_overlay_buttons(ui, &mut state.visualization); - render_overlay_stats(ui, &state.visualization); - render_overlay_node_legend(ui); - render_overlay_edge_legend(ui); - }); - - // Use panel rect directly - it's already in screen coordinates - // and represents the full panel area (ui.max_rect() excludes internal padding) - let panel_rect = inner_response.response.rect; - state.visualization.graph_rect = Some(panel_rect); -} From 3cd229bd9c2c9dc81e858e0b2db92f3f0abf38cd Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:05:48 +0100 Subject: [PATCH 110/127] refactor(gui): consolidate modules under run/ and centralize image assets --- crates/fosr-gui/src/about_tab.rs | 6 ++--- crates/fosr-gui/src/app.rs | 25 ++++--------------- crates/fosr-gui/src/lib.rs | 2 -- crates/fosr-gui/src/main.rs | 2 -- .../src/run/{ => generation}/bottom_panel.rs | 10 ++++---- .../fosr-gui/src/{ => run}/generation/core.rs | 0 .../fosr-gui/src/{ => run}/generation/mod.rs | 3 +++ .../options.rs} | 7 +++--- .../process.rs} | 4 +-- .../src/{ => run}/generation/state.rs | 0 .../src/{ => run}/generation/ui_components.rs | 0 .../src/{ => run}/generation/validation.rs | 0 .../src/{ => run}/generation/wireshark.rs | 0 .../src/run/{ => graph}/config_handling.rs | 2 +- .../src/run/{ => graph}/flow_processing.rs | 6 ++--- .../src/{visualization => run/graph}/mod.rs | 3 +++ .../graph}/node_modal.rs | 10 +++----- .../{visualization => run/graph}/overlays.rs | 23 +++++------------ .../graph}/screenshot.rs | 0 .../{visualization => run/graph}/shapes.rs | 6 +---- .../src/{visualization => run/graph}/state.rs | 0 .../{visualization => run/graph}/stream.rs | 0 .../src/{visualization => run/graph}/utils.rs | 0 .../src/run/{graph_view.rs => graph/view.rs} | 12 ++++----- crates/fosr-gui/src/run/mod.rs | 8 ++---- crates/fosr-gui/src/run/state.rs | 4 +-- crates/fosr-gui/src/run/tab.rs | 12 ++++----- crates/fosr-gui/src/shared/assets.rs | 13 ++++++++++ crates/fosr-gui/src/shared/mod.rs | 1 + 29 files changed, 69 insertions(+), 90 deletions(-) rename crates/fosr-gui/src/run/{ => generation}/bottom_panel.rs (97%) rename crates/fosr-gui/src/{ => run}/generation/core.rs (100%) rename crates/fosr-gui/src/{ => run}/generation/mod.rs (76%) rename crates/fosr-gui/src/run/{generation_options.rs => generation/options.rs} (98%) rename crates/fosr-gui/src/run/{generation_process.rs => generation/process.rs} (98%) rename crates/fosr-gui/src/{ => run}/generation/state.rs (100%) rename crates/fosr-gui/src/{ => run}/generation/ui_components.rs (100%) rename crates/fosr-gui/src/{ => run}/generation/validation.rs (100%) rename crates/fosr-gui/src/{ => run}/generation/wireshark.rs (100%) rename crates/fosr-gui/src/run/{ => graph}/config_handling.rs (98%) rename crates/fosr-gui/src/run/{ => graph}/flow_processing.rs (99%) rename crates/fosr-gui/src/{visualization => run/graph}/mod.rs (76%) rename crates/fosr-gui/src/{visualization => run/graph}/node_modal.rs (95%) rename crates/fosr-gui/src/{visualization => run/graph}/overlays.rs (93%) rename crates/fosr-gui/src/{visualization => run/graph}/screenshot.rs (100%) rename crates/fosr-gui/src/{visualization => run/graph}/shapes.rs (97%) rename crates/fosr-gui/src/{visualization => run/graph}/state.rs (100%) rename crates/fosr-gui/src/{visualization => run/graph}/stream.rs (100%) rename crates/fosr-gui/src/{visualization => run/graph}/utils.rs (100%) rename crates/fosr-gui/src/run/{graph_view.rs => graph/view.rs} (94%) create mode 100644 crates/fosr-gui/src/shared/assets.rs diff --git a/crates/fosr-gui/src/about_tab.rs b/crates/fosr-gui/src/about_tab.rs index df8bb22c..6c5ee01f 100644 --- a/crates/fosr-gui/src/about_tab.rs +++ b/crates/fosr-gui/src/about_tab.rs @@ -1,14 +1,12 @@ //! About tab: Fos-R information, usage guide, and repository links. +use crate::shared::assets::IMG_LOGO; use crate::shared::ui_constants::{LOGO_MAX_WIDTH, SPACING_LG, SPACING_SM, SPACING_XXL}; use eframe::egui; pub fn show_about_tab_content(ui: &mut egui::Ui) { ui.vertical_centered(|ui| { - ui.add( - egui::Image::new(egui::include_image!("../../../public/logo.png")) - .max_width(LOGO_MAX_WIDTH), - ); + ui.add(egui::Image::new(IMG_LOGO).max_width(LOGO_MAX_WIDTH)); }); ui.separator(); ui.add_space(SPACING_LG); diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index 6250501f..5252694b 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -4,6 +4,7 @@ use crate::about_tab::show_about_tab_content; use crate::config_templates::{all_templates, load_template_by_id}; use crate::configuration::tab::{ConfigurationTabState, show_configuration_tab_content}; use crate::run::{RunState, show_run_tab_content}; +use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_LOGO, IMG_SERVER}; use crate::shared::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; #[cfg(target_arch = "wasm32")] use crate::shared::configuration_file::poll_file_import; @@ -80,26 +81,10 @@ impl eframe::App for FosrApp { // 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(), - ); + let _ = IMG_SERVER.load(ctx, Default::default(), Default::default()); + let _ = IMG_COMPUTER.load(ctx, Default::default(), Default::default()); + let _ = IMG_INTERNET.load(ctx, Default::default(), Default::default()); + let _ = IMG_LOGO.load(ctx, Default::default(), Default::default()); self.images_preloaded = true; } diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index f074f749..50108c6b 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -6,11 +6,9 @@ mod about_tab; mod app; mod config_templates; mod configuration; -mod generation; mod run; mod shared; mod timepicker; -mod visualization; use app::FosrApp; use eframe::wasm_bindgen::JsCast; diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index ef2c88a5..a178e99f 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -5,11 +5,9 @@ mod about_tab; mod app; mod config_templates; mod configuration; -mod generation; mod run; mod shared; mod timepicker; -mod visualization; // Desktop: native compilation #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/fosr-gui/src/run/bottom_panel.rs b/crates/fosr-gui/src/run/generation/bottom_panel.rs similarity index 97% rename from crates/fosr-gui/src/run/bottom_panel.rs rename to crates/fosr-gui/src/run/generation/bottom_panel.rs index 77553904..b01674be 100644 --- a/crates/fosr-gui/src/run/bottom_panel.rs +++ b/crates/fosr-gui/src/run/generation/bottom_panel.rs @@ -3,12 +3,12 @@ //! Contains the action bar with Generate/Stop/Save buttons and //! the expandable generation options panel. -use super::generation_options::show_generation_options; -use super::generation_process::start_generation; -use super::state::RunState; -use crate::generation::validation::first_invalid_param; +use super::options::show_generation_options; +use super::process::start_generation; +use super::validation::first_invalid_param; #[cfg(not(target_arch = "wasm32"))] -use crate::generation::wireshark::open_in_wireshark; +use super::wireshark::open_in_wireshark; +use crate::run::state::RunState; use crate::shared::colors::{COLOR_ERROR, COLOR_STOP, COLOR_SUCCESS}; use crate::shared::configuration_file::ConfigurationFileState; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/fosr-gui/src/generation/core.rs b/crates/fosr-gui/src/run/generation/core.rs similarity index 100% rename from crates/fosr-gui/src/generation/core.rs rename to crates/fosr-gui/src/run/generation/core.rs diff --git a/crates/fosr-gui/src/generation/mod.rs b/crates/fosr-gui/src/run/generation/mod.rs similarity index 76% rename from crates/fosr-gui/src/generation/mod.rs rename to crates/fosr-gui/src/run/generation/mod.rs index 64002dd3..7b8001fc 100644 --- a/crates/fosr-gui/src/generation/mod.rs +++ b/crates/fosr-gui/src/run/generation/mod.rs @@ -1,6 +1,9 @@ //! PCAP generation module: core logic, state, validation, and Wireshark integration. +pub mod bottom_panel; pub mod core; +pub mod options; +pub mod process; pub mod state; pub mod ui_components; pub mod validation; diff --git a/crates/fosr-gui/src/run/generation_options.rs b/crates/fosr-gui/src/run/generation/options.rs similarity index 98% rename from crates/fosr-gui/src/run/generation_options.rs rename to crates/fosr-gui/src/run/generation/options.rs index 7379d7ce..9a101d2a 100644 --- a/crates/fosr-gui/src/run/generation_options.rs +++ b/crates/fosr-gui/src/run/generation/options.rs @@ -3,9 +3,10 @@ //! This module provides the UI for configuring PCAP generation parameters: //! duration, start time, timezone, seed, and advanced options. -use super::state::RunState; -use crate::generation::ui_components::{show_field_error, timezone_picker}; -use crate::generation::validation::{ +use crate::run::state::RunState; + +use super::ui_components::{show_field_error, timezone_picker}; +use super::validation::{ first_invalid_param, validate_duration, validate_optional_u64, validate_timezone, }; use crate::shared::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; diff --git a/crates/fosr-gui/src/run/generation_process.rs b/crates/fosr-gui/src/run/generation/process.rs similarity index 98% rename from crates/fosr-gui/src/run/generation_process.rs rename to crates/fosr-gui/src/run/generation/process.rs index 18142c7a..1a29455a 100644 --- a/crates/fosr-gui/src/run/generation_process.rs +++ b/crates/fosr-gui/src/run/generation/process.rs @@ -2,8 +2,8 @@ //! //! This module handles starting generation threads and polling for updates. -use super::state::RunState; -use crate::generation::core::generate; +use super::core::generate; +use crate::run::state::RunState; use crate::shared::configuration_file::ConfigurationFileState; use eframe::egui; use std::sync::Arc; diff --git a/crates/fosr-gui/src/generation/state.rs b/crates/fosr-gui/src/run/generation/state.rs similarity index 100% rename from crates/fosr-gui/src/generation/state.rs rename to crates/fosr-gui/src/run/generation/state.rs diff --git a/crates/fosr-gui/src/generation/ui_components.rs b/crates/fosr-gui/src/run/generation/ui_components.rs similarity index 100% rename from crates/fosr-gui/src/generation/ui_components.rs rename to crates/fosr-gui/src/run/generation/ui_components.rs diff --git a/crates/fosr-gui/src/generation/validation.rs b/crates/fosr-gui/src/run/generation/validation.rs similarity index 100% rename from crates/fosr-gui/src/generation/validation.rs rename to crates/fosr-gui/src/run/generation/validation.rs diff --git a/crates/fosr-gui/src/generation/wireshark.rs b/crates/fosr-gui/src/run/generation/wireshark.rs similarity index 100% rename from crates/fosr-gui/src/generation/wireshark.rs rename to crates/fosr-gui/src/run/generation/wireshark.rs diff --git a/crates/fosr-gui/src/run/config_handling.rs b/crates/fosr-gui/src/run/graph/config_handling.rs similarity index 98% rename from crates/fosr-gui/src/run/config_handling.rs rename to crates/fosr-gui/src/run/graph/config_handling.rs index 28882e73..fd9e2b92 100644 --- a/crates/fosr-gui/src/run/config_handling.rs +++ b/crates/fosr-gui/src/run/graph/config_handling.rs @@ -3,9 +3,9 @@ //! This module monitors the configuration file state and triggers //! visualization updates when the configuration changes. +use super::state::VisualizationState; use crate::shared::configuration_file::ConfigurationFileState; use crate::shared::ui_constants::DELAY_FRAMES_NORMAL; -use crate::visualization::state::VisualizationState; /// Handle configuration file changes and update visualization state. /// diff --git a/crates/fosr-gui/src/run/flow_processing.rs b/crates/fosr-gui/src/run/graph/flow_processing.rs similarity index 99% rename from crates/fosr-gui/src/run/flow_processing.rs rename to crates/fosr-gui/src/run/graph/flow_processing.rs index b8b62c81..ac047a48 100644 --- a/crates/fosr-gui/src/run/flow_processing.rs +++ b/crates/fosr-gui/src/run/graph/flow_processing.rs @@ -3,11 +3,11 @@ //! This module handles the processing of flow events from the streamer, //! updating active links, and synchronizing graph edge states. -use crate::shared::ui_constants::ACTIVE_LINK_BASE_TIMEOUT_MS; -use crate::visualization::state::{ +use super::state::{ ActiveLink, EdgeState, INTERNET_IP, LinkDirection, VisualizationState, }; -use crate::visualization::stream::FlowEvent; +use super::stream::FlowEvent; +use crate::shared::ui_constants::ACTIVE_LINK_BASE_TIMEOUT_MS; /// Process incoming flow events from the streamer. /// diff --git a/crates/fosr-gui/src/visualization/mod.rs b/crates/fosr-gui/src/run/graph/mod.rs similarity index 76% rename from crates/fosr-gui/src/visualization/mod.rs rename to crates/fosr-gui/src/run/graph/mod.rs index a85c6a1b..1b7d6710 100644 --- a/crates/fosr-gui/src/visualization/mod.rs +++ b/crates/fosr-gui/src/run/graph/mod.rs @@ -1,5 +1,7 @@ //! Network visualization module: real-time graph display with custom nodes and edges. +pub mod config_handling; +pub mod flow_processing; pub mod node_modal; pub mod overlays; pub mod screenshot; @@ -7,3 +9,4 @@ pub mod shapes; pub mod state; pub mod stream; pub mod utils; +pub mod view; diff --git a/crates/fosr-gui/src/visualization/node_modal.rs b/crates/fosr-gui/src/run/graph/node_modal.rs similarity index 95% rename from crates/fosr-gui/src/visualization/node_modal.rs rename to crates/fosr-gui/src/run/graph/node_modal.rs index 2cdd3fbc..9d14e2a7 100644 --- a/crates/fosr-gui/src/visualization/node_modal.rs +++ b/crates/fosr-gui/src/run/graph/node_modal.rs @@ -1,6 +1,7 @@ //! Node click handling and info/edit modal for the visualization graph. use super::state::{NodeType, VisualizationState}; +use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; use crate::shared::colors::{COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT}; use crate::shared::configuration_file::ConfigurationFileState; use crate::shared::ui_constants::{ @@ -72,12 +73,9 @@ pub fn render_node_info_modal( // Node type with icon ui.horizontal(|ui| { 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", - ), + NodeType::Server => (IMG_SERVER, "Server"), + NodeType::User => (IMG_COMPUTER, "User"), + NodeType::Internet => (IMG_INTERNET, "Internet"), }; let tint = if ui.style().visuals.dark_mode { COLOR_ICON_TINT_DARK diff --git a/crates/fosr-gui/src/visualization/overlays.rs b/crates/fosr-gui/src/run/graph/overlays.rs similarity index 93% rename from crates/fosr-gui/src/visualization/overlays.rs rename to crates/fosr-gui/src/run/graph/overlays.rs index 027c25f7..ad850aa5 100644 --- a/crates/fosr-gui/src/visualization/overlays.rs +++ b/crates/fosr-gui/src/run/graph/overlays.rs @@ -1,6 +1,7 @@ //! Graph overlay UI: control buttons, stats display, and legends for nodes/edges. use super::state::VisualizationState; +use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; use crate::shared::colors::{ COLOR_EDGE_INACTIVE, COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT, COLOR_PROTOCOL_DNS, COLOR_PROTOCOL_HTTP, COLOR_PROTOCOL_HTTPS, COLOR_PROTOCOL_OTHER, COLOR_PROTOCOL_SMTP, @@ -68,7 +69,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) egui_material_icons::icons::ICON_PLAY_ARROW, play_text ))) - .fill(accent); + .fill(accent); if ui.add(play_button).clicked() { state.user_has_started = true; // Pass the user config if loaded, otherwise None (uses default BN model) @@ -102,7 +103,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) "{} Stop", egui_material_icons::icons::ICON_STOP ))) - .fill(COLOR_STOP); + .fill(COLOR_STOP); if ui.add(stop_button).clicked() { state.stop_visualization(); } @@ -203,21 +204,9 @@ pub fn render_overlay_node_legend(ui: &mut egui::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"), - ); + legend_item_with_image(ui, "Server", IMG_SERVER); + legend_item_with_image(ui, "User", IMG_COMPUTER); + legend_item_with_image(ui, "Internet", IMG_INTERNET); }) .response .on_hover_text("Node types. Size reflects relative traffic activity."); diff --git a/crates/fosr-gui/src/visualization/screenshot.rs b/crates/fosr-gui/src/run/graph/screenshot.rs similarity index 100% rename from crates/fosr-gui/src/visualization/screenshot.rs rename to crates/fosr-gui/src/run/graph/screenshot.rs diff --git a/crates/fosr-gui/src/visualization/shapes.rs b/crates/fosr-gui/src/run/graph/shapes.rs similarity index 97% rename from crates/fosr-gui/src/visualization/shapes.rs rename to crates/fosr-gui/src/run/graph/shapes.rs index 12522d21..1135be1e 100644 --- a/crates/fosr-gui/src/visualization/shapes.rs +++ b/crates/fosr-gui/src/run/graph/shapes.rs @@ -1,6 +1,7 @@ //! Custom node and edge shapes with protocol colors, icons, and dynamic sizing. use super::state::{EdgeData, EdgeState, LinkDirection, NodeData, NodeType}; +use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; use crate::shared::colors::{ COLOR_EDGE_INACTIVE, COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT, COLOR_PROTOCOL_DNS, COLOR_PROTOCOL_HTTP, COLOR_PROTOCOL_HTTPS, COLOR_PROTOCOL_OTHER, COLOR_PROTOCOL_SMTP, @@ -14,11 +15,6 @@ use eframe::egui::{self, Color32, Pos2, Rect, Shape, TextureOptions, Vec2, load: use egui_graphs::{DisplayEdge, DisplayNode, DrawContext, Node, NodeProps}; use fosr_lib::L7Proto; -// 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"); - /// Custom node shape that displays hostname and IP, with icon based on node type #[derive(Clone)] pub struct NetworkNodeShape { diff --git a/crates/fosr-gui/src/visualization/state.rs b/crates/fosr-gui/src/run/graph/state.rs similarity index 100% rename from crates/fosr-gui/src/visualization/state.rs rename to crates/fosr-gui/src/run/graph/state.rs diff --git a/crates/fosr-gui/src/visualization/stream.rs b/crates/fosr-gui/src/run/graph/stream.rs similarity index 100% rename from crates/fosr-gui/src/visualization/stream.rs rename to crates/fosr-gui/src/run/graph/stream.rs diff --git a/crates/fosr-gui/src/visualization/utils.rs b/crates/fosr-gui/src/run/graph/utils.rs similarity index 100% rename from crates/fosr-gui/src/visualization/utils.rs rename to crates/fosr-gui/src/run/graph/utils.rs diff --git a/crates/fosr-gui/src/run/graph_view.rs b/crates/fosr-gui/src/run/graph/view.rs similarity index 94% rename from crates/fosr-gui/src/run/graph_view.rs rename to crates/fosr-gui/src/run/graph/view.rs index 7ca6a64a..4d340a61 100644 --- a/crates/fosr-gui/src/run/graph_view.rs +++ b/crates/fosr-gui/src/run/graph/view.rs @@ -3,15 +3,15 @@ //! Renders the network visualization with custom node/edge shapes, //! overlays, and screenshot export handling. -use super::state::RunState; -use crate::shared::ui_constants::FIT_TO_SCREEN_PADDING; -use crate::visualization::overlays::{ +use super::overlays::{ render_overlay_buttons, render_overlay_edge_legend, render_overlay_node_legend, render_overlay_stats, }; -use crate::visualization::screenshot::handle_screenshot_export; -use crate::visualization::shapes::{NetworkEdgeShape, NetworkNodeShape}; -use crate::visualization::state::{EdgeData, ExportState, NodeData}; +use super::screenshot::handle_screenshot_export; +use super::shapes::{NetworkEdgeShape, NetworkNodeShape}; +use super::state::{EdgeData, ExportState, NodeData}; +use crate::run::state::RunState; +use crate::shared::ui_constants::FIT_TO_SCREEN_PADDING; use eframe::egui; /// Render the graph view with overlays. diff --git a/crates/fosr-gui/src/run/mod.rs b/crates/fosr-gui/src/run/mod.rs index fd3c3872..12136379 100644 --- a/crates/fosr-gui/src/run/mod.rs +++ b/crates/fosr-gui/src/run/mod.rs @@ -1,11 +1,7 @@ //! Run module: combines live visualization and PCAP generation in a single tab. -mod bottom_panel; -mod config_handling; -mod flow_processing; -mod generation_options; -mod generation_process; -mod graph_view; +pub mod generation; +pub mod graph; mod state; mod tab; diff --git a/crates/fosr-gui/src/run/state.rs b/crates/fosr-gui/src/run/state.rs index e0d18f9e..1bcffe9e 100644 --- a/crates/fosr-gui/src/run/state.rs +++ b/crates/fosr-gui/src/run/state.rs @@ -1,7 +1,7 @@ //! Combined state for the Run tab: visualization and generation with panel toggle. -use crate::generation::state::GenerationState; -use crate::visualization::state::VisualizationState; +use super::generation::state::GenerationState; +use super::graph::state::VisualizationState; /// State for the unified Run tab. /// Combines visualization (live preview) and generation state. diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs index 2e3fe9ca..e434151a 100644 --- a/crates/fosr-gui/src/run/tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -1,13 +1,13 @@ //! Run tab: live network visualization combined with PCAP generation controls. -use super::bottom_panel::show_bottom_panel; -use super::config_handling::handle_config_changes; -use super::flow_processing::{process_flow_events, update_active_links, update_graph_edges}; -use super::generation_process::poll_generation_receivers; -use super::graph_view::render_graph_view; +use super::generation::bottom_panel::show_bottom_panel; +use super::generation::process::poll_generation_receivers; +use super::graph::config_handling::handle_config_changes; +use super::graph::flow_processing::{process_flow_events, update_active_links, update_graph_edges}; +use super::graph::node_modal::{process_graph_events, render_node_info_modal}; +use super::graph::view::render_graph_view; use super::state::RunState; use crate::shared::configuration_file::{ConfigurationFileState, load_config_file_contents}; -use crate::visualization::node_modal::{process_graph_events, render_node_info_modal}; use eframe::egui; pub fn show_run_tab_content( diff --git a/crates/fosr-gui/src/shared/assets.rs b/crates/fosr-gui/src/shared/assets.rs new file mode 100644 index 00000000..2f85ff23 --- /dev/null +++ b/crates/fosr-gui/src/shared/assets.rs @@ -0,0 +1,13 @@ +//! Embedded assets: images and icons used throughout the application. +//! +//! Centralizes all `include_image!` calls to avoid path issues when files move. + +use eframe::egui::{ImageSource, include_image}; + +// Node icons for graph visualization +pub const IMG_SERVER: ImageSource = include_image!("../../assets/server.png"); +pub const IMG_COMPUTER: ImageSource = include_image!("../../assets/computer.png"); +pub const IMG_INTERNET: ImageSource = include_image!("../../assets/internet.png"); + +// Application logo +pub const IMG_LOGO: ImageSource = include_image!("../../../../public/logo.png"); diff --git a/crates/fosr-gui/src/shared/mod.rs b/crates/fosr-gui/src/shared/mod.rs index 9980b9b8..a62df601 100644 --- a/crates/fosr-gui/src/shared/mod.rs +++ b/crates/fosr-gui/src/shared/mod.rs @@ -1,5 +1,6 @@ //! Shared utilities: config model, file I/O, and reusable UI components. +pub mod assets; pub mod colors; pub mod config_model; pub mod configuration_file; From c731e6f500432ad5419805ad66e763cc0aead85a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre-Fran=C3=A7ois=20Gimenez?= Date: Sun, 22 Mar 2026 17:45:28 +0100 Subject: [PATCH 111/127] New configuration format --- crates/fosr-lib/src/config.rs | 355 ++++++++++-------- .../fosr-lib/src/stage1/bayesian_networks.rs | 14 +- 2 files changed, 212 insertions(+), 157 deletions(-) diff --git a/crates/fosr-lib/src/config.rs b/crates/fosr-lib/src/config.rs index 92c23cf7..da4576bd 100644 --- a/crates/fosr-lib/src/config.rs +++ b/crates/fosr-lib/src/config.rs @@ -5,6 +5,7 @@ use serde::Deserialize; use std::collections::HashMap; use std::collections::HashSet; use std::net::Ipv4Addr; +use rand::prelude::*; #[derive(Debug)] /// The configuration file of the network and the hosts @@ -13,13 +14,13 @@ pub struct Configuration { /// The metadata of the configuration pub metadata: Metadata, /// The list of hosts - pub hosts: Vec, + // pub hosts: Vec, /// A hashmap that maps an IP to a MAC address (if it is defined in the config file) - pub mac_addr_map: HashMap, + // pub mac_addr_map: HashMap, /// A hashmap that maps an IP to an OS (if it is defined in the config file) pub os_map: HashMap, - /// The usages of each IP address - pub usages_map: HashMap, + // /// The usages of each IP address + // pub usages_map: HashMap, /// The list of "users" IPs pub users: Vec, /// The list of "servers" IPs @@ -36,14 +37,25 @@ pub struct Configuration { #[serde(deny_unknown_fields)] struct ConfigurationYaml { pub metadata: Metadata, - pub hosts: Vec, + pub networks: Vec, + pub internet: Vec +} + +#[derive(Deserialize, Debug)] +struct Network { + pub subnet: Ipv4Addr, + pub mask: u8, + pub name: String, + pub hosts: Vec } impl From for Configuration { fn from(c: ConfigurationYaml) -> Self { let users: Vec = c - .hosts + .networks .iter() + .map(|n| &n.hosts) + .flatten() .filter_map(|h| match h.host_type { HostType::User => Some(h.get_ip_addr()), HostType::Server => None, @@ -51,8 +63,10 @@ impl From for Configuration { .flatten() .collect(); let servers: Vec = c - .hosts + .networks .iter() + .map(|n| &n.hosts) + .flatten() .filter_map(|h| match h.host_type { HostType::Server => Some(h.get_ip_addr()), HostType::User => None, @@ -60,24 +74,34 @@ impl From for Configuration { .flatten() .collect(); let mut os_map: HashMap = HashMap::new(); - let mut usages_map: HashMap = HashMap::new(); - for host in c.hosts.iter() { + // let mut usages_map: HashMap = HashMap::new(); + for network in c.networks.iter() { + for host in network.hosts.iter() { + for interface in host.interfaces.iter() { + os_map.insert(interface.ip_addr, host.os); + // usages_map.insert(interface.ip_addr, host.usage); + } + } + } + for host in c.internet.iter() { for interface in host.interfaces.iter() { os_map.insert(interface.ip_addr, host.os); - usages_map.insert(interface.ip_addr, host.usage); + // usages_map.insert(interface.ip_addr, host.usage); } } - let mut mac_addr_map: HashMap = HashMap::new(); + + // let mut mac_addr_map: HashMap = HashMap::new(); let mut services: HashSet = HashSet::new(); let mut servers_per_service: HashMap> = HashMap::new(); let mut users_per_service: HashMap> = HashMap::new(); let mut open_ports: HashMap<(Ipv4Addr, L7Proto), u16> = HashMap::new(); - for interface in c.hosts.iter().flat_map(|h| &h.interfaces) { - if let Some(mac_addr) = interface.mac_addr { - mac_addr_map.insert(interface.ip_addr, mac_addr); - } + let all_hosts = c.internet.iter().chain(c.networks.iter().map(|n| n.hosts.iter()).flatten()); + for interface in all_hosts.flat_map(|h| &h.interfaces) { + // if let Some(mac_addr) = interface.mac_addr { + // mac_addr_map.insert(interface.ip_addr, mac_addr); + // } for k in interface.open_ports.keys() { open_ports.insert( (interface.ip_addr, *k), @@ -90,31 +114,35 @@ impl From for Configuration { v.push(interface.ip_addr); } } - for host in c.hosts.iter() { - if let Some(client) = &host.client { - // if a list is defined, then this host will only use these services - for s in client { - if services.contains(s) { + + let all_hosts = c.internet.iter().chain(c.networks.iter().map(|n| n.hosts.iter()).flatten()); + for host in all_hosts { + for i in &host.interfaces { + if let Some(client) = &i.uses { + // if a list is defined, then this host will only use these services + for s in client { + if services.contains(s) { + for interface in host.interfaces.iter() { + users_per_service + .entry(*s) + .or_default() + .push(interface.ip_addr) + } + } else { + log::warn!( + "There is a client of {s:?}, but that service is not proposed by any server" + ); + } + } + } else { + // otherwise, use all available services + for s in services.iter() { for interface in host.interfaces.iter() { users_per_service .entry(*s) .or_default() .push(interface.ip_addr) } - } else { - log::warn!( - "There is a client of {s:?}, but that service is not proposed by any server" - ); - } - } - } else { - // otherwise, use all available services - for s in services.iter() { - for interface in host.interfaces.iter() { - users_per_service - .entry(*s) - .or_default() - .push(interface.ip_addr) } } } @@ -127,10 +155,10 @@ impl From for Configuration { Configuration { metadata: c.metadata, - hosts: c.hosts, + // hosts: c.hosts, os_map, - usages_map, - mac_addr_map, + // usages_map, + // mac_addr_map, users, servers, services: services.into_iter().collect(), @@ -193,9 +221,9 @@ pub struct Host { pub hostname: Option, /// Its OS pub os: OS, - /// Its usage. 1 is standard, less than 1 is less usage than standard, more than 1 is more usage than standrad - pub usage: f64, - client: Option>, // we keep the option here, because there is a difference + // /// Its usage. 1 is standard, less than 1 is less usage than standard, more than 1 is more usage than standrad + // pub usage: f64, + // 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) /// The type of host (server or user) @@ -216,8 +244,8 @@ impl Host { struct HostYaml { hostname: Option, os: Option, - usage: Option, - client: Option>, + // usage: Option, + // client: Option>, #[serde(rename = "type")] host_type: Option, interfaces: Vec, @@ -236,10 +264,10 @@ impl From for Host { Host { hostname: h.hostname, os: h.os.unwrap_or(OS::Linux), - usage: h.usage.unwrap_or(1.0), + // usage: h.usage.unwrap_or(1.0), host_type, interfaces: h.interfaces, - client: h.client, + // client: h.client, } } } @@ -257,6 +285,8 @@ pub struct Interface { pub ip_addr: Ipv4Addr, /// The open ports of services, if they are not the default one pub open_ports: HashMap, + /// The services is uses (may be empty) + pub uses: Option>, } #[derive(Deserialize, Debug)] @@ -264,6 +294,7 @@ pub struct Interface { struct InterfaceYaml { mac_addr: Option, services: Option>, + uses: Option>, ip_addr: String, } @@ -285,12 +316,32 @@ impl TryFrom for Interface { } services.push(service); } + let uses = match i.uses { + None => None, + Some(l) => { + let mut uses = vec![]; + for s in l { + let v: Vec = s.as_str().split(':').map(|s| s.to_string()).collect(); + assert!(!v.is_empty() && v.len() <= 2); + let service: L7Proto = v[0].clone().try_into()?; + uses.push(service); + } + Some(uses) + } + }; + let mut rng = rand::rng(); + let ip_addr = match i.ip_addr.as_str() { + "auto" => Ipv4Addr::new(0,0,0,0), + "internet" => Ipv4Addr::new(rng.random::(),rng.random::(),rng.random::(),rng.random::()), // TODO: ne pas générer complètement au hasard pour éviter les collisions et permettre d’utiliser une seed + _ => i.ip_addr.parse().expect("Cannot parse IP address") + }; Ok(Interface { + uses, mac_addr: i .mac_addr .map(|s| s.parse().expect("Cannot parse MAC address")), - ip_addr: i.ip_addr.parse().expect("Cannot parse IP address"), + ip_addr, services, open_ports, }) @@ -308,114 +359,114 @@ pub fn import_config(config_string: &str) -> Configuration { config } -#[cfg(test)] -mod tests { - use super::*; +// #[cfg(test)] +// mod tests { +// use super::*; - #[test] - fn test_config_simple() { - let config = import_config( - r#" -metadata: - title: Sample configuration -hosts: - - interfaces: - - services: - - https - - ssh - ip_addr: 192.168.0.8 - - interfaces: - - ip_addr: 192.168.0.9 -"#, - ); - // TODO tester la config chargée - } +// #[test] +// fn test_config_simple() { +// let config = import_config( +// r#" +// metadata: +// title: Sample configuration +// hosts: +// - interfaces: +// - services: +// - https +// - ssh +// ip_addr: 192.168.0.8 +// - interfaces: +// - ip_addr: 192.168.0.9 +// "#, +// ); +// // TODO tester la config chargée +// } - #[test] - fn test_config_complex() { - let config = import_config( - r#" -metadata: - title: Sample configuration # Mandatory. The title of the configuration file. - desc: A sample configuration file to show all the different available fields # Optional. A description of the configuration file. - author: Jane Doe # Optional. Author of the file. - date: 2025/11/05 # Optional. Last modification date. - version: 0.1.0 # Optional. The version number of this configuration file. Format is free. - format: 1 # Reserved for now. The version will be bumped when the format changes. +// #[test] +// fn test_config_complex() { +// let config = import_config( +// r#" +// metadata: +// title: Sample configuration # Mandatory. The title of the configuration file. +// desc: A sample configuration file to show all the different available fields # Optional. A description of the configuration file. +// author: Jane Doe # Optional. Author of the file. +// date: 2025/11/05 # Optional. Last modification date. +// version: 0.1.0 # Optional. The version number of this configuration file. Format is free. +// format: 1 # Reserved for now. The version will be bumped when the format changes. -hosts: - - hostname: host1 # Optional. The hostname of the host. - os: Linux # Optional (default value: Linux). The OS of the host - usage: 0.8 # 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 - type: server # Optional (default value: "server" if there is at least one service, "user" otherwise). Whether this host is used by a user and is a server. Can be either "server" or "user" - client: # Optional (default value: all available services if type is "user", none otherwise). Specify what services the host is a client of. - - http - - https - - ssh - interfaces: - - mac_addr: 00:14:2A:3F:47:D8 # Optional. The MAC address of that interface - services: # Optional (default value: empty list). The list of available services - - http # an HTTP server - - https # an HTTPS server - - ssh # an SSH server - ip_addr: 192.168.0.8 # Mandatory. The IP address of this interface. - - ip_addr: 192.168.0.9 # This host has another interface that does not provide any service - - interfaces: - - ip_addr: 192.168.0.11 # Another host with a single interface -"#, - ); - println!("{config:?}"); - } +// hosts: +// - hostname: host1 # Optional. The hostname of the host. +// os: Linux # Optional (default value: Linux). The OS of the host +// usage: 0.8 # 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 +// type: server # Optional (default value: "server" if there is at least one service, "user" otherwise). Whether this host is used by a user and is a server. Can be either "server" or "user" +// client: # Optional (default value: all available services if type is "user", none otherwise). Specify what services the host is a client of. +// - http +// - https +// - ssh +// interfaces: +// - mac_addr: 00:14:2A:3F:47:D8 # Optional. The MAC address of that interface +// services: # Optional (default value: empty list). The list of available services +// - http # an HTTP server +// - https # an HTTPS server +// - ssh # an SSH server +// ip_addr: 192.168.0.8 # Mandatory. The IP address of this interface. +// - ip_addr: 192.168.0.9 # This host has another interface that does not provide any service +// - interfaces: +// - ip_addr: 192.168.0.11 # Another host with a single interface +// "#, +// ); +// println!("{config:?}"); +// } - #[test] - fn test_config_json() { - let config = import_config( - r#" -{ - "metadata": { - "title": "Sample JSON configuration", - "desc": "A sample configuration file to show all the different available fields", - "author": "Jane Doe", - "date": "2025/11/05", - "version": "0.1.0", - "format": 1 - }, - "hosts": [ - { - "hostname": "host1", - "os": "Linux", - "usage": 0.8, - "type": "server", - "client": [ - "http", - "https", - "ssh" - ], - "interfaces": [ - { - "mac_addr": "00:14:2A:3F:47:D8", - "services": [ - "http", - "https", - "ssh" - ], - "ip_addr": "192.168.0.8" - }, - { - "ip_addr": "192.168.0.9" - } - ] - }, - { - "interfaces": [ - { - "ip_addr": "192.168.0.11" - } - ] - } - ] -}"#, - ); - println!("{config:?}"); - } -} +// #[test] +// fn test_config_json() { +// let config = import_config( +// r#" +// { +// "metadata": { +// "title": "Sample JSON configuration", +// "desc": "A sample configuration file to show all the different available fields", +// "author": "Jane Doe", +// "date": "2025/11/05", +// "version": "0.1.0", +// "format": 1 +// }, +// "hosts": [ +// { +// "hostname": "host1", +// "os": "Linux", +// "usage": 0.8, +// "type": "server", +// "client": [ +// "http", +// "https", +// "ssh" +// ], +// "interfaces": [ +// { +// "mac_addr": "00:14:2A:3F:47:D8", +// "services": [ +// "http", +// "https", +// "ssh" +// ], +// "ip_addr": "192.168.0.8" +// }, +// { +// "ip_addr": "192.168.0.9" +// } +// ] +// }, +// { +// "interfaces": [ +// { +// "ip_addr": "192.168.0.11" +// } +// ] +// } +// ] +// }"#, +// ); +// println!("{config:?}"); +// } +// } diff --git a/crates/fosr-lib/src/stage1/bayesian_networks.rs b/crates/fosr-lib/src/stage1/bayesian_networks.rs index ddc9cc92..fa529138 100644 --- a/crates/fosr-lib/src/stage1/bayesian_networks.rs +++ b/crates/fosr-lib/src/stage1/bayesian_networks.rs @@ -568,8 +568,9 @@ impl BayesianModel { .into_iter() .map(|ip| { if proto_users.contains(&ip) { + 1f64 // this IP can be sampled - *config.usages_map.get(&ip).unwrap() + // *config.usages_map.get(&ip).unwrap() } else { // this IP cannot be sampled 0.0f64 @@ -586,15 +587,16 @@ impl BayesianModel { .into_iter() .map(|ip| { if proto_servers.contains(&ip) { + 1f64 // this IP can be sampled - *config.usages_map.get(&ip).unwrap() + // *config.usages_map.get(&ip).unwrap() } else { // this IP cannot be sampled 0.0f64 } }) .chain(iter::once(0.0f64)); // no internet - cpt.push(Some(WeightedIndex::new(proba).expect("Cannot create the probability distribution of SrcIp for {p} and {role}"))); + cpt.push(Some(WeightedIndex::new(proba).expect(&format!("Cannot create the probability distribution of SrcIp for {p:?} and {role:?}")))); } IpRole::Internet => { let mut proba: Vec = vec![]; @@ -644,7 +646,8 @@ impl BayesianModel { .map(|ip| { if proto_users.contains(&ip) { // this IP can be sampled - *config.usages_map.get(&ip).unwrap() + 1f64 + // *config.usages_map.get(&ip).unwrap() } else { // this IP cannot be sampled 0.0f64 @@ -661,8 +664,9 @@ impl BayesianModel { .into_iter() .map(|ip| { if proto_servers.contains(&ip) { + 1f64 // this IP can be sampled - *config.usages_map.get(&ip).unwrap() + // *config.usages_map.get(&ip).unwrap() } else { // this IP cannot be sampled 0.0f64 From b2eedb2a50c9533c70f019d034b0dc4fb7dfe8b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre-Fran=C3=A7ois=20Gimenez?= Date: Sun, 22 Mar 2026 17:48:12 +0100 Subject: [PATCH 112/127] Sample config with new format --- sample_config.yaml | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 sample_config.yaml diff --git a/sample_config.yaml b/sample_config.yaml new file mode 100644 index 00000000..facb0397 --- /dev/null +++ b/sample_config.yaml @@ -0,0 +1,44 @@ +metadata: + title: Sample configuration # Mandatory. The title of the configuration file. + desc: A sample configuration file to show all the different available fields # Optional. A description of the configuration file. + author: Jane Doe # Optional. Author of the file. + date: 2025/11/05 # Optional. Last modification date. + version: 0.1.0 # Optional. The version number of this configuration file. Format is free. + format: 1 # Reserved for now. The version will be bumped when the format changes. + +networks: + - subnet: 192.168.0.0 # Mandatory. The subnet of that network. + mask: 24 # Mandatory. The mask of the subnet. + name: "Subnet1" # Optional. The name of the subnet. + hosts: + - hostname: host1 # Optional. The hostname of the host. + os: Linux # Optional (default value: Linux). The OS of the host. + # usage: 0.8 # 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 + type: server # Optional (default value: "server" if there is at least one service, "user" otherwise). Whether this host is used by a user and is a server. Can be either "server" or "user". + interfaces: + - mac_addr: 00:14:2A:3F:47:D8 # Optional. The MAC address of that interface. + services: # Optional (default value: empty list). The list of available services. + - https:8080 # an HTTPS server. + - ssh # an SSH server. + - dns # a DNS server. + ip_addr: 192.168.0.8 # Mandatory. The IP address of this interface. Can be "auto" for automatic attribution. + - interfaces: + - ip_addr: auto # This host has an interface in the 192.168.0.0/24 subnet that does not provide any service. + - ip_addr: 192.168.1.0 # It also has an interface in another subnet (without any service) + uses: # Optional (default value: all available services if type is "user", none otherwise). Specify what services the host is a client of. The protocols must be written in lowercase. + - https + - ssh + - subnet: 192.168.1.0 # A second subnet. + mask: 24 + name: "Subnet2" + hosts: + - interfaces: + - ip_addr: auto # This machine has an IP in the 192.168.1.0/24 subnet. + +internet: # An optional description of Internet services + - hostname: "Google DNS" # Internet hosts follow the same structure as local hosts + type: server + interfaces: + - ip_addr: 8.8.8.8 + services: + - dns From 9a825b8bd722186f22c6c1570f5c7ba34deb5c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre-Fran=C3=A7ois=20Gimenez?= Date: Sun, 22 Mar 2026 17:53:47 +0100 Subject: [PATCH 113/127] Fix configuration creation --- crates/fosr-lib/src/config.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/fosr-lib/src/config.rs b/crates/fosr-lib/src/config.rs index da4576bd..3531834a 100644 --- a/crates/fosr-lib/src/config.rs +++ b/crates/fosr-lib/src/config.rs @@ -14,7 +14,7 @@ pub struct Configuration { /// The metadata of the configuration pub metadata: Metadata, /// The list of hosts - // pub hosts: Vec, + pub hosts: Vec, /// A hashmap that maps an IP to a MAC address (if it is defined in the config file) // pub mac_addr_map: HashMap, /// A hashmap that maps an IP to an OS (if it is defined in the config file) @@ -153,9 +153,11 @@ impl From for Configuration { assert!(users_per_service.contains_key(service)); } + let hosts = c.internet.into_iter().chain(c.networks.into_iter().map(|n| n.hosts.into_iter()).flatten()).collect(); + Configuration { metadata: c.metadata, - // hosts: c.hosts, + hosts: hosts, os_map, // usages_map, // mac_addr_map, From a3597797aafb67235c571ae6c419dec17bfd1b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre-Fran=C3=A7ois=20Gimenez?= Date: Sun, 22 Mar 2026 18:17:49 +0100 Subject: [PATCH 114/127] Give access to the networks description --- crates/fosr-gui/src/run/graph/state.rs | 2 +- crates/fosr-lib/src/config.rs | 20 +++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/fosr-gui/src/run/graph/state.rs b/crates/fosr-gui/src/run/graph/state.rs index b6a153ec..c2ff50bc 100644 --- a/crates/fosr-gui/src/run/graph/state.rs +++ b/crates/fosr-gui/src/run/graph/state.rs @@ -282,7 +282,7 @@ impl VisualizationState { let mut node_to_host: HashMap = HashMap::new(); // Add one node per host (with all its IPs) - for (host_idx, host) in config.hosts.iter().enumerate() { + for (host_idx, host) in config.get_hosts().iter().enumerate() { let all_ips: Vec = host.interfaces.iter().map(|i| i.ip_addr).collect(); let node_data = NodeData { diff --git a/crates/fosr-lib/src/config.rs b/crates/fosr-lib/src/config.rs index 3531834a..7c7edf29 100644 --- a/crates/fosr-lib/src/config.rs +++ b/crates/fosr-lib/src/config.rs @@ -13,8 +13,10 @@ pub struct Configuration { // TODO: faire du tri dans ce qui n’est pas utile /// The metadata of the configuration pub metadata: Metadata, - /// The list of hosts - pub hosts: Vec, + // /// The list of hosts + // pub hosts: Vec, + /// The list of networks + pub networks: Vec, /// A hashmap that maps an IP to a MAC address (if it is defined in the config file) // pub mac_addr_map: HashMap, /// A hashmap that maps an IP to an OS (if it is defined in the config file) @@ -42,7 +44,7 @@ struct ConfigurationYaml { } #[derive(Deserialize, Debug)] -struct Network { +pub struct Network { pub subnet: Ipv4Addr, pub mask: u8, pub name: String, @@ -153,11 +155,14 @@ impl From for Configuration { assert!(users_per_service.contains_key(service)); } - let hosts = c.internet.into_iter().chain(c.networks.into_iter().map(|n| n.hosts.into_iter()).flatten()).collect(); + // let hosts = c.internet.into_iter().chain(c.networks.into_iter().map(|n| n.hosts.into_iter()).flatten()).collect(); + + let mut networks = c.networks; + networks.push(Network { subnet: Ipv4Addr::new(0,0,0,0), mask: 0, name: "Internet".to_string(), hosts: c.internet }); Configuration { metadata: c.metadata, - hosts: hosts, + networks, os_map, // usages_map, // mac_addr_map, @@ -172,6 +177,11 @@ impl From for Configuration { } impl Configuration { + pub fn get_hosts(&self) -> Vec<&Host> { + let out: Vec<&Host> = self.networks.iter().map(|n| n.hosts.iter()).flatten().collect(); + out + } + /// Get the list of servers that provide a service pub fn get_servers_per_service(&self, service: &L7Proto) -> Vec { self.servers_per_service From 6703bbe4260c9cfaf09dc86da69dc44bfdb9615b Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:38:14 +0100 Subject: [PATCH 115/127] refactor(gui): split app.rs into app module + make zoom buttons closer to label --- crates/fosr-gui/src/app.rs | 411 ------------------ crates/fosr-gui/src/app/close_dialog.rs | 34 ++ crates/fosr-gui/src/app/mod.rs | 150 +++++++ crates/fosr-gui/src/app/startup_modal.rs | 137 ++++++ crates/fosr-gui/src/app/top_bar.rs | 174 ++++++++ .../src/run/generation/bottom_panel.rs | 4 +- crates/fosr-gui/src/run/generation/options.rs | 4 +- crates/fosr-gui/src/run/generation/process.rs | 6 +- .../fosr-gui/src/run/graph/flow_processing.rs | 4 +- crates/fosr-gui/src/run/graph/overlays.rs | 4 +- crates/fosr-gui/src/run/graph/view.rs | 4 +- crates/fosr-gui/src/run/mod.rs | 2 +- crates/fosr-gui/src/run/state.rs | 4 +- crates/fosr-gui/src/run/tab.rs | 4 +- 14 files changed, 512 insertions(+), 430 deletions(-) delete mode 100644 crates/fosr-gui/src/app.rs create mode 100644 crates/fosr-gui/src/app/close_dialog.rs create mode 100644 crates/fosr-gui/src/app/mod.rs create mode 100644 crates/fosr-gui/src/app/startup_modal.rs create mode 100644 crates/fosr-gui/src/app/top_bar.rs diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs deleted file mode 100644 index 5252694b..00000000 --- a/crates/fosr-gui/src/app.rs +++ /dev/null @@ -1,411 +0,0 @@ -//! Main Fos-R application: tab navigation, startup modal, and app state. - -use crate::about_tab::show_about_tab_content; -use crate::config_templates::{all_templates, load_template_by_id}; -use crate::configuration::tab::{ConfigurationTabState, show_configuration_tab_content}; -use crate::run::{RunState, show_run_tab_content}; -use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_LOGO, IMG_SERVER}; -use crate::shared::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; -#[cfg(target_arch = "wasm32")] -use crate::shared::configuration_file::poll_file_import; -use crate::shared::configuration_file::{ - ConfigurationFileState, StartupModalState, trigger_file_import, -}; -#[cfg(not(target_arch = "wasm32"))] -use crate::shared::ui_constants::MODAL_WIDTH_SM; -use crate::shared::ui_constants::{ - BUTTON_PADDING, ICON_SIZE_LG, MODAL_WIDTH_MD, PANEL_INNER_MARGIN, SPACING_LG, SPACING_SM, - SPACING_XL, SPACING_XS, STARTUP_CARD_HEIGHT, STARTUP_COLUMNS_INITIAL, - STARTUP_COLUMNS_TEMPLATES, TEXT_SIZE_DEFAULT, TEXT_SIZE_LG, TEXT_SIZE_SM, TOOLTIP_DELAY, - ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP, -}; -use eframe::egui; -use eframe::egui::global_theme_preference_switch; - -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -enum CurrentTab { - Configuration, - Run, - About, -} - -impl Default for CurrentTab { - fn default() -> Self { - CurrentTab::Run - } -} - -#[derive(Default)] -pub struct FosrApp { - current_tab: CurrentTab, - style_initialized: bool, - images_preloaded: bool, - zoom_factor: f32, - configuration_file_state: ConfigurationFileState, - configuration_tab_state: ConfigurationTabState, - run_state: RunState, - /// Whether to show the close confirmation dialog - #[cfg(not(target_arch = "wasm32"))] - show_close_confirmation: bool, - /// Whether the user has confirmed they want to close - #[cfg(not(target_arch = "wasm32"))] - allowed_to_close: bool, -} - -impl eframe::App for FosrApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Set default zoom once - if !self.style_initialized { - self.zoom_factor = ZOOM_DEFAULT; - ctx.options_mut(|option| option.zoom_factor = self.zoom_factor); - ctx.style_mut(|s| s.interaction.tooltip_delay = TOOLTIP_DELAY); - - // On web, use dark theme to match with the Fos-R website's theme - #[cfg(target_arch = "wasm32")] - ctx.set_theme(egui::Theme::Dark); - - self.style_initialized = true; - } - - // Clamp zoom to min/max (prevents Ctrl+/- from exceeding limits) - let current_zoom = ctx.zoom_factor(); - if current_zoom < ZOOM_MIN || current_zoom > ZOOM_MAX { - let clamped_zoom = current_zoom.clamp(ZOOM_MIN, ZOOM_MAX); - ctx.set_zoom_factor(clamped_zoom); - self.zoom_factor = clamped_zoom; - } - - // Set the image loaders - // 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 _ = IMG_SERVER.load(ctx, Default::default(), Default::default()); - let _ = IMG_COMPUTER.load(ctx, Default::default(), Default::default()); - let _ = IMG_INTERNET.load(ctx, Default::default(), Default::default()); - let _ = IMG_LOGO.load(ctx, Default::default(), Default::default()); - self.images_preloaded = true; - } - - // Handle close confirmation if there are active Wireshark sessions - #[cfg(not(target_arch = "wasm32"))] - { - let has_active_sessions = self - .run_state - .generation - .temp_pcap_files - .iter() - .any(|(handle, _)| !handle.is_finished()); - - if ctx.input(|i| i.viewport().close_requested()) { - if self.allowed_to_close { - // Do nothing, let the app close - } else if has_active_sessions { - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - self.show_close_confirmation = true; - } - } - } - - // Startup modal: choose configuration source - if !self.configuration_file_state.config_chosen { - // Render empty CentralPanel for background, then modal on top - egui::CentralPanel::default().show(ctx, |_ui| {}); - render_startup_modal(ctx, &mut self.configuration_file_state); - return; - } - - // Close confirmation dialog - #[cfg(not(target_arch = "wasm32"))] - if self.show_close_confirmation { - egui::Modal::new(egui::Id::new("close_confirmation_modal")).show(ctx, |ui| { - ui.set_width(MODAL_WIDTH_SM); - ui.heading("Confirm Exit"); - ui.add_space(SPACING_LG); - ui.label("You have Wireshark session(s) open with temporary PCAP files."); - ui.label("Closing will delete these files."); - ui.add_space(SPACING_XL); - ui.horizontal(|ui| { - if ui.button("Cancel").clicked() { - self.show_close_confirmation = false; - } - if ui.button("Exit").clicked() { - self.show_close_confirmation = false; - self.allowed_to_close = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - }); - }); - } - - // The Top Panel is logically at the top of the window. - egui::TopBottomPanel::top("top_panel") - .frame(egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin::symmetric(PANEL_INNER_MARGIN.0, PANEL_INNER_MARGIN.1))) - .show(ctx, |ui| { - // Add a Menu Bar to host the tabs buttons - egui::MenuBar::new().ui(ui, |ui| { - ui.spacing_mut().button_padding = egui::vec2(BUTTON_PADDING.0, BUTTON_PADDING.1); - let tab_text_size = TEXT_SIZE_DEFAULT; - - let has_errors = self.configuration_file_state.has_errors; - - // Run tab (combines Live Preview + Generation) - let run_button = egui::Button::new( - egui::RichText::new("Run").size(tab_text_size), - ) - .selected(self.current_tab == CurrentTab::Run); - - let response = ui.add_enabled(!has_errors, run_button); - - let response = if has_errors { - response.on_disabled_hover_text("Configuration is invalid. Fix errors in the Configuration tab to enable Run.") - } else { - response.on_hover_text("Live preview and PCAP generation from the current configuration.") - }; - - if !has_errors && response.clicked() { - self.current_tab = CurrentTab::Run; - } - - // Configuration tab - let label_text = if self.configuration_file_state.has_errors { - egui::RichText::new("⚠ Configuration").color(COLOR_ERROR).size(tab_text_size) - } else { - egui::RichText::new("Configuration").size(tab_text_size) - }; - if ui - .add(egui::Button::new(label_text) - .selected(self.current_tab == CurrentTab::Configuration)) - .on_hover_text("Edit the network configuration: hosts, interfaces, and services.") - .clicked() - { - self.current_tab = CurrentTab::Configuration; - }; - - // About tab - if ui - .add(egui::Button::new( - egui::RichText::new("About").size(tab_text_size), - ).selected(self.current_tab == CurrentTab::About)) - .on_hover_text("About Fos-R and its authors.") - .clicked() - { - 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 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(); - } - } - } - } - } - - #[cfg(not(target_arch = "wasm32"))] - ui.add_space(SPACING_SM); - - // Show the theme switch - global_theme_preference_switch(ui); - - // Zoom controls - if ui - .button(egui_material_icons::icons::ICON_ADD) - .on_hover_text("Zoom in") - .clicked() - { - let current_zoom = ctx.zoom_factor(); - let new_zoom = (current_zoom + ZOOM_STEP).min(ZOOM_MAX); - ctx.set_zoom_factor(new_zoom); - self.zoom_factor = new_zoom; - } - ui.label(format!("{:.0}%", ctx.zoom_factor() * 100.0)); - if ui - .button(egui_material_icons::icons::ICON_REMOVE) - .on_hover_text("Zoom out") - .clicked() - { - let current_zoom = ctx.zoom_factor(); - let new_zoom = (current_zoom - ZOOM_STEP).max(ZOOM_MIN); - ctx.set_zoom_factor(new_zoom); - self.zoom_factor = new_zoom; - } - }); - }); - }); - - // 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 - // Note: Run tab doesn't use ScrollArea as it has its own layout - match self.current_tab { - CurrentTab::Run => { - show_run_tab_content( - ui, - &mut self.run_state, - &mut self.configuration_file_state, - ); - } - CurrentTab::Configuration => { - // Wrap in ScrollArea for vertical scrolling - egui::ScrollArea::vertical().show(ui, |ui| { - show_configuration_tab_content( - ui, - &mut self.configuration_tab_state, - &mut self.configuration_file_state, - ); - }); - } - CurrentTab::About => { - // Wrap in ScrollArea for vertical scrolling - egui::ScrollArea::vertical().show(ui, |ui| { - show_about_tab_content(ui); - }); - } - } - }); - } -} - -/// 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(), STARTUP_CARD_HEIGHT); - 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| { - // 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| { - ui.add_space(SPACING_LG); - ui.label(egui::RichText::new(icon).size(ICON_SIZE_LG)); - ui.add_space(SPACING_SM); - ui.strong(egui::RichText::new(title).size(TEXT_SIZE_LG)); - ui.add_space(SPACING_XS); - ui.label( - egui::RichText::new(description) - .size(TEXT_SIZE_SM) - .color(COLOR_TEXT_MUTED), - ); - ui.add_space(SPACING_LG); - }); - }); - }); - - if response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - - response.clicked() -} - -fn render_startup_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { - match state.modal_state { - StartupModalState::Initial => render_initial_modal(ctx, state), - StartupModalState::TemplateSelection => render_template_selection_modal(ctx, state), - } -} - -fn render_initial_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { - // Use the same modal ID as template selection to avoid flicker when transitioning - egui::Modal::new(egui::Id::new("startup_modal")).show(ctx, |ui| { - ui.set_width(MODAL_WIDTH_MD); - ui.heading("Welcome to Fos-R"); - ui.add_space(SPACING_SM); - ui.label("Choose a configuration to get started:"); - ui.add_space(SPACING_XL); - - ui.columns(STARTUP_COLUMNS_INITIAL, |cols| { - // Left: default config - if startup_card( - &mut cols[0], - egui_material_icons::icons::ICON_LAN, - "Default configuration", - "Choose from preset templates\nfor different network types", - ) { - state.modal_state = StartupModalState::TemplateSelection; - } - - // 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); - }); -} - -fn render_template_selection_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { - // Use the same modal ID as initial modal to avoid flicker when transitioning - egui::Modal::new(egui::Id::new("startup_modal")).show(ctx, |ui| { - ui.set_width(MODAL_WIDTH_MD); - - // Header with back button - ui.horizontal(|ui| { - if ui - .button(egui_material_icons::icons::ICON_ARROW_BACK) - .on_hover_text("Back") - .clicked() - { - state.modal_state = StartupModalState::Initial; - } - ui.heading("Choose a template"); - }); - - ui.add_space(SPACING_XL); - - // Grid of template cards - let templates = all_templates(); - ui.columns(STARTUP_COLUMNS_TEMPLATES, |cols| { - for (i, template) in templates.iter().enumerate() { - if startup_card( - &mut cols[i % STARTUP_COLUMNS_TEMPLATES], - template.icon, - template.title, - template.description, - ) { - load_template_by_id(state, template.id); - } - } - }); - }); -} diff --git a/crates/fosr-gui/src/app/close_dialog.rs b/crates/fosr-gui/src/app/close_dialog.rs new file mode 100644 index 00000000..63fca9dc --- /dev/null +++ b/crates/fosr-gui/src/app/close_dialog.rs @@ -0,0 +1,34 @@ +//! Close confirmation dialog for desktop when Wireshark sessions are active. + +use crate::shared::ui_constants::{MODAL_WIDTH_SM, SPACING_LG, SPACING_XL}; +use eframe::egui; + +/// Render the close confirmation dialog if needed. +pub fn render_close_confirmation_dialog( + ctx: &egui::Context, + show_dialog: &mut bool, + allowed_to_close: &mut bool, +) { + if !*show_dialog { + return; + } + + egui::Modal::new(egui::Id::new("close_confirmation_modal")).show(ctx, |ui| { + ui.set_width(MODAL_WIDTH_SM); + ui.heading("Confirm Exit"); + ui.add_space(SPACING_LG); + ui.label("You have Wireshark session(s) open with temporary PCAP files."); + ui.label("Closing will delete these files."); + ui.add_space(SPACING_XL); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + *show_dialog = false; + } + if ui.button("Exit").clicked() { + *show_dialog = false; + *allowed_to_close = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + }); +} diff --git a/crates/fosr-gui/src/app/mod.rs b/crates/fosr-gui/src/app/mod.rs new file mode 100644 index 00000000..0cc69041 --- /dev/null +++ b/crates/fosr-gui/src/app/mod.rs @@ -0,0 +1,150 @@ +//! Main Fos-R application: tab navigation, startup modal, and app state. + +#[cfg(not(target_arch = "wasm32"))] +mod close_dialog; +mod startup_modal; +mod top_bar; + +use crate::about_tab::show_about_tab_content; +use crate::configuration::tab::{ConfigurationTabState, show_configuration_tab_content}; +use crate::run::{RunTabState, show_run_tab_content}; +use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_LOGO, IMG_SERVER}; +use crate::shared::configuration_file::ConfigurationFileState; +use crate::shared::ui_constants::{TOOLTIP_DELAY, ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN}; +#[cfg(not(target_arch = "wasm32"))] +use close_dialog::render_close_confirmation_dialog; +use eframe::egui; +use startup_modal::render_startup_modal; +use top_bar::{CurrentTab, TopBarState, render_top_bar}; + +#[derive(Default)] +pub struct FosrApp { + current_tab: CurrentTab, + style_initialized: bool, + images_preloaded: bool, + zoom_factor: f32, + configuration_file_state: ConfigurationFileState, + configuration_tab_state: ConfigurationTabState, + run_tab_state: RunTabState, + /// Whether to show the close confirmation dialog + #[cfg(not(target_arch = "wasm32"))] + show_close_confirmation: bool, + /// Whether the user has confirmed they want to close + #[cfg(not(target_arch = "wasm32"))] + allowed_to_close: bool, +} + +impl eframe::App for FosrApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Set default zoom once + if !self.style_initialized { + self.zoom_factor = ZOOM_DEFAULT; + ctx.options_mut(|option| option.zoom_factor = self.zoom_factor); + ctx.style_mut(|s| s.interaction.tooltip_delay = TOOLTIP_DELAY); + + // On web, use dark theme to match with the Fos-R website's theme + #[cfg(target_arch = "wasm32")] + ctx.set_theme(egui::Theme::Dark); + + self.style_initialized = true; + } + + // Clamp zoom to min/max (prevents Ctrl+/- from exceeding limits) + let current_zoom = ctx.zoom_factor(); + if current_zoom < ZOOM_MIN || current_zoom > ZOOM_MAX { + let clamped_zoom = current_zoom.clamp(ZOOM_MIN, ZOOM_MAX); + ctx.set_zoom_factor(clamped_zoom); + self.zoom_factor = clamped_zoom; + } + + // Set the image loaders + // 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 _ = IMG_SERVER.load(ctx, Default::default(), Default::default()); + let _ = IMG_COMPUTER.load(ctx, Default::default(), Default::default()); + let _ = IMG_INTERNET.load(ctx, Default::default(), Default::default()); + let _ = IMG_LOGO.load(ctx, Default::default(), Default::default()); + self.images_preloaded = true; + } + + // Handle close confirmation if there are active Wireshark sessions + #[cfg(not(target_arch = "wasm32"))] + { + let has_active_sessions = self + .run_tab_state + .generation + .temp_pcap_files + .iter() + .any(|(handle, _)| !handle.is_finished()); + + if ctx.input(|i| i.viewport().close_requested()) { + if self.allowed_to_close { + // Do nothing, let the app close + } else if has_active_sessions { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.show_close_confirmation = true; + } + } + } + + // Startup modal: choose configuration source + if !self.configuration_file_state.config_chosen { + // Render empty CentralPanel for background, then modal on top + egui::CentralPanel::default().show(ctx, |_ui| {}); + render_startup_modal(ctx, &mut self.configuration_file_state); + return; + } + + // Close confirmation dialog + #[cfg(not(target_arch = "wasm32"))] + render_close_confirmation_dialog( + ctx, + &mut self.show_close_confirmation, + &mut self.allowed_to_close, + ); + + // Render top bar and get updated state + let top_bar_state = TopBarState { + current_tab: self.current_tab, + zoom_factor: self.zoom_factor, + has_errors: self.configuration_file_state.has_errors, + }; + let updated_state = render_top_bar(ctx, top_bar_state); + self.current_tab = updated_state.current_tab; + self.zoom_factor = updated_state.zoom_factor; + + // 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 + // Note: Run tab doesn't use ScrollArea as it has its own layout + match self.current_tab { + CurrentTab::Run => { + show_run_tab_content( + ui, + &mut self.run_tab_state, + &mut self.configuration_file_state, + ); + } + CurrentTab::Configuration => { + // Wrap in ScrollArea for vertical scrolling + egui::ScrollArea::vertical().show(ui, |ui| { + show_configuration_tab_content( + ui, + &mut self.configuration_tab_state, + &mut self.configuration_file_state, + ); + }); + } + CurrentTab::About => { + // Wrap in ScrollArea for vertical scrolling + egui::ScrollArea::vertical().show(ui, |ui| { + show_about_tab_content(ui); + }); + } + } + }); + } +} diff --git a/crates/fosr-gui/src/app/startup_modal.rs b/crates/fosr-gui/src/app/startup_modal.rs new file mode 100644 index 00000000..a7930a21 --- /dev/null +++ b/crates/fosr-gui/src/app/startup_modal.rs @@ -0,0 +1,137 @@ +//! Startup modal for choosing configuration source (templates or import). + +use crate::config_templates::{all_templates, load_template_by_id}; +use crate::shared::colors::COLOR_TEXT_MUTED; +#[cfg(target_arch = "wasm32")] +use crate::shared::configuration_file::poll_file_import; +use crate::shared::configuration_file::{ + ConfigurationFileState, StartupModalState, trigger_file_import, +}; +use crate::shared::ui_constants::{ + ICON_SIZE_LG, MODAL_WIDTH_MD, SPACING_LG, SPACING_SM, SPACING_XL, SPACING_XS, + STARTUP_CARD_HEIGHT, STARTUP_COLUMNS_INITIAL, STARTUP_COLUMNS_TEMPLATES, TEXT_SIZE_LG, + TEXT_SIZE_SM, +}; +use eframe::egui; + +/// 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(), STARTUP_CARD_HEIGHT); + 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| { + // 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| { + ui.add_space(SPACING_LG); + ui.label(egui::RichText::new(icon).size(ICON_SIZE_LG)); + ui.add_space(SPACING_SM); + ui.strong(egui::RichText::new(title).size(TEXT_SIZE_LG)); + ui.add_space(SPACING_XS); + ui.label( + egui::RichText::new(description) + .size(TEXT_SIZE_SM) + .color(COLOR_TEXT_MUTED), + ); + ui.add_space(SPACING_LG); + }); + }); + }); + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + response.clicked() +} + +pub fn render_startup_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { + match state.modal_state { + StartupModalState::Initial => render_initial_modal(ctx, state), + StartupModalState::TemplateSelection => render_template_selection_modal(ctx, state), + } +} + +fn render_initial_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { + // Use the same modal ID as template selection to avoid flicker when transitioning + egui::Modal::new(egui::Id::new("startup_modal")).show(ctx, |ui| { + ui.set_width(MODAL_WIDTH_MD); + ui.heading("Welcome to Fos-R"); + ui.add_space(SPACING_SM); + ui.label("Choose a configuration to get started:"); + ui.add_space(SPACING_XL); + + ui.columns(STARTUP_COLUMNS_INITIAL, |cols| { + // Left: default config + if startup_card( + &mut cols[0], + egui_material_icons::icons::ICON_LAN, + "Default configuration", + "Choose from preset templates\nfor different network types", + ) { + state.modal_state = StartupModalState::TemplateSelection; + } + + // 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); + }); +} + +fn render_template_selection_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { + // Use the same modal ID as initial modal to avoid flicker when transitioning + egui::Modal::new(egui::Id::new("startup_modal")).show(ctx, |ui| { + ui.set_width(MODAL_WIDTH_MD); + + // Header with back button + ui.horizontal(|ui| { + if ui + .button(egui_material_icons::icons::ICON_ARROW_BACK) + .on_hover_text("Back") + .clicked() + { + state.modal_state = StartupModalState::Initial; + } + ui.heading("Choose a template"); + }); + + ui.add_space(SPACING_XL); + + // Grid of template cards + let templates = all_templates(); + ui.columns(STARTUP_COLUMNS_TEMPLATES, |cols| { + for (i, template) in templates.iter().enumerate() { + if startup_card( + &mut cols[i % STARTUP_COLUMNS_TEMPLATES], + template.icon, + template.title, + template.description, + ) { + load_template_by_id(state, template.id); + } + } + }); + }); +} diff --git a/crates/fosr-gui/src/app/top_bar.rs b/crates/fosr-gui/src/app/top_bar.rs new file mode 100644 index 00000000..766837d0 --- /dev/null +++ b/crates/fosr-gui/src/app/top_bar.rs @@ -0,0 +1,174 @@ +//! Top bar rendering: tab navigation, zoom controls, and theme switch. + +use crate::shared::colors::COLOR_ERROR; +#[cfg(not(target_arch = "wasm32"))] +use crate::shared::ui_constants::SPACING_SM; +use crate::shared::ui_constants::{ + BUTTON_PADDING, PANEL_INNER_MARGIN, TEXT_SIZE_DEFAULT, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP, +}; +use eframe::egui; +use eframe::egui::global_theme_preference_switch; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +pub enum CurrentTab { + #[default] + Run, + Configuration, + About, +} + +#[derive(Clone)] +pub struct TopBarState { + pub current_tab: CurrentTab, + pub zoom_factor: f32, + pub has_errors: bool, +} + +/// Render the top bar with tabs and utility buttons. +/// Returns the updated TopBarState. +pub fn render_top_bar(ctx: &egui::Context, state: TopBarState) -> TopBarState { + let mut new_state = state.clone(); + + egui::TopBottomPanel::top("top_panel") + .frame( + egui::Frame::side_top_panel(&ctx.style()) + .inner_margin(egui::Margin::symmetric(PANEL_INNER_MARGIN.0, PANEL_INNER_MARGIN.1)), + ) + .show(ctx, |ui| { + // Add a Menu Bar to host the tabs buttons + egui::MenuBar::new().ui(ui, |ui| { + ui.spacing_mut().button_padding = + egui::vec2(BUTTON_PADDING.0, BUTTON_PADDING.1); + let tab_text_size = TEXT_SIZE_DEFAULT; + + let has_errors = state.has_errors; + + // Run tab (combines Live Preview + Generation) + let run_button = + egui::Button::new(egui::RichText::new("Run").size(tab_text_size)) + .selected(state.current_tab == CurrentTab::Run); + + let response = ui.add_enabled(!has_errors, run_button); + + let response = if has_errors { + response.on_disabled_hover_text( + "Configuration is invalid. Fix errors in the Configuration tab to enable Run.", + ) + } else { + response + .on_hover_text("Live preview and PCAP generation from the current configuration.") + }; + + if !has_errors && response.clicked() { + new_state.current_tab = CurrentTab::Run; + } + + // Configuration tab + let label_text = if has_errors { + egui::RichText::new("⚠ Configuration") + .color(COLOR_ERROR) + .size(tab_text_size) + } else { + egui::RichText::new("Configuration").size(tab_text_size) + }; + if ui + .add( + egui::Button::new(label_text) + .selected(state.current_tab == CurrentTab::Configuration), + ) + .on_hover_text( + "Edit the network configuration: hosts, interfaces, and services.", + ) + .clicked() + { + new_state.current_tab = CurrentTab::Configuration; + }; + + // About tab + if ui + .add( + egui::Button::new( + egui::RichText::new("About").size(tab_text_size), + ) + .selected(state.current_tab == CurrentTab::About), + ) + .on_hover_text("About Fos-R and its authors.") + .clicked() + { + new_state.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 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(); + } + } + } + } + } + + #[cfg(not(target_arch = "wasm32"))] + ui.add_space(SPACING_SM); + + // Show the theme switch + global_theme_preference_switch(ui); + + // Zoom controls + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + if ui + .button(egui_material_icons::icons::ICON_ADD) + .on_hover_text("Zoom in") + .clicked() + { + let current_zoom = ctx.zoom_factor(); + let new_zoom = (current_zoom + ZOOM_STEP).min(ZOOM_MAX); + ctx.set_zoom_factor(new_zoom); + new_state.zoom_factor = new_zoom; + } + ui.label(format!("{:.0}%", ctx.zoom_factor() * 100.0)); + if ui + .button(egui_material_icons::icons::ICON_REMOVE) + .on_hover_text("Zoom out") + .clicked() + { + let current_zoom = ctx.zoom_factor(); + let new_zoom = (current_zoom - ZOOM_STEP).max(ZOOM_MIN); + ctx.set_zoom_factor(new_zoom); + new_state.zoom_factor = new_zoom; + } + }); + }); + }); + }); + + new_state +} diff --git a/crates/fosr-gui/src/run/generation/bottom_panel.rs b/crates/fosr-gui/src/run/generation/bottom_panel.rs index b01674be..44942050 100644 --- a/crates/fosr-gui/src/run/generation/bottom_panel.rs +++ b/crates/fosr-gui/src/run/generation/bottom_panel.rs @@ -8,7 +8,7 @@ use super::process::start_generation; use super::validation::first_invalid_param; #[cfg(not(target_arch = "wasm32"))] use super::wireshark::open_in_wireshark; -use crate::run::state::RunState; +use crate::run::state::RunTabState; use crate::shared::colors::{COLOR_ERROR, COLOR_STOP, COLOR_SUCCESS}; use crate::shared::configuration_file::ConfigurationFileState; #[cfg(not(target_arch = "wasm32"))] @@ -29,7 +29,7 @@ use std::sync::atomic::Ordering; /// - Action bar (always visible): Generate/Stop/Save buttons, progress bar pub fn show_bottom_panel( ctx: &egui::Context, - state: &mut RunState, + state: &mut RunTabState, configuration_file_state: &ConfigurationFileState, ) { // Options panel (shown above action bar when expanded) diff --git a/crates/fosr-gui/src/run/generation/options.rs b/crates/fosr-gui/src/run/generation/options.rs index 9a101d2a..2b7e37bf 100644 --- a/crates/fosr-gui/src/run/generation/options.rs +++ b/crates/fosr-gui/src/run/generation/options.rs @@ -3,7 +3,7 @@ //! This module provides the UI for configuring PCAP generation parameters: //! duration, start time, timezone, seed, and advanced options. -use crate::run::state::RunState; +use crate::run::state::RunTabState; use super::ui_components::{show_field_error, timezone_picker}; use super::validation::{ @@ -32,7 +32,7 @@ use egui_extras::DatePickerButton; /// - Optional seed for deterministic generation /// - Taint packets option /// - Temporal ordering option -pub fn show_generation_options(ui: &mut egui::Ui, state: &mut RunState) { +pub fn show_generation_options(ui: &mut egui::Ui, state: &mut RunTabState) { ui.columns(GENERATION_OPTIONS_COLUMNS, |cols| { // --- Column 1: Duration & Time --- let col1 = &mut cols[0]; diff --git a/crates/fosr-gui/src/run/generation/process.rs b/crates/fosr-gui/src/run/generation/process.rs index 1a29455a..1b49a2c0 100644 --- a/crates/fosr-gui/src/run/generation/process.rs +++ b/crates/fosr-gui/src/run/generation/process.rs @@ -3,7 +3,7 @@ //! This module handles starting generation threads and polling for updates. use super::core::generate; -use crate::run::state::RunState; +use crate::run::state::RunTabState; use crate::shared::configuration_file::ConfigurationFileState; use eframe::egui; use std::sync::Arc; @@ -15,7 +15,7 @@ use std::sync::mpsc::channel; /// Creates channels for progress updates, PCAP data, and throughput metrics. /// The generation runs asynchronously (native thread or WASM future). pub fn start_generation( - state: &mut RunState, + state: &mut RunTabState, configuration_file_state: &ConfigurationFileState, ctx: &egui::Context, ) { @@ -106,7 +106,7 @@ pub fn start_generation( /// Poll generation receivers for progress, PCAP data, and throughput. /// /// Should be called every frame to update the UI with generation status. -pub fn poll_generation_receivers(ctx: &egui::Context, state: &mut RunState) { +pub fn poll_generation_receivers(ctx: &egui::Context, state: &mut RunTabState) { // Poll progress receiver if let Some(receiver) = &state.generation.progress_receiver { // Request repaint to keep polling while generating diff --git a/crates/fosr-gui/src/run/graph/flow_processing.rs b/crates/fosr-gui/src/run/graph/flow_processing.rs index ac047a48..4b2c46c0 100644 --- a/crates/fosr-gui/src/run/graph/flow_processing.rs +++ b/crates/fosr-gui/src/run/graph/flow_processing.rs @@ -3,9 +3,7 @@ //! This module handles the processing of flow events from the streamer, //! updating active links, and synchronizing graph edge states. -use super::state::{ - ActiveLink, EdgeState, INTERNET_IP, LinkDirection, VisualizationState, -}; +use super::state::{ActiveLink, EdgeState, INTERNET_IP, LinkDirection, VisualizationState}; use super::stream::FlowEvent; use crate::shared::ui_constants::ACTIVE_LINK_BASE_TIMEOUT_MS; diff --git a/crates/fosr-gui/src/run/graph/overlays.rs b/crates/fosr-gui/src/run/graph/overlays.rs index ad850aa5..f6209ff3 100644 --- a/crates/fosr-gui/src/run/graph/overlays.rs +++ b/crates/fosr-gui/src/run/graph/overlays.rs @@ -69,7 +69,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) egui_material_icons::icons::ICON_PLAY_ARROW, play_text ))) - .fill(accent); + .fill(accent); if ui.add(play_button).clicked() { state.user_has_started = true; // Pass the user config if loaded, otherwise None (uses default BN model) @@ -103,7 +103,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) "{} Stop", egui_material_icons::icons::ICON_STOP ))) - .fill(COLOR_STOP); + .fill(COLOR_STOP); if ui.add(stop_button).clicked() { state.stop_visualization(); } diff --git a/crates/fosr-gui/src/run/graph/view.rs b/crates/fosr-gui/src/run/graph/view.rs index 4d340a61..16dad565 100644 --- a/crates/fosr-gui/src/run/graph/view.rs +++ b/crates/fosr-gui/src/run/graph/view.rs @@ -10,7 +10,7 @@ use super::overlays::{ use super::screenshot::handle_screenshot_export; use super::shapes::{NetworkEdgeShape, NetworkNodeShape}; use super::state::{EdgeData, ExportState, NodeData}; -use crate::run::state::RunState; +use crate::run::state::RunTabState; use crate::shared::ui_constants::FIT_TO_SCREEN_PADDING; use eframe::egui; @@ -22,7 +22,7 @@ use eframe::egui; /// 3. Disables force-directed layout (uses circle layout) /// 4. Handles screenshot export state machine /// 5. Renders UI overlays (buttons, stats, legends) -pub fn render_graph_view(ui: &mut egui::Ui, state: &mut RunState) { +pub fn render_graph_view(ui: &mut egui::Ui, state: &mut RunTabState) { let inner_response = egui::CentralPanel::default().show(ui.ctx(), |ui| { // Enable node clicking and dragging let interactions = egui_graphs::SettingsInteraction::new() diff --git a/crates/fosr-gui/src/run/mod.rs b/crates/fosr-gui/src/run/mod.rs index 12136379..2c02487d 100644 --- a/crates/fosr-gui/src/run/mod.rs +++ b/crates/fosr-gui/src/run/mod.rs @@ -5,5 +5,5 @@ pub mod graph; mod state; mod tab; -pub use state::RunState; +pub use state::RunTabState; pub use tab::show_run_tab_content; diff --git a/crates/fosr-gui/src/run/state.rs b/crates/fosr-gui/src/run/state.rs index 1bcffe9e..5ece2761 100644 --- a/crates/fosr-gui/src/run/state.rs +++ b/crates/fosr-gui/src/run/state.rs @@ -5,14 +5,14 @@ use super::graph::state::VisualizationState; /// State for the unified Run tab. /// Combines visualization (live preview) and generation state. -pub struct RunState { +pub struct RunTabState { pub visualization: VisualizationState, pub generation: GenerationState, /// Whether the side panel (generation options) is open pub panel_open: bool, } -impl Default for RunState { +impl Default for RunTabState { fn default() -> Self { Self { visualization: VisualizationState::default(), diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs index e434151a..b94dd183 100644 --- a/crates/fosr-gui/src/run/tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -6,13 +6,13 @@ use super::graph::config_handling::handle_config_changes; use super::graph::flow_processing::{process_flow_events, update_active_links, update_graph_edges}; use super::graph::node_modal::{process_graph_events, render_node_info_modal}; use super::graph::view::render_graph_view; -use super::state::RunState; +use super::state::RunTabState; use crate::shared::configuration_file::{ConfigurationFileState, load_config_file_contents}; use eframe::egui; pub fn show_run_tab_content( ui: &mut egui::Ui, - state: &mut RunState, + state: &mut RunTabState, configuration_file_state: &mut ConfigurationFileState, ) { // Load config file contents if a file is selected but content not yet loaded From 36badde671a26f129c09c63858c9914fdbd44c77 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:45:53 +0100 Subject: [PATCH 116/127] refactor(gui): update config templates to new config format --- .../src/config_templates/datacenter.yaml | 368 +++++++++--------- .../src/config_templates/enterprise.yaml | 264 ++++++------- .../fosr-gui/src/config_templates/home.yaml | 79 ++-- 3 files changed, 357 insertions(+), 354 deletions(-) diff --git a/crates/fosr-gui/src/config_templates/datacenter.yaml b/crates/fosr-gui/src/config_templates/datacenter.yaml index ea3ab435..639c4d35 100644 --- a/crates/fosr-gui/src/config_templates/datacenter.yaml +++ b/crates/fosr-gui/src/config_templates/datacenter.yaml @@ -1,5 +1,3 @@ -# This configuration file was generated by AI, it might contain errors or inaccuracies. -# To be corrected when the new configuration format is finalized. metadata: title: Datacenter Network Configuration desc: A server-heavy setup with multiple server types @@ -7,195 +5,203 @@ metadata: version: 1.0.0 format: 1 -hosts: +networks: # ==================== LOAD BALANCER TIER ==================== - - - hostname: lb-primary - os: Linux - usage: 1.5 - type: server - client: - - https - - http - - dns - interfaces: - - ip_addr: 10.0.1.10 - mac_addr: 00:14:2A:3F:01:01 - services: - - https:443 - - http:80 - - - hostname: lb-secondary - os: Linux - usage: 1.0 - type: server - client: - - https - - http - - dns - interfaces: - - ip_addr: 10.0.1.11 - mac_addr: 00:14:2A:3F:01:02 - services: - - https:443 - - http:80 + - subnet: 10.0.1.0 + mask: 24 + name: "Load Balancers" + hosts: + - hostname: lb-primary + os: Linux + type: server + interfaces: + - ip_addr: 10.0.1.10 + mac_addr: 00:14:2A:3F:01:01 + services: + - https:443 + - http:80 + uses: + - https + - http + - dns + + - hostname: lb-secondary + os: Linux + type: server + interfaces: + - ip_addr: 10.0.1.11 + mac_addr: 00:14:2A:3F:01:02 + services: + - https:443 + - http:80 + uses: + - https + - http + - dns # ==================== WEB SERVER TIER ==================== - - - hostname: web-server-01 - os: Linux - usage: 2.0 - type: server - client: - - https - - http - interfaces: - - ip_addr: 10.0.2.10 - mac_addr: 00:14:2A:3F:02:01 - services: - - https:443 - - http:80 - - - hostname: web-server-02 - os: Linux - usage: 2.0 - type: server - client: - - https - - http - interfaces: - - ip_addr: 10.0.2.11 - mac_addr: 00:14:2A:3F:02:02 - services: - - https:443 - - http:80 - - - hostname: web-server-03 - os: Linux - usage: 2.0 - type: server - client: - - https - - http - interfaces: - - ip_addr: 10.0.2.12 - mac_addr: 00:14:2A:3F:02:03 - services: - - https:443 - - http:80 + - subnet: 10.0.2.0 + mask: 24 + name: "Web Servers" + hosts: + - hostname: web-server-01 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.2.10 + mac_addr: 00:14:2A:3F:02:01 + services: + - https:443 + - http:80 + uses: + - https + - http + + - hostname: web-server-02 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.2.11 + mac_addr: 00:14:2A:3F:02:02 + services: + - https:443 + - http:80 + uses: + - https + - http + + - hostname: web-server-03 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.2.12 + mac_addr: 00:14:2A:3F:02:03 + services: + - https:443 + - http:80 + uses: + - https + - http # ==================== APPLICATION TIER ==================== - - - hostname: app-server-01 - os: Linux - usage: 1.8 - type: server - client: - - https - - ssh - interfaces: - - ip_addr: 10.0.3.10 - mac_addr: 00:14:2A:3F:03:01 - services: - - https:8443 - - ssh:22 - - - hostname: app-server-02 - os: Linux - usage: 1.8 - type: server - client: - - https - - ssh - interfaces: - - ip_addr: 10.0.3.11 - mac_addr: 00:14:2A:3F:03:02 - services: - - https:8443 - - ssh:22 + - subnet: 10.0.3.0 + mask: 24 + name: "Application Servers" + hosts: + - hostname: app-server-01 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.3.10 + mac_addr: 00:14:2A:3F:03:01 + services: + - https:8443 + - ssh:22 + uses: + - https + - ssh + + - hostname: app-server-02 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.3.11 + mac_addr: 00:14:2A:3F:03:02 + services: + - https:8443 + - ssh:22 + uses: + - https + - ssh # ==================== DATABASE TIER ==================== - - - hostname: db-primary - os: Linux - usage: 2.5 - type: server - client: - - https - - ssh - interfaces: - - ip_addr: 10.0.4.10 - mac_addr: 00:14:2A:3F:04:01 - services: - - https:5432 - - ssh:22 - - - hostname: db-replica-01 - os: Linux - usage: 1.5 - type: server - client: - - https - interfaces: - - ip_addr: 10.0.4.11 - mac_addr: 00:14:2A:3F:04:02 - services: - - https:5432 - - - hostname: db-replica-02 - os: Linux - usage: 1.5 - type: server - client: - - https - interfaces: - - ip_addr: 10.0.4.12 - mac_addr: 00:14:2A:3F:04:03 - services: - - https:5432 + - subnet: 10.0.4.0 + mask: 24 + name: "Database Servers" + hosts: + - hostname: db-primary + os: Linux + type: server + interfaces: + - ip_addr: 10.0.4.10 + mac_addr: 00:14:2A:3F:04:01 + services: + - https:5432 + - ssh:22 + uses: + - https + - ssh + + - hostname: db-replica-01 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.4.11 + mac_addr: 00:14:2A:3F:04:02 + services: + - https:5432 + uses: + - https + + - hostname: db-replica-02 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.4.12 + mac_addr: 00:14:2A:3F:04:03 + services: + - https:5432 + uses: + - https # ==================== CACHE TIER ==================== - - - hostname: cache-server - os: Linux - usage: 1.0 - type: server - client: - - https - interfaces: - - ip_addr: 10.0.5.10 - mac_addr: 00:14:2A:3F:05:01 - services: - - https:6379 + - subnet: 10.0.5.0 + mask: 24 + name: "Cache" + hosts: + - hostname: cache-server + os: Linux + type: server + interfaces: + - ip_addr: 10.0.5.10 + mac_addr: 00:14:2A:3F:05:01 + services: + - https:6379 + uses: + - https # ==================== MONITORING ==================== - - - hostname: monitoring-server - os: Linux - usage: 0.8 - type: server - client: - - https - - ssh - - dns - interfaces: - - ip_addr: 10.0.6.10 - mac_addr: 00:14:2A:3F:06:01 - services: - - https:9090 - - ssh:22 + - subnet: 10.0.6.0 + mask: 24 + name: "Monitoring" + hosts: + - hostname: monitoring-server + os: Linux + type: server + interfaces: + - ip_addr: 10.0.6.10 + mac_addr: 00:14:2A:3F:06:01 + services: + - https:9090 + - ssh:22 + uses: + - https + - ssh + - dns # ==================== DNS ==================== - - - hostname: dns-server - os: Linux - usage: 0.5 - type: server - client: - - https - - dns - interfaces: - - ip_addr: 10.0.7.10 - mac_addr: 00:14:2A:3F:07:01 - services: - - dns:53 + - subnet: 10.0.7.0 + mask: 24 + name: "DNS" + hosts: + - hostname: dns-server + os: Linux + type: server + interfaces: + - ip_addr: 10.0.7.10 + mac_addr: 00:14:2A:3F:07:01 + services: + - dns:53 + uses: + - https + - dns diff --git a/crates/fosr-gui/src/config_templates/enterprise.yaml b/crates/fosr-gui/src/config_templates/enterprise.yaml index a8ecf8cf..6fcf6d41 100644 --- a/crates/fosr-gui/src/config_templates/enterprise.yaml +++ b/crates/fosr-gui/src/config_templates/enterprise.yaml @@ -1,147 +1,147 @@ -# This configuration file was generated by AI, it might contain errors or inaccuracies. -# To be corrected when the new configuration format is finalized. metadata: - title: Default Network Configuration + title: Enterprise 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) ==================== +networks: + # ==================== DMZ SUBNET ==================== + - subnet: 192.168.10.0 + mask: 24 + name: "DMZ" + hosts: + - hostname: dmz-web-gateway + os: Linux + type: server + interfaces: + - ip_addr: 192.168.10.10 + mac_addr: 00:14:2A:3F:10:01 + services: + - https:443 + - http:80 + uses: + - https + - ssh + - ip_addr: 10.0.0.10 + mac_addr: 00:14:2A:3F:10:02 - - 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 + type: server + interfaces: + - ip_addr: 192.168.10.20 + mac_addr: 00:14:2A:3F:10:03 + services: + - dns:53 - - 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 + type: server + interfaces: + - ip_addr: 192.168.10.30 + mac_addr: 00:14:2A:3F:10:04 + services: + - smtp:587 + uses: + - smtp - - 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 ==================== + - subnet: 192.168.20.0 + mask: 24 + name: "Application" + hosts: + - hostname: app-api-frontend + os: Linux + type: server + interfaces: + - ip_addr: 192.168.20.10 + mac_addr: 00:14:2A:3F:20:01 + services: + - https:443 + - http:80 + uses: + - https - # ==================== APPLICATION SUBNET (192.168.20.x) ==================== + - hostname: app-api-backend + os: Linux + type: server + interfaces: + - ip_addr: 192.168.20.20 + mac_addr: 00:14:2A:3F:20:03 + services: + - https:8443 + uses: + - https - - 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 + # ==================== DATABASE SUBNET ==================== + - subnet: 192.168.30.0 + mask: 24 + name: "Database" + hosts: + - hostname: db-primary + os: Linux + type: server + interfaces: + - ip_addr: 192.168.30.10 + mac_addr: 00:14:2A:3F:30:01 + services: + - https:5432 + uses: + - https + - ip_addr: 10.0.20.10 + mac_addr: 00:14:2A:3F:30:02 + services: + - ssh:22 - - 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 + - hostname: db-replica + os: Linux + type: server + interfaces: + - ip_addr: 192.168.30.20 + mac_addr: 00:14:2A:3F:30:03 + services: + - https:5432 + uses: + - https - # ==================== DATABASE SUBNET (192.168.30.x) ==================== + # ==================== USER SUBNET ==================== + - subnet: 192.168.50.0 + mask: 24 + name: "Users" + hosts: + - hostname: admin-workstation + os: Linux + type: user + interfaces: + - ip_addr: 192.168.50.100 + mac_addr: AA:BB:CC:DD:EE:01 + uses: + - ssh + - https + - smtp + - dns - - 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: dev-workstation-01 + os: Linux + type: user + interfaces: + - ip_addr: 192.168.50.101 + mac_addr: AA:BB:CC:DD:EE:02 + uses: + - ssh + - https + - http + - dns - - 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 + - hostname: dev-workstation-02 + os: Linux + type: user + interfaces: + - ip_addr: 192.168.50.102 + mac_addr: AA:BB:CC:DD:EE:03 + uses: + - ssh + - https + - http + - dns diff --git a/crates/fosr-gui/src/config_templates/home.yaml b/crates/fosr-gui/src/config_templates/home.yaml index 9a8fd72b..8d74e8eb 100644 --- a/crates/fosr-gui/src/config_templates/home.yaml +++ b/crates/fosr-gui/src/config_templates/home.yaml @@ -1,5 +1,3 @@ -# This configuration file was generated by AI, it might contain errors or inaccuracies. -# To be corrected when the new configuration format is finalized. metadata: title: Home Network Configuration desc: A simple home network with a router and workstations @@ -7,44 +5,43 @@ metadata: version: 1.0.0 format: 1 -hosts: - # ==================== HOME NETWORK ==================== +networks: + - subnet: 192.168.1.0 + mask: 24 + name: "Home Network" + hosts: + - hostname: home-router + os: Linux + type: server + interfaces: + - ip_addr: 192.168.1.1 + mac_addr: 00:14:2A:3F:01:01 + services: + - dns:53 + - https:443 + uses: + - dns + - https - - hostname: home-router - os: Linux - usage: 0.5 - type: server - client: - - dns - - https - interfaces: - - ip_addr: 192.168.1.1 - mac_addr: 00:14:2A:3F:01:01 - services: - - dns:53 - - https:443 + - hostname: workstation + os: Windows + type: user + interfaces: + - ip_addr: 192.168.1.10 + mac_addr: AA:BB:CC:DD:EE:01 + uses: + - https + - dns + - ssh - - hostname: workstation - os: Windows - usage: 0.8 - type: user - client: - - https - - dns - - ssh - interfaces: - - ip_addr: 192.168.1.10 - mac_addr: AA:BB:CC:DD:EE:01 - - - hostname: nas - os: Linux - usage: 0.3 - type: server - client: - - https - interfaces: - - ip_addr: 192.168.1.20 - mac_addr: 00:14:2A:3F:01:02 - services: - - https:443 - - ssh:22 + - hostname: nas + os: Linux + type: server + interfaces: + - ip_addr: 192.168.1.20 + mac_addr: 00:14:2A:3F:01:02 + services: + - https:443 + - ssh:22 + uses: + - https From 5a0411146caba2e8cebddfca5787b685f2869ff4 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:59:30 +0100 Subject: [PATCH 117/127] refactor(gui): rename configuration module to config_editor and extract state --- crates/fosr-gui/src/app/mod.rs | 3 ++- .../src/{configuration => config_editor}/host.rs | 2 +- .../host_interfaces.rs | 2 +- .../host_services.rs | 0 .../host_validation.rs | 0 .../src/{configuration => config_editor}/mod.rs | 1 + crates/fosr-gui/src/config_editor/state.rs | 14 ++++++++++++++ .../src/{configuration => config_editor}/tab.rs | 16 ++-------------- .../yaml_editor.rs | 0 crates/fosr-gui/src/lib.rs | 2 +- crates/fosr-gui/src/main.rs | 2 +- crates/fosr-gui/src/shared/configuration_file.rs | 8 ++++---- 12 files changed, 27 insertions(+), 23 deletions(-) rename crates/fosr-gui/src/{configuration => config_editor}/host.rs (99%) rename crates/fosr-gui/src/{configuration => config_editor}/host_interfaces.rs (99%) rename crates/fosr-gui/src/{configuration => config_editor}/host_services.rs (100%) rename crates/fosr-gui/src/{configuration => config_editor}/host_validation.rs (100%) rename crates/fosr-gui/src/{configuration => config_editor}/mod.rs (92%) create mode 100644 crates/fosr-gui/src/config_editor/state.rs rename crates/fosr-gui/src/{configuration => config_editor}/tab.rs (93%) rename crates/fosr-gui/src/{configuration => config_editor}/yaml_editor.rs (100%) diff --git a/crates/fosr-gui/src/app/mod.rs b/crates/fosr-gui/src/app/mod.rs index 0cc69041..5140f29d 100644 --- a/crates/fosr-gui/src/app/mod.rs +++ b/crates/fosr-gui/src/app/mod.rs @@ -6,7 +6,8 @@ mod startup_modal; mod top_bar; use crate::about_tab::show_about_tab_content; -use crate::configuration::tab::{ConfigurationTabState, show_configuration_tab_content}; +use crate::config_editor::state::ConfigurationTabState; +use crate::config_editor::tab::show_configuration_tab_content; use crate::run::{RunTabState, show_run_tab_content}; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_LOGO, IMG_SERVER}; use crate::shared::configuration_file::ConfigurationFileState; diff --git a/crates/fosr-gui/src/configuration/host.rs b/crates/fosr-gui/src/config_editor/host.rs similarity index 99% rename from crates/fosr-gui/src/configuration/host.rs rename to crates/fosr-gui/src/config_editor/host.rs index d20ccbf8..4dc7df28 100644 --- a/crates/fosr-gui/src/configuration/host.rs +++ b/crates/fosr-gui/src/config_editor/host.rs @@ -1,6 +1,6 @@ //! Host editing UI: hostname, OS, type, and client protocols. -use crate::configuration::{host_interfaces, host_services, host_validation}; +use crate::config_editor::{host_interfaces, host_services, host_validation}; use crate::shared::colors::COLOR_ERROR; use crate::shared::config_model::{Configuration, Host}; use crate::shared::network_constants::HOST_USAGE_DEFAULT; diff --git a/crates/fosr-gui/src/configuration/host_interfaces.rs b/crates/fosr-gui/src/config_editor/host_interfaces.rs similarity index 99% rename from crates/fosr-gui/src/configuration/host_interfaces.rs rename to crates/fosr-gui/src/config_editor/host_interfaces.rs index 154bea16..ac5b82da 100644 --- a/crates/fosr-gui/src/configuration/host_interfaces.rs +++ b/crates/fosr-gui/src/config_editor/host_interfaces.rs @@ -1,6 +1,6 @@ //! Network interface editing UI: IP, MAC address, and associated services. -use crate::configuration::host_services; +use crate::config_editor::host_services; use crate::shared::colors::COLOR_ERROR; use crate::shared::config_model::{Host, Interface}; use crate::shared::network_constants::{ diff --git a/crates/fosr-gui/src/configuration/host_services.rs b/crates/fosr-gui/src/config_editor/host_services.rs similarity index 100% rename from crates/fosr-gui/src/configuration/host_services.rs rename to crates/fosr-gui/src/config_editor/host_services.rs diff --git a/crates/fosr-gui/src/configuration/host_validation.rs b/crates/fosr-gui/src/config_editor/host_validation.rs similarity index 100% rename from crates/fosr-gui/src/configuration/host_validation.rs rename to crates/fosr-gui/src/config_editor/host_validation.rs diff --git a/crates/fosr-gui/src/configuration/mod.rs b/crates/fosr-gui/src/config_editor/mod.rs similarity index 92% rename from crates/fosr-gui/src/configuration/mod.rs rename to crates/fosr-gui/src/config_editor/mod.rs index 84b2bd2c..51b2d861 100644 --- a/crates/fosr-gui/src/configuration/mod.rs +++ b/crates/fosr-gui/src/config_editor/mod.rs @@ -4,5 +4,6 @@ pub mod host; pub mod host_interfaces; pub mod host_services; pub mod host_validation; +pub mod state; pub mod tab; pub mod yaml_editor; diff --git a/crates/fosr-gui/src/config_editor/state.rs b/crates/fosr-gui/src/config_editor/state.rs new file mode 100644 index 00000000..5c90fc8a --- /dev/null +++ b/crates/fosr-gui/src/config_editor/state.rs @@ -0,0 +1,14 @@ +//! State for the configuration tab. + +/// Represents the state of the configuration tab. +pub struct ConfigurationTabState { + pub is_code_mode: bool, +} + +impl Default for ConfigurationTabState { + fn default() -> Self { + Self { + is_code_mode: false, + } + } +} diff --git a/crates/fosr-gui/src/configuration/tab.rs b/crates/fosr-gui/src/config_editor/tab.rs similarity index 93% rename from crates/fosr-gui/src/configuration/tab.rs rename to crates/fosr-gui/src/config_editor/tab.rs index 79c7f332..5ce79a76 100644 --- a/crates/fosr-gui/src/configuration/tab.rs +++ b/crates/fosr-gui/src/config_editor/tab.rs @@ -1,6 +1,7 @@ //! Configuration tab: toggles between visual mode and YAML editor. -use crate::configuration::{host, host_validation, yaml_editor}; +use crate::config_editor::state::ConfigurationTabState; +use crate::config_editor::{host, host_validation, yaml_editor}; use crate::shared::colors::{COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING}; use crate::shared::config_model::Configuration; use crate::shared::configuration_file::{ @@ -12,19 +13,6 @@ use crate::shared::ui_utils::{ }; use eframe::egui; -/// Represents the state of the configuration tab. -pub struct ConfigurationTabState { - pub is_code_mode: bool, -} - -impl Default for ConfigurationTabState { - fn default() -> Self { - Self { - is_code_mode: false, - } - } -} - /// The main tab component pub fn show_configuration_tab_content( ui: &mut egui::Ui, diff --git a/crates/fosr-gui/src/configuration/yaml_editor.rs b/crates/fosr-gui/src/config_editor/yaml_editor.rs similarity index 100% rename from crates/fosr-gui/src/configuration/yaml_editor.rs rename to crates/fosr-gui/src/config_editor/yaml_editor.rs diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index 50108c6b..4b0e633e 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -4,8 +4,8 @@ #![cfg(target_arch = "wasm32")] mod about_tab; mod app; +mod config_editor; mod config_templates; -mod configuration; mod run; mod shared; mod timepicker; diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index a178e99f..8819e0c2 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -3,8 +3,8 @@ mod about_tab; mod app; +mod config_editor; mod config_templates; -mod configuration; mod run; mod shared; mod timepicker; diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs index 2bb957fd..08d2f922 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -1,15 +1,15 @@ //! Configuration file state management: loading, parsing, and dirty tracking. +use crate::config_editor::state::ConfigurationTabState; +use crate::config_templates::load_template_by_id; use crate::shared::colors::COLOR_WARNING; +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::shared::ui_constants::SPACING_LG; -use crate::{ - config_templates::load_template_by_id, configuration::tab::ConfigurationTabState, - shared::config_model::Configuration, shared::ui_utils::labeled_toggle, -}; +use crate::shared::ui_utils::labeled_toggle; use chrono::{DateTime, Local}; use eframe::egui; use rfd::FileHandle; From 56d067f39f3cfb30dab8930fb480ab36518e05ec Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:11:16 +0100 Subject: [PATCH 118/127] refactor(gui): create shared/config module with model, state, parser, file_ops --- crates/fosr-gui/src/app/mod.rs | 2 +- crates/fosr-gui/src/app/startup_modal.rs | 7 +- crates/fosr-gui/src/config_editor/host.rs | 2 +- .../src/config_editor/host_interfaces.rs | 2 +- .../src/config_editor/host_services.rs | 2 +- .../src/config_editor/host_validation.rs | 2 +- crates/fosr-gui/src/config_editor/tab.rs | 7 +- .../fosr-gui/src/config_editor/yaml_editor.rs | 3 +- crates/fosr-gui/src/config_templates/mod.rs | 4 +- .../src/run/generation/bottom_panel.rs | 2 +- crates/fosr-gui/src/run/generation/process.rs | 2 +- .../fosr-gui/src/run/graph/config_handling.rs | 2 +- crates/fosr-gui/src/run/graph/node_modal.rs | 2 +- crates/fosr-gui/src/run/graph/state.rs | 2 +- crates/fosr-gui/src/run/tab.rs | 3 +- .../file_ops.rs} | 81 ++----------------- crates/fosr-gui/src/shared/config/mod.rs | 6 ++ .../{config_model.rs => config/model.rs} | 0 crates/fosr-gui/src/shared/config/parser.rs | 24 ++++++ crates/fosr-gui/src/shared/config/state.rs | 58 +++++++++++++ crates/fosr-gui/src/shared/mod.rs | 3 +- 21 files changed, 116 insertions(+), 100 deletions(-) rename crates/fosr-gui/src/shared/{configuration_file.rs => config/file_ops.rs} (79%) create mode 100644 crates/fosr-gui/src/shared/config/mod.rs rename crates/fosr-gui/src/shared/{config_model.rs => config/model.rs} (100%) create mode 100644 crates/fosr-gui/src/shared/config/parser.rs create mode 100644 crates/fosr-gui/src/shared/config/state.rs diff --git a/crates/fosr-gui/src/app/mod.rs b/crates/fosr-gui/src/app/mod.rs index 5140f29d..ad9ce472 100644 --- a/crates/fosr-gui/src/app/mod.rs +++ b/crates/fosr-gui/src/app/mod.rs @@ -10,7 +10,7 @@ use crate::config_editor::state::ConfigurationTabState; use crate::config_editor::tab::show_configuration_tab_content; use crate::run::{RunTabState, show_run_tab_content}; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_LOGO, IMG_SERVER}; -use crate::shared::configuration_file::ConfigurationFileState; +use crate::shared::config::state::ConfigurationFileState; use crate::shared::ui_constants::{TOOLTIP_DELAY, ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN}; #[cfg(not(target_arch = "wasm32"))] use close_dialog::render_close_confirmation_dialog; diff --git a/crates/fosr-gui/src/app/startup_modal.rs b/crates/fosr-gui/src/app/startup_modal.rs index a7930a21..6ff21b1f 100644 --- a/crates/fosr-gui/src/app/startup_modal.rs +++ b/crates/fosr-gui/src/app/startup_modal.rs @@ -3,10 +3,9 @@ use crate::config_templates::{all_templates, load_template_by_id}; use crate::shared::colors::COLOR_TEXT_MUTED; #[cfg(target_arch = "wasm32")] -use crate::shared::configuration_file::poll_file_import; -use crate::shared::configuration_file::{ - ConfigurationFileState, StartupModalState, trigger_file_import, -}; +use crate::shared::config::file_ops::poll_file_import; +use crate::shared::config::file_ops::trigger_file_import; +use crate::shared::config::state::{ConfigurationFileState, StartupModalState}; use crate::shared::ui_constants::{ ICON_SIZE_LG, MODAL_WIDTH_MD, SPACING_LG, SPACING_SM, SPACING_XL, SPACING_XS, STARTUP_CARD_HEIGHT, STARTUP_COLUMNS_INITIAL, STARTUP_COLUMNS_TEMPLATES, TEXT_SIZE_LG, diff --git a/crates/fosr-gui/src/config_editor/host.rs b/crates/fosr-gui/src/config_editor/host.rs index 4dc7df28..ff62650a 100644 --- a/crates/fosr-gui/src/config_editor/host.rs +++ b/crates/fosr-gui/src/config_editor/host.rs @@ -2,7 +2,7 @@ use crate::config_editor::{host_interfaces, host_services, host_validation}; use crate::shared::colors::COLOR_ERROR; -use crate::shared::config_model::{Configuration, Host}; +use crate::shared::config::model::{Configuration, Host}; use crate::shared::network_constants::HOST_USAGE_DEFAULT; use crate::shared::ui_constants::{ PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_MD, SPACING_SM, diff --git a/crates/fosr-gui/src/config_editor/host_interfaces.rs b/crates/fosr-gui/src/config_editor/host_interfaces.rs index ac5b82da..125e13cd 100644 --- a/crates/fosr-gui/src/config_editor/host_interfaces.rs +++ b/crates/fosr-gui/src/config_editor/host_interfaces.rs @@ -2,7 +2,7 @@ use crate::config_editor::host_services; use crate::shared::colors::COLOR_ERROR; -use crate::shared::config_model::{Host, Interface}; +use crate::shared::config::model::{Host, Interface}; use crate::shared::network_constants::{ IP_LOCAL_MAX, IP_LOCAL_MIN, MAC_ADDRESS_BYTES, MAC_LOCAL_BIT, MAC_LOCAL_MASK, }; diff --git a/crates/fosr-gui/src/config_editor/host_services.rs b/crates/fosr-gui/src/config_editor/host_services.rs index dff368ed..fb6243bb 100644 --- a/crates/fosr-gui/src/config_editor/host_services.rs +++ b/crates/fosr-gui/src/config_editor/host_services.rs @@ -1,6 +1,6 @@ //! Service editing UI: HTTP, SSH, DNS, etc. with custom port support. -use crate::shared::config_model::Interface; +use crate::shared::config::model::Interface; use crate::shared::network_constants::{PORT_DEFAULT_UNKNOWN, PORT_MAX, PORT_MIN}; use crate::shared::ui_constants::{ PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_SM, SPACING_XS, diff --git a/crates/fosr-gui/src/config_editor/host_validation.rs b/crates/fosr-gui/src/config_editor/host_validation.rs index 20eb52cd..1de840a0 100644 --- a/crates/fosr-gui/src/config_editor/host_validation.rs +++ b/crates/fosr-gui/src/config_editor/host_validation.rs @@ -1,6 +1,6 @@ //! Host validation: IP/MAC format, conflicts, and type/service consistency. -use crate::shared::config_model::{Configuration, Host}; +use crate::shared::config::model::{Configuration, Host}; use crate::shared::network_constants::{MAC_ADDRESS_PARTS, MAC_PART_LENGTH}; use std::collections::HashMap; diff --git a/crates/fosr-gui/src/config_editor/tab.rs b/crates/fosr-gui/src/config_editor/tab.rs index 5ce79a76..ea7bd356 100644 --- a/crates/fosr-gui/src/config_editor/tab.rs +++ b/crates/fosr-gui/src/config_editor/tab.rs @@ -3,10 +3,9 @@ use crate::config_editor::state::ConfigurationTabState; use crate::config_editor::{host, host_validation, yaml_editor}; use crate::shared::colors::{COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING}; -use crate::shared::config_model::Configuration; -use crate::shared::configuration_file::{ - ConfigurationFileState, configuration_file_picker, load_config_file_contents, -}; +use crate::shared::config::file_ops::{configuration_file_picker, load_config_file_contents}; +use crate::shared::config::model::Configuration; +use crate::shared::config::state::ConfigurationFileState; use crate::shared::ui_constants::{SPACING_MD, TEXT_EDIT_DEFAULT_ROWS}; use crate::shared::ui_utils::{ edit_optional_multiline_string, edit_optional_string, required_label, diff --git a/crates/fosr-gui/src/config_editor/yaml_editor.rs b/crates/fosr-gui/src/config_editor/yaml_editor.rs index c0327f40..dd0155b3 100644 --- a/crates/fosr-gui/src/config_editor/yaml_editor.rs +++ b/crates/fosr-gui/src/config_editor/yaml_editor.rs @@ -1,7 +1,8 @@ //! YAML editor with syntax highlighting and error line markers. use crate::shared::colors::COLOR_ERROR; -use crate::shared::configuration_file::{ConfigurationFileState, parse_config_yaml}; +use crate::shared::config::parser::parse_config_yaml; +use crate::shared::config::state::ConfigurationFileState; use crate::shared::ui_constants::{YAML_EDITOR_ROWS, YAML_GUTTER_PADDING, YAML_ICON_COL_WIDTH}; use eframe::egui; diff --git a/crates/fosr-gui/src/config_templates/mod.rs b/crates/fosr-gui/src/config_templates/mod.rs index bbc770f7..f67e4029 100644 --- a/crates/fosr-gui/src/config_templates/mod.rs +++ b/crates/fosr-gui/src/config_templates/mod.rs @@ -1,7 +1,7 @@ //! Predefined configuration templates: Home, Enterprise, Datacenter. -use crate::shared::config_model::Configuration; -use crate::shared::configuration_file::ConfigurationFileState; +use crate::shared::config::model::Configuration; +use crate::shared::config::state::ConfigurationFileState; use egui_material_icons::icons; /// A predefined configuration template. diff --git a/crates/fosr-gui/src/run/generation/bottom_panel.rs b/crates/fosr-gui/src/run/generation/bottom_panel.rs index 44942050..a132023e 100644 --- a/crates/fosr-gui/src/run/generation/bottom_panel.rs +++ b/crates/fosr-gui/src/run/generation/bottom_panel.rs @@ -10,7 +10,7 @@ use super::validation::first_invalid_param; use super::wireshark::open_in_wireshark; use crate::run::state::RunTabState; use crate::shared::colors::{COLOR_ERROR, COLOR_STOP, COLOR_SUCCESS}; -use crate::shared::configuration_file::ConfigurationFileState; +use crate::shared::config::state::ConfigurationFileState; #[cfg(not(target_arch = "wasm32"))] use crate::shared::file_io::save_file_desktop; #[cfg(target_arch = "wasm32")] diff --git a/crates/fosr-gui/src/run/generation/process.rs b/crates/fosr-gui/src/run/generation/process.rs index 1b49a2c0..2c0a4a10 100644 --- a/crates/fosr-gui/src/run/generation/process.rs +++ b/crates/fosr-gui/src/run/generation/process.rs @@ -4,7 +4,7 @@ use super::core::generate; use crate::run::state::RunTabState; -use crate::shared::configuration_file::ConfigurationFileState; +use crate::shared::config::state::ConfigurationFileState; use eframe::egui; use std::sync::Arc; use std::sync::atomic::AtomicBool; diff --git a/crates/fosr-gui/src/run/graph/config_handling.rs b/crates/fosr-gui/src/run/graph/config_handling.rs index fd9e2b92..09c2c430 100644 --- a/crates/fosr-gui/src/run/graph/config_handling.rs +++ b/crates/fosr-gui/src/run/graph/config_handling.rs @@ -4,7 +4,7 @@ //! visualization updates when the configuration changes. use super::state::VisualizationState; -use crate::shared::configuration_file::ConfigurationFileState; +use crate::shared::config::state::ConfigurationFileState; use crate::shared::ui_constants::DELAY_FRAMES_NORMAL; /// Handle configuration file changes and update visualization state. diff --git a/crates/fosr-gui/src/run/graph/node_modal.rs b/crates/fosr-gui/src/run/graph/node_modal.rs index 9d14e2a7..4263e9be 100644 --- a/crates/fosr-gui/src/run/graph/node_modal.rs +++ b/crates/fosr-gui/src/run/graph/node_modal.rs @@ -3,7 +3,7 @@ use super::state::{NodeType, VisualizationState}; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; use crate::shared::colors::{COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT}; -use crate::shared::configuration_file::ConfigurationFileState; +use crate::shared::config::state::ConfigurationFileState; use crate::shared::ui_constants::{ INDENT_STANDARD, LEGEND_ICON_SIZE, NODE_MODAL_WIDTH, SPACING_LG, SPACING_SM, }; diff --git a/crates/fosr-gui/src/run/graph/state.rs b/crates/fosr-gui/src/run/graph/state.rs index c2ff50bc..2ce312e2 100644 --- a/crates/fosr-gui/src/run/graph/state.rs +++ b/crates/fosr-gui/src/run/graph/state.rs @@ -3,7 +3,7 @@ use super::shapes::{NetworkEdgeShape, NetworkNodeShape}; use super::stream::{FlowEvent, FlowStreamer}; use super::utils::distribute_nodes_circle; -use crate::shared::config_model::Host; +use crate::shared::config::model::Host; use eframe::egui; use egui_graphs::events::Event; use fosr_lib::{L7Proto, OS, config, config::HostType}; diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs index b94dd183..124d6e55 100644 --- a/crates/fosr-gui/src/run/tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -7,7 +7,8 @@ use super::graph::flow_processing::{process_flow_events, update_active_links, up use super::graph::node_modal::{process_graph_events, render_node_info_modal}; use super::graph::view::render_graph_view; use super::state::RunTabState; -use crate::shared::configuration_file::{ConfigurationFileState, load_config_file_contents}; +use crate::shared::config::file_ops::load_config_file_contents; +use crate::shared::config::state::ConfigurationFileState; use eframe::egui; pub fn show_run_tab_content( diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/config/file_ops.rs similarity index 79% rename from crates/fosr-gui/src/shared/configuration_file.rs rename to crates/fosr-gui/src/shared/config/file_ops.rs index 08d2f922..da1cdd75 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/config/file_ops.rs @@ -1,9 +1,11 @@ -//! Configuration file state management: loading, parsing, and dirty tracking. +//! Configuration file operations: loading, saving, and file picker UI. use crate::config_editor::state::ConfigurationTabState; use crate::config_templates::load_template_by_id; use crate::shared::colors::COLOR_WARNING; -use crate::shared::config_model::Configuration; +use crate::shared::config::model::Configuration; +use crate::shared::config::parser::parse_config_yaml; +use crate::shared::config::state::ConfigurationFileState; #[cfg(not(target_arch = "wasm32"))] use crate::shared::file_io::{read_file_desktop, save_file_desktop, show_file_picker_desktop}; #[cfg(target_arch = "wasm32")] @@ -12,61 +14,8 @@ use crate::shared::ui_constants::SPACING_LG; use crate::shared::ui_utils::labeled_toggle; use chrono::{DateTime, Local}; use eframe::egui; -use rfd::FileHandle; #[cfg(target_arch = "wasm32")] -use std::sync::mpsc::{Receiver, channel}; - -/// State for the startup modal flow. -#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] -pub enum StartupModalState { - #[default] - Initial, - TemplateSelection, -} - -pub struct ConfigurationFileState { - pub picked_config_file: Option, - #[cfg(target_arch = "wasm32")] - pub config_file_receiver: Option>>, - pub config_file_content: Option, - #[cfg(target_arch = "wasm32")] - 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, - pub is_dirty: bool, - pub clean_snapshot: Option, - /// Whether the configuration has any errors (parse errors or validation errors). - /// Updated by the configuration tab rendering each frame. - pub has_errors: bool, - /// Current state of the startup modal. - pub modal_state: StartupModalState, - /// The ID of the currently loaded template, if any. - pub loaded_template_id: Option, -} - -impl Default for ConfigurationFileState { - fn default() -> Self { - Self { - picked_config_file: None, - #[cfg(target_arch = "wasm32")] - config_file_receiver: None, - config_file_content: None, - #[cfg(target_arch = "wasm32")] - config_file_content_receiver: None, - config_model: None, - parse_error: None, - config_chosen: false, - is_dirty: false, - clean_snapshot: None, - has_errors: false, - modal_state: StartupModalState::Initial, - loaded_template_id: None, - } - } -} +use std::sync::mpsc::channel; /// 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`. @@ -300,26 +249,6 @@ pub fn load_config_file_contents(configuration_file_state: &mut ConfigurationFil } } -pub 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) => { - 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()), - } -} - /// 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; diff --git a/crates/fosr-gui/src/shared/config/mod.rs b/crates/fosr-gui/src/shared/config/mod.rs new file mode 100644 index 00000000..21876032 --- /dev/null +++ b/crates/fosr-gui/src/shared/config/mod.rs @@ -0,0 +1,6 @@ +//! Configuration module: model, state, parsing, and file operations. + +pub mod file_ops; +pub mod model; +pub mod parser; +pub mod state; diff --git a/crates/fosr-gui/src/shared/config_model.rs b/crates/fosr-gui/src/shared/config/model.rs similarity index 100% rename from crates/fosr-gui/src/shared/config_model.rs rename to crates/fosr-gui/src/shared/config/model.rs diff --git a/crates/fosr-gui/src/shared/config/parser.rs b/crates/fosr-gui/src/shared/config/parser.rs new file mode 100644 index 00000000..cdf55e6a --- /dev/null +++ b/crates/fosr-gui/src/shared/config/parser.rs @@ -0,0 +1,24 @@ +//! Configuration YAML parsing. + +use crate::shared::config::model::Configuration; +use crate::shared::config::state::ConfigurationFileState; + +pub 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) => { + 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()), + } +} diff --git a/crates/fosr-gui/src/shared/config/state.rs b/crates/fosr-gui/src/shared/config/state.rs new file mode 100644 index 00000000..0b4e8ff3 --- /dev/null +++ b/crates/fosr-gui/src/shared/config/state.rs @@ -0,0 +1,58 @@ +//! Configuration file state management. + +use crate::shared::config::model::Configuration; +use rfd::FileHandle; +#[cfg(target_arch = "wasm32")] +use std::sync::mpsc::Receiver; + +/// State for the startup modal flow. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +pub enum StartupModalState { + #[default] + Initial, + TemplateSelection, +} + +pub struct ConfigurationFileState { + pub picked_config_file: Option, + #[cfg(target_arch = "wasm32")] + pub config_file_receiver: Option>>, + pub config_file_content: Option, + #[cfg(target_arch = "wasm32")] + 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, + pub is_dirty: bool, + pub clean_snapshot: Option, + /// Whether the configuration has any errors (parse errors or validation errors). + /// Updated by the configuration tab rendering each frame. + pub has_errors: bool, + /// Current state of the startup modal. + pub modal_state: StartupModalState, + /// The ID of the currently loaded template, if any. + pub loaded_template_id: Option, +} + +impl Default for ConfigurationFileState { + fn default() -> Self { + Self { + picked_config_file: None, + #[cfg(target_arch = "wasm32")] + config_file_receiver: None, + config_file_content: None, + #[cfg(target_arch = "wasm32")] + config_file_content_receiver: None, + config_model: None, + parse_error: None, + config_chosen: false, + is_dirty: false, + clean_snapshot: None, + has_errors: false, + modal_state: StartupModalState::Initial, + loaded_template_id: None, + } + } +} diff --git a/crates/fosr-gui/src/shared/mod.rs b/crates/fosr-gui/src/shared/mod.rs index a62df601..19ab12ea 100644 --- a/crates/fosr-gui/src/shared/mod.rs +++ b/crates/fosr-gui/src/shared/mod.rs @@ -2,8 +2,7 @@ pub mod assets; pub mod colors; -pub mod config_model; -pub mod configuration_file; +pub mod config; pub mod file_io; pub mod network_constants; pub mod ui_constants; From 870a12462c11ac31d3f1a3f641025331d0ae2617 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:18:01 +0100 Subject: [PATCH 119/127] refactor(gui): create shared/constants module with colors, ui, network --- crates/fosr-gui/src/about_tab.rs | 2 +- crates/fosr-gui/src/app/close_dialog.rs | 2 +- crates/fosr-gui/src/app/mod.rs | 2 +- crates/fosr-gui/src/app/startup_modal.rs | 4 ++-- crates/fosr-gui/src/app/top_bar.rs | 6 +++--- crates/fosr-gui/src/config_editor/host.rs | 6 +++--- crates/fosr-gui/src/config_editor/host_interfaces.rs | 6 +++--- crates/fosr-gui/src/config_editor/host_services.rs | 4 ++-- crates/fosr-gui/src/config_editor/host_validation.rs | 2 +- crates/fosr-gui/src/config_editor/tab.rs | 4 ++-- crates/fosr-gui/src/config_editor/yaml_editor.rs | 4 ++-- crates/fosr-gui/src/main.rs | 2 +- crates/fosr-gui/src/run/generation/bottom_panel.rs | 10 +++++----- crates/fosr-gui/src/run/generation/options.rs | 4 ++-- crates/fosr-gui/src/run/generation/ui_components.rs | 4 ++-- crates/fosr-gui/src/run/graph/config_handling.rs | 2 +- crates/fosr-gui/src/run/graph/flow_processing.rs | 2 +- crates/fosr-gui/src/run/graph/node_modal.rs | 4 ++-- crates/fosr-gui/src/run/graph/overlays.rs | 4 ++-- crates/fosr-gui/src/run/graph/shapes.rs | 4 ++-- crates/fosr-gui/src/run/graph/stream.rs | 6 +++--- crates/fosr-gui/src/run/graph/utils.rs | 2 +- crates/fosr-gui/src/run/graph/view.rs | 2 +- crates/fosr-gui/src/shared/config/file_ops.rs | 4 ++-- crates/fosr-gui/src/shared/{ => constants}/colors.rs | 0 crates/fosr-gui/src/shared/constants/mod.rs | 5 +++++ .../{network_constants.rs => constants/network.rs} | 0 .../src/shared/{ui_constants.rs => constants/ui.rs} | 0 crates/fosr-gui/src/shared/mod.rs | 4 +--- crates/fosr-gui/src/shared/ui_utils.rs | 4 ++-- 30 files changed, 54 insertions(+), 51 deletions(-) rename crates/fosr-gui/src/shared/{ => constants}/colors.rs (100%) create mode 100644 crates/fosr-gui/src/shared/constants/mod.rs rename crates/fosr-gui/src/shared/{network_constants.rs => constants/network.rs} (100%) rename crates/fosr-gui/src/shared/{ui_constants.rs => constants/ui.rs} (100%) diff --git a/crates/fosr-gui/src/about_tab.rs b/crates/fosr-gui/src/about_tab.rs index 6c5ee01f..41df3a16 100644 --- a/crates/fosr-gui/src/about_tab.rs +++ b/crates/fosr-gui/src/about_tab.rs @@ -1,7 +1,7 @@ //! About tab: Fos-R information, usage guide, and repository links. use crate::shared::assets::IMG_LOGO; -use crate::shared::ui_constants::{LOGO_MAX_WIDTH, SPACING_LG, SPACING_SM, SPACING_XXL}; +use crate::shared::constants::ui::{LOGO_MAX_WIDTH, SPACING_LG, SPACING_SM, SPACING_XXL}; use eframe::egui; pub fn show_about_tab_content(ui: &mut egui::Ui) { diff --git a/crates/fosr-gui/src/app/close_dialog.rs b/crates/fosr-gui/src/app/close_dialog.rs index 63fca9dc..35aa7d21 100644 --- a/crates/fosr-gui/src/app/close_dialog.rs +++ b/crates/fosr-gui/src/app/close_dialog.rs @@ -1,6 +1,6 @@ //! Close confirmation dialog for desktop when Wireshark sessions are active. -use crate::shared::ui_constants::{MODAL_WIDTH_SM, SPACING_LG, SPACING_XL}; +use crate::shared::constants::ui::{MODAL_WIDTH_SM, SPACING_LG, SPACING_XL}; use eframe::egui; /// Render the close confirmation dialog if needed. diff --git a/crates/fosr-gui/src/app/mod.rs b/crates/fosr-gui/src/app/mod.rs index ad9ce472..60595231 100644 --- a/crates/fosr-gui/src/app/mod.rs +++ b/crates/fosr-gui/src/app/mod.rs @@ -11,7 +11,7 @@ use crate::config_editor::tab::show_configuration_tab_content; use crate::run::{RunTabState, show_run_tab_content}; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_LOGO, IMG_SERVER}; use crate::shared::config::state::ConfigurationFileState; -use crate::shared::ui_constants::{TOOLTIP_DELAY, ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN}; +use crate::shared::constants::ui::{TOOLTIP_DELAY, ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN}; #[cfg(not(target_arch = "wasm32"))] use close_dialog::render_close_confirmation_dialog; use eframe::egui; diff --git a/crates/fosr-gui/src/app/startup_modal.rs b/crates/fosr-gui/src/app/startup_modal.rs index 6ff21b1f..26bec8db 100644 --- a/crates/fosr-gui/src/app/startup_modal.rs +++ b/crates/fosr-gui/src/app/startup_modal.rs @@ -1,12 +1,12 @@ //! Startup modal for choosing configuration source (templates or import). use crate::config_templates::{all_templates, load_template_by_id}; -use crate::shared::colors::COLOR_TEXT_MUTED; #[cfg(target_arch = "wasm32")] use crate::shared::config::file_ops::poll_file_import; use crate::shared::config::file_ops::trigger_file_import; use crate::shared::config::state::{ConfigurationFileState, StartupModalState}; -use crate::shared::ui_constants::{ +use crate::shared::constants::colors::COLOR_TEXT_MUTED; +use crate::shared::constants::ui::{ ICON_SIZE_LG, MODAL_WIDTH_MD, SPACING_LG, SPACING_SM, SPACING_XL, SPACING_XS, STARTUP_CARD_HEIGHT, STARTUP_COLUMNS_INITIAL, STARTUP_COLUMNS_TEMPLATES, TEXT_SIZE_LG, TEXT_SIZE_SM, diff --git a/crates/fosr-gui/src/app/top_bar.rs b/crates/fosr-gui/src/app/top_bar.rs index 766837d0..f323b79e 100644 --- a/crates/fosr-gui/src/app/top_bar.rs +++ b/crates/fosr-gui/src/app/top_bar.rs @@ -1,9 +1,9 @@ //! Top bar rendering: tab navigation, zoom controls, and theme switch. -use crate::shared::colors::COLOR_ERROR; +use crate::shared::constants::colors::COLOR_ERROR; #[cfg(not(target_arch = "wasm32"))] -use crate::shared::ui_constants::SPACING_SM; -use crate::shared::ui_constants::{ +use crate::shared::constants::ui::SPACING_SM; +use crate::shared::constants::ui::{ BUTTON_PADDING, PANEL_INNER_MARGIN, TEXT_SIZE_DEFAULT, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP, }; use eframe::egui; diff --git a/crates/fosr-gui/src/config_editor/host.rs b/crates/fosr-gui/src/config_editor/host.rs index ff62650a..a930e5f9 100644 --- a/crates/fosr-gui/src/config_editor/host.rs +++ b/crates/fosr-gui/src/config_editor/host.rs @@ -1,10 +1,10 @@ //! Host editing UI: hostname, OS, type, and client protocols. use crate::config_editor::{host_interfaces, host_services, host_validation}; -use crate::shared::colors::COLOR_ERROR; use crate::shared::config::model::{Configuration, Host}; -use crate::shared::network_constants::HOST_USAGE_DEFAULT; -use crate::shared::ui_constants::{ +use crate::shared::constants::colors::COLOR_ERROR; +use crate::shared::constants::network::HOST_USAGE_DEFAULT; +use crate::shared::constants::ui::{ PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_MD, SPACING_SM, }; use crate::shared::ui_utils::{edit_optional_string, info_icon}; diff --git a/crates/fosr-gui/src/config_editor/host_interfaces.rs b/crates/fosr-gui/src/config_editor/host_interfaces.rs index 125e13cd..497da0ac 100644 --- a/crates/fosr-gui/src/config_editor/host_interfaces.rs +++ b/crates/fosr-gui/src/config_editor/host_interfaces.rs @@ -1,12 +1,12 @@ //! Network interface editing UI: IP, MAC address, and associated services. use crate::config_editor::host_services; -use crate::shared::colors::COLOR_ERROR; use crate::shared::config::model::{Host, Interface}; -use crate::shared::network_constants::{ +use crate::shared::constants::colors::COLOR_ERROR; +use crate::shared::constants::network::{ IP_LOCAL_MAX, IP_LOCAL_MIN, MAC_ADDRESS_BYTES, MAC_LOCAL_BIT, MAC_LOCAL_MASK, }; -use crate::shared::ui_constants::{SPACING_MD, SPACING_SM}; +use crate::shared::constants::ui::{SPACING_MD, SPACING_SM}; use crate::shared::ui_utils::{edit_optional_string, required_label}; use eframe::egui; use std::collections::HashMap; diff --git a/crates/fosr-gui/src/config_editor/host_services.rs b/crates/fosr-gui/src/config_editor/host_services.rs index fb6243bb..85797b4a 100644 --- a/crates/fosr-gui/src/config_editor/host_services.rs +++ b/crates/fosr-gui/src/config_editor/host_services.rs @@ -1,8 +1,8 @@ //! Service editing UI: HTTP, SSH, DNS, etc. with custom port support. use crate::shared::config::model::Interface; -use crate::shared::network_constants::{PORT_DEFAULT_UNKNOWN, PORT_MAX, PORT_MIN}; -use crate::shared::ui_constants::{ +use crate::shared::constants::network::{PORT_DEFAULT_UNKNOWN, PORT_MAX, PORT_MIN}; +use crate::shared::constants::ui::{ PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_SM, SPACING_XS, }; use crate::shared::ui_utils::info_icon; diff --git a/crates/fosr-gui/src/config_editor/host_validation.rs b/crates/fosr-gui/src/config_editor/host_validation.rs index 1de840a0..81a08d46 100644 --- a/crates/fosr-gui/src/config_editor/host_validation.rs +++ b/crates/fosr-gui/src/config_editor/host_validation.rs @@ -1,7 +1,7 @@ //! Host validation: IP/MAC format, conflicts, and type/service consistency. use crate::shared::config::model::{Configuration, Host}; -use crate::shared::network_constants::{MAC_ADDRESS_PARTS, MAC_PART_LENGTH}; +use crate::shared::constants::network::{MAC_ADDRESS_PARTS, MAC_PART_LENGTH}; use std::collections::HashMap; /// Function to validate if a host is correct diff --git a/crates/fosr-gui/src/config_editor/tab.rs b/crates/fosr-gui/src/config_editor/tab.rs index ea7bd356..505da6fc 100644 --- a/crates/fosr-gui/src/config_editor/tab.rs +++ b/crates/fosr-gui/src/config_editor/tab.rs @@ -2,11 +2,11 @@ use crate::config_editor::state::ConfigurationTabState; use crate::config_editor::{host, host_validation, yaml_editor}; -use crate::shared::colors::{COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING}; use crate::shared::config::file_ops::{configuration_file_picker, load_config_file_contents}; use crate::shared::config::model::Configuration; use crate::shared::config::state::ConfigurationFileState; -use crate::shared::ui_constants::{SPACING_MD, TEXT_EDIT_DEFAULT_ROWS}; +use crate::shared::constants::colors::{COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING}; +use crate::shared::constants::ui::{SPACING_MD, TEXT_EDIT_DEFAULT_ROWS}; use crate::shared::ui_utils::{ edit_optional_multiline_string, edit_optional_string, required_label, }; diff --git a/crates/fosr-gui/src/config_editor/yaml_editor.rs b/crates/fosr-gui/src/config_editor/yaml_editor.rs index dd0155b3..e7dfbd05 100644 --- a/crates/fosr-gui/src/config_editor/yaml_editor.rs +++ b/crates/fosr-gui/src/config_editor/yaml_editor.rs @@ -1,9 +1,9 @@ //! YAML editor with syntax highlighting and error line markers. -use crate::shared::colors::COLOR_ERROR; use crate::shared::config::parser::parse_config_yaml; use crate::shared::config::state::ConfigurationFileState; -use crate::shared::ui_constants::{YAML_EDITOR_ROWS, YAML_GUTTER_PADDING, YAML_ICON_COL_WIDTH}; +use crate::shared::constants::colors::COLOR_ERROR; +use crate::shared::constants::ui::{YAML_EDITOR_ROWS, YAML_GUTTER_PADDING, YAML_ICON_COL_WIDTH}; use eframe::egui; fn parse_error_lines(err: &str) -> Vec { diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index 8819e0c2..c278be09 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -13,7 +13,7 @@ mod timepicker; #[cfg(not(target_arch = "wasm32"))] fn main() -> eframe::Result { use crate::app::FosrApp; - use crate::shared::ui_constants::{ + use crate::shared::constants::ui::{ WINDOW_DEFAULT_HEIGHT, WINDOW_DEFAULT_WIDTH, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, }; use eframe::egui; diff --git a/crates/fosr-gui/src/run/generation/bottom_panel.rs b/crates/fosr-gui/src/run/generation/bottom_panel.rs index a132023e..146a0ca6 100644 --- a/crates/fosr-gui/src/run/generation/bottom_panel.rs +++ b/crates/fosr-gui/src/run/generation/bottom_panel.rs @@ -9,16 +9,16 @@ use super::validation::first_invalid_param; #[cfg(not(target_arch = "wasm32"))] use super::wireshark::open_in_wireshark; use crate::run::state::RunTabState; -use crate::shared::colors::{COLOR_ERROR, COLOR_STOP, COLOR_SUCCESS}; use crate::shared::config::state::ConfigurationFileState; +use crate::shared::constants::colors::{COLOR_ERROR, COLOR_STOP, COLOR_SUCCESS}; +use crate::shared::constants::ui::{ + BOTTOM_BAR_INNER_MARGIN, BUTTON_HEIGHT, BUTTON_MIN_WIDTH_LG, BUTTON_MIN_WIDTH_SM, + DELAY_FRAMES_QUICK, OPTIONS_PANEL_INNER_MARGIN, TEXT_SIZE_MD, +}; #[cfg(not(target_arch = "wasm32"))] use crate::shared::file_io::save_file_desktop; #[cfg(target_arch = "wasm32")] use crate::shared::file_io::save_file_wasm; -use crate::shared::ui_constants::{ - BOTTOM_BAR_INNER_MARGIN, BUTTON_HEIGHT, BUTTON_MIN_WIDTH_LG, BUTTON_MIN_WIDTH_SM, - DELAY_FRAMES_QUICK, OPTIONS_PANEL_INNER_MARGIN, TEXT_SIZE_MD, -}; use eframe::egui; use std::sync::atomic::Ordering; diff --git a/crates/fosr-gui/src/run/generation/options.rs b/crates/fosr-gui/src/run/generation/options.rs index 2b7e37bf..dea9a9c2 100644 --- a/crates/fosr-gui/src/run/generation/options.rs +++ b/crates/fosr-gui/src/run/generation/options.rs @@ -9,8 +9,8 @@ use super::ui_components::{show_field_error, timezone_picker}; use super::validation::{ first_invalid_param, validate_duration, validate_optional_u64, validate_timezone, }; -use crate::shared::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; -use crate::shared::ui_constants::{ +use crate::shared::constants::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; +use crate::shared::constants::ui::{ DURATION_TEXT_WIDTH, GENERATION_COL1_MIN_WIDTH, GENERATION_COL2_MIN_WIDTH, GENERATION_OPTIONS_COLUMNS, SEED_INPUT_WIDTH, SPACING_LG, }; diff --git a/crates/fosr-gui/src/run/generation/ui_components.rs b/crates/fosr-gui/src/run/generation/ui_components.rs index a57270a8..fe1fd177 100644 --- a/crates/fosr-gui/src/run/generation/ui_components.rs +++ b/crates/fosr-gui/src/run/generation/ui_components.rs @@ -28,8 +28,8 @@ use super::state::GenerationState; use super::validation::FieldValidation; -use crate::shared::colors::COLOR_ERROR; -use crate::shared::ui_constants::{ +use crate::shared::constants::colors::COLOR_ERROR; +use crate::shared::constants::ui::{ SPACING_MD, TIMEZONE_LIST_MAX_HEIGHT, TIMEZONE_PICKER_WIDTH, TIMEZONE_POPUP_MAX_HEIGHT, }; use chrono_tz::TZ_VARIANTS; diff --git a/crates/fosr-gui/src/run/graph/config_handling.rs b/crates/fosr-gui/src/run/graph/config_handling.rs index 09c2c430..2990bd94 100644 --- a/crates/fosr-gui/src/run/graph/config_handling.rs +++ b/crates/fosr-gui/src/run/graph/config_handling.rs @@ -5,7 +5,7 @@ use super::state::VisualizationState; use crate::shared::config::state::ConfigurationFileState; -use crate::shared::ui_constants::DELAY_FRAMES_NORMAL; +use crate::shared::constants::ui::DELAY_FRAMES_NORMAL; /// Handle configuration file changes and update visualization state. /// diff --git a/crates/fosr-gui/src/run/graph/flow_processing.rs b/crates/fosr-gui/src/run/graph/flow_processing.rs index 4b2c46c0..ac74d065 100644 --- a/crates/fosr-gui/src/run/graph/flow_processing.rs +++ b/crates/fosr-gui/src/run/graph/flow_processing.rs @@ -5,7 +5,7 @@ use super::state::{ActiveLink, EdgeState, INTERNET_IP, LinkDirection, VisualizationState}; use super::stream::FlowEvent; -use crate::shared::ui_constants::ACTIVE_LINK_BASE_TIMEOUT_MS; +use crate::shared::constants::ui::ACTIVE_LINK_BASE_TIMEOUT_MS; /// Process incoming flow events from the streamer. /// diff --git a/crates/fosr-gui/src/run/graph/node_modal.rs b/crates/fosr-gui/src/run/graph/node_modal.rs index 4263e9be..61633f07 100644 --- a/crates/fosr-gui/src/run/graph/node_modal.rs +++ b/crates/fosr-gui/src/run/graph/node_modal.rs @@ -2,9 +2,9 @@ use super::state::{NodeType, VisualizationState}; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; -use crate::shared::colors::{COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT}; use crate::shared::config::state::ConfigurationFileState; -use crate::shared::ui_constants::{ +use crate::shared::constants::colors::{COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT}; +use crate::shared::constants::ui::{ INDENT_STANDARD, LEGEND_ICON_SIZE, NODE_MODAL_WIDTH, SPACING_LG, SPACING_SM, }; use eframe::egui; diff --git a/crates/fosr-gui/src/run/graph/overlays.rs b/crates/fosr-gui/src/run/graph/overlays.rs index f6209ff3..01a2d206 100644 --- a/crates/fosr-gui/src/run/graph/overlays.rs +++ b/crates/fosr-gui/src/run/graph/overlays.rs @@ -2,12 +2,12 @@ use super::state::VisualizationState; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; -use crate::shared::colors::{ +use crate::shared::constants::colors::{ COLOR_EDGE_INACTIVE, COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT, COLOR_PROTOCOL_DNS, COLOR_PROTOCOL_HTTP, COLOR_PROTOCOL_HTTPS, COLOR_PROTOCOL_OTHER, COLOR_PROTOCOL_SMTP, COLOR_PROTOCOL_SSH, COLOR_STOP, }; -use crate::shared::ui_constants::{ +use crate::shared::constants::ui::{ LEGEND_ICON_SIZE, LEGEND_MARKER_RADIUS, OVERLAY_MARGIN, PLAYBACK_SPEED_EPSILON, PLAYBACK_SPEED_STEPS, SPACING_NEGATIVE_XS, }; diff --git a/crates/fosr-gui/src/run/graph/shapes.rs b/crates/fosr-gui/src/run/graph/shapes.rs index 1135be1e..ead7fcfc 100644 --- a/crates/fosr-gui/src/run/graph/shapes.rs +++ b/crates/fosr-gui/src/run/graph/shapes.rs @@ -2,12 +2,12 @@ use super::state::{EdgeData, EdgeState, LinkDirection, NodeData, NodeType}; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; -use crate::shared::colors::{ +use crate::shared::constants::colors::{ COLOR_EDGE_INACTIVE, COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT, COLOR_PROTOCOL_DNS, COLOR_PROTOCOL_HTTP, COLOR_PROTOCOL_HTTPS, COLOR_PROTOCOL_OTHER, COLOR_PROTOCOL_SMTP, COLOR_PROTOCOL_SSH, COLOR_TEXT_MUTED, }; -use crate::shared::ui_constants::{ +use crate::shared::constants::ui::{ EDGE_ARROW_ANGLE_RAD, EDGE_ARROW_SIZE, EDGE_FLOW_SCALE, EDGE_WIDTH_MAX, EDGE_WIDTH_MIN, NODE_FLOW_SCALE_FACTOR, NODE_RADIUS_MAX, NODE_RADIUS_MIN, SPACING_XS, TEXT_SIZE_DEFAULT, }; diff --git a/crates/fosr-gui/src/run/graph/stream.rs b/crates/fosr-gui/src/run/graph/stream.rs index a2997f34..624bf00e 100644 --- a/crates/fosr-gui/src/run/graph/stream.rs +++ b/crates/fosr-gui/src/run/graph/stream.rs @@ -7,10 +7,10 @@ //! allowing multiple flows to be displayed in parallel. #[cfg(target_arch = "wasm32")] -use crate::shared::network_constants::STREAM_MAX_PER_CYCLE_WASM; +use crate::shared::constants::network::STREAM_MAX_PER_CYCLE_WASM; #[cfg(not(target_arch = "wasm32"))] -use crate::shared::network_constants::STREAM_RATE_LIMIT_MS; -use crate::shared::network_constants::{STREAM_BUFFER_AHEAD_SECS, STREAM_CHECK_INTERVAL_MS}; +use crate::shared::constants::network::STREAM_RATE_LIMIT_MS; +use crate::shared::constants::network::{STREAM_BUFFER_AHEAD_SECS, STREAM_CHECK_INTERVAL_MS}; use chrono::{DateTime, Offset, TimeZone}; use fosr_lib::{L7Proto, models, stage0, stage1::Stage1, stage1::bayesian_networks::BNGenerator}; use std::collections::BinaryHeap; diff --git a/crates/fosr-gui/src/run/graph/utils.rs b/crates/fosr-gui/src/run/graph/utils.rs index 08cab755..8c30e2aa 100644 --- a/crates/fosr-gui/src/run/graph/utils.rs +++ b/crates/fosr-gui/src/run/graph/utils.rs @@ -1,6 +1,6 @@ //! Graph layout utilities: circular node distribution for initial display. -use crate::shared::ui_constants::{GRAPH_LAYOUT_RADIUS_BASE, GRAPH_LAYOUT_RADIUS_MULTIPLIER}; +use crate::shared::constants::ui::{GRAPH_LAYOUT_RADIUS_BASE, GRAPH_LAYOUT_RADIUS_MULTIPLIER}; use eframe::egui; /// Distributes the graph nodes in a circle layout. diff --git a/crates/fosr-gui/src/run/graph/view.rs b/crates/fosr-gui/src/run/graph/view.rs index 16dad565..a5e74665 100644 --- a/crates/fosr-gui/src/run/graph/view.rs +++ b/crates/fosr-gui/src/run/graph/view.rs @@ -11,7 +11,7 @@ use super::screenshot::handle_screenshot_export; use super::shapes::{NetworkEdgeShape, NetworkNodeShape}; use super::state::{EdgeData, ExportState, NodeData}; use crate::run::state::RunTabState; -use crate::shared::ui_constants::FIT_TO_SCREEN_PADDING; +use crate::shared::constants::ui::FIT_TO_SCREEN_PADDING; use eframe::egui; /// Render the graph view with overlays. diff --git a/crates/fosr-gui/src/shared/config/file_ops.rs b/crates/fosr-gui/src/shared/config/file_ops.rs index da1cdd75..399b70f6 100644 --- a/crates/fosr-gui/src/shared/config/file_ops.rs +++ b/crates/fosr-gui/src/shared/config/file_ops.rs @@ -2,15 +2,15 @@ use crate::config_editor::state::ConfigurationTabState; use crate::config_templates::load_template_by_id; -use crate::shared::colors::COLOR_WARNING; use crate::shared::config::model::Configuration; use crate::shared::config::parser::parse_config_yaml; use crate::shared::config::state::ConfigurationFileState; +use crate::shared::constants::colors::COLOR_WARNING; +use crate::shared::constants::ui::SPACING_LG; #[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::shared::ui_constants::SPACING_LG; use crate::shared::ui_utils::labeled_toggle; use chrono::{DateTime, Local}; use eframe::egui; diff --git a/crates/fosr-gui/src/shared/colors.rs b/crates/fosr-gui/src/shared/constants/colors.rs similarity index 100% rename from crates/fosr-gui/src/shared/colors.rs rename to crates/fosr-gui/src/shared/constants/colors.rs diff --git a/crates/fosr-gui/src/shared/constants/mod.rs b/crates/fosr-gui/src/shared/constants/mod.rs new file mode 100644 index 00000000..e9c2b5b3 --- /dev/null +++ b/crates/fosr-gui/src/shared/constants/mod.rs @@ -0,0 +1,5 @@ +//! Constants module: colors, UI constants, and network constants. + +pub mod colors; +pub mod network; +pub mod ui; diff --git a/crates/fosr-gui/src/shared/network_constants.rs b/crates/fosr-gui/src/shared/constants/network.rs similarity index 100% rename from crates/fosr-gui/src/shared/network_constants.rs rename to crates/fosr-gui/src/shared/constants/network.rs diff --git a/crates/fosr-gui/src/shared/ui_constants.rs b/crates/fosr-gui/src/shared/constants/ui.rs similarity index 100% rename from crates/fosr-gui/src/shared/ui_constants.rs rename to crates/fosr-gui/src/shared/constants/ui.rs diff --git a/crates/fosr-gui/src/shared/mod.rs b/crates/fosr-gui/src/shared/mod.rs index 19ab12ea..dfc348df 100644 --- a/crates/fosr-gui/src/shared/mod.rs +++ b/crates/fosr-gui/src/shared/mod.rs @@ -1,9 +1,7 @@ //! Shared utilities: config model, file I/O, and reusable UI components. pub mod assets; -pub mod colors; pub mod config; +pub mod constants; pub mod file_io; -pub mod network_constants; -pub mod ui_constants; pub mod ui_utils; diff --git a/crates/fosr-gui/src/shared/ui_utils.rs b/crates/fosr-gui/src/shared/ui_utils.rs index b3eaa2e8..834a66ec 100644 --- a/crates/fosr-gui/src/shared/ui_utils.rs +++ b/crates/fosr-gui/src/shared/ui_utils.rs @@ -1,7 +1,7 @@ //! Reusable UI widgets: info icons, optional string editors, toggles, labels. -use crate::shared::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; -use crate::shared::ui_constants::{ +use crate::shared::constants::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; +use crate::shared::constants::ui::{ INFO_ICON_SIZE, INFO_ICON_SPACING, INFO_TOOLTIP_MAX_WIDTH, TOGGLE_INNER_MARGIN, TOGGLE_ITEM_SPACING, }; From 7404f09e4e4eda03304d6895d95392a14f2d60a2 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:33:43 +0100 Subject: [PATCH 120/127] refactor(gui): create shared/widgets module with time_picker, timezone_picker and helpers --- crates/fosr-gui/src/config_editor/host.rs | 2 +- .../src/config_editor/host_interfaces.rs | 2 +- .../src/config_editor/host_services.rs | 2 +- crates/fosr-gui/src/config_editor/tab.rs | 2 +- crates/fosr-gui/src/lib.rs | 1 - crates/fosr-gui/src/main.rs | 1 - crates/fosr-gui/src/run/generation/mod.rs | 1 - crates/fosr-gui/src/run/generation/options.rs | 11 +++---- .../fosr-gui/src/run/generation/validation.rs | 11 +++++++ crates/fosr-gui/src/shared/config/file_ops.rs | 2 +- crates/fosr-gui/src/shared/mod.rs | 2 +- .../{ui_utils.rs => widgets/helpers.rs} | 0 crates/fosr-gui/src/shared/widgets/mod.rs | 5 ++++ .../widgets/time_picker}/button.rs | 0 .../widgets/time_picker}/mod.rs | 0 .../widgets/time_picker}/popup.rs | 0 .../widgets/timezone_picker.rs} | 30 +++++++------------ 17 files changed, 38 insertions(+), 34 deletions(-) rename crates/fosr-gui/src/shared/{ui_utils.rs => widgets/helpers.rs} (100%) create mode 100644 crates/fosr-gui/src/shared/widgets/mod.rs rename crates/fosr-gui/src/{timepicker => shared/widgets/time_picker}/button.rs (100%) rename crates/fosr-gui/src/{timepicker => shared/widgets/time_picker}/mod.rs (100%) rename crates/fosr-gui/src/{timepicker => shared/widgets/time_picker}/popup.rs (100%) rename crates/fosr-gui/src/{run/generation/ui_components.rs => shared/widgets/timezone_picker.rs} (83%) diff --git a/crates/fosr-gui/src/config_editor/host.rs b/crates/fosr-gui/src/config_editor/host.rs index a930e5f9..4785ba12 100644 --- a/crates/fosr-gui/src/config_editor/host.rs +++ b/crates/fosr-gui/src/config_editor/host.rs @@ -7,7 +7,7 @@ use crate::shared::constants::network::HOST_USAGE_DEFAULT; use crate::shared::constants::ui::{ PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_MD, SPACING_SM, }; -use crate::shared::ui_utils::{edit_optional_string, info_icon}; +use crate::shared::widgets::helpers::{edit_optional_string, info_icon}; use eframe::egui; use std::collections::HashMap; diff --git a/crates/fosr-gui/src/config_editor/host_interfaces.rs b/crates/fosr-gui/src/config_editor/host_interfaces.rs index 497da0ac..9f6582ce 100644 --- a/crates/fosr-gui/src/config_editor/host_interfaces.rs +++ b/crates/fosr-gui/src/config_editor/host_interfaces.rs @@ -7,7 +7,7 @@ use crate::shared::constants::network::{ IP_LOCAL_MAX, IP_LOCAL_MIN, MAC_ADDRESS_BYTES, MAC_LOCAL_BIT, MAC_LOCAL_MASK, }; use crate::shared::constants::ui::{SPACING_MD, SPACING_SM}; -use crate::shared::ui_utils::{edit_optional_string, required_label}; +use crate::shared::widgets::helpers::{edit_optional_string, required_label}; use eframe::egui; use std::collections::HashMap; diff --git a/crates/fosr-gui/src/config_editor/host_services.rs b/crates/fosr-gui/src/config_editor/host_services.rs index 85797b4a..9f94bd8e 100644 --- a/crates/fosr-gui/src/config_editor/host_services.rs +++ b/crates/fosr-gui/src/config_editor/host_services.rs @@ -5,7 +5,7 @@ use crate::shared::constants::network::{PORT_DEFAULT_UNKNOWN, PORT_MAX, PORT_MIN use crate::shared::constants::ui::{ PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_SM, SPACING_XS, }; -use crate::shared::ui_utils::info_icon; +use crate::shared::widgets::helpers::info_icon; use eframe::egui; pub const KNOWN_SERVICES: &[(&str, Option)] = &[ diff --git a/crates/fosr-gui/src/config_editor/tab.rs b/crates/fosr-gui/src/config_editor/tab.rs index 505da6fc..981b83cb 100644 --- a/crates/fosr-gui/src/config_editor/tab.rs +++ b/crates/fosr-gui/src/config_editor/tab.rs @@ -7,7 +7,7 @@ use crate::shared::config::model::Configuration; use crate::shared::config::state::ConfigurationFileState; use crate::shared::constants::colors::{COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING}; use crate::shared::constants::ui::{SPACING_MD, TEXT_EDIT_DEFAULT_ROWS}; -use crate::shared::ui_utils::{ +use crate::shared::widgets::helpers::{ edit_optional_multiline_string, edit_optional_string, required_label, }; use eframe::egui; diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index 4b0e633e..def5b37f 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -8,7 +8,6 @@ mod config_editor; mod config_templates; mod run; mod shared; -mod timepicker; use app::FosrApp; use eframe::wasm_bindgen::JsCast; diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index c278be09..ddc03021 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -7,7 +7,6 @@ mod config_editor; mod config_templates; mod run; mod shared; -mod timepicker; // Desktop: native compilation #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/fosr-gui/src/run/generation/mod.rs b/crates/fosr-gui/src/run/generation/mod.rs index 7b8001fc..2302f7a1 100644 --- a/crates/fosr-gui/src/run/generation/mod.rs +++ b/crates/fosr-gui/src/run/generation/mod.rs @@ -5,6 +5,5 @@ pub mod core; pub mod options; pub mod process; pub mod state; -pub mod ui_components; pub mod validation; pub mod wireshark; diff --git a/crates/fosr-gui/src/run/generation/options.rs b/crates/fosr-gui/src/run/generation/options.rs index dea9a9c2..cc6dd5d2 100644 --- a/crates/fosr-gui/src/run/generation/options.rs +++ b/crates/fosr-gui/src/run/generation/options.rs @@ -5,17 +5,18 @@ use crate::run::state::RunTabState; -use super::ui_components::{show_field_error, timezone_picker}; use super::validation::{ - first_invalid_param, validate_duration, validate_optional_u64, validate_timezone, + first_invalid_param, show_field_error, validate_duration, validate_optional_u64, + validate_timezone, }; use crate::shared::constants::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; use crate::shared::constants::ui::{ DURATION_TEXT_WIDTH, GENERATION_COL1_MIN_WIDTH, GENERATION_COL2_MIN_WIDTH, GENERATION_OPTIONS_COLUMNS, SEED_INPUT_WIDTH, SPACING_LG, }; -use crate::shared::ui_utils::info_icon; -use crate::timepicker::TimePickerButton; +use crate::shared::widgets::helpers::info_icon; +use crate::shared::widgets::time_picker::TimePickerButton; +use crate::shared::widgets::timezone_picker::timezone_picker; use chrono::{Datelike, Local, TimeZone}; use chrono_tz::Tz; use eframe::egui::{self, Widget}; @@ -110,7 +111,7 @@ pub fn show_generation_options(ui: &mut egui::Ui, state: &mut RunTabState) { 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.generation.use_local_timezone { - timezone_picker(ui, &mut state.generation); + timezone_picker(ui, &mut state.generation.timezone_input); let result = validate_timezone(&state.generation.timezone_input); if result.is_ok() { diff --git a/crates/fosr-gui/src/run/generation/validation.rs b/crates/fosr-gui/src/run/generation/validation.rs index 1b71b922..abe55c73 100644 --- a/crates/fosr-gui/src/run/generation/validation.rs +++ b/crates/fosr-gui/src/run/generation/validation.rs @@ -1,7 +1,10 @@ //! Input validation helpers for duration, seed, and timezone fields. use super::state::{DURATION_MAX, DURATION_MIN, GenerationState}; +use crate::shared::constants::colors::COLOR_ERROR; +use crate::shared::constants::ui::SPACING_MD; use chrono_tz::Tz; +use eframe::egui; use std::time::Duration; /// Structure to handle inputs errors from the user @@ -19,6 +22,14 @@ impl FieldValidation { } } +/// 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(SPACING_MD); + ui.colored_label(COLOR_ERROR, msg); + } +} + // Spec expected for each parameter const SPEC_DURATION: &str = "a duration between 1 min and 3 days (e.g. 30m, 1h, 2d)"; const SPEC_SEED: &str = "an unsigned integer (u64) or empty for random"; diff --git a/crates/fosr-gui/src/shared/config/file_ops.rs b/crates/fosr-gui/src/shared/config/file_ops.rs index 399b70f6..9f61fbaa 100644 --- a/crates/fosr-gui/src/shared/config/file_ops.rs +++ b/crates/fosr-gui/src/shared/config/file_ops.rs @@ -11,7 +11,7 @@ use crate::shared::constants::ui::SPACING_LG; 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::shared::ui_utils::labeled_toggle; +use crate::shared::widgets::helpers::labeled_toggle; use chrono::{DateTime, Local}; use eframe::egui; #[cfg(target_arch = "wasm32")] diff --git a/crates/fosr-gui/src/shared/mod.rs b/crates/fosr-gui/src/shared/mod.rs index dfc348df..a27dac01 100644 --- a/crates/fosr-gui/src/shared/mod.rs +++ b/crates/fosr-gui/src/shared/mod.rs @@ -4,4 +4,4 @@ pub mod assets; pub mod config; pub mod constants; pub mod file_io; -pub mod ui_utils; +pub mod widgets; diff --git a/crates/fosr-gui/src/shared/ui_utils.rs b/crates/fosr-gui/src/shared/widgets/helpers.rs similarity index 100% rename from crates/fosr-gui/src/shared/ui_utils.rs rename to crates/fosr-gui/src/shared/widgets/helpers.rs diff --git a/crates/fosr-gui/src/shared/widgets/mod.rs b/crates/fosr-gui/src/shared/widgets/mod.rs new file mode 100644 index 00000000..9884cdca --- /dev/null +++ b/crates/fosr-gui/src/shared/widgets/mod.rs @@ -0,0 +1,5 @@ +//! Shared widgets: time picker, timezone picker, and UI helpers. + +pub mod helpers; +pub mod time_picker; +pub mod timezone_picker; diff --git a/crates/fosr-gui/src/timepicker/button.rs b/crates/fosr-gui/src/shared/widgets/time_picker/button.rs similarity index 100% rename from crates/fosr-gui/src/timepicker/button.rs rename to crates/fosr-gui/src/shared/widgets/time_picker/button.rs diff --git a/crates/fosr-gui/src/timepicker/mod.rs b/crates/fosr-gui/src/shared/widgets/time_picker/mod.rs similarity index 100% rename from crates/fosr-gui/src/timepicker/mod.rs rename to crates/fosr-gui/src/shared/widgets/time_picker/mod.rs diff --git a/crates/fosr-gui/src/timepicker/popup.rs b/crates/fosr-gui/src/shared/widgets/time_picker/popup.rs similarity index 100% rename from crates/fosr-gui/src/timepicker/popup.rs rename to crates/fosr-gui/src/shared/widgets/time_picker/popup.rs diff --git a/crates/fosr-gui/src/run/generation/ui_components.rs b/crates/fosr-gui/src/shared/widgets/timezone_picker.rs similarity index 83% rename from crates/fosr-gui/src/run/generation/ui_components.rs rename to crates/fosr-gui/src/shared/widgets/timezone_picker.rs index fe1fd177..93adaa4e 100644 --- a/crates/fosr-gui/src/run/generation/ui_components.rs +++ b/crates/fosr-gui/src/shared/widgets/timezone_picker.rs @@ -1,4 +1,4 @@ -//! UI components for generation: timezone picker and field error display. +//! Timezone picker widget with search functionality. // The `timezone_picker` function is inspired by egui's ComboBox (combo_box.rs). // @@ -26,24 +26,14 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -use super::state::GenerationState; -use super::validation::FieldValidation; -use crate::shared::constants::colors::COLOR_ERROR; use crate::shared::constants::ui::{ - SPACING_MD, TIMEZONE_LIST_MAX_HEIGHT, TIMEZONE_PICKER_WIDTH, TIMEZONE_POPUP_MAX_HEIGHT, + TIMEZONE_LIST_MAX_HEIGHT, TIMEZONE_PICKER_WIDTH, TIMEZONE_POPUP_MAX_HEIGHT, }; use chrono_tz::TZ_VARIANTS; use eframe::egui::{self, epaint}; -/// 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(SPACING_MD); - ui.colored_label(COLOR_ERROR, msg); - } -} - -pub fn timezone_picker(ui: &mut egui::Ui, state: &mut GenerationState) { +/// Timezone picker with search functionality. +pub fn timezone_picker(ui: &mut egui::Ui, timezone_input: &mut String) { let popup_id = ui.make_persistent_id("tz_popup"); let is_open = egui::Popup::is_id_open(ui.ctx(), popup_id); @@ -88,7 +78,7 @@ pub fn timezone_picker(ui: &mut egui::Ui, state: &mut GenerationState) { // 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(), + timezone_input.clone(), egui::TextStyle::Button.resolve(ui.style()), visuals.text_color(), ); @@ -100,7 +90,7 @@ pub fn timezone_picker(ui: &mut egui::Ui, state: &mut GenerationState) { .galley(text_pos, galley, visuals.text_color()); } - let response = response.on_hover_text(&state.timezone_input); + let response = response.on_hover_text(&*timezone_input); // --- Popup (inspired by combo_box_dyn) --- egui::Popup::menu(&response) @@ -116,7 +106,7 @@ pub fn timezone_picker(ui: &mut egui::Ui, state: &mut GenerationState) { // 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) + egui::TextEdit::singleline(timezone_input) .hint_text("Search...") .id(edit_id), ); @@ -131,15 +121,15 @@ pub fn timezone_picker(ui: &mut egui::Ui, state: &mut GenerationState) { .max_height(TIMEZONE_LIST_MAX_HEIGHT) .show(ui, |ui| { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); - let filter = state.timezone_input.to_lowercase(); + let filter = 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) + .selectable_label(*timezone_input == tz_str, &tz_str) .clicked() { - state.timezone_input = tz_str; + *timezone_input = tz_str; ui.close(); } } From 23a1ea1a15cf750a7112495f3b689b16e536aa1d Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:46:17 +0100 Subject: [PATCH 121/127] refactor(gui): remove pub use re-exports and use direct imports --- crates/fosr-gui/src/app/mod.rs | 3 ++- crates/fosr-gui/src/run/generation/options.rs | 2 +- crates/fosr-gui/src/run/mod.rs | 7 ++----- crates/fosr-gui/src/shared/widgets/time_picker/mod.rs | 4 +--- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/crates/fosr-gui/src/app/mod.rs b/crates/fosr-gui/src/app/mod.rs index 60595231..1c36cd5f 100644 --- a/crates/fosr-gui/src/app/mod.rs +++ b/crates/fosr-gui/src/app/mod.rs @@ -8,7 +8,8 @@ mod top_bar; use crate::about_tab::show_about_tab_content; use crate::config_editor::state::ConfigurationTabState; use crate::config_editor::tab::show_configuration_tab_content; -use crate::run::{RunTabState, show_run_tab_content}; +use crate::run::state::RunTabState; +use crate::run::tab::show_run_tab_content; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_LOGO, IMG_SERVER}; use crate::shared::config::state::ConfigurationFileState; use crate::shared::constants::ui::{TOOLTIP_DELAY, ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN}; diff --git a/crates/fosr-gui/src/run/generation/options.rs b/crates/fosr-gui/src/run/generation/options.rs index cc6dd5d2..3f70cbdc 100644 --- a/crates/fosr-gui/src/run/generation/options.rs +++ b/crates/fosr-gui/src/run/generation/options.rs @@ -15,7 +15,7 @@ use crate::shared::constants::ui::{ GENERATION_OPTIONS_COLUMNS, SEED_INPUT_WIDTH, SPACING_LG, }; use crate::shared::widgets::helpers::info_icon; -use crate::shared::widgets::time_picker::TimePickerButton; +use crate::shared::widgets::time_picker::button::TimePickerButton; use crate::shared::widgets::timezone_picker::timezone_picker; use chrono::{Datelike, Local, TimeZone}; use chrono_tz::Tz; diff --git a/crates/fosr-gui/src/run/mod.rs b/crates/fosr-gui/src/run/mod.rs index 2c02487d..47ff2214 100644 --- a/crates/fosr-gui/src/run/mod.rs +++ b/crates/fosr-gui/src/run/mod.rs @@ -2,8 +2,5 @@ pub mod generation; pub mod graph; -mod state; -mod tab; - -pub use state::RunTabState; -pub use tab::show_run_tab_content; +pub mod state; +pub mod tab; diff --git a/crates/fosr-gui/src/shared/widgets/time_picker/mod.rs b/crates/fosr-gui/src/shared/widgets/time_picker/mod.rs index 43ca49b9..b7f030c3 100644 --- a/crates/fosr-gui/src/shared/widgets/time_picker/mod.rs +++ b/crates/fosr-gui/src/shared/widgets/time_picker/mod.rs @@ -31,7 +31,5 @@ // - 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; +pub mod button; mod popup; - -pub use button::TimePickerButton; From 1af85462984fdf76c8a46413a05e4153d4de0480 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:01:46 +0100 Subject: [PATCH 122/127] refactor(gui): split VisualizationState and FosrApp's update method + update README --- crates/fosr-gui/Cargo.toml | 5 +- crates/fosr-gui/README.md | 68 ++++-- crates/fosr-gui/src/app/mod.rs | 111 ++++++--- crates/fosr-gui/src/lib.rs | 11 +- crates/fosr-gui/src/main.rs | 7 +- crates/fosr-gui/src/modules.rs | 8 + .../src/run/generation/bottom_panel.rs | 2 +- .../fosr-gui/src/run/graph/config_handling.rs | 10 +- .../fosr-gui/src/run/graph/flow_processing.rs | 44 ++-- crates/fosr-gui/src/run/graph/node_modal.rs | 34 +-- crates/fosr-gui/src/run/graph/overlays.rs | 26 +-- crates/fosr-gui/src/run/graph/screenshot.rs | 12 +- crates/fosr-gui/src/run/graph/shapes.rs | 89 ++++--- crates/fosr-gui/src/run/graph/state.rs | 218 ++++++++++-------- crates/fosr-gui/src/run/graph/view.rs | 44 ++-- crates/fosr-gui/src/run/tab.rs | 12 +- crates/fosr-gui/src/shared/config/model.rs | 2 +- crates/fosr-gui/src/shared/widgets/helpers.rs | 2 +- 18 files changed, 422 insertions(+), 283 deletions(-) create mode 100644 crates/fosr-gui/src/modules.rs diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index 8c7f9436..942cfc30 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -52,7 +52,10 @@ web-sys = { version = "0.3.85", features = ["Element", "Document", "Window"] } getrandom = { version = "0.4", features = ["wasm_js"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -env_logger = { version = "0.11.8", default-features = false, features = ["auto-color", "humantime"] } +env_logger = { version = "0.11.8", default-features = false, features = [ + "auto-color", + "humantime", +] } tempfile = "3.26.0" open = "5.3.3" which = "8.0.2" diff --git a/crates/fosr-gui/README.md b/crates/fosr-gui/README.md index b67007eb..f3c5def9 100644 --- a/crates/fosr-gui/README.md +++ b/crates/fosr-gui/README.md @@ -2,35 +2,50 @@ ## Architecture +### Module Overview + +| Module | Purpose | +| ------------------- | ------------------------------------------------------------- | +| `app/` | Core application: tab navigation, startup modal, close dialog | +| `config_editor/` | Visual and YAML configuration editor | +| `run/` | PCAP generation + live network visualization | +| `shared/` | Reusable: config model, constants, widgets | +| `config_templates/` | Pre-built network configuration templates | +| `about_tab.rs` | About page content | + ### File Structure + ``` Fos-R/ ├── crates/ │ └── fosr-gui/ │ ├── src/ -│ │ ├── main.rs # Desktop entry point (native binary) -│ │ ├── lib.rs # Web entry point (WASM export) -│ │ ├── app.rs # Core application logic (shared) -│ │ └── ui/ # UI components +│ │ ├── main.rs # Desktop entry point (native binary) +│ │ ├── lib.rs # Web entry point (WASM export) +│ │ ├── app/ # Core application +│ │ ├── config_editor/ # Configuration editor +│ │ ├── run/ # Generation + visualization +│ │ │ ├── generation/ # PCAP generation logic +│ │ │ └── graph/ # Network graph visualization +│ │ ├── shared/ # Reusable components +│ │ │ ├── config/ # Config model and state +│ │ │ ├── constants/ # Colors, UI constants +│ │ │ └── widgets/ # Reusable UI widgets +│ │ └── config_templates/ # Pre-built templates │ └── Cargo.toml └── public/ - ├── index.html # Web interface + ├── index.html # Web interface └── (generated WASM and JS files) ``` -### How It Works - -- `main.rs`: Desktop-only. Runs a native window via `eframe`. -- `lib.rs`: WASM-only. Exports a `start()` function callable from JavaScript. -- `app.rs`: Shared UI logic between desktop and web builds. -- `index.html`: Loads the WASM binary and initializes the app in a canvas element. - ## Building & Running ### Desktop (Native) + ```shell cargo run -p fosr-gui -r ``` + This compiles and launches the native GUI. #### macOS Bundling @@ -45,53 +60,66 @@ cargo bundle -p fosr-gui -r -f osx ### Web (WASM) #### Step 0: Requirements + You need to have the wasm32 toolchain and the `wasm-bindgen` CLI installed: + ```shell rustup target add wasm32-unknown-unknown cargo install wasm-bindgen-cli ``` +Note: the version of the `wasm-bindgen` CLI must match the version of the crate declared in `Cargo.toml`. + #### Step 1: Compile to WASM + ```shell cargo build -p fosr-gui -r --target wasm32-unknown-unknown --no-default-features ``` + Output: `Fos-R/target/wasm32-unknown-unknown/release/fosr_gui.wasm` #### Step 2: Generate JavaScript Glue + ```shell wasm-bindgen --out-dir public --target web target/wasm32-unknown-unknown/release/fosr_gui.wasm --no-typescript ``` + Generates in `Fos-R/public/`: + - `fosr_gui.js` - JavaScript glue code - `fosr_gui_bg.wasm` - WASM binary ### Step 3: Embedding in the HTML file #### HTML Structure + ```html
- +
``` #### JavaScript + ```html ``` ### Step 4: Serve with an HTTP server + Here is an example using `http-server`: + ```shell # Install http-server npm install -g http-server # Serve from project root http-server ./public -p 8080 -``` \ No newline at end of file +``` diff --git a/crates/fosr-gui/src/app/mod.rs b/crates/fosr-gui/src/app/mod.rs index 1c36cd5f..f2755b62 100644 --- a/crates/fosr-gui/src/app/mod.rs +++ b/crates/fosr-gui/src/app/mod.rs @@ -1,4 +1,7 @@ //! Main Fos-R application: tab navigation, startup modal, and app state. +//! +//! egui immediate mode: `update()` is called every frame, +//! you describe what to show, and state persists in the struct between frames. #[cfg(not(target_arch = "wasm32"))] mod close_dialog; @@ -36,8 +39,9 @@ pub struct FosrApp { allowed_to_close: bool, } -impl eframe::App for FosrApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { +impl FosrApp { + /// Initialize style and zoom settings once on first frame. + fn init_style_once(&mut self, ctx: &egui::Context) { // Set default zoom once if !self.style_initialized { self.zoom_factor = ZOOM_DEFAULT; @@ -50,7 +54,10 @@ impl eframe::App for FosrApp { self.style_initialized = true; } + } + /// Clamp zoom to min/max bounds (prevents Ctrl+/- from exceeding limits). + fn clamp_zoom(&mut self, ctx: &egui::Context) { // Clamp zoom to min/max (prevents Ctrl+/- from exceeding limits) let current_zoom = ctx.zoom_factor(); if current_zoom < ZOOM_MIN || current_zoom > ZOOM_MAX { @@ -58,11 +65,10 @@ impl eframe::App for FosrApp { ctx.set_zoom_factor(clamped_zoom); self.zoom_factor = clamped_zoom; } + } - // Set the image loaders - // Required for egui to display images - egui_extras::install_image_loaders(ctx); - + /// Preload images once to avoid spinners/fallbacks on first visit. + fn preload_images_once(&mut self, ctx: &egui::Context) { // Preload all images to avoid spinners/fallbacks on first visit if !self.images_preloaded { let _ = IMG_SERVER.load(ctx, Default::default(), Default::default()); @@ -71,43 +77,31 @@ impl eframe::App for FosrApp { let _ = IMG_LOGO.load(ctx, Default::default(), Default::default()); self.images_preloaded = true; } + } + /// Handle close confirmation when there are active Wireshark sessions. + #[cfg(not(target_arch = "wasm32"))] + fn handle_close_confirmation(&mut self, ctx: &egui::Context) { // Handle close confirmation if there are active Wireshark sessions - #[cfg(not(target_arch = "wasm32"))] - { - let has_active_sessions = self - .run_tab_state - .generation - .temp_pcap_files - .iter() - .any(|(handle, _)| !handle.is_finished()); - - if ctx.input(|i| i.viewport().close_requested()) { - if self.allowed_to_close { - // Do nothing, let the app close - } else if has_active_sessions { - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - self.show_close_confirmation = true; - } - } - } + let has_active_sessions = self + .run_tab_state + .generation + .temp_pcap_files + .iter() + .any(|(handle, _)| !handle.is_finished()); - // Startup modal: choose configuration source - if !self.configuration_file_state.config_chosen { - // Render empty CentralPanel for background, then modal on top - egui::CentralPanel::default().show(ctx, |_ui| {}); - render_startup_modal(ctx, &mut self.configuration_file_state); - return; + if ctx.input(|i| i.viewport().close_requested()) { + if self.allowed_to_close { + // Do nothing, let the app close + } else if has_active_sessions { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.show_close_confirmation = true; + } } + } - // Close confirmation dialog - #[cfg(not(target_arch = "wasm32"))] - render_close_confirmation_dialog( - ctx, - &mut self.show_close_confirmation, - &mut self.allowed_to_close, - ); - + /// Render the top bar and update internal state from user interactions. + fn render_top_bar(&mut self, ctx: &egui::Context) { // Render top bar and get updated state let top_bar_state = TopBarState { current_tab: self.current_tab, @@ -117,7 +111,10 @@ impl eframe::App for FosrApp { let updated_state = render_top_bar(ctx, top_bar_state); self.current_tab = updated_state.current_tab; self.zoom_factor = updated_state.zoom_factor; + } + /// Render the current tab content in the central panel. + fn render_current_tab(&mut self, ctx: &egui::Context) { // 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 @@ -150,3 +147,41 @@ impl eframe::App for FosrApp { }); } } + +impl eframe::App for FosrApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // One-time initialization + self.init_style_once(ctx); + self.clamp_zoom(ctx); + + // Set the image loaders + // Required for egui to display images + egui_extras::install_image_loaders(ctx); + + self.preload_images_once(ctx); + + // Handle close confirmation (native only) + #[cfg(not(target_arch = "wasm32"))] + self.handle_close_confirmation(ctx); + + // Startup modal: choose configuration source + if !self.configuration_file_state.config_chosen { + // Render empty CentralPanel for background, then modal on top + egui::CentralPanel::default().show(ctx, |_ui| {}); + render_startup_modal(ctx, &mut self.configuration_file_state); + return; + } + + // Close confirmation dialog (native only) + #[cfg(not(target_arch = "wasm32"))] + render_close_confirmation_dialog( + ctx, + &mut self.show_close_confirmation, + &mut self.allowed_to_close, + ); + + // Main UI + self.render_top_bar(ctx); + self.render_current_tab(ctx); + } +} diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index def5b37f..5ac9bfc5 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -2,12 +2,7 @@ //! Initializes eframe and starts the app in the browser canvas. #![cfg(target_arch = "wasm32")] -mod about_tab; -mod app; -mod config_editor; -mod config_templates; -mod run; -mod shared; +include!("modules.rs"); use app::FosrApp; use eframe::wasm_bindgen::JsCast; @@ -16,7 +11,9 @@ 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::Info).ok(); + if let Err(e) = eframe::WebLogger::init(log::LevelFilter::Info) { + log::warn!("WebLogger initialization failed: {:?}", e); + } let web_options = eframe::WebOptions::default(); diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index ddc03021..6bc5e089 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -1,12 +1,7 @@ //! Entry point for the Fos-R GUI application. //! Compiles natively for desktop; delegates to lib.rs for WASM builds. -mod about_tab; -mod app; -mod config_editor; -mod config_templates; -mod run; -mod shared; +include!("modules.rs"); // Desktop: native compilation #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/fosr-gui/src/modules.rs b/crates/fosr-gui/src/modules.rs new file mode 100644 index 00000000..247e8c7e --- /dev/null +++ b/crates/fosr-gui/src/modules.rs @@ -0,0 +1,8 @@ +/// Shared module declarations for both native and WASM builds. + +mod about_tab; +mod app; +mod config_editor; +mod config_templates; +mod run; +mod shared; diff --git a/crates/fosr-gui/src/run/generation/bottom_panel.rs b/crates/fosr-gui/src/run/generation/bottom_panel.rs index 146a0ca6..e824cb55 100644 --- a/crates/fosr-gui/src/run/generation/bottom_panel.rs +++ b/crates/fosr-gui/src/run/generation/bottom_panel.rs @@ -214,7 +214,7 @@ pub fn show_bottom_panel( .clicked() { state.panel_open = !state.panel_open; - state.visualization.delayed_fit_countdown = Some(DELAY_FRAMES_QUICK); + state.visualization.view.delayed_fit_countdown = Some(DELAY_FRAMES_QUICK); } // Throughput (when complete) - left of Options diff --git a/crates/fosr-gui/src/run/graph/config_handling.rs b/crates/fosr-gui/src/run/graph/config_handling.rs index 2990bd94..aa246522 100644 --- a/crates/fosr-gui/src/run/graph/config_handling.rs +++ b/crates/fosr-gui/src/run/graph/config_handling.rs @@ -34,12 +34,12 @@ pub fn handle_config_changes( if should_reset { // Stop visualization if running, then reset to default - if state.visualization_running { + if state.flow.running { state.stop_visualization(); } state.config_content = None; *state = VisualizationState::default(); - state.reset_view_requested = true; + state.view.reset_requested = true; log::warn!("Config removed or empty, visualization reset to default"); return; } @@ -62,7 +62,7 @@ pub fn handle_config_changes( if needs_update { if let Some(config_content) = &configuration_file_state.config_file_content { // Stop visualization if running before updating config - let was_running = state.visualization_running; + let was_running = state.flow.running; if was_running { state.stop_visualization(); } @@ -80,7 +80,7 @@ pub fn handle_config_changes( if was_running { state.auto_start_countdown = Some(DELAY_FRAMES_NORMAL); } - state.reset_view_requested = true; + state.view.reset_requested = true; } Err(e) => { // Log the error once and reset to default state instead of crashing @@ -88,7 +88,7 @@ pub fn handle_config_changes( log::error!("Failed to parse configuration: {:?}", e); *state = VisualizationState::default(); state.config_content = Some(config_content.clone()); - state.reset_view_requested = true; + state.view.reset_requested = true; } } } diff --git a/crates/fosr-gui/src/run/graph/flow_processing.rs b/crates/fosr-gui/src/run/graph/flow_processing.rs index ac74d065..28a5a343 100644 --- a/crates/fosr-gui/src/run/graph/flow_processing.rs +++ b/crates/fosr-gui/src/run/graph/flow_processing.rs @@ -16,7 +16,7 @@ use crate::shared::constants::ui::ACTIVE_LINK_BASE_TIMEOUT_MS; /// /// Flows between two unknown IPs (Internet<->Internet) are filtered out. pub fn process_flow_events(state: &mut VisualizationState) { - let events: Vec = if let Some(ref receiver) = state.flow_receiver { + let events: Vec = if let Some(ref receiver) = state.flow.receiver { receiver.try_iter().collect() } else { return; @@ -47,7 +47,7 @@ pub fn process_flow_events(state: &mut VisualizationState) { } // Increment total flows counter - state.total_flows += 1; + state.flow.total_flows += 1; // Map IPs to display IPs (unknown -> INTERNET_IP) let display_src = if src_known { event.src_ip } else { INTERNET_IP }; @@ -63,13 +63,13 @@ pub fn process_flow_events(state: &mut VisualizationState) { let key = (display_src, display_dst); let reverse_key = (display_dst, display_src); - let direction = if state.active_links.contains_key(&reverse_key) { + let direction = if state.flow.active_links.contains_key(&reverse_key) { LinkDirection::Bidirectional } else { LinkDirection::Forward }; - state.active_links.insert( + state.flow.active_links.insert( key, ActiveLink { protocol: event.protocol, @@ -80,26 +80,27 @@ pub fn process_flow_events(state: &mut VisualizationState) { // 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), + state.network.ip_to_node.get(&display_src), + state.network.ip_to_node.get(&display_dst), ) { // Find the edge (undirected graph, so check both directions) let edge_idx = state + .network .graph .g() .find_edge(src_idx, dst_idx) - .or_else(|| state.graph.g().find_edge(dst_idx, src_idx)); + .or_else(|| state.network.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) { + if let Some(node) = state.network.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) { + if let Some(node) = state.network.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) { + if let Some(edge) = state.network.graph.g_mut().edge_weight_mut(edge_idx) { edge.payload_mut().flow_count += 1; } } @@ -108,32 +109,34 @@ pub fn process_flow_events(state: &mut VisualizationState) { // Update max_flow_count for all nodes (for proportional sizing) let max_node_flow = state + .network .graph .g() .node_indices() - .filter_map(|idx| state.graph.g().node_weight(idx)) + .filter_map(|idx| state.network.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) { + for idx in state.network.graph.g().node_indices().collect::>() { + if let Some(node) = state.network.graph.g_mut().node_weight_mut(idx) { node.payload_mut().max_flow_count = max_node_flow; } } // Update max_flow_count for all edges (for proportional sizing) let max_edge_flow = state + .network .graph .g() .edge_indices() - .filter_map(|idx| state.graph.g().edge_weight(idx)) + .filter_map(|idx| state.network.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) { + for idx in state.network.graph.g().edge_indices().collect::>() { + if let Some(edge) = state.network.graph.g_mut().edge_weight_mut(idx) { edge.payload_mut().max_flow_count = max_edge_flow; } } @@ -146,10 +149,11 @@ pub fn update_active_links(state: &mut VisualizationState) { let now = web_time::Instant::now(); // Base display time is 0.5s, adjusted by speed (faster = shorter display) let base_timeout_ms = ACTIVE_LINK_BASE_TIMEOUT_MS; - let speed = *state.speed.read().unwrap(); + let speed = *state.flow.speed.read().unwrap(); let timeout = std::time::Duration::from_millis((base_timeout_ms / speed) as u64); state + .flow .active_links .retain(|_, link| now.duration_since(link.start_time) < timeout); } @@ -159,7 +163,7 @@ pub fn update_active_links(state: &mut VisualizationState) { /// For each edge, checks if any IP combination has an active link /// and updates the edge's visual state accordingly. pub fn update_graph_edges(state: &mut VisualizationState) { - let graph = &mut state.graph; + let graph = &mut state.network.graph; // Collect edge info first to avoid borrow issues // Each node can have multiple IPs, so we collect all IP lists for matching @@ -187,14 +191,14 @@ pub fn update_graph_edges(state: &mut VisualizationState) { let forward_key = (*src_ip, *dst_ip); let reverse_key = (*dst_ip, *src_ip); - if let Some(link) = state.active_links.get(&forward_key) { + if let Some(link) = state.flow.active_links.get(&forward_key) { 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) { + } else if let Some(link) = state.flow.active_links.get(&reverse_key) { new_state = EdgeState::Active { protocol: link.protocol, start_time: link.start_time, diff --git a/crates/fosr-gui/src/run/graph/node_modal.rs b/crates/fosr-gui/src/run/graph/node_modal.rs index 61633f07..e58aec32 100644 --- a/crates/fosr-gui/src/run/graph/node_modal.rs +++ b/crates/fosr-gui/src/run/graph/node_modal.rs @@ -15,17 +15,17 @@ pub fn process_graph_events( state: &mut VisualizationState, configuration_file_state: &ConfigurationFileState, ) { - let events: Vec = state.events_buffer.borrow_mut().drain(..).collect(); + let events: Vec = state.modal.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; + state.modal.clicked_node = Some(node_idx); + state.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| { + let host_idx = state.network.node_to_host.get(&node_idx).copied(); + state.modal.edit_buffer = host_idx.and_then(|idx| { configuration_file_state .config_model .as_ref() @@ -41,23 +41,23 @@ pub fn render_node_info_modal( state: &mut VisualizationState, config_file_state: &mut ConfigurationFileState, ) { - if !state.node_info_modal_open { + if !state.modal.open { return; } - let Some(node_idx) = state.clicked_node else { + let Some(node_idx) = state.modal.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; + let Some(node) = state.network.graph.g().node_weight(node_idx) else { + state.modal.open = false; + state.modal.clicked_node = None; return; }; 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 host_idx = state.network.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| { @@ -93,7 +93,7 @@ pub fn render_node_info_modal( ui.add_space(SPACING_SM); // Editable fields if we have an edit buffer (config loaded and host found) - if let Some(ref mut host) = state.modal_edit_buffer { + if let Some(ref mut host) = state.modal.edit_buffer { // Hostname ui.horizontal(|ui| { ui.label("Hostname:"); @@ -200,7 +200,7 @@ pub fn render_node_info_modal( // 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(idx), Some(buffer)) = (host_idx, state.modal.edit_buffer.take()) { if let Some(host) = config_file_state .config_model .as_mut() @@ -219,8 +219,8 @@ pub fn render_node_info_modal( // 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; + state.modal.open = false; + state.modal.clicked_node = None; + state.modal.edit_buffer = None; } } diff --git a/crates/fosr-gui/src/run/graph/overlays.rs b/crates/fosr-gui/src/run/graph/overlays.rs index 01a2d206..a6105bef 100644 --- a/crates/fosr-gui/src/run/graph/overlays.rs +++ b/crates/fosr-gui/src/run/graph/overlays.rs @@ -1,6 +1,6 @@ //! Graph overlay UI: control buttons, stats display, and legends for nodes/edges. -use super::state::VisualizationState; +use super::state::{ScreenshotStateMachine, VisualizationState}; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; use crate::shared::constants::colors::{ COLOR_EDGE_INACTIVE, COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT, COLOR_PROTOCOL_DNS, @@ -56,7 +56,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) .shadow(egui::epaint::Shadow::NONE) .show(ui, |ui| { ui.horizontal(|ui| { - if !state.visualization_running { + if !state.flow.running { // Play / Continue: resume without resetting flow counts let play_text = if state.user_has_started { "Continue" @@ -69,12 +69,12 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) egui_material_icons::icons::ICON_PLAY_ARROW, play_text ))) - .fill(accent); + .fill(accent); if ui.add(play_button).clicked() { state.user_has_started = true; // Pass the user config if loaded, otherwise None (uses default BN model) let config = state.config_content.clone(); - let speed = state.speed.clone(); + let speed = state.flow.speed.clone(); if let Err(e) = state.start_visualization(config.as_deref(), speed, false) { @@ -90,7 +90,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) .clicked() { let config = state.config_content.clone(); - let speed = state.speed.clone(); + let speed = state.flow.speed.clone(); if let Err(e) = state.start_visualization(config.as_deref(), speed, true) { @@ -103,7 +103,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) "{} Stop", egui_material_icons::icons::ICON_STOP ))) - .fill(COLOR_STOP); + .fill(COLOR_STOP); if ui.add(stop_button).clicked() { state.stop_visualization(); } @@ -113,14 +113,14 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) .on_hover_text("Fit to screen") .clicked() { - state.reset_view_requested = true; + state.view.reset_requested = true; } if ui .button(egui_material_icons::icons::ICON_IMAGE) .on_hover_text("Export as PNG") .clicked() { - state.export_state = super::state::ExportState::HidingOverlays; + state.screenshot_export = ScreenshotStateMachine::HidingOverlays; } ui.separator(); @@ -128,7 +128,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) // Playback speed: −/+ buttons with discrete steps // 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 mut speed_value = *state.flow.speed.read().unwrap(); let current_idx = PLAYBACK_SPEED_STEPS .iter() .position(|&s| (s - speed_value).abs() < PLAYBACK_SPEED_EPSILON); @@ -141,7 +141,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) if let Some(idx) = current_idx { if idx > 0 { speed_value = PLAYBACK_SPEED_STEPS[idx - 1]; - *state.speed.write().unwrap() = speed_value; + *state.flow.speed.write().unwrap() = speed_value; } } } @@ -156,7 +156,7 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) if let Some(idx) = current_idx { if idx < PLAYBACK_SPEED_STEPS.len() - 1 { speed_value = PLAYBACK_SPEED_STEPS[idx + 1]; - *state.speed.write().unwrap() = speed_value; + *state.flow.speed.write().unwrap() = speed_value; } } } @@ -178,12 +178,12 @@ pub fn render_overlay_stats(ui: &mut egui::Ui, state: &VisualizationState) { .shadow(egui::epaint::Shadow::NONE) .show(ui, |ui| { ui.horizontal(|ui| { - ui.label(format!("Active: {}", state.active_links.len())) + ui.label(format!("Active: {}", state.flow.active_links.len())) .on_hover_text( "Number of network links currently transmitting data.", ); ui.separator(); - ui.label(format!("Total flows: {}", state.total_flows)) + ui.label(format!("Total flows: {}", state.flow.total_flows)) .on_hover_text( "Cumulative number of flows generated since the simulation started.", ); diff --git a/crates/fosr-gui/src/run/graph/screenshot.rs b/crates/fosr-gui/src/run/graph/screenshot.rs index 5ff18470..c22d640c 100644 --- a/crates/fosr-gui/src/run/graph/screenshot.rs +++ b/crates/fosr-gui/src/run/graph/screenshot.rs @@ -1,6 +1,6 @@ //! Graph screenshot export with a 2-frame state machine for clean PNG output. -use super::state::ExportState; +use super::state::ScreenshotStateMachine; use super::state::VisualizationState; use eframe::egui; @@ -11,8 +11,8 @@ use eframe::egui; /// - Frame N+2: screenshot received → extract graph region → save → Idle pub fn handle_screenshot_export(ui: &mut egui::Ui, state: &mut VisualizationState) { // Transition: HidingOverlays → WaitingForScreenshot (request screenshot) - if state.export_state == ExportState::HidingOverlays { - state.export_state = ExportState::WaitingForScreenshot; + if state.screenshot_export == ScreenshotStateMachine::HidingOverlays { + state.screenshot_export = ScreenshotStateMachine::WaitingForScreenshot; ui.ctx() .send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); } @@ -21,14 +21,14 @@ pub fn handle_screenshot_export(ui: &mut egui::Ui, state: &mut VisualizationStat ui.input(|i| { for event in &i.raw.events { if let egui::Event::Screenshot { image, .. } = event { - if state.export_state == ExportState::WaitingForScreenshot { - if let Some(graph_rect) = state.graph_rect { + if state.screenshot_export == ScreenshotStateMachine::WaitingForScreenshot { + if let Some(graph_rect) = state.view.graph_rect { let graph_image = image.region(&graph_rect, Some(i.pixels_per_point())); save_graph_png(&graph_image); } else { log::error!("No graph rect stored for screenshot export"); } - state.export_state = ExportState::Idle; + state.screenshot_export = ScreenshotStateMachine::Idle; } } } diff --git a/crates/fosr-gui/src/run/graph/shapes.rs b/crates/fosr-gui/src/run/graph/shapes.rs index ead7fcfc..e62436cc 100644 --- a/crates/fosr-gui/src/run/graph/shapes.rs +++ b/crates/fosr-gui/src/run/graph/shapes.rs @@ -1,4 +1,10 @@ //! Custom node and edge shapes with protocol colors, icons, and dynamic sizing. +//! +//! egui_graphs rendering: +//! - `DisplayNode` trait: defines how a node is drawn (shapes, labels, icons) +//! - `DisplayEdge` trait: defines how an edge is drawn (lines, arrows, colors) +//! - `closest_boundary_point`: where edges connect to the node boundary +//! - `is_inside`: hit-testing for clicking and dragging use super::state::{EdgeData, EdgeState, LinkDirection, NodeData, NodeType}; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; @@ -15,6 +21,58 @@ use eframe::egui::{self, Color32, Pos2, Rect, Shape, TextureOptions, Vec2, load: use egui_graphs::{DisplayEdge, DisplayNode, DrawContext, Node, NodeProps}; use fosr_lib::L7Proto; +// ============================================================================ +// HYBRID SCALING FUNCTIONS +// ============================================================================ + +/// Calculate node radius using hybrid linear/proportional scaling. +/// +/// The scaling works in two phases: +/// 1. **Linear phase**: While the maximum possible radius is below `NODE_RADIUS_MAX`, +/// each node grows proportionally to its flow count. +/// 2. **Proportional phase**: Once we would exceed `NODE_RADIUS_MAX`, switch to +/// ratio-based scaling so the most active node is always at max size. +/// +/// This ensures nodes grow smoothly at low traffic, but remain comparable at high traffic. +fn calculate_node_radius(flow_count: u32, max_flow_count: u32) -> f32 { + let max_linear = NODE_RADIUS_MIN + max_flow_count as f32 * NODE_FLOW_SCALE_FACTOR; + + if max_linear < NODE_RADIUS_MAX { + // Linear phase: everyone grows normally + NODE_RADIUS_MIN + flow_count as f32 * NODE_FLOW_SCALE_FACTOR + } else { + // Proportional phase: scale by ratio to max + let ratio = if max_flow_count > 0 { + flow_count as f32 / max_flow_count as f32 + } else { + 0.0 + }; + NODE_RADIUS_MIN + ratio * (NODE_RADIUS_MAX - NODE_RADIUS_MIN) + } +} + +/// Calculate edge width using hybrid linear/proportional scaling. +/// +/// Uses the same two-phase approach as `calculate_node_radius`: +/// 1. **Linear phase**: Edges grow proportionally while below `EDGE_WIDTH_MAX`. +/// 2. **Proportional phase**: Ratio-based scaling to keep the busiest edge at max width. +fn calculate_edge_width(flow_count: u32, max_flow_count: u32) -> f32 { + let max_linear = EDGE_WIDTH_MIN + max_flow_count as f32 * EDGE_FLOW_SCALE; + + if max_linear < EDGE_WIDTH_MAX { + // Linear phase: all edges grow normally + EDGE_WIDTH_MIN + flow_count as f32 * EDGE_FLOW_SCALE + } else { + // Proportional phase: scale by ratio to max + let ratio = if max_flow_count > 0 { + flow_count as f32 / max_flow_count as f32 + } else { + 0.0 + }; + EDGE_WIDTH_MIN + ratio * (EDGE_WIDTH_MAX - EDGE_WIDTH_MIN) + } +} + /// Custom node shape that displays hostname and IP, with icon based on node type #[derive(Clone)] pub struct NetworkNodeShape { @@ -28,21 +86,7 @@ pub struct NetworkNodeShape { impl NetworkNodeShape { /// Compute node style from payload data. fn style_from_payload(payload: &NodeData) -> (f32, NodeType, Option, Vec) { - // Hybrid linear/proportional radius scaling - let max_linear = NODE_RADIUS_MIN + payload.max_flow_count as f32 * NODE_FLOW_SCALE_FACTOR; - let radius = if max_linear < NODE_RADIUS_MAX { - // Linear phase: everyone grows normally - NODE_RADIUS_MIN + payload.flow_count as f32 * NODE_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 - }; - NODE_RADIUS_MIN + ratio * (NODE_RADIUS_MAX - NODE_RADIUS_MIN) - }; - + let radius = calculate_node_radius(payload.flow_count, payload.max_flow_count); let ips: Vec = payload.ip_addrs.iter().map(|ip| ip.to_string()).collect(); ( radius, @@ -179,20 +223,7 @@ impl DisplayNode (Color32, f32, bool, bool) { 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) - }; + let width = calculate_edge_width(edge_data.flow_count, edge_data.max_flow_count); (COLOR_EDGE_INACTIVE, width, false, false) } EdgeState::Active { diff --git a/crates/fosr-gui/src/run/graph/state.rs b/crates/fosr-gui/src/run/graph/state.rs index 2ce312e2..6fef3e95 100644 --- a/crates/fosr-gui/src/run/graph/state.rs +++ b/crates/fosr-gui/src/run/graph/state.rs @@ -4,9 +4,11 @@ use super::shapes::{NetworkEdgeShape, NetworkNodeShape}; use super::stream::{FlowEvent, FlowStreamer}; use super::utils::distribute_nodes_circle; use crate::shared::config::model::Host; +use crate::shared::constants::ui::DELAY_FRAMES_QUICK; use eframe::egui; use egui_graphs::events::Event; use fosr_lib::{L7Proto, OS, config, config::HostType}; +use petgraph::graph::NodeIndex; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::fmt; @@ -163,7 +165,7 @@ pub type VisualizationGraph = egui_graphs::Graph< /// State machine for screenshot export #[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] -pub enum ExportState { +pub enum ScreenshotStateMachine { #[default] Idle, /// Hide overlays on next frame before taking screenshot @@ -172,85 +174,121 @@ pub enum ExportState { WaitingForScreenshot, } -/// Represents the state of the visualization tab. -pub struct VisualizationState { +/// Network structure and IP/Node lookups +pub struct NetworkData { pub graph: VisualizationGraph, - pub flow_receiver: Option>, + pub known_ips: HashSet, + pub ip_to_node: HashMap, + pub node_to_host: HashMap, +} + +impl Default for NetworkData { + fn default() -> Self { + Self { + graph: VisualizationGraph::new(petgraph::stable_graph::StableGraph::default()), + known_ips: HashSet::new(), + ip_to_node: HashMap::new(), + node_to_host: HashMap::new(), + } + } +} + +/// Flow processing and streaming state +pub struct FlowState { + pub receiver: Option>, pub active_links: HashMap<(Ipv4Addr, Ipv4Addr), ActiveLink>, - pub visualization_running: bool, - pub config_content: Option, pub streamer: Option, - pub layout_initialized: bool, - /// Set of known IPs from the configuration (for filtering Internet flows) - pub known_ips: HashSet, - /// Map from IP to node index for quick lookup - pub ip_to_node: HashMap, - /// Visualization start time (for timestamp-based flow display) - pub visualization_start: Option, - /// Speed multiplier (0.5 to 4.0) - shared for runtime updates + pub running: bool, pub speed: Arc>, - /// Buffer for graph events (clicks, etc.) - pub events_buffer: Rc>>, - /// Clicked node for info modal display - pub clicked_node: Option, - /// Node info modal open state - pub node_info_modal_open: bool, - /// Map from graph NodeIndex to config_model.hosts index - pub 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. - /// Note: 10 frames is an arbitrary value that gives enough time for the UI to render and images to load. - pub auto_start_countdown: Option, - /// Total number of flows processed since visualization started pub total_flows: u32, - /// Flag to request a zoom/pan reset on the next frame - pub reset_view_requested: bool, - /// Countdown to delay fit-to-screen (waiting for layout to settle, e.g., after panel toggle or on initial load) + pub visualization_start: Option, +} + +impl Default for FlowState { + fn default() -> Self { + Self { + receiver: None, + active_links: HashMap::new(), + streamer: None, + running: false, + speed: Arc::new(RwLock::new(1.0)), + total_flows: 0, + visualization_start: None, + } + } +} + +/// Layout and rendering state +pub struct ViewState { + pub layout_initialized: bool, + pub reset_requested: bool, pub delayed_fit_countdown: Option, - /// Previous screen size (to reset view on window resize) pub last_screen_size: Option, - /// Whether the user has manually started the visualization at least once. - /// Auto-restart on config change is only enabled after this. - pub user_has_started: bool, - /// Edit buffer for the node info modal (cloned from config on open, applied on Save) - pub modal_edit_buffer: Option, - /// The rect of the graph panel (updated each frame, used for screenshot region) pub graph_rect: Option, - /// Screenshot export state machine - pub export_state: ExportState, } -impl Default for VisualizationState { +impl Default for ViewState { fn default() -> Self { - // 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, - active_links: HashMap::new(), - visualization_running: false, - config_content: None, - streamer: None, layout_initialized: false, - known_ips: HashSet::new(), - ip_to_node: HashMap::new(), - visualization_start: None, - speed: Arc::new(RwLock::new(1.0)), + reset_requested: false, + delayed_fit_countdown: Some(DELAY_FRAMES_QUICK), // Delay initial fit for bottom panel + last_screen_size: None, + graph_rect: None, + } + } +} + +/// Node info modal state +pub struct ModalState { + pub events_buffer: Rc>>, + pub clicked_node: Option, + pub open: bool, + pub edit_buffer: Option, +} + +impl Default for ModalState { + fn default() -> Self { + Self { events_buffer: Rc::new(RefCell::new(Vec::new())), clicked_node: None, - node_info_modal_open: false, - node_to_host: HashMap::new(), + open: false, + edit_buffer: None, + } + } +} + +/// Represents the state of the visualization tab. +pub struct VisualizationState { + /// Network structure and lookups + pub network: NetworkData, + /// Flow processing and streaming + pub flow: FlowState, + /// Layout and rendering + pub view: ViewState, + /// Node info modal + pub modal: ModalState, + /// Screenshot export state machine + pub screenshot_export: ScreenshotStateMachine, + /// Config content tracking (for detecting changes) + pub config_content: Option, + /// Auto-start countdown frames + pub auto_start_countdown: Option, + /// Whether user has manually started visualization + pub user_has_started: bool, +} + +impl Default for VisualizationState { + fn default() -> Self { + Self { + network: NetworkData::default(), + flow: FlowState::default(), + view: ViewState::default(), + modal: ModalState::default(), + screenshot_export: ScreenshotStateMachine::default(), + config_content: None, auto_start_countdown: None, - total_flows: 0, user_has_started: false, - reset_view_requested: false, - delayed_fit_countdown: Some(2), // Delay initial fit for bottom panel to be laid out - last_screen_size: None, - modal_edit_buffer: None, - graph_rect: None, - export_state: ExportState::Idle, } } } @@ -260,11 +298,11 @@ impl VisualizationState { /// 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, 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; + self.network.graph = graph; + self.network.known_ips = known_ips; + self.network.ip_to_node = ip_to_node; + self.network.node_to_host = node_to_host; + self.view.layout_initialized = false; } /// Build graph from configuration (shared logic) @@ -273,13 +311,13 @@ impl VisualizationState { ) -> ( VisualizationGraph, HashSet, - HashMap, - HashMap, + 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(); + 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_idx, host) in config.get_hosts().iter().enumerate() { @@ -335,21 +373,21 @@ impl VisualizationState { /// Check if an IP is a known (configured) IP pub fn is_known_ip(&self, ip: Ipv4Addr) -> bool { - self.known_ips.contains(&ip) + self.network.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) { + self.flow.total_flows = 0; + for idx in self.network.graph.g().node_indices().collect::>() { + if let Some(node) = self.network.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) { + for idx in self.network.graph.g().edge_indices().collect::>() { + if let Some(edge) = self.network.graph.g_mut().edge_weight_mut(idx) { let payload = edge.payload_mut(); payload.flow_count = 0; payload.max_flow_count = 0; @@ -373,9 +411,9 @@ impl VisualizationState { log::debug!( "Starting visualization with {} known IPs:", - self.known_ips.len() + self.network.known_ips.len() ); - for ip in &self.known_ips { + for ip in &self.network.known_ips { log::debug!(" - {}", ip); } @@ -384,10 +422,10 @@ impl VisualizationState { 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()); + self.flow.streamer = Some(streamer); + self.flow.receiver = Some(receiver); + self.flow.running = true; + self.flow.visualization_start = Some(Instant::now()); log::info!( "Flow visualization started (config: {}, speed: {}x)", if config_content.is_some() { @@ -403,14 +441,14 @@ impl VisualizationState { /// Stop visualization pub fn stop_visualization(&mut self) { - self.visualization_running = false; - if let Some(streamer) = &self.streamer { + self.flow.running = false; + if let Some(streamer) = &self.flow.streamer { streamer.stop(); } - self.streamer = None; - self.flow_receiver = None; - self.active_links.clear(); - self.visualization_start = None; + self.flow.streamer = None; + self.flow.receiver = None; + self.flow.active_links.clear(); + self.flow.visualization_start = None; log::info!("Flow visualization stopped"); } } diff --git a/crates/fosr-gui/src/run/graph/view.rs b/crates/fosr-gui/src/run/graph/view.rs index a5e74665..d7cc44fd 100644 --- a/crates/fosr-gui/src/run/graph/view.rs +++ b/crates/fosr-gui/src/run/graph/view.rs @@ -9,7 +9,7 @@ use super::overlays::{ }; use super::screenshot::handle_screenshot_export; use super::shapes::{NetworkEdgeShape, NetworkNodeShape}; -use super::state::{EdgeData, ExportState, NodeData}; +use super::state::{EdgeData, NodeData, ScreenshotStateMachine}; use crate::run::state::RunTabState; use crate::shared::constants::ui::FIT_TO_SCREEN_PADDING; use eframe::egui; @@ -31,20 +31,20 @@ pub fn render_graph_view(ui: &mut egui::Ui, state: &mut RunTabState) { // Reset view on window resize let screen_size = ui.ctx().content_rect().size(); - match state.visualization.last_screen_size { + match state.visualization.view.last_screen_size { Some(last) if last != screen_size => { - state.visualization.last_screen_size = Some(screen_size); - state.visualization.reset_view_requested = true; + state.visualization.view.last_screen_size = Some(screen_size); + state.visualization.view.reset_requested = true; } - None => state.visualization.last_screen_size = Some(screen_size), + None => state.visualization.view.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.visualization.reset_view_requested; - if state.visualization.reset_view_requested { - state.visualization.reset_view_requested = false; + let fit_to_screen = state.visualization.view.reset_requested; + if state.visualization.view.reset_requested { + state.visualization.view.reset_requested = false; } let mut graph_view = egui_graphs::GraphView::< @@ -56,20 +56,20 @@ pub fn render_graph_view(ui: &mut egui::Ui, state: &mut RunTabState) { NetworkEdgeShape, egui_graphs::FruchtermanReingoldWithCenterGravityState, egui_graphs::LayoutForceDirected, - >::new(&mut state.visualization.graph) - .with_interactions(&interactions) - .with_event_sink(&state.visualization.events_buffer) - .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_fit_to_screen_padding(FIT_TO_SCREEN_PADDING) // padding to avoid cropping with labels and overlays - .with_zoom_and_pan_enabled(true), - ); + >::new(&mut state.visualization.network.graph) + .with_interactions(&interactions) + .with_event_sink(&state.visualization.modal.events_buffer) + .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_fit_to_screen_padding(FIT_TO_SCREEN_PADDING) // padding to avoid cropping with labels and overlays + .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 - if !state.visualization.layout_initialized { + if !state.visualization.view.layout_initialized { let layout_state = egui_graphs::FruchtermanReingoldWithCenterGravityState { base: egui_graphs::FruchtermanReingoldState { is_running: false, @@ -78,7 +78,7 @@ pub fn render_graph_view(ui: &mut egui::Ui, state: &mut RunTabState) { extras: Default::default(), }; egui_graphs::set_layout_state(ui, layout_state, None); - state.visualization.layout_initialized = true; + state.visualization.view.layout_initialized = true; } ui.add(&mut graph_view); @@ -87,7 +87,7 @@ pub fn render_graph_view(ui: &mut egui::Ui, state: &mut RunTabState) { handle_screenshot_export(ui, &mut state.visualization); // Hide overlays during export to get clean screenshot - if state.visualization.export_state != ExportState::Idle { + if state.visualization.screenshot_export != ScreenshotStateMachine::Idle { return; } @@ -101,5 +101,5 @@ pub fn render_graph_view(ui: &mut egui::Ui, state: &mut RunTabState) { // Use panel rect directly - it's already in screen coordinates // and represents the full panel area (ui.max_rect() excludes internal padding) let panel_rect = inner_response.response.rect; - state.visualization.graph_rect = Some(panel_rect); + state.visualization.view.graph_rect = Some(panel_rect); } diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs index 124d6e55..dfc7cd65 100644 --- a/crates/fosr-gui/src/run/tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -26,9 +26,9 @@ pub fn show_run_tab_content( if let Some(countdown) = state.visualization.auto_start_countdown { if countdown > 0 { state.visualization.auto_start_countdown = Some(countdown - 1); - } else if !state.visualization.visualization_running { + } else if !state.visualization.flow.running { let config = state.visualization.config_content.clone(); - let speed = state.visualization.speed.clone(); + let speed = state.visualization.flow.speed.clone(); if let Err(e) = state .visualization .start_visualization(config.as_deref(), speed, true) @@ -40,12 +40,12 @@ pub fn show_run_tab_content( } // Handle delayed fit-to-screen (after panel toggle or on initial load) - if let Some(countdown) = state.visualization.delayed_fit_countdown { + if let Some(countdown) = state.visualization.view.delayed_fit_countdown { if countdown > 0 { - state.visualization.delayed_fit_countdown = Some(countdown - 1); + state.visualization.view.delayed_fit_countdown = Some(countdown - 1); } else { - state.visualization.reset_view_requested = true; - state.visualization.delayed_fit_countdown = None; + state.visualization.view.reset_requested = true; + state.visualization.view.delayed_fit_countdown = None; } } diff --git a/crates/fosr-gui/src/shared/config/model.rs b/crates/fosr-gui/src/shared/config/model.rs index 7cea4855..181f516b 100644 --- a/crates/fosr-gui/src/shared/config/model.rs +++ b/crates/fosr-gui/src/shared/config/model.rs @@ -50,7 +50,7 @@ pub struct Interface { #[serde(skip_serializing_if = "Option::is_none")] pub mac_addr: Option, - // Mandatory dans ton YAML -> String (si absent, parsing échoue) + /// Required: the IPv4 address of this interface. pub ip_addr: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/crates/fosr-gui/src/shared/widgets/helpers.rs b/crates/fosr-gui/src/shared/widgets/helpers.rs index 834a66ec..06f0cda0 100644 --- a/crates/fosr-gui/src/shared/widgets/helpers.rs +++ b/crates/fosr-gui/src/shared/widgets/helpers.rs @@ -145,7 +145,7 @@ pub fn edit_optional_multiline_string( if trimmed.is_empty() { *value = None; } else { - *value = Some(buffer); // garde les retours à la ligne + *value = Some(buffer); // preserve line breaks } } From a80442e6117b66f885ae633957e1da6f644a70e6 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:02:31 +0100 Subject: [PATCH 123/127] refactor(gui): extract AddressCounts and helpers in host editor --- crates/fosr-gui/src/config_editor/host.rs | 389 ++++++++++++++-------- 1 file changed, 249 insertions(+), 140 deletions(-) diff --git a/crates/fosr-gui/src/config_editor/host.rs b/crates/fosr-gui/src/config_editor/host.rs index 4785ba12..1da80066 100644 --- a/crates/fosr-gui/src/config_editor/host.rs +++ b/crates/fosr-gui/src/config_editor/host.rs @@ -1,4 +1,8 @@ //! Host editing UI: hostname, OS, type, and client protocols. +//! +//! This module provides the visual editor for configuring hosts in the network. +//! Each host is displayed as a collapsible section with fields for basic settings +//! (hostname, OS, type, usage) and a nested interfaces section. use crate::config_editor::{host_interfaces, host_services, host_validation}; use crate::shared::config::model::{Configuration, Host}; @@ -11,7 +15,39 @@ use crate::shared::widgets::helpers::{edit_optional_string, info_icon}; use eframe::egui; use std::collections::HashMap; -/// Several host rendering. +/// Counts of IP and MAC address occurrences across all hosts. +/// Used to detect duplicates for validation warnings. +struct AddressCounts { + ip: HashMap, + mac: HashMap, +} + +impl AddressCounts { + /// Count IP and MAC addresses across all hosts in the configuration. + fn from_configuration(config: &Configuration) -> Self { + let mut ip_counts: HashMap = HashMap::new(); + let mut mac_counts: HashMap = HashMap::new(); + + for host in &config.hosts { + for iface in &host.interfaces { + *ip_counts.entry(iface.ip_addr.clone()).or_insert(0) += 1; + if let Some(mac) = &iface.mac_addr { + *mac_counts.entry(mac.clone()).or_insert(0) += 1; + } + } + } + + Self { + ip: ip_counts, + mac: mac_counts, + } + } +} + +/// Render the hosts section with add button and collapsible host cards. +/// +/// Each host shows validation errors in the header if present. +/// Supports adding new hosts (inserted at top) and removing hosts. pub fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { ui.horizontal(|ui| { ui.heading("Hosts"); @@ -30,111 +66,95 @@ pub fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { return; } - // 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 { - *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; - } - } - } + // Pre-compute address counts for duplicate detection + let addr_counts = AddressCounts::from_configuration(model); + // Track removal request (can't remove during iteration) let mut host_to_remove: Option = None; for (idx, host) in model.hosts.iter_mut().enumerate() { - ui_single_host(ui, idx, host, &ip_counts, &mac_counts, &mut host_to_remove); + ui_single_host(ui, idx, host, &addr_counts, &mut host_to_remove); ui.add_space(SPACING_MD); } + // Apply removal after iteration completes if let Some(idx) = host_to_remove { model.hosts.remove(idx); } } -/// Single host rendering +/// Render a single host as a collapsible card. +/// +/// The header shows the host name and any validation errors. +/// The body contains all editable fields organized in sections. fn ui_single_host( ui: &mut egui::Ui, index: usize, host: &mut Host, - ip_counts: &HashMap, - mac_counts: &HashMap, + addr_counts: &AddressCounts, remove_request: &mut Option, ) { - let host_name = host_display_name(host); - let errors = host_validation::validate_host(host, ip_counts, mac_counts); + let display_name = host_display_name(host); + let errors = + host_validation::validate_host(host, &addr_counts.ip, &addr_counts.mac); + // First host is expanded by default 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.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(COLOR_ERROR, label_text) - .on_hover_ui(|ui| { - ui_host_summary_tooltip(ui, host); - }); - } - }); - - 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() - { - *remove_request = Some(index); - } - }); + render_host_header(ui, &display_name, &errors, host); + render_host_delete_button(ui, index, remove_request); }) .body(|ui| { - ui_host_os_selector(ui, index, &mut host.os); - edit_optional_string(ui, "Hostname", &mut host.hostname, "host1"); + render_host_basic_fields(ui, index, host); + ui.separator(); + host_interfaces::ui_interfaces_section(ui, index, host, &addr_counts.ip, &addr_counts.mac); + }); +} - ui.horizontal(|ui| { - ui.label("Usage"); - info_icon(ui, &format!("Optional (default value: {0}). The usage intensity of the host. {0} is the baseline, < {0} means less usage than usual, and > {0} means higher usage", HOST_USAGE_DEFAULT)); - let mut usage_val = host.usage.unwrap_or(HOST_USAGE_DEFAULT); - if ui - .add(egui::DragValue::new(&mut usage_val).speed(0.1)) - .changed() - { - host.usage = if (usage_val - HOST_USAGE_DEFAULT).abs() < f32::EPSILON { - None - } else { - Some(usage_val) - }; - } - if ui - .button(egui_material_icons::icons::ICON_CLEAR) - .on_hover_text("Clear") - .clicked() - { - host.usage = None; - } +/// Render the host header with name, errors, and tooltip. +fn render_host_header( + ui: &mut egui::Ui, + display_name: &str, + errors: &[String], + host: &Host, +) { + ui.horizontal(|ui| { + if errors.is_empty() { + ui.label(display_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, display_name, error_text); + + ui.colored_label(COLOR_ERROR, label_text) + .on_hover_ui(|ui| { + ui_host_summary_tooltip(ui, host); + }); + } + }); +} - ui_host_type_selector(ui, index, host); - ui_host_client_protocols(ui, index, host); - ui.separator(); - host_interfaces::ui_interfaces_section(ui, index, host, ip_counts, mac_counts); - }); +/// Render the delete button aligned to the right of the header. +fn render_host_delete_button( + ui: &mut egui::Ui, + index: usize, + remove_request: &mut Option, +) { + 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() + { + *remove_request = Some(index); + } + }); } -/// Host summary +/// Render the host summary tooltip showing type, protocols, and interfaces. fn ui_host_summary_tooltip(ui: &mut egui::Ui, host: &Host) { let host_type = host.r#type.as_deref().unwrap_or(""); ui.horizontal(|ui| { @@ -161,13 +181,21 @@ fn ui_host_summary_tooltip(ui: &mut egui::Ui, host: &Host) { } else { iface.services.join(", ") }; - ui.label(format!(" • {} ({})", iface.ip_addr, services_str)); } } } -/// Dropdown selector for the Operating System +/// Render the basic host fields: OS, hostname, usage, type, and client protocols. +fn render_host_basic_fields(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { + ui_host_os_selector(ui, host_idx, &mut host.os); + edit_optional_string(ui, "Hostname", &mut host.hostname, "host1"); + ui_host_usage_field(ui, host); + ui_host_type_selector(ui, host_idx, host); + ui_host_client_protocols(ui, host_idx, host); +} + +/// Dropdown selector for the Operating System (Linux/Windows/none). fn ui_host_os_selector(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option) { ui.horizontal(|ui| { ui.label("OS"); @@ -208,11 +236,61 @@ fn ui_host_os_selector(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option< }); } -/// Type of host rendering +/// Usage intensity field with drag value and clear button. +/// +/// Usage affects how much network traffic this host generates. +/// Default is 1.0 (baseline), lower means less traffic, higher means more. +fn ui_host_usage_field(ui: &mut egui::Ui, host: &mut Host) { + ui.horizontal(|ui| { + ui.label("Usage"); + info_icon( + ui, + &format!( + "Optional (default value: {0}). The usage intensity of the host. \ + {0} is the baseline, < {0} means less usage than usual, \ + and > {0} means higher usage", + HOST_USAGE_DEFAULT + ), + ); + + let mut usage_val = host.usage.unwrap_or(HOST_USAGE_DEFAULT); + if ui + .add(egui::DragValue::new(&mut usage_val).speed(0.1)) + .changed() + { + // Store as None if at default (to avoid serializing default values) + host.usage = if (usage_val - HOST_USAGE_DEFAULT).abs() < f32::EPSILON { + None + } else { + Some(usage_val) + }; + } + + if ui + .button(egui_material_icons::icons::ICON_CLEAR) + .on_hover_text("Clear") + .clicked() + { + host.usage = None; + } + }); +} + +/// Dropdown selector for host type (server/user/auto). +/// +/// - server: provides services to other hosts +/// - user: consumes services from servers +/// - auto: determined based on whether services are defined fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { ui.horizontal(|ui| { 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."); + 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")) @@ -238,99 +316,130 @@ fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { } }); - 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.r#type = None; } }); } -/// Client protocols rendering +/// Client protocols selector with searchable popup. +/// +/// Shows a popup with all available protocols from KNOWN_SERVICES, +/// filtered by search text. Selected protocols appear as removable chips. 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 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)) .on_hover_text("Add protocol"); - egui::Popup::from_toggle_button_response(&add_btn_resp) - .id(popup_id) - .show(|ui| { - ui.set_min_width(POPUP_MIN_WIDTH); + render_protocol_popup(popup_id, host_idx, add_btn_resp, host); + render_protocol_chips(ui, host); + }); +} + +/// Render the searchable protocol selection popup. +fn render_protocol_popup( + popup_id: egui::Id, + host_idx: usize, + add_btn_resp: egui::Response, + host: &mut Host, +) { + egui::Popup::from_toggle_button_response(&add_btn_resp) + .id(popup_id) + .show(|ui| { + ui.set_min_width(POPUP_MIN_WIDTH); - 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()); + // Search field with auto-focus + 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...")); + 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)); - } + // Auto-focus search field when popup opens + 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.data_mut(|d| d.insert_temp(search_id, search_text.clone())); - ui.separator(); + ui.separator(); - egui::ScrollArea::vertical() - .max_height(POPUP_MAX_HEIGHT) - .auto_shrink([true; 2]) - .show(ui, |ui| { - ui.set_width(PANEL_MIN_WIDTH); - - let filter = search_text.to_lowercase(); - let mut any_shown = false; - - for (name, _) in host_services::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); - } + // Protocol list with filtering + egui::ScrollArea::vertical() + .max_height(POPUP_MAX_HEIGHT) + .auto_shrink([true, true]) + .show(ui, |ui| { + ui.set_width(PANEL_MIN_WIDTH); + + let filter = search_text.to_lowercase(); + let mut any_shown = false; + + for (name, _) in host_services::KNOWN_SERVICES { + let matches_filter = filter.is_empty() || name.to_lowercase().contains(&filter); + let already_added = host.client.contains(&name.to_string()); + + if matches_filter && !already_added { + 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(), + ); + } + }); + }); +} - if !any_shown { - ui.label( - egui::RichText::new("No available protocols") - .italics() - .weak(), - ); - } - }); - }); +/// Render selected protocols as removable chips. +fn render_protocol_chips(ui: &mut egui::Ui, host: &mut Host) { + let mut proto_to_remove: Option = None; - 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); + 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); + } } -/// Determines the best label for a host (Hostname > IP > Default) +/// Determine the display name for a host. +/// +/// Priority: hostname > first IP address > "Unconfigured host" fn host_display_name(host: &Host) -> String { + // Try hostname first if let Some(name) = host.hostname.as_deref() { if !name.trim().is_empty() { return name.to_string(); } } + // Fall back to first interface IP if let Some(iface) = host.interfaces.first() { if !iface.ip_addr.trim().is_empty() { return iface.ip_addr.clone(); From 06d3b43ef5fec25adeea8cb2be7758510ad877c5 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:03:02 +0100 Subject: [PATCH 124/127] refactor(gui): extract VirtualTime struct for speed-based time tracking --- crates/fosr-gui/src/run/graph/stream.rs | 93 +++++++++++++++++-------- 1 file changed, 63 insertions(+), 30 deletions(-) diff --git a/crates/fosr-gui/src/run/graph/stream.rs b/crates/fosr-gui/src/run/graph/stream.rs index 624bf00e..5b7f788c 100644 --- a/crates/fosr-gui/src/run/graph/stream.rs +++ b/crates/fosr-gui/src/run/graph/stream.rs @@ -21,6 +21,57 @@ use std::sync::{Arc, RwLock}; use std::time::Duration; use web_time::{Instant, SystemTime, UNIX_EPOCH}; +/// Tracks virtual time that progresses at a variable speed. +/// +/// Virtual time integrates speed changes smoothly: when speed changes, +/// only future time is affected, not the accumulated elapsed time. +/// This prevents "time travel" when reducing speed. +/// +/// # Why virtual time? +/// +/// Without this integration, changing speed would scale the entire elapsed time, +/// causing discontinuities. For example: +/// - Elapsed time is 20s at speed 1.0 +/// - Speed changes to 0.5 +/// - Naive approach: `elapsed = 20 * 0.5 = 10s` → goes back in time! +/// +/// With integration, only the delta since the last tick is scaled: +/// - After 20s at 1.0: virtual_elapsed = 20s +/// - Next tick with 1s real delta at 0.5x: virtual_elapsed = 20 + 0.5 = 20.5s +struct VirtualTime { + /// Accumulated virtual time (integrated over all speed changes) + elapsed: Duration, + /// Last real time measurement (for calculating delta) + last_tick: Instant, +} + +impl VirtualTime { + /// Create a new virtual time tracker starting at zero. + fn new() -> Self { + Self { + elapsed: Duration::ZERO, + last_tick: Instant::now(), + } + } + + /// Advance virtual time by the real time elapsed since last tick, + /// scaled by the current speed. + fn tick(&mut self, speed: f32) { + let now = Instant::now(); + let real_delta = now.duration_since(self.last_tick); + self.last_tick = now; + + // Scale the delta by speed and accumulate + let virtual_delta = Duration::from_secs_f64(real_delta.as_secs_f64() * speed as f64); + self.elapsed += virtual_delta; + } + + /// Get the current virtual elapsed time. + fn elapsed(&self) -> Duration { + self.elapsed + } +} + /// A flow event (subset of FlowData) #[derive(Clone, Debug, Eq, PartialEq)] pub struct FlowEvent { @@ -190,16 +241,8 @@ impl FlowStreamer { // How often to check for flows to emit let check_interval = Duration::from_millis(STREAM_CHECK_INTERVAL_MS); - // 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(); + // Track virtual time (see VirtualTime struct for explanation) + let mut virtual_time = VirtualTime::new(); log::info!( "Flow streaming loop started (timestamp-based, speed: {}x)", @@ -207,14 +250,10 @@ impl FlowStreamer { ); 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; + // Advance virtual time + let current_speed = *speed.read().unwrap(); + virtual_time.tick(current_speed); + let virtual_elapsed = virtual_time.elapsed(); // Generate more flows if buffer is running low let buffer_target = virtual_elapsed + buffer_ahead; @@ -305,7 +344,6 @@ impl FlowStreamer { ); } - // 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, @@ -319,19 +357,14 @@ impl FlowStreamer { let buffer_ahead = Duration::from_secs(STREAM_BUFFER_AHEAD_SECS); let check_interval = Duration::from_millis(STREAM_CHECK_INTERVAL_MS); - // Track virtual time by integrating speed changes - let mut virtual_elapsed = Duration::ZERO; - let mut last_loop_time = Instant::now(); + // Track virtual time (see VirtualTime struct for explanation) + let mut virtual_time = VirtualTime::new(); 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; + // Advance virtual time + let current_speed = *speed.read().unwrap(); + virtual_time.tick(current_speed); + let virtual_elapsed = virtual_time.elapsed(); // Generate more flows if buffer is running low let buffer_target = virtual_elapsed + buffer_ahead; From 379d462bf088716c179849c6944c09d536a1be3a Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:31:24 +0100 Subject: [PATCH 125/127] feat(gui): add index.html generation script and improve web build workflow --- crates/fosr-gui/Cargo.toml | 4 +-- crates/fosr-gui/README.md | 49 +++++++++++++++++++++++++---------- crates/fosr-gui/build-web.sh | 15 +++++++++++ public/generate-index-html.sh | 24 +++++++++++++++++ 4 files changed, 77 insertions(+), 15 deletions(-) create mode 100755 public/generate-index-html.sh diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index 942cfc30..225d1413 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -53,8 +53,8 @@ getrandom = { version = "0.4", features = ["wasm_js"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] env_logger = { version = "0.11.8", default-features = false, features = [ - "auto-color", - "humantime", + "auto-color", + "humantime", ] } tempfile = "3.26.0" open = "5.3.3" diff --git a/crates/fosr-gui/README.md b/crates/fosr-gui/README.md index f3c5def9..b34373c8 100644 --- a/crates/fosr-gui/README.md +++ b/crates/fosr-gui/README.md @@ -5,7 +5,7 @@ ### Module Overview | Module | Purpose | -| ------------------- | ------------------------------------------------------------- | +|---------------------|---------------------------------------------------------------| | `app/` | Core application: tab navigation, startup modal, close dialog | | `config_editor/` | Visual and YAML configuration editor | | `run/` | PCAP generation + live network visualization | @@ -68,7 +68,7 @@ rustup target add wasm32-unknown-unknown cargo install wasm-bindgen-cli ``` -Note: the version of the `wasm-bindgen` CLI must match the version of the crate declared in `Cargo.toml`. +Note: the version of the `wasm-bindgen` CLI must match the version of the `wasm-bindgen` crate declared in `Cargo.toml`. #### Step 1: Compile to WASM @@ -89,30 +89,34 @@ Generates in `Fos-R/public/`: - `fosr_gui.js` - JavaScript glue code - `fosr_gui_bg.wasm` - WASM binary -### Step 3: Embedding in the HTML file +#### Step 3: Embedding in the HTML file -#### HTML Structure +##### HTML Structure ```html +
- +
``` -#### JavaScript +##### JavaScript ```html + ``` -### Step 4: Serve with an HTTP server +#### Step 4: Serve with an HTTP server Here is an example using `http-server`: @@ -123,3 +127,22 @@ npm install -g http-server # Serve from project root http-server ./public -p 8080 ``` + +#### Shell script + +Use the `build-web.sh` script to automate the build and serve process (requires `http-server` and a pre-built +`public/index.html`). + +#### Generating `index.html` + +The `public/index.html` file is not versioned and must be generated from markdown sources using pandoc: + +```shell +# Install pandoc (if not already installed) + +# Generate index.html +./public/generate-index-html.sh +``` + +Note: This script generates a simplified version without the dynamic help output from the `fosr` binary. +For the full version, see the `pages` job in `.gitlab-ci.yml`. diff --git a/crates/fosr-gui/build-web.sh b/crates/fosr-gui/build-web.sh index 7a18265a..534a6681 100755 --- a/crates/fosr-gui/build-web.sh +++ b/crates/fosr-gui/build-web.sh @@ -3,9 +3,24 @@ set -e +# Check for required commands +for cmd in cargo wasm-bindgen http-server; do + if ! command -v "$cmd" &> /dev/null; then + echo "Error: $cmd is not installed." + exit 1 + fi +done + # Go to project root cd "$(dirname "$0")/../.." +# Check for index.html +if [ ! -f public/index.html ]; then + echo "Error: public/index.html not found." + echo "Run ./public/generate-index-html.sh to create it (requires pandoc)." + exit 1 +fi + echo "Building WASM..." cargo build -p fosr-gui -r --target wasm32-unknown-unknown --no-default-features diff --git a/public/generate-index-html.sh b/public/generate-index-html.sh new file mode 100755 index 00000000..00754df1 --- /dev/null +++ b/public/generate-index-html.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Generate index.html from markdown sources using pandoc +# Note: This is a simplified version without the dynamic help output from the fosr binary. +# For the full version with help text, see the pages job in .gitlab-ci.yml. + +set -e + +# Check for pandoc +if ! command -v pandoc &> /dev/null; then + echo "Error: pandoc is not installed." + exit 1 +fi + +cd "$(dirname "$0")" + +pandoc intro.md compile.md config_doc.md other_software.md schema.md lib.md roadmap.md usecases.md analysis.md limitations.md refs.md \ + -o index.html \ + --template template.html \ + --include-after-body footer.html \ + --standalone \ + --toc \ + --toc-depth 1 + +echo "Generated public/index.html" From 9452614a014267fc2345472fcb869afc6a324937 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:53:33 +0100 Subject: [PATCH 126/127] refactor(gui): general code cleanup and reorganization pass --- crates/fosr-gui/Cargo.toml | 1 + crates/fosr-gui/src/app/mod.rs | 19 +- crates/fosr-gui/src/app/startup_modal.rs | 67 +-- crates/fosr-gui/src/app/top_bar.rs | 313 +++++++------ crates/fosr-gui/src/config_editor/host.rs | 57 +-- .../src/config_editor/host_interfaces.rs | 113 +++-- .../src/config_editor/host_services.rs | 113 +++-- .../src/config_editor/host_validation.rs | 35 +- crates/fosr-gui/src/config_editor/mod.rs | 1 + crates/fosr-gui/src/config_editor/tab.rs | 81 ++-- crates/fosr-gui/src/config_editor/toolbar.rs | 185 ++++++++ .../fosr-gui/src/config_editor/yaml_editor.rs | 182 +++++--- crates/fosr-gui/src/config_templates/mod.rs | 54 +-- crates/fosr-gui/src/lib.rs | 6 +- crates/fosr-gui/src/main.rs | 2 +- .../src/run/generation/bottom_panel.rs | 367 +++++++++------- crates/fosr-gui/src/run/generation/core.rs | 217 +++++---- crates/fosr-gui/src/run/generation/options.rs | 367 ++++++++-------- crates/fosr-gui/src/run/generation/process.rs | 192 +++++--- crates/fosr-gui/src/run/generation/state.rs | 2 + .../fosr-gui/src/run/generation/validation.rs | 46 +- .../fosr-gui/src/run/graph/flow_processing.rs | 68 +-- crates/fosr-gui/src/run/graph/node_modal.rs | 305 +++++++------ crates/fosr-gui/src/run/graph/overlays.rs | 268 ++++++----- crates/fosr-gui/src/run/graph/shapes.rs | 87 ++-- crates/fosr-gui/src/run/graph/state.rs | 159 ++++--- crates/fosr-gui/src/run/graph/stream.rs | 415 ++++++++++-------- crates/fosr-gui/src/run/graph/view.rs | 111 +++-- crates/fosr-gui/src/run/tab.rs | 81 ++-- crates/fosr-gui/src/shared/assets.rs | 4 +- crates/fosr-gui/src/shared/config/file_ops.rs | 210 ++------- crates/fosr-gui/src/shared/config/parser.rs | 9 +- crates/fosr-gui/src/shared/config/state.rs | 9 +- .../fosr-gui/src/shared/constants/network.rs | 6 - crates/fosr-gui/src/shared/constants/ui.rs | 35 +- crates/fosr-gui/src/shared/file_io.rs | 13 +- crates/fosr-gui/src/shared/widgets/helpers.rs | 38 +- .../src/shared/widgets/time_picker/popup.rs | 1 - .../src/shared/widgets/timezone_picker.rs | 4 +- 39 files changed, 2449 insertions(+), 1794 deletions(-) create mode 100644 crates/fosr-gui/src/config_editor/toolbar.rs diff --git a/crates/fosr-gui/Cargo.toml b/crates/fosr-gui/Cargo.toml index 225d1413..e20349df 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" description = "A GUI for Fos-R." +# Bundle metadata for macOS app [package.metadata.bundle] name = "Fos-R" identifier = "com.inria.fosr" diff --git a/crates/fosr-gui/src/app/mod.rs b/crates/fosr-gui/src/app/mod.rs index f2755b62..e27d2327 100644 --- a/crates/fosr-gui/src/app/mod.rs +++ b/crates/fosr-gui/src/app/mod.rs @@ -79,21 +79,24 @@ impl FosrApp { } } - /// Handle close confirmation when there are active Wireshark sessions. + /// Check if any Wireshark sessions with temporary PCAP files are still running. #[cfg(not(target_arch = "wasm32"))] - fn handle_close_confirmation(&mut self, ctx: &egui::Context) { - // Handle close confirmation if there are active Wireshark sessions - let has_active_sessions = self - .run_tab_state + fn has_active_wireshark_sessions(&self) -> bool { + self.run_tab_state .generation .temp_pcap_files .iter() - .any(|(handle, _)| !handle.is_finished()); + .any(|(handle, _)| !handle.is_finished()) + } + /// Handle close confirmation when there are active Wireshark sessions. + #[cfg(not(target_arch = "wasm32"))] + fn handle_close_confirmation(&mut self, ctx: &egui::Context) { if ctx.input(|i| i.viewport().close_requested()) { if self.allowed_to_close { - // Do nothing, let the app close - } else if has_active_sessions { + // User confirmed: allow the app to close + } else if self.has_active_wireshark_sessions() { + // Active sessions: cancel close and show confirmation dialog ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.show_close_confirmation = true; } diff --git a/crates/fosr-gui/src/app/startup_modal.rs b/crates/fosr-gui/src/app/startup_modal.rs index 26bec8db..56dfd948 100644 --- a/crates/fosr-gui/src/app/startup_modal.rs +++ b/crates/fosr-gui/src/app/startup_modal.rs @@ -1,6 +1,6 @@ //! Startup modal for choosing configuration source (templates or import). -use crate::config_templates::{all_templates, load_template_by_id}; +use crate::config_templates::{load_template, TEMPLATES}; #[cfg(target_arch = "wasm32")] use crate::shared::config::file_ops::poll_file_import; use crate::shared::config::file_ops::trigger_file_import; @@ -13,39 +13,47 @@ use crate::shared::constants::ui::{ }; use eframe::egui; -/// 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(), STARTUP_CARD_HEIGHT); - let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); - - // Choose fill color based on hover - let fill = if response.hovered() { +/// Builds the frame style for a startup card based on hover state. +fn card_frame_for_hover(ui: &egui::Ui, is_hovered: bool) -> egui::Frame { + let fill = if is_hovered { ui.style().visuals.widgets.hovered.bg_fill } else { ui.style().visuals.widgets.inactive.bg_fill }; + egui::Frame::group(ui.style()).fill(fill) +} + +/// Renders the centered content inside a startup card (icon, title, description). +fn render_card_content(ui: &mut egui::Ui, icon: &str, title: &str, description: &str) { + // Disable text selection so the whole card acts as a single clickable area + ui.style_mut().interaction.selectable_labels = false; + ui.set_width(ui.available_width()); + + ui.vertical_centered(|ui| { + ui.add_space(SPACING_LG); + ui.label(egui::RichText::new(icon).size(ICON_SIZE_LG)); + ui.add_space(SPACING_SM); + ui.strong(egui::RichText::new(title).size(TEXT_SIZE_LG)); + ui.add_space(SPACING_XS); + ui.label( + egui::RichText::new(description) + .size(TEXT_SIZE_SM) + .color(COLOR_TEXT_MUTED), + ); + ui.add_space(SPACING_LG); + }); +} + +/// A clickable card with icon, title and description. +/// Returns true if the card was clicked. +fn startup_card(ui: &mut egui::Ui, icon: &str, title: &str, description: &str) -> bool { + let desired_size = egui::vec2(ui.available_width(), STARTUP_CARD_HEIGHT); + let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); - let frame = egui::Frame::group(ui.style()).fill(fill); + let frame = card_frame_for_hover(ui, response.hovered()); ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { frame.show(ui, |ui| { - // 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| { - ui.add_space(SPACING_LG); - ui.label(egui::RichText::new(icon).size(ICON_SIZE_LG)); - ui.add_space(SPACING_SM); - ui.strong(egui::RichText::new(title).size(TEXT_SIZE_LG)); - ui.add_space(SPACING_XS); - ui.label( - egui::RichText::new(description) - .size(TEXT_SIZE_SM) - .color(COLOR_TEXT_MUTED), - ); - ui.add_space(SPACING_LG); - }); + render_card_content(ui, icon, title, description); }); }); @@ -119,16 +127,15 @@ fn render_template_selection_modal(ctx: &egui::Context, state: &mut Configuratio ui.add_space(SPACING_XL); // Grid of template cards - let templates = all_templates(); ui.columns(STARTUP_COLUMNS_TEMPLATES, |cols| { - for (i, template) in templates.iter().enumerate() { + for (i, template) in TEMPLATES.iter().enumerate() { if startup_card( &mut cols[i % STARTUP_COLUMNS_TEMPLATES], template.icon, template.title, template.description, ) { - load_template_by_id(state, template.id); + load_template(state, template); } } }); diff --git a/crates/fosr-gui/src/app/top_bar.rs b/crates/fosr-gui/src/app/top_bar.rs index f323b79e..21b0039b 100644 --- a/crates/fosr-gui/src/app/top_bar.rs +++ b/crates/fosr-gui/src/app/top_bar.rs @@ -24,6 +24,189 @@ pub struct TopBarState { pub has_errors: bool, } +/// Renders a tab button with consistent styling. +/// +/// Returns `Some(clicked_tab)` if the button was clicked, `None` otherwise. +/// Handles both enabled and disabled states with appropriate tooltips. +fn render_tab_button( + ui: &mut egui::Ui, + text_size: f32, + label: &str, + tab: CurrentTab, + is_selected: bool, + is_enabled: bool, + tooltip: &str, + disabled_tooltip: &str, +) -> Option { + let button = egui::Button::new(egui::RichText::new(label).size(text_size)).selected(is_selected); + let response = ui.add_enabled(is_enabled, button); + + let response = if is_enabled { + response.on_hover_text(tooltip) + } else { + response.on_disabled_hover_text(disabled_tooltip) + }; + + if is_enabled && response.clicked() { + Some(tab) + } else { + None + } +} + +/// Renders the Configuration tab with error indicator when config is invalid. +fn render_config_tab_button( + ui: &mut egui::Ui, + text_size: f32, + has_errors: bool, + is_selected: bool, +) -> Option { + let label = if has_errors { + egui::RichText::new("⚠ Configuration") + .color(COLOR_ERROR) + .size(text_size) + } else { + egui::RichText::new("Configuration").size(text_size) + }; + + let button = egui::Button::new(label).selected(is_selected); + + if ui + .add(button) + .on_hover_text("Edit the network configuration: hosts, interfaces, and services.") + .clicked() + { + Some(CurrentTab::Configuration) + } else { + None + } +} + +/// Renders all tab buttons and returns the newly selected tab if changed. +fn render_tab_buttons( + ui: &mut egui::Ui, + state: &TopBarState, +) -> Option { + let text_size = TEXT_SIZE_DEFAULT; + let has_errors = state.has_errors; + + // Run tab (disabled when config has errors) + if let Some(tab) = render_tab_button( + ui, + text_size, + "Run", + CurrentTab::Run, + state.current_tab == CurrentTab::Run, + !has_errors, + "Live preview and PCAP generation from the current configuration.", + "Configuration is invalid. Fix errors in the Configuration tab to enable Run.", + ) { + return Some(tab); + } + + // Configuration tab (always enabled, shows warning icon on errors) + if let Some(tab) = render_config_tab_button(ui, text_size, has_errors, state.current_tab == CurrentTab::Configuration) { + return Some(tab); + } + + // About tab + if let Some(tab) = render_tab_button( + ui, + text_size, + "About", + CurrentTab::About, + state.current_tab == CurrentTab::About, + true, + "About Fos-R and its authors.", + "", + ) { + return Some(tab); + } + + None +} + +/// Renders zoom in/out controls and returns the updated zoom factor. +fn render_zoom_controls(ui: &mut egui::Ui, ctx: &egui::Context) -> f32 { + let mut new_zoom = ctx.zoom_factor(); + + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + + if ui + .button(egui_material_icons::icons::ICON_ADD) + .on_hover_text("Zoom in") + .clicked() + { + new_zoom = (new_zoom + ZOOM_STEP).min(ZOOM_MAX); + ctx.set_zoom_factor(new_zoom); + } + + ui.label(format!("{:.0}%", new_zoom * 100.0)); + + if ui + .button(egui_material_icons::icons::ICON_REMOVE) + .on_hover_text("Zoom out") + .clicked() + { + new_zoom = (new_zoom - ZOOM_STEP).max(ZOOM_MIN); + ctx.set_zoom_factor(new_zoom); + } + }); + + new_zoom +} + +#[cfg(target_arch = "wasm32")] +fn is_fullscreen() -> bool { + web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.fullscreen_element()) + .is_some() +} + +#[cfg(target_arch = "wasm32")] +fn toggle_fullscreen(is_fullscreen: bool) { + let Some(window) = web_sys::window() else { return }; + let Some(document) = window.document() else { return }; + + if is_fullscreen { + document.exit_fullscreen(); + } else if let Some(canvas) = document.get_element_by_id("fosr_gui_canvas") { + let _ = canvas.request_fullscreen(); + } +} + +#[cfg(target_arch = "wasm32")] +fn render_fullscreen_toggle(ui: &mut egui::Ui) { + let fullscreen = is_fullscreen(); + let (icon, tooltip) = if 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() { + toggle_fullscreen(fullscreen); + } +} + +/// Renders utility buttons on the right side of the top bar. +fn render_utility_buttons(ui: &mut egui::Ui, ctx: &egui::Context, state: &mut TopBarState) { + #[cfg(target_arch = "wasm32")] + render_fullscreen_toggle(ui); + + #[cfg(not(target_arch = "wasm32"))] + ui.add_space(SPACING_SM); + + global_theme_preference_switch(ui); + state.zoom_factor = render_zoom_controls(ui, ctx); +} + +// ----------------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------------- + /// Render the top bar with tabs and utility buttons. /// Returns the updated TopBarState. pub fn render_top_bar(ctx: &egui::Context, state: TopBarState) -> TopBarState { @@ -35,137 +218,15 @@ pub fn render_top_bar(ctx: &egui::Context, state: TopBarState) -> TopBarState { .inner_margin(egui::Margin::symmetric(PANEL_INNER_MARGIN.0, PANEL_INNER_MARGIN.1)), ) .show(ctx, |ui| { - // Add a Menu Bar to host the tabs buttons egui::MenuBar::new().ui(ui, |ui| { - ui.spacing_mut().button_padding = - egui::vec2(BUTTON_PADDING.0, BUTTON_PADDING.1); - let tab_text_size = TEXT_SIZE_DEFAULT; - - let has_errors = state.has_errors; - - // Run tab (combines Live Preview + Generation) - let run_button = - egui::Button::new(egui::RichText::new("Run").size(tab_text_size)) - .selected(state.current_tab == CurrentTab::Run); - - let response = ui.add_enabled(!has_errors, run_button); - - let response = if has_errors { - response.on_disabled_hover_text( - "Configuration is invalid. Fix errors in the Configuration tab to enable Run.", - ) - } else { - response - .on_hover_text("Live preview and PCAP generation from the current configuration.") - }; - - if !has_errors && response.clicked() { - new_state.current_tab = CurrentTab::Run; - } + ui.spacing_mut().button_padding = egui::vec2(BUTTON_PADDING.0, BUTTON_PADDING.1); - // Configuration tab - let label_text = if has_errors { - egui::RichText::new("⚠ Configuration") - .color(COLOR_ERROR) - .size(tab_text_size) - } else { - egui::RichText::new("Configuration").size(tab_text_size) - }; - if ui - .add( - egui::Button::new(label_text) - .selected(state.current_tab == CurrentTab::Configuration), - ) - .on_hover_text( - "Edit the network configuration: hosts, interfaces, and services.", - ) - .clicked() - { - new_state.current_tab = CurrentTab::Configuration; - }; - - // About tab - if ui - .add( - egui::Button::new( - egui::RichText::new("About").size(tab_text_size), - ) - .selected(state.current_tab == CurrentTab::About), - ) - .on_hover_text("About Fos-R and its authors.") - .clicked() - { - new_state.current_tab = CurrentTab::About; + if let Some(tab) = render_tab_buttons(ui, &state) { + new_state.current_tab = tab; } - // 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 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(); - } - } - } - } - } - - #[cfg(not(target_arch = "wasm32"))] - ui.add_space(SPACING_SM); - - // Show the theme switch - global_theme_preference_switch(ui); - - // Zoom controls - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - if ui - .button(egui_material_icons::icons::ICON_ADD) - .on_hover_text("Zoom in") - .clicked() - { - let current_zoom = ctx.zoom_factor(); - let new_zoom = (current_zoom + ZOOM_STEP).min(ZOOM_MAX); - ctx.set_zoom_factor(new_zoom); - new_state.zoom_factor = new_zoom; - } - ui.label(format!("{:.0}%", ctx.zoom_factor() * 100.0)); - if ui - .button(egui_material_icons::icons::ICON_REMOVE) - .on_hover_text("Zoom out") - .clicked() - { - let current_zoom = ctx.zoom_factor(); - let new_zoom = (current_zoom - ZOOM_STEP).max(ZOOM_MIN); - ctx.set_zoom_factor(new_zoom); - new_state.zoom_factor = new_zoom; - } - }); + render_utility_buttons(ui, ctx, &mut new_state); }); }); }); diff --git a/crates/fosr-gui/src/config_editor/host.rs b/crates/fosr-gui/src/config_editor/host.rs index 1da80066..61e1eee7 100644 --- a/crates/fosr-gui/src/config_editor/host.rs +++ b/crates/fosr-gui/src/config_editor/host.rs @@ -11,39 +11,10 @@ use crate::shared::constants::network::HOST_USAGE_DEFAULT; use crate::shared::constants::ui::{ PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_MD, SPACING_SM, }; -use crate::shared::widgets::helpers::{edit_optional_string, info_icon}; +use crate::shared::widgets::helpers::{edit_optional_string_singleline, info_icon_with_tooltip}; use eframe::egui; use std::collections::HashMap; -/// Counts of IP and MAC address occurrences across all hosts. -/// Used to detect duplicates for validation warnings. -struct AddressCounts { - ip: HashMap, - mac: HashMap, -} - -impl AddressCounts { - /// Count IP and MAC addresses across all hosts in the configuration. - fn from_configuration(config: &Configuration) -> Self { - let mut ip_counts: HashMap = HashMap::new(); - let mut mac_counts: HashMap = HashMap::new(); - - for host in &config.hosts { - for iface in &host.interfaces { - *ip_counts.entry(iface.ip_addr.clone()).or_insert(0) += 1; - if let Some(mac) = &iface.mac_addr { - *mac_counts.entry(mac.clone()).or_insert(0) += 1; - } - } - } - - Self { - ip: ip_counts, - mac: mac_counts, - } - } -} - /// Render the hosts section with add button and collapsible host cards. /// /// Each host shows validation errors in the header if present. @@ -67,13 +38,13 @@ pub fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { } // Pre-compute address counts for duplicate detection - let addr_counts = AddressCounts::from_configuration(model); + let (ip_counts, mac_counts) = host_validation::count_addresses(model); // Track removal request (can't remove during iteration) let mut host_to_remove: Option = None; for (idx, host) in model.hosts.iter_mut().enumerate() { - ui_single_host(ui, idx, host, &addr_counts, &mut host_to_remove); + ui_single_host(ui, idx, host, &ip_counts, &mac_counts, &mut host_to_remove); ui.add_space(SPACING_MD); } @@ -91,12 +62,12 @@ fn ui_single_host( ui: &mut egui::Ui, index: usize, host: &mut Host, - addr_counts: &AddressCounts, + ip_counts: &HashMap, + mac_counts: &HashMap, remove_request: &mut Option, ) { let display_name = host_display_name(host); - let errors = - host_validation::validate_host(host, &addr_counts.ip, &addr_counts.mac); + let errors = host_validation::validate_host(host, ip_counts, mac_counts); // First host is expanded by default let id = ui.make_persistent_id(("host", index)); @@ -108,7 +79,7 @@ fn ui_single_host( .body(|ui| { render_host_basic_fields(ui, index, host); ui.separator(); - host_interfaces::ui_interfaces_section(ui, index, host, &addr_counts.ip, &addr_counts.mac); + host_interfaces::ui_interfaces_section(ui, index, host, ip_counts, mac_counts); }); } @@ -189,7 +160,7 @@ fn ui_host_summary_tooltip(ui: &mut egui::Ui, host: &Host) { /// Render the basic host fields: OS, hostname, usage, type, and client protocols. fn render_host_basic_fields(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { ui_host_os_selector(ui, host_idx, &mut host.os); - edit_optional_string(ui, "Hostname", &mut host.hostname, "host1"); + edit_optional_string_singleline(ui, "Hostname", &mut host.hostname, "host1"); ui_host_usage_field(ui, host); ui_host_type_selector(ui, host_idx, host); ui_host_client_protocols(ui, host_idx, host); @@ -227,9 +198,9 @@ 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() + .button(egui_material_icons::icons::ICON_CLEAR) + .on_hover_text("Clear OS") + .clicked() { *host_os = None; } @@ -243,7 +214,7 @@ fn ui_host_os_selector(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option< fn ui_host_usage_field(ui: &mut egui::Ui, host: &mut Host) { ui.horizontal(|ui| { ui.label("Usage"); - info_icon( + info_icon_with_tooltip( ui, &format!( "Optional (default value: {0}). The usage intensity of the host. \ @@ -284,7 +255,7 @@ fn ui_host_usage_field(ui: &mut egui::Ui, host: &mut Host) { fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { ui.horizontal(|ui| { ui.label("Type"); - info_icon( + info_icon_with_tooltip( 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, \ @@ -333,7 +304,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_idx: usize, host: &mut Host) { ui.horizontal(|ui| { ui.label("Client protocols"); - info_icon(ui, "Specify what services the host is a client of."); + info_icon_with_tooltip(ui, "Specify what services the host is a client of."); let popup_id = ui.make_persistent_id(("client_proto_popup", host_idx)); let add_btn_resp = ui diff --git a/crates/fosr-gui/src/config_editor/host_interfaces.rs b/crates/fosr-gui/src/config_editor/host_interfaces.rs index 9f6582ce..7fb533d6 100644 --- a/crates/fosr-gui/src/config_editor/host_interfaces.rs +++ b/crates/fosr-gui/src/config_editor/host_interfaces.rs @@ -7,7 +7,7 @@ use crate::shared::constants::network::{ IP_LOCAL_MAX, IP_LOCAL_MIN, MAC_ADDRESS_BYTES, MAC_LOCAL_BIT, MAC_LOCAL_MASK, }; use crate::shared::constants::ui::{SPACING_MD, SPACING_SM}; -use crate::shared::widgets::helpers::{edit_optional_string, required_label}; +use crate::shared::widgets::helpers::{edit_optional_string_singleline, required_label}; use eframe::egui; use std::collections::HashMap; @@ -57,40 +57,15 @@ pub fn ui_interfaces_section( let mut iface_to_remove: Option = None; 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::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, if_idx == 0) - .show_header(ui, |ui| { - 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() - { - iface_to_remove = Some(if_idx); - } - }); - }) - .body(|ui| { - ui.add_space(SPACING_SM); - ui.horizontal(|ui| { - 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(COLOR_ERROR, "IP already in used"); - } - }); - 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(COLOR_ERROR, "MAC already in use"); - } - } - host_services::ui_services_section(ui, if_idx, host_idx, iface); - }); + ui_single_interface( + ui, + host_idx, + if_idx, + iface, + ip_counts, + mac_counts, + &mut iface_to_remove, + ); ui.add_space(SPACING_MD); } @@ -99,7 +74,73 @@ pub fn ui_interfaces_section( } } -/// Generate a random mac address +/// Render a single interface as a collapsible card. +/// +/// Shows IP address in header, with editable fields for IP, MAC, and services in the body. +fn ui_single_interface( + ui: &mut egui::Ui, + host_idx: usize, + if_idx: usize, + iface: &mut Interface, + ip_counts: &HashMap, + mac_counts: &HashMap, + remove_request: &mut Option, +) { + let ip_label = iface.ip_addr.clone(); + let id = ui.make_persistent_id(("iface", host_idx, if_idx)); + + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, if_idx == 0) + .show_header(ui, |ui| { + 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() + { + *remove_request = Some(if_idx); + } + }); + }) + .body(|ui| { + render_interface_fields(ui, host_idx, if_idx, iface, ip_counts, mac_counts); + }); +} + +/// Render the editable fields for an interface: IP, MAC, and services. +fn render_interface_fields( + ui: &mut egui::Ui, + host_idx: usize, + if_idx: usize, + iface: &mut Interface, + ip_counts: &HashMap, + mac_counts: &HashMap, +) { + ui.add_space(SPACING_SM); + + // IP address field with duplicate warning + ui.horizontal(|ui| { + 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(COLOR_ERROR, "IP already in use"); + } + }); + + // MAC address field with duplicate warning + edit_optional_string_singleline(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(COLOR_ERROR, "MAC already in use"); + } + } + + // Services section + host_services::ui_services_section(ui, host_idx, if_idx, iface); +} + +/// Generate a random MAC address with the locally administered bit set. fn random_mac() -> String { let mut bytes: [u8; MAC_ADDRESS_BYTES] = rand::random(); diff --git a/crates/fosr-gui/src/config_editor/host_services.rs b/crates/fosr-gui/src/config_editor/host_services.rs index 9f94bd8e..f558c1c5 100644 --- a/crates/fosr-gui/src/config_editor/host_services.rs +++ b/crates/fosr-gui/src/config_editor/host_services.rs @@ -5,7 +5,7 @@ use crate::shared::constants::network::{PORT_DEFAULT_UNKNOWN, PORT_MAX, PORT_MIN use crate::shared::constants::ui::{ PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_SM, SPACING_XS, }; -use crate::shared::widgets::helpers::info_icon; +use crate::shared::widgets::helpers::info_icon_with_tooltip; use eframe::egui; pub const KNOWN_SERVICES: &[(&str, Option)] = &[ @@ -35,6 +35,17 @@ fn format_service(name: &str, port: Option) -> String { } } +/// Look up the default port for a known service name. +/// +/// Returns `PORT_DEFAULT_UNKNOWN` (0) if the service is not in KNOWN_SERVICES. +fn default_port_for_service(name: &str) -> u16 { + KNOWN_SERVICES + .iter() + .find(|(n, _)| *n == name) + .and_then(|(_, p)| *p) + .unwrap_or(PORT_DEFAULT_UNKNOWN) +} + /// Service section rendering pub fn ui_services_section( ui: &mut egui::Ui, @@ -48,7 +59,7 @@ pub 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."); + info_icon_with_tooltip(ui, "The list of available services provided by the host."); }) .body(|ui| { let popup_id = ui.make_persistent_id(("svc_popup", host_idx, iface_idx)); @@ -134,7 +145,7 @@ pub fn ui_services_section( }); } -/// Single service rendering +/// Render a single service row with name, remove button, and optional custom port. fn ui_single_service( ui: &mut egui::Ui, host_idx: usize, @@ -144,19 +155,16 @@ fn ui_single_service( remove_request: &mut Option, ) { let (svc_name, mut svc_port) = parse_service(svc_raw); + let default_port = default_port_for_service(&svc_name); - let default_port = KNOWN_SERVICES - .iter() - .find(|(n, _)| *n == svc_name) - .and_then(|(_, p)| *p) - .unwrap_or(PORT_DEFAULT_UNKNOWN); - + // Track whether custom port mode is enabled (persists across frames) 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(is_custom_by_default)); ui.horizontal(|ui| { + // Remove button with service name let btn_text = format!("{} {}", svc_name, egui_material_icons::icons::ICON_CLEAR); if ui .button(btn_text) @@ -166,45 +174,72 @@ fn ui_single_service( *remove_request = Some(svc_idx); } + // Custom port toggle if ui .checkbox(&mut custom_port_enabled, "Custom port") .changed() { - if !custom_port_enabled { - svc_port = if default_port == 0 { - None - } else { - Some(default_port) - }; - } + svc_port = resolve_port_after_toggle(custom_port_enabled, 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(PORT_MIN..=PORT_MAX), - ) - .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) - }; - } + // Port value editor or default display + svc_port = render_port_editor(ui, custom_port_enabled, svc_port, default_port); }); + // Persist custom port state ui.data_mut(|d| d.insert_temp(custom_port_id, custom_port_enabled)); + // Update the service string *svc_raw = format_service(&svc_name, svc_port); } + +/// Resolve port value after toggling custom port checkbox. +/// +/// When disabling custom port, returns the default (or None if default is 0). +/// When enabling, returns None to be filled by the editor. +fn resolve_port_after_toggle(custom_enabled: bool, default_port: u16) -> Option { + if custom_enabled { + None // Will be set by the DragValue + } else if default_port == PORT_DEFAULT_UNKNOWN { + None + } else { + Some(default_port) + } +} + +/// Render the port editor: either a DragValue or a read-only default label. +/// +/// Returns the selected port value (if any). +fn render_port_editor( + ui: &mut egui::Ui, + custom_enabled: bool, + current_port: Option, + default_port: u16, +) -> Option { + if custom_enabled { + let mut port_val = current_port.unwrap_or(default_port); + if ui + .add( + egui::DragValue::new(&mut port_val) + .speed(1) + .range(PORT_MIN..=PORT_MAX), + ) + .changed() + { + Some(port_val) + } else { + Some(port_val) // Return current value even if unchanged + } + } else { + // Show default port as read-only label + ui.add_enabled( + false, + egui::Label::new(egui::RichText::new(format!("(default: {default_port})")).weak()), + ); + if default_port == PORT_DEFAULT_UNKNOWN { + None + } else { + Some(default_port) + } + } +} diff --git a/crates/fosr-gui/src/config_editor/host_validation.rs b/crates/fosr-gui/src/config_editor/host_validation.rs index 81a08d46..52d0d38d 100644 --- a/crates/fosr-gui/src/config_editor/host_validation.rs +++ b/crates/fosr-gui/src/config_editor/host_validation.rs @@ -4,7 +4,29 @@ use crate::shared::config::model::{Configuration, Host}; use crate::shared::constants::network::{MAC_ADDRESS_PARTS, MAC_PART_LENGTH}; use std::collections::HashMap; -/// Function to validate if a host is correct +/// Counts of IP and MAC address occurrences across all hosts. +pub type AddressCounts = (HashMap, HashMap); + +/// Count IP and MAC addresses across all hosts in the configuration. +/// +/// Returns a tuple of (ip_counts, mac_counts) used for duplicate detection. +pub fn count_addresses(config: &Configuration) -> AddressCounts { + let mut ip_counts: HashMap = HashMap::new(); + let mut mac_counts: HashMap = HashMap::new(); + + for host in &config.hosts { + for iface in &host.interfaces { + *ip_counts.entry(iface.ip_addr.clone()).or_insert(0) += 1; + if let Some(mac) = &iface.mac_addr { + *mac_counts.entry(mac.clone()).or_insert(0) += 1; + } + } + } + + (ip_counts, mac_counts) +} + +/// Validate a host configuration for correctness. pub fn validate_host( host: &Host, ip_counts: &HashMap, @@ -55,16 +77,7 @@ pub fn validate_host( /// Returns true if any host in the model has validation errors. pub fn has_model_errors(model: &Configuration) -> bool { - let mut ip_counts: HashMap = HashMap::new(); - let mut mac_counts: HashMap = HashMap::new(); - for h in &model.hosts { - for iface in &h.interfaces { - *ip_counts.entry(iface.ip_addr.clone()).or_insert(0) += 1; - if let Some(mac) = &iface.mac_addr { - *mac_counts.entry(mac.clone()).or_insert(0) += 1; - } - } - } + let (ip_counts, mac_counts) = count_addresses(model); model .hosts .iter() diff --git a/crates/fosr-gui/src/config_editor/mod.rs b/crates/fosr-gui/src/config_editor/mod.rs index 51b2d861..98872062 100644 --- a/crates/fosr-gui/src/config_editor/mod.rs +++ b/crates/fosr-gui/src/config_editor/mod.rs @@ -6,4 +6,5 @@ pub mod host_services; pub mod host_validation; pub mod state; pub mod tab; +pub mod toolbar; pub mod yaml_editor; diff --git a/crates/fosr-gui/src/config_editor/tab.rs b/crates/fosr-gui/src/config_editor/tab.rs index 981b83cb..6c348225 100644 --- a/crates/fosr-gui/src/config_editor/tab.rs +++ b/crates/fosr-gui/src/config_editor/tab.rs @@ -2,13 +2,14 @@ use crate::config_editor::state::ConfigurationTabState; use crate::config_editor::{host, host_validation, yaml_editor}; -use crate::shared::config::file_ops::{configuration_file_picker, load_config_file_contents}; +use crate::config_editor::toolbar::configuration_toolbar; +use crate::shared::config::file_ops::load_config_file_contents; use crate::shared::config::model::Configuration; use crate::shared::config::state::ConfigurationFileState; use crate::shared::constants::colors::{COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING}; use crate::shared::constants::ui::{SPACING_MD, TEXT_EDIT_DEFAULT_ROWS}; use crate::shared::widgets::helpers::{ - edit_optional_multiline_string, edit_optional_string, required_label, + edit_optional_string_multiline, edit_optional_string_singleline, required_label, }; use eframe::egui; @@ -23,7 +24,7 @@ pub fn show_configuration_tab_content( egui::ScrollArea::vertical().show(ui, |ui| { // File Selection - configuration_file_picker(ui, tab_state, file_state); + configuration_toolbar(ui, tab_state, file_state); ui_parsing_status(ui, file_state); @@ -41,49 +42,61 @@ pub fn show_configuration_tab_content( meta_id, false, ) - .show_header(ui, |ui| { - ui.heading("Metadata"); - }) - .body(|ui| { - ui_metadata(ui, model); - }); + .show_header(ui, |ui| { + ui.heading("Metadata"); + }) + .body(|ui| { + ui_metadata(ui, model); + }); } - 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()); - } - } - - 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; - } - } + sync_model_to_yaml_state(file_state); } else { yaml_editor::ui_yaml_editor(ui, file_state); } // Update error flag (parse errors + host validation errors) - file_state.has_errors = file_state.parse_error.is_some() + file_state.has_errors = file_state.config_error.is_some() || file_state - .config_model - .as_ref() - .is_some_and(host_validation::has_model_errors); + .config_model + .as_ref() + .is_some_and(host_validation::has_model_errors); } }); } +/// Sync model state to YAML content and update dirty flag. +/// +/// Serializes the current model to YAML string and compares against +/// the clean snapshot to detect unsaved changes. +fn sync_model_to_yaml_state(state: &mut ConfigurationFileState) { + let Some(model) = &state.config_model else { + return; + }; + + // Serialize model to YAML (done once, reused for dirty check) + let model_yaml = match serde_yaml::to_string(model) { + Ok(yaml) => { + state.config_error = None; + yaml + } + Err(e) => { + state.config_error = Some(e.to_string()); + return; + } + }; + state.config_file_content = Some(model_yaml.clone()); + + // Update dirty flag by comparing with cached snapshot YAML + if let Some(snap_yaml) = &state.clean_snapshot { + state.is_dirty = model_yaml != *snap_yaml; + } +} + /// 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 { + if let Some(err) = &state.config_error { ui.colored_label(COLOR_ERROR, "YAML parsing failed:"); ui.label(err); } else if state.config_model.is_some() { @@ -106,7 +119,7 @@ fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { ui.text_edit_singleline(title); }); - edit_optional_multiline_string( + edit_optional_string_multiline( ui, "Description", &mut model.metadata.desc, @@ -114,7 +127,7 @@ fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { TEXT_EDIT_DEFAULT_ROWS, ); - edit_optional_string(ui, "Author", &mut model.metadata.author, "Jane Doe"); + edit_optional_string_singleline(ui, "Author", &mut model.metadata.author, "Jane Doe"); - edit_optional_string(ui, "Version", &mut model.metadata.version, "0.1.0"); + edit_optional_string_singleline(ui, "Version", &mut model.metadata.version, "0.1.0"); } diff --git a/crates/fosr-gui/src/config_editor/toolbar.rs b/crates/fosr-gui/src/config_editor/toolbar.rs new file mode 100644 index 00000000..d4a414e3 --- /dev/null +++ b/crates/fosr-gui/src/config_editor/toolbar.rs @@ -0,0 +1,185 @@ +//! Configuration toolbar UI: file picker, template menu, save button, and mode toggle. + +use crate::config_editor::state::ConfigurationTabState; +use crate::config_templates::{load_template, TEMPLATES}; +use crate::shared::config::file_ops::{enforce_metadata_defaults, trigger_file_import}; +use crate::shared::config::state::ConfigurationFileState; +use crate::shared::constants::colors::COLOR_WARNING; +use crate::shared::constants::ui::SPACING_LG; +#[cfg(not(target_arch = "wasm32"))] +use crate::shared::file_io::save_file_desktop; +#[cfg(target_arch = "wasm32")] +use crate::shared::file_io::save_file_wasm; +use crate::shared::widgets::helpers::labeled_toggle; +use eframe::egui; + +#[cfg(target_arch = "wasm32")] +use crate::shared::config::file_ops::poll_file_import; + +/// Configuration toolbar UI. +/// +/// Displays a file picker button, a template selection dropdown menu, +/// the selected file name and a Visual/Code mode toggle. +pub fn configuration_toolbar( + ui: &mut egui::Ui, + tab_state: &mut ConfigurationTabState, + state: &mut ConfigurationFileState, +) { + ui.horizontal(|ui| { + ui.label("Configuration file:"); + file_import_button(ui, state); + template_menu_button(ui, state); + file_save_button(ui, state); + filename_display(ui, state); + mode_toggle(ui, tab_state); + }); +} + +/// File import button with folder icon. +/// +/// Opens a file picker dialog to select a configuration file. +/// On WASM, polls the async file picker result. +fn file_import_button(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { + if ui + .button(egui_material_icons::icons::ICON_FOLDER_OPEN) + .on_hover_text("Select a configuration file") + .clicked() + { + trigger_file_import(state, ui.ctx()); + } + + #[cfg(target_arch = "wasm32")] + poll_file_import(state); +} + +/// Template menu button with document icon. +/// +/// Dropdown menu listing available configuration templates. +/// Clicking a template loads it into the editor. +fn template_menu_button(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { + let template_menu = + ui.menu_button(egui_material_icons::icons::ICON_DESCRIPTION, |menu_ui| { + for template in TEMPLATES { + if menu_ui + .button(format!("{} {}", template.icon, template.title)) + .clicked() + { + menu_ui.close(); + load_template(state, template); + } + } + }); + template_menu.response.on_hover_text("Open template"); +} + +/// Save button with "Save as" functionality. +/// +/// Only visible when a configuration is loaded. +/// Serializes the config to YAML and triggers a file save dialog. +/// Updates metadata with current date before saving. +fn file_save_button(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { + if state.config_file_content.is_none() { + return; + } + + if ui + .button(egui_material_icons::icons::ICON_SAVE_AS) + .on_hover_text("Save as") + .clicked() + { + if let Some(model) = state.config_model.as_mut() { + enforce_metadata_defaults(model); + } + let content = match &state.config_model { + Some(model) => serde_yaml::to_string(model).unwrap_or_default(), + None => state.config_file_content.clone().unwrap_or_default(), + }; + state.is_dirty = false; + state.clean_snapshot = Some(content.clone()); + let default_name = 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), + } + }); + } + } +} + +/// Filename display with dirty indicator. +/// +/// Shows the selected file name or template name. +/// Displays a warning icon when there are unsaved changes. +/// On desktop, shows the full path on hover. +fn filename_display(ui: &mut egui::Ui, state: &ConfigurationFileState) { + let filename = if let Some(file) = &state.picked_config_file { + file.file_name() + } else if let Some(template_id) = &state.loaded_template_id { + format!("{}.yaml (built-in template)", template_id) + } else { + "No file selected".to_string() + }; + + #[cfg(not(target_arch = "wasm32"))] + let hover_text = state + .picked_config_file + .as_ref() + .map(|file| file.path().to_string_lossy().to_string()) + .unwrap_or_default(); + + #[cfg(target_arch = "wasm32")] + let hover_text = String::new(); + + render_filename_with_status(ui, &filename, state.is_dirty, &hover_text); +} + +/// Render filename label with optional dirty indicator and hover text. +fn render_filename_with_status(ui: &mut egui::Ui, filename: &str, is_dirty: bool, hover_text: &str) { + if is_dirty { + ui.colored_label(COLOR_WARNING, egui_material_icons::icons::ICON_WARNING) + .on_hover_text("Unsaved changes detected — download the file to avoid losing them."); + ui.colored_label(COLOR_WARNING, filename) + .on_hover_text(hover_text); + } else { + ui.label(filename).on_hover_text(hover_text); + } +} + +/// Mode toggle for switching between Visual and Code editing modes. +/// +/// Positioned on the right side of the toolbar using RTL layout. +fn mode_toggle(ui: &mut egui::Ui, tab_state: &mut ConfigurationTabState) { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(SPACING_LG); + // In RTL layout, rendering order is reversed, + // so Code is passed first to appear visually on the right. + labeled_toggle( + ui, + &mut tab_state.is_code_mode, + &format!("{} Code", egui_material_icons::icons::ICON_CODE), + &format!("{} Visual", egui_material_icons::icons::ICON_EDIT), + "Code Mode: edit as raw YAML.", + "Visual Mode: edit using the graphical interface.", + ); + }); +} diff --git a/crates/fosr-gui/src/config_editor/yaml_editor.rs b/crates/fosr-gui/src/config_editor/yaml_editor.rs index e7dfbd05..a039dd57 100644 --- a/crates/fosr-gui/src/config_editor/yaml_editor.rs +++ b/crates/fosr-gui/src/config_editor/yaml_editor.rs @@ -6,6 +6,9 @@ use crate::shared::constants::colors::COLOR_ERROR; use crate::shared::constants::ui::{YAML_EDITOR_ROWS, YAML_GUTTER_PADDING, YAML_ICON_COL_WIDTH}; use eframe::egui; +/// Extract line numbers from YAML parse error messages. +/// +/// Parses "line N" patterns from error strings to highlight problematic lines. fn parse_error_lines(err: &str) -> Vec { let mut found = Vec::new(); let mut search = err; @@ -22,40 +25,53 @@ fn parse_error_lines(err: &str) -> Vec { found } -/// Editable YAML Editor +/// Metrics for rendering the line number gutter. +struct GutterMetrics { + /// Width of a single digit in the current font. + digit_width: f32, + /// Number of digits needed for the largest line number. + digits: usize, + /// Total width of the gutter column. + width: f32, + /// Height of a single line of text. + line_height: f32, + /// Font for gutter text. + font_id: egui::FontId, +} + +impl GutterMetrics { + /// Calculate gutter metrics from the content and UI style. + fn from_content(ui: &egui::Ui, line_count: usize) -> Self { + 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(); + + Self { + digit_width, + digits, + width: digit_width * digits as f32 + YAML_GUTTER_PADDING + YAML_ICON_COL_WIDTH, + line_height, + font_id, + } + } +} + +/// Editable YAML Editor with syntax highlighting and error markers. pub fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { if state.config_file_content.is_none() { ui.label("No configuration file selected."); return; } - let error_lines: Vec = if let Some(err) = &state.parse_error { - ui.colored_label( - COLOR_ERROR, - format!( - "{} YAML parsing failed", - egui_material_icons::icons::ICON_WARNING - ), - ); - ui.colored_label(COLOR_ERROR, err); - ui.separator(); - parse_error_lines(err) - } else { - Vec::new() - }; + // Display error banner if parsing failed + let error_lines = render_error_banner(ui, state); 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 gutter_width = digit_width * digits as f32 + YAML_GUTTER_PADDING + YAML_ICON_COL_WIDTH; + let metrics = GutterMetrics::from_content(ui, line_count); let gutter_color = ui.visuals().weak_text_color(); - let error_color = COLOR_ERROR; let gutter_bg = ui.visuals().extreme_bg_color; let scroll_offset_id = ui.make_persistent_id("yaml_editor_scroll_y"); @@ -64,53 +80,30 @@ pub fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { let mut editor_changed = false; let mut new_scroll_y = scroll_y; + // Sync scroll position between gutter and editor ui.spacing_mut().item_spacing.x = 0.0; ui.horizontal_top(|ui| { let available_height = ui.available_height(); + // Allocate and render the gutter let (gutter_rect, _) = ui.allocate_exact_size( - egui::vec2(gutter_width, available_height), + egui::vec2(metrics.width, available_height), egui::Sense::hover(), ); ui.painter().rect_filled(gutter_rect, 0.0, gutter_bg); + render_gutter( + ui, + &gutter_rect, + &metrics, + scroll_y, + line_count, + &error_lines, + gutter_color, + ); - 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(YAML_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); - }); - } - } - + // Syntax-highlighted editor + let theme = egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx(), ui.style()); let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { let mut layout_job = egui_extras::syntax_highlighting::highlight( ui.ctx(), @@ -141,10 +134,79 @@ pub fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { editor_changed = scroll_out.inner.changed(); }); + // Persist scroll position ui.data_mut(|d| d.insert_temp(scroll_offset_id, new_scroll_y)); + // Re-parse if content changed if editor_changed { state.config_file_content = Some(content); parse_config_yaml(state); } } + +/// Render error banner and return parsed error line numbers. +fn render_error_banner(ui: &mut egui::Ui, state: &ConfigurationFileState) -> Vec { + if let Some(err) = &state.config_error { + ui.colored_label( + COLOR_ERROR, + format!( + "{} YAML parsing failed", + egui_material_icons::icons::ICON_WARNING + ), + ); + ui.colored_label(COLOR_ERROR, err); + ui.separator(); + parse_error_lines(err) + } else { + Vec::new() + } +} + +/// Render the line number gutter with optional error markers. +fn render_gutter( + ui: &mut egui::Ui, + gutter_rect: &egui::Rect, + metrics: &GutterMetrics, + scroll_y: f32, + line_count: usize, + error_lines: &[usize], + default_color: egui::Color32, +) { + let first_visible = (scroll_y / metrics.line_height).floor() as usize; + let visible_count = (gutter_rect.height() / metrics.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 * metrics.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 { COLOR_ERROR } else { default_color }; + + // Right-aligned line number + let num_str = format!("{:>width$}", line_num, width = metrics.digits); + ui.painter().text( + egui::pos2(gutter_rect.left() + 2.0, y), + egui::Align2::LEFT_TOP, + num_str, + metrics.font_id.clone(), + num_color, + ); + + // Error icon for lines with parse errors + if is_error { + let num_col_right = gutter_rect.left() + metrics.digit_width * metrics.digits as f32 + 4.0; + let icon_rect = egui::Rect::from_min_size( + egui::pos2(num_col_right, y), + egui::vec2(YAML_ICON_COL_WIDTH, metrics.line_height), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(icon_rect), |ui| { + ui.colored_label(COLOR_ERROR, egui_material_icons::icons::ICON_WARNING); + }); + } + } +} diff --git a/crates/fosr-gui/src/config_templates/mod.rs b/crates/fosr-gui/src/config_templates/mod.rs index f67e4029..0d026ee6 100644 --- a/crates/fosr-gui/src/config_templates/mod.rs +++ b/crates/fosr-gui/src/config_templates/mod.rs @@ -5,6 +5,7 @@ use crate::shared::config::state::ConfigurationFileState; use egui_material_icons::icons; /// A predefined configuration template. +#[derive(Clone, Copy)] pub struct Template { pub id: &'static str, pub title: &'static str, @@ -38,35 +39,36 @@ pub static TEMPLATES: &[Template] = &[ }, ]; -/// Load a template by its ID into the configuration file state. -pub fn load_template_by_id(state: &mut ConfigurationFileState, id: &str) { - if let Some(template) = TEMPLATES.iter().find(|t| t.id == id) { - load_template(state, template); - } -} - /// Load a template into the configuration file state. -fn load_template(state: &mut ConfigurationFileState, template: &Template) { - state.picked_config_file = None; - state.config_file_content = Some(template.yaml.to_string()); - state.config_model = None; - state.parse_error = None; - - match serde_yaml::from_str::(template.yaml) { - Ok(model) => { - state.config_model = Some(model); - state.clean_snapshot = state.config_model.clone(); - state.config_chosen = true; - state.is_dirty = false; - state.loaded_template_id = Some(template.id.to_string()); - } +/// +/// Parses the template YAML first, then applies state changes only on success. +/// On parse failure, sets the error without modifying the existing model. +pub fn load_template(state: &mut ConfigurationFileState, template: &Template) { + // Parse first, fail-fast if invalid + let model = match serde_yaml::from_str::(template.yaml) { + Ok(model) => model, Err(e) => { - state.parse_error = Some(e.to_string()); + state.config_error = Some(e.to_string()); + return; } - } + }; + + // Apply state changes only after successful parse + apply_template_to_state(state, template, model); } -/// Get all available templates. -pub fn all_templates() -> &'static [Template] { - TEMPLATES +/// Reset state and apply a successfully-parsed template. +fn apply_template_to_state( + state: &mut ConfigurationFileState, + template: &Template, + model: Configuration, +) { + state.picked_config_file = None; + state.config_file_content = Some(template.yaml.to_string()); + state.config_model = Some(model); + state.clean_snapshot = Some(template.yaml.to_string()); + state.config_error = None; + state.config_chosen = true; + state.is_dirty = false; + state.loaded_template_id = Some(template.id.to_string()); } diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index 5ac9bfc5..77b16567 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -8,9 +8,11 @@ use app::FosrApp; use eframe::wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; +/// Function that can be called from JavaScript to start the Fos-R GUI app. +/// The `canvas_id` argument is the ID of the HTML canvas element where the app will be rendered. #[wasm_bindgen] pub async fn start(canvas_id: &str) -> Result<(), JsValue> { - // Redirect `log` message to `console.log`: + // Redirect logs to the browser console if let Err(e) = eframe::WebLogger::init(log::LevelFilter::Info) { log::warn!("WebLogger initialization failed: {:?}", e); } @@ -22,7 +24,7 @@ pub async fn start(canvas_id: &str) -> Result<(), JsValue> { .document() .expect("No document"); - // The canvas_id is passed as an argument from the HTML file + // The `canvas_id` is passed as an argument from the HTML file // and identifies the canvas element on which the app will be rendered let canvas = document .get_element_by_id(canvas_id) diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index 6bc5e089..26680e79 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -13,7 +13,7 @@ fn main() -> eframe::Result { use eframe::egui; use env_logger; - // Redirect log messages to the console + // Redirect log messages to the terminal env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); let shared_viewport = egui::ViewportBuilder::default() diff --git a/crates/fosr-gui/src/run/generation/bottom_panel.rs b/crates/fosr-gui/src/run/generation/bottom_panel.rs index e824cb55..2c398b16 100644 --- a/crates/fosr-gui/src/run/generation/bottom_panel.rs +++ b/crates/fosr-gui/src/run/generation/bottom_panel.rs @@ -32,24 +32,35 @@ pub fn show_bottom_panel( state: &mut RunTabState, configuration_file_state: &ConfigurationFileState, ) { - // Options panel (shown above action bar when expanded) if state.panel_open { - egui::TopBottomPanel::bottom("run_options_panel") - .frame( - egui::Frame::side_top_panel(&ctx.style()) - .inner_margin(egui::Margin::symmetric( - OPTIONS_PANEL_INNER_MARGIN.0, - OPTIONS_PANEL_INNER_MARGIN.1, - )) - .fill(ctx.style().visuals.panel_fill), - ) - .resizable(false) - .show(ctx, |ui| { - show_generation_options(ui, state); - }); + show_options_panel(ctx, state); } + show_action_bar(ctx, state, configuration_file_state); +} - // Action bar (always visible) +/// Options panel shown above the action bar when expanded. +fn show_options_panel(ctx: &egui::Context, state: &mut RunTabState) { + egui::TopBottomPanel::bottom("run_options_panel") + .frame( + egui::Frame::side_top_panel(&ctx.style()) + .inner_margin(egui::Margin::symmetric( + OPTIONS_PANEL_INNER_MARGIN.0, + OPTIONS_PANEL_INNER_MARGIN.1, + )) + .fill(ctx.style().visuals.panel_fill), + ) + .resizable(false) + .show(ctx, |ui| { + show_generation_options(ui, state); + }); +} + +/// Action bar with Generate/Stop/Save buttons and progress indicators. +fn show_action_bar( + ctx: &egui::Context, + state: &mut RunTabState, + configuration_file_state: &ConfigurationFileState, +) { egui::TopBottomPanel::bottom("run_bottom_bar") .frame( egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin::symmetric( @@ -63,175 +74,195 @@ pub fn show_bottom_panel( let can_generate = first_invalid_param(&state.generation).is_none(); ui.horizontal(|ui| { - // Generate button (always visible, same style/position) + // Left side: action buttons 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(format!( - "{} Generate", - egui_material_icons::icons::ICON_PLAY_ARROW - )) - .size(TEXT_SIZE_MD), - ) - .fill(accent) - .min_size(egui::vec2(BUTTON_MIN_WIDTH_LG, BUTTON_HEIGHT)); - if ui - .add(generate_button) - .on_hover_text("Generate PCAP from configuration") - .clicked() - { - start_generation(state, configuration_file_state, ctx); - } - }); + show_generate_button(ui, state, configuration_file_state, ctx, can_generate); } - - // Stop button (when generating) if is_generating { - let stop_button = egui::Button::new( - egui::RichText::new(format!( - "{} Stop", - egui_material_icons::icons::ICON_STOP - )) - .size(TEXT_SIZE_MD), - ) - .fill(COLOR_STOP) - .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); - if ui - .add(stop_button) - .on_hover_text("Cancel generation") - .clicked() - { - state.generation.cancelled.store(true, Ordering::Relaxed); - state.generation.progress = 0.0; - state.generation.progress_receiver = None; - state.generation.pcap_receiver = None; - state.generation.throughput_receiver = None; - } + show_stop_button(ui, state); } - - // Save/Open buttons (when complete) if is_complete { - #[cfg(not(target_arch = "wasm32"))] - let save_text = format!("{} Save", egui_material_icons::icons::ICON_SAVE); - #[cfg(target_arch = "wasm32")] - let save_text = - format!("{} Download", egui_material_icons::icons::ICON_DOWNLOAD); - - let save_button = - egui::Button::new(egui::RichText::new(save_text).size(TEXT_SIZE_MD)) - .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); - if ui.add(save_button).clicked() { - let pcap_bytes = state.generation.pcap_bytes.clone(); - #[cfg(not(target_arch = "wasm32"))] - { - let data = pcap_bytes.as_ref().unwrap().as_slice(); - match save_file_desktop(data, &state.generation.output_file_name) { - Ok(file_handle) => { - log::info!( - "Successfully wrote to file: {}", - file_handle.path().to_string_lossy() - ); - } - Err(e) => { - log::error!("Failed to save file: {:?}", e); - state.generation.error = - Some(format!("Failed to save file: {e}")); - } - } - } - - #[cfg(target_arch = "wasm32")] - { - let file_name = state.generation.output_file_name.clone(); - wasm_bindgen_futures::spawn_local(async move { - let data = pcap_bytes.as_ref().unwrap().as_slice(); - match save_file_wasm(data, &file_name).await { - Ok(_) => log::info!("File written successfully!"), - Err(e) => log::error!("Failed to write file on WASM: {:?}", e), - } - }); - } - } - - // Open in Wireshark button (native only) - #[cfg(not(target_arch = "wasm32"))] - { - let open_button = egui::Button::new( - egui::RichText::new(format!( - "{} Open", - egui_material_icons::icons::ICON_LAN - )) - .size(TEXT_SIZE_MD), - ) - .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); - let response = - ui.add_enabled(state.generation.wireshark_available, open_button); - let response = if state.generation.wireshark_available { - response.on_hover_text("Open in Wireshark") - } else { - response.on_disabled_hover_text("Wireshark not found in PATH") - }; - if response.clicked() { - if let Some(ref pcap_bytes) = state.generation.pcap_bytes { - match open_in_wireshark( - pcap_bytes, - &mut state.generation.temp_pcap_files, - ) { - Ok(_) => log::info!("Opened PCAP in Wireshark"), - Err(e) => { - log::error!("Failed to open in Wireshark: {:?}", e); - state.generation.error = - Some(format!("Failed to open in Wireshark: {e}")); - } - } - } - } - } + show_completion_buttons(ui, state); } - - // Error display (when there's an error) if let Some(error) = &state.generation.error { ui.colored_label(COLOR_ERROR, error); } - // Options toggle button (right-aligned) with progress bar and throughput + // Right side: options toggle, throughput, progress ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Options button (rightmost) - let icon = if state.panel_open { - egui_material_icons::icons::ICON_KEYBOARD_ARROW_DOWN - } else { - egui_material_icons::icons::ICON_KEYBOARD_ARROW_UP - }; - let tooltip = if state.panel_open { - "Hide options" - } else { - "Show options" - }; - if ui - .button(format!("{} Options", icon)) - .on_hover_text(tooltip) - .clicked() - { - state.panel_open = !state.panel_open; - state.visualization.view.delayed_fit_countdown = Some(DELAY_FRAMES_QUICK); - } - - // Throughput (when complete) - left of Options + show_options_toggle(ui, state); if is_complete { - if let Some(throughput) = &state.generation.throughput { - ui.label(format!("Throughput: {throughput}")); - } + show_throughput(ui, state); } - - // Progress bar (when generating) - left of Throughput/Options if is_generating { - let progress = egui::ProgressBar::new(state.generation.progress) - .text("") - .fill(COLOR_SUCCESS); - ui.add(progress); + show_progress_bar(ui, state); } }); }); }); } + +/// Generate button with accent color. +fn show_generate_button( + ui: &mut egui::Ui, + state: &mut RunTabState, + configuration_file_state: &ConfigurationFileState, + ctx: &egui::Context, + can_generate: bool, +) { + ui.add_enabled_ui(can_generate, |ui| { + let accent = ui.visuals().selection.bg_fill; + let button = egui::Button::new( + egui::RichText::new(format!( + "{} Generate", + egui_material_icons::icons::ICON_PLAY_ARROW + )) + .size(TEXT_SIZE_MD), + ) + .fill(accent) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_LG, BUTTON_HEIGHT)); + + if ui.add(button).on_hover_text("Generate PCAP from configuration").clicked() { + start_generation(state, configuration_file_state, ctx); + } + }); +} + +/// Stop button to cancel ongoing generation. +fn show_stop_button(ui: &mut egui::Ui, state: &mut RunTabState) { + let button = egui::Button::new( + egui::RichText::new(format!("{} Stop", egui_material_icons::icons::ICON_STOP)) + .size(TEXT_SIZE_MD), + ) + .fill(COLOR_STOP) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); + + if ui.add(button).on_hover_text("Cancel generation").clicked() { + state.generation.cancelled.store(true, Ordering::Relaxed); + state.generation.progress = 0.0; + state.generation.progress_receiver = None; + state.generation.pcap_receiver = None; + state.generation.throughput_receiver = None; + } +} + +/// Save and Wireshark buttons shown when generation is complete. +fn show_completion_buttons(ui: &mut egui::Ui, state: &mut RunTabState) { + show_save_button(ui, state); + #[cfg(not(target_arch = "wasm32"))] + show_wireshark_button(ui, state); +} + +/// Save/Download button for the generated PCAP. +fn show_save_button(ui: &mut egui::Ui, state: &mut RunTabState) { + #[cfg(not(target_arch = "wasm32"))] + let save_text = format!("{} Save", egui_material_icons::icons::ICON_SAVE); + #[cfg(target_arch = "wasm32")] + let save_text = format!("{} Download", egui_material_icons::icons::ICON_DOWNLOAD); + + let button = egui::Button::new(egui::RichText::new(save_text).size(TEXT_SIZE_MD)) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); + + if ui.add(button).clicked() { + let pcap_bytes = state.generation.pcap_bytes.clone(); + #[cfg(not(target_arch = "wasm32"))] + save_pcap_desktop(pcap_bytes, state); + + #[cfg(target_arch = "wasm32")] + save_pcap_wasm(pcap_bytes, &state.generation.output_file_name); + } +} + +/// Saves PCAP to disk on desktop platforms. +#[cfg(not(target_arch = "wasm32"))] +fn save_pcap_desktop(pcap_bytes: Option>, state: &mut RunTabState) { + let Some(data) = pcap_bytes else { + log::error!("No PCAP data available to save"); + state.generation.error = Some("No PCAP data available".to_string()); + return; + }; + match save_file_desktop(&data, &state.generation.output_file_name) { + Ok(file_handle) => { + log::info!("Successfully wrote to file: {}", file_handle.path().to_string_lossy()); + } + Err(e) => { + log::error!("Failed to save file: {:?}", e); + state.generation.error = Some(format!("Failed to save file: {e}")); + } + } +} + +/// Triggers PCAP download on WASM platforms. +#[cfg(target_arch = "wasm32")] +fn save_pcap_wasm(pcap_bytes: Option>, file_name: &str) { + let Some(data) = pcap_bytes else { + log::error!("No PCAP data available to download"); + return; + }; + let file_name = file_name.to_string(); + wasm_bindgen_futures::spawn_local(async move { + match save_file_wasm(&data, &file_name).await { + Ok(_) => log::info!("File written successfully!"), + Err(e) => log::error!("Failed to write file on WASM: {:?}", e), + } + }); +} + +/// Wireshark button (native only) to open PCAP in external tool. +#[cfg(not(target_arch = "wasm32"))] +fn show_wireshark_button(ui: &mut egui::Ui, state: &mut RunTabState) { + let button = egui::Button::new( + egui::RichText::new(format!("{} Open", egui_material_icons::icons::ICON_LAN)) + .size(TEXT_SIZE_MD), + ) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); + + let response = ui.add_enabled(state.generation.wireshark_available, button); + let response = if state.generation.wireshark_available { + response.on_hover_text("Open in Wireshark") + } else { + response.on_disabled_hover_text("Wireshark not found in PATH") + }; + + if response.clicked() { + if let Some(ref pcap_bytes) = state.generation.pcap_bytes { + match open_in_wireshark(pcap_bytes, &mut state.generation.temp_pcap_files) { + Ok(_) => log::info!("Opened PCAP in Wireshark"), + Err(e) => { + log::error!("Failed to open in Wireshark: {:?}", e); + state.generation.error = Some(format!("Failed to open in Wireshark: {e}")); + } + } + } + } +} + +/// Options toggle button to show/hide the options panel. +fn show_options_toggle(ui: &mut egui::Ui, state: &mut RunTabState) { + let icon = if state.panel_open { + egui_material_icons::icons::ICON_KEYBOARD_ARROW_DOWN + } else { + egui_material_icons::icons::ICON_KEYBOARD_ARROW_UP + }; + let tooltip = if state.panel_open { "Hide options" } else { "Show options" }; + + if ui.button(format!("{} Options", icon)).on_hover_text(tooltip).clicked() { + state.panel_open = !state.panel_open; + state.visualization.view.delayed_fit_countdown = Some(DELAY_FRAMES_QUICK); + } +} + +/// Throughput display shown when generation is complete. +fn show_throughput(ui: &mut egui::Ui, state: &RunTabState) { + if let Some(throughput) = &state.generation.throughput { + ui.label(format!("Throughput: {throughput}")); + } +} + +/// Progress bar shown during generation. +fn show_progress_bar(ui: &mut egui::Ui, state: &RunTabState) { + let progress = egui::ProgressBar::new(state.generation.progress) + .text("") + .fill(COLOR_SUCCESS); + ui.add(progress); +} diff --git a/crates/fosr-gui/src/run/generation/core.rs b/crates/fosr-gui/src/run/generation/core.rs index 74a9ba46..dcf8f89e 100644 --- a/crates/fosr-gui/src/run/generation/core.rs +++ b/crates/fosr-gui/src/run/generation/core.rs @@ -12,6 +12,9 @@ use std::sync::mpsc::Sender; use std::time::UNIX_EPOCH as STD_UNIX_EPOCH; use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +/// Entry point for PCAP generation. Runs the 4-stage pipeline and sends results via channels. +/// +/// Returns `Ok(())` on success, or `Err(message)` if generation fails. pub fn generate( seed: Option, profile: Option, @@ -24,7 +27,7 @@ pub fn generate( pcap_sender: Option>>, throughput_sender: Option>, cancelled: Arc, -) { +) -> Result<(), String> { // Create a closure to send progress updates let send_progress = |progress: f32| { if let Some(sender) = &progress_sender { @@ -40,62 +43,21 @@ pub fn generate( }; // Load the models - let source = models::ModelsSource::Legacy; - let mut model = models::Models::from_source(source).unwrap(); - if let Some(config) = profile { - model = model.with_string_config(&config).unwrap(); - } - + let model = load_models(&profile)?; let automata_library = Arc::new(model.automata); let bn = Arc::new(model.bn); // Handle the parameters: either there is a packet count target or a duration - let d = humantime::parse_duration(&duration).expect("Duration could not be parsed."); + let d = humantime::parse_duration(&duration) + .map_err(|e| format!("Invalid duration '{}': {}", duration, e))?; log::info!("Generating a pcap of {d:?}"); let _target = Target::GenerationDuration(d); - let duration = Some(d); if let Some(s) = seed { log::info!("Generating with seed {s}"); } - let initial_ts: Duration = if let Some(start_time) = start_time { - // try to parse a date - if let Ok(d) = humantime::parse_rfc3339_weak(&start_time) { - d.duration_since(STD_UNIX_EPOCH).unwrap() - } else if let Ok(n) = start_time.parse::() { - Duration::from_secs(n) - } else { - panic!("Could not parse start time"); - } - } else { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap() - }; - - let tz_offset = match timezone { - Some(tz_str) => { - let tz = tz_str.parse::().expect("Could not parse the timezone"); - let date = DateTime::from_timestamp(initial_ts.as_secs() as i64, 0) - .unwrap() - .naive_utc(); - let tz = tz.offset_from_utc_datetime(&date).fix(); - log::info!("Using {tz_str} timezone (UTC{tz})"); - tz - } - None => { - // Detect the local timezone - 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 - } - }; + let initial_ts = parse_start_time(start_time)?; + let tz_offset = resolve_timezone_offset(timezone, initial_ts)?; let s0 = stage0::BinBasedGenerator::new( seed, @@ -103,7 +65,7 @@ pub fn generate( None, model.time_bins, initial_ts, - duration, + Some(d), tz_offset, ); let s1 = stage1::bayesian_networks::BNGenerator::new(bn, false); @@ -120,9 +82,80 @@ pub fn generate( send_pcap, throughput_sender, cancelled, - ); + ) } +/// Loads ML models from bundled assets, optionally applying a config profile. +fn load_models(profile: &Option) -> Result { + let source = models::ModelsSource::Legacy; + let mut model = models::Models::from_source(source) + .map_err(|e| format!("Failed to load ML models: {}", e))?; + if let Some(config) = profile { + model = model + .with_string_config(config) + .map_err(|e| format!("Failed to apply config: {}", e))?; + } + Ok(model) +} + +/// Parses start time from RFC3339 string, Unix timestamp, or returns current time if None. +fn parse_start_time(start_time: Option) -> Result { + match start_time { + Some(start_time) => { + // try to parse a date + if let Ok(d) = humantime::parse_rfc3339_weak(&start_time) { + d.duration_since(STD_UNIX_EPOCH) + .map_err(|e| format!("Invalid start time: {}", e)) + } else if let Ok(n) = start_time.parse::() { + Ok(Duration::from_secs(n)) + } else { + Err(format!( + "Could not parse start time '{}' (expected RFC3339 date or Unix timestamp)", + start_time + )) + } + } + None => SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| format!("Failed to get current time: {}", e)), + } +} + +/// Resolves timezone offset from IANA string, or detects local timezone if None. +fn resolve_timezone_offset( + timezone: Option, + initial_ts: Duration, +) -> Result { + let date = DateTime::from_timestamp(initial_ts.as_secs() as i64, 0) + .ok_or_else(|| "Invalid timestamp: value out of range".to_string())? + .naive_utc(); + + match timezone { + Some(tz_str) => { + let tz = tz_str + .parse::() + .map_err(|e| format!("Invalid timezone '{}': {}", tz_str, e))?; + let offset = tz.offset_from_utc_datetime(&date).fix(); + log::info!("Using {tz_str} timezone (UTC{offset})"); + Ok(offset) + } + None => { + // Detect the local timezone + let offset = chrono::Local::now() + .timezone() + .offset_from_local_datetime(&date) + .single() + .ok_or_else(|| { + "Could not determine local timezone (ambiguous or invalid date)".to_string() + })? + .fix(); + log::info!("Using local timezone (UTC{offset})"); + Ok(offset) + } + } +} + +/// Executes the 4-stage pipeline sequentially with cancellation support. fn run_single_thread( order_pcap: bool, s0: impl stage0::Stage0, @@ -133,7 +166,7 @@ fn run_single_thread( send_pcap: impl Fn(Vec), throughput_sender: Option>, cancelled: Arc, -) { +) -> Result<(), String> { let is_cancelled = || cancelled.load(Ordering::Relaxed); let start = Instant::now(); @@ -142,15 +175,15 @@ fn run_single_thread( let vec = stage0::run_vec(s0); if is_cancelled() { log::info!("Generation cancelled after stage 0"); - return; + return Ok(()); } send_progress(0.2); log::info!("Stage 1 generation"); - let vec = stage1::run_vec(s1, vec).unwrap(); + let vec = stage1::run_vec(s1, vec).map_err(|e| format!("Stage 1 failed: {}", e))?; if is_cancelled() { log::info!("Generation cancelled after stage 1"); - return; + return Ok(()); } send_progress(0.4); @@ -158,36 +191,16 @@ fn run_single_thread( let vec = stage2::run_vec(s2, vec); if is_cancelled() { log::info!("Generation cancelled after stage 2"); - return; + return Ok(()); } send_progress(0.6); - let mut all_packets = vec![]; log::info!("Stage 3 generation"); - all_packets.append(&mut stage3::run_vec( - |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; - } + let all_packets = generate_stage3_packets(&s3, vec, &is_cancelled); + let mut all_packets = match all_packets { + Some(p) => p, + None => return Ok(()), // Cancelled + }; send_progress(0.8); let gen_duration = start.elapsed().as_secs_f64(); @@ -203,7 +216,7 @@ fn run_single_thread( if is_cancelled() { log::info!("Generation cancelled"); - return; + return Ok(()); } if order_pcap { @@ -213,10 +226,50 @@ fn run_single_thread( if is_cancelled() { log::info!("Generation cancelled"); - return; + return Ok(()); } - let pcap_bytes = stage3::to_pcap_vec(&all_packets).expect("Error converting to pcap"); + let pcap_bytes = + stage3::to_pcap_vec(&all_packets).map_err(|e| format!("Failed to create PCAP: {}", e))?; send_pcap(pcap_bytes); send_progress(1.0); + Ok(()) +} + +/// Generates UDP, TCP, and ICMP packets. Returns None if cancelled. +fn generate_stage3_packets( + s3: &stage3::Stage3, + vec: stage2::S2Vector, + is_cancelled: &impl Fn() -> bool, +) -> Option> { + let mut all_packets = vec![]; + + all_packets.extend(stage3::run_vec( + |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 None; + } + + all_packets.extend(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 None; + } + + all_packets.extend(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 None; + } + + Some(all_packets) } diff --git a/crates/fosr-gui/src/run/generation/options.rs b/crates/fosr-gui/src/run/generation/options.rs index 3f70cbdc..9da3c748 100644 --- a/crates/fosr-gui/src/run/generation/options.rs +++ b/crates/fosr-gui/src/run/generation/options.rs @@ -14,7 +14,7 @@ use crate::shared::constants::ui::{ DURATION_TEXT_WIDTH, GENERATION_COL1_MIN_WIDTH, GENERATION_COL2_MIN_WIDTH, GENERATION_OPTIONS_COLUMNS, SEED_INPUT_WIDTH, SPACING_LG, }; -use crate::shared::widgets::helpers::info_icon; +use crate::shared::widgets::helpers::info_icon_with_tooltip; use crate::shared::widgets::time_picker::button::TimePickerButton; use crate::shared::widgets::timezone_picker::timezone_picker; use chrono::{Datelike, Local, TimeZone}; @@ -35,191 +35,214 @@ use egui_extras::DatePickerButton; /// - Temporal ordering option pub fn show_generation_options(ui: &mut egui::Ui, state: &mut RunTabState) { ui.columns(GENERATION_OPTIONS_COLUMNS, |cols| { - // --- Column 1: Duration & Time --- - let col1 = &mut cols[0]; - col1.set_min_width(GENERATION_COL1_MIN_WIDTH); - - // Duration - col1.horizontal(|ui| { - ui.label("Duration"); - info_icon(ui, "Minimum pcap traffic duration described in human-friendly time, such as \"30m\", \"1h\", \"2d\" or \"2days 30min 5s\"."); - - // Preset buttons - for preset in ["5min", "1h", "24h"] { - if ui.small_button(preset).clicked() { - state.generation.duration_str = preset.to_string(); - state.generation.duration_validation.set_ok(); - } + show_duration_column(&mut cols[0], state); + show_seed_column(&mut cols[1], state); + }); +} + +/// Column 1: Duration input, start time picker, and timezone selection. +fn show_duration_column(col: &mut egui::Ui, state: &mut RunTabState) { + col.set_min_width(GENERATION_COL1_MIN_WIDTH); + + show_duration_input(col, state); + col.add_space(SPACING_LG); + + show_start_time_options(col, state); + show_utc_preview(col, state); +} + +/// Duration input with preset buttons and validation. +fn show_duration_input(ui: &mut egui::Ui, state: &mut RunTabState) { + ui.horizontal(|ui| { + ui.label("Duration"); + info_icon_with_tooltip(ui, "Minimum pcap traffic duration described in human-friendly time, such as \"30m\", \"1h\", \"2d\" or \"2days 30min 5s\"."); + + // Preset buttons + for preset in ["5min", "1h", "24h"] { + if ui.small_button(preset).clicked() { + state.generation.duration_str = preset.to_string(); + state.generation.duration_validation.set_ok(); } + } - let text_response = egui::TextEdit::singleline(&mut state.generation.duration_str) - .desired_width(DURATION_TEXT_WIDTH) - .hint_text("ex: 30m, 1h, 2d") - .ui(ui); - - if text_response.changed() { - match validate_duration(&state.generation.duration_str) { - Ok(_) => { - state.generation.duration_validation.set_ok(); - } - Err(msg) => { - state.generation.duration_validation.set_err(msg); - } - } + let text_response = egui::TextEdit::singleline(&mut state.generation.duration_str) + .desired_width(DURATION_TEXT_WIDTH) + .hint_text("ex: 30m, 1h, 2d") + .ui(ui); + + if text_response.changed() { + match validate_duration(&state.generation.duration_str) { + Ok(_) => state.generation.duration_validation.set_ok(), + Err(msg) => state.generation.duration_validation.set_err(msg), } + } - show_field_error(ui, &state.generation.duration_validation); - }); - - col1.add_space(SPACING_LG); - - // Use current time - col1.horizontal(|ui| { - ui.checkbox(&mut state.generation.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.generation.use_current_time { - col1.horizontal(|ui| { - ui.label("Start time"); - let current_year = Local::now().date_naive().year(); - ui.add( - DatePickerButton::new(&mut state.generation.start_date) - .start_end_years((current_year - 5)..=(current_year + 30)), - ); - ui.add( - TimePickerButton::new(&mut state.generation.start_hour) - .show_seconds(true) - .use_dragvalue(true), - ); - }); - - col1.add_space(SPACING_LG); - - col1.horizontal(|ui| { - if ui - .checkbox(&mut state.generation.use_local_timezone, "Use local timezone") - .clicked() - { - if state.generation.use_local_timezone { - state.generation.timezone_input = String::new(); - state.generation.timezone_validation.set_ok(); - } else { - state.generation.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."); + show_field_error(ui, &state.generation.duration_validation); + }); +} - if !state.generation.use_local_timezone { - timezone_picker(ui, &mut state.generation.timezone_input); +/// Start time toggle and custom time picker with timezone selection. +fn show_start_time_options(ui: &mut egui::Ui, state: &mut RunTabState) { + ui.horizontal(|ui| { + ui.checkbox(&mut state.generation.use_current_time, "Use current time for start time"); + info_icon_with_tooltip(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."); + }); - let result = validate_timezone(&state.generation.timezone_input); - if result.is_ok() { - state.generation.timezone_validation.set_ok(); - } else { - state.generation.timezone_validation.set_err(result.err().unwrap()); - } - } - }); - } else { - state.generation.timezone_validation.set_ok(); - } + if state.generation.use_current_time { + state.generation.timezone_validation.set_ok(); + return; + } + + // Custom start time picker + ui.horizontal(|ui| { + ui.label("Start time"); + let current_year = Local::now().date_naive().year(); + ui.add( + DatePickerButton::new(&mut state.generation.start_date) + .start_end_years((current_year - 5)..=(current_year + 30)), + ); + ui.add( + TimePickerButton::new(&mut state.generation.start_hour) + .show_seconds(true) + .use_dragvalue(true), + ); + }); - // Show the equivalent UTC start time (or error if timezone is invalid) - let utc_text: Option = if state.generation.use_current_time { - Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()) - } else { - let local_dt = state - .generation - .start_date - .and_time(state.generation.start_hour); - - let utc = if state.generation.use_local_timezone { - Local::now() - .timezone() - .from_local_datetime(&local_dt) - .earliest() - .map(|dt| dt.with_timezone(&chrono::Utc)) + ui.add_space(SPACING_LG); + + show_timezone_picker(ui, state); +} + +/// Timezone picker with local/custom toggle. +fn show_timezone_picker(ui: &mut egui::Ui, state: &mut RunTabState) { + ui.horizontal(|ui| { + if ui + .checkbox(&mut state.generation.use_local_timezone, "Use local timezone") + .clicked() + { + if state.generation.use_local_timezone { + state.generation.timezone_input = String::new(); + state.generation.timezone_validation.set_ok(); } else { - state - .generation - .timezone_input - .parse::() - .ok() - .and_then(|tz| local_dt.and_local_timezone(tz).earliest()) - .map(|dt| dt.with_timezone(&chrono::Utc)) - }; - - utc.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) - .or_else(|| { - if !state.generation.use_local_timezone { - Some("select a valid timezone".to_string()) - } else { - None - } - }) - }; - - if let Some(text) = utc_text { - col1.label( - egui::RichText::new(format!("Start time (UTC): {}", text)) - .color(COLOR_TEXT_MUTED), - ); + state.generation.timezone_input = Tz::CET.to_string(); + } } + info_icon_with_tooltip(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."); - // --- Column 2: Seed & Advanced --- - let col2 = &mut cols[1]; - col2.set_min_width(GENERATION_COL2_MIN_WIDTH); - - // Seed - col2.horizontal(|ui| { - ui.checkbox(&mut state.generation.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.generation.use_seed { - let response = ui.add( - egui::TextEdit::singleline(&mut state.generation.seed_input) - .hint_text("enter a seed value") - .desired_width(SEED_INPUT_WIDTH), - ); - - if response.changed() { - match validate_optional_u64(&state.generation.seed_input) { - Ok(_) => { - state.generation.seed_validation.set_ok(); - } - Err(msg) => { - state.generation.seed_validation.set_err(msg); - } - } - } + if !state.generation.use_local_timezone { + timezone_picker(ui, &mut state.generation.timezone_input); - show_field_error(ui, &state.generation.seed_validation); + match validate_timezone(&state.generation.timezone_input) { + Ok(()) => state.generation.timezone_validation.set_ok(), + Err(msg) => state.generation.timezone_validation.set_err(msg), + } + } + }); +} + +/// Displays the UTC equivalent of the selected start time. +fn show_utc_preview(ui: &mut egui::Ui, state: &mut RunTabState) { + let utc_text = compute_utc_text(state); + + if let Some(text) = utc_text { + ui.label( + egui::RichText::new(format!("Start time (UTC): {}", text)) + .color(COLOR_TEXT_MUTED), + ); + } +} + +/// Computes the UTC representation of the selected start time. +/// Returns None if using local timezone and the datetime is ambiguous. +fn compute_utc_text(state: &RunTabState) -> Option { + if state.generation.use_current_time { + return Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()); + } + + let local_dt = state.generation.start_date.and_time(state.generation.start_hour); + + let utc = if state.generation.use_local_timezone { + Local::now() + .timezone() + .from_local_datetime(&local_dt) + .earliest() + .map(|dt| dt.with_timezone(&chrono::Utc)) + } else { + state.generation.timezone_input + .parse::() + .ok() + .and_then(|tz| local_dt.and_local_timezone(tz).earliest()) + .map(|dt| dt.with_timezone(&chrono::Utc)) + }; + + utc.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .or_else(|| { + if !state.generation.use_local_timezone { + Some("select a valid timezone".to_string()) } else { - state.generation.seed_validation.set_ok(); + None } - }); - - col2.add_space(SPACING_LG); - - // Advanced options - col2.horizontal(|ui| { - ui.checkbox(&mut state.generation.taint, "Taint the packets"); - info_icon(ui, "Taint the packets with special markers for identification."); - }); - col2.horizontal(|ui| { - ui.checkbox(&mut state.generation.order_pcap, "Order temporally"); - info_icon(ui, "Enable temporal sorting of the generated pcap. Disable to reduce significantly the RAM usage."); - }); - - col2.add_space(SPACING_LG); - - // Validation errors - if let Some((name, spec, err)) = first_invalid_param(&state.generation) { - col2.colored_label( - COLOR_ERROR, - format!("Invalid parameter: {name}. Expected: {spec}. ({err})"), + }) +} + +/// Column 2: Seed input and advanced options (taint, temporal ordering). +fn show_seed_column(col: &mut egui::Ui, state: &mut RunTabState) { + col.set_min_width(GENERATION_COL2_MIN_WIDTH); + + show_seed_input(col, state); + col.add_space(SPACING_LG); + + show_advanced_options(col, state); + col.add_space(SPACING_LG); + + show_validation_errors(col, state); +} + +/// Seed input with validation. +fn show_seed_input(ui: &mut egui::Ui, state: &mut RunTabState) { + ui.horizontal(|ui| { + ui.checkbox(&mut state.generation.use_seed, "Seed"); + info_icon_with_tooltip(ui, "Seed for random number generation. For deterministic generation, you must also specify duration, start time, and timezone."); + + if state.generation.use_seed { + let response = ui.add( + egui::TextEdit::singleline(&mut state.generation.seed_input) + .hint_text("enter a seed value") + .desired_width(SEED_INPUT_WIDTH), ); + + if response.changed() { + match validate_optional_u64(&state.generation.seed_input) { + Ok(_) => state.generation.seed_validation.set_ok(), + Err(msg) => state.generation.seed_validation.set_err(msg), + } + } + + show_field_error(ui, &state.generation.seed_validation); + } else { + state.generation.seed_validation.set_ok(); } }); } + +/// Advanced options: taint packets and temporal ordering. +fn show_advanced_options(ui: &mut egui::Ui, state: &mut RunTabState) { + ui.horizontal(|ui| { + ui.checkbox(&mut state.generation.taint, "Taint the packets"); + info_icon_with_tooltip(ui, "Taint the packets with special markers for identification."); + }); + ui.horizontal(|ui| { + ui.checkbox(&mut state.generation.order_pcap, "Order temporally"); + info_icon_with_tooltip(ui, "Enable temporal sorting of the generated pcap. Disable to reduce significantly the RAM usage."); + }); +} + +/// Shows the first validation error if any parameter is invalid. +fn show_validation_errors(ui: &mut egui::Ui, state: &RunTabState) { + if let Some((name, spec, err)) = first_invalid_param(&state.generation) { + ui.colored_label( + COLOR_ERROR, + format!("Invalid parameter: {name}. Expected: {spec}. ({err})"), + ); + } +} diff --git a/crates/fosr-gui/src/run/generation/process.rs b/crates/fosr-gui/src/run/generation/process.rs index 2c0a4a10..b534dd08 100644 --- a/crates/fosr-gui/src/run/generation/process.rs +++ b/crates/fosr-gui/src/run/generation/process.rs @@ -12,7 +12,7 @@ use std::sync::mpsc::channel; /// Start the PCAP generation process in a background thread. /// -/// Creates channels for progress updates, PCAP data, and throughput metrics. +/// Creates channels for progress updates, PCAP data, throughput metrics, and errors. /// The generation runs asynchronously (native thread or WASM future). pub fn start_generation( state: &mut RunTabState, @@ -34,80 +34,123 @@ pub fn start_generation( state.generation.throughput_receiver = Some(throughput_receiver); state.generation.throughput = None; - let seed = if state.generation.use_seed { - state.generation.seed_input.parse::().ok() - } else { - None - }; - let order_pcap = state.generation.order_pcap; - let start_time = if state.generation.use_current_time { - None - } else { - Some(format!( - "{}T{}Z", - state.generation.start_date.format("%Y-%m-%d"), - state.generation.start_hour.format("%H:%M:%S") - )) - }; - let duration = state.generation.duration_str.clone(); - let taint = state.generation.taint; - let timezone = if state.generation.timezone_input.is_empty() { - None - } else { - Some(state.generation.timezone_input.clone()) - }; - let ctx = ctx.clone(); + let (error_sender, error_receiver) = channel(); + state.generation.error_receiver = Some(error_receiver); + + let params = GenerationParams::from_state(state, configuration_file_state); let cancelled = state.generation.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(); + let ctx = ctx.clone(); - #[cfg(target_arch = "wasm32")] - { - wasm_bindgen_futures::spawn_local(async move { - generate( - seed, - config_content, - order_pcap, - start_time, - duration, - taint, - timezone, - Some(progress_sender), - Some(pcap_sender), - Some(throughput_sender), - cancelled, - ); - ctx.request_repaint(); - }); + spawn_generation_task( + params, + progress_sender, + pcap_sender, + throughput_sender, + error_sender, + cancelled, + ctx, + ); +} + +/// Generation parameters extracted from UI state. +struct GenerationParams { + seed: Option, + order_pcap: bool, + start_time: Option, + duration: String, + taint: bool, + timezone: Option, + config_content: Option, +} + +impl GenerationParams { + /// Extracts parameters from the UI state and config file. + fn from_state(state: &RunTabState, config_state: &ConfigurationFileState) -> Self { + let seed = if state.generation.use_seed { + state.generation.seed_input.parse::().ok() + } else { + None + }; + let start_time = if state.generation.use_current_time { + None + } else { + Some(format!( + "{}T{}Z", + state.generation.start_date.format("%Y-%m-%d"), + state.generation.start_hour.format("%H:%M:%S") + )) + }; + let timezone = if state.generation.timezone_input.is_empty() { + None + } else { + Some(state.generation.timezone_input.clone()) + }; + // Prefer in-memory config content (reflects edits from Configuration tab) + // over re-reading the file from disk + let config_content = config_state.config_file_content.clone(); + + Self { + seed, + order_pcap: state.generation.order_pcap, + start_time, + duration: state.generation.duration_str.clone(), + taint: state.generation.taint, + timezone, + config_content, + } } +} + +/// Spawns the generation task on the appropriate platform (WASM future or native thread). +fn spawn_generation_task( + params: GenerationParams, + progress_sender: std::sync::mpsc::Sender, + pcap_sender: std::sync::mpsc::Sender>, + throughput_sender: std::sync::mpsc::Sender, + error_sender: std::sync::mpsc::Sender, + cancelled: Arc, + ctx: egui::Context, +) { + let task = move || { + let result = generate( + params.seed, + params.config_content, + params.order_pcap, + params.start_time, + params.duration, + params.taint, + params.timezone, + Some(progress_sender), + Some(pcap_sender), + Some(throughput_sender), + cancelled, + ); + if let Err(e) = result { + log::error!("Generation failed: {}", e); + let _ = error_sender.send(e); + } + ctx.request_repaint(); + }; + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_futures::spawn_local(async move { task() }); #[cfg(not(target_arch = "wasm32"))] - { - std::thread::spawn(move || { - generate( - seed, - config_content, - order_pcap, - start_time, - duration, - taint, - timezone, - Some(progress_sender), - Some(pcap_sender), - Some(throughput_sender), - cancelled, - ); - ctx.request_repaint(); - }); - } + std::thread::spawn(task); } -/// Poll generation receivers for progress, PCAP data, and throughput. +/// Poll generation receivers for progress, PCAP data, throughput, and errors. /// /// Should be called every frame to update the UI with generation status. pub fn poll_generation_receivers(ctx: &egui::Context, state: &mut RunTabState) { - // Poll progress receiver + poll_progress(ctx, state); + poll_pcap(state); + poll_throughput(state); + poll_error(state); +} + +/// Polls for progress updates and requests repaints while generating. +fn poll_progress(ctx: &egui::Context, state: &mut RunTabState) { if let Some(receiver) = &state.generation.progress_receiver { // Request repaint to keep polling while generating ctx.request_repaint(); @@ -118,15 +161,19 @@ pub fn poll_generation_receivers(ctx: &egui::Context, state: &mut RunTabState) { } } } +} - // Poll pcap receiver +/// Polls for PCAP data when generation completes. +fn poll_pcap(state: &mut RunTabState) { if let Some(receiver) = &state.generation.pcap_receiver { if let Ok(pcap_bytes) = receiver.try_recv() { state.generation.pcap_bytes = Some(pcap_bytes); } } +} - // Poll throughput receiver +/// Polls for throughput metrics when generation completes. +fn poll_throughput(state: &mut RunTabState) { if let Some(receiver) = &state.generation.throughput_receiver { if let Ok(throughput) = receiver.try_recv() { state.generation.throughput = Some(throughput); @@ -134,3 +181,14 @@ pub fn poll_generation_receivers(ctx: &egui::Context, state: &mut RunTabState) { } } } + +/// Polls for error messages from the generation thread. +fn poll_error(state: &mut RunTabState) { + if let Some(receiver) = &state.generation.error_receiver { + if let Ok(error) = receiver.try_recv() { + state.generation.error = Some(error); + state.generation.error_receiver = None; + state.generation.progress_receiver = None; // Stop progress polling + } + } +} diff --git a/crates/fosr-gui/src/run/generation/state.rs b/crates/fosr-gui/src/run/generation/state.rs index 54eb471c..44750449 100644 --- a/crates/fosr-gui/src/run/generation/state.rs +++ b/crates/fosr-gui/src/run/generation/state.rs @@ -22,6 +22,7 @@ pub struct GenerationState { pub pcap_receiver: Option>>, pub throughput_receiver: Option>, pub throughput: Option, + pub error_receiver: Option>, pub cancelled: Arc, /// Error message to display, if any pub error: Option, @@ -80,6 +81,7 @@ impl Default for GenerationState { pcap_receiver: None, throughput_receiver: None, throughput: None, + error_receiver: None, cancelled: Arc::new(AtomicBool::new(false)), error: None, // Validation states diff --git a/crates/fosr-gui/src/run/generation/validation.rs b/crates/fosr-gui/src/run/generation/validation.rs index abe55c73..473f2374 100644 --- a/crates/fosr-gui/src/run/generation/validation.rs +++ b/crates/fosr-gui/src/run/generation/validation.rs @@ -7,22 +7,25 @@ use chrono_tz::Tz; use eframe::egui; use std::time::Duration; -/// Structure to handle inputs errors from the user +/// Holds validation state for a single input field. #[derive(Default, Clone)] pub struct FieldValidation { pub error: Option, } impl FieldValidation { + /// Clears any existing error. pub fn set_ok(&mut self) { self.error = None; } + + /// Sets an error message. pub fn set_err(&mut self, msg: impl Into) { self.error = Some(msg.into()); } } -/// Display the error in red +/// Display the error message in red below the field. pub fn show_field_error(ui: &mut egui::Ui, validation: &FieldValidation) { if let Some(msg) = &validation.error { ui.add_space(SPACING_MD); @@ -30,27 +33,25 @@ pub fn show_field_error(ui: &mut egui::Ui, validation: &FieldValidation) { } } -// Spec expected for each parameter +// Expected format for each parameter (shown in error messages) const SPEC_DURATION: &str = "a duration between 1 min and 3 days (e.g. 30m, 1h, 2d)"; const SPEC_SEED: &str = "an unsigned integer (u64) or empty for random"; const SPEC_TIMEZONE: &str = "a valid timezone"; -// return the first invalid parameter -pub fn first_invalid_param( - state: &GenerationState, -) -> Option<(&'static str, &'static str, String)> { - if let Some(err) = &state.duration_validation.error { - return Some(("Duration", SPEC_DURATION, err.clone())); - } - if let Some(err) = &state.seed_validation.error { - return Some(("Seed", SPEC_SEED, err.clone())); - } - if let Some(err) = &state.timezone_validation.error { - return Some(("Timezone", SPEC_TIMEZONE, err.clone())); - } - None +/// Returns the first invalid parameter (name, expected spec, error message). +pub fn first_invalid_param(state: &GenerationState) -> Option<(&'static str, &'static str, &str)> { + [ + ("Duration", SPEC_DURATION, &state.duration_validation), + ("Seed", SPEC_SEED, &state.seed_validation), + ("Timezone", SPEC_TIMEZONE, &state.timezone_validation), + ] + .into_iter() + .find_map(|(name, spec, validation)| { + validation.error.as_ref().map(|err| (name, spec, err.as_str())) + }) } +/// Validates a human-readable duration string and checks bounds. pub fn validate_duration(duration_str: &str) -> Result { let d = humantime::parse_duration(duration_str).map_err(|_| "Invalid value".to_string())?; @@ -64,6 +65,7 @@ pub fn validate_duration(duration_str: &str) -> Result { Ok(d) } +/// Validates an optional u64 input (empty is valid, means "use random"). pub fn validate_optional_u64(input: &str) -> Result, String> { let s = input.trim(); if s.is_empty() { @@ -74,10 +76,10 @@ pub fn validate_optional_u64(input: &str) -> Result, String> { .map_err(|_| "Invalid value".to_string()) } +/// Validates an IANA timezone string. pub fn validate_timezone(input: &str) -> Result<(), String> { - let parsed = input.parse::(); - match parsed { - Ok(_) => Ok(()), - Err(_) => Err("Invalid value".to_string()), - } + input + .parse::() + .map(|_| ()) + .map_err(|_| "Invalid value".to_string()) } diff --git a/crates/fosr-gui/src/run/graph/flow_processing.rs b/crates/fosr-gui/src/run/graph/flow_processing.rs index 28a5a343..abfa7a1a 100644 --- a/crates/fosr-gui/src/run/graph/flow_processing.rs +++ b/crates/fosr-gui/src/run/graph/flow_processing.rs @@ -183,36 +183,7 @@ pub fn update_graph_edges(state: &mut VisualizationState) { .collect(); for (edge, src_ips, dst_ips) in edges_data { - // Check all IP combinations for an active link - let mut new_state = EdgeState::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.flow.active_links.get(&forward_key) { - new_state = EdgeState::Active { - protocol: link.protocol, - start_time: link.start_time, - direction: link.direction.clone(), - }; - break 'outer; - } else if let Some(link) = state.flow.active_links.get(&reverse_key) { - 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 - direction: match link.direction { - LinkDirection::Forward => LinkDirection::Backward, - LinkDirection::Backward => LinkDirection::Forward, - LinkDirection::Bidirectional => LinkDirection::Bidirectional, - }, - }; - break 'outer; - } - } - } + let new_state = find_active_link_state(&state.flow.active_links, &src_ips, &dst_ips); // Update edge state (flow_count is preserved) if let Some(edge_mut) = graph.g_mut().edge_weight_mut(edge) { @@ -220,3 +191,40 @@ pub fn update_graph_edges(state: &mut VisualizationState) { } } } + +/// Find the active link state for an edge by checking all IP combinations. +/// +/// Searches for an active link between any source IP and any destination IP, +/// handling both forward and reverse directions. +fn find_active_link_state( + active_links: &std::collections::HashMap<(std::net::Ipv4Addr, std::net::Ipv4Addr), ActiveLink>, + src_ips: &[std::net::Ipv4Addr], + dst_ips: &[std::net::Ipv4Addr], +) -> EdgeState { + 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) = active_links.get(&forward_key) { + return EdgeState::Active { + protocol: link.protocol, + start_time: link.start_time, + direction: link.direction.clone(), + }; + } else if let Some(link) = active_links.get(&reverse_key) { + // Reverse key: flip the direction + return EdgeState::Active { + protocol: link.protocol, + start_time: link.start_time, + direction: match link.direction { + LinkDirection::Forward => LinkDirection::Backward, + LinkDirection::Backward => LinkDirection::Forward, + LinkDirection::Bidirectional => LinkDirection::Bidirectional, + }, + }; + } + } + } + EdgeState::Inactive +} diff --git a/crates/fosr-gui/src/run/graph/node_modal.rs b/crates/fosr-gui/src/run/graph/node_modal.rs index e58aec32..942171b5 100644 --- a/crates/fosr-gui/src/run/graph/node_modal.rs +++ b/crates/fosr-gui/src/run/graph/node_modal.rs @@ -1,7 +1,8 @@ //! Node click handling and info/edit modal for the visualization graph. -use super::state::{NodeType, VisualizationState}; +use super::state::{NodeData, NodeType, VisualizationState}; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; +use crate::shared::config::model::Host; use crate::shared::config::state::ConfigurationFileState; use crate::shared::constants::colors::{COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT}; use crate::shared::constants::ui::{ @@ -10,7 +11,7 @@ use crate::shared::constants::ui::{ use eframe::egui; use egui_graphs::events::{Event, PayloadNodeClick}; -/// Process graph click events from the event buffer +/// Process graph click events from the event buffer. pub fn process_graph_events( state: &mut VisualizationState, configuration_file_state: &ConfigurationFileState, @@ -35,7 +36,7 @@ pub fn process_graph_events( } } -/// Render the node information modal for the clicked node +/// Render the node information modal for the clicked node. pub fn render_node_info_modal( ctx: &egui::Context, state: &mut VisualizationState, @@ -62,165 +63,203 @@ pub fn render_node_info_modal( let mut save_clicked = false; let modal = egui::Modal::new(egui::Id::new("node_info_modal")).show(ctx, |ui| { ui.set_width(NODE_MODAL_WIDTH); - if has_edit_buffer { - ui.heading("Edit Node Information"); - } else { - ui.heading("Node Information"); - } + render_modal_header(ui, &node_data, has_edit_buffer); ui.separator(); - // Node type with icon - ui.horizontal(|ui| { - let (image, type_str) = match node_data.node_type { - NodeType::Server => (IMG_SERVER, "Server"), - NodeType::User => (IMG_COMPUTER, "User"), - NodeType::Internet => (IMG_INTERNET, "Internet"), - }; - let tint = if ui.style().visuals.dark_mode { - COLOR_ICON_TINT_DARK - } else { - COLOR_ICON_TINT_LIGHT - }; - ui.add( - egui::Image::new(image) - .fit_to_exact_size(egui::vec2(LEGEND_ICON_SIZE, LEGEND_ICON_SIZE)) - .tint(tint), - ); - ui.label(egui::RichText::new(type_str).strong()); - }); + if let Some(ref mut host) = state.modal.edit_buffer { + render_editable_fields(ui, host); + } else { + render_readonly_fields(ui, &node_data); + } - ui.add_space(SPACING_SM); + ui.add_space(SPACING_LG); + render_modal_footer(ui, has_edit_buffer, &mut save_clicked); + }); - // 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:"); - 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) - }; - } - }); + // Apply changes to config model on Save + if save_clicked { + apply_changes_to_config(state, config_file_state, host_idx); + } - // OS - ui.horizontal(|ui| { - 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()); - } - }); - }); + // Close on Escape or click outside (discard changes) + if modal.should_close() { + state.modal.open = false; + state.modal.clicked_node = None; + state.modal.edit_buffer = None; + } +} - // IP addresses - ui.label("IP Addresses:"); - for iface in &mut host.interfaces { - ui.horizontal(|ui| { - ui.add_space(INDENT_STANDARD); - ui.add(egui::TextEdit::singleline(&mut iface.ip_addr).hint_text("0.0.0.0")); - }); - } +/// Render modal header with title and node type icon. +fn render_modal_header(ui: &mut egui::Ui, node_data: &NodeData, has_edit_buffer: bool) { + let title = if has_edit_buffer { + "Edit Node Information" + } else { + "Node Information" + }; + ui.heading(title); + + // Node type with icon + ui.horizontal(|ui| { + let (image, type_str) = match node_data.node_type { + NodeType::Server => (IMG_SERVER, "Server"), + NodeType::User => (IMG_COMPUTER, "User"), + NodeType::Internet => (IMG_INTERNET, "Internet"), + }; + let tint = if ui.style().visuals.dark_mode { + COLOR_ICON_TINT_DARK } 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()); - }); - } - // Don't show OS or IP for Internet node - if node_data.node_type != NodeType::Internet { - 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(INDENT_STANDARD); - ui.label(egui::RichText::new(ip.to_string()).monospace()); - }); - } - } - } + COLOR_ICON_TINT_LIGHT + }; + ui.add( + egui::Image::new(image) + .fit_to_exact_size(egui::vec2(LEGEND_ICON_SIZE, LEGEND_ICON_SIZE)) + .tint(tint), + ); + ui.label(egui::RichText::new(type_str).strong()); + }); - ui.add_space(SPACING_LG); + ui.add_space(SPACING_SM); +} - if has_edit_buffer { - ui.horizontal(|ui| { +/// Render editable fields for the host (hostname, OS, IPs). +fn render_editable_fields(ui: &mut egui::Ui, host: &mut Host) { + render_hostname_field(ui, host); + render_os_dropdown(ui, host); + render_ip_fields(ui, host); +} + +/// Render editable hostname field. +fn render_hostname_field(ui: &mut egui::Ui, host: &mut Host) { + ui.horizontal(|ui| { + ui.label("Hostname:"); + 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) + }; + } + }); +} + +/// Render editable OS dropdown. +fn render_os_dropdown(ui: &mut egui::Ui, host: &mut Host) { + ui.horizontal(|ui| { + 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 - .button(egui_material_icons::icons::ICON_CLOSE) - .on_hover_text("Cancel") + .selectable_label(host.os.as_deref() == Some("Linux"), "Linux") .clicked() { - ui.close(); + host.os = Some("Linux".to_string()); } if ui - .button(egui_material_icons::icons::ICON_SAVE) - .on_hover_text("Save") + .selectable_label(host.os.as_deref() == Some("Windows"), "Windows") .clicked() { - save_clicked = true; - ui.close(); + host.os = Some("Windows".to_string()); } }); - } else { + }); +} + +/// Render editable IP address fields. +fn render_ip_fields(ui: &mut egui::Ui, host: &mut Host) { + ui.label("IP Addresses:"); + for iface in &mut host.interfaces { + ui.horizontal(|ui| { + ui.add_space(INDENT_STANDARD); + ui.add(egui::TextEdit::singleline(&mut iface.ip_addr).hint_text("0.0.0.0")); + }); + } +} + +/// Render read-only fields (no config loaded or Internet node). +fn render_readonly_fields(ui: &mut egui::Ui, node_data: &NodeData) { + if let Some(ref hostname) = node_data.hostname { + ui.horizontal(|ui| { + ui.label("Hostname:"); + ui.label(egui::RichText::new(hostname).monospace()); + }); + } + + // Don't show OS or IP for Internet node + if node_data.node_type != NodeType::Internet { + 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(INDENT_STANDARD); + ui.label(egui::RichText::new(ip.to_string()).monospace()); + }); + } + } +} + +/// Render modal footer with action buttons. +fn render_modal_footer(ui: &mut egui::Ui, has_edit_buffer: bool, save_clicked: &mut bool) { + if has_edit_buffer { + ui.horizontal(|ui| { if ui .button(egui_material_icons::icons::ICON_CLOSE) - .on_hover_text("Close") + .on_hover_text("Cancel") .clicked() { ui.close(); } - } - }); - - // 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)) + if ui + .button(egui_material_icons::icons::ICON_SAVE) + .on_hover_text("Save") + .clicked() { - *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); - } + *save_clicked = true; + ui.close(); } + }); + } else { + if ui + .button(egui_material_icons::icons::ICON_CLOSE) + .on_hover_text("Close") + .clicked() + { + ui.close(); } } +} - // Close on Escape or click outside (discard changes) - if modal.should_close() { - state.modal.open = false; - state.modal.clicked_node = None; - state.modal.edit_buffer = None; +/// Apply changes from edit buffer back to the config model. +fn apply_changes_to_config( + state: &mut VisualizationState, + config_file_state: &mut ConfigurationFileState, + host_idx: Option, +) { + 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); + } + } } } diff --git a/crates/fosr-gui/src/run/graph/overlays.rs b/crates/fosr-gui/src/run/graph/overlays.rs index a6105bef..608ea7a5 100644 --- a/crates/fosr-gui/src/run/graph/overlays.rs +++ b/crates/fosr-gui/src/run/graph/overlays.rs @@ -13,7 +13,7 @@ use crate::shared::constants::ui::{ }; use eframe::egui; -/// Helper to render a single legend item inline (for edges) +/// Render a legend item with a colored circle (for edge protocols). fn legend_item_inline(ui: &mut egui::Ui, label: &str, color: egui::Color32) { ui.horizontal(|ui| { let rect = ui @@ -26,7 +26,7 @@ fn legend_item_inline(ui: &mut egui::Ui, label: &str, color: egui::Color32) { }); } -/// Helper to render a legend item with an image (for nodes) +/// Render a legend item with an icon image (for node types). fn legend_item_with_image(ui: &mut egui::Ui, label: &str, image: egui::ImageSource) { ui.horizontal(|ui| { let tint = if ui.style().visuals.dark_mode { @@ -44,7 +44,7 @@ fn legend_item_with_image(ui: &mut egui::Ui, label: &str, image: egui::ImageSour }); } -/// Render overlay buttons in the top-left corner of the graph +/// Render overlay buttons in the top-left corner of the graph. pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) { let local_rect = ui.max_rect(); @@ -56,116 +56,166 @@ pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) .shadow(egui::epaint::Shadow::NONE) .show(ui, |ui| { ui.horizontal(|ui| { - if !state.flow.running { - // Play / Continue: resume without resetting flow counts - let play_text = if state.user_has_started { - "Continue" - } else { - "Start" - }; - let accent = ui.visuals().selection.bg_fill; - let play_button = egui::Button::new(egui::RichText::new(format!( - "{} {}", - egui_material_icons::icons::ICON_PLAY_ARROW, - play_text - ))) - .fill(accent); - if ui.add(play_button).clicked() { - state.user_has_started = true; - // Pass the user config if loaded, otherwise None (uses default BN model) - let config = state.config_content.clone(); - let speed = state.flow.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 - // Only visible after the user has started at least once - if state.user_has_started { - if ui - .button(egui_material_icons::icons::ICON_RESTART_ALT) - .on_hover_text("Restart") - .clicked() - { - let config = state.config_content.clone(); - let speed = state.flow.speed.clone(); - if let Err(e) = - state.start_visualization(config.as_deref(), speed, true) - { - log::error!("Failed to start flow streamer: {}", e); - } - } - } - } else { - let stop_button = egui::Button::new(egui::RichText::new(format!( - "{} Stop", - egui_material_icons::icons::ICON_STOP - ))) - .fill(COLOR_STOP); - if ui.add(stop_button).clicked() { - state.stop_visualization(); - } - } - if ui - .button(egui_material_icons::icons::ICON_FIT_SCREEN) - .on_hover_text("Fit to screen") - .clicked() - { - state.view.reset_requested = true; - } - if ui - .button(egui_material_icons::icons::ICON_IMAGE) - .on_hover_text("Export as PNG") - .clicked() - { - state.screenshot_export = ScreenshotStateMachine::HidingOverlays; - } - + render_playback_controls(ui, state); + render_view_controls(ui, state); ui.separator(); - - // Playback speed: −/+ buttons with discrete steps - // 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.flow.speed.read().unwrap(); - let current_idx = PLAYBACK_SPEED_STEPS - .iter() - .position(|&s| (s - speed_value).abs() < PLAYBACK_SPEED_EPSILON); - - 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 = PLAYBACK_SPEED_STEPS[idx - 1]; - *state.flow.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 < PLAYBACK_SPEED_STEPS.len() - 1 { - speed_value = PLAYBACK_SPEED_STEPS[idx + 1]; - *state.flow.speed.write().unwrap() = speed_value; - } - } - } + render_speed_controls(ui, state); }); }); }); } -/// Render overlay stats in the bottom-left corner of the graph +/// Render play/stop/restart buttons based on current state. +fn render_playback_controls(ui: &mut egui::Ui, state: &mut VisualizationState) { + if !state.flow.running { + render_play_button(ui, state); + if state.user_has_started { + render_restart_button(ui, state); + } + } else { + render_stop_button(ui, state); + } +} + +/// Render the Play/Continue button. +/// +/// Resumes visualization without resetting flow counts. Uses "Start" label +/// initially, then "Continue" after the user has started at least once. +fn render_play_button(ui: &mut egui::Ui, state: &mut VisualizationState) { + let play_text = if state.user_has_started { + "Continue" + } else { + "Start" + }; + let accent = ui.visuals().selection.bg_fill; + let play_button = egui::Button::new(egui::RichText::new(format!( + "{} {}", + egui_material_icons::icons::ICON_PLAY_ARROW, + play_text + ))) + .fill(accent); + + if ui.add(play_button).clicked() { + state.user_has_started = true; + let config = state.config_content.clone(); + let speed = state.flow.speed.clone(); + if let Err(e) = state.start_visualization(config.as_deref(), speed, false) { + log::error!("Failed to start flow streamer: {}", e); + } + } +} + +/// Render the Restart button. +/// +/// Resets all flow counts and starts fresh. Only visible after the user +/// has started at least once (otherwise the Play button shows "Start"). +fn render_restart_button(ui: &mut egui::Ui, state: &mut VisualizationState) { + if ui + .button(egui_material_icons::icons::ICON_RESTART_ALT) + .on_hover_text("Restart") + .clicked() + { + let config = state.config_content.clone(); + let speed = state.flow.speed.clone(); + if let Err(e) = state.start_visualization(config.as_deref(), speed, true) { + log::error!("Failed to start flow streamer: {}", e); + } + } +} + +/// Render the Stop button. +fn render_stop_button(ui: &mut egui::Ui, state: &mut VisualizationState) { + let stop_button = egui::Button::new(egui::RichText::new(format!( + "{} Stop", + egui_material_icons::icons::ICON_STOP + ))) + .fill(COLOR_STOP); + + if ui.add(stop_button).clicked() { + state.stop_visualization(); + } +} + +/// Render fit-to-screen and export buttons. +fn render_view_controls(ui: &mut egui::Ui, state: &mut VisualizationState) { + if ui + .button(egui_material_icons::icons::ICON_FIT_SCREEN) + .on_hover_text("Fit to screen") + .clicked() + { + state.view.reset_requested = true; + } + + if ui + .button(egui_material_icons::icons::ICON_IMAGE) + .on_hover_text("Export as PNG") + .clicked() + { + state.screenshot_export = ScreenshotStateMachine::HidingOverlays; + } +} + +/// Render playback speed controls with −/+ buttons. +/// +/// Speed is stored in an `Arc` for runtime updates, +/// so we read/write it manually rather than binding directly. +fn render_speed_controls(ui: &mut egui::Ui, state: &mut VisualizationState) { + // Use into_inner() on poison to recover the value anyway. + // Lock poisoning only happens if another thread panicked while holding the lock, + // and for a simple f32 speed value, there's no risk of data corruption. + let mut speed_value = *state + .flow + .speed + .read() + .unwrap_or_else(|e| e.into_inner()); + let current_idx = find_speed_step_index(speed_value); + + 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 = PLAYBACK_SPEED_STEPS[idx - 1]; + *state + .flow + .speed + .write() + .unwrap_or_else(|e| e.into_inner()) = 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 < PLAYBACK_SPEED_STEPS.len() - 1 { + speed_value = PLAYBACK_SPEED_STEPS[idx + 1]; + *state + .flow + .speed + .write() + .unwrap_or_else(|e| e.into_inner()) = speed_value; + } + } + } +} + +/// Find the index of the current speed in the predefined steps. +fn find_speed_step_index(current_speed: f32) -> Option { + PLAYBACK_SPEED_STEPS + .iter() + .position(|&s| (s - current_speed).abs() < PLAYBACK_SPEED_EPSILON) +} + +/// Render overlay stats in the bottom-left corner of the graph. pub fn render_overlay_stats(ui: &mut egui::Ui, state: &VisualizationState) { let local_rect = ui.max_rect(); @@ -192,7 +242,7 @@ pub fn render_overlay_stats(ui: &mut egui::Ui, state: &VisualizationState) { }); } -/// Render node legend in the top-right corner of the graph +/// Render node legend in the top-right corner of the graph. pub fn render_overlay_node_legend(ui: &mut egui::Ui) { let local_rect = ui.max_rect(); @@ -213,7 +263,7 @@ pub fn render_overlay_node_legend(ui: &mut egui::Ui) { }); } -/// Render edge legend in the bottom-right corner of the graph +/// Render edge legend in the bottom-right corner of the graph. pub fn render_overlay_edge_legend(ui: &mut egui::Ui) { let local_rect = ui.max_rect(); diff --git a/crates/fosr-gui/src/run/graph/shapes.rs b/crates/fosr-gui/src/run/graph/shapes.rs index e62436cc..afdfd6dd 100644 --- a/crates/fosr-gui/src/run/graph/shapes.rs +++ b/crates/fosr-gui/src/run/graph/shapes.rs @@ -21,10 +21,6 @@ use eframe::egui::{self, Color32, Pos2, Rect, Shape, TextureOptions, Vec2, load: use egui_graphs::{DisplayEdge, DisplayNode, DrawContext, Node, NodeProps}; use fosr_lib::L7Proto; -// ============================================================================ -// HYBRID SCALING FUNCTIONS -// ============================================================================ - /// Calculate node radius using hybrid linear/proportional scaling. /// /// The scaling works in two phases: @@ -96,7 +92,7 @@ impl NetworkNodeShape { ) } - /// Get the image source for this node type + /// 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, @@ -104,41 +100,10 @@ impl NetworkNodeShape { NodeType::User => IMG_COMPUTER, } } -} - -impl From> for NetworkNodeShape { - fn from(props: NodeProps) -> Self { - let (radius, node_type, hostname, ips) = Self::style_from_payload(&props.payload); - Self { - radius, - hostname, - ips, - location: props.location(), - node_type, - } - } -} -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 { + /// Render the node icon at the given position and radius. + fn render_icon(&self, ctx: &DrawContext, pos: Pos2, radius: f32) -> 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); - - // 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)); @@ -155,7 +120,12 @@ impl DisplayNode Vec { + let mut shapes = Vec::new(); let font_size = TEXT_SIZE_DEFAULT; let font_id = egui::FontId::proportional(font_size); let mut current_y = pos.y + radius + SPACING_XS; @@ -203,6 +173,45 @@ impl DisplayNode> for NetworkNodeShape { + fn from(props: NodeProps) -> Self { + let (radius, node_type, hostname, ips) = Self::style_from_payload(&props.payload); + Self { + radius, + hostname, + ips, + location: props.location(), + node_type, + } + } +} + +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 (icon + labels). + 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); + + shapes.extend(self.render_icon(ctx, pos, radius)); + shapes.extend(self.render_labels(ctx, pos, radius)); + + shapes + } fn update(&mut self, state: &NodeProps) { let (radius, node_type, hostname, ips) = Self::style_from_payload(&state.payload); diff --git a/crates/fosr-gui/src/run/graph/state.rs b/crates/fosr-gui/src/run/graph/state.rs index 6fef3e95..201da53c 100644 --- a/crates/fosr-gui/src/run/graph/state.rs +++ b/crates/fosr-gui/src/run/graph/state.rs @@ -278,48 +278,52 @@ pub struct VisualizationState { pub user_has_started: bool, } -impl Default for VisualizationState { - fn default() -> Self { - Self { - network: NetworkData::default(), - flow: FlowState::default(), - view: ViewState::default(), - modal: ModalState::default(), - screenshot_export: ScreenshotStateMachine::default(), - config_content: None, - auto_start_countdown: None, - user_has_started: false, - } - } +/// Result of building a graph from configuration. +struct GraphBuildResult { + graph: VisualizationGraph, + known_ips: HashSet, + ip_to_node: HashMap, + node_to_host: HashMap, } -impl VisualizationState { - /// 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, node_to_host) = Self::build_graph_from_config(config); - self.network.graph = graph; - self.network.known_ips = known_ips; - self.network.ip_to_node = ip_to_node; - self.network.node_to_host = node_to_host; - self.view.layout_initialized = false; +/// Helper for constructing visualization graphs from configuration. +/// +/// Groups mutable state during graph construction to avoid passing +/// multiple `&mut` parameters. The construction order is fixed: +/// 1. Create host nodes from config +/// 2. Layout (circular distribution) +/// 3. Add the Internet node (centered) +/// 4. Create edges between hosts and Internet +struct GraphConstructor { + graph: VisualizationGraph, + known_ips: HashSet, + ip_to_node: HashMap, + node_to_host: HashMap, +} + +impl GraphConstructor { + /// Build the complete graph from configuration. + fn build(config: &config::Configuration) -> GraphBuildResult { + let mut constructor = Self { + graph: VisualizationGraph::new(petgraph::stable_graph::StableGraph::default()), + known_ips: HashSet::new(), + ip_to_node: HashMap::new(), + node_to_host: HashMap::new(), + }; + constructor.add_host_nodes(config); + constructor.distribute_layout(); + constructor.add_internet_node(); + constructor.add_edges(config); + GraphBuildResult { + graph: constructor.graph, + known_ips: constructor.known_ips, + ip_to_node: constructor.ip_to_node, + node_to_host: constructor.node_to_host, + } } - /// Build graph from configuration (shared logic) - fn build_graph_from_config( - config: &config::Configuration, - ) -> ( - 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) + /// Add one node per host (with all its IPs). + fn add_host_nodes(&mut self, config: &config::Configuration) { for (host_idx, host) in config.get_hosts().iter().enumerate() { let all_ips: Vec = host.interfaces.iter().map(|i| i.ip_addr).collect(); @@ -331,44 +335,78 @@ impl VisualizationState { flow_count: 0, 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); + let idx = self.graph.add_node_with_location(node_data, egui::pos2(0.0, 0.0)); + self.node_to_host.insert(idx, host_idx); // 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); + self.known_ips.insert(ip); + self.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); + /// Distribute nodes in a circle (before adding Internet, so it stays centered). + fn distribute_layout(&mut self) { + distribute_nodes_circle(&mut self.graph); + } + + /// Add the Internet node at the center. + fn add_internet_node(&mut self) { + let internet_idx = self.graph.add_node_with_location(NodeData::internet(), egui::pos2(0.0, 0.0)); + self.ip_to_node.insert(INTERNET_IP, internet_idx); + } - // 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, servers, and Internet. + fn add_edges(&mut self, config: &config::Configuration) { + let internet_idx = self.ip_to_node[&INTERNET_IP]; - // Add edges for all possible connections between users and servers + // Add edges from users to servers and Internet for &user_ip in &config.users { - if let Some(&user_idx) = ip_to_node.get(&user_ip) { + if let Some(&user_idx) = self.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::default()); + if let Some(&server_idx) = self.ip_to_node.get(&server_ip) { + self.graph.add_edge(user_idx, server_idx, EdgeData::default()); } } - // Add edge to Internet for each user - graph.add_edge(user_idx, internet_idx, EdgeData::default()); + self.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::default()); + if let Some(&server_idx) = self.ip_to_node.get(&server_ip) { + self.graph.add_edge(server_idx, internet_idx, EdgeData::default()); } } + } +} - (graph, known_ips, ip_to_node, node_to_host) +impl Default for VisualizationState { + fn default() -> Self { + Self { + network: NetworkData::default(), + flow: FlowState::default(), + view: ViewState::default(), + modal: ModalState::default(), + screenshot_export: ScreenshotStateMachine::default(), + config_content: None, + auto_start_countdown: None, + user_has_started: false, + } + } +} + +impl VisualizationState { + /// 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 built = GraphConstructor::build(config); + self.network.graph = built.graph; + self.network.known_ips = built.known_ips; + self.network.ip_to_node = built.ip_to_node; + self.network.node_to_host = built.node_to_host; + self.view.layout_initialized = false; } /// Check if an IP is a known (configured) IP @@ -395,10 +433,11 @@ impl VisualizationState { } } - /// 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 + /// 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. + /// If `reset` is `true`, flow counts are reset to zero before starting. pub fn start_visualization( &mut self, config_content: Option<&str>, diff --git a/crates/fosr-gui/src/run/graph/stream.rs b/crates/fosr-gui/src/run/graph/stream.rs index 5b7f788c..4d108740 100644 --- a/crates/fosr-gui/src/run/graph/stream.rs +++ b/crates/fosr-gui/src/run/graph/stream.rs @@ -12,7 +12,12 @@ use crate::shared::constants::network::STREAM_MAX_PER_CYCLE_WASM; use crate::shared::constants::network::STREAM_RATE_LIMIT_MS; use crate::shared::constants::network::{STREAM_BUFFER_AHEAD_SECS, STREAM_CHECK_INTERVAL_MS}; use chrono::{DateTime, Offset, TimeZone}; -use fosr_lib::{L7Proto, models, stage0, stage1::Stage1, stage1::bayesian_networks::BNGenerator}; +use fosr_lib::{ + L7Proto, models, + stage0, + stage1::Stage1, + stage1::bayesian_networks::BNGenerator, +}; use std::collections::BinaryHeap; use std::net::Ipv4Addr; use std::sync::atomic::{AtomicBool, Ordering}; @@ -109,34 +114,220 @@ impl Ord for ScheduledFlow { } } +impl ScheduledFlow { + /// Create a scheduled flow from flow data, relative to the initial timestamp. + fn from_flow_data( + flow_data: &fosr_lib::FlowData, + initial_timestamp: Duration, + ) -> Self { + let scheduled_time = if flow_data.timestamp >= initial_timestamp { + flow_data.timestamp - initial_timestamp + } else { + Duration::ZERO + }; + + Self { + event: FlowEvent { + src_ip: flow_data.src_ip, + dst_ip: flow_data.dst_ip, + protocol: flow_data.l7_proto, + timestamp: flow_data.timestamp, + }, + scheduled_time, + } + } +} + +/// Mutable state for the streaming loop. +/// +/// Uses a binary heap to store flows in timestamp order, ensuring they are +/// emitted in the correct sequence. Flows are generated ahead of time (buffer) +/// to avoid overloading the CPU by continuously generating them. +struct StreamingState { + virtual_time: VirtualTime, + /// Pending flows ordered by scheduled time (max-heap, so we reverse the order). + pending_flows: BinaryHeap, + #[cfg(not(target_arch = "wasm32"))] + flow_count: usize, + #[cfg(not(target_arch = "wasm32"))] + last_generation: Instant, + /// How far ahead to generate flows (avoids CPU spinning). + buffer_ahead: Duration, +} + +impl StreamingState { + fn new() -> Self { + Self { + virtual_time: VirtualTime::new(), + pending_flows: BinaryHeap::new(), + #[cfg(not(target_arch = "wasm32"))] + flow_count: 0, + #[cfg(not(target_arch = "wasm32"))] + last_generation: Instant::now(), + buffer_ahead: Duration::from_secs(STREAM_BUFFER_AHEAD_SECS), + } + } + + /// Advance virtual time by the current speed. + fn tick(&mut self, speed: f32) { + self.virtual_time.tick(speed); + } + + /// Get the current virtual elapsed time. + fn virtual_elapsed(&self) -> Duration { + self.virtual_time.elapsed() + } + + /// Check if we need to generate more flows (buffer running low). + /// + /// If the heap is empty, `.peek()` returns `None`, and `.map_or()` returns `true` + /// (we need flows). Otherwise, check if the farthest scheduled flow is within + /// the buffer window. + fn needs_more_flows(&self) -> bool { + self.pending_flows + .peek() + .map_or(true, |f| f.scheduled_time < self.virtual_elapsed() + self.buffer_ahead) + } + + /// Check if rate limiting allows generation (desktop only). + /// + /// Limits generation rate to avoid CPU spinning when the buffer is already full. + #[cfg(not(target_arch = "wasm32"))] + fn can_generate(&self) -> bool { + self.pending_flows.is_empty() + || self.last_generation.elapsed() >= Duration::from_millis(STREAM_RATE_LIMIT_MS) + } +} + +/// Generate flows and add them to the pending queue. +/// +/// Returns false if no more timestamps are available from Stage 0. +#[cfg(not(target_arch = "wasm32"))] +fn generate_flows_to_buffer( + state: &mut StreamingState, + s0: &mut stage0::BinBasedGenerator, + s1: &BNGenerator, + initial_timestamp: Duration, +) -> bool { + if !state.can_generate() { + return true; // Rate limited, but Stage 0 may still have more + } + + 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 scheduled = ScheduledFlow::from_flow_data(&flow_data, initial_timestamp); + state.pending_flows.push(scheduled); + state.flow_count += 1; + } + } + state.last_generation = Instant::now(); + true + } else { + false // No more timestamps + } +} + +/// Generate flows for WASM (with per-cycle limit). +#[cfg(target_arch = "wasm32")] +fn generate_flows_to_buffer_wasm( + state: &mut StreamingState, + s0: &mut stage0::BinBasedGenerator, + s1: &BNGenerator, + initial_timestamp: Duration, +) { + let mut generated_this_cycle = 0; + + while state.needs_more_flows() && generated_this_cycle < STREAM_MAX_PER_CYCLE_WASM { + 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 scheduled = ScheduledFlow::from_flow_data(&flow_data, initial_timestamp); + state.pending_flows.push(scheduled); + } + } + generated_this_cycle += 1; + } else { + break; + } + } +} + +/// Emit flows whose scheduled time has passed (in virtual time). +#[cfg(not(target_arch = "wasm32"))] +fn emit_scheduled_flows( + state: &mut StreamingState, + sender: &Sender, +) { + let virtual_elapsed = state.virtual_elapsed(); + + while let Some(scheduled) = state.pending_flows.peek() { + if scheduled.scheduled_time <= virtual_elapsed { + let scheduled = state.pending_flows.pop().unwrap(); + log::debug!( + "Emitting flow #{}: {} -> {} ({:?}) at virtual {:?}", + state.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; + } + } +} + +/// Emit flows for WASM (simplified, no debug logging). +#[cfg(target_arch = "wasm32")] +fn emit_scheduled_flows_wasm( + state: &mut StreamingState, + sender: &Sender, +) { + let virtual_elapsed = state.virtual_elapsed(); + + while let Some(scheduled) = state.pending_flows.peek() { + if scheduled.scheduled_time <= virtual_elapsed { + let scheduled = state.pending_flows.pop().unwrap(); + let _ = sender.send(scheduled.event); + } else { + break; + } + } +} + /// 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 + /// 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) @@ -149,29 +340,12 @@ impl FlowStreamer { 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 - }; + let tz_offset = Self::get_local_timezone_offset(initial_ts); - // Create Stage 0 generator let s0 = stage0::BinBasedGenerator::new( None, // Random seed false, @@ -182,7 +356,6 @@ impl FlowStreamer { tz_offset, ); - // Create Stage 1 generator let s1 = BNGenerator::new(bn, false); Ok(Self { @@ -195,7 +368,25 @@ impl FlowStreamer { }) } - /// Start streaming flows in the background + /// Get the local timezone offset for the given timestamp. + /// + /// TODO: Use the value from the generation tab? + /// TODO: Extract this logic to share with generation_core. + fn get_local_timezone_offset(timestamp: Duration) -> chrono::FixedOffset { + let date = DateTime::from_timestamp(timestamp.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 + } + + /// Start streaming flows in the background. pub fn start(&self) { self.running.store(true, Ordering::SeqCst); let sender = self.sender.clone(); @@ -216,7 +407,7 @@ impl FlowStreamer { }); } - /// Stop streaming flows + /// Stop streaming flows. pub fn stop(&self) { self.running.store(false, Ordering::SeqCst); } @@ -230,20 +421,9 @@ impl FlowStreamer { 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(STREAM_BUFFER_AHEAD_SECS); - // How often to check for flows to emit + let mut state = StreamingState::new(); let check_interval = Duration::from_millis(STREAM_CHECK_INTERVAL_MS); - // Track virtual time (see VirtualTime struct for explanation) - let mut virtual_time = VirtualTime::new(); - log::info!( "Flow streaming loop started (timestamp-based, speed: {}x)", *speed.read().unwrap() @@ -252,95 +432,25 @@ impl FlowStreamer { while running.load(Ordering::SeqCst) { // Advance virtual time let current_speed = *speed.read().unwrap(); - virtual_time.tick(current_speed); - let virtual_elapsed = virtual_time.elapsed(); - - // 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(STREAM_RATE_LIMIT_MS) - && !pending_flows.is_empty() - { - break; - } + state.tick(current_speed); - 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; + // Generate flows until buffer is full or Stage 0 exhausted + while state.needs_more_flows() && running.load(Ordering::SeqCst) { + if !generate_flows_to_buffer(&mut state, &mut s0, &s1, initial_timestamp) { + break; // Stage 0 exhausted } } - // 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; - } - } + // Emit flows that are due + emit_scheduled_flows(&mut state, &sender); - // Sleep until next check std::thread::sleep(check_interval); } log::info!( "Flow streaming loop stopped ({} flows generated, {} pending)", - flow_count, - pending_flows.len() + state.flow_count, + state.pending_flows.len() ); } @@ -353,68 +463,19 @@ impl FlowStreamer { initial_timestamp: Duration, speed: Arc>, ) { - let mut pending_flows: BinaryHeap = BinaryHeap::new(); - let buffer_ahead = Duration::from_secs(STREAM_BUFFER_AHEAD_SECS); + let mut state = StreamingState::new(); let check_interval = Duration::from_millis(STREAM_CHECK_INTERVAL_MS); - // Track virtual time (see VirtualTime struct for explanation) - let mut virtual_time = VirtualTime::new(); - while running.load(Ordering::SeqCst) { // Advance virtual time let current_speed = *speed.read().unwrap(); - virtual_time.tick(current_speed); - let virtual_elapsed = virtual_time.elapsed(); - - // 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 < STREAM_MAX_PER_CYCLE_WASM - { - 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; - } - } + state.tick(current_speed); - // 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; - } - } + // Generate flows (WASM has per-cycle limit) + generate_flows_to_buffer_wasm(&mut state, &mut s0, &s1, initial_timestamp); + + // Emit flows that are due + emit_scheduled_flows_wasm(&mut state, &sender); gloo_timers::future::TimeoutFuture::new(check_interval.as_millis() as u32).await; } diff --git a/crates/fosr-gui/src/run/graph/view.rs b/crates/fosr-gui/src/run/graph/view.rs index d7cc44fd..d9502d3e 100644 --- a/crates/fosr-gui/src/run/graph/view.rs +++ b/crates/fosr-gui/src/run/graph/view.rs @@ -9,7 +9,7 @@ use super::overlays::{ }; use super::screenshot::handle_screenshot_export; use super::shapes::{NetworkEdgeShape, NetworkNodeShape}; -use super::state::{EdgeData, NodeData, ScreenshotStateMachine}; +use super::state::{EdgeData, NodeData, ScreenshotStateMachine, ViewState}; use crate::run::state::RunTabState; use crate::shared::constants::ui::FIT_TO_SCREEN_PADDING; use eframe::egui; @@ -24,28 +24,9 @@ use eframe::egui; /// 5. Renders UI overlays (buttons, stats, legends) pub fn render_graph_view(ui: &mut egui::Ui, state: &mut RunTabState) { let inner_response = egui::CentralPanel::default().show(ui.ctx(), |ui| { - // Enable node clicking and dragging - let interactions = egui_graphs::SettingsInteraction::new() - .with_node_clicking_enabled(true) - .with_dragging_enabled(true); + handle_window_resize(ui, &mut state.visualization.view); - // Reset view on window resize - let screen_size = ui.ctx().content_rect().size(); - match state.visualization.view.last_screen_size { - Some(last) if last != screen_size => { - state.visualization.view.last_screen_size = Some(screen_size); - state.visualization.view.reset_requested = true; - } - None => state.visualization.view.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.visualization.view.reset_requested; - if state.visualization.view.reset_requested { - state.visualization.view.reset_requested = false; - } + let fit_to_screen = consume_reset_request(&mut state.visualization.view); let mut graph_view = egui_graphs::GraphView::< NodeData, @@ -57,49 +38,83 @@ pub fn render_graph_view(ui: &mut egui::Ui, state: &mut RunTabState) { egui_graphs::FruchtermanReingoldWithCenterGravityState, egui_graphs::LayoutForceDirected, >::new(&mut state.visualization.network.graph) - .with_interactions(&interactions) + .with_interactions(&egui_graphs::SettingsInteraction::new() + .with_node_clicking_enabled(true) + .with_dragging_enabled(true)) .with_event_sink(&state.visualization.modal.events_buffer) .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_fit_to_screen_padding(FIT_TO_SCREEN_PADDING) // padding to avoid cropping with labels and overlays + // padding to avoid cropping with labels and overlays + .with_fit_to_screen_padding(FIT_TO_SCREEN_PADDING) .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 - if !state.visualization.view.layout_initialized { - let layout_state = egui_graphs::FruchtermanReingoldWithCenterGravityState { - base: egui_graphs::FruchtermanReingoldState { - is_running: false, - ..Default::default() - }, - extras: Default::default(), - }; - egui_graphs::set_layout_state(ui, layout_state, None); - state.visualization.view.layout_initialized = true; - } + // TODO: handle layout properly instead of just deactivating auto-layout + disable_force_directed_layout(ui, &mut state.visualization.view); ui.add(&mut graph_view); - // Handle screenshot export state machine handle_screenshot_export(ui, &mut state.visualization); // Hide overlays during export to get clean screenshot - if state.visualization.screenshot_export != ScreenshotStateMachine::Idle { - return; + if state.visualization.screenshot_export == ScreenshotStateMachine::Idle { + render_overlay_buttons(ui, &mut state.visualization); + render_overlay_stats(ui, &state.visualization); + render_overlay_node_legend(ui); + render_overlay_edge_legend(ui); } - - // Render overlays - render_overlay_buttons(ui, &mut state.visualization); - render_overlay_stats(ui, &state.visualization); - render_overlay_node_legend(ui); - render_overlay_edge_legend(ui); }); // Use panel rect directly - it's already in screen coordinates // and represents the full panel area (ui.max_rect() excludes internal padding) - let panel_rect = inner_response.response.rect; - state.visualization.view.graph_rect = Some(panel_rect); + state.visualization.view.graph_rect = Some(inner_response.response.rect); +} + +/// Detect window resize and trigger fit-to-screen. +/// +/// Compares current screen size with last known size. +/// On change, requests a reset to recalculate zoom/pan. +fn handle_window_resize(ui: &egui::Ui, view: &mut ViewState) { + let screen_size = ui.ctx().content_rect().size(); + match view.last_screen_size { + Some(last) if last != screen_size => { + view.last_screen_size = Some(screen_size); + view.reset_requested = true; + } + None => view.last_screen_size = Some(screen_size), + _ => {} + } +} + +/// Consume reset request and return whether fit-to-screen should run. +/// +/// Returns true for one frame when reset is requested, +/// then clears the flag so it doesn't repeat. +fn consume_reset_request(view: &mut ViewState) -> bool { + let fit = view.reset_requested; + if fit { + view.reset_requested = false; + } + fit +} + +/// Disable force-directed layout to preserve circle layout. +/// +/// Must be called once after the graph is first rendered. +/// The force-directed layout would override our circle layout. +fn disable_force_directed_layout(ui: &mut egui::Ui, view: &mut ViewState) { + if view.layout_initialized { + return; + } + let layout_state = egui_graphs::FruchtermanReingoldWithCenterGravityState { + base: egui_graphs::FruchtermanReingoldState { + is_running: false, + ..Default::default() + }, + extras: Default::default(), + }; + egui_graphs::set_layout_state(ui, layout_state, None); + view.layout_initialized = true; } diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs index dfc7cd65..e445450a 100644 --- a/crates/fosr-gui/src/run/tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -7,67 +7,70 @@ use super::graph::flow_processing::{process_flow_events, update_active_links, up use super::graph::node_modal::{process_graph_events, render_node_info_modal}; use super::graph::view::render_graph_view; use super::state::RunTabState; +use crate::run::graph::state::{ViewState, VisualizationState}; use crate::shared::config::file_ops::load_config_file_contents; use crate::shared::config::state::ConfigurationFileState; use eframe::egui; +/// Display the Run tab content. +/// +/// Orchestrates visualization updates, event processing, and UI rendering. pub fn show_run_tab_content( ui: &mut egui::Ui, state: &mut RunTabState, configuration_file_state: &mut ConfigurationFileState, ) { - // Load config file contents if a file is selected but content not yet loaded load_config_file_contents(configuration_file_state); - - // Handle config changes for visualization handle_config_changes(&mut state.visualization, configuration_file_state); - // Auto-start visualization with delay (allows UI to render first) - if let Some(countdown) = state.visualization.auto_start_countdown { - if countdown > 0 { - state.visualization.auto_start_countdown = Some(countdown - 1); - } else if !state.visualization.flow.running { - let config = state.visualization.config_content.clone(); - let speed = state.visualization.flow.speed.clone(); - if let Err(e) = state - .visualization - .start_visualization(config.as_deref(), speed, true) - { - log::error!("Failed to auto-start visualization: {}", e); - } - state.visualization.auto_start_countdown = None; - } - } - - // Handle delayed fit-to-screen (after panel toggle or on initial load) - if let Some(countdown) = state.visualization.view.delayed_fit_countdown { - if countdown > 0 { - state.visualization.view.delayed_fit_countdown = Some(countdown - 1); - } else { - state.visualization.view.reset_requested = true; - state.visualization.view.delayed_fit_countdown = None; - } - } + handle_auto_start_visualization(&mut state.visualization); + handle_delayed_fit_to_screen(&mut state.visualization.view); - // Process incoming flow events process_flow_events(&mut state.visualization); - - // Update active links (remove expired ones) update_active_links(&mut state.visualization); - - // Update graph edges based on active links update_graph_edges(&mut state.visualization); - // Poll generation receivers (must be done before rendering UI) poll_generation_receivers(ui.ctx(), state); - // Bottom panel (action bar + expandable options) show_bottom_panel(ui.ctx(), state, configuration_file_state); - // Render the graph view render_graph_view(ui, state); - - // Process node click events and render info modal process_graph_events(&mut state.visualization, configuration_file_state); + render_node_info_modal(ui.ctx(), &mut state.visualization, configuration_file_state); } + +/// Handle auto-start visualization with a frame delay. +/// +/// Waits for the countdown to reach zero before starting the visualization. +/// This allows the UI to render at least one frame before starting, +/// preventing visual glitches on initial load. +fn handle_auto_start_visualization(state: &mut VisualizationState) { + if let Some(countdown) = state.auto_start_countdown { + if countdown > 0 { + state.auto_start_countdown = Some(countdown - 1); + } else if !state.flow.running { + let config = state.config_content.clone(); + let speed = state.flow.speed.clone(); + 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; + } + } +} + +/// Handle delayed fit-to-screen after view changes. +/// +/// Waits for the countdown to reach zero before triggering a fit-to-screen. +/// Used after panel toggles or on initial load to ensure proper layout. +fn handle_delayed_fit_to_screen(view: &mut ViewState) { + if let Some(countdown) = view.delayed_fit_countdown { + if countdown > 0 { + view.delayed_fit_countdown = Some(countdown - 1); + } else { + view.reset_requested = true; + view.delayed_fit_countdown = None; + } + } +} diff --git a/crates/fosr-gui/src/shared/assets.rs b/crates/fosr-gui/src/shared/assets.rs index 2f85ff23..71f9dd1f 100644 --- a/crates/fosr-gui/src/shared/assets.rs +++ b/crates/fosr-gui/src/shared/assets.rs @@ -1,6 +1,6 @@ //! Embedded assets: images and icons used throughout the application. //! -//! Centralizes all `include_image!` calls to avoid path issues when files move. +//! Centralizes all `include_image!` calls. Reduces path issues when files move. use eframe::egui::{ImageSource, include_image}; @@ -9,5 +9,5 @@ pub const IMG_SERVER: ImageSource = include_image!("../../assets/server.png"); pub const IMG_COMPUTER: ImageSource = include_image!("../../assets/computer.png"); pub const IMG_INTERNET: ImageSource = include_image!("../../assets/internet.png"); -// Application logo +// Fos-R logo pub const IMG_LOGO: ImageSource = include_image!("../../../../public/logo.png"); diff --git a/crates/fosr-gui/src/shared/config/file_ops.rs b/crates/fosr-gui/src/shared/config/file_ops.rs index 9f61fbaa..6e2fd1c3 100644 --- a/crates/fosr-gui/src/shared/config/file_ops.rs +++ b/crates/fosr-gui/src/shared/config/file_ops.rs @@ -1,24 +1,22 @@ -//! Configuration file operations: loading, saving, and file picker UI. +//! Configuration file operations: loading and saving config files. -use crate::config_editor::state::ConfigurationTabState; -use crate::config_templates::load_template_by_id; use crate::shared::config::model::Configuration; use crate::shared::config::parser::parse_config_yaml; use crate::shared::config::state::ConfigurationFileState; -use crate::shared::constants::colors::COLOR_WARNING; -use crate::shared::constants::ui::SPACING_LG; #[cfg(not(target_arch = "wasm32"))] -use crate::shared::file_io::{read_file_desktop, save_file_desktop, show_file_picker_desktop}; +use crate::shared::file_io::{read_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::shared::widgets::helpers::labeled_toggle; +use crate::shared::file_io::{read_file_wasm, show_file_picker_wasm}; use chrono::{DateTime, Local}; use eframe::egui; #[cfg(target_arch = "wasm32")] use std::sync::mpsc::channel; -/// 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`. +/// Trigger a file import dialog to select a configuration file. +/// +/// On desktop: synchronous file picker via native dialog. +/// On WASM: async file picker, result arrives via `config_file_receiver`. +/// Clears any previously loaded config state before picking a new file. pub fn trigger_file_import(state: &mut ConfigurationFileState, ctx: &egui::Context) { state.config_file_content = None; #[cfg(target_arch = "wasm32")] @@ -51,6 +49,9 @@ pub fn trigger_file_import(state: &mut ConfigurationFileState, ctx: &egui::Conte } /// Poll the WASM async file picker and apply the result if ready. +/// +/// Should be called every frame in the UI to check for completed file picks. +/// When a file is picked, clears the loaded config and sets up for content loading. #[cfg(target_arch = "wasm32")] pub fn poll_file_import(state: &mut ConfigurationFileState) { if let Some(receiver) = &state.config_file_receiver { @@ -65,149 +66,12 @@ 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| { - ui.label("Configuration file:"); - - if ui - .button(egui_material_icons::icons::ICON_FOLDER_OPEN) - .on_hover_text("Select a configuration file") - .clicked() - { - trigger_file_import(configuration_file_state, ui.ctx()); - } - - #[cfg(target_arch = "wasm32")] - poll_file_import(configuration_file_state); - - // Template dropdown menu (always visible) - let template_menu = - ui.menu_button(egui_material_icons::icons::ICON_DESCRIPTION, |menu_ui| { - for template in crate::config_templates::all_templates() { - if menu_ui - .button(format!("{} {}", template.icon, template.title)) - .clicked() - { - menu_ui.close(); - load_template_by_id(configuration_file_state, template.id); - } - } - }); - template_menu.response.on_hover_text("Open template"); - - // Display the file name on disk, or indicate built-in template - let filename = if let Some(file) = &configuration_file_state.picked_config_file { - file.file_name() - } else if let Some(template_id) = &configuration_file_state.loaded_template_id { - format!("{}.yaml (built-in)", template_id) - } else { - "No file selected".to_string() - }; - - // 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 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() - .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"))] - { - let path_text = configuration_file_state - .picked_config_file - .as_ref() - .map(|file| file.path().to_string_lossy().to_string()) - .unwrap_or("Default config selected".to_string()); - - if configuration_file_state.is_dirty { - ui.colored_label(COLOR_WARNING, egui_material_icons::icons::ICON_WARNING) - .on_hover_text( - "Unsaved changes detected — download the file to avoid losing them.", - ); - ui.colored_label(COLOR_WARNING, &filename) - .on_hover_text(path_text); - } else { - ui.label(&filename).on_hover_text(path_text); - } - } - - #[cfg(target_arch = "wasm32")] - { - if configuration_file_state.is_dirty { - ui.colored_label(COLOR_WARNING, egui_material_icons::icons::ICON_WARNING) - .on_hover_text( - "Unsaved changes detected — download the file to avoid losing them.", - ); - ui.colored_label(COLOR_WARNING, &filename); - } else { - ui.label(&filename); - } - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_space(SPACING_LG); - // In RTL layout, rendering order is reversed, - // so Code is passed first to appear visually on the right. - labeled_toggle( - ui, - &mut tab_state.is_code_mode, - &format!("{} Code", egui_material_icons::icons::ICON_CODE), - &format!("{} Visual", egui_material_icons::icons::ICON_EDIT), - "Code Mode: edit as raw YAML.", - "Visual Mode: edit using the graphical interface.", - ); - }); - }); -} - +/// Load the contents of the selected configuration file. +/// +/// On desktop: reads file synchronously and parses YAML immediately. +/// On WASM: spawns async read, parses YAML when content arrives via channel. +/// +/// Skips if content is already loaded to avoid re-reading every frame. 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() { @@ -217,9 +81,15 @@ pub fn load_config_file_contents(configuration_file_state: &mut ConfigurationFil if let Some(file_handle) = &configuration_file_state.picked_config_file { #[cfg(not(target_arch = "wasm32"))] { - let content = read_file_desktop(file_handle); - configuration_file_state.config_file_content = Some(content); - parse_config_yaml(configuration_file_state); + match read_file_desktop(file_handle) { + Ok(content) => { + configuration_file_state.config_file_content = Some(content); + parse_config_yaml(configuration_file_state); + } + Err(e) => { + configuration_file_state.config_error = Some(e); + } + } } #[cfg(target_arch = "wasm32")] @@ -232,17 +102,22 @@ pub fn load_config_file_contents(configuration_file_state: &mut ConfigurationFil let file_handle_clone = file_handle.clone(); wasm_bindgen_futures::spawn_local(async move { - let content = read_file_wasm(&file_handle_clone).await; - let _ = sender.send(Some(content)); + let result = read_file_wasm(&file_handle_clone).await; + let _ = sender.send(result); }); } else { if let Some(receiver) = &configuration_file_state.config_file_content_receiver { - if let Ok(content) = receiver.try_recv() { - configuration_file_state.config_file_content = content; + if let Ok(result) = receiver.try_recv() { configuration_file_state.config_file_content_receiver = None; - } - if configuration_file_state.config_file_content.is_some() { - parse_config_yaml(configuration_file_state); + match result { + Ok(content) => { + configuration_file_state.config_file_content = Some(content); + parse_config_yaml(configuration_file_state); + } + Err(e) => { + configuration_file_state.config_error = Some(e); + } + } } } } @@ -253,7 +128,7 @@ pub fn load_config_file_contents(configuration_file_state: &mut ConfigurationFil 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.config_error = None; configuration_file_state.is_dirty = false; configuration_file_state.clean_snapshot = None; configuration_file_state.loaded_template_id = None; @@ -264,10 +139,11 @@ fn clear_loaded_config(configuration_file_state: &mut ConfigurationFileState) { } } -/// Enforce date and format in metadata -fn enforce_metadata_defaults(config: &mut Configuration) { +/// Enforce date and format in metadata before saving. +/// +/// Sets the date to today and format version to 1. +pub 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); } diff --git a/crates/fosr-gui/src/shared/config/parser.rs b/crates/fosr-gui/src/shared/config/parser.rs index cdf55e6a..8d763cf6 100644 --- a/crates/fosr-gui/src/shared/config/parser.rs +++ b/crates/fosr-gui/src/shared/config/parser.rs @@ -3,9 +3,12 @@ use crate::shared::config::model::Configuration; use crate::shared::config::state::ConfigurationFileState; +/// Parse the YAML content into a Configuration model. +/// Updates the state with the parsed model or an error message. +/// Handles change detection using a snapshot of the "clean" configuration. pub fn parse_config_yaml(configuration_file_state: &mut ConfigurationFileState) { configuration_file_state.config_model = None; - configuration_file_state.parse_error = None; + configuration_file_state.config_error = None; let Some(yaml) = configuration_file_state.config_file_content.as_deref() else { return; @@ -14,11 +17,11 @@ pub fn parse_config_yaml(configuration_file_state: &mut ConfigurationFileState) match serde_yaml::from_str::(yaml) { Ok(model) => { if configuration_file_state.clean_snapshot.is_none() { - configuration_file_state.clean_snapshot = Some(model.clone()); + configuration_file_state.clean_snapshot = Some(yaml.to_string()); } configuration_file_state.config_model = Some(model); configuration_file_state.is_dirty = true; } - Err(e) => configuration_file_state.parse_error = Some(e.to_string()), + Err(e) => configuration_file_state.config_error = Some(e.to_string()), } } diff --git a/crates/fosr-gui/src/shared/config/state.rs b/crates/fosr-gui/src/shared/config/state.rs index 0b4e8ff3..5436c534 100644 --- a/crates/fosr-gui/src/shared/config/state.rs +++ b/crates/fosr-gui/src/shared/config/state.rs @@ -19,14 +19,15 @@ pub struct ConfigurationFileState { pub config_file_receiver: Option>>, pub config_file_content: Option, #[cfg(target_arch = "wasm32")] - pub config_file_content_receiver: Option>>, + pub config_file_content_receiver: Option>>, pub config_model: Option, - pub parse_error: Option, + pub config_error: Option, /// 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, + /// YAML snapshot of the clean state (for dirty detection). + pub clean_snapshot: Option, /// Whether the configuration has any errors (parse errors or validation errors). /// Updated by the configuration tab rendering each frame. pub has_errors: bool, @@ -46,7 +47,7 @@ impl Default for ConfigurationFileState { #[cfg(target_arch = "wasm32")] config_file_content_receiver: None, config_model: None, - parse_error: None, + config_error: None, config_chosen: false, is_dirty: false, clean_snapshot: None, diff --git a/crates/fosr-gui/src/shared/constants/network.rs b/crates/fosr-gui/src/shared/constants/network.rs index afad6163..63c040ff 100644 --- a/crates/fosr-gui/src/shared/constants/network.rs +++ b/crates/fosr-gui/src/shared/constants/network.rs @@ -1,10 +1,4 @@ //! Network-related constants for configuration and visualization -//! -//! This module centralizes network-related constants including: -//! - Port ranges and defaults -//! - IP address ranges -//! - Stream timing parameters -//! - Generation progress stages // ============================================================================ // PORTS diff --git a/crates/fosr-gui/src/shared/constants/ui.rs b/crates/fosr-gui/src/shared/constants/ui.rs index ccc62900..ffa7c426 100644 --- a/crates/fosr-gui/src/shared/constants/ui.rs +++ b/crates/fosr-gui/src/shared/constants/ui.rs @@ -9,41 +9,41 @@ // SPACING // ============================================================================ -/// Extra small spacing (px) - minimal gaps +/// Extra small spacing (px) pub const SPACING_XS: f32 = 2.0; -/// Small spacing (px) - tight gaps between related items +/// Small spacing (px) pub const SPACING_SM: f32 = 4.0; -/// Medium spacing (px) - default gap between items +/// Medium spacing (px) pub const SPACING_MD: f32 = 6.0; -/// Large spacing (px) - gaps between sections +/// Large spacing (px) pub const SPACING_LG: f32 = 8.0; -/// Extra large spacing (px) - major section separators +/// Extra large spacing (px) pub const SPACING_XL: f32 = 12.0; -/// Extra extra large spacing (px) - large section gaps +/// Extra extra large spacing (px) pub const SPACING_XXL: f32 = 15.0; -/// Negative extra small spacing (px) - for pulling elements closer together +/// Negative extra small spacing (px) for pulling elements closer together pub const SPACING_NEGATIVE_XS: f32 = -2.0; // ============================================================================ // TYPOGRAPHY // ============================================================================ -/// Small text size (px) - captions, hints +/// Small text size (px) pub const TEXT_SIZE_SM: f32 = 12.0; -/// Medium text size (px) - button text +/// Medium text size (px) pub const TEXT_SIZE_MD: f32 = 13.0; -/// Default text size (px) - body text, tabs +/// Default text size (px) pub const TEXT_SIZE_DEFAULT: f32 = 14.0; -/// Large text size (px) - headings, emphasis +/// Large text size (px) pub const TEXT_SIZE_LG: f32 = 16.0; /// Icon size for startup cards and major UI elements (px) @@ -88,7 +88,7 @@ pub const POPUP_MAX_HEIGHT: f32 = 200.0; /// Node info modal width (px) pub const NODE_MODAL_WIDTH: f32 = 250.0; -/// Panel inner margin (horizontal, vertical) for top panels +/// Panel inner margin (horizontal, vertical) for top panel pub const PANEL_INNER_MARGIN: (i8, i8) = (4, 3); /// Bottom bar inner margin (horizontal, vertical) @@ -98,7 +98,7 @@ pub const BOTTOM_BAR_INNER_MARGIN: (i8, i8) = (8, 4); pub const OPTIONS_PANEL_INNER_MARGIN: (i8, i8) = (8, 8); // ============================================================================ -// WINDOW DIMENSIONS +// WINDOW DIMENSIONS (native builds) // ============================================================================ /// Default window width (px) @@ -122,10 +122,10 @@ pub const WINDOW_MIN_HEIGHT: f32 = 500.0; // ============================================================================ /// Minimum zoom level (fraction) -pub const ZOOM_MIN: f32 = 0.5; +pub const ZOOM_MIN: f32 = 0.8; /// Maximum zoom level (fraction) -pub const ZOOM_MAX: f32 = 3.0; +pub const ZOOM_MAX: f32 = 2.0; /// Zoom step increment (fraction) pub const ZOOM_STEP: f32 = 0.1; @@ -244,7 +244,8 @@ pub const GRAPH_LAYOUT_RADIUS_BASE: f32 = 100.0; /// Fit to screen padding (fraction of screen) pub const FIT_TO_SCREEN_PADDING: f32 = 0.15; -/// Base timeout for active links display (ms), adjusted by speed +/// Base timeout for active links display (ms), adjusted by speed. +/// Controls how long links stay in active state. pub const ACTIVE_LINK_BASE_TIMEOUT_MS: f32 = 500.0; /// Overlay margin from screen edges (px) @@ -276,7 +277,7 @@ pub const INFO_ICON_SPACING: f32 = -4.0; /// Info icon size pub const INFO_ICON_SIZE: f32 = 14.0; -/// Info tooltip maximum width +/// Info tooltip maximum width (px) pub const INFO_TOOLTIP_MAX_WIDTH: f32 = 300.0; // ============================================================================ diff --git a/crates/fosr-gui/src/shared/file_io.rs b/crates/fosr-gui/src/shared/file_io.rs index f5e7dea1..47ec26a6 100644 --- a/crates/fosr-gui/src/shared/file_io.rs +++ b/crates/fosr-gui/src/shared/file_io.rs @@ -6,7 +6,7 @@ use std::io::Error; #[cfg(not(target_arch = "wasm32"))] pub fn show_file_picker_desktop() -> Option { rfd::FileDialog::new() - .add_filter("Configuration files", &["json", "yaml", "yml"]) + .add_filter("YAML Configuration files", &["yaml", "yml"]) .set_directory(std::env::current_dir().unwrap_or(std::path::PathBuf::from("/"))) .pick_file() .map(|path| FileHandle::from(path)) @@ -15,20 +15,21 @@ pub fn show_file_picker_desktop() -> Option { #[cfg(target_arch = "wasm32")] pub async fn show_file_picker_wasm() -> Option { rfd::AsyncFileDialog::new() - .add_filter("Configuration files", &["json", "yaml", "yml"]) + .add_filter("YAML Configuration files", &["yaml", "yml"]) .pick_file() .await } #[cfg(not(target_arch = "wasm32"))] -pub fn read_file_desktop(file_handle: &FileHandle) -> String { - std::fs::read_to_string(file_handle.path()).unwrap() +pub fn read_file_desktop(file_handle: &FileHandle) -> Result { + std::fs::read_to_string(file_handle.path()) + .map_err(|e| format!("Failed to read file: {}", e)) } #[cfg(target_arch = "wasm32")] -pub async fn read_file_wasm(file_handle: &FileHandle) -> String { +pub async fn read_file_wasm(file_handle: &FileHandle) -> Result { let content = file_handle.read().await; - String::from_utf8(content).expect("Invalid UTF-8") + String::from_utf8(content).map_err(|e| format!("Invalid UTF-8 in file: {}", e)) } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/fosr-gui/src/shared/widgets/helpers.rs b/crates/fosr-gui/src/shared/widgets/helpers.rs index 06f0cda0..7847672f 100644 --- a/crates/fosr-gui/src/shared/widgets/helpers.rs +++ b/crates/fosr-gui/src/shared/widgets/helpers.rs @@ -8,44 +8,29 @@ use crate::shared::constants::ui::{ use eframe::egui::{self, TextFormat, text::LayoutJob}; /// Display a small info icon with a tooltip. -pub fn info_icon(ui: &mut egui::Ui, tooltip: &str) { +pub fn info_icon_with_tooltip(ui: &mut egui::Ui, tooltip: &str) { ui.add_space(INFO_ICON_SPACING); ui.label( egui::RichText::new("ℹ") .color(COLOR_TEXT_MUTED) .size(INFO_ICON_SIZE), ) - .on_hover_cursor(egui::CursorIcon::Help) - .on_hover_ui(|ui| { - ui.set_max_width(INFO_TOOLTIP_MAX_WIDTH); - ui.label(tooltip); - }); + .on_hover_cursor(egui::CursorIcon::Help) + .on_hover_ui(|ui| { + ui.set_max_width(INFO_TOOLTIP_MAX_WIDTH); + ui.label(tooltip); + }); } -/// Displays an editor for an `Option` field in an egui UI. +/// Displays a single-line editor for an `Option`. /// -/// 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( +/// This prevents exporting empty strings in YAML. +pub fn edit_optional_string_singleline( ui: &mut egui::Ui, label: &str, value: &mut Option, @@ -99,6 +84,7 @@ pub fn labeled_toggle( .show(ui, |ui| { // Remove the hover stroke on selectable labels inside this toggle ui.style_mut().visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE; + ui.spacing_mut().item_spacing = egui::vec2(TOGGLE_ITEM_SPACING, 0.0); ui.horizontal(|ui| { let first = ui.selectable_label(*is_first_selected, first_label); @@ -114,6 +100,7 @@ pub fn labeled_toggle( second.on_hover_text(tooltip_second); }); }); + // Avoid returning the response, as it's not used let _ = resp; } @@ -124,7 +111,7 @@ pub fn labeled_toggle( /// - 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( +pub fn edit_optional_string_multiline( ui: &mut egui::Ui, label: &str, value: &mut Option, @@ -160,6 +147,7 @@ pub fn edit_optional_multiline_string( // Helper for required label with red * pub fn required_label(ui: &mut egui::Ui, text: &str) { + // A layout job is a way to build complex text with different formatting let mut job = LayoutJob::default(); job.append( diff --git a/crates/fosr-gui/src/shared/widgets/time_picker/popup.rs b/crates/fosr-gui/src/shared/widgets/time_picker/popup.rs index aa1b1fa1..923104ca 100644 --- a/crates/fosr-gui/src/shared/widgets/time_picker/popup.rs +++ b/crates/fosr-gui/src/shared/widgets/time_picker/popup.rs @@ -6,7 +6,6 @@ // Licensed under the MIT License (see mod.rs for full text). use std::fmt::Display; - use chrono::{NaiveTime, Timelike}; use eframe::egui::{ self, Align2, Color32, DragValue, FontId, Id, Layout, Painter, Pos2, Response, RichText, Sense, diff --git a/crates/fosr-gui/src/shared/widgets/timezone_picker.rs b/crates/fosr-gui/src/shared/widgets/timezone_picker.rs index 93adaa4e..9cf341bd 100644 --- a/crates/fosr-gui/src/shared/widgets/timezone_picker.rs +++ b/crates/fosr-gui/src/shared/widgets/timezone_picker.rs @@ -103,7 +103,7 @@ pub fn timezone_picker(ui: &mut egui::Ui, timezone_input: &mut String) { // (workaround for https://github.com/emilk/egui/issues/5225) ui.set_max_height(TIMEZONE_POPUP_MAX_HEIGHT); - // Search input with auto-focus on open + // Search input with autofocus on open let edit_id = ui.make_persistent_id("tz_search"); ui.add( egui::TextEdit::singleline(timezone_input) @@ -125,11 +125,13 @@ pub fn timezone_picker(ui: &mut egui::Ui, timezone_input: &mut String) { for tz in TZ_VARIANTS { let tz_str = tz.to_string(); if filter.is_empty() || tz_str.to_lowercase().contains(&filter) { + // Display the timezone as a clickable option that updates the input if ui .selectable_label(*timezone_input == tz_str, &tz_str) .clicked() { *timezone_input = tz_str; + // Close the first closable parent, which is the popup ui.close(); } } From b0f5d7122fe35bcf2efb2d705bf0b1a18d7993d6 Mon Sep 17 00:00:00 2001 From: 2uent1n <127933396+2uent1n@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:48:42 +0100 Subject: [PATCH 127/127] refactor(gui): global renaming + add docstrings + rework graph constructor --- crates/fosr-gui/src/about_tab.rs | 3 +- crates/fosr-gui/src/app/mod.rs | 37 +-- crates/fosr-gui/src/app/startup_modal.rs | 19 +- crates/fosr-gui/src/app/top_bar.rs | 33 +-- .../src/{modules.rs => app_modules.rs} | 0 crates/fosr-gui/src/config_editor/host.rs | 62 ++--- .../src/config_editor/host_interfaces.rs | 57 ++--- .../src/config_editor/host_services.rs | 74 +++--- .../src/config_editor/host_validation.rs | 18 +- crates/fosr-gui/src/config_editor/tab.rs | 32 +-- crates/fosr-gui/src/config_editor/toolbar.rs | 26 +- .../fosr-gui/src/config_editor/yaml_editor.rs | 6 +- crates/fosr-gui/src/config_templates/mod.rs | 6 +- crates/fosr-gui/src/lib.rs | 2 +- crates/fosr-gui/src/main.rs | 2 +- .../src/run/generation/bottom_panel.rs | 66 ++--- crates/fosr-gui/src/run/generation/core.rs | 26 +- crates/fosr-gui/src/run/generation/options.rs | 50 ++-- crates/fosr-gui/src/run/generation/process.rs | 16 +- crates/fosr-gui/src/run/generation/state.rs | 4 +- .../fosr-gui/src/run/generation/validation.rs | 10 +- .../fosr-gui/src/run/graph/config_handling.rs | 4 +- .../fosr-gui/src/run/graph/flow_processing.rs | 8 +- .../run/graph/{utils.rs => graph_layout.rs} | 10 +- crates/fosr-gui/src/run/graph/mod.rs | 2 +- crates/fosr-gui/src/run/graph/node_modal.rs | 18 +- crates/fosr-gui/src/run/graph/screenshot.rs | 4 +- crates/fosr-gui/src/run/graph/shapes.rs | 52 ++-- crates/fosr-gui/src/run/graph/state.rs | 235 ++++++++---------- crates/fosr-gui/src/run/graph/stream.rs | 16 +- crates/fosr-gui/src/run/graph/view.rs | 14 +- crates/fosr-gui/src/run/tab.rs | 16 +- crates/fosr-gui/src/shared/config/file_ops.rs | 10 +- crates/fosr-gui/src/shared/config/model.rs | 4 + crates/fosr-gui/src/shared/config/parser.rs | 4 +- crates/fosr-gui/src/shared/config/state.rs | 11 +- .../fosr-gui/src/shared/constants/network.rs | 2 +- crates/fosr-gui/src/shared/file_io.rs | 6 + crates/fosr-gui/src/shared/widgets/helpers.rs | 4 +- .../src/shared/widgets/time_picker/button.rs | 22 +- .../src/shared/widgets/time_picker/popup.rs | 12 +- 41 files changed, 497 insertions(+), 506 deletions(-) rename crates/fosr-gui/src/{modules.rs => app_modules.rs} (100%) rename crates/fosr-gui/src/run/graph/{utils.rs => graph_layout.rs} (68%) diff --git a/crates/fosr-gui/src/about_tab.rs b/crates/fosr-gui/src/about_tab.rs index 41df3a16..7dcd267f 100644 --- a/crates/fosr-gui/src/about_tab.rs +++ b/crates/fosr-gui/src/about_tab.rs @@ -4,7 +4,8 @@ use crate::shared::assets::IMG_LOGO; use crate::shared::constants::ui::{LOGO_MAX_WIDTH, SPACING_LG, SPACING_SM, SPACING_XXL}; use eframe::egui; -pub fn show_about_tab_content(ui: &mut egui::Ui) { +/// Renders the About tab with application info, usage guide, and repository links. +pub fn render_about_tab(ui: &mut egui::Ui) { ui.vertical_centered(|ui| { ui.add(egui::Image::new(IMG_LOGO).max_width(LOGO_MAX_WIDTH)); }); diff --git a/crates/fosr-gui/src/app/mod.rs b/crates/fosr-gui/src/app/mod.rs index e27d2327..fa4e7b44 100644 --- a/crates/fosr-gui/src/app/mod.rs +++ b/crates/fosr-gui/src/app/mod.rs @@ -8,27 +8,28 @@ mod close_dialog; mod startup_modal; mod top_bar; -use crate::about_tab::show_about_tab_content; +use crate::about_tab::render_about_tab; use crate::config_editor::state::ConfigurationTabState; -use crate::config_editor::tab::show_configuration_tab_content; +use crate::config_editor::tab::render_configuration_tab; use crate::run::state::RunTabState; -use crate::run::tab::show_run_tab_content; +use crate::run::tab::render_run_tab; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_LOGO, IMG_SERVER}; -use crate::shared::config::state::ConfigurationFileState; +use crate::shared::config::state::ConfigFileState; use crate::shared::constants::ui::{TOOLTIP_DELAY, ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN}; #[cfg(not(target_arch = "wasm32"))] use close_dialog::render_close_confirmation_dialog; use eframe::egui; use startup_modal::render_startup_modal; -use top_bar::{CurrentTab, TopBarState, render_top_bar}; +use top_bar::{AppTab, TopBarState, render_top_bar}; +/// Main application state managing tabs, configuration, and PCAP generation. #[derive(Default)] pub struct FosrApp { - current_tab: CurrentTab, + current_tab: AppTab, style_initialized: bool, images_preloaded: bool, zoom_factor: f32, - configuration_file_state: ConfigurationFileState, + config_file_state: ConfigFileState, configuration_tab_state: ConfigurationTabState, run_tab_state: RunTabState, /// Whether to show the close confirmation dialog @@ -109,7 +110,7 @@ impl FosrApp { let top_bar_state = TopBarState { current_tab: self.current_tab, zoom_factor: self.zoom_factor, - has_errors: self.configuration_file_state.has_errors, + has_errors: self.config_file_state.has_errors, }; let updated_state = render_top_bar(ctx, top_bar_state); self.current_tab = updated_state.current_tab; @@ -123,27 +124,27 @@ impl FosrApp { // Display the tab content depending on the currently select tab // Note: Run tab doesn't use ScrollArea as it has its own layout match self.current_tab { - CurrentTab::Run => { - show_run_tab_content( + AppTab::Run => { + render_run_tab( ui, &mut self.run_tab_state, - &mut self.configuration_file_state, + &mut self.config_file_state, ); } - CurrentTab::Configuration => { + AppTab::Configuration => { // Wrap in ScrollArea for vertical scrolling egui::ScrollArea::vertical().show(ui, |ui| { - show_configuration_tab_content( + render_configuration_tab( ui, &mut self.configuration_tab_state, - &mut self.configuration_file_state, + &mut self.config_file_state, ); }); } - CurrentTab::About => { + AppTab::About => { // Wrap in ScrollArea for vertical scrolling egui::ScrollArea::vertical().show(ui, |ui| { - show_about_tab_content(ui); + render_about_tab(ui); }); } } @@ -168,10 +169,10 @@ impl eframe::App for FosrApp { self.handle_close_confirmation(ctx); // Startup modal: choose configuration source - if !self.configuration_file_state.config_chosen { + if !self.config_file_state.config_chosen { // Render empty CentralPanel for background, then modal on top egui::CentralPanel::default().show(ctx, |_ui| {}); - render_startup_modal(ctx, &mut self.configuration_file_state); + render_startup_modal(ctx, &mut self.config_file_state); return; } diff --git a/crates/fosr-gui/src/app/startup_modal.rs b/crates/fosr-gui/src/app/startup_modal.rs index 56dfd948..1c4ae613 100644 --- a/crates/fosr-gui/src/app/startup_modal.rs +++ b/crates/fosr-gui/src/app/startup_modal.rs @@ -4,7 +4,7 @@ use crate::config_templates::{load_template, TEMPLATES}; #[cfg(target_arch = "wasm32")] use crate::shared::config::file_ops::poll_file_import; use crate::shared::config::file_ops::trigger_file_import; -use crate::shared::config::state::{ConfigurationFileState, StartupModalState}; +use crate::shared::config::state::{ConfigFileState, StartupModalStep}; use crate::shared::constants::colors::COLOR_TEXT_MUTED; use crate::shared::constants::ui::{ ICON_SIZE_LG, MODAL_WIDTH_MD, SPACING_LG, SPACING_SM, SPACING_XL, SPACING_XS, @@ -64,14 +64,16 @@ fn startup_card(ui: &mut egui::Ui, icon: &str, title: &str, description: &str) - response.clicked() } -pub fn render_startup_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { +/// Renders the startup modal for choosing a configuration source. +pub fn render_startup_modal(ctx: &egui::Context, state: &mut ConfigFileState) { match state.modal_state { - StartupModalState::Initial => render_initial_modal(ctx, state), - StartupModalState::TemplateSelection => render_template_selection_modal(ctx, state), + StartupModalStep::Initial => render_initial_modal(ctx, state), + StartupModalStep::TemplateSelection => render_template_selection_modal(ctx, state), } } -fn render_initial_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { +/// Renders the initial modal with options to use templates or import a file. +fn render_initial_modal(ctx: &egui::Context, state: &mut ConfigFileState) { // Use the same modal ID as template selection to avoid flicker when transitioning egui::Modal::new(egui::Id::new("startup_modal")).show(ctx, |ui| { ui.set_width(MODAL_WIDTH_MD); @@ -88,7 +90,7 @@ fn render_initial_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) "Default configuration", "Choose from preset templates\nfor different network types", ) { - state.modal_state = StartupModalState::TemplateSelection; + state.modal_state = StartupModalStep::TemplateSelection; } // Right: import file @@ -107,7 +109,8 @@ fn render_initial_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) }); } -fn render_template_selection_modal(ctx: &egui::Context, state: &mut ConfigurationFileState) { +/// Renders the template selection modal with preset network configurations. +fn render_template_selection_modal(ctx: &egui::Context, state: &mut ConfigFileState) { // Use the same modal ID as initial modal to avoid flicker when transitioning egui::Modal::new(egui::Id::new("startup_modal")).show(ctx, |ui| { ui.set_width(MODAL_WIDTH_MD); @@ -119,7 +122,7 @@ fn render_template_selection_modal(ctx: &egui::Context, state: &mut Configuratio .on_hover_text("Back") .clicked() { - state.modal_state = StartupModalState::Initial; + state.modal_state = StartupModalStep::Initial; } ui.heading("Choose a template"); }); diff --git a/crates/fosr-gui/src/app/top_bar.rs b/crates/fosr-gui/src/app/top_bar.rs index 21b0039b..458c8fbf 100644 --- a/crates/fosr-gui/src/app/top_bar.rs +++ b/crates/fosr-gui/src/app/top_bar.rs @@ -9,17 +9,19 @@ use crate::shared::constants::ui::{ use eframe::egui; use eframe::egui::global_theme_preference_switch; +/// Available tabs in the Fos-R application. #[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] -pub enum CurrentTab { +pub enum AppTab { #[default] Run, Configuration, About, } +/// State passed between the top bar and the main app for rendering. #[derive(Clone)] pub struct TopBarState { - pub current_tab: CurrentTab, + pub current_tab: AppTab, pub zoom_factor: f32, pub has_errors: bool, } @@ -32,12 +34,12 @@ fn render_tab_button( ui: &mut egui::Ui, text_size: f32, label: &str, - tab: CurrentTab, + tab: AppTab, is_selected: bool, is_enabled: bool, tooltip: &str, disabled_tooltip: &str, -) -> Option { +) -> Option { let button = egui::Button::new(egui::RichText::new(label).size(text_size)).selected(is_selected); let response = ui.add_enabled(is_enabled, button); @@ -60,7 +62,7 @@ fn render_config_tab_button( text_size: f32, has_errors: bool, is_selected: bool, -) -> Option { +) -> Option { let label = if has_errors { egui::RichText::new("⚠ Configuration") .color(COLOR_ERROR) @@ -76,7 +78,7 @@ fn render_config_tab_button( .on_hover_text("Edit the network configuration: hosts, interfaces, and services.") .clicked() { - Some(CurrentTab::Configuration) + Some(AppTab::Configuration) } else { None } @@ -86,7 +88,7 @@ fn render_config_tab_button( fn render_tab_buttons( ui: &mut egui::Ui, state: &TopBarState, -) -> Option { +) -> Option { let text_size = TEXT_SIZE_DEFAULT; let has_errors = state.has_errors; @@ -95,8 +97,8 @@ fn render_tab_buttons( ui, text_size, "Run", - CurrentTab::Run, - state.current_tab == CurrentTab::Run, + AppTab::Run, + state.current_tab == AppTab::Run, !has_errors, "Live preview and PCAP generation from the current configuration.", "Configuration is invalid. Fix errors in the Configuration tab to enable Run.", @@ -105,7 +107,7 @@ fn render_tab_buttons( } // Configuration tab (always enabled, shows warning icon on errors) - if let Some(tab) = render_config_tab_button(ui, text_size, has_errors, state.current_tab == CurrentTab::Configuration) { + if let Some(tab) = render_config_tab_button(ui, text_size, has_errors, state.current_tab == AppTab::Configuration) { return Some(tab); } @@ -114,8 +116,8 @@ fn render_tab_buttons( ui, text_size, "About", - CurrentTab::About, - state.current_tab == CurrentTab::About, + AppTab::About, + state.current_tab == AppTab::About, true, "About Fos-R and its authors.", "", @@ -157,6 +159,7 @@ fn render_zoom_controls(ui: &mut egui::Ui, ctx: &egui::Context) -> f32 { new_zoom } +/// Returns true if the browser is currently in fullscreen mode. #[cfg(target_arch = "wasm32")] fn is_fullscreen() -> bool { web_sys::window() @@ -165,6 +168,7 @@ fn is_fullscreen() -> bool { .is_some() } +/// Toggles browser fullscreen mode on or off. #[cfg(target_arch = "wasm32")] fn toggle_fullscreen(is_fullscreen: bool) { let Some(window) = web_sys::window() else { return }; @@ -177,6 +181,7 @@ fn toggle_fullscreen(is_fullscreen: bool) { } } +/// Renders the fullscreen toggle button for web builds. #[cfg(target_arch = "wasm32")] fn render_fullscreen_toggle(ui: &mut egui::Ui) { let fullscreen = is_fullscreen(); @@ -203,10 +208,6 @@ fn render_utility_buttons(ui: &mut egui::Ui, ctx: &egui::Context, state: &mut To state.zoom_factor = render_zoom_controls(ui, ctx); } -// ----------------------------------------------------------------------------- -// Public API -// ----------------------------------------------------------------------------- - /// Render the top bar with tabs and utility buttons. /// Returns the updated TopBarState. pub fn render_top_bar(ctx: &egui::Context, state: TopBarState) -> TopBarState { diff --git a/crates/fosr-gui/src/modules.rs b/crates/fosr-gui/src/app_modules.rs similarity index 100% rename from crates/fosr-gui/src/modules.rs rename to crates/fosr-gui/src/app_modules.rs diff --git a/crates/fosr-gui/src/config_editor/host.rs b/crates/fosr-gui/src/config_editor/host.rs index 61e1eee7..217bae97 100644 --- a/crates/fosr-gui/src/config_editor/host.rs +++ b/crates/fosr-gui/src/config_editor/host.rs @@ -11,7 +11,7 @@ use crate::shared::constants::network::HOST_USAGE_DEFAULT; use crate::shared::constants::ui::{ PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_MD, SPACING_SM, }; -use crate::shared::widgets::helpers::{edit_optional_string_singleline, info_icon_with_tooltip}; +use crate::shared::widgets::helpers::{render_optional_string_input, info_icon_with_tooltip}; use eframe::egui; use std::collections::HashMap; @@ -19,7 +19,7 @@ use std::collections::HashMap; /// /// Each host shows validation errors in the header if present. /// Supports adding new hosts (inserted at top) and removing hosts. -pub fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { +pub fn render_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { ui.horizontal(|ui| { ui.heading("Hosts"); if ui @@ -44,7 +44,7 @@ pub fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { let mut host_to_remove: Option = None; for (idx, host) in model.hosts.iter_mut().enumerate() { - ui_single_host(ui, idx, host, &ip_counts, &mac_counts, &mut host_to_remove); + render_host_card(ui, idx, host, &ip_counts, &mac_counts, &mut host_to_remove); ui.add_space(SPACING_MD); } @@ -58,7 +58,7 @@ pub fn ui_hosts_section(ui: &mut egui::Ui, model: &mut Configuration) { /// /// The header shows the host name and any validation errors. /// The body contains all editable fields organized in sections. -fn ui_single_host( +fn render_host_card( ui: &mut egui::Ui, index: usize, host: &mut Host, @@ -79,7 +79,7 @@ fn ui_single_host( .body(|ui| { render_host_basic_fields(ui, index, host); ui.separator(); - host_interfaces::ui_interfaces_section(ui, index, host, ip_counts, mac_counts); + host_interfaces::render_interfaces_section(ui, index, host, ip_counts, mac_counts); }); } @@ -93,7 +93,7 @@ fn render_host_header( ui.horizontal(|ui| { if errors.is_empty() { ui.label(display_name).on_hover_ui(|ui| { - ui_host_summary_tooltip(ui, host); + render_host_tooltip(ui, host); }); } else { let warning_icon = egui_material_icons::icons::ICON_WARNING; @@ -102,7 +102,7 @@ fn render_host_header( ui.colored_label(COLOR_ERROR, label_text) .on_hover_ui(|ui| { - ui_host_summary_tooltip(ui, host); + render_host_tooltip(ui, host); }); } }); @@ -126,7 +126,7 @@ fn render_host_delete_button( } /// Render the host summary tooltip showing type, protocols, and interfaces. -fn ui_host_summary_tooltip(ui: &mut egui::Ui, host: &Host) { +fn render_host_tooltip(ui: &mut egui::Ui, host: &Host) { let host_type = host.r#type.as_deref().unwrap_or(""); ui.horizontal(|ui| { ui.label("Type :"); @@ -146,28 +146,28 @@ fn ui_host_summary_tooltip(ui: &mut egui::Ui, host: &Host) { if host.interfaces.is_empty() { ui.label(" No interfaces configured."); } else { - for iface in &host.interfaces { - let services_str = if iface.services.is_empty() { + for interface in &host.interfaces { + let services_str = if interface.services.is_empty() { "no services".to_string() } else { - iface.services.join(", ") + interface.services.join(", ") }; - ui.label(format!(" • {} ({})", iface.ip_addr, services_str)); + ui.label(format!(" • {} ({})", interface.ip_addr, services_str)); } } } /// Render the basic host fields: OS, hostname, usage, type, and client protocols. fn render_host_basic_fields(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { - ui_host_os_selector(ui, host_idx, &mut host.os); - edit_optional_string_singleline(ui, "Hostname", &mut host.hostname, "host1"); - ui_host_usage_field(ui, host); - ui_host_type_selector(ui, host_idx, host); - ui_host_client_protocols(ui, host_idx, host); + render_os_dropdown(ui, host_idx, &mut host.os); + render_optional_string_input(ui, "Hostname", &mut host.hostname, "host1"); + render_usage_field(ui, host); + render_type_dropdown(ui, host_idx, host); + render_client_protocols(ui, host_idx, host); } /// Dropdown selector for the Operating System (Linux/Windows/none). -fn ui_host_os_selector(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option) { +fn render_os_dropdown(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option) { ui.horizontal(|ui| { ui.label("OS"); @@ -211,7 +211,7 @@ fn ui_host_os_selector(ui: &mut egui::Ui, host_idx: usize, host_os: &mut Option< /// /// Usage affects how much network traffic this host generates. /// Default is 1.0 (baseline), lower means less traffic, higher means more. -fn ui_host_usage_field(ui: &mut egui::Ui, host: &mut Host) { +fn render_usage_field(ui: &mut egui::Ui, host: &mut Host) { ui.horizontal(|ui| { ui.label("Usage"); info_icon_with_tooltip( @@ -252,7 +252,7 @@ fn ui_host_usage_field(ui: &mut egui::Ui, host: &mut Host) { /// - server: provides services to other hosts /// - user: consumes services from servers /// - auto: determined based on whether services are defined -fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { +fn render_type_dropdown(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { ui.horizontal(|ui| { ui.label("Type"); info_icon_with_tooltip( @@ -301,7 +301,7 @@ fn ui_host_type_selector(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { /// /// Shows a popup with all available protocols from KNOWN_SERVICES, /// filtered by search text. Selected protocols appear as removable chips. -fn ui_host_client_protocols(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { +fn render_client_protocols(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { ui.horizontal(|ui| { ui.label("Client protocols"); info_icon_with_tooltip(ui, "Specify what services the host is a client of."); @@ -329,7 +329,7 @@ fn render_protocol_popup( ui.set_min_width(POPUP_MIN_WIDTH); // Search field with auto-focus - let search_id = ui.make_persistent_id(("proto_search", host_idx)); + let search_id = ui.make_persistent_id(("protocol_search", host_idx)); let mut search_text = ui.data_mut(|d| d.get_temp::(search_id).unwrap_or_default()); let search_resp = @@ -381,20 +381,20 @@ fn render_protocol_popup( /// Render selected protocols as removable chips. fn render_protocol_chips(ui: &mut egui::Ui, host: &mut Host) { - let mut proto_to_remove: Option = None; + let mut protocol_to_remove: Option = None; - for (p_idx, proto) in host.client.iter().enumerate() { - let btn_text = format!("{} {}", proto, egui_material_icons::icons::ICON_CLEAR); + for (protocol_idx, protocol) in host.client.iter().enumerate() { + let button_text = format!("{} {}", protocol, egui_material_icons::icons::ICON_CLEAR); if ui - .button(btn_text) + .button(button_text) .on_hover_text("Remove protocol") .clicked() { - proto_to_remove = Some(p_idx); + protocol_to_remove = Some(protocol_idx); } } - if let Some(idx) = proto_to_remove { + if let Some(idx) = protocol_to_remove { host.client.remove(idx); } } @@ -411,9 +411,9 @@ fn host_display_name(host: &Host) -> String { } // Fall back to first interface IP - if let Some(iface) = host.interfaces.first() { - if !iface.ip_addr.trim().is_empty() { - return iface.ip_addr.clone(); + if let Some(interface) = host.interfaces.first() { + if !interface.ip_addr.trim().is_empty() { + return interface.ip_addr.clone(); } } diff --git a/crates/fosr-gui/src/config_editor/host_interfaces.rs b/crates/fosr-gui/src/config_editor/host_interfaces.rs index 7fb533d6..4cc86904 100644 --- a/crates/fosr-gui/src/config_editor/host_interfaces.rs +++ b/crates/fosr-gui/src/config_editor/host_interfaces.rs @@ -7,12 +7,12 @@ use crate::shared::constants::network::{ IP_LOCAL_MAX, IP_LOCAL_MIN, MAC_ADDRESS_BYTES, MAC_LOCAL_BIT, MAC_LOCAL_MASK, }; use crate::shared::constants::ui::{SPACING_MD, SPACING_SM}; -use crate::shared::widgets::helpers::{edit_optional_string_singleline, required_label}; +use crate::shared::widgets::helpers::{render_optional_string_input, required_label}; use eframe::egui; use std::collections::HashMap; /// Scans all interfaces to find the next available IP in 192.168.0.x -fn next_free_ip(ip_counts: &HashMap) -> Option { +fn find_available_ip(ip_counts: &HashMap) -> Option { for x in IP_LOCAL_MIN..=IP_LOCAL_MAX { let candidate = format!("192.168.0.{x}"); if !ip_counts.contains_key(&candidate) { @@ -23,7 +23,7 @@ fn next_free_ip(ip_counts: &HashMap) -> Option { } /// Interface section rendering -pub fn ui_interfaces_section( +pub fn render_interfaces_section( ui: &mut egui::Ui, host_idx: usize, host: &mut Host, @@ -37,7 +37,7 @@ pub fn ui_interfaces_section( .on_hover_text("Add interface") .clicked() { - if let Some(ip) = next_free_ip(ip_counts) { + if let Some(ip) = find_available_ip(ip_counts) { host.interfaces.push(Interface { ip_addr: ip, mac_addr: Some(generate_mac_until_unique(mac_counts)), @@ -54,22 +54,22 @@ pub fn ui_interfaces_section( return; } - let mut iface_to_remove: Option = None; + let mut interface_to_remove: Option = None; - for (if_idx, iface) in host.interfaces.iter_mut().enumerate() { - ui_single_interface( + for (interface_idx, interface) in host.interfaces.iter_mut().enumerate() { + render_interface_card( ui, host_idx, - if_idx, - iface, + interface_idx, + interface, ip_counts, mac_counts, - &mut iface_to_remove, + &mut interface_to_remove, ); ui.add_space(SPACING_MD); } - if let Some(idx) = iface_to_remove { + if let Some(idx) = interface_to_remove { host.interfaces.remove(idx); } } @@ -77,19 +77,19 @@ pub fn ui_interfaces_section( /// Render a single interface as a collapsible card. /// /// Shows IP address in header, with editable fields for IP, MAC, and services in the body. -fn ui_single_interface( +fn render_interface_card( ui: &mut egui::Ui, host_idx: usize, - if_idx: usize, - iface: &mut Interface, + interface_idx: usize, + interface: &mut Interface, ip_counts: &HashMap, mac_counts: &HashMap, remove_request: &mut Option, ) { - let ip_label = iface.ip_addr.clone(); - let id = ui.make_persistent_id(("iface", host_idx, if_idx)); + let ip_label = interface.ip_addr.clone(); + let id = ui.make_persistent_id(("interface", host_idx, interface_idx)); - egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, if_idx == 0) + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, interface_idx == 0) .show_header(ui, |ui| { ui.label(format!("Interface — {ip_label}")); @@ -99,12 +99,12 @@ fn ui_single_interface( .on_hover_text("Remove interface") .clicked() { - *remove_request = Some(if_idx); + *remove_request = Some(interface_idx); } }); }) .body(|ui| { - render_interface_fields(ui, host_idx, if_idx, iface, ip_counts, mac_counts); + render_interface_fields(ui, host_idx, interface_idx, interface, ip_counts, mac_counts); }); } @@ -112,8 +112,8 @@ fn ui_single_interface( fn render_interface_fields( ui: &mut egui::Ui, host_idx: usize, - if_idx: usize, - iface: &mut Interface, + interface_idx: usize, + interface: &mut Interface, ip_counts: &HashMap, mac_counts: &HashMap, ) { @@ -122,26 +122,26 @@ fn render_interface_fields( // IP address field with duplicate warning ui.horizontal(|ui| { 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.text_edit_singleline(&mut interface.ip_addr); + if ip_counts.get(&interface.ip_addr).copied().unwrap_or(0) > 1 { ui.colored_label(COLOR_ERROR, "IP already in use"); } }); // MAC address field with duplicate warning - edit_optional_string_singleline(ui, "MAC", &mut iface.mac_addr, "00:14:2A:3F:47:D8"); - if let Some(mac) = &iface.mac_addr { + render_optional_string_input(ui, "MAC", &mut interface.mac_addr, "00:14:2A:3F:47:D8"); + if let Some(mac) = &interface.mac_addr { if mac_counts.get(mac).copied().unwrap_or(0) > 1 { ui.colored_label(COLOR_ERROR, "MAC already in use"); } } // Services section - host_services::ui_services_section(ui, host_idx, if_idx, iface); + host_services::render_services_section(ui, host_idx, interface_idx, interface); } /// Generate a random MAC address with the locally administered bit set. -fn random_mac() -> String { +fn generate_local_mac() -> String { let mut bytes: [u8; MAC_ADDRESS_BYTES] = rand::random(); // Forcing local MAC @@ -153,9 +153,10 @@ fn random_mac() -> String { ) } +/// Generate a unique random MAC address, retrying until one not in use is found. fn generate_mac_until_unique(mac_counts: &HashMap) -> String { loop { - let mac = random_mac(); + let mac = generate_local_mac(); if !mac_counts.contains_key(&mac) { return mac; diff --git a/crates/fosr-gui/src/config_editor/host_services.rs b/crates/fosr-gui/src/config_editor/host_services.rs index f558c1c5..b0c10e8f 100644 --- a/crates/fosr-gui/src/config_editor/host_services.rs +++ b/crates/fosr-gui/src/config_editor/host_services.rs @@ -1,7 +1,7 @@ //! Service editing UI: HTTP, SSH, DNS, etc. with custom port support. use crate::shared::config::model::Interface; -use crate::shared::constants::network::{PORT_DEFAULT_UNKNOWN, PORT_MAX, PORT_MIN}; +use crate::shared::constants::network::{PORT_UNSPECIFIED, PORT_MAX, PORT_MIN}; use crate::shared::constants::ui::{ PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_SM, SPACING_XS, }; @@ -37,32 +37,32 @@ fn format_service(name: &str, port: Option) -> String { /// Look up the default port for a known service name. /// -/// Returns `PORT_DEFAULT_UNKNOWN` (0) if the service is not in KNOWN_SERVICES. +/// Returns the default port for known services. fn default_port_for_service(name: &str) -> u16 { KNOWN_SERVICES .iter() .find(|(n, _)| *n == name) .and_then(|(_, p)| *p) - .unwrap_or(PORT_DEFAULT_UNKNOWN) + .unwrap_or(PORT_UNSPECIFIED) } /// Service section rendering -pub fn ui_services_section( +pub fn render_services_section( ui: &mut egui::Ui, host_idx: usize, - iface_idx: usize, - iface: &mut Interface, + interface_idx: usize, + interface: &mut Interface, ) { - let svc_count = iface.services.len(); - let id = ui.make_persistent_id(("services", host_idx, iface_idx)); + let service_count = interface.services.len(); + let id = ui.make_persistent_id(("services", host_idx, interface_idx)); egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false) .show_header(ui, |ui| { - ui.label(format!("Services ({svc_count})")); + ui.label(format!("Services ({service_count})")); info_icon_with_tooltip(ui, "The list of available services provided by the host."); }) .body(|ui| { - let popup_id = ui.make_persistent_id(("svc_popup", host_idx, iface_idx)); + let popup_id = ui.make_persistent_id(("service_popup", host_idx, interface_idx)); let add_btn_resp = ui .button(format!("{} Add", egui_material_icons::icons::ICON_ADD)) .on_hover_text("Add service"); @@ -72,7 +72,7 @@ pub fn ui_services_section( .show(|ui| { ui.set_min_width(POPUP_MIN_WIDTH); - let search_id = ui.make_persistent_id(("svc_search", host_idx, iface_idx)); + let search_id = ui.make_persistent_id(("service_search", host_idx, interface_idx)); let mut search_text = ui.data_mut(|d| d.get_temp::(search_id).unwrap_or_default()); @@ -96,7 +96,7 @@ pub fn ui_services_section( let mut any_shown = false; for (name, default_port) in KNOWN_SERVICES { - let already_present = iface.services.iter().any(|s| { + let already_present = interface.services.iter().any(|s| { let (sname, _) = parse_service(s); &sname == name }); @@ -106,7 +106,7 @@ pub fn ui_services_section( { any_shown = true; if ui.selectable_label(false, *name).clicked() { - iface.services.push(format_service(name, *default_port)); + interface.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); } @@ -125,53 +125,53 @@ pub fn ui_services_section( ui.add_space(SPACING_SM); - let mut svc_to_remove: Option = None; + let mut service_to_remove: Option = None; - for (svc_idx, svc_raw) in iface.services.iter_mut().enumerate() { - ui_single_service( + for (service_idx, service_raw) in interface.services.iter_mut().enumerate() { + render_service_row( ui, host_idx, - iface_idx, - svc_idx, - svc_raw, - &mut svc_to_remove, + interface_idx, + service_idx, + service_raw, + &mut service_to_remove, ); ui.add_space(SPACING_XS); } - if let Some(idx) = svc_to_remove { - iface.services.remove(idx); + if let Some(idx) = service_to_remove { + interface.services.remove(idx); } }); } /// Render a single service row with name, remove button, and optional custom port. -fn ui_single_service( +fn render_service_row( ui: &mut egui::Ui, host_idx: usize, - iface_idx: usize, - svc_idx: usize, - svc_raw: &mut String, + interface_idx: usize, + service_idx: usize, + service_raw: &mut String, remove_request: &mut Option, ) { - let (svc_name, mut svc_port) = parse_service(svc_raw); - let default_port = default_port_for_service(&svc_name); + let (service_name, mut service_port) = parse_service(service_raw); + let default_port = default_port_for_service(&service_name); // Track whether custom port mode is enabled (persists across frames) - 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 custom_port_id = ui.make_persistent_id(("custom_port", host_idx, interface_idx, service_idx)); + let is_custom_by_default = service_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(is_custom_by_default)); ui.horizontal(|ui| { // Remove button with service name - let btn_text = format!("{} {}", svc_name, egui_material_icons::icons::ICON_CLEAR); + let btn_text = format!("{} {}", service_name, egui_material_icons::icons::ICON_CLEAR); if ui .button(btn_text) .on_hover_text("Remove service") .clicked() { - *remove_request = Some(svc_idx); + *remove_request = Some(service_idx); } // Custom port toggle @@ -179,18 +179,18 @@ fn ui_single_service( .checkbox(&mut custom_port_enabled, "Custom port") .changed() { - svc_port = resolve_port_after_toggle(custom_port_enabled, default_port); + service_port = resolve_port_after_toggle(custom_port_enabled, default_port); } // Port value editor or default display - svc_port = render_port_editor(ui, custom_port_enabled, svc_port, default_port); + service_port = render_port_editor(ui, custom_port_enabled, service_port, default_port); }); // Persist custom port state ui.data_mut(|d| d.insert_temp(custom_port_id, custom_port_enabled)); // Update the service string - *svc_raw = format_service(&svc_name, svc_port); + *service_raw = format_service(&service_name, service_port); } /// Resolve port value after toggling custom port checkbox. @@ -200,7 +200,7 @@ fn ui_single_service( fn resolve_port_after_toggle(custom_enabled: bool, default_port: u16) -> Option { if custom_enabled { None // Will be set by the DragValue - } else if default_port == PORT_DEFAULT_UNKNOWN { + } else if default_port == PORT_UNSPECIFIED { None } else { Some(default_port) @@ -236,7 +236,7 @@ fn render_port_editor( false, egui::Label::new(egui::RichText::new(format!("(default: {default_port})")).weak()), ); - if default_port == PORT_DEFAULT_UNKNOWN { + if default_port == PORT_UNSPECIFIED { None } else { Some(default_port) diff --git a/crates/fosr-gui/src/config_editor/host_validation.rs b/crates/fosr-gui/src/config_editor/host_validation.rs index 52d0d38d..af375e31 100644 --- a/crates/fosr-gui/src/config_editor/host_validation.rs +++ b/crates/fosr-gui/src/config_editor/host_validation.rs @@ -15,9 +15,9 @@ pub fn count_addresses(config: &Configuration) -> AddressCounts { let mut mac_counts: HashMap = HashMap::new(); for host in &config.hosts { - for iface in &host.interfaces { - *ip_counts.entry(iface.ip_addr.clone()).or_insert(0) += 1; - if let Some(mac) = &iface.mac_addr { + for interface in &host.interfaces { + *ip_counts.entry(interface.ip_addr.clone()).or_insert(0) += 1; + if let Some(mac) = &interface.mac_addr { *mac_counts.entry(mac.clone()).or_insert(0) += 1; } } @@ -38,15 +38,15 @@ pub fn validate_host( errors.push("Missing interface".to_string()); } - for iface in &host.interfaces { - if iface.ip_addr.parse::().is_err() { + for interface in &host.interfaces { + if interface.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 { + } else if ip_counts.get(&interface.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) { + if let Some(mac) = &interface.mac_addr { + if !validate_mac_format(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()); @@ -85,7 +85,7 @@ pub fn has_model_errors(model: &Configuration) -> bool { } /// Check MAC format (ex: 00:14:2A:3F:47:D8) -fn is_valid_mac(mac: &str) -> bool { +fn validate_mac_format(mac: &str) -> bool { let parts: Vec<&str> = mac.split(':').collect(); if parts.len() != MAC_ADDRESS_PARTS { return false; diff --git a/crates/fosr-gui/src/config_editor/tab.rs b/crates/fosr-gui/src/config_editor/tab.rs index 6c348225..a198c9ca 100644 --- a/crates/fosr-gui/src/config_editor/tab.rs +++ b/crates/fosr-gui/src/config_editor/tab.rs @@ -2,31 +2,31 @@ use crate::config_editor::state::ConfigurationTabState; use crate::config_editor::{host, host_validation, yaml_editor}; -use crate::config_editor::toolbar::configuration_toolbar; +use crate::config_editor::toolbar::render_configuration_toolbar; use crate::shared::config::file_ops::load_config_file_contents; use crate::shared::config::model::Configuration; -use crate::shared::config::state::ConfigurationFileState; +use crate::shared::config::state::ConfigFileState; use crate::shared::constants::colors::{COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING}; use crate::shared::constants::ui::{SPACING_MD, TEXT_EDIT_DEFAULT_ROWS}; use crate::shared::widgets::helpers::{ - edit_optional_string_multiline, edit_optional_string_singleline, required_label, + render_optional_text_area, render_optional_string_input, required_label, }; use eframe::egui; /// The main tab component -pub fn show_configuration_tab_content( +pub fn render_configuration_tab( ui: &mut egui::Ui, tab_state: &mut ConfigurationTabState, - file_state: &mut ConfigurationFileState, + file_state: &mut ConfigFileState, ) { // 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_toolbar(ui, tab_state, file_state); + render_configuration_toolbar(ui, tab_state, file_state); - ui_parsing_status(ui, file_state); + render_parsing_status(ui, file_state); if file_state.config_chosen { ui.separator(); @@ -34,7 +34,7 @@ pub fn show_configuration_tab_content( if !tab_state.is_code_mode { // Visual mode if let Some(model) = file_state.config_model.as_mut() { - host::ui_hosts_section(ui, model); + host::render_hosts_section(ui, model); ui.separator(); let meta_id = ui.make_persistent_id("metadata_section"); egui::collapsing_header::CollapsingState::load_with_default_open( @@ -46,13 +46,13 @@ pub fn show_configuration_tab_content( ui.heading("Metadata"); }) .body(|ui| { - ui_metadata(ui, model); + render_metadata_section(ui, model); }); } sync_model_to_yaml_state(file_state); } else { - yaml_editor::ui_yaml_editor(ui, file_state); + yaml_editor::render_yaml_editor(ui, file_state); } // Update error flag (parse errors + host validation errors) @@ -69,7 +69,7 @@ pub fn show_configuration_tab_content( /// /// Serializes the current model to YAML string and compares against /// the clean snapshot to detect unsaved changes. -fn sync_model_to_yaml_state(state: &mut ConfigurationFileState) { +fn sync_model_to_yaml_state(state: &mut ConfigFileState) { let Some(model) = &state.config_model else { return; }; @@ -94,7 +94,7 @@ fn sync_model_to_yaml_state(state: &mut ConfigurationFileState) { } /// Status & Feedback -fn ui_parsing_status(ui: &mut egui::Ui, state: &ConfigurationFileState) { +fn render_parsing_status(ui: &mut egui::Ui, state: &ConfigFileState) { if state.picked_config_file.is_some() { if let Some(err) = &state.config_error { ui.colored_label(COLOR_ERROR, "YAML parsing failed:"); @@ -109,7 +109,7 @@ fn ui_parsing_status(ui: &mut egui::Ui, state: &ConfigurationFileState) { } /// Metadata rendering -fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { +fn render_metadata_section(ui: &mut egui::Ui, model: &mut Configuration) { ui.add_space(SPACING_MD); // Title @@ -119,7 +119,7 @@ fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { ui.text_edit_singleline(title); }); - edit_optional_string_multiline( + render_optional_text_area( ui, "Description", &mut model.metadata.desc, @@ -127,7 +127,7 @@ fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { TEXT_EDIT_DEFAULT_ROWS, ); - edit_optional_string_singleline(ui, "Author", &mut model.metadata.author, "Jane Doe"); + render_optional_string_input(ui, "Author", &mut model.metadata.author, "Jane Doe"); - edit_optional_string_singleline(ui, "Version", &mut model.metadata.version, "0.1.0"); + render_optional_string_input(ui, "Version", &mut model.metadata.version, "0.1.0"); } diff --git a/crates/fosr-gui/src/config_editor/toolbar.rs b/crates/fosr-gui/src/config_editor/toolbar.rs index d4a414e3..e17859f3 100644 --- a/crates/fosr-gui/src/config_editor/toolbar.rs +++ b/crates/fosr-gui/src/config_editor/toolbar.rs @@ -3,7 +3,7 @@ use crate::config_editor::state::ConfigurationTabState; use crate::config_templates::{load_template, TEMPLATES}; use crate::shared::config::file_ops::{enforce_metadata_defaults, trigger_file_import}; -use crate::shared::config::state::ConfigurationFileState; +use crate::shared::config::state::ConfigFileState; use crate::shared::constants::colors::COLOR_WARNING; use crate::shared::constants::ui::SPACING_LG; #[cfg(not(target_arch = "wasm32"))] @@ -20,18 +20,18 @@ use crate::shared::config::file_ops::poll_file_import; /// /// Displays a file picker button, a template selection dropdown menu, /// the selected file name and a Visual/Code mode toggle. -pub fn configuration_toolbar( +pub fn render_configuration_toolbar( ui: &mut egui::Ui, tab_state: &mut ConfigurationTabState, - state: &mut ConfigurationFileState, + state: &mut ConfigFileState, ) { ui.horizontal(|ui| { ui.label("Configuration file:"); - file_import_button(ui, state); - template_menu_button(ui, state); - file_save_button(ui, state); - filename_display(ui, state); - mode_toggle(ui, tab_state); + render_file_import_button(ui, state); + render_template_menu_button(ui, state); + render_file_save_button(ui, state); + render_filename(ui, state); + render_mode_toggle_button(ui, tab_state); }); } @@ -39,7 +39,7 @@ pub fn configuration_toolbar( /// /// Opens a file picker dialog to select a configuration file. /// On WASM, polls the async file picker result. -fn file_import_button(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { +fn render_file_import_button(ui: &mut egui::Ui, state: &mut ConfigFileState) { if ui .button(egui_material_icons::icons::ICON_FOLDER_OPEN) .on_hover_text("Select a configuration file") @@ -56,7 +56,7 @@ fn file_import_button(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { /// /// Dropdown menu listing available configuration templates. /// Clicking a template loads it into the editor. -fn template_menu_button(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { +fn render_template_menu_button(ui: &mut egui::Ui, state: &mut ConfigFileState) { let template_menu = ui.menu_button(egui_material_icons::icons::ICON_DESCRIPTION, |menu_ui| { for template in TEMPLATES { @@ -77,7 +77,7 @@ fn template_menu_button(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { /// Only visible when a configuration is loaded. /// Serializes the config to YAML and triggers a file save dialog. /// Updates metadata with current date before saving. -fn file_save_button(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { +fn render_file_save_button(ui: &mut egui::Ui, state: &mut ConfigFileState) { if state.config_file_content.is_none() { return; } @@ -131,7 +131,7 @@ fn file_save_button(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { /// Shows the selected file name or template name. /// Displays a warning icon when there are unsaved changes. /// On desktop, shows the full path on hover. -fn filename_display(ui: &mut egui::Ui, state: &ConfigurationFileState) { +fn render_filename(ui: &mut egui::Ui, state: &ConfigFileState) { let filename = if let Some(file) = &state.picked_config_file { file.file_name() } else if let Some(template_id) = &state.loaded_template_id { @@ -168,7 +168,7 @@ fn render_filename_with_status(ui: &mut egui::Ui, filename: &str, is_dirty: bool /// Mode toggle for switching between Visual and Code editing modes. /// /// Positioned on the right side of the toolbar using RTL layout. -fn mode_toggle(ui: &mut egui::Ui, tab_state: &mut ConfigurationTabState) { +fn render_mode_toggle_button(ui: &mut egui::Ui, tab_state: &mut ConfigurationTabState) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(SPACING_LG); // In RTL layout, rendering order is reversed, diff --git a/crates/fosr-gui/src/config_editor/yaml_editor.rs b/crates/fosr-gui/src/config_editor/yaml_editor.rs index a039dd57..648d8319 100644 --- a/crates/fosr-gui/src/config_editor/yaml_editor.rs +++ b/crates/fosr-gui/src/config_editor/yaml_editor.rs @@ -1,7 +1,7 @@ //! YAML editor with syntax highlighting and error line markers. use crate::shared::config::parser::parse_config_yaml; -use crate::shared::config::state::ConfigurationFileState; +use crate::shared::config::state::ConfigFileState; use crate::shared::constants::colors::COLOR_ERROR; use crate::shared::constants::ui::{YAML_EDITOR_ROWS, YAML_GUTTER_PADDING, YAML_ICON_COL_WIDTH}; use eframe::egui; @@ -58,7 +58,7 @@ impl GutterMetrics { } /// Editable YAML Editor with syntax highlighting and error markers. -pub fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { +pub fn render_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigFileState) { if state.config_file_content.is_none() { ui.label("No configuration file selected."); return; @@ -145,7 +145,7 @@ pub fn ui_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigurationFileState) { } /// Render error banner and return parsed error line numbers. -fn render_error_banner(ui: &mut egui::Ui, state: &ConfigurationFileState) -> Vec { +fn render_error_banner(ui: &mut egui::Ui, state: &ConfigFileState) -> Vec { if let Some(err) = &state.config_error { ui.colored_label( COLOR_ERROR, diff --git a/crates/fosr-gui/src/config_templates/mod.rs b/crates/fosr-gui/src/config_templates/mod.rs index 0d026ee6..8b76260b 100644 --- a/crates/fosr-gui/src/config_templates/mod.rs +++ b/crates/fosr-gui/src/config_templates/mod.rs @@ -1,7 +1,7 @@ //! Predefined configuration templates: Home, Enterprise, Datacenter. use crate::shared::config::model::Configuration; -use crate::shared::config::state::ConfigurationFileState; +use crate::shared::config::state::ConfigFileState; use egui_material_icons::icons; /// A predefined configuration template. @@ -43,7 +43,7 @@ pub static TEMPLATES: &[Template] = &[ /// /// Parses the template YAML first, then applies state changes only on success. /// On parse failure, sets the error without modifying the existing model. -pub fn load_template(state: &mut ConfigurationFileState, template: &Template) { +pub fn load_template(state: &mut ConfigFileState, template: &Template) { // Parse first, fail-fast if invalid let model = match serde_yaml::from_str::(template.yaml) { Ok(model) => model, @@ -59,7 +59,7 @@ pub fn load_template(state: &mut ConfigurationFileState, template: &Template) { /// Reset state and apply a successfully-parsed template. fn apply_template_to_state( - state: &mut ConfigurationFileState, + state: &mut ConfigFileState, template: &Template, model: Configuration, ) { diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index 77b16567..3deeb358 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -2,7 +2,7 @@ //! Initializes eframe and starts the app in the browser canvas. #![cfg(target_arch = "wasm32")] -include!("modules.rs"); +include!("app_modules.rs"); use app::FosrApp; use eframe::wasm_bindgen::JsCast; diff --git a/crates/fosr-gui/src/main.rs b/crates/fosr-gui/src/main.rs index 26680e79..2127f663 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -1,7 +1,7 @@ //! Entry point for the Fos-R GUI application. //! Compiles natively for desktop; delegates to lib.rs for WASM builds. -include!("modules.rs"); +include!("app_modules.rs"); // Desktop: native compilation #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/fosr-gui/src/run/generation/bottom_panel.rs b/crates/fosr-gui/src/run/generation/bottom_panel.rs index 2c398b16..e4f5de27 100644 --- a/crates/fosr-gui/src/run/generation/bottom_panel.rs +++ b/crates/fosr-gui/src/run/generation/bottom_panel.rs @@ -3,13 +3,13 @@ //! Contains the action bar with Generate/Stop/Save buttons and //! the expandable generation options panel. -use super::options::show_generation_options; +use super::options::render_generation_options; use super::process::start_generation; use super::validation::first_invalid_param; #[cfg(not(target_arch = "wasm32"))] use super::wireshark::open_in_wireshark; use crate::run::state::RunTabState; -use crate::shared::config::state::ConfigurationFileState; +use crate::shared::config::state::ConfigFileState; use crate::shared::constants::colors::{COLOR_ERROR, COLOR_STOP, COLOR_SUCCESS}; use crate::shared::constants::ui::{ BOTTOM_BAR_INNER_MARGIN, BUTTON_HEIGHT, BUTTON_MIN_WIDTH_LG, BUTTON_MIN_WIDTH_SM, @@ -27,19 +27,19 @@ use std::sync::atomic::Ordering; /// The panel consists of: /// - Options panel (shown when expanded): generation parameters /// - Action bar (always visible): Generate/Stop/Save buttons, progress bar -pub fn show_bottom_panel( +pub fn render_bottom_panel( ctx: &egui::Context, state: &mut RunTabState, - configuration_file_state: &ConfigurationFileState, + configuration_file_state: &ConfigFileState, ) { if state.panel_open { - show_options_panel(ctx, state); + render_options_panel(ctx, state); } - show_action_bar(ctx, state, configuration_file_state); + render_action_bar(ctx, state, configuration_file_state); } /// Options panel shown above the action bar when expanded. -fn show_options_panel(ctx: &egui::Context, state: &mut RunTabState) { +fn render_options_panel(ctx: &egui::Context, state: &mut RunTabState) { egui::TopBottomPanel::bottom("run_options_panel") .frame( egui::Frame::side_top_panel(&ctx.style()) @@ -51,15 +51,15 @@ fn show_options_panel(ctx: &egui::Context, state: &mut RunTabState) { ) .resizable(false) .show(ctx, |ui| { - show_generation_options(ui, state); + render_generation_options(ui, state); }); } /// Action bar with Generate/Stop/Save buttons and progress indicators. -fn show_action_bar( +fn render_action_bar( ctx: &egui::Context, state: &mut RunTabState, - configuration_file_state: &ConfigurationFileState, + configuration_file_state: &ConfigFileState, ) { egui::TopBottomPanel::bottom("run_bottom_bar") .frame( @@ -76,13 +76,13 @@ fn show_action_bar( ui.horizontal(|ui| { // Left side: action buttons if !is_generating { - show_generate_button(ui, state, configuration_file_state, ctx, can_generate); + render_generate_button(ui, state, configuration_file_state, ctx, can_generate); } if is_generating { - show_stop_button(ui, state); + render_stop_button(ui, state); } if is_complete { - show_completion_buttons(ui, state); + render_completion_buttons(ui, state); } if let Some(error) = &state.generation.error { ui.colored_label(COLOR_ERROR, error); @@ -90,12 +90,12 @@ fn show_action_bar( // Right side: options toggle, throughput, progress ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - show_options_toggle(ui, state); + render_options_toggle(ui, state); if is_complete { - show_throughput(ui, state); + render_throughput(ui, state); } if is_generating { - show_progress_bar(ui, state); + render_progress_bar(ui, state); } }); }); @@ -103,10 +103,10 @@ fn show_action_bar( } /// Generate button with accent color. -fn show_generate_button( +fn render_generate_button( ui: &mut egui::Ui, state: &mut RunTabState, - configuration_file_state: &ConfigurationFileState, + configuration_file_state: &ConfigFileState, ctx: &egui::Context, can_generate: bool, ) { @@ -117,10 +117,10 @@ fn show_generate_button( "{} Generate", egui_material_icons::icons::ICON_PLAY_ARROW )) - .size(TEXT_SIZE_MD), + .size(TEXT_SIZE_MD), ) - .fill(accent) - .min_size(egui::vec2(BUTTON_MIN_WIDTH_LG, BUTTON_HEIGHT)); + .fill(accent) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_LG, BUTTON_HEIGHT)); if ui.add(button).on_hover_text("Generate PCAP from configuration").clicked() { start_generation(state, configuration_file_state, ctx); @@ -129,13 +129,13 @@ fn show_generate_button( } /// Stop button to cancel ongoing generation. -fn show_stop_button(ui: &mut egui::Ui, state: &mut RunTabState) { +fn render_stop_button(ui: &mut egui::Ui, state: &mut RunTabState) { let button = egui::Button::new( egui::RichText::new(format!("{} Stop", egui_material_icons::icons::ICON_STOP)) .size(TEXT_SIZE_MD), ) - .fill(COLOR_STOP) - .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); + .fill(COLOR_STOP) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); if ui.add(button).on_hover_text("Cancel generation").clicked() { state.generation.cancelled.store(true, Ordering::Relaxed); @@ -147,14 +147,14 @@ fn show_stop_button(ui: &mut egui::Ui, state: &mut RunTabState) { } /// Save and Wireshark buttons shown when generation is complete. -fn show_completion_buttons(ui: &mut egui::Ui, state: &mut RunTabState) { - show_save_button(ui, state); +fn render_completion_buttons(ui: &mut egui::Ui, state: &mut RunTabState) { + render_save_button(ui, state); #[cfg(not(target_arch = "wasm32"))] - show_wireshark_button(ui, state); + render_wireshark_button(ui, state); } /// Save/Download button for the generated PCAP. -fn show_save_button(ui: &mut egui::Ui, state: &mut RunTabState) { +fn render_save_button(ui: &mut egui::Ui, state: &mut RunTabState) { #[cfg(not(target_arch = "wasm32"))] let save_text = format!("{} Save", egui_material_icons::icons::ICON_SAVE); #[cfg(target_arch = "wasm32")] @@ -210,12 +210,12 @@ fn save_pcap_wasm(pcap_bytes: Option>, file_name: &str) { /// Wireshark button (native only) to open PCAP in external tool. #[cfg(not(target_arch = "wasm32"))] -fn show_wireshark_button(ui: &mut egui::Ui, state: &mut RunTabState) { +fn render_wireshark_button(ui: &mut egui::Ui, state: &mut RunTabState) { let button = egui::Button::new( egui::RichText::new(format!("{} Open", egui_material_icons::icons::ICON_LAN)) .size(TEXT_SIZE_MD), ) - .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); let response = ui.add_enabled(state.generation.wireshark_available, button); let response = if state.generation.wireshark_available { @@ -238,7 +238,7 @@ fn show_wireshark_button(ui: &mut egui::Ui, state: &mut RunTabState) { } /// Options toggle button to show/hide the options panel. -fn show_options_toggle(ui: &mut egui::Ui, state: &mut RunTabState) { +fn render_options_toggle(ui: &mut egui::Ui, state: &mut RunTabState) { let icon = if state.panel_open { egui_material_icons::icons::ICON_KEYBOARD_ARROW_DOWN } else { @@ -253,14 +253,14 @@ fn show_options_toggle(ui: &mut egui::Ui, state: &mut RunTabState) { } /// Throughput display shown when generation is complete. -fn show_throughput(ui: &mut egui::Ui, state: &RunTabState) { +fn render_throughput(ui: &mut egui::Ui, state: &RunTabState) { if let Some(throughput) = &state.generation.throughput { ui.label(format!("Throughput: {throughput}")); } } /// Progress bar shown during generation. -fn show_progress_bar(ui: &mut egui::Ui, state: &RunTabState) { +fn render_progress_bar(ui: &mut egui::Ui, state: &RunTabState) { let progress = egui::ProgressBar::new(state.generation.progress) .text("") .fill(COLOR_SUCCESS); diff --git a/crates/fosr-gui/src/run/generation/core.rs b/crates/fosr-gui/src/run/generation/core.rs index dcf8f89e..a0774e12 100644 --- a/crates/fosr-gui/src/run/generation/core.rs +++ b/crates/fosr-gui/src/run/generation/core.rs @@ -45,13 +45,13 @@ pub fn generate( // Load the models let model = load_models(&profile)?; let automata_library = Arc::new(model.automata); - let bn = Arc::new(model.bn); + let bayesian_network = Arc::new(model.bn); // Handle the parameters: either there is a packet count target or a duration - let d = humantime::parse_duration(&duration) + let parsed_duration = humantime::parse_duration(&duration) .map_err(|e| format!("Invalid duration '{}': {}", duration, e))?; - log::info!("Generating a pcap of {d:?}"); - let _target = Target::GenerationDuration(d); + log::info!("Generating a pcap of {parsed_duration:?}"); + let _target = Target::GenerationDuration(parsed_duration); if let Some(s) = seed { log::info!("Generating with seed {s}"); @@ -65,14 +65,14 @@ pub fn generate( None, model.time_bins, initial_ts, - Some(d), + Some(parsed_duration), tz_offset, ); - let s1 = stage1::bayesian_networks::BNGenerator::new(bn, false); + let s1 = stage1::bayesian_networks::BNGenerator::new(bayesian_network, false); let s2 = TadamGenerator::new(automata_library); let s3 = stage3::Stage3::new(taint); log::info!("Run single thread"); - run_single_thread( + execute_generation_pipeline( order_pcap, s0, s1, @@ -156,7 +156,7 @@ fn resolve_timezone_offset( } /// Executes the 4-stage pipeline sequentially with cancellation support. -fn run_single_thread( +fn execute_generation_pipeline( order_pcap: bool, s0: impl stage0::Stage0, s1: impl stage1::Stage1, @@ -172,7 +172,7 @@ fn run_single_thread( let start = Instant::now(); log::info!("Stage 0 generation"); - let vec = stage0::run_vec(s0); + let stage0_output = stage0::run_vec(s0); if is_cancelled() { log::info!("Generation cancelled after stage 0"); return Ok(()); @@ -180,7 +180,7 @@ fn run_single_thread( send_progress(0.2); log::info!("Stage 1 generation"); - let vec = stage1::run_vec(s1, vec).map_err(|e| format!("Stage 1 failed: {}", e))?; + let stage1_output = stage1::run_vec(s1, stage0_output).map_err(|e| format!("Stage 1 failed: {}", e))?; if is_cancelled() { log::info!("Generation cancelled after stage 1"); return Ok(()); @@ -188,7 +188,7 @@ fn run_single_thread( send_progress(0.4); log::info!("Stage 2 generation"); - let vec = stage2::run_vec(s2, vec); + let stage2_output = stage2::run_vec(s2, stage1_output); if is_cancelled() { log::info!("Generation cancelled after stage 2"); return Ok(()); @@ -196,8 +196,8 @@ fn run_single_thread( send_progress(0.6); log::info!("Stage 3 generation"); - let all_packets = generate_stage3_packets(&s3, vec, &is_cancelled); - let mut all_packets = match all_packets { + let stage3_packets = generate_stage3_packets(&s3, stage2_output, &is_cancelled); + let mut all_packets = match stage3_packets { Some(p) => p, None => return Ok(()), // Cancelled }; diff --git a/crates/fosr-gui/src/run/generation/options.rs b/crates/fosr-gui/src/run/generation/options.rs index 9da3c748..3c40bea8 100644 --- a/crates/fosr-gui/src/run/generation/options.rs +++ b/crates/fosr-gui/src/run/generation/options.rs @@ -6,7 +6,7 @@ use crate::run::state::RunTabState; use super::validation::{ - first_invalid_param, show_field_error, validate_duration, validate_optional_u64, + first_invalid_param, render_field_error, validate_duration, validate_optional_u64, validate_timezone, }; use crate::shared::constants::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; @@ -33,26 +33,26 @@ use egui_extras::DatePickerButton; /// - Optional seed for deterministic generation /// - Taint packets option /// - Temporal ordering option -pub fn show_generation_options(ui: &mut egui::Ui, state: &mut RunTabState) { +pub fn render_generation_options(ui: &mut egui::Ui, state: &mut RunTabState) { ui.columns(GENERATION_OPTIONS_COLUMNS, |cols| { - show_duration_column(&mut cols[0], state); - show_seed_column(&mut cols[1], state); + render_duration_column(&mut cols[0], state); + render_seed_column(&mut cols[1], state); }); } /// Column 1: Duration input, start time picker, and timezone selection. -fn show_duration_column(col: &mut egui::Ui, state: &mut RunTabState) { +fn render_duration_column(col: &mut egui::Ui, state: &mut RunTabState) { col.set_min_width(GENERATION_COL1_MIN_WIDTH); - show_duration_input(col, state); + render_duration_input(col, state); col.add_space(SPACING_LG); - show_start_time_options(col, state); - show_utc_preview(col, state); + render_start_time_options(col, state); + render_utc_preview(col, state); } /// Duration input with preset buttons and validation. -fn show_duration_input(ui: &mut egui::Ui, state: &mut RunTabState) { +fn render_duration_input(ui: &mut egui::Ui, state: &mut RunTabState) { ui.horizontal(|ui| { ui.label("Duration"); info_icon_with_tooltip(ui, "Minimum pcap traffic duration described in human-friendly time, such as \"30m\", \"1h\", \"2d\" or \"2days 30min 5s\"."); @@ -77,12 +77,12 @@ fn show_duration_input(ui: &mut egui::Ui, state: &mut RunTabState) { } } - show_field_error(ui, &state.generation.duration_validation); + render_field_error(ui, &state.generation.duration_validation); }); } /// Start time toggle and custom time picker with timezone selection. -fn show_start_time_options(ui: &mut egui::Ui, state: &mut RunTabState) { +fn render_start_time_options(ui: &mut egui::Ui, state: &mut RunTabState) { ui.horizontal(|ui| { ui.checkbox(&mut state.generation.use_current_time, "Use current time for start time"); info_icon_with_tooltip(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."); @@ -102,19 +102,19 @@ fn show_start_time_options(ui: &mut egui::Ui, state: &mut RunTabState) { .start_end_years((current_year - 5)..=(current_year + 30)), ); ui.add( - TimePickerButton::new(&mut state.generation.start_hour) + TimePickerButton::new(&mut state.generation.start_time) .show_seconds(true) - .use_dragvalue(true), + .use_drag_value(true), ); }); ui.add_space(SPACING_LG); - show_timezone_picker(ui, state); + render_timezone_picker(ui, state); } /// Timezone picker with local/custom toggle. -fn show_timezone_picker(ui: &mut egui::Ui, state: &mut RunTabState) { +fn render_timezone_picker(ui: &mut egui::Ui, state: &mut RunTabState) { ui.horizontal(|ui| { if ui .checkbox(&mut state.generation.use_local_timezone, "Use local timezone") @@ -141,7 +141,7 @@ fn show_timezone_picker(ui: &mut egui::Ui, state: &mut RunTabState) { } /// Displays the UTC equivalent of the selected start time. -fn show_utc_preview(ui: &mut egui::Ui, state: &mut RunTabState) { +fn render_utc_preview(ui: &mut egui::Ui, state: &mut RunTabState) { let utc_text = compute_utc_text(state); if let Some(text) = utc_text { @@ -159,7 +159,7 @@ fn compute_utc_text(state: &RunTabState) -> Option { return Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()); } - let local_dt = state.generation.start_date.and_time(state.generation.start_hour); + let local_dt = state.generation.start_date.and_time(state.generation.start_time); let utc = if state.generation.use_local_timezone { Local::now() @@ -186,20 +186,20 @@ fn compute_utc_text(state: &RunTabState) -> Option { } /// Column 2: Seed input and advanced options (taint, temporal ordering). -fn show_seed_column(col: &mut egui::Ui, state: &mut RunTabState) { +fn render_seed_column(col: &mut egui::Ui, state: &mut RunTabState) { col.set_min_width(GENERATION_COL2_MIN_WIDTH); - show_seed_input(col, state); + render_seed_input(col, state); col.add_space(SPACING_LG); - show_advanced_options(col, state); + render_advanced_options(col, state); col.add_space(SPACING_LG); - show_validation_errors(col, state); + render_validation_errors(col, state); } /// Seed input with validation. -fn show_seed_input(ui: &mut egui::Ui, state: &mut RunTabState) { +fn render_seed_input(ui: &mut egui::Ui, state: &mut RunTabState) { ui.horizontal(|ui| { ui.checkbox(&mut state.generation.use_seed, "Seed"); info_icon_with_tooltip(ui, "Seed for random number generation. For deterministic generation, you must also specify duration, start time, and timezone."); @@ -218,7 +218,7 @@ fn show_seed_input(ui: &mut egui::Ui, state: &mut RunTabState) { } } - show_field_error(ui, &state.generation.seed_validation); + render_field_error(ui, &state.generation.seed_validation); } else { state.generation.seed_validation.set_ok(); } @@ -226,7 +226,7 @@ fn show_seed_input(ui: &mut egui::Ui, state: &mut RunTabState) { } /// Advanced options: taint packets and temporal ordering. -fn show_advanced_options(ui: &mut egui::Ui, state: &mut RunTabState) { +fn render_advanced_options(ui: &mut egui::Ui, state: &mut RunTabState) { ui.horizontal(|ui| { ui.checkbox(&mut state.generation.taint, "Taint the packets"); info_icon_with_tooltip(ui, "Taint the packets with special markers for identification."); @@ -238,7 +238,7 @@ fn show_advanced_options(ui: &mut egui::Ui, state: &mut RunTabState) { } /// Shows the first validation error if any parameter is invalid. -fn show_validation_errors(ui: &mut egui::Ui, state: &RunTabState) { +fn render_validation_errors(ui: &mut egui::Ui, state: &RunTabState) { if let Some((name, spec, err)) = first_invalid_param(&state.generation) { ui.colored_label( COLOR_ERROR, diff --git a/crates/fosr-gui/src/run/generation/process.rs b/crates/fosr-gui/src/run/generation/process.rs index b534dd08..ecb28ac0 100644 --- a/crates/fosr-gui/src/run/generation/process.rs +++ b/crates/fosr-gui/src/run/generation/process.rs @@ -4,7 +4,7 @@ use super::core::generate; use crate::run::state::RunTabState; -use crate::shared::config::state::ConfigurationFileState; +use crate::shared::config::state::ConfigFileState; use eframe::egui; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -16,7 +16,7 @@ use std::sync::mpsc::channel; /// The generation runs asynchronously (native thread or WASM future). pub fn start_generation( state: &mut RunTabState, - configuration_file_state: &ConfigurationFileState, + configuration_file_state: &ConfigFileState, ctx: &egui::Context, ) { // Reset state @@ -37,7 +37,7 @@ pub fn start_generation( let (error_sender, error_receiver) = channel(); state.generation.error_receiver = Some(error_receiver); - let params = GenerationParams::from_state(state, configuration_file_state); + let params = PcapGenerationParams::from_state(state, configuration_file_state); let cancelled = state.generation.cancelled.clone(); let ctx = ctx.clone(); @@ -53,7 +53,7 @@ pub fn start_generation( } /// Generation parameters extracted from UI state. -struct GenerationParams { +struct PcapGenerationParams { seed: Option, order_pcap: bool, start_time: Option, @@ -63,9 +63,9 @@ struct GenerationParams { config_content: Option, } -impl GenerationParams { +impl PcapGenerationParams { /// Extracts parameters from the UI state and config file. - fn from_state(state: &RunTabState, config_state: &ConfigurationFileState) -> Self { + fn from_state(state: &RunTabState, config_state: &ConfigFileState) -> Self { let seed = if state.generation.use_seed { state.generation.seed_input.parse::().ok() } else { @@ -77,7 +77,7 @@ impl GenerationParams { Some(format!( "{}T{}Z", state.generation.start_date.format("%Y-%m-%d"), - state.generation.start_hour.format("%H:%M:%S") + state.generation.start_time.format("%H:%M:%S") )) }; let timezone = if state.generation.timezone_input.is_empty() { @@ -103,7 +103,7 @@ impl GenerationParams { /// Spawns the generation task on the appropriate platform (WASM future or native thread). fn spawn_generation_task( - params: GenerationParams, + params: PcapGenerationParams, progress_sender: std::sync::mpsc::Sender, pcap_sender: std::sync::mpsc::Sender>, throughput_sender: std::sync::mpsc::Sender, diff --git a/crates/fosr-gui/src/run/generation/state.rs b/crates/fosr-gui/src/run/generation/state.rs index 44750449..b2aee700 100644 --- a/crates/fosr-gui/src/run/generation/state.rs +++ b/crates/fosr-gui/src/run/generation/state.rs @@ -40,7 +40,7 @@ pub struct GenerationState { pub use_current_time: bool, pub use_local_timezone: bool, pub start_date: NaiveDate, - pub start_hour: NaiveTime, + pub start_time: NaiveTime, pub output_file_name: String, /// Holds temporary PCAP files opened in Wireshark along with their background thread handles. /// @@ -98,7 +98,7 @@ impl Default for GenerationState { use_current_time: true, use_local_timezone: true, start_date: Local::now().date_naive(), - start_hour: Local::now().time(), + start_time: Local::now().time(), output_file_name: "output.pcap".to_string(), #[cfg(not(target_arch = "wasm32"))] temp_pcap_files: Vec::new(), diff --git a/crates/fosr-gui/src/run/generation/validation.rs b/crates/fosr-gui/src/run/generation/validation.rs index 473f2374..a07b6af5 100644 --- a/crates/fosr-gui/src/run/generation/validation.rs +++ b/crates/fosr-gui/src/run/generation/validation.rs @@ -26,7 +26,7 @@ impl FieldValidation { } /// Display the error message in red below the field. -pub fn show_field_error(ui: &mut egui::Ui, validation: &FieldValidation) { +pub fn render_field_error(ui: &mut egui::Ui, validation: &FieldValidation) { if let Some(msg) = &validation.error { ui.add_space(SPACING_MD); ui.colored_label(COLOR_ERROR, msg); @@ -45,10 +45,10 @@ pub fn first_invalid_param(state: &GenerationState) -> Option<(&'static str, &'s ("Seed", SPEC_SEED, &state.seed_validation), ("Timezone", SPEC_TIMEZONE, &state.timezone_validation), ] - .into_iter() - .find_map(|(name, spec, validation)| { - validation.error.as_ref().map(|err| (name, spec, err.as_str())) - }) + .into_iter() + .find_map(|(name, spec, validation)| { + validation.error.as_ref().map(|err| (name, spec, err.as_str())) + }) } /// Validates a human-readable duration string and checks bounds. diff --git a/crates/fosr-gui/src/run/graph/config_handling.rs b/crates/fosr-gui/src/run/graph/config_handling.rs index aa246522..657d2277 100644 --- a/crates/fosr-gui/src/run/graph/config_handling.rs +++ b/crates/fosr-gui/src/run/graph/config_handling.rs @@ -4,7 +4,7 @@ //! visualization updates when the configuration changes. use super::state::VisualizationState; -use crate::shared::config::state::ConfigurationFileState; +use crate::shared::config::state::ConfigFileState; use crate::shared::constants::ui::DELAY_FRAMES_NORMAL; /// Handle configuration file changes and update visualization state. @@ -17,7 +17,7 @@ use crate::shared::constants::ui::DELAY_FRAMES_NORMAL; /// - If parsing fails: logs error and resets to default state pub fn handle_config_changes( state: &mut VisualizationState, - configuration_file_state: &ConfigurationFileState, + configuration_file_state: &ConfigFileState, ) { // Check if config was removed or is empty let config_is_empty = configuration_file_state diff --git a/crates/fosr-gui/src/run/graph/flow_processing.rs b/crates/fosr-gui/src/run/graph/flow_processing.rs index abfa7a1a..6280eba7 100644 --- a/crates/fosr-gui/src/run/graph/flow_processing.rs +++ b/crates/fosr-gui/src/run/graph/flow_processing.rs @@ -3,7 +3,7 @@ //! This module handles the processing of flow events from the streamer, //! updating active links, and synchronizing graph edge states. -use super::state::{ActiveLink, EdgeState, INTERNET_IP, LinkDirection, VisualizationState}; +use super::state::{ActiveLink, EdgeState, INTERNET_NODE_IP, LinkDirection, VisualizationState}; use super::stream::FlowEvent; use crate::shared::constants::ui::ACTIVE_LINK_BASE_TIMEOUT_MS; @@ -49,9 +49,9 @@ pub fn process_flow_events(state: &mut VisualizationState) { // Increment total flows counter state.flow.total_flows += 1; - // 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 }; + // Map unknown IPs to the Internet node for display + let display_src = if src_known { event.src_ip } else { INTERNET_NODE_IP }; + let display_dst = if dst_known { event.dst_ip } else { INTERNET_NODE_IP }; log::debug!( " -> Displayed as: {} -> {} ({:?})", diff --git a/crates/fosr-gui/src/run/graph/utils.rs b/crates/fosr-gui/src/run/graph/graph_layout.rs similarity index 68% rename from crates/fosr-gui/src/run/graph/utils.rs rename to crates/fosr-gui/src/run/graph/graph_layout.rs index 8c30e2aa..0a79cc05 100644 --- a/crates/fosr-gui/src/run/graph/utils.rs +++ b/crates/fosr-gui/src/run/graph/graph_layout.rs @@ -1,11 +1,11 @@ -//! Graph layout utilities: circular node distribution for initial display. +//! Graph layout: circular node distribution for initial display. use crate::shared::constants::ui::{GRAPH_LAYOUT_RADIUS_BASE, GRAPH_LAYOUT_RADIUS_MULTIPLIER}; use eframe::egui; /// Distributes the graph nodes in a circle layout. /// Required for proper display on startup. -pub fn distribute_nodes_circle( +pub fn arrange_nodes_in_circle( graph: &mut egui_graphs::Graph, ) where N: Clone, @@ -15,13 +15,13 @@ pub fn distribute_nodes_circle( Dn: egui_graphs::DisplayNode, De: egui_graphs::DisplayEdge, { - let n = graph.node_count().max(1) as f32; - let radius = n.sqrt() * GRAPH_LAYOUT_RADIUS_MULTIPLIER + GRAPH_LAYOUT_RADIUS_BASE; + let node_count = graph.node_count().max(1) as f32; + let radius = node_count.sqrt() * GRAPH_LAYOUT_RADIUS_MULTIPLIER + GRAPH_LAYOUT_RADIUS_BASE; 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; + let angle = (i as f32 / node_count) * std::f32::consts::TAU; node.set_location(egui::pos2(radius * angle.cos(), radius * angle.sin())); } } diff --git a/crates/fosr-gui/src/run/graph/mod.rs b/crates/fosr-gui/src/run/graph/mod.rs index 1b7d6710..05ae2ca6 100644 --- a/crates/fosr-gui/src/run/graph/mod.rs +++ b/crates/fosr-gui/src/run/graph/mod.rs @@ -8,5 +8,5 @@ pub mod screenshot; pub mod shapes; pub mod state; pub mod stream; -pub mod utils; +pub mod graph_layout; pub mod view; diff --git a/crates/fosr-gui/src/run/graph/node_modal.rs b/crates/fosr-gui/src/run/graph/node_modal.rs index 942171b5..3bcb6d04 100644 --- a/crates/fosr-gui/src/run/graph/node_modal.rs +++ b/crates/fosr-gui/src/run/graph/node_modal.rs @@ -1,9 +1,9 @@ //! Node click handling and info/edit modal for the visualization graph. -use super::state::{NodeData, NodeType, VisualizationState}; +use super::state::{NetworkNode, NodeType, VisualizationState}; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; use crate::shared::config::model::Host; -use crate::shared::config::state::ConfigurationFileState; +use crate::shared::config::state::ConfigFileState; use crate::shared::constants::colors::{COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT}; use crate::shared::constants::ui::{ INDENT_STANDARD, LEGEND_ICON_SIZE, NODE_MODAL_WIDTH, SPACING_LG, SPACING_SM, @@ -14,7 +14,7 @@ use egui_graphs::events::{Event, PayloadNodeClick}; /// Process graph click events from the event buffer. pub fn process_graph_events( state: &mut VisualizationState, - configuration_file_state: &ConfigurationFileState, + configuration_file_state: &ConfigFileState, ) { let events: Vec = state.modal.events_buffer.borrow_mut().drain(..).collect(); @@ -40,7 +40,7 @@ pub fn process_graph_events( pub fn render_node_info_modal( ctx: &egui::Context, state: &mut VisualizationState, - config_file_state: &mut ConfigurationFileState, + config_file_state: &mut ConfigFileState, ) { if !state.modal.open { return; @@ -91,7 +91,7 @@ pub fn render_node_info_modal( } /// Render modal header with title and node type icon. -fn render_modal_header(ui: &mut egui::Ui, node_data: &NodeData, has_edit_buffer: bool) { +fn render_modal_header(ui: &mut egui::Ui, node_data: &NetworkNode, has_edit_buffer: bool) { let title = if has_edit_buffer { "Edit Node Information" } else { @@ -177,16 +177,16 @@ fn render_os_dropdown(ui: &mut egui::Ui, host: &mut Host) { /// Render editable IP address fields. fn render_ip_fields(ui: &mut egui::Ui, host: &mut Host) { ui.label("IP Addresses:"); - for iface in &mut host.interfaces { + for interface in &mut host.interfaces { ui.horizontal(|ui| { ui.add_space(INDENT_STANDARD); - ui.add(egui::TextEdit::singleline(&mut iface.ip_addr).hint_text("0.0.0.0")); + ui.add(egui::TextEdit::singleline(&mut interface.ip_addr).hint_text("0.0.0.0")); }); } } /// Render read-only fields (no config loaded or Internet node). -fn render_readonly_fields(ui: &mut egui::Ui, node_data: &NodeData) { +fn render_readonly_fields(ui: &mut egui::Ui, node_data: &NetworkNode) { if let Some(ref hostname) = node_data.hostname { ui.horizontal(|ui| { ui.label("Hostname:"); @@ -244,7 +244,7 @@ fn render_modal_footer(ui: &mut egui::Ui, has_edit_buffer: bool, save_clicked: & /// Apply changes from edit buffer back to the config model. fn apply_changes_to_config( state: &mut VisualizationState, - config_file_state: &mut ConfigurationFileState, + config_file_state: &mut ConfigFileState, host_idx: Option, ) { if let (Some(idx), Some(buffer)) = (host_idx, state.modal.edit_buffer.take()) { diff --git a/crates/fosr-gui/src/run/graph/screenshot.rs b/crates/fosr-gui/src/run/graph/screenshot.rs index c22d640c..d80e38eb 100644 --- a/crates/fosr-gui/src/run/graph/screenshot.rs +++ b/crates/fosr-gui/src/run/graph/screenshot.rs @@ -24,7 +24,7 @@ pub fn handle_screenshot_export(ui: &mut egui::Ui, state: &mut VisualizationStat if state.screenshot_export == ScreenshotStateMachine::WaitingForScreenshot { if let Some(graph_rect) = state.view.graph_rect { let graph_image = image.region(&graph_rect, Some(i.pixels_per_point())); - save_graph_png(&graph_image); + save_screenshot_as_png(&graph_image); } else { log::error!("No graph rect stored for screenshot export"); } @@ -36,7 +36,7 @@ pub fn handle_screenshot_export(ui: &mut egui::Ui, state: &mut VisualizationStat } /// Save the graph screenshot as a PNG file. -fn save_graph_png(image: &egui::ColorImage) { +fn save_screenshot_as_png(image: &egui::ColorImage) { let width = image.width() as u32; let height = image.height() as u32; let pixels = image.as_raw(); diff --git a/crates/fosr-gui/src/run/graph/shapes.rs b/crates/fosr-gui/src/run/graph/shapes.rs index afdfd6dd..25701865 100644 --- a/crates/fosr-gui/src/run/graph/shapes.rs +++ b/crates/fosr-gui/src/run/graph/shapes.rs @@ -6,7 +6,7 @@ //! - `closest_boundary_point`: where edges connect to the node boundary //! - `is_inside`: hit-testing for clicking and dragging -use super::state::{EdgeData, EdgeState, LinkDirection, NodeData, NodeType}; +use super::state::{NetworkEdge, EdgeState, LinkDirection, NetworkNode, NodeType}; use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; use crate::shared::constants::colors::{ COLOR_EDGE_INACTIVE, COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT, COLOR_PROTOCOL_DNS, @@ -81,7 +81,7 @@ pub struct NetworkNodeShape { impl NetworkNodeShape { /// Compute node style from payload data. - fn style_from_payload(payload: &NodeData) -> (f32, NodeType, Option, Vec) { + fn style_from_payload(payload: &NetworkNode) -> (f32, NodeType, Option, Vec) { let radius = calculate_node_radius(payload.flow_count, payload.max_flow_count); let ips: Vec = payload.ip_addrs.iter().map(|ip| ip.to_string()).collect(); ( @@ -175,8 +175,8 @@ impl NetworkNodeShape { } } -impl From> for NetworkNodeShape { - fn from(props: NodeProps) -> Self { +impl From> for NetworkNodeShape { + fn from(props: NodeProps) -> Self { let (radius, node_type, hostname, ips) = Self::style_from_payload(&props.payload); Self { radius, @@ -188,8 +188,8 @@ impl From> for NetworkNodeShape { } } -impl DisplayNode - for NetworkNodeShape +impl DisplayNode +for NetworkNodeShape { /// Determines where edges should connect to the node shape fn closest_boundary_point(&self, dir: Vec2) -> Pos2 { @@ -213,7 +213,7 @@ impl DisplayNode) { + fn update(&mut self, state: &NodeProps) { let (radius, node_type, hostname, ips) = Self::style_from_payload(&state.payload); self.radius = radius; self.node_type = node_type; @@ -229,7 +229,7 @@ impl DisplayNode (Color32, f32, bool, bool) { +fn edge_style(edge_data: &NetworkEdge) -> (Color32, f32, bool, bool) { match &edge_data.state { EdgeState::Inactive => { let width = calculate_edge_width(edge_data.flow_count, edge_data.max_flow_count); @@ -267,8 +267,8 @@ pub struct NetworkEdgeShape { arrow_end: bool, } -impl From> for NetworkEdgeShape { - fn from(props: egui_graphs::EdgeProps) -> Self { +impl From> for NetworkEdgeShape { + fn from(props: egui_graphs::EdgeProps) -> Self { let (color, width, arrow_start, arrow_end) = edge_style(&props.payload); Self { color, @@ -288,26 +288,26 @@ fn arrow_head(from: Pos2, to: Pos2, size: f32, angle: f32, color: Color32) -> Sh } impl - DisplayEdge< - NodeData, - EdgeData, - petgraph::Undirected, - petgraph::stable_graph::DefaultIx, - NetworkNodeShape, - > for NetworkEdgeShape +DisplayEdge< + NetworkNode, + NetworkEdge, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, +> for NetworkEdgeShape { fn shapes( &mut self, start: &Node< - NodeData, - EdgeData, + NetworkNode, + NetworkEdge, petgraph::Undirected, petgraph::stable_graph::DefaultIx, NetworkNodeShape, >, end: &Node< - NodeData, - EdgeData, + NetworkNode, + NetworkEdge, petgraph::Undirected, petgraph::stable_graph::DefaultIx, NetworkNodeShape, @@ -360,7 +360,7 @@ impl shapes } - fn update(&mut self, state: &egui_graphs::EdgeProps) { + 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; @@ -371,15 +371,15 @@ impl fn is_inside( &self, start: &Node< - NodeData, - EdgeData, + NetworkNode, + NetworkEdge, petgraph::Undirected, petgraph::stable_graph::DefaultIx, NetworkNodeShape, >, end: &Node< - NodeData, - EdgeData, + NetworkNode, + NetworkEdge, petgraph::Undirected, petgraph::stable_graph::DefaultIx, NetworkNodeShape, diff --git a/crates/fosr-gui/src/run/graph/state.rs b/crates/fosr-gui/src/run/graph/state.rs index 201da53c..9b93c3b3 100644 --- a/crates/fosr-gui/src/run/graph/state.rs +++ b/crates/fosr-gui/src/run/graph/state.rs @@ -2,7 +2,7 @@ use super::shapes::{NetworkEdgeShape, NetworkNodeShape}; use super::stream::{FlowEvent, FlowStreamer}; -use super::utils::distribute_nodes_circle; +use super::graph_layout::arrange_nodes_in_circle; use crate::shared::config::model::Host; use crate::shared::constants::ui::DELAY_FRAMES_QUICK; use eframe::egui; @@ -19,7 +19,7 @@ use std::sync::{Arc, RwLock}; use web_time::Instant; /// Special IP address representing "The Internet" node -pub const INTERNET_IP: Ipv4Addr = Ipv4Addr::new(0, 0, 0, 1); +pub const INTERNET_NODE_IP: Ipv4Addr = Ipv4Addr::new(0, 0, 0, 1); /// Node type for visualization (extends HostType with Internet) #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -40,7 +40,7 @@ impl From for NodeType { /// Node data: host information #[derive(Clone, Debug)] -pub struct NodeData { +pub struct NetworkNode { pub ip_addrs: Vec, pub hostname: Option, pub node_type: NodeType, @@ -54,11 +54,11 @@ pub struct NodeData { pub max_flow_count: u32, } -impl NodeData { +impl NetworkNode { /// Create an Internet node pub fn internet() -> Self { Self { - ip_addrs: vec![INTERNET_IP], + ip_addrs: vec![INTERNET_NODE_IP], hostname: Some("Internet".to_string()), node_type: NodeType::Internet, os: OS::Linux, // Doesn't matter for Internet node @@ -69,7 +69,7 @@ impl NodeData { } // Display the hostname plus all IP addresses -impl fmt::Display for NodeData { +impl fmt::Display for NetworkNode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if let Some(ref hostname) = self.hostname { if self.node_type == NodeType::Internet { @@ -99,7 +99,7 @@ impl fmt::Display for NodeData { /// Edge data: communication state with cumulative flow count for thickness #[derive(Clone, Debug)] -pub struct EdgeData { +pub struct NetworkEdge { /// Current visual state (active with protocol or inactive) pub state: EdgeState, /// Cumulative flow count - persists even when inactive, used for edge thickness @@ -108,7 +108,7 @@ pub struct EdgeData { pub max_flow_count: u32, } -impl Default for EdgeData { +impl Default for NetworkEdge { fn default() -> Self { Self { state: EdgeState::Inactive, @@ -131,7 +131,7 @@ pub enum EdgeState { }, } -impl fmt::Display for EdgeData { +impl fmt::Display for NetworkEdge { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.state { EdgeState::Inactive => write!(f, ""), @@ -140,6 +140,7 @@ impl fmt::Display for EdgeData { } } +/// Direction of traffic flow on an edge. #[derive(Clone, Debug, PartialEq)] pub enum LinkDirection { Forward, @@ -155,8 +156,8 @@ pub struct ActiveLink { } pub type VisualizationGraph = egui_graphs::Graph< - NodeData, - EdgeData, + NetworkNode, + NetworkEdge, petgraph::Undirected, petgraph::stable_graph::DefaultIx, NetworkNodeShape, @@ -174,7 +175,9 @@ pub enum ScreenshotStateMachine { WaitingForScreenshot, } -/// Network structure and IP/Node lookups +/// Network structure with graph, IP/Node lookups, and construction methods. +/// +/// Use [`NetworkData::from_config`] to build from a configuration. pub struct NetworkData { pub graph: VisualizationGraph, pub known_ips: HashSet, @@ -193,8 +196,82 @@ impl Default for NetworkData { } } +impl NetworkData { + /// Build network data from the configuration. + /// + /// Creates nodes for each host, lays them out in a circle, + /// adds the Internet node at center, and connects edges. + pub fn from_config(config: &config::Configuration) -> Self { + let mut data = Self::default(); + data.add_host_nodes(config); + data.distribute_layout(); + data.add_internet_node(); + data.add_edges(config); + data + } + + /// Add one node per host (with all its IPs). + fn add_host_nodes(&mut self, config: &config::Configuration) { + for (host_idx, host) in config.get_hosts().iter().enumerate() { + let all_ips: Vec = host.interfaces.iter().map(|i| i.ip_addr).collect(); + + let node_data = NetworkNode { + ip_addrs: all_ips.clone(), + hostname: host.hostname.clone(), + node_type: host.host_type.into(), + os: host.os, + flow_count: 0, + max_flow_count: 0, + }; + let idx = self.graph.add_node_with_location(node_data, egui::pos2(0.0, 0.0)); + self.node_to_host.insert(idx, host_idx); + + // Map all IPs of this host to the same node + for ip in all_ips { + self.known_ips.insert(ip); + self.ip_to_node.insert(ip, idx); + } + } + } + + /// Distribute nodes in a circle (before adding Internet, so it stays centered). + fn distribute_layout(&mut self) { + arrange_nodes_in_circle(&mut self.graph); + } + + /// Add the Internet node at the center. + fn add_internet_node(&mut self) { + let internet_idx = self.graph.add_node_with_location(NetworkNode::internet(), egui::pos2(0.0, 0.0)); + self.ip_to_node.insert(INTERNET_NODE_IP, internet_idx); + } + + /// Add edges between users, servers, and Internet. + fn add_edges(&mut self, config: &config::Configuration) { + let internet_idx = self.ip_to_node[&INTERNET_NODE_IP]; + + // Add edges from users to servers and Internet + for &user_ip in &config.users { + if let Some(&user_idx) = self.ip_to_node.get(&user_ip) { + for &server_ip in &config.servers { + if let Some(&server_idx) = self.ip_to_node.get(&server_ip) { + self.graph.add_edge(user_idx, server_idx, NetworkEdge::default()); + } + } + self.graph.add_edge(user_idx, internet_idx, NetworkEdge::default()); + } + } + + // Add edges from servers to Internet + for &server_ip in &config.servers { + if let Some(&server_idx) = self.ip_to_node.get(&server_ip) { + self.graph.add_edge(server_idx, internet_idx, NetworkEdge::default()); + } + } + } +} + /// Flow processing and streaming state -pub struct FlowState { +pub struct FlowVisualizationState { pub receiver: Option>, pub active_links: HashMap<(Ipv4Addr, Ipv4Addr), ActiveLink>, pub streamer: Option, @@ -204,7 +281,7 @@ pub struct FlowState { pub visualization_start: Option, } -impl Default for FlowState { +impl Default for FlowVisualizationState { fn default() -> Self { Self { receiver: None, @@ -219,7 +296,7 @@ impl Default for FlowState { } /// Layout and rendering state -pub struct ViewState { +pub struct GraphViewState { pub layout_initialized: bool, pub reset_requested: bool, pub delayed_fit_countdown: Option, @@ -227,7 +304,7 @@ pub struct ViewState { pub graph_rect: Option, } -impl Default for ViewState { +impl Default for GraphViewState { fn default() -> Self { Self { layout_initialized: false, @@ -240,14 +317,14 @@ impl Default for ViewState { } /// Node info modal state -pub struct ModalState { +pub struct NodeModalState { pub events_buffer: Rc>>, pub clicked_node: Option, pub open: bool, pub edit_buffer: Option, } -impl Default for ModalState { +impl Default for NodeModalState { fn default() -> Self { Self { events_buffer: Rc::new(RefCell::new(Vec::new())), @@ -263,11 +340,11 @@ pub struct VisualizationState { /// Network structure and lookups pub network: NetworkData, /// Flow processing and streaming - pub flow: FlowState, + pub flow: FlowVisualizationState, /// Layout and rendering - pub view: ViewState, + pub view: GraphViewState, /// Node info modal - pub modal: ModalState, + pub modal: NodeModalState, /// Screenshot export state machine pub screenshot_export: ScreenshotStateMachine, /// Config content tracking (for detecting changes) @@ -278,117 +355,13 @@ pub struct VisualizationState { pub user_has_started: bool, } -/// Result of building a graph from configuration. -struct GraphBuildResult { - graph: VisualizationGraph, - known_ips: HashSet, - ip_to_node: HashMap, - node_to_host: HashMap, -} - -/// Helper for constructing visualization graphs from configuration. -/// -/// Groups mutable state during graph construction to avoid passing -/// multiple `&mut` parameters. The construction order is fixed: -/// 1. Create host nodes from config -/// 2. Layout (circular distribution) -/// 3. Add the Internet node (centered) -/// 4. Create edges between hosts and Internet -struct GraphConstructor { - graph: VisualizationGraph, - known_ips: HashSet, - ip_to_node: HashMap, - node_to_host: HashMap, -} - -impl GraphConstructor { - /// Build the complete graph from configuration. - fn build(config: &config::Configuration) -> GraphBuildResult { - let mut constructor = Self { - graph: VisualizationGraph::new(petgraph::stable_graph::StableGraph::default()), - known_ips: HashSet::new(), - ip_to_node: HashMap::new(), - node_to_host: HashMap::new(), - }; - constructor.add_host_nodes(config); - constructor.distribute_layout(); - constructor.add_internet_node(); - constructor.add_edges(config); - GraphBuildResult { - graph: constructor.graph, - known_ips: constructor.known_ips, - ip_to_node: constructor.ip_to_node, - node_to_host: constructor.node_to_host, - } - } - - /// Add one node per host (with all its IPs). - fn add_host_nodes(&mut self, config: &config::Configuration) { - for (host_idx, host) in config.get_hosts().iter().enumerate() { - 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, - flow_count: 0, - max_flow_count: 0, - }; - let idx = self.graph.add_node_with_location(node_data, egui::pos2(0.0, 0.0)); - self.node_to_host.insert(idx, host_idx); - - // Map all IPs of this host to the same node - for ip in all_ips { - self.known_ips.insert(ip); - self.ip_to_node.insert(ip, idx); - } - } - } - - /// Distribute nodes in a circle (before adding Internet, so it stays centered). - fn distribute_layout(&mut self) { - distribute_nodes_circle(&mut self.graph); - } - - /// Add the Internet node at the center. - fn add_internet_node(&mut self) { - let internet_idx = self.graph.add_node_with_location(NodeData::internet(), egui::pos2(0.0, 0.0)); - self.ip_to_node.insert(INTERNET_IP, internet_idx); - } - - /// Add edges between users, servers, and Internet. - fn add_edges(&mut self, config: &config::Configuration) { - let internet_idx = self.ip_to_node[&INTERNET_IP]; - - // Add edges from users to servers and Internet - for &user_ip in &config.users { - if let Some(&user_idx) = self.ip_to_node.get(&user_ip) { - for &server_ip in &config.servers { - if let Some(&server_idx) = self.ip_to_node.get(&server_ip) { - self.graph.add_edge(user_idx, server_idx, EdgeData::default()); - } - } - self.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) = self.ip_to_node.get(&server_ip) { - self.graph.add_edge(server_idx, internet_idx, EdgeData::default()); - } - } - } -} - impl Default for VisualizationState { fn default() -> Self { Self { network: NetworkData::default(), - flow: FlowState::default(), - view: ViewState::default(), - modal: ModalState::default(), + flow: FlowVisualizationState::default(), + view: GraphViewState::default(), + modal: NodeModalState::default(), screenshot_export: ScreenshotStateMachine::default(), config_content: None, auto_start_countdown: None, @@ -401,11 +374,7 @@ impl VisualizationState { /// 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 built = GraphConstructor::build(config); - self.network.graph = built.graph; - self.network.known_ips = built.known_ips; - self.network.ip_to_node = built.ip_to_node; - self.network.node_to_host = built.node_to_host; + self.network = NetworkData::from_config(config); self.view.layout_initialized = false; } diff --git a/crates/fosr-gui/src/run/graph/stream.rs b/crates/fosr-gui/src/run/graph/stream.rs index 4d108740..5585f4ef 100644 --- a/crates/fosr-gui/src/run/graph/stream.rs +++ b/crates/fosr-gui/src/run/graph/stream.rs @@ -143,7 +143,7 @@ impl ScheduledFlow { /// Uses a binary heap to store flows in timestamp order, ensuring they are /// emitted in the correct sequence. Flows are generated ahead of time (buffer) /// to avoid overloading the CPU by continuously generating them. -struct StreamingState { +struct FlowStreamingState { virtual_time: VirtualTime, /// Pending flows ordered by scheduled time (max-heap, so we reverse the order). pending_flows: BinaryHeap, @@ -155,7 +155,7 @@ struct StreamingState { buffer_ahead: Duration, } -impl StreamingState { +impl FlowStreamingState { fn new() -> Self { Self { virtual_time: VirtualTime::new(), @@ -204,7 +204,7 @@ impl StreamingState { /// Returns false if no more timestamps are available from Stage 0. #[cfg(not(target_arch = "wasm32"))] fn generate_flows_to_buffer( - state: &mut StreamingState, + state: &mut FlowStreamingState, s0: &mut stage0::BinBasedGenerator, s1: &BNGenerator, initial_timestamp: Duration, @@ -232,7 +232,7 @@ fn generate_flows_to_buffer( /// Generate flows for WASM (with per-cycle limit). #[cfg(target_arch = "wasm32")] fn generate_flows_to_buffer_wasm( - state: &mut StreamingState, + state: &mut FlowStreamingState, s0: &mut stage0::BinBasedGenerator, s1: &BNGenerator, initial_timestamp: Duration, @@ -258,7 +258,7 @@ fn generate_flows_to_buffer_wasm( /// Emit flows whose scheduled time has passed (in virtual time). #[cfg(not(target_arch = "wasm32"))] fn emit_scheduled_flows( - state: &mut StreamingState, + state: &mut FlowStreamingState, sender: &Sender, ) { let virtual_elapsed = state.virtual_elapsed(); @@ -288,7 +288,7 @@ fn emit_scheduled_flows( /// Emit flows for WASM (simplified, no debug logging). #[cfg(target_arch = "wasm32")] fn emit_scheduled_flows_wasm( - state: &mut StreamingState, + state: &mut FlowStreamingState, sender: &Sender, ) { let virtual_elapsed = state.virtual_elapsed(); @@ -421,7 +421,7 @@ impl FlowStreamer { initial_timestamp: Duration, speed: Arc>, ) { - let mut state = StreamingState::new(); + let mut state = FlowStreamingState::new(); let check_interval = Duration::from_millis(STREAM_CHECK_INTERVAL_MS); log::info!( @@ -463,7 +463,7 @@ impl FlowStreamer { initial_timestamp: Duration, speed: Arc>, ) { - let mut state = StreamingState::new(); + let mut state = FlowStreamingState::new(); let check_interval = Duration::from_millis(STREAM_CHECK_INTERVAL_MS); while running.load(Ordering::SeqCst) { diff --git a/crates/fosr-gui/src/run/graph/view.rs b/crates/fosr-gui/src/run/graph/view.rs index d9502d3e..189f7a44 100644 --- a/crates/fosr-gui/src/run/graph/view.rs +++ b/crates/fosr-gui/src/run/graph/view.rs @@ -9,7 +9,7 @@ use super::overlays::{ }; use super::screenshot::handle_screenshot_export; use super::shapes::{NetworkEdgeShape, NetworkNodeShape}; -use super::state::{EdgeData, NodeData, ScreenshotStateMachine, ViewState}; +use super::state::{NetworkEdge, NetworkNode, ScreenshotStateMachine, GraphViewState}; use crate::run::state::RunTabState; use crate::shared::constants::ui::FIT_TO_SCREEN_PADDING; use eframe::egui; @@ -26,11 +26,11 @@ pub fn render_graph_view(ui: &mut egui::Ui, state: &mut RunTabState) { let inner_response = egui::CentralPanel::default().show(ui.ctx(), |ui| { handle_window_resize(ui, &mut state.visualization.view); - let fit_to_screen = consume_reset_request(&mut state.visualization.view); + let fit_to_screen = take_reset_request(&mut state.visualization.view); let mut graph_view = egui_graphs::GraphView::< - NodeData, - EdgeData, + NetworkNode, + NetworkEdge, petgraph::Undirected, petgraph::stable_graph::DefaultIx, NetworkNodeShape, @@ -76,7 +76,7 @@ pub fn render_graph_view(ui: &mut egui::Ui, state: &mut RunTabState) { /// /// Compares current screen size with last known size. /// On change, requests a reset to recalculate zoom/pan. -fn handle_window_resize(ui: &egui::Ui, view: &mut ViewState) { +fn handle_window_resize(ui: &egui::Ui, view: &mut GraphViewState) { let screen_size = ui.ctx().content_rect().size(); match view.last_screen_size { Some(last) if last != screen_size => { @@ -92,7 +92,7 @@ fn handle_window_resize(ui: &egui::Ui, view: &mut ViewState) { /// /// Returns true for one frame when reset is requested, /// then clears the flag so it doesn't repeat. -fn consume_reset_request(view: &mut ViewState) -> bool { +fn take_reset_request(view: &mut GraphViewState) -> bool { let fit = view.reset_requested; if fit { view.reset_requested = false; @@ -104,7 +104,7 @@ fn consume_reset_request(view: &mut ViewState) -> bool { /// /// Must be called once after the graph is first rendered. /// The force-directed layout would override our circle layout. -fn disable_force_directed_layout(ui: &mut egui::Ui, view: &mut ViewState) { +fn disable_force_directed_layout(ui: &mut egui::Ui, view: &mut GraphViewState) { if view.layout_initialized { return; } diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs index e445450a..b6cfedd2 100644 --- a/crates/fosr-gui/src/run/tab.rs +++ b/crates/fosr-gui/src/run/tab.rs @@ -1,24 +1,24 @@ //! Run tab: live network visualization combined with PCAP generation controls. -use super::generation::bottom_panel::show_bottom_panel; +use super::generation::bottom_panel::render_bottom_panel; use super::generation::process::poll_generation_receivers; use super::graph::config_handling::handle_config_changes; use super::graph::flow_processing::{process_flow_events, update_active_links, update_graph_edges}; use super::graph::node_modal::{process_graph_events, render_node_info_modal}; use super::graph::view::render_graph_view; use super::state::RunTabState; -use crate::run::graph::state::{ViewState, VisualizationState}; +use crate::run::graph::state::{GraphViewState, VisualizationState}; use crate::shared::config::file_ops::load_config_file_contents; -use crate::shared::config::state::ConfigurationFileState; +use crate::shared::config::state::ConfigFileState; use eframe::egui; /// Display the Run tab content. /// /// Orchestrates visualization updates, event processing, and UI rendering. -pub fn show_run_tab_content( +pub fn render_run_tab( ui: &mut egui::Ui, state: &mut RunTabState, - configuration_file_state: &mut ConfigurationFileState, + configuration_file_state: &mut ConfigFileState, ) { load_config_file_contents(configuration_file_state); handle_config_changes(&mut state.visualization, configuration_file_state); @@ -32,11 +32,11 @@ pub fn show_run_tab_content( poll_generation_receivers(ui.ctx(), state); - show_bottom_panel(ui.ctx(), state, configuration_file_state); + render_bottom_panel(ui.ctx(), state, configuration_file_state); render_graph_view(ui, state); process_graph_events(&mut state.visualization, configuration_file_state); - + render_node_info_modal(ui.ctx(), &mut state.visualization, configuration_file_state); } @@ -64,7 +64,7 @@ fn handle_auto_start_visualization(state: &mut VisualizationState) { /// /// Waits for the countdown to reach zero before triggering a fit-to-screen. /// Used after panel toggles or on initial load to ensure proper layout. -fn handle_delayed_fit_to_screen(view: &mut ViewState) { +fn handle_delayed_fit_to_screen(view: &mut GraphViewState) { if let Some(countdown) = view.delayed_fit_countdown { if countdown > 0 { view.delayed_fit_countdown = Some(countdown - 1); diff --git a/crates/fosr-gui/src/shared/config/file_ops.rs b/crates/fosr-gui/src/shared/config/file_ops.rs index 6e2fd1c3..1ad58c5a 100644 --- a/crates/fosr-gui/src/shared/config/file_ops.rs +++ b/crates/fosr-gui/src/shared/config/file_ops.rs @@ -2,7 +2,7 @@ use crate::shared::config::model::Configuration; use crate::shared::config::parser::parse_config_yaml; -use crate::shared::config::state::ConfigurationFileState; +use crate::shared::config::state::ConfigFileState; #[cfg(not(target_arch = "wasm32"))] use crate::shared::file_io::{read_file_desktop, show_file_picker_desktop}; #[cfg(target_arch = "wasm32")] @@ -17,7 +17,7 @@ use std::sync::mpsc::channel; /// On desktop: synchronous file picker via native dialog. /// On WASM: async file picker, result arrives via `config_file_receiver`. /// Clears any previously loaded config state before picking a new file. -pub fn trigger_file_import(state: &mut ConfigurationFileState, ctx: &egui::Context) { +pub fn trigger_file_import(state: &mut ConfigFileState, ctx: &egui::Context) { state.config_file_content = None; #[cfg(target_arch = "wasm32")] { @@ -53,7 +53,7 @@ pub fn trigger_file_import(state: &mut ConfigurationFileState, ctx: &egui::Conte /// Should be called every frame in the UI to check for completed file picks. /// When a file is picked, clears the loaded config and sets up for content loading. #[cfg(target_arch = "wasm32")] -pub fn poll_file_import(state: &mut ConfigurationFileState) { +pub fn poll_file_import(state: &mut ConfigFileState) { if let Some(receiver) = &state.config_file_receiver { if let Ok(file) = receiver.try_recv() { if file.is_some() { @@ -72,7 +72,7 @@ pub fn poll_file_import(state: &mut ConfigurationFileState) { /// On WASM: spawns async read, parses YAML when content arrives via channel. /// /// Skips if content is already loaded to avoid re-reading every frame. -pub fn load_config_file_contents(configuration_file_state: &mut ConfigurationFileState) { +pub fn load_config_file_contents(configuration_file_state: &mut ConfigFileState) { // Already loaded — don't re-read from disk every frame if configuration_file_state.config_file_content.is_some() { return; @@ -125,7 +125,7 @@ pub fn load_config_file_contents(configuration_file_state: &mut ConfigurationFil } /// Clear all loaded config state to allow loading a new file. -fn clear_loaded_config(configuration_file_state: &mut ConfigurationFileState) { +fn clear_loaded_config(configuration_file_state: &mut ConfigFileState) { configuration_file_state.config_file_content = None; configuration_file_state.config_model = None; configuration_file_state.config_error = None; diff --git a/crates/fosr-gui/src/shared/config/model.rs b/crates/fosr-gui/src/shared/config/model.rs index 181f516b..438410bd 100644 --- a/crates/fosr-gui/src/shared/config/model.rs +++ b/crates/fosr-gui/src/shared/config/model.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; +/// Root configuration structure containing metadata and host definitions. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Configuration { #[serde(default)] @@ -11,6 +12,7 @@ pub struct Configuration { pub hosts: Vec, } +/// Metadata describing the configuration file. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Metadata { #[serde(skip_serializing_if = "Option::is_none")] @@ -27,6 +29,7 @@ pub struct Metadata { pub format: Option, } +/// Represents a host machine in the network configuration. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Host { #[serde(skip_serializing_if = "Option::is_none")] @@ -45,6 +48,7 @@ pub struct Host { pub interfaces: Vec, } +/// Network interface configuration for a host. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Interface { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/fosr-gui/src/shared/config/parser.rs b/crates/fosr-gui/src/shared/config/parser.rs index 8d763cf6..0eed5db4 100644 --- a/crates/fosr-gui/src/shared/config/parser.rs +++ b/crates/fosr-gui/src/shared/config/parser.rs @@ -1,12 +1,12 @@ //! Configuration YAML parsing. use crate::shared::config::model::Configuration; -use crate::shared::config::state::ConfigurationFileState; +use crate::shared::config::state::ConfigFileState; /// Parse the YAML content into a Configuration model. /// Updates the state with the parsed model or an error message. /// Handles change detection using a snapshot of the "clean" configuration. -pub fn parse_config_yaml(configuration_file_state: &mut ConfigurationFileState) { +pub fn parse_config_yaml(configuration_file_state: &mut ConfigFileState) { configuration_file_state.config_model = None; configuration_file_state.config_error = None; diff --git a/crates/fosr-gui/src/shared/config/state.rs b/crates/fosr-gui/src/shared/config/state.rs index 5436c534..351c6820 100644 --- a/crates/fosr-gui/src/shared/config/state.rs +++ b/crates/fosr-gui/src/shared/config/state.rs @@ -7,13 +7,14 @@ use std::sync::mpsc::Receiver; /// State for the startup modal flow. #[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] -pub enum StartupModalState { +pub enum StartupModalStep { #[default] Initial, TemplateSelection, } -pub struct ConfigurationFileState { +/// State for configuration file management including content, model, and UI state. +pub struct ConfigFileState { pub picked_config_file: Option, #[cfg(target_arch = "wasm32")] pub config_file_receiver: Option>>, @@ -32,12 +33,12 @@ pub struct ConfigurationFileState { /// Updated by the configuration tab rendering each frame. pub has_errors: bool, /// Current state of the startup modal. - pub modal_state: StartupModalState, + pub modal_state: StartupModalStep, /// The ID of the currently loaded template, if any. pub loaded_template_id: Option, } -impl Default for ConfigurationFileState { +impl Default for ConfigFileState { fn default() -> Self { Self { picked_config_file: None, @@ -52,7 +53,7 @@ impl Default for ConfigurationFileState { is_dirty: false, clean_snapshot: None, has_errors: false, - modal_state: StartupModalState::Initial, + modal_state: StartupModalStep::Initial, loaded_template_id: None, } } diff --git a/crates/fosr-gui/src/shared/constants/network.rs b/crates/fosr-gui/src/shared/constants/network.rs index 63c040ff..5c6aac28 100644 --- a/crates/fosr-gui/src/shared/constants/network.rs +++ b/crates/fosr-gui/src/shared/constants/network.rs @@ -11,7 +11,7 @@ pub const PORT_MIN: u16 = 1; pub const PORT_MAX: u16 = 65535; /// Default port when service is unknown -pub const PORT_DEFAULT_UNKNOWN: u16 = 0; +pub const PORT_UNSPECIFIED: u16 = 0; // ============================================================================ // IP ADDRESS RANGES diff --git a/crates/fosr-gui/src/shared/file_io.rs b/crates/fosr-gui/src/shared/file_io.rs index 47ec26a6..878d68b5 100644 --- a/crates/fosr-gui/src/shared/file_io.rs +++ b/crates/fosr-gui/src/shared/file_io.rs @@ -3,6 +3,7 @@ use rfd::FileHandle; use std::io::Error; +/// Opens a native file picker dialog for selecting a YAML configuration file (desktop only). #[cfg(not(target_arch = "wasm32"))] pub fn show_file_picker_desktop() -> Option { rfd::FileDialog::new() @@ -12,6 +13,7 @@ pub fn show_file_picker_desktop() -> Option { .map(|path| FileHandle::from(path)) } +/// Opens an async file picker dialog for selecting a YAML configuration file (WASM only). #[cfg(target_arch = "wasm32")] pub async fn show_file_picker_wasm() -> Option { rfd::AsyncFileDialog::new() @@ -20,18 +22,21 @@ pub async fn show_file_picker_wasm() -> Option { .await } +/// Reads file content synchronously from the given file handle (desktop only). #[cfg(not(target_arch = "wasm32"))] pub fn read_file_desktop(file_handle: &FileHandle) -> Result { std::fs::read_to_string(file_handle.path()) .map_err(|e| format!("Failed to read file: {}", e)) } +/// Reads file content asynchronously from the given file handle (WASM only). #[cfg(target_arch = "wasm32")] pub async fn read_file_wasm(file_handle: &FileHandle) -> Result { let content = file_handle.read().await; String::from_utf8(content).map_err(|e| format!("Invalid UTF-8 in file: {}", e)) } +/// Opens a save dialog and writes data to the selected file (desktop only). #[cfg(not(target_arch = "wasm32"))] pub fn save_file_desktop(data: &[u8], file_name: &str) -> Result { let result = rfd::FileDialog::new() @@ -49,6 +54,7 @@ pub fn save_file_desktop(data: &[u8], file_name: &str) -> Result Result { let result = rfd::AsyncFileDialog::new() diff --git a/crates/fosr-gui/src/shared/widgets/helpers.rs b/crates/fosr-gui/src/shared/widgets/helpers.rs index 7847672f..0b86ac8e 100644 --- a/crates/fosr-gui/src/shared/widgets/helpers.rs +++ b/crates/fosr-gui/src/shared/widgets/helpers.rs @@ -30,7 +30,7 @@ pub fn info_icon_with_tooltip(ui: &mut egui::Ui, tooltip: &str) { /// the field is set back to `None`. /// /// This prevents exporting empty strings in YAML. -pub fn edit_optional_string_singleline( +pub fn render_optional_string_input( ui: &mut egui::Ui, label: &str, value: &mut Option, @@ -111,7 +111,7 @@ pub fn labeled_toggle( /// - If the user clears the text (or clicks "Clear"), it becomes `None`. /// /// This prevents exporting empty strings as `''` in YAML. -pub fn edit_optional_string_multiline( +pub fn render_optional_text_area( ui: &mut egui::Ui, label: &str, value: &mut Option, diff --git a/crates/fosr-gui/src/shared/widgets/time_picker/button.rs b/crates/fosr-gui/src/shared/widgets/time_picker/button.rs index 71101b16..c1243505 100644 --- a/crates/fosr-gui/src/shared/widgets/time_picker/button.rs +++ b/crates/fosr-gui/src/shared/widgets/time_picker/button.rs @@ -15,38 +15,42 @@ pub(crate) struct TimePickerButtonState { pub picker_visible: bool, } +/// Button widget that opens a time picker popup on click. pub struct TimePickerButton<'a> { selection: &'a mut NaiveTime, id_salt: Option<&'a str>, show_icon: bool, format: String, - show_clockface: bool, + show_clock_face: bool, use_12_hour_clock: bool, show_seconds: bool, - use_dragvalue: bool, + use_drag_value: bool, } impl<'a> TimePickerButton<'a> { + /// Creates a new time picker button bound to the given time selection. pub fn new(selection: &'a mut NaiveTime) -> Self { Self { selection, id_salt: None, show_icon: true, format: "%H:%M".to_string(), - show_clockface: true, + show_clock_face: true, use_12_hour_clock: false, show_seconds: false, - use_dragvalue: false, + use_drag_value: false, } } + /// Sets whether to show seconds in the time picker. 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; + /// Sets whether to use drag value input style for time selection. + pub fn use_drag_value(mut self, use_drag_value: bool) -> Self { + self.use_drag_value = use_drag_value; self } } @@ -111,12 +115,12 @@ impl Widget for TimePickerButton<'_> { TimePickerPopup { selection: self.selection, button_id: id, - show_clockface: self.show_clockface, + show_clock_face: self.show_clock_face, show_seconds: self.show_seconds, use_12_hour_clock: self.use_12_hour_clock, - use_dragvalue: self.use_dragvalue, + use_drag_value: self.use_drag_value, } - .draw(ui) + .draw(ui) }) .inner }); diff --git a/crates/fosr-gui/src/shared/widgets/time_picker/popup.rs b/crates/fosr-gui/src/shared/widgets/time_picker/popup.rs index 923104ca..c09d9e86 100644 --- a/crates/fosr-gui/src/shared/widgets/time_picker/popup.rs +++ b/crates/fosr-gui/src/shared/widgets/time_picker/popup.rs @@ -51,10 +51,10 @@ pub(crate) struct TimePickerPopupState { pub(crate) struct TimePickerPopup<'a> { pub selection: &'a mut NaiveTime, pub button_id: Id, - pub show_clockface: bool, + pub show_clock_face: bool, pub use_12_hour_clock: bool, pub show_seconds: bool, - pub use_dragvalue: bool, + pub use_drag_value: bool, } impl TimePickerPopup<'_> { @@ -78,7 +78,7 @@ impl TimePickerPopup<'_> { let mut minute_rect = None; let mut second_rect = None; - if self.use_dragvalue { + if self.use_drag_value { let range = if self.use_12_hour_clock { 0..=11 } else { @@ -99,7 +99,7 @@ impl TimePickerPopup<'_> { ui.label(RichText::new("h :").monospace()); - if self.use_dragvalue { + if self.use_drag_value { let r = ui.add(DragValue::new(&mut popup_state.minute).range(0..=59)); minute_rect = Some(r.rect); if r.clicked() || r.changed() { @@ -115,7 +115,7 @@ impl TimePickerPopup<'_> { if self.show_seconds { ui.label(RichText::new("m :").monospace()); - if self.use_dragvalue { + if self.use_drag_value { let r = ui.add(DragValue::new(&mut popup_state.second).range(0..=59)); second_rect = Some(r.rect); if r.clicked() || r.changed() { @@ -176,7 +176,7 @@ impl TimePickerPopup<'_> { TimeFrame::Second => &mut popup_state.second, }; - if self.show_clockface { + if self.show_clock_face { draw_timepicker( r_outer, r_inner,