Introduces a ScheduledTask mechanism that periodically calls rclone RC vfs/refresh to keep directory listing caches warm (no file downloads), with two-level config (global default + per-share override). Adds dir-refresh status badges and timestamps to the web UI shares tab and CLI status output, following the same pattern as warmup/warmed. - src/scheduler.rs: New generic ScheduledTask runner with generation-based cancellation and parse_interval() helper - src/rclone/rc.rs: Add vfs_refresh() RC API call - src/config.rs: Add DirRefreshConfig, per-share dir_refresh_interval override, effective_dir_refresh_interval() resolution method - src/config_diff.rs: Track dir_refresh_changed for hot-reload - src/daemon.rs: Track per-share last_dir_refresh timestamps (HashMap), add dir_refresh_ago_for() helper and format_ago() - src/supervisor.rs: spawn_dir_refresh() per-share background threads, called on startup and config reload - src/web/api.rs: Expose dir_refresh_active + last_dir_refresh_ago in ShareStatusResponse - src/web/pages.rs: Populate dir_refresh_active + last_dir_refresh_ago in ShareView and ShareDetailView - templates/web/tabs/shares.html: DIR-REFRESH badge (yellow=pending, green=N ago) in health column; Dir Refresh row in detail panel - templates/web/tabs/config.html: Dir Refresh section and per-share interval field in interactive config editor - src/cli/status.rs: Append Dir-Refresh suffix to mount status lines Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
553 lines
18 KiB
Rust
553 lines
18 KiB
Rust
//! Config diff engine — classifies changes between old and new configs into tiers.
|
|
//!
|
|
//! Tier A: Bandwidth — live RC API call, no restart.
|
|
//! Tier B: Protocols — regen SMB/NFS configs, restart smbd/exportfs.
|
|
//! Tier C: Per-share — drain affected share, unmount, remount.
|
|
//! Tier D: Global — drain all, stop everything, restart.
|
|
|
|
use crate::config::Config;
|
|
|
|
/// The set of changes between two configs, classified by tier.
|
|
#[derive(Debug, Default)]
|
|
pub struct ConfigDiff {
|
|
/// Tier A: bandwidth limits changed.
|
|
pub bandwidth_changed: bool,
|
|
/// Tier B: protocol settings changed (SMB/NFS/WebDAV toggles, SMB auth, NFS network).
|
|
pub protocols_changed: bool,
|
|
/// Tier C: shares that were removed (by name).
|
|
pub shares_removed: Vec<String>,
|
|
/// Tier C: shares that were added (by name).
|
|
pub shares_added: Vec<String>,
|
|
/// Tier C: shares that were modified (remote_path, mount_point, read_only, or connection changed).
|
|
pub shares_modified: Vec<String>,
|
|
/// 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,
|
|
/// Warmup settings changed (no restart needed, just update in-memory config).
|
|
pub warmup_changed: bool,
|
|
/// Dir-refresh settings changed (no restart needed, just re-spawn background threads).
|
|
pub dir_refresh_changed: bool,
|
|
}
|
|
|
|
impl ConfigDiff {
|
|
/// Returns true if no changes were detected.
|
|
pub fn is_empty(&self) -> bool {
|
|
!self.bandwidth_changed
|
|
&& !self.protocols_changed
|
|
&& self.shares_removed.is_empty()
|
|
&& self.shares_added.is_empty()
|
|
&& self.shares_modified.is_empty()
|
|
&& self.connections_added.is_empty()
|
|
&& self.connections_removed.is_empty()
|
|
&& self.connections_modified.is_empty()
|
|
&& !self.global_changed
|
|
&& !self.warmup_changed
|
|
&& !self.dir_refresh_changed
|
|
}
|
|
|
|
/// Returns the highest tier of change detected.
|
|
pub fn highest_tier(&self) -> ChangeTier {
|
|
if self.global_changed {
|
|
ChangeTier::Global
|
|
} else if !self.shares_removed.is_empty()
|
|
|| !self.shares_added.is_empty()
|
|
|| !self.shares_modified.is_empty()
|
|
|| !self.connections_added.is_empty()
|
|
|| !self.connections_removed.is_empty()
|
|
|| !self.connections_modified.is_empty()
|
|
{
|
|
ChangeTier::PerShare
|
|
} else if self.protocols_changed {
|
|
ChangeTier::Protocol
|
|
} else if self.bandwidth_changed {
|
|
ChangeTier::Live
|
|
} else {
|
|
ChangeTier::None
|
|
}
|
|
}
|
|
|
|
/// Human-readable summary of changes.
|
|
pub fn summary(&self) -> String {
|
|
let mut parts = Vec::new();
|
|
if self.global_changed {
|
|
parts.push("global settings changed (full restart required)".to_string());
|
|
}
|
|
if !self.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(", ")));
|
|
}
|
|
if !self.shares_added.is_empty() {
|
|
parts.push(format!("shares added: {}", self.shares_added.join(", ")));
|
|
}
|
|
if !self.shares_modified.is_empty() {
|
|
parts.push(format!(
|
|
"shares modified: {}",
|
|
self.shares_modified.join(", ")
|
|
));
|
|
}
|
|
if self.protocols_changed {
|
|
parts.push("protocol settings changed".to_string());
|
|
}
|
|
if self.bandwidth_changed {
|
|
parts.push("bandwidth limits changed".to_string());
|
|
}
|
|
if self.warmup_changed {
|
|
parts.push("warmup settings changed".to_string());
|
|
}
|
|
if self.dir_refresh_changed {
|
|
parts.push("dir-refresh settings changed".to_string());
|
|
}
|
|
if parts.is_empty() {
|
|
"no changes detected".to_string()
|
|
} else {
|
|
parts.join("; ")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tier of change, from least to most disruptive.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum ChangeTier {
|
|
None,
|
|
/// Tier A: live RC API call.
|
|
Live,
|
|
/// Tier B: protocol restart only.
|
|
Protocol,
|
|
/// Tier C: per-share mount restart.
|
|
PerShare,
|
|
/// Tier D: full global restart.
|
|
Global,
|
|
}
|
|
|
|
/// Compare two configs and classify all changes.
|
|
pub fn diff(old: &Config, new: &Config) -> ConfigDiff {
|
|
let mut d = ConfigDiff::default();
|
|
|
|
// Tier D: global settings (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
|
|
|| old.read.chunk_size != new.read.chunk_size
|
|
|| old.read.chunk_limit != new.read.chunk_limit
|
|
|| old.read.read_ahead != new.read.read_ahead
|
|
|| old.read.buffer_size != new.read.buffer_size
|
|
|| old.writeback.write_back != new.writeback.write_back
|
|
|| old.writeback.transfers != new.writeback.transfers
|
|
|| old.directory_cache.cache_time != new.directory_cache.cache_time;
|
|
|
|
// Tier A: bandwidth
|
|
d.bandwidth_changed = old.bandwidth.limit_up != new.bandwidth.limit_up
|
|
|| old.bandwidth.limit_down != new.bandwidth.limit_down
|
|
|| old.bandwidth.adaptive != new.bandwidth.adaptive;
|
|
|
|
// Tier B: protocols
|
|
d.protocols_changed = old.protocols.enable_smb != new.protocols.enable_smb
|
|
|| old.protocols.enable_nfs != new.protocols.enable_nfs
|
|
|| old.protocols.enable_webdav != new.protocols.enable_webdav
|
|
|| old.protocols.nfs_allowed_network != new.protocols.nfs_allowed_network
|
|
|| old.protocols.webdav_port != new.protocols.webdav_port
|
|
|| old.smb_auth.enabled != new.smb_auth.enabled
|
|
|| old.smb_auth.username != new.smb_auth.username
|
|
|| old.smb_auth.smb_pass != new.smb_auth.smb_pass;
|
|
|
|
// 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> =
|
|
old.shares.iter().map(|s| (s.name.as_str(), s)).collect();
|
|
let new_shares: std::collections::HashMap<&str, &crate::config::ShareConfig> =
|
|
new.shares.iter().map(|s| (s.name.as_str(), s)).collect();
|
|
|
|
// Removed shares
|
|
for name in old_shares.keys() {
|
|
if !new_shares.contains_key(name) {
|
|
d.shares_removed.push(name.to_string());
|
|
}
|
|
}
|
|
|
|
// Added shares
|
|
for name in new_shares.keys() {
|
|
if !old_shares.contains_key(name) {
|
|
d.shares_added.push(name.to_string());
|
|
}
|
|
}
|
|
|
|
// Modified shares (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
|
|
{
|
|
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();
|
|
|
|
// Warmup changes (no restart needed)
|
|
d.warmup_changed = old.warmup.auto != new.warmup.auto
|
|
|| old.warmup.rules.len() != new.warmup.rules.len()
|
|
|| old
|
|
.warmup
|
|
.rules
|
|
.iter()
|
|
.zip(new.warmup.rules.iter())
|
|
.any(|(o, n)| o.share != n.share || o.path != n.path || o.newer_than != n.newer_than);
|
|
|
|
// Dir-refresh changes (no restart needed, just re-spawn background threads)
|
|
let dir_refresh_global_changed = old.dir_refresh != new.dir_refresh;
|
|
let dir_refresh_per_share_changed = {
|
|
// Compare per-share overrides by (name → dir_refresh_interval) mapping
|
|
let old_map: std::collections::HashMap<&str, Option<&str>> = old
|
|
.shares
|
|
.iter()
|
|
.map(|s| (s.name.as_str(), s.dir_refresh_interval.as_deref()))
|
|
.collect();
|
|
let new_map: std::collections::HashMap<&str, Option<&str>> = new
|
|
.shares
|
|
.iter()
|
|
.map(|s| (s.name.as_str(), s.dir_refresh_interval.as_deref()))
|
|
.collect();
|
|
old_map != new_map
|
|
};
|
|
d.dir_refresh_changed = dir_refresh_global_changed || dir_refresh_per_share_changed;
|
|
|
|
d
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn minimal_config() -> Config {
|
|
toml::from_str(
|
|
r#"
|
|
[[connections]]
|
|
name = "nas"
|
|
nas_host = "10.0.0.1"
|
|
nas_user = "admin"
|
|
|
|
[cache]
|
|
dir = "/tmp/cache"
|
|
|
|
[read]
|
|
[bandwidth]
|
|
[writeback]
|
|
[directory_cache]
|
|
[protocols]
|
|
|
|
[[shares]]
|
|
name = "photos"
|
|
connection = "nas"
|
|
remote_path = "/photos"
|
|
mount_point = "/mnt/photos"
|
|
"#,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_changes() {
|
|
let config = minimal_config();
|
|
let d = diff(&config, &config);
|
|
assert!(d.is_empty());
|
|
assert_eq!(d.highest_tier(), ChangeTier::None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bandwidth_change() {
|
|
let old = minimal_config();
|
|
let mut new = old.clone();
|
|
new.bandwidth.limit_up = "10M".to_string();
|
|
let d = diff(&old, &new);
|
|
assert!(d.bandwidth_changed);
|
|
assert!(!d.global_changed);
|
|
assert_eq!(d.highest_tier(), ChangeTier::Live);
|
|
}
|
|
|
|
#[test]
|
|
fn test_protocol_change() {
|
|
let old = minimal_config();
|
|
let mut new = old.clone();
|
|
new.protocols.enable_nfs = true;
|
|
let d = diff(&old, &new);
|
|
assert!(d.protocols_changed);
|
|
assert_eq!(d.highest_tier(), ChangeTier::Protocol);
|
|
}
|
|
|
|
#[test]
|
|
fn test_share_added() {
|
|
let old = minimal_config();
|
|
let mut new = old.clone();
|
|
new.shares.push(crate::config::ShareConfig {
|
|
name: "videos".to_string(),
|
|
connection: "nas".to_string(),
|
|
remote_path: "/videos".to_string(),
|
|
mount_point: "/mnt/videos".into(),
|
|
read_only: false,
|
|
dir_refresh_interval: None,
|
|
});
|
|
let d = diff(&old, &new);
|
|
assert_eq!(d.shares_added, vec!["videos"]);
|
|
assert_eq!(d.highest_tier(), ChangeTier::PerShare);
|
|
}
|
|
|
|
#[test]
|
|
fn test_share_removed() {
|
|
let old = minimal_config();
|
|
let mut new = old.clone();
|
|
new.shares.clear();
|
|
new.shares.push(crate::config::ShareConfig {
|
|
name: "videos".to_string(),
|
|
connection: "nas".to_string(),
|
|
remote_path: "/videos".to_string(),
|
|
mount_point: "/mnt/videos".into(),
|
|
read_only: false,
|
|
dir_refresh_interval: None,
|
|
});
|
|
let d = diff(&old, &new);
|
|
assert_eq!(d.shares_removed, vec!["photos"]);
|
|
assert_eq!(d.shares_added, vec!["videos"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_share_modified() {
|
|
let old = minimal_config();
|
|
let mut new = old.clone();
|
|
new.shares[0].remote_path = "/volume1/photos".to_string();
|
|
let d = diff(&old, &new);
|
|
assert_eq!(d.shares_modified, vec!["photos"]);
|
|
assert_eq!(d.highest_tier(), ChangeTier::PerShare);
|
|
}
|
|
|
|
#[test]
|
|
fn test_global_change() {
|
|
let old = minimal_config();
|
|
let mut new = old.clone();
|
|
new.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();
|
|
let mut new = old.clone();
|
|
new.bandwidth.limit_up = "10M".to_string();
|
|
new.protocols.enable_nfs = true;
|
|
let d = diff(&old, &new);
|
|
let summary = d.summary();
|
|
assert!(summary.contains("protocol"));
|
|
assert!(summary.contains("bandwidth"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_warmup_change() {
|
|
let old = minimal_config();
|
|
let mut new = old.clone();
|
|
new.warmup.auto = true;
|
|
new.warmup.rules.push(crate::config::WarmupRule {
|
|
share: "photos".to_string(),
|
|
path: "/2024".to_string(),
|
|
newer_than: Some("7d".to_string()),
|
|
});
|
|
let d = diff(&old, &new);
|
|
assert!(d.warmup_changed);
|
|
assert!(!d.global_changed);
|
|
// Warmup-only changes need no restart
|
|
assert_eq!(d.highest_tier(), ChangeTier::None);
|
|
assert!(!d.is_empty());
|
|
assert!(d.summary().contains("warmup"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tier_ordering() {
|
|
assert!(ChangeTier::None < ChangeTier::Live);
|
|
assert!(ChangeTier::Live < ChangeTier::Protocol);
|
|
assert!(ChangeTier::Protocol < ChangeTier::PerShare);
|
|
assert!(ChangeTier::PerShare < ChangeTier::Global);
|
|
}
|
|
}
|