diff --git a/TICKET-kube-action-namespace-stability.md b/TICKET-kube-action-namespace-stability.md new file mode 100644 index 00000000..74cf4307 --- /dev/null +++ b/TICKET-kube-action-namespace-stability.md @@ -0,0 +1,75 @@ +# Ticket Summary — Kubernetes Action Namespace & Stability Fixes + +**Branch**: `fix/kube-action-namespace-and-stability` +**PR**: https://gogs.tftsr.com/sarman/tftsr-devops_investigation/pulls/86 + +--- + +## Description + +Seven bugs in the Kubernetes management interface were identified via systematic debugging and resolved across 6 commits. + +The most severe was a **temp kubeconfig race condition** in the Rust backend: every kubectl-based IPC command wrote a temp file to a static path derived only from `cluster_id`. Concurrent calls — triggered by rapid section or namespace switching — shared identical paths. `TempFileCleanup::drop()` on the first-to-finish call deleted the file while a concurrent kubectl process was still reading it. Errors were silently swallowed, leaving the UI showing stale/empty data. This was the root cause of "things stop loading after a few selection changes." + +The second major class of bugs was **namespace `"all"` passed to targeted kubectl commands**. When the user selects "All Namespaces", `KubernetesPage` stores `selectedNamespace = "all"` and passes it as a prop to all list components. `loadResourceData` correctly converts `"all" → ""` for list fetching (which becomes `--all-namespaces` in Rust). However, action handlers inside list components (edit, delete, scale, logs, shell, attach) used the raw prop and forwarded `"all"` to `kubectl -n all`, producing "namespaces 'all' not found" errors. + +--- + +## Acceptance Criteria + +- [x] Rapid section/namespace switching no longer causes data to stop loading +- [x] Pod Logs loads successfully when "All Namespaces" is selected +- [x] Pod Shell, Attach, and Edit open and target the pod's actual namespace +- [x] Deployment, StatefulSet, DaemonSet, and all other workload action commands work under "All Namespaces" +- [x] Network, Config, Storage, and Access Control action commands work under "All Namespaces" +- [x] Workloads → Overview shows actual resource counts (not all-zero) +- [x] Cluster connection errors display a visible banner instead of failing silently +- [x] `connectClusterFromKubeconfigCmd` is only called once on mount, not twice +- [x] Dark mode — all text is readable; status indicators are visible + +--- + +## Work Implemented + +### Commit 1 — `fix(kube): unique temp kubeconfig paths` +**File**: `src-tauri/src/commands/kube.rs` + +Added `KUBECONFIG_COUNTER: AtomicU64` and `unique_kubeconfig_path(cluster_id)` helper. Replaced all 74 static `temp_dir.join(format!("kubeconfig-{}-*.yaml"))` calls with the helper. Each invocation now gets a globally unique path, eliminating the race. + +### Commit 2 — `fix(ui): replace hardcoded colors with semantic Tailwind vars` +**Files**: `src/components/Kubernetes/PortForwardList.tsx`, `src/components/Kubernetes/WorkloadOverview.tsx` + +Replaced non-adaptive `text-gray-*` / `bg-gray-*` classes with `text-muted-foreground`, `bg-muted`, `border-border` — Tailwind CSS vars that correctly invert in dark mode. + +### Commit 3 — `fix(kube): WorkloadOverview loads data; single connect; visible error` +**Files**: `src/pages/Kubernetes/KubernetesPage.tsx`, `tests/unit/KubernetesPage.test.tsx` + +- Added `case "workloads_overview"` in `loadResourceData` that fetches pods + deployments + statefulsets + daemonsets + jobs + cronjobs via `Promise.allSettled` in parallel. +- Added `initializedRef` guard in `loadInitialData` to prevent double-connect when `selectedClusterId` changes. +- Connection errors now captured and shown as a dismissible banner. + +### Commit 4 — `fix(kube): add namespace to PodInfo; pod actions use pod.namespace` +**Files**: `src-tauri/src/commands/kube.rs`, `src/lib/tauriCommands.ts`, `src/components/Kubernetes/PodList.tsx`, `tests/unit/PodList.test.tsx` + +Added `namespace: String` to `PodInfo` Rust struct, extracted from `metadata.namespace` in `parse_pods_json`. Added `namespace: string` to TypeScript `PodInfo` interface. Updated all 6 action call sites in `PodList` to use `pod.namespace`. + +### Commit 5 — `fix(kube): network/config/storage list actions use item.namespace` +**Files**: `ServiceList`, `IngressList`, `ConfigMapList`, `SecretList`, `HPAList`, `PVCList`, `ServiceAccountList`, `RoleList`, `RoleBindingList`, `NetworkPolicyList`, `ResourceQuotaList`, `LimitRangeList` + `tests/unit/NamespaceActionFix.test.tsx` + +12 components fixed. 24 new tests (2 per component). + +### Commit 6 — `fix(kube): workload list actions use item.namespace not filter prop` +**Files**: `DeploymentList`, `StatefulSetList`, `DaemonSetList`, `ReplicaSetList`, `JobList`, `CronJobList` + `tests/unit/WorkloadListActions.test.tsx` + +6 components fixed. 21 new tests. + +--- + +## Testing Needed + +1. **Automated**: `cargo test` → 364 pass; `npm run test:run` → 325 pass; `npx tsc --noEmit` → 0; `npx eslint . --max-warnings 0` → 0; `cargo clippy -- -D warnings` → 0; `cargo fmt --check` → clean +2. **Manual — race condition**: With a live cluster, rapidly switch between Pods → Deployments → Services → ConfigMaps several times. Data should load reliably every time. +3. **Manual — pod actions**: Select "All Namespaces". Open pod action menu → Logs → should fetch without error. Shell/Attach → modals open, exec targets correct namespace. Edit → YAML editor opens. +4. **Manual — overview**: Navigate to Workloads → Overview. Cards should show actual pod/deployment/etc. counts. +5. **Manual — error banner**: Configure an invalid kubeconfig. Navigate to Kubernetes page. A red banner should appear with the connection error. Clicking Dismiss hides it. +6. **Manual — dark mode**: Switch to dark theme. All text in Kubernetes pages (sidebar, tables, status indicators) should be readable with good contrast. diff --git a/src-tauri/src/commands/kube.rs b/src-tauri/src/commands/kube.rs index 68679252..c75f7693 100644 --- a/src-tauri/src/commands/kube.rs +++ b/src-tauri/src/commands/kube.rs @@ -20,6 +20,13 @@ lazy_static! { static ref NAME_PATTERN_REGEX: Regex = Regex::new(r"^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$").unwrap(); } +static KUBECONFIG_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn unique_kubeconfig_path(cluster_id: impl AsRef) -> std::path::PathBuf { + let n = KUBECONFIG_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::env::temp_dir().join(format!("kubeconfig-{}-{}.yaml", cluster_id.as_ref(), n)) +} + struct TempFileCleanup(std::path::PathBuf); impl Drop for TempFileCleanup { fn drop(&mut self) { @@ -84,6 +91,7 @@ pub struct PortForwardResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PodInfo { pub name: String, + pub namespace: String, pub status: String, pub ready: String, pub age: String, @@ -319,8 +327,7 @@ pub async fn test_kubectl_connection( (cluster.kubeconfig_content.clone(), cluster.context.clone()) }; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-diag.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content.as_ref()) @@ -467,8 +474,7 @@ pub async fn test_cluster_connection( let context = &cluster.context; // Write kubeconfig to temp file and ensure cleanup even on panic - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(&cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -517,8 +523,7 @@ pub async fn discover_pods( let context = &cluster.context; // Write kubeconfig to temp file and ensure cleanup even on panic - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-pods.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -647,8 +652,7 @@ pub async fn start_port_forward( ); // Write kubeconfig to temp file - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}.yaml", request.cluster_id)); + let temp_path = unique_kubeconfig_path(&request.cluster_id); write_secure_temp_file(&temp_path, kubeconfig_content.as_ref()) .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; @@ -969,8 +973,7 @@ pub async fn list_namespaces( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-namespaces.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -1052,8 +1055,7 @@ pub async fn list_pods( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-pods.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -1106,6 +1108,13 @@ fn parse_pods_json(json_str: &str) -> Result, String> { .unwrap_or("unknown") .to_string(); + let namespace = item + .get("metadata") + .and_then(|m| m.get("namespace")) + .and_then(|n| n.as_str()) + .unwrap_or("default") + .to_string(); + let status = item .get("status") .and_then(|s| s.get("phase")) @@ -1152,6 +1161,7 @@ fn parse_pods_json(json_str: &str) -> Result, String> { pods.push(PodInfo { name, + namespace, status, ready, age, @@ -1176,8 +1186,7 @@ pub async fn list_services( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-services.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -1335,8 +1344,7 @@ pub async fn list_deployments( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-deployments.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -1470,8 +1478,7 @@ pub async fn list_statefulsets( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-statefulsets.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -1589,8 +1596,7 @@ pub async fn list_daemonsets( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-daemonsets.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -1758,8 +1764,7 @@ pub async fn get_pod_logs( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-logs.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -1808,8 +1813,7 @@ pub async fn scale_deployment( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-scale.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -1856,8 +1860,7 @@ pub async fn restart_deployment( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-restart.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -1904,8 +1907,7 @@ pub async fn delete_resource( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-delete.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -1953,8 +1955,7 @@ pub async fn exec_pod( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-exec.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -2232,8 +2233,7 @@ pub async fn list_replicasets( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-replicasets.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -2351,8 +2351,7 @@ pub async fn list_jobs( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-jobs.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -2508,8 +2507,7 @@ pub async fn list_cronjobs( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-cronjobs.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -2636,8 +2634,7 @@ pub async fn list_configmaps( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-configmaps.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -2735,8 +2732,7 @@ pub async fn list_secrets( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-secrets.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -2840,8 +2836,7 @@ pub async fn list_nodes( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-nodes.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -3029,8 +3024,7 @@ pub async fn list_events( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-events.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -3159,8 +3153,7 @@ pub async fn list_ingresses( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-ingresses.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -3286,8 +3279,7 @@ pub async fn list_persistentvolumeclaims( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-pvcs.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -3417,8 +3409,7 @@ pub async fn list_persistentvolumes( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-pvs.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -3544,8 +3535,7 @@ pub async fn list_serviceaccounts( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-sas.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -3643,8 +3633,7 @@ pub async fn list_roles( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-roles.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -3734,8 +3723,7 @@ pub async fn list_clusterroles( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-clusterroles.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -3810,8 +3798,7 @@ pub async fn list_rolebindings( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-rolebindings.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -3909,11 +3896,7 @@ pub async fn list_clusterrolebindings( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!( - "kubeconfig-{}-clusterrolebindings.yaml", - cluster_id - )); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -3999,8 +3982,7 @@ pub async fn list_horizontalpodautoscalers( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-hpas.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -4118,8 +4100,7 @@ pub async fn list_storageclasses( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-storageclasses.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -4224,8 +4205,7 @@ pub async fn list_networkpolicies( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-networkpolicies.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -4336,8 +4316,7 @@ pub async fn list_resourcequotas( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-resourcequotas.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -4458,8 +4437,7 @@ pub async fn list_limitranges( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-limitranges.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -4558,8 +4536,7 @@ pub async fn cordon_node( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-cordon.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -4600,8 +4577,7 @@ pub async fn uncordon_node( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-uncordon.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -4642,8 +4618,7 @@ pub async fn drain_node( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-drain.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -4688,8 +4663,7 @@ pub async fn rollback_deployment( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-rollback.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -4736,8 +4710,7 @@ pub async fn create_resource( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-create.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -4800,8 +4773,7 @@ pub async fn edit_resource( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-edit.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -5050,8 +5022,7 @@ pub async fn list_replicationcontrollers( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-rcs.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -5175,8 +5146,7 @@ pub async fn list_poddisruptionbudgets( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-pdbs.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -5291,8 +5261,7 @@ pub async fn list_priorityclasses( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-priorityclasses.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -5389,8 +5358,7 @@ pub async fn list_runtimeclasses( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-runtimeclasses.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -5475,8 +5443,7 @@ pub async fn list_leases( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-leases.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -5578,8 +5545,7 @@ pub async fn list_mutatingwebhookconfigurations( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-mwhc.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -5669,8 +5635,7 @@ pub async fn list_validatingwebhookconfigurations( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-vwhc.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -5761,8 +5726,7 @@ pub async fn list_endpoints( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-endpoints.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -5877,8 +5841,7 @@ pub async fn list_endpointslices( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-endpointslices.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6000,8 +5963,7 @@ pub async fn list_ingressclasses( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-ingressclasses.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6101,8 +6063,7 @@ pub async fn list_namespaces_resource( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-nsres.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6187,8 +6148,7 @@ pub async fn list_crds( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-crds.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6312,8 +6272,7 @@ pub async fn list_custom_resources( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-cr.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(&cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6439,8 +6398,7 @@ pub async fn force_delete_resource( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-force-delete.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(&cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6498,8 +6456,7 @@ pub async fn describe_resource( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-describe.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6553,8 +6510,7 @@ pub async fn get_resource_yaml( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-getyaml.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6608,8 +6564,7 @@ pub async fn attach_pod( let session_id = uuid::Uuid::now_v7().to_string(); - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-attach.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(&cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6676,8 +6631,7 @@ pub async fn restart_statefulset( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-restart-sts.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6724,8 +6678,7 @@ pub async fn restart_daemonset( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-restart-ds.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6773,8 +6726,7 @@ pub async fn scale_statefulset( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-scale-sts.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6822,8 +6774,7 @@ pub async fn scale_replicaset( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-scale-rs.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6871,8 +6822,7 @@ pub async fn scale_replicationcontroller( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-scale-rc.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6919,8 +6869,7 @@ pub async fn suspend_cronjob( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-suspend-cj.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -6968,8 +6917,7 @@ pub async fn resume_cronjob( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-resume-cj.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -7017,8 +6965,7 @@ pub async fn trigger_cronjob( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-trigger-cj.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -7069,8 +7016,7 @@ pub async fn create_namespace( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-create-ns.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(&cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -7116,8 +7062,7 @@ pub async fn delete_namespace( let kubeconfig_content = cluster.kubeconfig_content.as_ref(); let context = &cluster.context; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-delete-ns.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(&cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_content) @@ -7189,8 +7134,7 @@ pub async fn stream_pod_logs( let (kubeconfig_arc, context) = kubeconfig_content; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-stream.yaml", config.cluster_id)); + let temp_path = unique_kubeconfig_path(config.cluster_id); write_secure_temp_file(&temp_path, kubeconfig_arc.as_ref()) .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; @@ -7514,8 +7458,7 @@ pub async fn helm_list_releases( let (kubeconfig_arc, context) = kubeconfig_content; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-helm-list.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_arc.as_ref()) @@ -7638,8 +7581,7 @@ pub async fn helm_uninstall( let (kubeconfig_arc, context) = kubeconfig_content; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-helm-uninstall.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(&cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_arc.as_ref()) @@ -7690,8 +7632,7 @@ pub async fn helm_rollback( let (kubeconfig_arc, context) = kubeconfig_content; - let temp_dir = std::env::temp_dir(); - let temp_path = temp_dir.join(format!("kubeconfig-{}-helm-rollback.yaml", cluster_id)); + let temp_path = unique_kubeconfig_path(&cluster_id); let _cleanup = TempFileCleanup(temp_path.clone()); write_secure_temp_file(&temp_path, kubeconfig_arc.as_ref()) @@ -7909,4 +7850,14 @@ mod new_command_tests { let result = parse_crds_json(json).unwrap(); assert!(result.is_empty()); } + + #[test] + fn test_unique_kubeconfig_path_produces_distinct_paths() { + let path1 = unique_kubeconfig_path("test-cluster"); + let path2 = unique_kubeconfig_path("test-cluster"); + assert_ne!( + path1, path2, + "successive calls must return distinct paths to prevent concurrent-call race conditions" + ); + } } diff --git a/src/components/Kubernetes/ConfigMapList.tsx b/src/components/Kubernetes/ConfigMapList.tsx index 5f3cd5f9..64c315ad 100644 --- a/src/components/Kubernetes/ConfigMapList.tsx +++ b/src/components/Kubernetes/ConfigMapList.tsx @@ -19,7 +19,7 @@ type ActiveModal = | { type: "delete"; cm: ConfigMapInfo } | null; -export function ConfigMapList({ configmaps, clusterId, namespace, onRefresh }: ConfigMapListProps) { +export function ConfigMapList({ configmaps, clusterId, namespace: _namespace, onRefresh }: ConfigMapListProps) { const [activeModal, setActiveModal] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -27,7 +27,7 @@ export function ConfigMapList({ configmaps, clusterId, namespace, onRefresh }: C const openEdit = async (cm: ConfigMapInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(clusterId, "configmaps", namespace, cm.name); + const yaml = await getResourceYamlCmd(clusterId, "configmaps", cm.namespace, cm.name); setActiveModal({ type: "edit", cm, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -38,7 +38,7 @@ export function ConfigMapList({ configmaps, clusterId, namespace, onRefresh }: C if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(clusterId, "configmaps", namespace, activeModal.cm.name); + await deleteResourceCmd(clusterId, "configmaps", activeModal.cm.namespace, activeModal.cm.name); setActiveModal(null); onRefresh?.(); } finally { @@ -104,7 +104,7 @@ export function ConfigMapList({ configmaps, clusterId, namespace, onRefresh }: C (null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -44,7 +41,7 @@ export function CronJobList({ const openEdit = async (cj: CronJobInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(cid, "cronjobs", ns, cj.name); + const yaml = await getResourceYamlCmd(cid, "cronjobs", cj.namespace, cj.name); setActiveModal({ type: "edit", cj, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -54,7 +51,7 @@ export function CronJobList({ const handleSuspend = async (cj: CronJobInfo) => { setActionError(null); try { - await suspendCronjobCmd(cid, ns, cj.name); + await suspendCronjobCmd(cid, cj.namespace, cj.name); onRefresh?.(); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -64,7 +61,7 @@ export function CronJobList({ const handleResume = async (cj: CronJobInfo) => { setActionError(null); try { - await resumeCronjobCmd(cid, ns, cj.name); + await resumeCronjobCmd(cid, cj.namespace, cj.name); onRefresh?.(); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -74,7 +71,7 @@ export function CronJobList({ const handleTrigger = async (cj: CronJobInfo) => { setActionError(null); try { - await triggerCronjobCmd(cid, ns, cj.name); + await triggerCronjobCmd(cid, cj.namespace, cj.name); onRefresh?.(); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -85,7 +82,7 @@ export function CronJobList({ if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(cid, "cronjobs", ns, activeModal.cj.name); + await deleteResourceCmd(cid, "cronjobs", activeModal.cj.namespace, activeModal.cj.name); setActiveModal(null); onRefresh?.(); } finally { @@ -183,7 +180,7 @@ export function CronJobList({ (null); const [isActing, setIsActing] = useState(false); const [actionError, setActionError] = useState(null); @@ -32,7 +32,7 @@ export function DaemonSetList({ daemonsets, clusterId, namespace, onRefresh }: D const openEdit = async (ds: DaemonSetInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(clusterId, "daemonsets", namespace, ds.name); + const yaml = await getResourceYamlCmd(clusterId, "daemonsets", ds.namespace, ds.name); setActiveModal({ type: "edit", ds, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -43,7 +43,7 @@ export function DaemonSetList({ daemonsets, clusterId, namespace, onRefresh }: D if (activeModal?.type !== "restart") return; setIsActing(true); try { - await restartDaemonsetCmd(clusterId, namespace, activeModal.ds.name); + await restartDaemonsetCmd(clusterId, activeModal.ds.namespace, activeModal.ds.name); setActiveModal(null); onRefresh?.(); } catch (err) { @@ -57,7 +57,7 @@ export function DaemonSetList({ daemonsets, clusterId, namespace, onRefresh }: D if (activeModal?.type !== "delete") return; setIsActing(true); try { - await deleteResourceCmd(clusterId, "daemonsets", namespace, activeModal.ds.name); + await deleteResourceCmd(clusterId, "daemonsets", activeModal.ds.namespace, activeModal.ds.name); setActiveModal(null); onRefresh?.(); } finally { @@ -146,7 +146,7 @@ export function DaemonSetList({ daemonsets, clusterId, namespace, onRefresh }: D (null); const [isActing, setIsActing] = useState(false); const [actionError, setActionError] = useState(null); @@ -37,7 +37,7 @@ export function DeploymentList({ deployments, clusterId, namespace, onRefresh }: const openEdit = async (deployment: DeploymentInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(clusterId, "deployments", namespace, deployment.name); + const yaml = await getResourceYamlCmd(clusterId, "deployments", deployment.namespace, deployment.name); setActiveModal({ type: "edit", deployment, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -48,7 +48,7 @@ export function DeploymentList({ deployments, clusterId, namespace, onRefresh }: if (activeModal?.type !== "restart") return; setIsActing(true); try { - await restartDeploymentCmd(clusterId, namespace, activeModal.deployment.name); + await restartDeploymentCmd(clusterId, activeModal.deployment.namespace, activeModal.deployment.name); setActiveModal(null); onRefresh?.(); } catch (err) { @@ -62,7 +62,7 @@ export function DeploymentList({ deployments, clusterId, namespace, onRefresh }: if (activeModal?.type !== "rollback") return; setIsActing(true); try { - await rollbackDeploymentCmd(clusterId, namespace, activeModal.deployment.name); + await rollbackDeploymentCmd(clusterId, activeModal.deployment.namespace, activeModal.deployment.name); setActiveModal(null); onRefresh?.(); } catch (err) { @@ -76,7 +76,7 @@ export function DeploymentList({ deployments, clusterId, namespace, onRefresh }: if (activeModal?.type !== "delete") return; setIsActing(true); try { - await deleteResourceCmd(clusterId, "deployments", namespace, activeModal.deployment.name); + await deleteResourceCmd(clusterId, "deployments", activeModal.deployment.namespace, activeModal.deployment.name); setActiveModal(null); onRefresh?.(); } finally { @@ -165,7 +165,7 @@ export function DeploymentList({ deployments, clusterId, namespace, onRefresh }: resourceName={activeModal.deployment.name} currentReplicas={activeModal.deployment.replicas} onScale={(replicas) => - scaleDeploymentCmd(clusterId, namespace, activeModal.deployment.name, replicas).then(() => { + scaleDeploymentCmd(clusterId, activeModal.deployment.namespace, activeModal.deployment.name, replicas).then(() => { setActiveModal(null); onRefresh?.(); }) @@ -201,7 +201,7 @@ export function DeploymentList({ deployments, clusterId, namespace, onRefresh }: (null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -38,7 +35,7 @@ export function HPAList({ const openEdit = async (hpa: HorizontalPodAutoscalerInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(cid, "horizontalpodautoscalers", ns, hpa.name); + const yaml = await getResourceYamlCmd(cid, "horizontalpodautoscalers", hpa.namespace, hpa.name); setActiveModal({ type: "edit", hpa, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -49,7 +46,7 @@ export function HPAList({ if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(cid, "horizontalpodautoscalers", ns, activeModal.hpa.name); + await deleteResourceCmd(cid, "horizontalpodautoscalers", activeModal.hpa.namespace, activeModal.hpa.name); setActiveModal(null); onRefresh?.(); } finally { @@ -121,7 +118,7 @@ export function HPAList({ (null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -38,7 +35,7 @@ export function IngressList({ const openEdit = async (ingress: IngressInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(cid, "ingresses", ns, ingress.name); + const yaml = await getResourceYamlCmd(cid, "ingresses", ingress.namespace, ingress.name); setActiveModal({ type: "edit", ingress, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -49,7 +46,7 @@ export function IngressList({ if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(cid, "ingresses", ns, activeModal.ingress.name); + await deleteResourceCmd(cid, "ingresses", activeModal.ingress.namespace, activeModal.ingress.name); setActiveModal(null); onRefresh?.(); } finally { @@ -119,7 +116,7 @@ export function IngressList({ (null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -38,7 +35,7 @@ export function JobList({ const openEdit = async (job: JobInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(cid, "jobs", ns, job.name); + const yaml = await getResourceYamlCmd(cid, "jobs", job.namespace, job.name); setActiveModal({ type: "edit", job, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -49,7 +46,7 @@ export function JobList({ if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(cid, "jobs", ns, activeModal.job.name); + await deleteResourceCmd(cid, "jobs", activeModal.job.namespace, activeModal.job.name); setActiveModal(null); onRefresh?.(); } finally { @@ -123,7 +120,7 @@ export function JobList({ (null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -27,7 +27,7 @@ export function LimitRangeList({ limitranges, clusterId, namespace, onRefresh }: const openEdit = async (lr: LimitRangeInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(clusterId, "limitranges", namespace, lr.name); + const yaml = await getResourceYamlCmd(clusterId, "limitranges", lr.namespace, lr.name); setActiveModal({ type: "edit", lr, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -38,7 +38,7 @@ export function LimitRangeList({ limitranges, clusterId, namespace, onRefresh }: if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(clusterId, "limitranges", namespace, activeModal.lr.name); + await deleteResourceCmd(clusterId, "limitranges", activeModal.lr.namespace, activeModal.lr.name); setActiveModal(null); onRefresh?.(); } finally { @@ -104,7 +104,7 @@ export function LimitRangeList({ limitranges, clusterId, namespace, onRefresh }: (null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -27,7 +27,7 @@ export function NetworkPolicyList({ networkpolicies, clusterId, namespace, onRef const openEdit = async (np: NetworkPolicyInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(clusterId, "networkpolicies", namespace, np.name); + const yaml = await getResourceYamlCmd(clusterId, "networkpolicies", np.namespace, np.name); setActiveModal({ type: "edit", np, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -38,7 +38,7 @@ export function NetworkPolicyList({ networkpolicies, clusterId, namespace, onRef if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(clusterId, "networkpolicies", namespace, activeModal.np.name); + await deleteResourceCmd(clusterId, "networkpolicies", activeModal.np.namespace, activeModal.np.name); setActiveModal(null); onRefresh?.(); } finally { @@ -106,7 +106,7 @@ export function NetworkPolicyList({ networkpolicies, clusterId, namespace, onRef (null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -38,7 +35,7 @@ export function PVCList({ const openEdit = async (pvc: PersistentVolumeClaimInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(cid, "persistentvolumeclaims", ns, pvc.name); + const yaml = await getResourceYamlCmd(cid, "persistentvolumeclaims", pvc.namespace, pvc.name); setActiveModal({ type: "edit", pvc, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -49,7 +46,7 @@ export function PVCList({ if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(cid, "persistentvolumeclaims", ns, activeModal.pvc.name); + await deleteResourceCmd(cid, "persistentvolumeclaims", activeModal.pvc.namespace, activeModal.pvc.name); setActiveModal(null); onRefresh?.(); } finally { @@ -121,7 +118,7 @@ export function PVCList({ (null); + // namespace prop is retained for API compatibility (parent uses it to drive list fetches) + void namespace; + const getPodStatusColor = (status: string) => { switch (status.toLowerCase()) { case "running": @@ -52,7 +55,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) const openEdit = async (pod: PodInfo) => { setEditError(null); try { - const yaml = await getResourceYamlCmd(clusterId, "pods", namespace, pod.name); + const yaml = await getResourceYamlCmd(clusterId, "pods", pod.namespace, pod.name); setActiveModal({ type: "edit", pod, yaml }); } catch (err) { setEditError(err instanceof Error ? err.message : String(err)); @@ -65,9 +68,9 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) setIsDeleting(true); try { if (force) { - await forceDeleteResourceCmd(clusterId, "pods", namespace, modal.pod.name); + await forceDeleteResourceCmd(clusterId, "pods", modal.pod.namespace, modal.pod.name); } else { - await deleteResourceCmd(clusterId, "pods", namespace, modal.pod.name); + await deleteResourceCmd(clusterId, "pods", modal.pod.namespace, modal.pod.name); } setActiveModal(null); onRefresh?.(); @@ -167,7 +170,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) open onOpenChange={(o) => { if (!o) setActiveModal(null); }} clusterId={clusterId} - namespace={namespace} + namespace={activeModal.pod.namespace} podName={activeModal.pod.name} containers={activeModal.pod.containers} /> @@ -178,7 +181,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) open onOpenChange={(o) => { if (!o) setActiveModal(null); }} clusterId={clusterId} - namespace={namespace} + namespace={activeModal.pod.namespace} podName={activeModal.pod.name} containers={activeModal.pod.containers} /> @@ -189,7 +192,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) open onOpenChange={(o) => { if (!o) setActiveModal(null); }} clusterId={clusterId} - namespace={namespace} + namespace={activeModal.pod.namespace} podName={activeModal.pod.name} containers={activeModal.pod.containers} /> @@ -199,7 +202,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
Container Ports: {pf.container_ports.join(", ")} - | + | Local Ports: {pf.local_ports.some(p => p > 0) ? pf.local_ports.join(", ") : "pending"}
diff --git a/src/components/Kubernetes/ReplicaSetList.tsx b/src/components/Kubernetes/ReplicaSetList.tsx index d51d2dcd..1c251e57 100644 --- a/src/components/Kubernetes/ReplicaSetList.tsx +++ b/src/components/Kubernetes/ReplicaSetList.tsx @@ -31,12 +31,9 @@ export function ReplicaSetList({ replicaSets, clusterId, _clusterId, - namespace, - _namespace, onRefresh, }: ReplicaSetListProps) { const cid = clusterId ?? _clusterId ?? ""; - const ns = namespace ?? _namespace ?? ""; const [activeModal, setActiveModal] = useState(null); const [isActing, setIsActing] = useState(false); const [actionError, setActionError] = useState(null); @@ -44,7 +41,7 @@ export function ReplicaSetList({ const openEdit = async (rs: ReplicaSetInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(cid, "replicasets", ns, rs.name); + const yaml = await getResourceYamlCmd(cid, "replicasets", rs.namespace, rs.name); setActiveModal({ type: "edit", rs, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -55,7 +52,7 @@ export function ReplicaSetList({ if (activeModal?.type !== "delete") return; setIsActing(true); try { - await deleteResourceCmd(cid, "replicasets", ns, activeModal.rs.name); + await deleteResourceCmd(cid, "replicasets", activeModal.rs.namespace, activeModal.rs.name); setActiveModal(null); onRefresh?.(); } finally { @@ -138,7 +135,7 @@ export function ReplicaSetList({ resourceName={activeModal.rs.name} currentReplicas={activeModal.rs.replicas} onScale={(replicas) => - scaleReplicasetCmd(cid, ns, activeModal.rs.name, replicas).then(() => { + scaleReplicasetCmd(cid, activeModal.rs.namespace, activeModal.rs.name, replicas).then(() => { setActiveModal(null); onRefresh?.(); }) @@ -150,7 +147,7 @@ export function ReplicaSetList({ (null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -27,7 +27,7 @@ export function ResourceQuotaList({ resourcequotas, clusterId, namespace, onRefr const openEdit = async (rq: ResourceQuotaInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(clusterId, "resourcequotas", namespace, rq.name); + const yaml = await getResourceYamlCmd(clusterId, "resourcequotas", rq.namespace, rq.name); setActiveModal({ type: "edit", rq, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -38,7 +38,7 @@ export function ResourceQuotaList({ resourcequotas, clusterId, namespace, onRefr if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(clusterId, "resourcequotas", namespace, activeModal.rq.name); + await deleteResourceCmd(clusterId, "resourcequotas", activeModal.rq.namespace, activeModal.rq.name); setActiveModal(null); onRefresh?.(); } finally { @@ -110,7 +110,7 @@ export function ResourceQuotaList({ resourcequotas, clusterId, namespace, onRefr (null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -38,7 +35,7 @@ export function RoleBindingList({ const openEdit = async (rb: RoleBindingInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(cid, "rolebindings", ns, rb.name); + const yaml = await getResourceYamlCmd(cid, "rolebindings", rb.namespace, rb.name); setActiveModal({ type: "edit", rb, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -49,7 +46,7 @@ export function RoleBindingList({ if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(cid, "rolebindings", ns, activeModal.rb.name); + await deleteResourceCmd(cid, "rolebindings", activeModal.rb.namespace, activeModal.rb.name); setActiveModal(null); onRefresh?.(); } finally { @@ -115,7 +112,7 @@ export function RoleBindingList({ (null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -38,7 +35,7 @@ export function RoleList({ const openEdit = async (role: RoleInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(cid, "roles", ns, role.name); + const yaml = await getResourceYamlCmd(cid, "roles", role.namespace, role.name); setActiveModal({ type: "edit", role, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -49,7 +46,7 @@ export function RoleList({ if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(cid, "roles", ns, activeModal.role.name); + await deleteResourceCmd(cid, "roles", activeModal.role.namespace, activeModal.role.name); setActiveModal(null); onRefresh?.(); } finally { @@ -113,7 +110,7 @@ export function RoleList({ (null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -38,7 +35,7 @@ export function SecretList({ const openEdit = async (secret: SecretInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(cid, "secrets", ns, secret.name); + const yaml = await getResourceYamlCmd(cid, "secrets", secret.namespace, secret.name); setActiveModal({ type: "edit", secret, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -49,7 +46,7 @@ export function SecretList({ if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(cid, "secrets", ns, activeModal.secret.name); + await deleteResourceCmd(cid, "secrets", activeModal.secret.namespace, activeModal.secret.name); setActiveModal(null); onRefresh?.(); } finally { @@ -117,7 +114,7 @@ export function SecretList({ (null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -38,7 +35,7 @@ export function ServiceAccountList({ const openEdit = async (sa: ServiceAccountInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(cid, "serviceaccounts", ns, sa.name); + const yaml = await getResourceYamlCmd(cid, "serviceaccounts", sa.namespace, sa.name); setActiveModal({ type: "edit", sa, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -49,7 +46,7 @@ export function ServiceAccountList({ if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(cid, "serviceaccounts", ns, activeModal.sa.name); + await deleteResourceCmd(cid, "serviceaccounts", activeModal.sa.namespace, activeModal.sa.name); setActiveModal(null); onRefresh?.(); } finally { @@ -115,7 +112,7 @@ export function ServiceAccountList({ (null); const [isDeleting, setIsDeleting] = useState(false); const [actionError, setActionError] = useState(null); @@ -43,7 +43,7 @@ export function ServiceList({ services, clusterId, namespace, onRefresh }: Servi const openEdit = async (svc: ServiceInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(clusterId, "services", namespace, svc.name); + const yaml = await getResourceYamlCmd(clusterId, "services", svc.namespace, svc.name); setActiveModal({ type: "edit", svc, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -54,7 +54,7 @@ export function ServiceList({ services, clusterId, namespace, onRefresh }: Servi if (activeModal?.type !== "delete") return; setIsDeleting(true); try { - await deleteResourceCmd(clusterId, "services", namespace, activeModal.svc.name); + await deleteResourceCmd(clusterId, "services", activeModal.svc.namespace, activeModal.svc.name); setActiveModal(null); onRefresh?.(); } finally { @@ -140,7 +140,7 @@ export function ServiceList({ services, clusterId, namespace, onRefresh }: Servi (null); const [isActing, setIsActing] = useState(false); const [actionError, setActionError] = useState(null); @@ -35,7 +35,7 @@ export function StatefulSetList({ statefulsets, clusterId, namespace, onRefresh const openEdit = async (ss: StatefulSetInfo) => { setActionError(null); try { - const yaml = await getResourceYamlCmd(clusterId, "statefulsets", namespace, ss.name); + const yaml = await getResourceYamlCmd(clusterId, "statefulsets", ss.namespace, ss.name); setActiveModal({ type: "edit", ss, yaml }); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); @@ -46,7 +46,7 @@ export function StatefulSetList({ statefulsets, clusterId, namespace, onRefresh if (activeModal?.type !== "restart") return; setIsActing(true); try { - await restartStatefulsetCmd(clusterId, namespace, activeModal.ss.name); + await restartStatefulsetCmd(clusterId, activeModal.ss.namespace, activeModal.ss.name); setActiveModal(null); onRefresh?.(); } catch (err) { @@ -60,7 +60,7 @@ export function StatefulSetList({ statefulsets, clusterId, namespace, onRefresh if (activeModal?.type !== "delete") return; setIsActing(true); try { - await deleteResourceCmd(clusterId, "statefulsets", namespace, activeModal.ss.name); + await deleteResourceCmd(clusterId, "statefulsets", activeModal.ss.namespace, activeModal.ss.name); setActiveModal(null); onRefresh?.(); } finally { @@ -140,7 +140,7 @@ export function StatefulSetList({ statefulsets, clusterId, namespace, onRefresh resourceName={activeModal.ss.name} currentReplicas={activeModal.ss.replicas} onScale={(replicas) => - scaleStatefulsetCmd(clusterId, namespace, activeModal.ss.name, replicas).then(() => { + scaleStatefulsetCmd(clusterId, activeModal.ss.namespace, activeModal.ss.name, replicas).then(() => { setActiveModal(null); onRefresh?.(); }) @@ -164,7 +164,7 @@ export function StatefulSetList({ statefulsets, clusterId, namespace, onRefresh {pods.length - runningPods - pendingPods - failedPods > 0 && (
- + Other: {pods.length - runningPods - pendingPods - failedPods}
)} diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index 47a1bef4..31fc16d9 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -795,6 +795,7 @@ export interface PortForwardResponse { export interface PodInfo { name: string; + namespace: string; status: string; ready: string; age: string; diff --git a/src/pages/Kubernetes/KubernetesPage.tsx b/src/pages/Kubernetes/KubernetesPage.tsx index 86668837..21ae13a9 100644 --- a/src/pages/Kubernetes/KubernetesPage.tsx +++ b/src/pages/Kubernetes/KubernetesPage.tsx @@ -437,8 +437,10 @@ export function KubernetesPage() { const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false); const [isNotificationsOpen, setIsNotificationsOpen] = useState(false); + const [connectionError, setConnectionError] = useState(null); const lastLoadedRef = useRef<{ section: ActiveSection; clusterId: string; namespace: string } | null>(null); + const initializedRef = useRef(false); // ── Initial data load ────────────────────────────────────────────────────── @@ -451,12 +453,20 @@ export function KubernetesPage() { setKubeconfigs(kubeconfigsData); setPortForwards(portForwardsData); - const activeConfig = kubeconfigsData.find((c) => c.is_active); - if (activeConfig && !selectedClusterId) { - await connectClusterFromKubeconfigCmd(activeConfig.id).catch(() => {}); - setSelectedCluster(activeConfig.id); - } else if (selectedClusterId) { - await connectClusterFromKubeconfigCmd(selectedClusterId).catch(() => {}); + if (!initializedRef.current) { + initializedRef.current = true; + const activeConfig = kubeconfigsData.find((c) => c.is_active); + const targetId = selectedClusterId ?? activeConfig?.id; + if (targetId) { + const err = await connectClusterFromKubeconfigCmd(targetId) + .then(() => null) + .catch((e: unknown) => e); + if (err) { + setConnectionError(err instanceof Error ? err.message : String(err)); + } else { + setSelectedCluster(targetId); + } + } } } catch (err) { console.error("Failed to load initial Kubernetes data:", err); @@ -481,11 +491,7 @@ export function KubernetesPage() { const loadResourceData = useCallback( async (section: ActiveSection, clusterId: string, namespace: string) => { - if ( - section === "cluster_overview" || - section === "portforwarding" || - section === "workloads_overview" - ) { + if (section === "cluster_overview" || section === "portforwarding") { return; } @@ -494,6 +500,29 @@ export function KubernetesPage() { setIsLoadingResources(true); try { switch (section) { + case "workloads_overview": { + const [pods, deployments, statefulsets, daemonsets, jobs, cronjobs] = + await Promise.allSettled([ + listPodsCmd(clusterId, ns), + listDeploymentsCmd(clusterId, ns), + listStatefulsetsCmd(clusterId, ns), + listDaemonsetsCmd(clusterId, ns), + listJobsCmd(clusterId, ns), + listCronjobsCmd(clusterId, ns), + ]).then((results) => + results.map((r) => (r.status === "fulfilled" ? r.value : [])) + ); + setResources((r) => ({ + ...r, + pods: pods as PodInfo[], + deployments: deployments as DeploymentInfo[], + statefulsets: statefulsets as StatefulSetInfo[], + daemonsets: daemonsets as DaemonSetInfo[], + jobs: jobs as JobInfo[], + cronjobs: cronjobs as CronJobInfo[], + })); + break; + } case "pods": await listPodsCmd(clusterId, ns).then((data) => setResources((r) => ({ ...r, pods: data })) @@ -1131,6 +1160,13 @@ export function KubernetesPage() { )} + {connectionError && ( +
+ Cluster connection failed: {connectionError} + +
+ )} + {/* Main layout: sidebar + content */}
{/* Sidebar */} diff --git a/tests/unit/KubernetesPage.test.tsx b/tests/unit/KubernetesPage.test.tsx index 5bcaaf4d..8b31eec3 100644 --- a/tests/unit/KubernetesPage.test.tsx +++ b/tests/unit/KubernetesPage.test.tsx @@ -127,6 +127,19 @@ vi.mock("@/components/Kubernetes/Hotbar", () => ({ ), })); +vi.mock("@/components/Kubernetes/WorkloadOverview", () => ({ + WorkloadOverview: ({ resources }: { resources: { pods: unknown[]; deployments: unknown[]; statefulsets: unknown[]; daemonsets: unknown[]; jobs: unknown[]; cronjobs: unknown[] } }) => ( +
+ {resources.pods.length} + {resources.deployments.length} + {resources.statefulsets.length} + {resources.daemonsets.length} + {resources.jobs.length} + {resources.cronjobs.length} +
+ ), +})); + type MockedInvoke = ReturnType; const mockInvoke = invoke as unknown as MockedInvoke; @@ -504,4 +517,133 @@ describe("KubernetesPage", () => { expect(mockInvoke.mock.calls.length).toBeGreaterThanOrEqual(callsBefore); }); }); + + describe("WorkloadOverview data loading", () => { + beforeEach(() => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "list_kubeconfigs") return Promise.resolve(MOCK_KUBECONFIGS); + if (cmd === "list_namespaces") return Promise.resolve(MOCK_NAMESPACES); + if (cmd === "list_port_forwards") return Promise.resolve([]); + if (cmd === "list_pods") return Promise.resolve([{ name: "pod-1", namespace: "default", status: "Running", ready: "1/1", restarts: 0, age: "1d", node: "node-1", ip: "10.0.0.1" }]); + if (cmd === "list_deployments") return Promise.resolve([{ name: "deploy-1", namespace: "default", ready: "1/1", up_to_date: 1, available: 1, age: "1d" }]); + if (cmd === "list_statefulsets") return Promise.resolve([]); + if (cmd === "list_daemonsets") return Promise.resolve([]); + if (cmd === "list_jobs") return Promise.resolve([]); + if (cmd === "list_cronjobs") return Promise.resolve([]); + return Promise.resolve([]); + }); + }); + + it("renders WorkloadOverview when workloads_overview section is active", async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Overview" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Overview" })); + + await waitFor(() => { + expect(screen.getByTestId("workload-overview")).toBeInTheDocument(); + }); + }); + + it("calls list_pods, list_deployments, list_statefulsets, list_daemonsets, list_jobs, list_cronjobs when workloads_overview is active", async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Overview" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Overview" })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("list_pods", expect.anything()); + expect(mockInvoke).toHaveBeenCalledWith("list_deployments", expect.anything()); + expect(mockInvoke).toHaveBeenCalledWith("list_statefulsets", expect.anything()); + expect(mockInvoke).toHaveBeenCalledWith("list_daemonsets", expect.anything()); + expect(mockInvoke).toHaveBeenCalledWith("list_jobs", expect.anything()); + expect(mockInvoke).toHaveBeenCalledWith("list_cronjobs", expect.anything()); + }); + }); + + it("passes fetched resource counts to WorkloadOverview", async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Overview" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Overview" })); + + await waitFor(() => { + expect(screen.getByTestId("workload-overview")).toBeInTheDocument(); + expect(screen.getByTestId("pod-count").textContent).toBe("1"); + expect(screen.getByTestId("deployment-count").textContent).toBe("1"); + }); + }); + }); + + describe("Connection error banner", () => { + it("shows a connection error banner when connectClusterFromKubeconfig fails", async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "list_kubeconfigs") return Promise.resolve(MOCK_KUBECONFIGS); + if (cmd === "list_port_forwards") return Promise.resolve([]); + if (cmd === "connect_cluster_from_kubeconfig") return Promise.reject(new Error("connection refused")); + return Promise.resolve([]); + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/cluster connection failed/i)).toBeInTheDocument(); + }); + }); + + it("dismisses the error banner when Dismiss is clicked", async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "list_kubeconfigs") return Promise.resolve(MOCK_KUBECONFIGS); + if (cmd === "list_port_forwards") return Promise.resolve([]); + if (cmd === "connect_cluster_from_kubeconfig") return Promise.reject(new Error("connection refused")); + return Promise.resolve([]); + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/cluster connection failed/i)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /dismiss/i })); + + await waitFor(() => { + expect(screen.queryByText(/cluster connection failed/i)).not.toBeInTheDocument(); + }); + }); + }); + + describe("Double-connect prevention", () => { + it("calls connectClusterFromKubeconfig only once on mount even when store updates", async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "list_kubeconfigs") return Promise.resolve(MOCK_KUBECONFIGS); + if (cmd === "list_namespaces") return Promise.resolve(MOCK_NAMESPACES); + if (cmd === "list_port_forwards") return Promise.resolve([]); + return Promise.resolve([]); + }); + + renderPage(); + + await waitFor(() => { + expect(useKubernetesStore.getState().selectedClusterId).toBe("kc-1"); + }); + + // Allow any re-renders to settle + await waitFor(() => { + const connectCalls = mockInvoke.mock.calls.filter( + ([cmd]) => cmd === "connect_cluster_from_kubeconfig" + ); + expect(connectCalls.length).toBe(1); + }); + }); + }); }); diff --git a/tests/unit/NamespaceActionFix.test.tsx b/tests/unit/NamespaceActionFix.test.tsx new file mode 100644 index 00000000..3970ece0 --- /dev/null +++ b/tests/unit/NamespaceActionFix.test.tsx @@ -0,0 +1,663 @@ +/** + * TDD tests: action IPC calls in network/config/storage/access-control list + * components must use the item's own .namespace, never the filter prop (which + * can be "all" when the user is viewing all namespaces). + */ + +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; + +import { ServiceList } from "@/components/Kubernetes/ServiceList"; +import { IngressList } from "@/components/Kubernetes/IngressList"; +import { ConfigMapList } from "@/components/Kubernetes/ConfigMapList"; +import { SecretList } from "@/components/Kubernetes/SecretList"; +import { HPAList } from "@/components/Kubernetes/HPAList"; +import { PVCList } from "@/components/Kubernetes/PVCList"; +import { ServiceAccountList } from "@/components/Kubernetes/ServiceAccountList"; +import { RoleList } from "@/components/Kubernetes/RoleList"; +import { RoleBindingList } from "@/components/Kubernetes/RoleBindingList"; +import { NetworkPolicyList } from "@/components/Kubernetes/NetworkPolicyList"; +import { ResourceQuotaList } from "@/components/Kubernetes/ResourceQuotaList"; +import { LimitRangeList } from "@/components/Kubernetes/LimitRangeList"; + +import type { + ServiceInfo, + IngressInfo, + ConfigMapInfo, + SecretInfo, + HorizontalPodAutoscalerInfo, + PersistentVolumeClaimInfo, + ServiceAccountInfo, + RoleInfo, + RoleBindingInfo, + NetworkPolicyInfo, + ResourceQuotaInfo, + LimitRangeInfo, +} from "@/lib/tauriCommands"; + +type MockedInvoke = typeof invoke & { + mockResolvedValue: (v: unknown) => void; + mockImplementation: (fn: (cmd: string, args?: unknown) => Promise) => void; +}; + +const mockInvoke = invoke as MockedInvoke; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +/** Open the action menu for the first row whose name cell matches `name`. */ +function openActionMenu(name: string) { + const cell = screen.getByText(name); + const row = cell.closest("tr")!; + const btn = row.querySelector("button")!; + fireEvent.click(btn); +} + +/** Click the first menu item that contains `label`. */ +function clickMenuItem(label: string) { + const item = screen.getByText(label); + fireEvent.click(item); +} + +// ─── ServiceList ───────────────────────────────────────────────────────────── + +describe("ServiceList – action IPC uses item.namespace", () => { + const svc: ServiceInfo = { + name: "my-svc", + namespace: "production", + type: "ClusterIP", + cluster_ip: "10.0.0.1", + ports: [], + age: "1d", + selector: {}, + }; + + beforeEach(() => vi.clearAllMocks()); + + it("getResourceYamlCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue("yaml: content"); + render( + + ); + openActionMenu("my-svc"); + clickMenuItem("Edit"); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "services", + namespace: "production", + resourceName: "my-svc", + }) + ); + }); + + it("deleteResourceCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + render( + {}} /> + ); + openActionMenu("my-svc"); + clickMenuItem("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /confirm|delete/i }); + fireEvent.click(confirmBtn); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "services", + namespace: "production", + resourceName: "my-svc", + }) + ); + }); +}); + +// ─── IngressList ───────────────────────────────────────────────────────────── + +describe("IngressList – action IPC uses item.namespace", () => { + const ing: IngressInfo = { + name: "my-ingress", + namespace: "staging", + host: "example.com", + addresses: [], + age: "2d", + }; + + beforeEach(() => vi.clearAllMocks()); + + it("getResourceYamlCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue("yaml: content"); + render( + + ); + openActionMenu("my-ingress"); + clickMenuItem("Edit"); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "ingresses", + namespace: "staging", + resourceName: "my-ingress", + }) + ); + }); + + it("deleteResourceCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + render( + {}} /> + ); + openActionMenu("my-ingress"); + clickMenuItem("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /confirm|delete/i }); + fireEvent.click(confirmBtn); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "ingresses", + namespace: "staging", + resourceName: "my-ingress", + }) + ); + }); +}); + +// ─── ConfigMapList ──────────────────────────────────────────────────────────── + +describe("ConfigMapList – action IPC uses item.namespace", () => { + const cm: ConfigMapInfo = { + name: "app-config", + namespace: "kube-system", + data_keys: 3, + age: "10d", + }; + + beforeEach(() => vi.clearAllMocks()); + + it("getResourceYamlCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue("yaml: content"); + render( + + ); + openActionMenu("app-config"); + clickMenuItem("Edit"); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "configmaps", + namespace: "kube-system", + resourceName: "app-config", + }) + ); + }); + + it("deleteResourceCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + render( + {}} /> + ); + openActionMenu("app-config"); + clickMenuItem("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /confirm|delete/i }); + fireEvent.click(confirmBtn); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "configmaps", + namespace: "kube-system", + resourceName: "app-config", + }) + ); + }); +}); + +// ─── SecretList ─────────────────────────────────────────────────────────────── + +describe("SecretList – action IPC uses item.namespace", () => { + const secret: SecretInfo = { + name: "db-creds", + namespace: "production", + type: "Opaque", + data_keys: 2, + age: "5d", + }; + + beforeEach(() => vi.clearAllMocks()); + + it("getResourceYamlCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue("yaml: content"); + render( + + ); + openActionMenu("db-creds"); + clickMenuItem("Edit"); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "secrets", + namespace: "production", + resourceName: "db-creds", + }) + ); + }); + + it("deleteResourceCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + render( + {}} /> + ); + openActionMenu("db-creds"); + clickMenuItem("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /confirm|delete/i }); + fireEvent.click(confirmBtn); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "secrets", + namespace: "production", + resourceName: "db-creds", + }) + ); + }); +}); + +// ─── HPAList ────────────────────────────────────────────────────────────────── + +describe("HPAList – action IPC uses item.namespace", () => { + const hpa: HorizontalPodAutoscalerInfo = { + name: "web-hpa", + namespace: "default", + min_replicas: 1, + max_replicas: 10, + current_replicas: 3, + desired_replicas: 3, + age: "7d", + }; + + beforeEach(() => vi.clearAllMocks()); + + it("getResourceYamlCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue("yaml: content"); + render( + + ); + openActionMenu("web-hpa"); + clickMenuItem("Edit"); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "horizontalpodautoscalers", + namespace: "default", + resourceName: "web-hpa", + }) + ); + }); + + it("deleteResourceCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + render( + {}} /> + ); + openActionMenu("web-hpa"); + clickMenuItem("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /confirm|delete/i }); + fireEvent.click(confirmBtn); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "horizontalpodautoscalers", + namespace: "default", + resourceName: "web-hpa", + }) + ); + }); +}); + +// ─── PVCList ────────────────────────────────────────────────────────────────── + +describe("PVCList – action IPC uses item.namespace", () => { + const pvc: PersistentVolumeClaimInfo = { + name: "data-pvc", + namespace: "staging", + status: "Bound", + volume: "pv-001", + capacity: "10Gi", + access_modes: ["ReadWriteOnce"], + age: "3d", + }; + + beforeEach(() => vi.clearAllMocks()); + + it("getResourceYamlCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue("yaml: content"); + render( + + ); + openActionMenu("data-pvc"); + clickMenuItem("Edit"); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "persistentvolumeclaims", + namespace: "staging", + resourceName: "data-pvc", + }) + ); + }); + + it("deleteResourceCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + render( + {}} /> + ); + openActionMenu("data-pvc"); + clickMenuItem("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /confirm|delete/i }); + fireEvent.click(confirmBtn); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "persistentvolumeclaims", + namespace: "staging", + resourceName: "data-pvc", + }) + ); + }); +}); + +// ─── ServiceAccountList ─────────────────────────────────────────────────────── + +describe("ServiceAccountList – action IPC uses item.namespace", () => { + const sa: ServiceAccountInfo = { + name: "app-sa", + namespace: "production", + secrets: 1, + age: "30d", + }; + + beforeEach(() => vi.clearAllMocks()); + + it("getResourceYamlCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue("yaml: content"); + render( + + ); + openActionMenu("app-sa"); + clickMenuItem("Edit"); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "serviceaccounts", + namespace: "production", + resourceName: "app-sa", + }) + ); + }); + + it("deleteResourceCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + render( + {}} /> + ); + openActionMenu("app-sa"); + clickMenuItem("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /confirm|delete/i }); + fireEvent.click(confirmBtn); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "serviceaccounts", + namespace: "production", + resourceName: "app-sa", + }) + ); + }); +}); + +// ─── RoleList ───────────────────────────────────────────────────────────────── + +describe("RoleList – action IPC uses item.namespace", () => { + const role: RoleInfo = { + name: "pod-reader", + namespace: "default", + age: "14d", + }; + + beforeEach(() => vi.clearAllMocks()); + + it("getResourceYamlCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue("yaml: content"); + render( + + ); + openActionMenu("pod-reader"); + clickMenuItem("Edit"); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "roles", + namespace: "default", + resourceName: "pod-reader", + }) + ); + }); + + it("deleteResourceCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + render( + {}} /> + ); + openActionMenu("pod-reader"); + clickMenuItem("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /confirm|delete/i }); + fireEvent.click(confirmBtn); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "roles", + namespace: "default", + resourceName: "pod-reader", + }) + ); + }); +}); + +// ─── RoleBindingList ────────────────────────────────────────────────────────── + +describe("RoleBindingList – action IPC uses item.namespace", () => { + const rb: RoleBindingInfo = { + name: "pod-reader-binding", + namespace: "default", + role: "pod-reader", + age: "10d", + }; + + beforeEach(() => vi.clearAllMocks()); + + it("getResourceYamlCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue("yaml: content"); + render( + + ); + openActionMenu("pod-reader-binding"); + clickMenuItem("Edit"); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "rolebindings", + namespace: "default", + resourceName: "pod-reader-binding", + }) + ); + }); + + it("deleteResourceCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + render( + {}} /> + ); + openActionMenu("pod-reader-binding"); + clickMenuItem("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /confirm|delete/i }); + fireEvent.click(confirmBtn); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "rolebindings", + namespace: "default", + resourceName: "pod-reader-binding", + }) + ); + }); +}); + +// ─── NetworkPolicyList ──────────────────────────────────────────────────────── + +describe("NetworkPolicyList – action IPC uses item.namespace", () => { + const np: NetworkPolicyInfo = { + name: "deny-all", + namespace: "production", + pod_selector: "{}", + policy_types: ["Ingress"], + age: "3d", + }; + + beforeEach(() => vi.clearAllMocks()); + + it("getResourceYamlCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue("yaml: content"); + render( + + ); + openActionMenu("deny-all"); + clickMenuItem("Edit"); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "networkpolicies", + namespace: "production", + resourceName: "deny-all", + }) + ); + }); + + it("deleteResourceCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + render( + {}} /> + ); + openActionMenu("deny-all"); + clickMenuItem("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /confirm|delete/i }); + fireEvent.click(confirmBtn); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "networkpolicies", + namespace: "production", + resourceName: "deny-all", + }) + ); + }); +}); + +// ─── ResourceQuotaList ──────────────────────────────────────────────────────── + +describe("ResourceQuotaList – action IPC uses item.namespace", () => { + const rq: ResourceQuotaInfo = { + name: "compute-resources", + namespace: "default", + request_cpu: "4", + request_memory: "8Gi", + limit_cpu: "8", + limit_memory: "16Gi", + age: "7d", + }; + + beforeEach(() => vi.clearAllMocks()); + + it("getResourceYamlCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue("yaml: content"); + render( + + ); + openActionMenu("compute-resources"); + clickMenuItem("Edit"); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "resourcequotas", + namespace: "default", + resourceName: "compute-resources", + }) + ); + }); + + it("deleteResourceCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + render( + {}} /> + ); + openActionMenu("compute-resources"); + clickMenuItem("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /confirm|delete/i }); + fireEvent.click(confirmBtn); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "resourcequotas", + namespace: "default", + resourceName: "compute-resources", + }) + ); + }); +}); + +// ─── LimitRangeList ─────────────────────────────────────────────────────────── + +describe("LimitRangeList – action IPC uses item.namespace", () => { + const lr: LimitRangeInfo = { + name: "cpu-mem-limits", + namespace: "default", + limit_count: 3, + age: "14d", + }; + + beforeEach(() => vi.clearAllMocks()); + + it("getResourceYamlCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue("yaml: content"); + render( + + ); + openActionMenu("cpu-mem-limits"); + clickMenuItem("Edit"); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "limitranges", + namespace: "default", + resourceName: "cpu-mem-limits", + }) + ); + }); + + it("deleteResourceCmd receives item.namespace, not filter prop 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + render( + {}} /> + ); + openActionMenu("cpu-mem-limits"); + clickMenuItem("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /confirm|delete/i }); + fireEvent.click(confirmBtn); + await waitFor(() => + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "limitranges", + namespace: "default", + resourceName: "cpu-mem-limits", + }) + ); + }); +}); diff --git a/tests/unit/PodDetail.test.tsx b/tests/unit/PodDetail.test.tsx index 1ff8d505..f9df369a 100644 --- a/tests/unit/PodDetail.test.tsx +++ b/tests/unit/PodDetail.test.tsx @@ -17,6 +17,7 @@ const mockInvoke = invoke as MockedInvoke; const mockPod: PodInfo = { name: "nginx-abc123", + namespace: "default", status: "Running", ready: "2/2", age: "3h", diff --git a/tests/unit/PodList.test.tsx b/tests/unit/PodList.test.tsx new file mode 100644 index 00000000..3aaac7cc --- /dev/null +++ b/tests/unit/PodList.test.tsx @@ -0,0 +1,223 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import { PodList } from "@/components/Kubernetes/PodList"; +import type { PodInfo } from "@/lib/tauriCommands"; + +vi.mock("@tauri-apps/api/core"); + +// Silence console.error noise from modal portals in jsdom +vi.mock("@/components/Kubernetes/LogsModal", () => ({ + LogsModal: ({ namespace }: { namespace: string }) => ( +
+ ), +})); +vi.mock("@/components/Kubernetes/ShellExecModal", () => ({ + ShellExecModal: ({ namespace }: { namespace: string }) => ( +
+ ), +})); +vi.mock("@/components/Kubernetes/AttachModal", () => ({ + AttachModal: ({ namespace }: { namespace: string }) => ( +
+ ), +})); +vi.mock("@/components/Kubernetes/EditResourceModal", () => ({ + EditResourceModal: ({ namespace }: { namespace: string }) => ( +
+ ), +})); +vi.mock("@/components/Kubernetes/ConfirmDeleteDialog", () => ({ + ConfirmDeleteDialog: ({ + onConfirm, + resourceName, + }: { + onConfirm: () => void; + resourceName: string; + }) => ( +
+ {resourceName} + +
+ ), +})); + +type MockedInvoke = typeof invoke & { + mockResolvedValue: (v: unknown) => void; + mockRejectedValue: (e: Error) => void; + mockImplementation: (fn: (cmd: string) => Promise) => void; +}; + +const mockInvoke = invoke as MockedInvoke; + +// A pod whose own namespace ("default") differs from the filter prop ("all") +const mockPod: PodInfo = { + name: "test-pod", + namespace: "default", + status: "Running", + ready: "1/1", + age: "1h", + containers: ["app"], +}; + +function openActionMenu() { + const trigger = screen.getByRole("button", { name: /actions/i }); + fireEvent.click(trigger); +} + +describe("PodList — namespace isolation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('Edit action calls getResourceYamlCmd with pod.namespace ("default"), not filter "all"', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "get_resource_yaml") { + return Promise.resolve("apiVersion: v1\nkind: Pod"); + } + return Promise.resolve(undefined); + }); + + render( + {}} + /> + ); + + openActionMenu(); + fireEvent.click(screen.getByRole("button", { name: /^edit$/i })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + namespace: "default", + resourceType: "pods", + resourceName: "test-pod", + }); + }); + }); + + it('Delete action calls deleteResourceCmd with pod.namespace ("default"), not filter "all"', async () => { + mockInvoke.mockResolvedValue(undefined); + + render( + {}} + /> + ); + + openActionMenu(); + fireEvent.click(screen.getByRole("button", { name: /^delete$/i })); + + await waitFor(() => { + expect(screen.getByTestId("confirm-delete")).toBeDefined(); + }); + + fireEvent.click(screen.getByRole("button", { name: /confirm/i })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + namespace: "default", + resourceType: "pods", + resourceName: "test-pod", + }); + }); + }); + + it('Force Delete action calls forceDeleteResourceCmd with pod.namespace ("default"), not filter "all"', async () => { + mockInvoke.mockResolvedValue(undefined); + + // Force Delete is only visible when pod is Running or Pending + render( + {}} + /> + ); + + openActionMenu(); + fireEvent.click(screen.getByRole("button", { name: /^force delete$/i })); + + await waitFor(() => { + expect(screen.getByTestId("confirm-delete")).toBeDefined(); + }); + + fireEvent.click(screen.getByRole("button", { name: /confirm/i })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("force_delete_resource", { + clusterId: "c1", + namespace: "default", + resourceType: "pods", + resourceName: "test-pod", + }); + }); + }); + + it('Logs modal receives pod.namespace ("default"), not filter "all"', async () => { + render( + {}} + /> + ); + + openActionMenu(); + fireEvent.click(screen.getByRole("button", { name: /^logs$/i })); + + await waitFor(() => { + const modal = screen.getByTestId("logs-modal"); + expect(modal.getAttribute("data-namespace")).toBe("default"); + }); + }); + + it('Shell modal receives pod.namespace ("default"), not filter "all"', async () => { + render( + {}} + /> + ); + + openActionMenu(); + fireEvent.click(screen.getByRole("button", { name: /^shell$/i })); + + await waitFor(() => { + const modal = screen.getByTestId("shell-modal"); + expect(modal.getAttribute("data-namespace")).toBe("default"); + }); + }); + + it('Attach modal receives pod.namespace ("default"), not filter "all"', async () => { + render( + {}} + /> + ); + + openActionMenu(); + fireEvent.click(screen.getByRole("button", { name: /^attach$/i })); + + await waitFor(() => { + const modal = screen.getByTestId("attach-modal"); + expect(modal.getAttribute("data-namespace")).toBe("default"); + }); + }); +}); diff --git a/tests/unit/WorkloadListActions.test.tsx b/tests/unit/WorkloadListActions.test.tsx new file mode 100644 index 00000000..3702cd98 --- /dev/null +++ b/tests/unit/WorkloadListActions.test.tsx @@ -0,0 +1,695 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import { DeploymentList } from "@/components/Kubernetes/DeploymentList"; +import { StatefulSetList } from "@/components/Kubernetes/StatefulSetList"; +import { DaemonSetList } from "@/components/Kubernetes/DaemonSetList"; +import { ReplicaSetList } from "@/components/Kubernetes/ReplicaSetList"; +import { JobList } from "@/components/Kubernetes/JobList"; +import { CronJobList } from "@/components/Kubernetes/CronJobList"; +import type { + DeploymentInfo, + StatefulSetInfo, + DaemonSetInfo, + ReplicaSetInfo, + JobInfo, + CronJobInfo, +} from "@/lib/tauriCommands"; + +type MockedInvoke = typeof invoke & { + mockResolvedValue: (v: unknown) => void; + mockImplementation: (fn: (cmd: string) => Promise) => void; +}; + +const mockInvoke = invoke as MockedInvoke; + +// Helper: open the action menu for the first Actions button, then click a menu item by label +async function openMenuAndClick(label: string) { + const btn = screen.getAllByRole("button", { name: /actions/i })[0]; + fireEvent.click(btn); + const item = await screen.findByRole("button", { name: new RegExp(label, "i") }); + fireEvent.click(item); +} + +// ─── DeploymentList ────────────────────────────────────────────────────────── + +describe("DeploymentList — actions use item.namespace not filter prop", () => { + const deployment: DeploymentInfo = { + name: "nginx", + namespace: "kube-system", + ready: "1/1", + up_to_date: "1", + available: "1", + replicas: 1, + age: "1d", + labels: {}, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockInvoke.mockResolvedValue("apiVersion: apps/v1"); + }); + + it("openEdit calls getResourceYamlCmd with item.namespace, not 'all'", async () => { + render( + + ); + + await openMenuAndClick("Edit"); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "deployments", + namespace: "kube-system", + resourceName: "nginx", + }); + }); + }); + + it("handleDelete calls deleteResourceCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "delete_resource") return undefined; + return "yaml"; + }); + + render( + + ); + + await openMenuAndClick("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /delete|confirm/i }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "deployments", + namespace: "kube-system", + resourceName: "nginx", + }); + }); + }); + + it("ScaleModal onScale calls scaleDeploymentCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + + render( + + ); + + await openMenuAndClick("Scale"); + const scaleBtn = await screen.findByRole("button", { name: /scale/i }); + fireEvent.click(scaleBtn); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("scale_deployment", { + clusterId: "c1", + namespace: "kube-system", + deploymentName: "nginx", + replicas: expect.any(Number), + }); + }); + }); + + it("handleRestart calls restartDeploymentCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "restart_deployment") return undefined; + return "yaml"; + }); + + render( + + ); + + await openMenuAndClick("Restart"); + const confirmBtn = await screen.findByRole("button", { name: /delete|confirm|restart/i }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("restart_deployment", { + clusterId: "c1", + namespace: "kube-system", + deploymentName: "nginx", + }); + }); + }); + + it("handleRollback calls rollbackDeploymentCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "rollback_deployment") return undefined; + return "yaml"; + }); + + render( + + ); + + await openMenuAndClick("Rollback"); + const confirmBtn = await screen.findByRole("button", { name: /delete|confirm|rollback/i }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("rollback_deployment", { + clusterId: "c1", + namespace: "kube-system", + deploymentName: "nginx", + }); + }); + }); +}); + +// ─── StatefulSetList ───────────────────────────────────────────────────────── + +describe("StatefulSetList — actions use item.namespace not filter prop", () => { + const ss: StatefulSetInfo = { + name: "postgres", + namespace: "kube-system", + ready: "1/1", + replicas: 1, + age: "2d", + labels: {}, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockInvoke.mockResolvedValue("apiVersion: apps/v1"); + }); + + it("openEdit calls getResourceYamlCmd with item.namespace, not 'all'", async () => { + render( + + ); + + await openMenuAndClick("Edit"); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "statefulsets", + namespace: "kube-system", + resourceName: "postgres", + }); + }); + }); + + it("handleDelete calls deleteResourceCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "delete_resource") return undefined; + return "yaml"; + }); + + render( + + ); + + await openMenuAndClick("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /delete|confirm/i }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "statefulsets", + namespace: "kube-system", + resourceName: "postgres", + }); + }); + }); + + it("ScaleModal onScale calls scaleStatefulsetCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + + render( + + ); + + await openMenuAndClick("Scale"); + const scaleBtn = await screen.findByRole("button", { name: /scale/i }); + fireEvent.click(scaleBtn); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("scale_statefulset", { + clusterId: "c1", + namespace: "kube-system", + name: "postgres", + replicas: expect.any(Number), + }); + }); + }); + + it("handleRestart calls restartStatefulsetCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "restart_statefulset") return undefined; + return "yaml"; + }); + + render( + + ); + + await openMenuAndClick("Restart"); + const confirmBtn = await screen.findByRole("button", { name: /delete|confirm|restart/i }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("restart_statefulset", { + clusterId: "c1", + namespace: "kube-system", + name: "postgres", + }); + }); + }); +}); + +// ─── DaemonSetList ─────────────────────────────────────────────────────────── + +describe("DaemonSetList — actions use item.namespace not filter prop", () => { + const ds: DaemonSetInfo = { + name: "fluentd", + namespace: "kube-system", + desired: 3, + current: 3, + ready: 3, + up_to_date: 3, + available: 3, + age: "5d", + labels: {}, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockInvoke.mockResolvedValue("apiVersion: apps/v1"); + }); + + it("openEdit calls getResourceYamlCmd with item.namespace, not 'all'", async () => { + render( + + ); + + await openMenuAndClick("Edit"); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "daemonsets", + namespace: "kube-system", + resourceName: "fluentd", + }); + }); + }); + + it("handleDelete calls deleteResourceCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "delete_resource") return undefined; + return "yaml"; + }); + + render( + + ); + + await openMenuAndClick("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /delete|confirm/i }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "daemonsets", + namespace: "kube-system", + resourceName: "fluentd", + }); + }); + }); + + it("handleRestart calls restartDaemonsetCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "restart_daemonset") return undefined; + return "yaml"; + }); + + render( + + ); + + await openMenuAndClick("Restart"); + const confirmBtn = await screen.findByRole("button", { name: /delete|confirm|restart/i }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("restart_daemonset", { + clusterId: "c1", + namespace: "kube-system", + name: "fluentd", + }); + }); + }); +}); + +// ─── ReplicaSetList ────────────────────────────────────────────────────────── + +describe("ReplicaSetList — actions use item.namespace not filter prop", () => { + const rs: ReplicaSetInfo = { + name: "nginx-abc12", + namespace: "kube-system", + replicas: 2, + ready: "2", + age: "3d", + labels: { app: "nginx" }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockInvoke.mockResolvedValue("apiVersion: apps/v1"); + }); + + it("openEdit calls getResourceYamlCmd with item.namespace, not 'all'", async () => { + render( + + ); + + await openMenuAndClick("Edit"); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "replicasets", + namespace: "kube-system", + resourceName: "nginx-abc12", + }); + }); + }); + + it("handleDelete calls deleteResourceCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "delete_resource") return undefined; + return "yaml"; + }); + + render( + + ); + + await openMenuAndClick("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /delete|confirm/i }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "replicasets", + namespace: "kube-system", + resourceName: "nginx-abc12", + }); + }); + }); + + it("ScaleModal onScale calls scaleReplicasetCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockResolvedValue(undefined); + + render( + + ); + + await openMenuAndClick("Scale"); + const scaleBtn = await screen.findByRole("button", { name: /scale/i }); + fireEvent.click(scaleBtn); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("scale_replicaset", { + clusterId: "c1", + namespace: "kube-system", + name: "nginx-abc12", + replicas: expect.any(Number), + }); + }); + }); +}); + +// ─── JobList ───────────────────────────────────────────────────────────────── + +describe("JobList — actions use item.namespace not filter prop", () => { + const job: JobInfo = { + name: "db-migrate", + namespace: "kube-system", + completions: "1/1", + duration: "45s", + age: "1d", + labels: {}, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockInvoke.mockResolvedValue("apiVersion: batch/v1"); + }); + + it("openEdit calls getResourceYamlCmd with item.namespace, not 'all'", async () => { + render( + + ); + + await openMenuAndClick("Edit"); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "jobs", + namespace: "kube-system", + resourceName: "db-migrate", + }); + }); + }); + + it("handleDelete calls deleteResourceCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "delete_resource") return undefined; + return "yaml"; + }); + + render( + + ); + + await openMenuAndClick("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /delete|confirm/i }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "jobs", + namespace: "kube-system", + resourceName: "db-migrate", + }); + }); + }); +}); + +// ─── CronJobList ───────────────────────────────────────────────────────────── + +describe("CronJobList — actions use item.namespace not filter prop", () => { + const cj: CronJobInfo = { + name: "backup", + namespace: "kube-system", + schedule: "0 2 * * *", + active: 0, + last_schedule: "1h", + age: "10d", + labels: {}, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockInvoke.mockResolvedValue("apiVersion: batch/v1"); + }); + + it("openEdit calls getResourceYamlCmd with item.namespace, not 'all'", async () => { + render( + + ); + + await openMenuAndClick("Edit"); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_resource_yaml", { + clusterId: "c1", + resourceType: "cronjobs", + namespace: "kube-system", + resourceName: "backup", + }); + }); + }); + + it("handleDelete calls deleteResourceCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "delete_resource") return undefined; + return "yaml"; + }); + + render( + + ); + + await openMenuAndClick("Delete"); + const confirmBtn = await screen.findByRole("button", { name: /delete|confirm/i }); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("delete_resource", { + clusterId: "c1", + resourceType: "cronjobs", + namespace: "kube-system", + resourceName: "backup", + }); + }); + }); + + it("handleSuspend calls suspendCronjobCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "suspend_cronjob") return undefined; + return "yaml"; + }); + + render( + + ); + + await openMenuAndClick("Suspend"); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("suspend_cronjob", { + clusterId: "c1", + namespace: "kube-system", + name: "backup", + }); + }); + }); + + it("handleTrigger calls triggerCronjobCmd with item.namespace, not 'all'", async () => { + mockInvoke.mockImplementation(async (cmd: string) => { + if (cmd === "trigger_cronjob") return undefined; + return "yaml"; + }); + + render( + + ); + + await openMenuAndClick("Trigger"); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("trigger_cronjob", { + clusterId: "c1", + namespace: "kube-system", + name: "backup", + }); + }); + }); +});