diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e6eadb0..22e0e58 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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; diff --git a/src/cli/preset.rs b/src/cli/preset.rs new file mode 100644 index 0000000..5664275 --- /dev/null +++ b/src/cli/preset.rs @@ -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 { + 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(()) +} diff --git a/src/cli/reconnect.rs b/src/cli/reconnect.rs new file mode 100644 index 0000000..880c957 --- /dev/null +++ b/src/cli/reconnect.rs @@ -0,0 +1,42 @@ +//! `warpgate reconnect ` — 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(()) +} diff --git a/src/cli/setup.rs b/src/cli/setup.rs new file mode 100644 index 0000000..ce3bdc9 --- /dev/null +++ b/src/cli/setup.rs @@ -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) -> 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::() { + 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(()) +} diff --git a/src/cli/status.rs b/src/cli/status.rs index 3a5b1d6..fc6181e 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -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(()) } diff --git a/src/deploy/setup.rs b/src/deploy/setup.rs index 905b055..7dbf489 100644 --- a/src/deploy/setup.rs +++ b/src/deploy/setup.rs @@ -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()?; diff --git a/src/main.rs b/src/main.rs index 5b7761a..691aed4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,14 +84,31 @@ enum Commands { #[arg(short, long)] output: Option, }, + /// 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, + }, + /// 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!(), } } } diff --git a/src/supervisor.rs b/src/supervisor.rs index 0ebb7ec..458753d 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -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.");