Add pre-mount remote path probe and per-share health status
Before mounting, probe each share's remote path with `rclone lsf` (10s timeout, parallel execution). Failed shares are skipped — they never get mounted or exposed to SMB/NFS/WebDAV — preventing the silent hang that occurred when rclone mounted a nonexistent directory. - ShareHealth enum: Pending → Probing → Healthy / Failed(reason) - Supervisor: probe phase between preflight and mount, protocol configs generated after probe with only healthy shares - Web UI: health-aware badges (OK/FAILED/PROBING/PENDING) with error messages on dashboard, status partial, and share detail - JSON API: health + health_message fields on /api/status - CLI: `warpgate status` queries daemon API first for tri-state display (OK/FAILED/DOWN), falls back to direct mount checks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ba1cae7f75
commit
466ea5cfa8
@ -1,18 +1,130 @@
|
|||||||
//! `warpgate status` — show service status, cache stats, write-back queue, bandwidth.
|
//! `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 anyhow::Result;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::daemon::DEFAULT_WEB_PORT;
|
||||||
use crate::rclone::{mount, rc};
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run(config: &Config) -> Result<()> {
|
pub fn run(config: &Config) -> Result<()> {
|
||||||
// Check mount status for each share
|
// 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 {
|
||||||
|
match share.health.as_str() {
|
||||||
|
"OK" => {
|
||||||
|
if share.mounted {
|
||||||
|
println!("Mount: OK {}", share.name);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback: check mounts and RC API directly (no daemon API).
|
||||||
|
fn print_direct_status(config: &Config) -> Result<()> {
|
||||||
let mut any_mounted = false;
|
let mut any_mounted = false;
|
||||||
for share in &config.shares {
|
for share in &config.shares {
|
||||||
let mounted = match mount::is_mounted(&share.mount_point) {
|
let mounted = match mount::is_mounted(&share.mount_point) {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Warning: could not check mount for '{}': {}", share.name, e);
|
eprintln!(
|
||||||
|
"Warning: could not check mount for '{}': {}",
|
||||||
|
share.name, e
|
||||||
|
);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -20,14 +132,14 @@ pub fn run(config: &Config) -> Result<()> {
|
|||||||
let ro_tag = if share.read_only { " (ro)" } else { "" };
|
let ro_tag = if share.read_only { " (ro)" } else { "" };
|
||||||
if mounted {
|
if mounted {
|
||||||
println!(
|
println!(
|
||||||
"Mount: UP {} → {}{}",
|
"Mount: OK {} → {}{}",
|
||||||
share.mount_point.display(),
|
|
||||||
share.name,
|
share.name,
|
||||||
|
share.mount_point.display(),
|
||||||
ro_tag
|
ro_tag
|
||||||
);
|
);
|
||||||
any_mounted = true;
|
any_mounted = true;
|
||||||
} else {
|
} else {
|
||||||
println!("Mount: DOWN {}{}", share.name, ro_tag);
|
println!("Mount: DOWN {}{}", share.name, ro_tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,17 +180,17 @@ pub fn run(config: &Config) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rc_reachable {
|
if rc_reachable {
|
||||||
println!("Speed: {}/s", format_bytes(total_speed as u64));
|
println!("Speed: {}/s", format_bytes(total_speed as u64));
|
||||||
println!("Moved: {}", format_bytes(total_bytes));
|
println!("Moved: {}", format_bytes(total_bytes));
|
||||||
println!("Active: {} transfers", total_transfers);
|
println!("Active: {} transfers", total_transfers);
|
||||||
println!("Errors: {}", total_errors);
|
println!("Errors: {}", total_errors);
|
||||||
println!("Cache: {}", format_bytes(total_cache_used));
|
println!("Cache: {}", format_bytes(total_cache_used));
|
||||||
println!(
|
println!(
|
||||||
"Dirty: {} uploading, {} queued",
|
"Dirty: {} uploading, {} queued",
|
||||||
total_uploading, total_queued
|
total_uploading, total_queued
|
||||||
);
|
);
|
||||||
if total_errored > 0 {
|
if total_errored > 0 {
|
||||||
println!("Errored: {} files", total_errored);
|
println!("Errored: {} files", total_errored);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
eprintln!("Could not reach any rclone RC API.");
|
eprintln!("Could not reach any rclone RC API.");
|
||||||
|
|||||||
@ -56,6 +56,7 @@ impl DaemonStatus {
|
|||||||
speed: 0.0,
|
speed: 0.0,
|
||||||
transfers: 0,
|
transfers: 0,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
|
health: ShareHealth::Pending,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
smbd_running: false,
|
smbd_running: false,
|
||||||
@ -100,6 +101,8 @@ pub struct ShareStatus {
|
|||||||
pub transfers: u64,
|
pub transfers: u64,
|
||||||
/// Cumulative error count.
|
/// Cumulative error count.
|
||||||
pub errors: u64,
|
pub errors: u64,
|
||||||
|
/// Pre-mount probe result.
|
||||||
|
pub health: ShareHealth,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShareStatus {
|
impl ShareStatus {
|
||||||
@ -116,6 +119,29 @@ impl ShareStatus {
|
|||||||
format!("{}/s", format_bytes(self.speed as u64))
|
format!("{}/s", format_bytes(self.speed as u64))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Human-readable health label: "PENDING", "PROBING", "OK", or "FAILED".
|
||||||
|
pub fn health_label(&self) -> &str {
|
||||||
|
match &self.health {
|
||||||
|
ShareHealth::Pending => "PENDING",
|
||||||
|
ShareHealth::Probing => "PROBING",
|
||||||
|
ShareHealth::Healthy => "OK",
|
||||||
|
ShareHealth::Failed(_) => "FAILED",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error message when health is Failed, None otherwise.
|
||||||
|
pub fn health_message(&self) -> Option<&str> {
|
||||||
|
match &self.health {
|
||||||
|
ShareHealth::Failed(msg) => Some(msg),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the share is healthy (probe succeeded).
|
||||||
|
pub fn is_healthy(&self) -> bool {
|
||||||
|
self.health == ShareHealth::Healthy
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format bytes as human-readable (e.g. "45.2 GiB").
|
/// Format bytes as human-readable (e.g. "45.2 GiB").
|
||||||
@ -139,6 +165,19 @@ fn format_bytes(bytes: u64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-share health state from pre-mount probing.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum ShareHealth {
|
||||||
|
/// Not yet probed (initial state).
|
||||||
|
Pending,
|
||||||
|
/// Probe in progress.
|
||||||
|
Probing,
|
||||||
|
/// Remote path verified, ready to mount.
|
||||||
|
Healthy,
|
||||||
|
/// Probe failed — share will not be mounted.
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
/// Commands sent from the web server (or CLI) to the supervisor.
|
/// Commands sent from the web server (or CLI) to the supervisor.
|
||||||
pub enum SupervisorCmd {
|
pub enum SupervisorCmd {
|
||||||
/// Apply a new configuration (triggers tiered reload).
|
/// Apply a new configuration (triggers tiered reload).
|
||||||
@ -195,6 +234,7 @@ mod tests {
|
|||||||
speed: 2_200_000.0,
|
speed: 2_200_000.0,
|
||||||
transfers: 2,
|
transfers: 2,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
|
health: ShareHealth::Healthy,
|
||||||
};
|
};
|
||||||
assert!(share.cache_display().contains("GiB"));
|
assert!(share.cache_display().contains("GiB"));
|
||||||
assert!(share.speed_display().contains("/s"));
|
assert!(share.speed_display().contains("/s"));
|
||||||
@ -212,7 +252,39 @@ mod tests {
|
|||||||
speed: 0.0,
|
speed: 0.0,
|
||||||
transfers: 0,
|
transfers: 0,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
|
health: ShareHealth::Pending,
|
||||||
};
|
};
|
||||||
assert_eq!(share.speed_display(), "-");
|
assert_eq!(share.speed_display(), "-");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_share_health_labels() {
|
||||||
|
let mut share = ShareStatus {
|
||||||
|
name: "test".into(),
|
||||||
|
mounted: false,
|
||||||
|
rc_port: 0,
|
||||||
|
cache_bytes: 0,
|
||||||
|
dirty_count: 0,
|
||||||
|
errored_files: 0,
|
||||||
|
speed: 0.0,
|
||||||
|
transfers: 0,
|
||||||
|
errors: 0,
|
||||||
|
health: ShareHealth::Pending,
|
||||||
|
};
|
||||||
|
assert_eq!(share.health_label(), "PENDING");
|
||||||
|
assert!(share.health_message().is_none());
|
||||||
|
assert!(!share.is_healthy());
|
||||||
|
|
||||||
|
share.health = ShareHealth::Probing;
|
||||||
|
assert_eq!(share.health_label(), "PROBING");
|
||||||
|
|
||||||
|
share.health = ShareHealth::Healthy;
|
||||||
|
assert_eq!(share.health_label(), "OK");
|
||||||
|
assert!(share.is_healthy());
|
||||||
|
|
||||||
|
share.health = ShareHealth::Failed("remote path not found".into());
|
||||||
|
assert_eq!(share.health_label(), "FAILED");
|
||||||
|
assert_eq!(share.health_message(), Some("remote path not found"));
|
||||||
|
assert!(!share.is_healthy());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod mount;
|
pub mod mount;
|
||||||
|
pub mod probe;
|
||||||
pub mod rc;
|
pub mod rc;
|
||||||
|
|||||||
156
src/rclone/probe.rs
Normal file
156
src/rclone/probe.rs
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
//! Pre-mount remote path probing using `rclone lsf`.
|
||||||
|
//!
|
||||||
|
//! Before mounting, we verify that each share's remote path actually exists.
|
||||||
|
//! This prevents rclone from mounting a FUSE filesystem that silently fails
|
||||||
|
//! when clients try to access it.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use crate::config::{Config, ShareConfig};
|
||||||
|
use crate::rclone::config::RCLONE_CONF_PATH;
|
||||||
|
|
||||||
|
/// Probe timeout per share.
|
||||||
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Probe whether a remote path exists using `rclone lsf`.
|
||||||
|
///
|
||||||
|
/// Runs: `rclone lsf <connection>:<remote_path> --max-depth 1 --config <rclone.conf>`
|
||||||
|
///
|
||||||
|
/// Returns `Ok(())` if the directory exists, `Err` with a descriptive message if not.
|
||||||
|
pub fn probe_remote_path(_config: &Config, share: &ShareConfig) -> Result<()> {
|
||||||
|
let remote = format!("{}:{}", share.connection, share.remote_path);
|
||||||
|
|
||||||
|
let mut child = Command::new("rclone")
|
||||||
|
.args([
|
||||||
|
"lsf",
|
||||||
|
&remote,
|
||||||
|
"--max-depth",
|
||||||
|
"1",
|
||||||
|
"--config",
|
||||||
|
RCLONE_CONF_PATH,
|
||||||
|
])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("Failed to spawn rclone lsf for '{}'", share.name))?;
|
||||||
|
|
||||||
|
// Wait with timeout using a polling loop
|
||||||
|
let deadline = std::time::Instant::now() + PROBE_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(status)) => {
|
||||||
|
if status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Non-zero exit: extract stderr for error message
|
||||||
|
let stderr = if let Some(mut err) = child.stderr.take() {
|
||||||
|
let mut buf = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut err, &mut buf).unwrap_or(0);
|
||||||
|
buf
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let msg = stderr.trim();
|
||||||
|
if msg.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"remote path not found: {} (exit code {})",
|
||||||
|
share.remote_path,
|
||||||
|
status.code().unwrap_or(-1)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("{}", extract_rclone_error(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
// Still running — check timeout
|
||||||
|
if std::time::Instant::now() > deadline {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
anyhow::bail!(
|
||||||
|
"probe timed out after {}s for path: {}",
|
||||||
|
PROBE_TIMEOUT.as_secs(),
|
||||||
|
share.remote_path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
anyhow::bail!("failed to check rclone lsf status: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the most useful part of rclone's error output.
|
||||||
|
///
|
||||||
|
/// rclone stderr often contains timestamps and log levels; we strip those
|
||||||
|
/// to get a cleaner message for the user.
|
||||||
|
fn extract_rclone_error(stderr: &str) -> String {
|
||||||
|
// rclone errors look like:
|
||||||
|
// 2024/01/01 12:00:00 ERROR : : error listing: directory not found
|
||||||
|
// We want just the error message part after "ERROR : : ".
|
||||||
|
for line in stderr.lines().rev() {
|
||||||
|
if let Some(pos) = line.find("ERROR") {
|
||||||
|
let after_error = &line[pos + 5..]; // skip "ERROR"
|
||||||
|
// Skip " : : " or " : name : " separators
|
||||||
|
let trimmed = after_error.trim_start_matches(|c: char| c == ' ' || c == ':');
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return trimmed.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: return last non-empty line
|
||||||
|
stderr
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.find(|l| !l.trim().is_empty())
|
||||||
|
.unwrap_or(stderr)
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_rclone_error_with_error_prefix() {
|
||||||
|
let stderr = "2024/01/01 12:00:00 ERROR : : error listing: directory not found";
|
||||||
|
assert_eq!(
|
||||||
|
extract_rclone_error(stderr),
|
||||||
|
"error listing: directory not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_rclone_error_multiline() {
|
||||||
|
let stderr = "2024/01/01 12:00:00 NOTICE: some notice\n\
|
||||||
|
2024/01/01 12:00:01 ERROR : : Failed to ls: directory not found";
|
||||||
|
assert_eq!(
|
||||||
|
extract_rclone_error(stderr),
|
||||||
|
"Failed to ls: directory not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_rclone_error_no_error_prefix() {
|
||||||
|
let stderr = "some unexpected error output\n";
|
||||||
|
assert_eq!(
|
||||||
|
extract_rclone_error(stderr),
|
||||||
|
"some unexpected error output"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_rclone_error_empty() {
|
||||||
|
assert_eq!(extract_rclone_error(""), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_probe_timeout_is_10s() {
|
||||||
|
assert_eq!(PROBE_TIMEOUT, Duration::from_secs(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,7 +17,7 @@ use anyhow::{Context, Result};
|
|||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::config_diff::{self, ChangeTier};
|
use crate::config_diff::{self, ChangeTier};
|
||||||
use crate::daemon::{DaemonStatus, SupervisorCmd};
|
use crate::daemon::{DaemonStatus, ShareHealth, SupervisorCmd};
|
||||||
use crate::rclone::mount::{build_mount_args, is_mounted};
|
use crate::rclone::mount::{build_mount_args, is_mounted};
|
||||||
use crate::rclone::rc;
|
use crate::rclone::rc;
|
||||||
use crate::services::{nfs, samba, webdav};
|
use crate::services::{nfs, samba, webdav};
|
||||||
@ -132,22 +132,39 @@ pub fn run(config: &Config, config_path: PathBuf) -> Result<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Phase 1: Preflight — generate configs, create dirs
|
// Phase 1: Preflight — create dirs, write rclone.conf
|
||||||
println!("Preflight checks...");
|
println!("Preflight checks...");
|
||||||
preflight(config)?;
|
preflight(config)?;
|
||||||
|
|
||||||
// Phase 2: Start rclone mounts (one per share) and wait for all to become ready
|
// Phase 1.5: Probe remote paths in parallel
|
||||||
|
println!("Probing remote paths...");
|
||||||
|
let healthy_names = probe_all_shares(config, &shared_status, &shutdown)?;
|
||||||
|
|
||||||
|
if healthy_names.is_empty() {
|
||||||
|
anyhow::bail!("All shares failed probe — no healthy mounts to start");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a config containing only healthy shares for protocol configs
|
||||||
|
let mut healthy_config = config.clone();
|
||||||
|
healthy_config
|
||||||
|
.shares
|
||||||
|
.retain(|s| healthy_names.contains(&s.name));
|
||||||
|
|
||||||
|
// Phase 1.75: Generate protocol configs with only healthy shares
|
||||||
|
write_protocol_configs(&healthy_config)?;
|
||||||
|
|
||||||
|
// Phase 2: Start rclone mounts only for healthy shares
|
||||||
println!("Starting rclone mounts...");
|
println!("Starting rclone mounts...");
|
||||||
let mut mount_children = start_and_wait_mounts(config, &shutdown)?;
|
let mut mount_children = start_and_wait_mounts(&healthy_config, &shutdown)?;
|
||||||
for share in &config.shares {
|
for share in &healthy_config.shares {
|
||||||
println!(" Mount ready at {}", share.mount_point.display());
|
println!(" Mount ready at {}", share.mount_point.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update status: mounts are ready
|
// Update status: mounts are ready (match by name, not index)
|
||||||
{
|
{
|
||||||
let mut status = shared_status.write().unwrap();
|
let mut status = shared_status.write().unwrap();
|
||||||
for (i, mc) in mount_children.iter().enumerate() {
|
for mc in &mount_children {
|
||||||
if let Some(ss) = status.shares.get_mut(i) {
|
if let Some(ss) = status.shares.iter_mut().find(|s| s.name == mc.name) {
|
||||||
ss.mounted = true;
|
ss.mounted = true;
|
||||||
ss.rc_port = mc.rc_port;
|
ss.rc_port = mc.rc_port;
|
||||||
}
|
}
|
||||||
@ -164,14 +181,14 @@ pub fn run(config: &Config, config_path: PathBuf) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
println!("Starting protocol services...");
|
println!("Starting protocol services...");
|
||||||
let mut protocols = start_protocols(config)?;
|
let mut protocols = start_protocols(&healthy_config)?;
|
||||||
|
|
||||||
// Update status: protocols running
|
// Update status: protocols running
|
||||||
{
|
{
|
||||||
let mut status = shared_status.write().unwrap();
|
let mut status = shared_status.write().unwrap();
|
||||||
status.smbd_running = protocols.smbd.is_some();
|
status.smbd_running = protocols.smbd.is_some();
|
||||||
status.webdav_running = protocols.webdav.is_some();
|
status.webdav_running = protocols.webdav.is_some();
|
||||||
status.nfs_exported = config.protocols.enable_nfs;
|
status.nfs_exported = healthy_config.protocols.enable_nfs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3.5: Auto-warmup in background thread (non-blocking)
|
// Phase 3.5: Auto-warmup in background thread (non-blocking)
|
||||||
@ -217,7 +234,7 @@ pub fn run(config: &Config, config_path: PathBuf) -> Result<()> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write configs and create directories.
|
/// Write rclone config and create directories (protocol configs generated after probe).
|
||||||
fn preflight(config: &Config) -> Result<()> {
|
fn preflight(config: &Config) -> Result<()> {
|
||||||
// Ensure mount points exist for each share
|
// Ensure mount points exist for each share
|
||||||
for share in &config.shares {
|
for share in &config.shares {
|
||||||
@ -240,7 +257,13 @@ fn preflight(config: &Config) -> Result<()> {
|
|||||||
// Generate rclone config
|
// Generate rclone config
|
||||||
crate::rclone::config::write_config(config)?;
|
crate::rclone::config::write_config(config)?;
|
||||||
|
|
||||||
// Generate protocol configs
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate protocol configs (SMB/NFS) for the given config.
|
||||||
|
///
|
||||||
|
/// Called after probing so only healthy shares are included.
|
||||||
|
fn write_protocol_configs(config: &Config) -> Result<()> {
|
||||||
if config.protocols.enable_smb {
|
if config.protocols.enable_smb {
|
||||||
samba::write_config(config)?;
|
samba::write_config(config)?;
|
||||||
if config.smb_auth.enabled {
|
if config.smb_auth.enabled {
|
||||||
@ -250,10 +273,75 @@ fn preflight(config: &Config) -> Result<()> {
|
|||||||
if config.protocols.enable_nfs {
|
if config.protocols.enable_nfs {
|
||||||
nfs::write_config(config)?;
|
nfs::write_config(config)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Probe all shares in parallel and return the set of healthy share names.
|
||||||
|
///
|
||||||
|
/// Updates `shared_status` with probe results as they complete.
|
||||||
|
fn probe_all_shares(
|
||||||
|
config: &Config,
|
||||||
|
shared_status: &Arc<RwLock<DaemonStatus>>,
|
||||||
|
shutdown: &AtomicBool,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let shares: Vec<_> = config.shares.clone();
|
||||||
|
let config_clone = config.clone();
|
||||||
|
|
||||||
|
// Mark all shares as Probing
|
||||||
|
{
|
||||||
|
let mut status = shared_status.write().unwrap();
|
||||||
|
for ss in &mut status.shares {
|
||||||
|
ss.health = ShareHealth::Probing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn one thread per share
|
||||||
|
let handles: Vec<_> = shares
|
||||||
|
.into_iter()
|
||||||
|
.map(|share| {
|
||||||
|
let cfg = config_clone.clone();
|
||||||
|
let name = share.name.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let result = crate::rclone::probe::probe_remote_path(&cfg, &share);
|
||||||
|
(name, result)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Collect results
|
||||||
|
let mut healthy = HashSet::new();
|
||||||
|
for handle in handles {
|
||||||
|
if shutdown.load(Ordering::SeqCst) {
|
||||||
|
anyhow::bail!("Interrupted during probe");
|
||||||
|
}
|
||||||
|
match handle.join() {
|
||||||
|
Ok((name, Ok(()))) => {
|
||||||
|
println!(" Probe OK: {name}");
|
||||||
|
let mut status = shared_status.write().unwrap();
|
||||||
|
if let Some(ss) = status.shares.iter_mut().find(|s| s.name == name) {
|
||||||
|
ss.health = ShareHealth::Healthy;
|
||||||
|
}
|
||||||
|
healthy.insert(name);
|
||||||
|
}
|
||||||
|
Ok((name, Err(e))) => {
|
||||||
|
let msg = format!("{e}");
|
||||||
|
eprintln!(" Probe FAILED: {name} — {msg}");
|
||||||
|
let mut status = shared_status.write().unwrap();
|
||||||
|
if let Some(ss) = status.shares.iter_mut().find(|s| s.name == name) {
|
||||||
|
ss.health = ShareHealth::Failed(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!(" Probe thread panicked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(healthy.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn rclone mount processes for all shares and poll until each FUSE mount appears.
|
/// Spawn rclone mount processes for all shares and poll until each FUSE mount appears.
|
||||||
fn start_and_wait_mounts(config: &Config, shutdown: &AtomicBool) -> Result<Vec<MountChild>> {
|
fn start_and_wait_mounts(config: &Config, shutdown: &AtomicBool) -> Result<Vec<MountChild>> {
|
||||||
let mut children = Vec::new();
|
let mut children = Vec::new();
|
||||||
@ -645,6 +733,15 @@ fn handle_reload(
|
|||||||
apply_bwlimit(mounts, &new_config.bandwidth.limit_up, &new_config.bandwidth.limit_down);
|
apply_bwlimit(mounts, &new_config.bandwidth.limit_up, &new_config.bandwidth.limit_down);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regenerate rclone.conf if connections changed
|
||||||
|
if !diff.connections_added.is_empty()
|
||||||
|
|| !diff.connections_removed.is_empty()
|
||||||
|
|| !diff.connections_modified.is_empty()
|
||||||
|
{
|
||||||
|
println!(" Regenerating rclone.conf (connections changed)...");
|
||||||
|
crate::rclone::config::write_config(&new_config)?;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle removed shares: drain → unmount → kill
|
// Handle removed shares: drain → unmount → kill
|
||||||
for name in &diff.shares_removed {
|
for name in &diff.shares_removed {
|
||||||
println!(" Removing share '{name}'...");
|
println!(" Removing share '{name}'...");
|
||||||
@ -717,13 +814,24 @@ fn handle_reload(
|
|||||||
// Re-preflight with new config
|
// Re-preflight with new config
|
||||||
preflight(&new_config)?;
|
preflight(&new_config)?;
|
||||||
|
|
||||||
// Re-start mounts
|
// Re-probe all shares
|
||||||
let shutdown = AtomicBool::new(false);
|
let shutdown_flag = AtomicBool::new(false);
|
||||||
let mut new_mounts = start_and_wait_mounts(&new_config, &shutdown)?;
|
let healthy_names =
|
||||||
|
probe_all_shares(&new_config, shared_status, &shutdown_flag)?;
|
||||||
|
|
||||||
|
// Build healthy-only config for mounts and protocols
|
||||||
|
let mut healthy_config = new_config.clone();
|
||||||
|
healthy_config
|
||||||
|
.shares
|
||||||
|
.retain(|s| healthy_names.contains(&s.name));
|
||||||
|
write_protocol_configs(&healthy_config)?;
|
||||||
|
|
||||||
|
// Re-start mounts (healthy only)
|
||||||
|
let mut new_mounts = start_and_wait_mounts(&healthy_config, &shutdown_flag)?;
|
||||||
mounts.append(&mut new_mounts);
|
mounts.append(&mut new_mounts);
|
||||||
|
|
||||||
// Re-start protocols
|
// Re-start protocols
|
||||||
let new_protocols = start_protocols(&new_config)?;
|
let new_protocols = start_protocols(&healthy_config)?;
|
||||||
// Replace old protocol children (Drop will handle any leftover)
|
// Replace old protocol children (Drop will handle any leftover)
|
||||||
*protocols = new_protocols;
|
*protocols = new_protocols;
|
||||||
*smbd_tracker = RestartTracker::new();
|
*smbd_tracker = RestartTracker::new();
|
||||||
@ -757,6 +865,9 @@ fn handle_reload(
|
|||||||
speed: existing.map(|e| e.speed).unwrap_or(0.0),
|
speed: existing.map(|e| e.speed).unwrap_or(0.0),
|
||||||
transfers: existing.map(|e| e.transfers).unwrap_or(0),
|
transfers: existing.map(|e| e.transfers).unwrap_or(0),
|
||||||
errors: existing.map(|e| e.errors).unwrap_or(0),
|
errors: existing.map(|e| e.errors).unwrap_or(0),
|
||||||
|
health: existing
|
||||||
|
.map(|e| e.health.clone())
|
||||||
|
.unwrap_or(ShareHealth::Pending),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@ -45,6 +45,8 @@ struct ShareStatusResponse {
|
|||||||
speed_display: String,
|
speed_display: String,
|
||||||
transfers: u64,
|
transfers: u64,
|
||||||
errors: u64,
|
errors: u64,
|
||||||
|
health: String,
|
||||||
|
health_message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_status(State(state): State<SharedState>) -> Json<StatusResponse> {
|
async fn get_status(State(state): State<SharedState>) -> Json<StatusResponse> {
|
||||||
@ -66,6 +68,8 @@ async fn get_status(State(state): State<SharedState>) -> Json<StatusResponse> {
|
|||||||
speed_display: s.speed_display(),
|
speed_display: s.speed_display(),
|
||||||
transfers: s.transfers,
|
transfers: s.transfers,
|
||||||
errors: s.errors,
|
errors: s.errors,
|
||||||
|
health: s.health_label().to_string(),
|
||||||
|
health_message: s.health_message().map(|m| m.to_string()),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
smbd_running: status.smbd_running,
|
smbd_running: status.smbd_running,
|
||||||
@ -97,6 +101,8 @@ async fn get_share_status(
|
|||||||
speed_display: share.speed_display(),
|
speed_display: share.speed_display(),
|
||||||
transfers: share.transfers,
|
transfers: share.transfers,
|
||||||
errors: share.errors,
|
errors: share.errors,
|
||||||
|
health: share.health_label().to_string(),
|
||||||
|
health_message: share.health_message().map(|m| m.to_string()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,18 +34,22 @@ struct DashboardTemplate {
|
|||||||
|
|
||||||
struct ShareView {
|
struct ShareView {
|
||||||
name: String,
|
name: String,
|
||||||
|
connection: String,
|
||||||
mount_point: String,
|
mount_point: String,
|
||||||
mounted: bool,
|
mounted: bool,
|
||||||
cache_display: String,
|
cache_display: String,
|
||||||
dirty_count: u64,
|
dirty_count: u64,
|
||||||
speed_display: String,
|
speed_display: String,
|
||||||
read_only: bool,
|
read_only: bool,
|
||||||
|
health: String,
|
||||||
|
health_message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "web/share_detail.html")]
|
#[template(path = "web/share_detail.html")]
|
||||||
struct ShareDetailTemplate {
|
struct ShareDetailTemplate {
|
||||||
name: String,
|
name: String,
|
||||||
|
connection: String,
|
||||||
mount_point: String,
|
mount_point: String,
|
||||||
remote_path: String,
|
remote_path: String,
|
||||||
mounted: bool,
|
mounted: bool,
|
||||||
@ -57,6 +61,8 @@ struct ShareDetailTemplate {
|
|||||||
speed_display: String,
|
speed_display: String,
|
||||||
transfers: u64,
|
transfers: u64,
|
||||||
errors: u64,
|
errors: u64,
|
||||||
|
health: String,
|
||||||
|
health_message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
@ -88,21 +94,22 @@ async fn dashboard(State(state): State<SharedState>) -> Response {
|
|||||||
.shares
|
.shares
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
let read_only = config
|
let share_config = config.find_share(&s.name);
|
||||||
.find_share(&s.name)
|
|
||||||
.map(|sc| sc.read_only)
|
|
||||||
.unwrap_or(false);
|
|
||||||
ShareView {
|
ShareView {
|
||||||
name: s.name.clone(),
|
name: s.name.clone(),
|
||||||
mount_point: config
|
connection: share_config
|
||||||
.find_share(&s.name)
|
.map(|sc| sc.connection.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
mount_point: share_config
|
||||||
.map(|sc| sc.mount_point.display().to_string())
|
.map(|sc| sc.mount_point.display().to_string())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
mounted: s.mounted,
|
mounted: s.mounted,
|
||||||
cache_display: s.cache_display(),
|
cache_display: s.cache_display(),
|
||||||
dirty_count: s.dirty_count,
|
dirty_count: s.dirty_count,
|
||||||
speed_display: s.speed_display(),
|
speed_display: s.speed_display(),
|
||||||
read_only,
|
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();
|
.collect();
|
||||||
@ -138,6 +145,9 @@ async fn share_detail(
|
|||||||
|
|
||||||
let tmpl = ShareDetailTemplate {
|
let tmpl = ShareDetailTemplate {
|
||||||
name: share_status.name.clone(),
|
name: share_status.name.clone(),
|
||||||
|
connection: share_config
|
||||||
|
.map(|sc| sc.connection.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
mount_point: share_config
|
mount_point: share_config
|
||||||
.map(|sc| sc.mount_point.display().to_string())
|
.map(|sc| sc.mount_point.display().to_string())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
@ -153,6 +163,8 @@ async fn share_detail(
|
|||||||
speed_display: share_status.speed_display(),
|
speed_display: share_status.speed_display(),
|
||||||
transfers: share_status.transfers,
|
transfers: share_status.transfers,
|
||||||
errors: share_status.errors,
|
errors: share_status.errors,
|
||||||
|
health: share_status.health_label().to_string(),
|
||||||
|
health_message: share_status.health_message().unwrap_or("").to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
match tmpl.render() {
|
match tmpl.render() {
|
||||||
@ -278,21 +290,22 @@ async fn status_partial(State(state): State<SharedState>) -> Response {
|
|||||||
.shares
|
.shares
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
let read_only = config
|
let share_config = config.find_share(&s.name);
|
||||||
.find_share(&s.name)
|
|
||||||
.map(|sc| sc.read_only)
|
|
||||||
.unwrap_or(false);
|
|
||||||
ShareView {
|
ShareView {
|
||||||
name: s.name.clone(),
|
name: s.name.clone(),
|
||||||
mount_point: config
|
connection: share_config
|
||||||
.find_share(&s.name)
|
.map(|sc| sc.connection.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
mount_point: share_config
|
||||||
.map(|sc| sc.mount_point.display().to_string())
|
.map(|sc| sc.mount_point.display().to_string())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
mounted: s.mounted,
|
mounted: s.mounted,
|
||||||
cache_display: s.cache_display(),
|
cache_display: s.cache_display(),
|
||||||
dirty_count: s.dirty_count,
|
dirty_count: s.dirty_count,
|
||||||
speed_display: s.speed_display(),
|
speed_display: s.speed_display(),
|
||||||
read_only,
|
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();
|
.collect();
|
||||||
|
|||||||
@ -28,6 +28,8 @@
|
|||||||
.badge-ok { background: rgba(74,222,128,0.15); color: var(--green); }
|
.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-error { background: rgba(248,113,113,0.15); color: var(--red); }
|
||||||
.badge-ro { background: rgba(251,191,36,0.15); color: var(--yellow); }
|
.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 { display: flex; gap: 24px; font-size: 0.9em; color: var(--text-muted); flex-wrap: wrap; }
|
||||||
.stats span { white-space: nowrap; }
|
.stats span { white-space: nowrap; }
|
||||||
.stats .label { color: var(--text-muted); }
|
.stats .label { color: var(--text-muted); }
|
||||||
@ -55,10 +57,14 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2><a href="/shares/{{ share.name }}">{{ share.name }}</a></h2>
|
<h2><a href="/shares/{{ share.name }}">{{ share.name }}</a></h2>
|
||||||
<div>
|
<div>
|
||||||
{% if share.mounted %}
|
{% if share.health == "OK" %}
|
||||||
<span class="badge badge-ok">OK</span>
|
<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 %}
|
{% else %}
|
||||||
<span class="badge badge-error">DOWN</span>
|
<span class="badge badge-warn">PENDING</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if share.read_only %}
|
{% if share.read_only %}
|
||||||
<span class="badge badge-ro">RO</span>
|
<span class="badge badge-ro">RO</span>
|
||||||
@ -71,6 +77,9 @@
|
|||||||
<span><span class="label">Dirty:</span> <span class="value">{{ share.dirty_count }}</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>
|
<span><span class="label">Speed:</span> <span class="value">{{ share.speed_display }}</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
{% if share.health == "FAILED" %}
|
||||||
|
<div class="error-msg">{{ share.health_message }}</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,8 @@
|
|||||||
.badge-ok { background: rgba(74,222,128,0.15); color: var(--green); }
|
.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-error { background: rgba(248,113,113,0.15); color: var(--red); }
|
||||||
.badge-ro { background: rgba(251,191,36,0.15); color: var(--yellow); }
|
.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-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 { 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 .label { font-size: 0.8em; color: var(--text-muted); margin-bottom: 4px; }
|
||||||
@ -36,7 +38,7 @@
|
|||||||
|
|
||||||
<h1>
|
<h1>
|
||||||
{{ name }}
|
{{ name }}
|
||||||
{% if mounted %}<span class="badge badge-ok">OK</span>{% else %}<span class="badge badge-error">DOWN</span>{% endif %}
|
{% 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 %}
|
{% if read_only %}<span class="badge badge-ro">Read-Only</span>{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@ -60,6 +62,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="info-table">
|
<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>Mount Point</td><td>{{ mount_point }}</td></tr>
|
||||||
<tr><td>Remote Path</td><td>{{ remote_path }}</td></tr>
|
<tr><td>Remote Path</td><td>{{ remote_path }}</td></tr>
|
||||||
<tr><td>RC Port</td><td>{{ rc_port }}</td></tr>
|
<tr><td>RC Port</td><td>{{ rc_port }}</td></tr>
|
||||||
|
|||||||
@ -3,10 +3,14 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2><a href="/shares/{{ share.name }}">{{ share.name }}</a></h2>
|
<h2><a href="/shares/{{ share.name }}">{{ share.name }}</a></h2>
|
||||||
<div>
|
<div>
|
||||||
{% if share.mounted %}
|
{% if share.health == "OK" %}
|
||||||
<span class="badge badge-ok">OK</span>
|
<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 %}
|
{% else %}
|
||||||
<span class="badge badge-error">DOWN</span>
|
<span class="badge badge-warn">PENDING</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if share.read_only %}
|
{% if share.read_only %}
|
||||||
<span class="badge badge-ro">RO</span>
|
<span class="badge badge-ro">RO</span>
|
||||||
@ -19,6 +23,9 @@
|
|||||||
<span><span class="label">Dirty:</span> <span class="value">{{ share.dirty_count }}</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>
|
<span><span class="label">Speed:</span> <span class="value">{{ share.speed_display }}</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
{% if share.health == "FAILED" %}
|
||||||
|
<div class="error-msg">{{ share.health_message }}</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user