Add daemon web UI, JSON API, and config hot-reload engine

- New: axum web server on port 8090 with htmx dashboard
- New: JSON API endpoints (/api/status, /api/config, /api/bwlimit)
- New: config diff engine with 4-tier change classification
- New: tiered config hot-reload (live/protocol/per-share/global)
- Refactor: supervisor loop uses mpsc command channel (recv_timeout)
- Refactor: supervisor updates shared DaemonStatus every poll cycle
- Dependencies: tokio, axum, askama, tower-http

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
grabbit 2026-02-18 14:18:20 +08:00
parent 08f8fc4667
commit ba1cae7f75
14 changed files with 2322 additions and 44 deletions

6
.claude/settings.json Normal file
View File

@ -0,0 +1,6 @@
{
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
},
"teammateMode": "tmux"
}

495
Cargo.lock generated
View File

@ -64,12 +64,131 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" 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]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.0" version = "2.11.0"
@ -280,6 +399,39 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@ -313,12 +465,77 @@ dependencies = [
"itoa", "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]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 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]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.1.1" version = "2.1.1"
@ -467,12 +684,24 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@ -483,6 +712,17 @@ dependencies = [
"simd-adler32", "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]] [[package]]
name = "nix" name = "nix"
version = "0.31.1" version = "0.31.1"
@ -534,6 +774,18 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 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]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@ -581,6 +833,12 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.36" version = "0.23.36"
@ -616,6 +874,12 @@ dependencies = [
"untrusted", "untrusted",
] ]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@ -659,6 +923,17 @@ dependencies = [
"zmij", "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]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.0.4" version = "1.0.4"
@ -668,6 +943,18 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@ -680,12 +967,28 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.1" version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 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]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@ -715,6 +1018,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]] [[package]]
name = "synstructure" name = "synstructure"
version = "0.13.2" version = "0.13.2"
@ -787,6 +1096,31 @@ dependencies = [
"zerovec", "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]] [[package]]
name = "toml" name = "toml"
version = "1.0.2+spec-1.1.0" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" 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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@ -911,13 +1307,17 @@ name = "warpgate"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"askama",
"axum",
"clap", "clap",
"ctrlc", "ctrlc",
"libc", "libc",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror",
"tokio",
"toml", "toml",
"tower-http",
"ureq", "ureq",
] ]
@ -948,7 +1348,16 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [ 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]] [[package]]
@ -966,14 +1375,31 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc", "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]] [[package]]
@ -982,53 +1408,104 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 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]] [[package]]
name = "winnow" name = "winnow"
version = "0.7.14" version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "writeable" name = "writeable"

View File

@ -13,3 +13,7 @@ toml = "1.0.2"
ctrlc = "3.4" ctrlc = "3.4"
libc = "0.2" libc = "0.2"
ureq = { version = "3.2.0", features = ["json"] } 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"] }

304
src/config_diff.rs Normal file
View File

@ -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<String>,
/// Tier C: shares that were added (by name).
pub shares_added: Vec<String>,
/// Tier C: shares that were modified (remote_path, mount_point, or read_only changed).
pub shares_modified: Vec<String>,
/// 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);
}
}

218
src/daemon.rs Normal file
View File

@ -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<RwLock<DaemonStatus>>` 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<RwLock<Config>>,
/// Live daemon status (updated by supervisor every poll cycle).
pub status: Arc<RwLock<DaemonStatus>>,
/// Command channel: web server → supervisor.
pub cmd_tx: mpsc::Sender<SupervisorCmd>,
/// 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<ShareStatus>,
/// 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(), "-");
}
}

View File

@ -1,9 +1,12 @@
mod cli; mod cli;
mod config; mod config;
mod config_diff;
mod daemon;
mod deploy; mod deploy;
mod rclone; mod rclone;
mod services; mod services;
mod supervisor; mod supervisor;
mod web;
use std::path::PathBuf; use std::path::PathBuf;
@ -107,7 +110,7 @@ fn main() -> Result<()> {
} }
Commands::Log { lines, follow } => cli::log::run(&config, lines, follow), Commands::Log { lines, follow } => cli::log::run(&config, lines, follow),
Commands::SpeedTest => cli::speed_test::run(&config), Commands::SpeedTest => cli::speed_test::run(&config),
Commands::Run => supervisor::run(&config), Commands::Run => supervisor::run(&config, cli.config.clone()),
// already handled above // already handled above
Commands::ConfigInit { .. } | Commands::Deploy => unreachable!(), Commands::ConfigInit { .. } | Commands::Deploy => unreachable!(),
} }

View File

@ -1,19 +1,25 @@
//! `warpgate run` — single-process supervisor for all services. //! `warpgate run` — single-process supervisor for all services.
//! //!
//! Manages rclone mount processes (one per share) + protocol services in one //! 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::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::process::{Child, Command}; use std::process::{Child, Command};
use std::sync::atomic::{AtomicBool, Ordering}; 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::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crate::config::Config; 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::mount::{build_mount_args, is_mounted};
use crate::rclone::rc;
use crate::services::{nfs, samba, webdav}; use crate::services::{nfs, samba, webdav};
/// Mount ready timeout. /// Mount ready timeout.
@ -66,6 +72,7 @@ impl RestartTracker {
struct MountChild { struct MountChild {
name: String, name: String,
child: Child, child: Child,
rc_port: u16,
} }
/// Child processes for protocol servers managed by the supervisor. /// 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`. /// 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)); let shutdown = Arc::new(AtomicBool::new(false));
// Install signal handler (SIGTERM + SIGINT) // Install signal handler (SIGTERM + SIGINT)
@ -97,6 +104,34 @@ pub fn run(config: &Config) -> Result<()> {
}) })
.context("Failed to set signal handler")?; .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<String> = 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::<SupervisorCmd>();
// 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 // Phase 1: Preflight — generate configs, create dirs
println!("Preflight checks..."); println!("Preflight checks...");
preflight(config)?; preflight(config)?;
@ -108,6 +143,17 @@ pub fn run(config: &Config) -> Result<()> {
println!(" Mount ready at {}", share.mount_point.display()); 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 // Phase 3: Start protocol services
if shutdown.load(Ordering::SeqCst) { if shutdown.load(Ordering::SeqCst) {
println!("Shutdown signal received during mount."); println!("Shutdown signal received during mount.");
@ -120,6 +166,14 @@ pub fn run(config: &Config) -> Result<()> {
println!("Starting protocol services..."); println!("Starting protocol services...");
let mut protocols = start_protocols(config)?; 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) // Phase 3.5: Auto-warmup in background thread (non-blocking)
if !config.warmup.rules.is_empty() && config.warmup.auto { if !config.warmup.rules.is_empty() && config.warmup.auto {
let warmup_config = config.clone(); let warmup_config = config.clone();
@ -144,13 +198,21 @@ pub fn run(config: &Config) -> Result<()> {
}); });
} }
// Phase 4: Supervision loop // Phase 4: Supervision loop with command channel
println!("Supervision active. Press Ctrl+C to stop."); println!("Supervision active. Web UI at http://localhost:8090. Press Ctrl+C to stop.");
let result = supervise(config, &mut mount_children, &mut protocols, Arc::clone(&shutdown)); let result = supervise(
&shared_config,
&shared_status,
&cmd_rx,
&mut mount_children,
&mut protocols,
Arc::clone(&shutdown),
);
// Phase 5: Teardown (always runs) // Phase 5: Teardown (always runs)
println!("Shutting down..."); 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 result
} }
@ -209,6 +271,7 @@ fn start_and_wait_mounts(config: &Config, shutdown: &AtomicBool) -> Result<Vec<M
children.push(MountChild { children.push(MountChild {
name: share.name.clone(), name: share.name.clone(),
child, child,
rc_port,
}); });
} }
@ -339,12 +402,17 @@ fn spawn_webdav(config: &Config) -> Result<Child> {
.context("Failed to spawn rclone serve webdav") .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 any rclone mount dies → full shutdown (data safety).
/// - If smbd/WebDAV dies → restart up to 3 times. /// - If smbd/WebDAV dies → restart up to 3 times.
fn supervise( fn supervise(
config: &Config, shared_config: &Arc<RwLock<Config>>,
shared_status: &Arc<RwLock<DaemonStatus>>,
cmd_rx: &mpsc::Receiver<SupervisorCmd>,
mounts: &mut Vec<MountChild>, mounts: &mut Vec<MountChild>,
protocols: &mut ProtocolChildren, protocols: &mut ProtocolChildren,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
@ -353,6 +421,37 @@ fn supervise(
let mut webdav_tracker = RestartTracker::new(); let mut webdav_tracker = RestartTracker::new();
loop { 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) { if shutdown.load(Ordering::SeqCst) {
println!("Shutdown signal received."); println!("Shutdown signal received.");
return Ok(()); return Ok(());
@ -408,6 +507,7 @@ fn supervise(
} }
// Check WebDAV process (if enabled) // Check WebDAV process (if enabled)
let config = shared_config.read().unwrap().clone();
if let Some(child) = &mut protocols.webdav { if let Some(child) = &mut protocols.webdav {
match child.try_wait() { match child.try_wait() {
Ok(Some(status)) => { Ok(Some(status)) => {
@ -420,7 +520,7 @@ fn supervise(
webdav_tracker.count, webdav_tracker.count,
); );
thread::sleep(Duration::from_secs(delay.into())); thread::sleep(Duration::from_secs(delay.into()));
match spawn_webdav(config) { match spawn_webdav(&config) {
Ok(new_child) => *child = new_child, Ok(new_child) => *child = new_child,
Err(e) => { Err(e) => {
eprintln!("Failed to restart WebDAV: {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<RwLock<DaemonStatus>>,
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<RwLock<Config>>,
shared_status: &Arc<RwLock<DaemonStatus>>,
mounts: &mut Vec<MountChild>,
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<crate::daemon::ShareStatus> = 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<MountChild> {
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. /// Send SIGTERM, wait up to `SIGTERM_GRACE`, then SIGKILL if still alive.
fn graceful_kill(child: &mut Child) { fn graceful_kill(child: &mut Child) {
let pid = child.id() as i32; 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. /// Wait for rclone VFS write-back queue to drain on a specific RC port.
fn wait_writeback_drain(port: u16) { fn wait_writeback_drain(port: u16) {
use crate::rclone::rc;
let deadline = Instant::now() + WRITEBACK_DRAIN_TIMEOUT; let deadline = Instant::now() + WRITEBACK_DRAIN_TIMEOUT;
let mut first = true; let mut first = true;
@ -502,7 +924,7 @@ fn wait_writeback_drain(port: u16) {
if Instant::now() > deadline { if Instant::now() > deadline {
eprintln!(); eprintln!();
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() WRITEBACK_DRAIN_TIMEOUT.as_secs()
); );
return; return;
@ -514,27 +936,12 @@ fn wait_writeback_drain(port: u16) {
/// Reverse-order teardown of all services. /// Reverse-order teardown of all services.
fn shutdown_services(config: &Config, mounts: &mut Vec<MountChild>, protocols: &mut ProtocolChildren) { fn shutdown_services(config: &Config, mounts: &mut Vec<MountChild>, protocols: &mut ProtocolChildren) {
// Stop SMB // Stop protocol services
if let Some(child) = &mut protocols.smbd { stop_protocols(protocols, config);
graceful_kill(child);
println!(" SMB: stopped");
}
// Unexport NFS // Wait for write-back queues to drain on each mount
if config.protocols.enable_nfs { for mc in mounts.iter() {
let _ = Command::new("exportfs").arg("-ua").status(); wait_writeback_drain(mc.rc_port);
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));
} }
// Lazy unmount each share's FUSE mount // Lazy unmount each share's FUSE mount

241
src/web/api.rs Normal file
View File

@ -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<SharedState> {
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<ShareStatusResponse>,
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<SharedState>) -> Json<StatusResponse> {
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<SharedState>,
Path(share_name): Path<String>,
) -> Result<Json<ShareStatusResponse>, 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<SharedState>) -> Json<serde_json::Value> {
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<String>,
}
async fn post_config(
State(state): State<SharedState>,
Json(body): Json<ConfigSubmit>,
) -> (StatusCode, Json<ConfigResponse>) {
// 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<SharedState>,
Json(body): Json<BwLimitRequest>,
) -> Json<BwLimitResponse> {
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}"),
}),
}
}

64
src/web/mod.rs Normal file
View File

@ -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<RwLock<...>>` 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<AppState>;
/// 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<RwLock<Config>>,
status: Arc<RwLock<DaemonStatus>>,
cmd_tx: mpsc::Sender<SupervisorCmd>,
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}");
}
});
})
}

312
src/web/pages.rs Normal file
View File

@ -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<SharedState> {
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<ShareView>,
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<String>,
is_error: bool,
}
#[derive(Template)]
#[template(path = "web/status_partial.html")]
struct StatusPartialTemplate {
#[allow(dead_code)]
uptime: String,
shares: Vec<ShareView>,
smbd_running: bool,
webdav_running: bool,
nfs_exported: bool,
}
// --- Handlers ---
async fn dashboard(State(state): State<SharedState>) -> Response {
let status = state.status.read().unwrap();
let config = state.config.read().unwrap();
let shares: Vec<ShareView> = 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<SharedState>,
Path(name): Path<String>,
) -> 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<SharedState>) -> 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<SharedState>,
Form(form): Form<ConfigForm>,
) -> 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<SharedState>) -> Response {
let status = state.status.read().unwrap();
let config = state.config.read().unwrap();
let shares: Vec<ShareView> = 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(),
}
}

55
templates/web/config.html Normal file
View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Warpgate — Config</title>
<style>
:root {
--bg: #0f1117; --surface: #1a1d27; --border: #2a2d3a;
--text: #e1e4ed; --text-muted: #8b8fa3; --accent: #6c8aff;
--green: #4ade80; --red: #f87171;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: var(--font); padding: 24px; max-width: 960px; margin: 0 auto; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.breadcrumb { font-size: 0.85em; color: var(--text-muted); margin-bottom: 16px; }
h1 { font-size: 1.4em; margin-bottom: 16px; }
.message { padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 0.9em; }
.message-error { background: rgba(248,113,113,0.15); color: var(--red); border: 1px solid rgba(248,113,113,0.3); }
.message-ok { background: rgba(74,222,128,0.15); color: var(--green); border: 1px solid rgba(74,222,128,0.3); }
textarea {
width: 100%; min-height: 500px; background: var(--surface); color: var(--text);
border: 1px solid var(--border); border-radius: 8px; padding: 16px;
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; font-size: 0.85em;
line-height: 1.5; resize: vertical; tab-size: 4;
}
textarea:focus { outline: none; border-color: var(--accent); }
.form-actions { margin-top: 12px; display: flex; gap: 12px; }
.btn { display: inline-block; padding: 8px 20px; border-radius: 6px; font-size: 0.9em; font-weight: 500; cursor: pointer; border: none; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { opacity: 0.9; }
.btn-secondary { background: var(--surface); color: var(--text); border: 1px solid var(--border); text-decoration: none; text-align: center; }
.btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
</style>
</head>
<body>
<div class="breadcrumb"><a href="/">Dashboard</a> / Config</div>
<h1>Configuration Editor</h1>
{% if let Some(msg) = message %}
<div class="message {% if is_error %}message-error{% else %}message-ok{% endif %}">{{ msg }}</div>
{% endif %}
<form method="post" action="/config">
<textarea name="toml" spellcheck="false">{{ toml_content }}</textarea>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Apply Config</button>
<a href="/" class="btn btn-secondary">Cancel</a>
</div>
</form>
</body>
</html>

View File

@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Warpgate Dashboard</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style>
:root {
--bg: #0f1117; --surface: #1a1d27; --border: #2a2d3a;
--text: #e1e4ed; --text-muted: #8b8fa3; --accent: #6c8aff;
--green: #4ade80; --red: #f87171; --yellow: #fbbf24;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: var(--font); padding: 24px; max-width: 960px; margin: 0 auto; }
h1 { font-size: 1.4em; margin-bottom: 4px; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; border-bottom: 1px solid var(--border); padding-bottom: 16px; }
.header .status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--green); margin-right: 8px; }
.meta { color: var(--text-muted); font-size: 0.85em; }
.cards { display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.card-header h2 { font-size: 1.1em; }
.card-header h2 a { color: var(--accent); text-decoration: none; }
.card-header h2 a:hover { text-decoration: underline; }
.badge { font-size: 0.75em; padding: 2px 8px; border-radius: 4px; font-weight: 600; }
.badge-ok { background: rgba(74,222,128,0.15); color: var(--green); }
.badge-error { background: rgba(248,113,113,0.15); color: var(--red); }
.badge-ro { background: rgba(251,191,36,0.15); color: var(--yellow); }
.stats { display: flex; gap: 24px; font-size: 0.9em; color: var(--text-muted); flex-wrap: wrap; }
.stats span { white-space: nowrap; }
.stats .label { color: var(--text-muted); }
.stats .value { color: var(--text); }
.protocols { display: flex; gap: 16px; margin-bottom: 20px; font-size: 0.9em; }
.proto-badge { padding: 4px 12px; border-radius: 4px; font-weight: 600; }
.proto-on { background: rgba(74,222,128,0.15); color: var(--green); }
.proto-off { background: rgba(248,113,113,0.1); color: var(--text-muted); }
.actions { display: flex; gap: 12px; }
.btn { display: inline-block; padding: 8px 16px; border-radius: 6px; text-decoration: none; font-size: 0.9em; font-weight: 500; border: 1px solid var(--border); color: var(--text); background: var(--surface); cursor: pointer; }
.btn:hover { border-color: var(--accent); color: var(--accent); }
</style>
</head>
<body>
<div class="header">
<div>
<h1><span class="status-dot"></span>Warpgate Dashboard</h1>
<div class="meta">Uptime: {{ uptime }} &nbsp;|&nbsp; Config: {{ config_path }}</div>
</div>
</div>
<div id="status-area" hx-get="/partials/status" hx-trigger="every 3s" hx-swap="innerHTML">
{% for share in shares %}
<div class="card">
<div class="card-header">
<h2><a href="/shares/{{ share.name }}">{{ share.name }}</a></h2>
<div>
{% if share.mounted %}
<span class="badge badge-ok">OK</span>
{% else %}
<span class="badge badge-error">DOWN</span>
{% endif %}
{% if share.read_only %}
<span class="badge badge-ro">RO</span>
{% endif %}
</div>
</div>
<div class="stats">
<span><span class="label">Mount:</span> <span class="value">{{ share.mount_point }}</span></span>
<span><span class="label">Cache:</span> <span class="value">{{ share.cache_display }}</span></span>
<span><span class="label">Dirty:</span> <span class="value">{{ share.dirty_count }}</span></span>
<span><span class="label">Speed:</span> <span class="value">{{ share.speed_display }}</span></span>
</div>
</div>
{% endfor %}
<div class="protocols">
<span class="proto-badge {% if smbd_running %}proto-on{% else %}proto-off{% endif %}">SMB: {% if smbd_running %}ON{% else %}OFF{% endif %}</span>
<span class="proto-badge {% if nfs_exported %}proto-on{% else %}proto-off{% endif %}">NFS: {% if nfs_exported %}ON{% else %}OFF{% endif %}</span>
<span class="proto-badge {% if webdav_running %}proto-on{% else %}proto-off{% endif %}">WebDAV: {% if webdav_running %}ON{% else %}OFF{% endif %}</span>
</div>
</div>
<div class="actions">
<a class="btn" href="/config">Edit Config</a>
</div>
</body>
</html>

View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Warpgate — {{ name }}</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style>
:root {
--bg: #0f1117; --surface: #1a1d27; --border: #2a2d3a;
--text: #e1e4ed; --text-muted: #8b8fa3; --accent: #6c8aff;
--green: #4ade80; --red: #f87171; --yellow: #fbbf24;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: var(--font); padding: 24px; max-width: 960px; margin: 0 auto; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.breadcrumb { font-size: 0.85em; color: var(--text-muted); margin-bottom: 16px; }
h1 { font-size: 1.4em; margin-bottom: 16px; }
.badge { font-size: 0.75em; padding: 2px 8px; border-radius: 4px; font-weight: 600; vertical-align: middle; }
.badge-ok { background: rgba(74,222,128,0.15); color: var(--green); }
.badge-error { background: rgba(248,113,113,0.15); color: var(--red); }
.badge-ro { background: rgba(251,191,36,0.15); color: var(--yellow); }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px; }
.detail-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
.detail-card .label { font-size: 0.8em; color: var(--text-muted); margin-bottom: 4px; }
.detail-card .value { font-size: 1.2em; font-weight: 600; }
.info-table { width: 100%; border-collapse: collapse; }
.info-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); }
.info-table td:first-child { color: var(--text-muted); width: 140px; }
</style>
</head>
<body>
<div class="breadcrumb"><a href="/">Dashboard</a> / {{ name }}</div>
<h1>
{{ name }}
{% if mounted %}<span class="badge badge-ok">OK</span>{% else %}<span class="badge badge-error">DOWN</span>{% endif %}
{% if read_only %}<span class="badge badge-ro">Read-Only</span>{% endif %}
</h1>
<div class="detail-grid">
<div class="detail-card">
<div class="label">Cache Used</div>
<div class="value">{{ cache_display }}</div>
</div>
<div class="detail-card">
<div class="label">Dirty Files</div>
<div class="value">{{ dirty_count }}</div>
</div>
<div class="detail-card">
<div class="label">Transfer Speed</div>
<div class="value">{{ speed_display }}</div>
</div>
<div class="detail-card">
<div class="label">Active Transfers</div>
<div class="value">{{ transfers }}</div>
</div>
</div>
<table class="info-table">
<tr><td>Mount Point</td><td>{{ mount_point }}</td></tr>
<tr><td>Remote Path</td><td>{{ remote_path }}</td></tr>
<tr><td>RC Port</td><td>{{ rc_port }}</td></tr>
<tr><td>Errored Files</td><td>{{ errored_files }}</td></tr>
<tr><td>Total Errors</td><td>{{ errors }}</td></tr>
</table>
</body>
</html>

View File

@ -0,0 +1,29 @@
{% for share in shares %}
<div class="card">
<div class="card-header">
<h2><a href="/shares/{{ share.name }}">{{ share.name }}</a></h2>
<div>
{% if share.mounted %}
<span class="badge badge-ok">OK</span>
{% else %}
<span class="badge badge-error">DOWN</span>
{% endif %}
{% if share.read_only %}
<span class="badge badge-ro">RO</span>
{% endif %}
</div>
</div>
<div class="stats">
<span><span class="label">Mount:</span> <span class="value">{{ share.mount_point }}</span></span>
<span><span class="label">Cache:</span> <span class="value">{{ share.cache_display }}</span></span>
<span><span class="label">Dirty:</span> <span class="value">{{ share.dirty_count }}</span></span>
<span><span class="label">Speed:</span> <span class="value">{{ share.speed_display }}</span></span>
</div>
</div>
{% endfor %}
<div class="protocols">
<span class="proto-badge {% if smbd_running %}proto-on{% else %}proto-off{% endif %}">SMB: {% if smbd_running %}ON{% else %}OFF{% endif %}</span>
<span class="proto-badge {% if nfs_exported %}proto-on{% else %}proto-off{% endif %}">NFS: {% if nfs_exported %}ON{% else %}OFF{% endif %}</span>
<span class="proto-badge {% if webdav_running %}proto-on{% else %}proto-off{% endif %}">WebDAV: {% if webdav_running %}ON{% else %}OFF{% endif %}</span>
</div>