diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7e2e33cf..ffee2d80 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -162,7 +162,7 @@ build-wasm32: image: rust script: - rustup target add wasm32-unknown-unknown - - cargo install wasm-bindgen-cli --version 0.2.108 + - cargo install wasm-bindgen-cli --version 0.2.114 - cargo build -p fosr-gui -r --target wasm32-unknown-unknown --no-default-features - wasm-bindgen --out-dir public --target web target/wasm32-unknown-unknown/release/fosr_gui.wasm --no-typescript artifacts: diff --git a/Cargo.lock b/Cargo.lock index 06d751b2..fad79bd4 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" @@ -386,7 +358,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.3", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -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" @@ -428,7 +389,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.3", + "rustix 1.1.4", ] [[package]] @@ -454,7 +415,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.3", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -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" @@ -797,7 +761,7 @@ checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" dependencies = [ "bitflags 2.10.0", "polling", - "rustix 1.1.3", + "rustix 1.1.4", "slab", "tracing", ] @@ -821,7 +785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.3", - "rustix 1.1.3", + "rustix 1.1.4", "wayland-backend", "wayland-client", ] @@ -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" @@ -1494,12 +1560,6 @@ dependencies = [ "log", ] -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - [[package]] name = "env_logger" version = "0.11.8" @@ -1529,6 +1589,7 @@ dependencies = [ "nohash-hasher", "parking_lot", "profiling", + "serde", ] [[package]] @@ -1550,7 +1611,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 +1702,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,16 +1823,28 @@ dependencies = [ "chrono-tz", "eframe", "egui_extras", + "egui_graphs", + "egui_material_icons", "env_logger", "fosr-lib", + "getrandom 0.4.2", + "gloo-timers", "humantime", + "image", "indicatif", "log", + "open", + "petgraph", + "rand 0.10.0", "rfd", + "serde", + "serde_yaml", + "tempfile", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "web-time", + "which", ] [[package]] @@ -1855,10 +1934,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", @@ -1880,7 +1957,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -1891,8 +1968,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1904,8 +1983,24 @@ 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", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", "wasip2", + "wasip3", "wasm-bindgen", ] @@ -1936,6 +2031,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 +2246,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.61.2", ] [[package]] @@ -2232,6 +2339,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 +2428,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -2339,6 +2454,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2418,9 +2552,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -2466,11 +2600,17 @@ 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" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libloading" @@ -2513,9 +2653,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -3072,6 +3212,17 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "orbclient" version = "0.3.50" @@ -3145,6 +3296,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pcap-file" version = "2.0.0" @@ -3162,6 +3319,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" @@ -3419,7 +3589,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3501,6 +3671,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 +3767,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 +3792,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 +3828,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 +3943,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 +3991,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" @@ -3818,15 +4037,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -4017,7 +4236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4117,7 +4336,7 @@ dependencies = [ "libc", "log", "memmap2", - "rustix 1.1.3", + "rustix 1.1.4", "thiserror 2.0.17", "wayland-backend", "wayland-client", @@ -4259,15 +4478,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", - "rustix 1.1.3", - "windows-sys 0.52.0", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -4581,6 +4800,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 +4850,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,11 +4937,20 @@ 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" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -4734,9 +4961,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", "futures-util", @@ -4748,9 +4975,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4758,9 +4985,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -4771,13 +4998,47 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] +[[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" @@ -4786,7 +5047,7 @@ checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.3", + "rustix 1.1.4", "scoped-tls", "smallvec", "wayland-sys", @@ -4799,7 +5060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ "bitflags 2.10.0", - "rustix 1.1.3", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -4821,7 +5082,7 @@ version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "wayland-client", "xcursor", ] @@ -4915,9 +5176,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -5126,13 +5387,11 @@ dependencies = [ [[package]] name = "which" -version = "8.0.0" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" dependencies = [ - "env_home", - "rustix 1.1.3", - "winsafe", + "libc", ] [[package]] @@ -5157,7 +5416,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]] @@ -5649,16 +5908,92 @@ dependencies = [ ] [[package]] -name = "winsafe" -version = "0.0.19" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "wit-bindgen" +name = "wit-bindgen-core" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +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" @@ -5688,7 +6023,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "x11rb-protocol", ] @@ -5795,7 +6130,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.3", + "rustix 1.1.4", "serde", "serde_repr", "tracing", @@ -6009,7 +6344,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..e20349df 100644 --- a/crates/fosr-gui/Cargo.toml +++ b/crates/fosr-gui/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" description = "A GUI for Fos-R." +# Bundle metadata for macOS app [package.metadata.bundle] name = "Fos-R" identifier = "com.inria.fosr" @@ -29,19 +30,34 @@ 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" +image = { version = "0.25", default-features = false, features = ["png"] } [target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2.114" +gloo-timers = { version = "0.3", features = ["futures"] } wasm-bindgen-futures = "0.4.58" -web-sys = "0.3.85" +web-sys = { version = "0.3.85", features = ["Element", "Document", "Window"] } +getrandom = { version = "0.4", features = ["wasm_js"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -env_logger = { version = "0.11.8", default-features = false, features = ["auto-color", "humantime"] } +env_logger = { version = "0.11.8", default-features = false, features = [ + "auto-color", + "humantime", +] } +tempfile = "3.26.0" +open = "5.3.3" +which = "8.0.2" diff --git a/crates/fosr-gui/README.md b/crates/fosr-gui/README.md index b67007eb..b34373c8 100644 --- a/crates/fosr-gui/README.md +++ b/crates/fosr-gui/README.md @@ -2,35 +2,50 @@ ## Architecture +### Module Overview + +| Module | Purpose | +|---------------------|---------------------------------------------------------------| +| `app/` | Core application: tab navigation, startup modal, close dialog | +| `config_editor/` | Visual and YAML configuration editor | +| `run/` | PCAP generation + live network visualization | +| `shared/` | Reusable: config model, constants, widgets | +| `config_templates/` | Pre-built network configuration templates | +| `about_tab.rs` | About page content | + ### File Structure + ``` Fos-R/ ├── crates/ │ └── fosr-gui/ │ ├── src/ -│ │ ├── main.rs # Desktop entry point (native binary) -│ │ ├── lib.rs # Web entry point (WASM export) -│ │ ├── app.rs # Core application logic (shared) -│ │ └── ui/ # UI components +│ │ ├── main.rs # Desktop entry point (native binary) +│ │ ├── lib.rs # Web entry point (WASM export) +│ │ ├── app/ # Core application +│ │ ├── config_editor/ # Configuration editor +│ │ ├── run/ # Generation + visualization +│ │ │ ├── generation/ # PCAP generation logic +│ │ │ └── graph/ # Network graph visualization +│ │ ├── shared/ # Reusable components +│ │ │ ├── config/ # Config model and state +│ │ │ ├── constants/ # Colors, UI constants +│ │ │ └── widgets/ # Reusable UI widgets +│ │ └── config_templates/ # Pre-built templates │ └── Cargo.toml └── public/ - ├── index.html # Web interface + ├── index.html # Web interface └── (generated WASM and JS files) ``` -### How It Works - -- `main.rs`: Desktop-only. Runs a native window via `eframe`. -- `lib.rs`: WASM-only. Exports a `start()` function callable from JavaScript. -- `app.rs`: Shared UI logic between desktop and web builds. -- `index.html`: Loads the WASM binary and initializes the app in a canvas element. - ## Building & Running ### Desktop (Native) + ```shell cargo run -p fosr-gui -r ``` + This compiles and launches the native GUI. #### macOS Bundling @@ -45,53 +60,89 @@ cargo bundle -p fosr-gui -r -f osx ### Web (WASM) #### Step 0: Requirements + You need to have the wasm32 toolchain and the `wasm-bindgen` CLI installed: + ```shell rustup target add wasm32-unknown-unknown cargo install wasm-bindgen-cli ``` +Note: the version of the `wasm-bindgen` CLI must match the version of the `wasm-bindgen` crate declared in `Cargo.toml`. + #### Step 1: Compile to WASM + ```shell cargo build -p fosr-gui -r --target wasm32-unknown-unknown --no-default-features ``` + Output: `Fos-R/target/wasm32-unknown-unknown/release/fosr_gui.wasm` #### Step 2: Generate JavaScript Glue + ```shell wasm-bindgen --out-dir public --target web target/wasm32-unknown-unknown/release/fosr_gui.wasm --no-typescript ``` + Generates in `Fos-R/public/`: + - `fosr_gui.js` - JavaScript glue code - `fosr_gui_bg.wasm` - WASM binary -### Step 3: Embedding in the HTML file +#### Step 3: Embedding in the HTML file + +##### HTML Structure -#### HTML Structure ```html +
``` -#### JavaScript +##### JavaScript + ```html + ``` -### Step 4: Serve with an HTTP server +#### Step 4: Serve with an HTTP server + Here is an example using `http-server`: + ```shell # Install http-server npm install -g http-server # Serve from project root http-server ./public -p 8080 -``` \ No newline at end of file +``` + +#### Shell script + +Use the `build-web.sh` script to automate the build and serve process (requires `http-server` and a pre-built +`public/index.html`). + +#### Generating `index.html` + +The `public/index.html` file is not versioned and must be generated from markdown sources using pandoc: + +```shell +# Install pandoc (if not already installed) + +# Generate index.html +./public/generate-index-html.sh +``` + +Note: This script generates a simplified version without the dynamic help output from the `fosr` binary. +For the full version, see the `pages` job in `.gitlab-ci.yml`. diff --git a/crates/fosr-gui/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/build-web.sh b/crates/fosr-gui/build-web.sh new file mode 100755 index 00000000..534a6681 --- /dev/null +++ b/crates/fosr-gui/build-web.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Build and serve the WASM version of Fos-R GUI + +set -e + +# Check for required commands +for cmd in cargo wasm-bindgen http-server; do + if ! command -v "$cmd" &> /dev/null; then + echo "Error: $cmd is not installed." + exit 1 + fi +done + +# Go to project root +cd "$(dirname "$0")/../.." + +# Check for index.html +if [ ! -f public/index.html ]; then + echo "Error: public/index.html not found." + echo "Run ./public/generate-index-html.sh to create it (requires pandoc)." + exit 1 +fi + +echo "Building WASM..." +cargo build -p fosr-gui -r --target wasm32-unknown-unknown --no-default-features + +echo "Generating JS glue with wasm-bindgen..." +wasm-bindgen --out-dir public --target web target/wasm32-unknown-unknown/release/fosr_gui.wasm --no-typescript + +echo "Starting HTTP server on port 8080..." +http-server ./public -p 8080 diff --git a/crates/fosr-gui/src/about_tab.rs b/crates/fosr-gui/src/about_tab.rs index fb5b2a8d..7dcd267f 100644 --- a/crates/fosr-gui/src/about_tab.rs +++ b/crates/fosr-gui/src/about_tab.rs @@ -1,38 +1,60 @@ +//! About tab: Fos-R information, usage guide, and repository links. + +use crate::shared::assets::IMG_LOGO; +use crate::shared::constants::ui::{LOGO_MAX_WIDTH, SPACING_LG, SPACING_SM, SPACING_XXL}; use eframe::egui; -pub fn show_about_tab_content(ui: &mut egui::Ui) { - ui.image(egui::include_image!("../../../public/logo.png")); +/// Renders the About tab with application info, usage guide, and repository links. +pub fn render_about_tab(ui: &mut egui::Ui) { + ui.vertical_centered(|ui| { + ui.add(egui::Image::new(IMG_LOGO).max_width(LOGO_MAX_WIDTH)); + }); ui.separator(); - ui.add_space(10.0); + ui.add_space(SPACING_LG); ui.heading("Fos-R - Synthetic Network Traffic Generator"); - ui.add_space(5.0); + ui.add_space(SPACING_SM); ui.label( "Fos-R is a high-quality and high-throughput network traffic generator based on AI models.", ); - ui.add_space(15.0); + ui.add_space(SPACING_XXL); + + ui.heading("Usage Guide"); + ui.add_space(SPACING_SM); + + ui.label("This GUI helps you design a Fos-R network configuration visually and generate synthetic network traffic as PCAP files. Export your configuration to use it with the CLI, which can generate and inject live traffic directly on a network."); + ui.add_space(SPACING_LG); + + ui.label(egui::RichText::new("Run").strong()); + ui.label("• Live Preview: visualize what Fos-R would generate based on your configuration. This is a real-time simulation, not the actual generation. Use it to quickly verify your network topology. Click on a node to adjust some properties."); + ui.label("• Generation: generate the PCAP file from your configuration. Set parameters like duration and start time. For reproducible results, use a fixed seed. On desktop, open the result in Wireshark to take a quick look."); + ui.add_space(SPACING_LG); + + ui.label(egui::RichText::new("Configuration").strong()); + ui.label("Define your network: hosts, their interfaces, and the services they provide. Create a configuration from a template or import an existing YAML file. Switch between visual editing and raw YAML at any time."); + ui.add_space(SPACING_XXL); ui.heading("Repository Information"); - ui.add_space(5.0); + ui.add_space(SPACING_SM); ui.horizontal(|ui| { ui.label("GitLab - Main repository:"); ui.hyperlink("https://gitlab.inria.fr/pirat-public/Fos-R"); }); - ui.add_space(5.0); + ui.add_space(SPACING_SM); ui.horizontal(|ui| { ui.label("GitHub mirror:"); ui.hyperlink("https://github.com/Fos-R/Fos-R"); }); - ui.add_space(5.0); + ui.add_space(SPACING_SM); ui.horizontal(|ui| { ui.label("Website:"); ui.hyperlink("https://fosr.inria.fr"); }); - ui.add_space(15.0); + ui.add_space(SPACING_XXL); ui.heading("Contact"); - ui.add_space(5.0); + ui.add_space(SPACING_SM); ui.horizontal(|ui| { ui.label("Maintainer:"); ui.hyperlink_to( diff --git a/crates/fosr-gui/src/app.rs b/crates/fosr-gui/src/app.rs deleted file mode 100644 index 1c52912c..00000000 --- a/crates/fosr-gui/src/app.rs +++ /dev/null @@ -1,129 +0,0 @@ -use crate::about_tab::show_about_tab_content; -use crate::configuration::configuration_tab::{ - ConfigurationTabState, show_configuration_tab_content, -}; -use crate::generation::generation_tab::{GenerationTabState, show_generation_tab_content}; -#[cfg(not(target_arch = "wasm32"))] -use crate::injection_tab::show_injection_tab_content; -use crate::shared::configuration_file::ConfigurationFileState; -use crate::visualization::visualization_tab::{ - VisualizationTabState, show_visualization_tab_content, -}; -use eframe::egui; -#[cfg(not(target_arch = "wasm32"))] -use eframe::egui::global_theme_preference_switch; - -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -enum CurrentTab { - Configuration, - Visualization, - Generation, - #[cfg(not(target_arch = "wasm32"))] - Injection, - About, -} - -impl Default for CurrentTab { - fn default() -> Self { - CurrentTab::Generation - } -} - -#[derive(Default)] -pub struct FosrApp { - current_tab: CurrentTab, - configuration_file_state: ConfigurationFileState, - configuration_tab_state: ConfigurationTabState, - visualization_tab_state: VisualizationTabState, - generation_tab_state: GenerationTabState, -} - -impl eframe::App for FosrApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Set the image loaders - // Required for egui to display images - egui_extras::install_image_loaders(ctx); - - // 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") - .clicked() - { - self.current_tab = CurrentTab::Generation; - } - if ui - .selectable_label( - self.current_tab == CurrentTab::Configuration, - "Configuration", - ) - .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") - .clicked() - { - self.current_tab = CurrentTab::Injection; - } - if ui - .selectable_label(self.current_tab == CurrentTab::About, "About") - .clicked() - { - self.current_tab = CurrentTab::About; - } - }); - }); - - // The Central Panel is the region left after adding the Top, Bottom and Side panels. - egui::CentralPanel::default().show(ctx, |ui| { - // Display the tab content depending on the currently select tab - match self.current_tab { - CurrentTab::Generation => { - show_generation_tab_content( - ui, - &mut self.generation_tab_state, - &mut self.configuration_file_state, - ); - } - 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); - } - } - }); - } -} diff --git a/crates/fosr-gui/src/app/close_dialog.rs b/crates/fosr-gui/src/app/close_dialog.rs new file mode 100644 index 00000000..35aa7d21 --- /dev/null +++ b/crates/fosr-gui/src/app/close_dialog.rs @@ -0,0 +1,34 @@ +//! Close confirmation dialog for desktop when Wireshark sessions are active. + +use crate::shared::constants::ui::{MODAL_WIDTH_SM, SPACING_LG, SPACING_XL}; +use eframe::egui; + +/// Render the close confirmation dialog if needed. +pub fn render_close_confirmation_dialog( + ctx: &egui::Context, + show_dialog: &mut bool, + allowed_to_close: &mut bool, +) { + if !*show_dialog { + return; + } + + egui::Modal::new(egui::Id::new("close_confirmation_modal")).show(ctx, |ui| { + ui.set_width(MODAL_WIDTH_SM); + ui.heading("Confirm Exit"); + ui.add_space(SPACING_LG); + ui.label("You have Wireshark session(s) open with temporary PCAP files."); + ui.label("Closing will delete these files."); + ui.add_space(SPACING_XL); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + *show_dialog = false; + } + if ui.button("Exit").clicked() { + *show_dialog = false; + *allowed_to_close = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + }); +} diff --git a/crates/fosr-gui/src/app/mod.rs b/crates/fosr-gui/src/app/mod.rs new file mode 100644 index 00000000..fa4e7b44 --- /dev/null +++ b/crates/fosr-gui/src/app/mod.rs @@ -0,0 +1,191 @@ +//! Main Fos-R application: tab navigation, startup modal, and app state. +//! +//! egui immediate mode: `update()` is called every frame, +//! you describe what to show, and state persists in the struct between frames. + +#[cfg(not(target_arch = "wasm32"))] +mod close_dialog; +mod startup_modal; +mod top_bar; + +use crate::about_tab::render_about_tab; +use crate::config_editor::state::ConfigurationTabState; +use crate::config_editor::tab::render_configuration_tab; +use crate::run::state::RunTabState; +use crate::run::tab::render_run_tab; +use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_LOGO, IMG_SERVER}; +use crate::shared::config::state::ConfigFileState; +use crate::shared::constants::ui::{TOOLTIP_DELAY, ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN}; +#[cfg(not(target_arch = "wasm32"))] +use close_dialog::render_close_confirmation_dialog; +use eframe::egui; +use startup_modal::render_startup_modal; +use top_bar::{AppTab, TopBarState, render_top_bar}; + +/// Main application state managing tabs, configuration, and PCAP generation. +#[derive(Default)] +pub struct FosrApp { + current_tab: AppTab, + style_initialized: bool, + images_preloaded: bool, + zoom_factor: f32, + config_file_state: ConfigFileState, + configuration_tab_state: ConfigurationTabState, + run_tab_state: RunTabState, + /// Whether to show the close confirmation dialog + #[cfg(not(target_arch = "wasm32"))] + show_close_confirmation: bool, + /// Whether the user has confirmed they want to close + #[cfg(not(target_arch = "wasm32"))] + allowed_to_close: bool, +} + +impl FosrApp { + /// Initialize style and zoom settings once on first frame. + fn init_style_once(&mut self, ctx: &egui::Context) { + // Set default zoom once + if !self.style_initialized { + self.zoom_factor = ZOOM_DEFAULT; + ctx.options_mut(|option| option.zoom_factor = self.zoom_factor); + ctx.style_mut(|s| s.interaction.tooltip_delay = TOOLTIP_DELAY); + + // On web, use dark theme to match with the Fos-R website's theme + #[cfg(target_arch = "wasm32")] + ctx.set_theme(egui::Theme::Dark); + + self.style_initialized = true; + } + } + + /// Clamp zoom to min/max bounds (prevents Ctrl+/- from exceeding limits). + fn clamp_zoom(&mut self, ctx: &egui::Context) { + // Clamp zoom to min/max (prevents Ctrl+/- from exceeding limits) + let current_zoom = ctx.zoom_factor(); + if current_zoom < ZOOM_MIN || current_zoom > ZOOM_MAX { + let clamped_zoom = current_zoom.clamp(ZOOM_MIN, ZOOM_MAX); + ctx.set_zoom_factor(clamped_zoom); + self.zoom_factor = clamped_zoom; + } + } + + /// Preload images once to avoid spinners/fallbacks on first visit. + fn preload_images_once(&mut self, ctx: &egui::Context) { + // Preload all images to avoid spinners/fallbacks on first visit + if !self.images_preloaded { + let _ = IMG_SERVER.load(ctx, Default::default(), Default::default()); + let _ = IMG_COMPUTER.load(ctx, Default::default(), Default::default()); + let _ = IMG_INTERNET.load(ctx, Default::default(), Default::default()); + let _ = IMG_LOGO.load(ctx, Default::default(), Default::default()); + self.images_preloaded = true; + } + } + + /// Check if any Wireshark sessions with temporary PCAP files are still running. + #[cfg(not(target_arch = "wasm32"))] + fn has_active_wireshark_sessions(&self) -> bool { + self.run_tab_state + .generation + .temp_pcap_files + .iter() + .any(|(handle, _)| !handle.is_finished()) + } + + /// Handle close confirmation when there are active Wireshark sessions. + #[cfg(not(target_arch = "wasm32"))] + fn handle_close_confirmation(&mut self, ctx: &egui::Context) { + if ctx.input(|i| i.viewport().close_requested()) { + if self.allowed_to_close { + // User confirmed: allow the app to close + } else if self.has_active_wireshark_sessions() { + // Active sessions: cancel close and show confirmation dialog + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.show_close_confirmation = true; + } + } + } + + /// Render the top bar and update internal state from user interactions. + fn render_top_bar(&mut self, ctx: &egui::Context) { + // Render top bar and get updated state + let top_bar_state = TopBarState { + current_tab: self.current_tab, + zoom_factor: self.zoom_factor, + has_errors: self.config_file_state.has_errors, + }; + let updated_state = render_top_bar(ctx, top_bar_state); + self.current_tab = updated_state.current_tab; + self.zoom_factor = updated_state.zoom_factor; + } + + /// Render the current tab content in the central panel. + fn render_current_tab(&mut self, ctx: &egui::Context) { + // The Central Panel is the region left after adding the Top, Bottom and Side panels. + egui::CentralPanel::default().show(ctx, |ui| { + // Display the tab content depending on the currently select tab + // Note: Run tab doesn't use ScrollArea as it has its own layout + match self.current_tab { + AppTab::Run => { + render_run_tab( + ui, + &mut self.run_tab_state, + &mut self.config_file_state, + ); + } + AppTab::Configuration => { + // Wrap in ScrollArea for vertical scrolling + egui::ScrollArea::vertical().show(ui, |ui| { + render_configuration_tab( + ui, + &mut self.configuration_tab_state, + &mut self.config_file_state, + ); + }); + } + AppTab::About => { + // Wrap in ScrollArea for vertical scrolling + egui::ScrollArea::vertical().show(ui, |ui| { + render_about_tab(ui); + }); + } + } + }); + } +} + +impl eframe::App for FosrApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // One-time initialization + self.init_style_once(ctx); + self.clamp_zoom(ctx); + + // Set the image loaders + // Required for egui to display images + egui_extras::install_image_loaders(ctx); + + self.preload_images_once(ctx); + + // Handle close confirmation (native only) + #[cfg(not(target_arch = "wasm32"))] + self.handle_close_confirmation(ctx); + + // Startup modal: choose configuration source + if !self.config_file_state.config_chosen { + // Render empty CentralPanel for background, then modal on top + egui::CentralPanel::default().show(ctx, |_ui| {}); + render_startup_modal(ctx, &mut self.config_file_state); + return; + } + + // Close confirmation dialog (native only) + #[cfg(not(target_arch = "wasm32"))] + render_close_confirmation_dialog( + ctx, + &mut self.show_close_confirmation, + &mut self.allowed_to_close, + ); + + // Main UI + self.render_top_bar(ctx); + self.render_current_tab(ctx); + } +} diff --git a/crates/fosr-gui/src/app/startup_modal.rs b/crates/fosr-gui/src/app/startup_modal.rs new file mode 100644 index 00000000..1c4ae613 --- /dev/null +++ b/crates/fosr-gui/src/app/startup_modal.rs @@ -0,0 +1,146 @@ +//! Startup modal for choosing configuration source (templates or import). + +use crate::config_templates::{load_template, TEMPLATES}; +#[cfg(target_arch = "wasm32")] +use crate::shared::config::file_ops::poll_file_import; +use crate::shared::config::file_ops::trigger_file_import; +use crate::shared::config::state::{ConfigFileState, StartupModalStep}; +use crate::shared::constants::colors::COLOR_TEXT_MUTED; +use crate::shared::constants::ui::{ + ICON_SIZE_LG, MODAL_WIDTH_MD, SPACING_LG, SPACING_SM, SPACING_XL, SPACING_XS, + STARTUP_CARD_HEIGHT, STARTUP_COLUMNS_INITIAL, STARTUP_COLUMNS_TEMPLATES, TEXT_SIZE_LG, + TEXT_SIZE_SM, +}; +use eframe::egui; + +/// Builds the frame style for a startup card based on hover state. +fn card_frame_for_hover(ui: &egui::Ui, is_hovered: bool) -> egui::Frame { + let fill = if is_hovered { + ui.style().visuals.widgets.hovered.bg_fill + } else { + ui.style().visuals.widgets.inactive.bg_fill + }; + egui::Frame::group(ui.style()).fill(fill) +} + +/// Renders the centered content inside a startup card (icon, title, description). +fn render_card_content(ui: &mut egui::Ui, icon: &str, title: &str, description: &str) { + // Disable text selection so the whole card acts as a single clickable area + ui.style_mut().interaction.selectable_labels = false; + ui.set_width(ui.available_width()); + + ui.vertical_centered(|ui| { + ui.add_space(SPACING_LG); + ui.label(egui::RichText::new(icon).size(ICON_SIZE_LG)); + ui.add_space(SPACING_SM); + ui.strong(egui::RichText::new(title).size(TEXT_SIZE_LG)); + ui.add_space(SPACING_XS); + ui.label( + egui::RichText::new(description) + .size(TEXT_SIZE_SM) + .color(COLOR_TEXT_MUTED), + ); + ui.add_space(SPACING_LG); + }); +} + +/// A clickable card with icon, title and description. +/// Returns true if the card was clicked. +fn startup_card(ui: &mut egui::Ui, icon: &str, title: &str, description: &str) -> bool { + let desired_size = egui::vec2(ui.available_width(), STARTUP_CARD_HEIGHT); + let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); + + let frame = card_frame_for_hover(ui, response.hovered()); + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + frame.show(ui, |ui| { + render_card_content(ui, icon, title, description); + }); + }); + + if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + response.clicked() +} + +/// Renders the startup modal for choosing a configuration source. +pub fn render_startup_modal(ctx: &egui::Context, state: &mut ConfigFileState) { + match state.modal_state { + StartupModalStep::Initial => render_initial_modal(ctx, state), + StartupModalStep::TemplateSelection => render_template_selection_modal(ctx, state), + } +} + +/// Renders the initial modal with options to use templates or import a file. +fn render_initial_modal(ctx: &egui::Context, state: &mut ConfigFileState) { + // Use the same modal ID as template selection to avoid flicker when transitioning + egui::Modal::new(egui::Id::new("startup_modal")).show(ctx, |ui| { + ui.set_width(MODAL_WIDTH_MD); + ui.heading("Welcome to Fos-R"); + ui.add_space(SPACING_SM); + ui.label("Choose a configuration to get started:"); + ui.add_space(SPACING_XL); + + ui.columns(STARTUP_COLUMNS_INITIAL, |cols| { + // Left: default config + if startup_card( + &mut cols[0], + egui_material_icons::icons::ICON_LAN, + "Default configuration", + "Choose from preset templates\nfor different network types", + ) { + state.modal_state = StartupModalStep::TemplateSelection; + } + + // Right: import file + if startup_card( + &mut cols[1], + egui_material_icons::icons::ICON_UPLOAD_FILE, + "Import YAML file", + "Load your own network\nconfiguration from a file", + ) { + trigger_file_import(state, cols[1].ctx()); + } + }); + + #[cfg(target_arch = "wasm32")] + poll_file_import(state); + }); +} + +/// Renders the template selection modal with preset network configurations. +fn render_template_selection_modal(ctx: &egui::Context, state: &mut ConfigFileState) { + // Use the same modal ID as initial modal to avoid flicker when transitioning + egui::Modal::new(egui::Id::new("startup_modal")).show(ctx, |ui| { + ui.set_width(MODAL_WIDTH_MD); + + // Header with back button + ui.horizontal(|ui| { + if ui + .button(egui_material_icons::icons::ICON_ARROW_BACK) + .on_hover_text("Back") + .clicked() + { + state.modal_state = StartupModalStep::Initial; + } + ui.heading("Choose a template"); + }); + + ui.add_space(SPACING_XL); + + // Grid of template cards + ui.columns(STARTUP_COLUMNS_TEMPLATES, |cols| { + for (i, template) in TEMPLATES.iter().enumerate() { + if startup_card( + &mut cols[i % STARTUP_COLUMNS_TEMPLATES], + template.icon, + template.title, + template.description, + ) { + load_template(state, template); + } + } + }); + }); +} diff --git a/crates/fosr-gui/src/app/top_bar.rs b/crates/fosr-gui/src/app/top_bar.rs new file mode 100644 index 00000000..458c8fbf --- /dev/null +++ b/crates/fosr-gui/src/app/top_bar.rs @@ -0,0 +1,236 @@ +//! Top bar rendering: tab navigation, zoom controls, and theme switch. + +use crate::shared::constants::colors::COLOR_ERROR; +#[cfg(not(target_arch = "wasm32"))] +use crate::shared::constants::ui::SPACING_SM; +use crate::shared::constants::ui::{ + BUTTON_PADDING, PANEL_INNER_MARGIN, TEXT_SIZE_DEFAULT, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP, +}; +use eframe::egui; +use eframe::egui::global_theme_preference_switch; + +/// Available tabs in the Fos-R application. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +pub enum AppTab { + #[default] + Run, + Configuration, + About, +} + +/// State passed between the top bar and the main app for rendering. +#[derive(Clone)] +pub struct TopBarState { + pub current_tab: AppTab, + pub zoom_factor: f32, + pub has_errors: bool, +} + +/// Renders a tab button with consistent styling. +/// +/// Returns `Some(clicked_tab)` if the button was clicked, `None` otherwise. +/// Handles both enabled and disabled states with appropriate tooltips. +fn render_tab_button( + ui: &mut egui::Ui, + text_size: f32, + label: &str, + tab: AppTab, + is_selected: bool, + is_enabled: bool, + tooltip: &str, + disabled_tooltip: &str, +) -> Option { + let button = egui::Button::new(egui::RichText::new(label).size(text_size)).selected(is_selected); + let response = ui.add_enabled(is_enabled, button); + + let response = if is_enabled { + response.on_hover_text(tooltip) + } else { + response.on_disabled_hover_text(disabled_tooltip) + }; + + if is_enabled && response.clicked() { + Some(tab) + } else { + None + } +} + +/// Renders the Configuration tab with error indicator when config is invalid. +fn render_config_tab_button( + ui: &mut egui::Ui, + text_size: f32, + has_errors: bool, + is_selected: bool, +) -> Option { + let label = if has_errors { + egui::RichText::new("⚠ Configuration") + .color(COLOR_ERROR) + .size(text_size) + } else { + egui::RichText::new("Configuration").size(text_size) + }; + + let button = egui::Button::new(label).selected(is_selected); + + if ui + .add(button) + .on_hover_text("Edit the network configuration: hosts, interfaces, and services.") + .clicked() + { + Some(AppTab::Configuration) + } else { + None + } +} + +/// Renders all tab buttons and returns the newly selected tab if changed. +fn render_tab_buttons( + ui: &mut egui::Ui, + state: &TopBarState, +) -> Option { + let text_size = TEXT_SIZE_DEFAULT; + let has_errors = state.has_errors; + + // Run tab (disabled when config has errors) + if let Some(tab) = render_tab_button( + ui, + text_size, + "Run", + AppTab::Run, + state.current_tab == AppTab::Run, + !has_errors, + "Live preview and PCAP generation from the current configuration.", + "Configuration is invalid. Fix errors in the Configuration tab to enable Run.", + ) { + return Some(tab); + } + + // Configuration tab (always enabled, shows warning icon on errors) + if let Some(tab) = render_config_tab_button(ui, text_size, has_errors, state.current_tab == AppTab::Configuration) { + return Some(tab); + } + + // About tab + if let Some(tab) = render_tab_button( + ui, + text_size, + "About", + AppTab::About, + state.current_tab == AppTab::About, + true, + "About Fos-R and its authors.", + "", + ) { + return Some(tab); + } + + None +} + +/// Renders zoom in/out controls and returns the updated zoom factor. +fn render_zoom_controls(ui: &mut egui::Ui, ctx: &egui::Context) -> f32 { + let mut new_zoom = ctx.zoom_factor(); + + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + + if ui + .button(egui_material_icons::icons::ICON_ADD) + .on_hover_text("Zoom in") + .clicked() + { + new_zoom = (new_zoom + ZOOM_STEP).min(ZOOM_MAX); + ctx.set_zoom_factor(new_zoom); + } + + ui.label(format!("{:.0}%", new_zoom * 100.0)); + + if ui + .button(egui_material_icons::icons::ICON_REMOVE) + .on_hover_text("Zoom out") + .clicked() + { + new_zoom = (new_zoom - ZOOM_STEP).max(ZOOM_MIN); + ctx.set_zoom_factor(new_zoom); + } + }); + + new_zoom +} + +/// Returns true if the browser is currently in fullscreen mode. +#[cfg(target_arch = "wasm32")] +fn is_fullscreen() -> bool { + web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.fullscreen_element()) + .is_some() +} + +/// Toggles browser fullscreen mode on or off. +#[cfg(target_arch = "wasm32")] +fn toggle_fullscreen(is_fullscreen: bool) { + let Some(window) = web_sys::window() else { return }; + let Some(document) = window.document() else { return }; + + if is_fullscreen { + document.exit_fullscreen(); + } else if let Some(canvas) = document.get_element_by_id("fosr_gui_canvas") { + let _ = canvas.request_fullscreen(); + } +} + +/// Renders the fullscreen toggle button for web builds. +#[cfg(target_arch = "wasm32")] +fn render_fullscreen_toggle(ui: &mut egui::Ui) { + let fullscreen = is_fullscreen(); + let (icon, tooltip) = if fullscreen { + (egui_material_icons::icons::ICON_FULLSCREEN_EXIT, "Exit fullscreen") + } else { + (egui_material_icons::icons::ICON_FULLSCREEN, "Fullscreen") + }; + + if ui.button(icon).on_hover_text(tooltip).clicked() { + toggle_fullscreen(fullscreen); + } +} + +/// Renders utility buttons on the right side of the top bar. +fn render_utility_buttons(ui: &mut egui::Ui, ctx: &egui::Context, state: &mut TopBarState) { + #[cfg(target_arch = "wasm32")] + render_fullscreen_toggle(ui); + + #[cfg(not(target_arch = "wasm32"))] + ui.add_space(SPACING_SM); + + global_theme_preference_switch(ui); + state.zoom_factor = render_zoom_controls(ui, ctx); +} + +/// Render the top bar with tabs and utility buttons. +/// Returns the updated TopBarState. +pub fn render_top_bar(ctx: &egui::Context, state: TopBarState) -> TopBarState { + let mut new_state = state.clone(); + + egui::TopBottomPanel::top("top_panel") + .frame( + egui::Frame::side_top_panel(&ctx.style()) + .inner_margin(egui::Margin::symmetric(PANEL_INNER_MARGIN.0, PANEL_INNER_MARGIN.1)), + ) + .show(ctx, |ui| { + egui::MenuBar::new().ui(ui, |ui| { + ui.spacing_mut().button_padding = egui::vec2(BUTTON_PADDING.0, BUTTON_PADDING.1); + + if let Some(tab) = render_tab_buttons(ui, &state) { + new_state.current_tab = tab; + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + render_utility_buttons(ui, ctx, &mut new_state); + }); + }); + }); + + new_state +} diff --git a/crates/fosr-gui/src/app_modules.rs b/crates/fosr-gui/src/app_modules.rs new file mode 100644 index 00000000..247e8c7e --- /dev/null +++ b/crates/fosr-gui/src/app_modules.rs @@ -0,0 +1,8 @@ +/// Shared module declarations for both native and WASM builds. + +mod about_tab; +mod app; +mod config_editor; +mod config_templates; +mod run; +mod shared; diff --git a/crates/fosr-gui/src/config_editor/host.rs b/crates/fosr-gui/src/config_editor/host.rs new file mode 100644 index 00000000..217bae97 --- /dev/null +++ b/crates/fosr-gui/src/config_editor/host.rs @@ -0,0 +1,421 @@ +//! Host editing UI: hostname, OS, type, and client protocols. +//! +//! This module provides the visual editor for configuring hosts in the network. +//! Each host is displayed as a collapsible section with fields for basic settings +//! (hostname, OS, type, usage) and a nested interfaces section. + +use crate::config_editor::{host_interfaces, host_services, host_validation}; +use crate::shared::config::model::{Configuration, Host}; +use crate::shared::constants::colors::COLOR_ERROR; +use crate::shared::constants::network::HOST_USAGE_DEFAULT; +use crate::shared::constants::ui::{ + PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_MD, SPACING_SM, +}; +use crate::shared::widgets::helpers::{render_optional_string_input, info_icon_with_tooltip}; +use eframe::egui; +use std::collections::HashMap; + +/// Render the hosts section with add button and collapsible host cards. +/// +/// Each host shows validation errors in the header if present. +/// Supports adding new hosts (inserted at top) and removing hosts. +pub fn render_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(SPACING_MD); + + if model.hosts.is_empty() { + ui.label("No hosts in this configuration."); + return; + } + + // Pre-compute address counts for duplicate detection + let (ip_counts, mac_counts) = host_validation::count_addresses(model); + + // Track removal request (can't remove during iteration) + let mut host_to_remove: Option = None; + + for (idx, host) in model.hosts.iter_mut().enumerate() { + render_host_card(ui, idx, host, &ip_counts, &mac_counts, &mut host_to_remove); + ui.add_space(SPACING_MD); + } + + // Apply removal after iteration completes + if let Some(idx) = host_to_remove { + model.hosts.remove(idx); + } +} + +/// Render a single host as a collapsible card. +/// +/// The header shows the host name and any validation errors. +/// The body contains all editable fields organized in sections. +fn render_host_card( + ui: &mut egui::Ui, + index: usize, + host: &mut Host, + ip_counts: &HashMap, + mac_counts: &HashMap, + remove_request: &mut Option, +) { + let display_name = host_display_name(host); + let errors = host_validation::validate_host(host, ip_counts, mac_counts); + + // First host is expanded by default + let id = ui.make_persistent_id(("host", index)); + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, index == 0) + .show_header(ui, |ui| { + render_host_header(ui, &display_name, &errors, host); + render_host_delete_button(ui, index, remove_request); + }) + .body(|ui| { + render_host_basic_fields(ui, index, host); + ui.separator(); + host_interfaces::render_interfaces_section(ui, index, host, ip_counts, mac_counts); + }); +} + +/// Render the host header with name, errors, and tooltip. +fn render_host_header( + ui: &mut egui::Ui, + display_name: &str, + errors: &[String], + host: &Host, +) { + ui.horizontal(|ui| { + if errors.is_empty() { + ui.label(display_name).on_hover_ui(|ui| { + render_host_tooltip(ui, host); + }); + } else { + let warning_icon = egui_material_icons::icons::ICON_WARNING; + let error_text = errors.join(", "); + let label_text = format!("{} {} - {}", warning_icon, display_name, error_text); + + ui.colored_label(COLOR_ERROR, label_text) + .on_hover_ui(|ui| { + render_host_tooltip(ui, host); + }); + } + }); +} + +/// Render the delete button aligned to the right of the header. +fn render_host_delete_button( + ui: &mut egui::Ui, + index: usize, + remove_request: &mut Option, +) { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .button(egui_material_icons::icons::ICON_DELETE) + .on_hover_text("Remove host") + .clicked() + { + *remove_request = Some(index); + } + }); +} + +/// Render the host summary tooltip showing type, protocols, and interfaces. +fn render_host_tooltip(ui: &mut egui::Ui, host: &Host) { + let host_type = host.r#type.as_deref().unwrap_or(""); + ui.horizontal(|ui| { + ui.label("Type :"); + ui.strong(host_type); + }); + + if !host.client.is_empty() { + ui.horizontal(|ui| { + ui.label("Client protocols :"); + ui.strong(host.client.join(", ")); + }); + } + + ui.add_space(SPACING_SM); + + ui.label("Interfaces :"); + if host.interfaces.is_empty() { + ui.label(" No interfaces configured."); + } else { + for interface in &host.interfaces { + let services_str = if interface.services.is_empty() { + "no services".to_string() + } else { + interface.services.join(", ") + }; + ui.label(format!(" • {} ({})", interface.ip_addr, services_str)); + } + } +} + +/// Render the basic host fields: OS, hostname, usage, type, and client protocols. +fn render_host_basic_fields(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { + render_os_dropdown(ui, host_idx, &mut host.os); + render_optional_string_input(ui, "Hostname", &mut host.hostname, "host1"); + render_usage_field(ui, host); + render_type_dropdown(ui, host_idx, host); + render_client_protocols(ui, host_idx, host); +} + +/// Dropdown selector for the Operating System (Linux/Windows/none). +fn render_os_dropdown(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; + } + }); +} + +/// Usage intensity field with drag value and clear button. +/// +/// Usage affects how much network traffic this host generates. +/// Default is 1.0 (baseline), lower means less traffic, higher means more. +fn render_usage_field(ui: &mut egui::Ui, host: &mut Host) { + ui.horizontal(|ui| { + ui.label("Usage"); + info_icon_with_tooltip( + ui, + &format!( + "Optional (default value: {0}). The usage intensity of the host. \ + {0} is the baseline, < {0} means less usage than usual, \ + and > {0} means higher usage", + HOST_USAGE_DEFAULT + ), + ); + + let mut usage_val = host.usage.unwrap_or(HOST_USAGE_DEFAULT); + if ui + .add(egui::DragValue::new(&mut usage_val).speed(0.1)) + .changed() + { + // Store as None if at default (to avoid serializing default values) + host.usage = if (usage_val - HOST_USAGE_DEFAULT).abs() < f32::EPSILON { + None + } else { + Some(usage_val) + }; + } + + if ui + .button(egui_material_icons::icons::ICON_CLEAR) + .on_hover_text("Clear") + .clicked() + { + host.usage = None; + } + }); +} + +/// Dropdown selector for host type (server/user/auto). +/// +/// - server: provides services to other hosts +/// - user: consumes services from servers +/// - auto: determined based on whether services are defined +fn render_type_dropdown(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { + ui.horizontal(|ui| { + ui.label("Type"); + info_icon_with_tooltip( + ui, + "Defines the role of the host. A server provides services, while a user (client) \ + consumes services. If the host is a server, it must define services. If it is a user, \ + 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 selector with searchable popup. +/// +/// Shows a popup with all available protocols from KNOWN_SERVICES, +/// filtered by search text. Selected protocols appear as removable chips. +fn render_client_protocols(ui: &mut egui::Ui, host_idx: usize, host: &mut Host) { + ui.horizontal(|ui| { + ui.label("Client protocols"); + info_icon_with_tooltip(ui, "Specify what services the host is a client of."); + + 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"); + + render_protocol_popup(popup_id, host_idx, add_btn_resp, host); + render_protocol_chips(ui, host); + }); +} + +/// Render the searchable protocol selection popup. +fn render_protocol_popup( + popup_id: egui::Id, + host_idx: usize, + add_btn_resp: egui::Response, + host: &mut Host, +) { + egui::Popup::from_toggle_button_response(&add_btn_resp) + .id(popup_id) + .show(|ui| { + ui.set_min_width(POPUP_MIN_WIDTH); + + // Search field with auto-focus + let search_id = ui.make_persistent_id(("protocol_search", host_idx)); + let mut search_text = ui.data_mut(|d| d.get_temp::(search_id).unwrap_or_default()); + + let search_resp = + ui.add(egui::TextEdit::singleline(&mut search_text).hint_text("Search...")); + + // Auto-focus search field when popup opens + if ui.memory(|m| m.focused().is_none()) { + ui.memory_mut(|m| m.request_focus(search_resp.id)); + } + + ui.data_mut(|d| d.insert_temp(search_id, search_text.clone())); + + ui.separator(); + + // Protocol list with filtering + egui::ScrollArea::vertical() + .max_height(POPUP_MAX_HEIGHT) + .auto_shrink([true, true]) + .show(ui, |ui| { + ui.set_width(PANEL_MIN_WIDTH); + + let filter = search_text.to_lowercase(); + let mut any_shown = false; + + for (name, _) in host_services::KNOWN_SERVICES { + let matches_filter = filter.is_empty() || name.to_lowercase().contains(&filter); + let already_added = host.client.contains(&name.to_string()); + + if matches_filter && !already_added { + any_shown = true; + if ui.selectable_label(false, *name).clicked() { + host.client.push(name.to_string()); + ui.data_mut(|d| d.insert_temp(search_id, String::new())); + egui::Popup::close_id(ui.ctx(), popup_id); + } + } + } + + if !any_shown { + ui.label( + egui::RichText::new("No available protocols") + .italics() + .weak(), + ); + } + }); + }); +} + +/// Render selected protocols as removable chips. +fn render_protocol_chips(ui: &mut egui::Ui, host: &mut Host) { + let mut protocol_to_remove: Option = None; + + for (protocol_idx, protocol) in host.client.iter().enumerate() { + let button_text = format!("{} {}", protocol, egui_material_icons::icons::ICON_CLEAR); + if ui + .button(button_text) + .on_hover_text("Remove protocol") + .clicked() + { + protocol_to_remove = Some(protocol_idx); + } + } + + if let Some(idx) = protocol_to_remove { + host.client.remove(idx); + } +} + +/// Determine the display name for a host. +/// +/// Priority: hostname > first IP address > "Unconfigured host" +fn host_display_name(host: &Host) -> String { + // Try hostname first + if let Some(name) = host.hostname.as_deref() { + if !name.trim().is_empty() { + return name.to_string(); + } + } + + // Fall back to first interface IP + if let Some(interface) = host.interfaces.first() { + if !interface.ip_addr.trim().is_empty() { + return interface.ip_addr.clone(); + } + } + + "Unconfigured host".to_string() +} diff --git a/crates/fosr-gui/src/config_editor/host_interfaces.rs b/crates/fosr-gui/src/config_editor/host_interfaces.rs new file mode 100644 index 00000000..4cc86904 --- /dev/null +++ b/crates/fosr-gui/src/config_editor/host_interfaces.rs @@ -0,0 +1,165 @@ +//! Network interface editing UI: IP, MAC address, and associated services. + +use crate::config_editor::host_services; +use crate::shared::config::model::{Host, Interface}; +use crate::shared::constants::colors::COLOR_ERROR; +use crate::shared::constants::network::{ + IP_LOCAL_MAX, IP_LOCAL_MIN, MAC_ADDRESS_BYTES, MAC_LOCAL_BIT, MAC_LOCAL_MASK, +}; +use crate::shared::constants::ui::{SPACING_MD, SPACING_SM}; +use crate::shared::widgets::helpers::{render_optional_string_input, required_label}; +use eframe::egui; +use std::collections::HashMap; + +/// Scans all interfaces to find the next available IP in 192.168.0.x +fn find_available_ip(ip_counts: &HashMap) -> Option { + for x in IP_LOCAL_MIN..=IP_LOCAL_MAX { + let candidate = format!("192.168.0.{x}"); + if !ip_counts.contains_key(&candidate) { + return Some(candidate); + } + } + None +} + +/// Interface section rendering +pub fn render_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) = find_available_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(COLOR_ERROR, "No free IP available in 192.168.0.0/24"); + } + } + }); + + if host.interfaces.is_empty() { + ui.label("No interfaces."); + return; + } + + let mut interface_to_remove: Option = None; + + for (interface_idx, interface) in host.interfaces.iter_mut().enumerate() { + render_interface_card( + ui, + host_idx, + interface_idx, + interface, + ip_counts, + mac_counts, + &mut interface_to_remove, + ); + ui.add_space(SPACING_MD); + } + + if let Some(idx) = interface_to_remove { + host.interfaces.remove(idx); + } +} + +/// Render a single interface as a collapsible card. +/// +/// Shows IP address in header, with editable fields for IP, MAC, and services in the body. +fn render_interface_card( + ui: &mut egui::Ui, + host_idx: usize, + interface_idx: usize, + interface: &mut Interface, + ip_counts: &HashMap, + mac_counts: &HashMap, + remove_request: &mut Option, +) { + let ip_label = interface.ip_addr.clone(); + let id = ui.make_persistent_id(("interface", host_idx, interface_idx)); + + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, interface_idx == 0) + .show_header(ui, |ui| { + ui.label(format!("Interface — {ip_label}")); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .button(egui_material_icons::icons::ICON_DELETE) + .on_hover_text("Remove interface") + .clicked() + { + *remove_request = Some(interface_idx); + } + }); + }) + .body(|ui| { + render_interface_fields(ui, host_idx, interface_idx, interface, ip_counts, mac_counts); + }); +} + +/// Render the editable fields for an interface: IP, MAC, and services. +fn render_interface_fields( + ui: &mut egui::Ui, + host_idx: usize, + interface_idx: usize, + interface: &mut Interface, + ip_counts: &HashMap, + mac_counts: &HashMap, +) { + ui.add_space(SPACING_SM); + + // IP address field with duplicate warning + ui.horizontal(|ui| { + required_label(ui, "IP"); + ui.text_edit_singleline(&mut interface.ip_addr); + if ip_counts.get(&interface.ip_addr).copied().unwrap_or(0) > 1 { + ui.colored_label(COLOR_ERROR, "IP already in use"); + } + }); + + // MAC address field with duplicate warning + render_optional_string_input(ui, "MAC", &mut interface.mac_addr, "00:14:2A:3F:47:D8"); + if let Some(mac) = &interface.mac_addr { + if mac_counts.get(mac).copied().unwrap_or(0) > 1 { + ui.colored_label(COLOR_ERROR, "MAC already in use"); + } + } + + // Services section + host_services::render_services_section(ui, host_idx, interface_idx, interface); +} + +/// Generate a random MAC address with the locally administered bit set. +fn generate_local_mac() -> String { + let mut bytes: [u8; MAC_ADDRESS_BYTES] = rand::random(); + + // Forcing local MAC + bytes[0] = (bytes[0] | MAC_LOCAL_BIT) & MAC_LOCAL_MASK; + + format!( + "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5] + ) +} + +/// Generate a unique random MAC address, retrying until one not in use is found. +fn generate_mac_until_unique(mac_counts: &HashMap) -> String { + loop { + let mac = generate_local_mac(); + + if !mac_counts.contains_key(&mac) { + return mac; + } + } +} diff --git a/crates/fosr-gui/src/config_editor/host_services.rs b/crates/fosr-gui/src/config_editor/host_services.rs new file mode 100644 index 00000000..b0c10e8f --- /dev/null +++ b/crates/fosr-gui/src/config_editor/host_services.rs @@ -0,0 +1,245 @@ +//! Service editing UI: HTTP, SSH, DNS, etc. with custom port support. + +use crate::shared::config::model::Interface; +use crate::shared::constants::network::{PORT_UNSPECIFIED, PORT_MAX, PORT_MIN}; +use crate::shared::constants::ui::{ + PANEL_MIN_WIDTH, POPUP_MAX_HEIGHT, POPUP_MIN_WIDTH, SPACING_SM, SPACING_XS, +}; +use crate::shared::widgets::helpers::info_icon_with_tooltip; +use eframe::egui; + +pub const KNOWN_SERVICES: &[(&str, Option)] = &[ + ("http", Some(80)), + ("https", Some(443)), + ("ssh", Some(22)), + ("ftp", Some(21)), + ("smtp", Some(25)), + ("dns", Some(53)), +]; + +/// Splits "name:port" into ("name", Some(port)) +fn parse_service(s: &str) -> (String, Option) { + if let Some((name, port)) = s.split_once(':') { + if let Ok(p) = port.parse::() { + return (name.to_string(), Some(p)); + } + } + (s.to_string(), None) +} + +/// Joins name and port into "name:port" or just "name" +fn format_service(name: &str, port: Option) -> String { + match port { + Some(p) => format!("{name}:{p}"), + None => name.to_string(), + } +} + +/// Look up the default port for a known service name. +/// +/// Returns the default port for known services. +fn default_port_for_service(name: &str) -> u16 { + KNOWN_SERVICES + .iter() + .find(|(n, _)| *n == name) + .and_then(|(_, p)| *p) + .unwrap_or(PORT_UNSPECIFIED) +} + +/// Service section rendering +pub fn render_services_section( + ui: &mut egui::Ui, + host_idx: usize, + interface_idx: usize, + interface: &mut Interface, +) { + let service_count = interface.services.len(); + let id = ui.make_persistent_id(("services", host_idx, interface_idx)); + + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false) + .show_header(ui, |ui| { + ui.label(format!("Services ({service_count})")); + info_icon_with_tooltip(ui, "The list of available services provided by the host."); + }) + .body(|ui| { + let popup_id = ui.make_persistent_id(("service_popup", host_idx, interface_idx)); + let add_btn_resp = ui + .button(format!("{} Add", egui_material_icons::icons::ICON_ADD)) + .on_hover_text("Add service"); + + egui::Popup::from_toggle_button_response(&add_btn_resp) + .id(popup_id) + .show(|ui| { + ui.set_min_width(POPUP_MIN_WIDTH); + + let search_id = ui.make_persistent_id(("service_search", host_idx, interface_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(POPUP_MAX_HEIGHT) + .auto_shrink([true; 2]) + .show(ui, |ui| { + ui.set_width(PANEL_MIN_WIDTH); + + let filter = search_text.to_lowercase(); + let mut any_shown = false; + + for (name, default_port) in KNOWN_SERVICES { + let already_present = interface.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() { + interface.services.push(format_service(name, *default_port)); + ui.data_mut(|d| d.insert_temp(search_id, String::new())); + egui::Popup::close_id(ui.ctx(), popup_id); + } + } + } + + if !any_shown { + ui.label( + egui::RichText::new("No available services") + .italics() + .weak(), + ); + } + }); + }); + + ui.add_space(SPACING_SM); + + let mut service_to_remove: Option = None; + + for (service_idx, service_raw) in interface.services.iter_mut().enumerate() { + render_service_row( + ui, + host_idx, + interface_idx, + service_idx, + service_raw, + &mut service_to_remove, + ); + ui.add_space(SPACING_XS); + } + + if let Some(idx) = service_to_remove { + interface.services.remove(idx); + } + }); +} + +/// Render a single service row with name, remove button, and optional custom port. +fn render_service_row( + ui: &mut egui::Ui, + host_idx: usize, + interface_idx: usize, + service_idx: usize, + service_raw: &mut String, + remove_request: &mut Option, +) { + let (service_name, mut service_port) = parse_service(service_raw); + let default_port = default_port_for_service(&service_name); + + // Track whether custom port mode is enabled (persists across frames) + let custom_port_id = ui.make_persistent_id(("custom_port", host_idx, interface_idx, service_idx)); + let is_custom_by_default = service_port.map_or(false, |p| p != default_port); + let mut custom_port_enabled: bool = + ui.data_mut(|d| d.get_temp(custom_port_id).unwrap_or(is_custom_by_default)); + + ui.horizontal(|ui| { + // Remove button with service name + let btn_text = format!("{} {}", service_name, egui_material_icons::icons::ICON_CLEAR); + if ui + .button(btn_text) + .on_hover_text("Remove service") + .clicked() + { + *remove_request = Some(service_idx); + } + + // Custom port toggle + if ui + .checkbox(&mut custom_port_enabled, "Custom port") + .changed() + { + service_port = resolve_port_after_toggle(custom_port_enabled, default_port); + } + + // Port value editor or default display + service_port = render_port_editor(ui, custom_port_enabled, service_port, default_port); + }); + + // Persist custom port state + ui.data_mut(|d| d.insert_temp(custom_port_id, custom_port_enabled)); + + // Update the service string + *service_raw = format_service(&service_name, service_port); +} + +/// Resolve port value after toggling custom port checkbox. +/// +/// When disabling custom port, returns the default (or None if default is 0). +/// When enabling, returns None to be filled by the editor. +fn resolve_port_after_toggle(custom_enabled: bool, default_port: u16) -> Option { + if custom_enabled { + None // Will be set by the DragValue + } else if default_port == PORT_UNSPECIFIED { + None + } else { + Some(default_port) + } +} + +/// Render the port editor: either a DragValue or a read-only default label. +/// +/// Returns the selected port value (if any). +fn render_port_editor( + ui: &mut egui::Ui, + custom_enabled: bool, + current_port: Option, + default_port: u16, +) -> Option { + if custom_enabled { + let mut port_val = current_port.unwrap_or(default_port); + if ui + .add( + egui::DragValue::new(&mut port_val) + .speed(1) + .range(PORT_MIN..=PORT_MAX), + ) + .changed() + { + Some(port_val) + } else { + Some(port_val) // Return current value even if unchanged + } + } else { + // Show default port as read-only label + ui.add_enabled( + false, + egui::Label::new(egui::RichText::new(format!("(default: {default_port})")).weak()), + ); + if default_port == PORT_UNSPECIFIED { + None + } else { + Some(default_port) + } + } +} diff --git a/crates/fosr-gui/src/config_editor/host_validation.rs b/crates/fosr-gui/src/config_editor/host_validation.rs new file mode 100644 index 00000000..af375e31 --- /dev/null +++ b/crates/fosr-gui/src/config_editor/host_validation.rs @@ -0,0 +1,96 @@ +//! Host validation: IP/MAC format, conflicts, and type/service consistency. + +use crate::shared::config::model::{Configuration, Host}; +use crate::shared::constants::network::{MAC_ADDRESS_PARTS, MAC_PART_LENGTH}; +use std::collections::HashMap; + +/// Counts of IP and MAC address occurrences across all hosts. +pub type AddressCounts = (HashMap, HashMap); + +/// Count IP and MAC addresses across all hosts in the configuration. +/// +/// Returns a tuple of (ip_counts, mac_counts) used for duplicate detection. +pub fn count_addresses(config: &Configuration) -> AddressCounts { + let mut ip_counts: HashMap = HashMap::new(); + let mut mac_counts: HashMap = HashMap::new(); + + for host in &config.hosts { + for interface in &host.interfaces { + *ip_counts.entry(interface.ip_addr.clone()).or_insert(0) += 1; + if let Some(mac) = &interface.mac_addr { + *mac_counts.entry(mac.clone()).or_insert(0) += 1; + } + } + } + + (ip_counts, mac_counts) +} + +/// Validate a host configuration for correctness. +pub fn validate_host( + host: &Host, + ip_counts: &HashMap, + mac_counts: &HashMap, +) -> Vec { + let mut errors = Vec::new(); + + if host.interfaces.is_empty() { + errors.push("Missing interface".to_string()); + } + + for interface in &host.interfaces { + if interface.ip_addr.parse::().is_err() { + errors.push("Invalid IP format".to_string()); + } else if ip_counts.get(&interface.ip_addr).copied().unwrap_or(0) > 1 { + errors.push("IP conflict".to_string()); + } + + if let Some(mac) = &interface.mac_addr { + if !validate_mac_format(mac) { + errors.push("Invalid MAC format".to_string()); + } else if mac_counts.get(mac).copied().unwrap_or(0) > 1 { + errors.push("MAC conflict".to_string()); + } + } + } + + if host.r#type.as_deref() == Some("server") { + let has_service = host.interfaces.iter().any(|i| !i.services.is_empty()); + if !has_service { + errors.push("Server missing service".to_string()); + } + } + + if host.r#type.as_deref() == Some("user") { + if host.client.is_empty() { + errors.push("Client missing protocols".to_string()); + } + let has_service = host.interfaces.iter().any(|i| !i.services.is_empty()); + if has_service { + errors.push("User host should not have services (use type: server)".to_string()); + } + } + + errors.dedup(); + errors +} + +/// Returns true if any host in the model has validation errors. +pub fn has_model_errors(model: &Configuration) -> bool { + let (ip_counts, mac_counts) = count_addresses(model); + model + .hosts + .iter() + .any(|host| !validate_host(host, &ip_counts, &mac_counts).is_empty()) +} + +/// Check MAC format (ex: 00:14:2A:3F:47:D8) +fn validate_mac_format(mac: &str) -> bool { + let parts: Vec<&str> = mac.split(':').collect(); + if parts.len() != MAC_ADDRESS_PARTS { + return false; + } + parts + .iter() + .all(|p| p.len() == MAC_PART_LENGTH && u8::from_str_radix(p, 16).is_ok()) +} diff --git a/crates/fosr-gui/src/config_editor/mod.rs b/crates/fosr-gui/src/config_editor/mod.rs new file mode 100644 index 00000000..98872062 --- /dev/null +++ b/crates/fosr-gui/src/config_editor/mod.rs @@ -0,0 +1,10 @@ +//! Configuration module: visual and YAML editing of network configuration. + +pub mod host; +pub mod host_interfaces; +pub mod host_services; +pub mod host_validation; +pub mod state; +pub mod tab; +pub mod toolbar; +pub mod yaml_editor; diff --git a/crates/fosr-gui/src/config_editor/state.rs b/crates/fosr-gui/src/config_editor/state.rs new file mode 100644 index 00000000..5c90fc8a --- /dev/null +++ b/crates/fosr-gui/src/config_editor/state.rs @@ -0,0 +1,14 @@ +//! State for the configuration tab. + +/// Represents the state of the configuration tab. +pub struct ConfigurationTabState { + pub is_code_mode: bool, +} + +impl Default for ConfigurationTabState { + fn default() -> Self { + Self { + is_code_mode: false, + } + } +} diff --git a/crates/fosr-gui/src/config_editor/tab.rs b/crates/fosr-gui/src/config_editor/tab.rs new file mode 100644 index 00000000..a198c9ca --- /dev/null +++ b/crates/fosr-gui/src/config_editor/tab.rs @@ -0,0 +1,133 @@ +//! Configuration tab: toggles between visual mode and YAML editor. + +use crate::config_editor::state::ConfigurationTabState; +use crate::config_editor::{host, host_validation, yaml_editor}; +use crate::config_editor::toolbar::render_configuration_toolbar; +use crate::shared::config::file_ops::load_config_file_contents; +use crate::shared::config::model::Configuration; +use crate::shared::config::state::ConfigFileState; +use crate::shared::constants::colors::{COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING}; +use crate::shared::constants::ui::{SPACING_MD, TEXT_EDIT_DEFAULT_ROWS}; +use crate::shared::widgets::helpers::{ + render_optional_text_area, render_optional_string_input, required_label, +}; +use eframe::egui; + +/// The main tab component +pub fn render_configuration_tab( + ui: &mut egui::Ui, + tab_state: &mut ConfigurationTabState, + file_state: &mut ConfigFileState, +) { + // Eagerly load config file contents when a file is selected + load_config_file_contents(file_state); + + egui::ScrollArea::vertical().show(ui, |ui| { + // File Selection + render_configuration_toolbar(ui, tab_state, file_state); + + render_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() { + host::render_hosts_section(ui, model); + ui.separator(); + let meta_id = ui.make_persistent_id("metadata_section"); + egui::collapsing_header::CollapsingState::load_with_default_open( + ui.ctx(), + meta_id, + false, + ) + .show_header(ui, |ui| { + ui.heading("Metadata"); + }) + .body(|ui| { + render_metadata_section(ui, model); + }); + } + + sync_model_to_yaml_state(file_state); + } else { + yaml_editor::render_yaml_editor(ui, file_state); + } + + // Update error flag (parse errors + host validation errors) + file_state.has_errors = file_state.config_error.is_some() + || file_state + .config_model + .as_ref() + .is_some_and(host_validation::has_model_errors); + } + }); +} + +/// Sync model state to YAML content and update dirty flag. +/// +/// Serializes the current model to YAML string and compares against +/// the clean snapshot to detect unsaved changes. +fn sync_model_to_yaml_state(state: &mut ConfigFileState) { + let Some(model) = &state.config_model else { + return; + }; + + // Serialize model to YAML (done once, reused for dirty check) + let model_yaml = match serde_yaml::to_string(model) { + Ok(yaml) => { + state.config_error = None; + yaml + } + Err(e) => { + state.config_error = Some(e.to_string()); + return; + } + }; + state.config_file_content = Some(model_yaml.clone()); + + // Update dirty flag by comparing with cached snapshot YAML + if let Some(snap_yaml) = &state.clean_snapshot { + state.is_dirty = model_yaml != *snap_yaml; + } +} + +/// Status & Feedback +fn render_parsing_status(ui: &mut egui::Ui, state: &ConfigFileState) { + if state.picked_config_file.is_some() { + if let Some(err) = &state.config_error { + ui.colored_label(COLOR_ERROR, "YAML parsing failed:"); + ui.label(err); + } else if state.config_model.is_some() { + ui.colored_label(COLOR_SUCCESS, "YAML parsed successfully"); + } else if state.config_file_content.is_some() { + ui.colored_label(COLOR_WARNING, "YAML loaded, but not parsed yet."); + } + ui.separator(); + } +} + +/// Metadata rendering +fn render_metadata_section(ui: &mut egui::Ui, model: &mut Configuration) { + ui.add_space(SPACING_MD); + + // Title + ui.horizontal(|ui| { + required_label(ui, "Title"); + let title = model.metadata.title.get_or_insert_with(String::new); + ui.text_edit_singleline(title); + }); + + render_optional_text_area( + ui, + "Description", + &mut model.metadata.desc, + "Optional description", + TEXT_EDIT_DEFAULT_ROWS, + ); + + render_optional_string_input(ui, "Author", &mut model.metadata.author, "Jane Doe"); + + render_optional_string_input(ui, "Version", &mut model.metadata.version, "0.1.0"); +} diff --git a/crates/fosr-gui/src/config_editor/toolbar.rs b/crates/fosr-gui/src/config_editor/toolbar.rs new file mode 100644 index 00000000..e17859f3 --- /dev/null +++ b/crates/fosr-gui/src/config_editor/toolbar.rs @@ -0,0 +1,185 @@ +//! Configuration toolbar UI: file picker, template menu, save button, and mode toggle. + +use crate::config_editor::state::ConfigurationTabState; +use crate::config_templates::{load_template, TEMPLATES}; +use crate::shared::config::file_ops::{enforce_metadata_defaults, trigger_file_import}; +use crate::shared::config::state::ConfigFileState; +use crate::shared::constants::colors::COLOR_WARNING; +use crate::shared::constants::ui::SPACING_LG; +#[cfg(not(target_arch = "wasm32"))] +use crate::shared::file_io::save_file_desktop; +#[cfg(target_arch = "wasm32")] +use crate::shared::file_io::save_file_wasm; +use crate::shared::widgets::helpers::labeled_toggle; +use eframe::egui; + +#[cfg(target_arch = "wasm32")] +use crate::shared::config::file_ops::poll_file_import; + +/// Configuration toolbar UI. +/// +/// Displays a file picker button, a template selection dropdown menu, +/// the selected file name and a Visual/Code mode toggle. +pub fn render_configuration_toolbar( + ui: &mut egui::Ui, + tab_state: &mut ConfigurationTabState, + state: &mut ConfigFileState, +) { + ui.horizontal(|ui| { + ui.label("Configuration file:"); + render_file_import_button(ui, state); + render_template_menu_button(ui, state); + render_file_save_button(ui, state); + render_filename(ui, state); + render_mode_toggle_button(ui, tab_state); + }); +} + +/// File import button with folder icon. +/// +/// Opens a file picker dialog to select a configuration file. +/// On WASM, polls the async file picker result. +fn render_file_import_button(ui: &mut egui::Ui, state: &mut ConfigFileState) { + if ui + .button(egui_material_icons::icons::ICON_FOLDER_OPEN) + .on_hover_text("Select a configuration file") + .clicked() + { + trigger_file_import(state, ui.ctx()); + } + + #[cfg(target_arch = "wasm32")] + poll_file_import(state); +} + +/// Template menu button with document icon. +/// +/// Dropdown menu listing available configuration templates. +/// Clicking a template loads it into the editor. +fn render_template_menu_button(ui: &mut egui::Ui, state: &mut ConfigFileState) { + let template_menu = + ui.menu_button(egui_material_icons::icons::ICON_DESCRIPTION, |menu_ui| { + for template in TEMPLATES { + if menu_ui + .button(format!("{} {}", template.icon, template.title)) + .clicked() + { + menu_ui.close(); + load_template(state, template); + } + } + }); + template_menu.response.on_hover_text("Open template"); +} + +/// Save button with "Save as" functionality. +/// +/// Only visible when a configuration is loaded. +/// Serializes the config to YAML and triggers a file save dialog. +/// Updates metadata with current date before saving. +fn render_file_save_button(ui: &mut egui::Ui, state: &mut ConfigFileState) { + if state.config_file_content.is_none() { + return; + } + + if ui + .button(egui_material_icons::icons::ICON_SAVE_AS) + .on_hover_text("Save as") + .clicked() + { + if let Some(model) = state.config_model.as_mut() { + enforce_metadata_defaults(model); + } + let content = match &state.config_model { + Some(model) => serde_yaml::to_string(model).unwrap_or_default(), + None => state.config_file_content.clone().unwrap_or_default(), + }; + state.is_dirty = false; + state.clean_snapshot = Some(content.clone()); + let default_name = state + .picked_config_file + .as_ref() + .map(|f| f.file_name()) + .unwrap_or_else(|| "config.yaml".to_string()); + + #[cfg(not(target_arch = "wasm32"))] + { + match save_file_desktop(content.as_bytes(), &default_name) { + Ok(handle) => { + log::info!("Config saved to {}", handle.path().display()); + } + Err(e) => { + log::error!("Failed to save config: {}", e); + } + } + } + + #[cfg(target_arch = "wasm32")] + { + wasm_bindgen_futures::spawn_local(async move { + match save_file_wasm(content.as_bytes(), &default_name).await { + Ok(_) => log::info!("Config saved"), + Err(e) => log::error!("Failed to save config: {}", e), + } + }); + } + } +} + +/// Filename display with dirty indicator. +/// +/// Shows the selected file name or template name. +/// Displays a warning icon when there are unsaved changes. +/// On desktop, shows the full path on hover. +fn render_filename(ui: &mut egui::Ui, state: &ConfigFileState) { + let filename = if let Some(file) = &state.picked_config_file { + file.file_name() + } else if let Some(template_id) = &state.loaded_template_id { + format!("{}.yaml (built-in template)", template_id) + } else { + "No file selected".to_string() + }; + + #[cfg(not(target_arch = "wasm32"))] + let hover_text = state + .picked_config_file + .as_ref() + .map(|file| file.path().to_string_lossy().to_string()) + .unwrap_or_default(); + + #[cfg(target_arch = "wasm32")] + let hover_text = String::new(); + + render_filename_with_status(ui, &filename, state.is_dirty, &hover_text); +} + +/// Render filename label with optional dirty indicator and hover text. +fn render_filename_with_status(ui: &mut egui::Ui, filename: &str, is_dirty: bool, hover_text: &str) { + if is_dirty { + ui.colored_label(COLOR_WARNING, egui_material_icons::icons::ICON_WARNING) + .on_hover_text("Unsaved changes detected — download the file to avoid losing them."); + ui.colored_label(COLOR_WARNING, filename) + .on_hover_text(hover_text); + } else { + ui.label(filename).on_hover_text(hover_text); + } +} + +/// Mode toggle for switching between Visual and Code editing modes. +/// +/// Positioned on the right side of the toolbar using RTL layout. +fn render_mode_toggle_button(ui: &mut egui::Ui, tab_state: &mut ConfigurationTabState) { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(SPACING_LG); + // In RTL layout, rendering order is reversed, + // so Code is passed first to appear visually on the right. + labeled_toggle( + ui, + &mut tab_state.is_code_mode, + &format!("{} Code", egui_material_icons::icons::ICON_CODE), + &format!("{} Visual", egui_material_icons::icons::ICON_EDIT), + "Code Mode: edit as raw YAML.", + "Visual Mode: edit using the graphical interface.", + ); + }); +} diff --git a/crates/fosr-gui/src/config_editor/yaml_editor.rs b/crates/fosr-gui/src/config_editor/yaml_editor.rs new file mode 100644 index 00000000..648d8319 --- /dev/null +++ b/crates/fosr-gui/src/config_editor/yaml_editor.rs @@ -0,0 +1,212 @@ +//! YAML editor with syntax highlighting and error line markers. + +use crate::shared::config::parser::parse_config_yaml; +use crate::shared::config::state::ConfigFileState; +use crate::shared::constants::colors::COLOR_ERROR; +use crate::shared::constants::ui::{YAML_EDITOR_ROWS, YAML_GUTTER_PADDING, YAML_ICON_COL_WIDTH}; +use eframe::egui; + +/// Extract line numbers from YAML parse error messages. +/// +/// Parses "line N" patterns from error strings to highlight problematic lines. +fn parse_error_lines(err: &str) -> Vec { + let mut found = Vec::new(); + let mut search = err; + 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 +} + +/// Metrics for rendering the line number gutter. +struct GutterMetrics { + /// Width of a single digit in the current font. + digit_width: f32, + /// Number of digits needed for the largest line number. + digits: usize, + /// Total width of the gutter column. + width: f32, + /// Height of a single line of text. + line_height: f32, + /// Font for gutter text. + font_id: egui::FontId, +} + +impl GutterMetrics { + /// Calculate gutter metrics from the content and UI style. + fn from_content(ui: &egui::Ui, line_count: usize) -> Self { + let font_id = egui::TextStyle::Monospace.resolve(ui.style()); + let line_height = ui.fonts_mut(|f| f.row_height(&font_id)); + let digit_width = ui.fonts_mut(|f| f.glyph_width(&font_id, '0')); + let digits = line_count.to_string().len(); + + Self { + digit_width, + digits, + width: digit_width * digits as f32 + YAML_GUTTER_PADDING + YAML_ICON_COL_WIDTH, + line_height, + font_id, + } + } +} + +/// Editable YAML Editor with syntax highlighting and error markers. +pub fn render_yaml_editor(ui: &mut egui::Ui, state: &mut ConfigFileState) { + if state.config_file_content.is_none() { + ui.label("No configuration file selected."); + return; + } + + // Display error banner if parsing failed + let error_lines = render_error_banner(ui, state); + + let mut content = state.config_file_content.clone().unwrap(); + let line_count = content.lines().count().max(1); + let metrics = GutterMetrics::from_content(ui, line_count); + + let gutter_color = ui.visuals().weak_text_color(); + 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; + + // Sync scroll position between gutter and editor + ui.spacing_mut().item_spacing.x = 0.0; + ui.horizontal_top(|ui| { + let available_height = ui.available_height(); + + // Allocate and render the gutter + let (gutter_rect, _) = ui.allocate_exact_size( + egui::vec2(metrics.width, available_height), + egui::Sense::hover(), + ); + + ui.painter().rect_filled(gutter_rect, 0.0, gutter_bg); + render_gutter( + ui, + &gutter_rect, + &metrics, + scroll_y, + line_count, + &error_lines, + gutter_color, + ); + + // Syntax-highlighted editor + let theme = egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx(), ui.style()); + let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { + let mut layout_job = egui_extras::syntax_highlighting::highlight( + ui.ctx(), + ui.style(), + &theme, + text.as_str(), + "yaml", + ); + layout_job.wrap.max_width = wrap_width; + ui.fonts_mut(|f| f.layout_job(layout_job)) + }; + + let scroll_out = egui::ScrollArea::vertical() + .id_salt("yaml_scroll_area") + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut content) + .font(egui::TextStyle::Monospace) + .code_editor() + .desired_rows(YAML_EDITOR_ROWS) + .lock_focus(true) + .desired_width(f32::INFINITY) + .layouter(&mut layouter), + ) + }); + + new_scroll_y = scroll_out.state.offset.y; + editor_changed = scroll_out.inner.changed(); + }); + + // Persist scroll position + ui.data_mut(|d| d.insert_temp(scroll_offset_id, new_scroll_y)); + + // Re-parse if content changed + if editor_changed { + state.config_file_content = Some(content); + parse_config_yaml(state); + } +} + +/// Render error banner and return parsed error line numbers. +fn render_error_banner(ui: &mut egui::Ui, state: &ConfigFileState) -> Vec { + if let Some(err) = &state.config_error { + ui.colored_label( + COLOR_ERROR, + format!( + "{} YAML parsing failed", + egui_material_icons::icons::ICON_WARNING + ), + ); + ui.colored_label(COLOR_ERROR, err); + ui.separator(); + parse_error_lines(err) + } else { + Vec::new() + } +} + +/// Render the line number gutter with optional error markers. +fn render_gutter( + ui: &mut egui::Ui, + gutter_rect: &egui::Rect, + metrics: &GutterMetrics, + scroll_y: f32, + line_count: usize, + error_lines: &[usize], + default_color: egui::Color32, +) { + let first_visible = (scroll_y / metrics.line_height).floor() as usize; + let visible_count = (gutter_rect.height() / metrics.line_height).ceil() as usize + 2; + + for i in first_visible..(first_visible + visible_count).min(line_count) { + let line_num = i + 1; + let y_offset = i as f32 * metrics.line_height - scroll_y; + let y = gutter_rect.top() + y_offset; + + if y > gutter_rect.bottom() { + break; + } + + let is_error = error_lines.contains(&line_num); + let num_color = if is_error { COLOR_ERROR } else { default_color }; + + // Right-aligned line number + let num_str = format!("{:>width$}", line_num, width = metrics.digits); + ui.painter().text( + egui::pos2(gutter_rect.left() + 2.0, y), + egui::Align2::LEFT_TOP, + num_str, + metrics.font_id.clone(), + num_color, + ); + + // Error icon for lines with parse errors + if is_error { + let num_col_right = gutter_rect.left() + metrics.digit_width * metrics.digits as f32 + 4.0; + let icon_rect = egui::Rect::from_min_size( + egui::pos2(num_col_right, y), + egui::vec2(YAML_ICON_COL_WIDTH, metrics.line_height), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(icon_rect), |ui| { + ui.colored_label(COLOR_ERROR, egui_material_icons::icons::ICON_WARNING); + }); + } + } +} diff --git a/crates/fosr-gui/src/config_templates/datacenter.yaml b/crates/fosr-gui/src/config_templates/datacenter.yaml new file mode 100644 index 00000000..639c4d35 --- /dev/null +++ b/crates/fosr-gui/src/config_templates/datacenter.yaml @@ -0,0 +1,207 @@ +metadata: + title: Datacenter Network Configuration + desc: A server-heavy setup with multiple server types + author: FosR + version: 1.0.0 + format: 1 + +networks: + # ==================== LOAD BALANCER TIER ==================== + - subnet: 10.0.1.0 + mask: 24 + name: "Load Balancers" + hosts: + - hostname: lb-primary + os: Linux + type: server + interfaces: + - ip_addr: 10.0.1.10 + mac_addr: 00:14:2A:3F:01:01 + services: + - https:443 + - http:80 + uses: + - https + - http + - dns + + - hostname: lb-secondary + os: Linux + type: server + interfaces: + - ip_addr: 10.0.1.11 + mac_addr: 00:14:2A:3F:01:02 + services: + - https:443 + - http:80 + uses: + - https + - http + - dns + + # ==================== WEB SERVER TIER ==================== + - subnet: 10.0.2.0 + mask: 24 + name: "Web Servers" + hosts: + - hostname: web-server-01 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.2.10 + mac_addr: 00:14:2A:3F:02:01 + services: + - https:443 + - http:80 + uses: + - https + - http + + - hostname: web-server-02 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.2.11 + mac_addr: 00:14:2A:3F:02:02 + services: + - https:443 + - http:80 + uses: + - https + - http + + - hostname: web-server-03 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.2.12 + mac_addr: 00:14:2A:3F:02:03 + services: + - https:443 + - http:80 + uses: + - https + - http + + # ==================== APPLICATION TIER ==================== + - subnet: 10.0.3.0 + mask: 24 + name: "Application Servers" + hosts: + - hostname: app-server-01 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.3.10 + mac_addr: 00:14:2A:3F:03:01 + services: + - https:8443 + - ssh:22 + uses: + - https + - ssh + + - hostname: app-server-02 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.3.11 + mac_addr: 00:14:2A:3F:03:02 + services: + - https:8443 + - ssh:22 + uses: + - https + - ssh + + # ==================== DATABASE TIER ==================== + - subnet: 10.0.4.0 + mask: 24 + name: "Database Servers" + hosts: + - hostname: db-primary + os: Linux + type: server + interfaces: + - ip_addr: 10.0.4.10 + mac_addr: 00:14:2A:3F:04:01 + services: + - https:5432 + - ssh:22 + uses: + - https + - ssh + + - hostname: db-replica-01 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.4.11 + mac_addr: 00:14:2A:3F:04:02 + services: + - https:5432 + uses: + - https + + - hostname: db-replica-02 + os: Linux + type: server + interfaces: + - ip_addr: 10.0.4.12 + mac_addr: 00:14:2A:3F:04:03 + services: + - https:5432 + uses: + - https + + # ==================== CACHE TIER ==================== + - subnet: 10.0.5.0 + mask: 24 + name: "Cache" + hosts: + - hostname: cache-server + os: Linux + type: server + interfaces: + - ip_addr: 10.0.5.10 + mac_addr: 00:14:2A:3F:05:01 + services: + - https:6379 + uses: + - https + + # ==================== MONITORING ==================== + - subnet: 10.0.6.0 + mask: 24 + name: "Monitoring" + hosts: + - hostname: monitoring-server + os: Linux + type: server + interfaces: + - ip_addr: 10.0.6.10 + mac_addr: 00:14:2A:3F:06:01 + services: + - https:9090 + - ssh:22 + uses: + - https + - ssh + - dns + + # ==================== DNS ==================== + - subnet: 10.0.7.0 + mask: 24 + name: "DNS" + hosts: + - hostname: dns-server + os: Linux + type: server + interfaces: + - ip_addr: 10.0.7.10 + mac_addr: 00:14:2A:3F:07:01 + services: + - dns:53 + uses: + - https + - dns diff --git a/crates/fosr-gui/src/config_templates/enterprise.yaml b/crates/fosr-gui/src/config_templates/enterprise.yaml new file mode 100644 index 00000000..6fcf6d41 --- /dev/null +++ b/crates/fosr-gui/src/config_templates/enterprise.yaml @@ -0,0 +1,147 @@ +metadata: + title: Enterprise Network Configuration + desc: A multi-subnet demo network with common services + author: FosR + version: 1.0.0 + format: 1 + +networks: + # ==================== DMZ SUBNET ==================== + - subnet: 192.168.10.0 + mask: 24 + name: "DMZ" + hosts: + - hostname: dmz-web-gateway + os: Linux + type: server + interfaces: + - ip_addr: 192.168.10.10 + mac_addr: 00:14:2A:3F:10:01 + services: + - https:443 + - http:80 + uses: + - https + - ssh + - ip_addr: 10.0.0.10 + mac_addr: 00:14:2A:3F:10:02 + + - hostname: dmz-dns-server + os: Linux + type: server + interfaces: + - ip_addr: 192.168.10.20 + mac_addr: 00:14:2A:3F:10:03 + services: + - dns:53 + + - hostname: dmz-mail-relay + os: Linux + type: server + interfaces: + - ip_addr: 192.168.10.30 + mac_addr: 00:14:2A:3F:10:04 + services: + - smtp:587 + uses: + - smtp + + # ==================== APPLICATION SUBNET ==================== + - subnet: 192.168.20.0 + mask: 24 + name: "Application" + hosts: + - hostname: app-api-frontend + os: Linux + type: server + interfaces: + - ip_addr: 192.168.20.10 + mac_addr: 00:14:2A:3F:20:01 + services: + - https:443 + - http:80 + uses: + - https + + - hostname: app-api-backend + os: Linux + type: server + interfaces: + - ip_addr: 192.168.20.20 + mac_addr: 00:14:2A:3F:20:03 + services: + - https:8443 + uses: + - https + + # ==================== DATABASE SUBNET ==================== + - subnet: 192.168.30.0 + mask: 24 + name: "Database" + hosts: + - hostname: db-primary + os: Linux + type: server + interfaces: + - ip_addr: 192.168.30.10 + mac_addr: 00:14:2A:3F:30:01 + services: + - https:5432 + uses: + - https + - ip_addr: 10.0.20.10 + mac_addr: 00:14:2A:3F:30:02 + services: + - ssh:22 + + - hostname: db-replica + os: Linux + type: server + interfaces: + - ip_addr: 192.168.30.20 + mac_addr: 00:14:2A:3F:30:03 + services: + - https:5432 + uses: + - https + + # ==================== USER SUBNET ==================== + - subnet: 192.168.50.0 + mask: 24 + name: "Users" + hosts: + - hostname: admin-workstation + os: Linux + type: user + interfaces: + - ip_addr: 192.168.50.100 + mac_addr: AA:BB:CC:DD:EE:01 + uses: + - ssh + - https + - smtp + - dns + + - hostname: dev-workstation-01 + os: Linux + type: user + interfaces: + - ip_addr: 192.168.50.101 + mac_addr: AA:BB:CC:DD:EE:02 + uses: + - ssh + - https + - http + - dns + + - hostname: dev-workstation-02 + os: Linux + type: user + interfaces: + - ip_addr: 192.168.50.102 + mac_addr: AA:BB:CC:DD:EE:03 + uses: + - ssh + - https + - http + - dns diff --git a/crates/fosr-gui/src/config_templates/home.yaml b/crates/fosr-gui/src/config_templates/home.yaml new file mode 100644 index 00000000..8d74e8eb --- /dev/null +++ b/crates/fosr-gui/src/config_templates/home.yaml @@ -0,0 +1,47 @@ +metadata: + title: Home Network Configuration + desc: A simple home network with a router and workstations + author: FosR + version: 1.0.0 + format: 1 + +networks: + - subnet: 192.168.1.0 + mask: 24 + name: "Home Network" + hosts: + - hostname: home-router + os: Linux + type: server + interfaces: + - ip_addr: 192.168.1.1 + mac_addr: 00:14:2A:3F:01:01 + services: + - dns:53 + - https:443 + uses: + - dns + - https + + - hostname: workstation + os: Windows + type: user + interfaces: + - ip_addr: 192.168.1.10 + mac_addr: AA:BB:CC:DD:EE:01 + uses: + - https + - dns + - ssh + + - hostname: nas + os: Linux + type: server + interfaces: + - ip_addr: 192.168.1.20 + mac_addr: 00:14:2A:3F:01:02 + services: + - https:443 + - ssh:22 + uses: + - https diff --git a/crates/fosr-gui/src/config_templates/mod.rs b/crates/fosr-gui/src/config_templates/mod.rs new file mode 100644 index 00000000..8b76260b --- /dev/null +++ b/crates/fosr-gui/src/config_templates/mod.rs @@ -0,0 +1,74 @@ +//! Predefined configuration templates: Home, Enterprise, Datacenter. + +use crate::shared::config::model::Configuration; +use crate::shared::config::state::ConfigFileState; +use egui_material_icons::icons; + +/// A predefined configuration template. +#[derive(Clone, Copy)] +pub struct Template { + pub id: &'static str, + pub title: &'static str, + pub description: &'static str, + pub icon: &'static str, + pub yaml: &'static str, +} + +/// All available templates. +pub static TEMPLATES: &[Template] = &[ + Template { + id: "home", + title: "Home Network", + description: "2-3 hosts, simple setup", + icon: icons::ICON_HOME, + yaml: include_str!("home.yaml"), + }, + Template { + id: "enterprise", + title: "Enterprise", + description: "DMZ + App + DB + Users", + icon: icons::ICON_BUSINESS, + yaml: include_str!("enterprise.yaml"), + }, + Template { + id: "datacenter", + title: "Datacenter", + description: "Many servers, no users", + icon: icons::ICON_DNS, + yaml: include_str!("datacenter.yaml"), + }, +]; + +/// Load a template into the configuration file state. +/// +/// Parses the template YAML first, then applies state changes only on success. +/// On parse failure, sets the error without modifying the existing model. +pub fn load_template(state: &mut ConfigFileState, template: &Template) { + // Parse first, fail-fast if invalid + let model = match serde_yaml::from_str::(template.yaml) { + Ok(model) => model, + Err(e) => { + state.config_error = Some(e.to_string()); + return; + } + }; + + // Apply state changes only after successful parse + apply_template_to_state(state, template, model); +} + +/// Reset state and apply a successfully-parsed template. +fn apply_template_to_state( + state: &mut ConfigFileState, + template: &Template, + model: Configuration, +) { + state.picked_config_file = None; + state.config_file_content = Some(template.yaml.to_string()); + state.config_model = Some(model); + state.clean_snapshot = Some(template.yaml.to_string()); + state.config_error = None; + state.config_chosen = true; + state.is_dirty = false; + state.loaded_template_id = Some(template.id.to_string()); +} diff --git a/crates/fosr-gui/src/configuration/configuration_tab.rs b/crates/fosr-gui/src/configuration/configuration_tab.rs deleted file mode 100644 index 26f96b43..00000000 --- a/crates/fosr-gui/src/configuration/configuration_tab.rs +++ /dev/null @@ -1,84 +0,0 @@ -use crate::shared::configuration_file::{ - ConfigurationFileState, configuration_file_picker, load_config_file_contents, -}; -use eframe::egui; - -/** - * Represents the state of the configuration tab. - */ -pub struct ConfigurationTabState {} - -impl Default for ConfigurationTabState { - fn default() -> Self { - Self {} - } -} - -pub fn show_configuration_tab_content( - ui: &mut egui::Ui, - _configuration_tab_state: &mut ConfigurationTabState, - configuration_file_state: &mut ConfigurationFileState, -) { - // Config file picker - configuration_file_picker(ui, configuration_file_state); - - ui.separator(); - - // 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); - } else { - let content = configuration_file_state - .config_file_content - .as_ref() - .unwrap(); - let theme = - egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx(), ui.style()); - let language = "yaml"; - - let mut layout_job = egui_extras::syntax_highlighting::highlight( - ui.ctx(), - ui.style(), - &theme, - content, - language, - ); - layout_job.wrap.max_width = ui.available_width(); - egui::ScrollArea::vertical().show(ui, |ui| { - ui.add( - egui::Label::new(layout_job).selectable(true), // Allows the user to copy the code even if they can't edit it - ); - }); - - // 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), - // ); - // }); - } - } -} diff --git a/crates/fosr-gui/src/configuration/mod.rs b/crates/fosr-gui/src/configuration/mod.rs deleted file mode 100644 index e039e2c1..00000000 --- a/crates/fosr-gui/src/configuration/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod configuration_tab; diff --git a/crates/fosr-gui/src/generation/generation_core.rs b/crates/fosr-gui/src/generation/generation_core.rs deleted file mode 100644 index 702feb36..00000000 --- a/crates/fosr-gui/src/generation/generation_core.rs +++ /dev/null @@ -1,164 +0,0 @@ -use chrono::{DateTime, Offset, TimeZone}; -use chrono_tz::Tz; -use fosr_lib::{ - models, stage0, stage1, stage2, stage2::tadam::TadamGenerator, stage3, stats::Target, -}; -use indicatif::HumanBytes; -use std::sync::{Arc, mpsc::Sender}; -use std::time::UNIX_EPOCH as STD_UNIX_EPOCH; -use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -pub fn generate( - seed: Option, - profile: Option, - order_pcap: bool, - start_time: Option, - duration: String, - taint: bool, - timezone: Option, - progress_sender: Option>, - pcap_sender: Option>>, -) { - // Create a closure to send progress updates - let send_progress = |progress: f32| { - if let Some(sender) = &progress_sender { - let _ = sender.send(progress); - } - }; - - // Create a closure to send pcap data - let send_pcap = |pcap_bytes: Vec| { - if let Some(sender) = &pcap_sender { - let _ = sender.send(pcap_bytes); - } - }; - - // Load the models - let source = models::ModelsSource::Legacy; - let mut model = models::Models::from_source(source).unwrap(); - if let Some(config) = profile { - model = model.with_string_config(&config).unwrap(); - } - - let automata_library = Arc::new(model.automata); - let bn = Arc::new(model.bn); - - // Handle the parameters: either there is a packet count target or a duration - let d = humantime::parse_duration(&duration).expect("Duration could not be parsed."); - log::info!("Generating a pcap of {d:?}"); - let _target = Target::GenerationDuration(d); - let duration = Some(d); - - if let Some(s) = seed { - log::info!("Generating with seed {s}"); - } - let initial_ts: Duration = if let Some(start_time) = start_time { - // try to parse a date - if let Ok(d) = humantime::parse_rfc3339_weak(&start_time) { - d.duration_since(STD_UNIX_EPOCH).unwrap() - } else if let Ok(n) = start_time.parse::() { - Duration::from_secs(n) - } else { - panic!("Could not parse start time"); - } - } else { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap() - }; - - let tz_offset = match timezone { - Some(tz_str) => { - let tz = tz_str.parse::().expect("Could not parse the timezone"); - let date = DateTime::from_timestamp(initial_ts.as_secs() as i64, 0) - .unwrap() - .naive_utc(); - let tz = tz.offset_from_utc_datetime(&date).fix(); - log::info!("Using {tz_str} timezone (UTC{tz})"); - tz - } - None => { - // Detect the local timezone - let date = DateTime::from_timestamp(initial_ts.as_secs() as i64, 0) - .unwrap() - .naive_utc(); - let tz = chrono::Local::now() - .timezone() - .offset_from_local_datetime(&date) - .single() - .expect("Ambiguous local date from timestamp") - .fix(); - log::info!("Using local timezone (UTC{tz})"); - tz - } - }; - - let s0 = stage0::BinBasedGenerator::new( - seed, - false, - None, - model.time_bins, - initial_ts, - duration, - tz_offset, - ); - let s1 = stage1::bayesian_networks::BNGenerator::new(bn, false); - 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); -} - -fn run_single_thread( - order_pcap: bool, - s0: impl stage0::Stage0, - s1: impl stage1::Stage1, - s2: impl stage2::Stage2, - s3: stage3::Stage3, - send_progress: impl Fn(f32), - send_pcap: impl Fn(Vec), -) { - let start = Instant::now(); - - log::info!("Stage 0 generation"); - let vec = stage0::run_vec(s0); - send_progress(0.2); - - log::info!("Stage 1 generation"); - let vec = stage1::run_vec(s1, vec).unwrap(); - send_progress(0.4); - - log::info!("Stage 2 generation"); - let vec = stage2::run_vec(s2, vec); - send_progress(0.6); - - let mut all_packets = vec![]; - log::info!("Stage 3 generation"); - all_packets.append(&mut stage3::run_vec( - |f, p, v, a| s3.generate_udp_packets(f, p, v, a), - vec.udp, - )); - all_packets.append(&mut stage3::run_vec( - |f, p, v, a| s3.generate_tcp_packets(f, p, v, a), - vec.tcp, - )); - all_packets.append(&mut stage3::run_vec( - |f, p, v, a| s3.generate_icmp_packets(f, p, v, a), - vec.icmp, - )); - 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) - ); - - if order_pcap { - log::info!("Sorting the packets"); - all_packets.sort_unstable(); - } - - 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 deleted file mode 100644 index b033df90..00000000 --- a/crates/fosr-gui/src/generation/generation_tab.rs +++ /dev/null @@ -1,411 +0,0 @@ -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, -}; -use crate::shared::configuration_file::{ConfigurationFileState, configuration_file_picker}; -#[cfg(not(target_arch = "wasm32"))] -use crate::shared::file_io::{read_file_desktop, save_file_desktop}; -#[cfg(target_arch = "wasm32")] -use crate::shared::file_io::{read_file_wasm, save_file_wasm}; -use chrono::NaiveDate; -use chrono_tz::Tz; -use eframe::egui; -use eframe::egui::{SliderClamping, Widget}; -use egui_extras::DatePickerButton; -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 -pub const DURATION_MAX: Duration = Duration::from_secs(3 * 24 * 3600); // 3 days - -pub enum UiStatus { - Idle, - Generating, - Generated, - #[cfg(not(target_arch = "wasm32"))] - Saved(String), - #[cfg(not(target_arch = "wasm32"))] - Error(String), -} - -/** - * Represents the state of the generation tab. - */ -pub struct GenerationTabState { - pub progress: f32, - pub progress_receiver: Option>, - pub pcap_bytes: Option>, - pub pcap_receiver: Option>>, - pub 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 seed_input: String, - pub timezone_input: String, - pub use_local_timezone: bool, - pub start_date: NaiveDate, - pub start_hour: String, - 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, - 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, - taint: false, - duration_str: default_duration, - duration_slider_value, - seed_input: String::new(), - timezone_input: String::new(), - use_local_timezone: true, - start_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), - start_hour: "00:00:00".to_string(), - output_file_name: "output.pcap".to_string(), - } - } -} - -pub fn show_generation_tab_content( - ui: &mut egui::Ui, - 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(); - } - - ui.horizontal(|ui| { - ui.label("Duration"); - - let response = egui::TextEdit::singleline(&mut state.duration_str) - .desired_width(100.0) - .hint_text("ex: 30m, 1h, 2d") - .ui(ui); - - if response.changed() { - match validate_duration(&state.duration_str) { - Ok(d) => { - state.duration_validation.set_ok(); - state.duration_slider_value = duration_to_slider(d); - } - Err(msg) => { - state.duration_validation.set_err(msg); - } - } - } - - 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.add(DatePickerButton::new(&mut state.start_date)); - - let response = egui::TextEdit::singleline(&mut state.start_hour) - .hint_text("HH:MM") - .desired_width(50.0) - .ui(ui); - - if response.changed() { - match validate_start_hour(&state.start_hour) { - Ok(()) => state.start_hour_validation.set_ok(), - Err(msg) => state.start_hour_validation.set_err(msg), - } - } - - show_field_error(ui, &state.start_hour_validation); - }); - - ui.add_space(10.0); - - ui.horizontal(|ui| { - if ui - .checkbox(&mut state.use_local_timezone, "Use local timezone") - .clicked() - { - if state.use_local_timezone { - // Reset the timezone - state.timezone_input = String::new(); - state.timezone_validation.set_ok(); - } else { - // Set the default timezone - state.timezone_input = Tz::CET.to_string(); - } - } - 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()); - } - } - }); - - 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); - } - } - } - - show_field_error(ui, &state.seed_validation); - }); - - ui.add_space(15.0); - - ui.checkbox(&mut state.taint, "Taint the packets"); - - ui.checkbox(&mut state.order_pcap, "Order temporally"); - - 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(); - - ui.horizontal(|ui| { - ui.add_enabled_ui(can_generate, |ui| { - if ui.button("Generate").clicked() { - state.status = UiStatus::Generating; - - // Reset the progress value - state.progress = 0.0; - - 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(); - - #[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(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(); - }); - } - } - - 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 state.pcap_bytes.is_some() && state.progress == 1.0 { - #[cfg(not(target_arch = "wasm32"))] - if !matches!(state.status, UiStatus::Saved(_) | UiStatus::Error(_)) { - state.status = UiStatus::Generated; - } - #[cfg(target_arch = "wasm32")] - { - state.status = UiStatus::Generated; - } - #[cfg(not(target_arch = "wasm32"))] - let save_button_label = "Save"; - #[cfg(target_arch = "wasm32")] - let save_button_label = "Download"; - if ui.button(save_button_label).clicked() { - // --- Save file --- - let pcap_bytes = state.pcap_bytes.clone(); - #[cfg(not(target_arch = "wasm32"))] - { - let data = pcap_bytes.as_ref().unwrap().as_slice(); - match save_file_desktop(data, &state.output_file_name) { - Ok(file_handle) => { - log::info!( - "Successfully wrote to file: {}", - file_handle.path().to_string_lossy() - ); - state.status = UiStatus::Saved(format!( - "Saved to: {}", - file_handle.path().to_string_lossy() - )); - } - Err(e) => { - log::error!("Failed to save file: {:?}", e); - state.status = UiStatus::Error(format!("Failed to save file: {e}")); - } - } - } - - #[cfg(target_arch = "wasm32")] - { - // 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)); - - ui.add_sized([ui.available_width(), 20.0], progress); -} diff --git a/crates/fosr-gui/src/generation/generation_ui_components.rs b/crates/fosr-gui/src/generation/generation_ui_components.rs deleted file mode 100644 index 026779e5..00000000 --- a/crates/fosr-gui/src/generation/generation_ui_components.rs +++ /dev/null @@ -1,77 +0,0 @@ -use super::generation_tab::{GenerationTabState, UiStatus}; -use super::generation_validation::FieldValidation; -use chrono_tz::TZ_VARIANTS; -use eframe::{egui, egui::PopupCloseBehavior}; - -/** - * Display the error in red - */ -pub fn show_field_error(ui: &mut egui::Ui, validation: &FieldValidation) { - if let Some(msg) = &validation.error { - ui.add_space(6.0); - ui.colored_label(egui::Color32::RED, msg); - } -} - -pub fn show_status(ui: &mut egui::Ui, status: &UiStatus) { - match status { - UiStatus::Idle => {} - UiStatus::Generating => { - ui.label("Generating file…"); - } - UiStatus::Generated => { - ui.label("File generated. You can save it."); - } - #[cfg(not(target_arch = "wasm32"))] - UiStatus::Saved(msg) => { - ui.label(format!("File saved. {}", msg)); - } - #[cfg(not(target_arch = "wasm32"))] - UiStatus::Error(msg) => { - ui.colored_label(egui::Color32::RED, format!("Error: {msg}")); - } - } -} - -pub fn timezone_picker(ui: &mut egui::Ui, state: &mut GenerationTabState) { - 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); - - // Define a unique ID for focus and state tracking - let edit_id = ui.make_persistent_id("timezone_search_input"); - - // Add the text edit widget - 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(); - } - } - } - }); -} 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 deleted file mode 100644 index cd8d13e5..00000000 --- a/crates/fosr-gui/src/generation/generation_validation.rs +++ /dev/null @@ -1,104 +0,0 @@ -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 - */ -#[derive(Default, Clone)] -pub struct FieldValidation { - pub error: Option, -} - -impl FieldValidation { - pub fn set_ok(&mut self) { - self.error = None; - } - pub fn set_err(&mut self, msg: impl Into) { - self.error = Some(msg.into()); - } -} - -// 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"; - -// return the first invalid parameter -pub fn first_invalid_param( - state: &GenerationTabState, -) -> Option<(&'static str, &'static str, String)> { - if let Some(err) = &state.duration_validation.error { - return Some(("Duration", SPEC_DURATION, err.clone())); - } - if let Some(err) = &state.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())); - } - if let Some(err) = &state.timezone_validation.error { - return Some(("Timezone", SPEC_TIMEZONE, err.clone())); - } - None -} - -pub fn validate_duration(duration_str: &str) -> Result { - let d = humantime::parse_duration(duration_str).map_err(|_| "Invalid value".to_string())?; - - if d < DURATION_MIN || d > DURATION_MAX { - return Err(format!( - "Out of range ({} – {})", - humantime::format_duration(DURATION_MIN), - humantime::format_duration(DURATION_MAX), - )); - } - 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(); - if s.is_empty() { - return Ok(None); - } - s.parse::() - .map(Some) - .map_err(|_| "Invalid value".to_string()) -} - -pub fn validate_timezone(input: &str) -> Result<(), String> { - let parsed = input.parse::(); - match parsed { - Ok(_) => Ok(()), - Err(_) => Err("Invalid value".to_string()), - } -} diff --git a/crates/fosr-gui/src/generation/mod.rs b/crates/fosr-gui/src/generation/mod.rs deleted file mode 100644 index f8bb99fc..00000000 --- a/crates/fosr-gui/src/generation/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 9dfd6668..00000000 --- a/crates/fosr-gui/src/injection_tab.rs +++ /dev/null @@ -1,6 +0,0 @@ -#![cfg(not(target_arch = "wasm32"))] -use eframe::egui; - -pub fn show_injection_tab_content(ui: &mut egui::Ui) { - ui.heading("COMING SOON"); -} diff --git a/crates/fosr-gui/src/lib.rs b/crates/fosr-gui/src/lib.rs index f274a927..3deeb358 100644 --- a/crates/fosr-gui/src/lib.rs +++ b/crates/fosr-gui/src/lib.rs @@ -1,21 +1,21 @@ +//! WASM entry point for the Fos-R GUI web application. +//! Initializes eframe and starts the app in the browser canvas. + #![cfg(target_arch = "wasm32")] -mod about_tab; -mod app; -mod configuration; -mod generation; -#[cfg(not(target_arch = "wasm32"))] -mod injection_tab; -mod shared; -mod visualization; +include!("app_modules.rs"); use app::FosrApp; use eframe::wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; +/// Function that can be called from JavaScript to start the Fos-R GUI app. +/// The `canvas_id` argument is the ID of the HTML canvas element where the app will be rendered. #[wasm_bindgen] pub async fn start(canvas_id: &str) -> Result<(), JsValue> { - // Redirect `log` message to `console.log`: - eframe::WebLogger::init(log::LevelFilter::Debug).ok(); + // Redirect logs to the browser console + if let Err(e) = eframe::WebLogger::init(log::LevelFilter::Info) { + log::warn!("WebLogger initialization failed: {:?}", e); + } let web_options = eframe::WebOptions::default(); @@ -24,7 +24,7 @@ pub async fn start(canvas_id: &str) -> Result<(), JsValue> { .document() .expect("No document"); - // The canvas_id is passed as an argument from the HTML file + // The `canvas_id` is passed as an argument from the HTML file // and identifies the canvas element on which the app will be rendered let canvas = document .get_element_by_id(canvas_id) @@ -36,7 +36,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..2127f663 100644 --- a/crates/fosr-gui/src/main.rs +++ b/crates/fosr-gui/src/main.rs @@ -1,25 +1,24 @@ -mod about_tab; -mod app; -mod configuration; -mod generation; -#[cfg(not(target_arch = "wasm32"))] -mod injection_tab; -mod shared; -mod visualization; +//! Entry point for the Fos-R GUI application. +//! Compiles natively for desktop; delegates to lib.rs for WASM builds. + +include!("app_modules.rs"); // Desktop: native compilation #[cfg(not(target_arch = "wasm32"))] fn main() -> eframe::Result { use crate::app::FosrApp; + use crate::shared::constants::ui::{ + WINDOW_DEFAULT_HEIGHT, WINDOW_DEFAULT_WIDTH, WINDOW_MIN_HEIGHT, WINDOW_MIN_WIDTH, + }; use eframe::egui; use env_logger; - // Redirect log messages to the console + // Redirect log messages to the terminal env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); let shared_viewport = egui::ViewportBuilder::default() - .with_inner_size([500.0, 440.0]) - .with_min_inner_size([400.0, 350.0]) + .with_inner_size([WINDOW_DEFAULT_WIDTH, WINDOW_DEFAULT_HEIGHT]) + .with_min_inner_size([WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT]) .with_title("Fos-R"); #[cfg(target_os = "macos")] @@ -41,7 +40,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/run/generation/bottom_panel.rs b/crates/fosr-gui/src/run/generation/bottom_panel.rs new file mode 100644 index 00000000..e4f5de27 --- /dev/null +++ b/crates/fosr-gui/src/run/generation/bottom_panel.rs @@ -0,0 +1,268 @@ +//! Bottom panel UI for the Run tab. +//! +//! Contains the action bar with Generate/Stop/Save buttons and +//! the expandable generation options panel. + +use super::options::render_generation_options; +use super::process::start_generation; +use super::validation::first_invalid_param; +#[cfg(not(target_arch = "wasm32"))] +use super::wireshark::open_in_wireshark; +use crate::run::state::RunTabState; +use crate::shared::config::state::ConfigFileState; +use crate::shared::constants::colors::{COLOR_ERROR, COLOR_STOP, COLOR_SUCCESS}; +use crate::shared::constants::ui::{ + BOTTOM_BAR_INNER_MARGIN, BUTTON_HEIGHT, BUTTON_MIN_WIDTH_LG, BUTTON_MIN_WIDTH_SM, + DELAY_FRAMES_QUICK, OPTIONS_PANEL_INNER_MARGIN, TEXT_SIZE_MD, +}; +#[cfg(not(target_arch = "wasm32"))] +use crate::shared::file_io::save_file_desktop; +#[cfg(target_arch = "wasm32")] +use crate::shared::file_io::save_file_wasm; +use eframe::egui; +use std::sync::atomic::Ordering; + +/// Show the bottom panel with action bar and expandable options. +/// +/// The panel consists of: +/// - Options panel (shown when expanded): generation parameters +/// - Action bar (always visible): Generate/Stop/Save buttons, progress bar +pub fn render_bottom_panel( + ctx: &egui::Context, + state: &mut RunTabState, + configuration_file_state: &ConfigFileState, +) { + if state.panel_open { + render_options_panel(ctx, state); + } + render_action_bar(ctx, state, configuration_file_state); +} + +/// Options panel shown above the action bar when expanded. +fn render_options_panel(ctx: &egui::Context, state: &mut RunTabState) { + egui::TopBottomPanel::bottom("run_options_panel") + .frame( + egui::Frame::side_top_panel(&ctx.style()) + .inner_margin(egui::Margin::symmetric( + OPTIONS_PANEL_INNER_MARGIN.0, + OPTIONS_PANEL_INNER_MARGIN.1, + )) + .fill(ctx.style().visuals.panel_fill), + ) + .resizable(false) + .show(ctx, |ui| { + render_generation_options(ui, state); + }); +} + +/// Action bar with Generate/Stop/Save buttons and progress indicators. +fn render_action_bar( + ctx: &egui::Context, + state: &mut RunTabState, + configuration_file_state: &ConfigFileState, +) { + egui::TopBottomPanel::bottom("run_bottom_bar") + .frame( + egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin::symmetric( + BOTTOM_BAR_INNER_MARGIN.0, + BOTTOM_BAR_INNER_MARGIN.1, + )), + ) + .show(ctx, |ui| { + let is_generating = state.generation.is_generating(); + let is_complete = state.generation.is_complete(); + let can_generate = first_invalid_param(&state.generation).is_none(); + + ui.horizontal(|ui| { + // Left side: action buttons + if !is_generating { + render_generate_button(ui, state, configuration_file_state, ctx, can_generate); + } + if is_generating { + render_stop_button(ui, state); + } + if is_complete { + render_completion_buttons(ui, state); + } + if let Some(error) = &state.generation.error { + ui.colored_label(COLOR_ERROR, error); + } + + // Right side: options toggle, throughput, progress + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + render_options_toggle(ui, state); + if is_complete { + render_throughput(ui, state); + } + if is_generating { + render_progress_bar(ui, state); + } + }); + }); + }); +} + +/// Generate button with accent color. +fn render_generate_button( + ui: &mut egui::Ui, + state: &mut RunTabState, + configuration_file_state: &ConfigFileState, + ctx: &egui::Context, + can_generate: bool, +) { + ui.add_enabled_ui(can_generate, |ui| { + let accent = ui.visuals().selection.bg_fill; + let button = egui::Button::new( + egui::RichText::new(format!( + "{} Generate", + egui_material_icons::icons::ICON_PLAY_ARROW + )) + .size(TEXT_SIZE_MD), + ) + .fill(accent) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_LG, BUTTON_HEIGHT)); + + if ui.add(button).on_hover_text("Generate PCAP from configuration").clicked() { + start_generation(state, configuration_file_state, ctx); + } + }); +} + +/// Stop button to cancel ongoing generation. +fn render_stop_button(ui: &mut egui::Ui, state: &mut RunTabState) { + let button = egui::Button::new( + egui::RichText::new(format!("{} Stop", egui_material_icons::icons::ICON_STOP)) + .size(TEXT_SIZE_MD), + ) + .fill(COLOR_STOP) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); + + if ui.add(button).on_hover_text("Cancel generation").clicked() { + state.generation.cancelled.store(true, Ordering::Relaxed); + state.generation.progress = 0.0; + state.generation.progress_receiver = None; + state.generation.pcap_receiver = None; + state.generation.throughput_receiver = None; + } +} + +/// Save and Wireshark buttons shown when generation is complete. +fn render_completion_buttons(ui: &mut egui::Ui, state: &mut RunTabState) { + render_save_button(ui, state); + #[cfg(not(target_arch = "wasm32"))] + render_wireshark_button(ui, state); +} + +/// Save/Download button for the generated PCAP. +fn render_save_button(ui: &mut egui::Ui, state: &mut RunTabState) { + #[cfg(not(target_arch = "wasm32"))] + let save_text = format!("{} Save", egui_material_icons::icons::ICON_SAVE); + #[cfg(target_arch = "wasm32")] + let save_text = format!("{} Download", egui_material_icons::icons::ICON_DOWNLOAD); + + let button = egui::Button::new(egui::RichText::new(save_text).size(TEXT_SIZE_MD)) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); + + if ui.add(button).clicked() { + let pcap_bytes = state.generation.pcap_bytes.clone(); + #[cfg(not(target_arch = "wasm32"))] + save_pcap_desktop(pcap_bytes, state); + + #[cfg(target_arch = "wasm32")] + save_pcap_wasm(pcap_bytes, &state.generation.output_file_name); + } +} + +/// Saves PCAP to disk on desktop platforms. +#[cfg(not(target_arch = "wasm32"))] +fn save_pcap_desktop(pcap_bytes: Option>, state: &mut RunTabState) { + let Some(data) = pcap_bytes else { + log::error!("No PCAP data available to save"); + state.generation.error = Some("No PCAP data available".to_string()); + return; + }; + match save_file_desktop(&data, &state.generation.output_file_name) { + Ok(file_handle) => { + log::info!("Successfully wrote to file: {}", file_handle.path().to_string_lossy()); + } + Err(e) => { + log::error!("Failed to save file: {:?}", e); + state.generation.error = Some(format!("Failed to save file: {e}")); + } + } +} + +/// Triggers PCAP download on WASM platforms. +#[cfg(target_arch = "wasm32")] +fn save_pcap_wasm(pcap_bytes: Option>, file_name: &str) { + let Some(data) = pcap_bytes else { + log::error!("No PCAP data available to download"); + return; + }; + let file_name = file_name.to_string(); + wasm_bindgen_futures::spawn_local(async move { + match save_file_wasm(&data, &file_name).await { + Ok(_) => log::info!("File written successfully!"), + Err(e) => log::error!("Failed to write file on WASM: {:?}", e), + } + }); +} + +/// Wireshark button (native only) to open PCAP in external tool. +#[cfg(not(target_arch = "wasm32"))] +fn render_wireshark_button(ui: &mut egui::Ui, state: &mut RunTabState) { + let button = egui::Button::new( + egui::RichText::new(format!("{} Open", egui_material_icons::icons::ICON_LAN)) + .size(TEXT_SIZE_MD), + ) + .min_size(egui::vec2(BUTTON_MIN_WIDTH_SM, BUTTON_HEIGHT)); + + let response = ui.add_enabled(state.generation.wireshark_available, button); + let response = if state.generation.wireshark_available { + response.on_hover_text("Open in Wireshark") + } else { + response.on_disabled_hover_text("Wireshark not found in PATH") + }; + + if response.clicked() { + if let Some(ref pcap_bytes) = state.generation.pcap_bytes { + match open_in_wireshark(pcap_bytes, &mut state.generation.temp_pcap_files) { + Ok(_) => log::info!("Opened PCAP in Wireshark"), + Err(e) => { + log::error!("Failed to open in Wireshark: {:?}", e); + state.generation.error = Some(format!("Failed to open in Wireshark: {e}")); + } + } + } + } +} + +/// Options toggle button to show/hide the options panel. +fn render_options_toggle(ui: &mut egui::Ui, state: &mut RunTabState) { + let icon = if state.panel_open { + egui_material_icons::icons::ICON_KEYBOARD_ARROW_DOWN + } else { + egui_material_icons::icons::ICON_KEYBOARD_ARROW_UP + }; + let tooltip = if state.panel_open { "Hide options" } else { "Show options" }; + + if ui.button(format!("{} Options", icon)).on_hover_text(tooltip).clicked() { + state.panel_open = !state.panel_open; + state.visualization.view.delayed_fit_countdown = Some(DELAY_FRAMES_QUICK); + } +} + +/// Throughput display shown when generation is complete. +fn render_throughput(ui: &mut egui::Ui, state: &RunTabState) { + if let Some(throughput) = &state.generation.throughput { + ui.label(format!("Throughput: {throughput}")); + } +} + +/// Progress bar shown during generation. +fn render_progress_bar(ui: &mut egui::Ui, state: &RunTabState) { + let progress = egui::ProgressBar::new(state.generation.progress) + .text("") + .fill(COLOR_SUCCESS); + ui.add(progress); +} diff --git a/crates/fosr-gui/src/run/generation/core.rs b/crates/fosr-gui/src/run/generation/core.rs new file mode 100644 index 00000000..a0774e12 --- /dev/null +++ b/crates/fosr-gui/src/run/generation/core.rs @@ -0,0 +1,275 @@ +//! Core PCAP generation: runs the 4-stage Fos-R pipeline (S0→S1→S2→S3). + +use chrono::{DateTime, Offset, TimeZone}; +use chrono_tz::Tz; +use fosr_lib::{ + models, stage0, stage1, stage2, stage2::tadam::TadamGenerator, stage3, stats::Target, +}; +use indicatif::HumanBytes; +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}; + +/// Entry point for PCAP generation. Runs the 4-stage pipeline and sends results via channels. +/// +/// Returns `Ok(())` on success, or `Err(message)` if generation fails. +pub fn generate( + seed: Option, + profile: Option, + order_pcap: bool, + start_time: Option, + duration: String, + taint: bool, + timezone: Option, + progress_sender: Option>, + pcap_sender: Option>>, + throughput_sender: Option>, + cancelled: Arc, +) -> Result<(), String> { + // Create a closure to send progress updates + let send_progress = |progress: f32| { + if let Some(sender) = &progress_sender { + let _ = sender.send(progress); + } + }; + + // Create a closure to send pcap data + let send_pcap = |pcap_bytes: Vec| { + if let Some(sender) = &pcap_sender { + let _ = sender.send(pcap_bytes); + } + }; + + // Load the models + let model = load_models(&profile)?; + let automata_library = Arc::new(model.automata); + let bayesian_network = Arc::new(model.bn); + + // Handle the parameters: either there is a packet count target or a duration + let parsed_duration = humantime::parse_duration(&duration) + .map_err(|e| format!("Invalid duration '{}': {}", duration, e))?; + log::info!("Generating a pcap of {parsed_duration:?}"); + let _target = Target::GenerationDuration(parsed_duration); + + if let Some(s) = seed { + log::info!("Generating with seed {s}"); + } + let initial_ts = parse_start_time(start_time)?; + let tz_offset = resolve_timezone_offset(timezone, initial_ts)?; + + let s0 = stage0::BinBasedGenerator::new( + seed, + false, + None, + model.time_bins, + initial_ts, + Some(parsed_duration), + tz_offset, + ); + let s1 = stage1::bayesian_networks::BNGenerator::new(bayesian_network, false); + let s2 = TadamGenerator::new(automata_library); + let s3 = stage3::Stage3::new(taint); + log::info!("Run single thread"); + execute_generation_pipeline( + order_pcap, + s0, + s1, + s2, + s3, + send_progress, + send_pcap, + throughput_sender, + cancelled, + ) +} + +/// Loads ML models from bundled assets, optionally applying a config profile. +fn load_models(profile: &Option) -> Result { + let source = models::ModelsSource::Legacy; + let mut model = models::Models::from_source(source) + .map_err(|e| format!("Failed to load ML models: {}", e))?; + if let Some(config) = profile { + model = model + .with_string_config(config) + .map_err(|e| format!("Failed to apply config: {}", e))?; + } + Ok(model) +} + +/// Parses start time from RFC3339 string, Unix timestamp, or returns current time if None. +fn parse_start_time(start_time: Option) -> Result { + match start_time { + Some(start_time) => { + // try to parse a date + if let Ok(d) = humantime::parse_rfc3339_weak(&start_time) { + d.duration_since(STD_UNIX_EPOCH) + .map_err(|e| format!("Invalid start time: {}", e)) + } else if let Ok(n) = start_time.parse::() { + Ok(Duration::from_secs(n)) + } else { + Err(format!( + "Could not parse start time '{}' (expected RFC3339 date or Unix timestamp)", + start_time + )) + } + } + None => SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| format!("Failed to get current time: {}", e)), + } +} + +/// Resolves timezone offset from IANA string, or detects local timezone if None. +fn resolve_timezone_offset( + timezone: Option, + initial_ts: Duration, +) -> Result { + let date = DateTime::from_timestamp(initial_ts.as_secs() as i64, 0) + .ok_or_else(|| "Invalid timestamp: value out of range".to_string())? + .naive_utc(); + + match timezone { + Some(tz_str) => { + let tz = tz_str + .parse::() + .map_err(|e| format!("Invalid timezone '{}': {}", tz_str, e))?; + let offset = tz.offset_from_utc_datetime(&date).fix(); + log::info!("Using {tz_str} timezone (UTC{offset})"); + Ok(offset) + } + None => { + // Detect the local timezone + let offset = chrono::Local::now() + .timezone() + .offset_from_local_datetime(&date) + .single() + .ok_or_else(|| { + "Could not determine local timezone (ambiguous or invalid date)".to_string() + })? + .fix(); + log::info!("Using local timezone (UTC{offset})"); + Ok(offset) + } + } +} + +/// Executes the 4-stage pipeline sequentially with cancellation support. +fn execute_generation_pipeline( + order_pcap: bool, + s0: impl stage0::Stage0, + s1: impl stage1::Stage1, + s2: impl stage2::Stage2, + s3: stage3::Stage3, + send_progress: impl Fn(f32), + send_pcap: impl Fn(Vec), + throughput_sender: Option>, + cancelled: Arc, +) -> Result<(), String> { + let is_cancelled = || cancelled.load(Ordering::Relaxed); + + let start = Instant::now(); + + log::info!("Stage 0 generation"); + let stage0_output = stage0::run_vec(s0); + if is_cancelled() { + log::info!("Generation cancelled after stage 0"); + return Ok(()); + } + send_progress(0.2); + + log::info!("Stage 1 generation"); + let stage1_output = stage1::run_vec(s1, stage0_output).map_err(|e| format!("Stage 1 failed: {}", e))?; + if is_cancelled() { + log::info!("Generation cancelled after stage 1"); + return Ok(()); + } + send_progress(0.4); + + log::info!("Stage 2 generation"); + let stage2_output = stage2::run_vec(s2, stage1_output); + if is_cancelled() { + log::info!("Generation cancelled after stage 2"); + return Ok(()); + } + send_progress(0.6); + + log::info!("Stage 3 generation"); + let stage3_packets = generate_stage3_packets(&s3, stage2_output, &is_cancelled); + let mut all_packets = match stage3_packets { + Some(p) => p, + None => return Ok(()), // Cancelled + }; + send_progress(0.8); + + let gen_duration = start.elapsed().as_secs_f64(); + let total_size = all_packets.iter().map(|p| p.data.len()).sum::() as u64; + let throughput_str = format!( + "{}/s", + HumanBytes(((total_size as f64) / gen_duration) as u64) + ); + 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 Ok(()); + } + + if order_pcap { + log::info!("Sorting the packets"); + all_packets.sort_unstable(); + } + + if is_cancelled() { + log::info!("Generation cancelled"); + return Ok(()); + } + + let pcap_bytes = + stage3::to_pcap_vec(&all_packets).map_err(|e| format!("Failed to create PCAP: {}", e))?; + send_pcap(pcap_bytes); + send_progress(1.0); + Ok(()) +} + +/// Generates UDP, TCP, and ICMP packets. Returns None if cancelled. +fn generate_stage3_packets( + s3: &stage3::Stage3, + vec: stage2::S2Vector, + is_cancelled: &impl Fn() -> bool, +) -> Option> { + let mut all_packets = vec![]; + + all_packets.extend(stage3::run_vec( + |f, p, v, a| s3.generate_udp_packets(f, p, v, a), + vec.udp, + )); + if is_cancelled() { + log::info!("Generation cancelled during stage 3"); + return None; + } + + all_packets.extend(stage3::run_vec( + |f, p, v, a| s3.generate_tcp_packets(f, p, v, a), + vec.tcp, + )); + if is_cancelled() { + log::info!("Generation cancelled during stage 3"); + return None; + } + + all_packets.extend(stage3::run_vec( + |f, p, v, a| s3.generate_icmp_packets(f, p, v, a), + vec.icmp, + )); + if is_cancelled() { + log::info!("Generation cancelled during stage 3"); + return None; + } + + Some(all_packets) +} diff --git a/crates/fosr-gui/src/run/generation/mod.rs b/crates/fosr-gui/src/run/generation/mod.rs new file mode 100644 index 00000000..2302f7a1 --- /dev/null +++ b/crates/fosr-gui/src/run/generation/mod.rs @@ -0,0 +1,9 @@ +//! PCAP generation module: core logic, state, validation, and Wireshark integration. + +pub mod bottom_panel; +pub mod core; +pub mod options; +pub mod process; +pub mod state; +pub mod validation; +pub mod wireshark; diff --git a/crates/fosr-gui/src/run/generation/options.rs b/crates/fosr-gui/src/run/generation/options.rs new file mode 100644 index 00000000..3c40bea8 --- /dev/null +++ b/crates/fosr-gui/src/run/generation/options.rs @@ -0,0 +1,248 @@ +//! Generation options UI components. +//! +//! This module provides the UI for configuring PCAP generation parameters: +//! duration, start time, timezone, seed, and advanced options. + +use crate::run::state::RunTabState; + +use super::validation::{ + first_invalid_param, render_field_error, validate_duration, validate_optional_u64, + validate_timezone, +}; +use crate::shared::constants::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; +use crate::shared::constants::ui::{ + DURATION_TEXT_WIDTH, GENERATION_COL1_MIN_WIDTH, GENERATION_COL2_MIN_WIDTH, + GENERATION_OPTIONS_COLUMNS, SEED_INPUT_WIDTH, SPACING_LG, +}; +use crate::shared::widgets::helpers::info_icon_with_tooltip; +use crate::shared::widgets::time_picker::button::TimePickerButton; +use crate::shared::widgets::timezone_picker::timezone_picker; +use chrono::{Datelike, Local, TimeZone}; +use chrono_tz::Tz; +use eframe::egui::{self, Widget}; +use egui_extras::DatePickerButton; + +/// Show the generation options panel with two columns. +/// +/// **Column 1:** Duration and time settings +/// - Duration input with preset buttons (5min, 1h, 24h) +/// - Start time toggle (current time vs custom) +/// - Timezone selection +/// +/// **Column 2:** Seed and advanced options +/// - Optional seed for deterministic generation +/// - Taint packets option +/// - Temporal ordering option +pub fn render_generation_options(ui: &mut egui::Ui, state: &mut RunTabState) { + ui.columns(GENERATION_OPTIONS_COLUMNS, |cols| { + render_duration_column(&mut cols[0], state); + render_seed_column(&mut cols[1], state); + }); +} + +/// Column 1: Duration input, start time picker, and timezone selection. +fn render_duration_column(col: &mut egui::Ui, state: &mut RunTabState) { + col.set_min_width(GENERATION_COL1_MIN_WIDTH); + + render_duration_input(col, state); + col.add_space(SPACING_LG); + + render_start_time_options(col, state); + render_utc_preview(col, state); +} + +/// Duration input with preset buttons and validation. +fn render_duration_input(ui: &mut egui::Ui, state: &mut RunTabState) { + ui.horizontal(|ui| { + ui.label("Duration"); + info_icon_with_tooltip(ui, "Minimum pcap traffic duration described in human-friendly time, such as \"30m\", \"1h\", \"2d\" or \"2days 30min 5s\"."); + + // Preset buttons + for preset in ["5min", "1h", "24h"] { + if ui.small_button(preset).clicked() { + state.generation.duration_str = preset.to_string(); + state.generation.duration_validation.set_ok(); + } + } + + let text_response = egui::TextEdit::singleline(&mut state.generation.duration_str) + .desired_width(DURATION_TEXT_WIDTH) + .hint_text("ex: 30m, 1h, 2d") + .ui(ui); + + if text_response.changed() { + match validate_duration(&state.generation.duration_str) { + Ok(_) => state.generation.duration_validation.set_ok(), + Err(msg) => state.generation.duration_validation.set_err(msg), + } + } + + render_field_error(ui, &state.generation.duration_validation); + }); +} + +/// Start time toggle and custom time picker with timezone selection. +fn render_start_time_options(ui: &mut egui::Ui, state: &mut RunTabState) { + ui.horizontal(|ui| { + ui.checkbox(&mut state.generation.use_current_time, "Use current time for start time"); + info_icon_with_tooltip(ui, "Beginning time of the pcap. By default, use the current time. For deterministic generation, you must specify this along with duration, timezone and seed."); + }); + + if state.generation.use_current_time { + state.generation.timezone_validation.set_ok(); + return; + } + + // Custom start time picker + ui.horizontal(|ui| { + ui.label("Start time"); + let current_year = Local::now().date_naive().year(); + ui.add( + DatePickerButton::new(&mut state.generation.start_date) + .start_end_years((current_year - 5)..=(current_year + 30)), + ); + ui.add( + TimePickerButton::new(&mut state.generation.start_time) + .show_seconds(true) + .use_drag_value(true), + ); + }); + + ui.add_space(SPACING_LG); + + render_timezone_picker(ui, state); +} + +/// Timezone picker with local/custom toggle. +fn render_timezone_picker(ui: &mut egui::Ui, state: &mut RunTabState) { + ui.horizontal(|ui| { + if ui + .checkbox(&mut state.generation.use_local_timezone, "Use local timezone") + .clicked() + { + if state.generation.use_local_timezone { + state.generation.timezone_input = String::new(); + state.generation.timezone_validation.set_ok(); + } else { + state.generation.timezone_input = Tz::CET.to_string(); + } + } + info_icon_with_tooltip(ui, "Timezone used for realistic work hours. Use an IANA time zone (like Europe/Paris) or an abbreviation (like CET). The offset is assumed constant during the generation time range."); + + if !state.generation.use_local_timezone { + timezone_picker(ui, &mut state.generation.timezone_input); + + match validate_timezone(&state.generation.timezone_input) { + Ok(()) => state.generation.timezone_validation.set_ok(), + Err(msg) => state.generation.timezone_validation.set_err(msg), + } + } + }); +} + +/// Displays the UTC equivalent of the selected start time. +fn render_utc_preview(ui: &mut egui::Ui, state: &mut RunTabState) { + let utc_text = compute_utc_text(state); + + if let Some(text) = utc_text { + ui.label( + egui::RichText::new(format!("Start time (UTC): {}", text)) + .color(COLOR_TEXT_MUTED), + ); + } +} + +/// Computes the UTC representation of the selected start time. +/// Returns None if using local timezone and the datetime is ambiguous. +fn compute_utc_text(state: &RunTabState) -> Option { + if state.generation.use_current_time { + return Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()); + } + + let local_dt = state.generation.start_date.and_time(state.generation.start_time); + + let utc = if state.generation.use_local_timezone { + Local::now() + .timezone() + .from_local_datetime(&local_dt) + .earliest() + .map(|dt| dt.with_timezone(&chrono::Utc)) + } else { + state.generation.timezone_input + .parse::() + .ok() + .and_then(|tz| local_dt.and_local_timezone(tz).earliest()) + .map(|dt| dt.with_timezone(&chrono::Utc)) + }; + + utc.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .or_else(|| { + if !state.generation.use_local_timezone { + Some("select a valid timezone".to_string()) + } else { + None + } + }) +} + +/// Column 2: Seed input and advanced options (taint, temporal ordering). +fn render_seed_column(col: &mut egui::Ui, state: &mut RunTabState) { + col.set_min_width(GENERATION_COL2_MIN_WIDTH); + + render_seed_input(col, state); + col.add_space(SPACING_LG); + + render_advanced_options(col, state); + col.add_space(SPACING_LG); + + render_validation_errors(col, state); +} + +/// Seed input with validation. +fn render_seed_input(ui: &mut egui::Ui, state: &mut RunTabState) { + ui.horizontal(|ui| { + ui.checkbox(&mut state.generation.use_seed, "Seed"); + info_icon_with_tooltip(ui, "Seed for random number generation. For deterministic generation, you must also specify duration, start time, and timezone."); + + if state.generation.use_seed { + let response = ui.add( + egui::TextEdit::singleline(&mut state.generation.seed_input) + .hint_text("enter a seed value") + .desired_width(SEED_INPUT_WIDTH), + ); + + if response.changed() { + match validate_optional_u64(&state.generation.seed_input) { + Ok(_) => state.generation.seed_validation.set_ok(), + Err(msg) => state.generation.seed_validation.set_err(msg), + } + } + + render_field_error(ui, &state.generation.seed_validation); + } else { + state.generation.seed_validation.set_ok(); + } + }); +} + +/// Advanced options: taint packets and temporal ordering. +fn render_advanced_options(ui: &mut egui::Ui, state: &mut RunTabState) { + ui.horizontal(|ui| { + ui.checkbox(&mut state.generation.taint, "Taint the packets"); + info_icon_with_tooltip(ui, "Taint the packets with special markers for identification."); + }); + ui.horizontal(|ui| { + ui.checkbox(&mut state.generation.order_pcap, "Order temporally"); + info_icon_with_tooltip(ui, "Enable temporal sorting of the generated pcap. Disable to reduce significantly the RAM usage."); + }); +} + +/// Shows the first validation error if any parameter is invalid. +fn render_validation_errors(ui: &mut egui::Ui, state: &RunTabState) { + if let Some((name, spec, err)) = first_invalid_param(&state.generation) { + ui.colored_label( + COLOR_ERROR, + format!("Invalid parameter: {name}. Expected: {spec}. ({err})"), + ); + } +} diff --git a/crates/fosr-gui/src/run/generation/process.rs b/crates/fosr-gui/src/run/generation/process.rs new file mode 100644 index 00000000..ecb28ac0 --- /dev/null +++ b/crates/fosr-gui/src/run/generation/process.rs @@ -0,0 +1,194 @@ +//! PCAP generation process management. +//! +//! This module handles starting generation threads and polling for updates. + +use super::core::generate; +use crate::run::state::RunTabState; +use crate::shared::config::state::ConfigFileState; +use eframe::egui; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::mpsc::channel; + +/// Start the PCAP generation process in a background thread. +/// +/// Creates channels for progress updates, PCAP data, throughput metrics, and errors. +/// The generation runs asynchronously (native thread or WASM future). +pub fn start_generation( + state: &mut RunTabState, + configuration_file_state: &ConfigFileState, + ctx: &egui::Context, +) { + // Reset state + state.generation.progress = 0.0; + state.generation.error = None; + state.generation.cancelled = Arc::new(AtomicBool::new(false)); + + let (progress_sender, progress_receiver) = channel(); + state.generation.progress_receiver = Some(progress_receiver); + + let (pcap_sender, pcap_receiver) = channel(); + state.generation.pcap_receiver = Some(pcap_receiver); + + let (throughput_sender, throughput_receiver) = channel(); + state.generation.throughput_receiver = Some(throughput_receiver); + state.generation.throughput = None; + + let (error_sender, error_receiver) = channel(); + state.generation.error_receiver = Some(error_receiver); + + let params = PcapGenerationParams::from_state(state, configuration_file_state); + let cancelled = state.generation.cancelled.clone(); + let ctx = ctx.clone(); + + spawn_generation_task( + params, + progress_sender, + pcap_sender, + throughput_sender, + error_sender, + cancelled, + ctx, + ); +} + +/// Generation parameters extracted from UI state. +struct PcapGenerationParams { + seed: Option, + order_pcap: bool, + start_time: Option, + duration: String, + taint: bool, + timezone: Option, + config_content: Option, +} + +impl PcapGenerationParams { + /// Extracts parameters from the UI state and config file. + fn from_state(state: &RunTabState, config_state: &ConfigFileState) -> Self { + let seed = if state.generation.use_seed { + state.generation.seed_input.parse::().ok() + } else { + None + }; + let start_time = if state.generation.use_current_time { + None + } else { + Some(format!( + "{}T{}Z", + state.generation.start_date.format("%Y-%m-%d"), + state.generation.start_time.format("%H:%M:%S") + )) + }; + let timezone = if state.generation.timezone_input.is_empty() { + None + } else { + Some(state.generation.timezone_input.clone()) + }; + // Prefer in-memory config content (reflects edits from Configuration tab) + // over re-reading the file from disk + let config_content = config_state.config_file_content.clone(); + + Self { + seed, + order_pcap: state.generation.order_pcap, + start_time, + duration: state.generation.duration_str.clone(), + taint: state.generation.taint, + timezone, + config_content, + } + } +} + +/// Spawns the generation task on the appropriate platform (WASM future or native thread). +fn spawn_generation_task( + params: PcapGenerationParams, + progress_sender: std::sync::mpsc::Sender, + pcap_sender: std::sync::mpsc::Sender>, + throughput_sender: std::sync::mpsc::Sender, + error_sender: std::sync::mpsc::Sender, + cancelled: Arc, + ctx: egui::Context, +) { + let task = move || { + let result = generate( + params.seed, + params.config_content, + params.order_pcap, + params.start_time, + params.duration, + params.taint, + params.timezone, + Some(progress_sender), + Some(pcap_sender), + Some(throughput_sender), + cancelled, + ); + if let Err(e) = result { + log::error!("Generation failed: {}", e); + let _ = error_sender.send(e); + } + ctx.request_repaint(); + }; + + #[cfg(target_arch = "wasm32")] + wasm_bindgen_futures::spawn_local(async move { task() }); + + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(task); +} + +/// Poll generation receivers for progress, PCAP data, throughput, and errors. +/// +/// Should be called every frame to update the UI with generation status. +pub fn poll_generation_receivers(ctx: &egui::Context, state: &mut RunTabState) { + poll_progress(ctx, state); + poll_pcap(state); + poll_throughput(state); + poll_error(state); +} + +/// Polls for progress updates and requests repaints while generating. +fn poll_progress(ctx: &egui::Context, state: &mut RunTabState) { + if let Some(receiver) = &state.generation.progress_receiver { + // Request repaint to keep polling while generating + ctx.request_repaint(); + if let Ok(progress) = receiver.try_recv() { + state.generation.progress = progress; + if progress == 1.0 { + state.generation.progress_receiver = None; + } + } + } +} + +/// Polls for PCAP data when generation completes. +fn poll_pcap(state: &mut RunTabState) { + if let Some(receiver) = &state.generation.pcap_receiver { + if let Ok(pcap_bytes) = receiver.try_recv() { + state.generation.pcap_bytes = Some(pcap_bytes); + } + } +} + +/// Polls for throughput metrics when generation completes. +fn poll_throughput(state: &mut RunTabState) { + if let Some(receiver) = &state.generation.throughput_receiver { + if let Ok(throughput) = receiver.try_recv() { + state.generation.throughput = Some(throughput); + state.generation.throughput_receiver = None; + } + } +} + +/// Polls for error messages from the generation thread. +fn poll_error(state: &mut RunTabState) { + if let Some(receiver) = &state.generation.error_receiver { + if let Ok(error) = receiver.try_recv() { + state.generation.error = Some(error); + state.generation.error_receiver = None; + state.generation.progress_receiver = None; // Stop progress polling + } + } +} diff --git a/crates/fosr-gui/src/run/generation/state.rs b/crates/fosr-gui/src/run/generation/state.rs new file mode 100644 index 00000000..b2aee700 --- /dev/null +++ b/crates/fosr-gui/src/run/generation/state.rs @@ -0,0 +1,109 @@ +//! Generation state: parameters, progress tracking, and validation states. + +use super::validation::FieldValidation; +use chrono::{Local, NaiveDate, NaiveTime}; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::mpsc::Receiver; +use std::time::Duration; + +// Time interval for the slider. +pub const DURATION_MIN: Duration = Duration::from_secs(60); // 1 min +#[cfg(not(target_arch = "wasm32"))] +pub const DURATION_MAX: Duration = Duration::from_secs(3 * 24 * 3600); // 3 days +#[cfg(target_arch = "wasm32")] +pub const DURATION_MAX: Duration = Duration::from_secs(24 * 3600); // 1 day (browser tab memory is limited) + +/// Represents the state of the generation tab. +pub struct GenerationState { + pub progress: f32, + pub progress_receiver: Option>, + pub pcap_bytes: Option>, + pub pcap_receiver: Option>>, + pub throughput_receiver: Option>, + pub throughput: Option, + pub error_receiver: Option>, + pub cancelled: Arc, + /// Error message to display, if any + pub error: Option, + // Validation states + pub duration_validation: FieldValidation, + pub seed_validation: FieldValidation, + pub timezone_validation: FieldValidation, + // Parameters + pub order_pcap: bool, + pub taint: bool, + pub duration_str: String, + pub use_seed: bool, + pub seed_input: String, + pub timezone_input: String, + pub use_current_time: bool, + pub use_local_timezone: bool, + pub start_date: NaiveDate, + pub start_time: NaiveTime, + pub output_file_name: String, + /// Holds temporary PCAP files opened in Wireshark along with their background thread handles. + /// + /// `NamedTempFile` automatically deletes the file when dropped. By storing them here, + /// the files stay alive until the app closes. Multiple files can be open simultaneously + /// for comparison purposes. + /// + /// The `JoinHandle` comes from `open::with_in_background()`. The thread stays alive while + /// Wireshark is running, so `is_finished()` can detect active sessions. + #[cfg(not(target_arch = "wasm32"))] + pub temp_pcap_files: Vec<( + std::thread::JoinHandle>, + tempfile::NamedTempFile, + )>, + /// Whether Wireshark is available on the system + #[cfg(not(target_arch = "wasm32"))] + pub wireshark_available: bool, +} + +impl GenerationState { + /// Returns true if generation is currently in progress + pub fn is_generating(&self) -> bool { + self.progress_receiver.is_some() + } + + /// Returns true if generation is complete (PCAP ready) + pub fn is_complete(&self) -> bool { + self.progress == 1.0 + } +} + +impl Default for GenerationState { + fn default() -> Self { + Self { + progress: 0.0, + progress_receiver: None, + pcap_bytes: None, + pcap_receiver: None, + throughput_receiver: None, + throughput: None, + error_receiver: None, + cancelled: Arc::new(AtomicBool::new(false)), + error: None, + // Validation states + duration_validation: FieldValidation::default(), + seed_validation: FieldValidation::default(), + timezone_validation: FieldValidation::default(), + // Parameters + order_pcap: true, + taint: false, + duration_str: "1h".to_string(), + use_seed: false, + seed_input: String::new(), + timezone_input: String::new(), + use_current_time: true, + use_local_timezone: true, + start_date: Local::now().date_naive(), + start_time: Local::now().time(), + output_file_name: "output.pcap".to_string(), + #[cfg(not(target_arch = "wasm32"))] + temp_pcap_files: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + wireshark_available: which::which("wireshark").is_ok(), + } + } +} diff --git a/crates/fosr-gui/src/run/generation/validation.rs b/crates/fosr-gui/src/run/generation/validation.rs new file mode 100644 index 00000000..a07b6af5 --- /dev/null +++ b/crates/fosr-gui/src/run/generation/validation.rs @@ -0,0 +1,85 @@ +//! Input validation helpers for duration, seed, and timezone fields. + +use super::state::{DURATION_MAX, DURATION_MIN, GenerationState}; +use crate::shared::constants::colors::COLOR_ERROR; +use crate::shared::constants::ui::SPACING_MD; +use chrono_tz::Tz; +use eframe::egui; +use std::time::Duration; + +/// Holds validation state for a single input field. +#[derive(Default, Clone)] +pub struct FieldValidation { + pub error: Option, +} + +impl FieldValidation { + /// Clears any existing error. + pub fn set_ok(&mut self) { + self.error = None; + } + + /// Sets an error message. + pub fn set_err(&mut self, msg: impl Into) { + self.error = Some(msg.into()); + } +} + +/// Display the error message in red below the field. +pub fn render_field_error(ui: &mut egui::Ui, validation: &FieldValidation) { + if let Some(msg) = &validation.error { + ui.add_space(SPACING_MD); + ui.colored_label(COLOR_ERROR, msg); + } +} + +// Expected format for each parameter (shown in error messages) +const SPEC_DURATION: &str = "a duration between 1 min and 3 days (e.g. 30m, 1h, 2d)"; +const SPEC_SEED: &str = "an unsigned integer (u64) or empty for random"; +const SPEC_TIMEZONE: &str = "a valid timezone"; + +/// Returns the first invalid parameter (name, expected spec, error message). +pub fn first_invalid_param(state: &GenerationState) -> Option<(&'static str, &'static str, &str)> { + [ + ("Duration", SPEC_DURATION, &state.duration_validation), + ("Seed", SPEC_SEED, &state.seed_validation), + ("Timezone", SPEC_TIMEZONE, &state.timezone_validation), + ] + .into_iter() + .find_map(|(name, spec, validation)| { + validation.error.as_ref().map(|err| (name, spec, err.as_str())) + }) +} + +/// Validates a human-readable duration string and checks bounds. +pub fn validate_duration(duration_str: &str) -> Result { + let d = humantime::parse_duration(duration_str).map_err(|_| "Invalid value".to_string())?; + + if d < DURATION_MIN || d > DURATION_MAX { + return Err(format!( + "Out of range ({} – {})", + humantime::format_duration(DURATION_MIN), + humantime::format_duration(DURATION_MAX), + )); + } + Ok(d) +} + +/// Validates an optional u64 input (empty is valid, means "use random"). +pub fn validate_optional_u64(input: &str) -> Result, String> { + let s = input.trim(); + if s.is_empty() { + return Ok(None); + } + s.parse::() + .map(Some) + .map_err(|_| "Invalid value".to_string()) +} + +/// Validates an IANA timezone string. +pub fn validate_timezone(input: &str) -> Result<(), String> { + input + .parse::() + .map(|_| ()) + .map_err(|_| "Invalid value".to_string()) +} diff --git a/crates/fosr-gui/src/run/generation/wireshark.rs b/crates/fosr-gui/src/run/generation/wireshark.rs new file mode 100644 index 00000000..070700ef --- /dev/null +++ b/crates/fosr-gui/src/run/generation/wireshark.rs @@ -0,0 +1,61 @@ +//! Wireshark integration: opens PCAP files in a temporary file for analysis. + +/// Opens the PCAP data in Wireshark. +/// +/// This creates a temporary file with `.pcap` extension and opens it in Wireshark. +/// The `NamedTempFile` handle and `JoinHandle` are stored to keep the file alive. +/// When the handle is dropped (app closed), the temp file is deleted. +/// +/// Platform-specific behavior: +/// - **Linux**: Uses `open::with_in_background()` which spawns Wireshark directly. +/// The thread stays alive while Wireshark is running. +/// - **macOS**: Uses `open -n -W -a Wireshark` which waits for the app to close. +#[cfg(not(target_arch = "wasm32"))] +pub fn open_in_wireshark( + pcap_bytes: &[u8], + temp_files: &mut Vec<( + std::thread::JoinHandle>, + tempfile::NamedTempFile, + )>, +) -> Result<(), String> { + use std::io::Write; + + // Create a temporary file with .pcap extension + let mut temp_file = tempfile::Builder::new() + .suffix(".pcap") + .tempfile() + .map_err(|e| format!("Failed to create temp file: {e}"))?; + + // Write the PCAP data + temp_file + .write_all(pcap_bytes) + .map_err(|e| format!("Failed to write PCAP data: {e}"))?; + + // Get the path + let path = temp_file.path().to_path_buf(); + + log::info!("Opening PCAP file in Wireshark: {}", path.display()); + + // Platform-specific launch + #[cfg(target_os = "macos")] + let handle = { + // On macOS, use `open -n -W -a Wireshark`: + // - `-n` opens a new instance even if one is already running + // - `-W` waits for the app to close + std::thread::spawn(move || { + std::process::Command::new("open") + .args(["-n", "-W", "-a", "Wireshark"]) + .arg(&path) + .status() + .map(|_| ()) + }) + }; + + #[cfg(not(target_os = "macos"))] + let handle = open::with_in_background(&path, "wireshark"); + + // Store the handle and temp file to keep them alive until app closes + temp_files.push((handle, temp_file)); + + Ok(()) +} diff --git a/crates/fosr-gui/src/run/graph/config_handling.rs b/crates/fosr-gui/src/run/graph/config_handling.rs new file mode 100644 index 00000000..657d2277 --- /dev/null +++ b/crates/fosr-gui/src/run/graph/config_handling.rs @@ -0,0 +1,96 @@ +//! Configuration change detection and handling for visualization. +//! +//! This module monitors the configuration file state and triggers +//! visualization updates when the configuration changes. + +use super::state::VisualizationState; +use crate::shared::config::state::ConfigFileState; +use crate::shared::constants::ui::DELAY_FRAMES_NORMAL; + +/// Handle configuration file changes and update visualization state. +/// +/// This function detects when the configuration has been removed, modified, +/// or replaced, and updates the visualization accordingly: +/// +/// - If config is removed: stops visualization and resets to default state +/// - If config changes: parses new config and updates graph nodes/edges +/// - If parsing fails: logs error and resets to default state +pub fn handle_config_changes( + state: &mut VisualizationState, + configuration_file_state: &ConfigFileState, +) { + // Check if config was removed or is empty + let config_is_empty = configuration_file_state + .config_file_content + .as_ref() + .map(|c| c.trim().is_empty()) + .unwrap_or(true); + + let was_config_removed = + state.config_content.is_some() && configuration_file_state.config_file_content.is_none(); + + // Only reset if we previously had a config (avoid resetting every frame when starting empty) + let should_reset = was_config_removed || (config_is_empty && state.config_content.is_some()); + + if should_reset { + // Stop visualization if running, then reset to default + if state.flow.running { + state.stop_visualization(); + } + state.config_content = None; + *state = VisualizationState::default(); + state.view.reset_requested = true; + log::warn!("Config removed or empty, visualization reset to default"); + return; + } + + // If config is empty and we have no config loaded, nothing to do + if config_is_empty && state.config_content.is_none() { + return; + } + + // Check if config content has changed + let needs_update = match ( + &state.config_content, + &configuration_file_state.config_file_content, + ) { + (Some(current), Some(new)) => current != new, + (None, Some(_)) => true, + _ => false, + }; + + if needs_update { + if let Some(config_content) = &configuration_file_state.config_file_content { + // Stop visualization if running before updating config + let was_running = state.flow.running; + if was_running { + state.stop_visualization(); + } + + // Try to parse the config, handle errors gracefully + // Use catch_unwind because import_config uses .expect() internally + let config_result = + std::panic::catch_unwind(|| fosr_lib::config::import_config(config_content)); + + match config_result { + Ok(config) => { + state.update_from_config(&config); + state.config_content = Some(config_content.clone()); + // Only auto-restart if visualization was running before config change + if was_running { + state.auto_start_countdown = Some(DELAY_FRAMES_NORMAL); + } + state.view.reset_requested = true; + } + Err(e) => { + // Log the error once and reset to default state instead of crashing + // Store the config content so we don't retry parsing every frame + log::error!("Failed to parse configuration: {:?}", e); + *state = VisualizationState::default(); + state.config_content = Some(config_content.clone()); + state.view.reset_requested = true; + } + } + } + } +} diff --git a/crates/fosr-gui/src/run/graph/flow_processing.rs b/crates/fosr-gui/src/run/graph/flow_processing.rs new file mode 100644 index 00000000..6280eba7 --- /dev/null +++ b/crates/fosr-gui/src/run/graph/flow_processing.rs @@ -0,0 +1,230 @@ +//! Flow event processing for network visualization. +//! +//! This module handles the processing of flow events from the streamer, +//! updating active links, and synchronizing graph edge states. + +use super::state::{ActiveLink, EdgeState, INTERNET_NODE_IP, LinkDirection, VisualizationState}; +use super::stream::FlowEvent; +use crate::shared::constants::ui::ACTIVE_LINK_BASE_TIMEOUT_MS; + +/// Process incoming flow events from the streamer. +/// +/// Reads all pending events from the flow receiver and updates: +/// - Active links (for visual edge highlighting) +/// - Node flow counters (for proportional node sizing) +/// - Edge flow counters (for edge thickness) +/// +/// Flows between two unknown IPs (Internet<->Internet) are filtered out. +pub fn process_flow_events(state: &mut VisualizationState) { + let events: Vec = if let Some(ref receiver) = state.flow.receiver { + receiver.try_iter().collect() + } else { + return; + }; + + let now = web_time::Instant::now(); + + for event in events { + // Determine if this flow should be displayed: + // - Both IPs known: display + // - One IP known, one unknown: display as host<->Internet + // - Both IPs unknown: skip (Internet<->Internet) + let src_known = state.is_known_ip(event.src_ip); + let dst_known = state.is_known_ip(event.dst_ip); + + log::debug!( + "Flow: {} -> {} | src_known={}, dst_known={}", + event.src_ip, + event.dst_ip, + src_known, + dst_known + ); + + if !src_known && !dst_known { + // Both are Internet IPs - skip this flow + log::debug!(" -> Skipping (Internet<->Internet)"); + continue; + } + + // Increment total flows counter + state.flow.total_flows += 1; + + // Map unknown IPs to the Internet node for display + let display_src = if src_known { event.src_ip } else { INTERNET_NODE_IP }; + let display_dst = if dst_known { event.dst_ip } else { INTERNET_NODE_IP }; + + log::debug!( + " -> Displayed as: {} -> {} ({:?})", + display_src, + display_dst, + event.protocol + ); + + let key = (display_src, display_dst); + let reverse_key = (display_dst, display_src); + + let direction = if state.flow.active_links.contains_key(&reverse_key) { + LinkDirection::Bidirectional + } else { + LinkDirection::Forward + }; + + state.flow.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.network.ip_to_node.get(&display_src), + state.network.ip_to_node.get(&display_dst), + ) { + // Find the edge (undirected graph, so check both directions) + let edge_idx = state + .network + .graph + .g() + .find_edge(src_idx, dst_idx) + .or_else(|| state.network.graph.g().find_edge(dst_idx, src_idx)); + + if let Some(edge_idx) = edge_idx { + // Increment node flow counters + if let Some(node) = state.network.graph.g_mut().node_weight_mut(src_idx) { + node.payload_mut().flow_count += 1; + } + if let Some(node) = state.network.graph.g_mut().node_weight_mut(dst_idx) { + node.payload_mut().flow_count += 1; + } + // Increment edge flow counter (for thickness) + if let Some(edge) = state.network.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 + .network + .graph + .g() + .node_indices() + .filter_map(|idx| state.network.graph.g().node_weight(idx)) + .map(|n| n.payload().flow_count) + .max() + .unwrap_or(0); + + for idx in state.network.graph.g().node_indices().collect::>() { + if let Some(node) = state.network.graph.g_mut().node_weight_mut(idx) { + node.payload_mut().max_flow_count = max_node_flow; + } + } + + // Update max_flow_count for all edges (for proportional sizing) + let max_edge_flow = state + .network + .graph + .g() + .edge_indices() + .filter_map(|idx| state.network.graph.g().edge_weight(idx)) + .map(|e| e.payload().flow_count) + .max() + .unwrap_or(0); + + for idx in state.network.graph.g().edge_indices().collect::>() { + if let Some(edge) = state.network.graph.g_mut().edge_weight_mut(idx) { + edge.payload_mut().max_flow_count = max_edge_flow; + } + } +} + +/// Update active links by removing expired ones. +/// +/// Links expire after a timeout period adjusted by the current speed setting. +pub fn update_active_links(state: &mut VisualizationState) { + let now = web_time::Instant::now(); + // Base display time is 0.5s, adjusted by speed (faster = shorter display) + let base_timeout_ms = ACTIVE_LINK_BASE_TIMEOUT_MS; + let speed = *state.flow.speed.read().unwrap(); + let timeout = std::time::Duration::from_millis((base_timeout_ms / speed) as u64); + + state + .flow + .active_links + .retain(|_, link| now.duration_since(link.start_time) < timeout); +} + +/// Update graph edge states based on active links. +/// +/// For each edge, checks if any IP combination has an active link +/// and updates the edge's visual state accordingly. +pub fn update_graph_edges(state: &mut VisualizationState) { + let graph = &mut state.network.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 { + let new_state = find_active_link_state(&state.flow.active_links, &src_ips, &dst_ips); + + // Update edge state (flow_count is preserved) + if let Some(edge_mut) = graph.g_mut().edge_weight_mut(edge) { + edge_mut.payload_mut().state = new_state; + } + } +} + +/// Find the active link state for an edge by checking all IP combinations. +/// +/// Searches for an active link between any source IP and any destination IP, +/// handling both forward and reverse directions. +fn find_active_link_state( + active_links: &std::collections::HashMap<(std::net::Ipv4Addr, std::net::Ipv4Addr), ActiveLink>, + src_ips: &[std::net::Ipv4Addr], + dst_ips: &[std::net::Ipv4Addr], +) -> EdgeState { + for src_ip in src_ips { + for dst_ip in dst_ips { + let forward_key = (*src_ip, *dst_ip); + let reverse_key = (*dst_ip, *src_ip); + + if let Some(link) = active_links.get(&forward_key) { + return EdgeState::Active { + protocol: link.protocol, + start_time: link.start_time, + direction: link.direction.clone(), + }; + } else if let Some(link) = active_links.get(&reverse_key) { + // Reverse key: flip the direction + return EdgeState::Active { + protocol: link.protocol, + start_time: link.start_time, + direction: match link.direction { + LinkDirection::Forward => LinkDirection::Backward, + LinkDirection::Backward => LinkDirection::Forward, + LinkDirection::Bidirectional => LinkDirection::Bidirectional, + }, + }; + } + } + } + EdgeState::Inactive +} diff --git a/crates/fosr-gui/src/run/graph/graph_layout.rs b/crates/fosr-gui/src/run/graph/graph_layout.rs new file mode 100644 index 00000000..0a79cc05 --- /dev/null +++ b/crates/fosr-gui/src/run/graph/graph_layout.rs @@ -0,0 +1,28 @@ +//! Graph layout: circular node distribution for initial display. + +use crate::shared::constants::ui::{GRAPH_LAYOUT_RADIUS_BASE, GRAPH_LAYOUT_RADIUS_MULTIPLIER}; +use eframe::egui; + +/// Distributes the graph nodes in a circle layout. +/// Required for proper display on startup. +pub fn arrange_nodes_in_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 node_count = graph.node_count().max(1) as f32; + let radius = node_count.sqrt() * GRAPH_LAYOUT_RADIUS_MULTIPLIER + GRAPH_LAYOUT_RADIUS_BASE; + + let indices: Vec<_> = graph.g().node_indices().collect(); + for (i, idx) in indices.into_iter().enumerate() { + if let Some(node) = graph.g_mut().node_weight_mut(idx) { + let angle = (i as f32 / node_count) * std::f32::consts::TAU; + node.set_location(egui::pos2(radius * angle.cos(), radius * angle.sin())); + } + } +} diff --git a/crates/fosr-gui/src/run/graph/mod.rs b/crates/fosr-gui/src/run/graph/mod.rs new file mode 100644 index 00000000..05ae2ca6 --- /dev/null +++ b/crates/fosr-gui/src/run/graph/mod.rs @@ -0,0 +1,12 @@ +//! Network visualization module: real-time graph display with custom nodes and edges. + +pub mod config_handling; +pub mod flow_processing; +pub mod node_modal; +pub mod overlays; +pub mod screenshot; +pub mod shapes; +pub mod state; +pub mod stream; +pub mod graph_layout; +pub mod view; diff --git a/crates/fosr-gui/src/run/graph/node_modal.rs b/crates/fosr-gui/src/run/graph/node_modal.rs new file mode 100644 index 00000000..3bcb6d04 --- /dev/null +++ b/crates/fosr-gui/src/run/graph/node_modal.rs @@ -0,0 +1,265 @@ +//! Node click handling and info/edit modal for the visualization graph. + +use super::state::{NetworkNode, NodeType, VisualizationState}; +use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; +use crate::shared::config::model::Host; +use crate::shared::config::state::ConfigFileState; +use crate::shared::constants::colors::{COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT}; +use crate::shared::constants::ui::{ + INDENT_STANDARD, LEGEND_ICON_SIZE, NODE_MODAL_WIDTH, SPACING_LG, SPACING_SM, +}; +use eframe::egui; +use egui_graphs::events::{Event, PayloadNodeClick}; + +/// Process graph click events from the event buffer. +pub fn process_graph_events( + state: &mut VisualizationState, + configuration_file_state: &ConfigFileState, +) { + let events: Vec = state.modal.events_buffer.borrow_mut().drain(..).collect(); + + for event in events { + if let Event::NodeClick(PayloadNodeClick { id }) = event { + let node_idx = petgraph::graph::NodeIndex::new(id); + state.modal.clicked_node = Some(node_idx); + state.modal.open = true; + + // Clone the host into the edit buffer + let host_idx = state.network.node_to_host.get(&node_idx).copied(); + state.modal.edit_buffer = host_idx.and_then(|idx| { + configuration_file_state + .config_model + .as_ref() + .and_then(|c| c.hosts.get(idx).cloned()) + }); + } + } +} + +/// Render the node information modal for the clicked node. +pub fn render_node_info_modal( + ctx: &egui::Context, + state: &mut VisualizationState, + config_file_state: &mut ConfigFileState, +) { + if !state.modal.open { + return; + } + + let Some(node_idx) = state.modal.clicked_node else { + return; + }; + + let Some(node) = state.network.graph.g().node_weight(node_idx) else { + state.modal.open = false; + state.modal.clicked_node = None; + return; + }; + + let node_data = node.payload().clone(); + let host_idx = state.network.node_to_host.get(&node_idx).copied(); + let has_edit_buffer = state.modal.edit_buffer.is_some(); + + let mut save_clicked = false; + let modal = egui::Modal::new(egui::Id::new("node_info_modal")).show(ctx, |ui| { + ui.set_width(NODE_MODAL_WIDTH); + + render_modal_header(ui, &node_data, has_edit_buffer); + ui.separator(); + + if let Some(ref mut host) = state.modal.edit_buffer { + render_editable_fields(ui, host); + } else { + render_readonly_fields(ui, &node_data); + } + + ui.add_space(SPACING_LG); + render_modal_footer(ui, has_edit_buffer, &mut save_clicked); + }); + + // Apply changes to config model on Save + if save_clicked { + apply_changes_to_config(state, config_file_state, host_idx); + } + + // Close on Escape or click outside (discard changes) + if modal.should_close() { + state.modal.open = false; + state.modal.clicked_node = None; + state.modal.edit_buffer = None; + } +} + +/// Render modal header with title and node type icon. +fn render_modal_header(ui: &mut egui::Ui, node_data: &NetworkNode, has_edit_buffer: bool) { + let title = if has_edit_buffer { + "Edit Node Information" + } else { + "Node Information" + }; + ui.heading(title); + + // Node type with icon + ui.horizontal(|ui| { + let (image, type_str) = match node_data.node_type { + NodeType::Server => (IMG_SERVER, "Server"), + NodeType::User => (IMG_COMPUTER, "User"), + NodeType::Internet => (IMG_INTERNET, "Internet"), + }; + let tint = if ui.style().visuals.dark_mode { + COLOR_ICON_TINT_DARK + } else { + COLOR_ICON_TINT_LIGHT + }; + ui.add( + egui::Image::new(image) + .fit_to_exact_size(egui::vec2(LEGEND_ICON_SIZE, LEGEND_ICON_SIZE)) + .tint(tint), + ); + ui.label(egui::RichText::new(type_str).strong()); + }); + + ui.add_space(SPACING_SM); +} + +/// Render editable fields for the host (hostname, OS, IPs). +fn render_editable_fields(ui: &mut egui::Ui, host: &mut Host) { + render_hostname_field(ui, host); + render_os_dropdown(ui, host); + render_ip_fields(ui, host); +} + +/// Render editable hostname field. +fn render_hostname_field(ui: &mut egui::Ui, host: &mut Host) { + ui.horizontal(|ui| { + ui.label("Hostname:"); + let mut buf = host.hostname.clone().unwrap_or_default(); + if ui + .add(egui::TextEdit::singleline(&mut buf).hint_text("hostname")) + .changed() + { + host.hostname = if buf.trim().is_empty() { + None + } else { + Some(buf) + }; + } + }); +} + +/// Render editable OS dropdown. +fn render_os_dropdown(ui: &mut egui::Ui, host: &mut Host) { + ui.horizontal(|ui| { + ui.label("OS:"); + let selected = host.os.as_deref().unwrap_or(""); + egui::ComboBox::from_id_salt("modal_os") + .selected_text(selected) + .show_ui(ui, |ui| { + if ui.selectable_label(host.os.is_none(), "").clicked() { + host.os = None; + } + if ui + .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()); + } + }); + }); +} + +/// Render editable IP address fields. +fn render_ip_fields(ui: &mut egui::Ui, host: &mut Host) { + ui.label("IP Addresses:"); + for interface in &mut host.interfaces { + ui.horizontal(|ui| { + ui.add_space(INDENT_STANDARD); + ui.add(egui::TextEdit::singleline(&mut interface.ip_addr).hint_text("0.0.0.0")); + }); + } +} + +/// Render read-only fields (no config loaded or Internet node). +fn render_readonly_fields(ui: &mut egui::Ui, node_data: &NetworkNode) { + if let Some(ref hostname) = node_data.hostname { + ui.horizontal(|ui| { + ui.label("Hostname:"); + ui.label(egui::RichText::new(hostname).monospace()); + }); + } + + // Don't show OS or IP for Internet node + if node_data.node_type != NodeType::Internet { + ui.horizontal(|ui| { + ui.label("OS:"); + ui.label(egui::RichText::new(format!("{:?}", node_data.os)).monospace()); + }); + ui.label("IP Addresses:"); + for ip in &node_data.ip_addrs { + ui.horizontal(|ui| { + ui.add_space(INDENT_STANDARD); + ui.label(egui::RichText::new(ip.to_string()).monospace()); + }); + } + } +} + +/// Render modal footer with action buttons. +fn render_modal_footer(ui: &mut egui::Ui, has_edit_buffer: bool, save_clicked: &mut bool) { + if has_edit_buffer { + ui.horizontal(|ui| { + if ui + .button(egui_material_icons::icons::ICON_CLOSE) + .on_hover_text("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 from edit buffer back to the config model. +fn apply_changes_to_config( + state: &mut VisualizationState, + config_file_state: &mut ConfigFileState, + host_idx: Option, +) { + if let (Some(idx), Some(buffer)) = (host_idx, state.modal.edit_buffer.take()) { + if let Some(host) = config_file_state + .config_model + .as_mut() + .and_then(|c| c.hosts.get_mut(idx)) + { + *host = buffer; + } + // Sync config model back to YAML so other tabs and handle_config_changes pick it up + if let Some(model) = &config_file_state.config_model { + if let Ok(yaml) = serde_yaml::to_string(model) { + config_file_state.config_file_content = Some(yaml); + } + } + } +} diff --git a/crates/fosr-gui/src/run/graph/overlays.rs b/crates/fosr-gui/src/run/graph/overlays.rs new file mode 100644 index 00000000..608ea7a5 --- /dev/null +++ b/crates/fosr-gui/src/run/graph/overlays.rs @@ -0,0 +1,291 @@ +//! Graph overlay UI: control buttons, stats display, and legends for nodes/edges. + +use super::state::{ScreenshotStateMachine, VisualizationState}; +use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; +use crate::shared::constants::colors::{ + COLOR_EDGE_INACTIVE, COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT, COLOR_PROTOCOL_DNS, + COLOR_PROTOCOL_HTTP, COLOR_PROTOCOL_HTTPS, COLOR_PROTOCOL_OTHER, COLOR_PROTOCOL_SMTP, + COLOR_PROTOCOL_SSH, COLOR_STOP, +}; +use crate::shared::constants::ui::{ + LEGEND_ICON_SIZE, LEGEND_MARKER_RADIUS, OVERLAY_MARGIN, PLAYBACK_SPEED_EPSILON, + PLAYBACK_SPEED_STEPS, SPACING_NEGATIVE_XS, +}; +use eframe::egui; + +/// Render a legend item with a colored circle (for edge protocols). +fn legend_item_inline(ui: &mut egui::Ui, label: &str, color: egui::Color32) { + ui.horizontal(|ui| { + let rect = ui + .allocate_space(egui::vec2(LEGEND_ICON_SIZE, LEGEND_ICON_SIZE)) + .1; + let painter = ui.painter(); + painter.circle_filled(rect.center(), LEGEND_MARKER_RADIUS, color); + ui.add_space(SPACING_NEGATIVE_XS); + ui.label(label); + }); +} + +/// Render a legend item with an icon image (for node types). +fn legend_item_with_image(ui: &mut egui::Ui, label: &str, image: egui::ImageSource) { + ui.horizontal(|ui| { + let tint = if ui.style().visuals.dark_mode { + COLOR_ICON_TINT_DARK + } else { + COLOR_ICON_TINT_LIGHT + }; + ui.add( + egui::Image::new(image) + .fit_to_exact_size(egui::vec2(LEGEND_ICON_SIZE, LEGEND_ICON_SIZE)) + .tint(tint), + ); + ui.add_space(SPACING_NEGATIVE_XS); + ui.label(label); + }); +} + +/// Render overlay buttons in the top-left corner of the graph. +pub fn render_overlay_buttons(ui: &mut egui::Ui, state: &mut VisualizationState) { + let local_rect = ui.max_rect(); + + egui::Area::new(egui::Id::new("viz_overlay_buttons")) + .fixed_pos(local_rect.left_top() + egui::vec2(OVERLAY_MARGIN, OVERLAY_MARGIN)) + .order(egui::Order::Foreground) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()) + .shadow(egui::epaint::Shadow::NONE) + .show(ui, |ui| { + ui.horizontal(|ui| { + render_playback_controls(ui, state); + render_view_controls(ui, state); + ui.separator(); + render_speed_controls(ui, state); + }); + }); + }); +} + +/// Render play/stop/restart buttons based on current state. +fn render_playback_controls(ui: &mut egui::Ui, state: &mut VisualizationState) { + if !state.flow.running { + render_play_button(ui, state); + if state.user_has_started { + render_restart_button(ui, state); + } + } else { + render_stop_button(ui, state); + } +} + +/// Render the Play/Continue button. +/// +/// Resumes visualization without resetting flow counts. Uses "Start" label +/// initially, then "Continue" after the user has started at least once. +fn render_play_button(ui: &mut egui::Ui, state: &mut VisualizationState) { + let play_text = if state.user_has_started { + "Continue" + } else { + "Start" + }; + let accent = ui.visuals().selection.bg_fill; + let play_button = egui::Button::new(egui::RichText::new(format!( + "{} {}", + egui_material_icons::icons::ICON_PLAY_ARROW, + play_text + ))) + .fill(accent); + + if ui.add(play_button).clicked() { + state.user_has_started = true; + let config = state.config_content.clone(); + let speed = state.flow.speed.clone(); + if let Err(e) = state.start_visualization(config.as_deref(), speed, false) { + log::error!("Failed to start flow streamer: {}", e); + } + } +} + +/// Render the Restart button. +/// +/// Resets all flow counts and starts fresh. Only visible after the user +/// has started at least once (otherwise the Play button shows "Start"). +fn render_restart_button(ui: &mut egui::Ui, state: &mut VisualizationState) { + if ui + .button(egui_material_icons::icons::ICON_RESTART_ALT) + .on_hover_text("Restart") + .clicked() + { + let config = state.config_content.clone(); + let speed = state.flow.speed.clone(); + if let Err(e) = state.start_visualization(config.as_deref(), speed, true) { + log::error!("Failed to start flow streamer: {}", e); + } + } +} + +/// Render the Stop button. +fn render_stop_button(ui: &mut egui::Ui, state: &mut VisualizationState) { + let stop_button = egui::Button::new(egui::RichText::new(format!( + "{} Stop", + egui_material_icons::icons::ICON_STOP + ))) + .fill(COLOR_STOP); + + if ui.add(stop_button).clicked() { + state.stop_visualization(); + } +} + +/// Render fit-to-screen and export buttons. +fn render_view_controls(ui: &mut egui::Ui, state: &mut VisualizationState) { + if ui + .button(egui_material_icons::icons::ICON_FIT_SCREEN) + .on_hover_text("Fit to screen") + .clicked() + { + state.view.reset_requested = true; + } + + if ui + .button(egui_material_icons::icons::ICON_IMAGE) + .on_hover_text("Export as PNG") + .clicked() + { + state.screenshot_export = ScreenshotStateMachine::HidingOverlays; + } +} + +/// Render playback speed controls with −/+ buttons. +/// +/// Speed is stored in an `Arc` for runtime updates, +/// so we read/write it manually rather than binding directly. +fn render_speed_controls(ui: &mut egui::Ui, state: &mut VisualizationState) { + // Use into_inner() on poison to recover the value anyway. + // Lock poisoning only happens if another thread panicked while holding the lock, + // and for a simple f32 speed value, there's no risk of data corruption. + let mut speed_value = *state + .flow + .speed + .read() + .unwrap_or_else(|e| e.into_inner()); + let current_idx = find_speed_step_index(speed_value); + + if ui + .button(egui_material_icons::icons::ICON_REMOVE) + .on_hover_text("Slow down") + .clicked() + { + if let Some(idx) = current_idx { + if idx > 0 { + speed_value = PLAYBACK_SPEED_STEPS[idx - 1]; + *state + .flow + .speed + .write() + .unwrap_or_else(|e| e.into_inner()) = speed_value; + } + } + } + + ui.label(format!("{:.1}x", speed_value)) + .on_hover_text("Playback speed — controls how fast network flows are simulated"); + + if ui + .button(egui_material_icons::icons::ICON_ADD) + .on_hover_text("Speed up") + .clicked() + { + if let Some(idx) = current_idx { + if idx < PLAYBACK_SPEED_STEPS.len() - 1 { + speed_value = PLAYBACK_SPEED_STEPS[idx + 1]; + *state + .flow + .speed + .write() + .unwrap_or_else(|e| e.into_inner()) = speed_value; + } + } + } +} + +/// Find the index of the current speed in the predefined steps. +fn find_speed_step_index(current_speed: f32) -> Option { + PLAYBACK_SPEED_STEPS + .iter() + .position(|&s| (s - current_speed).abs() < PLAYBACK_SPEED_EPSILON) +} + +/// Render overlay stats in the bottom-left corner of the graph. +pub fn render_overlay_stats(ui: &mut egui::Ui, state: &VisualizationState) { + let local_rect = ui.max_rect(); + + egui::Area::new(egui::Id::new("viz_overlay_stats")) + .fixed_pos(local_rect.left_bottom() + egui::vec2(OVERLAY_MARGIN, 0.0)) + .pivot(egui::Align2::LEFT_BOTTOM) + .order(egui::Order::Foreground) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()) + .shadow(egui::epaint::Shadow::NONE) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(format!("Active: {}", state.flow.active_links.len())) + .on_hover_text( + "Number of network links currently transmitting data.", + ); + ui.separator(); + ui.label(format!("Total flows: {}", state.flow.total_flows)) + .on_hover_text( + "Cumulative number of flows generated since the simulation started.", + ); + }); + }); + }); +} + +/// Render node legend in the top-right corner of the graph. +pub fn render_overlay_node_legend(ui: &mut egui::Ui) { + let local_rect = ui.max_rect(); + + egui::Area::new(egui::Id::new("viz_overlay_node_legend")) + .pivot(egui::Align2::RIGHT_TOP) + .fixed_pos(local_rect.right_top() + egui::vec2(-OVERLAY_MARGIN, OVERLAY_MARGIN)) + .order(egui::Order::Foreground) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()) + .shadow(egui::epaint::Shadow::NONE) + .show(ui, |ui| { + legend_item_with_image(ui, "Server", IMG_SERVER); + legend_item_with_image(ui, "User", IMG_COMPUTER); + legend_item_with_image(ui, "Internet", IMG_INTERNET); + }) + .response + .on_hover_text("Node types. Size reflects relative traffic activity."); + }); +} + +/// Render edge legend in the bottom-right corner of the graph. +pub fn render_overlay_edge_legend(ui: &mut egui::Ui) { + let local_rect = ui.max_rect(); + + egui::Area::new(egui::Id::new("viz_overlay_edge_legend")) + .pivot(egui::Align2::RIGHT_BOTTOM) + .fixed_pos(local_rect.right_bottom() + egui::vec2(-OVERLAY_MARGIN, -OVERLAY_MARGIN)) + .order(egui::Order::Foreground) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()) + .shadow(egui::epaint::Shadow::NONE) + .show(ui, |ui| { + legend_item_inline(ui, "Inactive", COLOR_EDGE_INACTIVE); + legend_item_inline(ui, "HTTP", COLOR_PROTOCOL_HTTP); + legend_item_inline(ui, "HTTPS", COLOR_PROTOCOL_HTTPS); + legend_item_inline(ui, "SSH", COLOR_PROTOCOL_SSH); + legend_item_inline(ui, "DNS", COLOR_PROTOCOL_DNS); + legend_item_inline(ui, "SMTP", COLOR_PROTOCOL_SMTP); + legend_item_inline(ui, "Other", COLOR_PROTOCOL_OTHER); + }) + .response + .on_hover_text( + "Link protocols. Color shows protocol, thickness reflects relative traffic volume.", + ); + }); +} diff --git a/crates/fosr-gui/src/run/graph/screenshot.rs b/crates/fosr-gui/src/run/graph/screenshot.rs new file mode 100644 index 00000000..d80e38eb --- /dev/null +++ b/crates/fosr-gui/src/run/graph/screenshot.rs @@ -0,0 +1,84 @@ +//! Graph screenshot export with a 2-frame state machine for clean PNG output. + +use super::state::ScreenshotStateMachine; +use super::state::VisualizationState; +use eframe::egui; + +/// Handle screenshot export state machine. +/// Uses a 2-frame approach: +/// - Frame N: user clicks export → HidingOverlays +/// - Frame N+1: overlays hidden → request screenshot → WaitingForScreenshot +/// - Frame N+2: screenshot received → extract graph region → save → Idle +pub fn handle_screenshot_export(ui: &mut egui::Ui, state: &mut VisualizationState) { + // Transition: HidingOverlays → WaitingForScreenshot (request screenshot) + if state.screenshot_export == ScreenshotStateMachine::HidingOverlays { + state.screenshot_export = ScreenshotStateMachine::WaitingForScreenshot; + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + } + + // Handle screenshot result + ui.input(|i| { + for event in &i.raw.events { + if let egui::Event::Screenshot { image, .. } = event { + if state.screenshot_export == ScreenshotStateMachine::WaitingForScreenshot { + if let Some(graph_rect) = state.view.graph_rect { + let graph_image = image.region(&graph_rect, Some(i.pixels_per_point())); + save_screenshot_as_png(&graph_image); + } else { + log::error!("No graph rect stored for screenshot export"); + } + state.screenshot_export = ScreenshotStateMachine::Idle; + } + } + } + }); +} + +/// Save the graph screenshot as a PNG file. +fn save_screenshot_as_png(image: &egui::ColorImage) { + let width = image.width() as u32; + let height = image.height() as u32; + let pixels = image.as_raw(); + + // Convert RGBA to ImageBuffer + let img_buffer = image::RgbaImage::from_raw(width, height, pixels.to_vec()) + .expect("Failed to create image buffer"); + + // Generate filename with timestamp + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); + let filename = format!("fosr_graph_{}.png", timestamp); + + // Convert to PNG bytes + let mut buffer = Vec::new(); + match img_buffer.write_to( + &mut std::io::Cursor::new(&mut buffer), + image::ImageFormat::Png, + ) { + Ok(_) => { + #[cfg(not(target_arch = "wasm32"))] + { + match crate::shared::file_io::save_file_desktop(&buffer, &filename) { + Ok(file_handle) => { + log::info!("Exported graph to {}", file_handle.path().to_string_lossy()); + } + Err(e) => { + log::error!("Failed to save graph PNG: {:?}", e); + } + } + } + + #[cfg(target_arch = "wasm32")] + { + let filename_clone = filename.clone(); + wasm_bindgen_futures::spawn_local(async move { + match crate::shared::file_io::save_file_wasm(&buffer, &filename_clone).await { + Ok(_) => log::info!("Exported graph to {}", filename_clone), + Err(e) => log::error!("Failed to save PNG on WASM: {:?}", e), + } + }); + } + } + Err(e) => log::error!("Failed to write PNG to buffer: {}", e), + } +} diff --git a/crates/fosr-gui/src/run/graph/shapes.rs b/crates/fosr-gui/src/run/graph/shapes.rs new file mode 100644 index 00000000..25701865 --- /dev/null +++ b/crates/fosr-gui/src/run/graph/shapes.rs @@ -0,0 +1,407 @@ +//! Custom node and edge shapes with protocol colors, icons, and dynamic sizing. +//! +//! egui_graphs rendering: +//! - `DisplayNode` trait: defines how a node is drawn (shapes, labels, icons) +//! - `DisplayEdge` trait: defines how an edge is drawn (lines, arrows, colors) +//! - `closest_boundary_point`: where edges connect to the node boundary +//! - `is_inside`: hit-testing for clicking and dragging + +use super::state::{NetworkEdge, EdgeState, LinkDirection, NetworkNode, NodeType}; +use crate::shared::assets::{IMG_COMPUTER, IMG_INTERNET, IMG_SERVER}; +use crate::shared::constants::colors::{ + COLOR_EDGE_INACTIVE, COLOR_ICON_TINT_DARK, COLOR_ICON_TINT_LIGHT, COLOR_PROTOCOL_DNS, + COLOR_PROTOCOL_HTTP, COLOR_PROTOCOL_HTTPS, COLOR_PROTOCOL_OTHER, COLOR_PROTOCOL_SMTP, + COLOR_PROTOCOL_SSH, COLOR_TEXT_MUTED, +}; +use crate::shared::constants::ui::{ + EDGE_ARROW_ANGLE_RAD, EDGE_ARROW_SIZE, EDGE_FLOW_SCALE, EDGE_WIDTH_MAX, EDGE_WIDTH_MIN, + NODE_FLOW_SCALE_FACTOR, NODE_RADIUS_MAX, NODE_RADIUS_MIN, SPACING_XS, TEXT_SIZE_DEFAULT, +}; +use eframe::egui::{self, Color32, Pos2, Rect, Shape, TextureOptions, Vec2, load::SizeHint}; +use egui_graphs::{DisplayEdge, DisplayNode, DrawContext, Node, NodeProps}; +use fosr_lib::L7Proto; + +/// Calculate node radius using hybrid linear/proportional scaling. +/// +/// The scaling works in two phases: +/// 1. **Linear phase**: While the maximum possible radius is below `NODE_RADIUS_MAX`, +/// each node grows proportionally to its flow count. +/// 2. **Proportional phase**: Once we would exceed `NODE_RADIUS_MAX`, switch to +/// ratio-based scaling so the most active node is always at max size. +/// +/// This ensures nodes grow smoothly at low traffic, but remain comparable at high traffic. +fn calculate_node_radius(flow_count: u32, max_flow_count: u32) -> f32 { + let max_linear = NODE_RADIUS_MIN + max_flow_count as f32 * NODE_FLOW_SCALE_FACTOR; + + if max_linear < NODE_RADIUS_MAX { + // Linear phase: everyone grows normally + NODE_RADIUS_MIN + flow_count as f32 * NODE_FLOW_SCALE_FACTOR + } else { + // Proportional phase: scale by ratio to max + let ratio = if max_flow_count > 0 { + flow_count as f32 / max_flow_count as f32 + } else { + 0.0 + }; + NODE_RADIUS_MIN + ratio * (NODE_RADIUS_MAX - NODE_RADIUS_MIN) + } +} + +/// Calculate edge width using hybrid linear/proportional scaling. +/// +/// Uses the same two-phase approach as `calculate_node_radius`: +/// 1. **Linear phase**: Edges grow proportionally while below `EDGE_WIDTH_MAX`. +/// 2. **Proportional phase**: Ratio-based scaling to keep the busiest edge at max width. +fn calculate_edge_width(flow_count: u32, max_flow_count: u32) -> f32 { + let max_linear = EDGE_WIDTH_MIN + max_flow_count as f32 * EDGE_FLOW_SCALE; + + if max_linear < EDGE_WIDTH_MAX { + // Linear phase: all edges grow normally + EDGE_WIDTH_MIN + flow_count as f32 * EDGE_FLOW_SCALE + } else { + // Proportional phase: scale by ratio to max + let ratio = if max_flow_count > 0 { + flow_count as f32 / max_flow_count as f32 + } else { + 0.0 + }; + EDGE_WIDTH_MIN + ratio * (EDGE_WIDTH_MAX - EDGE_WIDTH_MIN) + } +} + +/// Custom node shape that displays hostname and IP, with icon based on node type +#[derive(Clone)] +pub struct NetworkNodeShape { + radius: f32, + hostname: Option, + ips: Vec, + location: Pos2, + node_type: NodeType, +} + +impl NetworkNodeShape { + /// Compute node style from payload data. + fn style_from_payload(payload: &NetworkNode) -> (f32, NodeType, Option, Vec) { + let radius = calculate_node_radius(payload.flow_count, payload.max_flow_count); + let ips: Vec = payload.ip_addrs.iter().map(|ip| ip.to_string()).collect(); + ( + radius, + payload.node_type.clone(), + payload.hostname.clone(), + ips, + ) + } + + /// 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, + } + } + + /// Render the node icon at the given position and radius. + fn render_icon(&self, ctx: &DrawContext, pos: Pos2, radius: f32) -> Vec { + let mut shapes = Vec::new(); + let 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 { + COLOR_ICON_TINT_DARK + } else { + COLOR_ICON_TINT_LIGHT + }; + shapes.push(Shape::image(texture.id, rect, uv, tint)); + } + + shapes + } + + /// Render the node labels (hostname + IPs) below the icon. + fn render_labels(&self, ctx: &DrawContext, pos: Pos2, radius: f32) -> Vec { + let mut shapes = Vec::new(); + let font_size = TEXT_SIZE_DEFAULT; + let font_id = egui::FontId::proportional(font_size); + let mut current_y = pos.y + radius + SPACING_XS; + + ctx.ctx.fonts_mut(|f| { + // Draw hostname in italic + if let Some(ref hostname) = self.hostname { + let mut job = egui::text::LayoutJob::default(); + job.append( + hostname, + 0.0, + egui::TextFormat { + font_id: font_id.clone(), + color: COLOR_TEXT_MUTED, + italics: true, + ..Default::default() + }, + ); + let galley = f.layout_job(job); + let label_pos = Pos2::new(pos.x - galley.size().x / 2.0, current_y); + shapes.push(Shape::galley(label_pos, galley, COLOR_TEXT_MUTED)); + current_y += font_size + SPACING_XS; + } + + // Draw IPs (normal) - skip for Internet node + if self.node_type != NodeType::Internet { + for ip in &self.ips { + let mut job = egui::text::LayoutJob::default(); + job.append( + ip, + 0.0, + egui::TextFormat { + font_id: font_id.clone(), + color: COLOR_TEXT_MUTED, + ..Default::default() + }, + ); + let galley = f.layout_job(job); + let label_pos = Pos2::new(pos.x - galley.size().x / 2.0, current_y); + shapes.push(Shape::galley(label_pos, galley, COLOR_TEXT_MUTED)); + current_y += font_size + SPACING_XS; + } + } + }); + + shapes + } +} + +impl From> for NetworkNodeShape { + fn from(props: NodeProps) -> Self { + let (radius, node_type, hostname, ips) = Self::style_from_payload(&props.payload); + Self { + radius, + hostname, + ips, + location: props.location(), + node_type, + } + } +} + +impl DisplayNode +for NetworkNodeShape +{ + /// Determines where edges should connect to the node shape + fn closest_boundary_point(&self, dir: Vec2) -> Pos2 { + if dir.length() == 0.0 { + self.location + } else { + self.location + dir.normalized() * self.radius + } + } + + /// Set how a node is drawn in the graph. + /// A node can be composed of several shapes (icon + labels). + fn shapes(&mut self, ctx: &DrawContext) -> Vec { + let mut shapes = Vec::new(); + let pos = ctx.meta.canvas_to_screen_pos(self.location); + let radius = ctx.meta.canvas_to_screen_size(self.radius); + + shapes.extend(self.render_icon(ctx, pos, radius)); + shapes.extend(self.render_labels(ctx, pos, radius)); + + shapes + } + + fn update(&mut self, state: &NodeProps) { + let (radius, node_type, hostname, ips) = Self::style_from_payload(&state.payload); + self.radius = radius; + self.node_type = node_type; + self.hostname = hostname; + self.ips = ips; + 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: &NetworkEdge) -> (Color32, f32, bool, bool) { + match &edge_data.state { + EdgeState::Inactive => { + let width = calculate_edge_width(edge_data.flow_count, edge_data.max_flow_count); + (COLOR_EDGE_INACTIVE, width, false, false) + } + EdgeState::Active { + protocol, + direction, + .. + } => { + let color = match protocol { + L7Proto::HTTP => COLOR_PROTOCOL_HTTP, + L7Proto::HTTPS => COLOR_PROTOCOL_HTTPS, + L7Proto::SSH => COLOR_PROTOCOL_SSH, + L7Proto::DNS => COLOR_PROTOCOL_DNS, + L7Proto::SMTP => COLOR_PROTOCOL_SMTP, + _ => COLOR_PROTOCOL_OTHER, + }; + let (arrow_start, arrow_end) = match direction { + LinkDirection::Forward => (false, true), + 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< + NetworkNode, + NetworkEdge, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, +> for NetworkEdgeShape +{ + fn shapes( + &mut self, + start: &Node< + NetworkNode, + NetworkEdge, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, + >, + end: &Node< + NetworkNode, + NetworkEdge, + 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(EDGE_ARROW_SIZE); + let arrow_angle = EDGE_ARROW_ANGLE_RAD; + // Extend arrow tip past the line to avoid square appearance due to line width + let arrow_tip_offset = ctx.meta.canvas_to_screen_size(self.width); + + 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< + NetworkNode, + NetworkEdge, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, + >, + end: &Node< + NetworkNode, + NetworkEdge, + 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/run/graph/state.rs b/crates/fosr-gui/src/run/graph/state.rs new file mode 100644 index 00000000..9b93c3b3 --- /dev/null +++ b/crates/fosr-gui/src/run/graph/state.rs @@ -0,0 +1,462 @@ +//! Visualization state: graph data, active links, flow processing, and export. + +use super::shapes::{NetworkEdgeShape, NetworkNodeShape}; +use super::stream::{FlowEvent, FlowStreamer}; +use super::graph_layout::arrange_nodes_in_circle; +use crate::shared::config::model::Host; +use crate::shared::constants::ui::DELAY_FRAMES_QUICK; +use eframe::egui; +use egui_graphs::events::Event; +use fosr_lib::{L7Proto, OS, config, config::HostType}; +use petgraph::graph::NodeIndex; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::net::Ipv4Addr; +use std::rc::Rc; +use std::sync::mpsc::Receiver; +use std::sync::{Arc, RwLock}; +use web_time::Instant; + +/// Special IP address representing "The Internet" node +pub const INTERNET_NODE_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 NetworkNode { + 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 NetworkNode { + /// Create an Internet node + pub fn internet() -> Self { + Self { + ip_addrs: vec![INTERNET_NODE_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 NetworkNode { + 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 NetworkEdge { + /// 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 NetworkEdge { + 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 NetworkEdge { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.state { + EdgeState::Inactive => write!(f, ""), + EdgeState::Active { protocol, .. } => write!(f, "{:?}", protocol), + } + } +} + +/// Direction of traffic flow on an edge. +#[derive(Clone, Debug, PartialEq)] +pub enum LinkDirection { + Forward, + Backward, + Bidirectional, +} + +/// An active link being displayed +pub struct ActiveLink { + pub protocol: L7Proto, + pub start_time: Instant, + pub direction: LinkDirection, +} + +pub type VisualizationGraph = egui_graphs::Graph< + NetworkNode, + NetworkEdge, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, + NetworkEdgeShape, +>; + +/// State machine for screenshot export +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScreenshotStateMachine { + #[default] + Idle, + /// Hide overlays on next frame before taking screenshot + HidingOverlays, + /// Screenshot requested, waiting for result + WaitingForScreenshot, +} + +/// Network structure with graph, IP/Node lookups, and construction methods. +/// +/// Use [`NetworkData::from_config`] to build from a configuration. +pub struct NetworkData { + pub graph: VisualizationGraph, + pub known_ips: HashSet, + pub ip_to_node: HashMap, + pub node_to_host: HashMap, +} + +impl Default for NetworkData { + fn default() -> Self { + Self { + graph: VisualizationGraph::new(petgraph::stable_graph::StableGraph::default()), + known_ips: HashSet::new(), + ip_to_node: HashMap::new(), + node_to_host: HashMap::new(), + } + } +} + +impl NetworkData { + /// Build network data from the configuration. + /// + /// Creates nodes for each host, lays them out in a circle, + /// adds the Internet node at center, and connects edges. + pub fn from_config(config: &config::Configuration) -> Self { + let mut data = Self::default(); + data.add_host_nodes(config); + data.distribute_layout(); + data.add_internet_node(); + data.add_edges(config); + data + } + + /// Add one node per host (with all its IPs). + fn add_host_nodes(&mut self, config: &config::Configuration) { + for (host_idx, host) in config.get_hosts().iter().enumerate() { + let all_ips: Vec = host.interfaces.iter().map(|i| i.ip_addr).collect(); + + let node_data = NetworkNode { + ip_addrs: all_ips.clone(), + hostname: host.hostname.clone(), + node_type: host.host_type.into(), + os: host.os, + flow_count: 0, + max_flow_count: 0, + }; + let idx = self.graph.add_node_with_location(node_data, egui::pos2(0.0, 0.0)); + self.node_to_host.insert(idx, host_idx); + + // Map all IPs of this host to the same node + for ip in all_ips { + self.known_ips.insert(ip); + self.ip_to_node.insert(ip, idx); + } + } + } + + /// Distribute nodes in a circle (before adding Internet, so it stays centered). + fn distribute_layout(&mut self) { + arrange_nodes_in_circle(&mut self.graph); + } + + /// Add the Internet node at the center. + fn add_internet_node(&mut self) { + let internet_idx = self.graph.add_node_with_location(NetworkNode::internet(), egui::pos2(0.0, 0.0)); + self.ip_to_node.insert(INTERNET_NODE_IP, internet_idx); + } + + /// Add edges between users, servers, and Internet. + fn add_edges(&mut self, config: &config::Configuration) { + let internet_idx = self.ip_to_node[&INTERNET_NODE_IP]; + + // Add edges from users to servers and Internet + for &user_ip in &config.users { + if let Some(&user_idx) = self.ip_to_node.get(&user_ip) { + for &server_ip in &config.servers { + if let Some(&server_idx) = self.ip_to_node.get(&server_ip) { + self.graph.add_edge(user_idx, server_idx, NetworkEdge::default()); + } + } + self.graph.add_edge(user_idx, internet_idx, NetworkEdge::default()); + } + } + + // Add edges from servers to Internet + for &server_ip in &config.servers { + if let Some(&server_idx) = self.ip_to_node.get(&server_ip) { + self.graph.add_edge(server_idx, internet_idx, NetworkEdge::default()); + } + } + } +} + +/// Flow processing and streaming state +pub struct FlowVisualizationState { + pub receiver: Option>, + pub active_links: HashMap<(Ipv4Addr, Ipv4Addr), ActiveLink>, + pub streamer: Option, + pub running: bool, + pub speed: Arc>, + pub total_flows: u32, + pub visualization_start: Option, +} + +impl Default for FlowVisualizationState { + fn default() -> Self { + Self { + receiver: None, + active_links: HashMap::new(), + streamer: None, + running: false, + speed: Arc::new(RwLock::new(1.0)), + total_flows: 0, + visualization_start: None, + } + } +} + +/// Layout and rendering state +pub struct GraphViewState { + pub layout_initialized: bool, + pub reset_requested: bool, + pub delayed_fit_countdown: Option, + pub last_screen_size: Option, + pub graph_rect: Option, +} + +impl Default for GraphViewState { + fn default() -> Self { + Self { + layout_initialized: false, + reset_requested: false, + delayed_fit_countdown: Some(DELAY_FRAMES_QUICK), // Delay initial fit for bottom panel + last_screen_size: None, + graph_rect: None, + } + } +} + +/// Node info modal state +pub struct NodeModalState { + pub events_buffer: Rc>>, + pub clicked_node: Option, + pub open: bool, + pub edit_buffer: Option, +} + +impl Default for NodeModalState { + fn default() -> Self { + Self { + events_buffer: Rc::new(RefCell::new(Vec::new())), + clicked_node: None, + open: false, + edit_buffer: None, + } + } +} + +/// Represents the state of the visualization tab. +pub struct VisualizationState { + /// Network structure and lookups + pub network: NetworkData, + /// Flow processing and streaming + pub flow: FlowVisualizationState, + /// Layout and rendering + pub view: GraphViewState, + /// Node info modal + pub modal: NodeModalState, + /// Screenshot export state machine + pub screenshot_export: ScreenshotStateMachine, + /// Config content tracking (for detecting changes) + pub config_content: Option, + /// Auto-start countdown frames + pub auto_start_countdown: Option, + /// Whether user has manually started visualization + pub user_has_started: bool, +} + +impl Default for VisualizationState { + fn default() -> Self { + Self { + network: NetworkData::default(), + flow: FlowVisualizationState::default(), + view: GraphViewState::default(), + modal: NodeModalState::default(), + screenshot_export: ScreenshotStateMachine::default(), + config_content: None, + auto_start_countdown: None, + user_has_started: false, + } + } +} + +impl VisualizationState { + /// Update state from a configuration (preserves some state). + /// Note: caller should stop visualization before calling this if running. + pub fn update_from_config(&mut self, config: &config::Configuration) { + self.network = NetworkData::from_config(config); + self.view.layout_initialized = false; + } + + /// Check if an IP is a known (configured) IP + pub fn is_known_ip(&self, ip: Ipv4Addr) -> bool { + self.network.known_ips.contains(&ip) + } + + /// Reset all flow counts on nodes and edges + fn reset_flow_counts(&mut self) { + self.flow.total_flows = 0; + for idx in self.network.graph.g().node_indices().collect::>() { + if let Some(node) = self.network.graph.g_mut().node_weight_mut(idx) { + let payload = node.payload_mut(); + payload.flow_count = 0; + payload.max_flow_count = 0; + } + } + for idx in self.network.graph.g().edge_indices().collect::>() { + if let Some(edge) = self.network.graph.g_mut().edge_weight_mut(idx) { + let payload = edge.payload_mut(); + payload.flow_count = 0; + payload.max_flow_count = 0; + } + } + } + + /// Start visualization. + /// + /// If `config_content` is `None`, the FlowStreamer uses the default BN model (no config applied). + /// Speed controls how fast flows are emitted (1.0 = real-time, 2.0 = 2x faster) - can be updated at runtime. + /// If `reset` is `true`, flow counts are reset to zero before starting. + pub fn start_visualization( + &mut self, + config_content: Option<&str>, + speed: Arc>, + reset: bool, + ) -> Result<(), String> { + if reset { + self.reset_flow_counts(); + } + + log::debug!( + "Starting visualization with {} known IPs:", + self.network.known_ips.len() + ); + for ip in &self.network.known_ips { + log::debug!(" - {}", ip); + } + + let (sender, receiver) = std::sync::mpsc::channel(); + + let streamer = FlowStreamer::new(config_content, speed.clone(), sender)?; + streamer.start(); + + self.flow.streamer = Some(streamer); + self.flow.receiver = Some(receiver); + self.flow.running = true; + self.flow.visualization_start = Some(Instant::now()); + log::info!( + "Flow visualization started (config: {}, speed: {}x)", + if config_content.is_some() { + "user-provided" + } else { + "default BN model" + }, + *speed.read().unwrap() + ); + + Ok(()) + } + + /// Stop visualization + pub fn stop_visualization(&mut self) { + self.flow.running = false; + if let Some(streamer) = &self.flow.streamer { + streamer.stop(); + } + self.flow.streamer = None; + self.flow.receiver = None; + self.flow.active_links.clear(); + self.flow.visualization_start = None; + log::info!("Flow visualization stopped"); + } +} diff --git a/crates/fosr-gui/src/run/graph/stream.rs b/crates/fosr-gui/src/run/graph/stream.rs new file mode 100644 index 00000000..5585f4ef --- /dev/null +++ b/crates/fosr-gui/src/run/graph/stream.rs @@ -0,0 +1,483 @@ +//! 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. + +#[cfg(target_arch = "wasm32")] +use crate::shared::constants::network::STREAM_MAX_PER_CYCLE_WASM; +#[cfg(not(target_arch = "wasm32"))] +use crate::shared::constants::network::STREAM_RATE_LIMIT_MS; +use crate::shared::constants::network::{STREAM_BUFFER_AHEAD_SECS, STREAM_CHECK_INTERVAL_MS}; +use chrono::{DateTime, Offset, TimeZone}; +use fosr_lib::{ + L7Proto, models, + stage0, + stage1::Stage1, + stage1::bayesian_networks::BNGenerator, +}; +use std::collections::BinaryHeap; +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}; + +/// Tracks virtual time that progresses at a variable speed. +/// +/// Virtual time integrates speed changes smoothly: when speed changes, +/// only future time is affected, not the accumulated elapsed time. +/// This prevents "time travel" when reducing speed. +/// +/// # Why virtual time? +/// +/// Without this integration, changing speed would scale the entire elapsed time, +/// causing discontinuities. For example: +/// - Elapsed time is 20s at speed 1.0 +/// - Speed changes to 0.5 +/// - Naive approach: `elapsed = 20 * 0.5 = 10s` → goes back in time! +/// +/// With integration, only the delta since the last tick is scaled: +/// - After 20s at 1.0: virtual_elapsed = 20s +/// - Next tick with 1s real delta at 0.5x: virtual_elapsed = 20 + 0.5 = 20.5s +struct VirtualTime { + /// Accumulated virtual time (integrated over all speed changes) + elapsed: Duration, + /// Last real time measurement (for calculating delta) + last_tick: Instant, +} + +impl VirtualTime { + /// Create a new virtual time tracker starting at zero. + fn new() -> Self { + Self { + elapsed: Duration::ZERO, + last_tick: Instant::now(), + } + } + + /// Advance virtual time by the real time elapsed since last tick, + /// scaled by the current speed. + fn tick(&mut self, speed: f32) { + let now = Instant::now(); + let real_delta = now.duration_since(self.last_tick); + self.last_tick = now; + + // Scale the delta by speed and accumulate + let virtual_delta = Duration::from_secs_f64(real_delta.as_secs_f64() * speed as f64); + self.elapsed += virtual_delta; + } + + /// Get the current virtual elapsed time. + fn elapsed(&self) -> Duration { + self.elapsed + } +} + +/// A flow event (subset of FlowData) +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FlowEvent { + 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) + } +} + +impl ScheduledFlow { + /// Create a scheduled flow from flow data, relative to the initial timestamp. + fn from_flow_data( + flow_data: &fosr_lib::FlowData, + initial_timestamp: Duration, + ) -> Self { + let scheduled_time = if flow_data.timestamp >= initial_timestamp { + flow_data.timestamp - initial_timestamp + } else { + Duration::ZERO + }; + + Self { + event: FlowEvent { + src_ip: flow_data.src_ip, + dst_ip: flow_data.dst_ip, + protocol: flow_data.l7_proto, + timestamp: flow_data.timestamp, + }, + scheduled_time, + } + } +} + +/// Mutable state for the streaming loop. +/// +/// Uses a binary heap to store flows in timestamp order, ensuring they are +/// emitted in the correct sequence. Flows are generated ahead of time (buffer) +/// to avoid overloading the CPU by continuously generating them. +struct FlowStreamingState { + virtual_time: VirtualTime, + /// Pending flows ordered by scheduled time (max-heap, so we reverse the order). + pending_flows: BinaryHeap, + #[cfg(not(target_arch = "wasm32"))] + flow_count: usize, + #[cfg(not(target_arch = "wasm32"))] + last_generation: Instant, + /// How far ahead to generate flows (avoids CPU spinning). + buffer_ahead: Duration, +} + +impl FlowStreamingState { + fn new() -> Self { + Self { + virtual_time: VirtualTime::new(), + pending_flows: BinaryHeap::new(), + #[cfg(not(target_arch = "wasm32"))] + flow_count: 0, + #[cfg(not(target_arch = "wasm32"))] + last_generation: Instant::now(), + buffer_ahead: Duration::from_secs(STREAM_BUFFER_AHEAD_SECS), + } + } + + /// Advance virtual time by the current speed. + fn tick(&mut self, speed: f32) { + self.virtual_time.tick(speed); + } + + /// Get the current virtual elapsed time. + fn virtual_elapsed(&self) -> Duration { + self.virtual_time.elapsed() + } + + /// Check if we need to generate more flows (buffer running low). + /// + /// If the heap is empty, `.peek()` returns `None`, and `.map_or()` returns `true` + /// (we need flows). Otherwise, check if the farthest scheduled flow is within + /// the buffer window. + fn needs_more_flows(&self) -> bool { + self.pending_flows + .peek() + .map_or(true, |f| f.scheduled_time < self.virtual_elapsed() + self.buffer_ahead) + } + + /// Check if rate limiting allows generation (desktop only). + /// + /// Limits generation rate to avoid CPU spinning when the buffer is already full. + #[cfg(not(target_arch = "wasm32"))] + fn can_generate(&self) -> bool { + self.pending_flows.is_empty() + || self.last_generation.elapsed() >= Duration::from_millis(STREAM_RATE_LIMIT_MS) + } +} + +/// Generate flows and add them to the pending queue. +/// +/// Returns false if no more timestamps are available from Stage 0. +#[cfg(not(target_arch = "wasm32"))] +fn generate_flows_to_buffer( + state: &mut FlowStreamingState, + s0: &mut stage0::BinBasedGenerator, + s1: &BNGenerator, + initial_timestamp: Duration, +) -> bool { + if !state.can_generate() { + return true; // Rate limited, but Stage 0 may still have more + } + + if let Some(timestamp) = s0.next() { + if let Ok(flows) = s1.generate_flows(timestamp) { + for seeded_flow in flows { + let flow_data = seeded_flow.data.get_data(); + let scheduled = ScheduledFlow::from_flow_data(&flow_data, initial_timestamp); + state.pending_flows.push(scheduled); + state.flow_count += 1; + } + } + state.last_generation = Instant::now(); + true + } else { + false // No more timestamps + } +} + +/// Generate flows for WASM (with per-cycle limit). +#[cfg(target_arch = "wasm32")] +fn generate_flows_to_buffer_wasm( + state: &mut FlowStreamingState, + s0: &mut stage0::BinBasedGenerator, + s1: &BNGenerator, + initial_timestamp: Duration, +) { + let mut generated_this_cycle = 0; + + while state.needs_more_flows() && generated_this_cycle < STREAM_MAX_PER_CYCLE_WASM { + if let Some(timestamp) = s0.next() { + if let Ok(flows) = s1.generate_flows(timestamp) { + for seeded_flow in flows { + let flow_data = seeded_flow.data.get_data(); + let scheduled = ScheduledFlow::from_flow_data(&flow_data, initial_timestamp); + state.pending_flows.push(scheduled); + } + } + generated_this_cycle += 1; + } else { + break; + } + } +} + +/// Emit flows whose scheduled time has passed (in virtual time). +#[cfg(not(target_arch = "wasm32"))] +fn emit_scheduled_flows( + state: &mut FlowStreamingState, + sender: &Sender, +) { + let virtual_elapsed = state.virtual_elapsed(); + + while let Some(scheduled) = state.pending_flows.peek() { + if scheduled.scheduled_time <= virtual_elapsed { + let scheduled = state.pending_flows.pop().unwrap(); + log::debug!( + "Emitting flow #{}: {} -> {} ({:?}) at virtual {:?}", + state.flow_count, + scheduled.event.src_ip, + scheduled.event.dst_ip, + scheduled.event.protocol, + virtual_elapsed + ); + + if let Err(e) = sender.send(scheduled.event) { + log::error!("Failed to send flow event: {}", e); + break; + } + } else { + break; + } + } +} + +/// Emit flows for WASM (simplified, no debug logging). +#[cfg(target_arch = "wasm32")] +fn emit_scheduled_flows_wasm( + state: &mut FlowStreamingState, + sender: &Sender, +) { + let virtual_elapsed = state.virtual_elapsed(); + + while let Some(scheduled) = state.pending_flows.peek() { + if scheduled.scheduled_time <= virtual_elapsed { + let scheduled = state.pending_flows.pop().unwrap(); + let _ = sender.send(scheduled.event); + } else { + break; + } + } +} + +/// Flow streamer that continuously generates flow events +pub struct FlowStreamer { + s0: stage0::BinBasedGenerator, + s1: BNGenerator, + sender: Sender, + running: Arc, + initial_timestamp: Duration, + 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 { + let source = models::ModelsSource::Legacy; + let mut model = models::Models::from_source(source) + .map_err(|e| format!("Failed to load models: {}", e))?; + + 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); + + let initial_ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| format!("Failed to get time: {}", e))?; + + let tz_offset = Self::get_local_timezone_offset(initial_ts); + + let s0 = stage0::BinBasedGenerator::new( + None, // Random seed + false, + None, + model.time_bins, + initial_ts, + None, // Infinite duration + tz_offset, + ); + + let s1 = BNGenerator::new(bn, false); + + Ok(Self { + s0, + s1, + sender, + running: Arc::new(AtomicBool::new(false)), + initial_timestamp: initial_ts, + speed, + }) + } + + /// Get the local timezone offset for the given timestamp. + /// + /// TODO: Use the value from the generation tab? + /// TODO: Extract this logic to share with generation_core. + fn get_local_timezone_offset(timestamp: Duration) -> chrono::FixedOffset { + let date = DateTime::from_timestamp(timestamp.as_secs() as i64, 0) + .unwrap() + .naive_utc(); + let tz = chrono::Local::now() + .timezone() + .offset_from_local_datetime(&date) + .single() + .expect("Ambiguous local date from timestamp") + .fix(); + log::info!("Using local timezone (UTC{tz})"); + tz + } + + /// Start streaming flows in the background. + pub fn start(&self) { + self.running.store(true, Ordering::SeqCst); + let sender = self.sender.clone(); + 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>, + ) { + let mut state = FlowStreamingState::new(); + let check_interval = Duration::from_millis(STREAM_CHECK_INTERVAL_MS); + + log::info!( + "Flow streaming loop started (timestamp-based, speed: {}x)", + *speed.read().unwrap() + ); + + while running.load(Ordering::SeqCst) { + // Advance virtual time + let current_speed = *speed.read().unwrap(); + state.tick(current_speed); + + // Generate flows until buffer is full or Stage 0 exhausted + while state.needs_more_flows() && running.load(Ordering::SeqCst) { + if !generate_flows_to_buffer(&mut state, &mut s0, &s1, initial_timestamp) { + break; // Stage 0 exhausted + } + } + + // Emit flows that are due + emit_scheduled_flows(&mut state, &sender); + + std::thread::sleep(check_interval); + } + + log::info!( + "Flow streaming loop stopped ({} flows generated, {} pending)", + state.flow_count, + state.pending_flows.len() + ); + } + + #[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 state = FlowStreamingState::new(); + let check_interval = Duration::from_millis(STREAM_CHECK_INTERVAL_MS); + + while running.load(Ordering::SeqCst) { + // Advance virtual time + let current_speed = *speed.read().unwrap(); + state.tick(current_speed); + + // Generate flows (WASM has per-cycle limit) + generate_flows_to_buffer_wasm(&mut state, &mut s0, &s1, initial_timestamp); + + // Emit flows that are due + emit_scheduled_flows_wasm(&mut state, &sender); + + gloo_timers::future::TimeoutFuture::new(check_interval.as_millis() as u32).await; + } + } +} diff --git a/crates/fosr-gui/src/run/graph/view.rs b/crates/fosr-gui/src/run/graph/view.rs new file mode 100644 index 00000000..189f7a44 --- /dev/null +++ b/crates/fosr-gui/src/run/graph/view.rs @@ -0,0 +1,120 @@ +//! Graph view rendering for the Run tab. +//! +//! Renders the network visualization with custom node/edge shapes, +//! overlays, and screenshot export handling. + +use super::overlays::{ + render_overlay_buttons, render_overlay_edge_legend, render_overlay_node_legend, + render_overlay_stats, +}; +use super::screenshot::handle_screenshot_export; +use super::shapes::{NetworkEdgeShape, NetworkNodeShape}; +use super::state::{NetworkEdge, NetworkNode, ScreenshotStateMachine, GraphViewState}; +use crate::run::state::RunTabState; +use crate::shared::constants::ui::FIT_TO_SCREEN_PADDING; +use eframe::egui; + +/// Render the graph view with overlays. +/// +/// This function: +/// 1. Sets up the egui_graphs GraphView with custom shapes +/// 2. Handles window resize (triggers fit-to-screen) +/// 3. Disables force-directed layout (uses circle layout) +/// 4. Handles screenshot export state machine +/// 5. Renders UI overlays (buttons, stats, legends) +pub fn render_graph_view(ui: &mut egui::Ui, state: &mut RunTabState) { + let inner_response = egui::CentralPanel::default().show(ui.ctx(), |ui| { + handle_window_resize(ui, &mut state.visualization.view); + + let fit_to_screen = take_reset_request(&mut state.visualization.view); + + let mut graph_view = egui_graphs::GraphView::< + NetworkNode, + NetworkEdge, + petgraph::Undirected, + petgraph::stable_graph::DefaultIx, + NetworkNodeShape, + NetworkEdgeShape, + egui_graphs::FruchtermanReingoldWithCenterGravityState, + egui_graphs::LayoutForceDirected, + >::new(&mut state.visualization.network.graph) + .with_interactions(&egui_graphs::SettingsInteraction::new() + .with_node_clicking_enabled(true) + .with_dragging_enabled(true)) + .with_event_sink(&state.visualization.modal.events_buffer) + .with_styles(&egui_graphs::SettingsStyle::new().with_labels_always(true)) + .with_navigations( + &egui_graphs::SettingsNavigation::new() + .with_fit_to_screen_enabled(fit_to_screen) + // padding to avoid cropping with labels and overlays + .with_fit_to_screen_padding(FIT_TO_SCREEN_PADDING) + .with_zoom_and_pan_enabled(true), + ); + + // TODO: handle layout properly instead of just deactivating auto-layout + disable_force_directed_layout(ui, &mut state.visualization.view); + + ui.add(&mut graph_view); + + handle_screenshot_export(ui, &mut state.visualization); + + // Hide overlays during export to get clean screenshot + if state.visualization.screenshot_export == ScreenshotStateMachine::Idle { + render_overlay_buttons(ui, &mut state.visualization); + render_overlay_stats(ui, &state.visualization); + render_overlay_node_legend(ui); + render_overlay_edge_legend(ui); + } + }); + + // Use panel rect directly - it's already in screen coordinates + // and represents the full panel area (ui.max_rect() excludes internal padding) + state.visualization.view.graph_rect = Some(inner_response.response.rect); +} + +/// Detect window resize and trigger fit-to-screen. +/// +/// Compares current screen size with last known size. +/// On change, requests a reset to recalculate zoom/pan. +fn handle_window_resize(ui: &egui::Ui, view: &mut GraphViewState) { + let screen_size = ui.ctx().content_rect().size(); + match view.last_screen_size { + Some(last) if last != screen_size => { + view.last_screen_size = Some(screen_size); + view.reset_requested = true; + } + None => view.last_screen_size = Some(screen_size), + _ => {} + } +} + +/// Consume reset request and return whether fit-to-screen should run. +/// +/// Returns true for one frame when reset is requested, +/// then clears the flag so it doesn't repeat. +fn take_reset_request(view: &mut GraphViewState) -> bool { + let fit = view.reset_requested; + if fit { + view.reset_requested = false; + } + fit +} + +/// Disable force-directed layout to preserve circle layout. +/// +/// Must be called once after the graph is first rendered. +/// The force-directed layout would override our circle layout. +fn disable_force_directed_layout(ui: &mut egui::Ui, view: &mut GraphViewState) { + if view.layout_initialized { + return; + } + let layout_state = egui_graphs::FruchtermanReingoldWithCenterGravityState { + base: egui_graphs::FruchtermanReingoldState { + is_running: false, + ..Default::default() + }, + extras: Default::default(), + }; + egui_graphs::set_layout_state(ui, layout_state, None); + view.layout_initialized = true; +} diff --git a/crates/fosr-gui/src/run/mod.rs b/crates/fosr-gui/src/run/mod.rs new file mode 100644 index 00000000..47ff2214 --- /dev/null +++ b/crates/fosr-gui/src/run/mod.rs @@ -0,0 +1,6 @@ +//! Run module: combines live visualization and PCAP generation in a single tab. + +pub mod generation; +pub mod graph; +pub mod state; +pub mod tab; diff --git a/crates/fosr-gui/src/run/state.rs b/crates/fosr-gui/src/run/state.rs new file mode 100644 index 00000000..5ece2761 --- /dev/null +++ b/crates/fosr-gui/src/run/state.rs @@ -0,0 +1,23 @@ +//! Combined state for the Run tab: visualization and generation with panel toggle. + +use super::generation::state::GenerationState; +use super::graph::state::VisualizationState; + +/// State for the unified Run tab. +/// Combines visualization (live preview) and generation state. +pub struct RunTabState { + pub visualization: VisualizationState, + pub generation: GenerationState, + /// Whether the side panel (generation options) is open + pub panel_open: bool, +} + +impl Default for RunTabState { + fn default() -> Self { + Self { + visualization: VisualizationState::default(), + generation: GenerationState::default(), + panel_open: true, + } + } +} diff --git a/crates/fosr-gui/src/run/tab.rs b/crates/fosr-gui/src/run/tab.rs new file mode 100644 index 00000000..b6cfedd2 --- /dev/null +++ b/crates/fosr-gui/src/run/tab.rs @@ -0,0 +1,76 @@ +//! Run tab: live network visualization combined with PCAP generation controls. + +use super::generation::bottom_panel::render_bottom_panel; +use super::generation::process::poll_generation_receivers; +use super::graph::config_handling::handle_config_changes; +use super::graph::flow_processing::{process_flow_events, update_active_links, update_graph_edges}; +use super::graph::node_modal::{process_graph_events, render_node_info_modal}; +use super::graph::view::render_graph_view; +use super::state::RunTabState; +use crate::run::graph::state::{GraphViewState, VisualizationState}; +use crate::shared::config::file_ops::load_config_file_contents; +use crate::shared::config::state::ConfigFileState; +use eframe::egui; + +/// Display the Run tab content. +/// +/// Orchestrates visualization updates, event processing, and UI rendering. +pub fn render_run_tab( + ui: &mut egui::Ui, + state: &mut RunTabState, + configuration_file_state: &mut ConfigFileState, +) { + load_config_file_contents(configuration_file_state); + handle_config_changes(&mut state.visualization, configuration_file_state); + + handle_auto_start_visualization(&mut state.visualization); + handle_delayed_fit_to_screen(&mut state.visualization.view); + + process_flow_events(&mut state.visualization); + update_active_links(&mut state.visualization); + update_graph_edges(&mut state.visualization); + + poll_generation_receivers(ui.ctx(), state); + + render_bottom_panel(ui.ctx(), state, configuration_file_state); + + render_graph_view(ui, state); + process_graph_events(&mut state.visualization, configuration_file_state); + + render_node_info_modal(ui.ctx(), &mut state.visualization, configuration_file_state); +} + +/// Handle auto-start visualization with a frame delay. +/// +/// Waits for the countdown to reach zero before starting the visualization. +/// This allows the UI to render at least one frame before starting, +/// preventing visual glitches on initial load. +fn handle_auto_start_visualization(state: &mut VisualizationState) { + if let Some(countdown) = state.auto_start_countdown { + if countdown > 0 { + state.auto_start_countdown = Some(countdown - 1); + } else if !state.flow.running { + let config = state.config_content.clone(); + let speed = state.flow.speed.clone(); + if let Err(e) = state.start_visualization(config.as_deref(), speed, true) { + log::error!("Failed to auto-start visualization: {}", e); + } + state.auto_start_countdown = None; + } + } +} + +/// Handle delayed fit-to-screen after view changes. +/// +/// Waits for the countdown to reach zero before triggering a fit-to-screen. +/// Used after panel toggles or on initial load to ensure proper layout. +fn handle_delayed_fit_to_screen(view: &mut GraphViewState) { + if let Some(countdown) = view.delayed_fit_countdown { + if countdown > 0 { + view.delayed_fit_countdown = Some(countdown - 1); + } else { + view.reset_requested = true; + view.delayed_fit_countdown = None; + } + } +} diff --git a/crates/fosr-gui/src/shared/assets.rs b/crates/fosr-gui/src/shared/assets.rs new file mode 100644 index 00000000..71f9dd1f --- /dev/null +++ b/crates/fosr-gui/src/shared/assets.rs @@ -0,0 +1,13 @@ +//! Embedded assets: images and icons used throughout the application. +//! +//! Centralizes all `include_image!` calls. Reduces path issues when files move. + +use eframe::egui::{ImageSource, include_image}; + +// Node icons for graph visualization +pub const IMG_SERVER: ImageSource = include_image!("../../assets/server.png"); +pub const IMG_COMPUTER: ImageSource = include_image!("../../assets/computer.png"); +pub const IMG_INTERNET: ImageSource = include_image!("../../assets/internet.png"); + +// Fos-R logo +pub const IMG_LOGO: ImageSource = include_image!("../../../../public/logo.png"); diff --git a/crates/fosr-gui/src/shared/config/file_ops.rs b/crates/fosr-gui/src/shared/config/file_ops.rs new file mode 100644 index 00000000..1ad58c5a --- /dev/null +++ b/crates/fosr-gui/src/shared/config/file_ops.rs @@ -0,0 +1,149 @@ +//! Configuration file operations: loading and saving config files. + +use crate::shared::config::model::Configuration; +use crate::shared::config::parser::parse_config_yaml; +use crate::shared::config::state::ConfigFileState; +#[cfg(not(target_arch = "wasm32"))] +use crate::shared::file_io::{read_file_desktop, show_file_picker_desktop}; +#[cfg(target_arch = "wasm32")] +use crate::shared::file_io::{read_file_wasm, show_file_picker_wasm}; +use chrono::{DateTime, Local}; +use eframe::egui; +#[cfg(target_arch = "wasm32")] +use std::sync::mpsc::channel; + +/// Trigger a file import dialog to select a configuration file. +/// +/// On desktop: synchronous file picker via native dialog. +/// On WASM: async file picker, result arrives via `config_file_receiver`. +/// Clears any previously loaded config state before picking a new file. +pub fn trigger_file_import(state: &mut ConfigFileState, 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. +/// +/// Should be called every frame in the UI to check for completed file picks. +/// When a file is picked, clears the loaded config and sets up for content loading. +#[cfg(target_arch = "wasm32")] +pub fn poll_file_import(state: &mut ConfigFileState) { + 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; + } + } +} + +/// Load the contents of the selected configuration file. +/// +/// On desktop: reads file synchronously and parses YAML immediately. +/// On WASM: spawns async read, parses YAML when content arrives via channel. +/// +/// Skips if content is already loaded to avoid re-reading every frame. +pub fn load_config_file_contents(configuration_file_state: &mut ConfigFileState) { + // 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"))] + { + match read_file_desktop(file_handle) { + Ok(content) => { + configuration_file_state.config_file_content = Some(content); + parse_config_yaml(configuration_file_state); + } + Err(e) => { + configuration_file_state.config_error = Some(e); + } + } + } + + #[cfg(target_arch = "wasm32")] + if configuration_file_state + .config_file_content_receiver + .is_none() + { + let (sender, receiver) = channel(); + configuration_file_state.config_file_content_receiver = Some(receiver); + let file_handle_clone = file_handle.clone(); + + wasm_bindgen_futures::spawn_local(async move { + let result = read_file_wasm(&file_handle_clone).await; + let _ = sender.send(result); + }); + } else { + if let Some(receiver) = &configuration_file_state.config_file_content_receiver { + if let Ok(result) = receiver.try_recv() { + configuration_file_state.config_file_content_receiver = None; + match result { + Ok(content) => { + configuration_file_state.config_file_content = Some(content); + parse_config_yaml(configuration_file_state); + } + Err(e) => { + configuration_file_state.config_error = Some(e); + } + } + } + } + } + } +} + +/// Clear all loaded config state to allow loading a new file. +fn clear_loaded_config(configuration_file_state: &mut ConfigFileState) { + configuration_file_state.config_file_content = None; + configuration_file_state.config_model = None; + configuration_file_state.config_error = None; + configuration_file_state.is_dirty = false; + configuration_file_state.clean_snapshot = None; + configuration_file_state.loaded_template_id = None; + + #[cfg(target_arch = "wasm32")] + { + configuration_file_state.config_file_content_receiver = None; + } +} + +/// Enforce date and format in metadata before saving. +/// +/// Sets the date to today and format version to 1. +pub fn enforce_metadata_defaults(config: &mut Configuration) { + let now: DateTime = Local::now(); + config.metadata.date = Some(now.format("%Y/%m/%d").to_string()); + config.metadata.format = Some(1); +} diff --git a/crates/fosr-gui/src/shared/config/mod.rs b/crates/fosr-gui/src/shared/config/mod.rs new file mode 100644 index 00000000..21876032 --- /dev/null +++ b/crates/fosr-gui/src/shared/config/mod.rs @@ -0,0 +1,6 @@ +//! Configuration module: model, state, parsing, and file operations. + +pub mod file_ops; +pub mod model; +pub mod parser; +pub mod state; diff --git a/crates/fosr-gui/src/shared/config/model.rs b/crates/fosr-gui/src/shared/config/model.rs new file mode 100644 index 00000000..438410bd --- /dev/null +++ b/crates/fosr-gui/src/shared/config/model.rs @@ -0,0 +1,62 @@ +//! Configuration data model: Metadata, Host, Interface structs for YAML serde. + +use serde::{Deserialize, Serialize}; + +/// Root configuration structure containing metadata and host definitions. +#[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, +} + +/// Metadata describing the configuration file. +#[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, +} + +/// Represents a host machine in the network configuration. +#[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, +} + +/// Network interface configuration for a host. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Interface { + #[serde(skip_serializing_if = "Option::is_none")] + pub mac_addr: Option, + + /// Required: the IPv4 address of this interface. + pub ip_addr: String, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub services: Vec, +} diff --git a/crates/fosr-gui/src/shared/config/parser.rs b/crates/fosr-gui/src/shared/config/parser.rs new file mode 100644 index 00000000..0eed5db4 --- /dev/null +++ b/crates/fosr-gui/src/shared/config/parser.rs @@ -0,0 +1,27 @@ +//! Configuration YAML parsing. + +use crate::shared::config::model::Configuration; +use crate::shared::config::state::ConfigFileState; + +/// Parse the YAML content into a Configuration model. +/// Updates the state with the parsed model or an error message. +/// Handles change detection using a snapshot of the "clean" configuration. +pub fn parse_config_yaml(configuration_file_state: &mut ConfigFileState) { + configuration_file_state.config_model = None; + configuration_file_state.config_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(yaml.to_string()); + } + configuration_file_state.config_model = Some(model); + configuration_file_state.is_dirty = true; + } + Err(e) => configuration_file_state.config_error = Some(e.to_string()), + } +} diff --git a/crates/fosr-gui/src/shared/config/state.rs b/crates/fosr-gui/src/shared/config/state.rs new file mode 100644 index 00000000..351c6820 --- /dev/null +++ b/crates/fosr-gui/src/shared/config/state.rs @@ -0,0 +1,60 @@ +//! Configuration file state management. + +use crate::shared::config::model::Configuration; +use rfd::FileHandle; +#[cfg(target_arch = "wasm32")] +use std::sync::mpsc::Receiver; + +/// State for the startup modal flow. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +pub enum StartupModalStep { + #[default] + Initial, + TemplateSelection, +} + +/// State for configuration file management including content, model, and UI state. +pub struct ConfigFileState { + pub picked_config_file: Option, + #[cfg(target_arch = "wasm32")] + pub config_file_receiver: Option>>, + pub config_file_content: Option, + #[cfg(target_arch = "wasm32")] + pub config_file_content_receiver: Option>>, + pub config_model: Option, + pub config_error: Option, + /// Whether the user has chosen a configuration (default or imported). + /// When false, the startup modal is shown. + pub config_chosen: bool, + pub is_dirty: bool, + /// YAML snapshot of the clean state (for dirty detection). + pub clean_snapshot: Option, + /// Whether the configuration has any errors (parse errors or validation errors). + /// Updated by the configuration tab rendering each frame. + pub has_errors: bool, + /// Current state of the startup modal. + pub modal_state: StartupModalStep, + /// The ID of the currently loaded template, if any. + pub loaded_template_id: Option, +} + +impl Default for ConfigFileState { + fn default() -> Self { + Self { + picked_config_file: None, + #[cfg(target_arch = "wasm32")] + config_file_receiver: None, + config_file_content: None, + #[cfg(target_arch = "wasm32")] + config_file_content_receiver: None, + config_model: None, + config_error: None, + config_chosen: false, + is_dirty: false, + clean_snapshot: None, + has_errors: false, + modal_state: StartupModalStep::Initial, + loaded_template_id: None, + } + } +} diff --git a/crates/fosr-gui/src/shared/configuration_file.rs b/crates/fosr-gui/src/shared/configuration_file.rs deleted file mode 100644 index 7eeb848f..00000000 --- a/crates/fosr-gui/src/shared/configuration_file.rs +++ /dev/null @@ -1,135 +0,0 @@ -#[cfg(not(target_arch = "wasm32"))] -use crate::shared::file_io::{read_file_desktop, show_file_picker_desktop}; -#[cfg(target_arch = "wasm32")] -use crate::shared::file_io::{read_file_wasm, show_file_picker_wasm}; -use eframe::egui; -use rfd::FileHandle; -#[cfg(target_arch = "wasm32")] -use std::sync::mpsc::{Receiver, channel}; - -pub struct ConfigurationFileState { - pub picked_config_file: Option, - #[cfg(target_arch = "wasm32")] - pub config_file_receiver: Option>>, - pub config_file_content: Option, - #[cfg(target_arch = "wasm32")] - pub config_file_content_receiver: Option>>, -} - -impl Default for ConfigurationFileState { - fn default() -> Self { - Self { - picked_config_file: None, - #[cfg(target_arch = "wasm32")] - config_file_receiver: None, - config_file_content: None, - #[cfg(target_arch = "wasm32")] - config_file_content_receiver: None, - } - } -} - -pub fn configuration_file_picker( - ui: &mut egui::Ui, - 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(); - }); - } - } - - #[cfg(target_arch = "wasm32")] - // Check if we received a file from the async task - { - if let Some(receiver) = &configuration_file_state.config_file_receiver { - if let Ok(file) = receiver.try_recv() { - // Only update if a file was actually selected - if file.is_some() { - configuration_file_state.picked_config_file = file; - } - configuration_file_state.config_file_receiver = None; // Dialog finished - } - } - } - - // 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"))] - { - let path_text = configuration_file_state - .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); - } - - #[cfg(target_arch = "wasm32")] - ui.label(&filename); - }); -} - -pub fn load_config_file_contents(configuration_file_state: &mut ConfigurationFileState) { - 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); - } - - #[cfg(target_arch = "wasm32")] - if configuration_file_state - .config_file_content_receiver - .is_none() - { - let (sender, receiver) = channel(); - configuration_file_state.config_file_content_receiver = Some(receiver); - let file_handle_clone = file_handle.clone(); - - wasm_bindgen_futures::spawn_local(async move { - let content = read_file_wasm(&file_handle_clone).await; - let _ = sender.send(Some(content)); - }); - } else { - if let Some(receiver) = &configuration_file_state.config_file_content_receiver { - if let Ok(content) = receiver.try_recv() { - configuration_file_state.config_file_content = content; - configuration_file_state.config_file_content_receiver = None; - } - } - } - } -} diff --git a/crates/fosr-gui/src/shared/constants/colors.rs b/crates/fosr-gui/src/shared/constants/colors.rs new file mode 100644 index 00000000..1b81803c --- /dev/null +++ b/crates/fosr-gui/src/shared/constants/colors.rs @@ -0,0 +1,70 @@ +//! Centralized color constants for consistent styling across the application. +//! +//! This module centralizes all color constants to ensure: +//! - Consistent colors across the app +//! - Easy theme adjustments in one place +//! - Self-documenting code with named colors + +use eframe::egui::Color32; + +// ============================================================================ +// SEMANTIC COLORS (UI state) +// ============================================================================ + +/// Error color (red) - for validation errors and error messages +pub const COLOR_ERROR: Color32 = Color32::from_rgb(220, 50, 50); + +/// Warning color (amber/orange) - for warnings and unsaved changes +pub const COLOR_WARNING: Color32 = Color32::from_rgb(230, 160, 0); + +/// Success color (light green) - for success states and progress bars +pub const COLOR_SUCCESS: Color32 = Color32::from_rgb(144, 238, 144); + +/// Stop/Danger color (red) - for stop buttons and dangerous actions +pub const COLOR_STOP: Color32 = Color32::from_rgb(200, 80, 80); + +// ============================================================================ +// TEXT COLORS +// ============================================================================ + +/// Muted/secondary text color (gray) - for labels and secondary info +pub const COLOR_TEXT_MUTED: Color32 = Color32::GRAY; + +// ============================================================================ +// VISUALIZATION: NODE ICONS +// ============================================================================ + +/// Icon tint for dark mode - gray instead of pure white +pub const COLOR_ICON_TINT_DARK: Color32 = Color32::from_rgb(180, 180, 180); + +/// Icon tint for light mode - gray instead of pure black +pub const COLOR_ICON_TINT_LIGHT: Color32 = Color32::from_rgb(40, 40, 40); + +// ============================================================================ +// VISUALIZATION: EDGE STATES +// ============================================================================ + +/// Inactive edge color (light gray) +pub const COLOR_EDGE_INACTIVE: Color32 = Color32::from_rgb(200, 200, 200); + +// ============================================================================ +// VISUALIZATION: PROTOCOL COLORS +// ============================================================================ + +/// HTTP protocol color (blue) +pub const COLOR_PROTOCOL_HTTP: Color32 = Color32::from_rgb(52, 152, 219); + +/// HTTPS protocol color (green) +pub const COLOR_PROTOCOL_HTTPS: Color32 = Color32::from_rgb(46, 204, 113); + +/// SSH protocol color (purple) +pub const COLOR_PROTOCOL_SSH: Color32 = Color32::from_rgb(155, 89, 182); + +/// DNS protocol color (orange) +pub const COLOR_PROTOCOL_DNS: Color32 = Color32::from_rgb(230, 126, 34); + +/// SMTP protocol color (yellow) +pub const COLOR_PROTOCOL_SMTP: Color32 = Color32::from_rgb(241, 196, 15); + +/// Other/unknown protocol color (gray) +pub const COLOR_PROTOCOL_OTHER: Color32 = Color32::from_rgb(149, 165, 166); diff --git a/crates/fosr-gui/src/shared/constants/mod.rs b/crates/fosr-gui/src/shared/constants/mod.rs new file mode 100644 index 00000000..e9c2b5b3 --- /dev/null +++ b/crates/fosr-gui/src/shared/constants/mod.rs @@ -0,0 +1,5 @@ +//! Constants module: colors, UI constants, and network constants. + +pub mod colors; +pub mod network; +pub mod ui; diff --git a/crates/fosr-gui/src/shared/constants/network.rs b/crates/fosr-gui/src/shared/constants/network.rs new file mode 100644 index 00000000..5c6aac28 --- /dev/null +++ b/crates/fosr-gui/src/shared/constants/network.rs @@ -0,0 +1,68 @@ +//! Network-related constants for configuration and visualization + +// ============================================================================ +// PORTS +// ============================================================================ + +/// Minimum valid port number +pub const PORT_MIN: u16 = 1; + +/// Maximum valid port number +pub const PORT_MAX: u16 = 65535; + +/// Default port when service is unknown +pub const PORT_UNSPECIFIED: u16 = 0; + +// ============================================================================ +// IP ADDRESS RANGES +// ============================================================================ + +/// Minimum IP octet value for local network (192.168.0.x) +pub const IP_LOCAL_MIN: u8 = 1; + +/// Maximum IP octet value for local network (192.168.0.x) +pub const IP_LOCAL_MAX: u8 = 254; + +// ============================================================================ +// MAC ADDRESS +// ============================================================================ + +/// Number of bytes in a MAC address +pub const MAC_ADDRESS_BYTES: usize = 6; + +/// Number of parts when parsing MAC address (XX:XX:XX:XX:XX:XX) +pub const MAC_ADDRESS_PARTS: usize = 6; + +/// Expected length of each MAC address part (2 hex chars) +pub const MAC_PART_LENGTH: usize = 2; + +/// MAC address local bit (second character of first octet) +pub const MAC_LOCAL_BIT: u8 = 0x02; + +/// MAC address mask for local bit manipulation +pub const MAC_LOCAL_MASK: u8 = 0xFE; + +// ============================================================================ +// HOST CONFIGURATION DEFAULTS +// ============================================================================ + +/// Default usage intensity for hosts (1.0 = baseline, <1 = less active, >1 = more active) +pub const HOST_USAGE_DEFAULT: f32 = 1.0; + +// ============================================================================ +// STREAM TIMING (for visualization) +// ============================================================================ + +/// Buffer ahead duration in seconds - how far ahead to generate flows +pub const STREAM_BUFFER_AHEAD_SECS: u64 = 5; + +/// Check interval in milliseconds - how often to check for flows to emit +pub const STREAM_CHECK_INTERVAL_MS: u64 = 50; + +/// Rate limit for generation in milliseconds - prevents CPU spinning +#[cfg(not(target_arch = "wasm32"))] +pub const STREAM_RATE_LIMIT_MS: u64 = 100; + +/// Maximum flows generated per cycle (WASM only) +#[cfg(target_arch = "wasm32")] +pub const STREAM_MAX_PER_CYCLE_WASM: usize = 10; diff --git a/crates/fosr-gui/src/shared/constants/ui.rs b/crates/fosr-gui/src/shared/constants/ui.rs new file mode 100644 index 00000000..ffa7c426 --- /dev/null +++ b/crates/fosr-gui/src/shared/constants/ui.rs @@ -0,0 +1,314 @@ +//! UI constants for consistent styling across the application +//! +//! This module centralizes all UI-related constants to ensure: +//! - Consistent spacing, sizing, and colors across the app +//! - Easy theme adjustments in one place +//! - Self-documenting code with named constants + +// ============================================================================ +// SPACING +// ============================================================================ + +/// Extra small spacing (px) +pub const SPACING_XS: f32 = 2.0; + +/// Small spacing (px) +pub const SPACING_SM: f32 = 4.0; + +/// Medium spacing (px) +pub const SPACING_MD: f32 = 6.0; + +/// Large spacing (px) +pub const SPACING_LG: f32 = 8.0; + +/// Extra large spacing (px) +pub const SPACING_XL: f32 = 12.0; + +/// Extra extra large spacing (px) +pub const SPACING_XXL: f32 = 15.0; + +/// Negative extra small spacing (px) for pulling elements closer together +pub const SPACING_NEGATIVE_XS: f32 = -2.0; + +// ============================================================================ +// TYPOGRAPHY +// ============================================================================ + +/// Small text size (px) +pub const TEXT_SIZE_SM: f32 = 12.0; + +/// Medium text size (px) +pub const TEXT_SIZE_MD: f32 = 13.0; + +/// Default text size (px) +pub const TEXT_SIZE_DEFAULT: f32 = 14.0; + +/// Large text size (px) +pub const TEXT_SIZE_LG: f32 = 16.0; + +/// Icon size for startup cards and major UI elements (px) +pub const ICON_SIZE_LG: f32 = 28.0; + +// ============================================================================ +// BUTTONS +// ============================================================================ + +/// Standard button height (px) +pub const BUTTON_HEIGHT: f32 = 24.0; + +/// Small button minimum width (px) - Stop, Save, Open +pub const BUTTON_MIN_WIDTH_SM: f32 = 75.0; + +/// Large button minimum width (px) - Generate +pub const BUTTON_MIN_WIDTH_LG: f32 = 85.0; + +/// Button padding (horizontal, vertical) +pub const BUTTON_PADDING: (f32, f32) = (5.0, 2.0); + +// ============================================================================ +// PANELS & MODALS +// ============================================================================ + +/// Small modal width (px) - close confirmation +#[cfg(not(target_arch = "wasm32"))] +pub const MODAL_WIDTH_SM: f32 = 370.0; + +/// Default modal width (px) - startup, template selection +pub const MODAL_WIDTH_MD: f32 = 400.0; + +/// Panel minimum width for popup content (px) +pub const PANEL_MIN_WIDTH: f32 = 250.0; + +/// Panel popup minimum width (px) +pub const POPUP_MIN_WIDTH: f32 = 180.0; + +/// Panel popup maximum height (px) +pub const POPUP_MAX_HEIGHT: f32 = 200.0; + +/// Node info modal width (px) +pub const NODE_MODAL_WIDTH: f32 = 250.0; + +/// Panel inner margin (horizontal, vertical) for top panel +pub const PANEL_INNER_MARGIN: (i8, i8) = (4, 3); + +/// Bottom bar inner margin (horizontal, vertical) +pub const BOTTOM_BAR_INNER_MARGIN: (i8, i8) = (8, 4); + +/// Options panel inner margin (horizontal, vertical) +pub const OPTIONS_PANEL_INNER_MARGIN: (i8, i8) = (8, 8); + +// ============================================================================ +// WINDOW DIMENSIONS (native builds) +// ============================================================================ + +/// Default window width (px) +#[cfg(not(target_arch = "wasm32"))] +pub const WINDOW_DEFAULT_WIDTH: f32 = 1200.0; + +/// Default window height (px) +#[cfg(not(target_arch = "wasm32"))] +pub const WINDOW_DEFAULT_HEIGHT: f32 = 1000.0; + +/// Minimum window width (px) +#[cfg(not(target_arch = "wasm32"))] +pub const WINDOW_MIN_WIDTH: f32 = 550.0; + +/// Minimum window height (px) +#[cfg(not(target_arch = "wasm32"))] +pub const WINDOW_MIN_HEIGHT: f32 = 500.0; + +// ============================================================================ +// ZOOM +// ============================================================================ + +/// Minimum zoom level (fraction) +pub const ZOOM_MIN: f32 = 0.8; + +/// Maximum zoom level (fraction) +pub const ZOOM_MAX: f32 = 2.0; + +/// Zoom step increment (fraction) +pub const ZOOM_STEP: f32 = 0.1; + +/// Default zoom for native builds (fraction) +#[cfg(not(target_arch = "wasm32"))] +pub const ZOOM_DEFAULT: f32 = 1.4; + +/// Default zoom for WASM builds (fraction) +#[cfg(target_arch = "wasm32")] +pub const ZOOM_DEFAULT: f32 = 1.2; + +/// Tooltip delay in seconds +pub const TOOLTIP_DELAY: f32 = 0.1; + +// ============================================================================ +// STARTUP CARDS +// ============================================================================ + +/// Startup card height (px) +pub const STARTUP_CARD_HEIGHT: f32 = 120.0; + +/// Number of columns in initial startup modal +pub const STARTUP_COLUMNS_INITIAL: usize = 2; + +/// Number of columns in template selection modal +pub const STARTUP_COLUMNS_TEMPLATES: usize = 3; + +// ============================================================================ +// ABOUT TAB +// ============================================================================ + +/// Logo max width in about tab (px) +pub const LOGO_MAX_WIDTH: f32 = 450.0; + +// ============================================================================ +// GENERATION OPTIONS +// ============================================================================ + +/// Number of columns in generation options +pub const GENERATION_OPTIONS_COLUMNS: usize = 2; + +/// Column 1 minimum width (duration & time options) +pub const GENERATION_COL1_MIN_WIDTH: f32 = 280.0; + +/// Column 2 minimum width (seed & advanced options) +pub const GENERATION_COL2_MIN_WIDTH: f32 = 200.0; + +/// Duration text field width +pub const DURATION_TEXT_WIDTH: f32 = 80.0; + +/// Seed input field width +pub const SEED_INPUT_WIDTH: f32 = 120.0; + +/// Timezone picker width +pub const TIMEZONE_PICKER_WIDTH: f32 = 160.0; + +/// Timezone popup maximum height +pub const TIMEZONE_POPUP_MAX_HEIGHT: f32 = 450.0; + +/// Timezone list maximum height +pub const TIMEZONE_LIST_MAX_HEIGHT: f32 = 400.0; + +// ============================================================================ +// VISUALIZATION: NODE SIZING +// ============================================================================ + +/// Minimum radius for graph nodes (px) - starting size for all nodes +pub const NODE_RADIUS_MIN: f32 = 15.0; + +/// Maximum radius for graph nodes (px) - cap to prevent oversized nodes +pub const NODE_RADIUS_MAX: f32 = 25.0; + +/// Radius increase per flow (px) - controls how fast nodes grow with traffic +pub const NODE_FLOW_SCALE_FACTOR: f32 = 0.3; + +// ============================================================================ +// VISUALIZATION: EDGE SIZING +// ============================================================================ + +/// Minimum width for graph edges (px) - inactive edges are invisible +pub const EDGE_WIDTH_MIN: f32 = 0.0; + +/// Maximum width for graph edges (px) - cap to prevent oversized edges +pub const EDGE_WIDTH_MAX: f32 = 3.0; + +/// Width increase per flow (px) - controls how fast edges grow with traffic +pub const EDGE_FLOW_SCALE: f32 = 0.2; + +/// Arrow size at edge endpoints (px) - indicates direction of traffic +pub const EDGE_ARROW_SIZE: f32 = 16.0; + +/// Arrow angle in radians (30 degrees) - PI/6 +pub const EDGE_ARROW_ANGLE_RAD: f32 = std::f32::consts::PI / 6.0; + +// ============================================================================ +// VISUALIZATION: PLAYBACK SPEED +// ============================================================================ + +/// Available playback speed steps for visualization +pub const PLAYBACK_SPEED_STEPS: &[f32] = &[0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0]; + +/// Epsilon for comparing playback speed values (to handle floating point precision) +pub const PLAYBACK_SPEED_EPSILON: f32 = 0.01; + +// ============================================================================ +// VISUALIZATION: LAYOUT & DISPLAY +// ============================================================================ + +/// Graph layout radius multiplier (scales with node count) +pub const GRAPH_LAYOUT_RADIUS_MULTIPLIER: f32 = 80.0; + +/// Graph layout radius base offset +pub const GRAPH_LAYOUT_RADIUS_BASE: f32 = 100.0; + +/// Fit to screen padding (fraction of screen) +pub const FIT_TO_SCREEN_PADDING: f32 = 0.15; + +/// Base timeout for active links display (ms), adjusted by speed. +/// Controls how long links stay in active state. +pub const ACTIVE_LINK_BASE_TIMEOUT_MS: f32 = 500.0; + +/// Overlay margin from screen edges (px) +pub const OVERLAY_MARGIN: f32 = 4.0; + +/// Legend icon size (px) - for node/edge legend icons +pub const LEGEND_ICON_SIZE: f32 = 20.0; + +/// Legend marker radius (px) - for edge protocol color dots +pub const LEGEND_MARKER_RADIUS: f32 = 6.0; + +// ============================================================================ +// TIMING (frames) +// ============================================================================ + +/// Quick delay for UI state transitions (frames) +pub const DELAY_FRAMES_QUICK: u8 = 2; + +/// Normal delay for UI state transitions (frames) +pub const DELAY_FRAMES_NORMAL: u8 = 10; + +// ============================================================================ +// INFO ICON & TOOLTIPS +// ============================================================================ + +/// Info icon spacing adjustment (negative to pull closer) +pub const INFO_ICON_SPACING: f32 = -4.0; + +/// Info icon size +pub const INFO_ICON_SIZE: f32 = 14.0; + +/// Info tooltip maximum width (px) +pub const INFO_TOOLTIP_MAX_WIDTH: f32 = 300.0; + +// ============================================================================ +// TEXT EDITOR +// ============================================================================ + +/// Default number of rows for multiline text editors +pub const TEXT_EDIT_DEFAULT_ROWS: usize = 3; + +/// Number of rows for YAML editor +pub const YAML_EDITOR_ROWS: usize = 20; + +/// Icon column width in YAML editor +pub const YAML_ICON_COL_WIDTH: f32 = 20.0; + +/// Gutter padding in YAML editor +pub const YAML_GUTTER_PADDING: f32 = 6.0; + +// ============================================================================ +// TOGGLE COMPONENT +// ============================================================================ + +/// Toggle frame inner margin +pub const TOGGLE_INNER_MARGIN: f32 = 3.0; + +/// Toggle item spacing +pub const TOGGLE_ITEM_SPACING: f32 = 3.0; + +// ============================================================================ +// INDENTATION +// ============================================================================ + +/// Standard indentation for nested content (px) +pub const INDENT_STANDARD: f32 = 16.0; diff --git a/crates/fosr-gui/src/shared/file_io.rs b/crates/fosr-gui/src/shared/file_io.rs index ac470a85..878d68b5 100644 --- a/crates/fosr-gui/src/shared/file_io.rs +++ b/crates/fosr-gui/src/shared/file_io.rs @@ -1,34 +1,42 @@ +//! Cross-platform file I/O: desktop (native) and WASM (async) implementations. + use rfd::FileHandle; use std::io::Error; +/// Opens a native file picker dialog for selecting a YAML configuration file (desktop only). #[cfg(not(target_arch = "wasm32"))] pub fn show_file_picker_desktop() -> Option { rfd::FileDialog::new() - .add_filter("Configuration files", &["json", "yaml", "yml"]) + .add_filter("YAML Configuration files", &["yaml", "yml"]) .set_directory(std::env::current_dir().unwrap_or(std::path::PathBuf::from("/"))) .pick_file() .map(|path| FileHandle::from(path)) } +/// Opens an async file picker dialog for selecting a YAML configuration file (WASM only). #[cfg(target_arch = "wasm32")] pub async fn show_file_picker_wasm() -> Option { rfd::AsyncFileDialog::new() - .add_filter("Configuration files", &["json", "yaml", "yml"]) + .add_filter("YAML Configuration files", &["yaml", "yml"]) .pick_file() .await } +/// Reads file content synchronously from the given file handle (desktop only). #[cfg(not(target_arch = "wasm32"))] -pub fn read_file_desktop(file_handle: &FileHandle) -> String { - std::fs::read_to_string(file_handle.path()).unwrap() +pub fn read_file_desktop(file_handle: &FileHandle) -> Result { + std::fs::read_to_string(file_handle.path()) + .map_err(|e| format!("Failed to read file: {}", e)) } +/// Reads file content asynchronously from the given file handle (WASM only). #[cfg(target_arch = "wasm32")] -pub async fn read_file_wasm(file_handle: &FileHandle) -> String { +pub async fn read_file_wasm(file_handle: &FileHandle) -> Result { let content = file_handle.read().await; - String::from_utf8(content).expect("Invalid UTF-8") + String::from_utf8(content).map_err(|e| format!("Invalid UTF-8 in file: {}", e)) } +/// Opens a save dialog and writes data to the selected file (desktop only). #[cfg(not(target_arch = "wasm32"))] pub fn save_file_desktop(data: &[u8], file_name: &str) -> Result { let result = rfd::FileDialog::new() @@ -46,6 +54,7 @@ pub fn save_file_desktop(data: &[u8], file_name: &str) -> Result Result { let result = rfd::AsyncFileDialog::new() diff --git a/crates/fosr-gui/src/shared/mod.rs b/crates/fosr-gui/src/shared/mod.rs index 65b099e1..a27dac01 100644 --- a/crates/fosr-gui/src/shared/mod.rs +++ b/crates/fosr-gui/src/shared/mod.rs @@ -1,2 +1,7 @@ -pub mod configuration_file; +//! Shared utilities: config model, file I/O, and reusable UI components. + +pub mod assets; +pub mod config; +pub mod constants; pub mod file_io; +pub mod widgets; diff --git a/crates/fosr-gui/src/shared/widgets/helpers.rs b/crates/fosr-gui/src/shared/widgets/helpers.rs new file mode 100644 index 00000000..0b86ac8e --- /dev/null +++ b/crates/fosr-gui/src/shared/widgets/helpers.rs @@ -0,0 +1,171 @@ +//! Reusable UI widgets: info icons, optional string editors, toggles, labels. + +use crate::shared::constants::colors::{COLOR_ERROR, COLOR_TEXT_MUTED}; +use crate::shared::constants::ui::{ + INFO_ICON_SIZE, INFO_ICON_SPACING, INFO_TOOLTIP_MAX_WIDTH, TOGGLE_INNER_MARGIN, + TOGGLE_ITEM_SPACING, +}; +use eframe::egui::{self, TextFormat, text::LayoutJob}; + +/// Display a small info icon with a tooltip. +pub fn info_icon_with_tooltip(ui: &mut egui::Ui, tooltip: &str) { + ui.add_space(INFO_ICON_SPACING); + ui.label( + egui::RichText::new("ℹ") + .color(COLOR_TEXT_MUTED) + .size(INFO_ICON_SIZE), + ) + .on_hover_cursor(egui::CursorIcon::Help) + .on_hover_ui(|ui| { + ui.set_max_width(INFO_TOOLTIP_MAX_WIDTH); + ui.label(tooltip); + }); +} + +/// Displays a single-line editor for an `Option`. +/// +/// - 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`. +/// +/// This prevents exporting empty strings in YAML. +pub fn render_optional_string_input( + 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; + } + }); +} + +/// Segmented toggle button between two options. +/// Displays two buttons side by side in a grouped frame. +/// Rendering order follows the parent layout direction. +pub fn labeled_toggle( + ui: &mut egui::Ui, + is_first_selected: &mut bool, + first_label: &str, + second_label: &str, + tooltip_first: &str, + tooltip_second: &str, +) { + // Use a group frame with tight padding to auto-size around the content + let resp = egui::Frame::group(ui.style()) + .inner_margin(TOGGLE_INNER_MARGIN) + .show(ui, |ui| { + // Remove the hover stroke on selectable labels inside this toggle + ui.style_mut().visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE; + + ui.spacing_mut().item_spacing = egui::vec2(TOGGLE_ITEM_SPACING, 0.0); + ui.horizontal(|ui| { + let first = ui.selectable_label(*is_first_selected, first_label); + if first.clicked() { + *is_first_selected = true; + } + first.on_hover_text(tooltip_first); + + let second = ui.selectable_label(!*is_first_selected, second_label); + if second.clicked() { + *is_first_selected = false; + } + second.on_hover_text(tooltip_second); + }); + }); + // Avoid returning the response, as it's not used + let _ = resp; +} + +/// 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 render_optional_text_area( + 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); // preserve line breaks + } + } + + if ui + .button(egui_material_icons::icons::ICON_CLEAR) + .on_hover_text("Clear") + .clicked() + { + *value = None; + } +} + +// Helper for required label with red * +pub fn required_label(ui: &mut egui::Ui, text: &str) { + // A layout job is a way to build complex text with different formatting + let mut job = LayoutJob::default(); + + job.append( + text, + 0.0, + TextFormat { + color: ui.visuals().text_color(), + ..Default::default() + }, + ); + + job.append( + "*", + 0.0, + TextFormat { + color: COLOR_ERROR, + ..Default::default() + }, + ); + ui.label(job).on_hover_text("Mandatory"); +} diff --git a/crates/fosr-gui/src/shared/widgets/mod.rs b/crates/fosr-gui/src/shared/widgets/mod.rs new file mode 100644 index 00000000..9884cdca --- /dev/null +++ b/crates/fosr-gui/src/shared/widgets/mod.rs @@ -0,0 +1,5 @@ +//! Shared widgets: time picker, timezone picker, and UI helpers. + +pub mod helpers; +pub mod time_picker; +pub mod timezone_picker; diff --git a/crates/fosr-gui/src/shared/widgets/time_picker/button.rs b/crates/fosr-gui/src/shared/widgets/time_picker/button.rs new file mode 100644 index 00000000..c1243505 --- /dev/null +++ b/crates/fosr-gui/src/shared/widgets/time_picker/button.rs @@ -0,0 +1,144 @@ +//! Time picker button widget that toggles a popup on click. +//! Derived from egui_timepicker (MIT License). + +// This file is derived from egui_timepicker. +// Copyright (c) 2025 Tilen Pintarič +// Licensed under the MIT License (see mod.rs for full text). + +use chrono::NaiveTime; +use eframe::egui::{self, 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, +} + +/// Button widget that opens a time picker popup on click. +pub struct TimePickerButton<'a> { + selection: &'a mut NaiveTime, + id_salt: Option<&'a str>, + show_icon: bool, + format: String, + show_clock_face: bool, + use_12_hour_clock: bool, + show_seconds: bool, + use_drag_value: bool, +} + +impl<'a> TimePickerButton<'a> { + /// Creates a new time picker button bound to the given time selection. + pub fn new(selection: &'a mut NaiveTime) -> Self { + Self { + selection, + id_salt: None, + show_icon: true, + format: "%H:%M".to_string(), + show_clock_face: true, + use_12_hour_clock: false, + show_seconds: false, + use_drag_value: false, + } + } + + /// Sets whether to show seconds in the time picker. + pub fn show_seconds(mut self, show_seconds: bool) -> Self { + self.show_seconds = show_seconds; + self + } + + /// Sets whether to use drag value input style for time selection. + pub fn use_drag_value(mut self, use_drag_value: bool) -> Self { + self.use_drag_value = use_drag_value; + self + } +} + +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_clock_face: self.show_clock_face, + show_seconds: self.show_seconds, + use_12_hour_clock: self.use_12_hour_clock, + use_drag_value: self.use_drag_value, + } + .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/shared/widgets/time_picker/mod.rs b/crates/fosr-gui/src/shared/widgets/time_picker/mod.rs new file mode 100644 index 00000000..b7f030c3 --- /dev/null +++ b/crates/fosr-gui/src/shared/widgets/time_picker/mod.rs @@ -0,0 +1,35 @@ +//! Time picker widget (derived from egui_timepicker) with theme support. + +// Portions of this software are derived from egui_timepicker v0.1.0 (https://crates.io/crates/egui_timepicker). +// +// MIT License +// +// 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) + +pub mod button; +mod popup; diff --git a/crates/fosr-gui/src/shared/widgets/time_picker/popup.rs b/crates/fosr-gui/src/shared/widgets/time_picker/popup.rs new file mode 100644 index 00000000..c09d9e86 --- /dev/null +++ b/crates/fosr-gui/src/shared/widgets/time_picker/popup.rs @@ -0,0 +1,360 @@ +//! Time picker popup with clock face and drag-value inputs. +//! Derived from egui_timepicker (MIT License). + +// This file is derived from egui_timepicker. +// Copyright (c) 2025 Tilen Pintarič +// Licensed under the MIT License (see mod.rs for full text). + +use std::fmt::Display; +use chrono::{NaiveTime, Timelike}; +use eframe::egui::{ + self, 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_clock_face: bool, + pub use_12_hour_clock: bool, + pub show_seconds: bool, + pub use_drag_value: 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_drag_value { + 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_drag_value { + let r = ui.add(DragValue::new(&mut popup_state.minute).range(0..=59)); + minute_rect = Some(r.rect); + if r.clicked() || r.changed() { + 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_drag_value { + let r = ui.add(DragValue::new(&mut popup_state.second).range(0..=59)); + second_rect = Some(r.rect); + if r.clicked() || r.changed() { + 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_clock_face { + 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/shared/widgets/timezone_picker.rs b/crates/fosr-gui/src/shared/widgets/timezone_picker.rs new file mode 100644 index 00000000..9cf341bd --- /dev/null +++ b/crates/fosr-gui/src/shared/widgets/timezone_picker.rs @@ -0,0 +1,141 @@ +//! Timezone picker widget with search functionality. + +// 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 crate::shared::constants::ui::{ + TIMEZONE_LIST_MAX_HEIGHT, TIMEZONE_PICKER_WIDTH, TIMEZONE_POPUP_MAX_HEIGHT, +}; +use chrono_tz::TZ_VARIANTS; +use eframe::egui::{self, epaint}; + +/// Timezone picker with search functionality. +pub fn timezone_picker(ui: &mut egui::Ui, timezone_input: &mut String) { + let popup_id = ui.make_persistent_id("tz_popup"); + let is_open = egui::Popup::is_id_open(ui.ctx(), popup_id); + + // --- ComboBox-style button (inspired by egui's combo_box.rs button_frame) --- + 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(TIMEZONE_PICKER_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); + + // 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, + )); + + // Selected text on the left + let text_rect = inner.with_max_x(icon_rect.left() - icon_spacing); + let galley = ui.painter().layout_no_wrap( + 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(&*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(TIMEZONE_POPUP_MAX_HEIGHT); + + // Search input with autofocus on open + let edit_id = ui.make_persistent_id("tz_search"); + ui.add( + egui::TextEdit::singleline(timezone_input) + .hint_text("Search...") + .id(edit_id), + ); + if ui.memory(|m| m.focused().is_none()) { + ui.memory_mut(|m| m.request_focus(edit_id)); + } + + ui.separator(); + + // Filtered timezone list + egui::ScrollArea::vertical() + .max_height(TIMEZONE_LIST_MAX_HEIGHT) + .show(ui, |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + let filter = timezone_input.to_lowercase(); + for tz in TZ_VARIANTS { + let tz_str = tz.to_string(); + if filter.is_empty() || tz_str.to_lowercase().contains(&filter) { + // Display the timezone as a clickable option that updates the input + if ui + .selectable_label(*timezone_input == tz_str, &tz_str) + .clicked() + { + *timezone_input = tz_str; + // Close the first closable parent, which is the popup + ui.close(); + } + } + } + }); + }); +} diff --git a/crates/fosr-gui/src/visualization/mod.rs b/crates/fosr-gui/src/visualization/mod.rs deleted file mode 100644 index e2f33c50..00000000 --- a/crates/fosr-gui/src/visualization/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod visualization_tab; \ No newline at end of file diff --git a/crates/fosr-gui/src/visualization/visualization_tab.rs b/crates/fosr-gui/src/visualization/visualization_tab.rs deleted file mode 100644 index 0cd3c8c5..00000000 --- a/crates/fosr-gui/src/visualization/visualization_tab.rs +++ /dev/null @@ -1,19 +0,0 @@ -use eframe::egui; - -/** - * Represents the state of the visualization tab. - */ -pub struct VisualizationTabState {} - -impl Default for VisualizationTabState { - fn default() -> Self { - Self {} - } -} - -pub fn show_visualization_tab_content( - ui: &mut egui::Ui, - _visualization_tab_state: &mut VisualizationTabState, -) { - ui.heading("COMING SOON"); -} diff --git a/crates/fosr-lib/src/config.rs b/crates/fosr-lib/src/config.rs index cade66b1..7c7edf29 100644 --- a/crates/fosr-lib/src/config.rs +++ b/crates/fosr-lib/src/config.rs @@ -5,6 +5,7 @@ use serde::Deserialize; use std::collections::HashMap; use std::collections::HashSet; use std::net::Ipv4Addr; +use rand::prelude::*; #[derive(Debug)] /// The configuration file of the network and the hosts @@ -12,14 +13,16 @@ pub struct Configuration { // TODO: faire du tri dans ce qui n’est pas utile /// The metadata of the configuration pub metadata: Metadata, - /// The list of hosts - pub hosts: Vec, + // /// The list of hosts + // pub hosts: Vec, + /// The list of networks + pub networks: Vec, /// A hashmap that maps an IP to a MAC address (if it is defined in the config file) - pub mac_addr_map: HashMap, + // pub mac_addr_map: HashMap, /// A hashmap that maps an IP to an OS (if it is defined in the config file) pub os_map: HashMap, - /// The usages of each IP address - pub usages_map: HashMap, + // /// The usages of each IP address + // pub usages_map: HashMap, /// The list of "users" IPs pub users: Vec, /// The list of "servers" IPs @@ -36,14 +39,25 @@ pub struct Configuration { #[serde(deny_unknown_fields)] struct ConfigurationYaml { pub metadata: Metadata, - pub hosts: Vec, + pub networks: Vec, + pub internet: Vec +} + +#[derive(Deserialize, Debug)] +pub struct Network { + pub subnet: Ipv4Addr, + pub mask: u8, + pub name: String, + pub hosts: Vec } impl From for Configuration { fn from(c: ConfigurationYaml) -> Self { let users: Vec = c - .hosts + .networks .iter() + .map(|n| &n.hosts) + .flatten() .filter_map(|h| match h.host_type { HostType::User => Some(h.get_ip_addr()), HostType::Server => None, @@ -51,8 +65,10 @@ impl From for Configuration { .flatten() .collect(); let servers: Vec = c - .hosts + .networks .iter() + .map(|n| &n.hosts) + .flatten() .filter_map(|h| match h.host_type { HostType::Server => Some(h.get_ip_addr()), HostType::User => None, @@ -60,24 +76,34 @@ impl From for Configuration { .flatten() .collect(); let mut os_map: HashMap = HashMap::new(); - let mut usages_map: HashMap = HashMap::new(); - for host in c.hosts.iter() { + // let mut usages_map: HashMap = HashMap::new(); + for network in c.networks.iter() { + for host in network.hosts.iter() { + for interface in host.interfaces.iter() { + os_map.insert(interface.ip_addr, host.os); + // usages_map.insert(interface.ip_addr, host.usage); + } + } + } + for host in c.internet.iter() { for interface in host.interfaces.iter() { os_map.insert(interface.ip_addr, host.os); - usages_map.insert(interface.ip_addr, host.usage); + // usages_map.insert(interface.ip_addr, host.usage); } } - let mut mac_addr_map: HashMap = HashMap::new(); + + // let mut mac_addr_map: HashMap = HashMap::new(); let mut services: HashSet = HashSet::new(); let mut servers_per_service: HashMap> = HashMap::new(); let mut users_per_service: HashMap> = HashMap::new(); let mut open_ports: HashMap<(Ipv4Addr, L7Proto), u16> = HashMap::new(); - for interface in c.hosts.iter().flat_map(|h| &h.interfaces) { - if let Some(mac_addr) = interface.mac_addr { - mac_addr_map.insert(interface.ip_addr, mac_addr); - } + let all_hosts = c.internet.iter().chain(c.networks.iter().map(|n| n.hosts.iter()).flatten()); + for interface in all_hosts.flat_map(|h| &h.interfaces) { + // if let Some(mac_addr) = interface.mac_addr { + // mac_addr_map.insert(interface.ip_addr, mac_addr); + // } for k in interface.open_ports.keys() { open_ports.insert( (interface.ip_addr, *k), @@ -90,31 +116,35 @@ impl From for Configuration { v.push(interface.ip_addr); } } - for host in c.hosts.iter() { - if let Some(client) = &host.client { - // if a list is defined, then this host will only use these services - for s in client { - if services.contains(s) { + + let all_hosts = c.internet.iter().chain(c.networks.iter().map(|n| n.hosts.iter()).flatten()); + for host in all_hosts { + for i in &host.interfaces { + if let Some(client) = &i.uses { + // if a list is defined, then this host will only use these services + for s in client { + if services.contains(s) { + for interface in host.interfaces.iter() { + users_per_service + .entry(*s) + .or_default() + .push(interface.ip_addr) + } + } else { + log::warn!( + "There is a client of {s:?}, but that service is not proposed by any server" + ); + } + } + } else { + // otherwise, use all available services + for s in services.iter() { for interface in host.interfaces.iter() { users_per_service .entry(*s) .or_default() .push(interface.ip_addr) } - } else { - log::warn!( - "There is a client of {s:?}, but that service is not proposed by any server" - ); - } - } - } else { - // otherwise, use all available services - for s in services.iter() { - for interface in host.interfaces.iter() { - users_per_service - .entry(*s) - .or_default() - .push(interface.ip_addr) } } } @@ -125,12 +155,17 @@ impl From for Configuration { assert!(users_per_service.contains_key(service)); } + // let hosts = c.internet.into_iter().chain(c.networks.into_iter().map(|n| n.hosts.into_iter()).flatten()).collect(); + + let mut networks = c.networks; + networks.push(Network { subnet: Ipv4Addr::new(0,0,0,0), mask: 0, name: "Internet".to_string(), hosts: c.internet }); + Configuration { metadata: c.metadata, - hosts: c.hosts, + networks, os_map, - usages_map, - mac_addr_map, + // usages_map, + // mac_addr_map, users, servers, services: services.into_iter().collect(), @@ -142,6 +177,11 @@ impl From for Configuration { } impl Configuration { + pub fn get_hosts(&self) -> Vec<&Host> { + let out: Vec<&Host> = self.networks.iter().map(|n| n.hosts.iter()).flatten().collect(); + out + } + /// Get the list of servers that provide a service pub fn get_servers_per_service(&self, service: &L7Proto) -> Vec { self.servers_per_service @@ -177,9 +217,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, } @@ -193,12 +233,13 @@ pub struct Host { pub hostname: Option, /// Its OS pub os: OS, - /// Its usage. 1 is standard, less than 1 is less usage than standard, more than 1 is more usage than standrad - pub usage: f64, - client: Option>, // we keep the option here, because there is a difference + // /// Its usage. 1 is standard, less than 1 is less usage than standard, more than 1 is more usage than standrad + // pub usage: f64, + // client: Option>, // we keep the option here, because there is a difference // between an empty list (no service is used) and nothing // (default services are used) - host_type: HostType, + /// The type of host (server or user) + pub host_type: HostType, /// Its interfaces pub interfaces: Vec, } @@ -215,8 +256,8 @@ impl Host { struct HostYaml { hostname: Option, os: Option, - usage: Option, - client: Option>, + // usage: Option, + // client: Option>, #[serde(rename = "type")] host_type: Option, interfaces: Vec, @@ -235,10 +276,10 @@ impl From for Host { Host { hostname: h.hostname, os: h.os.unwrap_or(OS::Linux), - usage: h.usage.unwrap_or(1.0), + // usage: h.usage.unwrap_or(1.0), host_type, interfaces: h.interfaces, - client: h.client, + // client: h.client, } } } @@ -256,6 +297,8 @@ pub struct Interface { pub ip_addr: Ipv4Addr, /// The open ports of services, if they are not the default one pub open_ports: HashMap, + /// The services is uses (may be empty) + pub uses: Option>, } #[derive(Deserialize, Debug)] @@ -263,6 +306,7 @@ pub struct Interface { struct InterfaceYaml { mac_addr: Option, services: Option>, + uses: Option>, ip_addr: String, } @@ -284,12 +328,32 @@ impl TryFrom for Interface { } services.push(service); } + let uses = match i.uses { + None => None, + Some(l) => { + let mut uses = vec![]; + for s in l { + let v: Vec = s.as_str().split(':').map(|s| s.to_string()).collect(); + assert!(!v.is_empty() && v.len() <= 2); + let service: L7Proto = v[0].clone().try_into()?; + uses.push(service); + } + Some(uses) + } + }; + let mut rng = rand::rng(); + let ip_addr = match i.ip_addr.as_str() { + "auto" => Ipv4Addr::new(0,0,0,0), + "internet" => Ipv4Addr::new(rng.random::(),rng.random::(),rng.random::(),rng.random::()), // TODO: ne pas générer complètement au hasard pour éviter les collisions et permettre d’utiliser une seed + _ => i.ip_addr.parse().expect("Cannot parse IP address") + }; Ok(Interface { + uses, mac_addr: i .mac_addr .map(|s| s.parse().expect("Cannot parse MAC address")), - ip_addr: i.ip_addr.parse().expect("Cannot parse IP address"), + ip_addr, services, open_ports, }) @@ -307,114 +371,114 @@ pub fn import_config(config_string: &str) -> Configuration { config } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_config_simple() { - let config = import_config( - r#" -metadata: - title: Sample configuration -hosts: - - interfaces: - - services: - - https - - ssh - ip_addr: 192.168.0.8 - - interfaces: - - ip_addr: 192.168.0.9 -"#, - ); - // TODO tester la config chargée - } +// #[cfg(test)] +// mod tests { +// use super::*; - #[test] - fn test_config_complex() { - let config = import_config( - r#" -metadata: - title: Sample configuration # Mandatory. The title of the configuration file. - desc: A sample configuration file to show all the different available fields # Optional. A description of the configuration file. - author: Jane Doe # Optional. Author of the file. - date: 2025/11/05 # Optional. Last modification date. - version: 0.1.0 # Optional. The version number of this configuration file. Format is free. - format: 1 # Reserved for now. The version will be bumped when the format changes. - -hosts: - - hostname: host1 # Optional. The hostname of the host. - os: Linux # Optional (default value: Linux). The OS of the host - usage: 0.8 # Optional (default value: 1.0). The usage intensity of the host. 1 is the baseline, < 1 means less usage than usual, and > 1 means higher usage - type: server # Optional (default value: "server" if there is at least one service, "user" otherwise). Whether this host is used by a user and is a server. Can be either "server" or "user" - client: # Optional (default value: all available services if type is "user", none otherwise). Specify what services the host is a client of. - - http - - https - - ssh - interfaces: - - mac_addr: 00:14:2A:3F:47:D8 # Optional. The MAC address of that interface - services: # Optional (default value: empty list). The list of available services - - http # an HTTP server - - https # an HTTPS server - - ssh # an SSH server - ip_addr: 192.168.0.8 # Mandatory. The IP address of this interface. - - ip_addr: 192.168.0.9 # This host has another interface that does not provide any service - - interfaces: - - ip_addr: 192.168.0.11 # Another host with a single interface -"#, - ); - println!("{config:?}"); - } +// #[test] +// fn test_config_simple() { +// let config = import_config( +// r#" +// metadata: +// title: Sample configuration +// hosts: +// - interfaces: +// - services: +// - https +// - ssh +// ip_addr: 192.168.0.8 +// - interfaces: +// - ip_addr: 192.168.0.9 +// "#, +// ); +// // TODO tester la config chargée +// } - #[test] - fn test_config_json() { - let config = import_config( - r#" -{ - "metadata": { - "title": "Sample JSON configuration", - "desc": "A sample configuration file to show all the different available fields", - "author": "Jane Doe", - "date": "2025/11/05", - "version": "0.1.0", - "format": 1 - }, - "hosts": [ - { - "hostname": "host1", - "os": "Linux", - "usage": 0.8, - "type": "server", - "client": [ - "http", - "https", - "ssh" - ], - "interfaces": [ - { - "mac_addr": "00:14:2A:3F:47:D8", - "services": [ - "http", - "https", - "ssh" - ], - "ip_addr": "192.168.0.8" - }, - { - "ip_addr": "192.168.0.9" - } - ] - }, - { - "interfaces": [ - { - "ip_addr": "192.168.0.11" - } - ] - } - ] -}"#, - ); - println!("{config:?}"); - } -} +// #[test] +// fn test_config_complex() { +// let config = import_config( +// r#" +// metadata: +// title: Sample configuration # Mandatory. The title of the configuration file. +// desc: A sample configuration file to show all the different available fields # Optional. A description of the configuration file. +// author: Jane Doe # Optional. Author of the file. +// date: 2025/11/05 # Optional. Last modification date. +// version: 0.1.0 # Optional. The version number of this configuration file. Format is free. +// format: 1 # Reserved for now. The version will be bumped when the format changes. + +// hosts: +// - hostname: host1 # Optional. The hostname of the host. +// os: Linux # Optional (default value: Linux). The OS of the host +// usage: 0.8 # Optional (default value: 1.0). The usage intensity of the host. 1 is the baseline, < 1 means less usage than usual, and > 1 means higher usage +// type: server # Optional (default value: "server" if there is at least one service, "user" otherwise). Whether this host is used by a user and is a server. Can be either "server" or "user" +// client: # Optional (default value: all available services if type is "user", none otherwise). Specify what services the host is a client of. +// - http +// - https +// - ssh +// interfaces: +// - mac_addr: 00:14:2A:3F:47:D8 # Optional. The MAC address of that interface +// services: # Optional (default value: empty list). The list of available services +// - http # an HTTP server +// - https # an HTTPS server +// - ssh # an SSH server +// ip_addr: 192.168.0.8 # Mandatory. The IP address of this interface. +// - ip_addr: 192.168.0.9 # This host has another interface that does not provide any service +// - interfaces: +// - ip_addr: 192.168.0.11 # Another host with a single interface +// "#, +// ); +// println!("{config:?}"); +// } + +// #[test] +// fn test_config_json() { +// let config = import_config( +// r#" +// { +// "metadata": { +// "title": "Sample JSON configuration", +// "desc": "A sample configuration file to show all the different available fields", +// "author": "Jane Doe", +// "date": "2025/11/05", +// "version": "0.1.0", +// "format": 1 +// }, +// "hosts": [ +// { +// "hostname": "host1", +// "os": "Linux", +// "usage": 0.8, +// "type": "server", +// "client": [ +// "http", +// "https", +// "ssh" +// ], +// "interfaces": [ +// { +// "mac_addr": "00:14:2A:3F:47:D8", +// "services": [ +// "http", +// "https", +// "ssh" +// ], +// "ip_addr": "192.168.0.8" +// }, +// { +// "ip_addr": "192.168.0.9" +// } +// ] +// }, +// { +// "interfaces": [ +// { +// "ip_addr": "192.168.0.11" +// } +// ] +// } +// ] +// }"#, +// ); +// println!("{config:?}"); +// } +// } diff --git a/crates/fosr-lib/src/stage1/bayesian_networks.rs b/crates/fosr-lib/src/stage1/bayesian_networks.rs index ddc9cc92..fa529138 100644 --- a/crates/fosr-lib/src/stage1/bayesian_networks.rs +++ b/crates/fosr-lib/src/stage1/bayesian_networks.rs @@ -568,8 +568,9 @@ impl BayesianModel { .into_iter() .map(|ip| { if proto_users.contains(&ip) { + 1f64 // this IP can be sampled - *config.usages_map.get(&ip).unwrap() + // *config.usages_map.get(&ip).unwrap() } else { // this IP cannot be sampled 0.0f64 @@ -586,15 +587,16 @@ impl BayesianModel { .into_iter() .map(|ip| { if proto_servers.contains(&ip) { + 1f64 // this IP can be sampled - *config.usages_map.get(&ip).unwrap() + // *config.usages_map.get(&ip).unwrap() } else { // this IP cannot be sampled 0.0f64 } }) .chain(iter::once(0.0f64)); // no internet - cpt.push(Some(WeightedIndex::new(proba).expect("Cannot create the probability distribution of SrcIp for {p} and {role}"))); + cpt.push(Some(WeightedIndex::new(proba).expect(&format!("Cannot create the probability distribution of SrcIp for {p:?} and {role:?}")))); } IpRole::Internet => { let mut proba: Vec = vec![]; @@ -644,7 +646,8 @@ impl BayesianModel { .map(|ip| { if proto_users.contains(&ip) { // this IP can be sampled - *config.usages_map.get(&ip).unwrap() + 1f64 + // *config.usages_map.get(&ip).unwrap() } else { // this IP cannot be sampled 0.0f64 @@ -661,8 +664,9 @@ impl BayesianModel { .into_iter() .map(|ip| { if proto_servers.contains(&ip) { + 1f64 // this IP can be sampled - *config.usages_map.get(&ip).unwrap() + // *config.usages_map.get(&ip).unwrap() } else { // this IP cannot be sampled 0.0f64 diff --git a/public/generate-index-html.sh b/public/generate-index-html.sh new file mode 100755 index 00000000..00754df1 --- /dev/null +++ b/public/generate-index-html.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Generate index.html from markdown sources using pandoc +# Note: This is a simplified version without the dynamic help output from the fosr binary. +# For the full version with help text, see the pages job in .gitlab-ci.yml. + +set -e + +# Check for pandoc +if ! command -v pandoc &> /dev/null; then + echo "Error: pandoc is not installed." + exit 1 +fi + +cd "$(dirname "$0")" + +pandoc intro.md compile.md config_doc.md other_software.md schema.md lib.md roadmap.md usecases.md analysis.md limitations.md refs.md \ + -o index.html \ + --template template.html \ + --include-after-body footer.html \ + --standalone \ + --toc \ + --toc-depth 1 + +echo "Generated public/index.html" diff --git a/public/styles.css b/public/styles.css index cb5a7f97..34e757e5 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: 800px; background: var(--bs-body-bg, #ffffff); margin: 15px auto; border-radius: 0.5rem; diff --git a/sample_config.yaml b/sample_config.yaml new file mode 100644 index 00000000..facb0397 --- /dev/null +++ b/sample_config.yaml @@ -0,0 +1,44 @@ +metadata: + title: Sample configuration # Mandatory. The title of the configuration file. + desc: A sample configuration file to show all the different available fields # Optional. A description of the configuration file. + author: Jane Doe # Optional. Author of the file. + date: 2025/11/05 # Optional. Last modification date. + version: 0.1.0 # Optional. The version number of this configuration file. Format is free. + format: 1 # Reserved for now. The version will be bumped when the format changes. + +networks: + - subnet: 192.168.0.0 # Mandatory. The subnet of that network. + mask: 24 # Mandatory. The mask of the subnet. + name: "Subnet1" # Optional. The name of the subnet. + hosts: + - hostname: host1 # Optional. The hostname of the host. + os: Linux # Optional (default value: Linux). The OS of the host. + # usage: 0.8 # Optional (default value: 1.0). The usage intensity of the host. 1 is the baseline, < 1 means less usage than usual, and > 1 means higher usage + type: server # Optional (default value: "server" if there is at least one service, "user" otherwise). Whether this host is used by a user and is a server. Can be either "server" or "user". + interfaces: + - mac_addr: 00:14:2A:3F:47:D8 # Optional. The MAC address of that interface. + services: # Optional (default value: empty list). The list of available services. + - https:8080 # an HTTPS server. + - ssh # an SSH server. + - dns # a DNS server. + ip_addr: 192.168.0.8 # Mandatory. The IP address of this interface. Can be "auto" for automatic attribution. + - interfaces: + - ip_addr: auto # This host has an interface in the 192.168.0.0/24 subnet that does not provide any service. + - ip_addr: 192.168.1.0 # It also has an interface in another subnet (without any service) + uses: # Optional (default value: all available services if type is "user", none otherwise). Specify what services the host is a client of. The protocols must be written in lowercase. + - https + - ssh + - subnet: 192.168.1.0 # A second subnet. + mask: 24 + name: "Subnet2" + hosts: + - interfaces: + - ip_addr: auto # This machine has an IP in the 192.168.1.0/24 subnet. + +internet: # An optional description of Internet services + - hostname: "Google DNS" # Internet hosts follow the same structure as local hosts + type: server + interfaces: + - ip_addr: 8.8.8.8 + services: + - dns