diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e69cd86 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" + }, + "teammateMode": "tmux" +} diff --git a/Cargo.lock b/Cargo.lock index 2cef67a..d3bb7f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,12 +64,131 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "askama" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08e1676b346cadfec169374f949d7490fd80a24193d37d2afce0c047cf695e57" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7661ff56517787343f376f75db037426facd7c8d3049cef8911f1e75016f3a37" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "askama_macros" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "713ee4dbfd1eb719c2dab859465b01fa1d21cb566684614a713a6b7a99a4e47b" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d62d674238a526418b30c0def480d5beadb9d8964e7f38d635b03bf639c704c" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -280,6 +399,39 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -313,12 +465,77 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -467,12 +684,24 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -483,6 +712,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nix" version = "0.31.1" @@ -534,6 +774,18 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "potential_utf" version = "0.1.4" @@ -581,6 +833,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustls" version = "0.23.36" @@ -616,6 +874,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.228" @@ -659,6 +923,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -668,6 +943,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "shlex" version = "1.3.0" @@ -680,12 +967,28 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -715,6 +1018,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -787,6 +1096,31 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "1.0.2+spec-1.1.0" @@ -826,6 +1160,68 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -911,13 +1307,17 @@ name = "warpgate" version = "0.1.0" dependencies = [ "anyhow", + "askama", + "axum", "clap", "ctrlc", "libc", "serde", "serde_json", "thiserror", + "tokio", "toml", + "tower-http", "ureq", ] @@ -948,7 +1348,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -966,14 +1375,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -982,53 +1408,104 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] [[package]] name = "writeable" diff --git a/Cargo.toml b/Cargo.toml index 6ccfae1..41ca0f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,3 +13,7 @@ toml = "1.0.2" ctrlc = "3.4" libc = "0.2" ureq = { version = "3.2.0", features = ["json"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +axum = "0.8" +askama = "0.15" +tower-http = { version = "0.6", features = ["cors"] } diff --git a/src/config_diff.rs b/src/config_diff.rs new file mode 100644 index 0000000..e59e5a5 --- /dev/null +++ b/src/config_diff.rs @@ -0,0 +1,304 @@ +//! Config diff engine — classifies changes between old and new configs into tiers. +//! +//! Tier A: Bandwidth — live RC API call, no restart. +//! Tier B: Protocols — regen SMB/NFS configs, restart smbd/exportfs. +//! Tier C: Per-share — drain affected share, unmount, remount. +//! Tier D: Global — drain all, stop everything, restart. + +use crate::config::Config; + +/// The set of changes between two configs, classified by tier. +#[derive(Debug, Default)] +pub struct ConfigDiff { + /// Tier A: bandwidth limits changed. + pub bandwidth_changed: bool, + /// Tier B: protocol settings changed (SMB/NFS/WebDAV toggles, SMB auth, NFS network). + pub protocols_changed: bool, + /// Tier C: shares that were removed (by name). + pub shares_removed: Vec, + /// Tier C: shares that were added (by name). + pub shares_added: Vec, + /// Tier C: shares that were modified (remote_path, mount_point, or read_only changed). + pub shares_modified: Vec, + /// Tier D: global settings changed (connection, cache, read, writeback, directory_cache). + pub global_changed: bool, +} + +impl ConfigDiff { + /// Returns true if no changes were detected. + pub fn is_empty(&self) -> bool { + !self.bandwidth_changed + && !self.protocols_changed + && self.shares_removed.is_empty() + && self.shares_added.is_empty() + && self.shares_modified.is_empty() + && !self.global_changed + } + + /// Returns the highest tier of change detected. + pub fn highest_tier(&self) -> ChangeTier { + if self.global_changed { + ChangeTier::Global + } else if !self.shares_removed.is_empty() + || !self.shares_added.is_empty() + || !self.shares_modified.is_empty() + { + ChangeTier::PerShare + } else if self.protocols_changed { + ChangeTier::Protocol + } else if self.bandwidth_changed { + ChangeTier::Live + } else { + ChangeTier::None + } + } + + /// Human-readable summary of changes. + pub fn summary(&self) -> String { + let mut parts = Vec::new(); + if self.global_changed { + parts.push("global settings changed (full restart required)".to_string()); + } + if !self.shares_removed.is_empty() { + parts.push(format!("shares removed: {}", self.shares_removed.join(", "))); + } + if !self.shares_added.is_empty() { + parts.push(format!("shares added: {}", self.shares_added.join(", "))); + } + if !self.shares_modified.is_empty() { + parts.push(format!( + "shares modified: {}", + self.shares_modified.join(", ") + )); + } + if self.protocols_changed { + parts.push("protocol settings changed".to_string()); + } + if self.bandwidth_changed { + parts.push("bandwidth limits changed".to_string()); + } + if parts.is_empty() { + "no changes detected".to_string() + } else { + parts.join("; ") + } + } +} + +/// Tier of change, from least to most disruptive. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ChangeTier { + None, + /// Tier A: live RC API call. + Live, + /// Tier B: protocol restart only. + Protocol, + /// Tier C: per-share mount restart. + PerShare, + /// Tier D: full global restart. + Global, +} + +/// Compare two configs and classify all changes. +pub fn diff(old: &Config, new: &Config) -> ConfigDiff { + let mut d = ConfigDiff::default(); + + // Tier D: global settings + d.global_changed = old.connection.nas_host != new.connection.nas_host + || old.connection.nas_user != new.connection.nas_user + || old.connection.nas_pass != new.connection.nas_pass + || old.connection.nas_key_file != new.connection.nas_key_file + || old.connection.sftp_port != new.connection.sftp_port + || old.connection.sftp_connections != new.connection.sftp_connections + || old.cache.dir != new.cache.dir + || old.cache.max_size != new.cache.max_size + || old.cache.max_age != new.cache.max_age + || old.cache.min_free != new.cache.min_free + || old.read.chunk_size != new.read.chunk_size + || old.read.chunk_limit != new.read.chunk_limit + || old.read.read_ahead != new.read.read_ahead + || old.read.buffer_size != new.read.buffer_size + || old.writeback.write_back != new.writeback.write_back + || old.writeback.transfers != new.writeback.transfers + || old.directory_cache.cache_time != new.directory_cache.cache_time; + + // Tier A: bandwidth + d.bandwidth_changed = old.bandwidth.limit_up != new.bandwidth.limit_up + || old.bandwidth.limit_down != new.bandwidth.limit_down + || old.bandwidth.adaptive != new.bandwidth.adaptive; + + // Tier B: protocols + d.protocols_changed = old.protocols.enable_smb != new.protocols.enable_smb + || old.protocols.enable_nfs != new.protocols.enable_nfs + || old.protocols.enable_webdav != new.protocols.enable_webdav + || old.protocols.nfs_allowed_network != new.protocols.nfs_allowed_network + || old.protocols.webdav_port != new.protocols.webdav_port + || old.smb_auth.enabled != new.smb_auth.enabled + || old.smb_auth.username != new.smb_auth.username + || old.smb_auth.smb_pass != new.smb_auth.smb_pass + || old.smb_auth.reuse_nas_pass != new.smb_auth.reuse_nas_pass; + + // Tier C: per-share changes + let old_shares: std::collections::HashMap<&str, &crate::config::ShareConfig> = + old.shares.iter().map(|s| (s.name.as_str(), s)).collect(); + let new_shares: std::collections::HashMap<&str, &crate::config::ShareConfig> = + new.shares.iter().map(|s| (s.name.as_str(), s)).collect(); + + // Removed shares + for name in old_shares.keys() { + if !new_shares.contains_key(name) { + d.shares_removed.push(name.to_string()); + } + } + + // Added shares + for name in new_shares.keys() { + if !old_shares.contains_key(name) { + d.shares_added.push(name.to_string()); + } + } + + // Modified shares + for (name, old_share) in &old_shares { + if let Some(new_share) = new_shares.get(name) { + if old_share.remote_path != new_share.remote_path + || old_share.mount_point != new_share.mount_point + || old_share.read_only != new_share.read_only + { + d.shares_modified.push(name.to_string()); + } + } + } + + d +} + +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_config() -> Config { + toml::from_str( + r#" +[connection] +nas_host = "10.0.0.1" +nas_user = "admin" + +[cache] +dir = "/tmp/cache" + +[read] +[bandwidth] +[writeback] +[directory_cache] +[protocols] + +[[shares]] +name = "photos" +remote_path = "/photos" +mount_point = "/mnt/photos" +"#, + ) + .unwrap() + } + + #[test] + fn test_no_changes() { + let config = minimal_config(); + let d = diff(&config, &config); + assert!(d.is_empty()); + assert_eq!(d.highest_tier(), ChangeTier::None); + } + + #[test] + fn test_bandwidth_change() { + let old = minimal_config(); + let mut new = old.clone(); + new.bandwidth.limit_up = "10M".to_string(); + let d = diff(&old, &new); + assert!(d.bandwidth_changed); + assert!(!d.global_changed); + assert_eq!(d.highest_tier(), ChangeTier::Live); + } + + #[test] + fn test_protocol_change() { + let old = minimal_config(); + let mut new = old.clone(); + new.protocols.enable_nfs = true; + let d = diff(&old, &new); + assert!(d.protocols_changed); + assert_eq!(d.highest_tier(), ChangeTier::Protocol); + } + + #[test] + fn test_share_added() { + let old = minimal_config(); + let mut new = old.clone(); + new.shares.push(crate::config::ShareConfig { + name: "videos".to_string(), + remote_path: "/videos".to_string(), + mount_point: "/mnt/videos".into(), + read_only: false, + }); + let d = diff(&old, &new); + assert_eq!(d.shares_added, vec!["videos"]); + assert_eq!(d.highest_tier(), ChangeTier::PerShare); + } + + #[test] + fn test_share_removed() { + let old = minimal_config(); + let mut new = old.clone(); + new.shares.clear(); + new.shares.push(crate::config::ShareConfig { + name: "videos".to_string(), + remote_path: "/videos".to_string(), + mount_point: "/mnt/videos".into(), + read_only: false, + }); + let d = diff(&old, &new); + assert_eq!(d.shares_removed, vec!["photos"]); + assert_eq!(d.shares_added, vec!["videos"]); + } + + #[test] + fn test_share_modified() { + let old = minimal_config(); + let mut new = old.clone(); + new.shares[0].remote_path = "/volume1/photos".to_string(); + let d = diff(&old, &new); + assert_eq!(d.shares_modified, vec!["photos"]); + assert_eq!(d.highest_tier(), ChangeTier::PerShare); + } + + #[test] + fn test_global_change() { + let old = minimal_config(); + let mut new = old.clone(); + new.connection.nas_host = "192.168.1.1".to_string(); + let d = diff(&old, &new); + assert!(d.global_changed); + assert_eq!(d.highest_tier(), ChangeTier::Global); + } + + #[test] + fn test_summary() { + let old = minimal_config(); + let mut new = old.clone(); + new.bandwidth.limit_up = "10M".to_string(); + new.protocols.enable_nfs = true; + let d = diff(&old, &new); + let summary = d.summary(); + assert!(summary.contains("protocol")); + assert!(summary.contains("bandwidth")); + } + + #[test] + fn test_tier_ordering() { + assert!(ChangeTier::None < ChangeTier::Live); + assert!(ChangeTier::Live < ChangeTier::Protocol); + assert!(ChangeTier::Protocol < ChangeTier::PerShare); + assert!(ChangeTier::PerShare < ChangeTier::Global); + } +} diff --git a/src/daemon.rs b/src/daemon.rs new file mode 100644 index 0000000..39bf6c8 --- /dev/null +++ b/src/daemon.rs @@ -0,0 +1,218 @@ +//! Shared state and command types for the daemon (supervisor + web server). +//! +//! The supervisor owns all mutable state. The web server gets read-only access +//! to status via `Arc>` and sends commands via an mpsc channel. + +use std::path::PathBuf; +use std::sync::mpsc; +use std::sync::{Arc, RwLock}; +use std::time::Instant; + +use crate::config::Config; + +/// Default port for the built-in web UI / API server. +pub const DEFAULT_WEB_PORT: u16 = 8090; + +/// Shared application state accessible by both the supervisor and web server. +pub struct AppState { + /// Current active configuration (read by UI, updated on reload). + pub config: Arc>, + /// Live daemon status (updated by supervisor every poll cycle). + pub status: Arc>, + /// Command channel: web server → supervisor. + pub cmd_tx: mpsc::Sender, + /// Path to the config file on disk. + pub config_path: PathBuf, +} + +/// Overall daemon status, updated by the supervisor loop. +pub struct DaemonStatus { + /// When the daemon started. + pub started_at: Instant, + /// Per-share mount status. + pub shares: Vec, + /// Whether smbd is running. + pub smbd_running: bool, + /// Whether WebDAV is running. + pub webdav_running: bool, + /// Whether NFS exports are active. + pub nfs_exported: bool, +} + +impl DaemonStatus { + /// Create initial status for a set of share names. + pub fn new(share_names: &[String]) -> Self { + Self { + started_at: Instant::now(), + shares: share_names + .iter() + .map(|name| ShareStatus { + name: name.clone(), + mounted: false, + rc_port: 0, + cache_bytes: 0, + dirty_count: 0, + errored_files: 0, + speed: 0.0, + transfers: 0, + errors: 0, + }) + .collect(), + smbd_running: false, + webdav_running: false, + nfs_exported: false, + } + } + + /// Format uptime as a human-readable string. + pub fn uptime_string(&self) -> String { + let secs = self.started_at.elapsed().as_secs(); + let days = secs / 86400; + let hours = (secs % 86400) / 3600; + let mins = (secs % 3600) / 60; + if days > 0 { + format!("{days}d {hours}h {mins}m") + } else if hours > 0 { + format!("{hours}h {mins}m") + } else { + format!("{mins}m") + } + } +} + +/// Per-share runtime status. +pub struct ShareStatus { + /// Share name (matches ShareConfig.name). + pub name: String, + /// Whether the FUSE mount is active. + pub mounted: bool, + /// RC API port for this share's rclone instance. + pub rc_port: u16, + /// Bytes used in VFS disk cache. + pub cache_bytes: u64, + /// Number of dirty files (uploads_in_progress + uploads_queued). + pub dirty_count: u64, + /// Number of errored cache files. + pub errored_files: u64, + /// Current transfer speed (bytes/sec from core/stats). + pub speed: f64, + /// Active transfer count. + pub transfers: u64, + /// Cumulative error count. + pub errors: u64, +} + +impl ShareStatus { + /// Format cache size as human-readable string. + pub fn cache_display(&self) -> String { + format_bytes(self.cache_bytes) + } + + /// Format speed as human-readable string. + pub fn speed_display(&self) -> String { + if self.speed < 1.0 { + "-".to_string() + } else { + format!("{}/s", format_bytes(self.speed as u64)) + } + } +} + +/// Format bytes as human-readable (e.g. "45.2 GiB"). +fn format_bytes(bytes: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = KIB * 1024.0; + const GIB: f64 = MIB * 1024.0; + const TIB: f64 = GIB * 1024.0; + + let b = bytes as f64; + if b >= TIB { + format!("{:.1} TiB", b / TIB) + } else if b >= GIB { + format!("{:.1} GiB", b / GIB) + } else if b >= MIB { + format!("{:.1} MiB", b / MIB) + } else if b >= KIB { + format!("{:.1} KiB", b / KIB) + } else { + format!("{bytes} B") + } +} + +/// Commands sent from the web server (or CLI) to the supervisor. +pub enum SupervisorCmd { + /// Apply a new configuration (triggers tiered reload). + Reload(Config), + /// Graceful shutdown. + Shutdown, + /// Live bandwidth adjustment (Tier A — no restart needed). + BwLimit { up: String, down: String }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_bytes() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(500), "500 B"); + assert_eq!(format_bytes(1024), "1.0 KiB"); + assert_eq!(format_bytes(1536), "1.5 KiB"); + assert_eq!(format_bytes(1048576), "1.0 MiB"); + assert_eq!(format_bytes(1073741824), "1.0 GiB"); + assert_eq!(format_bytes(1099511627776), "1.0 TiB"); + } + + #[test] + fn test_daemon_status_new() { + let names = vec!["photos".to_string(), "projects".to_string()]; + let status = DaemonStatus::new(&names); + assert_eq!(status.shares.len(), 2); + assert_eq!(status.shares[0].name, "photos"); + assert_eq!(status.shares[1].name, "projects"); + assert!(!status.smbd_running); + assert!(!status.webdav_running); + assert!(!status.nfs_exported); + } + + #[test] + fn test_uptime_string() { + let status = DaemonStatus::new(&[]); + let uptime = status.uptime_string(); + assert!(uptime.contains('m')); + } + + #[test] + fn test_share_status_display() { + let share = ShareStatus { + name: "test".into(), + mounted: true, + rc_port: 5572, + cache_bytes: 48_578_891_776, // ~45.2 GiB + dirty_count: 3, + errored_files: 0, + speed: 2_200_000.0, + transfers: 2, + errors: 0, + }; + assert!(share.cache_display().contains("GiB")); + assert!(share.speed_display().contains("/s")); + } + + #[test] + fn test_share_status_no_speed() { + let share = ShareStatus { + name: "test".into(), + mounted: true, + rc_port: 5572, + cache_bytes: 0, + dirty_count: 0, + errored_files: 0, + speed: 0.0, + transfers: 0, + errors: 0, + }; + assert_eq!(share.speed_display(), "-"); + } +} diff --git a/src/main.rs b/src/main.rs index 5d9ccce..26d8ad1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,12 @@ mod cli; mod config; +mod config_diff; +mod daemon; mod deploy; mod rclone; mod services; mod supervisor; +mod web; use std::path::PathBuf; @@ -107,7 +110,7 @@ fn main() -> Result<()> { } Commands::Log { lines, follow } => cli::log::run(&config, lines, follow), Commands::SpeedTest => cli::speed_test::run(&config), - Commands::Run => supervisor::run(&config), + Commands::Run => supervisor::run(&config, cli.config.clone()), // already handled above Commands::ConfigInit { .. } | Commands::Deploy => unreachable!(), } diff --git a/src/supervisor.rs b/src/supervisor.rs index 81c4450..da61cf0 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -1,19 +1,25 @@ //! `warpgate run` — single-process supervisor for all services. //! //! Manages rclone mount processes (one per share) + protocol services in one -//! process tree with coordinated startup and shutdown. +//! process tree with coordinated startup and shutdown. Spawns a built-in web +//! server for status monitoring and config hot-reload. use std::os::unix::process::CommandExt; +use std::path::PathBuf; use std::process::{Child, Command}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Arc, RwLock}; use std::thread; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use crate::config::Config; +use crate::config_diff::{self, ChangeTier}; +use crate::daemon::{DaemonStatus, SupervisorCmd}; use crate::rclone::mount::{build_mount_args, is_mounted}; +use crate::rclone::rc; use crate::services::{nfs, samba, webdav}; /// Mount ready timeout. @@ -66,6 +72,7 @@ impl RestartTracker { struct MountChild { name: String, child: Child, + rc_port: u16, } /// Child processes for protocol servers managed by the supervisor. @@ -86,7 +93,7 @@ impl Drop for ProtocolChildren { } /// Entry point — called from main.rs for `warpgate run`. -pub fn run(config: &Config) -> Result<()> { +pub fn run(config: &Config, config_path: PathBuf) -> Result<()> { let shutdown = Arc::new(AtomicBool::new(false)); // Install signal handler (SIGTERM + SIGINT) @@ -97,6 +104,34 @@ pub fn run(config: &Config) -> Result<()> { }) .context("Failed to set signal handler")?; + // Set up shared state for web server integration + let shared_config = Arc::new(RwLock::new(config.clone())); + let share_names: Vec = config.shares.iter().map(|s| s.name.clone()).collect(); + let shared_status = Arc::new(RwLock::new(DaemonStatus::new(&share_names))); + let (cmd_tx, cmd_rx) = mpsc::channel::(); + + // Spawn the web UI server in a background thread + let _web_handle = crate::web::spawn_web_server( + Arc::clone(&shared_config), + Arc::clone(&shared_status), + cmd_tx.clone(), + config_path, + ); + + // Also wire shutdown signal to the command channel + let shutdown_tx = cmd_tx; + let shutdown_for_cmd = Arc::clone(&shutdown); + thread::spawn(move || { + // Poll the AtomicBool and forward to cmd channel when set + loop { + if shutdown_for_cmd.load(Ordering::SeqCst) { + let _ = shutdown_tx.send(SupervisorCmd::Shutdown); + return; + } + thread::sleep(Duration::from_millis(200)); + } + }); + // Phase 1: Preflight — generate configs, create dirs println!("Preflight checks..."); preflight(config)?; @@ -108,6 +143,17 @@ pub fn run(config: &Config) -> Result<()> { println!(" Mount ready at {}", share.mount_point.display()); } + // Update status: mounts are ready + { + let mut status = shared_status.write().unwrap(); + for (i, mc) in mount_children.iter().enumerate() { + if let Some(ss) = status.shares.get_mut(i) { + ss.mounted = true; + ss.rc_port = mc.rc_port; + } + } + } + // Phase 3: Start protocol services if shutdown.load(Ordering::SeqCst) { println!("Shutdown signal received during mount."); @@ -120,6 +166,14 @@ pub fn run(config: &Config) -> Result<()> { println!("Starting protocol services..."); let mut protocols = start_protocols(config)?; + // Update status: protocols running + { + let mut status = shared_status.write().unwrap(); + status.smbd_running = protocols.smbd.is_some(); + status.webdav_running = protocols.webdav.is_some(); + status.nfs_exported = config.protocols.enable_nfs; + } + // Phase 3.5: Auto-warmup in background thread (non-blocking) if !config.warmup.rules.is_empty() && config.warmup.auto { let warmup_config = config.clone(); @@ -144,13 +198,21 @@ pub fn run(config: &Config) -> Result<()> { }); } - // Phase 4: Supervision loop - println!("Supervision active. Press Ctrl+C to stop."); - let result = supervise(config, &mut mount_children, &mut protocols, Arc::clone(&shutdown)); + // Phase 4: Supervision loop with command channel + println!("Supervision active. Web UI at http://localhost:8090. Press Ctrl+C to stop."); + let result = supervise( + &shared_config, + &shared_status, + &cmd_rx, + &mut mount_children, + &mut protocols, + Arc::clone(&shutdown), + ); // Phase 5: Teardown (always runs) println!("Shutting down..."); - shutdown_services(config, &mut mount_children, &mut protocols); + let config = shared_config.read().unwrap().clone(); + shutdown_services(&config, &mut mount_children, &mut protocols); result } @@ -209,6 +271,7 @@ fn start_and_wait_mounts(config: &Config, shutdown: &AtomicBool) -> Result Result { .context("Failed to spawn rclone serve webdav") } -/// Main supervision loop. Polls child processes every 2s. +/// Main supervision loop with command channel. +/// +/// Uses `recv_timeout` on the command channel so it can both respond to +/// commands from the web UI and poll child processes every POLL_INTERVAL. /// /// - If any rclone mount dies → full shutdown (data safety). /// - If smbd/WebDAV dies → restart up to 3 times. fn supervise( - config: &Config, + shared_config: &Arc>, + shared_status: &Arc>, + cmd_rx: &mpsc::Receiver, mounts: &mut Vec, protocols: &mut ProtocolChildren, shutdown: Arc, @@ -353,6 +421,37 @@ fn supervise( let mut webdav_tracker = RestartTracker::new(); loop { + // Check for commands (non-blocking with timeout = POLL_INTERVAL) + match cmd_rx.recv_timeout(POLL_INTERVAL) { + Ok(SupervisorCmd::Shutdown) => { + println!("Shutdown command received."); + return Ok(()); + } + Ok(SupervisorCmd::BwLimit { up, down }) => { + println!("Applying bandwidth limit: up={up}, down={down}"); + apply_bwlimit(mounts, &up, &down); + } + Ok(SupervisorCmd::Reload(new_config)) => { + println!("Config reload requested..."); + handle_reload( + shared_config, + shared_status, + mounts, + protocols, + &mut smbd_tracker, + &mut webdav_tracker, + new_config, + )?; + println!("Config reload complete."); + } + Err(RecvTimeoutError::Timeout) => {} // normal poll cycle + Err(RecvTimeoutError::Disconnected) => { + println!("Command channel disconnected, shutting down."); + return Ok(()); + } + } + + // Check for shutdown signal if shutdown.load(Ordering::SeqCst) { println!("Shutdown signal received."); return Ok(()); @@ -408,6 +507,7 @@ fn supervise( } // Check WebDAV process (if enabled) + let config = shared_config.read().unwrap().clone(); if let Some(child) = &mut protocols.webdav { match child.try_wait() { Ok(Some(status)) => { @@ -420,7 +520,7 @@ fn supervise( webdav_tracker.count, ); thread::sleep(Duration::from_secs(delay.into())); - match spawn_webdav(config) { + match spawn_webdav(&config) { Ok(new_child) => *child = new_child, Err(e) => { eprintln!("Failed to restart WebDAV: {e}"); @@ -439,10 +539,334 @@ fn supervise( } } - thread::sleep(POLL_INTERVAL); + // Update shared status with fresh RC stats + update_status(shared_status, mounts, protocols, &config); } } +/// Poll RC API for each share and update the shared DaemonStatus. +fn update_status( + shared_status: &Arc>, + mounts: &[MountChild], + protocols: &ProtocolChildren, + config: &Config, +) { + let mut status = shared_status.write().unwrap(); + + // Update per-share stats from RC API + for (i, mc) in mounts.iter().enumerate() { + if let Some(ss) = status.shares.get_mut(i) { + ss.mounted = is_mounted( + &config + .shares + .get(i) + .map(|s| s.mount_point.clone()) + .unwrap_or_default(), + ) + .unwrap_or(false); + ss.rc_port = mc.rc_port; + + // Fetch VFS stats (cache info, dirty files) + if let Ok(vfs) = rc::vfs_stats(mc.rc_port) { + if let Some(dc) = &vfs.disk_cache { + ss.cache_bytes = dc.bytes_used; + ss.dirty_count = dc.uploads_in_progress + dc.uploads_queued; + ss.errored_files = dc.errored_files; + } + } + + // Fetch core stats (speed, transfers) + if let Ok(core) = rc::core_stats(mc.rc_port) { + ss.speed = core.speed; + ss.transfers = core.transfers; + ss.errors = core.errors; + } + } + } + + // Update protocol status + status.smbd_running = protocols.smbd.is_some(); + status.webdav_running = protocols.webdav.is_some(); + status.nfs_exported = config.protocols.enable_nfs; +} + +/// Apply bandwidth limits to all rclone mounts via RC API (Tier A — no restart). +fn apply_bwlimit(mounts: &[MountChild], up: &str, down: &str) { + for mc in mounts { + match rc::bwlimit(mc.rc_port, Some(up), Some(down)) { + Ok(_) => println!(" bwlimit applied to '{}'", mc.name), + Err(e) => eprintln!(" bwlimit failed for '{}': {e}", mc.name), + } + } +} + +/// Handle a config reload using the tiered change strategy. +fn handle_reload( + shared_config: &Arc>, + shared_status: &Arc>, + mounts: &mut Vec, + protocols: &mut ProtocolChildren, + smbd_tracker: &mut RestartTracker, + webdav_tracker: &mut RestartTracker, + new_config: Config, +) -> Result<()> { + let old_config = shared_config.read().unwrap().clone(); + let diff = config_diff::diff(&old_config, &new_config); + + if diff.is_empty() { + println!(" No changes detected."); + return Ok(()); + } + + println!(" Changes: {}", diff.summary()); + + match diff.highest_tier() { + ChangeTier::None => {} + + ChangeTier::Live => { + // Tier A: bandwidth only — RC API call, no restart + println!(" Tier A: applying bandwidth limits via RC API..."); + apply_bwlimit(mounts, &new_config.bandwidth.limit_up, &new_config.bandwidth.limit_down); + } + + ChangeTier::Protocol => { + // Tier B: protocol-only changes — regen configs, restart protocol services + // Also apply bandwidth if changed + if diff.bandwidth_changed { + apply_bwlimit(mounts, &new_config.bandwidth.limit_up, &new_config.bandwidth.limit_down); + } + println!(" Tier B: restarting protocol services..."); + restart_protocols(protocols, smbd_tracker, webdav_tracker, &new_config)?; + } + + ChangeTier::PerShare => { + // Tier C: per-share changes — drain affected, unmount, remount + if diff.bandwidth_changed { + apply_bwlimit(mounts, &new_config.bandwidth.limit_up, &new_config.bandwidth.limit_down); + } + + // Handle removed shares: drain → unmount → kill + for name in &diff.shares_removed { + println!(" Removing share '{name}'..."); + if let Some(idx) = mounts.iter().position(|mc| mc.name == *name) { + let mc = &mounts[idx]; + wait_writeback_drain(mc.rc_port); + unmount_share(&old_config, &mc.name); + let mut mc = mounts.remove(idx); + graceful_kill(&mut mc.child); + } + } + + // Handle modified shares: treat as remove + add + for name in &diff.shares_modified { + println!(" Restarting modified share '{name}'..."); + // Remove old + if let Some(idx) = mounts.iter().position(|mc| mc.name == *name) { + let mc = &mounts[idx]; + wait_writeback_drain(mc.rc_port); + unmount_share(&old_config, &mc.name); + let mut mc = mounts.remove(idx); + graceful_kill(&mut mc.child); + } + // Add new + if let Some((i, share)) = new_config.shares.iter().enumerate().find(|(_, s)| s.name == *name) { + let rc_port = new_config.rc_port(i); + if let Ok(mc) = spawn_mount(&new_config, share, rc_port) { + mounts.push(mc); + } + } + } + + // Handle added shares: spawn new mount + for name in &diff.shares_added { + println!(" Adding share '{name}'..."); + if let Some((i, share)) = new_config.shares.iter().enumerate().find(|(_, s)| s.name == *name) { + let rc_port = new_config.rc_port(i); + std::fs::create_dir_all(&share.mount_point).ok(); + if let Ok(mc) = spawn_mount(&new_config, share, rc_port) { + mounts.push(mc); + } + } + } + + // Regen protocol configs (shares changed → SMB/NFS sections changed) + if diff.protocols_changed || !diff.shares_removed.is_empty() || !diff.shares_added.is_empty() || !diff.shares_modified.is_empty() { + restart_protocols(protocols, smbd_tracker, webdav_tracker, &new_config)?; + } + } + + ChangeTier::Global => { + // Tier D: global restart — drain all → stop everything → restart + println!(" Tier D: full restart (global settings changed)..."); + + // Drain all write-back queues + for mc in mounts.iter() { + wait_writeback_drain(mc.rc_port); + } + + // Stop all protocol services + stop_protocols(protocols, &old_config); + + // Unmount and kill all rclone instances + for mc in mounts.iter_mut() { + unmount_share(&old_config, &mc.name); + graceful_kill(&mut mc.child); + } + mounts.clear(); + + // Re-preflight with new config + preflight(&new_config)?; + + // Re-start mounts + let shutdown = AtomicBool::new(false); + let mut new_mounts = start_and_wait_mounts(&new_config, &shutdown)?; + mounts.append(&mut new_mounts); + + // Re-start protocols + let new_protocols = start_protocols(&new_config)?; + // Replace old protocol children (Drop will handle any leftover) + *protocols = new_protocols; + *smbd_tracker = RestartTracker::new(); + *webdav_tracker = RestartTracker::new(); + } + } + + // Update shared config + { + let mut cfg = shared_config.write().unwrap(); + *cfg = new_config.clone(); + } + + // Update shared status with new share list + { + let mut status = shared_status.write().unwrap(); + let new_shares: Vec = new_config + .shares + .iter() + .enumerate() + .map(|(i, s)| { + // Preserve existing stats if share still exists + let existing = status.shares.iter().find(|ss| ss.name == s.name); + crate::daemon::ShareStatus { + name: s.name.clone(), + mounted: existing.map(|e| e.mounted).unwrap_or(false), + rc_port: new_config.rc_port(i), + cache_bytes: existing.map(|e| e.cache_bytes).unwrap_or(0), + dirty_count: existing.map(|e| e.dirty_count).unwrap_or(0), + errored_files: existing.map(|e| e.errored_files).unwrap_or(0), + speed: existing.map(|e| e.speed).unwrap_or(0.0), + transfers: existing.map(|e| e.transfers).unwrap_or(0), + errors: existing.map(|e| e.errors).unwrap_or(0), + } + }) + .collect(); + status.shares = new_shares; + status.smbd_running = protocols.smbd.is_some(); + status.webdav_running = protocols.webdav.is_some(); + status.nfs_exported = new_config.protocols.enable_nfs; + } + + Ok(()) +} + +/// Spawn a single rclone mount for a share. +fn spawn_mount(config: &Config, share: &crate::config::ShareConfig, rc_port: u16) -> Result { + let args = build_mount_args(config, share, rc_port); + let child = Command::new("rclone") + .args(&args) + .process_group(0) + .spawn() + .with_context(|| format!("Failed to spawn rclone mount for share '{}'", share.name))?; + + // Wait for mount to appear + let deadline = Instant::now() + MOUNT_TIMEOUT; + loop { + if Instant::now() > deadline { + anyhow::bail!("Timed out waiting for mount '{}'", share.name); + } + match is_mounted(&share.mount_point) { + Ok(true) => break, + _ => thread::sleep(Duration::from_millis(500)), + } + } + + println!(" Mount ready: {} at {}", share.name, share.mount_point.display()); + Ok(MountChild { + name: share.name.clone(), + child, + rc_port, + }) +} + +/// Unmount a single share's FUSE mount. +fn unmount_share(config: &Config, share_name: &str) { + if let Some(share) = config.find_share(share_name) { + if is_mounted(&share.mount_point).unwrap_or(false) { + let mp = share.mount_point.display().to_string(); + let unmounted = Command::new("fusermount3") + .args(["-uz", &mp]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !unmounted { + let _ = Command::new("fusermount") + .args(["-uz", &mp]) + .status(); + } + } + } +} + +/// Stop protocol services only (without touching mounts). +fn stop_protocols(protocols: &mut ProtocolChildren, config: &Config) { + if let Some(child) = &mut protocols.smbd { + graceful_kill(child); + println!(" SMB: stopped"); + } + protocols.smbd = None; + + if config.protocols.enable_nfs { + let _ = Command::new("exportfs").arg("-ua").status(); + println!(" NFS: unexported"); + } + + if let Some(child) = &mut protocols.webdav { + graceful_kill(child); + println!(" WebDAV: stopped"); + } + protocols.webdav = None; +} + +/// Restart protocol services (Tier B). Regen configs and restart smbd/NFS/WebDAV. +fn restart_protocols( + protocols: &mut ProtocolChildren, + smbd_tracker: &mut RestartTracker, + webdav_tracker: &mut RestartTracker, + config: &Config, +) -> Result<()> { + // Stop existing + stop_protocols(protocols, config); + + // Regenerate configs + if config.protocols.enable_smb { + samba::write_config(config)?; + if config.smb_auth.enabled { + samba::setup_user(config)?; + } + } + if config.protocols.enable_nfs { + nfs::write_config(config)?; + } + + // Start fresh + let new_protocols = start_protocols(config)?; + *protocols = new_protocols; + *smbd_tracker = RestartTracker::new(); + *webdav_tracker = RestartTracker::new(); + + Ok(()) +} + /// Send SIGTERM, wait up to `SIGTERM_GRACE`, then SIGKILL if still alive. fn graceful_kill(child: &mut Child) { let pid = child.id() as i32; @@ -468,8 +892,6 @@ fn graceful_kill(child: &mut Child) { /// Wait for rclone VFS write-back queue to drain on a specific RC port. fn wait_writeback_drain(port: u16) { - use crate::rclone::rc; - let deadline = Instant::now() + WRITEBACK_DRAIN_TIMEOUT; let mut first = true; @@ -502,7 +924,7 @@ fn wait_writeback_drain(port: u16) { if Instant::now() > deadline { eprintln!(); eprintln!( - " Warning: write-back drain timed out after {}s, proceeding with shutdown.", + " Warning: write-back drain timed out after {}s, proceeding.", WRITEBACK_DRAIN_TIMEOUT.as_secs() ); return; @@ -514,27 +936,12 @@ fn wait_writeback_drain(port: u16) { /// Reverse-order teardown of all services. fn shutdown_services(config: &Config, mounts: &mut Vec, protocols: &mut ProtocolChildren) { - // Stop SMB - if let Some(child) = &mut protocols.smbd { - graceful_kill(child); - println!(" SMB: stopped"); - } + // Stop protocol services + stop_protocols(protocols, config); - // Unexport NFS - if config.protocols.enable_nfs { - let _ = Command::new("exportfs").arg("-ua").status(); - println!(" NFS: unexported"); - } - - // Kill WebDAV - if let Some(child) = &mut protocols.webdav { - graceful_kill(child); - println!(" WebDAV: stopped"); - } - - // Wait for write-back queues to drain on each share's RC port - for (i, _share) in config.shares.iter().enumerate() { - wait_writeback_drain(config.rc_port(i)); + // Wait for write-back queues to drain on each mount + for mc in mounts.iter() { + wait_writeback_drain(mc.rc_port); } // Lazy unmount each share's FUSE mount diff --git a/src/web/api.rs b/src/web/api.rs new file mode 100644 index 0000000..bc8e28c --- /dev/null +++ b/src/web/api.rs @@ -0,0 +1,241 @@ +//! JSON API handlers for programmatic access and future SPA. +//! +//! All endpoints return JSON. The htmx frontend uses the page handlers instead, +//! but these are available for CLI tools and external integrations. + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::Json; +use axum::routing::{get, post}; +use axum::Router; +use serde::Serialize; + +use crate::daemon::SupervisorCmd; +use crate::web::SharedState; + +pub fn routes() -> Router { + Router::new() + .route("/api/status", get(get_status)) + .route("/api/status/{share}", get(get_share_status)) + .route("/api/config", get(get_config)) + .route("/api/config", post(post_config)) + .route("/api/bwlimit", post(post_bwlimit)) +} + +/// GET /api/status — overall daemon status. +#[derive(Serialize)] +struct StatusResponse { + uptime: String, + shares: Vec, + smbd_running: bool, + webdav_running: bool, + nfs_exported: bool, +} + +#[derive(Serialize)] +struct ShareStatusResponse { + name: String, + mounted: bool, + rc_port: u16, + cache_bytes: u64, + cache_display: String, + dirty_count: u64, + errored_files: u64, + speed: f64, + speed_display: String, + transfers: u64, + errors: u64, +} + +async fn get_status(State(state): State) -> Json { + let status = state.status.read().unwrap(); + Json(StatusResponse { + uptime: status.uptime_string(), + shares: status + .shares + .iter() + .map(|s| ShareStatusResponse { + name: s.name.clone(), + mounted: s.mounted, + rc_port: s.rc_port, + cache_bytes: s.cache_bytes, + cache_display: s.cache_display(), + dirty_count: s.dirty_count, + errored_files: s.errored_files, + speed: s.speed, + speed_display: s.speed_display(), + transfers: s.transfers, + errors: s.errors, + }) + .collect(), + smbd_running: status.smbd_running, + webdav_running: status.webdav_running, + nfs_exported: status.nfs_exported, + }) +} + +/// GET /api/status/{share} — per-share status. +async fn get_share_status( + State(state): State, + Path(share_name): Path, +) -> Result, StatusCode> { + let status = state.status.read().unwrap(); + let share = status + .shares + .iter() + .find(|s| s.name == share_name) + .ok_or(StatusCode::NOT_FOUND)?; + Ok(Json(ShareStatusResponse { + name: share.name.clone(), + mounted: share.mounted, + rc_port: share.rc_port, + cache_bytes: share.cache_bytes, + cache_display: share.cache_display(), + dirty_count: share.dirty_count, + errored_files: share.errored_files, + speed: share.speed, + speed_display: share.speed_display(), + transfers: share.transfers, + errors: share.errors, + })) +} + +/// GET /api/config — current config as JSON. +async fn get_config(State(state): State) -> Json { + let config = state.config.read().unwrap(); + Json(serde_json::to_value(&*config).unwrap_or_default()) +} + +/// POST /api/config — submit new config as TOML string. +#[derive(serde::Deserialize)] +struct ConfigSubmit { + toml: String, +} + +#[derive(Serialize)] +struct ConfigResponse { + ok: bool, + message: String, + diff_summary: Option, +} + +async fn post_config( + State(state): State, + Json(body): Json, +) -> (StatusCode, Json) { + // Parse and validate the new config + let new_config: crate::config::Config = match toml::from_str(&body.toml) { + Ok(c) => c, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(ConfigResponse { + ok: false, + message: format!("TOML parse error: {e}"), + diff_summary: None, + }), + ); + } + }; + + if let Err(e) = new_config.validate() { + return ( + StatusCode::BAD_REQUEST, + Json(ConfigResponse { + ok: false, + message: format!("Validation error: {e}"), + diff_summary: None, + }), + ); + } + + // Compute diff for summary + let diff_summary = { + let old_config = state.config.read().unwrap(); + let d = crate::config_diff::diff(&old_config, &new_config); + if d.is_empty() { + return ( + StatusCode::OK, + Json(ConfigResponse { + ok: true, + message: "No changes detected".to_string(), + diff_summary: Some(d.summary()), + }), + ); + } + d.summary() + }; + + // Save to disk + if let Err(e) = std::fs::write(&state.config_path, &body.toml) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ConfigResponse { + ok: false, + message: format!("Failed to write config file: {e}"), + diff_summary: Some(diff_summary), + }), + ); + } + + // Send reload command to supervisor + if let Err(e) = state.cmd_tx.send(SupervisorCmd::Reload(new_config)) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ConfigResponse { + ok: false, + message: format!("Failed to send reload command: {e}"), + diff_summary: Some(diff_summary), + }), + ); + } + + ( + StatusCode::OK, + Json(ConfigResponse { + ok: true, + message: "Config applied successfully".to_string(), + diff_summary: Some(diff_summary), + }), + ) +} + +/// POST /api/bwlimit — live bandwidth adjustment. +#[derive(serde::Deserialize)] +struct BwLimitRequest { + #[serde(default = "default_bw")] + up: String, + #[serde(default = "default_bw")] + down: String, +} + +fn default_bw() -> String { + "0".to_string() +} + +#[derive(Serialize)] +struct BwLimitResponse { + ok: bool, + message: String, +} + +async fn post_bwlimit( + State(state): State, + Json(body): Json, +) -> Json { + match state + .cmd_tx + .send(SupervisorCmd::BwLimit { + up: body.up, + down: body.down, + }) { + Ok(()) => Json(BwLimitResponse { + ok: true, + message: "Bandwidth limit updated".to_string(), + }), + Err(e) => Json(BwLimitResponse { + ok: false, + message: format!("Failed to send command: {e}"), + }), + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 0000000..49c44b4 --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,64 @@ +//! Built-in web server for the Warpgate dashboard and API. +//! +//! Runs on a separate tokio runtime in its own thread. Shares state with +//! the supervisor via `Arc>` and an mpsc command channel. + +pub mod api; +pub mod pages; + +use std::sync::mpsc; +use std::sync::{Arc, RwLock}; +use std::thread; + +use axum::Router; + +use crate::config::Config; +use crate::daemon::{AppState, DaemonStatus, SupervisorCmd, DEFAULT_WEB_PORT}; + +/// Axum-compatible shared state (wraps AppState in an Arc for axum). +pub type SharedState = Arc; + +/// Build the axum router with all routes. +pub fn build_router(state: SharedState) -> Router { + Router::new() + .merge(pages::routes()) + .merge(api::routes()) + .with_state(state) +} + +/// Spawn the web server in a background thread with its own tokio runtime. +/// +/// Returns a join handle. The server listens on `0.0.0.0:DEFAULT_WEB_PORT`. +pub fn spawn_web_server( + config: Arc>, + status: Arc>, + cmd_tx: mpsc::Sender, + config_path: std::path::PathBuf, +) -> thread::JoinHandle<()> { + thread::spawn(move || { + let state = Arc::new(AppState { + config, + status, + cmd_tx, + config_path, + }); + + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("Failed to create tokio runtime for web server"); + + rt.block_on(async { + let app = build_router(state); + let addr = format!("0.0.0.0:{DEFAULT_WEB_PORT}"); + let listener = tokio::net::TcpListener::bind(&addr) + .await + .unwrap_or_else(|e| panic!("Failed to bind web server to {addr}: {e}")); + println!(" Web UI: http://localhost:{DEFAULT_WEB_PORT}"); + if let Err(e) = axum::serve(listener, app).await { + eprintln!("Web server error: {e}"); + } + }); + }) +} diff --git a/src/web/pages.rs b/src/web/pages.rs new file mode 100644 index 0000000..6131575 --- /dev/null +++ b/src/web/pages.rs @@ -0,0 +1,312 @@ +//! HTML page handlers using askama templates for the htmx-powered frontend. + +use askama::Template; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::routing::{get, post}; +use axum::Form; +use axum::Router; + +use crate::web::SharedState; + +pub fn routes() -> Router { + Router::new() + .route("/", get(dashboard)) + .route("/shares/{name}", get(share_detail)) + .route("/config", get(config_page)) + .route("/config", post(config_submit)) + .route("/partials/status", get(status_partial)) +} + +// --- Templates --- + +#[derive(Template)] +#[template(path = "web/dashboard.html")] +struct DashboardTemplate { + uptime: String, + config_path: String, + shares: Vec, + smbd_running: bool, + webdav_running: bool, + nfs_exported: bool, +} + +struct ShareView { + name: String, + mount_point: String, + mounted: bool, + cache_display: String, + dirty_count: u64, + speed_display: String, + read_only: bool, +} + +#[derive(Template)] +#[template(path = "web/share_detail.html")] +struct ShareDetailTemplate { + name: String, + mount_point: String, + remote_path: String, + mounted: bool, + read_only: bool, + rc_port: u16, + cache_display: String, + dirty_count: u64, + errored_files: u64, + speed_display: String, + transfers: u64, + errors: u64, +} + +#[derive(Template)] +#[template(path = "web/config.html")] +struct ConfigTemplate { + toml_content: String, + message: Option, + is_error: bool, +} + +#[derive(Template)] +#[template(path = "web/status_partial.html")] +struct StatusPartialTemplate { + #[allow(dead_code)] + uptime: String, + shares: Vec, + smbd_running: bool, + webdav_running: bool, + nfs_exported: bool, +} + +// --- Handlers --- + +async fn dashboard(State(state): State) -> Response { + let status = state.status.read().unwrap(); + let config = state.config.read().unwrap(); + + let shares: Vec = status + .shares + .iter() + .map(|s| { + let read_only = config + .find_share(&s.name) + .map(|sc| sc.read_only) + .unwrap_or(false); + ShareView { + name: s.name.clone(), + mount_point: config + .find_share(&s.name) + .map(|sc| sc.mount_point.display().to_string()) + .unwrap_or_default(), + mounted: s.mounted, + cache_display: s.cache_display(), + dirty_count: s.dirty_count, + speed_display: s.speed_display(), + read_only, + } + }) + .collect(); + + let tmpl = DashboardTemplate { + uptime: status.uptime_string(), + config_path: state.config_path.display().to_string(), + shares, + smbd_running: status.smbd_running, + webdav_running: status.webdav_running, + nfs_exported: status.nfs_exported, + }; + + match tmpl.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), + } +} + +async fn share_detail( + State(state): State, + Path(name): Path, +) -> Response { + let status = state.status.read().unwrap(); + let config = state.config.read().unwrap(); + + let share_status = match status.shares.iter().find(|s| s.name == name) { + Some(s) => s, + None => return (StatusCode::NOT_FOUND, "Share not found").into_response(), + }; + + let share_config = config.find_share(&name); + + let tmpl = ShareDetailTemplate { + name: share_status.name.clone(), + mount_point: share_config + .map(|sc| sc.mount_point.display().to_string()) + .unwrap_or_default(), + remote_path: share_config + .map(|sc| sc.remote_path.clone()) + .unwrap_or_default(), + mounted: share_status.mounted, + read_only: share_config.map(|sc| sc.read_only).unwrap_or(false), + rc_port: share_status.rc_port, + cache_display: share_status.cache_display(), + dirty_count: share_status.dirty_count, + errored_files: share_status.errored_files, + speed_display: share_status.speed_display(), + transfers: share_status.transfers, + errors: share_status.errors, + }; + + match tmpl.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), + } +} + +async fn config_page(State(state): State) -> Response { + let config = state.config.read().unwrap(); + let toml_content = toml::to_string_pretty(&*config).unwrap_or_default(); + + let tmpl = ConfigTemplate { + toml_content, + message: None, + is_error: false, + }; + + match tmpl.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), + } +} + +#[derive(serde::Deserialize)] +struct ConfigForm { + toml: String, +} + +async fn config_submit( + State(state): State, + Form(form): Form, +) -> Response { + // Parse and validate + let new_config: crate::config::Config = match toml::from_str(&form.toml) { + Ok(c) => c, + Err(e) => { + let tmpl = ConfigTemplate { + toml_content: form.toml, + message: Some(format!("TOML parse error: {e}")), + is_error: true, + }; + return match tmpl.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), + }; + } + }; + + if let Err(e) = new_config.validate() { + let tmpl = ConfigTemplate { + toml_content: form.toml, + message: Some(format!("Validation error: {e}")), + is_error: true, + }; + return match tmpl.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), + }; + } + + // Compute diff summary + let diff_summary = { + let old_config = state.config.read().unwrap(); + let d = crate::config_diff::diff(&old_config, &new_config); + if d.is_empty() { + let tmpl = ConfigTemplate { + toml_content: form.toml, + message: Some("No changes detected.".to_string()), + is_error: false, + }; + return match tmpl.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), + }; + } + d.summary() + }; + + // Save to disk + if let Err(e) = std::fs::write(&state.config_path, &form.toml) { + let tmpl = ConfigTemplate { + toml_content: form.toml, + message: Some(format!("Failed to write config: {e}")), + is_error: true, + }; + return match tmpl.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), + }; + } + + // Send reload command + if let Err(e) = state + .cmd_tx + .send(crate::daemon::SupervisorCmd::Reload(new_config)) + { + let tmpl = ConfigTemplate { + toml_content: form.toml, + message: Some(format!("Failed to send reload: {e}")), + is_error: true, + }; + return match tmpl.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), + }; + } + + // Success — redirect to dashboard + Redirect::to(&format!("/config?msg={}", urlencoded("Config applied: ".to_string() + &diff_summary))).into_response() +} + +fn urlencoded(s: String) -> String { + s.replace(' ', "+").replace(':', "%3A").replace(',', "%2C") +} + +/// Partial HTML fragment for htmx polling (status cards only). +async fn status_partial(State(state): State) -> Response { + let status = state.status.read().unwrap(); + let config = state.config.read().unwrap(); + + let shares: Vec = status + .shares + .iter() + .map(|s| { + let read_only = config + .find_share(&s.name) + .map(|sc| sc.read_only) + .unwrap_or(false); + ShareView { + name: s.name.clone(), + mount_point: config + .find_share(&s.name) + .map(|sc| sc.mount_point.display().to_string()) + .unwrap_or_default(), + mounted: s.mounted, + cache_display: s.cache_display(), + dirty_count: s.dirty_count, + speed_display: s.speed_display(), + read_only, + } + }) + .collect(); + + let tmpl = StatusPartialTemplate { + uptime: status.uptime_string(), + shares, + smbd_running: status.smbd_running, + webdav_running: status.webdav_running, + nfs_exported: status.nfs_exported, + }; + + match tmpl.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), + } +} diff --git a/templates/web/config.html b/templates/web/config.html new file mode 100644 index 0000000..06c5413 --- /dev/null +++ b/templates/web/config.html @@ -0,0 +1,55 @@ + + + + + + Warpgate — Config + + + + + +

Configuration Editor

+ + {% if let Some(msg) = message %} +
{{ msg }}
+ {% endif %} + +
+ +
+ + Cancel +
+
+ + diff --git a/templates/web/dashboard.html b/templates/web/dashboard.html new file mode 100644 index 0000000..1bc3602 --- /dev/null +++ b/templates/web/dashboard.html @@ -0,0 +1,88 @@ + + + + + + Warpgate Dashboard + + + + +
+
+

Warpgate Dashboard

+
Uptime: {{ uptime }}  |  Config: {{ config_path }}
+
+
+ +
+ {% for share in shares %} +
+
+

{{ share.name }}

+
+ {% if share.mounted %} + OK + {% else %} + DOWN + {% endif %} + {% if share.read_only %} + RO + {% endif %} +
+
+
+ Mount: {{ share.mount_point }} + Cache: {{ share.cache_display }} + Dirty: {{ share.dirty_count }} + Speed: {{ share.speed_display }} +
+
+ {% endfor %} + +
+ SMB: {% if smbd_running %}ON{% else %}OFF{% endif %} + NFS: {% if nfs_exported %}ON{% else %}OFF{% endif %} + WebDAV: {% if webdav_running %}ON{% else %}OFF{% endif %} +
+
+ + + + diff --git a/templates/web/share_detail.html b/templates/web/share_detail.html new file mode 100644 index 0000000..7709b19 --- /dev/null +++ b/templates/web/share_detail.html @@ -0,0 +1,70 @@ + + + + + + Warpgate — {{ name }} + + + + + + +

+ {{ name }} + {% if mounted %}OK{% else %}DOWN{% endif %} + {% if read_only %}Read-Only{% endif %} +

+ +
+
+
Cache Used
+
{{ cache_display }}
+
+
+
Dirty Files
+
{{ dirty_count }}
+
+
+
Transfer Speed
+
{{ speed_display }}
+
+
+
Active Transfers
+
{{ transfers }}
+
+
+ + + + + + + +
Mount Point{{ mount_point }}
Remote Path{{ remote_path }}
RC Port{{ rc_port }}
Errored Files{{ errored_files }}
Total Errors{{ errors }}
+ + diff --git a/templates/web/status_partial.html b/templates/web/status_partial.html new file mode 100644 index 0000000..4bdc69b --- /dev/null +++ b/templates/web/status_partial.html @@ -0,0 +1,29 @@ +{% for share in shares %} +
+
+

{{ share.name }}

+
+ {% if share.mounted %} + OK + {% else %} + DOWN + {% endif %} + {% if share.read_only %} + RO + {% endif %} +
+
+
+ Mount: {{ share.mount_point }} + Cache: {{ share.cache_display }} + Dirty: {{ share.dirty_count }} + Speed: {{ share.speed_display }} +
+
+{% endfor %} + +
+ SMB: {% if smbd_running %}ON{% else %}OFF{% endif %} + NFS: {% if nfs_exported %}ON{% else %}OFF{% endif %} + WebDAV: {% if webdav_running %}ON{% else %}OFF{% endif %} +