Merge pull request 'fix(kube): action namespace, race condition, stability, dark mode' (#86) from fix/kube-action-namespace-and-stability into master
Some checks failed
Auto Tag / autotag (push) Successful in 7s
Auto Tag / wiki-sync (push) Successful in 8s
Auto Tag / changelog (push) Successful in 1m23s
Test / frontend-tests (push) Successful in 1m47s
Test / frontend-typecheck (push) Successful in 1m49s
Auto Tag / build-macos-arm64 (push) Successful in 6m49s
Auto Tag / build-linux-amd64 (push) Successful in 10m2s
Auto Tag / build-windows-amd64 (push) Successful in 11m36s
Auto Tag / build-linux-arm64 (push) Successful in 11m52s
Test / rust-fmt-check (push) Successful in 16m20s
Test / rust-clippy (push) Successful in 17m56s
Test / rust-tests (push) Successful in 19m45s
Renovate / renovate (push) Failing after 18s

Reviewed-on: #86
This commit is contained in:
sarman 2026-06-09 03:21:47 +00:00
commit 1d61e7ceb3
30 changed files with 2030 additions and 270 deletions

View File

@ -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.

View File

@ -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<str>) -> 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<Vec<PodInfo>, 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<Vec<PodInfo>, 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"
);
}
}

View File

@ -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<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
namespace={activeModal.cm.namespace}
resourceType="configmaps"
resourceName={activeModal.cm.name}
initialYaml={activeModal.yaml}

View File

@ -31,12 +31,9 @@ export function CronJobList({
cronJobs,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: CronJobListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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({
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
namespace={activeModal.cj.namespace}
resourceType="cronjobs"
resourceName={activeModal.cj.name}
initialYaml={activeModal.yaml}

View File

@ -24,7 +24,7 @@ type ActiveModal =
| { type: "delete"; ds: DaemonSetInfo }
| null;
export function DaemonSetList({ daemonsets, clusterId, namespace, onRefresh }: DaemonSetListProps) {
export function DaemonSetList({ daemonsets, clusterId, namespace: _namespace, onRefresh }: DaemonSetListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isActing, setIsActing] = useState(false);
const [actionError, setActionError] = useState<string | null>(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
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
namespace={activeModal.ds.namespace}
resourceType="daemonsets"
resourceName={activeModal.ds.name}
initialYaml={activeModal.yaml}

View File

@ -29,7 +29,7 @@ type ActiveModal =
| { type: "delete"; deployment: DeploymentInfo }
| null;
export function DeploymentList({ deployments, clusterId, namespace, onRefresh }: DeploymentListProps) {
export function DeploymentList({ deployments, clusterId, namespace: _namespace, onRefresh }: DeploymentListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isActing, setIsActing] = useState(false);
const [actionError, setActionError] = useState<string | null>(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 }:
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
namespace={activeModal.deployment.namespace}
resourceType="deployments"
resourceName={activeModal.deployment.name}
initialYaml={activeModal.yaml}

View File

@ -25,12 +25,9 @@ export function HPAList({
hpas,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: HPAListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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({
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
namespace={activeModal.hpa.namespace}
resourceType="horizontalpodautoscalers"
resourceName={activeModal.hpa.name}
initialYaml={activeModal.yaml}

View File

@ -25,12 +25,9 @@ export function IngressList({
ingresses,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: IngressListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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({
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
namespace={activeModal.ingress.namespace}
resourceType="ingresses"
resourceName={activeModal.ingress.name}
initialYaml={activeModal.yaml}

View File

@ -25,12 +25,9 @@ export function JobList({
jobs,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: JobListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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({
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
namespace={activeModal.job.namespace}
resourceType="jobs"
resourceName={activeModal.job.name}
initialYaml={activeModal.yaml}

View File

@ -19,7 +19,7 @@ type ActiveModal =
| { type: "delete"; lr: LimitRangeInfo }
| null;
export function LimitRangeList({ limitranges, clusterId, namespace, onRefresh }: LimitRangeListProps) {
export function LimitRangeList({ limitranges, clusterId, namespace: _namespace, onRefresh }: LimitRangeListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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 }:
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
namespace={activeModal.lr.namespace}
resourceType="limitranges"
resourceName={activeModal.lr.name}
initialYaml={activeModal.yaml}

View File

@ -19,7 +19,7 @@ type ActiveModal =
| { type: "delete"; np: NetworkPolicyInfo }
| null;
export function NetworkPolicyList({ networkpolicies, clusterId, namespace, onRefresh }: NetworkPolicyListProps) {
export function NetworkPolicyList({ networkpolicies, clusterId, namespace: _namespace, onRefresh }: NetworkPolicyListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
namespace={activeModal.np.namespace}
resourceType="networkpolicies"
resourceName={activeModal.np.name}
initialYaml={activeModal.yaml}

View File

@ -25,12 +25,9 @@ export function PVCList({
pvcs,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: PVCListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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({
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
namespace={activeModal.pvc.namespace}
resourceType="persistentvolumeclaims"
resourceName={activeModal.pvc.name}
initialYaml={activeModal.yaml}

View File

@ -32,6 +32,9 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
const [isDeleting, setIsDeleting] = useState(false);
const [editError, setEditError] = useState<string | null>(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)
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
namespace={activeModal.pod.namespace}
resourceType="pods"
resourceName={activeModal.pod.name}
initialYaml={activeModal.yaml}

View File

@ -32,7 +32,7 @@ export function PortForwardList({ portForwards, onStart, onStop, onDelete }: Por
case "active":
return "bg-green-500/15 text-green-600 dark:text-green-400 border-green-500/20";
case "stopped":
return "bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-500/20";
return "bg-muted text-muted-foreground border-border";
case "error":
return "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20";
default:
@ -95,7 +95,7 @@ export function PortForwardList({ portForwards, onStart, onStop, onDelete }: Por
</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Container Ports: {pf.container_ports.join(", ")}</span>
<span className="text-gray-300 dark:text-gray-600">|</span>
<span className="text-muted-foreground/50">|</span>
<span>Local Ports: {pf.local_ports.some(p => p > 0) ? pf.local_ports.join(", ") : "pending"}</span>
</div>
</div>

View File

@ -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<ActiveModal>(null);
const [isActing, setIsActing] = useState(false);
const [actionError, setActionError] = useState<string | null>(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({
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
namespace={activeModal.rs.namespace}
resourceType="replicasets"
resourceName={activeModal.rs.name}
initialYaml={activeModal.yaml}

View File

@ -19,7 +19,7 @@ type ActiveModal =
| { type: "delete"; rq: ResourceQuotaInfo }
| null;
export function ResourceQuotaList({ resourcequotas, clusterId, namespace, onRefresh }: ResourceQuotaListProps) {
export function ResourceQuotaList({ resourcequotas, clusterId, namespace: _namespace, onRefresh }: ResourceQuotaListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
namespace={activeModal.rq.namespace}
resourceType="resourcequotas"
resourceName={activeModal.rq.name}
initialYaml={activeModal.yaml}

View File

@ -25,12 +25,9 @@ export function RoleBindingList({
roleBindings,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: RoleBindingListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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({
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
namespace={activeModal.rb.namespace}
resourceType="rolebindings"
resourceName={activeModal.rb.name}
initialYaml={activeModal.yaml}

View File

@ -25,12 +25,9 @@ export function RoleList({
roles,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: RoleListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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({
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
namespace={activeModal.role.namespace}
resourceType="roles"
resourceName={activeModal.role.name}
initialYaml={activeModal.yaml}

View File

@ -25,12 +25,9 @@ export function SecretList({
secrets,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: SecretListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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({
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
namespace={activeModal.secret.namespace}
resourceType="secrets"
resourceName={activeModal.secret.name}
initialYaml={activeModal.yaml}

View File

@ -25,12 +25,9 @@ export function ServiceAccountList({
serviceAccounts,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: ServiceAccountListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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({
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
namespace={activeModal.sa.namespace}
resourceType="serviceaccounts"
resourceName={activeModal.sa.name}
initialYaml={activeModal.yaml}

View File

@ -20,7 +20,7 @@ type ActiveModal =
| { type: "delete"; svc: ServiceInfo }
| null;
export function ServiceList({ services, clusterId, namespace, onRefresh }: ServiceListProps) {
export function ServiceList({ services, clusterId, namespace: _namespace, onRefresh }: ServiceListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(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
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
namespace={activeModal.svc.namespace}
resourceType="services"
resourceName={activeModal.svc.name}
initialYaml={activeModal.yaml}

View File

@ -27,7 +27,7 @@ type ActiveModal =
| { type: "delete"; ss: StatefulSetInfo }
| null;
export function StatefulSetList({ statefulsets, clusterId, namespace, onRefresh }: StatefulSetListProps) {
export function StatefulSetList({ statefulsets, clusterId, namespace: _namespace, onRefresh }: StatefulSetListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isActing, setIsActing] = useState(false);
const [actionError, setActionError] = useState<string | null>(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
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
namespace={activeModal.ss.namespace}
resourceType="statefulsets"
resourceName={activeModal.ss.name}
initialYaml={activeModal.yaml}

View File

@ -135,7 +135,7 @@ export function WorkloadOverview({ resources }: WorkloadOverviewProps) {
</div>
{pods.length - runningPods - pendingPods - failedPods > 0 && (
<div className="flex items-center gap-2">
<span className="inline-block w-3 h-3 rounded-full bg-gray-400" />
<span className="inline-block w-3 h-3 rounded-full bg-muted-foreground" />
<span>Other: {pods.length - runningPods - pendingPods - failedPods}</span>
</div>
)}

View File

@ -795,6 +795,7 @@ export interface PortForwardResponse {
export interface PodInfo {
name: string;
namespace: string;
status: string;
ready: string;
age: string;

View File

@ -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<string | null>(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() {
)}
</div>
{connectionError && (
<div className="flex items-center gap-2 px-4 py-2 bg-destructive/10 border-b border-destructive/20 text-destructive text-sm">
<span>Cluster connection failed: {connectionError}</span>
<button className="ml-auto underline" onClick={() => setConnectionError(null)}>Dismiss</button>
</div>
)}
{/* Main layout: sidebar + content */}
<div className="flex flex-1 overflow-hidden">
{/* Sidebar */}

View File

@ -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[] } }) => (
<div data-testid="workload-overview">
<span data-testid="pod-count">{resources.pods.length}</span>
<span data-testid="deployment-count">{resources.deployments.length}</span>
<span data-testid="statefulset-count">{resources.statefulsets.length}</span>
<span data-testid="daemonset-count">{resources.daemonsets.length}</span>
<span data-testid="job-count">{resources.jobs.length}</span>
<span data-testid="cronjob-count">{resources.cronjobs.length}</span>
</div>
),
}));
type MockedInvoke = ReturnType<typeof vi.fn>;
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);
});
});
});
});

View File

@ -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<unknown>) => 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(
<ServiceList services={[svc]} clusterId="c1" namespace="all" />
);
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(
<ServiceList services={[svc]} clusterId="c1" namespace="all" onRefresh={() => {}} />
);
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(
<IngressList ingresses={[ing]} clusterId="c1" namespace="all" />
);
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(
<IngressList ingresses={[ing]} clusterId="c1" namespace="all" onRefresh={() => {}} />
);
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(
<ConfigMapList configmaps={[cm]} clusterId="c1" namespace="all" />
);
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(
<ConfigMapList configmaps={[cm]} clusterId="c1" namespace="all" onRefresh={() => {}} />
);
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(
<SecretList secrets={[secret]} clusterId="c1" namespace="all" />
);
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(
<SecretList secrets={[secret]} clusterId="c1" namespace="all" onRefresh={() => {}} />
);
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(
<HPAList hpas={[hpa]} clusterId="c1" namespace="all" />
);
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(
<HPAList hpas={[hpa]} clusterId="c1" namespace="all" onRefresh={() => {}} />
);
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(
<PVCList pvcs={[pvc]} clusterId="c1" namespace="all" />
);
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(
<PVCList pvcs={[pvc]} clusterId="c1" namespace="all" onRefresh={() => {}} />
);
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(
<ServiceAccountList serviceAccounts={[sa]} clusterId="c1" namespace="all" />
);
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(
<ServiceAccountList serviceAccounts={[sa]} clusterId="c1" namespace="all" onRefresh={() => {}} />
);
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(
<RoleList roles={[role]} clusterId="c1" namespace="all" />
);
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(
<RoleList roles={[role]} clusterId="c1" namespace="all" onRefresh={() => {}} />
);
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(
<RoleBindingList roleBindings={[rb]} clusterId="c1" namespace="all" />
);
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(
<RoleBindingList roleBindings={[rb]} clusterId="c1" namespace="all" onRefresh={() => {}} />
);
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(
<NetworkPolicyList networkpolicies={[np]} clusterId="c1" namespace="all" />
);
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(
<NetworkPolicyList networkpolicies={[np]} clusterId="c1" namespace="all" onRefresh={() => {}} />
);
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(
<ResourceQuotaList resourcequotas={[rq]} clusterId="c1" namespace="all" />
);
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(
<ResourceQuotaList resourcequotas={[rq]} clusterId="c1" namespace="all" onRefresh={() => {}} />
);
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(
<LimitRangeList limitranges={[lr]} clusterId="c1" namespace="all" />
);
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(
<LimitRangeList limitranges={[lr]} clusterId="c1" namespace="all" onRefresh={() => {}} />
);
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",
})
);
});
});

View File

@ -17,6 +17,7 @@ const mockInvoke = invoke as MockedInvoke;
const mockPod: PodInfo = {
name: "nginx-abc123",
namespace: "default",
status: "Running",
ready: "2/2",
age: "3h",

223
tests/unit/PodList.test.tsx Normal file
View File

@ -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 }) => (
<div data-testid="logs-modal" data-namespace={namespace} />
),
}));
vi.mock("@/components/Kubernetes/ShellExecModal", () => ({
ShellExecModal: ({ namespace }: { namespace: string }) => (
<div data-testid="shell-modal" data-namespace={namespace} />
),
}));
vi.mock("@/components/Kubernetes/AttachModal", () => ({
AttachModal: ({ namespace }: { namespace: string }) => (
<div data-testid="attach-modal" data-namespace={namespace} />
),
}));
vi.mock("@/components/Kubernetes/EditResourceModal", () => ({
EditResourceModal: ({ namespace }: { namespace: string }) => (
<div data-testid="edit-modal" data-namespace={namespace} />
),
}));
vi.mock("@/components/Kubernetes/ConfirmDeleteDialog", () => ({
ConfirmDeleteDialog: ({
onConfirm,
resourceName,
}: {
onConfirm: () => void;
resourceName: string;
}) => (
<div data-testid="confirm-delete">
<span>{resourceName}</span>
<button onClick={onConfirm}>confirm</button>
</div>
),
}));
type MockedInvoke = typeof invoke & {
mockResolvedValue: (v: unknown) => void;
mockRejectedValue: (e: Error) => void;
mockImplementation: (fn: (cmd: string) => Promise<unknown>) => 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(
<PodList
pods={[mockPod]}
clusterId="c1"
namespace="all"
onRefresh={() => {}}
/>
);
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(
<PodList
pods={[mockPod]}
clusterId="c1"
namespace="all"
onRefresh={() => {}}
/>
);
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(
<PodList
pods={[mockPod]}
clusterId="c1"
namespace="all"
onRefresh={() => {}}
/>
);
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(
<PodList
pods={[mockPod]}
clusterId="c1"
namespace="all"
onRefresh={() => {}}
/>
);
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(
<PodList
pods={[mockPod]}
clusterId="c1"
namespace="all"
onRefresh={() => {}}
/>
);
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(
<PodList
pods={[mockPod]}
clusterId="c1"
namespace="all"
onRefresh={() => {}}
/>
);
openActionMenu();
fireEvent.click(screen.getByRole("button", { name: /^attach$/i }));
await waitFor(() => {
const modal = screen.getByTestId("attach-modal");
expect(modal.getAttribute("data-namespace")).toBe("default");
});
});
});

View File

@ -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<unknown>) => 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(
<DeploymentList
deployments={[deployment]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<DeploymentList
deployments={[deployment]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<DeploymentList
deployments={[deployment]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<DeploymentList
deployments={[deployment]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<DeploymentList
deployments={[deployment]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<StatefulSetList
statefulsets={[ss]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<StatefulSetList
statefulsets={[ss]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<StatefulSetList
statefulsets={[ss]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<StatefulSetList
statefulsets={[ss]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<DaemonSetList
daemonsets={[ds]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<DaemonSetList
daemonsets={[ds]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<DaemonSetList
daemonsets={[ds]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<ReplicaSetList
replicaSets={[rs]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<ReplicaSetList
replicaSets={[rs]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<ReplicaSetList
replicaSets={[rs]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<JobList
jobs={[job]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<JobList
jobs={[job]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<CronJobList
cronJobs={[cj]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<CronJobList
cronJobs={[cj]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<CronJobList
cronJobs={[cj]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
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(
<CronJobList
cronJobs={[cj]}
clusterId="c1"
namespace="all"
onRefresh={vi.fn()}
/>
);
await openMenuAndClick("Trigger");
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith("trigger_cronjob", {
clusterId: "c1",
namespace: "kube-system",
name: "backup",
});
});
});
});