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 [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 */}
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user