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.
This commit is contained in:
Shaun Arman 2026-06-08 21:56:56 -05:00
parent bf8443c9f5
commit 84bac9aa34
5 changed files with 244 additions and 7 deletions

View File

@ -91,6 +91,7 @@ pub struct PortForwardResponse {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PodInfo { pub struct PodInfo {
pub name: String, pub name: String,
pub namespace: String,
pub status: String, pub status: String,
pub ready: String, pub ready: String,
pub age: String, pub age: String,
@ -1107,6 +1108,13 @@ fn parse_pods_json(json_str: &str) -> Result<Vec<PodInfo>, String> {
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .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 let status = item
.get("status") .get("status")
.and_then(|s| s.get("phase")) .and_then(|s| s.get("phase"))
@ -1153,6 +1161,7 @@ fn parse_pods_json(json_str: &str) -> Result<Vec<PodInfo>, String> {
pods.push(PodInfo { pods.push(PodInfo {
name, name,
namespace,
status, status,
ready, ready,
age, age,

View File

@ -32,6 +32,9 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [editError, setEditError] = useState<string | null>(null); 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) => { const getPodStatusColor = (status: string) => {
switch (status.toLowerCase()) { switch (status.toLowerCase()) {
case "running": case "running":
@ -52,7 +55,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
const openEdit = async (pod: PodInfo) => { const openEdit = async (pod: PodInfo) => {
setEditError(null); setEditError(null);
try { 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 }); setActiveModal({ type: "edit", pod, yaml });
} catch (err) { } catch (err) {
setEditError(err instanceof Error ? err.message : String(err)); setEditError(err instanceof Error ? err.message : String(err));
@ -65,9 +68,9 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
setIsDeleting(true); setIsDeleting(true);
try { try {
if (force) { if (force) {
await forceDeleteResourceCmd(clusterId, "pods", namespace, modal.pod.name); await forceDeleteResourceCmd(clusterId, "pods", modal.pod.namespace, modal.pod.name);
} else { } else {
await deleteResourceCmd(clusterId, "pods", namespace, modal.pod.name); await deleteResourceCmd(clusterId, "pods", modal.pod.namespace, modal.pod.name);
} }
setActiveModal(null); setActiveModal(null);
onRefresh?.(); onRefresh?.();
@ -167,7 +170,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
open open
onOpenChange={(o) => { if (!o) setActiveModal(null); }} onOpenChange={(o) => { if (!o) setActiveModal(null); }}
clusterId={clusterId} clusterId={clusterId}
namespace={namespace} namespace={activeModal.pod.namespace}
podName={activeModal.pod.name} podName={activeModal.pod.name}
containers={activeModal.pod.containers} containers={activeModal.pod.containers}
/> />
@ -178,7 +181,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
open open
onOpenChange={(o) => { if (!o) setActiveModal(null); }} onOpenChange={(o) => { if (!o) setActiveModal(null); }}
clusterId={clusterId} clusterId={clusterId}
namespace={namespace} namespace={activeModal.pod.namespace}
podName={activeModal.pod.name} podName={activeModal.pod.name}
containers={activeModal.pod.containers} containers={activeModal.pod.containers}
/> />
@ -189,7 +192,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
open open
onOpenChange={(o) => { if (!o) setActiveModal(null); }} onOpenChange={(o) => { if (!o) setActiveModal(null); }}
clusterId={clusterId} clusterId={clusterId}
namespace={namespace} namespace={activeModal.pod.namespace}
podName={activeModal.pod.name} podName={activeModal.pod.name}
containers={activeModal.pod.containers} containers={activeModal.pod.containers}
/> />
@ -199,7 +202,7 @@ export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps)
<EditResourceModal <EditResourceModal
isOpen isOpen
clusterId={clusterId} clusterId={clusterId}
namespace={namespace} namespace={activeModal.pod.namespace}
resourceType="pods" resourceType="pods"
resourceName={activeModal.pod.name} resourceName={activeModal.pod.name}
initialYaml={activeModal.yaml} initialYaml={activeModal.yaml}

View File

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

View File

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