Web UI overhaul: interactive config editor, SSE live updates, log viewer, and SMB reload fixes
- Replace raw TOML textarea with Alpine.js interactive form editor (10 collapsible sections with change-tier badges, dynamic array management for connections/shares/ warmup rules, proper input controls per field type) - Add SSE-based live dashboard updates replacing htmx polling - Add log viewer tab with ring buffer backend and incremental polling - Fix SMB not seeing new shares after config reload: kill entire smbd process group (not just parent PID) so forked workers release port 445 - Add SIGHUP-based smbd config reload for share changes instead of full restart, preserving existing client connections - Generate human-readable commented TOML from config editor instead of bare toml::to_string_pretty() output - Fix Alpine.js 2.x __x.$data calls in dashboard/share templates (now Alpine 3.x) - Fix toggle switch CSS overlap with field labels - Fix dashboard going blank on tab switch (remove hx-swap-oob from tab content) - Add htmx:afterSettle → Alpine.initTree() bridge for robust tab switching Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
466ea5cfa8
commit
6bb7ec4d27
32
Cargo.lock
generated
32
Cargo.lock
generated
@ -414,6 +414,12 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@ -1121,6 +1127,31 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-stream"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "1.0.2+spec-1.1.0"
|
version = "1.0.2+spec-1.1.0"
|
||||||
@ -1316,6 +1347,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
"toml",
|
"toml",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
|||||||
@ -13,7 +13,8 @@ toml = "1.0.2"
|
|||||||
ctrlc = "3.4"
|
ctrlc = "3.4"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
ureq = { version = "3.2.0", features = ["json"] }
|
ureq = { version = "3.2.0", features = ["json"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||||
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
askama = "0.15"
|
askama = "0.15"
|
||||||
tower-http = { version = "0.6", features = ["cors"] }
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
|
|||||||
@ -13,10 +13,11 @@ const TEST_SIZE: usize = 10 * 1024 * 1024; // 10 MiB
|
|||||||
|
|
||||||
pub fn run(config: &Config) -> Result<()> {
|
pub fn run(config: &Config) -> Result<()> {
|
||||||
let tmp_local = std::env::temp_dir().join("warpgate-speedtest");
|
let tmp_local = std::env::temp_dir().join("warpgate-speedtest");
|
||||||
// Use the first share's remote_path for the speed test
|
// Use the first share's connection and remote_path for the speed test
|
||||||
|
let share = &config.shares[0];
|
||||||
let remote_path = format!(
|
let remote_path = format!(
|
||||||
"nas:{}/.warpgate-speedtest",
|
"{}:{}/.warpgate-speedtest",
|
||||||
config.shares[0].remote_path
|
share.connection, share.remote_path
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a 10 MiB test file
|
// Create a 10 MiB test file
|
||||||
|
|||||||
@ -17,7 +17,7 @@ pub fn run(config: &Config, share_name: &str, path: &str, newer_than: Option<&st
|
|||||||
.with_context(|| format!("Share '{}' not found in config", share_name))?;
|
.with_context(|| format!("Share '{}' not found in config", share_name))?;
|
||||||
|
|
||||||
let warmup_path = share.mount_point.join(path);
|
let warmup_path = share.mount_point.join(path);
|
||||||
let remote_src = format!("nas:{}/{}", share.remote_path, path);
|
let remote_src = format!("{}:{}/{}", share.connection, share.remote_path, path);
|
||||||
|
|
||||||
println!("Warming up: {remote_src}");
|
println!("Warming up: {remote_src}");
|
||||||
println!(" via mount: {}", warmup_path.display());
|
println!(" via mount: {}", warmup_path.display());
|
||||||
@ -66,7 +66,7 @@ pub fn run(config: &Config, share_name: &str, path: &str, newer_than: Option<&st
|
|||||||
let mut errors = 0usize;
|
let mut errors = 0usize;
|
||||||
|
|
||||||
for file in &files {
|
for file in &files {
|
||||||
if is_cached(config, &share.remote_path, path, file) {
|
if is_cached(config, &share.connection, &share.remote_path, path, file) {
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -97,12 +97,12 @@ pub fn run(config: &Config, share_name: &str, path: &str, newer_than: Option<&st
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a file is already in the rclone VFS cache.
|
/// Check if a file is already in the rclone VFS cache.
|
||||||
fn is_cached(config: &Config, remote_path: &str, warmup_path: &str, relative_path: &str) -> bool {
|
fn is_cached(config: &Config, connection: &str, remote_path: &str, warmup_path: &str, relative_path: &str) -> bool {
|
||||||
let cache_path = config
|
let cache_path = config
|
||||||
.cache
|
.cache
|
||||||
.dir
|
.dir
|
||||||
.join("vfs")
|
.join("vfs")
|
||||||
.join("nas")
|
.join(connection)
|
||||||
.join(remote_path.trim_start_matches('/'))
|
.join(remote_path.trim_start_matches('/'))
|
||||||
.join(warmup_path)
|
.join(warmup_path)
|
||||||
.join(relative_path);
|
.join(relative_path);
|
||||||
@ -116,7 +116,8 @@ mod tests {
|
|||||||
fn test_config() -> Config {
|
fn test_config() -> Config {
|
||||||
toml::from_str(
|
toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "nas"
|
||||||
nas_host = "10.0.0.1"
|
nas_host = "10.0.0.1"
|
||||||
nas_user = "admin"
|
nas_user = "admin"
|
||||||
|
|
||||||
@ -131,6 +132,7 @@ dir = "/tmp/warpgate-test-cache"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "photos"
|
name = "photos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/photos"
|
remote_path = "/photos"
|
||||||
mount_point = "/mnt/photos"
|
mount_point = "/mnt/photos"
|
||||||
"#,
|
"#,
|
||||||
@ -141,13 +143,13 @@ mount_point = "/mnt/photos"
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_is_cached_nonexistent_file() {
|
fn test_is_cached_nonexistent_file() {
|
||||||
let config = test_config();
|
let config = test_config();
|
||||||
assert!(!is_cached(&config, "/photos", "2024", "IMG_001.jpg"));
|
assert!(!is_cached(&config, "nas", "/photos", "2024", "IMG_001.jpg"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_cached_deep_path() {
|
fn test_is_cached_deep_path() {
|
||||||
let config = test_config();
|
let config = test_config();
|
||||||
assert!(!is_cached(&config, "/photos", "Images/2024/January", "photo.cr3"));
|
assert!(!is_cached(&config, "nas", "/photos", "Images/2024/January", "photo.cr3"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -176,17 +178,18 @@ mount_point = "/mnt/photos"
|
|||||||
fn test_is_cached_remote_path_trimming() {
|
fn test_is_cached_remote_path_trimming() {
|
||||||
let config = test_config();
|
let config = test_config();
|
||||||
|
|
||||||
|
let connection = "home";
|
||||||
let remote_path = "/volume1/photos";
|
let remote_path = "/volume1/photos";
|
||||||
let cache_path = config
|
let cache_path = config
|
||||||
.cache
|
.cache
|
||||||
.dir
|
.dir
|
||||||
.join("vfs")
|
.join("vfs")
|
||||||
.join("nas")
|
.join(connection)
|
||||||
.join(remote_path.trim_start_matches('/'))
|
.join(remote_path.trim_start_matches('/'))
|
||||||
.join("2024")
|
.join("2024")
|
||||||
.join("file.jpg");
|
.join("file.jpg");
|
||||||
|
|
||||||
assert!(cache_path.to_string_lossy().contains("nas/volume1/photos"));
|
assert!(cache_path.to_string_lossy().contains("home/volume1/photos"));
|
||||||
assert!(!cache_path.to_string_lossy().contains("nas//volume1"));
|
assert!(!cache_path.to_string_lossy().contains("home//volume1"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
721
src/config.rs
721
src/config.rs
File diff suppressed because it is too large
Load Diff
@ -18,9 +18,15 @@ pub struct ConfigDiff {
|
|||||||
pub shares_removed: Vec<String>,
|
pub shares_removed: Vec<String>,
|
||||||
/// Tier C: shares that were added (by name).
|
/// Tier C: shares that were added (by name).
|
||||||
pub shares_added: Vec<String>,
|
pub shares_added: Vec<String>,
|
||||||
/// Tier C: shares that were modified (remote_path, mount_point, or read_only changed).
|
/// Tier C: shares that were modified (remote_path, mount_point, read_only, or connection changed).
|
||||||
pub shares_modified: Vec<String>,
|
pub shares_modified: Vec<String>,
|
||||||
/// Tier D: global settings changed (connection, cache, read, writeback, directory_cache).
|
/// Tier C: connections that were added (by name).
|
||||||
|
pub connections_added: Vec<String>,
|
||||||
|
/// Tier C: connections that were removed (by name).
|
||||||
|
pub connections_removed: Vec<String>,
|
||||||
|
/// Tier C: connections that were modified (by name) — affects shares referencing them.
|
||||||
|
pub connections_modified: Vec<String>,
|
||||||
|
/// Tier D: global settings changed (cache, read, writeback, directory_cache).
|
||||||
pub global_changed: bool,
|
pub global_changed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,6 +38,9 @@ impl ConfigDiff {
|
|||||||
&& self.shares_removed.is_empty()
|
&& self.shares_removed.is_empty()
|
||||||
&& self.shares_added.is_empty()
|
&& self.shares_added.is_empty()
|
||||||
&& self.shares_modified.is_empty()
|
&& self.shares_modified.is_empty()
|
||||||
|
&& self.connections_added.is_empty()
|
||||||
|
&& self.connections_removed.is_empty()
|
||||||
|
&& self.connections_modified.is_empty()
|
||||||
&& !self.global_changed
|
&& !self.global_changed
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,6 +51,9 @@ impl ConfigDiff {
|
|||||||
} else if !self.shares_removed.is_empty()
|
} else if !self.shares_removed.is_empty()
|
||||||
|| !self.shares_added.is_empty()
|
|| !self.shares_added.is_empty()
|
||||||
|| !self.shares_modified.is_empty()
|
|| !self.shares_modified.is_empty()
|
||||||
|
|| !self.connections_added.is_empty()
|
||||||
|
|| !self.connections_removed.is_empty()
|
||||||
|
|| !self.connections_modified.is_empty()
|
||||||
{
|
{
|
||||||
ChangeTier::PerShare
|
ChangeTier::PerShare
|
||||||
} else if self.protocols_changed {
|
} else if self.protocols_changed {
|
||||||
@ -59,6 +71,15 @@ impl ConfigDiff {
|
|||||||
if self.global_changed {
|
if self.global_changed {
|
||||||
parts.push("global settings changed (full restart required)".to_string());
|
parts.push("global settings changed (full restart required)".to_string());
|
||||||
}
|
}
|
||||||
|
if !self.connections_removed.is_empty() {
|
||||||
|
parts.push(format!("connections removed: {}", self.connections_removed.join(", ")));
|
||||||
|
}
|
||||||
|
if !self.connections_added.is_empty() {
|
||||||
|
parts.push(format!("connections added: {}", self.connections_added.join(", ")));
|
||||||
|
}
|
||||||
|
if !self.connections_modified.is_empty() {
|
||||||
|
parts.push(format!("connections modified: {}", self.connections_modified.join(", ")));
|
||||||
|
}
|
||||||
if !self.shares_removed.is_empty() {
|
if !self.shares_removed.is_empty() {
|
||||||
parts.push(format!("shares removed: {}", self.shares_removed.join(", ")));
|
parts.push(format!("shares removed: {}", self.shares_removed.join(", ")));
|
||||||
}
|
}
|
||||||
@ -103,14 +124,8 @@ pub enum ChangeTier {
|
|||||||
pub fn diff(old: &Config, new: &Config) -> ConfigDiff {
|
pub fn diff(old: &Config, new: &Config) -> ConfigDiff {
|
||||||
let mut d = ConfigDiff::default();
|
let mut d = ConfigDiff::default();
|
||||||
|
|
||||||
// Tier D: global settings
|
// Tier D: global settings (cache, read, writeback, directory_cache)
|
||||||
d.global_changed = old.connection.nas_host != new.connection.nas_host
|
d.global_changed = old.cache.dir != new.cache.dir
|
||||||
|| 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_size != new.cache.max_size
|
||||||
|| old.cache.max_age != new.cache.max_age
|
|| old.cache.max_age != new.cache.max_age
|
||||||
|| old.cache.min_free != new.cache.min_free
|
|| old.cache.min_free != new.cache.min_free
|
||||||
@ -135,8 +150,36 @@ pub fn diff(old: &Config, new: &Config) -> ConfigDiff {
|
|||||||
|| old.protocols.webdav_port != new.protocols.webdav_port
|
|| old.protocols.webdav_port != new.protocols.webdav_port
|
||||||
|| old.smb_auth.enabled != new.smb_auth.enabled
|
|| old.smb_auth.enabled != new.smb_auth.enabled
|
||||||
|| old.smb_auth.username != new.smb_auth.username
|
|| old.smb_auth.username != new.smb_auth.username
|
||||||
|| old.smb_auth.smb_pass != new.smb_auth.smb_pass
|
|| old.smb_auth.smb_pass != new.smb_auth.smb_pass;
|
||||||
|| old.smb_auth.reuse_nas_pass != new.smb_auth.reuse_nas_pass;
|
|
||||||
|
// Tier C: connection changes
|
||||||
|
let old_conns: std::collections::HashMap<&str, &crate::config::ConnectionConfig> =
|
||||||
|
old.connections.iter().map(|c| (c.name.as_str(), c)).collect();
|
||||||
|
let new_conns: std::collections::HashMap<&str, &crate::config::ConnectionConfig> =
|
||||||
|
new.connections.iter().map(|c| (c.name.as_str(), c)).collect();
|
||||||
|
|
||||||
|
// Removed connections
|
||||||
|
for name in old_conns.keys() {
|
||||||
|
if !new_conns.contains_key(name) {
|
||||||
|
d.connections_removed.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Added connections
|
||||||
|
for name in new_conns.keys() {
|
||||||
|
if !old_conns.contains_key(name) {
|
||||||
|
d.connections_added.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modified connections
|
||||||
|
for (name, old_conn) in &old_conns {
|
||||||
|
if let Some(new_conn) = new_conns.get(name) {
|
||||||
|
if old_conn != new_conn {
|
||||||
|
d.connections_modified.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tier C: per-share changes
|
// Tier C: per-share changes
|
||||||
let old_shares: std::collections::HashMap<&str, &crate::config::ShareConfig> =
|
let old_shares: std::collections::HashMap<&str, &crate::config::ShareConfig> =
|
||||||
@ -158,18 +201,39 @@ pub fn diff(old: &Config, new: &Config) -> ConfigDiff {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modified shares
|
// Modified shares (direct changes or indirectly via connection modification)
|
||||||
|
let mut modified_set = std::collections::HashSet::new();
|
||||||
for (name, old_share) in &old_shares {
|
for (name, old_share) in &old_shares {
|
||||||
if let Some(new_share) = new_shares.get(name) {
|
if let Some(new_share) = new_shares.get(name) {
|
||||||
if old_share.remote_path != new_share.remote_path
|
if old_share.remote_path != new_share.remote_path
|
||||||
|| old_share.mount_point != new_share.mount_point
|
|| old_share.mount_point != new_share.mount_point
|
||||||
|| old_share.read_only != new_share.read_only
|
|| old_share.read_only != new_share.read_only
|
||||||
|
|| old_share.connection != new_share.connection
|
||||||
{
|
{
|
||||||
d.shares_modified.push(name.to_string());
|
modified_set.insert(name.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shares indirectly affected by connection modifications
|
||||||
|
for conn_name in &d.connections_modified {
|
||||||
|
for share in &new.shares {
|
||||||
|
if share.connection == *conn_name && !modified_set.contains(&share.name) {
|
||||||
|
modified_set.insert(share.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shares referencing removed connections
|
||||||
|
for conn_name in &d.connections_removed {
|
||||||
|
for share in &old.shares {
|
||||||
|
if share.connection == *conn_name && !modified_set.contains(&share.name) {
|
||||||
|
modified_set.insert(share.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.shares_modified = modified_set.into_iter().collect();
|
||||||
|
|
||||||
d
|
d
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,7 +244,8 @@ mod tests {
|
|||||||
fn minimal_config() -> Config {
|
fn minimal_config() -> Config {
|
||||||
toml::from_str(
|
toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "nas"
|
||||||
nas_host = "10.0.0.1"
|
nas_host = "10.0.0.1"
|
||||||
nas_user = "admin"
|
nas_user = "admin"
|
||||||
|
|
||||||
@ -195,6 +260,7 @@ dir = "/tmp/cache"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "photos"
|
name = "photos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/photos"
|
remote_path = "/photos"
|
||||||
mount_point = "/mnt/photos"
|
mount_point = "/mnt/photos"
|
||||||
"#,
|
"#,
|
||||||
@ -237,6 +303,7 @@ mount_point = "/mnt/photos"
|
|||||||
let mut new = old.clone();
|
let mut new = old.clone();
|
||||||
new.shares.push(crate::config::ShareConfig {
|
new.shares.push(crate::config::ShareConfig {
|
||||||
name: "videos".to_string(),
|
name: "videos".to_string(),
|
||||||
|
connection: "nas".to_string(),
|
||||||
remote_path: "/videos".to_string(),
|
remote_path: "/videos".to_string(),
|
||||||
mount_point: "/mnt/videos".into(),
|
mount_point: "/mnt/videos".into(),
|
||||||
read_only: false,
|
read_only: false,
|
||||||
@ -253,6 +320,7 @@ mount_point = "/mnt/photos"
|
|||||||
new.shares.clear();
|
new.shares.clear();
|
||||||
new.shares.push(crate::config::ShareConfig {
|
new.shares.push(crate::config::ShareConfig {
|
||||||
name: "videos".to_string(),
|
name: "videos".to_string(),
|
||||||
|
connection: "nas".to_string(),
|
||||||
remote_path: "/videos".to_string(),
|
remote_path: "/videos".to_string(),
|
||||||
mount_point: "/mnt/videos".into(),
|
mount_point: "/mnt/videos".into(),
|
||||||
read_only: false,
|
read_only: false,
|
||||||
@ -276,12 +344,131 @@ mount_point = "/mnt/photos"
|
|||||||
fn test_global_change() {
|
fn test_global_change() {
|
||||||
let old = minimal_config();
|
let old = minimal_config();
|
||||||
let mut new = old.clone();
|
let mut new = old.clone();
|
||||||
new.connection.nas_host = "192.168.1.1".to_string();
|
new.cache.max_size = "500G".to_string();
|
||||||
let d = diff(&old, &new);
|
let d = diff(&old, &new);
|
||||||
assert!(d.global_changed);
|
assert!(d.global_changed);
|
||||||
assert_eq!(d.highest_tier(), ChangeTier::Global);
|
assert_eq!(d.highest_tier(), ChangeTier::Global);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connection_modified_affects_shares() {
|
||||||
|
let old = minimal_config();
|
||||||
|
let mut new = old.clone();
|
||||||
|
new.connections[0].nas_host = "192.168.1.1".to_string();
|
||||||
|
let d = diff(&old, &new);
|
||||||
|
assert_eq!(d.connections_modified, vec!["nas"]);
|
||||||
|
// Share "photos" references "nas", so it should be in shares_modified
|
||||||
|
assert!(d.shares_modified.contains(&"photos".to_string()));
|
||||||
|
assert_eq!(d.highest_tier(), ChangeTier::PerShare);
|
||||||
|
assert!(!d.global_changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connection_added() {
|
||||||
|
let old = minimal_config();
|
||||||
|
let mut new = old.clone();
|
||||||
|
new.connections.push(crate::config::ConnectionConfig {
|
||||||
|
name: "office".to_string(),
|
||||||
|
nas_host: "10.0.0.2".to_string(),
|
||||||
|
nas_user: "admin".to_string(),
|
||||||
|
nas_pass: None,
|
||||||
|
nas_key_file: None,
|
||||||
|
sftp_port: 22,
|
||||||
|
sftp_connections: 8,
|
||||||
|
});
|
||||||
|
let d = diff(&old, &new);
|
||||||
|
assert_eq!(d.connections_added, vec!["office"]);
|
||||||
|
assert_eq!(d.highest_tier(), ChangeTier::PerShare);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connection_removed_affects_shares() {
|
||||||
|
let config: Config = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[[connections]]
|
||||||
|
name = "home"
|
||||||
|
nas_host = "10.0.0.1"
|
||||||
|
nas_user = "admin"
|
||||||
|
|
||||||
|
[[connections]]
|
||||||
|
name = "office"
|
||||||
|
nas_host = "10.0.0.2"
|
||||||
|
nas_user = "admin"
|
||||||
|
|
||||||
|
[cache]
|
||||||
|
dir = "/tmp/cache"
|
||||||
|
|
||||||
|
[read]
|
||||||
|
[bandwidth]
|
||||||
|
[writeback]
|
||||||
|
[directory_cache]
|
||||||
|
[protocols]
|
||||||
|
|
||||||
|
[[shares]]
|
||||||
|
name = "photos"
|
||||||
|
connection = "home"
|
||||||
|
remote_path = "/photos"
|
||||||
|
mount_point = "/mnt/photos"
|
||||||
|
|
||||||
|
[[shares]]
|
||||||
|
name = "projects"
|
||||||
|
connection = "office"
|
||||||
|
remote_path = "/projects"
|
||||||
|
mount_point = "/mnt/projects"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut new = config.clone();
|
||||||
|
// Remove "office" connection and its share
|
||||||
|
new.connections.retain(|c| c.name != "office");
|
||||||
|
new.shares.retain(|s| s.name != "projects");
|
||||||
|
|
||||||
|
let d = diff(&config, &new);
|
||||||
|
assert_eq!(d.connections_removed, vec!["office"]);
|
||||||
|
assert!(d.shares_removed.contains(&"projects".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_share_connection_changed() {
|
||||||
|
let config: Config = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[[connections]]
|
||||||
|
name = "home"
|
||||||
|
nas_host = "10.0.0.1"
|
||||||
|
nas_user = "admin"
|
||||||
|
|
||||||
|
[[connections]]
|
||||||
|
name = "office"
|
||||||
|
nas_host = "10.0.0.2"
|
||||||
|
nas_user = "admin"
|
||||||
|
|
||||||
|
[cache]
|
||||||
|
dir = "/tmp/cache"
|
||||||
|
|
||||||
|
[read]
|
||||||
|
[bandwidth]
|
||||||
|
[writeback]
|
||||||
|
[directory_cache]
|
||||||
|
[protocols]
|
||||||
|
|
||||||
|
[[shares]]
|
||||||
|
name = "photos"
|
||||||
|
connection = "home"
|
||||||
|
remote_path = "/photos"
|
||||||
|
mount_point = "/mnt/photos"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut new = config.clone();
|
||||||
|
new.shares[0].connection = "office".to_string();
|
||||||
|
|
||||||
|
let d = diff(&config, &new);
|
||||||
|
assert!(d.shares_modified.contains(&"photos".to_string()));
|
||||||
|
assert_eq!(d.highest_tier(), ChangeTier::PerShare);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_summary() {
|
fn test_summary() {
|
||||||
let old = minimal_config();
|
let old = minimal_config();
|
||||||
|
|||||||
@ -3,10 +3,11 @@
|
|||||||
//! The supervisor owns all mutable state. The web server gets read-only access
|
//! 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.
|
//! to status via `Arc<RwLock<DaemonStatus>>` and sends commands via an mpsc channel.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
use std::time::Instant;
|
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
|
||||||
@ -23,6 +24,70 @@ pub struct AppState {
|
|||||||
pub cmd_tx: mpsc::Sender<SupervisorCmd>,
|
pub cmd_tx: mpsc::Sender<SupervisorCmd>,
|
||||||
/// Path to the config file on disk.
|
/// Path to the config file on disk.
|
||||||
pub config_path: PathBuf,
|
pub config_path: PathBuf,
|
||||||
|
/// SSE broadcast: supervisor sends `()` after each status update;
|
||||||
|
/// web server subscribers render partials and push to connected clients.
|
||||||
|
pub sse_tx: tokio::sync::broadcast::Sender<()>,
|
||||||
|
/// Ring buffer of log entries for the web UI.
|
||||||
|
pub logs: Arc<RwLock<LogBuffer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ring buffer of timestamped log entries for the web log viewer.
|
||||||
|
pub struct LogBuffer {
|
||||||
|
entries: VecDeque<LogEntry>,
|
||||||
|
/// Monotonically increasing ID for the next entry.
|
||||||
|
next_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single log entry with unix timestamp and message.
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
pub struct LogEntry {
|
||||||
|
pub id: u64,
|
||||||
|
pub ts: u64,
|
||||||
|
pub msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_BUFFER_MAX: usize = 500;
|
||||||
|
|
||||||
|
impl LogBuffer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: VecDeque::new(),
|
||||||
|
next_id: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a new log message. Timestamps are added automatically.
|
||||||
|
pub fn push(&mut self, msg: impl Into<String>) {
|
||||||
|
let ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
self.entries.push_back(LogEntry {
|
||||||
|
id: self.next_id,
|
||||||
|
ts,
|
||||||
|
msg: msg.into(),
|
||||||
|
});
|
||||||
|
self.next_id += 1;
|
||||||
|
if self.entries.len() > LOG_BUFFER_MAX {
|
||||||
|
self.entries.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get entries with ID >= `since_id`.
|
||||||
|
pub fn since(&self, since_id: u64) -> Vec<LogEntry> {
|
||||||
|
let start_id = self.next_id.saturating_sub(self.entries.len() as u64);
|
||||||
|
let skip = if since_id > start_id {
|
||||||
|
(since_id - start_id) as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
self.entries.iter().skip(skip).cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ID that the next pushed entry will have.
|
||||||
|
pub fn next_id(&self) -> u64 {
|
||||||
|
self.next_id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Overall daemon status, updated by the supervisor loop.
|
/// Overall daemon status, updated by the supervisor loop.
|
||||||
|
|||||||
@ -10,33 +10,35 @@ use crate::config::Config;
|
|||||||
/// Default path for generated rclone config.
|
/// Default path for generated rclone config.
|
||||||
pub const RCLONE_CONF_PATH: &str = "/etc/warpgate/rclone.conf";
|
pub const RCLONE_CONF_PATH: &str = "/etc/warpgate/rclone.conf";
|
||||||
|
|
||||||
/// Generate rclone.conf content for the SFTP remote.
|
/// Generate rclone.conf content with one SFTP remote section per connection.
|
||||||
///
|
///
|
||||||
/// Produces an INI-style config with a `[nas]` section containing all SFTP
|
/// Each connection produces an INI-style `[name]` section (where `name` is
|
||||||
/// connection parameters from the Warpgate config.
|
/// `ConnectionConfig.name`) containing all SFTP parameters.
|
||||||
pub fn generate(config: &Config) -> Result<String> {
|
pub fn generate(config: &Config) -> Result<String> {
|
||||||
let conn = &config.connection;
|
|
||||||
let mut conf = String::new();
|
let mut conf = String::new();
|
||||||
|
|
||||||
writeln!(conf, "[nas]")?;
|
for conn in &config.connections {
|
||||||
writeln!(conf, "type = sftp")?;
|
writeln!(conf, "[{}]", conn.name)?;
|
||||||
writeln!(conf, "host = {}", conn.nas_host)?;
|
writeln!(conf, "type = sftp")?;
|
||||||
writeln!(conf, "user = {}", conn.nas_user)?;
|
writeln!(conf, "host = {}", conn.nas_host)?;
|
||||||
writeln!(conf, "port = {}", conn.sftp_port)?;
|
writeln!(conf, "user = {}", conn.nas_user)?;
|
||||||
|
writeln!(conf, "port = {}", conn.sftp_port)?;
|
||||||
|
|
||||||
if let Some(pass) = &conn.nas_pass {
|
if let Some(pass) = &conn.nas_pass {
|
||||||
let obscured = obscure_password(pass)?;
|
let obscured = obscure_password(pass)?;
|
||||||
writeln!(conf, "pass = {obscured}")?;
|
writeln!(conf, "pass = {obscured}")?;
|
||||||
|
}
|
||||||
|
if let Some(key_file) = &conn.nas_key_file {
|
||||||
|
writeln!(conf, "key_file = {key_file}")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeln!(conf, "connections = {}", conn.sftp_connections)?;
|
||||||
|
|
||||||
|
// Disable hash checking — many NAS SFTP servers (e.g. Synology) don't support
|
||||||
|
// running shell commands like md5sum, causing upload verification to fail.
|
||||||
|
writeln!(conf, "disable_hashcheck = true")?;
|
||||||
|
writeln!(conf)?; // blank line between sections
|
||||||
}
|
}
|
||||||
if let Some(key_file) = &conn.nas_key_file {
|
|
||||||
writeln!(conf, "key_file = {key_file}")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeln!(conf, "connections = {}", conn.sftp_connections)?;
|
|
||||||
|
|
||||||
// Disable hash checking — many NAS SFTP servers (e.g. Synology) don't support
|
|
||||||
// running shell commands like md5sum, causing upload verification to fail.
|
|
||||||
writeln!(conf, "disable_hashcheck = true")?;
|
|
||||||
|
|
||||||
Ok(conf)
|
Ok(conf)
|
||||||
}
|
}
|
||||||
@ -79,7 +81,8 @@ mod tests {
|
|||||||
fn test_config() -> Config {
|
fn test_config() -> Config {
|
||||||
toml::from_str(
|
toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "nas"
|
||||||
nas_host = "10.0.0.1"
|
nas_host = "10.0.0.1"
|
||||||
nas_user = "admin"
|
nas_user = "admin"
|
||||||
|
|
||||||
@ -94,6 +97,7 @@ dir = "/tmp/cache"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "photos"
|
name = "photos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/photos"
|
remote_path = "/photos"
|
||||||
mount_point = "/mnt/photos"
|
mount_point = "/mnt/photos"
|
||||||
"#,
|
"#,
|
||||||
@ -121,7 +125,7 @@ mount_point = "/mnt/photos"
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_generate_rclone_config_with_key_file() {
|
fn test_generate_rclone_config_with_key_file() {
|
||||||
let mut config = test_config();
|
let mut config = test_config();
|
||||||
config.connection.nas_key_file = Some("/root/.ssh/id_rsa".into());
|
config.connections[0].nas_key_file = Some("/root/.ssh/id_rsa".into());
|
||||||
|
|
||||||
let content = generate(&config).unwrap();
|
let content = generate(&config).unwrap();
|
||||||
assert!(content.contains("key_file = /root/.ssh/id_rsa"));
|
assert!(content.contains("key_file = /root/.ssh/id_rsa"));
|
||||||
@ -130,8 +134,8 @@ mount_point = "/mnt/photos"
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_generate_rclone_config_custom_port_and_connections() {
|
fn test_generate_rclone_config_custom_port_and_connections() {
|
||||||
let mut config = test_config();
|
let mut config = test_config();
|
||||||
config.connection.sftp_port = 2222;
|
config.connections[0].sftp_port = 2222;
|
||||||
config.connection.sftp_connections = 16;
|
config.connections[0].sftp_connections = 16;
|
||||||
|
|
||||||
let content = generate(&config).unwrap();
|
let content = generate(&config).unwrap();
|
||||||
assert!(content.contains("port = 2222"));
|
assert!(content.contains("port = 2222"));
|
||||||
@ -149,4 +153,56 @@ mount_point = "/mnt/photos"
|
|||||||
let content = generate(&config).unwrap();
|
let content = generate(&config).unwrap();
|
||||||
assert!(content.starts_with("[nas]\n"));
|
assert!(content.starts_with("[nas]\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_multi_connection() {
|
||||||
|
let config: Config = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[[connections]]
|
||||||
|
name = "home"
|
||||||
|
nas_host = "10.0.0.1"
|
||||||
|
nas_user = "admin"
|
||||||
|
|
||||||
|
[[connections]]
|
||||||
|
name = "office"
|
||||||
|
nas_host = "192.168.1.100"
|
||||||
|
nas_user = "photographer"
|
||||||
|
sftp_port = 2222
|
||||||
|
|
||||||
|
[cache]
|
||||||
|
dir = "/tmp/cache"
|
||||||
|
|
||||||
|
[read]
|
||||||
|
[bandwidth]
|
||||||
|
[writeback]
|
||||||
|
[directory_cache]
|
||||||
|
[protocols]
|
||||||
|
|
||||||
|
[[shares]]
|
||||||
|
name = "photos"
|
||||||
|
connection = "home"
|
||||||
|
remote_path = "/photos"
|
||||||
|
mount_point = "/mnt/photos"
|
||||||
|
|
||||||
|
[[shares]]
|
||||||
|
name = "projects"
|
||||||
|
connection = "office"
|
||||||
|
remote_path = "/data/projects"
|
||||||
|
mount_point = "/mnt/projects"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let content = generate(&config).unwrap();
|
||||||
|
|
||||||
|
// Check first section
|
||||||
|
assert!(content.contains("[home]"));
|
||||||
|
assert!(content.contains("host = 10.0.0.1"));
|
||||||
|
|
||||||
|
// Check second section
|
||||||
|
assert!(content.contains("[office]"));
|
||||||
|
assert!(content.contains("host = 192.168.1.100"));
|
||||||
|
assert!(content.contains("user = photographer"));
|
||||||
|
assert!(content.contains("port = 2222"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ pub fn build_mount_args(config: &Config, share: &ShareConfig, rc_port: u16) -> V
|
|||||||
|
|
||||||
// Subcommand and source:dest
|
// Subcommand and source:dest
|
||||||
args.push("mount".into());
|
args.push("mount".into());
|
||||||
args.push(format!("nas:{}", share.remote_path));
|
args.push(format!("{}:{}", share.connection, share.remote_path));
|
||||||
args.push(share.mount_point.display().to_string());
|
args.push(share.mount_point.display().to_string());
|
||||||
|
|
||||||
// Point to our generated rclone.conf
|
// Point to our generated rclone.conf
|
||||||
@ -150,7 +150,8 @@ mod tests {
|
|||||||
fn test_config() -> Config {
|
fn test_config() -> Config {
|
||||||
toml::from_str(
|
toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "nas"
|
||||||
nas_host = "10.0.0.1"
|
nas_host = "10.0.0.1"
|
||||||
nas_user = "admin"
|
nas_user = "admin"
|
||||||
|
|
||||||
@ -165,6 +166,7 @@ dir = "/tmp/cache"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "photos"
|
name = "photos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/photos"
|
remote_path = "/photos"
|
||||||
mount_point = "/mnt/photos"
|
mount_point = "/mnt/photos"
|
||||||
"#,
|
"#,
|
||||||
|
|||||||
@ -58,7 +58,8 @@ mod tests {
|
|||||||
fn test_config() -> Config {
|
fn test_config() -> Config {
|
||||||
toml::from_str(
|
toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "nas"
|
||||||
nas_host = "10.0.0.1"
|
nas_host = "10.0.0.1"
|
||||||
nas_user = "admin"
|
nas_user = "admin"
|
||||||
|
|
||||||
@ -73,6 +74,7 @@ dir = "/tmp/cache"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "photos"
|
name = "photos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/photos"
|
remote_path = "/photos"
|
||||||
mount_point = "/mnt/photos"
|
mount_point = "/mnt/photos"
|
||||||
"#,
|
"#,
|
||||||
@ -83,7 +85,8 @@ mount_point = "/mnt/photos"
|
|||||||
fn test_config_with_shares() -> Config {
|
fn test_config_with_shares() -> Config {
|
||||||
toml::from_str(
|
toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "nas"
|
||||||
nas_host = "10.0.0.1"
|
nas_host = "10.0.0.1"
|
||||||
nas_user = "admin"
|
nas_user = "admin"
|
||||||
|
|
||||||
@ -100,16 +103,19 @@ nfs_allowed_network = "192.168.0.0/24"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "photos"
|
name = "photos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/volume1/photos"
|
remote_path = "/volume1/photos"
|
||||||
mount_point = "/mnt/photos"
|
mount_point = "/mnt/photos"
|
||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "projects"
|
name = "projects"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/volume1/projects"
|
remote_path = "/volume1/projects"
|
||||||
mount_point = "/mnt/projects"
|
mount_point = "/mnt/projects"
|
||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "backups"
|
name = "backups"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/volume1/backups"
|
remote_path = "/volume1/backups"
|
||||||
mount_point = "/mnt/backups"
|
mount_point = "/mnt/backups"
|
||||||
read_only = true
|
read_only = true
|
||||||
|
|||||||
@ -32,7 +32,7 @@ pub fn generate(config: &Config) -> Result<String> {
|
|||||||
writeln!(conf)?;
|
writeln!(conf)?;
|
||||||
|
|
||||||
if config.smb_auth.enabled {
|
if config.smb_auth.enabled {
|
||||||
let username = config.smb_username();
|
let username = config.smb_username().unwrap_or("warpgate");
|
||||||
writeln!(conf, " # User authentication")?;
|
writeln!(conf, " # User authentication")?;
|
||||||
writeln!(conf, " security = user")?;
|
writeln!(conf, " security = user")?;
|
||||||
writeln!(conf, " map to guest = Never")?;
|
writeln!(conf, " map to guest = Never")?;
|
||||||
@ -107,7 +107,8 @@ pub fn setup_user(config: &Config) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let username = config.smb_username();
|
let username = config.smb_username()
|
||||||
|
.context("SMB auth enabled but username not set")?;
|
||||||
let password = config
|
let password = config
|
||||||
.smb_password()?
|
.smb_password()?
|
||||||
.context("SMB auth enabled but no password resolved")?;
|
.context("SMB auth enabled but no password resolved")?;
|
||||||
@ -167,7 +168,8 @@ mod tests {
|
|||||||
fn test_config() -> Config {
|
fn test_config() -> Config {
|
||||||
toml::from_str(
|
toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "nas"
|
||||||
nas_host = "10.0.0.1"
|
nas_host = "10.0.0.1"
|
||||||
nas_user = "admin"
|
nas_user = "admin"
|
||||||
|
|
||||||
@ -182,6 +184,7 @@ dir = "/tmp/cache"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "photos"
|
name = "photos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/photos"
|
remote_path = "/photos"
|
||||||
mount_point = "/mnt/photos"
|
mount_point = "/mnt/photos"
|
||||||
"#,
|
"#,
|
||||||
@ -192,7 +195,8 @@ mount_point = "/mnt/photos"
|
|||||||
fn test_config_with_shares() -> Config {
|
fn test_config_with_shares() -> Config {
|
||||||
toml::from_str(
|
toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "nas"
|
||||||
nas_host = "10.0.0.1"
|
nas_host = "10.0.0.1"
|
||||||
nas_user = "admin"
|
nas_user = "admin"
|
||||||
|
|
||||||
@ -207,16 +211,19 @@ dir = "/tmp/cache"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "photos"
|
name = "photos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/volume1/photos"
|
remote_path = "/volume1/photos"
|
||||||
mount_point = "/mnt/photos"
|
mount_point = "/mnt/photos"
|
||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "projects"
|
name = "projects"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/volume1/projects"
|
remote_path = "/volume1/projects"
|
||||||
mount_point = "/mnt/projects"
|
mount_point = "/mnt/projects"
|
||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "backups"
|
name = "backups"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/volume1/backups"
|
remote_path = "/volume1/backups"
|
||||||
mount_point = "/mnt/backups"
|
mount_point = "/mnt/backups"
|
||||||
read_only = true
|
read_only = true
|
||||||
@ -228,7 +235,8 @@ read_only = true
|
|||||||
fn test_config_with_auth() -> Config {
|
fn test_config_with_auth() -> Config {
|
||||||
toml::from_str(
|
toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "nas"
|
||||||
nas_host = "10.0.0.1"
|
nas_host = "10.0.0.1"
|
||||||
nas_user = "admin"
|
nas_user = "admin"
|
||||||
|
|
||||||
@ -248,6 +256,7 @@ smb_pass = "my-password"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "photos"
|
name = "photos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/volume1/photos"
|
remote_path = "/volume1/photos"
|
||||||
mount_point = "/mnt/photos"
|
mount_point = "/mnt/photos"
|
||||||
"#,
|
"#,
|
||||||
|
|||||||
@ -69,7 +69,8 @@ mod tests {
|
|||||||
fn test_config() -> Config {
|
fn test_config() -> Config {
|
||||||
toml::from_str(
|
toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "nas"
|
||||||
nas_host = "10.0.0.1"
|
nas_host = "10.0.0.1"
|
||||||
nas_user = "admin"
|
nas_user = "admin"
|
||||||
|
|
||||||
@ -84,6 +85,7 @@ dir = "/tmp/cache"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "photos"
|
name = "photos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/photos"
|
remote_path = "/photos"
|
||||||
mount_point = "/mnt/photos"
|
mount_point = "/mnt/photos"
|
||||||
"#,
|
"#,
|
||||||
|
|||||||
@ -33,7 +33,8 @@ mod tests {
|
|||||||
fn test_config() -> Config {
|
fn test_config() -> Config {
|
||||||
toml::from_str(
|
toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "nas"
|
||||||
nas_host = "10.0.0.1"
|
nas_host = "10.0.0.1"
|
||||||
nas_user = "admin"
|
nas_user = "admin"
|
||||||
|
|
||||||
@ -48,6 +49,7 @@ dir = "/tmp/cache"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "photos"
|
name = "photos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/photos"
|
remote_path = "/photos"
|
||||||
mount_point = "/mnt/photos"
|
mount_point = "/mnt/photos"
|
||||||
"#,
|
"#,
|
||||||
@ -81,7 +83,8 @@ mount_point = "/mnt/photos"
|
|||||||
fn test_build_serve_args_uses_first_share() {
|
fn test_build_serve_args_uses_first_share() {
|
||||||
let config: Config = toml::from_str(
|
let config: Config = toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "nas"
|
||||||
nas_host = "10.0.0.1"
|
nas_host = "10.0.0.1"
|
||||||
nas_user = "admin"
|
nas_user = "admin"
|
||||||
|
|
||||||
@ -96,11 +99,13 @@ dir = "/tmp/cache"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "photos"
|
name = "photos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/volume1/photos"
|
remote_path = "/volume1/photos"
|
||||||
mount_point = "/mnt/photos"
|
mount_point = "/mnt/photos"
|
||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "videos"
|
name = "videos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/volume1/videos"
|
remote_path = "/volume1/videos"
|
||||||
mount_point = "/mnt/videos"
|
mount_point = "/mnt/videos"
|
||||||
"#,
|
"#,
|
||||||
|
|||||||
@ -110,12 +110,16 @@ pub fn run(config: &Config, config_path: PathBuf) -> Result<()> {
|
|||||||
let shared_status = Arc::new(RwLock::new(DaemonStatus::new(&share_names)));
|
let shared_status = Arc::new(RwLock::new(DaemonStatus::new(&share_names)));
|
||||||
let (cmd_tx, cmd_rx) = mpsc::channel::<SupervisorCmd>();
|
let (cmd_tx, cmd_rx) = mpsc::channel::<SupervisorCmd>();
|
||||||
|
|
||||||
|
// Create SSE broadcast channel (supervisor → web clients)
|
||||||
|
let (sse_tx, _) = tokio::sync::broadcast::channel::<()>(16);
|
||||||
|
|
||||||
// Spawn the web UI server in a background thread
|
// Spawn the web UI server in a background thread
|
||||||
let _web_handle = crate::web::spawn_web_server(
|
let _web_handle = crate::web::spawn_web_server(
|
||||||
Arc::clone(&shared_config),
|
Arc::clone(&shared_config),
|
||||||
Arc::clone(&shared_status),
|
Arc::clone(&shared_status),
|
||||||
cmd_tx.clone(),
|
cmd_tx.clone(),
|
||||||
config_path,
|
config_path,
|
||||||
|
sse_tx.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Also wire shutdown signal to the command channel
|
// Also wire shutdown signal to the command channel
|
||||||
@ -224,6 +228,7 @@ pub fn run(config: &Config, config_path: PathBuf) -> Result<()> {
|
|||||||
&mut mount_children,
|
&mut mount_children,
|
||||||
&mut protocols,
|
&mut protocols,
|
||||||
Arc::clone(&shutdown),
|
Arc::clone(&shutdown),
|
||||||
|
&sse_tx,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Phase 5: Teardown (always runs)
|
// Phase 5: Teardown (always runs)
|
||||||
@ -504,6 +509,7 @@ fn supervise(
|
|||||||
mounts: &mut Vec<MountChild>,
|
mounts: &mut Vec<MountChild>,
|
||||||
protocols: &mut ProtocolChildren,
|
protocols: &mut ProtocolChildren,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
|
sse_tx: &tokio::sync::broadcast::Sender<()>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut smbd_tracker = RestartTracker::new();
|
let mut smbd_tracker = RestartTracker::new();
|
||||||
let mut webdav_tracker = RestartTracker::new();
|
let mut webdav_tracker = RestartTracker::new();
|
||||||
@ -629,6 +635,9 @@ fn supervise(
|
|||||||
|
|
||||||
// Update shared status with fresh RC stats
|
// Update shared status with fresh RC stats
|
||||||
update_status(shared_status, mounts, protocols, &config);
|
update_status(shared_status, mounts, protocols, &config);
|
||||||
|
|
||||||
|
// Notify SSE subscribers that status was refreshed
|
||||||
|
let _ = sse_tx.send(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -786,9 +795,13 @@ fn handle_reload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regen protocol configs (shares changed → SMB/NFS sections changed)
|
// Update protocol configs to reflect share changes
|
||||||
if diff.protocols_changed || !diff.shares_removed.is_empty() || !diff.shares_added.is_empty() || !diff.shares_modified.is_empty() {
|
if diff.protocols_changed {
|
||||||
|
// Protocol settings changed too — full restart needed
|
||||||
restart_protocols(protocols, smbd_tracker, webdav_tracker, &new_config)?;
|
restart_protocols(protocols, smbd_tracker, webdav_tracker, &new_config)?;
|
||||||
|
} else if !diff.shares_removed.is_empty() || !diff.shares_added.is_empty() || !diff.shares_modified.is_empty() {
|
||||||
|
// Only shares changed — live reload is sufficient
|
||||||
|
reload_protocol_configs(protocols, &new_config)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -948,6 +961,32 @@ fn stop_protocols(protocols: &mut ProtocolChildren, config: &Config) {
|
|||||||
protocols.webdav = None;
|
protocols.webdav = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reload protocol configs without full restart (share add/remove/modify).
|
||||||
|
///
|
||||||
|
/// Writes updated smb.conf / NFS exports, then signals the running services
|
||||||
|
/// to re-read them:
|
||||||
|
/// - smbd: SIGHUP causes it to reload smb.conf (new shares appear, removed
|
||||||
|
/// shares disappear for new connections).
|
||||||
|
/// - NFS: `exportfs -ra` re-reads the exports file.
|
||||||
|
/// - WebDAV: no action needed (serves from FUSE mount directly).
|
||||||
|
fn reload_protocol_configs(protocols: &ProtocolChildren, config: &Config) -> Result<()> {
|
||||||
|
if config.protocols.enable_smb {
|
||||||
|
samba::write_config(config)?;
|
||||||
|
if let Some(child) = &protocols.smbd {
|
||||||
|
let pid = child.id() as i32;
|
||||||
|
// SAFETY: sending SIGHUP to a known child PID is safe.
|
||||||
|
unsafe { libc::kill(pid, libc::SIGHUP) };
|
||||||
|
println!(" SMB: config reloaded (SIGHUP)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.protocols.enable_nfs {
|
||||||
|
nfs::write_config(config)?;
|
||||||
|
let _ = Command::new("exportfs").arg("-ra").status();
|
||||||
|
println!(" NFS: re-exported");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Restart protocol services (Tier B). Regen configs and restart smbd/NFS/WebDAV.
|
/// Restart protocol services (Tier B). Regen configs and restart smbd/NFS/WebDAV.
|
||||||
fn restart_protocols(
|
fn restart_protocols(
|
||||||
protocols: &mut ProtocolChildren,
|
protocols: &mut ProtocolChildren,
|
||||||
@ -978,11 +1017,17 @@ fn restart_protocols(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send SIGTERM, wait up to `SIGTERM_GRACE`, then SIGKILL if still alive.
|
/// Send SIGTERM to the entire process group, wait up to `SIGTERM_GRACE`,
|
||||||
|
/// then SIGKILL if still alive.
|
||||||
|
///
|
||||||
|
/// All children are spawned with `.process_group(0)` so the child PID equals
|
||||||
|
/// the process group ID. Using `-pid` ensures forked workers (e.g. smbd
|
||||||
|
/// per-client forks) are also terminated — otherwise orphaned workers hold
|
||||||
|
/// the listening socket and prevent the new process from binding.
|
||||||
fn graceful_kill(child: &mut Child) {
|
fn graceful_kill(child: &mut Child) {
|
||||||
let pid = child.id() as i32;
|
let pid = child.id() as i32;
|
||||||
// SAFETY: sending a signal to a known child PID is safe.
|
// SAFETY: sending a signal to a known child process group is safe.
|
||||||
unsafe { libc::kill(pid, libc::SIGTERM) };
|
unsafe { libc::kill(-pid, libc::SIGTERM) };
|
||||||
|
|
||||||
let deadline = Instant::now() + SIGTERM_GRACE;
|
let deadline = Instant::now() + SIGTERM_GRACE;
|
||||||
loop {
|
loop {
|
||||||
@ -997,7 +1042,8 @@ fn graceful_kill(child: &mut Child) {
|
|||||||
thread::sleep(Duration::from_millis(100));
|
thread::sleep(Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = child.kill();
|
// Escalate: SIGKILL the entire process group
|
||||||
|
unsafe { libc::kill(-pid, libc::SIGKILL) };
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,14 +3,14 @@
|
|||||||
//! All endpoints return JSON. The htmx frontend uses the page handlers instead,
|
//! All endpoints return JSON. The htmx frontend uses the page handlers instead,
|
||||||
//! but these are available for CLI tools and external integrations.
|
//! but these are available for CLI tools and external integrations.
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::Json;
|
use axum::response::Json;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::daemon::SupervisorCmd;
|
use crate::daemon::{LogEntry, SupervisorCmd};
|
||||||
use crate::web::SharedState;
|
use crate::web::SharedState;
|
||||||
|
|
||||||
pub fn routes() -> Router<SharedState> {
|
pub fn routes() -> Router<SharedState> {
|
||||||
@ -20,6 +20,7 @@ pub fn routes() -> Router<SharedState> {
|
|||||||
.route("/api/config", get(get_config))
|
.route("/api/config", get(get_config))
|
||||||
.route("/api/config", post(post_config))
|
.route("/api/config", post(post_config))
|
||||||
.route("/api/bwlimit", post(post_bwlimit))
|
.route("/api/bwlimit", post(post_bwlimit))
|
||||||
|
.route("/api/logs", get(get_logs))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/status — overall daemon status.
|
/// GET /api/status — overall daemon status.
|
||||||
@ -245,3 +246,28 @@ async fn post_bwlimit(
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/logs?since=0 — recent log entries.
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct LogsQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
since: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct LogsResponse {
|
||||||
|
next_id: u64,
|
||||||
|
entries: Vec<LogEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_logs(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Query(params): Query<LogsQuery>,
|
||||||
|
) -> Json<LogsResponse> {
|
||||||
|
let logs = state.logs.read().unwrap();
|
||||||
|
let entries = logs.since(params.since);
|
||||||
|
Json(LogsResponse {
|
||||||
|
next_id: logs.next_id(),
|
||||||
|
entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -5,23 +5,36 @@
|
|||||||
|
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod pages;
|
pub mod pages;
|
||||||
|
pub mod sse;
|
||||||
|
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
|
use axum::http::header;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::routing::get;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::daemon::{AppState, DaemonStatus, SupervisorCmd, DEFAULT_WEB_PORT};
|
use crate::daemon::{AppState, DaemonStatus, LogBuffer, SupervisorCmd, DEFAULT_WEB_PORT};
|
||||||
|
|
||||||
/// Axum-compatible shared state (wraps AppState in an Arc for axum).
|
/// Axum-compatible shared state (wraps AppState in an Arc for axum).
|
||||||
pub type SharedState = Arc<AppState>;
|
pub type SharedState = Arc<AppState>;
|
||||||
|
|
||||||
|
/// Embedded CSS served at `/static/style.css`.
|
||||||
|
const STYLE_CSS: &str = include_str!("../../static/style.css");
|
||||||
|
|
||||||
|
async fn style_css() -> impl IntoResponse {
|
||||||
|
([(header::CONTENT_TYPE, "text/css")], STYLE_CSS)
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the axum router with all routes.
|
/// Build the axum router with all routes.
|
||||||
pub fn build_router(state: SharedState) -> Router {
|
pub fn build_router(state: SharedState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/static/style.css", get(style_css))
|
||||||
.merge(pages::routes())
|
.merge(pages::routes())
|
||||||
|
.merge(sse::routes())
|
||||||
.merge(api::routes())
|
.merge(api::routes())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
@ -34,13 +47,21 @@ pub fn spawn_web_server(
|
|||||||
status: Arc<RwLock<DaemonStatus>>,
|
status: Arc<RwLock<DaemonStatus>>,
|
||||||
cmd_tx: mpsc::Sender<SupervisorCmd>,
|
cmd_tx: mpsc::Sender<SupervisorCmd>,
|
||||||
config_path: std::path::PathBuf,
|
config_path: std::path::PathBuf,
|
||||||
|
sse_tx: tokio::sync::broadcast::Sender<()>,
|
||||||
) -> thread::JoinHandle<()> {
|
) -> thread::JoinHandle<()> {
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
|
let logs = Arc::new(RwLock::new(LogBuffer::new()));
|
||||||
|
{
|
||||||
|
let mut lb = logs.write().unwrap();
|
||||||
|
lb.push("Web UI started");
|
||||||
|
}
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
config,
|
config,
|
||||||
status,
|
status,
|
||||||
cmd_tx,
|
cmd_tx,
|
||||||
config_path,
|
config_path,
|
||||||
|
sse_tx,
|
||||||
|
logs,
|
||||||
});
|
});
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
|||||||
601
src/web/pages.rs
601
src/web/pages.rs
@ -1,37 +1,40 @@
|
|||||||
//! HTML page handlers using askama templates for the htmx-powered frontend.
|
//! HTML page handlers using askama templates for the htmx + Alpine.js frontend.
|
||||||
|
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Form;
|
use axum::{Form, Json};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::daemon::{DaemonStatus, ShareStatus};
|
||||||
use crate::web::SharedState;
|
use crate::web::SharedState;
|
||||||
|
|
||||||
pub fn routes() -> Router<SharedState> {
|
pub fn routes() -> Router<SharedState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(dashboard))
|
// Full-page routes (serve layout shell with embedded tab content)
|
||||||
.route("/shares/{name}", get(share_detail))
|
.route("/", get(page_dashboard))
|
||||||
.route("/config", get(config_page))
|
.route("/shares", get(page_shares))
|
||||||
|
.route("/shares/{name}", get(share_redirect))
|
||||||
|
.route("/config", get(page_config))
|
||||||
.route("/config", post(config_submit))
|
.route("/config", post(config_submit))
|
||||||
|
.route("/config/apply", post(config_apply))
|
||||||
|
.route("/logs", get(page_logs))
|
||||||
|
// Tab partial routes (htmx async load)
|
||||||
|
.route("/tabs/dashboard", get(tab_dashboard))
|
||||||
|
.route("/tabs/shares", get(tab_shares))
|
||||||
|
.route("/tabs/config", get(tab_config))
|
||||||
|
.route("/tabs/logs", get(tab_logs))
|
||||||
|
// Legacy compatibility
|
||||||
.route("/partials/status", get(status_partial))
|
.route("/partials/status", get(status_partial))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Templates ---
|
// ─── View models ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// Compact share view for dashboard cards and status partial.
|
||||||
|
#[allow(dead_code)] // fields used by askama templates
|
||||||
struct ShareView {
|
struct ShareView {
|
||||||
name: String,
|
name: String,
|
||||||
connection: String,
|
connection: String,
|
||||||
@ -45,9 +48,9 @@ struct ShareView {
|
|||||||
health_message: String,
|
health_message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
/// Extended share view for the shares table with all detail fields.
|
||||||
#[template(path = "web/share_detail.html")]
|
#[allow(dead_code)] // fields used by askama templates
|
||||||
struct ShareDetailTemplate {
|
struct ShareDetailView {
|
||||||
name: String,
|
name: String,
|
||||||
connection: String,
|
connection: String,
|
||||||
mount_point: String,
|
mount_point: String,
|
||||||
@ -65,130 +68,307 @@ struct ShareDetailTemplate {
|
|||||||
health_message: String,
|
health_message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build compact share views from status + config.
|
||||||
|
fn build_share_views(status: &DaemonStatus, config: &Config) -> Vec<ShareView> {
|
||||||
|
status
|
||||||
|
.shares
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
let sc = config.find_share(&s.name);
|
||||||
|
ShareView {
|
||||||
|
name: s.name.clone(),
|
||||||
|
connection: sc.map(|c| c.connection.clone()).unwrap_or_default(),
|
||||||
|
mount_point: sc
|
||||||
|
.map(|c| c.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: sc.map(|c| c.read_only).unwrap_or(false),
|
||||||
|
health: s.health_label().to_string(),
|
||||||
|
health_message: s.health_message().unwrap_or("").to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build extended share detail views from status + config.
|
||||||
|
fn build_share_detail_views(status: &DaemonStatus, config: &Config) -> Vec<ShareDetailView> {
|
||||||
|
status
|
||||||
|
.shares
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
let sc = config.find_share(&s.name);
|
||||||
|
ShareDetailView {
|
||||||
|
name: s.name.clone(),
|
||||||
|
connection: sc.map(|c| c.connection.clone()).unwrap_or_default(),
|
||||||
|
mount_point: sc
|
||||||
|
.map(|c| c.mount_point.display().to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
remote_path: sc.map(|c| c.remote_path.clone()).unwrap_or_default(),
|
||||||
|
mounted: s.mounted,
|
||||||
|
read_only: sc.map(|c| c.read_only).unwrap_or(false),
|
||||||
|
rc_port: s.rc_port,
|
||||||
|
cache_display: s.cache_display(),
|
||||||
|
dirty_count: s.dirty_count,
|
||||||
|
errored_files: s.errored_files,
|
||||||
|
speed_display: s.speed_display(),
|
||||||
|
transfers: s.transfers,
|
||||||
|
errors: s.errors,
|
||||||
|
health: s.health_label().to_string(),
|
||||||
|
health_message: s.health_message().unwrap_or("").to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate stats from share statuses.
|
||||||
|
fn aggregate_stats(shares: &[ShareStatus]) -> (u64, f64, u64) {
|
||||||
|
let total_cache: u64 = shares.iter().map(|s| s.cache_bytes).sum();
|
||||||
|
let total_speed: f64 = shares.iter().map(|s| s.speed).sum();
|
||||||
|
let active_transfers: u64 = shares.iter().map(|s| s.transfers).sum();
|
||||||
|
(total_cache, total_speed, active_transfers)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_speed(speed: f64) -> String {
|
||||||
|
if speed < 1.0 {
|
||||||
|
"-".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/s", format_bytes(speed as u64))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Templates ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "web/config.html")]
|
#[template(path = "web/layout.html", escape = "none")]
|
||||||
struct ConfigTemplate {
|
struct LayoutTemplate {
|
||||||
toml_content: String,
|
active_tab: String,
|
||||||
message: Option<String>,
|
tab_content: String,
|
||||||
is_error: bool,
|
uptime: String,
|
||||||
|
config_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "web/status_partial.html")]
|
#[template(path = "web/tabs/dashboard.html")]
|
||||||
struct StatusPartialTemplate {
|
struct DashboardTabTemplate {
|
||||||
|
total_shares: usize,
|
||||||
|
healthy_count: usize,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
uptime: String,
|
failed_count: usize,
|
||||||
|
total_cache_display: String,
|
||||||
|
aggregate_speed_display: String,
|
||||||
|
active_transfers: u64,
|
||||||
shares: Vec<ShareView>,
|
shares: Vec<ShareView>,
|
||||||
smbd_running: bool,
|
smbd_running: bool,
|
||||||
webdav_running: bool,
|
webdav_running: bool,
|
||||||
nfs_exported: bool,
|
nfs_exported: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Handlers ---
|
#[derive(Template)]
|
||||||
|
#[template(path = "web/tabs/shares.html")]
|
||||||
|
struct SharesTabTemplate {
|
||||||
|
shares: Vec<ShareDetailView>,
|
||||||
|
expand: String,
|
||||||
|
}
|
||||||
|
|
||||||
async fn dashboard(State(state): State<SharedState>) -> Response {
|
#[derive(Template)]
|
||||||
|
#[template(path = "web/tabs/config.html", escape = "none")]
|
||||||
|
struct ConfigTabTemplate {
|
||||||
|
init_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data embedded as JSON for the Alpine.js config editor.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct ConfigTabInit {
|
||||||
|
config: Config,
|
||||||
|
message: Option<String>,
|
||||||
|
is_error: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON response for the `POST /config/apply` endpoint.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct ConfigApplyResponse {
|
||||||
|
ok: bool,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "web/tabs/logs.html")]
|
||||||
|
struct LogsTabTemplate;
|
||||||
|
|
||||||
|
/// Legacy htmx polling partial (backward compat for `/partials/status`).
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "web/tabs/dashboard.html")]
|
||||||
|
struct StatusPartialTemplate {
|
||||||
|
total_shares: usize,
|
||||||
|
healthy_count: usize,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
failed_count: usize,
|
||||||
|
total_cache_display: String,
|
||||||
|
aggregate_speed_display: String,
|
||||||
|
active_transfers: u64,
|
||||||
|
shares: Vec<ShareView>,
|
||||||
|
smbd_running: bool,
|
||||||
|
webdav_running: bool,
|
||||||
|
nfs_exported: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Full-page handlers (layout shell + tab content) ──────────────────────
|
||||||
|
|
||||||
|
async fn page_dashboard(State(state): State<SharedState>) -> Response {
|
||||||
|
render_layout("dashboard", &state, |status, config| {
|
||||||
|
render_dashboard_tab(status, config)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn page_shares(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Query(params): Query<ExpandQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let expand = params.expand.unwrap_or_default();
|
||||||
|
render_layout("shares", &state, |status, config| {
|
||||||
|
render_shares_tab(status, config, &expand)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn page_config(State(state): State<SharedState>) -> Response {
|
||||||
|
render_layout("config", &state, |_status, config| {
|
||||||
|
render_config_tab_html(config, None, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn page_logs(State(state): State<SharedState>) -> Response {
|
||||||
|
render_layout("logs", &state, |_status, _config| {
|
||||||
|
LogsTabTemplate.render().unwrap_or_default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: render the layout shell wrapping a tab content generator.
|
||||||
|
fn render_layout(
|
||||||
|
tab: &str,
|
||||||
|
state: &SharedState,
|
||||||
|
tab_fn: impl FnOnce(&DaemonStatus, &Config) -> String,
|
||||||
|
) -> Response {
|
||||||
let status = state.status.read().unwrap();
|
let status = state.status.read().unwrap();
|
||||||
let config = state.config.read().unwrap();
|
let config = state.config.read().unwrap();
|
||||||
|
|
||||||
let shares: Vec<ShareView> = status
|
let tab_content = tab_fn(&status, &config);
|
||||||
.shares
|
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
let share_config = config.find_share(&s.name);
|
|
||||||
ShareView {
|
|
||||||
name: s.name.clone(),
|
|
||||||
connection: share_config
|
|
||||||
.map(|sc| sc.connection.clone())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
mount_point: share_config
|
|
||||||
.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: share_config.map(|sc| sc.read_only).unwrap_or(false),
|
|
||||||
health: s.health_label().to_string(),
|
|
||||||
health_message: s.health_message().unwrap_or("").to_string(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let tmpl = DashboardTemplate {
|
let tmpl = LayoutTemplate {
|
||||||
|
active_tab: tab.to_string(),
|
||||||
|
tab_content,
|
||||||
uptime: status.uptime_string(),
|
uptime: status.uptime_string(),
|
||||||
config_path: state.config_path.display().to_string(),
|
config_path: state.config_path.display().to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match tmpl.render() {
|
||||||
|
Ok(html) => Html(html).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab partial handlers (htmx async) ───────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ExpandQuery {
|
||||||
|
expand: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tab_dashboard(State(state): State<SharedState>) -> Response {
|
||||||
|
let status = state.status.read().unwrap();
|
||||||
|
let config = state.config.read().unwrap();
|
||||||
|
let html = render_dashboard_tab(&status, &config);
|
||||||
|
Html(html).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tab_shares(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Query(params): Query<ExpandQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let status = state.status.read().unwrap();
|
||||||
|
let config = state.config.read().unwrap();
|
||||||
|
let expand = params.expand.unwrap_or_default();
|
||||||
|
let html = render_shares_tab(&status, &config, &expand);
|
||||||
|
Html(html).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tab_config(State(state): State<SharedState>) -> Response {
|
||||||
|
let config = state.config.read().unwrap();
|
||||||
|
let html = render_config_tab_html(&config, None, false);
|
||||||
|
Html(html).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tab_logs() -> Response {
|
||||||
|
match LogsTabTemplate.render() {
|
||||||
|
Ok(html) => Html(html).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab render helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_dashboard_tab(status: &DaemonStatus, config: &Config) -> String {
|
||||||
|
let shares = build_share_views(status, config);
|
||||||
|
let healthy_count = shares.iter().filter(|s| s.health == "OK").count();
|
||||||
|
let failed_count = shares.iter().filter(|s| s.health == "FAILED").count();
|
||||||
|
let (total_cache, total_speed, active_transfers) = aggregate_stats(&status.shares);
|
||||||
|
|
||||||
|
let tmpl = DashboardTabTemplate {
|
||||||
|
total_shares: shares.len(),
|
||||||
|
healthy_count,
|
||||||
|
failed_count,
|
||||||
|
total_cache_display: format_bytes(total_cache),
|
||||||
|
aggregate_speed_display: format_speed(total_speed),
|
||||||
|
active_transfers,
|
||||||
shares,
|
shares,
|
||||||
smbd_running: status.smbd_running,
|
smbd_running: status.smbd_running,
|
||||||
webdav_running: status.webdav_running,
|
webdav_running: status.webdav_running,
|
||||||
nfs_exported: status.nfs_exported,
|
nfs_exported: status.nfs_exported,
|
||||||
};
|
};
|
||||||
|
|
||||||
match tmpl.render() {
|
tmpl.render().unwrap_or_default()
|
||||||
Ok(html) => Html(html).into_response(),
|
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn share_detail(
|
fn render_shares_tab(status: &DaemonStatus, config: &Config, expand: &str) -> String {
|
||||||
State(state): State<SharedState>,
|
let shares = build_share_detail_views(status, config);
|
||||||
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) {
|
let tmpl = SharesTabTemplate {
|
||||||
Some(s) => s,
|
shares,
|
||||||
None => return (StatusCode::NOT_FOUND, "Share not found").into_response(),
|
expand: expand.to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let share_config = config.find_share(&name);
|
tmpl.render().unwrap_or_default()
|
||||||
|
|
||||||
let tmpl = ShareDetailTemplate {
|
|
||||||
name: share_status.name.clone(),
|
|
||||||
connection: share_config
|
|
||||||
.map(|sc| sc.connection.clone())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
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,
|
|
||||||
health: share_status.health_label().to_string(),
|
|
||||||
health_message: share_status.health_message().unwrap_or("").to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
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 {
|
// ─── Share detail redirect ────────────────────────────────────────────────
|
||||||
let config = state.config.read().unwrap();
|
|
||||||
let toml_content = toml::to_string_pretty(&*config).unwrap_or_default();
|
|
||||||
|
|
||||||
let tmpl = ConfigTemplate {
|
async fn share_redirect(Path(name): Path<String>) -> Response {
|
||||||
toml_content,
|
Redirect::to(&format!("/shares?expand={name}")).into_response()
|
||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Config submit ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct ConfigForm {
|
struct ConfigForm {
|
||||||
toml: String,
|
toml: String,
|
||||||
@ -199,31 +379,26 @@ async fn config_submit(
|
|||||||
Form(form): Form<ConfigForm>,
|
Form(form): Form<ConfigForm>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// Parse and validate
|
// Parse and validate
|
||||||
let new_config: crate::config::Config = match toml::from_str(&form.toml) {
|
let new_config: Config = match toml::from_str(&form.toml) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let tmpl = ConfigTemplate {
|
let config = state.config.read().unwrap();
|
||||||
toml_content: form.toml,
|
let html = render_config_tab_html(
|
||||||
message: Some(format!("TOML parse error: {e}")),
|
&config,
|
||||||
is_error: true,
|
Some(format!("TOML parse error: {e}")),
|
||||||
};
|
true,
|
||||||
return match tmpl.render() {
|
);
|
||||||
Ok(html) => Html(html).into_response(),
|
return Html(html).into_response();
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = new_config.validate() {
|
if let Err(e) = new_config.validate() {
|
||||||
let tmpl = ConfigTemplate {
|
let html = render_config_tab_html(
|
||||||
toml_content: form.toml,
|
&new_config,
|
||||||
message: Some(format!("Validation error: {e}")),
|
Some(format!("Validation error: {e}")),
|
||||||
is_error: true,
|
true,
|
||||||
};
|
);
|
||||||
return match tmpl.render() {
|
return Html(html).into_response();
|
||||||
Ok(html) => Html(html).into_response(),
|
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute diff summary
|
// Compute diff summary
|
||||||
@ -231,30 +406,24 @@ async fn config_submit(
|
|||||||
let old_config = state.config.read().unwrap();
|
let old_config = state.config.read().unwrap();
|
||||||
let d = crate::config_diff::diff(&old_config, &new_config);
|
let d = crate::config_diff::diff(&old_config, &new_config);
|
||||||
if d.is_empty() {
|
if d.is_empty() {
|
||||||
let tmpl = ConfigTemplate {
|
let html = render_config_tab_html(
|
||||||
toml_content: form.toml,
|
&new_config,
|
||||||
message: Some("No changes detected.".to_string()),
|
Some("No changes detected.".to_string()),
|
||||||
is_error: false,
|
false,
|
||||||
};
|
);
|
||||||
return match tmpl.render() {
|
return Html(html).into_response();
|
||||||
Ok(html) => Html(html).into_response(),
|
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
d.summary()
|
d.summary()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to disk
|
// Save to disk
|
||||||
if let Err(e) = std::fs::write(&state.config_path, &form.toml) {
|
if let Err(e) = std::fs::write(&state.config_path, &form.toml) {
|
||||||
let tmpl = ConfigTemplate {
|
let html = render_config_tab_html(
|
||||||
toml_content: form.toml,
|
&new_config,
|
||||||
message: Some(format!("Failed to write config: {e}")),
|
Some(format!("Failed to write config: {e}")),
|
||||||
is_error: true,
|
true,
|
||||||
};
|
);
|
||||||
return match tmpl.render() {
|
return Html(html).into_response();
|
||||||
Ok(html) => Html(html).into_response(),
|
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send reload command
|
// Send reload command
|
||||||
@ -262,56 +431,116 @@ async fn config_submit(
|
|||||||
.cmd_tx
|
.cmd_tx
|
||||||
.send(crate::daemon::SupervisorCmd::Reload(new_config))
|
.send(crate::daemon::SupervisorCmd::Reload(new_config))
|
||||||
{
|
{
|
||||||
let tmpl = ConfigTemplate {
|
let config = state.config.read().unwrap();
|
||||||
toml_content: form.toml,
|
let html = render_config_tab_html(
|
||||||
message: Some(format!("Failed to send reload: {e}")),
|
&config,
|
||||||
is_error: true,
|
Some(format!("Failed to send reload: {e}")),
|
||||||
};
|
true,
|
||||||
return match tmpl.render() {
|
);
|
||||||
Ok(html) => Html(html).into_response(),
|
return Html(html).into_response();
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success — redirect to dashboard
|
// Success — re-read config and show success message
|
||||||
Redirect::to(&format!("/config?msg={}", urlencoded("Config applied: ".to_string() + &diff_summary))).into_response()
|
let config = state.config.read().unwrap();
|
||||||
|
let html = render_config_tab_html(
|
||||||
|
&config,
|
||||||
|
Some(format!("Config applied: {diff_summary}")),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
Html(html).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn urlencoded(s: String) -> String {
|
/// JSON endpoint: apply config from the interactive form editor.
|
||||||
s.replace(' ', "+").replace(':', "%3A").replace(',', "%2C")
|
async fn config_apply(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Json(new_config): Json<Config>,
|
||||||
|
) -> Json<ConfigApplyResponse> {
|
||||||
|
// Validate
|
||||||
|
if let Err(e) = new_config.validate() {
|
||||||
|
return Json(ConfigApplyResponse {
|
||||||
|
ok: false,
|
||||||
|
message: format!("Validation error: {e}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute diff
|
||||||
|
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 Json(ConfigApplyResponse {
|
||||||
|
ok: true,
|
||||||
|
message: "No changes detected.".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
d.summary()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serialize to human-readable TOML and write to disk
|
||||||
|
let toml_content = new_config.to_commented_toml();
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::write(&state.config_path, &toml_content) {
|
||||||
|
return Json(ConfigApplyResponse {
|
||||||
|
ok: false,
|
||||||
|
message: format!("Failed to write config: {e}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send reload command
|
||||||
|
if let Err(e) = state
|
||||||
|
.cmd_tx
|
||||||
|
.send(crate::daemon::SupervisorCmd::Reload(new_config))
|
||||||
|
{
|
||||||
|
return Json(ConfigApplyResponse {
|
||||||
|
ok: false,
|
||||||
|
message: format!("Failed to send reload: {e}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut logs = state.logs.write().unwrap();
|
||||||
|
logs.push(format!("Config applied: {diff_summary}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(ConfigApplyResponse {
|
||||||
|
ok: true,
|
||||||
|
message: format!("Config applied: {diff_summary}"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Partial HTML fragment for htmx polling (status cards only).
|
/// Render the config tab HTML using the interactive form editor.
|
||||||
|
fn render_config_tab_html(config: &Config, message: Option<String>, is_error: bool) -> String {
|
||||||
|
let init = ConfigTabInit {
|
||||||
|
config: config.clone(),
|
||||||
|
message,
|
||||||
|
is_error,
|
||||||
|
};
|
||||||
|
// Escape </ to prevent breaking out of <script> tags
|
||||||
|
let init_json = serde_json::to_string(&init)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.replace("</", "<\\/");
|
||||||
|
let tmpl = ConfigTabTemplate { init_json };
|
||||||
|
tmpl.render().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Legacy partial (backward compat) ─────────────────────────────────────
|
||||||
|
|
||||||
async fn status_partial(State(state): State<SharedState>) -> Response {
|
async fn status_partial(State(state): State<SharedState>) -> Response {
|
||||||
let status = state.status.read().unwrap();
|
let status = state.status.read().unwrap();
|
||||||
let config = state.config.read().unwrap();
|
let config = state.config.read().unwrap();
|
||||||
|
|
||||||
let shares: Vec<ShareView> = status
|
let shares = build_share_views(&status, &config);
|
||||||
.shares
|
let healthy_count = shares.iter().filter(|s| s.health == "OK").count();
|
||||||
.iter()
|
let failed_count = shares.iter().filter(|s| s.health == "FAILED").count();
|
||||||
.map(|s| {
|
let (total_cache, total_speed, active_transfers) = aggregate_stats(&status.shares);
|
||||||
let share_config = config.find_share(&s.name);
|
|
||||||
ShareView {
|
|
||||||
name: s.name.clone(),
|
|
||||||
connection: share_config
|
|
||||||
.map(|sc| sc.connection.clone())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
mount_point: share_config
|
|
||||||
.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: share_config.map(|sc| sc.read_only).unwrap_or(false),
|
|
||||||
health: s.health_label().to_string(),
|
|
||||||
health_message: s.health_message().unwrap_or("").to_string(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let tmpl = StatusPartialTemplate {
|
let tmpl = StatusPartialTemplate {
|
||||||
uptime: status.uptime_string(),
|
total_shares: shares.len(),
|
||||||
|
healthy_count,
|
||||||
|
failed_count,
|
||||||
|
total_cache_display: format_bytes(total_cache),
|
||||||
|
aggregate_speed_display: format_speed(total_speed),
|
||||||
|
active_transfers,
|
||||||
shares,
|
shares,
|
||||||
smbd_running: status.smbd_running,
|
smbd_running: status.smbd_running,
|
||||||
webdav_running: status.webdav_running,
|
webdav_running: status.webdav_running,
|
||||||
|
|||||||
199
src/web/sse.rs
Normal file
199
src/web/sse.rs
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
//! Server-Sent Events endpoint for real-time status push.
|
||||||
|
//!
|
||||||
|
//! The supervisor sends `()` on a `broadcast::Sender` after every
|
||||||
|
//! `update_status()` cycle. Each SSE client subscribes, renders the
|
||||||
|
//! partial templates, and pushes them as a single `status` event with
|
||||||
|
//! htmx OOB swap attributes so multiple DOM regions update at once.
|
||||||
|
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use tokio_stream::wrappers::BroadcastStream;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
use crate::web::SharedState;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<SharedState> {
|
||||||
|
Router::new().route("/events", get(sse_handler))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sse_handler(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||||
|
let rx = state.sse_tx.subscribe();
|
||||||
|
let stream = BroadcastStream::new(rx).filter_map(move |r| {
|
||||||
|
match r {
|
||||||
|
Ok(()) => {
|
||||||
|
let status = state.status.read().unwrap();
|
||||||
|
let config = state.config.read().unwrap();
|
||||||
|
|
||||||
|
let html = render_sse_payload(&status, &config);
|
||||||
|
Some(Ok(Event::default().event("status").data(html)))
|
||||||
|
}
|
||||||
|
Err(_) => None, // lagged, skip
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render all SSE partials into a single HTML payload.
|
||||||
|
///
|
||||||
|
/// Uses htmx OOB (Out-of-Band) swap so a single SSE event can update
|
||||||
|
/// multiple independent DOM regions:
|
||||||
|
/// - `#dashboard-stats` — stat cards
|
||||||
|
/// - `#share-rows` — share card list
|
||||||
|
/// - `#protocol-badges` — SMB/NFS/WebDAV badges
|
||||||
|
fn render_sse_payload(
|
||||||
|
status: &crate::daemon::DaemonStatus,
|
||||||
|
config: &crate::config::Config,
|
||||||
|
) -> String {
|
||||||
|
let shares: Vec<SseShareView> = status
|
||||||
|
.shares
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
let share_config = config.find_share(&s.name);
|
||||||
|
SseShareView {
|
||||||
|
name: s.name.clone(),
|
||||||
|
connection: share_config
|
||||||
|
.map(|sc| sc.connection.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
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: s.mounted,
|
||||||
|
cache_display: s.cache_display(),
|
||||||
|
dirty_count: s.dirty_count,
|
||||||
|
errored_files: s.errored_files,
|
||||||
|
speed_display: s.speed_display(),
|
||||||
|
transfers: s.transfers,
|
||||||
|
errors: s.errors,
|
||||||
|
read_only: share_config.map(|sc| sc.read_only).unwrap_or(false),
|
||||||
|
health: s.health_label().to_string(),
|
||||||
|
health_message: s.health_message().unwrap_or("").to_string(),
|
||||||
|
rc_port: s.rc_port,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let healthy_count = shares.iter().filter(|s| s.health == "OK").count();
|
||||||
|
let failed_count = shares.iter().filter(|s| s.health == "FAILED").count();
|
||||||
|
let total_cache: u64 = status.shares.iter().map(|s| s.cache_bytes).sum();
|
||||||
|
let total_speed: f64 = status.shares.iter().map(|s| s.speed).sum();
|
||||||
|
let active_transfers: u64 = status.shares.iter().map(|s| s.transfers).sum();
|
||||||
|
|
||||||
|
let stats = DashboardStatsPartial {
|
||||||
|
total_shares: shares.len(),
|
||||||
|
healthy_count,
|
||||||
|
failed_count,
|
||||||
|
total_cache_display: format_bytes_static(total_cache),
|
||||||
|
aggregate_speed_display: if total_speed < 1.0 {
|
||||||
|
"-".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/s", format_bytes_static(total_speed as u64))
|
||||||
|
},
|
||||||
|
active_transfers,
|
||||||
|
uptime: status.uptime_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let share_rows = ShareRowsPartial {
|
||||||
|
shares: shares.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let badges = ProtocolBadgesPartial {
|
||||||
|
smbd_running: status.smbd_running,
|
||||||
|
nfs_exported: status.nfs_exported,
|
||||||
|
webdav_running: status.webdav_running,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut html = String::new();
|
||||||
|
// Primary target: dashboard stats
|
||||||
|
if let Ok(s) = stats.render() {
|
||||||
|
html.push_str(&s);
|
||||||
|
}
|
||||||
|
// OOB: share rows
|
||||||
|
if let Ok(s) = share_rows.render() {
|
||||||
|
html.push_str(&s);
|
||||||
|
}
|
||||||
|
// OOB: protocol badges
|
||||||
|
if let Ok(s) = badges.render() {
|
||||||
|
html.push_str(&s);
|
||||||
|
}
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_bytes_static(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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SSE partial templates ---
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
#[allow(dead_code)] // fields used by askama templates
|
||||||
|
pub struct SseShareView {
|
||||||
|
pub name: String,
|
||||||
|
pub connection: String,
|
||||||
|
pub mount_point: String,
|
||||||
|
pub remote_path: String,
|
||||||
|
pub mounted: bool,
|
||||||
|
pub cache_display: String,
|
||||||
|
pub dirty_count: u64,
|
||||||
|
pub errored_files: u64,
|
||||||
|
pub speed_display: String,
|
||||||
|
pub transfers: u64,
|
||||||
|
pub errors: u64,
|
||||||
|
pub read_only: bool,
|
||||||
|
pub health: String,
|
||||||
|
pub health_message: String,
|
||||||
|
pub rc_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "web/partials/dashboard_stats.html")]
|
||||||
|
struct DashboardStatsPartial {
|
||||||
|
total_shares: usize,
|
||||||
|
healthy_count: usize,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
failed_count: usize,
|
||||||
|
total_cache_display: String,
|
||||||
|
aggregate_speed_display: String,
|
||||||
|
active_transfers: u64,
|
||||||
|
uptime: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "web/partials/share_rows.html")]
|
||||||
|
struct ShareRowsPartial {
|
||||||
|
shares: Vec<SseShareView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "web/partials/protocol_badges.html")]
|
||||||
|
struct ProtocolBadgesPartial {
|
||||||
|
smbd_running: bool,
|
||||||
|
nfs_exported: bool,
|
||||||
|
webdav_running: bool,
|
||||||
|
}
|
||||||
588
static/style.css
Normal file
588
static/style.css
Normal file
@ -0,0 +1,588 @@
|
|||||||
|
/* Warpgate Dashboard — unified stylesheet */
|
||||||
|
|
||||||
|
: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;
|
||||||
|
--mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.mono { font-family: var(--mono); }
|
||||||
|
|
||||||
|
/* ─── Shell layout ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 { font-size: 1.4em; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* ─── Tab navigation ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Stat cards (dashboard) ───────────────────────────── */
|
||||||
|
|
||||||
|
.stat-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .label {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 1.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Share cards ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* ─── Badges ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.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); }
|
||||||
|
.badge-warn { background: rgba(251,191,36,0.15); color: var(--yellow); }
|
||||||
|
|
||||||
|
/* ─── Stats row (inside share cards) ──────────────────── */
|
||||||
|
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(248,113,113,0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Protocol badges ──────────────────────────────────── */
|
||||||
|
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* ─── Share table (shares tab) ─────────────────────────── */
|
||||||
|
|
||||||
|
.share-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-row:hover {
|
||||||
|
background: rgba(108,138,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row td {
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 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: 6px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table td:first-child {
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text { color: var(--red); }
|
||||||
|
|
||||||
|
/* ─── Config editor ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.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: var(--mono);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Config form (interactive editor) ─────────────────── */
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h3 {
|
||||||
|
font-size: 1em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-body { padding: 16px; }
|
||||||
|
|
||||||
|
.tier-badge {
|
||||||
|
font-size: 0.7em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-live { background: rgba(74,222,128,0.15); color: var(--green); }
|
||||||
|
.tier-protocol { background: rgba(251,191,36,0.15); color: var(--yellow); }
|
||||||
|
.tier-pershare { background: rgba(251,191,36,0.15); color: var(--yellow); }
|
||||||
|
.tier-global { background: rgba(248,113,113,0.15); color: var(--red); }
|
||||||
|
.tier-none { background: rgba(74,222,128,0.15); color: var(--green); }
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row { margin-bottom: 12px; }
|
||||||
|
.field-grid .field-row { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.field-row label:not(.toggle) {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row input[type="text"],
|
||||||
|
.field-row input[type="password"],
|
||||||
|
.field-row input[type="number"],
|
||||||
|
.field-row select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row input:focus,
|
||||||
|
.field-row select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row input.mono { font-family: var(--mono); }
|
||||||
|
|
||||||
|
.array-item {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.array-item .item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(248,113,113,0.3);
|
||||||
|
color: var(--red);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn:hover { background: rgba(248,113,113,0.1); }
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
background: none;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle switch */
|
||||||
|
.toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input { display: none; }
|
||||||
|
|
||||||
|
.toggle .slider {
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle .slider::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .slider { background: var(--accent); }
|
||||||
|
.toggle input:checked + .slider::after { transform: translateX(16px); background: #fff; }
|
||||||
|
|
||||||
|
.chevron { font-size: 0.9em; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ─── Buttons ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover { opacity: 0.9; }
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Log viewer ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.log-viewer {
|
||||||
|
background: #0a0c10;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1.6;
|
||||||
|
height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-viewer .log-line { color: var(--text-muted); }
|
||||||
|
.log-viewer .log-ts { color: var(--accent); margin-right: 8px; opacity: 0.7; }
|
||||||
|
.log-viewer .log-msg { color: var(--text); }
|
||||||
|
|
||||||
|
.log-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toolbar .log-count { font-family: var(--mono); }
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stat-cards { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.detail-grid { grid-template-columns: 1fr; }
|
||||||
|
.field-grid { grid-template-columns: 1fr; }
|
||||||
|
.share-table th:nth-child(n+5),
|
||||||
|
.share-table td:nth-child(n+5) { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.shell { padding: 12px; }
|
||||||
|
.stat-cards { grid-template-columns: 1fr; }
|
||||||
|
.tabs { overflow-x: auto; }
|
||||||
|
.tab-btn { padding: 8px 14px; font-size: 0.85em; }
|
||||||
|
}
|
||||||
@ -1,7 +1,13 @@
|
|||||||
# Warpgate Configuration
|
# Warpgate Configuration
|
||||||
# See: https://github.com/user/warpgate for documentation
|
# See: https://github.com/user/warpgate for documentation
|
||||||
|
|
||||||
[connection]
|
# --- NAS Connections ---
|
||||||
|
# Each connection defines an SFTP endpoint to a remote NAS.
|
||||||
|
# The "name" is used as the rclone remote identifier and must be unique.
|
||||||
|
|
||||||
|
[[connections]]
|
||||||
|
# Unique name for this connection (alphanumeric, hyphens, underscores)
|
||||||
|
name = "nas"
|
||||||
# Remote NAS Tailscale IP or hostname
|
# Remote NAS Tailscale IP or hostname
|
||||||
nas_host = "100.x.x.x"
|
nas_host = "100.x.x.x"
|
||||||
# SFTP username
|
# SFTP username
|
||||||
@ -15,6 +21,15 @@ sftp_port = 22
|
|||||||
# SFTP connection pool size
|
# SFTP connection pool size
|
||||||
sftp_connections = 8
|
sftp_connections = 8
|
||||||
|
|
||||||
|
# --- Additional NAS (uncomment to add) ---
|
||||||
|
# [[connections]]
|
||||||
|
# name = "office"
|
||||||
|
# nas_host = "192.168.1.100"
|
||||||
|
# nas_user = "photographer"
|
||||||
|
# nas_pass = "secret"
|
||||||
|
# sftp_port = 22
|
||||||
|
# sftp_connections = 8
|
||||||
|
|
||||||
[cache]
|
[cache]
|
||||||
# Cache storage directory (should be on SSD, prefer btrfs/ZFS filesystem)
|
# Cache storage directory (should be on SSD, prefer btrfs/ZFS filesystem)
|
||||||
dir = "/mnt/ssd/warpgate"
|
dir = "/mnt/ssd/warpgate"
|
||||||
@ -71,29 +86,39 @@ webdav_port = 8080
|
|||||||
#
|
#
|
||||||
# [smb_auth]
|
# [smb_auth]
|
||||||
# enabled = true
|
# enabled = true
|
||||||
# username = "photographer" # defaults to connection.nas_user
|
# username = "photographer"
|
||||||
# smb_pass = "my-password" # option 1: dedicated password
|
# smb_pass = "my-password"
|
||||||
# reuse_nas_pass = true # option 2: reuse connection.nas_pass
|
|
||||||
|
|
||||||
# --- Shares ---
|
# --- Shares ---
|
||||||
# Each share maps a remote NAS path to a local mount point.
|
# Each share maps a remote NAS path to a local mount point.
|
||||||
# Each gets its own rclone mount process with independent FUSE mount.
|
# Each gets its own rclone mount process with independent FUSE mount.
|
||||||
|
# The "connection" field references a [[connections]] entry by name.
|
||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "photos"
|
name = "photos"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/volume1/photos"
|
remote_path = "/volume1/photos"
|
||||||
mount_point = "/mnt/photos"
|
mount_point = "/mnt/photos"
|
||||||
|
|
||||||
# [[shares]]
|
# [[shares]]
|
||||||
# name = "projects"
|
# name = "projects"
|
||||||
|
# connection = "nas"
|
||||||
# remote_path = "/volume1/projects"
|
# remote_path = "/volume1/projects"
|
||||||
# mount_point = "/mnt/projects"
|
# mount_point = "/mnt/projects"
|
||||||
#
|
#
|
||||||
# [[shares]]
|
# [[shares]]
|
||||||
# name = "backups"
|
# name = "backups"
|
||||||
|
# connection = "nas"
|
||||||
# remote_path = "/volume1/backups"
|
# remote_path = "/volume1/backups"
|
||||||
# mount_point = "/mnt/backups"
|
# mount_point = "/mnt/backups"
|
||||||
# read_only = true
|
# read_only = true
|
||||||
|
#
|
||||||
|
# # Share from a different NAS:
|
||||||
|
# [[shares]]
|
||||||
|
# name = "office-docs"
|
||||||
|
# connection = "office"
|
||||||
|
# remote_path = "/data/documents"
|
||||||
|
# mount_point = "/mnt/office-docs"
|
||||||
|
|
||||||
[warmup]
|
[warmup]
|
||||||
# Auto-warmup configured paths on startup
|
# Auto-warmup configured paths on startup
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
<!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); }
|
|
||||||
.badge-warn { background: rgba(251,191,36,0.15); color: var(--yellow); }
|
|
||||||
.error-msg { margin-top: 8px; padding: 8px 12px; background: rgba(248,113,113,0.08); border-radius: 4px; color: var(--red); font-size: 0.85em; }
|
|
||||||
.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.health == "OK" %}
|
|
||||||
<span class="badge badge-ok">OK</span>
|
|
||||||
{% elif share.health == "FAILED" %}
|
|
||||||
<span class="badge badge-error" title="{{ share.health_message }}">FAILED</span>
|
|
||||||
{% elif share.health == "PROBING" %}
|
|
||||||
<span class="badge badge-warn">PROBING</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-warn">PENDING</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>
|
|
||||||
{% if share.health == "FAILED" %}
|
|
||||||
<div class="error-msg">{{ share.health_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</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>
|
|
||||||
59
templates/web/layout.html
Normal file
59
templates/web/layout.html
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Warpgate Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
|
||||||
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body hx-ext="sse" sse-connect="/events" x-data="{ activeTab: '{{ active_tab }}' }">
|
||||||
|
<div class="shell">
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h1><span class="status-dot"></span>Warpgate</h1>
|
||||||
|
<div class="meta">Uptime: <span id="uptime">{{ uptime }}</span> | Config: {{ config_path }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="tabs">
|
||||||
|
<button class="tab-btn" :class="{ active: activeTab === 'dashboard' }"
|
||||||
|
@click="activeTab = 'dashboard'"
|
||||||
|
hx-get="/tabs/dashboard" hx-target="#tab-content" hx-swap="innerHTML">
|
||||||
|
Dashboard
|
||||||
|
</button>
|
||||||
|
<button class="tab-btn" :class="{ active: activeTab === 'shares' }"
|
||||||
|
@click="activeTab = 'shares'"
|
||||||
|
hx-get="/tabs/shares" hx-target="#tab-content" hx-swap="innerHTML">
|
||||||
|
Shares
|
||||||
|
</button>
|
||||||
|
<button class="tab-btn" :class="{ active: activeTab === 'config' }"
|
||||||
|
@click="activeTab = 'config'"
|
||||||
|
hx-get="/tabs/config" hx-target="#tab-content" hx-swap="innerHTML">
|
||||||
|
Config
|
||||||
|
</button>
|
||||||
|
<button class="tab-btn" :class="{ active: activeTab === 'logs' }"
|
||||||
|
@click="activeTab = 'logs'"
|
||||||
|
hx-get="/tabs/logs" hx-target="#tab-content" hx-swap="innerHTML">
|
||||||
|
Logs
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="tab-content">
|
||||||
|
{{ tab_content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden SSE listener: OOB swaps update regions inside dashboard tab -->
|
||||||
|
<div sse-swap="status" hx-swap="none" style="display:none"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* Alpine.js + htmx bridge: re-initialize Alpine trees after htmx content swaps */
|
||||||
|
document.body.addEventListener('htmx:afterSettle', function(evt) {
|
||||||
|
if (window.Alpine) Alpine.initTree(evt.detail.target || evt.detail.elt);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
templates/web/partials/dashboard_stats.html
Normal file
21
templates/web/partials/dashboard_stats.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<div id="dashboard-stats" hx-swap-oob="innerHTML:#dashboard-stats">
|
||||||
|
<div class="stat-cards">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Shares</div>
|
||||||
|
<div class="value">{{ healthy_count }} / {{ total_shares }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Cache</div>
|
||||||
|
<div class="value">{{ total_cache_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Speed</div>
|
||||||
|
<div class="value">{{ aggregate_speed_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Transfers</div>
|
||||||
|
<div class="value">{{ active_transfers }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="uptime-oob" hx-swap-oob="innerHTML:#uptime">{{ uptime }}</div>
|
||||||
7
templates/web/partials/protocol_badges.html
Normal file
7
templates/web/partials/protocol_badges.html
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<div id="protocol-badges" hx-swap-oob="innerHTML:#protocol-badges">
|
||||||
|
<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>
|
||||||
36
templates/web/partials/share_rows.html
Normal file
36
templates/web/partials/share_rows.html
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<div id="share-rows" hx-swap-oob="innerHTML:#share-rows">
|
||||||
|
<div class="cards">
|
||||||
|
{% for share in shares %}
|
||||||
|
<div class="card" style="cursor:pointer"
|
||||||
|
hx-get="/tabs/shares?expand={{ share.name }}" hx-target="#tab-content" hx-swap="innerHTML"
|
||||||
|
@click="activeTab = 'shares'">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>{{ share.name }}</h2>
|
||||||
|
<div>
|
||||||
|
{% if share.health == "OK" %}
|
||||||
|
<span class="badge badge-ok">OK</span>
|
||||||
|
{% elif share.health == "FAILED" %}
|
||||||
|
<span class="badge badge-error" title="{{ share.health_message }}">FAILED</span>
|
||||||
|
{% elif share.health == "PROBING" %}
|
||||||
|
<span class="badge badge-warn">PROBING</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warn">PENDING</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>
|
||||||
|
{% if share.health == "FAILED" %}
|
||||||
|
<div class="error-msg">{{ share.health_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -1,76 +0,0 @@
|
|||||||
<!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); }
|
|
||||||
.badge-warn { background: rgba(251,191,36,0.15); color: var(--yellow); }
|
|
||||||
.error-text { color: var(--red); }
|
|
||||||
.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 health == "OK" %}<span class="badge badge-ok">OK</span>{% elif health == "FAILED" %}<span class="badge badge-error">FAILED</span>{% elif health == "PROBING" %}<span class="badge badge-warn">PROBING</span>{% else %}<span class="badge badge-warn">PENDING</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>Health</td><td>{{ health }}</td></tr>
|
|
||||||
{% if health == "FAILED" %}
|
|
||||||
<tr><td>Probe Error</td><td class="error-text">{{ health_message }}</td></tr>
|
|
||||||
{% endif %}
|
|
||||||
<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>
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
{% for share in shares %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h2><a href="/shares/{{ share.name }}">{{ share.name }}</a></h2>
|
|
||||||
<div>
|
|
||||||
{% if share.health == "OK" %}
|
|
||||||
<span class="badge badge-ok">OK</span>
|
|
||||||
{% elif share.health == "FAILED" %}
|
|
||||||
<span class="badge badge-error" title="{{ share.health_message }}">FAILED</span>
|
|
||||||
{% elif share.health == "PROBING" %}
|
|
||||||
<span class="badge badge-warn">PROBING</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-warn">PENDING</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>
|
|
||||||
{% if share.health == "FAILED" %}
|
|
||||||
<div class="error-msg">{{ share.health_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</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>
|
|
||||||
475
templates/web/tabs/config.html
Normal file
475
templates/web/tabs/config.html
Normal file
@ -0,0 +1,475 @@
|
|||||||
|
<script id="config-init" type="application/json">{{ init_json }}</script>
|
||||||
|
<script>
|
||||||
|
function configEditorFn() {
|
||||||
|
return {
|
||||||
|
config: {},
|
||||||
|
originalConfig: {},
|
||||||
|
submitting: false,
|
||||||
|
message: null,
|
||||||
|
isError: false,
|
||||||
|
sections: {
|
||||||
|
connections: true,
|
||||||
|
shares: true,
|
||||||
|
cache: false,
|
||||||
|
read: false,
|
||||||
|
bandwidth: false,
|
||||||
|
writeback: false,
|
||||||
|
directory_cache: false,
|
||||||
|
protocols: false,
|
||||||
|
smb_auth: false,
|
||||||
|
warmup: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const data = JSON.parse(document.getElementById('config-init').textContent);
|
||||||
|
this.config = this.prepareForEdit(data.config);
|
||||||
|
this.originalConfig = JSON.parse(JSON.stringify(this.config));
|
||||||
|
if (data.message) {
|
||||||
|
this.message = data.message;
|
||||||
|
this.isError = data.is_error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Convert null optional fields to empty strings for form binding. */
|
||||||
|
prepareForEdit(config) {
|
||||||
|
for (const conn of config.connections) {
|
||||||
|
if (conn.nas_pass == null) conn.nas_pass = '';
|
||||||
|
if (conn.nas_key_file == null) conn.nas_key_file = '';
|
||||||
|
}
|
||||||
|
if (config.smb_auth.username == null) config.smb_auth.username = '';
|
||||||
|
if (config.smb_auth.smb_pass == null) config.smb_auth.smb_pass = '';
|
||||||
|
for (const rule of config.warmup.rules) {
|
||||||
|
if (rule.newer_than == null) rule.newer_than = '';
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Convert empty optional strings back to null for the API. */
|
||||||
|
prepareForSubmit(config) {
|
||||||
|
const c = JSON.parse(JSON.stringify(config));
|
||||||
|
for (const conn of c.connections) {
|
||||||
|
if (!conn.nas_pass) conn.nas_pass = null;
|
||||||
|
if (!conn.nas_key_file) conn.nas_key_file = null;
|
||||||
|
}
|
||||||
|
if (!c.smb_auth.username) c.smb_auth.username = null;
|
||||||
|
if (!c.smb_auth.smb_pass) c.smb_auth.smb_pass = null;
|
||||||
|
for (const rule of c.warmup.rules) {
|
||||||
|
if (!rule.newer_than) rule.newer_than = null;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
},
|
||||||
|
|
||||||
|
addConnection() {
|
||||||
|
this.config.connections.push({
|
||||||
|
name: '', nas_host: '', nas_user: '',
|
||||||
|
nas_pass: '', nas_key_file: '',
|
||||||
|
sftp_port: 22, sftp_connections: 8
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addShare() {
|
||||||
|
this.config.shares.push({
|
||||||
|
name: '',
|
||||||
|
connection: this.config.connections[0]?.name || '',
|
||||||
|
remote_path: '/',
|
||||||
|
mount_point: '/mnt/',
|
||||||
|
read_only: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addWarmupRule() {
|
||||||
|
this.config.warmup.rules.push({
|
||||||
|
share: this.config.shares[0]?.name || '',
|
||||||
|
path: '',
|
||||||
|
newer_than: ''
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitConfig() {
|
||||||
|
this.submitting = true;
|
||||||
|
this.message = null;
|
||||||
|
try {
|
||||||
|
const payload = this.prepareForSubmit(this.config);
|
||||||
|
const resp = await fetch('/config/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
this.message = result.message;
|
||||||
|
this.isError = !result.ok;
|
||||||
|
if (result.ok) {
|
||||||
|
this.originalConfig = JSON.parse(JSON.stringify(this.config));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.message = 'Network error: ' + e.message;
|
||||||
|
this.isError = true;
|
||||||
|
}
|
||||||
|
this.submitting = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetConfig() {
|
||||||
|
this.config = JSON.parse(JSON.stringify(this.originalConfig));
|
||||||
|
this.message = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Register with Alpine.data for robust htmx swap support.
|
||||||
|
On initial load: alpine:init fires before Alpine scans the DOM.
|
||||||
|
On htmx swap: Alpine is already loaded, register directly. */
|
||||||
|
if (window.Alpine) {
|
||||||
|
Alpine.data('configEditor', configEditorFn);
|
||||||
|
} else {
|
||||||
|
document.addEventListener('alpine:init', function() {
|
||||||
|
Alpine.data('configEditor', configEditorFn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div x-data="configEditor">
|
||||||
|
|
||||||
|
<!-- Message banner -->
|
||||||
|
<template x-if="message">
|
||||||
|
<div class="message" :class="isError ? 'message-error' : 'message-ok'" x-text="message"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ═══ Section: Connections ═══ -->
|
||||||
|
<section class="config-section">
|
||||||
|
<div class="section-header" @click="sections.connections = !sections.connections">
|
||||||
|
<h3>Connections <span class="tier-badge tier-pershare">Per-share restart</span></h3>
|
||||||
|
<span class="chevron" x-text="sections.connections ? '▾' : '▸'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body" x-show="sections.connections" x-transition>
|
||||||
|
<template x-for="(conn, i) in config.connections" :key="i">
|
||||||
|
<div class="array-item">
|
||||||
|
<div class="item-header">
|
||||||
|
<strong x-text="conn.name || 'New Connection'"></strong>
|
||||||
|
<button type="button" @click="config.connections.splice(i, 1)" class="remove-btn">Remove</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Name *</label>
|
||||||
|
<input type="text" x-model="conn.name" required placeholder="e.g. home-nas">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>NAS Host *</label>
|
||||||
|
<input type="text" x-model="conn.nas_host" required placeholder="e.g. 100.64.0.1">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Username *</label>
|
||||||
|
<input type="text" x-model="conn.nas_user" required placeholder="e.g. admin">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" x-model="conn.nas_pass" placeholder="(optional if using key)">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>SSH Key File</label>
|
||||||
|
<input type="text" x-model="conn.nas_key_file" class="mono" placeholder="/root/.ssh/id_rsa">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>SFTP Port</label>
|
||||||
|
<input type="number" x-model.number="conn.sftp_port" min="1" max="65535">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>SFTP Connections</label>
|
||||||
|
<input type="number" x-model.number="conn.sftp_connections" min="1" max="128">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button type="button" @click="addConnection()" class="add-btn">+ Add Connection</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Section: Shares ═══ -->
|
||||||
|
<section class="config-section">
|
||||||
|
<div class="section-header" @click="sections.shares = !sections.shares">
|
||||||
|
<h3>Shares <span class="tier-badge tier-pershare">Per-share restart</span></h3>
|
||||||
|
<span class="chevron" x-text="sections.shares ? '▾' : '▸'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body" x-show="sections.shares" x-transition>
|
||||||
|
<template x-for="(share, i) in config.shares" :key="i">
|
||||||
|
<div class="array-item">
|
||||||
|
<div class="item-header">
|
||||||
|
<strong x-text="share.name || 'New Share'"></strong>
|
||||||
|
<button type="button" @click="config.shares.splice(i, 1)" class="remove-btn">Remove</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Name *</label>
|
||||||
|
<input type="text" x-model="share.name" required placeholder="e.g. photos">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Connection *</label>
|
||||||
|
<select x-model="share.connection">
|
||||||
|
<template x-for="c in config.connections" :key="c.name">
|
||||||
|
<option :value="c.name" x-text="c.name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Remote Path *</label>
|
||||||
|
<input type="text" x-model="share.remote_path" class="mono" required placeholder="/volume1/photos">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Mount Point *</label>
|
||||||
|
<input type="text" x-model="share.mount_point" class="mono" required placeholder="/mnt/photos">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-row" style="margin-top:12px">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" x-model="share.read_only">
|
||||||
|
<span class="slider"></span>
|
||||||
|
Read Only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button type="button" @click="addShare()" class="add-btn">+ Add Share</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Section: Cache ═══ -->
|
||||||
|
<section class="config-section">
|
||||||
|
<div class="section-header" @click="sections.cache = !sections.cache">
|
||||||
|
<h3>Cache <span class="tier-badge tier-global">Full restart</span></h3>
|
||||||
|
<span class="chevron" x-text="sections.cache ? '▾' : '▸'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body" x-show="sections.cache" x-transition>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Cache Directory *</label>
|
||||||
|
<input type="text" x-model="config.cache.dir" class="mono" required placeholder="/mnt/ssd/cache">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Max Size</label>
|
||||||
|
<input type="text" x-model="config.cache.max_size" placeholder="e.g. 200G">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Max Age</label>
|
||||||
|
<input type="text" x-model="config.cache.max_age" placeholder="e.g. 720h">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Min Free Space</label>
|
||||||
|
<input type="text" x-model="config.cache.min_free" placeholder="e.g. 10G">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Section: Read Tuning ═══ -->
|
||||||
|
<section class="config-section">
|
||||||
|
<div class="section-header" @click="sections.read = !sections.read">
|
||||||
|
<h3>Read Tuning <span class="tier-badge tier-global">Full restart</span></h3>
|
||||||
|
<span class="chevron" x-text="sections.read ? '▾' : '▸'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body" x-show="sections.read" x-transition>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Chunk Size</label>
|
||||||
|
<input type="text" x-model="config.read.chunk_size" placeholder="e.g. 256M">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Chunk Limit</label>
|
||||||
|
<input type="text" x-model="config.read.chunk_limit" placeholder="e.g. 1G">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Read Ahead</label>
|
||||||
|
<input type="text" x-model="config.read.read_ahead" placeholder="e.g. 512M">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Buffer Size</label>
|
||||||
|
<input type="text" x-model="config.read.buffer_size" placeholder="e.g. 256M">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Section: Bandwidth ═══ -->
|
||||||
|
<section class="config-section">
|
||||||
|
<div class="section-header" @click="sections.bandwidth = !sections.bandwidth">
|
||||||
|
<h3>Bandwidth <span class="tier-badge tier-live">Live</span></h3>
|
||||||
|
<span class="chevron" x-text="sections.bandwidth ? '▾' : '▸'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body" x-show="sections.bandwidth" x-transition>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Upload Limit</label>
|
||||||
|
<input type="text" x-model="config.bandwidth.limit_up" placeholder="0 = unlimited, e.g. 10M">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Download Limit</label>
|
||||||
|
<input type="text" x-model="config.bandwidth.limit_down" placeholder="0 = unlimited, e.g. 50M">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-row" style="margin-top:12px">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" x-model="config.bandwidth.adaptive">
|
||||||
|
<span class="slider"></span>
|
||||||
|
Adaptive Throttling
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Section: Write-back ═══ -->
|
||||||
|
<section class="config-section">
|
||||||
|
<div class="section-header" @click="sections.writeback = !sections.writeback">
|
||||||
|
<h3>Write-back <span class="tier-badge tier-global">Full restart</span></h3>
|
||||||
|
<span class="chevron" x-text="sections.writeback ? '▾' : '▸'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body" x-show="sections.writeback" x-transition>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Write-back Delay</label>
|
||||||
|
<input type="text" x-model="config.writeback.write_back" placeholder="e.g. 5s">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Concurrent Transfers</label>
|
||||||
|
<input type="number" x-model.number="config.writeback.transfers" min="1" max="64">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Section: Directory Cache ═══ -->
|
||||||
|
<section class="config-section">
|
||||||
|
<div class="section-header" @click="sections.directory_cache = !sections.directory_cache">
|
||||||
|
<h3>Directory Cache <span class="tier-badge tier-global">Full restart</span></h3>
|
||||||
|
<span class="chevron" x-text="sections.directory_cache ? '▾' : '▸'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body" x-show="sections.directory_cache" x-transition>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Cache Time (TTL)</label>
|
||||||
|
<input type="text" x-model="config.directory_cache.cache_time" placeholder="e.g. 1h" style="max-width:300px">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Section: Protocols ═══ -->
|
||||||
|
<section class="config-section">
|
||||||
|
<div class="section-header" @click="sections.protocols = !sections.protocols">
|
||||||
|
<h3>Protocols <span class="tier-badge tier-protocol">Protocol restart</span></h3>
|
||||||
|
<span class="chevron" x-text="sections.protocols ? '▾' : '▸'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body" x-show="sections.protocols" x-transition>
|
||||||
|
<div class="field-row">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" x-model="config.protocols.enable_smb">
|
||||||
|
<span class="slider"></span>
|
||||||
|
Enable SMB (Samba)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-row" style="margin-top:12px">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" x-model="config.protocols.enable_nfs">
|
||||||
|
<span class="slider"></span>
|
||||||
|
Enable NFS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-row" x-show="config.protocols.enable_nfs" x-transition style="margin-top:12px">
|
||||||
|
<label>NFS Allowed Network</label>
|
||||||
|
<input type="text" x-model="config.protocols.nfs_allowed_network" placeholder="e.g. 192.168.0.0/24" style="max-width:300px">
|
||||||
|
</div>
|
||||||
|
<div class="field-row" style="margin-top:12px">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" x-model="config.protocols.enable_webdav">
|
||||||
|
<span class="slider"></span>
|
||||||
|
Enable WebDAV
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-row" x-show="config.protocols.enable_webdav" x-transition style="margin-top:12px">
|
||||||
|
<label>WebDAV Port</label>
|
||||||
|
<input type="number" x-model.number="config.protocols.webdav_port" min="1" max="65535" style="max-width:300px">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Section: SMB Auth ═══ -->
|
||||||
|
<section class="config-section">
|
||||||
|
<div class="section-header" @click="sections.smb_auth = !sections.smb_auth">
|
||||||
|
<h3>SMB Auth <span class="tier-badge tier-protocol">Protocol restart</span></h3>
|
||||||
|
<span class="chevron" x-text="sections.smb_auth ? '▾' : '▸'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body" x-show="sections.smb_auth" x-transition>
|
||||||
|
<div class="field-row">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" x-model="config.smb_auth.enabled">
|
||||||
|
<span class="slider"></span>
|
||||||
|
Enable SMB Authentication
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div x-show="config.smb_auth.enabled" x-transition>
|
||||||
|
<div class="field-grid" style="margin-top:12px">
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Username *</label>
|
||||||
|
<input type="text" x-model="config.smb_auth.username" placeholder="SMB username">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Password *</label>
|
||||||
|
<input type="password" x-model="config.smb_auth.smb_pass" placeholder="SMB password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Section: Warmup ═══ -->
|
||||||
|
<section class="config-section">
|
||||||
|
<div class="section-header" @click="sections.warmup = !sections.warmup">
|
||||||
|
<h3>Warmup <span class="tier-badge tier-none">No restart</span></h3>
|
||||||
|
<span class="chevron" x-text="sections.warmup ? '▾' : '▸'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body" x-show="sections.warmup" x-transition>
|
||||||
|
<div class="field-row">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" x-model="config.warmup.auto">
|
||||||
|
<span class="slider"></span>
|
||||||
|
Auto-warmup on Startup
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:16px">
|
||||||
|
<label style="font-size:0.85em;color:var(--text-muted);display:block;margin-bottom:8px">Warmup Rules</label>
|
||||||
|
<template x-for="(rule, i) in config.warmup.rules" :key="i">
|
||||||
|
<div class="array-item">
|
||||||
|
<div class="item-header">
|
||||||
|
<strong x-text="(rule.share || '?') + ':' + (rule.path || '/')"></strong>
|
||||||
|
<button type="button" @click="config.warmup.rules.splice(i, 1)" class="remove-btn">Remove</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Share *</label>
|
||||||
|
<select x-model="rule.share">
|
||||||
|
<template x-for="s in config.shares" :key="s.name">
|
||||||
|
<option :value="s.name" x-text="s.name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Path *</label>
|
||||||
|
<input type="text" x-model="rule.path" class="mono" placeholder="e.g. Images/2024">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Newer Than</label>
|
||||||
|
<input type="text" x-model="rule.newer_than" placeholder="e.g. 7d, 24h (optional)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button type="button" @click="addWarmupRule()" class="add-btn">+ Add Warmup Rule</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Form Actions ═══ -->
|
||||||
|
<div class="form-actions" style="margin-top:24px">
|
||||||
|
<button type="button" @click="submitConfig()" class="btn btn-primary" :disabled="submitting">
|
||||||
|
<span x-show="!submitting">Apply Config</span>
|
||||||
|
<span x-show="submitting">Applying...</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="resetConfig()" class="btn btn-secondary">Reset</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
65
templates/web/tabs/dashboard.html
Normal file
65
templates/web/tabs/dashboard.html
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<div id="dashboard-stats">
|
||||||
|
<div class="stat-cards">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Shares</div>
|
||||||
|
<div class="value">{{ healthy_count }} / {{ total_shares }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Cache</div>
|
||||||
|
<div class="value">{{ total_cache_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Speed</div>
|
||||||
|
<div class="value">{{ aggregate_speed_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Transfers</div>
|
||||||
|
<div class="value">{{ active_transfers }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="share-rows">
|
||||||
|
<div class="cards">
|
||||||
|
{% for share in shares %}
|
||||||
|
<div class="card" style="cursor:pointer"
|
||||||
|
hx-get="/tabs/shares?expand={{ share.name }}" hx-target="#tab-content" hx-swap="innerHTML"
|
||||||
|
@click="activeTab = 'shares'">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>{{ share.name }}</h2>
|
||||||
|
<div>
|
||||||
|
{% if share.health == "OK" %}
|
||||||
|
<span class="badge badge-ok">OK</span>
|
||||||
|
{% elif share.health == "FAILED" %}
|
||||||
|
<span class="badge badge-error" title="{{ share.health_message }}">FAILED</span>
|
||||||
|
{% elif share.health == "PROBING" %}
|
||||||
|
<span class="badge badge-warn">PROBING</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warn">PENDING</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>
|
||||||
|
{% if share.health == "FAILED" %}
|
||||||
|
<div class="error-msg">{{ share.health_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="protocol-badges">
|
||||||
|
<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>
|
||||||
84
templates/web/tabs/logs.html
Normal file
84
templates/web/tabs/logs.html
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<script>
|
||||||
|
function logViewerFn() {
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
nextId: 0,
|
||||||
|
polling: null,
|
||||||
|
autoScroll: true,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.fetchLogs();
|
||||||
|
this.polling = setInterval(() => this.fetchLogs(), 2000);
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.polling) clearInterval(this.polling);
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchLogs() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/logs?since=' + this.nextId);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.entries.length > 0) {
|
||||||
|
this.entries = this.entries.concat(data.entries);
|
||||||
|
// Keep client-side buffer reasonable
|
||||||
|
if (this.entries.length > 1000) {
|
||||||
|
this.entries = this.entries.slice(-500);
|
||||||
|
}
|
||||||
|
this.nextId = data.next_id;
|
||||||
|
if (this.autoScroll) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const el = this.$refs.logBox;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) { /* ignore fetch errors */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTime(ts) {
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
return d.toLocaleTimeString('en-GB', { hour12: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.entries = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.Alpine) {
|
||||||
|
Alpine.data('logViewer', logViewerFn);
|
||||||
|
} else {
|
||||||
|
document.addEventListener('alpine:init', function() {
|
||||||
|
Alpine.data('logViewer', logViewerFn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div x-data="logViewer" x-init="init()" @htmx:before-swap.window="destroy()">
|
||||||
|
<div class="log-toolbar">
|
||||||
|
<div>
|
||||||
|
<span class="log-count" x-text="entries.length + ' entries'"></span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:12px;align-items:center">
|
||||||
|
<label class="toggle" style="font-size:0.85em">
|
||||||
|
<input type="checkbox" x-model="autoScroll">
|
||||||
|
<span class="slider"></span>
|
||||||
|
Auto-scroll
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn btn-secondary" style="padding:4px 12px;font-size:0.8em" @click="clear()">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="log-viewer" x-ref="logBox">
|
||||||
|
<template x-if="entries.length === 0">
|
||||||
|
<div style="color:var(--text-muted);padding:24px;text-align:center">No log entries yet. Events will appear here as they occur.</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="entry in entries" :key="entry.id">
|
||||||
|
<div class="log-line">
|
||||||
|
<span class="log-ts" x-text="formatTime(entry.ts)"></span>
|
||||||
|
<span class="log-msg" x-text="entry.msg"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
79
templates/web/tabs/shares.html
Normal file
79
templates/web/tabs/shares.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<div x-data="{ expanded: new URLSearchParams(location.search).get('expand') || '{{ expand }}' }">
|
||||||
|
<table class="share-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Health</th>
|
||||||
|
<th>Mount</th>
|
||||||
|
<th>Cache</th>
|
||||||
|
<th>Dirty</th>
|
||||||
|
<th>Speed</th>
|
||||||
|
<th>Transfers</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for share in shares %}
|
||||||
|
<tr class="share-row" @click="expanded = expanded === '{{ share.name }}' ? '' : '{{ share.name }}'">
|
||||||
|
<td><strong>{{ share.name }}</strong></td>
|
||||||
|
<td>
|
||||||
|
{% if share.health == "OK" %}
|
||||||
|
<span class="badge badge-ok">OK</span>
|
||||||
|
{% elif share.health == "FAILED" %}
|
||||||
|
<span class="badge badge-error">FAILED</span>
|
||||||
|
{% elif share.health == "PROBING" %}
|
||||||
|
<span class="badge badge-warn">PROBING</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warn">PENDING</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if share.read_only %}
|
||||||
|
<span class="badge badge-ro">RO</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ share.mount_point }}</td>
|
||||||
|
<td>{{ share.cache_display }}</td>
|
||||||
|
<td>{{ share.dirty_count }}</td>
|
||||||
|
<td>{{ share.speed_display }}</td>
|
||||||
|
<td>{{ share.transfers }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr x-show="expanded === '{{ share.name }}'" x-transition x-cloak class="detail-row">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="detail-panel">
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="label">Cache Used</div>
|
||||||
|
<div class="value">{{ share.cache_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="label">Dirty Files</div>
|
||||||
|
<div class="value">{{ share.dirty_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="label">Transfer Speed</div>
|
||||||
|
<div class="value">{{ share.speed_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="label">Active Transfers</div>
|
||||||
|
<div class="value">{{ share.transfers }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="info-table">
|
||||||
|
<tr><td>Health</td><td>{{ share.health }}</td></tr>
|
||||||
|
{% if share.health == "FAILED" %}
|
||||||
|
<tr><td>Probe Error</td><td class="error-text">{{ share.health_message }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr><td>Connection</td><td class="mono">{{ share.connection }}</td></tr>
|
||||||
|
<tr><td>Mount Point</td><td class="mono">{{ share.mount_point }}</td></tr>
|
||||||
|
<tr><td>Remote Path</td><td class="mono">{{ share.remote_path }}</td></tr>
|
||||||
|
<tr><td>RC Port</td><td>{{ share.rc_port }}</td></tr>
|
||||||
|
<tr><td>Errored Files</td><td>{{ share.errored_files }}</td></tr>
|
||||||
|
<tr><td>Total Errors</td><td>{{ share.errors }}</td></tr>
|
||||||
|
<tr><td>Mounted</td><td>{% if share.mounted %}Yes{% else %}No{% endif %}</td></tr>
|
||||||
|
<tr><td>Read-Only</td><td>{% if share.read_only %}Yes{% else %}No{% endif %}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@ -6,9 +6,9 @@ source "$SCRIPT_DIR/../harness/helpers.sh"
|
|||||||
setup_test_env
|
setup_test_env
|
||||||
trap teardown_test_env EXIT
|
trap teardown_test_env EXIT
|
||||||
|
|
||||||
# Generate config with only the required fields (connection.nas_host,
|
# Generate config with only the required fields (connections[].name,
|
||||||
# connection.nas_user, connection.remote_path, cache.dir). All other
|
# connections[].nas_host, connections[].nas_user, cache.dir, shares[].connection).
|
||||||
# fields should be filled in by the binary's defaults.
|
# All other fields should be filled in by the binary's defaults.
|
||||||
source "$HARNESS_DIR/config-gen.sh"
|
source "$HARNESS_DIR/config-gen.sh"
|
||||||
_gen_minimal_config
|
_gen_minimal_config
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,9 @@
|
|||||||
# Test: VFS cache stores files at the expected filesystem path
|
# Test: VFS cache stores files at the expected filesystem path
|
||||||
#
|
#
|
||||||
# Verifies that when a file is read through the FUSE mount, it appears
|
# Verifies that when a file is read through the FUSE mount, it appears
|
||||||
# at $CACHE_DIR/vfs/nas/FILENAME — the exact path that warmup's
|
# at $CACHE_DIR/vfs/<connection_name>/FILENAME — the exact path that warmup's
|
||||||
# is_cached logic checks to decide whether to skip a file.
|
# is_cached logic checks to decide whether to skip a file.
|
||||||
|
# The default connection name is "nas".
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
source "$SCRIPT_DIR/../harness/helpers.sh"
|
source "$SCRIPT_DIR/../harness/helpers.sh"
|
||||||
|
|||||||
@ -16,6 +16,7 @@ _gen_config() {
|
|||||||
local config_file="${TEST_CONFIG:-$TEST_DIR/config.toml}"
|
local config_file="${TEST_CONFIG:-$TEST_DIR/config.toml}"
|
||||||
|
|
||||||
# Defaults pointing at mock NAS
|
# Defaults pointing at mock NAS
|
||||||
|
local conn_name="${TEST_CONN_NAME:-nas}"
|
||||||
local nas_host="${MOCK_NAS_IP:-10.99.0.2}"
|
local nas_host="${MOCK_NAS_IP:-10.99.0.2}"
|
||||||
local nas_user="root"
|
local nas_user="root"
|
||||||
local nas_key_file="${TEST_SSH_KEY:-$TEST_DIR/test_key}"
|
local nas_key_file="${TEST_SSH_KEY:-$TEST_DIR/test_key}"
|
||||||
@ -53,7 +54,6 @@ _gen_config() {
|
|||||||
local smb_auth_enabled="false"
|
local smb_auth_enabled="false"
|
||||||
local smb_auth_username=""
|
local smb_auth_username=""
|
||||||
local smb_auth_smb_pass=""
|
local smb_auth_smb_pass=""
|
||||||
local smb_auth_reuse_nas_pass="false"
|
|
||||||
|
|
||||||
# Default share: single share at /
|
# Default share: single share at /
|
||||||
local share_name="${TEST_SHARE_NAME:-data}"
|
local share_name="${TEST_SHARE_NAME:-data}"
|
||||||
@ -67,6 +67,7 @@ _gen_config() {
|
|||||||
local value="${override#*=}"
|
local value="${override#*=}"
|
||||||
|
|
||||||
case "$key" in
|
case "$key" in
|
||||||
|
connection.name|conn_name) conn_name="$value" ;;
|
||||||
connection.nas_host|nas_host) nas_host="$value" ;;
|
connection.nas_host|nas_host) nas_host="$value" ;;
|
||||||
connection.nas_user|nas_user) nas_user="$value" ;;
|
connection.nas_user|nas_user) nas_user="$value" ;;
|
||||||
connection.nas_key_file|nas_key_file) nas_key_file="$value" ;;
|
connection.nas_key_file|nas_key_file) nas_key_file="$value" ;;
|
||||||
@ -96,7 +97,6 @@ _gen_config() {
|
|||||||
smb_auth.enabled|smb_auth_enabled) smb_auth_enabled="$value" ;;
|
smb_auth.enabled|smb_auth_enabled) smb_auth_enabled="$value" ;;
|
||||||
smb_auth.username|smb_auth_username) smb_auth_username="$value" ;;
|
smb_auth.username|smb_auth_username) smb_auth_username="$value" ;;
|
||||||
smb_auth.smb_pass|smb_auth_smb_pass) smb_auth_smb_pass="$value" ;;
|
smb_auth.smb_pass|smb_auth_smb_pass) smb_auth_smb_pass="$value" ;;
|
||||||
smb_auth.reuse_nas_pass|smb_auth_reuse_nas_pass) smb_auth_reuse_nas_pass="$value" ;;
|
|
||||||
share.name|share_name) share_name="$value" ;;
|
share.name|share_name) share_name="$value" ;;
|
||||||
share.remote_path|share_remote_path) share_remote_path="$value" ;;
|
share.remote_path|share_remote_path) share_remote_path="$value" ;;
|
||||||
share.mount_point|share_mount_point) share_mount_point="$value" ;;
|
share.mount_point|share_mount_point) share_mount_point="$value" ;;
|
||||||
@ -106,7 +106,8 @@ _gen_config() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
cat > "$config_file" <<CONFIG_EOF
|
cat > "$config_file" <<CONFIG_EOF
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "$conn_name"
|
||||||
nas_host = "$nas_host"
|
nas_host = "$nas_host"
|
||||||
nas_user = "$nas_user"
|
nas_user = "$nas_user"
|
||||||
nas_key_file = "$nas_key_file"
|
nas_key_file = "$nas_key_file"
|
||||||
@ -161,9 +162,6 @@ SMB_AUTH_EOF
|
|||||||
if [[ -n "$smb_auth_smb_pass" ]]; then
|
if [[ -n "$smb_auth_smb_pass" ]]; then
|
||||||
echo "smb_pass = \"$smb_auth_smb_pass\"" >> "$config_file"
|
echo "smb_pass = \"$smb_auth_smb_pass\"" >> "$config_file"
|
||||||
fi
|
fi
|
||||||
if [[ "$smb_auth_reuse_nas_pass" == "true" ]]; then
|
|
||||||
echo "reuse_nas_pass = true" >> "$config_file"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Append shares config — use override or default single share
|
# Append shares config — use override or default single share
|
||||||
@ -175,6 +173,7 @@ SMB_AUTH_EOF
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "$share_name"
|
name = "$share_name"
|
||||||
|
connection = "$conn_name"
|
||||||
remote_path = "$share_remote_path"
|
remote_path = "$share_remote_path"
|
||||||
mount_point = "$share_mount_point"
|
mount_point = "$share_mount_point"
|
||||||
SHARES_EOF
|
SHARES_EOF
|
||||||
@ -192,9 +191,11 @@ SHARES_EOF
|
|||||||
# Generate a minimal config (only required fields)
|
# Generate a minimal config (only required fields)
|
||||||
_gen_minimal_config() {
|
_gen_minimal_config() {
|
||||||
local config_file="${TEST_CONFIG:-$TEST_DIR/config.toml}"
|
local config_file="${TEST_CONFIG:-$TEST_DIR/config.toml}"
|
||||||
|
local conn_name="${TEST_CONN_NAME:-nas}"
|
||||||
|
|
||||||
cat > "$config_file" <<CONFIG_EOF
|
cat > "$config_file" <<CONFIG_EOF
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "$conn_name"
|
||||||
nas_host = "${MOCK_NAS_IP:-10.99.0.2}"
|
nas_host = "${MOCK_NAS_IP:-10.99.0.2}"
|
||||||
nas_user = "root"
|
nas_user = "root"
|
||||||
nas_key_file = "${TEST_SSH_KEY:-$TEST_DIR/test_key}"
|
nas_key_file = "${TEST_SSH_KEY:-$TEST_DIR/test_key}"
|
||||||
@ -204,6 +205,7 @@ dir = "${CACHE_DIR:-$TEST_DIR/cache}"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "data"
|
name = "data"
|
||||||
|
connection = "$conn_name"
|
||||||
remote_path = "/"
|
remote_path = "/"
|
||||||
mount_point = "${TEST_MOUNT:-$TEST_DIR/mnt}"
|
mount_point = "${TEST_MOUNT:-$TEST_DIR/mnt}"
|
||||||
CONFIG_EOF
|
CONFIG_EOF
|
||||||
@ -220,7 +222,8 @@ _gen_broken_config() {
|
|||||||
missing_field)
|
missing_field)
|
||||||
# Missing nas_host
|
# Missing nas_host
|
||||||
cat > "$config_file" <<CONFIG_EOF
|
cat > "$config_file" <<CONFIG_EOF
|
||||||
[connection]
|
[[connections]]
|
||||||
|
name = "nas"
|
||||||
nas_user = "root"
|
nas_user = "root"
|
||||||
|
|
||||||
[cache]
|
[cache]
|
||||||
@ -228,14 +231,15 @@ dir = "/tmp/cache"
|
|||||||
|
|
||||||
[[shares]]
|
[[shares]]
|
||||||
name = "data"
|
name = "data"
|
||||||
|
connection = "nas"
|
||||||
remote_path = "/"
|
remote_path = "/"
|
||||||
mount_point = "/tmp/mnt"
|
mount_point = "/tmp/mnt"
|
||||||
CONFIG_EOF
|
CONFIG_EOF
|
||||||
;;
|
;;
|
||||||
bad_toml)
|
bad_toml)
|
||||||
cat > "$config_file" <<CONFIG_EOF
|
cat > "$config_file" <<CONFIG_EOF
|
||||||
[connection
|
[[connections
|
||||||
nas_host = "broken toml
|
name = "broken toml
|
||||||
CONFIG_EOF
|
CONFIG_EOF
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user