warpgate/src/config_diff.rs
grabbit 74b0e72549 Add periodic dir-refresh and per-share refresh status display
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>
2026-02-19 10:54:08 +08:00

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);
}
}