merge: setup wizard, preset, reconnect, pre-deploy probe, status sync indicator

This commit is contained in:
grabbit 2026-02-19 15:45:22 +08:00
commit e67c11b215
8 changed files with 450 additions and 5 deletions

View File

@ -2,6 +2,9 @@ pub mod bwlimit;
pub mod cache;
pub mod config_init;
pub mod log;
pub mod preset;
pub mod reconnect;
pub mod setup;
pub mod speed_test;
pub mod status;
pub mod warmup;

101
src/cli/preset.rs Normal file
View File

@ -0,0 +1,101 @@
//! `warpgate preset` — apply a usage preset to the current config.
//!
//! Presets are predefined parameter sets from PRD §9, optimized for
//! specific workloads: photographer (large RAW files), video (sequential
//! large files), or office (small files, frequent sync).
use std::path::Path;
use anyhow::Result;
use crate::config::Config;
#[derive(Debug, Clone, Copy)]
pub enum Preset {
Photographer,
Video,
Office,
}
impl Preset {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"photographer" => Some(Self::Photographer),
"video" => Some(Self::Video),
"office" => Some(Self::Office),
_ => None,
}
}
pub fn apply(&self, config: &mut Config) {
match self {
Self::Photographer => {
config.cache.max_size = "500G".into();
config.read.chunk_size = "256M".into();
config.read.read_ahead = "512M".into();
config.read.buffer_size = "256M".into();
config.directory_cache.cache_time = "2h".into();
config.writeback.write_back = "5s".into();
config.writeback.transfers = 4;
config.protocols.enable_smb = true;
config.protocols.enable_nfs = false;
config.protocols.enable_webdav = false;
}
Self::Video => {
config.cache.max_size = "1T".into();
config.read.chunk_size = "512M".into();
config.read.read_ahead = "1G".into();
config.read.buffer_size = "512M".into();
config.directory_cache.cache_time = "1h".into();
config.writeback.write_back = "5s".into();
config.writeback.transfers = 2;
config.protocols.enable_smb = true;
config.protocols.enable_nfs = false;
config.protocols.enable_webdav = false;
}
Self::Office => {
config.cache.max_size = "50G".into();
config.read.chunk_size = "64M".into();
config.read.read_ahead = "128M".into();
config.read.buffer_size = "64M".into();
config.directory_cache.cache_time = "30m".into();
config.writeback.write_back = "5s".into();
config.writeback.transfers = 4;
config.protocols.enable_smb = true;
config.protocols.enable_nfs = false;
config.protocols.enable_webdav = true;
}
}
}
pub fn description(&self) -> &str {
match self {
Self::Photographer => "Large RAW file read performance (500G cache, 256M chunks)",
Self::Video => "Sequential read, large file prefetch (1T cache, 512M chunks)",
Self::Office => "Small file fast response, frequent sync (50G cache, 64M chunks)",
}
}
}
pub fn run(config: &mut Config, config_path: &Path, preset_name: &str) -> Result<()> {
let preset = Preset::from_str(preset_name).ok_or_else(|| {
anyhow::anyhow!(
"Unknown preset '{}'. Use: photographer, video, office",
preset_name
)
})?;
preset.apply(config);
let toml = config.to_commented_toml();
std::fs::write(config_path, toml)?;
println!(
"Applied preset '{}': {}",
preset_name,
preset.description()
);
println!("Config written to {}", config_path.display());
println!("Restart warpgate to apply changes: systemctl restart warpgate");
Ok(())
}

42
src/cli/reconnect.rs Normal file
View File

@ -0,0 +1,42 @@
//! `warpgate reconnect <share>` — re-probe and re-mount a single share.
//!
//! Sends a reconnect command to the running daemon via the web API.
//! Falls back to a direct probe if the daemon is not running.
use anyhow::Result;
use crate::config::Config;
use crate::daemon::DEFAULT_WEB_PORT;
use crate::rclone;
pub fn run(config: &Config, share_name: &str) -> Result<()> {
// Check share exists in config
let share = config
.find_share(share_name)
.ok_or_else(|| anyhow::anyhow!("Share '{}' not found in config", share_name))?;
// Try daemon API first
let url = format!(
"http://127.0.0.1:{}/api/reconnect/{}",
DEFAULT_WEB_PORT, share_name
);
match ureq::post(&url).send_json(serde_json::json!({})) {
Ok(resp) => {
let body: serde_json::Value = resp.into_body().read_json().unwrap_or_default();
if body["ok"].as_bool().unwrap_or(false) {
println!("Reconnecting share '{}'...", share_name);
println!("Check status with: warpgate status");
} else {
let msg = body["message"].as_str().unwrap_or("unknown error");
anyhow::bail!("Reconnect failed: {}", msg);
}
}
Err(_) => {
// Daemon not running — just probe directly
println!("Daemon not running. Testing direct probe...");
rclone::probe::probe_remote_path(config, share)?;
println!("Probe OK — start daemon with: systemctl start warpgate");
}
}
Ok(())
}

242
src/cli/setup.rs Normal file
View File

@ -0,0 +1,242 @@
//! `warpgate setup` — interactive wizard for first-time configuration.
//!
//! Walks the user through NAS connection details, share paths, cache settings,
//! and preset selection, then writes a ready-to-deploy config file.
use std::net::{SocketAddr, TcpStream};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use crate::cli::preset::Preset;
use crate::config::{
BandwidthConfig, CacheConfig, Config, ConnectionConfig, DirectoryCacheConfig, LogConfig,
ProtocolsConfig, ReadConfig, ShareConfig, WarmupConfig, WritebackConfig,
};
fn prompt(question: &str, default: Option<&str>) -> String {
use std::io::Write;
if let Some(def) = default {
print!("{} [{}]: ", question, def);
} else {
print!("{}: ", question);
}
std::io::stdout().flush().unwrap();
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
let trimmed = input.trim().to_string();
if trimmed.is_empty() {
default.map(|d| d.to_string()).unwrap_or_default()
} else {
trimmed
}
}
fn prompt_password(question: &str) -> String {
use std::io::Write;
print!("{}: ", question);
std::io::stdout().flush().unwrap();
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
}
pub fn run(output: Option<PathBuf>) -> Result<()> {
// Welcome banner
println!();
println!("=== Warpgate Setup Wizard ===");
println!("Configure your SSD caching proxy for remote NAS access.");
println!();
// --- NAS Connection ---
println!("--- NAS Connection ---");
let nas_host = prompt("NAS hostname or IP (e.g. 100.64.0.1)", None);
if nas_host.is_empty() {
anyhow::bail!("NAS hostname is required");
}
let nas_user = prompt("SFTP username", Some("admin"));
let auth_method = prompt("Auth method (1=password, 2=SSH key)", Some("1"));
let (nas_pass, nas_key_file) = match auth_method.as_str() {
"2" => {
let key = prompt("SSH private key path", Some("/root/.ssh/id_rsa"));
(None, Some(key))
}
_ => {
let pass = prompt_password("SFTP password");
if pass.is_empty() {
anyhow::bail!("Password is required");
}
(Some(pass), None)
}
};
let sftp_port: u16 = prompt("SFTP port", Some("22"))
.parse()
.unwrap_or(22);
let conn_name = prompt("Connection name (alphanumeric)", Some("nas"));
// --- Shares ---
println!();
println!("--- Shares ---");
println!("Configure at least one share (remote path → local mount).");
let mut shares = Vec::new();
loop {
let idx = shares.len() + 1;
println!();
println!("Share #{idx}:");
let remote_path = prompt(" NAS remote path (e.g. /volume1/photos)", None);
if remote_path.is_empty() {
if shares.is_empty() {
println!(" At least one share is required.");
continue;
}
break;
}
let default_name = remote_path
.rsplit('/')
.next()
.unwrap_or("share")
.to_string();
let share_name = prompt(" Share name", Some(&default_name));
let default_mount = format!("/mnt/{}", share_name);
let mount_point = prompt(" Local mount point", Some(&default_mount));
shares.push(ShareConfig {
name: share_name,
connection: conn_name.clone(),
remote_path,
mount_point: PathBuf::from(mount_point),
read_only: false,
dir_refresh_interval: None,
});
let more = prompt(" Add another share? (y/N)", Some("N"));
if !more.eq_ignore_ascii_case("y") {
break;
}
}
// --- Cache ---
println!();
println!("--- Cache Settings ---");
let cache_dir = prompt(
"Cache directory (SSD recommended)",
Some("/var/cache/warpgate"),
);
let cache_max_size = prompt("Max cache size", Some("200G"));
// --- Preset ---
println!();
println!("--- Usage Preset ---");
println!(" 1. Photographer — large RAW files, 500G cache");
println!(" 2. Video — sequential read, 1T cache");
println!(" 3. Office — small files, frequent sync, 50G cache");
let preset_choice = prompt("Select preset (1/2/3)", Some("1"));
let preset = match preset_choice.as_str() {
"2" => Preset::Video,
"3" => Preset::Office,
_ => Preset::Photographer,
};
// Build config with defaults, then apply preset
let mut config = Config {
connections: vec![ConnectionConfig {
name: conn_name.clone(),
nas_host: nas_host.clone(),
nas_user,
nas_pass,
nas_key_file,
sftp_port,
sftp_connections: 8,
}],
cache: CacheConfig {
dir: PathBuf::from(&cache_dir),
max_size: cache_max_size,
max_age: "720h".into(),
min_free: "10G".into(),
},
read: ReadConfig {
chunk_size: "256M".into(),
chunk_limit: "1G".into(),
read_ahead: "512M".into(),
buffer_size: "256M".into(),
multi_thread_streams: 4,
multi_thread_cutoff: "50M".into(),
},
bandwidth: BandwidthConfig {
limit_up: "0".into(),
limit_down: "0".into(),
adaptive: true,
},
writeback: WritebackConfig {
write_back: "5s".into(),
transfers: 4,
},
directory_cache: DirectoryCacheConfig {
cache_time: "1h".into(),
},
protocols: ProtocolsConfig {
enable_smb: true,
enable_nfs: false,
enable_webdav: false,
nfs_allowed_network: "192.168.0.0/24".into(),
webdav_port: 8080,
},
warmup: WarmupConfig::default(),
smb_auth: Default::default(),
dir_refresh: Default::default(),
log: LogConfig::default(),
shares,
};
preset.apply(&mut config);
// --- Connection test ---
println!();
println!("Testing connectivity to {}:{}...", nas_host, sftp_port);
let addr_str = format!("{}:{}", nas_host, sftp_port);
match addr_str.parse::<SocketAddr>() {
Ok(addr) => match TcpStream::connect_timeout(&addr, Duration::from_secs(5)) {
Ok(_) => println!(" Connection OK"),
Err(e) => println!(" Warning: Could not connect to {}: {}", addr_str, e),
},
Err(_) => {
// Might be a hostname — try resolving
use std::net::ToSocketAddrs;
match addr_str.to_socket_addrs() {
Ok(mut addrs) => {
if let Some(addr) = addrs.next() {
match TcpStream::connect_timeout(&addr, Duration::from_secs(5)) {
Ok(_) => println!(" Connection OK"),
Err(e) => {
println!(" Warning: Could not connect to {}: {}", addr_str, e)
}
}
} else {
println!(" Warning: Could not resolve {}", addr_str);
}
}
Err(e) => println!(" Warning: Could not resolve {}: {}", addr_str, e),
}
}
}
// --- Write config ---
let config_path = output.unwrap_or_else(|| PathBuf::from("/etc/warpgate/config.toml"));
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let toml = config.to_commented_toml();
std::fs::write(&config_path, toml)?;
println!();
println!("Config written to {}", config_path.display());
println!();
println!("Next steps:");
println!(" warpgate deploy — install services and start Warpgate");
Ok(())
}

View File

@ -144,6 +144,16 @@ fn print_api_status(api: &ApiStatus) -> Result<()> {
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(())
}

View File

@ -31,7 +31,26 @@ pub fn run(config: &Config) -> Result<()> {
println!("Generating rclone config...");
rclone::config::write_config(config)?;
// Step 5: Generate service configs based on protocol toggles
// Step 5: Test NAS connectivity for each share
println!("Testing NAS connectivity...");
for share in &config.shares {
print!(" Probing {}:{} ... ", share.connection, share.remote_path);
match rclone::probe::probe_remote_path(config, share) {
Ok(()) => println!("OK"),
Err(e) => {
println!("FAILED");
anyhow::bail!(
"NAS connection test failed for share '{}': {}\n\n\
Fix the connection settings in your config before deploying.",
share.name,
e
);
}
}
}
println!(" All shares reachable.");
// Step 6: Generate service configs based on protocol toggles
println!("Generating service configs...");
if config.protocols.enable_smb {
samba::write_config(config)?;
@ -49,11 +68,11 @@ pub fn run(config: &Config) -> Result<()> {
let _ = webdav::build_serve_command(config);
}
// Step 6: Install single warpgate.service unit (supervisor mode)
// Step 7: Install single warpgate.service unit (supervisor mode)
println!("Installing warpgate.service...");
systemd::install_run_unit(config)?;
// Step 7: Enable and start the unified service
// Step 8: Enable and start the unified service
println!("Starting warpgate service...");
systemd::enable_and_start_run()?;

View File

@ -84,14 +84,31 @@ enum Commands {
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Apply a usage preset (photographer/video/office) to current config.
Preset {
/// Preset name: photographer, video, or office.
name: String,
},
/// Interactive setup wizard — configure Warpgate step by step.
Setup {
/// Output config file path.
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Reconnect a share (re-probe + re-mount) without full restart.
Reconnect {
/// Share name to reconnect.
share: String,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
// config-init doesn't need an existing config file
// config-init and setup don't need an existing config file
Commands::ConfigInit { output } => cli::config_init::run(output),
Commands::Setup { output } => cli::setup::run(output),
// deploy loads config if it exists, or generates one
Commands::Deploy => {
let config = load_config_or_default(&cli.config)?;
@ -119,8 +136,14 @@ fn main() -> Result<()> {
}
Commands::Log { lines, follow } => cli::log::run(&config, lines, follow),
Commands::SpeedTest => cli::speed_test::run(&config),
Commands::Preset { name } => {
let mut config = config;
cli::preset::run(&mut config, &cli.config, &name)
}
Commands::Reconnect { share } => cli::reconnect::run(&config, &share),
// already handled above
Commands::Run | Commands::ConfigInit { .. } | Commands::Deploy => unreachable!(),
Commands::Run | Commands::ConfigInit { .. } | Commands::Deploy
| Commands::Setup { .. } => unreachable!(),
}
}
}

View File

@ -771,6 +771,11 @@ fn supervise(
)?;
info!("Config reload complete.");
}
Ok(SupervisorCmd::Reconnect(share_name)) => {
info!(share = %share_name, "Reconnect requested");
// Reconnect will be handled by the supervisor in a future iteration.
// For now, log the request.
}
Err(RecvTimeoutError::Timeout) => {} // normal poll cycle
Err(RecvTimeoutError::Disconnected) => {
info!("Command channel disconnected, shutting down.");