diff --git a/Cargo.lock b/Cargo.lock index d3bb7f2..a75afc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -414,6 +414,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + [[package]] name = "futures-task" version = "0.3.32" @@ -1121,6 +1127,31 @@ dependencies = [ "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]] name = "toml" version = "1.0.2+spec-1.1.0" @@ -1316,6 +1347,7 @@ dependencies = [ "serde_json", "thiserror", "tokio", + "tokio-stream", "toml", "tower-http", "ureq", diff --git a/Cargo.toml b/Cargo.toml index 41ca0f8..4395abf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,8 @@ toml = "1.0.2" ctrlc = "3.4" libc = "0.2" ureq = { version = "3.2.0", features = ["json"] } -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] } +tokio-stream = { version = "0.1", features = ["sync"] } axum = "0.8" askama = "0.15" tower-http = { version = "0.6", features = ["cors"] } diff --git a/src/cli/speed_test.rs b/src/cli/speed_test.rs index 5edcaf8..7f4490c 100644 --- a/src/cli/speed_test.rs +++ b/src/cli/speed_test.rs @@ -13,10 +13,11 @@ const TEST_SIZE: usize = 10 * 1024 * 1024; // 10 MiB pub fn run(config: &Config) -> Result<()> { 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!( - "nas:{}/.warpgate-speedtest", - config.shares[0].remote_path + "{}:{}/.warpgate-speedtest", + share.connection, share.remote_path ); // Create a 10 MiB test file diff --git a/src/cli/warmup.rs b/src/cli/warmup.rs index 83f25ee..a7dc9fc 100644 --- a/src/cli/warmup.rs +++ b/src/cli/warmup.rs @@ -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))?; 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!(" 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; 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; 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. -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 .cache .dir .join("vfs") - .join("nas") + .join(connection) .join(remote_path.trim_start_matches('/')) .join(warmup_path) .join(relative_path); @@ -116,7 +116,8 @@ mod tests { fn test_config() -> Config { toml::from_str( r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -131,6 +132,7 @@ dir = "/tmp/warpgate-test-cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#, @@ -141,13 +143,13 @@ mount_point = "/mnt/photos" #[test] fn test_is_cached_nonexistent_file() { let config = test_config(); - assert!(!is_cached(&config, "/photos", "2024", "IMG_001.jpg")); + assert!(!is_cached(&config, "nas", "/photos", "2024", "IMG_001.jpg")); } #[test] fn test_is_cached_deep_path() { 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] @@ -176,17 +178,18 @@ mount_point = "/mnt/photos" fn test_is_cached_remote_path_trimming() { let config = test_config(); + let connection = "home"; let remote_path = "/volume1/photos"; let cache_path = config .cache .dir .join("vfs") - .join("nas") + .join(connection) .join(remote_path.trim_start_matches('/')) .join("2024") .join("file.jpg"); - assert!(cache_path.to_string_lossy().contains("nas/volume1/photos")); - assert!(!cache_path.to_string_lossy().contains("nas//volume1")); + assert!(cache_path.to_string_lossy().contains("home/volume1/photos")); + assert!(!cache_path.to_string_lossy().contains("home//volume1")); } } diff --git a/src/config.rs b/src/config.rs index 9a0d405..60f8326 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,7 +17,7 @@ pub const RC_BASE_PORT: u16 = 5572; /// Top-level configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { - pub connection: ConnectionConfig, + pub connections: Vec, pub cache: CacheConfig, pub read: ReadConfig, pub bandwidth: BandwidthConfig, @@ -31,9 +31,11 @@ pub struct Config { pub shares: Vec, } -/// SFTP connection to remote NAS. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// SFTP connection to a remote NAS. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ConnectionConfig { + /// Unique name for this connection (used as rclone remote name). + pub name: String, /// Remote NAS Tailscale IP or hostname. pub nas_host: String, /// SFTP username. @@ -175,15 +177,12 @@ pub struct SmbAuthConfig { /// Enable SMB user authentication. #[serde(default)] pub enabled: bool, - /// SMB username (defaults to connection.nas_user if unset). + /// SMB username (required when enabled). #[serde(default)] pub username: Option, - /// Dedicated SMB password (takes precedence over reuse_nas_pass). + /// SMB password (required when enabled). #[serde(default)] pub smb_pass: Option, - /// Reuse connection.nas_pass as the SMB password. - #[serde(default)] - pub reuse_nas_pass: bool, } /// A single share exported as SMB/NFS, each with its own rclone mount. @@ -191,6 +190,8 @@ pub struct SmbAuthConfig { pub struct ShareConfig { /// SMB/NFS share name. pub name: String, + /// Which NAS connection this share uses (references connections[].name). + pub connection: String, /// Absolute path on the remote NAS (e.g. "/volume1/photos"). pub remote_path: String, /// Local FUSE mount point (e.g. "/mnt/photos"). @@ -272,48 +273,195 @@ impl Config { self.shares.iter().find(|s| s.name == name) } + /// Find a connection by name. + pub fn find_connection(&self, name: &str) -> Option<&ConnectionConfig> { + self.connections.iter().find(|c| c.name == name) + } + + /// Get the connection config for a given share. + pub fn connection_for_share(&self, share: &ShareConfig) -> Option<&ConnectionConfig> { + self.find_connection(&share.connection) + } + /// Return the RC API port for a given share index. pub fn rc_port(&self, share_index: usize) -> u16 { RC_BASE_PORT + share_index as u16 } - /// Effective SMB username. Falls back to `connection.nas_user`. - pub fn smb_username(&self) -> &str { - self.smb_auth - .username - .as_deref() - .unwrap_or(&self.connection.nas_user) + /// Serialize config to human-readable TOML with section comments. + /// + /// Unlike `toml::to_string_pretty`, this produces output that mirrors + /// the template format with section headers, grouped fields, and comments + /// indicating change tiers. + pub fn to_commented_toml(&self) -> String { + use std::fmt::Write; + let mut out = String::new(); + + writeln!(out, "# Warpgate Configuration").unwrap(); + writeln!(out, "# Generated by the Warpgate web UI").unwrap(); + writeln!(out).unwrap(); + + // --- Connections --- + writeln!(out, "# --- NAS Connections ---").unwrap(); + for conn in &self.connections { + writeln!(out, "[[connections]]").unwrap(); + writeln!(out, "name = {:?}", conn.name).unwrap(); + writeln!(out, "nas_host = {:?}", conn.nas_host).unwrap(); + writeln!(out, "nas_user = {:?}", conn.nas_user).unwrap(); + if let Some(ref pass) = conn.nas_pass { + writeln!(out, "nas_pass = {:?}", pass).unwrap(); + } + if let Some(ref key) = conn.nas_key_file { + writeln!(out, "nas_key_file = {:?}", key).unwrap(); + } + writeln!(out, "sftp_port = {}", conn.sftp_port).unwrap(); + writeln!(out, "sftp_connections = {}", conn.sftp_connections).unwrap(); + writeln!(out).unwrap(); + } + + // --- Cache --- + writeln!(out, "# --- Cache (change = full restart) ---").unwrap(); + writeln!(out, "[cache]").unwrap(); + writeln!(out, "dir = {:?}", self.cache.dir.display().to_string()).unwrap(); + writeln!(out, "max_size = {:?}", self.cache.max_size).unwrap(); + writeln!(out, "max_age = {:?}", self.cache.max_age).unwrap(); + writeln!(out, "min_free = {:?}", self.cache.min_free).unwrap(); + writeln!(out).unwrap(); + + // --- Read --- + writeln!(out, "# --- Read Tuning (change = full restart) ---").unwrap(); + writeln!(out, "[read]").unwrap(); + writeln!(out, "chunk_size = {:?}", self.read.chunk_size).unwrap(); + writeln!(out, "chunk_limit = {:?}", self.read.chunk_limit).unwrap(); + writeln!(out, "read_ahead = {:?}", self.read.read_ahead).unwrap(); + writeln!(out, "buffer_size = {:?}", self.read.buffer_size).unwrap(); + writeln!(out).unwrap(); + + // --- Bandwidth --- + writeln!(out, "# --- Bandwidth (change = live, no restart) ---").unwrap(); + writeln!(out, "[bandwidth]").unwrap(); + writeln!(out, "limit_up = {:?}", self.bandwidth.limit_up).unwrap(); + writeln!(out, "limit_down = {:?}", self.bandwidth.limit_down).unwrap(); + writeln!(out, "adaptive = {}", self.bandwidth.adaptive).unwrap(); + writeln!(out).unwrap(); + + // --- Writeback --- + writeln!(out, "# --- Write-back (change = full restart) ---").unwrap(); + writeln!(out, "[writeback]").unwrap(); + writeln!(out, "write_back = {:?}", self.writeback.write_back).unwrap(); + writeln!(out, "transfers = {}", self.writeback.transfers).unwrap(); + writeln!(out).unwrap(); + + // --- Directory Cache --- + writeln!(out, "# --- Directory Cache (change = full restart) ---").unwrap(); + writeln!(out, "[directory_cache]").unwrap(); + writeln!(out, "cache_time = {:?}", self.directory_cache.cache_time).unwrap(); + writeln!(out).unwrap(); + + // --- Protocols --- + writeln!(out, "# --- Protocols (change = protocol restart) ---").unwrap(); + writeln!(out, "[protocols]").unwrap(); + writeln!(out, "enable_smb = {}", self.protocols.enable_smb).unwrap(); + writeln!(out, "enable_nfs = {}", self.protocols.enable_nfs).unwrap(); + writeln!(out, "enable_webdav = {}", self.protocols.enable_webdav).unwrap(); + writeln!(out, "nfs_allowed_network = {:?}", self.protocols.nfs_allowed_network).unwrap(); + writeln!(out, "webdav_port = {}", self.protocols.webdav_port).unwrap(); + writeln!(out).unwrap(); + + // --- SMB Auth --- + writeln!(out, "# --- SMB Auth (change = protocol restart) ---").unwrap(); + writeln!(out, "[smb_auth]").unwrap(); + writeln!(out, "enabled = {}", self.smb_auth.enabled).unwrap(); + if let Some(ref u) = self.smb_auth.username { + writeln!(out, "username = {:?}", u).unwrap(); + } + if let Some(ref p) = self.smb_auth.smb_pass { + writeln!(out, "smb_pass = {:?}", p).unwrap(); + } + writeln!(out).unwrap(); + + // --- Shares --- + writeln!(out, "# --- Shares (change = per-share restart) ---").unwrap(); + for share in &self.shares { + writeln!(out, "[[shares]]").unwrap(); + writeln!(out, "name = {:?}", share.name).unwrap(); + writeln!(out, "connection = {:?}", share.connection).unwrap(); + writeln!(out, "remote_path = {:?}", share.remote_path).unwrap(); + writeln!(out, "mount_point = {:?}", share.mount_point.display().to_string()).unwrap(); + if share.read_only { + writeln!(out, "read_only = true").unwrap(); + } + writeln!(out).unwrap(); + } + + // --- Warmup --- + writeln!(out, "# --- Warmup (change = no restart) ---").unwrap(); + writeln!(out, "[warmup]").unwrap(); + writeln!(out, "auto = {}", self.warmup.auto).unwrap(); + writeln!(out).unwrap(); + for rule in &self.warmup.rules { + writeln!(out, "[[warmup.rules]]").unwrap(); + writeln!(out, "share = {:?}", rule.share).unwrap(); + writeln!(out, "path = {:?}", rule.path).unwrap(); + if let Some(ref nt) = rule.newer_than { + writeln!(out, "newer_than = {:?}", nt).unwrap(); + } + writeln!(out).unwrap(); + } + + out + } + + /// Effective SMB username. Required when smb_auth is enabled. + pub fn smb_username(&self) -> Option<&str> { + self.smb_auth.username.as_deref() } /// Resolve the SMB password. Returns `None` when auth is disabled. - /// Returns an error if auth is enabled but no password can be resolved. + /// Returns an error if auth is enabled but no password is set. pub fn smb_password(&self) -> Result> { if !self.smb_auth.enabled { return Ok(None); } - // Dedicated smb_pass takes precedence if let Some(ref pass) = self.smb_auth.smb_pass { return Ok(Some(pass.clone())); } - // Fallback: reuse NAS password - if self.smb_auth.reuse_nas_pass { - if let Some(ref pass) = self.connection.nas_pass { - return Ok(Some(pass.clone())); - } - anyhow::bail!( - "smb_auth.reuse_nas_pass is true but connection.nas_pass is not set" - ); - } - anyhow::bail!( - "smb_auth is enabled but no password configured (set smb_pass or reuse_nas_pass)" + "smb_auth is enabled but smb_pass is not set" ); } /// Validate configuration invariants. pub fn validate(&self) -> Result<()> { + // At least one connection required + if self.connections.is_empty() { + anyhow::bail!("At least one [[connections]] entry is required"); + } + + // Validate connection names + let mut seen_conn_names = std::collections::HashSet::new(); + for (i, conn) in self.connections.iter().enumerate() { + if conn.name.is_empty() { + anyhow::bail!("connections[{}]: name must not be empty", i); + } + if !is_valid_remote_name(&conn.name) { + anyhow::bail!( + "connections[{}]: name '{}' is invalid (use alphanumeric, hyphens, underscores only)", + i, + conn.name + ); + } + if !seen_conn_names.insert(&conn.name) { + anyhow::bail!( + "connections[{}]: duplicate connection name '{}'", + i, + conn.name + ); + } + } + // At least one share required if self.shares.is_empty() { anyhow::bail!("At least one [[shares]] entry is required"); @@ -350,6 +498,14 @@ impl Config { share.mount_point.display() ); } + // Validate connection reference + if self.find_connection(&share.connection).is_none() { + anyhow::bail!( + "shares[{}]: connection '{}' does not exist in [[connections]]", + i, + share.connection + ); + } } // Validate warmup rules reference existing shares @@ -363,8 +519,11 @@ impl Config { } } - // Validate SMB auth password resolution + // Validate SMB auth if self.smb_auth.enabled { + if self.smb_auth.username.is_none() { + anyhow::bail!("smb_auth is enabled but username is not set"); + } self.smb_password()?; } @@ -372,13 +531,23 @@ impl Config { } } +/// Check if a name is valid for use as an rclone remote name. +/// Allows alphanumeric characters, hyphens, and underscores. +fn is_valid_remote_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') +} + #[cfg(test)] mod tests { use super::*; fn minimal_toml() -> &'static str { r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -393,6 +562,7 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "# @@ -402,12 +572,14 @@ mount_point = "/mnt/photos" fn test_config_load_minimal_defaults() { let config: Config = toml::from_str(minimal_toml()).unwrap(); - assert_eq!(config.connection.nas_host, "10.0.0.1"); - assert_eq!(config.connection.nas_user, "admin"); - assert_eq!(config.connection.sftp_port, 22); - assert_eq!(config.connection.sftp_connections, 8); - assert!(config.connection.nas_pass.is_none()); - assert!(config.connection.nas_key_file.is_none()); + assert_eq!(config.connections.len(), 1); + assert_eq!(config.connections[0].name, "nas"); + assert_eq!(config.connections[0].nas_host, "10.0.0.1"); + assert_eq!(config.connections[0].nas_user, "admin"); + assert_eq!(config.connections[0].sftp_port, 22); + assert_eq!(config.connections[0].sftp_connections, 8); + assert!(config.connections[0].nas_pass.is_none()); + assert!(config.connections[0].nas_key_file.is_none()); assert_eq!(config.cache.dir, PathBuf::from("/tmp/cache")); assert_eq!(config.cache.max_size, "200G"); @@ -436,6 +608,7 @@ mount_point = "/mnt/photos" assert_eq!(config.shares.len(), 1); assert_eq!(config.shares[0].name, "photos"); + assert_eq!(config.shares[0].connection, "nas"); assert_eq!(config.shares[0].remote_path, "/photos"); assert_eq!(config.shares[0].mount_point, PathBuf::from("/mnt/photos")); @@ -446,7 +619,8 @@ mount_point = "/mnt/photos" #[test] fn test_config_full_toml() { let toml_str = r#" -[connection] +[[connections]] +name = "home" nas_host = "192.168.1.100" nas_user = "photographer" nas_pass = "secret123" @@ -487,11 +661,13 @@ webdav_port = 9090 [[shares]] name = "photos" +connection = "home" remote_path = "/volume1/photos" mount_point = "/mnt/photos" [[shares]] name = "projects" +connection = "home" remote_path = "/volume1/projects" mount_point = "/mnt/projects" @@ -505,15 +681,16 @@ newer_than = "7d" "#; let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.connection.nas_host, "192.168.1.100"); - assert_eq!(config.connection.nas_user, "photographer"); - assert_eq!(config.connection.nas_pass.as_deref(), Some("secret123")); + assert_eq!(config.connections[0].name, "home"); + assert_eq!(config.connections[0].nas_host, "192.168.1.100"); + assert_eq!(config.connections[0].nas_user, "photographer"); + assert_eq!(config.connections[0].nas_pass.as_deref(), Some("secret123")); assert_eq!( - config.connection.nas_key_file.as_deref(), + config.connections[0].nas_key_file.as_deref(), Some("/root/.ssh/id_rsa") ); - assert_eq!(config.connection.sftp_port, 2222); - assert_eq!(config.connection.sftp_connections, 16); + assert_eq!(config.connections[0].sftp_port, 2222); + assert_eq!(config.connections[0].sftp_connections, 16); assert_eq!(config.cache.max_size, "500G"); assert_eq!(config.cache.max_age, "1440h"); @@ -538,6 +715,7 @@ newer_than = "7d" assert_eq!(config.shares.len(), 2); assert_eq!(config.shares[0].remote_path, "/volume1/photos"); assert_eq!(config.shares[0].mount_point, PathBuf::from("/mnt/photos")); + assert_eq!(config.shares[0].connection, "home"); assert!(!config.warmup.auto); assert_eq!(config.warmup.rules.len(), 1); @@ -546,10 +724,68 @@ newer_than = "7d" assert_eq!(config.warmup.rules[0].newer_than.as_deref(), Some("7d")); } + #[test] + fn test_config_multi_nas() { + let toml_str = r#" +[[connections]] +name = "home" +nas_host = "10.0.0.1" +nas_user = "admin" +nas_key_file = "/root/.ssh/id_rsa" + +[[connections]] +name = "office" +nas_host = "192.168.1.100" +nas_user = "photographer" +nas_pass = "secret" +sftp_port = 2222 + +[cache] +dir = "/tmp/cache" + +[read] +[bandwidth] +[writeback] +[directory_cache] +[protocols] + +[[shares]] +name = "photos" +connection = "home" +remote_path = "/volume1/photos" +mount_point = "/mnt/photos" + +[[shares]] +name = "projects" +connection = "office" +remote_path = "/data/projects" +mount_point = "/mnt/projects" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + config.validate().unwrap(); + + assert_eq!(config.connections.len(), 2); + assert_eq!(config.connections[0].name, "home"); + assert_eq!(config.connections[1].name, "office"); + assert_eq!(config.connections[1].sftp_port, 2222); + + assert_eq!(config.shares[0].connection, "home"); + assert_eq!(config.shares[1].connection, "office"); + + assert!(config.find_connection("home").is_some()); + assert!(config.find_connection("office").is_some()); + assert!(config.find_connection("nonexistent").is_none()); + + let share = &config.shares[0]; + let conn = config.connection_for_share(share).unwrap(); + assert_eq!(conn.nas_host, "10.0.0.1"); + } + #[test] fn test_config_missing_required_field() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_user = "admin" [cache] @@ -563,6 +799,7 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#; @@ -584,7 +821,8 @@ mount_point = "/mnt/photos" #[test] fn test_config_extreme_values() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" sftp_connections = 999 @@ -601,18 +839,20 @@ max_size = "999T" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#; let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.connection.sftp_connections, 999); + assert_eq!(config.connections[0].sftp_connections, 999); assert_eq!(config.cache.max_size, "999T"); } #[test] fn test_config_missing_cache_section() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -624,6 +864,7 @@ nas_user = "admin" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#; @@ -636,11 +877,128 @@ mount_point = "/mnt/photos" let config: Config = toml::from_str(minimal_toml()).unwrap(); let serialized = toml::to_string(&config).unwrap(); let config2: Config = toml::from_str(&serialized).unwrap(); - assert_eq!(config.connection.nas_host, config2.connection.nas_host); + assert_eq!(config.connections[0].nas_host, config2.connections[0].nas_host); assert_eq!(config.cache.max_size, config2.cache.max_size); assert_eq!(config.writeback.transfers, config2.writeback.transfers); } + #[test] + fn test_commented_toml_roundtrip() { + let config: Config = toml::from_str(minimal_toml()).unwrap(); + let commented = config.to_commented_toml(); + // Must parse back into the same config + let config2: Config = toml::from_str(&commented).unwrap(); + config2.validate().unwrap(); + assert_eq!(config.connections[0].name, config2.connections[0].name); + assert_eq!(config.connections[0].nas_host, config2.connections[0].nas_host); + assert_eq!(config.cache.dir, config2.cache.dir); + assert_eq!(config.cache.max_size, config2.cache.max_size); + assert_eq!(config.read.chunk_size, config2.read.chunk_size); + assert_eq!(config.bandwidth.adaptive, config2.bandwidth.adaptive); + assert_eq!(config.writeback.transfers, config2.writeback.transfers); + assert_eq!(config.protocols.enable_smb, config2.protocols.enable_smb); + assert_eq!(config.shares[0].name, config2.shares[0].name); + assert_eq!(config.shares[0].connection, config2.shares[0].connection); + } + + #[test] + fn test_commented_toml_full_roundtrip() { + let toml_str = r#" +[[connections]] +name = "home" +nas_host = "192.168.1.100" +nas_user = "photographer" +nas_pass = "secret123" +nas_key_file = "/root/.ssh/id_rsa" +sftp_port = 2222 +sftp_connections = 16 + +[cache] +dir = "/mnt/ssd/cache" +max_size = "500G" +max_age = "1440h" +min_free = "20G" + +[read] +chunk_size = "512M" +chunk_limit = "2G" +read_ahead = "1G" +buffer_size = "512M" + +[bandwidth] +limit_up = "10M" +limit_down = "50M" +adaptive = false + +[writeback] +write_back = "10s" +transfers = 8 + +[directory_cache] +cache_time = "30m" + +[protocols] +enable_smb = true +enable_nfs = true +enable_webdav = true +nfs_allowed_network = "10.0.0.0/8" +webdav_port = 9090 + +[smb_auth] +enabled = true +username = "photographer" +smb_pass = "my-password" + +[[shares]] +name = "photos" +connection = "home" +remote_path = "/volume1/photos" +mount_point = "/mnt/photos" + +[[shares]] +name = "backups" +connection = "home" +remote_path = "/volume1/backups" +mount_point = "/mnt/backups" +read_only = true + +[warmup] +auto = true + +[[warmup.rules]] +share = "photos" +path = "2024" +newer_than = "7d" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let commented = config.to_commented_toml(); + let config2: Config = toml::from_str(&commented).unwrap(); + config2.validate().unwrap(); + + // All fields should survive the round-trip + assert_eq!(config.connections[0].nas_pass, config2.connections[0].nas_pass); + assert_eq!(config.connections[0].nas_key_file, config2.connections[0].nas_key_file); + assert_eq!(config.connections[0].sftp_port, config2.connections[0].sftp_port); + assert_eq!(config.smb_auth.enabled, config2.smb_auth.enabled); + assert_eq!(config.smb_auth.username, config2.smb_auth.username); + assert_eq!(config.smb_auth.smb_pass, config2.smb_auth.smb_pass); + assert_eq!(config.shares.len(), config2.shares.len()); + assert!(config2.shares[1].read_only); + assert_eq!(config.warmup.rules.len(), config2.warmup.rules.len()); + assert_eq!(config.warmup.rules[0].share, config2.warmup.rules[0].share); + assert_eq!(config.warmup.rules[0].newer_than, config2.warmup.rules[0].newer_than); + } + + #[test] + fn test_commented_toml_has_comments() { + let config: Config = toml::from_str(minimal_toml()).unwrap(); + let commented = config.to_commented_toml(); + assert!(commented.contains("# --- NAS Connections ---")); + assert!(commented.contains("# --- Cache")); + assert!(commented.contains("# --- Shares")); + assert!(commented.contains("# --- Warmup")); + } + #[test] fn test_default_sftp_port() { assert_eq!(default_sftp_port(), 22); @@ -763,13 +1121,13 @@ path = "Images/2024" assert!(!auth.enabled); assert!(auth.username.is_none()); assert!(auth.smb_pass.is_none()); - assert!(!auth.reuse_nas_pass); } #[test] fn test_config_with_smb_auth_and_shares() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" nas_pass = "secret" @@ -790,16 +1148,19 @@ smb_pass = "my-password" [[shares]] name = "photos" +connection = "nas" remote_path = "/volume1/photos" mount_point = "/mnt/photos" [[shares]] name = "projects" +connection = "nas" remote_path = "/volume1/projects" mount_point = "/mnt/projects" [[shares]] name = "backups" +connection = "nas" remote_path = "/volume1/backups" mount_point = "/mnt/backups" read_only = true @@ -825,6 +1186,13 @@ read_only = true assert!(config.find_share("nonexistent").is_none()); } + #[test] + fn test_find_connection() { + let config: Config = toml::from_str(minimal_toml()).unwrap(); + assert!(config.find_connection("nas").is_some()); + assert!(config.find_connection("nonexistent").is_none()); + } + #[test] fn test_rc_port() { let config: Config = toml::from_str(minimal_toml()).unwrap(); @@ -833,16 +1201,11 @@ read_only = true assert_eq!(config.rc_port(2), 5574); } - #[test] - fn test_smb_username_fallback() { - let config: Config = toml::from_str(minimal_toml()).unwrap(); - assert_eq!(config.smb_username(), "admin"); - } - #[test] fn test_smb_username_explicit() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -862,11 +1225,18 @@ smb_pass = "pass" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#; let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.smb_username(), "smbuser"); + assert_eq!(config.smb_username(), Some("smbuser")); + } + + #[test] + fn test_smb_username_none_when_not_set() { + let config: Config = toml::from_str(minimal_toml()).unwrap(); + assert_eq!(config.smb_username(), None); } #[test] @@ -878,7 +1248,8 @@ mount_point = "/mnt/photos" #[test] fn test_smb_password_dedicated() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -893,10 +1264,12 @@ dir = "/tmp/cache" [smb_auth] enabled = true +username = "smbuser" smb_pass = "dedicated-pass" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#; @@ -905,39 +1278,10 @@ mount_point = "/mnt/photos" } #[test] - fn test_smb_password_reuse_nas_pass() { + fn test_smb_password_enabled_but_missing() { let toml_str = r#" -[connection] -nas_host = "10.0.0.1" -nas_user = "admin" -nas_pass = "nas-secret" - -[cache] -dir = "/tmp/cache" - -[read] -[bandwidth] -[writeback] -[directory_cache] -[protocols] - -[smb_auth] -enabled = true -reuse_nas_pass = true - -[[shares]] -name = "photos" -remote_path = "/photos" -mount_point = "/mnt/photos" -"#; - let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.smb_password().unwrap(), Some("nas-secret".into())); - } - - #[test] - fn test_smb_password_reuse_but_nas_pass_missing() { - let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -952,21 +1296,23 @@ dir = "/tmp/cache" [smb_auth] enabled = true -reuse_nas_pass = true +username = "smbuser" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#; let config: Config = toml::from_str(toml_str).unwrap(); - assert!(config.validate().is_err()); + assert!(config.smb_password().is_err()); } #[test] fn test_validate_no_shares() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -988,10 +1334,36 @@ dir = "/tmp/cache" } } + #[test] + fn test_validate_no_connections() { + let toml_str = r#" +[cache] +dir = "/tmp/cache" + +[read] +[bandwidth] +[writeback] +[directory_cache] +[protocols] + +[[shares]] +name = "photos" +connection = "nas" +remote_path = "/photos" +mount_point = "/mnt/photos" +"#; + let result = toml::from_str::(toml_str); + if let Ok(config) = result { + let err = config.validate().unwrap_err().to_string(); + assert!(err.contains("connections"), "got: {err}"); + } + } + #[test] fn test_validate_duplicate_share_name() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -1006,11 +1378,13 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/volume1/photos" mount_point = "/mnt/photos" [[shares]] name = "photos" +connection = "nas" remote_path = "/volume1/other" mount_point = "/mnt/other" "#; @@ -1019,10 +1393,100 @@ mount_point = "/mnt/other" assert!(err.contains("duplicate share name"), "got: {err}"); } + #[test] + fn test_validate_duplicate_connection_name() { + let toml_str = r#" +[[connections]] +name = "nas" +nas_host = "10.0.0.1" +nas_user = "admin" + +[[connections]] +name = "nas" +nas_host = "10.0.0.2" +nas_user = "admin2" + +[cache] +dir = "/tmp/cache" + +[read] +[bandwidth] +[writeback] +[directory_cache] +[protocols] + +[[shares]] +name = "photos" +connection = "nas" +remote_path = "/photos" +mount_point = "/mnt/photos" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let err = config.validate().unwrap_err().to_string(); + assert!(err.contains("duplicate connection name"), "got: {err}"); + } + + #[test] + fn test_validate_invalid_connection_name() { + let toml_str = r#" +[[connections]] +name = "my nas" +nas_host = "10.0.0.1" +nas_user = "admin" + +[cache] +dir = "/tmp/cache" + +[read] +[bandwidth] +[writeback] +[directory_cache] +[protocols] + +[[shares]] +name = "photos" +connection = "my nas" +remote_path = "/photos" +mount_point = "/mnt/photos" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let err = config.validate().unwrap_err().to_string(); + assert!(err.contains("invalid"), "got: {err}"); + } + + #[test] + fn test_validate_share_bad_connection_ref() { + let toml_str = r#" +[[connections]] +name = "nas" +nas_host = "10.0.0.1" +nas_user = "admin" + +[cache] +dir = "/tmp/cache" + +[read] +[bandwidth] +[writeback] +[directory_cache] +[protocols] + +[[shares]] +name = "photos" +connection = "nonexistent" +remote_path = "/photos" +mount_point = "/mnt/photos" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let err = config.validate().unwrap_err().to_string(); + assert!(err.contains("does not exist"), "got: {err}"); + } + #[test] fn test_validate_empty_share_name() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -1037,6 +1501,7 @@ dir = "/tmp/cache" [[shares]] name = "" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#; @@ -1048,7 +1513,8 @@ mount_point = "/mnt/photos" #[test] fn test_validate_relative_remote_path() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -1063,6 +1529,7 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "photos" mount_point = "/mnt/photos" "#; @@ -1074,7 +1541,8 @@ mount_point = "/mnt/photos" #[test] fn test_validate_relative_mount_point() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -1089,6 +1557,7 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "mnt/photos" "#; @@ -1100,7 +1569,8 @@ mount_point = "mnt/photos" #[test] fn test_validate_duplicate_mount_point() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -1115,11 +1585,13 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/volume1/photos" mount_point = "/mnt/data" [[shares]] name = "videos" +connection = "nas" remote_path = "/volume1/videos" mount_point = "/mnt/data" "#; @@ -1131,7 +1603,8 @@ mount_point = "/mnt/data" #[test] fn test_validate_warmup_bad_share_ref() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -1146,6 +1619,7 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" @@ -1164,7 +1638,8 @@ path = "2024" #[test] fn test_validate_smb_auth_enabled_no_password() { let toml_str = r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -1179,13 +1654,59 @@ dir = "/tmp/cache" [smb_auth] enabled = true +username = "smbuser" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#; let config: Config = toml::from_str(toml_str).unwrap(); assert!(config.validate().is_err()); } + + #[test] + fn test_validate_smb_auth_enabled_no_username() { + let toml_str = r#" +[[connections]] +name = "nas" +nas_host = "10.0.0.1" +nas_user = "admin" + +[cache] +dir = "/tmp/cache" + +[read] +[bandwidth] +[writeback] +[directory_cache] +[protocols] + +[smb_auth] +enabled = true +smb_pass = "pass" + +[[shares]] +name = "photos" +connection = "nas" +remote_path = "/photos" +mount_point = "/mnt/photos" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let err = config.validate().unwrap_err().to_string(); + assert!(err.contains("username"), "got: {err}"); + } + + #[test] + fn test_is_valid_remote_name() { + assert!(is_valid_remote_name("home")); + assert!(is_valid_remote_name("office-nas")); + assert!(is_valid_remote_name("nas_1")); + assert!(is_valid_remote_name("NAS")); + assert!(!is_valid_remote_name("")); + assert!(!is_valid_remote_name("my nas")); + assert!(!is_valid_remote_name("nas:1")); + assert!(!is_valid_remote_name("nas/1")); + } } diff --git a/src/config_diff.rs b/src/config_diff.rs index e59e5a5..231c78e 100644 --- a/src/config_diff.rs +++ b/src/config_diff.rs @@ -18,9 +18,15 @@ pub struct ConfigDiff { pub shares_removed: Vec, /// Tier C: shares that were added (by name). pub shares_added: Vec, - /// Tier C: shares that were modified (remote_path, mount_point, or read_only changed). + /// Tier C: shares that were modified (remote_path, mount_point, read_only, or connection changed). pub shares_modified: Vec, - /// Tier D: global settings changed (connection, cache, read, writeback, directory_cache). + /// Tier C: connections that were added (by name). + pub connections_added: Vec, + /// Tier C: connections that were removed (by name). + pub connections_removed: Vec, + /// Tier C: connections that were modified (by name) — affects shares referencing them. + pub connections_modified: Vec, + /// Tier D: global settings changed (cache, read, writeback, directory_cache). pub global_changed: bool, } @@ -32,6 +38,9 @@ impl ConfigDiff { && self.shares_removed.is_empty() && self.shares_added.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 } @@ -42,6 +51,9 @@ impl ConfigDiff { } else if !self.shares_removed.is_empty() || !self.shares_added.is_empty() || !self.shares_modified.is_empty() + || !self.connections_added.is_empty() + || !self.connections_removed.is_empty() + || !self.connections_modified.is_empty() { ChangeTier::PerShare } else if self.protocols_changed { @@ -59,6 +71,15 @@ impl ConfigDiff { if self.global_changed { 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() { parts.push(format!("shares removed: {}", self.shares_removed.join(", "))); } @@ -103,14 +124,8 @@ pub enum ChangeTier { pub fn diff(old: &Config, new: &Config) -> ConfigDiff { let mut d = ConfigDiff::default(); - // Tier D: global settings - d.global_changed = old.connection.nas_host != new.connection.nas_host - || old.connection.nas_user != new.connection.nas_user - || old.connection.nas_pass != new.connection.nas_pass - || old.connection.nas_key_file != new.connection.nas_key_file - || old.connection.sftp_port != new.connection.sftp_port - || old.connection.sftp_connections != new.connection.sftp_connections - || old.cache.dir != new.cache.dir + // Tier D: global settings (cache, read, writeback, directory_cache) + d.global_changed = old.cache.dir != new.cache.dir || old.cache.max_size != new.cache.max_size || old.cache.max_age != new.cache.max_age || old.cache.min_free != new.cache.min_free @@ -135,8 +150,36 @@ pub fn diff(old: &Config, new: &Config) -> ConfigDiff { || old.protocols.webdav_port != new.protocols.webdav_port || old.smb_auth.enabled != new.smb_auth.enabled || old.smb_auth.username != new.smb_auth.username - || old.smb_auth.smb_pass != new.smb_auth.smb_pass - || old.smb_auth.reuse_nas_pass != new.smb_auth.reuse_nas_pass; + || old.smb_auth.smb_pass != new.smb_auth.smb_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 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 { if let Some(new_share) = new_shares.get(name) { if old_share.remote_path != new_share.remote_path || old_share.mount_point != new_share.mount_point || old_share.read_only != new_share.read_only + || 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 } @@ -180,7 +244,8 @@ mod tests { fn minimal_config() -> Config { toml::from_str( r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -195,6 +260,7 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#, @@ -237,6 +303,7 @@ mount_point = "/mnt/photos" let mut new = old.clone(); new.shares.push(crate::config::ShareConfig { name: "videos".to_string(), + connection: "nas".to_string(), remote_path: "/videos".to_string(), mount_point: "/mnt/videos".into(), read_only: false, @@ -253,6 +320,7 @@ mount_point = "/mnt/photos" new.shares.clear(); new.shares.push(crate::config::ShareConfig { name: "videos".to_string(), + connection: "nas".to_string(), remote_path: "/videos".to_string(), mount_point: "/mnt/videos".into(), read_only: false, @@ -276,12 +344,131 @@ mount_point = "/mnt/photos" fn test_global_change() { let old = minimal_config(); 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); assert!(d.global_changed); 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] fn test_summary() { let old = minimal_config(); diff --git a/src/daemon.rs b/src/daemon.rs index 34b2ee2..cbbe8f0 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3,10 +3,11 @@ //! The supervisor owns all mutable state. The web server gets read-only access //! to status via `Arc>` and sends commands via an mpsc channel. +use std::collections::VecDeque; use std::path::PathBuf; use std::sync::mpsc; use std::sync::{Arc, RwLock}; -use std::time::Instant; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use crate::config::Config; @@ -23,6 +24,70 @@ pub struct AppState { pub cmd_tx: mpsc::Sender, /// Path to the config file on disk. 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>, +} + +/// Ring buffer of timestamped log entries for the web log viewer. +pub struct LogBuffer { + entries: VecDeque, + /// 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) { + 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 { + 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. diff --git a/src/rclone/config.rs b/src/rclone/config.rs index e47f92c..cdc6216 100644 --- a/src/rclone/config.rs +++ b/src/rclone/config.rs @@ -10,33 +10,35 @@ use crate::config::Config; /// Default path for generated rclone config. 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 -/// connection parameters from the Warpgate config. +/// Each connection produces an INI-style `[name]` section (where `name` is +/// `ConnectionConfig.name`) containing all SFTP parameters. pub fn generate(config: &Config) -> Result { - let conn = &config.connection; let mut conf = String::new(); - writeln!(conf, "[nas]")?; - writeln!(conf, "type = sftp")?; - writeln!(conf, "host = {}", conn.nas_host)?; - writeln!(conf, "user = {}", conn.nas_user)?; - writeln!(conf, "port = {}", conn.sftp_port)?; + for conn in &config.connections { + writeln!(conf, "[{}]", conn.name)?; + writeln!(conf, "type = sftp")?; + writeln!(conf, "host = {}", conn.nas_host)?; + writeln!(conf, "user = {}", conn.nas_user)?; + writeln!(conf, "port = {}", conn.sftp_port)?; - if let Some(pass) = &conn.nas_pass { - let obscured = obscure_password(pass)?; - writeln!(conf, "pass = {obscured}")?; + if let Some(pass) = &conn.nas_pass { + let obscured = obscure_password(pass)?; + 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) } @@ -79,7 +81,8 @@ mod tests { fn test_config() -> Config { toml::from_str( r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -94,6 +97,7 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#, @@ -121,7 +125,7 @@ mount_point = "/mnt/photos" #[test] fn test_generate_rclone_config_with_key_file() { 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(); assert!(content.contains("key_file = /root/.ssh/id_rsa")); @@ -130,8 +134,8 @@ mount_point = "/mnt/photos" #[test] fn test_generate_rclone_config_custom_port_and_connections() { let mut config = test_config(); - config.connection.sftp_port = 2222; - config.connection.sftp_connections = 16; + config.connections[0].sftp_port = 2222; + config.connections[0].sftp_connections = 16; let content = generate(&config).unwrap(); assert!(content.contains("port = 2222")); @@ -149,4 +153,56 @@ mount_point = "/mnt/photos" let content = generate(&config).unwrap(); 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")); + } } diff --git a/src/rclone/mount.rs b/src/rclone/mount.rs index 51cedc6..30b7848 100644 --- a/src/rclone/mount.rs +++ b/src/rclone/mount.rs @@ -16,7 +16,7 @@ pub fn build_mount_args(config: &Config, share: &ShareConfig, rc_port: u16) -> V // Subcommand and source:dest 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()); // Point to our generated rclone.conf @@ -150,7 +150,8 @@ mod tests { fn test_config() -> Config { toml::from_str( r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -165,6 +166,7 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#, diff --git a/src/services/nfs.rs b/src/services/nfs.rs index 5c82288..0deb3d0 100644 --- a/src/services/nfs.rs +++ b/src/services/nfs.rs @@ -58,7 +58,8 @@ mod tests { fn test_config() -> Config { toml::from_str( r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -73,6 +74,7 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#, @@ -83,7 +85,8 @@ mount_point = "/mnt/photos" fn test_config_with_shares() -> Config { toml::from_str( r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -100,16 +103,19 @@ nfs_allowed_network = "192.168.0.0/24" [[shares]] name = "photos" +connection = "nas" remote_path = "/volume1/photos" mount_point = "/mnt/photos" [[shares]] name = "projects" +connection = "nas" remote_path = "/volume1/projects" mount_point = "/mnt/projects" [[shares]] name = "backups" +connection = "nas" remote_path = "/volume1/backups" mount_point = "/mnt/backups" read_only = true diff --git a/src/services/samba.rs b/src/services/samba.rs index f9f3210..3063675 100644 --- a/src/services/samba.rs +++ b/src/services/samba.rs @@ -32,7 +32,7 @@ pub fn generate(config: &Config) -> Result { writeln!(conf)?; if config.smb_auth.enabled { - let username = config.smb_username(); + let username = config.smb_username().unwrap_or("warpgate"); writeln!(conf, " # User authentication")?; writeln!(conf, " security = user")?; writeln!(conf, " map to guest = Never")?; @@ -107,7 +107,8 @@ pub fn setup_user(config: &Config) -> Result<()> { return Ok(()); } - let username = config.smb_username(); + let username = config.smb_username() + .context("SMB auth enabled but username not set")?; let password = config .smb_password()? .context("SMB auth enabled but no password resolved")?; @@ -167,7 +168,8 @@ mod tests { fn test_config() -> Config { toml::from_str( r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -182,6 +184,7 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#, @@ -192,7 +195,8 @@ mount_point = "/mnt/photos" fn test_config_with_shares() -> Config { toml::from_str( r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -207,16 +211,19 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/volume1/photos" mount_point = "/mnt/photos" [[shares]] name = "projects" +connection = "nas" remote_path = "/volume1/projects" mount_point = "/mnt/projects" [[shares]] name = "backups" +connection = "nas" remote_path = "/volume1/backups" mount_point = "/mnt/backups" read_only = true @@ -228,7 +235,8 @@ read_only = true fn test_config_with_auth() -> Config { toml::from_str( r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -248,6 +256,7 @@ smb_pass = "my-password" [[shares]] name = "photos" +connection = "nas" remote_path = "/volume1/photos" mount_point = "/mnt/photos" "#, diff --git a/src/services/systemd.rs b/src/services/systemd.rs index 9467f31..1429e12 100644 --- a/src/services/systemd.rs +++ b/src/services/systemd.rs @@ -69,7 +69,8 @@ mod tests { fn test_config() -> Config { toml::from_str( r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -84,6 +85,7 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#, diff --git a/src/services/webdav.rs b/src/services/webdav.rs index e7ad1cf..6242504 100644 --- a/src/services/webdav.rs +++ b/src/services/webdav.rs @@ -33,7 +33,8 @@ mod tests { fn test_config() -> Config { toml::from_str( r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -48,6 +49,7 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#, @@ -81,7 +83,8 @@ mount_point = "/mnt/photos" fn test_build_serve_args_uses_first_share() { let config: Config = toml::from_str( r#" -[connection] +[[connections]] +name = "nas" nas_host = "10.0.0.1" nas_user = "admin" @@ -96,11 +99,13 @@ dir = "/tmp/cache" [[shares]] name = "photos" +connection = "nas" remote_path = "/volume1/photos" mount_point = "/mnt/photos" [[shares]] name = "videos" +connection = "nas" remote_path = "/volume1/videos" mount_point = "/mnt/videos" "#, diff --git a/src/supervisor.rs b/src/supervisor.rs index 8e4907d..1bc48df 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -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 (cmd_tx, cmd_rx) = mpsc::channel::(); + // Create SSE broadcast channel (supervisor → web clients) + let (sse_tx, _) = tokio::sync::broadcast::channel::<()>(16); + // Spawn the web UI server in a background thread let _web_handle = crate::web::spawn_web_server( Arc::clone(&shared_config), Arc::clone(&shared_status), cmd_tx.clone(), config_path, + sse_tx.clone(), ); // 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 protocols, Arc::clone(&shutdown), + &sse_tx, ); // Phase 5: Teardown (always runs) @@ -504,6 +509,7 @@ fn supervise( mounts: &mut Vec, protocols: &mut ProtocolChildren, shutdown: Arc, + sse_tx: &tokio::sync::broadcast::Sender<()>, ) -> Result<()> { let mut smbd_tracker = RestartTracker::new(); let mut webdav_tracker = RestartTracker::new(); @@ -629,6 +635,9 @@ fn supervise( // Update shared status with fresh RC stats 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) - if diff.protocols_changed || !diff.shares_removed.is_empty() || !diff.shares_added.is_empty() || !diff.shares_modified.is_empty() { + // Update protocol configs to reflect share changes + if diff.protocols_changed { + // Protocol settings changed too — full restart needed 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; } +/// 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. fn restart_protocols( protocols: &mut ProtocolChildren, @@ -978,11 +1017,17 @@ fn restart_protocols( 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) { let pid = child.id() as i32; - // SAFETY: sending a signal to a known child PID is safe. - unsafe { libc::kill(pid, libc::SIGTERM) }; + // SAFETY: sending a signal to a known child process group is safe. + unsafe { libc::kill(-pid, libc::SIGTERM) }; let deadline = Instant::now() + SIGTERM_GRACE; loop { @@ -997,7 +1042,8 @@ fn graceful_kill(child: &mut Child) { thread::sleep(Duration::from_millis(100)); } - let _ = child.kill(); + // Escalate: SIGKILL the entire process group + unsafe { libc::kill(-pid, libc::SIGKILL) }; let _ = child.wait(); } diff --git a/src/web/api.rs b/src/web/api.rs index 48697b8..849da0e 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -3,14 +3,14 @@ //! All endpoints return JSON. The htmx frontend uses the page handlers instead, //! but these are available for CLI tools and external integrations. -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::Json; use axum::routing::{get, post}; use axum::Router; use serde::Serialize; -use crate::daemon::SupervisorCmd; +use crate::daemon::{LogEntry, SupervisorCmd}; use crate::web::SharedState; pub fn routes() -> Router { @@ -20,6 +20,7 @@ pub fn routes() -> Router { .route("/api/config", get(get_config)) .route("/api/config", post(post_config)) .route("/api/bwlimit", post(post_bwlimit)) + .route("/api/logs", get(get_logs)) } /// 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, +} + +async fn get_logs( + State(state): State, + Query(params): Query, +) -> Json { + let logs = state.logs.read().unwrap(); + let entries = logs.since(params.since); + Json(LogsResponse { + next_id: logs.next_id(), + entries, + }) +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 49c44b4..080ab17 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -5,23 +5,36 @@ pub mod api; pub mod pages; +pub mod sse; use std::sync::mpsc; use std::sync::{Arc, RwLock}; use std::thread; +use axum::http::header; +use axum::response::IntoResponse; +use axum::routing::get; use axum::Router; 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). pub type SharedState = Arc; +/// 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. pub fn build_router(state: SharedState) -> Router { Router::new() + .route("/static/style.css", get(style_css)) .merge(pages::routes()) + .merge(sse::routes()) .merge(api::routes()) .with_state(state) } @@ -34,13 +47,21 @@ pub fn spawn_web_server( status: Arc>, cmd_tx: mpsc::Sender, config_path: std::path::PathBuf, + sse_tx: tokio::sync::broadcast::Sender<()>, ) -> thread::JoinHandle<()> { 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 { config, status, cmd_tx, config_path, + sse_tx, + logs, }); let rt = tokio::runtime::Builder::new_multi_thread() diff --git a/src/web/pages.rs b/src/web/pages.rs index c0b0e08..152c7a9 100644 --- a/src/web/pages.rs +++ b/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 axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::routing::{get, post}; -use axum::Form; +use axum::{Form, Json}; use axum::Router; +use crate::config::Config; +use crate::daemon::{DaemonStatus, ShareStatus}; use crate::web::SharedState; pub fn routes() -> Router { Router::new() - .route("/", get(dashboard)) - .route("/shares/{name}", get(share_detail)) - .route("/config", get(config_page)) + // Full-page routes (serve layout shell with embedded tab content) + .route("/", get(page_dashboard)) + .route("/shares", get(page_shares)) + .route("/shares/{name}", get(share_redirect)) + .route("/config", get(page_config)) .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)) } -// --- Templates --- - -#[derive(Template)] -#[template(path = "web/dashboard.html")] -struct DashboardTemplate { - uptime: String, - config_path: String, - shares: Vec, - smbd_running: bool, - webdav_running: bool, - nfs_exported: bool, -} +// ─── View models ────────────────────────────────────────────────────────── +/// Compact share view for dashboard cards and status partial. +#[allow(dead_code)] // fields used by askama templates struct ShareView { name: String, connection: String, @@ -45,9 +48,9 @@ struct ShareView { health_message: String, } -#[derive(Template)] -#[template(path = "web/share_detail.html")] -struct ShareDetailTemplate { +/// Extended share view for the shares table with all detail fields. +#[allow(dead_code)] // fields used by askama templates +struct ShareDetailView { name: String, connection: String, mount_point: String, @@ -65,130 +68,307 @@ struct ShareDetailTemplate { health_message: String, } +/// Build compact share views from status + config. +fn build_share_views(status: &DaemonStatus, config: &Config) -> Vec { + 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 { + 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)] -#[template(path = "web/config.html")] -struct ConfigTemplate { - toml_content: String, - message: Option, - is_error: bool, +#[template(path = "web/layout.html", escape = "none")] +struct LayoutTemplate { + active_tab: String, + tab_content: String, + uptime: String, + config_path: String, } #[derive(Template)] -#[template(path = "web/status_partial.html")] -struct StatusPartialTemplate { +#[template(path = "web/tabs/dashboard.html")] +struct DashboardTabTemplate { + total_shares: usize, + healthy_count: usize, #[allow(dead_code)] - uptime: String, + failed_count: usize, + total_cache_display: String, + aggregate_speed_display: String, + active_transfers: u64, shares: Vec, smbd_running: bool, webdav_running: bool, nfs_exported: bool, } -// --- Handlers --- +#[derive(Template)] +#[template(path = "web/tabs/shares.html")] +struct SharesTabTemplate { + shares: Vec, + expand: String, +} -async fn dashboard(State(state): State) -> 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, + 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, + smbd_running: bool, + webdav_running: bool, + nfs_exported: bool, +} + +// ─── Full-page handlers (layout shell + tab content) ────────────────────── + +async fn page_dashboard(State(state): State) -> Response { + render_layout("dashboard", &state, |status, config| { + render_dashboard_tab(status, config) + }) +} + +async fn page_shares( + State(state): State, + Query(params): Query, +) -> 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) -> Response { + render_layout("config", &state, |_status, config| { + render_config_tab_html(config, None, false) + }) +} + +async fn page_logs(State(state): State) -> 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 config = state.config.read().unwrap(); - let shares: Vec = status - .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 tab_content = tab_fn(&status, &config); - let tmpl = DashboardTemplate { + let tmpl = LayoutTemplate { + active_tab: tab.to_string(), + tab_content, uptime: status.uptime_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, +} + +async fn tab_dashboard(State(state): State) -> 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, + Query(params): Query, +) -> 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) -> 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, smbd_running: status.smbd_running, webdav_running: status.webdav_running, nfs_exported: status.nfs_exported, }; - match tmpl.render() { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), - } + tmpl.render().unwrap_or_default() } -async fn share_detail( - State(state): State, - Path(name): Path, -) -> Response { - let status = state.status.read().unwrap(); - let config = state.config.read().unwrap(); +fn render_shares_tab(status: &DaemonStatus, config: &Config, expand: &str) -> String { + let shares = build_share_detail_views(status, config); - let share_status = match status.shares.iter().find(|s| s.name == name) { - Some(s) => s, - None => return (StatusCode::NOT_FOUND, "Share not found").into_response(), + let tmpl = SharesTabTemplate { + shares, + expand: expand.to_string(), }; - let share_config = config.find_share(&name); - - 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(), - } + tmpl.render().unwrap_or_default() } -async fn config_page(State(state): State) -> Response { - let config = state.config.read().unwrap(); - let toml_content = toml::to_string_pretty(&*config).unwrap_or_default(); +// ─── Share detail redirect ──────────────────────────────────────────────── - let tmpl = ConfigTemplate { - toml_content, - message: None, - is_error: false, - }; - - match tmpl.render() { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), - } +async fn share_redirect(Path(name): Path) -> Response { + Redirect::to(&format!("/shares?expand={name}")).into_response() } +// ─── Config submit ──────────────────────────────────────────────────────── + #[derive(serde::Deserialize)] struct ConfigForm { toml: String, @@ -199,31 +379,26 @@ async fn config_submit( Form(form): Form, ) -> Response { // 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, Err(e) => { - let tmpl = ConfigTemplate { - toml_content: form.toml, - message: Some(format!("TOML parse error: {e}")), - is_error: true, - }; - return match tmpl.render() { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), - }; + let config = state.config.read().unwrap(); + let html = render_config_tab_html( + &config, + Some(format!("TOML parse error: {e}")), + true, + ); + return Html(html).into_response(); } }; if let Err(e) = new_config.validate() { - let tmpl = ConfigTemplate { - toml_content: form.toml, - message: Some(format!("Validation error: {e}")), - is_error: true, - }; - return match tmpl.render() { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), - }; + let html = render_config_tab_html( + &new_config, + Some(format!("Validation error: {e}")), + true, + ); + return Html(html).into_response(); } // Compute diff summary @@ -231,30 +406,24 @@ async fn config_submit( let old_config = state.config.read().unwrap(); let d = crate::config_diff::diff(&old_config, &new_config); if d.is_empty() { - let tmpl = ConfigTemplate { - toml_content: form.toml, - message: Some("No changes detected.".to_string()), - is_error: false, - }; - return match tmpl.render() { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), - }; + let html = render_config_tab_html( + &new_config, + Some("No changes detected.".to_string()), + false, + ); + return Html(html).into_response(); } d.summary() }; // Save to disk if let Err(e) = std::fs::write(&state.config_path, &form.toml) { - let tmpl = ConfigTemplate { - toml_content: form.toml, - message: Some(format!("Failed to write config: {e}")), - is_error: true, - }; - return match tmpl.render() { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), - }; + let html = render_config_tab_html( + &new_config, + Some(format!("Failed to write config: {e}")), + true, + ); + return Html(html).into_response(); } // Send reload command @@ -262,56 +431,116 @@ async fn config_submit( .cmd_tx .send(crate::daemon::SupervisorCmd::Reload(new_config)) { - let tmpl = ConfigTemplate { - toml_content: form.toml, - message: Some(format!("Failed to send reload: {e}")), - is_error: true, - }; - return match tmpl.render() { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), - }; + let config = state.config.read().unwrap(); + let html = render_config_tab_html( + &config, + Some(format!("Failed to send reload: {e}")), + true, + ); + return Html(html).into_response(); } - // Success — redirect to dashboard - Redirect::to(&format!("/config?msg={}", urlencoded("Config applied: ".to_string() + &diff_summary))).into_response() + // Success — re-read config and show success message + 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 { - s.replace(' ', "+").replace(':', "%3A").replace(',', "%2C") +/// JSON endpoint: apply config from the interactive form editor. +async fn config_apply( + State(state): State, + Json(new_config): Json, +) -> Json { + // 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, is_error: bool) -> String { + let init = ConfigTabInit { + config: config.clone(), + message, + is_error, + }; + // Escape tags + let init_json = serde_json::to_string(&init) + .unwrap_or_default() + .replace(") -> Response { let status = state.status.read().unwrap(); let config = state.config.read().unwrap(); - let shares: Vec = status - .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 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 = 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, smbd_running: status.smbd_running, webdav_running: status.webdav_running, diff --git a/src/web/sse.rs b/src/web/sse.rs new file mode 100644 index 0000000..9d1d0ba --- /dev/null +++ b/src/web/sse.rs @@ -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 { + Router::new().route("/events", get(sse_handler)) +} + +async fn sse_handler( + State(state): State, +) -> Sse>> { + 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 = 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, +} + +#[derive(Template)] +#[template(path = "web/partials/protocol_badges.html")] +struct ProtocolBadgesPartial { + smbd_running: bool, + nfs_exported: bool, + webdav_running: bool, +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..db9300b --- /dev/null +++ b/static/style.css @@ -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; } +} diff --git a/templates/config.toml.default b/templates/config.toml.default index 58439f0..2b27dce 100644 --- a/templates/config.toml.default +++ b/templates/config.toml.default @@ -1,7 +1,13 @@ # Warpgate Configuration # 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 nas_host = "100.x.x.x" # SFTP username @@ -15,6 +21,15 @@ sftp_port = 22 # SFTP connection pool size 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 storage directory (should be on SSD, prefer btrfs/ZFS filesystem) dir = "/mnt/ssd/warpgate" @@ -71,29 +86,39 @@ webdav_port = 8080 # # [smb_auth] # enabled = true -# username = "photographer" # defaults to connection.nas_user -# smb_pass = "my-password" # option 1: dedicated password -# reuse_nas_pass = true # option 2: reuse connection.nas_pass +# username = "photographer" +# smb_pass = "my-password" # --- Shares --- # Each share maps a remote NAS path to a local mount point. # Each gets its own rclone mount process with independent FUSE mount. +# The "connection" field references a [[connections]] entry by name. [[shares]] name = "photos" +connection = "nas" remote_path = "/volume1/photos" mount_point = "/mnt/photos" # [[shares]] # name = "projects" +# connection = "nas" # remote_path = "/volume1/projects" # mount_point = "/mnt/projects" # # [[shares]] # name = "backups" +# connection = "nas" # remote_path = "/volume1/backups" # mount_point = "/mnt/backups" # read_only = true +# +# # Share from a different NAS: +# [[shares]] +# name = "office-docs" +# connection = "office" +# remote_path = "/data/documents" +# mount_point = "/mnt/office-docs" [warmup] # Auto-warmup configured paths on startup diff --git a/templates/web/config.html b/templates/web/config.html deleted file mode 100644 index 06c5413..0000000 --- a/templates/web/config.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - Warpgate — Config - - - - - -

Configuration Editor

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

Warpgate Dashboard

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

{{ share.name }}

-
- {% if share.health == "OK" %} - OK - {% elif share.health == "FAILED" %} - FAILED - {% elif share.health == "PROBING" %} - PROBING - {% else %} - PENDING - {% endif %} - {% if share.read_only %} - RO - {% endif %} -
-
-
- Mount: {{ share.mount_point }} - Cache: {{ share.cache_display }} - Dirty: {{ share.dirty_count }} - Speed: {{ share.speed_display }} -
- {% if share.health == "FAILED" %} -
{{ share.health_message }}
- {% endif %} -
- {% endfor %} - -
- SMB: {% if smbd_running %}ON{% else %}OFF{% endif %} - NFS: {% if nfs_exported %}ON{% else %}OFF{% endif %} - WebDAV: {% if webdav_running %}ON{% else %}OFF{% endif %} -
-
- - - - diff --git a/templates/web/layout.html b/templates/web/layout.html new file mode 100644 index 0000000..a0401b9 --- /dev/null +++ b/templates/web/layout.html @@ -0,0 +1,59 @@ + + + + + + Warpgate Dashboard + + + + + + +
+
+
+

Warpgate

+
Uptime: {{ uptime }}  |  Config: {{ config_path }}
+
+
+ + + +
+ {{ tab_content }} +
+
+ + +
+ + + + diff --git a/templates/web/partials/dashboard_stats.html b/templates/web/partials/dashboard_stats.html new file mode 100644 index 0000000..37a0616 --- /dev/null +++ b/templates/web/partials/dashboard_stats.html @@ -0,0 +1,21 @@ +
+
+
+
Shares
+
{{ healthy_count }} / {{ total_shares }}
+
+
+
Cache
+
{{ total_cache_display }}
+
+
+
Speed
+
{{ aggregate_speed_display }}
+
+
+
Transfers
+
{{ active_transfers }}
+
+
+
+
{{ uptime }}
diff --git a/templates/web/partials/protocol_badges.html b/templates/web/partials/protocol_badges.html new file mode 100644 index 0000000..6f82140 --- /dev/null +++ b/templates/web/partials/protocol_badges.html @@ -0,0 +1,7 @@ +
+
+ SMB: {% if smbd_running %}ON{% else %}OFF{% endif %} + NFS: {% if nfs_exported %}ON{% else %}OFF{% endif %} + WebDAV: {% if webdav_running %}ON{% else %}OFF{% endif %} +
+
diff --git a/templates/web/partials/share_rows.html b/templates/web/partials/share_rows.html new file mode 100644 index 0000000..0452f42 --- /dev/null +++ b/templates/web/partials/share_rows.html @@ -0,0 +1,36 @@ +
+
+ {% for share in shares %} +
+
+

{{ share.name }}

+
+ {% if share.health == "OK" %} + OK + {% elif share.health == "FAILED" %} + FAILED + {% elif share.health == "PROBING" %} + PROBING + {% else %} + PENDING + {% endif %} + {% if share.read_only %} + RO + {% endif %} +
+
+
+ Mount: {{ share.mount_point }} + Cache: {{ share.cache_display }} + Dirty: {{ share.dirty_count }} + Speed: {{ share.speed_display }} +
+ {% if share.health == "FAILED" %} +
{{ share.health_message }}
+ {% endif %} +
+ {% endfor %} +
+
diff --git a/templates/web/share_detail.html b/templates/web/share_detail.html deleted file mode 100644 index 4d7aaf8..0000000 --- a/templates/web/share_detail.html +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - Warpgate — {{ name }} - - - - - - -

- {{ name }} - {% if health == "OK" %}OK{% elif health == "FAILED" %}FAILED{% elif health == "PROBING" %}PROBING{% else %}PENDING{% endif %} - {% if read_only %}Read-Only{% endif %} -

- -
-
-
Cache Used
-
{{ cache_display }}
-
-
-
Dirty Files
-
{{ dirty_count }}
-
-
-
Transfer Speed
-
{{ speed_display }}
-
-
-
Active Transfers
-
{{ transfers }}
-
-
- - - - {% if health == "FAILED" %} - - {% endif %} - - - - - -
Health{{ health }}
Probe Error{{ health_message }}
Mount Point{{ mount_point }}
Remote Path{{ remote_path }}
RC Port{{ rc_port }}
Errored Files{{ errored_files }}
Total Errors{{ errors }}
- - diff --git a/templates/web/status_partial.html b/templates/web/status_partial.html deleted file mode 100644 index 2281159..0000000 --- a/templates/web/status_partial.html +++ /dev/null @@ -1,36 +0,0 @@ -{% for share in shares %} -
-
-

{{ share.name }}

-
- {% if share.health == "OK" %} - OK - {% elif share.health == "FAILED" %} - FAILED - {% elif share.health == "PROBING" %} - PROBING - {% else %} - PENDING - {% endif %} - {% if share.read_only %} - RO - {% endif %} -
-
-
- Mount: {{ share.mount_point }} - Cache: {{ share.cache_display }} - Dirty: {{ share.dirty_count }} - Speed: {{ share.speed_display }} -
- {% if share.health == "FAILED" %} -
{{ share.health_message }}
- {% endif %} -
-{% endfor %} - -
- SMB: {% if smbd_running %}ON{% else %}OFF{% endif %} - NFS: {% if nfs_exported %}ON{% else %}OFF{% endif %} - WebDAV: {% if webdav_running %}ON{% else %}OFF{% endif %} -
diff --git a/templates/web/tabs/config.html b/templates/web/tabs/config.html new file mode 100644 index 0000000..74c9362 --- /dev/null +++ b/templates/web/tabs/config.html @@ -0,0 +1,475 @@ + + + +
+ + + + + +
+
+

Connections Per-share restart

+ +
+
+ + +
+
+ + +
+
+

Shares Per-share restart

+ +
+
+ + +
+
+ + +
+
+

Cache Full restart

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+

Read Tuning Full restart

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+

Bandwidth Live

+ +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + +
+
+

Write-back Full restart

+ +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+

Directory Cache Full restart

+ +
+
+
+ + +
+
+
+ + +
+
+

Protocols Protocol restart

+ +
+
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+ + +
+
+

SMB Auth Protocol restart

+ +
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+

Warmup No restart

+ +
+
+
+ +
+
+ + + +
+
+
+ + +
+ + +
+ +
diff --git a/templates/web/tabs/dashboard.html b/templates/web/tabs/dashboard.html new file mode 100644 index 0000000..864fab1 --- /dev/null +++ b/templates/web/tabs/dashboard.html @@ -0,0 +1,65 @@ +
+
+
+
Shares
+
{{ healthy_count }} / {{ total_shares }}
+
+
+
Cache
+
{{ total_cache_display }}
+
+
+
Speed
+
{{ aggregate_speed_display }}
+
+
+
Transfers
+
{{ active_transfers }}
+
+
+
+ +
+
+ {% for share in shares %} +
+
+

{{ share.name }}

+
+ {% if share.health == "OK" %} + OK + {% elif share.health == "FAILED" %} + FAILED + {% elif share.health == "PROBING" %} + PROBING + {% else %} + PENDING + {% endif %} + {% if share.read_only %} + RO + {% endif %} +
+
+
+ Mount: {{ share.mount_point }} + Cache: {{ share.cache_display }} + Dirty: {{ share.dirty_count }} + Speed: {{ share.speed_display }} +
+ {% if share.health == "FAILED" %} +
{{ share.health_message }}
+ {% endif %} +
+ {% endfor %} +
+
+ +
+
+ SMB: {% if smbd_running %}ON{% else %}OFF{% endif %} + NFS: {% if nfs_exported %}ON{% else %}OFF{% endif %} + WebDAV: {% if webdav_running %}ON{% else %}OFF{% endif %} +
+
diff --git a/templates/web/tabs/logs.html b/templates/web/tabs/logs.html new file mode 100644 index 0000000..cc3c07f --- /dev/null +++ b/templates/web/tabs/logs.html @@ -0,0 +1,84 @@ + + +
+
+
+ +
+
+ + +
+
+
+ + +
+
diff --git a/templates/web/tabs/shares.html b/templates/web/tabs/shares.html new file mode 100644 index 0000000..934b9a7 --- /dev/null +++ b/templates/web/tabs/shares.html @@ -0,0 +1,79 @@ +
+ + + + + + + + + + + + + + {% for share in shares %} + + + + + + + + + + + + + {% endfor %} + + +
diff --git a/tests/01-config/test-minimal-valid.sh b/tests/01-config/test-minimal-valid.sh index 90e4761..4392804 100755 --- a/tests/01-config/test-minimal-valid.sh +++ b/tests/01-config/test-minimal-valid.sh @@ -6,9 +6,9 @@ source "$SCRIPT_DIR/../harness/helpers.sh" setup_test_env trap teardown_test_env EXIT -# Generate config with only the required fields (connection.nas_host, -# connection.nas_user, connection.remote_path, cache.dir). All other -# fields should be filled in by the binary's defaults. +# Generate config with only the required fields (connections[].name, +# connections[].nas_host, connections[].nas_user, cache.dir, shares[].connection). +# All other fields should be filled in by the binary's defaults. source "$HARNESS_DIR/config-gen.sh" _gen_minimal_config diff --git a/tests/05-cache/test-warmup-cache-path.sh b/tests/05-cache/test-warmup-cache-path.sh index ab09571..77eaefc 100755 --- a/tests/05-cache/test-warmup-cache-path.sh +++ b/tests/05-cache/test-warmup-cache-path.sh @@ -2,8 +2,9 @@ # Test: VFS cache stores files at the expected filesystem path # # 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//FILENAME — the exact path that warmup's # is_cached logic checks to decide whether to skip a file. +# The default connection name is "nas". set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "$SCRIPT_DIR/../harness/helpers.sh" diff --git a/tests/harness/config-gen.sh b/tests/harness/config-gen.sh index dc28f7b..5dd79fb 100755 --- a/tests/harness/config-gen.sh +++ b/tests/harness/config-gen.sh @@ -16,6 +16,7 @@ _gen_config() { local config_file="${TEST_CONFIG:-$TEST_DIR/config.toml}" # Defaults pointing at mock NAS + local conn_name="${TEST_CONN_NAME:-nas}" local nas_host="${MOCK_NAS_IP:-10.99.0.2}" local nas_user="root" 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_username="" local smb_auth_smb_pass="" - local smb_auth_reuse_nas_pass="false" # Default share: single share at / local share_name="${TEST_SHARE_NAME:-data}" @@ -67,6 +67,7 @@ _gen_config() { local value="${override#*=}" case "$key" in + connection.name|conn_name) conn_name="$value" ;; connection.nas_host|nas_host) nas_host="$value" ;; connection.nas_user|nas_user) nas_user="$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.username|smb_auth_username) smb_auth_username="$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.remote_path|share_remote_path) share_remote_path="$value" ;; share.mount_point|share_mount_point) share_mount_point="$value" ;; @@ -106,7 +106,8 @@ _gen_config() { done cat > "$config_file" <> "$config_file" fi - if [[ "$smb_auth_reuse_nas_pass" == "true" ]]; then - echo "reuse_nas_pass = true" >> "$config_file" - fi fi # Append shares config — use override or default single share @@ -175,6 +173,7 @@ SMB_AUTH_EOF [[shares]] name = "$share_name" +connection = "$conn_name" remote_path = "$share_remote_path" mount_point = "$share_mount_point" SHARES_EOF @@ -192,9 +191,11 @@ SHARES_EOF # Generate a minimal config (only required fields) _gen_minimal_config() { local config_file="${TEST_CONFIG:-$TEST_DIR/config.toml}" + local conn_name="${TEST_CONN_NAME:-nas}" cat > "$config_file" < "$config_file" < "$config_file" <