//! `warpgate status` — show service status, cache stats, write-back queue, bandwidth. //! //! Prefers querying the daemon's web API for complete health information. //! Falls back to direct mount/RC checks when the daemon API is unreachable. use anyhow::Result; use serde::Deserialize; use crate::config::Config; use crate::daemon::DEFAULT_WEB_PORT; use crate::rclone::{mount, rc}; /// JSON response from GET /api/status. #[derive(Deserialize)] struct ApiStatus { shares: Vec, } #[derive(Deserialize)] struct ApiShare { name: String, mounted: bool, health: String, health_message: Option, cache_bytes: u64, dirty_count: u64, errored_files: u64, speed: f64, transfers: u64, errors: u64, #[serde(default)] warmup_state: Option, #[serde(default)] warmup_done: Option, #[serde(default)] warmup_total: Option, #[serde(default)] dir_refresh_active: Option, #[serde(default)] last_dir_refresh_ago: Option, } pub fn run(config: &Config) -> Result<()> { // Try daemon API first for full health information if let Some(api) = try_daemon_api() { return print_api_status(&api); } // Fallback: direct mount/RC checks (daemon not running) print_direct_status(config) } /// Try to reach the daemon's web API. fn try_daemon_api() -> Option { let url = format!("http://127.0.0.1:{DEFAULT_WEB_PORT}/api/status"); let resp = ureq::get(&url).call().ok()?; resp.into_body().read_json().ok() } /// Print status using daemon API response (includes health info). fn print_api_status(api: &ApiStatus) -> Result<()> { let mut any_active = false; for share in &api.shares { // Build warmup suffix let warmup_suffix = match share.warmup_state.as_deref() { Some("running") => { let done = share.warmup_done.unwrap_or(0); let total = share.warmup_total.unwrap_or(0); format!("\tWarmup [{done}/{total}]") } Some("pending") => "\tWarmup...".to_string(), Some("complete") => "\tWarmup done".to_string(), Some("failed") => "\tWarmup FAILED".to_string(), _ => String::new(), }; // Build dir-refresh suffix let dir_refresh_suffix = if share.dir_refresh_active == Some(true) { match share.last_dir_refresh_ago.as_deref() { Some(ago) => format!("\tDir-Refresh {ago}"), None => "\tDir-Refresh pending...".to_string(), } } else { String::new() }; match share.health.as_str() { "OK" => { if share.mounted { println!("Mount: OK {}{}{}", share.name, warmup_suffix, dir_refresh_suffix); any_active = true; } else { println!("Mount: DOWN {} — mount lost", share.name); any_active = true; } } "FAILED" => { let msg = share .health_message .as_deref() .unwrap_or("probe failed"); println!("Mount: FAILED {} — {}", share.name, msg); } "PROBING" => { println!("Mount: PROBING {}", share.name); } _ => { println!("Mount: PENDING {}", share.name); } } } if !any_active { println!("\nNo healthy mounts are active."); return Ok(()); } // Aggregate stats from API response let mut total_speed = 0.0f64; let mut total_cache = 0u64; let mut total_dirty = 0u64; let mut total_transfers = 0u64; let mut total_errors = 0u64; let mut total_errored = 0u64; for share in &api.shares { if share.health == "OK" { total_speed += share.speed; total_cache += share.cache_bytes; total_dirty += share.dirty_count; total_transfers += share.transfers; total_errors += share.errors; total_errored += share.errored_files; } } println!("Speed: {}/s", format_bytes(total_speed as u64)); println!("Active: {} transfers", total_transfers); println!("Errors: {}", total_errors); println!("Cache: {}", format_bytes(total_cache)); println!("Dirty: {}", total_dirty); if total_errored > 0 { println!("Errored: {} files", total_errored); } // "Safe to disconnect" indicator if total_dirty == 0 && total_transfers == 0 { println!("\n[OK] All synced — safe to disconnect"); } else { println!( "\n[!!] {} dirty files, {} active transfers — DO NOT disconnect", total_dirty, total_transfers ); } Ok(()) } /// Fallback: check mounts and RC API directly (no daemon API). fn print_direct_status(config: &Config) -> Result<()> { let mut any_mounted = false; for share in &config.shares { let mounted = match mount::is_mounted(&share.mount_point) { Ok(m) => m, Err(e) => { eprintln!( "Warning: could not check mount for '{}': {}", share.name, e ); false } }; let ro_tag = if share.read_only { " (ro)" } else { "" }; if mounted { println!( "Mount: OK {} → {}{}", share.name, share.mount_point.display(), ro_tag ); any_mounted = true; } else { println!("Mount: DOWN {}{}", share.name, ro_tag); } } if !any_mounted { println!("\nNo rclone VFS mounts are active."); println!("Start with: systemctl start warpgate"); return Ok(()); } // Aggregate stats from all share RC ports let mut total_bytes = 0u64; let mut total_speed = 0.0f64; let mut total_transfers = 0u64; let mut total_errors = 0u64; let mut total_cache_used = 0u64; let mut total_uploading = 0u64; let mut total_queued = 0u64; let mut total_errored = 0u64; let mut rc_reachable = false; for (i, _share) in config.shares.iter().enumerate() { let port = config.rc_port(i); if let Ok(stats) = rc::core_stats(port) { rc_reachable = true; total_bytes += stats.bytes; total_speed += stats.speed; total_transfers += stats.transfers; total_errors += stats.errors; } if let Ok(vfs) = rc::vfs_stats(port) { if let Some(dc) = vfs.disk_cache { total_cache_used += dc.bytes_used; total_uploading += dc.uploads_in_progress; total_queued += dc.uploads_queued; total_errored += dc.errored_files; } } } if rc_reachable { println!("Speed: {}/s", format_bytes(total_speed as u64)); println!("Moved: {}", format_bytes(total_bytes)); println!("Active: {} transfers", total_transfers); println!("Errors: {}", total_errors); println!("Cache: {}", format_bytes(total_cache_used)); println!( "Dirty: {} uploading, {} queued", total_uploading, total_queued ); if total_errored > 0 { println!("Errored: {} files", total_errored); } } else { eprintln!("Could not reach any rclone RC API."); } Ok(()) } fn format_bytes(bytes: u64) -> String { const KIB: f64 = 1024.0; const MIB: f64 = KIB * 1024.0; const GIB: f64 = MIB * 1024.0; let b = bytes as f64; 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!("{} B", bytes) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_format_bytes_zero() { assert_eq!(format_bytes(0), "0 B"); } #[test] fn test_format_bytes_bytes() { assert_eq!(format_bytes(512), "512 B"); } #[test] fn test_format_bytes_kib() { assert_eq!(format_bytes(1024), "1.0 KiB"); assert_eq!(format_bytes(1536), "1.5 KiB"); } #[test] fn test_format_bytes_mib() { assert_eq!(format_bytes(1048576), "1.0 MiB"); } #[test] fn test_format_bytes_gib() { assert_eq!(format_bytes(1073741824), "1.0 GiB"); } #[test] fn test_format_bytes_boundary() { assert_eq!(format_bytes(1023), "1023 B"); assert_eq!(format_bytes(1024), "1.0 KiB"); } }