Web UI overhaul: interactive config editor, SSE live updates, log viewer, and SMB reload fixes

- Replace raw TOML textarea with Alpine.js interactive form editor (10 collapsible
  sections with change-tier badges, dynamic array management for connections/shares/
  warmup rules, proper input controls per field type)
- Add SSE-based live dashboard updates replacing htmx polling
- Add log viewer tab with ring buffer backend and incremental polling
- Fix SMB not seeing new shares after config reload: kill entire smbd process group
  (not just parent PID) so forked workers release port 445
- Add SIGHUP-based smbd config reload for share changes instead of full restart,
  preserving existing client connections
- Generate human-readable commented TOML from config editor instead of bare
  toml::to_string_pretty() output
- Fix Alpine.js 2.x __x.$data calls in dashboard/share templates (now Alpine 3.x)
- Fix toggle switch CSS overlap with field labels
- Fix dashboard going blank on tab switch (remove hx-swap-oob from tab content)
- Add htmx:afterSettle → Alpine.initTree() bridge for robust tab switching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
grabbit 2026-02-18 18:06:52 +08:00
parent 466ea5cfa8
commit 6bb7ec4d27
35 changed files with 3236 additions and 645 deletions

32
Cargo.lock generated
View File

@ -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",

View File

@ -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"] }

View File

@ -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

View File

@ -17,7 +17,7 @@ pub fn run(config: &Config, share_name: &str, path: &str, newer_than: Option<&st
.with_context(|| format!("Share '{}' not found in config", share_name))?;
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"));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -18,9 +18,15 @@ pub struct ConfigDiff {
pub shares_removed: Vec<String>,
/// Tier C: shares that were added (by name).
pub shares_added: Vec<String>,
/// Tier C: shares that were modified (remote_path, mount_point, or read_only changed).
/// Tier C: shares that were modified (remote_path, mount_point, read_only, or connection changed).
pub shares_modified: Vec<String>,
/// Tier D: global settings changed (connection, cache, read, writeback, directory_cache).
/// Tier C: connections that were added (by name).
pub connections_added: Vec<String>,
/// Tier C: connections that were removed (by name).
pub connections_removed: Vec<String>,
/// Tier C: connections that were modified (by name) — affects shares referencing them.
pub connections_modified: Vec<String>,
/// Tier D: global settings changed (cache, read, writeback, directory_cache).
pub global_changed: bool,
}
@ -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();

View File

@ -3,10 +3,11 @@
//! The supervisor owns all mutable state. The web server gets read-only access
//! to status via `Arc<RwLock<DaemonStatus>>` and sends commands via an mpsc channel.
use std::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<SupervisorCmd>,
/// 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<RwLock<LogBuffer>>,
}
/// Ring buffer of timestamped log entries for the web log viewer.
pub struct LogBuffer {
entries: VecDeque<LogEntry>,
/// Monotonically increasing ID for the next entry.
next_id: u64,
}
/// A single log entry with unix timestamp and message.
#[derive(Clone, serde::Serialize)]
pub struct LogEntry {
pub id: u64,
pub ts: u64,
pub msg: String,
}
const LOG_BUFFER_MAX: usize = 500;
impl LogBuffer {
pub fn new() -> Self {
Self {
entries: VecDeque::new(),
next_id: 0,
}
}
/// Push a new log message. Timestamps are added automatically.
pub fn push(&mut self, msg: impl Into<String>) {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.entries.push_back(LogEntry {
id: self.next_id,
ts,
msg: msg.into(),
});
self.next_id += 1;
if self.entries.len() > LOG_BUFFER_MAX {
self.entries.pop_front();
}
}
/// Get entries with ID >= `since_id`.
pub fn since(&self, since_id: u64) -> Vec<LogEntry> {
let start_id = self.next_id.saturating_sub(self.entries.len() as u64);
let skip = if since_id > start_id {
(since_id - start_id) as usize
} else {
0
};
self.entries.iter().skip(skip).cloned().collect()
}
/// The ID that the next pushed entry will have.
pub fn next_id(&self) -> u64 {
self.next_id
}
}
/// Overall daemon status, updated by the supervisor loop.

View File

@ -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<String> {
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"));
}
}

View File

@ -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"
"#,

View File

@ -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

View File

@ -32,7 +32,7 @@ pub fn generate(config: &Config) -> Result<String> {
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"
"#,

View File

@ -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"
"#,

View File

@ -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"
"#,

View File

@ -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::<SupervisorCmd>();
// Create SSE broadcast channel (supervisor → web clients)
let (sse_tx, _) = tokio::sync::broadcast::channel::<()>(16);
// Spawn the web UI server in a background thread
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<MountChild>,
protocols: &mut ProtocolChildren,
shutdown: Arc<AtomicBool>,
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();
}

View File

@ -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<SharedState> {
@ -20,6 +20,7 @@ pub fn routes() -> Router<SharedState> {
.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<LogEntry>,
}
async fn get_logs(
State(state): State<SharedState>,
Query(params): Query<LogsQuery>,
) -> Json<LogsResponse> {
let logs = state.logs.read().unwrap();
let entries = logs.since(params.since);
Json(LogsResponse {
next_id: logs.next_id(),
entries,
})
}

View File

@ -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<AppState>;
/// Embedded CSS served at `/static/style.css`.
const STYLE_CSS: &str = include_str!("../../static/style.css");
async fn style_css() -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/css")], STYLE_CSS)
}
/// Build the axum router with all routes.
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<RwLock<DaemonStatus>>,
cmd_tx: mpsc::Sender<SupervisorCmd>,
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()

View File

@ -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<SharedState> {
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<ShareView>,
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<ShareView> {
status
.shares
.iter()
.map(|s| {
let sc = config.find_share(&s.name);
ShareView {
name: s.name.clone(),
connection: sc.map(|c| c.connection.clone()).unwrap_or_default(),
mount_point: sc
.map(|c| c.mount_point.display().to_string())
.unwrap_or_default(),
mounted: s.mounted,
cache_display: s.cache_display(),
dirty_count: s.dirty_count,
speed_display: s.speed_display(),
read_only: sc.map(|c| c.read_only).unwrap_or(false),
health: s.health_label().to_string(),
health_message: s.health_message().unwrap_or("").to_string(),
}
})
.collect()
}
/// Build extended share detail views from status + config.
fn build_share_detail_views(status: &DaemonStatus, config: &Config) -> Vec<ShareDetailView> {
status
.shares
.iter()
.map(|s| {
let sc = config.find_share(&s.name);
ShareDetailView {
name: s.name.clone(),
connection: sc.map(|c| c.connection.clone()).unwrap_or_default(),
mount_point: sc
.map(|c| c.mount_point.display().to_string())
.unwrap_or_default(),
remote_path: sc.map(|c| c.remote_path.clone()).unwrap_or_default(),
mounted: s.mounted,
read_only: sc.map(|c| c.read_only).unwrap_or(false),
rc_port: s.rc_port,
cache_display: s.cache_display(),
dirty_count: s.dirty_count,
errored_files: s.errored_files,
speed_display: s.speed_display(),
transfers: s.transfers,
errors: s.errors,
health: s.health_label().to_string(),
health_message: s.health_message().unwrap_or("").to_string(),
}
})
.collect()
}
/// Aggregate stats from share statuses.
fn aggregate_stats(shares: &[ShareStatus]) -> (u64, f64, u64) {
let total_cache: u64 = shares.iter().map(|s| s.cache_bytes).sum();
let total_speed: f64 = shares.iter().map(|s| s.speed).sum();
let active_transfers: u64 = shares.iter().map(|s| s.transfers).sum();
(total_cache, total_speed, active_transfers)
}
fn format_bytes(bytes: u64) -> String {
const KIB: f64 = 1024.0;
const MIB: f64 = KIB * 1024.0;
const GIB: f64 = MIB * 1024.0;
const TIB: f64 = GIB * 1024.0;
let b = bytes as f64;
if b >= TIB {
format!("{:.1} TiB", b / TIB)
} else if b >= GIB {
format!("{:.1} GiB", b / GIB)
} else if b >= MIB {
format!("{:.1} MiB", b / MIB)
} else if b >= KIB {
format!("{:.1} KiB", b / KIB)
} else {
format!("{bytes} B")
}
}
fn format_speed(speed: f64) -> String {
if speed < 1.0 {
"-".to_string()
} else {
format!("{}/s", format_bytes(speed as u64))
}
}
// ─── Templates ────────────────────────────────────────────────────────────
#[derive(Template)]
#[template(path = "web/config.html")]
struct ConfigTemplate {
toml_content: String,
message: Option<String>,
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<ShareView>,
smbd_running: bool,
webdav_running: bool,
nfs_exported: bool,
}
// --- Handlers ---
#[derive(Template)]
#[template(path = "web/tabs/shares.html")]
struct SharesTabTemplate {
shares: Vec<ShareDetailView>,
expand: String,
}
async fn dashboard(State(state): State<SharedState>) -> Response {
#[derive(Template)]
#[template(path = "web/tabs/config.html", escape = "none")]
struct ConfigTabTemplate {
init_json: String,
}
/// Data embedded as JSON for the Alpine.js config editor.
#[derive(serde::Serialize)]
struct ConfigTabInit {
config: Config,
message: Option<String>,
is_error: bool,
}
/// JSON response for the `POST /config/apply` endpoint.
#[derive(serde::Serialize)]
struct ConfigApplyResponse {
ok: bool,
message: String,
}
#[derive(Template)]
#[template(path = "web/tabs/logs.html")]
struct LogsTabTemplate;
/// Legacy htmx polling partial (backward compat for `/partials/status`).
#[derive(Template)]
#[template(path = "web/tabs/dashboard.html")]
struct StatusPartialTemplate {
total_shares: usize,
healthy_count: usize,
#[allow(dead_code)]
failed_count: usize,
total_cache_display: String,
aggregate_speed_display: String,
active_transfers: u64,
shares: Vec<ShareView>,
smbd_running: bool,
webdav_running: bool,
nfs_exported: bool,
}
// ─── Full-page handlers (layout shell + tab content) ──────────────────────
async fn page_dashboard(State(state): State<SharedState>) -> Response {
render_layout("dashboard", &state, |status, config| {
render_dashboard_tab(status, config)
})
}
async fn page_shares(
State(state): State<SharedState>,
Query(params): Query<ExpandQuery>,
) -> Response {
let expand = params.expand.unwrap_or_default();
render_layout("shares", &state, |status, config| {
render_shares_tab(status, config, &expand)
})
}
async fn page_config(State(state): State<SharedState>) -> Response {
render_layout("config", &state, |_status, config| {
render_config_tab_html(config, None, false)
})
}
async fn page_logs(State(state): State<SharedState>) -> Response {
render_layout("logs", &state, |_status, _config| {
LogsTabTemplate.render().unwrap_or_default()
})
}
/// Helper: render the layout shell wrapping a tab content generator.
fn render_layout(
tab: &str,
state: &SharedState,
tab_fn: impl FnOnce(&DaemonStatus, &Config) -> String,
) -> Response {
let status = state.status.read().unwrap();
let config = state.config.read().unwrap();
let shares: Vec<ShareView> = 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<String>,
}
async fn tab_dashboard(State(state): State<SharedState>) -> Response {
let status = state.status.read().unwrap();
let config = state.config.read().unwrap();
let html = render_dashboard_tab(&status, &config);
Html(html).into_response()
}
async fn tab_shares(
State(state): State<SharedState>,
Query(params): Query<ExpandQuery>,
) -> Response {
let status = state.status.read().unwrap();
let config = state.config.read().unwrap();
let expand = params.expand.unwrap_or_default();
let html = render_shares_tab(&status, &config, &expand);
Html(html).into_response()
}
async fn tab_config(State(state): State<SharedState>) -> Response {
let config = state.config.read().unwrap();
let html = render_config_tab_html(&config, None, false);
Html(html).into_response()
}
async fn tab_logs() -> Response {
match LogsTabTemplate.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(),
}
}
// ─── Tab render helpers ───────────────────────────────────────────────────
fn render_dashboard_tab(status: &DaemonStatus, config: &Config) -> String {
let shares = build_share_views(status, config);
let healthy_count = shares.iter().filter(|s| s.health == "OK").count();
let failed_count = shares.iter().filter(|s| s.health == "FAILED").count();
let (total_cache, total_speed, active_transfers) = aggregate_stats(&status.shares);
let tmpl = DashboardTabTemplate {
total_shares: shares.len(),
healthy_count,
failed_count,
total_cache_display: format_bytes(total_cache),
aggregate_speed_display: format_speed(total_speed),
active_transfers,
shares,
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<SharedState>,
Path(name): Path<String>,
) -> 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<SharedState>) -> 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<String>) -> 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<ConfigForm>,
) -> 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<SharedState>,
Json(new_config): Json<Config>,
) -> Json<ConfigApplyResponse> {
// Validate
if let Err(e) = new_config.validate() {
return Json(ConfigApplyResponse {
ok: false,
message: format!("Validation error: {e}"),
});
}
// Compute diff
let diff_summary = {
let old_config = state.config.read().unwrap();
let d = crate::config_diff::diff(&old_config, &new_config);
if d.is_empty() {
return Json(ConfigApplyResponse {
ok: true,
message: "No changes detected.".to_string(),
});
}
d.summary()
};
// Serialize to human-readable TOML and write to disk
let toml_content = new_config.to_commented_toml();
if let Err(e) = std::fs::write(&state.config_path, &toml_content) {
return Json(ConfigApplyResponse {
ok: false,
message: format!("Failed to write config: {e}"),
});
}
// Send reload command
if let Err(e) = state
.cmd_tx
.send(crate::daemon::SupervisorCmd::Reload(new_config))
{
return Json(ConfigApplyResponse {
ok: false,
message: format!("Failed to send reload: {e}"),
});
}
{
let mut logs = state.logs.write().unwrap();
logs.push(format!("Config applied: {diff_summary}"));
}
Json(ConfigApplyResponse {
ok: true,
message: format!("Config applied: {diff_summary}"),
})
}
/// Partial HTML fragment for htmx polling (status cards only).
/// Render the config tab HTML using the interactive form editor.
fn render_config_tab_html(config: &Config, message: Option<String>, is_error: bool) -> String {
let init = ConfigTabInit {
config: config.clone(),
message,
is_error,
};
// Escape </ to prevent breaking out of <script> tags
let init_json = serde_json::to_string(&init)
.unwrap_or_default()
.replace("</", "<\\/");
let tmpl = ConfigTabTemplate { init_json };
tmpl.render().unwrap_or_default()
}
// ─── Legacy partial (backward compat) ─────────────────────────────────────
async fn status_partial(State(state): State<SharedState>) -> Response {
let status = state.status.read().unwrap();
let config = state.config.read().unwrap();
let shares: Vec<ShareView> = 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,

199
src/web/sse.rs Normal file
View File

@ -0,0 +1,199 @@
//! Server-Sent Events endpoint for real-time status push.
//!
//! The supervisor sends `()` on a `broadcast::Sender` after every
//! `update_status()` cycle. Each SSE client subscribes, renders the
//! partial templates, and pushes them as a single `status` event with
//! htmx OOB swap attributes so multiple DOM regions update at once.
use std::convert::Infallible;
use std::time::Duration;
use askama::Template;
use axum::extract::State;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::routing::get;
use axum::Router;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
use crate::web::SharedState;
pub fn routes() -> Router<SharedState> {
Router::new().route("/events", get(sse_handler))
}
async fn sse_handler(
State(state): State<SharedState>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
let rx = state.sse_tx.subscribe();
let stream = BroadcastStream::new(rx).filter_map(move |r| {
match r {
Ok(()) => {
let status = state.status.read().unwrap();
let config = state.config.read().unwrap();
let html = render_sse_payload(&status, &config);
Some(Ok(Event::default().event("status").data(html)))
}
Err(_) => None, // lagged, skip
}
});
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
}
/// Render all SSE partials into a single HTML payload.
///
/// Uses htmx OOB (Out-of-Band) swap so a single SSE event can update
/// multiple independent DOM regions:
/// - `#dashboard-stats` — stat cards
/// - `#share-rows` — share card list
/// - `#protocol-badges` — SMB/NFS/WebDAV badges
fn render_sse_payload(
status: &crate::daemon::DaemonStatus,
config: &crate::config::Config,
) -> String {
let shares: Vec<SseShareView> = status
.shares
.iter()
.map(|s| {
let share_config = config.find_share(&s.name);
SseShareView {
name: s.name.clone(),
connection: share_config
.map(|sc| sc.connection.clone())
.unwrap_or_default(),
mount_point: share_config
.map(|sc| sc.mount_point.display().to_string())
.unwrap_or_default(),
remote_path: share_config
.map(|sc| sc.remote_path.clone())
.unwrap_or_default(),
mounted: s.mounted,
cache_display: s.cache_display(),
dirty_count: s.dirty_count,
errored_files: s.errored_files,
speed_display: s.speed_display(),
transfers: s.transfers,
errors: s.errors,
read_only: share_config.map(|sc| sc.read_only).unwrap_or(false),
health: s.health_label().to_string(),
health_message: s.health_message().unwrap_or("").to_string(),
rc_port: s.rc_port,
}
})
.collect();
let healthy_count = shares.iter().filter(|s| s.health == "OK").count();
let failed_count = shares.iter().filter(|s| s.health == "FAILED").count();
let total_cache: u64 = status.shares.iter().map(|s| s.cache_bytes).sum();
let total_speed: f64 = status.shares.iter().map(|s| s.speed).sum();
let active_transfers: u64 = status.shares.iter().map(|s| s.transfers).sum();
let stats = DashboardStatsPartial {
total_shares: shares.len(),
healthy_count,
failed_count,
total_cache_display: format_bytes_static(total_cache),
aggregate_speed_display: if total_speed < 1.0 {
"-".to_string()
} else {
format!("{}/s", format_bytes_static(total_speed as u64))
},
active_transfers,
uptime: status.uptime_string(),
};
let share_rows = ShareRowsPartial {
shares: shares.clone(),
};
let badges = ProtocolBadgesPartial {
smbd_running: status.smbd_running,
nfs_exported: status.nfs_exported,
webdav_running: status.webdav_running,
};
let mut html = String::new();
// Primary target: dashboard stats
if let Ok(s) = stats.render() {
html.push_str(&s);
}
// OOB: share rows
if let Ok(s) = share_rows.render() {
html.push_str(&s);
}
// OOB: protocol badges
if let Ok(s) = badges.render() {
html.push_str(&s);
}
html
}
fn format_bytes_static(bytes: u64) -> String {
const KIB: f64 = 1024.0;
const MIB: f64 = KIB * 1024.0;
const GIB: f64 = MIB * 1024.0;
const TIB: f64 = GIB * 1024.0;
let b = bytes as f64;
if b >= TIB {
format!("{:.1} TiB", b / TIB)
} else if b >= GIB {
format!("{:.1} GiB", b / GIB)
} else if b >= MIB {
format!("{:.1} MiB", b / MIB)
} else if b >= KIB {
format!("{:.1} KiB", b / KIB)
} else {
format!("{bytes} B")
}
}
// --- SSE partial templates ---
#[derive(Clone)]
#[allow(dead_code)] // fields used by askama templates
pub struct SseShareView {
pub name: String,
pub connection: String,
pub mount_point: String,
pub remote_path: String,
pub mounted: bool,
pub cache_display: String,
pub dirty_count: u64,
pub errored_files: u64,
pub speed_display: String,
pub transfers: u64,
pub errors: u64,
pub read_only: bool,
pub health: String,
pub health_message: String,
pub rc_port: u16,
}
#[derive(Template)]
#[template(path = "web/partials/dashboard_stats.html")]
struct DashboardStatsPartial {
total_shares: usize,
healthy_count: usize,
#[allow(dead_code)]
failed_count: usize,
total_cache_display: String,
aggregate_speed_display: String,
active_transfers: u64,
uptime: String,
}
#[derive(Template)]
#[template(path = "web/partials/share_rows.html")]
struct ShareRowsPartial {
shares: Vec<SseShareView>,
}
#[derive(Template)]
#[template(path = "web/partials/protocol_badges.html")]
struct ProtocolBadgesPartial {
smbd_running: bool,
nfs_exported: bool,
webdav_running: bool,
}

588
static/style.css Normal file
View File

@ -0,0 +1,588 @@
/* Warpgate Dashboard — unified stylesheet */
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e1e4ed;
--text-muted: #8b8fa3;
--accent: #6c8aff;
--green: #4ade80;
--red: #f87171;
--yellow: #fbbf24;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
--mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
padding: 0;
margin: 0;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.mono { font-family: var(--mono); }
/* ─── Shell layout ─────────────────────────────────────── */
.shell {
max-width: 1080px;
margin: 0 auto;
padding: 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0;
padding-bottom: 16px;
}
.header h1 { font-size: 1.4em; }
.header .status-dot {
display: inline-block;
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green);
margin-right: 8px;
}
.meta { color: var(--text-muted); font-size: 0.85em; }
/* ─── Tab navigation ───────────────────────────────────── */
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: 24px;
}
.tab-btn {
padding: 10px 20px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
font-family: var(--font);
font-size: 0.9em;
font-weight: 500;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.tab-btn:hover {
color: var(--text);
}
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ─── Stat cards (dashboard) ───────────────────────────── */
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.stat-card .label {
font-size: 0.8em;
color: var(--text-muted);
margin-bottom: 4px;
}
.stat-card .value {
font-size: 1.3em;
font-weight: 600;
}
/* ─── Share cards ──────────────────────────────────────── */
.cards {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.card-header h2 { font-size: 1.1em; }
.card-header h2 a { color: var(--accent); text-decoration: none; }
.card-header h2 a:hover { text-decoration: underline; }
/* ─── Badges ───────────────────────────────────────────── */
.badge {
font-size: 0.75em;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
vertical-align: middle;
}
.badge-ok { background: rgba(74,222,128,0.15); color: var(--green); }
.badge-error { background: rgba(248,113,113,0.15); color: var(--red); }
.badge-ro { background: rgba(251,191,36,0.15); color: var(--yellow); }
.badge-warn { background: rgba(251,191,36,0.15); color: var(--yellow); }
/* ─── Stats row (inside share cards) ──────────────────── */
.stats {
display: flex;
gap: 24px;
font-size: 0.9em;
color: var(--text-muted);
flex-wrap: wrap;
}
.stats span { white-space: nowrap; }
.stats .label { color: var(--text-muted); }
.stats .value { color: var(--text); }
.error-msg {
margin-top: 8px;
padding: 8px 12px;
background: rgba(248,113,113,0.08);
border-radius: 4px;
color: var(--red);
font-size: 0.85em;
}
/* ─── Protocol badges ──────────────────────────────────── */
.protocols {
display: flex;
gap: 16px;
margin-bottom: 20px;
font-size: 0.9em;
}
.proto-badge {
padding: 4px 12px;
border-radius: 4px;
font-weight: 600;
}
.proto-on { background: rgba(74,222,128,0.15); color: var(--green); }
.proto-off { background: rgba(248,113,113,0.1); color: var(--text-muted); }
/* ─── Share table (shares tab) ─────────────────────────── */
.share-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 24px;
}
.share-table th {
text-align: left;
padding: 8px 12px;
border-bottom: 2px solid var(--border);
color: var(--text-muted);
font-size: 0.8em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.share-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.share-row {
cursor: pointer;
transition: background 0.1s;
}
.share-row:hover {
background: rgba(108,138,255,0.05);
}
.detail-row td {
padding: 0;
border-bottom: 1px solid var(--border);
}
.detail-panel {
background: var(--surface);
padding: 16px 20px;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.detail-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
}
.detail-card .label {
font-size: 0.8em;
color: var(--text-muted);
margin-bottom: 4px;
}
.detail-card .value {
font-size: 1.2em;
font-weight: 600;
}
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table td {
padding: 6px 12px;
border-bottom: 1px solid var(--border);
font-size: 0.9em;
}
.info-table td:first-child {
color: var(--text-muted);
width: 140px;
}
.error-text { color: var(--red); }
/* ─── Config editor ────────────────────────────────────── */
.message {
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 0.9em;
}
.message-error {
background: rgba(248,113,113,0.15);
color: var(--red);
border: 1px solid rgba(248,113,113,0.3);
}
.message-ok {
background: rgba(74,222,128,0.15);
color: var(--green);
border: 1px solid rgba(74,222,128,0.3);
}
textarea {
width: 100%;
min-height: 500px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
font-family: var(--mono);
font-size: 0.85em;
line-height: 1.5;
resize: vertical;
tab-size: 4;
}
textarea:focus {
outline: none;
border-color: var(--accent);
}
.form-actions {
margin-top: 12px;
display: flex;
gap: 12px;
}
/* ─── Config form (interactive editor) ─────────────────── */
.config-section {
margin-bottom: 16px;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--surface);
cursor: pointer;
user-select: none;
}
.section-header h3 {
font-size: 1em;
display: flex;
align-items: center;
gap: 8px;
}
.section-body { padding: 16px; }
.tier-badge {
font-size: 0.7em;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
}
.tier-live { background: rgba(74,222,128,0.15); color: var(--green); }
.tier-protocol { background: rgba(251,191,36,0.15); color: var(--yellow); }
.tier-pershare { background: rgba(251,191,36,0.15); color: var(--yellow); }
.tier-global { background: rgba(248,113,113,0.15); color: var(--red); }
.tier-none { background: rgba(74,222,128,0.15); color: var(--green); }
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.field-row { margin-bottom: 12px; }
.field-grid .field-row { margin-bottom: 0; }
.field-row label:not(.toggle) {
display: block;
font-size: 0.8em;
color: var(--text-muted);
margin-bottom: 4px;
}
.field-row input[type="text"],
.field-row input[type="password"],
.field-row input[type="number"],
.field-row select {
width: 100%;
padding: 8px 12px;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--font);
font-size: 0.9em;
}
.field-row input:focus,
.field-row select:focus {
outline: none;
border-color: var(--accent);
}
.field-row input.mono { font-family: var(--mono); }
.array-item {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
position: relative;
}
.array-item .item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.remove-btn {
background: none;
border: 1px solid rgba(248,113,113,0.3);
color: var(--red);
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8em;
}
.remove-btn:hover { background: rgba(248,113,113,0.1); }
.add-btn {
width: 100%;
padding: 8px;
background: none;
border: 1px dashed var(--border);
border-radius: 8px;
color: var(--text-muted);
cursor: pointer;
font-size: 0.9em;
}
.add-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
/* Toggle switch */
.toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
gap: 8px;
font-size: 0.9em;
color: var(--text);
}
.toggle input { display: none; }
.toggle .slider {
width: 36px;
height: 20px;
background: var(--border);
border-radius: 10px;
position: relative;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle .slider::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: var(--text-muted);
border-radius: 50%;
transition: transform 0.2s;
}
.toggle input:checked + .slider { background: var(--accent); }
.toggle input:checked + .slider::after { transform: translateX(16px); background: #fff; }
.chevron { font-size: 0.9em; color: var(--text-muted); }
/* ─── Buttons ──────────────────────────────────────────── */
.btn {
display: inline-block;
padding: 8px 20px;
border-radius: 6px;
font-size: 0.9em;
font-weight: 500;
cursor: pointer;
border: none;
text-decoration: none;
text-align: center;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover { opacity: 0.9; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
border-color: var(--accent);
color: var(--accent);
}
/* ─── Log viewer ──────────────────────────────────────── */
.log-viewer {
background: #0a0c10;
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
font-family: var(--mono);
font-size: 0.8em;
line-height: 1.6;
height: 70vh;
overflow-y: auto;
overflow-x: hidden;
word-break: break-all;
}
.log-viewer .log-line { color: var(--text-muted); }
.log-viewer .log-ts { color: var(--accent); margin-right: 8px; opacity: 0.7; }
.log-viewer .log-msg { color: var(--text); }
.log-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 0.85em;
color: var(--text-muted);
}
.log-toolbar .log-count { font-family: var(--mono); }
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
}
.empty-state h2 {
font-size: 1.2em;
margin-bottom: 8px;
color: var(--text);
}
/* ─── Responsive ───────────────────────────────────────── */
@media (max-width: 768px) {
.stat-cards { grid-template-columns: repeat(2, 1fr); }
.detail-grid { grid-template-columns: 1fr; }
.field-grid { grid-template-columns: 1fr; }
.share-table th:nth-child(n+5),
.share-table td:nth-child(n+5) { display: none; }
}
@media (max-width: 480px) {
.shell { padding: 12px; }
.stat-cards { grid-template-columns: 1fr; }
.tabs { overflow-x: auto; }
.tab-btn { padding: 8px 14px; font-size: 0.85em; }
}

View File

@ -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

View File

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

View File

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

59
templates/web/layout.html Normal file
View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Warpgate Dashboard</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body hx-ext="sse" sse-connect="/events" x-data="{ activeTab: '{{ active_tab }}' }">
<div class="shell">
<div class="header">
<div>
<h1><span class="status-dot"></span>Warpgate</h1>
<div class="meta">Uptime: <span id="uptime">{{ uptime }}</span> &nbsp;|&nbsp; Config: {{ config_path }}</div>
</div>
</div>
<nav class="tabs">
<button class="tab-btn" :class="{ active: activeTab === 'dashboard' }"
@click="activeTab = 'dashboard'"
hx-get="/tabs/dashboard" hx-target="#tab-content" hx-swap="innerHTML">
Dashboard
</button>
<button class="tab-btn" :class="{ active: activeTab === 'shares' }"
@click="activeTab = 'shares'"
hx-get="/tabs/shares" hx-target="#tab-content" hx-swap="innerHTML">
Shares
</button>
<button class="tab-btn" :class="{ active: activeTab === 'config' }"
@click="activeTab = 'config'"
hx-get="/tabs/config" hx-target="#tab-content" hx-swap="innerHTML">
Config
</button>
<button class="tab-btn" :class="{ active: activeTab === 'logs' }"
@click="activeTab = 'logs'"
hx-get="/tabs/logs" hx-target="#tab-content" hx-swap="innerHTML">
Logs
</button>
</nav>
<div id="tab-content">
{{ tab_content }}
</div>
</div>
<!-- Hidden SSE listener: OOB swaps update regions inside dashboard tab -->
<div sse-swap="status" hx-swap="none" style="display:none"></div>
<script>
/* Alpine.js + htmx bridge: re-initialize Alpine trees after htmx content swaps */
document.body.addEventListener('htmx:afterSettle', function(evt) {
if (window.Alpine) Alpine.initTree(evt.detail.target || evt.detail.elt);
});
</script>
</body>
</html>

View File

@ -0,0 +1,21 @@
<div id="dashboard-stats" hx-swap-oob="innerHTML:#dashboard-stats">
<div class="stat-cards">
<div class="stat-card">
<div class="label">Shares</div>
<div class="value">{{ healthy_count }} / {{ total_shares }}</div>
</div>
<div class="stat-card">
<div class="label">Cache</div>
<div class="value">{{ total_cache_display }}</div>
</div>
<div class="stat-card">
<div class="label">Speed</div>
<div class="value">{{ aggregate_speed_display }}</div>
</div>
<div class="stat-card">
<div class="label">Transfers</div>
<div class="value">{{ active_transfers }}</div>
</div>
</div>
</div>
<div id="uptime-oob" hx-swap-oob="innerHTML:#uptime">{{ uptime }}</div>

View File

@ -0,0 +1,7 @@
<div id="protocol-badges" hx-swap-oob="innerHTML:#protocol-badges">
<div class="protocols">
<span class="proto-badge {% if smbd_running %}proto-on{% else %}proto-off{% endif %}">SMB: {% if smbd_running %}ON{% else %}OFF{% endif %}</span>
<span class="proto-badge {% if nfs_exported %}proto-on{% else %}proto-off{% endif %}">NFS: {% if nfs_exported %}ON{% else %}OFF{% endif %}</span>
<span class="proto-badge {% if webdav_running %}proto-on{% else %}proto-off{% endif %}">WebDAV: {% if webdav_running %}ON{% else %}OFF{% endif %}</span>
</div>
</div>

View File

@ -0,0 +1,36 @@
<div id="share-rows" hx-swap-oob="innerHTML:#share-rows">
<div class="cards">
{% for share in shares %}
<div class="card" style="cursor:pointer"
hx-get="/tabs/shares?expand={{ share.name }}" hx-target="#tab-content" hx-swap="innerHTML"
@click="activeTab = 'shares'">
<div class="card-header">
<h2>{{ share.name }}</h2>
<div>
{% if share.health == "OK" %}
<span class="badge badge-ok">OK</span>
{% elif share.health == "FAILED" %}
<span class="badge badge-error" title="{{ share.health_message }}">FAILED</span>
{% elif share.health == "PROBING" %}
<span class="badge badge-warn">PROBING</span>
{% else %}
<span class="badge badge-warn">PENDING</span>
{% endif %}
{% if share.read_only %}
<span class="badge badge-ro">RO</span>
{% endif %}
</div>
</div>
<div class="stats">
<span><span class="label">Mount:</span> <span class="value">{{ share.mount_point }}</span></span>
<span><span class="label">Cache:</span> <span class="value">{{ share.cache_display }}</span></span>
<span><span class="label">Dirty:</span> <span class="value">{{ share.dirty_count }}</span></span>
<span><span class="label">Speed:</span> <span class="value">{{ share.speed_display }}</span></span>
</div>
{% if share.health == "FAILED" %}
<div class="error-msg">{{ share.health_message }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>

View File

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

View File

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

View File

@ -0,0 +1,475 @@
<script id="config-init" type="application/json">{{ init_json }}</script>
<script>
function configEditorFn() {
return {
config: {},
originalConfig: {},
submitting: false,
message: null,
isError: false,
sections: {
connections: true,
shares: true,
cache: false,
read: false,
bandwidth: false,
writeback: false,
directory_cache: false,
protocols: false,
smb_auth: false,
warmup: false,
},
init() {
const data = JSON.parse(document.getElementById('config-init').textContent);
this.config = this.prepareForEdit(data.config);
this.originalConfig = JSON.parse(JSON.stringify(this.config));
if (data.message) {
this.message = data.message;
this.isError = data.is_error;
}
},
/** Convert null optional fields to empty strings for form binding. */
prepareForEdit(config) {
for (const conn of config.connections) {
if (conn.nas_pass == null) conn.nas_pass = '';
if (conn.nas_key_file == null) conn.nas_key_file = '';
}
if (config.smb_auth.username == null) config.smb_auth.username = '';
if (config.smb_auth.smb_pass == null) config.smb_auth.smb_pass = '';
for (const rule of config.warmup.rules) {
if (rule.newer_than == null) rule.newer_than = '';
}
return config;
},
/** Convert empty optional strings back to null for the API. */
prepareForSubmit(config) {
const c = JSON.parse(JSON.stringify(config));
for (const conn of c.connections) {
if (!conn.nas_pass) conn.nas_pass = null;
if (!conn.nas_key_file) conn.nas_key_file = null;
}
if (!c.smb_auth.username) c.smb_auth.username = null;
if (!c.smb_auth.smb_pass) c.smb_auth.smb_pass = null;
for (const rule of c.warmup.rules) {
if (!rule.newer_than) rule.newer_than = null;
}
return c;
},
addConnection() {
this.config.connections.push({
name: '', nas_host: '', nas_user: '',
nas_pass: '', nas_key_file: '',
sftp_port: 22, sftp_connections: 8
});
},
addShare() {
this.config.shares.push({
name: '',
connection: this.config.connections[0]?.name || '',
remote_path: '/',
mount_point: '/mnt/',
read_only: false
});
},
addWarmupRule() {
this.config.warmup.rules.push({
share: this.config.shares[0]?.name || '',
path: '',
newer_than: ''
});
},
async submitConfig() {
this.submitting = true;
this.message = null;
try {
const payload = this.prepareForSubmit(this.config);
const resp = await fetch('/config/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await resp.json();
this.message = result.message;
this.isError = !result.ok;
if (result.ok) {
this.originalConfig = JSON.parse(JSON.stringify(this.config));
}
} catch (e) {
this.message = 'Network error: ' + e.message;
this.isError = true;
}
this.submitting = false;
},
resetConfig() {
this.config = JSON.parse(JSON.stringify(this.originalConfig));
this.message = null;
}
}
}
/* Register with Alpine.data for robust htmx swap support.
On initial load: alpine:init fires before Alpine scans the DOM.
On htmx swap: Alpine is already loaded, register directly. */
if (window.Alpine) {
Alpine.data('configEditor', configEditorFn);
} else {
document.addEventListener('alpine:init', function() {
Alpine.data('configEditor', configEditorFn);
});
}
</script>
<div x-data="configEditor">
<!-- Message banner -->
<template x-if="message">
<div class="message" :class="isError ? 'message-error' : 'message-ok'" x-text="message"></div>
</template>
<!-- ═══ Section: Connections ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.connections = !sections.connections">
<h3>Connections <span class="tier-badge tier-pershare">Per-share restart</span></h3>
<span class="chevron" x-text="sections.connections ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.connections" x-transition>
<template x-for="(conn, i) in config.connections" :key="i">
<div class="array-item">
<div class="item-header">
<strong x-text="conn.name || 'New Connection'"></strong>
<button type="button" @click="config.connections.splice(i, 1)" class="remove-btn">Remove</button>
</div>
<div class="field-grid">
<div class="field-row">
<label>Name *</label>
<input type="text" x-model="conn.name" required placeholder="e.g. home-nas">
</div>
<div class="field-row">
<label>NAS Host *</label>
<input type="text" x-model="conn.nas_host" required placeholder="e.g. 100.64.0.1">
</div>
<div class="field-row">
<label>Username *</label>
<input type="text" x-model="conn.nas_user" required placeholder="e.g. admin">
</div>
<div class="field-row">
<label>Password</label>
<input type="password" x-model="conn.nas_pass" placeholder="(optional if using key)">
</div>
<div class="field-row">
<label>SSH Key File</label>
<input type="text" x-model="conn.nas_key_file" class="mono" placeholder="/root/.ssh/id_rsa">
</div>
<div class="field-row">
<label>SFTP Port</label>
<input type="number" x-model.number="conn.sftp_port" min="1" max="65535">
</div>
<div class="field-row">
<label>SFTP Connections</label>
<input type="number" x-model.number="conn.sftp_connections" min="1" max="128">
</div>
</div>
</div>
</template>
<button type="button" @click="addConnection()" class="add-btn">+ Add Connection</button>
</div>
</section>
<!-- ═══ Section: Shares ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.shares = !sections.shares">
<h3>Shares <span class="tier-badge tier-pershare">Per-share restart</span></h3>
<span class="chevron" x-text="sections.shares ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.shares" x-transition>
<template x-for="(share, i) in config.shares" :key="i">
<div class="array-item">
<div class="item-header">
<strong x-text="share.name || 'New Share'"></strong>
<button type="button" @click="config.shares.splice(i, 1)" class="remove-btn">Remove</button>
</div>
<div class="field-grid">
<div class="field-row">
<label>Name *</label>
<input type="text" x-model="share.name" required placeholder="e.g. photos">
</div>
<div class="field-row">
<label>Connection *</label>
<select x-model="share.connection">
<template x-for="c in config.connections" :key="c.name">
<option :value="c.name" x-text="c.name"></option>
</template>
</select>
</div>
<div class="field-row">
<label>Remote Path *</label>
<input type="text" x-model="share.remote_path" class="mono" required placeholder="/volume1/photos">
</div>
<div class="field-row">
<label>Mount Point *</label>
<input type="text" x-model="share.mount_point" class="mono" required placeholder="/mnt/photos">
</div>
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="share.read_only">
<span class="slider"></span>
Read Only
</label>
</div>
</div>
</template>
<button type="button" @click="addShare()" class="add-btn">+ Add Share</button>
</div>
</section>
<!-- ═══ Section: Cache ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.cache = !sections.cache">
<h3>Cache <span class="tier-badge tier-global">Full restart</span></h3>
<span class="chevron" x-text="sections.cache ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.cache" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Cache Directory *</label>
<input type="text" x-model="config.cache.dir" class="mono" required placeholder="/mnt/ssd/cache">
</div>
<div class="field-row">
<label>Max Size</label>
<input type="text" x-model="config.cache.max_size" placeholder="e.g. 200G">
</div>
<div class="field-row">
<label>Max Age</label>
<input type="text" x-model="config.cache.max_age" placeholder="e.g. 720h">
</div>
<div class="field-row">
<label>Min Free Space</label>
<input type="text" x-model="config.cache.min_free" placeholder="e.g. 10G">
</div>
</div>
</div>
</section>
<!-- ═══ Section: Read Tuning ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.read = !sections.read">
<h3>Read Tuning <span class="tier-badge tier-global">Full restart</span></h3>
<span class="chevron" x-text="sections.read ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.read" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Chunk Size</label>
<input type="text" x-model="config.read.chunk_size" placeholder="e.g. 256M">
</div>
<div class="field-row">
<label>Chunk Limit</label>
<input type="text" x-model="config.read.chunk_limit" placeholder="e.g. 1G">
</div>
<div class="field-row">
<label>Read Ahead</label>
<input type="text" x-model="config.read.read_ahead" placeholder="e.g. 512M">
</div>
<div class="field-row">
<label>Buffer Size</label>
<input type="text" x-model="config.read.buffer_size" placeholder="e.g. 256M">
</div>
</div>
</div>
</section>
<!-- ═══ Section: Bandwidth ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.bandwidth = !sections.bandwidth">
<h3>Bandwidth <span class="tier-badge tier-live">Live</span></h3>
<span class="chevron" x-text="sections.bandwidth ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.bandwidth" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Upload Limit</label>
<input type="text" x-model="config.bandwidth.limit_up" placeholder="0 = unlimited, e.g. 10M">
</div>
<div class="field-row">
<label>Download Limit</label>
<input type="text" x-model="config.bandwidth.limit_down" placeholder="0 = unlimited, e.g. 50M">
</div>
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="config.bandwidth.adaptive">
<span class="slider"></span>
Adaptive Throttling
</label>
</div>
</div>
</section>
<!-- ═══ Section: Write-back ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.writeback = !sections.writeback">
<h3>Write-back <span class="tier-badge tier-global">Full restart</span></h3>
<span class="chevron" x-text="sections.writeback ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.writeback" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Write-back Delay</label>
<input type="text" x-model="config.writeback.write_back" placeholder="e.g. 5s">
</div>
<div class="field-row">
<label>Concurrent Transfers</label>
<input type="number" x-model.number="config.writeback.transfers" min="1" max="64">
</div>
</div>
</div>
</section>
<!-- ═══ Section: Directory Cache ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.directory_cache = !sections.directory_cache">
<h3>Directory Cache <span class="tier-badge tier-global">Full restart</span></h3>
<span class="chevron" x-text="sections.directory_cache ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.directory_cache" x-transition>
<div class="field-row">
<label>Cache Time (TTL)</label>
<input type="text" x-model="config.directory_cache.cache_time" placeholder="e.g. 1h" style="max-width:300px">
</div>
</div>
</section>
<!-- ═══ Section: Protocols ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.protocols = !sections.protocols">
<h3>Protocols <span class="tier-badge tier-protocol">Protocol restart</span></h3>
<span class="chevron" x-text="sections.protocols ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.protocols" x-transition>
<div class="field-row">
<label class="toggle">
<input type="checkbox" x-model="config.protocols.enable_smb">
<span class="slider"></span>
Enable SMB (Samba)
</label>
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="config.protocols.enable_nfs">
<span class="slider"></span>
Enable NFS
</label>
</div>
<div class="field-row" x-show="config.protocols.enable_nfs" x-transition style="margin-top:12px">
<label>NFS Allowed Network</label>
<input type="text" x-model="config.protocols.nfs_allowed_network" placeholder="e.g. 192.168.0.0/24" style="max-width:300px">
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="config.protocols.enable_webdav">
<span class="slider"></span>
Enable WebDAV
</label>
</div>
<div class="field-row" x-show="config.protocols.enable_webdav" x-transition style="margin-top:12px">
<label>WebDAV Port</label>
<input type="number" x-model.number="config.protocols.webdav_port" min="1" max="65535" style="max-width:300px">
</div>
</div>
</section>
<!-- ═══ Section: SMB Auth ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.smb_auth = !sections.smb_auth">
<h3>SMB Auth <span class="tier-badge tier-protocol">Protocol restart</span></h3>
<span class="chevron" x-text="sections.smb_auth ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.smb_auth" x-transition>
<div class="field-row">
<label class="toggle">
<input type="checkbox" x-model="config.smb_auth.enabled">
<span class="slider"></span>
Enable SMB Authentication
</label>
</div>
<div x-show="config.smb_auth.enabled" x-transition>
<div class="field-grid" style="margin-top:12px">
<div class="field-row">
<label>Username *</label>
<input type="text" x-model="config.smb_auth.username" placeholder="SMB username">
</div>
<div class="field-row">
<label>Password *</label>
<input type="password" x-model="config.smb_auth.smb_pass" placeholder="SMB password">
</div>
</div>
</div>
</div>
</section>
<!-- ═══ Section: Warmup ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.warmup = !sections.warmup">
<h3>Warmup <span class="tier-badge tier-none">No restart</span></h3>
<span class="chevron" x-text="sections.warmup ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.warmup" x-transition>
<div class="field-row">
<label class="toggle">
<input type="checkbox" x-model="config.warmup.auto">
<span class="slider"></span>
Auto-warmup on Startup
</label>
</div>
<div style="margin-top:16px">
<label style="font-size:0.85em;color:var(--text-muted);display:block;margin-bottom:8px">Warmup Rules</label>
<template x-for="(rule, i) in config.warmup.rules" :key="i">
<div class="array-item">
<div class="item-header">
<strong x-text="(rule.share || '?') + ':' + (rule.path || '/')"></strong>
<button type="button" @click="config.warmup.rules.splice(i, 1)" class="remove-btn">Remove</button>
</div>
<div class="field-grid">
<div class="field-row">
<label>Share *</label>
<select x-model="rule.share">
<template x-for="s in config.shares" :key="s.name">
<option :value="s.name" x-text="s.name"></option>
</template>
</select>
</div>
<div class="field-row">
<label>Path *</label>
<input type="text" x-model="rule.path" class="mono" placeholder="e.g. Images/2024">
</div>
<div class="field-row">
<label>Newer Than</label>
<input type="text" x-model="rule.newer_than" placeholder="e.g. 7d, 24h (optional)">
</div>
</div>
</div>
</template>
<button type="button" @click="addWarmupRule()" class="add-btn">+ Add Warmup Rule</button>
</div>
</div>
</section>
<!-- ═══ Form Actions ═══ -->
<div class="form-actions" style="margin-top:24px">
<button type="button" @click="submitConfig()" class="btn btn-primary" :disabled="submitting">
<span x-show="!submitting">Apply Config</span>
<span x-show="submitting">Applying...</span>
</button>
<button type="button" @click="resetConfig()" class="btn btn-secondary">Reset</button>
</div>
</div>

View File

@ -0,0 +1,65 @@
<div id="dashboard-stats">
<div class="stat-cards">
<div class="stat-card">
<div class="label">Shares</div>
<div class="value">{{ healthy_count }} / {{ total_shares }}</div>
</div>
<div class="stat-card">
<div class="label">Cache</div>
<div class="value">{{ total_cache_display }}</div>
</div>
<div class="stat-card">
<div class="label">Speed</div>
<div class="value">{{ aggregate_speed_display }}</div>
</div>
<div class="stat-card">
<div class="label">Transfers</div>
<div class="value">{{ active_transfers }}</div>
</div>
</div>
</div>
<div id="share-rows">
<div class="cards">
{% for share in shares %}
<div class="card" style="cursor:pointer"
hx-get="/tabs/shares?expand={{ share.name }}" hx-target="#tab-content" hx-swap="innerHTML"
@click="activeTab = 'shares'">
<div class="card-header">
<h2>{{ share.name }}</h2>
<div>
{% if share.health == "OK" %}
<span class="badge badge-ok">OK</span>
{% elif share.health == "FAILED" %}
<span class="badge badge-error" title="{{ share.health_message }}">FAILED</span>
{% elif share.health == "PROBING" %}
<span class="badge badge-warn">PROBING</span>
{% else %}
<span class="badge badge-warn">PENDING</span>
{% endif %}
{% if share.read_only %}
<span class="badge badge-ro">RO</span>
{% endif %}
</div>
</div>
<div class="stats">
<span><span class="label">Mount:</span> <span class="value">{{ share.mount_point }}</span></span>
<span><span class="label">Cache:</span> <span class="value">{{ share.cache_display }}</span></span>
<span><span class="label">Dirty:</span> <span class="value">{{ share.dirty_count }}</span></span>
<span><span class="label">Speed:</span> <span class="value">{{ share.speed_display }}</span></span>
</div>
{% if share.health == "FAILED" %}
<div class="error-msg">{{ share.health_message }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<div id="protocol-badges">
<div class="protocols">
<span class="proto-badge {% if smbd_running %}proto-on{% else %}proto-off{% endif %}">SMB: {% if smbd_running %}ON{% else %}OFF{% endif %}</span>
<span class="proto-badge {% if nfs_exported %}proto-on{% else %}proto-off{% endif %}">NFS: {% if nfs_exported %}ON{% else %}OFF{% endif %}</span>
<span class="proto-badge {% if webdav_running %}proto-on{% else %}proto-off{% endif %}">WebDAV: {% if webdav_running %}ON{% else %}OFF{% endif %}</span>
</div>
</div>

View File

@ -0,0 +1,84 @@
<script>
function logViewerFn() {
return {
entries: [],
nextId: 0,
polling: null,
autoScroll: true,
init() {
this.fetchLogs();
this.polling = setInterval(() => this.fetchLogs(), 2000);
},
destroy() {
if (this.polling) clearInterval(this.polling);
},
async fetchLogs() {
try {
const resp = await fetch('/api/logs?since=' + this.nextId);
const data = await resp.json();
if (data.entries.length > 0) {
this.entries = this.entries.concat(data.entries);
// Keep client-side buffer reasonable
if (this.entries.length > 1000) {
this.entries = this.entries.slice(-500);
}
this.nextId = data.next_id;
if (this.autoScroll) {
this.$nextTick(() => {
const el = this.$refs.logBox;
if (el) el.scrollTop = el.scrollHeight;
});
}
}
} catch(e) { /* ignore fetch errors */ }
},
formatTime(ts) {
const d = new Date(ts * 1000);
return d.toLocaleTimeString('en-GB', { hour12: false });
},
clear() {
this.entries = [];
}
}
}
if (window.Alpine) {
Alpine.data('logViewer', logViewerFn);
} else {
document.addEventListener('alpine:init', function() {
Alpine.data('logViewer', logViewerFn);
});
}
</script>
<div x-data="logViewer" x-init="init()" @htmx:before-swap.window="destroy()">
<div class="log-toolbar">
<div>
<span class="log-count" x-text="entries.length + ' entries'"></span>
</div>
<div style="display:flex;gap:12px;align-items:center">
<label class="toggle" style="font-size:0.85em">
<input type="checkbox" x-model="autoScroll">
<span class="slider"></span>
Auto-scroll
</label>
<button type="button" class="btn btn-secondary" style="padding:4px 12px;font-size:0.8em" @click="clear()">Clear</button>
</div>
</div>
<div class="log-viewer" x-ref="logBox">
<template x-if="entries.length === 0">
<div style="color:var(--text-muted);padding:24px;text-align:center">No log entries yet. Events will appear here as they occur.</div>
</template>
<template x-for="entry in entries" :key="entry.id">
<div class="log-line">
<span class="log-ts" x-text="formatTime(entry.ts)"></span>
<span class="log-msg" x-text="entry.msg"></span>
</div>
</template>
</div>
</div>

View File

@ -0,0 +1,79 @@
<div x-data="{ expanded: new URLSearchParams(location.search).get('expand') || '{{ expand }}' }">
<table class="share-table">
<thead>
<tr>
<th>Name</th>
<th>Health</th>
<th>Mount</th>
<th>Cache</th>
<th>Dirty</th>
<th>Speed</th>
<th>Transfers</th>
</tr>
</thead>
<tbody>
{% for share in shares %}
<tr class="share-row" @click="expanded = expanded === '{{ share.name }}' ? '' : '{{ share.name }}'">
<td><strong>{{ share.name }}</strong></td>
<td>
{% if share.health == "OK" %}
<span class="badge badge-ok">OK</span>
{% elif share.health == "FAILED" %}
<span class="badge badge-error">FAILED</span>
{% elif share.health == "PROBING" %}
<span class="badge badge-warn">PROBING</span>
{% else %}
<span class="badge badge-warn">PENDING</span>
{% endif %}
{% if share.read_only %}
<span class="badge badge-ro">RO</span>
{% endif %}
</td>
<td class="mono">{{ share.mount_point }}</td>
<td>{{ share.cache_display }}</td>
<td>{{ share.dirty_count }}</td>
<td>{{ share.speed_display }}</td>
<td>{{ share.transfers }}</td>
</tr>
<tr x-show="expanded === '{{ share.name }}'" x-transition x-cloak class="detail-row">
<td colspan="7">
<div class="detail-panel">
<div class="detail-grid">
<div class="detail-card">
<div class="label">Cache Used</div>
<div class="value">{{ share.cache_display }}</div>
</div>
<div class="detail-card">
<div class="label">Dirty Files</div>
<div class="value">{{ share.dirty_count }}</div>
</div>
<div class="detail-card">
<div class="label">Transfer Speed</div>
<div class="value">{{ share.speed_display }}</div>
</div>
<div class="detail-card">
<div class="label">Active Transfers</div>
<div class="value">{{ share.transfers }}</div>
</div>
</div>
<table class="info-table">
<tr><td>Health</td><td>{{ share.health }}</td></tr>
{% if share.health == "FAILED" %}
<tr><td>Probe Error</td><td class="error-text">{{ share.health_message }}</td></tr>
{% endif %}
<tr><td>Connection</td><td class="mono">{{ share.connection }}</td></tr>
<tr><td>Mount Point</td><td class="mono">{{ share.mount_point }}</td></tr>
<tr><td>Remote Path</td><td class="mono">{{ share.remote_path }}</td></tr>
<tr><td>RC Port</td><td>{{ share.rc_port }}</td></tr>
<tr><td>Errored Files</td><td>{{ share.errored_files }}</td></tr>
<tr><td>Total Errors</td><td>{{ share.errors }}</td></tr>
<tr><td>Mounted</td><td>{% if share.mounted %}Yes{% else %}No{% endif %}</td></tr>
<tr><td>Read-Only</td><td>{% if share.read_only %}Yes{% else %}No{% endif %}</td></tr>
</table>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

View File

@ -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

View File

@ -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/<connection_name>/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"

View File

@ -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_EOF
[connection]
[[connections]]
name = "$conn_name"
nas_host = "$nas_host"
nas_user = "$nas_user"
nas_key_file = "$nas_key_file"
@ -161,9 +162,6 @@ SMB_AUTH_EOF
if [[ -n "$smb_auth_smb_pass" ]]; then
echo "smb_pass = \"$smb_auth_smb_pass\"" >> "$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_EOF
[connection]
[[connections]]
name = "$conn_name"
nas_host = "${MOCK_NAS_IP:-10.99.0.2}"
nas_user = "root"
nas_key_file = "${TEST_SSH_KEY:-$TEST_DIR/test_key}"
@ -204,6 +205,7 @@ dir = "${CACHE_DIR:-$TEST_DIR/cache}"
[[shares]]
name = "data"
connection = "$conn_name"
remote_path = "/"
mount_point = "${TEST_MOUNT:-$TEST_DIR/mnt}"
CONFIG_EOF
@ -220,7 +222,8 @@ _gen_broken_config() {
missing_field)
# Missing nas_host
cat > "$config_file" <<CONFIG_EOF
[connection]
[[connections]]
name = "nas"
nas_user = "root"
[cache]
@ -228,14 +231,15 @@ dir = "/tmp/cache"
[[shares]]
name = "data"
connection = "nas"
remote_path = "/"
mount_point = "/tmp/mnt"
CONFIG_EOF
;;
bad_toml)
cat > "$config_file" <<CONFIG_EOF
[connection
nas_host = "broken toml
[[connections
name = "broken toml
CONFIG_EOF
;;
*)