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 da85e3402..4fa1fa66d 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 @@ -8,13 +8,14 @@ const h = vi.hoisted(() => ({ appKind: "Postgres", appPlural: "postgreses", tabLabels: [] as string[], + configMaps: [] as unknown[], })) vi.mock("@cozystack/k8s-client", () => ({ useK8sGet: () => h.get, useK8sDelete: () => ({ mutateAsync: vi.fn() }), // Presence probes (use-resource-presence.ts) — report empty lists. - useK8sList: () => ({ data: undefined, isLoading: false }), + useK8sList: (ref: { plural: string }) => ({ data: { items: ref.plural === "configmaps" ? h.configMaps : [] }, isLoading: false }), })) vi.mock("../../lib/app-definitions.ts", () => ({ useApplicationDefinitions: () => ({ @@ -109,3 +110,21 @@ describe("ApplicationDetailPage tabs for a virtual machine", () => { ]) }) }) + +describe("ApplicationDetailPage configuration", () => { + it("offers ConfigMaps declared by the application's resource map", () => { + h.appKind = "FoundationDB" + h.appPlural = "foundationdbs" + h.get = { + data: { kind: "FoundationDB", 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: foundationdb-demo-config", + } }] + h.tabLabels = [] + renderPage() + expect(h.tabLabels).toContain("ConfigMaps") + }) +}) diff --git a/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.tsx b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.tsx index e3bd39000..55fa684b8 100644 --- a/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.tsx +++ b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.tsx @@ -40,6 +40,8 @@ const SerialTab = lazy(() => import("./SerialTab.tsx").then((m) => ({ default: m import { VMPowerControls } from "./VMPowerControls.tsx" import { useResourceBasePath } from "../../lib/portal.ts" import { useResourcePresence } from "./use-resource-presence.ts" +import { useApplicationConfigMaps } from "./use-app-configmaps.ts" +import { ConfigMapsTab } from "./ConfigMapsTab.tsx" export function ApplicationDetailPage() { const { plural, name } = useParams<{ plural: string; name: string }>() @@ -76,6 +78,7 @@ export function ApplicationDetailPage() { }) const presence = useResourcePresence(ad, instance) + const configMaps = useApplicationConfigMaps(ad, instance, tenantNamespace ?? undefined) if (!plural || !name) return // Check the fetch error before the loading guard: on a failed GET, isLoading @@ -155,6 +158,9 @@ export function ApplicationDetailPage() { if (presence.ingresses) { tabs.push({ to: `${base}/ingresses`, label: "Ingresses", end: false }) } + if (configMaps.names.length > 0 || configMaps.error) { + tabs.push({ to: `${base}/configmaps`, label: "ConfigMaps", end: false }) + } tabs.push( { to: `${base}/secrets`, label: "Secrets", end: false }, { to: `${base}/events`, label: "Events", end: false }, @@ -229,6 +235,10 @@ export function ApplicationDetailPage() { path="ingresses" element={} /> + } + /> } 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 new file mode 100644 index 000000000..eabdbd613 --- /dev/null +++ b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.test.tsx @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { 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" +import { ConfigMapsTab } from "./ConfigMapsTab.tsx" +import { configMapNames, useApplicationConfigMaps } from "./use-app-configmaps.ts" + +const namespace = "tenant-test" +const clusterFile = "demo:example@192.0.2.1:4500" +const ad: ApplicationDefinition = { + apiVersion: "cozystack.io/v1alpha1", kind: "ApplicationDefinition", + metadata: { name: "foundationdb" }, + spec: { application: { kind: "FoundationDB", plural: "foundationdbs", singular: "foundationdb", openAPISchema: "{}" } }, +} +const instance: ApplicationInstance = { + apiVersion: "apps.cozystack.io/v1alpha1", kind: "FoundationDB", + metadata: { name: "demo", namespace }, +} +const resources = "- apiVersion: v1\n kind: ConfigMap\n name: foundationdb-demo-config" + +function Configuration() { + const maps = useApplicationConfigMaps(ad, instance, namespace) + return +} + +function makeClient(error?: Error) { + const client = new K8sClient() + vi.spyOn(client, "watch").mockReturnValue(() => {}) + vi.spyOn(client, "list").mockImplementation(async (_group, _version, _plural, ns, options) => { + expect(ns).toBe(namespace) + 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 } }, + ] } + } + expect(name).toBe("foundationdb-demo-config") + if (error) throw error + return { apiVersion: "v1", kind: "ConfigMapList", metadata: { resourceVersion: "1" }, items: [ + { metadata: { name: "foundationdb-demo-config", namespace }, data: { "cluster-file": clusterFile } }, + ] } + }) + return client +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe("application ConfigMaps", () => { + it("discovers and reads only the named ConfigMaps in the active namespace", async () => { + const client = makeClient() + renderWithK8sProvider(, { client }) + expect(await screen.findByText(clusterFile)).toBeInTheDocument() + expect(client.list).toHaveBeenCalledWith("", "v1", "configmaps", namespace, { + labelSelector: undefined, fieldSelector: "metadata.name=foundationdb-demo-resourcemap", + }) + expect(client.list).toHaveBeenCalledWith("", "v1", "configmaps", namespace, { + labelSelector: undefined, fieldSelector: "metadata.name=foundationdb-demo-config", + }) + expect(client.watch).toHaveBeenCalledWith("", "v1", "configmaps", namespace, "1", + expect.any(Function), expect.any(Function), { + labelSelector: undefined, fieldSelector: "metadata.name=foundationdb-demo-config", + }) + }) + + it("copies the cluster file without base64 decoding", async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal("navigator", { clipboard: { writeText } }) + renderWithK8sProvider(, { client: makeClient() }) + fireEvent.click(await screen.findByRole("button", { name: "Copy cluster-file" })) + await waitFor(() => expect(writeText).toHaveBeenCalledWith(clusterFile)) + expect(await screen.findByText("Copied.")).toBeInTheDocument() + }) + + it("reports a clipboard failure", async () => { + vi.stubGlobal("navigator", { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Denied")) } }) + renderWithK8sProvider(, { client: makeClient() }) + fireEvent.click(await screen.findByRole("button", { name: "Copy cluster-file" })) + expect(await screen.findByRole("status")).toHaveTextContent("Could not copy") + }) + + it("shows a read denial instead of reporting an empty ConfigMap", async () => { + renderWithK8sProvider(, { client: makeClient(new K8sApiError(403, "ConfigMap access denied")) }) + expect(await screen.findByRole("alert")).toHaveTextContent("ConfigMap access denied") + expect(screen.queryByText("No configuration values.")).not.toBeInTheDocument() + }) + + it("ignores non-ConfigMap and cross-namespace entries and removes duplicate names", () => { + expect(configMapNames(JSON.stringify([ + { apiVersion: "v1", kind: "ConfigMap", name: "connection" }, + { apiVersion: "v1", kind: "ConfigMap", name: "connection", namespace }, + { apiVersion: "v1", kind: "ConfigMap", name: "foreign", namespace: "tenant-other" }, + { apiVersion: "v1", kind: "Secret", name: "password" }, + { apiVersion: "other/v1", kind: "ConfigMap", name: "unrelated" }, + null, + ]), namespace)).toEqual(["connection"]) + }) + + it("rejects malformed maps and unsafe resource names", () => { + expect(() => configMapNames("{broken", namespace)).toThrow() + expect(() => configMapNames("not a list", namespace)).toThrow("must contain a list") + expect(() => configMapNames(resources.replace("foundationdb-demo-config", "../secrets"), namespace)).toThrow("invalid ConfigMap name") + }) +}) diff --git a/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.tsx b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.tsx new file mode 100644 index 000000000..c883c827b --- /dev/null +++ b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.tsx @@ -0,0 +1,57 @@ +import { useState } from "react" +import { useK8sList } from "@cozystack/k8s-client" +import { Button, Section, Spinner } from "@cozystack/ui" +import type { ConfigMap } from "./use-app-configmaps.ts" + +function ConfigValue({ name, value }: { name: string, value: string }) { + const [copyResult, setCopyResult] = useState<{ value: string, copied: boolean } | null>(null) + const copy = async () => { + try { + await navigator.clipboard.writeText(value) + setCopyResult({ value, copied: true }) + } catch { + setCopyResult({ value, copied: false }) + } + } + return ( +
+
+ {name} + +
+
{value || "(empty)"}
+ {copyResult?.value === value &&

{copyResult.copied ? "Copied." : "Could not copy. Select and copy the value manually."}

} +
+ ) +} + +function ConfigMapItem({ namespace, name }: { namespace: string, name: string }) { + const { data, isLoading, error } = useK8sList( + { apiGroup: "", apiVersion: "v1", plural: "configmaps", namespace }, + { enabled: !!namespace, fieldSelector: `metadata.name=${name}`, retry: false }, + ) + const item = data?.items.find((entry) => entry.metadata.name === name) + return ( +
+ {error ?

{error.message}

+ : isLoading ?
Loading…
+ : !item ?

ConfigMap is not available yet.

+ : Object.keys(item.data ?? {}).length === 0 ?

No configuration values.

+ :
{Object.entries(item.data ?? {}).map(([key, value]) => ( + + ))}
} +
+ ) +} + +export function ConfigMapsTab({ namespace, names, error }: { namespace: string, names: string[], error: Error | null }) { + return ( +
+ {error ?

{error.message}

+ : names.length === 0 ?

No ConfigMaps.

+ : names.map((name) => )} +
+ ) +} 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 new file mode 100644 index 000000000..7a5953882 --- /dev/null +++ b/packages/system/dashboard/images/console/apps/console/src/routes/detail/use-app-configmaps.ts @@ -0,0 +1,48 @@ +import { useMemo } from "react" +import { JSON_SCHEMA, load } from "js-yaml" +import { K8sApiError, useK8sList, type K8sResource } from "@cozystack/k8s-client" +import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types" +import { releasePrefix } from "../../lib/app-definitions.ts" + +export type ConfigMap = K8sResource & { data?: Record } + +export function configMapNames(resources: string | undefined, namespace: string): string[] { + if (!resources) return [] + const entries: unknown = load(resources, { schema: JSON_SCHEMA }) + if (!Array.isArray(entries)) throw new Error("The application's resource map must contain a list.") + const names = new Set() + for (const value of entries as unknown[]) { + if (!value || typeof value !== "object") continue + const entry = value as Record + if (entry.apiVersion !== "v1" || entry.kind !== "ConfigMap") continue + if (entry.namespace && entry.namespace !== namespace) continue + if (typeof entry.name !== "string" || entry.name.length > 253 || + !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(entry.name)) { + throw new Error("The application's resource map contains an invalid ConfigMap name.") + } + names.add(entry.name) + } + return [...names] +} + +export function useApplicationConfigMaps( + ad: ApplicationDefinition | undefined, + instance: ApplicationInstance | undefined, + namespace: string | undefined, +) { + const name = ad && instance ? `${releasePrefix(ad)}${instance.metadata.name}-resourcemap` : "" + const query = useK8sList( + { 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 parsed = useMemo(() => { + try { + return { names: configMapNames(resources, namespace ?? ""), error: null } + } catch (error) { + 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 } +}