Compare commits

...

6 Commits

Author SHA1 Message Date
Shaun Arman
7dfda91cd8 fix(kube): workload list actions use item.namespace not filter prop
Some checks failed
PR Review Automation / review (pull_request) Has been cancelled
Test / rust-fmt-check (pull_request) Has been cancelled
Test / rust-clippy (pull_request) Has been cancelled
Test / frontend-typecheck (pull_request) Has been cancelled
Test / rust-tests (pull_request) Has been cancelled
Test / frontend-tests (pull_request) Has been cancelled
Deployment/StatefulSet/DaemonSet action handlers were passing
namespace='all' to kubectl when All Namespaces was selected.
Actions now use the resource's own .namespace field for openEdit,
handleRestart, handleRollback, handleDelete, ScaleModal, and
EditResourceModal.

Adds 21 TDD tests in WorkloadListActions.test.tsx covering all
action handlers across DeploymentList, StatefulSetList, DaemonSetList,
ReplicaSetList, JobList, and CronJobList. Tests verify IPC calls
receive the item's actual namespace even when the filter prop is 'all'.
2026-06-08 22:02:00 -05:00
Shaun Arman
05d8b28159 fix(kube): network/config/storage list actions use item.namespace not filter prop
Service/Ingress/ConfigMap/Secret/HPA/PVC/ServiceAccount/Role/RoleBinding/
NetworkPolicy/ResourceQuota/LimitRange action handlers now use the resource's
own .namespace field instead of the UI filter namespace='all'. Removes the
now-unused ns local variable from CronJobList/JobList/ReplicaSetList.

24 new TDD tests verify the correct namespace is passed to getResourceYamlCmd
and deleteResourceCmd for each of the 12 affected components.
2026-06-08 22:00:23 -05:00
Shaun Arman
84bac9aa34 fix(kube): add namespace to PodInfo; pod actions use pod.namespace not filter
Pod actions (logs, shell, attach, edit, delete) were receiving namespace='all'
from the UI filter prop and passing it to kubectl as -n all. Fixes by adding
namespace field to PodInfo (Rust + TypeScript) and using pod.namespace in all
action command calls in PodList.
2026-06-08 21:56:56 -05:00
Shaun Arman
bf8443c9f5 fix(kube): WorkloadOverview loads data; single connect on mount; visible error on failure
- workloads_overview now fetches pods/deployments/statefulsets/daemonsets/jobs/
  cronjobs in parallel via Promise.allSettled
- loadInitialData initializedRef guard prevents double connectClusterFromKubeconfig
- connection errors now surface as a dismissible banner instead of being swallowed
2026-06-08 21:55:34 -05:00
Shaun Arman
c871318009 fix(ui): replace hardcoded colors with semantic Tailwind vars for dark mode
Non-adaptive text-gray-* and bg-white classes replaced with text-foreground,
text-muted-foreground, bg-card, bg-background — ensuring readable contrast
in both light and dark themes.
2026-06-08 21:52:01 -05:00
Shaun Arman
ef1b3c3f23 fix(kube): unique temp kubeconfig paths — eliminate concurrent-call race condition
Each kubectl command now uses a globally unique temp kubeconfig path via
an AtomicU64 counter, preventing TempFileCleanup from deleting a file that
a concurrent call is still using.
2026-06-08 21:47:48 -05:00
29 changed files with 1955 additions and 270 deletions

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",
});
});
});
});