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:
parent
08f8fc4667
commit
ba1cae7f75
6
.claude/settings.json
Normal file
6
.claude/settings.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"env": {
|
||||
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
|
||||
},
|
||||
"teammateMode": "tmux"
|
||||
}
|
||||
495
Cargo.lock
generated
495
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"] }
|
||||
|
||||
304
src/config_diff.rs
Normal file
304
src/config_diff.rs
Normal 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
218
src/daemon.rs
Normal 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(), "-");
|
||||
}
|
||||
}
|
||||
@ -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!(),
|
||||
}
|
||||
|
||||
@ -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<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
|
||||
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<Vec<M
|
||||
children.push(MountChild {
|
||||
name: share.name.clone(),
|
||||
child,
|
||||
rc_port,
|
||||
});
|
||||
}
|
||||
|
||||
@ -339,12 +402,17 @@ fn spawn_webdav(config: &Config) -> Result<Child> {
|
||||
.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<RwLock<Config>>,
|
||||
shared_status: &Arc<RwLock<DaemonStatus>>,
|
||||
cmd_rx: &mpsc::Receiver<SupervisorCmd>,
|
||||
mounts: &mut Vec<MountChild>,
|
||||
protocols: &mut ProtocolChildren,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
@ -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<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.
|
||||
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<MountChild>, 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
|
||||
|
||||
241
src/web/api.rs
Normal file
241
src/web/api.rs
Normal 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
64
src/web/mod.rs
Normal 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
312
src/web/pages.rs
Normal 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
55
templates/web/config.html
Normal 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>
|
||||
88
templates/web/dashboard.html
Normal file
88
templates/web/dashboard.html
Normal 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 }} | 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>
|
||||
70
templates/web/share_detail.html
Normal file
70
templates/web/share_detail.html
Normal 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>
|
||||
29
templates/web/status_partial.html
Normal file
29
templates/web/status_partial.html
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user