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 }
+}