From 07be46d37c08c30388a2c192b41365f05cfef7f8 Mon Sep 17 00:00:00 2001 From: Yan Bondarenko <202671653+yankawai@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:00:18 +0300 Subject: [PATCH] fix(dashboard): drop the resource map ConfigMaps once the map is unreadable React Query keeps the last successful list when a refetch fails. After the resource map became forbidden or went away, the hook went on parsing that old copy, so the ConfigMaps tab stayed with stale names, and a malformed map kept its old error, instead of the tab going away as it does when the map is unreadable from the start. Assisted-by: LLM Signed-off-by: Yan Bondarenko <202671653+yankawai@users.noreply.github.com> (cherry picked from commit 8ce3f853aff3ca6c57c2db8e4836e9dfd9f31c5a) --- .../detail/ApplicationDetailPage.test.tsx | 24 +++++++++++++++-- .../src/routes/detail/ConfigMapsTab.test.tsx | 27 ++++++++++++++++--- .../src/routes/detail/use-app-configmaps.ts | 4 +-- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.test.tsx b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.test.tsx index 9d3af4404..34655dbaa 100644 --- a/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.test.tsx +++ b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.test.tsx @@ -1,19 +1,24 @@ import { describe, it, expect, vi } from "vitest" import { render, screen } from "@testing-library/react" import { MemoryRouter, Routes, Route } from "react-router" +import { K8sApiError } from "@cozystack/k8s-client" // Drive useK8sGet's result per test; the page's loading/error guards are the unit under test. const h = vi.hoisted(() => ({ get: { data: undefined as unknown, isLoading: true, error: undefined as unknown }, configMaps: [] as unknown[], + configMapsError: undefined as unknown, tabLabels: [] as string[], })) -vi.mock("@cozystack/k8s-client", () => ({ +vi.mock("@cozystack/k8s-client", async (importOriginal) => ({ + K8sApiError: (await importOriginal()).K8sApiError, useK8sGet: () => h.get, useK8sDelete: () => ({ mutateAsync: vi.fn() }), // Presence probes (use-resource-presence.ts) — report empty lists. - useK8sList: (ref: { plural: string }) => ({ data: { items: ref.plural === "configmaps" ? h.configMaps : [] }, isLoading: false }), + useK8sList: (ref: { plural: string }) => ref.plural === "configmaps" + ? { data: { items: h.configMaps }, error: h.configMapsError, isLoading: false } + : { data: { items: [] }, isLoading: false }, })) vi.mock("../../lib/app-definitions.ts", () => ({ useApplicationDefinitions: () => ({ @@ -89,4 +94,19 @@ describe("ApplicationDetailPage configuration", () => { renderPage() expect(h.tabLabels).toContain("ConfigMaps") }) + + it("drops the ConfigMaps tab when the resource map stops being readable", () => { + h.get = { + data: { kind: "Postgres", metadata: { name: "demo", namespace: "tenant-test" } }, + isLoading: false, + error: undefined, + } + h.configMaps = [{ metadata: { name: "demo-resourcemap" }, data: { + resources: "- apiVersion: v1\n kind: ConfigMap\n name: postgres-demo-config", + } }] + h.configMapsError = new K8sApiError(403, "forbidden") + h.tabLabels = [] + renderPage() + expect(h.tabLabels).not.toContain("ConfigMaps") + }) }) diff --git a/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.test.tsx b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.test.tsx index eabdbd613..23030d5d4 100644 --- a/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.test.tsx +++ b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest" -import { fireEvent, screen, waitFor } from "@testing-library/react" +import { act, fireEvent, screen, waitFor } from "@testing-library/react" import { K8sApiError, K8sClient } from "@cozystack/k8s-client" import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types" import { renderWithK8sProvider } from "../../test-utils/render.tsx" @@ -24,7 +24,7 @@ function Configuration() { return } -function makeClient(error?: Error) { +function makeClient(error?: Error, map = resources) { const client = new K8sClient() vi.spyOn(client, "watch").mockReturnValue(() => {}) vi.spyOn(client, "list").mockImplementation(async (_group, _version, _plural, ns, options) => { @@ -32,7 +32,7 @@ function makeClient(error?: Error) { const name = options?.fieldSelector?.replace("metadata.name=", "") if (name === "foundationdb-demo-resourcemap") { return { apiVersion: "v1", kind: "ConfigMapList", metadata: { resourceVersion: "1" }, items: [ - { metadata: { name, namespace }, data: { resources } }, + { metadata: { name, namespace }, data: { resources: map } }, ] } } expect(name).toBe("foundationdb-demo-config") @@ -88,6 +88,27 @@ describe("application ConfigMaps", () => { expect(screen.queryByText("No configuration values.")).not.toBeInTheDocument() }) + it.each([403, 404])("drops the ConfigMaps when the resource map answers %i after a successful read", async (status) => { + const client = makeClient() + const { queryClient } = renderWithK8sProvider(, { client }) + expect(await screen.findByText(clusterFile)).toBeInTheDocument() + vi.mocked(client.list).mockRejectedValue(new K8sApiError(status, "resource map unavailable")) + await act(() => queryClient.invalidateQueries()) + expect(await screen.findByText("No ConfigMaps.")).toBeInTheDocument() + expect(screen.queryByText(clusterFile)).not.toBeInTheDocument() + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + }) + + it("drops a malformed-map error when the resource map turns forbidden", async () => { + const client = makeClient(undefined, "not a list") + const { queryClient } = renderWithK8sProvider(, { client }) + expect(await screen.findByRole("alert")).toHaveTextContent("must contain a list") + vi.mocked(client.list).mockRejectedValue(new K8sApiError(403, "resource map unavailable")) + await act(() => queryClient.invalidateQueries()) + expect(await screen.findByText("No ConfigMaps.")).toBeInTheDocument() + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + }) + it("ignores non-ConfigMap and cross-namespace entries and removes duplicate names", () => { expect(configMapNames(JSON.stringify([ { apiVersion: "v1", kind: "ConfigMap", name: "connection" }, diff --git a/packages/system/dashboard/images/console/apps/console/src/routes/detail/use-app-configmaps.ts b/packages/system/dashboard/images/console/apps/console/src/routes/detail/use-app-configmaps.ts index 7a5953882..5d35c0586 100644 --- a/packages/system/dashboard/images/console/apps/console/src/routes/detail/use-app-configmaps.ts +++ b/packages/system/dashboard/images/console/apps/console/src/routes/detail/use-app-configmaps.ts @@ -35,7 +35,8 @@ export function useApplicationConfigMaps( { apiGroup: "", apiVersion: "v1", plural: "configmaps", namespace }, { enabled: !!name && !!namespace, fieldSelector: `metadata.name=${name}`, retry: false }, ) - const resources = query.data?.items.find((item) => item.metadata.name === name)?.data?.resources + const unavailable = query.error && query.error instanceof K8sApiError && [403, 404].includes(query.error.status) + const resources = unavailable ? undefined : query.data?.items.find((item) => item.metadata.name === name)?.data?.resources const parsed = useMemo(() => { try { return { names: configMapNames(resources, namespace ?? ""), error: null } @@ -43,6 +44,5 @@ export function useApplicationConfigMaps( return { names: [], error: error instanceof Error ? error : new Error("Invalid resource map.") } } }, [resources, namespace]) - const unavailable = query.error && query.error instanceof K8sApiError && [403, 404].includes(query.error.status) return { ...parsed, isLoading: query.isLoading, error: parsed.error ?? (unavailable ? null : query.error) ?? null } }