- warpgate setup: interactive Q&A wizard for first-time configuration - warpgate preset: apply photographer/video/office presets from PRD §9 - warpgate reconnect: re-probe + re-mount share without full restart - warpgate deploy: test NAS SFTP connectivity before installing systemd - warpgate status: show 'All synced — safe to disconnect' indicator - SupervisorCmd::Reconnect(String) for daemon command channel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
298 lines
8.6 KiB
Rust
298 lines
8.6 KiB
Rust
//! `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<ApiShare>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ApiShare {
|
|
name: String,
|
|
mounted: bool,
|
|
health: String,
|
|
health_message: Option<String>,
|
|
cache_bytes: u64,
|
|
dirty_count: u64,
|
|
errored_files: u64,
|
|
speed: f64,
|
|
transfers: u64,
|
|
errors: u64,
|
|
#[serde(default)]
|
|
warmup_state: Option<String>,
|
|
#[serde(default)]
|
|
warmup_done: Option<usize>,
|
|
#[serde(default)]
|
|
warmup_total: Option<usize>,
|
|
#[serde(default)]
|
|
dir_refresh_active: Option<bool>,
|
|
#[serde(default)]
|
|
last_dir_refresh_ago: Option<String>,
|
|
}
|
|
|
|
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<ApiStatus> {
|
|
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");
|
|
}
|
|
}
|