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