diff --git a/new_banner.png b/new_banner.png new file mode 100644 index 00000000..6dd676fe Binary files /dev/null and b/new_banner.png differ diff --git a/src-tauri/src/commands/kube.rs b/src-tauri/src/commands/kube.rs index d556b987..d2f19159 100644 --- a/src-tauri/src/commands/kube.rs +++ b/src-tauri/src/commands/kube.rs @@ -1,6 +1,9 @@ +use crate::kube::portforward::PortForwardSessionConfig; use crate::kube::ClusterClient; use crate::state::AppState; use serde::{Deserialize, Serialize}; +use serde_yaml::Value; +use std::sync::Arc; use tauri::State; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -37,14 +40,20 @@ pub async fn add_cluster( kubeconfig_content: String, state: State<'_, AppState>, ) -> Result { + if kubeconfig_content.trim().is_empty() { + return Err("Kubeconfig content cannot be empty".to_string()); + } + let context = extract_context(&kubeconfig_content)?; let server_url = extract_server_url(&kubeconfig_content)?; + let kubeconfig_arc = Arc::new(kubeconfig_content.clone()); let client = ClusterClient::new( id.clone(), name.clone(), context.clone(), server_url.clone(), + kubeconfig_arc, ); { @@ -60,6 +69,49 @@ pub async fn add_cluster( }) } +fn extract_context(content: &str) -> Result { + let value: Value = + serde_yaml::from_str(content).map_err(|e| format!("Invalid kubeconfig YAML: {}", e))?; + + let contexts = value + .get("contexts") + .and_then(|c| c.as_sequence()) + .ok_or("Missing 'contexts' field in kubeconfig")?; + + if contexts.is_empty() { + return Err("No contexts found in kubeconfig".to_string()); + } + + let first_context = contexts[0].get("name").and_then(|n| n.as_str()); + first_context + .map(|s| s.to_string()) + .ok_or_else(|| "Context name not found".to_string()) +} + +fn extract_server_url(content: &str) -> Result { + let value: Value = + serde_yaml::from_str(content).map_err(|e| format!("Invalid kubeconfig YAML: {}", e))?; + + let clusters = value + .get("clusters") + .and_then(|c| c.as_sequence()) + .ok_or("Missing 'clusters' field in kubeconfig")?; + + if clusters.is_empty() { + return Err("No clusters found in kubeconfig".to_string()); + } + + let cluster = &clusters[0]; + let server = cluster + .get("cluster") + .and_then(|c| c.get("server")) + .and_then(|s| s.as_str()); + + server + .map(|s| s.to_string()) + .ok_or_else(|| "Server URL not found in cluster".to_string()) +} + #[tauri::command] pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> { let mut clusters = state.clusters.lock().await; @@ -95,15 +147,24 @@ pub async fn start_port_forward( ) -> Result { let session_id = uuid::Uuid::now_v7().to_string(); - let session = crate::kube::PortForwardSession::new( - session_id.clone(), - request.cluster_id.clone(), - request.namespace.clone(), - request.pod.clone(), - None, - vec![request.container_port], - vec![0], - ); + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&request.cluster_id) + .ok_or_else(|| format!("Cluster {} not found", request.cluster_id))?; + + let cluster_name = cluster.name.clone(); + let _kubeconfig_content = cluster.kubeconfig_content.clone(); + + let session = crate::kube::PortForwardSession::new(PortForwardSessionConfig { + id: session_id.clone(), + cluster_id: request.cluster_id.clone(), + cluster_name, + namespace: request.namespace.clone(), + pod: request.pod.clone(), + container: None, + ports: vec![request.container_port], + local_ports: vec![0], + }); { let mut port_forwards = state.port_forwards.lock().await; @@ -159,10 +220,13 @@ pub async fn list_port_forwards( Ok(forwards) } -fn extract_context(_content: &str) -> Result { - Ok("default".to_string()) -} +#[tauri::command] +pub async fn delete_port_forward(id: String, state: State<'_, AppState>) -> Result<(), String> { + let mut port_forwards = state.port_forwards.lock().await; -fn extract_server_url(_content: &str) -> Result { - Ok("unknown".to_string()) + if port_forwards.remove(&id).is_none() { + return Err(format!("Port forward session {id} not found")); + } + + Ok(()) } diff --git a/src-tauri/src/kube/client.rs b/src-tauri/src/kube/client.rs index f33b18c1..81f11cf7 100644 --- a/src-tauri/src/kube/client.rs +++ b/src-tauri/src/kube/client.rs @@ -1,17 +1,27 @@ +use std::sync::Arc; + pub struct ClusterClient { pub id: String, pub name: String, pub context: String, pub server_url: String, + pub kubeconfig_content: Arc, } impl ClusterClient { - pub fn new(id: String, name: String, context: String, server_url: String) -> Self { + pub fn new( + id: String, + name: String, + context: String, + server_url: String, + kubeconfig_content: Arc, + ) -> Self { Self { id, name, context, server_url, + kubeconfig_content, } } } diff --git a/src-tauri/src/kube/portforward.rs b/src-tauri/src/kube/portforward.rs index 44944971..2b70ffc6 100644 --- a/src-tauri/src/kube/portforward.rs +++ b/src-tauri/src/kube/portforward.rs @@ -1,12 +1,18 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + pub struct PortForwardSession { pub id: String, pub cluster_id: String, + pub cluster_name: String, pub namespace: String, pub pod: String, pub container: Option, pub ports: Vec, pub local_ports: Vec, pub status: PortForwardStatus, + pub kubectl_child: Option>>, + pub is_stopped: Arc, } pub enum PortForwardStatus { @@ -15,33 +21,59 @@ pub enum PortForwardStatus { Error(String), } +#[derive(Debug, Clone)] +pub struct PortForwardSessionConfig { + pub id: String, + pub cluster_id: String, + pub cluster_name: String, + pub namespace: String, + pub pod: String, + pub container: Option, + pub ports: Vec, + pub local_ports: Vec, +} + impl PortForwardSession { - pub fn new( - id: String, - cluster_id: String, - namespace: String, - pod: String, - container: Option, - ports: Vec, - local_ports: Vec, - ) -> Self { + pub fn new(config: PortForwardSessionConfig) -> Self { Self { - id, - cluster_id, - namespace, - pod, - container, - ports, - local_ports, + id: config.id, + cluster_id: config.cluster_id, + cluster_name: config.cluster_name, + namespace: config.namespace, + pod: config.pod, + container: config.container, + ports: config.ports, + local_ports: config.local_ports, status: PortForwardStatus::Active, + kubectl_child: None, + is_stopped: Arc::new(AtomicBool::new(false)), } } pub fn stop(&mut self) { + self.is_stopped.store(true, Ordering::SeqCst); self.status = PortForwardStatus::Stopped; + + if let Some(child_mutex) = &self.kubectl_child { + let mut child = child_mutex.lock().unwrap(); + let _ = child.kill(); + } } pub fn is_active(&self) -> bool { matches!(self.status, PortForwardStatus::Active) } } + +impl Drop for PortForwardSession { + fn drop(&mut self) { + if self.is_stopped.load(Ordering::SeqCst) { + return; + } + + if let Some(child_mutex) = &self.kubectl_child { + let mut child = child_mutex.lock().unwrap(); + let _ = child.kill(); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b2916bed..49da2a05 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -181,6 +181,7 @@ pub fn run() { commands::kube::start_port_forward, commands::kube::stop_port_forward, commands::kube::list_port_forwards, + commands::kube::delete_port_forward, ]) .run(tauri::generate_context!()) .expect("Error running Troubleshooting and RCA Assistant application"); diff --git a/src/components/Kubernetes/AddClusterModal.tsx b/src/components/Kubernetes/AddClusterModal.tsx new file mode 100644 index 00000000..f09c98de --- /dev/null +++ b/src/components/Kubernetes/AddClusterModal.tsx @@ -0,0 +1,130 @@ +import React, { useState } from "react"; +import { X, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui"; +import type { ClusterInfo } from "@/lib/tauriCommands"; +import { addClusterCmd } from "@/lib/tauriCommands"; + +interface AddClusterModalProps { + isOpen: boolean; + onClose: () => void; + onAdd: (cluster: ClusterInfo) => void; +} + +export function AddClusterModal({ isOpen, onClose, onAdd }: AddClusterModalProps) { + const [id, setId] = useState(""); + const [name, setName] = useState(""); + const [kubeconfig, setKubeconfig] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + if (!isOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (!id.trim() || !name.trim() || !kubeconfig.trim()) { + setError("All fields are required"); + return; + } + + setIsLoading(true); + + try { + const cluster = await addClusterCmd(id, name, kubeconfig); + onAdd(cluster); + onClose(); + setId(""); + setName(""); + setKubeconfig(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to add cluster"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+

Add Kubernetes Cluster

+ +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setId(e.target.value)} + placeholder="e.g., prod-cluster-01" + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + disabled={isLoading} + /> +
+ +
+ + setName(e.target.value)} + placeholder="e.g., Production Cluster" + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + disabled={isLoading} + /> +
+ +
+ +