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
This commit is contained in:
Shaun Arman 2026-06-08 21:55:34 -05:00
parent c871318009
commit bf8443c9f5
2 changed files with 189 additions and 11 deletions

View File

@ -437,8 +437,10 @@ export function KubernetesPage() {
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false); const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false);
const [isNotificationsOpen, setIsNotificationsOpen] = 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 lastLoadedRef = useRef<{ section: ActiveSection; clusterId: string; namespace: string } | null>(null);
const initializedRef = useRef(false);
// ── Initial data load ────────────────────────────────────────────────────── // ── Initial data load ──────────────────────────────────────────────────────
@ -451,12 +453,20 @@ export function KubernetesPage() {
setKubeconfigs(kubeconfigsData); setKubeconfigs(kubeconfigsData);
setPortForwards(portForwardsData); setPortForwards(portForwardsData);
const activeConfig = kubeconfigsData.find((c) => c.is_active); if (!initializedRef.current) {
if (activeConfig && !selectedClusterId) { initializedRef.current = true;
await connectClusterFromKubeconfigCmd(activeConfig.id).catch(() => {}); const activeConfig = kubeconfigsData.find((c) => c.is_active);
setSelectedCluster(activeConfig.id); const targetId = selectedClusterId ?? activeConfig?.id;
} else if (selectedClusterId) { if (targetId) {
await connectClusterFromKubeconfigCmd(selectedClusterId).catch(() => {}); 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) { } catch (err) {
console.error("Failed to load initial Kubernetes data:", err); console.error("Failed to load initial Kubernetes data:", err);
@ -481,11 +491,7 @@ export function KubernetesPage() {
const loadResourceData = useCallback( const loadResourceData = useCallback(
async (section: ActiveSection, clusterId: string, namespace: string) => { async (section: ActiveSection, clusterId: string, namespace: string) => {
if ( if (section === "cluster_overview" || section === "portforwarding") {
section === "cluster_overview" ||
section === "portforwarding" ||
section === "workloads_overview"
) {
return; return;
} }
@ -494,6 +500,29 @@ export function KubernetesPage() {
setIsLoadingResources(true); setIsLoadingResources(true);
try { try {
switch (section) { 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": case "pods":
await listPodsCmd(clusterId, ns).then((data) => await listPodsCmd(clusterId, ns).then((data) =>
setResources((r) => ({ ...r, pods: data })) setResources((r) => ({ ...r, pods: data }))
@ -1131,6 +1160,13 @@ export function KubernetesPage() {
)} )}
</div> </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 */} {/* Main layout: sidebar + content */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Sidebar */} {/* 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>; type MockedInvoke = ReturnType<typeof vi.fn>;
const mockInvoke = invoke as unknown as MockedInvoke; const mockInvoke = invoke as unknown as MockedInvoke;
@ -504,4 +517,133 @@ describe("KubernetesPage", () => {
expect(mockInvoke.mock.calls.length).toBeGreaterThanOrEqual(callsBefore); 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);
});
});
});
}); });