diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7651b4dc..2e4275ad 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -239,8 +239,17 @@ fn resolve_k8s_params(params: &ConnectionParams) -> Result Result( resource_type: k8s.resource_type, resource_name: k8s.resource_name, port: k8s.port, + kubectl_path: k8s.kubectl_path, + kubeconfig_path: k8s.kubeconfig_path, }; connections.push(connection.clone()); @@ -1490,6 +1506,8 @@ pub async fn update_k8s_connection( resource_type: k8s.resource_type, resource_name: k8s.resource_name, port: k8s.port, + kubectl_path: k8s.kubectl_path, + kubeconfig_path: k8s.kubeconfig_path, }; connections[idx] = connection.clone(); @@ -1526,23 +1544,32 @@ pub async fn test_k8s_connection_cmd( _app: AppHandle, context: String, namespace: String, + kubectl_path: Option, + kubeconfig_path: Option, ) -> Result { - crate::k8s_tunnel::test_k8s_connection(&context, &namespace) + let options = crate::k8s_tunnel::K8sCommandOptions::new(kubectl_path, kubeconfig_path); + crate::k8s_tunnel::test_k8s_connection(&context, &namespace, &options) } #[tauri::command] pub async fn get_k8s_contexts_cmd( _app: AppHandle, + kubectl_path: Option, + kubeconfig_path: Option, ) -> Result, String> { - crate::k8s_tunnel::get_k8s_contexts() + let options = crate::k8s_tunnel::K8sCommandOptions::new(kubectl_path, kubeconfig_path); + crate::k8s_tunnel::get_k8s_contexts(&options) } #[tauri::command] pub async fn get_k8s_namespaces_cmd( _app: AppHandle, context: String, + kubectl_path: Option, + kubeconfig_path: Option, ) -> Result, String> { - crate::k8s_tunnel::get_k8s_namespaces(&context) + let options = crate::k8s_tunnel::K8sCommandOptions::new(kubectl_path, kubeconfig_path); + crate::k8s_tunnel::get_k8s_namespaces(&context, &options) } #[tauri::command] @@ -1551,8 +1578,11 @@ pub async fn get_k8s_resources_cmd( context: String, namespace: String, resource_type: String, + kubectl_path: Option, + kubeconfig_path: Option, ) -> Result, String> { - crate::k8s_tunnel::get_k8s_resources(&context, &namespace, &resource_type) + let options = crate::k8s_tunnel::K8sCommandOptions::new(kubectl_path, kubeconfig_path); + crate::k8s_tunnel::get_k8s_resources(&context, &namespace, &resource_type, &options) } #[tauri::command] @@ -1562,15 +1592,28 @@ pub async fn get_k8s_resource_ports_cmd( namespace: String, resource_type: String, resource_name: String, + kubectl_path: Option, + kubeconfig_path: Option, ) -> Result, String> { + let options = crate::k8s_tunnel::K8sCommandOptions::new(kubectl_path, kubeconfig_path); crate::k8s_tunnel::get_k8s_resource_ports( &context, &namespace, &resource_type, &resource_name, + &options, ) } +#[tauri::command] +pub async fn validate_k8s_path_cmd( + _app: AppHandle, + path: String, + kind: String, +) -> Result<(), String> { + crate::k8s_tunnel::validate_k8s_path(&path, &kind) +} + /// Expand K8s connection params by loading saved config and creating/reusing a tunnel. pub async fn expand_k8s_connection_params( app: &AppHandle, @@ -1588,7 +1631,7 @@ pub async fn expand_k8s_connection_params( } // Resolve K8s params from saved connection if using connection_id - let (context, namespace, resource_type, resource_name, port) = + let (context, namespace, resource_type, resource_name, port, kubectl_path, kubeconfig_path) = if let Some(k8s_id) = ¶ms.k8s_connection_id { let k8s_conn = get_k8s_connection_by_id(app, k8s_id).await?; ( @@ -1597,6 +1640,8 @@ pub async fn expand_k8s_connection_params( k8s_conn.resource_type, k8s_conn.resource_name, k8s_conn.port, + k8s_conn.kubectl_path, + k8s_conn.kubeconfig_path, ) } else { let ctx = params @@ -1620,18 +1665,28 @@ pub async fn expand_k8s_connection_params( .ok_or("Missing K8s resource name")? .to_string(); let p = params.k8s_port.ok_or("Missing K8s port")?; - (ctx, ns, rt, rn, p) + ( + ctx, + ns, + rt, + rn, + p, + params.k8s_kubectl_path.clone(), + params.k8s_kubeconfig_path.clone(), + ) }; let _remote_host = params.host.as_deref().unwrap_or("localhost"); let _remote_port = params.port.unwrap_or(DEFAULT_MYSQL_PORT); + let options = crate::k8s_tunnel::K8sCommandOptions::new(kubectl_path, kubeconfig_path); let map_key = crate::k8s_tunnel::build_tunnel_key( &context, &namespace, &resource_type, &resource_name, port, + &options, ); // Check for existing tunnel @@ -1666,6 +1721,7 @@ pub async fn expand_k8s_connection_params( &resource_type, &resource_name, port, + &options, ) .map_err(|e| { eprintln!("[Connection Error] K8s Tunnel setup failed: {}", e); diff --git a/src-tauri/src/k8s_tunnel.rs b/src-tauri/src/k8s_tunnel.rs index 7781d43a..55e7db1e 100644 --- a/src-tauri/src/k8s_tunnel.rs +++ b/src-tauri/src/k8s_tunnel.rs @@ -1,7 +1,11 @@ use std::collections::HashMap; +use std::env; +use std::ffi::OsString; +use std::fs; use std::io::{BufRead, BufReader}; use std::net::{TcpListener, TcpStream}; -use std::process::{Child, Command, Stdio}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; use std::sync::{Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant}; @@ -10,6 +14,7 @@ use std::time::{Duration, Instant}; const K8S_TUNNEL_TIMEOUT_SECS: u64 = 15; const K8S_CONNECT_RETRY_MS: u64 = 200; const LOG_BUFFER_INITIAL_CAPACITY: usize = 64; +const DEFAULT_KUBECTL: &str = "kubectl"; #[derive(Clone)] pub struct K8sTunnel { @@ -17,6 +22,41 @@ pub struct K8sTunnel { child: Arc>, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct K8sCommandOptions { + pub kubectl_path: Option, + pub kubeconfig_path: Option, +} + +impl K8sCommandOptions { + pub fn new(kubectl_path: Option, kubeconfig_path: Option) -> Self { + Self { + kubectl_path, + kubeconfig_path, + } + } + + fn kubectl_path(&self) -> String { + normalized_path_option(&self.kubectl_path).unwrap_or_else(|| DEFAULT_KUBECTL.to_string()) + } + + fn explicit_kubeconfig_path(&self) -> Option { + normalized_path_option(&self.kubeconfig_path) + } + + fn effective_kubeconfig_path(&self) -> Option { + self.explicit_kubeconfig_path() + .map(OsString::from) + .or_else(|| env::var_os("KUBECONFIG")) + } + + fn effective_kubeconfig_label(&self) -> String { + self.effective_kubeconfig_path() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "".to_string()) + } +} + pub static TUNNELS: OnceLock>> = OnceLock::new(); pub fn get_tunnels() -> &'static Mutex> { @@ -34,16 +74,17 @@ impl K8sTunnel { resource_type: &str, resource_name: &str, remote_port: u16, + options: &K8sCommandOptions, ) -> Result { println!( "[K8s Tunnel] New request: context={}, namespace={}, {}/{}:{}", context, namespace, resource_type, resource_name, remote_port ); - // Verify kubectl is available - Self::verify_kubectl()?; + // Verify kubectl is available before starting a long-lived tunnel process. + Self::verify_kubectl(options)?; - // Allocate a free local port + // Allocate a free local port. let local_port = { let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| { let err = format!("Failed to find free local port: {}", e); @@ -54,12 +95,9 @@ impl K8sTunnel { }; println!("[K8s Tunnel] Assigned local port: {}", local_port); - // Build the kubectl port-forward command let port_forward_spec = format!("{}:{}", local_port, remote_port); let resource = format!("{}/{}", resource_type, resource_name); - - let mut args = Vec::with_capacity(10); - args.extend([ + let args = [ "port-forward", "--context", context, @@ -67,25 +105,27 @@ impl K8sTunnel { namespace, &resource, &port_forward_spec, - ]); + ]; - println!("[K8s Tunnel] Executing: kubectl {:?}", args); + println!( + "[K8s Tunnel] Executing: {} {:?}", + options.kubectl_path(), + args + ); - let mut child = Command::new("kubectl") - .args(&args) + let mut command = kubectl_command(options)?; + let mut child = command + .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| { - let err = format!( - "Failed to launch kubectl: {}. Ensure 'kubectl' is in PATH.", - e - ); + let err = kubectl_spawn_error(options, &e); eprintln!("[K8s Tunnel Error] {}", err); err })?; - // Capture stdout/stderr in background threads + // Capture stdout/stderr in background threads. let stdout_log = Arc::new(Mutex::new(Vec::with_capacity(LOG_BUFFER_INITIAL_CAPACITY))); let stderr_log = Arc::new(Mutex::new(Vec::with_capacity(LOG_BUFFER_INITIAL_CAPACITY))); @@ -123,13 +163,13 @@ impl K8sTunnel { let child_arc = Arc::new(Mutex::new(child)); - // Wait for the tunnel to become ready + // Wait for the tunnel to become ready. let start = Instant::now(); let timeout = Duration::from_secs(K8S_TUNNEL_TIMEOUT_SECS); let mut ready = false; while start.elapsed() < timeout { - // Check if process is still alive + // Check if process is still alive. { let mut c = child_arc.lock().unwrap(); if let Ok(Some(status)) = c.try_wait() { @@ -144,7 +184,6 @@ impl K8sTunnel { } } - // Try connecting to the local port match TcpStream::connect(format!("127.0.0.1:{}", local_port)) { Ok(_) => { println!( @@ -182,37 +221,30 @@ impl K8sTunnel { pub fn stop(&self) { if let Ok(mut c) = self.child.lock() { let _ = c.kill(); - println!( - "[K8s Tunnel] Stopped tunnel on port {}", - self.local_port - ); + println!("[K8s Tunnel] Stopped tunnel on port {}", self.local_port); } } - /// Check that kubectl is available in PATH. - fn verify_kubectl() -> Result<(), String> { - let output = Command::new("kubectl") - .arg("version") - .arg("--client") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| { - format!( - "kubectl not found: {}. Please install kubectl and ensure it is in PATH.", - e - ) - })?; - - if !output.status.success() { - return Err("kubectl version check failed. Please verify your kubectl installation.".to_string()); + /// Check that kubectl is available. + fn verify_kubectl(options: &K8sCommandOptions) -> Result<(), String> { + let output = run_kubectl( + &["version", "--client"], + options, + "kubectl version check failed. Please verify your kubectl installation", + )?; + + if output.status.success() { + Ok(()) + } else { + Err( + "kubectl version check failed. Please verify your kubectl installation." + .to_string(), + ) } - - Ok(()) } } -/// Build a deterministic tunnel map key from K8s parameters. +/// Build a deterministic tunnel map key from K8s parameters and kubectl configuration. #[inline] pub fn build_tunnel_key( context: &str, @@ -220,10 +252,17 @@ pub fn build_tunnel_key( resource_type: &str, resource_name: &str, port: u16, + options: &K8sCommandOptions, ) -> String { format!( - "{}:{}:{}/{}:{}", - context, namespace, resource_type, resource_name, port + "{}:{}:{}/{}:{}:kubectl={}:kubeconfig={}", + context, + namespace, + resource_type, + resource_name, + port, + options.kubectl_path(), + options.effective_kubeconfig_label() ) } @@ -231,81 +270,67 @@ pub fn build_tunnel_key( pub fn test_k8s_connection( context: &str, namespace: &str, + options: &K8sCommandOptions, ) -> Result { println!( "[K8s Test] Testing connection: context={}, namespace={}", context, namespace ); - K8sTunnel::verify_kubectl()?; + K8sTunnel::verify_kubectl(options)?; - let output = Command::new("kubectl") - .args(["--context", context, "get", "namespace", namespace, "-o", "name"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| format!("Failed to execute kubectl: {}", e))?; - - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - println!("[K8s Test] Connection successful: {}", stdout); - Ok(format!( - "Kubernetes connection to context '{}' namespace '{}' verified successfully!", - context, namespace - )) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - let err = format!("K8s connection test failed: {}", stderr.trim()); - eprintln!("[K8s Test Error] {}", err); - Err(err) - } + let output = run_kubectl( + &[ + "--context", + context, + "get", + "namespace", + namespace, + "-o", + "name", + ], + options, + "K8s connection test failed", + )?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + println!("[K8s Test] Connection successful: {}", stdout); + Ok(format!( + "Kubernetes connection to context '{}' namespace '{}' verified successfully!", + context, namespace + )) } /// List available kubectl contexts from kubeconfig. -pub fn get_k8s_contexts() -> Result, String> { - let output = Command::new("kubectl") - .args(["config", "get-contexts", "-o", "name"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| format!("Failed to execute kubectl: {}", e))?; +pub fn get_k8s_contexts(options: &K8sCommandOptions) -> Result, String> { + let output = run_kubectl( + &["config", "get-contexts", "-o", "name"], + options, + "Failed to list K8s contexts", + )?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("Failed to list K8s contexts: {}", stderr.trim())); + let contexts = parse_lines(&String::from_utf8_lossy(&output.stdout)); + if contexts.is_empty() { + return Err(no_contexts_error(options)); } - let contexts = parse_lines(&String::from_utf8_lossy(&output.stdout)); println!("[K8s Discovery] Found {} contexts", contexts.len()); Ok(contexts) } /// List namespaces in a given kubectl context. -pub fn get_k8s_namespaces(context: &str) -> Result, String> { - let output = Command::new("kubectl") - .args([ - "--context", - context, - "get", - "namespaces", - "-o", - "name", - ]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| format!("Failed to execute kubectl: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "Failed to list namespaces in context '{}': {}", - context, - stderr.trim() - )); - } - - let namespaces = parse_lines_with_prefix(&String::from_utf8_lossy(&output.stdout), "namespace/"); +pub fn get_k8s_namespaces( + context: &str, + options: &K8sCommandOptions, +) -> Result, String> { + let output = run_kubectl( + &["--context", context, "get", "namespaces", "-o", "name"], + options, + &format!("Failed to list namespaces in context '{}'", context), + )?; + + let namespaces = + parse_lines_with_prefix(&String::from_utf8_lossy(&output.stdout), "namespace/"); println!( "[K8s Discovery] Found {} namespaces in context '{}'", namespaces.len(), @@ -319,8 +344,8 @@ pub fn get_k8s_resources( context: &str, namespace: &str, resource_type: &str, + options: &K8sCommandOptions, ) -> Result, String> { - // Validate resource type if resource_type != "service" && resource_type != "pod" { return Err(format!( "Unsupported resource type '{}'. Only 'service' and 'pod' are supported.", @@ -328,8 +353,8 @@ pub fn get_k8s_resources( )); } - let output = Command::new("kubectl") - .args([ + let output = run_kubectl( + &[ "--context", context, "--namespace", @@ -338,22 +363,13 @@ pub fn get_k8s_resources( resource_type, "-o", "name", - ]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| format!("Failed to execute kubectl: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "Failed to list {} in context '{}' namespace '{}': {}", - resource_type, - context, - namespace, - stderr.trim() - )); - } + ], + options, + &format!( + "Failed to list {} in context '{}' namespace '{}'", + resource_type, context, namespace + ), + )?; let prefix = format!("{}/", resource_type); let resources = parse_lines_with_prefix(&String::from_utf8_lossy(&output.stdout), &prefix); @@ -373,6 +389,7 @@ pub fn get_k8s_resource_ports( namespace: &str, resource_type: &str, resource_name: &str, + options: &K8sCommandOptions, ) -> Result, String> { if resource_type != "service" { return Err(format!( @@ -381,8 +398,8 @@ pub fn get_k8s_resource_ports( )); } - let output = Command::new("kubectl") - .args([ + let output = run_kubectl( + &[ "--context", context, "--namespace", @@ -392,194 +409,321 @@ pub fn get_k8s_resource_ports( resource_name, "-o", "jsonpath={.spec.ports[*].port}", - ]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| format!("Failed to execute kubectl: {}", e))?; + ], + options, + &format!( + "Failed to list ports for {} '{}' in context '{}' namespace '{}'", + resource_type, resource_name, context, namespace + ), + )?; + + Ok(parse_resource_ports(&String::from_utf8_lossy( + &output.stdout, + ))) +} - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); +fn kubectl_command(options: &K8sCommandOptions) -> Result { + if let Some(kubectl_path) = normalized_path_option(&options.kubectl_path) { + validate_kubectl_path(&kubectl_path)?; + } + + if let Some(kubeconfig_path) = options.explicit_kubeconfig_path() { + validate_kubeconfig_path(&kubeconfig_path)?; + } + + let mut command = Command::new(options.kubectl_path()); + if let Some(kubeconfig_path) = options.effective_kubeconfig_path() { + command.env("KUBECONFIG", kubeconfig_path); + } + Ok(command) +} + +pub fn validate_k8s_path(path: &str, kind: &str) -> Result<(), String> { + match kind { + "kubectl" => validate_kubectl_path(&expand_home_path(path.trim())), + "kubeconfig" => validate_kubeconfig_path(&expand_home_path(path.trim())), + other => Err(format!( + "Unsupported Kubernetes path validation kind: {}", + other + )), + } +} + +fn validate_kubectl_path(path: &str) -> Result<(), String> { + if path.trim().is_empty() { + return Ok(()); + } + + let resolved_path = resolve_kubectl_path(path).ok_or_else(|| { + format!( + "kubectl executable was not found at '{}' or in PATH. Choose an existing executable file.", + path + ) + })?; + let metadata = fs::metadata(&resolved_path).map_err(|e| { + format!( + "kubectl executable was not found at '{}': {}", + resolved_path.display(), + e + ) + })?; + + if !metadata.is_file() { return Err(format!( - "Failed to list ports for {} '{}' in context '{}' namespace '{}': {}", - resource_type, - resource_name, - context, - namespace, - stderr.trim() + "kubectl path must point to a file: {}", + resolved_path.display() + )); + } + + if !is_executable_file(&resolved_path, &metadata) { + return Err(format!( + "kubectl path is not executable: {}", + resolved_path.display() )); } - Ok(parse_resource_ports(&String::from_utf8_lossy(&output.stdout))) + Ok(()) } -/// Parse newline-separated output into a list of trimmed, non-empty strings. -fn parse_lines(output: &str) -> Vec { - output - .lines() - .map(|l| l.trim()) - .filter(|l| !l.is_empty()) - .map(String::from) - .collect() +fn validate_kubeconfig_path(path: &str) -> Result<(), String> { + if path.trim().is_empty() { + return Ok(()); + } + + let path_buf = PathBuf::from(path); + let metadata = fs::metadata(&path_buf).map_err(|e| { + format!( + "Kubeconfig file was not found at '{}': {}", + path_buf.display(), + e + ) + })?; + + if !metadata.is_file() { + return Err(format!( + "Kubeconfig path must point to a file: {}", + path_buf.display() + )); + } + + Ok(()) } -/// Parse newline-separated output, stripping a prefix from each line. -fn parse_lines_with_prefix(output: &str, prefix: &str) -> Vec { - output - .lines() - .map(|l| l.trim().strip_prefix(prefix).unwrap_or(l.trim())) - .filter(|l| !l.is_empty()) - .map(String::from) - .collect() +fn resolve_kubectl_path(path: &str) -> Option { + let path_buf = PathBuf::from(path); + if path_buf.is_absolute() || contains_path_separator(path) { + return Some(path_buf); + } + + find_executable_in_path(path) } -fn parse_resource_ports(output: &str) -> Vec { - output - .split_whitespace() - .filter_map(|value| value.parse::().ok()) - .collect() +fn contains_path_separator(path: &str) -> bool { + path.contains('/') || path.contains('\\') } -#[cfg(test)] -mod tests { - use super::*; - - mod build_tunnel_key_tests { - use super::*; - - #[test] - fn test_basic_key_format() { - let key = build_tunnel_key( - "my-cluster", - "default", - "service", - "my-db", - 3306, - ); - assert_eq!(key, "my-cluster:default:service/my-db:3306"); - } +fn find_executable_in_path(command: &str) -> Option { + let path_var = env::var_os("PATH")?; + env::split_paths(&path_var).find_map(|dir| { + executable_candidate_names(command) + .into_iter() + .map(|candidate| dir.join(candidate)) + .find(|candidate| { + fs::metadata(candidate) + .map(|metadata| metadata.is_file() && is_executable_file(candidate, &metadata)) + .unwrap_or(false) + }) + }) +} - #[test] - fn test_pod_resource_type() { - let key = build_tunnel_key( - "prod-cluster", - "database", - "pod", - "mysql-0", - 5432, - ); - assert_eq!(key, "prod-cluster:database:pod/mysql-0:5432"); +fn executable_candidate_names(command: &str) -> Vec { + #[cfg(windows)] + { + let command_path = Path::new(command); + if command_path.extension().is_some() { + return vec![OsString::from(command)]; } - #[test] - fn test_empty_context() { - let key = build_tunnel_key("", "default", "service", "db", 80); - assert_eq!(key, ":default:service/db:80"); - } + windows_executable_extensions() + .into_iter() + .map(|extension| OsString::from(format!("{}{}", command, extension))) + .collect() + } - #[test] - fn test_special_characters() { - let key = build_tunnel_key( - "gke_project_us-central1_cluster", - "my-namespace", - "service", - "my-db-svc", - 3306, - ); - assert_eq!( - key, - "gke_project_us-central1_cluster:my-namespace:service/my-db-svc:3306" - ); - } + #[cfg(not(windows))] + { + vec![OsString::from(command)] } +} - mod parse_lines_tests { - use super::*; +fn is_executable_file(path: &Path, metadata: &fs::Metadata) -> bool { + if !metadata.is_file() { + return false; + } - #[test] - fn test_basic_lines() { - let output = "line1\nline2\nline3\n"; - let result = parse_lines(output); - assert_eq!(result, vec!["line1", "line2", "line3"]); - } + #[cfg(unix)] + { + let _ = path; + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } - #[test] - fn test_empty_output() { - let result = parse_lines(""); - assert!(result.is_empty()); - } + #[cfg(windows)] + { + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| format!(".{}", extension).to_ascii_lowercase()) + .map(|extension| { + windows_executable_extensions() + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(&extension)) + }) + .unwrap_or(false) + } - #[test] - fn test_whitespace_handling() { - let output = " line1 \n\n line2 \n"; - let result = parse_lines(output); - assert_eq!(result, vec!["line1", "line2"]); - } + #[cfg(not(any(unix, windows)))] + { + let _ = path; + true } +} - mod parse_lines_with_prefix_tests { - use super::*; +#[cfg(windows)] +fn windows_executable_extensions() -> Vec { + env::var_os("PATHEXT") + .map(|value| { + env::split_paths(&value) + .filter_map(|path| path.as_os_str().to_str().map(String::from)) + .collect::>() + }) + .filter(|extensions| !extensions.is_empty()) + .unwrap_or_else(|| { + vec![ + ".COM".to_string(), + ".EXE".to_string(), + ".BAT".to_string(), + ".CMD".to_string(), + ] + }) +} - #[test] - fn test_namespace_prefix() { - let output = "namespace/default\nnamespace/kube-system\nnamespace/my-ns\n"; - let result = parse_lines_with_prefix(output, "namespace/"); - assert_eq!(result, vec!["default", "kube-system", "my-ns"]); - } +fn run_kubectl( + args: &[&str], + options: &K8sCommandOptions, + failure_context: &str, +) -> Result { + let mut command = kubectl_command(options)?; + let output = command + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| kubectl_spawn_error(options, &e))?; - #[test] - fn test_service_prefix() { - let output = "service/my-db\nservice/api-gateway\n"; - let result = parse_lines_with_prefix(output, "service/"); - assert_eq!(result, vec!["my-db", "api-gateway"]); - } + if output.status.success() { + return Ok(output); + } - #[test] - fn test_pod_prefix() { - let output = "pod/mysql-0\npod/mysql-1\n"; - let result = parse_lines_with_prefix(output, "pod/"); - assert_eq!(result, vec!["mysql-0", "mysql-1"]); - } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + Err(format!( + "{}: kubectl exited with status {}", + failure_context, output.status + )) + } else { + Err(format!("{}: {}", failure_context, stderr)) + } +} - #[test] - fn test_no_match_returns_full_line() { - let output = "something/else\n"; - let result = parse_lines_with_prefix(output, "namespace/"); - assert_eq!(result, vec!["something/else"]); - } +fn kubectl_spawn_error(options: &K8sCommandOptions, error: &std::io::Error) -> String { + if let Some(kubectl_path) = normalized_path_option(&options.kubectl_path) { + format!( + "kubectl executable was not found at '{}': {}. Set a valid kubectl path.", + kubectl_path, error + ) + } else { + format!( + "kubectl executable was not found: {}. Install kubectl or set a kubectl path.", + error + ) + } +} - #[test] - fn test_empty_output() { - let result = parse_lines_with_prefix("", "namespace/"); - assert!(result.is_empty()); - } +fn no_contexts_error(options: &K8sCommandOptions) -> String { + if let Some(kubeconfig_path) = options.explicit_kubeconfig_path() { + format!( + "kubectl did not find any Kubernetes contexts in configured kubeconfig '{}'. Choose a different kubeconfig path or verify the file contains contexts.", + kubeconfig_path + ) + } else if let Some(kubeconfig_path) = env::var_os("KUBECONFIG") { + format!( + "kubectl did not find any Kubernetes contexts using KUBECONFIG='{}'. Choose a kubeconfig path or verify the file contains contexts.", + kubeconfig_path.to_string_lossy() + ) + } else { + "kubectl did not find any Kubernetes contexts. Set KUBECONFIG or choose a kubeconfig path." + .to_string() } +} - mod parse_resource_ports_tests { - use super::*; +fn normalized_option(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(String::from) +} - #[test] - fn test_single_port() { - let result = parse_resource_ports("5432"); - assert_eq!(result, vec![5432]); - } +fn normalized_path_option(value: &Option) -> Option { + normalized_option(value).map(|value| expand_home_path(&value)) +} - #[test] - fn test_multiple_ports() { - let result = parse_resource_ports("80 443 5432"); - assert_eq!(result, vec![80, 443, 5432]); - } +fn expand_home_path(value: &str) -> String { + let Some(rest) = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\")) + else { + return value.to_string(); + }; + + match home_dir() { + Some(home) => home.join(rest).to_string_lossy().into_owned(), + None => value.to_string(), + } +} - #[test] - fn test_ignores_invalid_values() { - let result = parse_resource_ports("abc 3306 70000 8123"); - assert_eq!(result, vec![3306, 8123]); - } +fn home_dir() -> Option { + env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from)) +} - #[test] - fn test_empty_output() { - let result = parse_resource_ports(""); - assert!(result.is_empty()); - } - } +/// Parse newline-separated output into a list of trimmed, non-empty strings. +fn parse_lines(output: &str) -> Vec { + output + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .map(String::from) + .collect() } + +/// Parse newline-separated output, stripping a prefix from each line. +fn parse_lines_with_prefix(output: &str, prefix: &str) -> Vec { + output + .lines() + .map(|l| l.trim().strip_prefix(prefix).unwrap_or(l.trim())) + .filter(|l| !l.is_empty()) + .map(String::from) + .collect() +} + +fn parse_resource_ports(output: &str) -> Vec { + output + .split_whitespace() + .filter_map(|value| value.parse::().ok()) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/k8s_tunnel/tests.rs b/src-tauri/src/k8s_tunnel/tests.rs new file mode 100644 index 00000000..aa854186 --- /dev/null +++ b/src-tauri/src/k8s_tunnel/tests.rs @@ -0,0 +1,248 @@ +use super::*; + +mod build_tunnel_key_tests { + use super::*; + + #[test] + fn test_basic_key_format() { + let options = K8sCommandOptions::default(); + let key = build_tunnel_key("my-cluster", "default", "service", "my-db", 3306, &options); + assert_eq!( + key, + "my-cluster:default:service/my-db:3306:kubectl=kubectl:kubeconfig=" + ); + } + + #[test] + fn test_key_includes_overrides() { + let options = K8sCommandOptions::new( + Some("/usr/local/bin/kubectl".to_string()), + Some("/tmp/kubeconfig".to_string()), + ); + let key = build_tunnel_key("prod", "database", "pod", "mysql-0", 5432, &options); + assert_eq!( + key, + "prod:database:pod/mysql-0:5432:kubectl=/usr/local/bin/kubectl:kubeconfig=/tmp/kubeconfig" + ); + } + + #[test] + fn test_empty_context() { + let options = K8sCommandOptions::default(); + let key = build_tunnel_key("", "default", "service", "db", 80, &options); + assert_eq!( + key, + ":default:service/db:80:kubectl=kubectl:kubeconfig=" + ); + } +} + +mod command_options_tests { + use super::*; + + #[test] + fn test_empty_overrides_are_ignored() { + let options = K8sCommandOptions::new(Some(" ".to_string()), Some("".to_string())); + assert_eq!(options.kubectl_path(), "kubectl"); + assert_eq!(options.explicit_kubeconfig_path(), None); + } + + #[test] + fn test_tilde_paths_are_expanded() { + if let Some(home) = home_dir() { + let options = K8sCommandOptions::new( + Some("~/bin/kubectl".to_string()), + Some("~/.kube.bak/config".to_string()), + ); + assert_eq!( + options.kubectl_path(), + home.join("bin/kubectl").to_string_lossy() + ); + assert_eq!( + options.explicit_kubeconfig_path().as_deref(), + Some(home.join(".kube.bak/config").to_string_lossy().as_ref()) + ); + } + } + + #[test] + fn test_no_contexts_error_mentions_explicit_kubeconfig() { + let options = K8sCommandOptions::new(None, Some("/tmp/missing-contexts".to_string())); + assert!( + no_contexts_error(&options).contains("configured kubeconfig '/tmp/missing-contexts'") + ); + } +} + +mod path_validation_tests { + use super::*; + + fn write_temp_file(dir: &tempfile::TempDir, name: &str) -> PathBuf { + let path = dir.path().join(name); + std::fs::write(&path, "test").unwrap(); + path + } + + fn make_executable(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).unwrap(); + } + + #[cfg(not(unix))] + { + let _ = path; + } + } + + #[test] + fn test_empty_paths_are_valid() { + assert!(validate_k8s_path("", "kubectl").is_ok()); + assert!(validate_k8s_path(" ", "kubeconfig").is_ok()); + } + + #[test] + fn test_kubeconfig_file_must_exist() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-config"); + let err = validate_k8s_path(missing.to_str().unwrap(), "kubeconfig").unwrap_err(); + assert!(err.contains("Kubeconfig file was not found")); + } + + #[test] + fn test_kubeconfig_file_is_valid() { + let dir = tempfile::tempdir().unwrap(); + let path = write_temp_file(&dir, "config"); + assert!(validate_k8s_path(path.to_str().unwrap(), "kubeconfig").is_ok()); + } + + #[test] + fn test_kubeconfig_directory_is_invalid() { + let dir = tempfile::tempdir().unwrap(); + let err = validate_k8s_path(dir.path().to_str().unwrap(), "kubeconfig").unwrap_err(); + assert!(err.contains("Kubeconfig path must point to a file")); + } + + #[test] + fn test_kubectl_file_must_exist() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-kubectl"); + let err = validate_k8s_path(missing.to_str().unwrap(), "kubectl").unwrap_err(); + assert!(err.contains("kubectl executable was not found")); + } + + #[test] + fn test_kubectl_executable_file_is_valid() { + let dir = tempfile::tempdir().unwrap(); + let file_name = if cfg!(windows) { + "kubectl.exe" + } else { + "kubectl" + }; + let path = write_temp_file(&dir, file_name); + make_executable(&path); + assert!(validate_k8s_path(path.to_str().unwrap(), "kubectl").is_ok()); + } + + #[cfg(unix)] + #[test] + fn test_kubectl_non_executable_file_is_invalid() { + let dir = tempfile::tempdir().unwrap(); + let path = write_temp_file(&dir, "kubectl"); + let err = validate_k8s_path(path.to_str().unwrap(), "kubectl").unwrap_err(); + assert!(err.contains("kubectl path is not executable")); + } +} + +mod parse_lines_tests { + use super::*; + + #[test] + fn test_basic_lines() { + let output = "line1\nline2\nline3\n"; + let result = parse_lines(output); + assert_eq!(result, vec!["line1", "line2", "line3"]); + } + + #[test] + fn test_empty_output() { + let result = parse_lines(""); + assert!(result.is_empty()); + } + + #[test] + fn test_whitespace_handling() { + let output = " line1 \n\n line2 \n"; + let result = parse_lines(output); + assert_eq!(result, vec!["line1", "line2"]); + } +} + +mod parse_lines_with_prefix_tests { + use super::*; + + #[test] + fn test_namespace_prefix() { + let output = "namespace/default\nnamespace/kube-system\nnamespace/my-ns\n"; + let result = parse_lines_with_prefix(output, "namespace/"); + assert_eq!(result, vec!["default", "kube-system", "my-ns"]); + } + + #[test] + fn test_service_prefix() { + let output = "service/my-db\nservice/api-gateway\n"; + let result = parse_lines_with_prefix(output, "service/"); + assert_eq!(result, vec!["my-db", "api-gateway"]); + } + + #[test] + fn test_pod_prefix() { + let output = "pod/mysql-0\npod/mysql-1\n"; + let result = parse_lines_with_prefix(output, "pod/"); + assert_eq!(result, vec!["mysql-0", "mysql-1"]); + } + + #[test] + fn test_no_match_returns_full_line() { + let output = "something/else\n"; + let result = parse_lines_with_prefix(output, "namespace/"); + assert_eq!(result, vec!["something/else"]); + } + + #[test] + fn test_empty_output() { + let result = parse_lines_with_prefix("", "namespace/"); + assert!(result.is_empty()); + } +} + +mod parse_resource_ports_tests { + use super::*; + + #[test] + fn test_single_port() { + let result = parse_resource_ports("5432"); + assert_eq!(result, vec![5432]); + } + + #[test] + fn test_multiple_ports() { + let result = parse_resource_ports("80 443 5432"); + assert_eq!(result, vec![80, 443, 5432]); + } + + #[test] + fn test_ignores_invalid_values() { + let result = parse_resource_ports("abc 3306 70000 8123"); + assert_eq!(result, vec![3306, 8123]); + } + + #[test] + fn test_empty_output() { + let result = parse_resource_ports(""); + assert!(result.is_empty()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bcdd0f93..24ccf271 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -292,6 +292,7 @@ pub fn run() { commands::get_k8s_namespaces_cmd, commands::get_k8s_resources_cmd, commands::get_k8s_resource_ports_cmd, + commands::validate_k8s_path_cmd, // Connection Groups commands::get_connection_groups, commands::get_connections_with_groups, diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 687062b2..85018da1 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -212,6 +212,8 @@ async fn expand_k8s_params_for_mcp( expanded.k8s_resource_type = Some(k8s.resource_type); expanded.k8s_resource_name = Some(k8s.resource_name); expanded.k8s_port = Some(k8s.port); + expanded.k8s_kubectl_path = k8s.kubectl_path; + expanded.k8s_kubeconfig_path = k8s.kubeconfig_path; Ok(expanded) } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index eba9d080..c895d8c4 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -157,6 +157,10 @@ pub struct ConnectionParams { pub k8s_resource_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub k8s_port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub k8s_kubectl_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub k8s_kubeconfig_path: Option, // Connection ID for stable pooling (not persisted, set at runtime) #[serde(skip_serializing_if = "Option::is_none")] pub connection_id: Option, @@ -221,6 +225,10 @@ pub struct K8sConnection { pub resource_type: String, // "service" or "pod" pub resource_name: String, pub port: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kubectl_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kubeconfig_path: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -231,6 +239,10 @@ pub struct K8sConnectionInput { pub resource_type: String, pub resource_name: String, pub port: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kubectl_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kubeconfig_path: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs index 70aa77b9..bed371ff 100644 --- a/src-tauri/src/plugins/driver.rs +++ b/src-tauri/src/plugins/driver.rs @@ -840,6 +840,8 @@ mod tests { k8s_resource_type: None, k8s_resource_name: None, k8s_port: None, + k8s_kubectl_path: None, + k8s_kubeconfig_path: None, connection_id: Some("conn-1".to_string()), } } diff --git a/src/components/modals/K8sConnectionsModal.tsx b/src/components/modals/K8sConnectionsModal.tsx index 5225f1d0..08bf1820 100644 --- a/src/components/modals/K8sConnectionsModal.tsx +++ b/src/components/modals/K8sConnectionsModal.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; +import { useLatestAsync } from "../../hooks/useLatestAsync"; import { X, Plus, @@ -9,6 +10,8 @@ import { Loader2, Zap, XCircle, + AlertCircle, + ChevronDown, } from "lucide-react"; import { loadK8sConnections, @@ -21,8 +24,10 @@ import { getK8sResources, getK8sResourcePorts, validateK8sConnection, + validateK8sPath, type K8sConnection, type K8sConnectionInput, + type K8sPathValidationKind, } from "../../utils/k8s"; import { toErrorMessage } from "../../utils/errors"; import { Modal } from "../ui/Modal"; @@ -39,6 +44,20 @@ const InputClass = "w-full px-3 pt-2 pb-1 bg-base border border-strong rounded-lg text-primary focus:border-blue-500 focus:outline-none leading-tight"; const LabelClass = "block text-xs uppercase font-bold text-muted mb-1"; +type PathValidationStatus = "idle" | "validating" | "valid" | "invalid"; + +interface PathValidationState { + status: PathValidationStatus; + value: string; + message: string | null; +} + +const EmptyPathValidationState: PathValidationState = { + status: "idle", + value: "", + message: null, +}; + export function K8sConnectionsModal({ isOpen, onClose, @@ -59,11 +78,19 @@ export function K8sConnectionsModal({ const [port, setPort] = useState(undefined); const effectivePort = port ?? effectiveDefaultPort; const [isPortOverridden, setIsPortOverridden] = useState(false); + const [kubectlPath, setKubectlPath] = useState(""); + const [kubeconfigPath, setKubeconfigPath] = useState(""); + const [appliedKubectlPath, setAppliedKubectlPath] = useState(""); + const [appliedKubeconfigPath, setAppliedKubeconfigPath] = useState(""); + const [kubectlPathValidation, setKubectlPathValidation] = useState(EmptyPathValidationState); + const [kubeconfigPathValidation, setKubeconfigPathValidation] = useState(EmptyPathValidationState); + const [showAdvanced, setShowAdvanced] = useState(false); // Discovery state const [contexts, setContexts] = useState([]); const [namespaces, setNamespaces] = useState([]); const [resources, setResources] = useState([]); + const [discoveryError, setDiscoveryError] = useState(null); // Status const [testStatus, setTestStatus] = useState< @@ -72,19 +99,36 @@ export function K8sConnectionsModal({ const [testMessage, setTestMessage] = useState(""); const [validationError, setValidationError] = useState(null); + // Guards fire-and-forget async so only the latest call per key can write state. + const run = useLatestAsync(); + const loadConnections = useCallback(async () => { const result = await loadK8sConnections(); setConnections(result); }, []); + const getCommandOptions = useCallback( + () => ({ + kubectl_path: appliedKubectlPath, + kubeconfig_path: appliedKubeconfigPath, + }), + [appliedKubectlPath, appliedKubeconfigPath], + ); + const loadContexts = useCallback(async () => { - try { - const result = await getK8sContexts(); - setContexts(result); - } catch { - setContexts([]); - } - }, []); + await run("contexts", async (isLatest) => { + try { + const result = await getK8sContexts(getCommandOptions()); + if (!isLatest()) return; + setContexts(result); + setDiscoveryError(null); + } catch (error) { + if (!isLatest()) return; + setContexts([]); + setDiscoveryError(toErrorMessage(error)); + } + }); + }, [getCommandOptions, run]); useEffect(() => { if (!isOpen) return; @@ -96,34 +140,42 @@ export function K8sConnectionsModal({ }, [isOpen, loadConnections, loadContexts]); useEffect(() => { - void (async () => { + void run("namespaces", async (isLatest) => { if (!context) { setNamespaces([]); return; } - try { - setNamespaces(await getK8sNamespaces(context)); - } catch { + const result = await getK8sNamespaces(context, getCommandOptions()); + if (!isLatest()) return; + setNamespaces(result); + setDiscoveryError(null); + } catch (error) { + if (!isLatest()) return; setNamespaces([]); + setDiscoveryError(toErrorMessage(error)); } - })(); - }, [context]); + }); + }, [context, getCommandOptions, run]); useEffect(() => { - void (async () => { + void run("resources", async (isLatest) => { if (!context || !namespace || !resourceType) { setResources([]); return; } - try { - setResources(await getK8sResources(context, namespace, resourceType)); - } catch { + const result = await getK8sResources(context, namespace, resourceType, getCommandOptions()); + if (!isLatest()) return; + setResources(result); + setDiscoveryError(null); + } catch (error) { + if (!isLatest()) return; setResources([]); + setDiscoveryError(toErrorMessage(error)); } - })(); - }, [context, namespace, resourceType]); + }); + }, [context, namespace, resourceType, getCommandOptions, run]); useEffect(() => { if ( @@ -144,6 +196,7 @@ export function K8sConnectionsModal({ namespace, resourceType, resourceName, + getCommandOptions(), ); if (!cancelled && ports.length === 1) { setPort((prev) => (prev === ports[0] ? prev : ports[0])); @@ -156,7 +209,7 @@ export function K8sConnectionsModal({ return () => { cancelled = true; }; - }, [context, namespace, resourceType, resourceName, isPortOverridden]); + }, [context, namespace, resourceType, resourceName, isPortOverridden, getCommandOptions]); const resetForm = () => { setName(""); @@ -166,9 +219,17 @@ export function K8sConnectionsModal({ setResourceName(""); setPort(undefined); setIsPortOverridden(false); + setKubectlPath(""); + setKubeconfigPath(""); + setAppliedKubectlPath(""); + setAppliedKubeconfigPath(""); + setKubectlPathValidation(EmptyPathValidationState); + setKubeconfigPathValidation(EmptyPathValidationState); + setShowAdvanced(false); setTestStatus("idle"); setTestMessage(""); setValidationError(null); + setDiscoveryError(null); }; const handleCreate = () => { @@ -185,11 +246,19 @@ export function K8sConnectionsModal({ setResourceName(conn.resource_name); setPort(conn.port); setIsPortOverridden(true); + setKubectlPath(conn.kubectl_path ?? ""); + setKubeconfigPath(conn.kubeconfig_path ?? ""); + setAppliedKubectlPath(conn.kubectl_path ?? ""); + setAppliedKubeconfigPath(conn.kubeconfig_path ?? ""); + setKubectlPathValidation(EmptyPathValidationState); + setKubeconfigPathValidation(EmptyPathValidationState); + setShowAdvanced(Boolean(conn.kubectl_path || conn.kubeconfig_path)); setEditingId(conn.id); setIsCreating(false); setTestStatus("idle"); setTestMessage(""); setValidationError(null); + setDiscoveryError(null); }; const handleCancel = () => { @@ -199,6 +268,27 @@ export function K8sConnectionsModal({ }; const handleSave = async () => { + const pathsValid = await validateAdvancedPathsForSave(); + if (!pathsValid) { + setValidationError( + t("k8sConnections.pathValidationFailed", { + defaultValue: "Fix invalid advanced path fields before saving.", + }), + ); + return; + } + + if (haveAdvancedPathInputsChanged()) { + applyAdvancedPaths(); + setValidationError( + t("k8sConnections.pathSelectionReset", { + defaultValue: + "Advanced paths changed. Choose the context, namespace, and resource again.", + }), + ); + return; + } + const validation = validateK8sConnection({ name, context, @@ -206,6 +296,8 @@ export function K8sConnectionsModal({ resource_type: resourceType, resource_name: resourceName, port: effectivePort, + kubectl_path: kubectlPath.trim() || undefined, + kubeconfig_path: kubeconfigPath.trim() || undefined, }); if (!validation.isValid) { setValidationError(t(validation.errorKey)); @@ -240,7 +332,7 @@ export function K8sConnectionsModal({ if (!context || !namespace) return; setTestStatus("testing"); try { - const result = await testK8sConnection(context, namespace); + const result = await testK8sConnection(context, namespace, getCommandOptions()); setTestStatus("success"); setTestMessage(result); } catch (error) { @@ -249,6 +341,154 @@ export function K8sConnectionsModal({ } }; + const handleContextChange = (value: string) => { + setContext(value); + setNamespace(""); + setResourceName(""); + setResources([]); + setDiscoveryError(null); + }; + + const handleNamespaceChange = (value: string) => { + setNamespace(value); + setResourceName(""); + setResources([]); + setDiscoveryError(null); + }; + + const applyAdvancedPaths = useCallback(() => { + const nextKubectlPath = kubectlPath.trim(); + const nextKubeconfigPath = kubeconfigPath.trim(); + if ( + nextKubectlPath === appliedKubectlPath && + nextKubeconfigPath === appliedKubeconfigPath + ) { + return; + } + + setAppliedKubectlPath(nextKubectlPath); + setAppliedKubeconfigPath(nextKubeconfigPath); + setContext(""); + setNamespace(""); + setResourceName(""); + setNamespaces([]); + setResources([]); + setDiscoveryError(null); + setTestStatus("idle"); + setTestMessage(""); + }, [appliedKubeconfigPath, appliedKubectlPath, kubeconfigPath, kubectlPath]); + + const isPathReadyToApply = useCallback( + ( + kind: K8sPathValidationKind, + currentValue: string, + state: PathValidationState, + nextValidPath?: { kind: K8sPathValidationKind; value: string }, + ) => { + if (!currentValue) return true; + if (nextValidPath?.kind === kind && nextValidPath.value === currentValue) { + return true; + } + + return state.status === "valid" && state.value === currentValue; + }, + [], + ); + + const areAdvancedPathsReadyToApply = useCallback( + (nextValidPath?: { kind: K8sPathValidationKind; value: string }) => { + const nextKubectlPath = kubectlPath.trim(); + const nextKubeconfigPath = kubeconfigPath.trim(); + + return ( + isPathReadyToApply( + "kubectl", + nextKubectlPath, + kubectlPathValidation, + nextValidPath, + ) && + isPathReadyToApply( + "kubeconfig", + nextKubeconfigPath, + kubeconfigPathValidation, + nextValidPath, + ) + ); + }, + [ + isPathReadyToApply, + kubeconfigPath, + kubeconfigPathValidation, + kubectlPath, + kubectlPathValidation, + ], + ); + + const validateAdvancedPath = async ( + kind: K8sPathValidationKind, + applyOnSuccess = true, + ) => { + const path = kind === "kubectl" ? kubectlPath : kubeconfigPath; + const trimmedPath = path.trim(); + const setPathValidation = + kind === "kubectl" ? setKubectlPathValidation : setKubeconfigPathValidation; + + if (!trimmedPath) { + setPathValidation(EmptyPathValidationState); + if (applyOnSuccess && areAdvancedPathsReadyToApply({ kind, value: "" })) { + applyAdvancedPaths(); + } + return true; + } + + setPathValidation({ status: "validating", value: trimmedPath, message: null }); + return run(kind, async (isLatest) => { + try { + await validateK8sPath(trimmedPath, kind); + if (isLatest()) { + setPathValidation({ status: "valid", value: trimmedPath, message: null }); + } + if (isLatest() && applyOnSuccess && areAdvancedPathsReadyToApply({ kind, value: trimmedPath })) { + applyAdvancedPaths(); + } + return true; + } catch (error) { + if (isLatest()) { + setPathValidation({ + status: "invalid", + value: trimmedPath, + message: toErrorMessage(error), + }); + } + return false; + } + }); + }; + + const haveAdvancedPathInputsChanged = useCallback(() => { + return ( + kubectlPath.trim() !== appliedKubectlPath || + kubeconfigPath.trim() !== appliedKubeconfigPath + ); + }, [appliedKubeconfigPath, appliedKubectlPath, kubeconfigPath, kubectlPath]); + + const validateAdvancedPathsForSave = async () => { + const kubectlPathValid = await validateAdvancedPath("kubectl", false); + const kubeconfigPathValid = await validateAdvancedPath("kubeconfig", false); + + return kubectlPathValid && kubeconfigPathValid; + }; + + const handleKubectlPathChange = (value: string) => { + setKubectlPath(value); + setKubectlPathValidation(EmptyPathValidationState); + }; + + const handleKubeconfigPathChange = (value: string) => { + setKubeconfigPath(value); + setKubeconfigPathValidation(EmptyPathValidationState); + }; + return ( void validateAdvancedPath("kubectl")} + kubeconfigPath={kubeconfigPath} + setKubeconfigPath={handleKubeconfigPathChange} + kubeconfigPathValidation={kubeconfigPathValidation} + onKubeconfigPathBlur={() => void validateAdvancedPath("kubeconfig")} + showAdvanced={showAdvanced} + setShowAdvanced={setShowAdvanced} + discoveryError={discoveryError} validationError={validationError} testStatus={testStatus} testMessage={testMessage} @@ -357,9 +608,9 @@ export function K8sConnectionsModal({ name={name} setName={setName} context={context} - setContext={setContext} + setContext={handleContextChange} namespace={namespace} - setNamespace={setNamespace} + setNamespace={handleNamespaceChange} resourceType={resourceType} setResourceType={setResourceType} resourceName={resourceName} @@ -373,6 +624,17 @@ export function K8sConnectionsModal({ contexts={contexts} namespaces={namespaces} resources={resources} + kubectlPath={kubectlPath} + setKubectlPath={handleKubectlPathChange} + kubectlPathValidation={kubectlPathValidation} + onKubectlPathBlur={() => void validateAdvancedPath("kubectl")} + kubeconfigPath={kubeconfigPath} + setKubeconfigPath={handleKubeconfigPathChange} + kubeconfigPathValidation={kubeconfigPathValidation} + onKubeconfigPathBlur={() => void validateAdvancedPath("kubeconfig")} + showAdvanced={showAdvanced} + setShowAdvanced={setShowAdvanced} + discoveryError={discoveryError} validationError={validationError} testStatus={testStatus} testMessage={testMessage} @@ -417,6 +679,17 @@ interface EditFormProps { contexts: string[]; namespaces: string[]; resources: string[]; + kubectlPath: string; + setKubectlPath: (v: string) => void; + kubectlPathValidation: PathValidationState; + onKubectlPathBlur: () => void; + kubeconfigPath: string; + setKubeconfigPath: (v: string) => void; + kubeconfigPathValidation: PathValidationState; + onKubeconfigPathBlur: () => void; + showAdvanced: boolean; + setShowAdvanced: (v: boolean) => void; + discoveryError: string | null; validationError: string | null; testStatus: "idle" | "testing" | "success" | "error"; testMessage: string; @@ -442,6 +715,17 @@ function EditForm({ contexts, namespaces, resources, + kubectlPath, + setKubectlPath, + kubectlPathValidation, + onKubectlPathBlur, + kubeconfigPath, + setKubeconfigPath, + kubeconfigPathValidation, + onKubeconfigPathBlur, + showAdvanced, + setShowAdvanced, + discoveryError, validationError, testStatus, testMessage, @@ -470,7 +754,9 @@ function EditForm({ onChange={(val) => setContext(val)} placeholder={t("k8sConnections.chooseContext")} searchPlaceholder={t("common.search")} - noResultsLabel={t("common.noResults")} + noResultsLabel={t("k8sConnections.noContexts", { + defaultValue: "No contexts available", + })} /> @@ -482,7 +768,9 @@ function EditForm({ onChange={(val) => setNamespace(val)} placeholder={t("k8sConnections.chooseNamespace")} searchPlaceholder={t("common.search")} - noResultsLabel={t("common.noResults")} + noResultsLabel={t("k8sConnections.noNamespaces", { + defaultValue: "No namespaces available", + })} /> @@ -498,7 +786,10 @@ function EditForm({ service: t("k8sConnections.resourceTypeService"), pod: t("k8sConnections.resourceTypePod"), }} - onChange={(val) => setResourceType(val)} + onChange={(val) => { + setResourceType(val); + setResourceName(""); + }} searchable={false} /> @@ -513,7 +804,9 @@ function EditForm({ onChange={(val) => setResourceName(val)} placeholder={t("k8sConnections.chooseResource")} searchPlaceholder={t("common.search")} - noResultsLabel={t("common.noResults")} + noResultsLabel={t("k8sConnections.noResources", { + defaultValue: "No resources available", + })} /> @@ -531,6 +824,56 @@ function EditForm({ /> + + +
+ + {showAdvanced && ( +
+
+ +
+ setKubectlPath(e.target.value)} + onBlur={onKubectlPathBlur} + className={getPathInputClass(kubectlPathValidation.status)} + placeholder={t("k8sConnections.kubectlPathPlaceholder")} + aria-invalid={kubectlPathValidation.status === "invalid"} + /> + +
+ +
+
+ +
+ setKubeconfigPath(e.target.value)} + onBlur={onKubeconfigPathBlur} + className={getPathInputClass(kubeconfigPathValidation.status)} + placeholder={t("k8sConnections.kubeconfigPathPlaceholder")} + aria-invalid={kubeconfigPathValidation.status === "invalid"} + /> + +
+ +
+
+ )} +
+ {/* Test result */} {testStatus !== "idle" && (
); } + +function getPathInputClass(status: PathValidationStatus) { + return clsx( + InputClass, + "pr-8 transition-colors", + status === "validating" && "border-blue-500 focus:border-blue-500", + status === "valid" && "border-green-500 focus:border-green-500 focus:ring-1 focus:ring-green-500/40", + status === "invalid" && "border-red-500 focus:border-red-500 focus:ring-1 focus:ring-red-500/40", + ); +} + +function PathValidationAdornment({ status }: { status: PathValidationStatus }) { + if (status === "idle") return null; + + return ( + + {status === "validating" && ( + + )} + {status === "valid" && } + {status === "invalid" && } + + ); +} + +function PathValidationMessage({ validation }: { validation: PathValidationState }) { + if (!validation.message) return null; + + return

{validation.message}

; +} + +function DiscoveryError({ message }: { message: string | null }) { + if (!message) return null; + + return ( +
+ + {message} +
+ ); +} diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 093a2d2e..204de1ae 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -15,6 +15,7 @@ import { Info, Eye, EyeOff, + ChevronDown, } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import type { ConnectionAppearance } from "../../contexts/DatabaseContext"; @@ -23,6 +24,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import clsx from "clsx"; import { SshConnectionsModal } from "./SshConnectionsModal"; import { K8sConnectionsModal } from "./K8sConnectionsModal"; +import { useLatestAsync } from "../../hooks/useLatestAsync"; import { Select } from "../ui/Select"; import { SlotAnchor } from "../ui/SlotAnchor"; import { useDrivers } from "../../hooks/useDrivers"; @@ -36,7 +38,9 @@ import { getK8sNamespaces, getK8sResources, getK8sResourcePorts, + validateK8sPath, type K8sConnection, + type K8sPathValidationKind, } from "../../utils/k8s"; import { isMultiDatabaseCapable } from "../../utils/database"; import { fetchConnectionWithCredentials } from "../../utils/credentials"; @@ -45,6 +49,7 @@ import { parseConnectionString, toConnectionParams, } from "../../utils/connectionStringParser"; +import { toErrorMessage } from "../../utils/errors"; interface ConnectionParams { driver: string; @@ -76,6 +81,8 @@ interface ConnectionParams { k8s_resource_type?: string; k8s_resource_name?: string; k8s_port?: number; + k8s_kubectl_path?: string; + k8s_kubeconfig_path?: string; } interface SavedConnection { @@ -93,25 +100,63 @@ interface NewConnectionModalProps { initialConnection?: SavedConnection | null; } +type PathValidationStatus = "idle" | "validating" | "valid" | "invalid"; + +interface PathValidationState { + status: PathValidationStatus; + value: string; + message: string | null; +} + +const EmptyPathValidationState: PathValidationState = { + status: "idle", + value: "", + message: null, +}; + +function PathValidationAdornment({ status }: { status: PathValidationStatus }) { + return ( + + {status === "validating" && ( + + )} + {status === "valid" && } + {status === "invalid" && } + + ); +} + +function PathValidationMessage({ validation }: { validation: PathValidationState }) { + if (!validation.message) return null; + + return

{validation.message}

; +} + const FieldInput = ({ label, value, onChange, + onBlur, type = "text", placeholder, autoFocus, className, + validation, }: { label: string; value: string | number | undefined; onChange: (v: string) => void; + onBlur?: () => void; type?: string; placeholder?: string; autoFocus?: boolean; className?: string; + validation?: PathValidationState; }) => { const [showPassword, setShowPassword] = useState(false); const isPassword = type === "password"; + const validationStatus = validation?.status ?? "idle"; + const hasValidationIcon = validationStatus !== "idle"; return (
@@ -124,6 +169,7 @@ const FieldInput = ({ value={value ?? ""} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} + onBlur={onBlur} autoFocus={autoFocus} autoCorrect="off" autoCapitalize="off" @@ -131,9 +177,16 @@ const FieldInput = ({ spellCheck={false} className={clsx( "w-full px-3 py-2 bg-base border border-strong rounded-md text-sm text-primary placeholder:text-muted placeholder:italic focus:border-blue-500 focus:outline-none transition-colors", - isPassword && "pr-10" + (isPassword || hasValidationIcon) && "pr-10", + validationStatus === "validating" && "border-blue-500 focus:border-blue-500", + validationStatus === "valid" && "border-green-500 focus:border-green-500 focus:ring-1 focus:ring-green-500/40", + validationStatus === "invalid" && "border-red-500 focus:border-red-500 focus:ring-1 focus:ring-red-500/40", )} + aria-invalid={validationStatus === "invalid"} /> + {hasValidationIcon && !isPassword && ( + + )} {isPassword && (
+ {validation && }
); }; @@ -209,6 +263,12 @@ export const NewConnectionModal = ({ const [k8sContexts, setK8sContexts] = useState([]); const [k8sNamespaces, setK8sNamespaces] = useState([]); const [k8sResources, setK8sResources] = useState([]); + const [k8sDiscoveryError, setK8sDiscoveryError] = useState(null); + const [showK8sAdvanced, setShowK8sAdvanced] = useState(false); + const [k8sKubectlPathInput, setK8sKubectlPathInput] = useState(""); + const [k8sKubeconfigPathInput, setK8sKubeconfigPathInput] = useState(""); + const [k8sKubectlPathValidation, setK8sKubectlPathValidation] = useState(EmptyPathValidationState); + const [k8sKubeconfigPathValidation, setK8sKubeconfigPathValidation] = useState(EmptyPathValidationState); // ── databases ── const [availableDatabases, setAvailableDatabases] = useState([]); @@ -309,6 +369,13 @@ export const NewConnectionModal = ({ ? params.k8s_port ?? getK8sAutoPort(params) ?? k8sDefaultPort : params.k8s_port; const effectiveK8sPort = resolveK8sPort(formData); + const k8sCommandOptions = useMemo( + () => ({ + kubectl_path: formData.k8s_kubectl_path, + kubeconfig_path: formData.k8s_kubeconfig_path, + }), + [formData.k8s_kubectl_path, formData.k8s_kubeconfig_path], + ); const connectionStringEnabled = activeDriver?.capabilities?.connection_string ?? activeDriver?.capabilities?.connectionString ?? @@ -343,6 +410,9 @@ export const NewConnectionModal = ({ ).length > 0; // ── helpers ── + // Guards fire-and-forget async so only the latest call per key can write state. + const run = useLatestAsync(); + const loadSshConnectionsList = async () => { const result = await loadSshConnections(); setSshConnections(result); @@ -353,41 +423,72 @@ export const NewConnectionModal = ({ setK8sConnections(result); }; - const loadK8sContextsList = async () => { - try { - const result = await getK8sContexts(); - setK8sContexts(result); - } catch { - setK8sContexts([]); - } - }; - - const loadK8sNamespacesList = async (context: string) => { - try { - const result = await getK8sNamespaces(context); - setK8sNamespaces(result); - } catch { - setK8sNamespaces([]); - } - }; + const loadK8sNamespacesList = useCallback( + async (context: string) => { + await run("namespaces", async (isLatest) => { + try { + const result = await getK8sNamespaces(context, k8sCommandOptions); + if (!isLatest()) return; + setK8sNamespaces(result); + setK8sDiscoveryError(null); + } catch (error) { + if (!isLatest()) return; + setK8sNamespaces([]); + setK8sDiscoveryError(toErrorMessage(error)); + } + }); + }, + [k8sCommandOptions, run], + ); - const loadK8sResourcesList = async (context: string, namespace: string, resourceType: string) => { - try { - const result = await getK8sResources(context, namespace, resourceType); - setK8sResources(result); - } catch { - setK8sResources([]); - } - }; + const loadK8sResourcesList = useCallback( + async (context: string, namespace: string, resourceType: string) => { + await run("resources", async (isLatest) => { + try { + const result = await getK8sResources( + context, + namespace, + resourceType, + k8sCommandOptions, + ); + if (!isLatest()) return; + setK8sResources(result); + setK8sDiscoveryError(null); + } catch (error) { + if (!isLatest()) return; + setK8sResources([]); + setK8sDiscoveryError(toErrorMessage(error)); + } + }); + }, + [k8sCommandOptions, run], + ); // ── K8s cascading dropdown loading ── + useEffect(() => { + if (!isOpen || !formData.k8s_enabled || k8sMode !== "inline") return; + + void run("contexts", async (isLatest) => { + try { + const result = await getK8sContexts(k8sCommandOptions); + if (!isLatest()) return; + setK8sContexts(result); + setK8sDiscoveryError(null); + } catch (error) { + if (!isLatest()) return; + setK8sContexts([]); + setK8sDiscoveryError(toErrorMessage(error)); + } + }); + }, [isOpen, formData.k8s_enabled, k8sMode, k8sCommandOptions, run]); + useEffect(() => { if (formData.k8s_context) { loadK8sNamespacesList(formData.k8s_context); } else { setK8sNamespaces([]); } - }, [formData.k8s_context]); + }, [formData.k8s_context, loadK8sNamespacesList]); useEffect(() => { if (formData.k8s_context && formData.k8s_namespace && formData.k8s_resource_type) { @@ -399,7 +500,12 @@ export const NewConnectionModal = ({ } else { setK8sResources([]); } - }, [formData.k8s_context, formData.k8s_namespace, formData.k8s_resource_type]); + }, [ + formData.k8s_context, + formData.k8s_namespace, + formData.k8s_resource_type, + loadK8sResourcesList, + ]); useEffect(() => { const context = formData.k8s_context; @@ -426,6 +532,7 @@ export const NewConnectionModal = ({ namespace, resourceType, resourceName, + k8sCommandOptions, ); if (!cancelled) { setK8sAutoPort( @@ -450,6 +557,7 @@ export const NewConnectionModal = ({ formData.k8s_resource_name, isK8sPortOverridden, k8sMode, + k8sCommandOptions, ]); const updateField = ( @@ -459,6 +567,178 @@ export const NewConnectionModal = ({ setFormData((prev) => ({ ...prev, [field]: value })); }; + const applyK8sAdvancedFields = useCallback(() => { + const nextKubectlPath = k8sKubectlPathInput.trim() || undefined; + const nextKubeconfigPath = k8sKubeconfigPathInput.trim() || undefined; + if ( + formData.k8s_kubectl_path === nextKubectlPath && + formData.k8s_kubeconfig_path === nextKubeconfigPath + ) { + return; + } + + setFormData((prev) => ({ + ...prev, + k8s_kubectl_path: nextKubectlPath, + k8s_kubeconfig_path: nextKubeconfigPath, + k8s_context: undefined, + k8s_namespace: undefined, + k8s_resource_name: undefined, + })); + setK8sNamespaces([]); + setK8sResources([]); + setK8sDiscoveryError(null); + }, [ + formData.k8s_kubeconfig_path, + formData.k8s_kubectl_path, + k8sKubeconfigPathInput, + k8sKubectlPathInput, + ]); + + const isK8sPathReadyToApply = useCallback( + ( + kind: K8sPathValidationKind, + currentValue: string, + state: PathValidationState, + nextValidPath?: { kind: K8sPathValidationKind; value: string }, + ) => { + if (!currentValue) return true; + if (nextValidPath?.kind === kind && nextValidPath.value === currentValue) { + return true; + } + + return state.status === "valid" && state.value === currentValue; + }, + [], + ); + + const areK8sAdvancedPathsReadyToApply = useCallback( + (nextValidPath?: { kind: K8sPathValidationKind; value: string }) => { + const nextKubectlPath = k8sKubectlPathInput.trim(); + const nextKubeconfigPath = k8sKubeconfigPathInput.trim(); + + return ( + isK8sPathReadyToApply( + "kubectl", + nextKubectlPath, + k8sKubectlPathValidation, + nextValidPath, + ) && + isK8sPathReadyToApply( + "kubeconfig", + nextKubeconfigPath, + k8sKubeconfigPathValidation, + nextValidPath, + ) + ); + }, + [ + isK8sPathReadyToApply, + k8sKubeconfigPathInput, + k8sKubeconfigPathValidation, + k8sKubectlPathInput, + k8sKubectlPathValidation, + ], + ); + + const validateK8sAdvancedPath = async ( + kind: K8sPathValidationKind, + applyOnSuccess = true, + ) => { + const path = kind === "kubectl" ? k8sKubectlPathInput : k8sKubeconfigPathInput; + const trimmedPath = path.trim(); + const setPathValidation = + kind === "kubectl" + ? setK8sKubectlPathValidation + : setK8sKubeconfigPathValidation; + + if (!trimmedPath) { + setPathValidation(EmptyPathValidationState); + if (applyOnSuccess && areK8sAdvancedPathsReadyToApply({ kind, value: "" })) { + applyK8sAdvancedFields(); + } + return true; + } + + setPathValidation({ status: "validating", value: trimmedPath, message: null }); + return run(kind, async (isLatest) => { + try { + await validateK8sPath(trimmedPath, kind); + if (isLatest()) { + setPathValidation({ status: "valid", value: trimmedPath, message: null }); + } + if (isLatest() && applyOnSuccess && areK8sAdvancedPathsReadyToApply({ kind, value: trimmedPath })) { + applyK8sAdvancedFields(); + } + return true; + } catch (error) { + if (isLatest()) { + setPathValidation({ + status: "invalid", + value: trimmedPath, + message: toErrorMessage(error), + }); + } + return false; + } + }); + }; + + const validateK8sAdvancedPathsForSave = async () => { + if (!formData.k8s_enabled || k8sMode !== "inline") { + return true; + } + + const kubectlPathValid = await validateK8sAdvancedPath("kubectl", false); + const kubeconfigPathValid = await validateK8sAdvancedPath("kubeconfig", false); + + return kubectlPathValid && kubeconfigPathValid; + }; + + const getK8sAdvancedPathParams = useCallback((): Partial => { + if (!formData.k8s_enabled || k8sMode !== "inline") { + return {}; + } + + return { + k8s_kubectl_path: k8sKubectlPathInput.trim() || undefined, + k8s_kubeconfig_path: k8sKubeconfigPathInput.trim() || undefined, + }; + }, [ + formData.k8s_enabled, + k8sKubeconfigPathInput, + k8sKubectlPathInput, + k8sMode, + ]); + + const haveK8sAdvancedPathInputsChanged = useCallback(() => { + if (!formData.k8s_enabled || k8sMode !== "inline") { + return false; + } + + const currentPaths = getK8sAdvancedPathParams(); + return ( + formData.k8s_kubectl_path !== currentPaths.k8s_kubectl_path || + formData.k8s_kubeconfig_path !== currentPaths.k8s_kubeconfig_path + ); + }, [ + formData.k8s_enabled, + formData.k8s_kubeconfig_path, + formData.k8s_kubectl_path, + getK8sAdvancedPathParams, + k8sMode, + ]); + + const handleK8sKubectlPathInputChange = (value: string) => { + setK8sKubectlPathInput(value); + setK8sKubectlPathValidation(EmptyPathValidationState); + }; + + const handleK8sKubeconfigPathInputChange = (value: string) => { + setK8sKubeconfigPathInput(value); + setK8sKubeconfigPathValidation(EmptyPathValidationState); + }; + const loadDatabases = async (overrides?: Partial) => { const effectiveDriver = overrides?.driver ?? driver; const targetDriver = drivers.find((d) => d.id === effectiveDriver); @@ -539,6 +819,8 @@ export const NewConnectionModal = ({ setConnectionStringError(null); setNameError(false); setDatabasesTabError(false); + setK8sDiscoveryError(null); + setShowK8sAdvanced(false); setIsK8sPortOverridden(false); if (initialConnection) { @@ -567,6 +849,9 @@ export const NewConnectionModal = ({ } setIsK8sPortOverridden(params.k8s_port != null); + setShowK8sAdvanced(Boolean(params.k8s_kubectl_path || params.k8s_kubeconfig_path)); + setK8sKubectlPathInput(params.k8s_kubectl_path ?? ""); + setK8sKubeconfigPathInput(params.k8s_kubeconfig_path ?? ""); if (Array.isArray(db)) { setSelectedDatabasesState(db); setFormData({ ...params, database: db[0] ?? "" }); @@ -593,18 +878,23 @@ export const NewConnectionModal = ({ ssh_enabled: false, ssh_port: 22, k8s_enabled: false, + k8s_kubectl_path: undefined, + k8s_kubeconfig_path: undefined, }); setSelectedDatabasesState([]); setSshMode("existing"); setK8sMode("existing"); setIsK8sPortOverridden(false); + setK8sDiscoveryError(null); + setShowK8sAdvanced(false); + setK8sKubectlPathInput(""); + setK8sKubeconfigPathInput(""); setDetectJsonInTextColumns(false); setAppearance({}); } await loadSshConnectionsList(); await loadK8sConnectionsList(); - await loadK8sContextsList(); }; void init(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -636,8 +926,14 @@ export const NewConnectionModal = ({ k8s_resource_type: undefined, k8s_resource_name: undefined, k8s_port: undefined, + k8s_kubectl_path: undefined, + k8s_kubeconfig_path: undefined, }); setIsK8sPortOverridden(false); + setK8sKubectlPathInput(""); + setK8sKubeconfigPathInput(""); + setK8sKubectlPathValidation(EmptyPathValidationState); + setK8sKubeconfigPathValidation(EmptyPathValidationState); setSelectedDatabasesState([]); setDbSearchQuery(""); setAvailableDatabases([]); @@ -652,6 +948,33 @@ export const NewConnectionModal = ({ }; const testConnection = async () => { + const k8sAdvancedPathsValid = await validateK8sAdvancedPathsForSave(); + if (!k8sAdvancedPathsValid) { + setStatus("error"); + setMessage( + t("newConnection.k8sPathValidationFailed", { + defaultValue: "Fix invalid Kubernetes advanced path fields before saving.", + }), + ); + setTestResult("error"); + setActiveTab("k8s"); + return false; + } + + if (haveK8sAdvancedPathInputsChanged()) { + applyK8sAdvancedFields(); + setStatus("error"); + setMessage( + t("newConnection.k8sPathSelectionReset", { + defaultValue: + "Advanced Kubernetes paths changed. Choose the context, namespace, and resource again.", + }), + ); + setTestResult("error"); + setActiveTab("k8s"); + return false; + } + setStatus("testing"); setMessage(""); setTestResult(null); @@ -659,6 +982,7 @@ export const NewConnectionModal = ({ const testParams: Partial = { driver, ...formData, + ...getK8sAdvancedPathParams(), port: formData.port != null ? Number(formData.port) : undefined, k8s_port: effectiveK8sPort, database: isMultiDb @@ -727,6 +1051,34 @@ export const NewConnectionModal = ({ setTestResult("error"); return; } + + const k8sAdvancedPathsValid = await validateK8sAdvancedPathsForSave(); + if (!k8sAdvancedPathsValid) { + setStatus("error"); + setMessage( + t("newConnection.k8sPathValidationFailed", { + defaultValue: "Fix invalid Kubernetes advanced path fields before saving.", + }), + ); + setTestResult("error"); + setActiveTab("k8s"); + return; + } + + if (haveK8sAdvancedPathInputsChanged()) { + applyK8sAdvancedFields(); + setStatus("error"); + setMessage( + t("newConnection.k8sPathSelectionReset", { + defaultValue: + "Advanced Kubernetes paths changed. Choose the context, namespace, and resource again.", + }), + ); + setTestResult("error"); + setActiveTab("k8s"); + return; + } + setStatus("saving"); setMessage(""); setTestResult(null); @@ -734,6 +1086,7 @@ export const NewConnectionModal = ({ const params: Partial = { driver, ...formData, + ...getK8sAdvancedPathParams(), port: formData.port != null ? Number(formData.port) : undefined, k8s_port: effectiveK8sPort, database: isMultiDb @@ -1677,6 +2030,14 @@ export const NewConnectionModal = ({ updateField("k8s_resource_type", undefined); updateField("k8s_resource_name", undefined); updateField("k8s_port", undefined); + updateField("k8s_kubectl_path", undefined); + updateField("k8s_kubeconfig_path", undefined); + setK8sDiscoveryError(null); + setShowK8sAdvanced(false); + setK8sKubectlPathInput(""); + setK8sKubeconfigPathInput(""); + setK8sKubectlPathValidation(EmptyPathValidationState); + setK8sKubeconfigPathValidation(EmptyPathValidationState); setIsK8sPortOverridden(false); } else { updateField("k8s_connection_id", undefined); @@ -1761,13 +2122,18 @@ export const NewConnectionModal = ({ options={k8sContexts} onChange={(val) => { updateField("k8s_context", val); + updateField("k8s_namespace", undefined); + updateField("k8s_resource_name", undefined); + setK8sDiscoveryError(null); }} searchPlaceholder={t("common.search")} - noResultsLabel={t("common.noResults")} + noResultsLabel={t("newConnection.noK8sContexts", { + defaultValue: "No contexts available", + })} placeholder={ k8sContexts.length === 0 ? t("newConnection.noK8sContexts", { - defaultValue: "No contexts found (is kubectl installed?)", + defaultValue: "No contexts available", }) : t("newConnection.chooseContext", { defaultValue: "Choose a context...", @@ -1787,9 +2153,13 @@ export const NewConnectionModal = ({ options={k8sNamespaces} onChange={(val) => { updateField("k8s_namespace", val); + updateField("k8s_resource_name", undefined); + setK8sDiscoveryError(null); }} searchPlaceholder={t("common.search")} - noResultsLabel={t("common.noResults")} + noResultsLabel={t("newConnection.noK8sNamespaces", { + defaultValue: "No namespaces available", + })} placeholder={ k8sNamespaces.length === 0 ? t("newConnection.selectContextFirst", { @@ -1822,6 +2192,7 @@ export const NewConnectionModal = ({ }} onChange={(val) => { updateField("k8s_resource_type", val); + updateField("k8s_resource_name", undefined); }} placeholder={t("newConnection.k8sSelectType", { defaultValue: "Select type...", @@ -1839,11 +2210,14 @@ export const NewConnectionModal = ({