diff --git a/Cargo.lock b/Cargo.lock index 06d751b2..8c09ddc7 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", ] @@ -214,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]] @@ -225,7 +230,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -281,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.2" @@ -362,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" @@ -402,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" @@ -664,6 +625,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" @@ -898,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" @@ -1104,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" @@ -1113,6 +1097,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" @@ -1122,6 +1119,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" @@ -1270,6 +1295,7 @@ checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" dependencies = [ "bytemuck", "emath", + "serde", ] [[package]] @@ -1321,6 +1347,8 @@ dependencies = [ "log", "nohash-hasher", "profiling", + "ron", + "serde", "smallvec", "unicode-segmentation", ] @@ -1383,6 +1411,7 @@ dependencies = [ "mime_guess2", "profiling", "resvg", + "serde", "syntect", ] @@ -1403,6 +1432,30 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_graphs" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0673eb7bb77708b8f59ef30fd90fa6537026f078fe72579ee68720073b6c8423" +dependencies = [ + "crossbeam", + "egui", + "getrandom 0.2.17", + "petgraph", + "rand 0.9.2", + "serde", + "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" @@ -1430,6 +1483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -1451,6 +1505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" dependencies = [ "enum-map-derive", + "serde", ] [[package]] @@ -1485,6 +1540,17 @@ dependencies = [ "syn 2.0.114", ] +[[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.114", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -1529,6 +1595,7 @@ dependencies = [ "nohash-hasher", "parking_lot", "profiling", + "serde", ] [[package]] @@ -1550,7 +1617,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]] @@ -1641,6 +1708,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +[[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.8" @@ -1756,12 +1829,19 @@ dependencies = [ "chrono-tz", "eframe", "egui_extras", + "egui_graphs", + "egui_material_icons", "env_logger", "fosr-lib", + "gloo-timers", "humantime", "indicatif", "log", + "petgraph", + "rand 0.10.0", "rfd", + "serde", + "serde_yaml", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -1855,10 +1935,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", @@ -1891,8 +1969,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1904,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" @@ -1936,6 +2030,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" @@ -2139,7 +2245,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.61.2", ] [[package]] @@ -2232,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" @@ -2315,6 +2427,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -2466,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" @@ -3162,6 +3282,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" @@ -3501,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" @@ -3587,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" @@ -3606,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" @@ -3631,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" @@ -3740,26 +3906,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]] @@ -3785,6 +3954,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" @@ -3826,7 +4008,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4017,7 +4199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4267,7 +4449,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4581,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" @@ -4625,15 +4813,8 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] -[[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" @@ -4719,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" @@ -4778,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" @@ -5157,7 +5381,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]] @@ -5659,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" @@ -6009,7 +6315,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 b1c1a133..887417ae 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -29,18 +29,25 @@ native = [] chrono = "0.4.43" 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.29" wasm-bindgen = "0.2.108" 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" +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"] } 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/assets/computer.png b/crates/fosr-gui/assets/computer.png new file mode 100644 index 00000000..4221f9ca Binary files /dev/null and b/crates/fosr-gui/assets/computer.png differ diff --git a/crates/fosr-gui/assets/internet.png b/crates/fosr-gui/assets/internet.png new file mode 100644 index 00000000..9312f396 Binary files /dev/null and b/crates/fosr-gui/assets/internet.png differ diff --git a/crates/fosr-gui/assets/server.png b/crates/fosr-gui/assets/server.png new file mode 100644 index 00000000..496574a5 Binary files /dev/null and b/crates/fosr-gui/assets/server.png differ diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs index 1c52912c..bb9c825f 100644 --- a/crates/fosr-gui/src/app.rs +++ b/crates/fosr-gui/src/app.rs @@ -3,9 +3,13 @@ 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; -use crate::shared::configuration_file::ConfigurationFileState; +// #[cfg(not(target_arch = "wasm32"))] +// use crate::injection_tab::show_injection_tab_content; +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, }; @@ -18,20 +22,25 @@ enum CurrentTab { Configuration, Visualization, Generation, - #[cfg(not(target_arch = "wasm32"))] - Injection, + // To be implemented + // #[cfg(not(target_arch = "wasm32"))] + // Injection, About, } impl Default for CurrentTab { fn default() -> Self { - CurrentTab::Generation + CurrentTab::Visualization } } +pub const DEFAULT_ZOOM: f32 = 1.4; + #[derive(Default)] pub struct FosrApp { current_tab: CurrentTab, + style_initialized: bool, + images_preloaded: bool, configuration_file_state: ConfigurationFileState, configuration_tab_state: ConfigurationTabState, visualization_tab_state: VisualizationTabState, @@ -40,90 +49,238 @@ 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.style_initialized { + ctx.options_mut(|option| option.zoom_factor = DEFAULT_ZOOM); + ctx.style_mut(|s| s.interaction.tooltip_delay = 0.1); + self.style_initialized = true; + } + // 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 _ = 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 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); 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", - ) - .clicked() - { - self.current_tab = CurrentTab::Visualization; - } - #[cfg(not(target_arch = "wasm32"))] - if ui - .selectable_label(self.current_tab == CurrentTab::Injection, "Injection") + .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::Injection; + 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.") .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); + + // 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(); + } + } + } + } + } + }); }); }); + // 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| { - // 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); - } - #[cfg(not(target_arch = "wasm32"))] - CurrentTab::Injection => { - show_injection_tab_content(ui); - } - CurrentTab::About => { - show_about_tab_content(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 + 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, + &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); + } + } + }); }); } } + +/// 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| { + // 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(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/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs index 26f96b43..463077c9 100644 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ b/crates/fosr-gui/src/configuration/configuration_tab.rs @@ -1,84 +1,991 @@ +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; +use egui::{TextFormat, text::LayoutJob}; +use std::collections::HashMap; -/** - * Represents the state of the configuration tab. - */ -pub struct ConfigurationTabState {} +/// 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 { + 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, - _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); + // Eagerly load config file contents when a file is selected + load_config_file_contents(file_state); - ui.separator(); + egui::ScrollArea::vertical().show(ui, |ui| { + // File Selection + configuration_file_picker(ui, tab_state, file_state); - // Config file editor - if configuration_file_state.picked_config_file.is_none() { - ui.label("No configuration file selected"); - } else { - if configuration_file_state.config_file_content.is_none() { - ui.label("Loading configuration file..."); - load_config_file_contents(configuration_file_state); + ui_parsing_status(ui, file_state); + + if file_state.config_chosen { + ui.separator(); + + 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(); + 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 { + 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; + } + } + } else { + ui_yaml_editor(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 state.config_model.is_some() { + 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."); + } + ui.separator(); + } +} + +/// Metadata rendering +fn ui_metadata(ui: &mut egui::Ui, model: &mut Configuration) { + ui.add_space(6.0); + + // Title + ui.horizontal(|ui| { + 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", + &mut model.metadata.desc, + "Optional description", + 3, + ); + + edit_optional_string(ui, "Author", &mut model.metadata.author, "Jane Doe"); + + 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.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 { - 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"; + 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, - content, - language, + text.as_str(), + "yaml", ); - layout_job.wrap.max_width = ui.available_width(); - egui::ScrollArea::vertical().show(ui, |ui| { + 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::Label::new(layout_job).selectable(true), // Allows the user to copy the code even if they can't edit it - ); + egui::TextEdit::multiline(&mut content) + .font(egui::TextStyle::Monospace) + .code_editor() + .desired_rows(20) + .lock_focus(true) + .desired_width(f32::INFINITY) + .layouter(&mut layouter), + ) }); - // 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), - // ); - // }); - } + 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/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/generation/generation_core.rs b/crates/fosr-gui/src/generation/generation_core.rs index 702feb36..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}; @@ -18,6 +20,8 @@ pub fn generate( timezone: Option, progress_sender: Option>, pcap_sender: Option>>, + throughput_sender: Option>, + cancelled: Arc, ) { // Create a closure to send progress updates let send_progress = |progress: f32| { @@ -104,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); + run_single_thread(order_pcap, s0, s1, s2, s3, send_progress, send_pcap, throughput_sender, cancelled); } fn run_single_thread( @@ -115,19 +119,26 @@ fn run_single_thread( s3: stage3::Stage3, 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![]; @@ -136,28 +147,36 @@ 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(); 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 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 b033df90..043cc57d 100644 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ b/crates/fosr-gui/src/generation/generation_tab.rs @@ -1,28 +1,34 @@ 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_start_hour, validate_timezone, + validate_timezone, +}; +use crate::shared::configuration_file::{ + ConfigurationFileState, load_config_file_contents, }; -use crate::shared::configuration_file::{ConfigurationFileState, configuration_file_picker}; +use crate::shared::ui_utils::info_icon; #[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 chrono::NaiveDate; +use crate::shared::file_io::save_file_wasm; +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}; 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, @@ -34,59 +40,60 @@ 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>, 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 start_hour_validation: FieldValidation, pub seed_validation: FieldValidation, pub timezone_validation: FieldValidation, // Parameters 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, + 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, } 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, 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(), - start_hour_validation: FieldValidation::default(), seed_validation: FieldValidation::default(), timezone_validation: FieldValidation::default(), // Parameters - order_pcap: false, + 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(), + 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_date: Local::now().date_naive(), + start_hour: Local::now().time(), output_file_name: "output.pcap".to_string(), } } @@ -97,37 +104,30 @@ pub fn show_generation_tab_content( state: &mut GenerationTabState, configuration_file_state: &mut ConfigurationFileState, ) { - configuration_file_picker(ui, configuration_file_state); - - 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(); - } + // 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 response = egui::TextEdit::singleline(&mut state.duration_str) + 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) => { + Ok(_) => { state.duration_validation.set_ok(); - state.duration_slider_value = duration_to_slider(d); } Err(msg) => { state.duration_validation.set_err(msg); @@ -138,274 +138,347 @@ 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.horizontal(|ui| { - ui.label("Start time"); + 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."); + }); - ui.add(DatePickerButton::new(&mut state.start_date)); + 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), + ); + }); - let response = egui::TextEdit::singleline(&mut state.start_hour) - .hint_text("HH:MM") - .desired_width(50.0) - .ui(ui); + ui.add_space(10.0); - 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), + 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); - show_field_error(ui, &state.start_hour_validation); - }); + 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()); + } + } + }); - ui.add_space(10.0); + } else { + state.timezone_validation.set_ok(); + } - 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(); - } - } - 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()); - } + // 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.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"); + 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); + show_field_error(ui, &state.seed_validation); + } else { + state.seed_validation.set_ok(); + } }); 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"); + 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((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); ui.horizontal(|ui| { - ui.add_enabled_ui(can_generate, |ui| { - if ui.button("Generate").clicked() { - state.status = UiStatus::Generating; - - // Reset the progress value + if is_generating { + 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)); + if ui.add(stop_button).on_hover_text("Stop").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; + } + } - 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 seed = state.seed_input.parse::().ok(); - let order_pcap = state.order_pcap; - let start_time = 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() { - None - } else { - Some(state.timezone_input.clone()) - }; - let ctx = ui.ctx().clone(); - let file_handle = configuration_file_state.picked_config_file.clone(); + 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(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() { + 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 { - 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), - ); - ctx.request_repaint(); - }); + #[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(); + }); + } } + }); + } - #[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), - ); - 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.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; - } - } + 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 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_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_tooltip = "Download"; + 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() { + 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"; - if ui.button(save_button_label).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); - 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); + } + + show_status(ui, &state.status); - ui.add_sized([ui.available_width(), 20.0], progress); + 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})"), + ); + } } diff --git a/crates/fosr-gui/src/generation/generation_ui_components.rs b/crates/fosr-gui/src/generation/generation_ui_components.rs index 026779e5..5b1df0c1 100644 --- a/crates/fosr-gui/src/generation/generation_ui_components.rs +++ b/crates/fosr-gui/src/generation/generation_ui_components.rs @@ -1,11 +1,35 @@ +// 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 - */ +/// 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); @@ -34,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(); + } + } } - } - } + }); }); } 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 cafcb8f6..00000000 --- a/crates/fosr-gui/src/generation/generation_utils.rs +++ /dev/null @@ -1,62 +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/generation_validation.rs b/crates/fosr-gui/src/generation/generation_validation.rs index cd8d13e5..3d7d4f3b 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, @@ -21,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"; @@ -32,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())); } @@ -57,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/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; 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; diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index f274a927..52ec90b0 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; @@ -15,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(); @@ -36,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 c713c929..35889231 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 @@ -18,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([1200.0, 1000.0]) + .with_min_inner_size([550.0, 500.0]) .with_title("Fos-R"); #[cfg(target_os = "macos")] @@ -41,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/config_model.rs b/crates/fosr-gui/src/shared/config_model.rs new file mode 100644 index 00000000..cfe961f1 --- /dev/null +++ b/crates/fosr-gui/src/shared/config_model.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Configuration { + #[serde(default)] + pub metadata: Metadata, + + #[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, skip_serializing_if = "Vec::is_empty")] + pub client: Vec, + + #[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, skip_serializing_if = "Vec::is_empty")] + 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..890068a9 100644 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ b/crates/fosr-gui/src/shared/configuration_file.rs @@ -1,12 +1,18 @@ #[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 crate::{ + configuration::configuration_tab::ConfigurationTabState, shared::config_model::Configuration, +}; +use chrono::{DateTime, Local}; use eframe::egui; 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")] @@ -14,6 +20,13 @@ 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, + /// 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 { @@ -25,67 +38,157 @@ impl Default for ConfigurationFileState { 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, + } + } +} + +/// 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) { + 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; } } } 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:"); - // File Dialog to pick a config file - if ui.button("Select file").clicked() { - #[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; - } - } - - #[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(); - }); - } + 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")] - // Check if we received a file from the async task + 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 { + file.file_name() + } 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() { - 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; + configuration_file_state.picked_config_file = None; + 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(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); + } } - configuration_file_state.config_file_receiver = None; // Dialog finished + } + + #[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), + } + }); } } } - // 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() { - configuration_file_state.picked_config_file = None; - }; - // On desktop: filename with its full path on hover, on WASM: just the filename #[cfg(not(target_arch = "wasm32"))] { @@ -93,21 +196,75 @@ 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()); - ui.label(&filename).on_hover_text(path_text); + .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) + .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); + { + 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); + }); }); } 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"))] { 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 +286,62 @@ 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); + } } } } } + +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; + 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")] + { + 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); + + #[cfg(target_arch = "wasm32")] + { + 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); +} diff --git a/crates/fosr-gui/src/shared/mod.rs b/crates/fosr-gui/src/shared/mod.rs index 65b099e1..69e0f1f5 100644 --- a/crates/fosr-gui/src/shared/mod.rs +++ b/crates/fosr-gui/src/shared/mod.rs @@ -1,2 +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..902de2e5 --- /dev/null +++ b/crates/fosr-gui/src/shared/ui_utils.rs @@ -0,0 +1,105 @@ +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); + }); +} + +/// 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(egui_material_icons::icons::ICON_CLEAR).on_hover_text("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(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 new file mode 100644 index 00000000..d0f21992 --- /dev/null +++ b/crates/fosr-gui/src/timepicker/button.rs @@ -0,0 +1,138 @@ +// 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}; + +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..a5857691 --- /dev/null +++ b/crates/fosr-gui/src/timepicker/mod.rs @@ -0,0 +1,35 @@ +// 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 +// - 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..a90d9c3f --- /dev/null +++ b/crates/fosr-gui/src/timepicker/popup.rs @@ -0,0 +1,370 @@ +// 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}; +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); +} diff --git a/crates/fosr-gui/src/visualization/mod.rs b/crates/fosr-gui/src/visualization/mod.rs index e2f33c50..b04ef22d 100644 --- a/crates/fosr-gui/src/visualization/mod.rs +++ b/crates/fosr-gui/src/visualization/mod.rs @@ -1 +1,4 @@ -pub mod visualization_tab; \ No newline at end of file +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..ebb2bd36 --- /dev/null +++ b/crates/fosr-gui/src/visualization/visualization_shapes.rs @@ -0,0 +1,326 @@ +//! Custom node and edge shapes for network visualization + +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}; +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"); + +// 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 { + radius: f32, + label: String, + location: Pos2, + node_type: NodeType, +} + +impl NetworkNodeShape { + /// Compute node style from payload data. + 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 { + // 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) + }; + + (radius, payload.node_type.clone(), 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, node_type, label) = Self::style_from_payload(&props.payload); + Self { + radius, + label, + 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 { + 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)); + + 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)); + 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 + 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, + ); + + 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 (radius, node_type, label) = Self::style_from_payload(&state.payload); + self.radius = radius; + self.node_type = node_type; + self.label = label; + 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, direction, and flow count +fn edge_style(edge_data: &EdgeData) -> (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) + }; + (COLOR_INACTIVE, width, false, false) + } + EdgeState::Active { protocol, direction, .. } => { + 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, + }; + let (arrow_start, arrow_end) = match direction { + LinkDirection::Forward => (false, true), + LinkDirection::Backward => (true, false), + LinkDirection::Bidirectional => (true, true), + }; + (color, EDGE_WIDTH_MAX, 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; + // 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 { + 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 { + 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 + } + + 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..4f7ce49c --- /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; + } + } + + 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 0cd3c8c5..328da70e 100644 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ b/crates/fosr-gui/src/visualization/visualization_tab.rs @@ -1,19 +1,1089 @@ +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, +}; +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::{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; +use web_time::Instant; -/** - * Represents the state of the visualization tab. - */ -pub struct VisualizationTabState {} +/// 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, + } + } +} + +/// 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, +>; + +/// 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, + /// Edit buffer for the node info modal (cloned from config on open, applied on Save) + modal_edit_buffer: Option, +} impl Default for VisualizationTabState { fn default() -> Self { - 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, + 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) { + 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, - _visualization_tab_state: &mut VisualizationTabState, + 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 + let was_config_removed = + state.config_content.is_some() && configuration_file_state.config_file_content.is_none(); + + 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(); + state.reset_view_requested = true; + 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(); + } + + 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); + 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, ) { - ui.heading("COMING SOON"); + 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()); + }); + } + 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) { + 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); + + // 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 { + // 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(); + 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]; + // 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(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: {}", 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(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")); + }).response.on_hover_text("Node types"); + }); + + // 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); + }).response.on_hover_text("Link protocols"); + }); + }); } 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..9245656a --- /dev/null +++ b/crates/fosr-gui/src/visualization/visualization_utils.rs @@ -0,0 +1,26 @@ +use eframe::egui; + + +/// 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() * 80.0 + 100.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())); + } + } +} 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, } diff --git a/public/styles.css b/public/styles.css index cb5a7f97..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,13 +115,13 @@ table { } table tr:nth-child(even) { - background: var(--bd-pre-bg); + background: var(--bd-pre-bg); } #gui_wrapper { position: relative; width: 100%; - height: 440px; + height: 550px; background: var(--bs-body-bg, #ffffff); margin: 15px auto; border-radius: 0.5rem;