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:
parent
bf8443c9f5
commit
84bac9aa34
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
223
tests/unit/PodList.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user