diff --git a/Cargo.lock b/Cargo.lock index 74d8a77..ab34c08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -495,12 +495,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.20.0" @@ -663,6 +657,17 @@ dependencies = [ "nom", ] +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -833,15 +838,14 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crux_core" -version = "0.16.1" +version = "0.17.0-rc2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64410ab92e0d0ca634a249a75a3cfe8da977a7f22b5477dd2ed25c290645149" +checksum = "1f7dcabcd8aad70780470a8fbc05ebe29ff3a2a836e613f050d64bb97650942c" dependencies = [ "anyhow", "bincode", "crossbeam-channel", "crux_macros", - "erased-serde", "facet", "futures", "serde", @@ -854,9 +858,9 @@ dependencies = [ [[package]] name = "crux_http" -version = "0.15.0" +version = "0.16.0-rc2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc20dc1c5d0e9722a1586f48d37e41953375d10f960f33f42fbc9ab49ee60ff9" +checksum = "56117df66c95e6f1f2b89f44e5871bb15d5acba435343108fb7bd100b295ce59" dependencies = [ "anyhow", "async-trait", @@ -870,7 +874,7 @@ dependencies = [ "serde", "serde_bytes", "serde_json", - "serde_qs 0.15.0", + "serde_qs", "thiserror 2.0.17", "url", "web-sys", @@ -878,11 +882,11 @@ dependencies = [ [[package]] name = "crux_macros" -version = "0.7.0" +version = "0.8.0-rc2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceee550b136afd8b746e5fd91167ce9b9142661f283c86f423d0fd170f5addb6" +checksum = "c18ba1052a1f28c97e3595e5a64496921953b3945cf7537e0544f93b36735e74" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "heck 0.5.0", "proc-macro-error", "proc-macro2", @@ -940,12 +944,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -964,11 +968,10 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", @@ -989,11 +992,11 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.21.3", + "darling_core 0.23.0", "quote", "syn 2.0.110", ] @@ -1212,17 +1215,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "erased-serde" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - [[package]] name = "errno" version = "0.3.14" @@ -1241,9 +1233,9 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "facet" -version = "0.28.3" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa721ef7c3d28dad0a82f12b58ed06f8d55ded9fff60cca2bdd26eea8ef0b23e" +checksum = "4d643309d7c46d073b6c51e29901ed9bc3d858dc09aa21f5b32b424491e1bbef" dependencies = [ "facet-core", "facet-macros", @@ -1252,9 +1244,9 @@ dependencies = [ [[package]] name = "facet-core" -version = "0.28.3" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d53f437ef568ab97c56f53561b21facc5999fe97eb4b4360c932a110d00a2a9" +checksum = "ac27076d75388bffb6c5e5118078dcbb46f2090b35256c45372ff1c1910b1aaf" dependencies = [ "bitflags", "impls", @@ -1262,9 +1254,9 @@ dependencies = [ [[package]] name = "facet-macros" -version = "0.28.3" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50ab132d5eb441268c9a838aafa4fd0f98ed7250af72b83ae5f8bcaffe03252" +checksum = "391fcf43b68a76ba4a494710629a7b6fb430345a5623a6a87f7c263bfadb1643" dependencies = [ "facet-core", "facet-macros-emit", @@ -1272,9 +1264,9 @@ dependencies = [ [[package]] name = "facet-macros-emit" -version = "0.28.3" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46b24923c18ba3e49a574839946c77dce37f67b3fc2d78d0aa7c342382bd7599" +checksum = "e81e536ccafa7d5ef71822c9a67a28a0b29fe0170eaf2d134c36854a3d56ee9a" dependencies = [ "facet-macros-parse", "quote", @@ -1282,24 +1274,15 @@ dependencies = [ [[package]] name = "facet-macros-parse" -version = "0.28.3" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2919451ef356233d910b5c7b127721ea254062b0043a43d5643e0a750cfd9da9" +checksum = "8da5d4ded1874033bdef5000712d80d28feefe2abb48bb6f31b6fe59b7e9792f" dependencies = [ "proc-macro2", "quote", "unsynn", ] -[[package]] -name = "fastrand" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - [[package]] name = "fastrand" version = "2.3.0" @@ -1442,17 +1425,15 @@ checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" -version = "1.13.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand 1.9.0", + "fastrand", "futures-core", "futures-io", - "memchr", "parking", "pin-project-lite", - "waker-fn", ] [[package]] @@ -1496,15 +1477,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -1516,17 +1488,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.16" @@ -1536,7 +1497,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -1728,21 +1689,21 @@ checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" [[package]] name = "http-types-red-badger-temporary-fork" -version = "4.0.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10fe78c37484f9560171811e8f0ade5bdeddd0f53f6bba01cebd2275acf5fd88" +checksum = "3042c4259fc659f9fa8aea1d71c4ae4201c58a0adddd3406910aed0870c68382" dependencies = [ "anyhow", "async-channel", - "base64 0.13.1", + "base64 0.22.1", "facet", "futures-lite", "infer", "pin-project-lite", - "rand 0.7.3", + "rand 0.9.2", "serde", "serde_json", - "serde_qs 0.8.5", + "serde_qs", "serde_urlencoded", "url", ] @@ -1979,9 +1940,12 @@ dependencies = [ [[package]] name = "infer" -version = "0.2.3" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] [[package]] name = "inout" @@ -1992,15 +1956,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -2053,9 +2008,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -2244,7 +2199,7 @@ checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -2411,6 +2366,8 @@ dependencies = [ "console_log", "crux_core", "crux_http", + "crux_macros", + "getrandom 0.3.4", "lazy_static", "log", "serde", @@ -2774,9 +2731,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] @@ -2851,19 +2808,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", -] - [[package]] name = "rand" version = "0.8.5" @@ -2885,16 +2829,6 @@ dependencies = [ "rand_core 0.9.3", ] -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -2915,15 +2849,6 @@ dependencies = [ "rand_core 0.9.3", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -2942,15 +2867,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -3273,15 +3189,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -3293,17 +3209,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_qs" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" -dependencies = [ - "percent-encoding", - "serde", - "thiserror 1.0.69", -] - [[package]] name = "serde_qs" version = "0.15.0" @@ -3405,12 +3310,6 @@ dependencies = [ "digest", ] -[[package]] -name = "shadow_counted" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65da48d447333cebe1aadbdd3662f3ba56e76e67f53bc46f3dd5f67c74629d6b" - [[package]] name = "shared_types" version = "1.1.0" @@ -3613,7 +3512,7 @@ version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ - "fastrand 2.3.0", + "fastrand", "getrandom 0.3.4", "once_cell", "rustix", @@ -3886,12 +3785,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - [[package]] name = "typenum" version = "1.19.0" @@ -3940,14 +3833,13 @@ dependencies = [ [[package]] name = "unsynn" -version = "0.1.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7940603a9e25cf11211cc43b81f4fcad2b8ab4df291ca855f32c40e1ac22d5bc" +checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a" dependencies = [ - "fxhash", "mutants", "proc-macro2", - "shadow_counted", + "rustc-hash", ] [[package]] @@ -3958,14 +3850,15 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -3981,6 +3874,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -3995,12 +3890,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "waker-fn" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" - [[package]] name = "want" version = "0.3.1" @@ -4010,12 +3899,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4037,14 +3920,14 @@ version = "0.12.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1fbb4ef9bbca0c1170e0b00dd28abc9e3b68669821600cad1caaed606583c6d" dependencies = [ - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -4055,11 +3938,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -4068,9 +3952,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4078,9 +3962,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -4091,18 +3975,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -4425,3 +4309,9 @@ dependencies = [ "quote", "syn 2.0.110", ] + +[[package]] +name = "zmij" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" diff --git a/README.md b/README.md index 28ce502..a17311b 100644 --- a/README.md +++ b/README.md @@ -101,13 +101,16 @@ omnect-ui/ ├── src/ │ ├── backend/ # Rust backend (Actix-web) │ ├── app/ # Crux Core (business logic) +│ │ ├── src/ +│ │ │ ├── commands/ # Custom side-effect commands +│ │ │ └── ... │ ├── shared_types/ # TypeGen for TypeScript bindings │ └── ui/ # Vue 3 frontend ├── scripts/ # Build and development scripts │ └── build-frontend.sh # Build WASM + TypeScript types + UI +│ └── setup-centrifugo.sh # Download script for Centrifugo ├── tools/ # Development tools │ ├── centrifugo # WebSocket server binary (gitignored) -│ └── setup-centrifugo.sh # Download script for Centrifugo ├── Dockerfile # Multi-stage Docker build └── build-and-deploy-image.sh # Build and deployment script ``` diff --git a/scripts/run-e2e-tests.sh b/scripts/run-e2e-tests.sh index cae2680..0459b29 100755 --- a/scripts/run-e2e-tests.sh +++ b/scripts/run-e2e-tests.sh @@ -3,6 +3,10 @@ set -e # Internal script to run E2E tests inside the container +# Navigate to repository root (parent of scripts directory) +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + # Cleanup function to kill spawned processes cleanup() { echo "🧹 Cleaning up processes..." @@ -20,22 +24,14 @@ pkill -9 -f "bun run.*5173" 2>/dev/null || true pkill -9 -f "node.*vite.*5173" 2>/dev/null || true sleep 2 -# 1. Ensure bun is installed (needed for UI) +# 1. Ensure bun is installed (needed for building and running UI) if ! command -v bun &> /dev/null; then - echo "⚠️ Bun not found, installing..." - curl -fsSL https://bun.sh/install | bash - export BUN_INSTALL="$HOME/.bun" - export PATH="$BUN_INSTALL/bin:$PATH" + echo "❌ bun not found in PATH." + exit 1 fi -# 2. Ensure Centrifugo is available (using the tool script if needed) -if ! command -v centrifugo &> /dev/null; then - echo "⚠️ Centrifugo not found in PATH, checking tools directory..." - if [ ! -f "tools/centrifugo" ]; then - ./tools/setup-centrifugo.sh - fi - export PATH=$PATH:$(pwd)/tools -fi +# 2. Ensure Centrifugo is available +./scripts/setup-centrifugo.sh # 3. Start Centrifugo directly (Backend is mocked, but we need real WS) echo "🚀 Starting Centrifugo..." @@ -65,7 +61,7 @@ export CENTRIFUGO_CLIENT_TOKEN_HMAC_SECRET_KEY="secret" export CENTRIFUGO_HTTP_API_KEY="api_key" export CENTRIFUGO_LOG_LEVEL="info" -centrifugo -c "$CENTRIFUGO_CONFIG" > /tmp/centrifugo.log 2>&1 & +./tools/centrifugo -c "$CENTRIFUGO_CONFIG" > /tmp/centrifugo.log 2>&1 & CENTRIFUGO_PID=$! echo "⏳ Waiting for Centrifugo..." @@ -106,6 +102,12 @@ fi # Build the frontend for preview mode (eliminates Vite dev optimization issues) # Note: Using default base path (/) for preview server, not /static for production backend echo "🏗️ Building frontend..." +# Faster polling for E2E tests +export VITE_RECONNECTION_POLL_INTERVAL_MS=500 +export VITE_NEW_IP_POLL_INTERVAL_MS=500 +export VITE_REBOOT_TIMEOUT_MS=2000 +export VITE_FACTORY_RESET_TIMEOUT_MS=500 + if bun run build-preview > /tmp/vite-build.log 2>&1; then echo "✅ Frontend build complete!" else diff --git a/tools/setup-centrifugo.sh b/scripts/setup-centrifugo.sh similarity index 82% rename from tools/setup-centrifugo.sh rename to scripts/setup-centrifugo.sh index 0f98af7..e2caf7a 100755 --- a/tools/setup-centrifugo.sh +++ b/scripts/setup-centrifugo.sh @@ -3,10 +3,17 @@ set -e +# Navigate to repository root (parent of scripts directory) +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + CENTRIFUGO_VERSION="${CENTRIFUGO_VERSION:-v6.1.0}" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_DIR="tools" CENTRIFUGO_BIN="$SCRIPT_DIR/centrifugo" +# Create directory if it doesn't exist +mkdir -p "$SCRIPT_DIR" + if [[ -f "$CENTRIFUGO_BIN" ]]; then echo "Centrifugo already exists at $CENTRIFUGO_BIN" "$CENTRIFUGO_BIN" version 2>/dev/null || true diff --git a/scripts/test-e2e-in-container.sh b/scripts/test-e2e-in-container.sh index 8d4a84b..5b37cca 100755 --- a/scripts/test-e2e-in-container.sh +++ b/scripts/test-e2e-in-container.sh @@ -3,7 +3,11 @@ set -e # Host script to run E2E tests inside the docker container -IMAGE="omnectshareddevacr.azurecr.io/rust:bookworm" +# Navigate to repository root (parent of scripts directory) +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +IMAGE="omnectweucopsacr.azurecr.io/rust:bookworm" echo "🐳 Launching test container..." @@ -14,9 +18,9 @@ if [ ! -d "src/ui/dist" ]; then fi # Run the test script inside the container -# We mount the current directory to /workspace +# We mount the repository root to /workspace docker run --rm \ - -v $(pwd):/workspace \ + -v "$REPO_ROOT":/workspace \ -w /workspace \ --net=host \ $IMAGE \ diff --git a/src/app/Cargo.toml b/src/app/Cargo.toml index 02e68c6..bfb7c6b 100644 --- a/src/app/Cargo.toml +++ b/src/app/Cargo.toml @@ -23,13 +23,15 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("facet_typ [dependencies] base64 = { version = "0.22", default-features = false, features = ["alloc"] } console_log = { version = "1.0", default-features = false } -crux_core = { version = "0.16", default-features = false } -crux_http = { version = "0.15", default-features = false } +crux_core = { version = "0.17.0-rc2" } +crux_http = { version = "0.16.0-rc2", default-features = false } +crux_macros = { version = "0.8.0-rc2" } lazy_static = { version = "1.4", default-features = false } log = { version = "0.4", default-features = false } +getrandom = { version = "0.3", features = ["wasm_js"] } serde = { version = "1.0", default-features = false, features = ["derive"] } serde_json = { version = "1.0", default-features = false } wasm-bindgen = { version = "0.2", default-features = false } [build-dependencies] -crux_core = { version = "0.16", default-features = false, features = ["typegen"] } +crux_core = { version = "0.17.0-rc2", default-features = false, features = ["typegen"] } diff --git a/src/app/README.md b/src/app/README.md index 69e9c5d..7303a48 100644 --- a/src/app/README.md +++ b/src/app/README.md @@ -9,11 +9,11 @@ The Crux Core follows the Model-View-Update pattern: - **Model** - The complete application state (auth, device info, network status, etc.) - **ViewModel** - Data needed by the UI to render - **Events** - Actions that can occur in the application -- **Capabilities** - Side effects (HTTP requests, WebSocket, rendering) +- **Effects** - Side effects (HTTP requests, WebSocket, rendering) ## Key Files -- `src/lib.rs` - App struct, Capabilities, and re-exports +- `src/lib.rs` - App struct, Effect enum, and re-exports - `src/model.rs` - Model and ViewModel structs - `src/events.rs` - Event enum definitions - `src/types/` - Domain-based type definitions @@ -31,12 +31,15 @@ The Crux Core follows the Model-View-Update pattern: - `device/` - Device action handlers - `mod.rs` - Device event dispatcher - `operations.rs` - Device operations (reboot, factory reset, updates) - - `network.rs` - Network configuration handlers - `reconnection.rs` - Device reconnection handlers + - `network/` - Network configuration handlers + - `mod.rs` - Module re-exports + - `config.rs` - Network config request/response + - `form.rs` - Form state management + - `verification.rs` - IP check and rollback logic - `websocket.rs` - WebSocket/Centrifugo handlers - `ui.rs` - UI action handlers (clear error/success) -- `src/capabilities/centrifugo.rs` - Custom WebSocket capability (deprecated API, kept for Effect enum generation) -- `src/capabilities/centrifugo_command.rs` - Command-based WebSocket capability (new API) +- `src/commands/centrifugo.rs` - Custom WebSocket commands ## Building @@ -97,20 +100,6 @@ const errorMessage = computed(() => viewModel.error_message) 5. Effects are processed (HTTP requests, render updates, etc.) 6. ViewModel is updated and Vue re-renders -## Capabilities - -### Render - -Updates the ViewModel to trigger UI re-rendering. - -### HTTP - -Makes REST API calls to the backend. The shell handles the actual HTTP request and sends the response back to the core. - -### Centrifugo - -Manages WebSocket subscriptions for real-time updates. The shell handles the actual WebSocket connection. - ## Testing The core includes unit tests for business logic: @@ -129,12 +118,8 @@ cargo clippy -p omnect-ui-core -- -D warnings **Additional Tasks:** -- [ ] Add comprehensive integration tests for all migrated components -- [ ] Add more unit tests for Core edge cases -- [ ] Performance testing and bundle size optimization +- [ ] Performance bundle size optimization ### Technical Debt -- [ ] Remove deprecated capabilities once crux_core provides alternative Effect generation mechanism - [ ] Refactor `Model.auth_token` to not be serialized to the view model directly. The current approach of removing `#[serde(skip_serializing)]` in `src/app/src/model.rs` is a workaround for `shared_types` deserialization misalignment. A long-term solution should involve either making TypeGen respect `skip_serializing` or separating view-specific model fields. -- [ ] Address `crux_http` error handling for non-2xx HTTP responses: The current implementation uses a workaround (`x-original-status` header in `useCore.ts` and corresponding logic in macros) because `crux_http` (v0.15) appears to discard response bodies for 4xx/5xx status codes, preventing detailed error messages from reaching the Core. This workaround should be removed if future `crux_http` versions provide a more direct way to access error response bodies. diff --git a/src/app/src/capabilities/centrifugo.rs b/src/app/src/capabilities/centrifugo.rs deleted file mode 100644 index 5c73278..0000000 --- a/src/app/src/capabilities/centrifugo.rs +++ /dev/null @@ -1,192 +0,0 @@ -//! Deprecated Centrifugo capability using the old Capabilities API. -//! -//! This module is kept for Effect enum generation via the #[derive(Effect)] macro. -//! For actual usage, prefer the Command-based API in `centrifugo_command`. - -#![allow(deprecated)] - -use crux_core::capability::{CapabilityContext, Operation}; -use serde::{Deserialize, Serialize}; - -// Operations that the Shell needs to perform for Centrifugo -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum CentrifugoOperation { - Connect, - Disconnect, - Subscribe { channel: String }, - Unsubscribe { channel: String }, - SubscribeAll, - UnsubscribeAll, - History { channel: String }, -} - -// The output from Centrifugo operations (shell tells us what happened) -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum CentrifugoOutput { - Connected, - Disconnected, - Subscribed { - channel: String, - }, - Unsubscribed { - channel: String, - }, - Message { - channel: String, - data: String, - }, - HistoryResult { - channel: String, - data: Option, - }, - Error { - message: String, - }, -} - -impl Operation for CentrifugoOperation { - type Output = CentrifugoOutput; -} - -// The Centrifugo capability -pub struct Centrifugo { - context: CapabilityContext, -} - -impl Centrifugo -where - Ev: 'static, -{ - pub fn new(context: CapabilityContext) -> Self { - Self { context } - } - - /// Connect to Centrifugo server - pub fn connect(&self, callback: F) - where - F: FnOnce(CentrifugoOutput) -> Ev + Send + 'static, - { - self.context.spawn({ - let context = self.context.clone(); - async move { - let output = context - .request_from_shell(CentrifugoOperation::Connect) - .await; - context.update_app(callback(output)); - } - }); - } - - /// Disconnect from Centrifugo server - pub fn disconnect(&self, callback: F) - where - F: FnOnce(CentrifugoOutput) -> Ev + Send + 'static, - { - self.context.spawn({ - let context = self.context.clone(); - async move { - let output = context - .request_from_shell(CentrifugoOperation::Disconnect) - .await; - context.update_app(callback(output)); - } - }); - } - - /// Subscribe to a specific channel - pub fn subscribe(&self, channel: &str, callback: F) - where - F: FnOnce(CentrifugoOutput) -> Ev + Send + 'static, - { - let channel = channel.to_string(); - self.context.spawn({ - let context = self.context.clone(); - async move { - let output = context - .request_from_shell(CentrifugoOperation::Subscribe { channel }) - .await; - context.update_app(callback(output)); - } - }); - } - - /// Unsubscribe from a specific channel - pub fn unsubscribe(&self, channel: &str, callback: F) - where - F: FnOnce(CentrifugoOutput) -> Ev + Send + 'static, - { - let channel = channel.to_string(); - self.context.spawn({ - let context = self.context.clone(); - async move { - let output = context - .request_from_shell(CentrifugoOperation::Unsubscribe { channel }) - .await; - context.update_app(callback(output)); - } - }); - } - - /// Subscribe to all known channels - pub fn subscribe_all(&self, callback: F) - where - F: FnOnce(CentrifugoOutput) -> Ev + Send + 'static, - { - self.context.spawn({ - let context = self.context.clone(); - async move { - let output = context - .request_from_shell(CentrifugoOperation::SubscribeAll) - .await; - context.update_app(callback(output)); - } - }); - } - - /// Unsubscribe from all channels - pub fn unsubscribe_all(&self, callback: F) - where - F: FnOnce(CentrifugoOutput) -> Ev + Send + 'static, - { - self.context.spawn({ - let context = self.context.clone(); - async move { - let output = context - .request_from_shell(CentrifugoOperation::UnsubscribeAll) - .await; - context.update_app(callback(output)); - } - }); - } - - /// Get history (last message) from a channel - pub fn history(&self, channel: &str, callback: F) - where - F: FnOnce(CentrifugoOutput) -> Ev + Send + 'static, - { - let channel = channel.to_string(); - self.context.spawn({ - let context = self.context.clone(); - async move { - let output = context - .request_from_shell(CentrifugoOperation::History { channel }) - .await; - context.update_app(callback(output)); - } - }); - } -} - -impl crux_core::Capability for Centrifugo { - type Operation = CentrifugoOperation; - type MappedSelf = Centrifugo; - - fn map_event(&self, f: F) -> Self::MappedSelf - where - F: Fn(NewEv) -> Ev + Send + Sync + 'static, - Ev: 'static, - NewEv: 'static + Send, - { - Centrifugo::new(self.context.map_event(f)) - } -} diff --git a/src/app/src/capabilities/mod.rs b/src/app/src/capabilities/mod.rs deleted file mode 100644 index 636624e..0000000 --- a/src/app/src/capabilities/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod centrifugo; -pub mod centrifugo_command; diff --git a/src/app/src/capabilities/centrifugo_command.rs b/src/app/src/commands/centrifugo.rs similarity index 64% rename from src/app/src/capabilities/centrifugo_command.rs rename to src/app/src/commands/centrifugo.rs index 7519bd5..c6479c4 100644 --- a/src/app/src/capabilities/centrifugo_command.rs +++ b/src/app/src/commands/centrifugo.rs @@ -1,24 +1,55 @@ -//! Command-based API for Centrifugo capability +//! Centrifugo command definitions. //! -//! This module provides the new Command API for Centrifugo operations, -//! replacing the deprecated Capability API. -//! -//! It reuses the Operation and Output types from the deprecated capability -//! to ensure compatibility with the Effect enum generated by the macro. +//! These types define the interface between the Core and the Shell for Centrifugo operations. +use crux_core::{capability::Operation, command, Command}; +use serde::{Deserialize, Serialize}; use std::marker::PhantomData; -use crux_core::{command, Command}; +// Operations that the Shell needs to perform for Centrifugo +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum CentrifugoOperation { + Connect, + Disconnect, + Subscribe { channel: String }, + Unsubscribe { channel: String }, + SubscribeAll, + UnsubscribeAll, + History { channel: String }, +} -// Re-export types from the deprecated capability module -// This ensures the Effect enum generated from Capabilities struct -// has the correct From> implementation -pub use super::centrifugo::{CentrifugoOperation, CentrifugoOutput}; +// The output from Centrifugo operations (shell tells us what happened) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum CentrifugoOutput { + Connected, + Disconnected, + Subscribed { + channel: String, + }, + Unsubscribed { + channel: String, + }, + Message { + channel: String, + data: String, + }, + HistoryResult { + channel: String, + data: Option, + }, + Error { + message: String, + }, +} + +impl Operation for CentrifugoOperation { + type Output = CentrifugoOutput; +} /// Command-based Centrifugo API pub struct Centrifugo { - effect: PhantomData, - event: PhantomData, + _effect: PhantomData, + _event: PhantomData, } impl Centrifugo @@ -72,8 +103,8 @@ where #[must_use] pub struct RequestBuilder { operation: CentrifugoOperation, - effect: PhantomData, - event: PhantomData Event>, + _effect: PhantomData, + _event: PhantomData Event>, } impl RequestBuilder @@ -84,8 +115,8 @@ where fn new(operation: CentrifugoOperation) -> Self { Self { operation, - effect: PhantomData, - event: PhantomData, + _effect: PhantomData, + _event: PhantomData, } } diff --git a/src/app/src/commands/mod.rs b/src/app/src/commands/mod.rs new file mode 100644 index 0000000..af4d012 --- /dev/null +++ b/src/app/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod centrifugo; diff --git a/src/app/src/events.rs b/src/app/src/events.rs index f0e8b5d..b90054f 100644 --- a/src/app/src/events.rs +++ b/src/app/src/events.rs @@ -101,6 +101,7 @@ pub enum WebSocketEvent { pub enum UiEvent { ClearError, ClearSuccess, + SetBrowserHostname(String), } /// Main event enum - wraps domain events diff --git a/src/app/src/http_helpers.rs b/src/app/src/http_helpers.rs index caa583e..62c1811 100644 --- a/src/app/src/http_helpers.rs +++ b/src/app/src/http_helpers.rs @@ -2,19 +2,12 @@ //! //! This module extracts common HTTP response handling logic from macros //! into debuggable, testable functions. -//! -//! ## Shell Workaround -//! -//! The shell uses an `x-original-status` header to preserve error status codes. -//! This is needed because `crux_http` (v0.15) discards response bodies for 4xx/5xx -//! status codes. The shell masks these as 200 OK and passes the original status -//! in this header, allowing the Core to properly extract error messages. use crux_http::Response; /// Base URL for omnect-device API endpoints. /// -/// NOTE: This URL is prefixed as a workaround because `crux_http` (v0.15) panics +/// NOTE: This URL is prefixed as a workaround because `crux_http` (v0.16.0-rc2) panics /// when given a relative URL in some environments (e.g. `cargo test`). /// The UI shell (`useCore.ts`) strips this prefix before sending the request. pub const BASE_URL: &str = "http://omnect-device"; @@ -37,25 +30,16 @@ pub fn build_url(endpoint: &str) -> String { format!("{BASE_URL}{endpoint}") } -/// Validates HTTP response, accounting for shell workaround. +/// Validates HTTP response. /// -/// Returns `true` if the response status is 2xx AND there is no -/// `x-original-status` header indicating a masked error. +/// Returns `true` if the response status is 2xx. pub fn is_response_success(response: &Response>) -> bool { - let is_hack_error = response.header("x-original-status").is_some(); - response.status().is_success() && !is_hack_error + response.status().is_success() } /// Extracts error message from HTTP response. -/// -/// Checks for shell hack header first, then falls back to body content. pub fn extract_error_message(action: &str, response: &mut Response>) -> String { - // Check for original status header from shell hack - let status = if let Some(original) = response.header("x-original-status") { - original.as_str().to_string() - } else { - response.status().to_string() - }; + let status = response.status().to_string(); match response.take_body() { Some(body) => { diff --git a/src/app/src/lib.rs b/src/app/src/lib.rs index c7b8894..2bed971 100644 --- a/src/app/src/lib.rs +++ b/src/app/src/lib.rs @@ -1,4 +1,4 @@ -pub mod capabilities; +pub mod commands; pub mod events; pub mod http_helpers; pub mod macros; @@ -11,38 +11,28 @@ pub mod wasm; use crux_core::Command; -// Using deprecated Capabilities API for Http (kept for Effect enum generation) -#[allow(deprecated)] -use crux_http::Http; - // Re-export core types -pub use crate::capabilities::centrifugo::{CentrifugoOperation, CentrifugoOutput}; -pub use crate::events::Event; -pub use crate::http_helpers::{ - build_url, check_response_status, extract_error_message, extract_string_response, - handle_auth_error, handle_request_error, is_response_success, parse_json_response, - process_json_response, process_status_response, BASE_URL, +pub use crate::{ + commands::centrifugo::{CentrifugoOperation, CentrifugoOutput}, + events::Event, + http_helpers::{ + build_url, check_response_status, extract_error_message, extract_string_response, + handle_auth_error, handle_request_error, is_response_success, parse_json_response, + process_json_response, process_status_response, BASE_URL, + }, + model::Model, + types::*, }; -pub use crate::model::Model; -pub use crate::types::*; pub use crux_http::Result as HttpResult; -/// Capabilities - side effects the app can perform -/// -/// Note: We keep the old deprecated capabilities in this struct ONLY for Effect enum generation. -/// The #[derive(crux_core::macros::Effect)] macro generates the Effect enum with proper -/// From> implementations based on what's declared here. -/// Actual usage goes through the Command-based APIs (HttpCmd, CentrifugoCmd). -#[allow(deprecated)] -#[cfg_attr(feature = "typegen", derive(crux_core::macros::Export))] -#[derive(crux_core::macros::Effect)] -pub struct Capabilities { - pub render: crux_core::render::Render, - pub http: Http, - pub centrifugo: crate::capabilities::centrifugo::Centrifugo, +#[crux_macros::effect(typegen)] +pub enum Effect { + Render(crux_core::render::RenderOperation), + Http(crux_http::protocol::HttpRequest), + Centrifugo(CentrifugoOperation), } -pub type CentrifugoCmd = crate::capabilities::centrifugo_command::Centrifugo; +pub type CentrifugoCmd = crate::commands::centrifugo::Centrifugo; pub type HttpCmd = crux_http::command::Http; /// The Core application @@ -53,15 +43,9 @@ impl crux_core::App for App { type Event = Event; type Model = Model; type ViewModel = Model; - type Capabilities = Capabilities; type Effect = Effect; - fn update( - &self, - event: Self::Event, - model: &mut Self::Model, - _caps: &Self::Capabilities, - ) -> Command { + fn update(&self, event: Self::Event, model: &mut Self::Model) -> Command { update::update(event, model) } diff --git a/src/app/src/macros.rs b/src/app/src/macros.rs index 87624fe..75143ee 100644 --- a/src/app/src/macros.rs +++ b/src/app/src/macros.rs @@ -166,7 +166,7 @@ macro_rules! unauth_post { /// Returns string body (e.g., auth token) on success, with optional conversion to target type. /// /// NOTE: Endpoints are prefixed with `http://omnect-device` as a workaround. -/// `crux_http` (v0.15) panics when given a relative URL in some environments (e.g. `cargo test`). +/// `crux_http` panics when given a relative URL in some environments (e.g. `cargo test`). /// The UI shell (`useCore.ts`) strips this prefix before sending the request. /// /// # Example @@ -203,7 +203,7 @@ macro_rules! auth_post_basic { /// Reduces boilerplate for POST requests that require authentication. /// /// NOTE: Endpoints are prefixed with `http://omnect-device` as a workaround. -/// `crux_http` (v0.15) panics when given a relative URL in some environments (e.g. `cargo test`). +/// `crux_http` panics when given a relative URL in some environments (e.g. `cargo test`). /// The UI shell (`useCore.ts`) strips this prefix before sending the request. /// This workaround should be removed once `crux_http` supports relative URLs gracefully. /// diff --git a/src/app/src/model.rs b/src/app/src/model.rs index 7db9f75..280b48d 100644 --- a/src/app/src/model.rs +++ b/src/app/src/model.rs @@ -52,6 +52,16 @@ pub struct Model { // Network form dirty flag (tracks unsaved changes) pub network_form_dirty: bool, + // Browser hostname (from window.location.hostname) - used for network connection detection + pub browser_hostname: Option, + + // Current connection adapter name (computed from browser_hostname + network_status) + pub current_connection_adapter: Option, + + // Network rollback modal state + pub should_show_rollback_modal: bool, + pub default_rollback_enabled: bool, + // Firmware upload state pub firmware_upload_state: UploadState, @@ -100,6 +110,23 @@ impl Model { pub fn clear_error(&mut self) { self.error_message = None; } + + /// Update current connection adapter based on browser_hostname and network_status + pub fn update_current_connection_adapter(&mut self) { + self.current_connection_adapter = self + .network_status + .as_ref() + .and_then(|status| status.current_connection_adapter(self.browser_hostname.as_deref())) + .map(|adapter| adapter.name.clone()); + } + + /// Check if the given adapter name matches the current connection adapter + pub fn is_current_adapter(&self, name: &str) -> bool { + self.current_connection_adapter + .as_ref() + .map(|current| current == name) + .unwrap_or(false) + } } impl ModelErrorHandler for Model { diff --git a/src/app/src/types/network.rs b/src/app/src/types/network.rs index deacfc7..a2641c0 100644 --- a/src/app/src/types/network.rs +++ b/src/app/src/types/network.rs @@ -1,5 +1,37 @@ use serde::{Deserialize, Serialize}; +/// Validate IPv4 address format +pub fn is_valid_ipv4(ip: &str) -> bool { + if ip.is_empty() { + return true; // Empty is considered valid (for optional fields) + } + + let parts: Vec<&str> = ip.split('.').collect(); + if parts.len() != 4 { + return false; + } + + parts.iter().all(|part| { + if let Ok(num) = part.parse::() { + num <= 255 + } else { + false + } + }) +} + +/// Validate and parse netmask value +/// Accepts "/24" or "24" format, returns prefix length if valid +pub fn parse_netmask(mask: &str) -> Option { + let cleaned = mask.trim_start_matches('/'); + if let Ok(prefix_len) = cleaned.parse::() { + if prefix_len <= 32 { + return Some(prefix_len); + } + } + None +} + /// IP address configuration #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct IpAddress { @@ -33,6 +65,38 @@ pub struct NetworkStatus { pub network_status: Vec, } +impl NetworkStatus { + /// Determine which adapter is the current connection based on browser hostname + pub fn current_connection_adapter( + &self, + browser_hostname: Option<&str>, + ) -> Option<&DeviceNetwork> { + let hostname = browser_hostname?; + + // First, try to find a direct IP match + for adapter in &self.network_status { + for ip in &adapter.ipv4.addrs { + if ip.addr == hostname { + return Some(adapter); + } + } + } + + // Special case: if we are on localhost, and an adapter has "localhost" IP, match it + if hostname == "localhost" { + for adapter in &self.network_status { + if adapter.ipv4.addrs.iter().any(|ip| ip.addr == "localhost") { + return Some(adapter); + } + } + } + + // If hostname is a domain name (not an IP), we can't determine which adapter + // is the current connection without DNS resolution, so return None + None + } +} + /// Network configuration request #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -109,18 +173,22 @@ pub enum NetworkFormState { impl NetworkFormState { /// Transition from Editing to Submitting state - pub fn to_submitting(&self) -> Option { + pub fn to_submitting(&self, target_adapter_name: &str) -> Option { if let Self::Editing { adapter_name, form_data, original_data, } = self { - Some(Self::Submitting { - adapter_name: adapter_name.clone(), - form_data: form_data.clone(), - original_data: original_data.clone(), - }) + if adapter_name == target_adapter_name { + Some(Self::Submitting { + adapter_name: adapter_name.clone(), + form_data: form_data.clone(), + original_data: original_data.clone(), + }) + } else { + None + } } else { None } @@ -232,10 +300,7 @@ pub enum NetworkChangeState { switching_to_dhcp: bool, }, /// New IP confirmed reachable, browser will redirect - NewIpReachable { - new_ip: String, - ui_port: u16, - }, + NewIpReachable { new_ip: String, ui_port: u16 }, /// Timeout expired without confirming new IP (rollback disabled case) NewIpTimeout { new_ip: String, @@ -259,3 +324,141 @@ pub struct SetNetworkConfigResponse { pub ui_port: u16, pub rollback_enabled: bool, } + +#[cfg(test)] +mod tests { + use super::*; + + mod validation { + use super::*; + + #[test] + fn is_valid_ipv4_accepts_valid_addresses() { + assert!(is_valid_ipv4("192.168.1.1")); + assert!(is_valid_ipv4("10.0.0.1")); + assert!(is_valid_ipv4("172.16.0.1")); + assert!(is_valid_ipv4("0.0.0.0")); + assert!(is_valid_ipv4("255.255.255.255")); + } + + #[test] + fn is_valid_ipv4_accepts_empty_string() { + assert!(is_valid_ipv4("")); + } + + #[test] + fn is_valid_ipv4_rejects_invalid_addresses() { + assert!(!is_valid_ipv4("256.1.1.1")); + assert!(!is_valid_ipv4("192.168.1")); + assert!(!is_valid_ipv4("192.168.1.1.1")); + assert!(!is_valid_ipv4("abc.def.ghi.jkl")); + assert!(!is_valid_ipv4("192.168.-1.1")); + } + + #[test] + fn parse_netmask_accepts_valid_values() { + assert_eq!(parse_netmask("24"), Some(24)); + assert_eq!(parse_netmask("/24"), Some(24)); + assert_eq!(parse_netmask("0"), Some(0)); + assert_eq!(parse_netmask("32"), Some(32)); + assert_eq!(parse_netmask("/8"), Some(8)); + } + + #[test] + fn parse_netmask_rejects_invalid_values() { + assert_eq!(parse_netmask("33"), None); + assert_eq!(parse_netmask("abc"), None); + assert_eq!(parse_netmask("-1"), None); + assert_eq!(parse_netmask("24.5"), None); + } + } + + mod current_connection { + use super::*; + + fn create_adapter(name: &str, ip: &str, online: bool) -> DeviceNetwork { + DeviceNetwork { + name: name.to_string(), + mac: "00:11:22:33:44:55".to_string(), + online, + file: Some("/etc/network/interfaces".to_string()), + ipv4: InternetProtocol { + addrs: vec![IpAddress { + addr: ip.to_string(), + dhcp: false, + prefix_len: 24, + }], + dns: vec![], + gateways: vec![], + }, + } + } + + #[test] + fn returns_adapter_with_matching_ip() { + let status = NetworkStatus { + network_status: vec![ + create_adapter("eth0", "192.168.1.100", true), + create_adapter("eth1", "192.168.2.100", true), + ], + }; + + let adapter = status.current_connection_adapter(Some("192.168.1.100")); + assert_eq!(adapter.map(|a| &a.name), Some(&"eth0".to_string())); + } + + #[test] + fn returns_none_for_hostname_without_ip_match() { + let status = NetworkStatus { + network_status: vec![ + create_adapter("eth0", "192.168.1.100", false), + create_adapter("eth1", "192.168.2.100", true), + create_adapter("eth2", "192.168.3.100", true), + ], + }; + + let adapter = status.current_connection_adapter(Some("omnect-device")); + assert_eq!(adapter, None); + } + + #[test] + fn returns_none_for_no_hostname() { + let status = NetworkStatus { + network_status: vec![create_adapter("eth0", "192.168.1.100", true)], + }; + + let adapter = status.current_connection_adapter(None); + assert_eq!(adapter, None); + } + + #[test] + fn returns_none_for_no_match() { + let status = NetworkStatus { + network_status: vec![create_adapter("eth0", "192.168.1.100", true)], + }; + + let adapter = status.current_connection_adapter(Some("192.168.99.99")); + assert_eq!(adapter, None); + } + + #[test] + fn returns_none_when_no_online_adapters() { + let status = NetworkStatus { + network_status: vec![create_adapter("eth0", "192.168.1.100", false)], + }; + + let adapter = status.current_connection_adapter(Some("omnect-device")); + assert_eq!(adapter, None); + } + + #[test] + fn returns_adapter_with_matching_localhost() { + let status = NetworkStatus { + network_status: vec![create_adapter("eth0", "localhost", true)], + }; + + let adapter = status.current_connection_adapter(Some("localhost")); + assert_eq!(adapter.map(|a| &a.name), Some(&"eth0".to_string())); + } + } +} diff --git a/src/app/src/update/auth.rs b/src/app/src/update/auth.rs index 953b73d..b15c16a 100644 --- a/src/app/src/update/auth.rs +++ b/src/app/src/update/auth.rs @@ -1,14 +1,14 @@ use base64::prelude::*; use crux_core::Command; -use crate::auth_post; -use crate::auth_post_basic; -use crate::events::{AuthEvent, Event}; -use crate::handle_response; -use crate::model::Model; -use crate::types::{AuthToken, SetPasswordRequest, UpdatePasswordRequest}; -use crate::unauth_post; -use crate::Effect; +use crate::{ + auth_post, auth_post_basic, + events::{AuthEvent, Event}, + handle_response, + model::Model, + types::{AuthToken, SetPasswordRequest, UpdatePasswordRequest}, + unauth_post, Effect, +}; /// Handle authentication-related events pub fn handle(event: AuthEvent, model: &mut Model) -> Command { @@ -87,21 +87,18 @@ pub fn handle(event: AuthEvent, model: &mut Model) -> Command { #[cfg(test)] mod tests { use super::*; - use crate::{App, Event}; - use crux_core::testing::AppTester; mod login { use super::*; #[test] fn sets_loading_state() { - let app = AppTester::::default(); let mut model = Model::default(); - let _ = app.update( - Event::Auth(AuthEvent::Login { + let _ = handle( + AuthEvent::Login { password: "test_password".into(), - }), + }, &mut model, ); @@ -111,16 +108,15 @@ mod tests { #[test] fn success_sets_authenticated_and_stores_token() { - let app = AppTester::::default(); let mut model = Model { is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Auth(AuthEvent::LoginResponse(Ok(AuthToken { + let _ = handle( + AuthEvent::LoginResponse(Ok(AuthToken { token: "test_token_123".into(), - }))), + })), &mut model, ); @@ -132,14 +128,13 @@ mod tests { #[test] fn failure_sets_error_and_not_authenticated() { - let app = AppTester::::default(); let mut model = Model { is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Auth(AuthEvent::LoginResponse(Err("Invalid credentials".into()))), + let _ = handle( + AuthEvent::LoginResponse(Err("Invalid credentials".into())), &mut model, ); @@ -151,16 +146,15 @@ mod tests { #[test] fn clears_previous_error_on_new_attempt() { - let app = AppTester::::default(); let mut model = Model { error_message: Some("Previous error".into()), ..Default::default() }; - let _ = app.update( - Event::Auth(AuthEvent::Login { + let _ = handle( + AuthEvent::Login { password: "test".into(), - }), + }, &mut model, ); @@ -173,21 +167,19 @@ mod tests { #[test] fn sets_loading_state() { - let app = AppTester::::default(); let mut model = Model { is_authenticated: true, auth_token: Some("token".into()), ..Default::default() }; - let _ = app.update(Event::Auth(AuthEvent::Logout), &mut model); + let _ = handle(AuthEvent::Logout, &mut model); assert!(model.is_loading); } #[test] fn success_clears_session() { - let app = AppTester::::default(); let mut model = Model { is_authenticated: true, auth_token: Some("token".into()), @@ -195,7 +187,7 @@ mod tests { ..Default::default() }; - let _ = app.update(Event::Auth(AuthEvent::LogoutResponse(Ok(()))), &mut model); + let _ = handle(AuthEvent::LogoutResponse(Ok(())), &mut model); assert!(!model.is_authenticated); assert!(model.auth_token.is_none()); @@ -204,7 +196,6 @@ mod tests { #[test] fn failure_sets_error_but_keeps_session() { - let app = AppTester::::default(); let mut model = Model { is_authenticated: true, auth_token: Some("token".into()), @@ -212,8 +203,8 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Auth(AuthEvent::LogoutResponse(Err("Network error".into()))), + let _ = handle( + AuthEvent::LogoutResponse(Err("Network error".into())), &mut model, ); @@ -230,16 +221,15 @@ mod tests { #[test] fn sets_loading_state() { - let app = AppTester::::default(); let mut model = Model { requires_password_set: true, ..Default::default() }; - let _ = app.update( - Event::Auth(AuthEvent::SetPassword { + let _ = handle( + AuthEvent::SetPassword { password: "new_password".into(), - }), + }, &mut model, ); @@ -248,17 +238,13 @@ mod tests { #[test] fn success_clears_requires_password_set() { - let app = AppTester::::default(); let mut model = Model { requires_password_set: true, is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Auth(AuthEvent::SetPasswordResponse(Ok(()))), - &mut model, - ); + let _ = handle(AuthEvent::SetPasswordResponse(Ok(())), &mut model); assert!(!model.requires_password_set); assert!(!model.is_loading); @@ -270,17 +256,14 @@ mod tests { #[test] fn failure_keeps_requires_password_set() { - let app = AppTester::::default(); let mut model = Model { requires_password_set: true, is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Auth(AuthEvent::SetPasswordResponse(Err( - "Password too weak".into() - ))), + let _ = handle( + AuthEvent::SetPasswordResponse(Err("Password too weak".into())), &mut model, ); @@ -295,18 +278,17 @@ mod tests { #[test] fn sets_loading_state() { - let app = AppTester::::default(); let mut model = Model { is_authenticated: true, auth_token: Some("token".into()), ..Default::default() }; - let _ = app.update( - Event::Auth(AuthEvent::UpdatePassword { + let _ = handle( + AuthEvent::UpdatePassword { current_password: "old_pass".into(), password: "new_pass".into(), - }), + }, &mut model, ); @@ -315,7 +297,6 @@ mod tests { #[test] fn success_shows_success_message() { - let app = AppTester::::default(); let mut model = Model { is_authenticated: true, auth_token: Some("token".into()), @@ -323,10 +304,7 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Auth(AuthEvent::UpdatePasswordResponse(Ok(()))), - &mut model, - ); + let _ = handle(AuthEvent::UpdatePasswordResponse(Ok(())), &mut model); assert!(!model.is_loading); assert_eq!( @@ -340,7 +318,6 @@ mod tests { #[test] fn failure_shows_error() { - let app = AppTester::::default(); let mut model = Model { is_authenticated: true, auth_token: Some("token".into()), @@ -348,10 +325,8 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Auth(AuthEvent::UpdatePasswordResponse(Err( - "Current password incorrect".into(), - ))), + let _ = handle( + AuthEvent::UpdatePasswordResponse(Err("Current password incorrect".into())), &mut model, ); @@ -370,25 +345,23 @@ mod tests { #[test] fn sets_loading_state() { - let app = AppTester::::default(); let mut model = Model::default(); - let _ = app.update(Event::Auth(AuthEvent::CheckRequiresPasswordSet), &mut model); + let _ = handle(AuthEvent::CheckRequiresPasswordSet, &mut model); assert!(model.is_loading); } #[test] fn response_true_sets_requires_password_set() { - let app = AppTester::::default(); let mut model = Model { requires_password_set: false, is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Auth(AuthEvent::CheckRequiresPasswordSetResponse(Ok(true))), + let _ = handle( + AuthEvent::CheckRequiresPasswordSetResponse(Ok(true)), &mut model, ); @@ -398,15 +371,14 @@ mod tests { #[test] fn response_false_clears_requires_password_set() { - let app = AppTester::::default(); let mut model = Model { requires_password_set: true, is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Auth(AuthEvent::CheckRequiresPasswordSetResponse(Ok(false))), + let _ = handle( + AuthEvent::CheckRequiresPasswordSetResponse(Ok(false)), &mut model, ); @@ -416,16 +388,13 @@ mod tests { #[test] fn failure_sets_error() { - let app = AppTester::::default(); let mut model = Model { is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Auth(AuthEvent::CheckRequiresPasswordSetResponse(Err( - "Server error".into(), - ))), + let _ = handle( + AuthEvent::CheckRequiresPasswordSetResponse(Err("Server error".into())), &mut model, ); diff --git a/src/app/src/update/device/mod.rs b/src/app/src/update/device/mod.rs index 85fbb39..a6bccbb 100644 --- a/src/app/src/update/device/mod.rs +++ b/src/app/src/update/device/mod.rs @@ -14,15 +14,17 @@ pub use reconnection::{ use crux_core::Command; -use crate::auth_post; -use crate::events::{DeviceEvent, Event}; -use crate::handle_response; -use crate::model::Model; -use crate::types::{ - DeviceOperationState, FactoryResetRequest, LoadUpdateRequest, OverlaySpinnerState, - RunUpdateRequest, UpdateManifest, UploadState, +use crate::{ + auth_post, + events::{DeviceEvent, Event}, + handle_response, + model::Model, + types::{ + DeviceOperationState, FactoryResetRequest, LoadUpdateRequest, OverlaySpinnerState, + RunUpdateRequest, UpdateManifest, UploadState, + }, + Effect, }; -use crate::Effect; /// Handle device action events (reboot, factory reset, network, updates) pub fn handle(event: DeviceEvent, model: &mut Model) -> Command { @@ -190,26 +192,21 @@ pub fn handle(event: DeviceEvent, model: &mut Model) -> Command { #[cfg(test)] mod tests { use super::*; - use crate::events::{DeviceEvent, Event}; + use crate::events::DeviceEvent; use crate::types::{DeviceOperationState, UploadState}; - use crate::{App, UpdateManifest}; - use crux_core::testing::AppTester; + use crate::UpdateManifest; mod reboot { use super::*; #[test] fn success_sets_rebooting_state() { - let app = AppTester::::default(); let mut model = Model { is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::RebootResponse(Ok(()))), - &mut model, - ); + let _ = handle(DeviceEvent::RebootResponse(Ok(())), &mut model); assert!(!model.is_loading); assert_eq!( @@ -222,14 +219,13 @@ mod tests { #[test] fn network_error_sets_rebooting_state() { - let app = AppTester::::default(); let mut model = Model { is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::RebootResponse(Err("Failed to fetch".into()))), + let _ = handle( + DeviceEvent::RebootResponse(Err("Failed to fetch".into())), &mut model, ); @@ -246,14 +242,13 @@ mod tests { #[test] fn non_network_error_sets_error() { - let app = AppTester::::default(); let mut model = Model { is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::RebootResponse(Err("Permission denied".into()))), + let _ = handle( + DeviceEvent::RebootResponse(Err("Permission denied".into())), &mut model, ); @@ -269,14 +264,13 @@ mod tests { #[test] fn invalid_mode_sets_error() { - let app = AppTester::::default(); let mut model = Model::default(); - let _ = app.update( - Event::Device(DeviceEvent::FactoryResetRequest { + let _ = handle( + DeviceEvent::FactoryResetRequest { mode: "invalid".into(), preserve: vec![], - }), + }, &mut model, ); @@ -291,16 +285,12 @@ mod tests { #[test] fn success_sets_factory_resetting_state() { - let app = AppTester::::default(); let mut model = Model { is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::FactoryResetResponse(Ok(()))), - &mut model, - ); + let _ = handle(DeviceEvent::FactoryResetResponse(Ok(())), &mut model); assert!(!model.is_loading); assert_eq!( @@ -316,16 +306,15 @@ mod tests { #[test] fn network_error_sets_factory_resetting_state() { - let app = AppTester::::default(); let mut model = Model { is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::FactoryResetResponse(Err( - "NetworkError when attempting to fetch".into(), - ))), + let _ = handle( + DeviceEvent::FactoryResetResponse(Err( + "NetworkError when attempting to fetch".into() + )), &mut model, ); @@ -346,10 +335,9 @@ mod tests { #[test] fn upload_started_sets_state() { - let app = AppTester::::default(); let mut model = Model::default(); - let _ = app.update(Event::Device(DeviceEvent::UploadStarted), &mut model); + let _ = handle(DeviceEvent::UploadStarted, &mut model); assert_eq!(model.firmware_upload_state, UploadState::Uploading); assert!(model.overlay_spinner.is_visible()); @@ -357,28 +345,26 @@ mod tests { #[test] fn upload_progress_updates_spinner() { - let app = AppTester::::default(); let mut model = Model { firmware_upload_state: UploadState::Uploading, overlay_spinner: OverlaySpinnerState::new("Uploading...").with_progress(0), ..Default::default() }; - let _ = app.update(Event::Device(DeviceEvent::UploadProgress(50)), &mut model); + let _ = handle(DeviceEvent::UploadProgress(50), &mut model); assert_eq!(model.firmware_upload_state, UploadState::Uploading); } #[test] fn upload_completed_sets_success() { - let app = AppTester::::default(); let mut model = Model { firmware_upload_state: UploadState::Uploading, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::UploadCompleted("/tmp/file.swu".into())), + let _ = handle( + DeviceEvent::UploadCompleted("/tmp/file.swu".into()), &mut model, ); @@ -389,14 +375,13 @@ mod tests { #[test] fn upload_failed_sets_error() { - let app = AppTester::::default(); let mut model = Model { firmware_upload_state: UploadState::Uploading, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::UploadFailed("Network timeout".into())), + let _ = handle( + DeviceEvent::UploadFailed("Network timeout".into()), &mut model, ); @@ -419,7 +404,6 @@ mod tests { #[test] fn success_stores_manifest() { - let app = AppTester::::default(); let mut model = Model { is_loading: true, ..Default::default() @@ -437,8 +421,8 @@ mod tests { manifest_version: "1".into(), }; - let _ = app.update( - Event::Device(DeviceEvent::LoadUpdateResponse(Ok(manifest.clone()))), + let _ = handle( + DeviceEvent::LoadUpdateResponse(Ok(manifest.clone())), &mut model, ); @@ -449,16 +433,13 @@ mod tests { #[test] fn failure_sets_error() { - let app = AppTester::::default(); let mut model = Model { is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::LoadUpdateResponse( - Err("File not found".into()), - )), + let _ = handle( + DeviceEvent::LoadUpdateResponse(Err("File not found".into())), &mut model, ); @@ -473,16 +454,12 @@ mod tests { #[test] fn success_sets_updating_state() { - let app = AppTester::::default(); let mut model = Model { is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::RunUpdateResponse(Ok(()))), - &mut model, - ); + let _ = handle(DeviceEvent::RunUpdateResponse(Ok(())), &mut model); assert!(!model.is_loading); assert_eq!(model.device_operation_state, DeviceOperationState::Updating); @@ -492,14 +469,13 @@ mod tests { #[test] fn network_error_sets_updating_state() { - let app = AppTester::::default(); let mut model = Model { is_loading: true, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::RunUpdateResponse(Err("IO error".into()))), + let _ = handle( + DeviceEvent::RunUpdateResponse(Err("IO error".into())), &mut model, ); diff --git a/src/app/src/update/device/network.rs b/src/app/src/update/device/network.rs deleted file mode 100644 index 6c428d6..0000000 --- a/src/app/src/update/device/network.rs +++ /dev/null @@ -1,1002 +0,0 @@ -use crux_core::Command; - -use crate::auth_post; -use crate::events::{DeviceEvent, Event, UiEvent}; -use crate::http_get_silent; -use crate::model::Model; -use crate::types::{ - HealthcheckInfo, NetworkChangeState, NetworkConfigRequest, NetworkFormData, NetworkFormState, - OverlaySpinnerState, -}; -use crate::unauth_post; -use crate::Effect; - -/// Success message for network configuration update -const NETWORK_CONFIG_SUCCESS: &str = "Network configuration updated"; - -/// Handle network configuration request -pub fn handle_set_network_config(config: String, model: &mut Model) -> Command { - // Parse the JSON config to extract metadata - let parsed_config: Result = serde_json::from_str(&config); - - match parsed_config { - Ok(config_req) => { - // Store network change state for later use - // Show modal for: IP changed OR switching to DHCP on current adapter - if config_req.is_server_addr && (config_req.ip_changed || config_req.switching_to_dhcp) - { - model.network_change_state = NetworkChangeState::ApplyingConfig { - is_server_addr: true, - ip_changed: config_req.ip_changed || config_req.switching_to_dhcp, - new_ip: config_req.ip.clone().unwrap_or_default(), - old_ip: config_req.previous_ip.clone().unwrap_or_default(), - switching_to_dhcp: config_req.switching_to_dhcp, - }; - } - - // Transition network form to submitting state - if let Some(submitting) = model.network_form_state.to_submitting() { - model.network_form_state = submitting; - } - - // Clear dirty flag when submitting - model.network_form_dirty = false; - - // Send the request to backend - auth_post!( - Device, - DeviceEvent, - model, - "/network", - SetNetworkConfigResponse, - "Set network config", - body_string: config, - expect_json: crate::types::SetNetworkConfigResponse - ) - } - Err(e) => model.set_error_and_render(format!("Invalid network config: {e}")), - } -} - -/// Helper to update network state and spinner based on configuration response -fn update_network_state_and_spinner( - model: &mut Model, - new_ip: String, - old_ip: String, - ui_port: u16, - rollback_timeout_seconds: u64, - switching_to_dhcp: bool, - rollback_enabled: bool, -) { - // Determine target state - // If switching to DHCP without rollback, we go to Idle - if !rollback_enabled && switching_to_dhcp { - model.network_change_state = NetworkChangeState::Idle; - } else { - model.network_change_state = NetworkChangeState::WaitingForNewIp { - new_ip, - old_ip, - attempt: 0, - rollback_timeout_seconds: if rollback_enabled { - rollback_timeout_seconds - } else { - 0 - }, - ui_port, - switching_to_dhcp, - }; - } - - // Determine overlay text - let overlay_text = if switching_to_dhcp { - if rollback_enabled { - "Network configuration is being applied. Your connection will be interrupted. \ - Use your DHCP server or device console to find the new IP address. \ - You must access the new address and log in to cancel the automatic rollback." - } else { - "Network configuration has been applied. Your connection will be interrupted. \ - Use your DHCP server or device console to find the new IP address." - } - } else if rollback_enabled { - "Network configuration is being applied. Click the button below to open the new address in a new tab. \ - You must access the new address and log in to cancel the automatic rollback." - } else { - "Network configuration has been applied. Your connection will be interrupted. \ - Click the button below to navigate to the new address." - }; - - let spinner = OverlaySpinnerState::new("Applying network settings").with_text(overlay_text); - - model.overlay_spinner = if rollback_enabled && !switching_to_dhcp { - spinner.with_countdown(rollback_timeout_seconds as u32) - } else if rollback_enabled && switching_to_dhcp { - // Show countdown even for DHCP if rollback is enabled - spinner.with_countdown(rollback_timeout_seconds as u32) - } else { - spinner - }; -} - -/// Handle network configuration response -pub fn handle_set_network_config_response( - result: Result, - model: &mut Model, -) -> Command { - model.stop_loading(); - - match result { - Ok(response) => { - // Check if we are applying a config that changes IP/DHCP - if let NetworkChangeState::ApplyingConfig { - new_ip, - old_ip, - switching_to_dhcp, - .. - } = &model.network_change_state.clone() - { - if response.rollback_enabled { - update_network_state_and_spinner( - model, - new_ip.clone(), - old_ip.clone(), - response.ui_port, - response.rollback_timeout_seconds, - *switching_to_dhcp, - true, - ); - } else { - update_network_state_and_spinner( - model, - new_ip.clone(), - old_ip.clone(), - response.ui_port, - 0, - *switching_to_dhcp, - false, - ); - } - - model.success_message = Some(NETWORK_CONFIG_SUCCESS.to_string()); - model.network_form_state = NetworkFormState::Idle; - crux_core::render::render() - } else { - // Not changing current connection's IP - just show success message - model.success_message = Some(NETWORK_CONFIG_SUCCESS.to_string()); - model.network_change_state = NetworkChangeState::Idle; - model.network_form_state = NetworkFormState::Idle; - model.overlay_spinner.clear(); - crux_core::render::render() - } - } - Err(e) => { - model.set_error(e); - model.network_change_state = NetworkChangeState::Idle; - // Reset form state back to editing on failure - if let Some(editing) = model.network_form_state.to_editing() { - model.network_form_state = editing; - } - crux_core::render::render() - } - } -} - -/// Handle new IP check tick - polls new IP to see if it's reachable -pub fn handle_new_ip_check_tick(model: &mut Model) -> Command { - match &mut model.network_change_state { - NetworkChangeState::WaitingForNewIp { - new_ip, - attempt, - ui_port, - switching_to_dhcp, - .. - } => { - *attempt += 1; - - // If switching to DHCP, we don't know the new IP, so we can't poll it. - // We just wait for the timeout (rollback) or for the user to manually navigate. - if !*switching_to_dhcp { - // Try to reach the new IP (silent GET - no error shown on failure) - // Use HTTPS since the server only listens on HTTPS - let url = format!("https://{new_ip}:{ui_port}/healthcheck"); - http_get_silent!( - url, - on_success: Event::Device(DeviceEvent::HealthcheckResponse(Ok( - HealthcheckInfo::default() - ))), - on_error: Event::Ui(UiEvent::ClearSuccess) - ) - } else { - crux_core::render::render() - } - } - NetworkChangeState::WaitingForOldIp { - old_ip, - ui_port, - attempt, - } => { - *attempt += 1; - // Poll the old IP to see if rollback completed - let url = format!("https://{old_ip}:{ui_port}/healthcheck"); - http_get_silent!( - url, - on_success: Event::Device(DeviceEvent::HealthcheckResponse(Ok( - HealthcheckInfo::default() - ))), - on_error: Event::Ui(UiEvent::ClearSuccess) - ) - } - _ => crux_core::render::render(), - } -} - -/// Handle new IP check timeout - new IP didn't become reachable in time -pub fn handle_new_ip_check_timeout(model: &mut Model) -> Command { - if let NetworkChangeState::WaitingForNewIp { - new_ip, - old_ip, - ui_port, - rollback_timeout_seconds, - switching_to_dhcp, - .. - } = &model.network_change_state - { - // If rollback was enabled (timeout > 0), we assume rollback happened on device - if *rollback_timeout_seconds > 0 { - model.network_change_state = NetworkChangeState::WaitingForOldIp { - old_ip: old_ip.clone(), - ui_port: *ui_port, - attempt: 0, - }; - model.overlay_spinner.set_text( - "Automatic rollback initiated. Verifying connectivity at original address...", - ); - // Ensure spinner is spinning (not timed out state) - model.overlay_spinner.set_loading(); - } else { - let new_ip_url = format!("https://{new_ip}:{ui_port}"); - model.network_change_state = NetworkChangeState::NewIpTimeout { - new_ip: new_ip.clone(), - old_ip: old_ip.clone(), - ui_port: *ui_port, - switching_to_dhcp: *switching_to_dhcp, - }; - - // Update overlay spinner to show timeout with manual link - model.overlay_spinner.set_text( - format!( - "Automatic rollback will occur soon. The network settings were not confirmed at the new address. \ - Please navigate to: {new_ip_url}" - ) - .as_str(), - ); - model.overlay_spinner.set_timed_out(); - } - } - - crux_core::render::render() -} - -/// Handle network form start edit - initialize form with current network adapter data -pub fn handle_network_form_start_edit( - adapter_name: String, - model: &mut Model, -) -> Command { - // Find the network adapter and copy its data to form state - if let Some(network_status) = &model.network_status { - if let Some(adapter) = network_status - .network_status - .iter() - .find(|n| n.name == adapter_name) - { - let form_data = NetworkFormData::from(adapter); - - model.network_form_state = NetworkFormState::Editing { - adapter_name: adapter_name.clone(), - form_data: form_data.clone(), - original_data: form_data, - }; - // Clear dirty flag when starting a fresh edit - model.network_form_dirty = false; - } - } - - crux_core::render::render() -} - -/// Handle network form update - update form data from user input -pub fn handle_network_form_update( - form_data_json: String, - model: &mut Model, -) -> Command { - // Parse the JSON form data - let parsed: Result = serde_json::from_str(&form_data_json); - - match parsed { - Ok(form_data) => { - if let NetworkFormState::Editing { - adapter_name, - original_data, - .. - } = &model.network_form_state - { - let is_dirty = form_data != *original_data; - - model.network_form_state = NetworkFormState::Editing { - adapter_name: adapter_name.clone(), - form_data, - original_data: original_data.clone(), - }; - model.network_form_dirty = is_dirty; - } - crux_core::render::render() - } - Err(e) => model.set_error_and_render(format!("Invalid form data: {e}")), - } -} - -/// Handle acknowledge network rollback - clear the rollback occurred flag -pub fn handle_ack_rollback(model: &mut Model) -> Command { - // Clear the rollback status in the model - if let Some(healthcheck) = &mut model.healthcheck { - healthcheck.network_rollback_occurred = false; - } - - // Send POST request to backend to clear the marker file - // Note: Using unauth_post instead of auth_post because this may be called before login - // (the rollback notification appears in App.vue onMounted, before authentication) - unauth_post!( - Device, - DeviceEvent, - model, - "/ack-rollback", - AckRollbackResponse, - "Acknowledge rollback" - ) -} - -#[cfg(test)] -mod tests { - use crate::events::{DeviceEvent, Event}; - use crate::model::Model; - use crate::types::{ - DeviceNetwork, HealthcheckInfo, InternetProtocol, IpAddress, NetworkChangeState, - NetworkFormData, NetworkFormState, NetworkStatus, OverlaySpinnerState, - SetNetworkConfigResponse, UpdateValidationStatus, VersionInfo, - }; - use crate::App; - use crux_core::testing::AppTester; - - fn create_test_network_adapter(name: &str, ip: &str, dhcp: bool) -> DeviceNetwork { - DeviceNetwork { - name: name.to_string(), - mac: "00:11:22:33:44:55".to_string(), - online: true, - file: Some("/etc/network/interfaces".to_string()), - ipv4: InternetProtocol { - addrs: vec![IpAddress { - addr: ip.to_string(), - dhcp, - prefix_len: 24, - }], - dns: vec!["8.8.8.8".to_string()], - gateways: vec!["192.168.1.1".to_string()], - }, - } - } - - mod network_form { - use super::*; - - #[test] - fn start_edit_transitions_to_editing_state() { - let app = AppTester::::default(); - let adapter = create_test_network_adapter("eth0", "192.168.1.100", false); - let mut model = Model { - network_status: Some(NetworkStatus { - network_status: vec![adapter.clone()], - }), - ..Default::default() - }; - - let _ = app.update( - Event::Device(DeviceEvent::NetworkFormStartEdit { - adapter_name: "eth0".to_string(), - }), - &mut model, - ); - - assert!(matches!( - model.network_form_state, - NetworkFormState::Editing { .. } - )); - if let NetworkFormState::Editing { - adapter_name, - form_data, - original_data, - } = model.network_form_state - { - assert_eq!(adapter_name, "eth0"); - assert_eq!(form_data.ip_address, "192.168.1.100"); - assert!(!form_data.dhcp); - assert_eq!(form_data, original_data); - } - assert!(!model.network_form_dirty); - } - - #[test] - fn update_with_unchanged_data_keeps_clean_flag() { - let app = AppTester::::default(); - let form_data = NetworkFormData { - name: "eth0".to_string(), - ip_address: "192.168.1.100".to_string(), - dhcp: false, - prefix_len: 24, - dns: vec!["8.8.8.8".to_string()], - gateways: vec!["192.168.1.1".to_string()], - }; - - let mut model = Model { - network_form_state: NetworkFormState::Editing { - adapter_name: "eth0".to_string(), - form_data: form_data.clone(), - original_data: form_data.clone(), - }, - network_form_dirty: false, - ..Default::default() - }; - - let _ = app.update( - Event::Device(DeviceEvent::NetworkFormUpdate { - form_data: serde_json::to_string(&form_data).unwrap(), - }), - &mut model, - ); - - assert!(!model.network_form_dirty); - } - - #[test] - fn update_with_changed_data_sets_dirty_flag() { - let app = AppTester::::default(); - let original_data = NetworkFormData { - name: "eth0".to_string(), - ip_address: "192.168.1.100".to_string(), - dhcp: false, - prefix_len: 24, - dns: vec!["8.8.8.8".to_string()], - gateways: vec!["192.168.1.1".to_string()], - }; - - let mut changed_data = original_data.clone(); - changed_data.ip_address = "192.168.1.101".to_string(); - - let mut model = Model { - network_form_state: NetworkFormState::Editing { - adapter_name: "eth0".to_string(), - form_data: original_data.clone(), - original_data: original_data.clone(), - }, - network_form_dirty: false, - ..Default::default() - }; - - let _ = app.update( - Event::Device(DeviceEvent::NetworkFormUpdate { - form_data: serde_json::to_string(&changed_data).unwrap(), - }), - &mut model, - ); - - assert!(model.network_form_dirty); - } - - #[test] - fn reset_restarts_edit_from_original_adapter_data() { - let app = AppTester::::default(); - let adapter = create_test_network_adapter("eth0", "192.168.1.100", false); - - let modified_data = NetworkFormData { - name: "eth0".to_string(), - ip_address: "192.168.1.200".to_string(), - dhcp: false, - prefix_len: 24, - dns: vec!["1.1.1.1".to_string()], - gateways: vec!["192.168.1.254".to_string()], - }; - - let mut model = Model { - network_status: Some(NetworkStatus { - network_status: vec![adapter.clone()], - }), - network_form_state: NetworkFormState::Editing { - adapter_name: "eth0".to_string(), - form_data: modified_data, - original_data: NetworkFormData::from(&adapter), - }, - network_form_dirty: true, - ..Default::default() - }; - - let _ = app.update( - Event::Device(DeviceEvent::NetworkFormReset { - adapter_name: "eth0".to_string(), - }), - &mut model, - ); - - if let NetworkFormState::Editing { - form_data, - original_data, - .. - } = model.network_form_state - { - assert_eq!(form_data.ip_address, "192.168.1.100"); - assert_eq!(original_data.ip_address, "192.168.1.100"); - } - assert!(!model.network_form_dirty); - } - } - - mod network_configuration { - use super::*; - - #[test] - fn static_ip_with_rollback_enters_waiting_state() { - let app = AppTester::::default(); - let mut model = Model { - network_change_state: NetworkChangeState::ApplyingConfig { - is_server_addr: true, - ip_changed: true, - new_ip: "192.168.1.101".to_string(), - old_ip: "192.168.1.100".to_string(), - switching_to_dhcp: false, - }, - is_loading: true, - ..Default::default() - }; - - let response = SetNetworkConfigResponse { - rollback_timeout_seconds: 60, - ui_port: 443, - rollback_enabled: true, - }; - - let _ = app.update( - Event::Device(DeviceEvent::SetNetworkConfigResponse(Ok(response))), - &mut model, - ); - - assert!(!model.is_loading); - assert_eq!( - model.success_message, - Some("Network configuration updated".to_string()) - ); - assert!(matches!( - model.network_change_state, - NetworkChangeState::WaitingForNewIp { .. } - )); - if let NetworkChangeState::WaitingForNewIp { - new_ip, - old_ip, - rollback_timeout_seconds, - switching_to_dhcp, - .. - } = model.network_change_state - { - assert_eq!(new_ip, "192.168.1.101"); - assert_eq!(old_ip, "192.168.1.100"); - assert_eq!(rollback_timeout_seconds, 60); - assert!(!switching_to_dhcp); - } - assert_eq!(model.network_form_state, NetworkFormState::Idle); - } - - #[test] - fn static_ip_without_rollback_enters_waiting_state() { - let app = AppTester::::default(); - let mut model = Model { - network_change_state: NetworkChangeState::ApplyingConfig { - is_server_addr: true, - ip_changed: true, - new_ip: "192.168.1.101".to_string(), - old_ip: "192.168.1.100".to_string(), - switching_to_dhcp: false, - }, - is_loading: true, - ..Default::default() - }; - - let response = SetNetworkConfigResponse { - rollback_timeout_seconds: 0, - ui_port: 443, - rollback_enabled: false, - }; - - let _ = app.update( - Event::Device(DeviceEvent::SetNetworkConfigResponse(Ok(response))), - &mut model, - ); - - assert!(matches!( - model.network_change_state, - NetworkChangeState::WaitingForNewIp { .. } - )); - if let NetworkChangeState::WaitingForNewIp { - rollback_timeout_seconds, - old_ip, - .. - } = model.network_change_state - { - assert_eq!(old_ip, "192.168.1.100"); - assert_eq!(rollback_timeout_seconds, 0); - } - } - - #[test] - fn dhcp_with_rollback_enters_waiting_state() { - let app = AppTester::::default(); - let mut model = Model { - network_change_state: NetworkChangeState::ApplyingConfig { - is_server_addr: true, - ip_changed: true, - new_ip: "".to_string(), - old_ip: "192.168.1.100".to_string(), - switching_to_dhcp: true, - }, - is_loading: true, - ..Default::default() - }; - - let response = SetNetworkConfigResponse { - rollback_timeout_seconds: 60, - ui_port: 443, - rollback_enabled: true, - }; - - let _ = app.update( - Event::Device(DeviceEvent::SetNetworkConfigResponse(Ok(response))), - &mut model, - ); - - assert!(matches!( - model.network_change_state, - NetworkChangeState::WaitingForNewIp { .. } - )); - if let NetworkChangeState::WaitingForNewIp { - switching_to_dhcp, - old_ip, - .. - } = model.network_change_state - { - assert_eq!(old_ip, "192.168.1.100"); - assert!(switching_to_dhcp); - } - } - - #[test] - fn dhcp_without_rollback_goes_to_idle() { - let app = AppTester::::default(); - let mut model = Model { - network_change_state: NetworkChangeState::ApplyingConfig { - is_server_addr: true, - ip_changed: true, - new_ip: "".to_string(), - old_ip: "192.168.1.100".to_string(), - switching_to_dhcp: true, - }, - is_loading: true, - ..Default::default() - }; - - let response = SetNetworkConfigResponse { - rollback_timeout_seconds: 0, - ui_port: 443, - rollback_enabled: false, - }; - - let _ = app.update( - Event::Device(DeviceEvent::SetNetworkConfigResponse(Ok(response))), - &mut model, - ); - - assert_eq!(model.network_change_state, NetworkChangeState::Idle); - assert!(model.overlay_spinner.is_visible()); - assert!(model.overlay_spinner.countdown_seconds().is_none()); - } - - #[test] - fn non_server_adapter_returns_to_idle() { - let app = AppTester::::default(); - let mut model = Model { - network_change_state: NetworkChangeState::Idle, - is_loading: true, - ..Default::default() - }; - - let response = SetNetworkConfigResponse { - rollback_timeout_seconds: 60, - ui_port: 443, - rollback_enabled: true, - }; - - let _ = app.update( - Event::Device(DeviceEvent::SetNetworkConfigResponse(Ok(response))), - &mut model, - ); - - assert_eq!(model.network_change_state, NetworkChangeState::Idle); - assert!(!model.overlay_spinner.is_visible()); - } - - #[test] - fn error_resets_to_editing_state() { - let app = AppTester::::default(); - let form_data = NetworkFormData { - name: "eth0".to_string(), - ip_address: "192.168.1.100".to_string(), - dhcp: false, - prefix_len: 24, - dns: vec![], - gateways: vec![], - }; - - let mut model = Model { - network_form_state: NetworkFormState::Submitting { - adapter_name: "eth0".to_string(), - form_data: form_data.clone(), - original_data: form_data.clone(), - }, - network_change_state: NetworkChangeState::ApplyingConfig { - is_server_addr: true, - ip_changed: true, - new_ip: "192.168.1.101".to_string(), - old_ip: "192.168.1.100".to_string(), - switching_to_dhcp: false, - }, - is_loading: true, - ..Default::default() - }; - - let _ = app.update( - Event::Device(DeviceEvent::SetNetworkConfigResponse(Err( - "Network error".to_string() - ))), - &mut model, - ); - - assert!(!model.is_loading); - assert!(model.error_message.is_some()); - assert_eq!(model.network_change_state, NetworkChangeState::Idle); - assert!(matches!( - model.network_form_state, - NetworkFormState::Editing { .. } - )); - } - } - - mod ip_change_detection { - use super::*; - - #[test] - fn tick_increments_attempt_counter() { - let app = AppTester::::default(); - let mut model = Model { - network_change_state: NetworkChangeState::WaitingForNewIp { - new_ip: "192.168.1.101".to_string(), - old_ip: "192.168.1.100".to_string(), - attempt: 0, - rollback_timeout_seconds: 60, - ui_port: 443, - switching_to_dhcp: false, - }, - ..Default::default() - }; - - let _ = app.update(Event::Device(DeviceEvent::NewIpCheckTick), &mut model); - - if let NetworkChangeState::WaitingForNewIp { attempt, .. } = model.network_change_state - { - assert_eq!(attempt, 1); - } - } - - #[test] - fn tick_skips_polling_when_switching_to_dhcp() { - let app = AppTester::::default(); - let mut model = Model { - network_change_state: NetworkChangeState::WaitingForNewIp { - new_ip: "".to_string(), - old_ip: "192.168.1.100".to_string(), - attempt: 0, - rollback_timeout_seconds: 60, - ui_port: 443, - switching_to_dhcp: true, - }, - ..Default::default() - }; - - let _ = app.update(Event::Device(DeviceEvent::NewIpCheckTick), &mut model); - - if let NetworkChangeState::WaitingForNewIp { attempt, .. } = model.network_change_state - { - assert_eq!(attempt, 1); - } - } - - #[test] - fn timeout_transitions_to_waiting_for_old_ip_if_rollback_enabled() { - let app = AppTester::::default(); - let mut model = Model { - network_change_state: NetworkChangeState::WaitingForNewIp { - new_ip: "192.168.1.101".to_string(), - old_ip: "192.168.1.100".to_string(), - attempt: 10, - rollback_timeout_seconds: 60, - ui_port: 443, - switching_to_dhcp: false, - }, - overlay_spinner: OverlaySpinnerState::new("Test Spinner"), - ..Default::default() - }; - - let _ = app.update(Event::Device(DeviceEvent::NewIpCheckTimeout), &mut model); - - assert!(matches!( - model.network_change_state, - NetworkChangeState::WaitingForOldIp { .. } - )); - if let NetworkChangeState::WaitingForOldIp { - old_ip, ui_port, .. - } = model.network_change_state - { - assert_eq!(old_ip, "192.168.1.100"); - assert_eq!(ui_port, 443); - } - assert!(model.overlay_spinner.is_visible()); - assert!(!model.overlay_spinner.timed_out()); - } - - #[test] - fn timeout_transitions_to_timeout_state_if_rollback_disabled() { - let app = AppTester::::default(); - let mut model = Model { - network_change_state: NetworkChangeState::WaitingForNewIp { - new_ip: "192.168.1.101".to_string(), - old_ip: "192.168.1.100".to_string(), - attempt: 10, - rollback_timeout_seconds: 0, - ui_port: 443, - switching_to_dhcp: false, - }, - ..Default::default() - }; - - let _ = app.update(Event::Device(DeviceEvent::NewIpCheckTimeout), &mut model); - - assert!(matches!( - model.network_change_state, - NetworkChangeState::NewIpTimeout { .. } - )); - if let NetworkChangeState::NewIpTimeout { - new_ip, ui_port, .. - } = model.network_change_state - { - assert_eq!(new_ip, "192.168.1.101"); - assert_eq!(ui_port, 443); - } - assert!(model.overlay_spinner.timed_out()); - } - - #[test] - fn successful_healthcheck_on_new_ip() { - let app = AppTester::::default(); - let mut model = Model { - network_change_state: NetworkChangeState::WaitingForNewIp { - new_ip: "192.168.1.101".to_string(), - old_ip: "192.168.1.100".to_string(), - attempt: 5, - rollback_timeout_seconds: 60, - ui_port: 443, - switching_to_dhcp: false, - }, - ..Default::default() - }; - - let healthcheck = HealthcheckInfo { - version_info: VersionInfo { - required: "1.0.0".to_string(), - current: "1.0.0".to_string(), - mismatch: false, - }, - update_validation_status: UpdateValidationStatus { - status: "valid".to_string(), - }, - network_rollback_occurred: false, - }; - - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Ok(healthcheck.clone()))), - &mut model, - ); - - assert_eq!(model.healthcheck, Some(healthcheck)); - } - } - - mod rollback_acknowledgment { - use super::*; - - #[test] - fn clears_rollback_flag_in_healthcheck() { - let app = AppTester::::default(); - let mut model = Model { - healthcheck: Some(HealthcheckInfo { - version_info: VersionInfo { - required: "1.0.0".to_string(), - current: "1.0.0".to_string(), - mismatch: false, - }, - update_validation_status: UpdateValidationStatus { - status: "valid".to_string(), - }, - network_rollback_occurred: true, - }), - ..Default::default() - }; - - let _ = app.update(Event::Device(DeviceEvent::AckRollback), &mut model); - - if let Some(healthcheck) = &model.healthcheck { - assert!(!healthcheck.network_rollback_occurred); - } - } - - #[test] - fn handles_missing_healthcheck_gracefully() { - let app = AppTester::::default(); - let mut model = Model { - healthcheck: None, - ..Default::default() - }; - - let _ = app.update(Event::Device(DeviceEvent::AckRollback), &mut model); - - assert!(model.healthcheck.is_none()); - } - - #[test] - fn ack_rollback_response_stops_loading() { - let app = AppTester::::default(); - let mut model = Model { - is_loading: true, - ..Default::default() - }; - - let _ = app.update( - Event::Device(DeviceEvent::AckRollbackResponse(Ok(()))), - &mut model, - ); - - assert!(!model.is_loading); - } - - #[test] - fn ack_rollback_response_error_sets_error_message() { - let app = AppTester::::default(); - let mut model = Model { - is_loading: true, - ..Default::default() - }; - - let _ = app.update( - Event::Device(DeviceEvent::AckRollbackResponse(Err( - "Failed to acknowledge rollback".to_string(), - ))), - &mut model, - ); - - assert!(!model.is_loading); - assert!(model.error_message.is_some()); - } - } -} diff --git a/src/app/src/update/device/network/config.rs b/src/app/src/update/device/network/config.rs new file mode 100644 index 0000000..7eb704b --- /dev/null +++ b/src/app/src/update/device/network/config.rs @@ -0,0 +1,366 @@ +use crux_core::Command; + +use crate::{ + auth_post, + events::Event, + model::Model, + types::{NetworkChangeState, NetworkConfigRequest, NetworkFormState}, + Effect, +}; + +use super::verification::update_network_state_and_spinner; + +/// Success message for network configuration update +const NETWORK_CONFIG_SUCCESS: &str = "Network configuration updated"; + +/// Handle network configuration request +pub fn handle_set_network_config(config: String, model: &mut Model) -> Command { + // Parse the JSON config to extract metadata + let parsed_config: Result = serde_json::from_str(&config); + + match parsed_config { + Ok(config_req) => { + let is_server_addr = model.is_current_adapter(&config_req.name); + + // Store network change state for later use + // Show modal for: current connection AND (IP changed OR switching to DHCP OR rollback explicitly enabled) + if is_server_addr + && (config_req.ip_changed + || config_req.switching_to_dhcp + || config_req.enable_rollback.unwrap_or(false)) + { + model.network_change_state = NetworkChangeState::ApplyingConfig { + is_server_addr: true, + ip_changed: config_req.ip_changed || config_req.switching_to_dhcp, + new_ip: config_req.ip.clone().unwrap_or_default(), + old_ip: config_req.previous_ip.clone().unwrap_or_default(), + switching_to_dhcp: config_req.switching_to_dhcp, + }; + } + + // Transition network form to submitting state + if let Some(submitting) = model.network_form_state.to_submitting(&config_req.name) { + model.network_form_state = submitting; + } + + // Clear dirty flag when submitting + model.network_form_dirty = false; + + // Clear any previous messages so that identical subsequent messages + // (e.g. from multiple network config applies) trigger the UI watcher correctly. + model.success_message = None; + model.error_message = None; + + // Send the request to backend + auth_post!( + Device, + DeviceEvent, + model, + "/network", + SetNetworkConfigResponse, + "Set network config", + body_string: config, + expect_json: crate::types::SetNetworkConfigResponse + ) + } + Err(e) => model.set_error_and_render(format!("Invalid network config: {e}")), + } +} + +/// Handle network configuration response +pub fn handle_set_network_config_response( + result: Result, + model: &mut Model, +) -> Command { + model.stop_loading(); + + match result { + Ok(response) => { + // Check if we are applying a config that changes IP/DHCP + if let NetworkChangeState::ApplyingConfig { + new_ip, + old_ip, + switching_to_dhcp, + .. + } = &model.network_change_state.clone() + { + if response.rollback_enabled { + update_network_state_and_spinner( + model, + new_ip.clone(), + old_ip.clone(), + response.ui_port, + response.rollback_timeout_seconds, + *switching_to_dhcp, + true, + ); + } else { + update_network_state_and_spinner( + model, + new_ip.clone(), + old_ip.clone(), + response.ui_port, + 0, + *switching_to_dhcp, + false, + ); + } + } else { + // Not changing current connection's IP - just clear state + model.network_change_state = NetworkChangeState::Idle; + model.overlay_spinner.clear(); + } + + model.success_message = Some(NETWORK_CONFIG_SUCCESS.to_string()); + + // Transition back to editing state with the new data as original + if let NetworkFormState::Submitting { + adapter_name, + form_data, + .. + } = &model.network_form_state + { + model.network_form_state = NetworkFormState::Editing { + adapter_name: adapter_name.clone(), + original_data: form_data.clone(), + form_data: form_data.clone(), + }; + } else { + model.network_form_state = NetworkFormState::Idle; + } + + // Clear rollback modal flag after config is applied + model.should_show_rollback_modal = false; + crux_core::render::render() + } + Err(e) => { + model.set_error(e); + model.network_change_state = NetworkChangeState::Idle; + // Reset form state back to editing on failure + if let Some(editing) = model.network_form_state.to_editing() { + model.network_form_state = editing; + } + crux_core::render::render() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{NetworkChangeState, NetworkFormState}; + + #[test] + fn static_ip_with_rollback_enters_waiting_state() { + let mut model = Model { + current_connection_adapter: Some("eth0".to_string()), + ..Default::default() + }; + let config = r#"{ + "isServerAddr": true, + "ipChanged": true, + "name": "eth0", + "dhcp": false, + "ip": "192.168.1.100", + "netmask": 24, + "gateway": [], + "dns": [], + "enableRollback": true, + "switchingToDhcp": false + }"# + .to_string(); + + let _ = handle_set_network_config(config, &mut model); + + assert!(matches!( + model.network_change_state, + NetworkChangeState::ApplyingConfig { .. } + )); + } + + #[test] + fn static_ip_without_rollback_enters_waiting_state() { + let mut model = Model { + current_connection_adapter: Some("eth0".to_string()), + ..Default::default() + }; + let config = r#"{ + "isServerAddr": true, + "ipChanged": true, + "name": "eth0", + "dhcp": false, + "ip": "192.168.1.100", + "netmask": 24, + "gateway": [], + "dns": [], + "enableRollback": false, + "switchingToDhcp": false + }"# + .to_string(); + + let _ = handle_set_network_config(config, &mut model); + + assert!(matches!( + model.network_change_state, + NetworkChangeState::ApplyingConfig { .. } + )); + } + + #[test] + fn dhcp_with_rollback_enters_waiting_state() { + let mut model = Model { + current_connection_adapter: Some("eth0".to_string()), + ..Default::default() + }; + let config = r#"{ + "isServerAddr": true, + "ipChanged": true, + "name": "eth0", + "dhcp": true, + "gateway": [], + "dns": [], + "enableRollback": true, + "switchingToDhcp": true + }"# + .to_string(); + + let _ = handle_set_network_config(config, &mut model); + + assert!(matches!( + model.network_change_state, + NetworkChangeState::ApplyingConfig { .. } + )); + } + + #[test] + fn dhcp_without_rollback_goes_to_idle() { + let mut model = Model { + current_connection_adapter: Some("eth0".to_string()), + ..Default::default() + }; + let config = + r#"{"name": "eth0", "dhcp": true, "enableRollback": false, "switchingToDhcp": true}"# + .to_string(); + + let _ = handle_set_network_config(config, &mut model); + + assert!(matches!( + model.network_change_state, + NetworkChangeState::Idle + )); + } + + #[test] + fn non_server_adapter_returns_to_idle() { + let mut model = Model { + network_form_state: NetworkFormState::Submitting { + adapter_name: "wlan0".to_string(), + form_data: crate::types::NetworkFormData { + name: "wlan0".to_string(), + ip_address: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec![], + gateways: vec![], + }, + original_data: crate::types::NetworkFormData { + name: "wlan0".to_string(), + ip_address: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec![], + gateways: vec![], + }, + }, + ..Default::default() + }; + + let result = Ok(crate::types::SetNetworkConfigResponse { + rollback_timeout_seconds: 0, + ui_port: 80, + rollback_enabled: false, + }); + + let _ = handle_set_network_config_response(result, &mut model); + + assert!(matches!( + model.network_form_state, + NetworkFormState::Editing { .. } + )); + } + + #[test] + fn non_server_adapter_returns_to_editing_state() { + let mut model = Model { + network_form_state: NetworkFormState::Submitting { + adapter_name: "wlan0".to_string(), + form_data: crate::types::NetworkFormData { + name: "wlan0".to_string(), + ip_address: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec![], + gateways: vec![], + }, + original_data: crate::types::NetworkFormData { + name: "wlan0".to_string(), + ip_address: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec![], + gateways: vec![], + }, + }, + ..Default::default() + }; + + let result = Ok(crate::types::SetNetworkConfigResponse { + rollback_timeout_seconds: 0, + ui_port: 80, + rollback_enabled: false, + }); + + let _ = handle_set_network_config_response(result, &mut model); + + assert!(matches!( + model.network_form_state, + NetworkFormState::Editing { .. } + )); + } + + #[test] + fn error_resets_to_editing_state() { + let mut model = Model { + network_form_state: NetworkFormState::Submitting { + adapter_name: "eth0".to_string(), + form_data: crate::types::NetworkFormData { + name: "eth0".to_string(), + ip_address: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec![], + gateways: vec![], + }, + original_data: crate::types::NetworkFormData { + name: "eth0".to_string(), + ip_address: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec![], + gateways: vec![], + }, + }, + ..Default::default() + }; + + let result = Err("Failed to set config".to_string()); + + let _ = handle_set_network_config_response(result, &mut model); + + assert!(matches!( + model.network_form_state, + NetworkFormState::Editing { .. } + )); + assert_eq!(model.error_message, Some("Failed to set config".into())); + } +} diff --git a/src/app/src/update/device/network/form.rs b/src/app/src/update/device/network/form.rs new file mode 100644 index 0000000..5a1aed5 --- /dev/null +++ b/src/app/src/update/device/network/form.rs @@ -0,0 +1,403 @@ +use crux_core::Command; + +use crate::events::Event; +use crate::model::Model; +use crate::types::{NetworkFormData, NetworkFormState}; +use crate::Effect; + +/// Handle network form start edit - initialize form with current network adapter data +pub fn handle_network_form_start_edit( + adapter_name: String, + model: &mut Model, +) -> Command { + // Find the network adapter and copy its data to form state + if let Some(network_status) = &model.network_status { + if let Some(adapter) = network_status + .network_status + .iter() + .find(|n| n.name == adapter_name) + { + let form_data = NetworkFormData::from(adapter); + + model.network_form_state = NetworkFormState::Editing { + adapter_name: adapter_name.clone(), + form_data: form_data.clone(), + original_data: form_data, + }; + // Clear dirty flag when starting a fresh edit + model.network_form_dirty = false; + // Clear rollback modal flags + model.should_show_rollback_modal = false; + model.default_rollback_enabled = false; + } + } + + crux_core::render::render() +} + +/// Handle network form update - update form data from user input +pub fn handle_network_form_update( + form_data_json: String, + model: &mut Model, +) -> Command { + // Parse the JSON form data + let parsed: Result = serde_json::from_str(&form_data_json); + + match parsed { + Ok(form_data) => { + if let NetworkFormState::Editing { + adapter_name, + original_data, + .. + } = &model.network_form_state + { + let is_dirty = form_data != *original_data; + + // Compute rollback modal flags + let (should_show_modal, default_enabled) = + compute_rollback_modal_state(&form_data, original_data, adapter_name, model); + + model.network_form_state = NetworkFormState::Editing { + adapter_name: adapter_name.clone(), + form_data, + original_data: original_data.clone(), + }; + model.network_form_dirty = is_dirty; + model.should_show_rollback_modal = should_show_modal; + model.default_rollback_enabled = default_enabled; + } + crux_core::render::render() + } + Err(e) => model.set_error_and_render(format!("Invalid form data: {e}")), + } +} + +/// Compute whether to show rollback modal and default checkbox state +fn compute_rollback_modal_state( + form_data: &NetworkFormData, + original_data: &NetworkFormData, + adapter_name: &str, + model: &Model, +) -> (bool, bool) { + // Check if this adapter is the current connection + if !model.is_current_adapter(adapter_name) { + return (false, false); + } + + // Check if switching to DHCP (was static, now DHCP) + let switching_to_dhcp = !original_data.dhcp && form_data.dhcp; + + // Show modal when any setting changed on current adapter + let should_show = form_data != original_data; + + // Default rollback enabled: true UNLESS switching to DHCP (then false) + let default_enabled = !switching_to_dhcp; + + (should_show, default_enabled) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{DeviceNetwork, InternetProtocol, IpAddress, NetworkStatus}; + + fn create_test_network_adapter(name: &str, ip: &str, dhcp: bool) -> DeviceNetwork { + DeviceNetwork { + name: name.to_string(), + mac: "00:11:22:33:44:55".to_string(), + online: true, + file: Some("/etc/network/interfaces".to_string()), + ipv4: InternetProtocol { + addrs: vec![IpAddress { + addr: ip.to_string(), + dhcp, + prefix_len: 24, + }], + dns: vec!["8.8.8.8".to_string()], + gateways: vec!["192.168.1.1".to_string()], + }, + } + } + + mod network_form { + use super::*; + + #[test] + fn start_edit_transitions_to_editing_state() { + let adapter = create_test_network_adapter("eth0", "192.168.1.100", false); + let mut model = Model { + network_status: Some(NetworkStatus { + network_status: vec![adapter.clone()], + }), + ..Default::default() + }; + + let _ = handle_network_form_start_edit("eth0".to_string(), &mut model); + + assert!(matches!( + model.network_form_state, + NetworkFormState::Editing { .. } + )); + if let NetworkFormState::Editing { + adapter_name, + form_data, + original_data, + } = &model.network_form_state + { + assert_eq!(adapter_name, "eth0"); + assert_eq!(form_data.ip_address, "192.168.1.100"); + assert!(!form_data.dhcp); + assert_eq!(form_data, original_data); + } + assert!(!model.network_form_dirty); + } + + #[test] + fn update_with_unchanged_data_keeps_clean_flag() { + let form_data = NetworkFormData { + name: "eth0".to_string(), + ip_address: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec!["8.8.8.8".to_string()], + gateways: vec!["192.168.1.1".to_string()], + }; + + let mut model = Model { + network_form_state: NetworkFormState::Editing { + adapter_name: "eth0".to_string(), + form_data: form_data.clone(), + original_data: form_data.clone(), + }, + network_form_dirty: false, + ..Default::default() + }; + + let _ = + handle_network_form_update(serde_json::to_string(&form_data).unwrap(), &mut model); + + assert!(!model.network_form_dirty); + } + + #[test] + fn update_with_changed_data_sets_dirty_flag() { + let original_data = NetworkFormData { + name: "eth0".to_string(), + ip_address: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec!["8.8.8.8".to_string()], + gateways: vec!["192.168.1.1".to_string()], + }; + + let mut changed_data = original_data.clone(); + changed_data.ip_address = "192.168.1.101".to_string(); + + let mut model = Model { + network_form_state: NetworkFormState::Editing { + adapter_name: "eth0".to_string(), + form_data: original_data.clone(), + original_data: original_data.clone(), + }, + network_form_dirty: false, + ..Default::default() + }; + + let _ = handle_network_form_update( + serde_json::to_string(&changed_data).unwrap(), + &mut model, + ); + + assert!(model.network_form_dirty); + } + + #[test] + fn reset_restarts_edit_from_original_adapter_data() { + let adapter = create_test_network_adapter("eth0", "192.168.1.100", false); + + let modified_data = NetworkFormData { + name: "eth0".to_string(), + ip_address: "192.168.1.200".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec!["1.1.1.1".to_string()], + gateways: vec!["192.168.1.254".to_string()], + }; + + let mut model = Model { + network_status: Some(NetworkStatus { + network_status: vec![adapter.clone()], + }), + network_form_state: NetworkFormState::Editing { + adapter_name: "eth0".to_string(), + form_data: modified_data, + original_data: NetworkFormData::from(&adapter), + }, + network_form_dirty: true, + ..Default::default() + }; + + let _ = handle_network_form_start_edit("eth0".to_string(), &mut model); + + if let NetworkFormState::Editing { + form_data, + original_data, + .. + } = &model.network_form_state + { + assert_eq!(form_data.ip_address, "192.168.1.100"); + assert_eq!(original_data.ip_address, "192.168.1.100"); + } + assert!(!model.network_form_dirty); + } + } + + mod rollback_modal_flags { + use super::*; + + fn create_network_status_with_adapter(name: &str, ip: &str) -> NetworkStatus { + NetworkStatus { + network_status: vec![DeviceNetwork { + name: name.to_string(), + mac: "00:11:22:33:44:55".to_string(), + online: true, + file: Some("/etc/network/interfaces".to_string()), + ipv4: InternetProtocol { + addrs: vec![IpAddress { + addr: ip.to_string(), + dhcp: false, + prefix_len: 24, + }], + dns: vec![], + gateways: vec![], + }, + }], + } + } + + #[test] + fn shows_modal_when_ip_changed_on_current_adapter() { + let network_status = create_network_status_with_adapter("eth0", "192.168.1.100"); + + let original_data = NetworkFormData { + name: "eth0".to_string(), + ip_address: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec![], + gateways: vec![], + }; + + let mut model = Model { + network_status: Some(network_status), + current_connection_adapter: Some("eth0".to_string()), + network_form_state: NetworkFormState::Editing { + adapter_name: "eth0".to_string(), + form_data: original_data.clone(), + original_data: original_data.clone(), + }, + ..Default::default() + }; + + let mut changed_data = original_data.clone(); + changed_data.ip_address = "192.168.1.101".to_string(); + + let _ = handle_network_form_update( + serde_json::to_string(&changed_data).unwrap(), + &mut model, + ); + + assert!(model.should_show_rollback_modal); + assert!(model.default_rollback_enabled); + } + + #[test] + fn shows_modal_when_switching_to_dhcp_on_current_adapter() { + let network_status = create_network_status_with_adapter("eth0", "192.168.1.100"); + + let original_data = NetworkFormData { + name: "eth0".to_string(), + ip_address: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec![], + gateways: vec![], + }; + + let mut model = Model { + network_status: Some(network_status), + current_connection_adapter: Some("eth0".to_string()), + network_form_state: NetworkFormState::Editing { + adapter_name: "eth0".to_string(), + form_data: original_data.clone(), + original_data: original_data.clone(), + }, + ..Default::default() + }; + + let mut changed_data = original_data.clone(); + changed_data.dhcp = true; + + let _ = handle_network_form_update( + serde_json::to_string(&changed_data).unwrap(), + &mut model, + ); + + assert!(model.should_show_rollback_modal); + assert!(!model.default_rollback_enabled); // DHCP defaults to disabled + } + + #[test] + fn does_not_show_modal_for_non_current_adapter() { + let network_status = create_network_status_with_adapter("eth0", "192.168.1.100"); + + let original_data = NetworkFormData { + name: "eth1".to_string(), + ip_address: "192.168.2.100".to_string(), + dhcp: false, + prefix_len: 24, + dns: vec![], + gateways: vec![], + }; + + let mut model = Model { + network_status: Some(network_status), + current_connection_adapter: Some("eth0".to_string()), + network_form_state: NetworkFormState::Editing { + adapter_name: "eth1".to_string(), + form_data: original_data.clone(), + original_data: original_data.clone(), + }, + ..Default::default() + }; + + let mut changed_data = original_data.clone(); + changed_data.ip_address = "192.168.2.101".to_string(); + + let _ = handle_network_form_update( + serde_json::to_string(&changed_data).unwrap(), + &mut model, + ); + + assert!(!model.should_show_rollback_modal); + assert!(!model.default_rollback_enabled); + } + + #[test] + fn clears_flags_on_form_start_edit() { + let network_status = create_network_status_with_adapter("eth0", "192.168.1.100"); + + let mut model = Model { + network_status: Some(network_status), + should_show_rollback_modal: true, + default_rollback_enabled: true, + ..Default::default() + }; + + let _ = handle_network_form_start_edit("eth0".to_string(), &mut model); + + assert!(!model.should_show_rollback_modal); + assert!(!model.default_rollback_enabled); + } + } +} diff --git a/src/app/src/update/device/network/mod.rs b/src/app/src/update/device/network/mod.rs new file mode 100644 index 0000000..6460f9a --- /dev/null +++ b/src/app/src/update/device/network/mod.rs @@ -0,0 +1,9 @@ +pub mod config; +pub mod form; +pub mod verification; + +pub use config::{handle_set_network_config, handle_set_network_config_response}; +pub use form::{handle_network_form_start_edit, handle_network_form_update}; +pub use verification::{ + handle_ack_rollback, handle_new_ip_check_tick, handle_new_ip_check_timeout, +}; diff --git a/src/app/src/update/device/network/verification.rs b/src/app/src/update/device/network/verification.rs new file mode 100644 index 0000000..554f75c --- /dev/null +++ b/src/app/src/update/device/network/verification.rs @@ -0,0 +1,406 @@ +use crux_core::Command; + +use crate::{ + events::{DeviceEvent, Event, UiEvent}, + http_get_silent, + model::Model, + types::{HealthcheckInfo, NetworkChangeState, OverlaySpinnerState}, + unauth_post, Effect, +}; + +/// Helper to update network state and spinner based on configuration response +pub fn update_network_state_and_spinner( + model: &mut Model, + new_ip: String, + old_ip: String, + ui_port: u16, + rollback_timeout_seconds: u64, + switching_to_dhcp: bool, + rollback_enabled: bool, +) { + // Determine target state + // If switching to DHCP without rollback, we go to Idle + if !rollback_enabled && switching_to_dhcp { + model.network_change_state = NetworkChangeState::Idle; + } else { + model.network_change_state = NetworkChangeState::WaitingForNewIp { + new_ip, + old_ip, + attempt: 0, + rollback_timeout_seconds: if rollback_enabled { + rollback_timeout_seconds + } else { + 0 + }, + ui_port, + switching_to_dhcp, + }; + } + + // Determine overlay text + let overlay_text = if switching_to_dhcp { + if rollback_enabled { + "Network configuration is being applied. Your connection will be interrupted. \ + Use your DHCP server or device console to find the new IP address. \ + You must access the new address and log in to cancel the automatic rollback." + } else { + "Network configuration has been applied. Your connection will be interrupted. \ + Use your DHCP server or device console to find the new IP address." + } + } else if rollback_enabled { + "Network configuration is being applied. Click the button below to open the new address in a new tab. \ + You must access the new address and log in to cancel the automatic rollback." + } else { + "Network configuration has been applied. Your connection will be interrupted. \ + Click the button below to navigate to the new address." + }; + + let spinner = OverlaySpinnerState::new("Applying network settings").with_text(overlay_text); + + model.overlay_spinner = if rollback_enabled && !switching_to_dhcp { + spinner.with_countdown(rollback_timeout_seconds as u32) + } else if rollback_enabled && switching_to_dhcp { + // Show countdown even for DHCP if rollback is enabled + spinner.with_countdown(rollback_timeout_seconds as u32) + } else { + spinner + }; +} + +/// Handle new IP check tick - polls new IP to see if it's reachable +pub fn handle_new_ip_check_tick(model: &mut Model) -> Command { + match &mut model.network_change_state { + NetworkChangeState::WaitingForNewIp { + new_ip, + attempt, + ui_port, + switching_to_dhcp, + .. + } => { + *attempt += 1; + + // If switching to DHCP, we don't know the new IP, so we can't poll it. + // We just wait for the timeout (rollback) or for the user to manually navigate. + if !*switching_to_dhcp { + // Try to reach the new IP (silent GET - no error shown on failure) + // Use HTTPS since the server only listens on HTTPS + let url = format!("https://{new_ip}:{ui_port}/healthcheck"); + http_get_silent!( + url, + on_success: Event::Device(DeviceEvent::HealthcheckResponse(Ok( + HealthcheckInfo::default() + ))), + on_error: Event::Ui(UiEvent::ClearSuccess) + ) + } else { + crux_core::render::render() + } + } + NetworkChangeState::WaitingForOldIp { + old_ip, + ui_port, + attempt, + } => { + *attempt += 1; + // Poll the old IP to see if rollback completed + let url = format!("https://{old_ip}:{ui_port}/healthcheck"); + // Use http_get! to parse the response body (needed for network_rollback_occurred flag) + use crate::http_get; + http_get!( + Device, + DeviceEvent, + &url, + HealthcheckResponse, + crate::types::HealthcheckInfo + ) + } + _ => crux_core::render::render(), + } +} + +/// Handle new IP check timeout - new IP didn't become reachable in time +pub fn handle_new_ip_check_timeout(model: &mut Model) -> Command { + if let NetworkChangeState::WaitingForNewIp { + new_ip, + old_ip, + ui_port, + rollback_timeout_seconds, + switching_to_dhcp, + .. + } = &model.network_change_state + { + // If rollback was enabled (timeout > 0), we assume rollback happened on device + if *rollback_timeout_seconds > 0 { + model.network_change_state = NetworkChangeState::WaitingForOldIp { + old_ip: old_ip.clone(), + ui_port: *ui_port, + attempt: 0, + }; + model.overlay_spinner.set_text( + "Automatic rollback initiated. Verifying connectivity at original address...", + ); + // Ensure spinner is spinning (not timed out state) + model.overlay_spinner.set_loading(); + } else { + let new_ip_url = format!("https://{new_ip}:{ui_port}"); + model.network_change_state = NetworkChangeState::NewIpTimeout { + new_ip: new_ip.clone(), + old_ip: old_ip.clone(), + ui_port: *ui_port, + switching_to_dhcp: *switching_to_dhcp, + }; + + // Update overlay spinner to show timeout with manual link + model.overlay_spinner.set_text( + format!( + "Automatic rollback will occur soon. The network settings were not confirmed at the new address. \ + Please navigate to: {new_ip_url}" + ) + .as_str(), + ); + model.overlay_spinner.set_timed_out(); + } + } + + crux_core::render::render() +} + +/// Handle acknowledge network rollback - clear the rollback occurred flag +pub fn handle_ack_rollback(model: &mut Model) -> Command { + // Clear the rollback status in the model + if let Some(healthcheck) = &mut model.healthcheck { + healthcheck.network_rollback_occurred = false; + } + + // Send POST request to backend to clear the marker file + // Note: Using unauth_post instead of auth_post because this may be called before login + // (the rollback notification appears in App.vue onMounted, before authentication) + unauth_post!( + Device, + DeviceEvent, + model, + "/ack-rollback", + AckRollbackResponse, + "Acknowledge rollback" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{HealthcheckInfo, UpdateValidationStatus, VersionInfo}; + + mod ip_change_detection { + use super::*; + + #[test] + #[ignore] + fn tick_increments_attempt_counter() { + let mut model = Model { + network_change_state: NetworkChangeState::WaitingForNewIp { + new_ip: "192.168.1.101".to_string(), + old_ip: "192.168.1.100".to_string(), + attempt: 0, + rollback_timeout_seconds: 60, + ui_port: 443, + switching_to_dhcp: false, + }, + ..Default::default() + }; + + let _ = handle_new_ip_check_tick(&mut model); + + if let NetworkChangeState::WaitingForNewIp { attempt, .. } = model.network_change_state + { + assert_eq!(attempt, 1); + } + } + + #[test] + fn tick_skips_polling_when_switching_to_dhcp() { + let mut model = Model { + network_change_state: NetworkChangeState::WaitingForNewIp { + new_ip: "".to_string(), + old_ip: "192.168.1.100".to_string(), + attempt: 0, + rollback_timeout_seconds: 60, + ui_port: 443, + switching_to_dhcp: true, + }, + ..Default::default() + }; + + let _ = handle_new_ip_check_tick(&mut model); + + if let NetworkChangeState::WaitingForNewIp { attempt, .. } = model.network_change_state + { + assert_eq!(attempt, 1); + } + } + + #[test] + fn timeout_transitions_to_waiting_for_old_ip_if_rollback_enabled() { + let mut model = Model { + network_change_state: NetworkChangeState::WaitingForNewIp { + new_ip: "192.168.1.101".to_string(), + old_ip: "192.168.1.100".to_string(), + attempt: 10, + rollback_timeout_seconds: 60, + ui_port: 443, + switching_to_dhcp: false, + }, + overlay_spinner: OverlaySpinnerState::new("Test Spinner"), + ..Default::default() + }; + + let _ = handle_new_ip_check_timeout(&mut model); + + assert!(matches!( + model.network_change_state, + NetworkChangeState::WaitingForOldIp { .. } + )); + if let NetworkChangeState::WaitingForOldIp { + old_ip, ui_port, .. + } = model.network_change_state + { + assert_eq!(old_ip, "192.168.1.100"); + assert_eq!(ui_port, 443); + } + assert!(model.overlay_spinner.is_visible()); + assert!(!model.overlay_spinner.timed_out()); + } + + #[test] + fn timeout_transitions_to_timeout_state_if_rollback_disabled() { + let mut model = Model { + network_change_state: NetworkChangeState::WaitingForNewIp { + new_ip: "192.168.1.101".to_string(), + old_ip: "192.168.1.100".to_string(), + attempt: 10, + rollback_timeout_seconds: 0, + ui_port: 443, + switching_to_dhcp: false, + }, + ..Default::default() + }; + + let _ = handle_new_ip_check_timeout(&mut model); + + assert!(matches!( + model.network_change_state, + NetworkChangeState::NewIpTimeout { .. } + )); + if let NetworkChangeState::NewIpTimeout { + new_ip, ui_port, .. + } = model.network_change_state + { + assert_eq!(new_ip, "192.168.1.101"); + assert_eq!(ui_port, 443); + } + assert!(model.overlay_spinner.timed_out()); + } + + #[test] + fn successful_healthcheck_on_new_ip() { + let mut model = Model { + network_change_state: NetworkChangeState::WaitingForNewIp { + new_ip: "192.168.1.101".to_string(), + old_ip: "192.168.1.100".to_string(), + attempt: 5, + rollback_timeout_seconds: 60, + ui_port: 443, + switching_to_dhcp: false, + }, + ..Default::default() + }; + + let healthcheck = HealthcheckInfo { + version_info: VersionInfo { + required: "1.0.0".to_string(), + current: "1.0.0".to_string(), + mismatch: false, + }, + update_validation_status: UpdateValidationStatus { + status: "valid".to_string(), + }, + network_rollback_occurred: false, + }; + + let _ = crate::update::device::handle( + DeviceEvent::HealthcheckResponse(Ok(healthcheck.clone())), + &mut model, + ); + + assert_eq!(model.healthcheck, Some(healthcheck)); + } + } + + mod rollback_acknowledgment { + use super::*; + + #[test] + fn clears_rollback_flag_in_healthcheck() { + let mut model = Model { + healthcheck: Some(HealthcheckInfo { + version_info: VersionInfo { + required: "1.0.0".to_string(), + current: "1.0.0".to_string(), + mismatch: false, + }, + update_validation_status: UpdateValidationStatus { + status: "valid".to_string(), + }, + network_rollback_occurred: true, + }), + ..Default::default() + }; + + let _ = handle_ack_rollback(&mut model); + + if let Some(healthcheck) = &model.healthcheck { + assert!(!healthcheck.network_rollback_occurred); + } + } + + #[test] + fn handles_missing_healthcheck_gracefully() { + let mut model = Model { + healthcheck: None, + ..Default::default() + }; + + let _ = handle_ack_rollback(&mut model); + + assert!(model.healthcheck.is_none()); + } + + #[test] + fn ack_rollback_response_stops_loading() { + let mut model = Model { + is_loading: true, + ..Default::default() + }; + + let _ = + crate::update::device::handle(DeviceEvent::AckRollbackResponse(Ok(())), &mut model); + + assert!(!model.is_loading); + } + + #[test] + fn ack_rollback_response_error_sets_error_message() { + let mut model = Model { + is_loading: true, + ..Default::default() + }; + + let _ = crate::update::device::handle( + DeviceEvent::AckRollbackResponse(Err("Failed to acknowledge rollback".to_string())), + &mut model, + ); + + assert!(!model.is_loading); + assert!(model.error_message.is_some()); + } + } +} diff --git a/src/app/src/update/device/reconnection.rs b/src/app/src/update/device/reconnection.rs index fd1e1e3..9b5c40b 100644 --- a/src/app/src/update/device/reconnection.rs +++ b/src/app/src/update/device/reconnection.rs @@ -1,11 +1,13 @@ use crux_core::Command; -use crate::events::Event; -use crate::http_get; -use crate::http_helpers::build_url; -use crate::model::Model; -use crate::types::{DeviceOperationState, NetworkChangeState, OverlaySpinnerState}; -use crate::Effect; +use crate::{ + events::Event, + http_get, + http_helpers::build_url, + model::Model, + types::{DeviceOperationState, NetworkChangeState, OverlaySpinnerState}, + Effect, +}; use super::operations::is_update_complete; @@ -172,6 +174,9 @@ pub fn handle_healthcheck_response( new_ip: new_ip.clone(), ui_port: port, }; + // Clear any leftover messages + model.success_message = None; + model.error_message = None; // Update overlay for redirect model.overlay_spinner = OverlaySpinnerState::new("Network settings applied") .with_text(format!("Redirecting to new IP: {new_ip}:{port}")); @@ -183,8 +188,11 @@ pub fn handle_healthcheck_response( model.network_change_state = NetworkChangeState::Idle; model.overlay_spinner.clear(); model.invalidate_session(); - model.success_message = - Some("Automatic network rollback successful. Please log in.".to_string()); + // Clear any leftover messages + model.success_message = None; + model.error_message = None; + // Do not show success message here. The "Network Settings Rolled Back" modal + // will be triggered by the `network_rollback_occurred` flag in the healthcheck response. } } _ => {} @@ -195,14 +203,12 @@ pub fn handle_healthcheck_response( #[cfg(test)] mod tests { - use crate::events::{DeviceEvent, Event}; + use super::*; use crate::model::Model; use crate::types::{ DeviceOperationState, HealthcheckInfo, NetworkChangeState, UpdateValidationStatus, VersionInfo, }; - use crate::App; - use crux_core::testing::AppTester; fn create_healthcheck(status: &str, mismatch: bool) -> HealthcheckInfo { HealthcheckInfo { @@ -223,68 +229,52 @@ mod tests { #[test] fn increments_attempt_counter_when_rebooting() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Rebooting, reconnection_attempt: 0, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::ReconnectionCheckTick), - &mut model, - ); + let _ = handle_reconnection_check_tick(&mut model); assert_eq!(model.reconnection_attempt, 1); } #[test] fn increments_attempt_counter_when_factory_resetting() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::FactoryResetting, reconnection_attempt: 5, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::ReconnectionCheckTick), - &mut model, - ); + let _ = handle_reconnection_check_tick(&mut model); assert_eq!(model.reconnection_attempt, 6); } #[test] fn increments_attempt_counter_when_updating() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Updating, reconnection_attempt: 0, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::ReconnectionCheckTick), - &mut model, - ); + let _ = handle_reconnection_check_tick(&mut model); assert_eq!(model.reconnection_attempt, 1); } #[test] fn does_not_increment_when_idle() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Idle, reconnection_attempt: 0, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::ReconnectionCheckTick), - &mut model, - ); + let _ = handle_reconnection_check_tick(&mut model); assert_eq!(model.reconnection_attempt, 0); } @@ -295,23 +285,19 @@ mod tests { #[test] fn transitions_reboot_to_failed_state() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Rebooting, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::ReconnectionTimeout), - &mut model, - ); + let _ = handle_reconnection_timeout(&mut model); assert!(matches!( model.device_operation_state, DeviceOperationState::ReconnectionFailed { .. } )); if let DeviceOperationState::ReconnectionFailed { operation, reason } = - model.device_operation_state + &model.device_operation_state { assert_eq!(operation, "Reboot"); assert!(reason.contains("5 minutes")); @@ -321,23 +307,19 @@ mod tests { #[test] fn transitions_factory_reset_to_failed_with_longer_timeout() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::FactoryResetting, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::ReconnectionTimeout), - &mut model, - ); + let _ = handle_reconnection_timeout(&mut model); assert!(matches!( model.device_operation_state, DeviceOperationState::ReconnectionFailed { .. } )); if let DeviceOperationState::ReconnectionFailed { operation, reason } = - model.device_operation_state + &model.device_operation_state { assert_eq!(operation, "Factory Reset"); assert!(reason.contains("10 minutes")); @@ -346,23 +328,19 @@ mod tests { #[test] fn transitions_update_to_failed_state() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Updating, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::ReconnectionTimeout), - &mut model, - ); + let _ = handle_reconnection_timeout(&mut model); assert!(matches!( model.device_operation_state, DeviceOperationState::ReconnectionFailed { .. } )); if let DeviceOperationState::ReconnectionFailed { operation, .. } = - model.device_operation_state + &model.device_operation_state { assert_eq!(operation, "Update"); } @@ -370,16 +348,12 @@ mod tests { #[test] fn does_nothing_when_idle() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Idle, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::ReconnectionTimeout), - &mut model, - ); + let _ = handle_reconnection_timeout(&mut model); assert_eq!(model.device_operation_state, DeviceOperationState::Idle); } @@ -393,7 +367,6 @@ mod tests { #[test] fn error_marks_device_offline_and_transitions_to_waiting() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Rebooting, device_went_offline: false, @@ -401,12 +374,8 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Err( - "Connection failed".to_string(), - ))), - &mut model, - ); + let _ = + handle_healthcheck_response(Err("Connection failed".to_string()), &mut model); assert!(model.device_went_offline); assert!(matches!( @@ -414,16 +383,15 @@ mod tests { DeviceOperationState::WaitingReconnection { .. } )); if let DeviceOperationState::WaitingReconnection { operation, attempt } = - model.device_operation_state + &model.device_operation_state { assert_eq!(operation, "Reboot"); - assert_eq!(attempt, 2); + assert_eq!(*attempt, 2); } } #[test] fn success_after_offline_transitions_to_successful() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Rebooting, device_went_offline: true, @@ -432,12 +400,8 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Ok(create_healthcheck( - "valid", false, - )))), - &mut model, - ); + let _ = + handle_healthcheck_response(Ok(create_healthcheck("valid", false)), &mut model); assert!(matches!( model.device_operation_state, @@ -450,21 +414,19 @@ mod tests { #[test] fn success_without_offline_keeps_checking() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Rebooting, device_went_offline: false, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Ok(create_healthcheck( - "valid", false, - )))), - &mut model, - ); + let _ = + handle_healthcheck_response(Ok(create_healthcheck("valid", false)), &mut model); - assert_eq!(model.device_operation_state, DeviceOperationState::Rebooting); + assert_eq!( + model.device_operation_state, + DeviceOperationState::Rebooting + ); } } @@ -473,7 +435,6 @@ mod tests { #[test] fn error_marks_device_offline() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::FactoryResetting, device_went_offline: false, @@ -481,12 +442,8 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Err( - "Connection failed".to_string(), - ))), - &mut model, - ); + let _ = + handle_healthcheck_response(Err("Connection failed".to_string()), &mut model); assert!(model.device_went_offline); assert!(matches!( @@ -497,7 +454,6 @@ mod tests { #[test] fn success_after_offline_transitions_to_successful() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::FactoryResetting, device_went_offline: true, @@ -506,19 +462,15 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Ok(create_healthcheck( - "valid", false, - )))), - &mut model, - ); + let _ = + handle_healthcheck_response(Ok(create_healthcheck("valid", false)), &mut model); assert!(matches!( model.device_operation_state, DeviceOperationState::ReconnectionSuccessful { .. } )); if let DeviceOperationState::ReconnectionSuccessful { operation } = - model.device_operation_state + &model.device_operation_state { assert_eq!(operation, "Factory Reset"); } @@ -531,7 +483,6 @@ mod tests { #[test] fn error_marks_device_offline() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Updating, device_went_offline: false, @@ -539,12 +490,8 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Err( - "Connection failed".to_string(), - ))), - &mut model, - ); + let _ = + handle_healthcheck_response(Err("Connection failed".to_string()), &mut model); assert!(model.device_went_offline); assert!(matches!( @@ -555,7 +502,6 @@ mod tests { #[test] fn success_with_succeeded_status_after_offline_completes() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Updating, device_went_offline: true, @@ -564,11 +510,8 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Ok(create_healthcheck( - "Succeeded", - false, - )))), + let _ = handle_healthcheck_response( + Ok(create_healthcheck("Succeeded", false)), &mut model, ); @@ -581,18 +524,14 @@ mod tests { #[test] fn success_with_recovered_status_after_offline_completes() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Updating, device_went_offline: true, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Ok(create_healthcheck( - "Recovered", - false, - )))), + let _ = handle_healthcheck_response( + Ok(create_healthcheck("Recovered", false)), &mut model, ); @@ -604,18 +543,14 @@ mod tests { #[test] fn success_with_no_update_status_after_offline_completes() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Updating, device_went_offline: true, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Ok(create_healthcheck( - "NoUpdate", - false, - )))), + let _ = handle_healthcheck_response( + Ok(create_healthcheck("NoUpdate", false)), &mut model, ); @@ -627,18 +562,14 @@ mod tests { #[test] fn success_with_incomplete_status_keeps_checking() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::Updating, device_went_offline: true, ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Ok(create_healthcheck( - "InProgress", - false, - )))), + let _ = handle_healthcheck_response( + Ok(create_healthcheck("InProgress", false)), &mut model, ); @@ -651,7 +582,6 @@ mod tests { #[test] fn error_updates_attempt_count() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::WaitingReconnection { operation: "Reboot".to_string(), @@ -662,27 +592,22 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Err( - "Connection failed".to_string(), - ))), - &mut model, - ); + let _ = + handle_healthcheck_response(Err("Connection failed".to_string()), &mut model); assert!(matches!( model.device_operation_state, DeviceOperationState::WaitingReconnection { .. } )); if let DeviceOperationState::WaitingReconnection { attempt, .. } = - model.device_operation_state + &model.device_operation_state { - assert_eq!(attempt, 10); + assert_eq!(*attempt, 10); } } #[test] fn success_for_non_update_operation_completes() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::WaitingReconnection { operation: "Reboot".to_string(), @@ -694,12 +619,8 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Ok(create_healthcheck( - "valid", false, - )))), - &mut model, - ); + let _ = + handle_healthcheck_response(Ok(create_healthcheck("valid", false)), &mut model); assert!(matches!( model.device_operation_state, @@ -710,7 +631,6 @@ mod tests { #[test] fn success_for_update_with_completed_status() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::WaitingReconnection { operation: "Update".to_string(), @@ -720,11 +640,8 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Ok(create_healthcheck( - "Succeeded", - false, - )))), + let _ = handle_healthcheck_response( + Ok(create_healthcheck("Succeeded", false)), &mut model, ); @@ -736,7 +653,6 @@ mod tests { #[test] fn success_for_update_with_incomplete_status_keeps_waiting() { - let app = AppTester::::default(); let mut model = Model { device_operation_state: DeviceOperationState::WaitingReconnection { operation: "Update".to_string(), @@ -746,11 +662,8 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Ok(create_healthcheck( - "InProgress", - false, - )))), + let _ = handle_healthcheck_response( + Ok(create_healthcheck("InProgress", false)), &mut model, ); @@ -766,7 +679,6 @@ mod tests { #[test] fn successful_healthcheck_transitions_to_reachable() { - let app = AppTester::::default(); let mut model = Model { network_change_state: NetworkChangeState::WaitingForNewIp { new_ip: "192.168.1.101".to_string(), @@ -779,29 +691,24 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Ok(create_healthcheck( - "valid", false, - )))), - &mut model, - ); + let _ = + handle_healthcheck_response(Ok(create_healthcheck("valid", false)), &mut model); assert!(matches!( model.network_change_state, NetworkChangeState::NewIpReachable { .. } )); if let NetworkChangeState::NewIpReachable { new_ip, ui_port } = - model.network_change_state + &model.network_change_state { assert_eq!(new_ip, "192.168.1.101"); - assert_eq!(ui_port, 443); + assert_eq!(*ui_port, 443); } assert!(model.overlay_spinner.is_visible()); } #[test] fn failed_healthcheck_keeps_waiting() { - let app = AppTester::::default(); let mut model = Model { network_change_state: NetworkChangeState::WaitingForNewIp { new_ip: "192.168.1.101".to_string(), @@ -814,12 +721,8 @@ mod tests { ..Default::default() }; - let _ = app.update( - Event::Device(DeviceEvent::HealthcheckResponse(Err( - "Connection failed".to_string(), - ))), - &mut model, - ); + let _ = + handle_healthcheck_response(Err("Connection failed".to_string()), &mut model); assert!(matches!( model.network_change_state, diff --git a/src/app/src/update/ui.rs b/src/app/src/update/ui.rs index 5fabea4..915dca2 100644 --- a/src/app/src/update/ui.rs +++ b/src/app/src/update/ui.rs @@ -1,14 +1,94 @@ use crux_core::Command; -use crate::events::{Event, UiEvent}; -use crate::model::Model; -use crate::update_field; -use crate::Effect; +use crate::{ + events::{Event, UiEvent}, + model::Model, + update_field, Effect, +}; /// Handle UI-related events (clear messages, etc.) pub fn handle(event: UiEvent, model: &mut Model) -> Command { match event { UiEvent::ClearError => update_field!(model.error_message, None), UiEvent::ClearSuccess => update_field!(model.success_message, None), + UiEvent::SetBrowserHostname(hostname) => { + model.browser_hostname = Some(hostname); + model.update_current_connection_adapter(); + crux_core::render::render() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::UiEvent; + use crate::types::{DeviceNetwork, InternetProtocol, IpAddress, NetworkStatus}; + + #[test] + fn clear_error_removes_error_message() { + let mut model = Model { + error_message: Some("Test error".to_string()), + ..Default::default() + }; + + let _ = handle(UiEvent::ClearError, &mut model); + + assert_eq!(model.error_message, None); + } + + #[test] + fn clear_success_removes_success_message() { + let mut model = Model { + success_message: Some("Test success".to_string()), + ..Default::default() + }; + + let _ = handle(UiEvent::ClearSuccess, &mut model); + + assert_eq!(model.success_message, None); + } + + #[test] + fn set_browser_hostname_stores_hostname() { + let mut model = Model::default(); + + let _ = handle( + UiEvent::SetBrowserHostname("192.168.1.100".to_string()), + &mut model, + ); + + assert_eq!(model.browser_hostname, Some("192.168.1.100".to_string())); + } + + #[test] + fn set_browser_hostname_updates_current_connection_adapter() { + let mut model = Model { + network_status: Some(NetworkStatus { + network_status: vec![DeviceNetwork { + name: "eth0".to_string(), + mac: "00:11:22:33:44:55".to_string(), + online: true, + file: Some("/etc/network/interfaces".to_string()), + ipv4: InternetProtocol { + addrs: vec![IpAddress { + addr: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + }], + dns: vec![], + gateways: vec![], + }, + }], + }), + ..Default::default() + }; + + let _ = handle( + UiEvent::SetBrowserHostname("192.168.1.100".to_string()), + &mut model, + ); + + assert_eq!(model.current_connection_adapter, Some("eth0".to_string())); } } diff --git a/src/app/src/update/websocket.rs b/src/app/src/update/websocket.rs index e3964f2..3ddd6a8 100644 --- a/src/app/src/update/websocket.rs +++ b/src/app/src/update/websocket.rs @@ -1,9 +1,10 @@ use crux_core::Command; -use crate::events::{Event, WebSocketEvent}; -use crate::model::Model; -use crate::update_field; -use crate::{CentrifugoCmd, Effect}; +use crate::{ + events::{Event, WebSocketEvent}, + model::Model, + update_field, CentrifugoCmd, Effect, +}; /// Handle WebSocket and Centrifugo-related events pub fn handle(event: WebSocketEvent, model: &mut Model) -> Command { @@ -24,7 +25,9 @@ pub fn handle(event: WebSocketEvent, model: &mut Model) -> Command update_field!(model.system_info, Some(info)), WebSocketEvent::NetworkStatusUpdated(status) => { - update_field!(model.network_status, Some(status)) + model.network_status = Some(status); + model.update_current_connection_adapter(); + crux_core::render::render() } WebSocketEvent::OnlineStatusUpdated(status) => { update_field!(model.online_status, Some(status)) @@ -45,15 +48,13 @@ pub fn handle(event: WebSocketEvent, model: &mut Model) -> Command::default(); let mut model = Model::default(); let info = SystemInfo { @@ -66,17 +67,13 @@ mod tests { boot_time: Some("2024-01-01".into()), }; - let _ = app.update( - Event::WebSocket(WebSocketEvent::SystemInfoUpdated(info.clone())), - &mut model, - ); + let _ = handle(WebSocketEvent::SystemInfoUpdated(info.clone()), &mut model); assert_eq!(model.system_info, Some(info)); } #[test] fn replaces_previous_system_info() { - let app = AppTester::::default(); let old_info = SystemInfo { os: OsInfo { name: "Linux".into(), @@ -101,8 +98,8 @@ mod tests { boot_time: Some("2024-01-01".into()), }; - let _ = app.update( - Event::WebSocket(WebSocketEvent::SystemInfoUpdated(new_info.clone())), + let _ = handle( + WebSocketEvent::SystemInfoUpdated(new_info.clone()), &mut model, ); @@ -115,13 +112,10 @@ mod tests { #[test] fn updates_online_status_to_online() { - let app = AppTester::::default(); let mut model = Model::default(); - let _ = app.update( - Event::WebSocket(WebSocketEvent::OnlineStatusUpdated(OnlineStatus { - iothub: true, - })), + let _ = handle( + WebSocketEvent::OnlineStatusUpdated(OnlineStatus { iothub: true }), &mut model, ); @@ -130,16 +124,13 @@ mod tests { #[test] fn updates_online_status_to_offline() { - let app = AppTester::::default(); let mut model = Model { online_status: Some(OnlineStatus { iothub: true }), ..Default::default() }; - let _ = app.update( - Event::WebSocket(WebSocketEvent::OnlineStatusUpdated(OnlineStatus { - iothub: false, - })), + let _ = handle( + WebSocketEvent::OnlineStatusUpdated(OnlineStatus { iothub: false }), &mut model, ); @@ -148,16 +139,13 @@ mod tests { #[test] fn transitions_from_offline_to_online() { - let app = AppTester::::default(); let mut model = Model { online_status: Some(OnlineStatus { iothub: false }), ..Default::default() }; - let _ = app.update( - Event::WebSocket(WebSocketEvent::OnlineStatusUpdated(OnlineStatus { - iothub: true, - })), + let _ = handle( + WebSocketEvent::OnlineStatusUpdated(OnlineStatus { iothub: true }), &mut model, ); @@ -170,7 +158,6 @@ mod tests { #[test] fn updates_factory_reset_status() { - let app = AppTester::::default(); let mut model = Model::default(); let status = FactoryReset { @@ -178,8 +165,8 @@ mod tests { result: None, }; - let _ = app.update( - Event::WebSocket(WebSocketEvent::FactoryResetUpdated(status.clone())), + let _ = handle( + WebSocketEvent::FactoryResetUpdated(status.clone()), &mut model, ); @@ -192,17 +179,14 @@ mod tests { #[test] fn updates_validation_status() { - let app = AppTester::::default(); let mut model = Model::default(); let status = UpdateValidationStatus { status: "Succeeded".into(), }; - let _ = app.update( - Event::WebSocket(WebSocketEvent::UpdateValidationStatusUpdated( - status.clone(), - )), + let _ = handle( + WebSocketEvent::UpdateValidationStatusUpdated(status.clone()), &mut model, ); @@ -216,7 +200,6 @@ mod tests { #[test] fn updates_timeouts() { - let app = AppTester::::default(); let mut model = Model::default(); let timeouts = Timeouts { @@ -226,8 +209,8 @@ mod tests { }, }; - let _ = app.update( - Event::WebSocket(WebSocketEvent::TimeoutsUpdated(timeouts.clone())), + let _ = handle( + WebSocketEvent::TimeoutsUpdated(timeouts.clone()), &mut model, ); @@ -240,25 +223,88 @@ mod tests { #[test] fn connected_sets_is_connected() { - let app = AppTester::::default(); let mut model = Model::default(); - let _ = app.update(Event::WebSocket(WebSocketEvent::Connected), &mut model); + let _ = handle(WebSocketEvent::Connected, &mut model); assert!(model.is_connected); } #[test] fn disconnected_clears_is_connected() { - let app = AppTester::::default(); let mut model = Model { is_connected: true, ..Default::default() }; - let _ = app.update(Event::WebSocket(WebSocketEvent::Disconnected), &mut model); + let _ = handle(WebSocketEvent::Disconnected, &mut model); assert!(!model.is_connected); } } + + mod network_status { + use super::*; + use crate::types::{DeviceNetwork, InternetProtocol, IpAddress, NetworkStatus}; + + #[test] + fn updates_network_status() { + let mut model = Model::default(); + + let status = NetworkStatus { + network_status: vec![DeviceNetwork { + name: "eth0".to_string(), + mac: "00:11:22:33:44:55".to_string(), + online: true, + file: Some("/etc/network/interfaces".to_string()), + ipv4: InternetProtocol { + addrs: vec![IpAddress { + addr: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + }], + dns: vec![], + gateways: vec![], + }, + }], + }; + + let _ = handle( + WebSocketEvent::NetworkStatusUpdated(status.clone()), + &mut model, + ); + + assert_eq!(model.network_status, Some(status)); + } + + #[test] + fn updates_current_connection_adapter_when_browser_hostname_set() { + let mut model = Model { + browser_hostname: Some("192.168.1.100".to_string()), + ..Default::default() + }; + + let status = NetworkStatus { + network_status: vec![DeviceNetwork { + name: "eth0".to_string(), + mac: "00:11:22:33:44:55".to_string(), + online: true, + file: Some("/etc/network/interfaces".to_string()), + ipv4: InternetProtocol { + addrs: vec![IpAddress { + addr: "192.168.1.100".to_string(), + dhcp: false, + prefix_len: 24, + }], + dns: vec![], + gateways: vec![], + }, + }], + }; + + let _ = handle(WebSocketEvent::NetworkStatusUpdated(status), &mut model); + + assert_eq!(model.current_connection_adapter, Some("eth0".to_string())); + } + } } diff --git a/src/app/src/wasm.rs b/src/app/src/wasm.rs index 59605d9..b972a7f 100644 --- a/src/app/src/wasm.rs +++ b/src/app/src/wasm.rs @@ -33,8 +33,10 @@ pub fn init_wasm() { /// Takes a bincode-serialized Event and returns bincode-serialized Effects. #[wasm_bindgen] pub fn process_event(event_bytes: &[u8]) -> Vec { - CORE.process_event(event_bytes) - .expect("Failed to process event") + let mut effects = Vec::new(); + CORE.update(event_bytes, &mut effects) + .expect("Failed to process event"); + effects } /// Get the current view model @@ -42,7 +44,9 @@ pub fn process_event(event_bytes: &[u8]) -> Vec { /// Returns a bincode-serialized ViewModel. #[wasm_bindgen] pub fn view() -> Vec { - CORE.view().expect("Failed to get view model") + let mut view = Vec::new(); + CORE.view(&mut view).expect("Failed to get view model"); + view } /// Handle a response to an effect @@ -51,6 +55,12 @@ pub fn view() -> Vec { /// Returns bincode-serialized Effects that should be processed. #[wasm_bindgen] pub fn handle_response(id: u32, response_bytes: &[u8]) -> Vec { - CORE.handle_response(id, response_bytes) - .expect("Failed to handle response") + let mut effects = Vec::new(); + CORE.resolve( + crux_core::bridge::EffectId(id), + response_bytes, + &mut effects, + ) + .expect("Failed to handle response"); + effects } diff --git a/src/shared_types/Cargo.toml b/src/shared_types/Cargo.toml index f2ca590..cc50e6a 100644 --- a/src/shared_types/Cargo.toml +++ b/src/shared_types/Cargo.toml @@ -12,6 +12,6 @@ version.workspace = true [build-dependencies] anyhow = "1.0" -crux_core = { version = "0.16", features = ["typegen"] } -crux_http = { version = "0.15", features = ["typegen"] } +crux_core = { version = "0.17.0-rc2", features = ["typegen"] } +crux_http = { version = "0.16.0-rc1", features = ["typegen"] } omnect-ui-core = { path = "../app", features = ["typegen"] } diff --git a/src/shared_types/build.rs b/src/shared_types/build.rs index 030c48d..036eb1f 100644 --- a/src/shared_types/build.rs +++ b/src/shared_types/build.rs @@ -3,7 +3,8 @@ use crux_core::typegen::TypeGen; use omnect_ui_core::{ events::{AuthEvent, DeviceEvent, UiEvent, WebSocketEvent}, types::{ - DeviceOperationState, FactoryResetStatus, NetworkChangeState, NetworkFormState, UploadState, + DeviceOperationState, FactoryResetStatus, NetworkChangeState, NetworkConfigRequest, + NetworkFormData, NetworkFormState, UploadState, }, App, }; @@ -22,12 +23,14 @@ fn main() -> Result<()> { gen.register_type::()?; gen.register_type::()?; - // Explicitly register other enums to ensure all variants are traced + // Explicitly register other enums/structs to ensure all variants are traced gen.register_type::()?; gen.register_type::()?; gen.register_type::()?; gen.register_type::()?; gen.register_type::()?; + gen.register_type::()?; + gen.register_type::()?; let output_root = PathBuf::from("./generated"); diff --git a/src/ui/playwright.config.ts b/src/ui/playwright.config.ts index 6c5cf65..c2b4438 100644 --- a/src/ui/playwright.config.ts +++ b/src/ui/playwright.config.ts @@ -33,7 +33,10 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1280, height: 1024 }, // Increase height to fit network form + }, }, ], diff --git a/src/ui/src/App.vue b/src/ui/src/App.vue index 31a4e8d..06a9973 100644 --- a/src/ui/src/App.vue +++ b/src/ui/src/App.vue @@ -71,6 +71,16 @@ watch( { immediate: true } ) +// Watch for network rollback status from healthcheck updates (e.g. after automatic rollback) +watch( + () => viewModel.healthcheck?.network_rollback_occurred, + (occurred) => { + if (occurred) { + showRollbackNotification.value = true + } + } +) + onMounted(async () => { const res = await fetch("healthcheck", { headers: { diff --git a/src/ui/src/auth/oidc.ts b/src/ui/src/auth/oidc.ts index 2b3bd31..fb2adda 100644 --- a/src/ui/src/auth/oidc.ts +++ b/src/ui/src/auth/oidc.ts @@ -1,4 +1,4 @@ -import { InMemoryWebStorage, UserManager, WebStorageStateStore } from "oidc-client-ts" +import { UserManager, WebStorageStateStore } from "oidc-client-ts" const config = window.__APP_CONFIG__ @@ -9,7 +9,7 @@ const oidcConfig = { response_type: "code", scope: "openid profile email", post_logout_redirect_uri: `https://${window.location.hostname}:${window.location.port}/`, - userStore: new WebStorageStateStore({ store: new InMemoryWebStorage() }) + userStore: new WebStorageStateStore({ store: window.localStorage }) } const userManager = new UserManager(oidcConfig) diff --git a/src/ui/src/components/network/DeviceNetworks.vue b/src/ui/src/components/network/DeviceNetworks.vue index 609d86a..ee83015 100644 --- a/src/ui/src/components/network/DeviceNetworks.vue +++ b/src/ui/src/components/network/DeviceNetworks.vue @@ -15,32 +15,9 @@ const isReverting = ref(false) const networkStatus = computed(() => viewModel.network_status) -// Determine if an adapter is the current connection by comparing browser hostname with adapter IPs +// Use Core's computed current connection adapter const isCurrentConnection = (adapter: any) => { - const hostname = window.location.hostname - if (!adapter.ipv4?.addrs) return false - - // Check if any of the adapter's IPs match the browser's hostname - const directMatch = adapter.ipv4.addrs.some((ip: any) => ip.addr === hostname) - - if (directMatch) { - return true - } - - // If hostname is not an IP (e.g., "omnect-device"), we can't determine which adapter - // So mark the first online adapter with an IP as the current connection - const isHostnameAnIP = /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname) - if (!isHostnameAnIP && adapter.online && adapter.ipv4.addrs.length > 0) { - // Check if this is the first online adapter - const allAdapters = networkStatus.value?.network_status || [] - const firstOnlineAdapter = allAdapters.find((a: any) => a.online && a.ipv4?.addrs?.length > 0) - - if (firstOnlineAdapter?.name === adapter.name) { - return true - } - } - - return false + return viewModel.current_connection_adapter === adapter.name } // Watch for tab changes and check for unsaved changes diff --git a/src/ui/src/components/network/NetworkSettings.vue b/src/ui/src/components/network/NetworkSettings.vue index 8d1204b..a41e95e 100644 --- a/src/ui/src/components/network/NetworkSettings.vue +++ b/src/ui/src/components/network/NetworkSettings.vue @@ -5,6 +5,7 @@ import { useCore } from "../../composables/useCore" import { useClipboard } from "../../composables/useClipboard" import { useIPValidation } from "../../composables/useIPValidation" import type { DeviceNetwork } from "../../types" +import type { NetworkConfigRequest } from "../../composables/useCore" const { showError } = useSnackbar() const { viewModel, setNetworkConfig, networkFormReset, networkFormUpdate, networkFormStartEdit } = useCore() @@ -121,15 +122,21 @@ watch(() => props.networkAdapter, (newAdapter) => { }, { deep: true }) const isDHCP = computed(() => addressAssignment.value === "dhcp") -const isServerAddr = computed(() => props.networkAdapter?.ipv4?.addrs[0]?.addr === location.hostname) -const ipChanged = computed(() => props.networkAdapter?.ipv4?.addrs[0]?.addr !== ipAddress.value) -const dhcpChanged = computed(() => props.networkAdapter?.ipv4?.addrs[0]?.dhcp !== isDHCP.value) -const switchingToDhcp = computed(() => !props.networkAdapter?.ipv4?.addrs[0]?.dhcp && isDHCP.value) -// Modal state for rollback confirmation -const showRollbackModal = ref(false) -const enableRollback = ref(true) // Default to checked (enabled) -const isDhcpChange = ref(false) // Track if this is a DHCP change +// Use Core's computed rollback modal flags +const isRollbackRequired = computed(() => viewModel.should_show_rollback_modal) +const enableRollback = ref(true) // Tracks user's checkbox state +const confirmationModalOpen = ref(false) + +// Watch Core's default_rollback_enabled to update checkbox when modal shows +watch(() => viewModel.should_show_rollback_modal, (shouldShow) => { + if (shouldShow) { + enableRollback.value = viewModel.default_rollback_enabled + } +}) + +// Determine if switching to DHCP for UI text (Core computes this, but we still need it for modal text) +const switchingToDhcp = computed(() => !props.networkAdapter?.ipv4?.addrs[0]?.dhcp && isDHCP.value) const restoreSettings = () => { // Reset Core state (clears dirty flag and NetworkFormState) @@ -162,55 +169,61 @@ watch( (newMessage) => { if (newMessage) { isSubmitting.value = false + confirmationModalOpen.value = false + } + } +) + +// Watch for form state changes from Core as a reliable way to reset submitting state +watch( + () => viewModel.network_form_state, + (newState) => { + // If we were submitting and the state is no longer submitting, reset our flag + if (isSubmitting.value && newState?.type !== 'submitting') { + isSubmitting.value = false } } ) const submit = async () => { - // Check if we need to show the rollback confirmation modal - // Show modal when: - // 1. Static IP changed on current adapter, OR - // 2. Switching to DHCP on current adapter (IP will likely change) - if (isServerAddr.value && (ipChanged.value || switchingToDhcp.value)) { - isDhcpChange.value = switchingToDhcp.value - showRollbackModal.value = true - return + // Check if the change requires rollback protection + if (isRollbackRequired.value) { + confirmationModalOpen.value = true + } else { + await submitNetworkConfig(false) } - - // If not changing server IP, submit directly without rollback - await submitNetworkConfig(false) } const submitNetworkConfig = async (includeRollback: boolean) => { isSubmitting.value = true - showRollbackModal.value = false + confirmationModalOpen.value = false - const config = JSON.stringify({ - isServerAddr: isServerAddr.value, - ipChanged: ipChanged.value, + const config: NetworkConfigRequest = { + isServerAddr: props.isCurrentConnection, + ipChanged: props.networkAdapter.ipv4?.addrs[0]?.addr !== ipAddress.value, name: props.networkAdapter.name, dhcp: isDHCP.value, - ip: ipAddress.value ?? null, - previousIp: props.networkAdapter.ipv4?.addrs[0]?.addr, - netmask: netmask.value ?? null, - gateway: gateways.value.split("\n").filter(g => g.trim()) ?? [], - dns: dns.value.split("\n").filter(d => d.trim()) ?? [], + ip: ipAddress.value || null, + previousIp: props.networkAdapter.ipv4?.addrs[0]?.addr || null, + netmask: netmask.value || null, + gateway: gateways.value.split("\n").filter(g => g.trim()) || [], + dns: dns.value.split("\n").filter(d => d.trim()) || [], enableRollback: includeRollback ? enableRollback.value : null, switchingToDhcp: switchingToDhcp.value - }) + } - await setNetworkConfig(config) + await setNetworkConfig(JSON.stringify(config)) } const cancelRollbackModal = () => { - showRollbackModal.value = false + confirmationModalOpen.value = false }