diff --git a/src/pages/Kubernetes/KubernetesPage.tsx b/src/pages/Kubernetes/KubernetesPage.tsx index 86668837..21ae13a9 100644 --- a/src/pages/Kubernetes/KubernetesPage.tsx +++ b/src/pages/Kubernetes/KubernetesPage.tsx @@ -437,8 +437,10 @@ export function KubernetesPage() { const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false); const [isNotificationsOpen, setIsNotificationsOpen] = useState(false); + const [connectionError, setConnectionError] = useState(null); const lastLoadedRef = useRef<{ section: ActiveSection; clusterId: string; namespace: string } | null>(null); + const initializedRef = useRef(false); // ── Initial data load ────────────────────────────────────────────────────── @@ -451,12 +453,20 @@ export function KubernetesPage() { setKubeconfigs(kubeconfigsData); setPortForwards(portForwardsData); - const activeConfig = kubeconfigsData.find((c) => c.is_active); - if (activeConfig && !selectedClusterId) { - await connectClusterFromKubeconfigCmd(activeConfig.id).catch(() => {}); - setSelectedCluster(activeConfig.id); - } else if (selectedClusterId) { - await connectClusterFromKubeconfigCmd(selectedClusterId).catch(() => {}); + if (!initializedRef.current) { + initializedRef.current = true; + const activeConfig = kubeconfigsData.find((c) => c.is_active); + const targetId = selectedClusterId ?? activeConfig?.id; + if (targetId) { + const err = await connectClusterFromKubeconfigCmd(targetId) + .then(() => null) + .catch((e: unknown) => e); + if (err) { + setConnectionError(err instanceof Error ? err.message : String(err)); + } else { + setSelectedCluster(targetId); + } + } } } catch (err) { console.error("Failed to load initial Kubernetes data:", err); @@ -481,11 +491,7 @@ export function KubernetesPage() { const loadResourceData = useCallback( async (section: ActiveSection, clusterId: string, namespace: string) => { - if ( - section === "cluster_overview" || - section === "portforwarding" || - section === "workloads_overview" - ) { + if (section === "cluster_overview" || section === "portforwarding") { return; } @@ -494,6 +500,29 @@ export function KubernetesPage() { setIsLoadingResources(true); try { switch (section) { + case "workloads_overview": { + const [pods, deployments, statefulsets, daemonsets, jobs, cronjobs] = + await Promise.allSettled([ + listPodsCmd(clusterId, ns), + listDeploymentsCmd(clusterId, ns), + listStatefulsetsCmd(clusterId, ns), + listDaemonsetsCmd(clusterId, ns), + listJobsCmd(clusterId, ns), + listCronjobsCmd(clusterId, ns), + ]).then((results) => + results.map((r) => (r.status === "fulfilled" ? r.value : [])) + ); + setResources((r) => ({ + ...r, + pods: pods as PodInfo[], + deployments: deployments as DeploymentInfo[], + statefulsets: statefulsets as StatefulSetInfo[], + daemonsets: daemonsets as DaemonSetInfo[], + jobs: jobs as JobInfo[], + cronjobs: cronjobs as CronJobInfo[], + })); + break; + } case "pods": await listPodsCmd(clusterId, ns).then((data) => setResources((r) => ({ ...r, pods: data })) @@ -1131,6 +1160,13 @@ export function KubernetesPage() { )} + {connectionError && ( +
+ Cluster connection failed: {connectionError} + +
+ )} + {/* Main layout: sidebar + content */}
{/* Sidebar */} diff --git a/tests/unit/KubernetesPage.test.tsx b/tests/unit/KubernetesPage.test.tsx index 5bcaaf4d..8b31eec3 100644 --- a/tests/unit/KubernetesPage.test.tsx +++ b/tests/unit/KubernetesPage.test.tsx @@ -127,6 +127,19 @@ vi.mock("@/components/Kubernetes/Hotbar", () => ({ ), })); +vi.mock("@/components/Kubernetes/WorkloadOverview", () => ({ + WorkloadOverview: ({ resources }: { resources: { pods: unknown[]; deployments: unknown[]; statefulsets: unknown[]; daemonsets: unknown[]; jobs: unknown[]; cronjobs: unknown[] } }) => ( +
+ {resources.pods.length} + {resources.deployments.length} + {resources.statefulsets.length} + {resources.daemonsets.length} + {resources.jobs.length} + {resources.cronjobs.length} +
+ ), +})); + type MockedInvoke = ReturnType; const mockInvoke = invoke as unknown as MockedInvoke; @@ -504,4 +517,133 @@ describe("KubernetesPage", () => { expect(mockInvoke.mock.calls.length).toBeGreaterThanOrEqual(callsBefore); }); }); + + describe("WorkloadOverview data loading", () => { + beforeEach(() => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "list_kubeconfigs") return Promise.resolve(MOCK_KUBECONFIGS); + if (cmd === "list_namespaces") return Promise.resolve(MOCK_NAMESPACES); + if (cmd === "list_port_forwards") return Promise.resolve([]); + if (cmd === "list_pods") return Promise.resolve([{ name: "pod-1", namespace: "default", status: "Running", ready: "1/1", restarts: 0, age: "1d", node: "node-1", ip: "10.0.0.1" }]); + if (cmd === "list_deployments") return Promise.resolve([{ name: "deploy-1", namespace: "default", ready: "1/1", up_to_date: 1, available: 1, age: "1d" }]); + if (cmd === "list_statefulsets") return Promise.resolve([]); + if (cmd === "list_daemonsets") return Promise.resolve([]); + if (cmd === "list_jobs") return Promise.resolve([]); + if (cmd === "list_cronjobs") return Promise.resolve([]); + return Promise.resolve([]); + }); + }); + + it("renders WorkloadOverview when workloads_overview section is active", async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Overview" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Overview" })); + + await waitFor(() => { + expect(screen.getByTestId("workload-overview")).toBeInTheDocument(); + }); + }); + + it("calls list_pods, list_deployments, list_statefulsets, list_daemonsets, list_jobs, list_cronjobs when workloads_overview is active", async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Overview" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Overview" })); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("list_pods", expect.anything()); + expect(mockInvoke).toHaveBeenCalledWith("list_deployments", expect.anything()); + expect(mockInvoke).toHaveBeenCalledWith("list_statefulsets", expect.anything()); + expect(mockInvoke).toHaveBeenCalledWith("list_daemonsets", expect.anything()); + expect(mockInvoke).toHaveBeenCalledWith("list_jobs", expect.anything()); + expect(mockInvoke).toHaveBeenCalledWith("list_cronjobs", expect.anything()); + }); + }); + + it("passes fetched resource counts to WorkloadOverview", async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Overview" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Overview" })); + + await waitFor(() => { + expect(screen.getByTestId("workload-overview")).toBeInTheDocument(); + expect(screen.getByTestId("pod-count").textContent).toBe("1"); + expect(screen.getByTestId("deployment-count").textContent).toBe("1"); + }); + }); + }); + + describe("Connection error banner", () => { + it("shows a connection error banner when connectClusterFromKubeconfig fails", async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "list_kubeconfigs") return Promise.resolve(MOCK_KUBECONFIGS); + if (cmd === "list_port_forwards") return Promise.resolve([]); + if (cmd === "connect_cluster_from_kubeconfig") return Promise.reject(new Error("connection refused")); + return Promise.resolve([]); + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/cluster connection failed/i)).toBeInTheDocument(); + }); + }); + + it("dismisses the error banner when Dismiss is clicked", async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "list_kubeconfigs") return Promise.resolve(MOCK_KUBECONFIGS); + if (cmd === "list_port_forwards") return Promise.resolve([]); + if (cmd === "connect_cluster_from_kubeconfig") return Promise.reject(new Error("connection refused")); + return Promise.resolve([]); + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/cluster connection failed/i)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /dismiss/i })); + + await waitFor(() => { + expect(screen.queryByText(/cluster connection failed/i)).not.toBeInTheDocument(); + }); + }); + }); + + describe("Double-connect prevention", () => { + it("calls connectClusterFromKubeconfig only once on mount even when store updates", async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "list_kubeconfigs") return Promise.resolve(MOCK_KUBECONFIGS); + if (cmd === "list_namespaces") return Promise.resolve(MOCK_NAMESPACES); + if (cmd === "list_port_forwards") return Promise.resolve([]); + return Promise.resolve([]); + }); + + renderPage(); + + await waitFor(() => { + expect(useKubernetesStore.getState().selectedClusterId).toBe("kc-1"); + }); + + // Allow any re-renders to settle + await waitFor(() => { + const connectCalls = mockInvoke.mock.calls.filter( + ([cmd]) => cmd === "connect_cluster_from_kubeconfig" + ); + expect(connectCalls.length).toBe(1); + }); + }); + }); });