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:
parent
c871318009
commit
bf8443c9f5
@ -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 */}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user