From b2dd4f544d047c2e05d966a0c778c2cb86f74f5f Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 20:34:20 +0200 Subject: [PATCH 01/19] feat(console): vertical Cluster Usage table with per-resource drill-down Lay the cluster-usage resources out top-to-bottom (one row per resource: CPU, Memory, Storage, Pods, then each discovered extended resource) instead of a left-to-right card grid. Each requestable resource row links to a new drill-down page that lists who consumes it across the cluster, grouped by tenant namespace and owning application (read from pod apps.cozystack.io/application.{kind,name} labels). The resource key flows through a splat route so vendor.com/model GPU names survive routing. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- .../ClusterUsageAggregates.test.tsx | 120 ++++++------ .../cluster-usage/ClusterUsageAggregates.tsx | 182 +++++++++++++----- .../src/routes/ClusterUsagePage.test.tsx | 15 +- .../routes/ClusterUsageResourcePage.test.tsx | 108 +++++++++++ .../src/routes/ClusterUsageResourcePage.tsx | 177 +++++++++++++++++ 5 files changed, 492 insertions(+), 110 deletions(-) create mode 100644 apps/console/src/routes/ClusterUsageResourcePage.test.tsx create mode 100644 apps/console/src/routes/ClusterUsageResourcePage.tsx diff --git a/apps/console/src/components/cluster-usage/ClusterUsageAggregates.test.tsx b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.test.tsx index 17159ed..d94fa07 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageAggregates.test.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest" import { render, screen } from "@testing-library/react" +import { MemoryRouter } from "react-router" import { ClusterUsageAggregates } from "./ClusterUsageAggregates.tsx" import type { AggregateResources } from "../../lib/cluster-usage/types.ts" import type { NodeSummary } from "../../hooks/useClusterUsageData.tsx" @@ -20,14 +21,26 @@ function summary(overrides: Partial = {}): NodeSummary { return { total: 0, ready: 0, notReady: 0, schedulingDisabled: 0, ...overrides } } +function renderAgg(props: Parameters[0]) { + return render( + + + , + ) +} + +function rowLabels(container: HTMLElement): (string | null)[] { + return Array.from(container.querySelectorAll("[data-resource-row]")).map((el) => + el.getAttribute("data-resource-row"), + ) +} + describe("ClusterUsageAggregates", () => { it("renders the node-summary header line", () => { - render( - , - ) + renderAgg({ + aggregates: empty(), + nodeSummary: summary({ total: 12, ready: 10, notReady: 1, schedulingDisabled: 1 }), + }) expect(screen.getByText("12 nodes")).toBeInTheDocument() expect( screen.getByText(/10 Ready · 1 NotReady · 1 SchedulingDisabled/), @@ -35,80 +48,77 @@ describe("ClusterUsageAggregates", () => { }) it("uses singular 'node' in the header for a one-node cluster", () => { - render( - , - ) + renderAgg({ aggregates: empty(), nodeSummary: summary({ total: 1, ready: 1 }) }) expect(screen.getByText("1 node")).toBeInTheDocument() }) - it("renders the four standard cards in order CPU, Memory, Storage, Pods", () => { - render() - const headings = screen.getAllByText(/^(CPU|Memory|Storage|Pods)$/) - const labels = headings.map((h) => h.textContent) - // Exact array (not arrayContaining) so the card order is actually pinned. - expect(labels).toEqual(["CPU", "Memory", "Storage", "Pods"]) + it("renders the standard resources as rows top-to-bottom in order CPU, Memory, Storage, Pods", () => { + const { container } = renderAgg({ aggregates: empty(), nodeSummary: summary() }) + expect(rowLabels(container)).toEqual(["CPU", "Memory", "Storage", "Pods"]) }) - it("does not render the extended-resources section when none are present", () => { - render() - expect(screen.queryByText(/extended resources/i)).toBeNull() - }) - - it("renders one card per extended-resource key with the full key as the title", () => { + it("appends extended-resource rows after the standard rows, sorted alphabetically by key", () => { const agg = empty() agg.extended["nvidia.com/gpu"] = { capacity: 4, allocatable: 4, requested: 1 } agg.extended["amd.com/gpu"] = { capacity: 2, allocatable: 2, requested: 0 } - render() - expect(screen.getByText("nvidia.com/gpu")).toBeInTheDocument() - expect(screen.getByText("amd.com/gpu")).toBeInTheDocument() + const { container } = renderAgg({ aggregates: agg, nodeSummary: summary() }) + expect(rowLabels(container)).toEqual([ + "CPU", + "Memory", + "Storage", + "Pods", + "amd.com/gpu", + "nvidia.com/gpu", + ]) }) - it("sorts extended-resource cards alphabetically by key", () => { + it("links requestable resource rows to the per-resource drill-down", () => { const agg = empty() agg.extended["nvidia.com/gpu"] = { capacity: 4, allocatable: 4, requested: 1 } - agg.extended["amd.com/gpu"] = { capacity: 2, allocatable: 2, requested: 0 } - const { container } = render( - , + renderAgg({ aggregates: agg, nodeSummary: summary() }) + expect(screen.getByRole("link", { name: "CPU" })).toHaveAttribute( + "href", + "/admin/cluster-usage/r/cpu", ) - const titles = Array.from(container.querySelectorAll('[data-extended-card]')).map( - (el) => el.getAttribute("data-extended-card"), + expect(screen.getByRole("link", { name: "nvidia.com/gpu" })).toHaveAttribute( + "href", + "/admin/cluster-usage/r/nvidia.com/gpu", ) - expect(titles).toEqual(["amd.com/gpu", "nvidia.com/gpu"]) }) - it("does not render a 'Used' line on any card when no card has used data", () => { - render() - expect(screen.queryByText(/used/i)).toBeNull() + it("does not link the Pods count row (not a requestable resource)", () => { + renderAgg({ aggregates: empty(), nodeSummary: summary() }) + expect(screen.queryByRole("link", { name: "Pods" })).toBeNull() + expect(screen.getByText("Pods")).toBeInTheDocument() + }) + + it("shows an em-dash in the Used cell when no usage data is present", () => { + const agg = empty() + agg.standard.cpu = { capacity: 8, allocatable: 8, requested: 2 } + const { container } = renderAgg({ aggregates: agg, nodeSummary: summary({ total: 1 }) }) + const cpuRow = container.querySelector('[data-resource-row="CPU"]') as HTMLElement + const cells = cpuRow.querySelectorAll("td") + // Last column is Used. + expect(cells[cells.length - 1].textContent).toBe("—") }) - it("renders the 'Used' line on standard cards when usage data is present", () => { + it("shows the Used value when usage data is present", () => { const agg = empty() agg.standard.cpu = { capacity: 8, allocatable: 8, requested: 2, used: 1 } - agg.standard.memory = { - capacity: 16 * 1024 ** 3, - allocatable: 16 * 1024 ** 3, - requested: 0, - used: 4 * 1024 ** 3, - } - render() - expect(screen.getAllByText(/used/i).length).toBeGreaterThan(0) + const { container } = renderAgg({ aggregates: agg, nodeSummary: summary({ total: 1 }) }) + const cpuRow = container.querySelector('[data-resource-row="CPU"]') as HTMLElement + const cells = cpuRow.querySelectorAll("td") + expect(cells[cells.length - 1].textContent).not.toBe("—") }) it("replaces Requested numbers with an em-dash tooltip when pods are unavailable", () => { const agg = empty() agg.standard.cpu = { capacity: 8, allocatable: 8, requested: 3 } - render( - , - ) - // The numeric Requested value should not be visible; em dashes appear - // and at least one element has the explanatory tooltip on title. + renderAgg({ + aggregates: agg, + nodeSummary: summary({ total: 1, ready: 1 }), + podsUnavailable: true, + }) expect( screen.getAllByTitle("Requires cluster-wide pod read access").length, ).toBeGreaterThan(0) diff --git a/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx index 09bb8b4..bb5ff1a 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx @@ -1,5 +1,10 @@ -import { ResourceCard } from "./ResourceCard.tsx" -import type { AggregateResources } from "../../lib/cluster-usage/types.ts" +import { Link } from "react-router" +import { humanizeBytes, humanizeCpu } from "../../lib/k8s-quantity.ts" +import type { + AggregateResources, + ResourceTotals, + StandardResourceKey, +} from "../../lib/cluster-usage/types.ts" import type { NodeSummary } from "../../hooks/useClusterUsageData.tsx" interface ClusterUsageAggregatesProps { @@ -14,13 +19,53 @@ interface ClusterUsageAggregatesProps { podsUnavailable?: boolean } +type ResourceFormat = "cpu" | "bytes" | "count" + +interface ResourceRow { + /** Display label. */ + label: string + totals: ResourceTotals + format: ResourceFormat + /** + * Resource key as it appears in pod container requests, used to build the + * drill-down link. Null for rows that are not a requestable resource + * (e.g. the node Pods count), which then render without a link. + */ + linkKey: string | null +} + +const STANDARD_ROWS: { key: StandardResourceKey; label: string; format: ResourceFormat; requestable: boolean }[] = [ + { key: "cpu", label: "CPU", format: "cpu", requestable: true }, + { key: "memory", label: "Memory", format: "bytes", requestable: true }, + { key: "ephemeral-storage", label: "Storage", format: "bytes", requestable: true }, + { key: "pods", label: "Pods", format: "count", requestable: false }, +] + +function formatValue(value: number, format: ResourceFormat): string { + switch (format) { + case "cpu": + return humanizeCpu(value) + case "bytes": + return humanizeBytes(value) + case "count": + default: + return value % 1 === 0 ? `${value}` : value.toFixed(2) + } +} + +function percent(value: number, allocatable: number): number | null { + if (allocatable <= 0) return null + return Math.min(100, Math.round((value / allocatable) * 100)) +} + /** * Top panel of the Cluster Usage admin page. A header line shows total * node count broken down by Ready / NotReady / SchedulingDisabled, - * followed by four fixed cards for the standard scheduler resources, - * followed by one card per extended resource discovered in - * node.status.capacity (alphabetical, full key verbatim). The extended - * section disappears entirely when no extended resources are present. + * followed by a single resources table laid out TOP-TO-BOTTOM: one row per + * resource (the standard scheduler resources first, then every extended + * resource discovered in node.status.capacity, alphabetical, full key + * verbatim). Each requestable resource row links to a drill-down showing + * which tenants/workloads consume it. */ export function ClusterUsageAggregates({ aggregates, @@ -28,8 +73,26 @@ export function ClusterUsageAggregates({ podsUnavailable = false, }: ClusterUsageAggregatesProps) { const extendedKeys = Object.keys(aggregates.extended).sort() + + const rows: ResourceRow[] = [ + ...STANDARD_ROWS.map((r) => ({ + label: r.label, + totals: aggregates.standard[r.key], + format: r.format, + linkKey: r.requestable ? r.key : null, + })), + ...extendedKeys.map((key) => ({ + label: key, + totals: aggregates.extended[key], + format: "count" as ResourceFormat, + linkKey: key, + })), + ] + + const REQUESTED_UNAVAILABLE_REASON = "Requires cluster-wide pod read access" + return ( -
+
{nodeSummary.total} node{nodeSummary.total === 1 ? "" : "s"} @@ -39,51 +102,68 @@ export function ClusterUsageAggregates({ {nodeSummary.schedulingDisabled} SchedulingDisabled
-
- - - - + +
+ + + + + + + + + + + + {rows.map((row) => { + const allocatableZero = row.totals.allocatable <= 0 + const requestedPct = percent(row.totals.requested, row.totals.allocatable) + const usedDefined = row.totals.used !== undefined + return ( + + + + + + + + ) + })} + +
ResourceCapacityAllocatableRequestedUsed
+ {row.linkKey ? ( + + {row.label} + + ) : ( + {row.label} + )} + + {allocatableZero ? "—" : formatValue(row.totals.capacity, row.format)} + + {allocatableZero ? "—" : formatValue(row.totals.allocatable, row.format)} + + {podsUnavailable || allocatableZero + ? "—" + : `${formatValue(row.totals.requested, row.format)}${ + requestedPct !== null ? ` (${requestedPct}%)` : "" + }`} + + {usedDefined && !allocatableZero + ? formatValue(row.totals.used ?? 0, row.format) + : "—"} +
- {extendedKeys.length > 0 ? ( -
-

- Extended resources (discovered) -

-
- {extendedKeys.map((key) => ( -
- -
- ))} -
-
- ) : null}
) } diff --git a/apps/console/src/routes/ClusterUsagePage.test.tsx b/apps/console/src/routes/ClusterUsagePage.test.tsx index 3275004..fc1a871 100644 --- a/apps/console/src/routes/ClusterUsagePage.test.tsx +++ b/apps/console/src/routes/ClusterUsagePage.test.tsx @@ -161,15 +161,22 @@ describe("ClusterUsagePage", () => { ).toBeInTheDocument() }) - it("omits the Used line everywhere when metrics-server is not registered", async () => { + it("shows only em-dashes in the aggregate Used column when metrics-server is not registered", async () => { const client = makeClient({ nodes: nodesListFixture, pods: podsListFixture, groups: groupsWithoutMetrics, }) - renderWithK8sProvider(, { client }) - // Wait for the page to settle by waiting on an aggregate-card label. + const { container } = renderWithK8sProvider(, { client }) + // Wait for the page to settle by waiting on an aggregate label. await screen.findAllByText(/allocatable/i) - expect(screen.queryByText(/used/i)).toBeNull() + // The aggregate resources table always renders a Used column; without + // metrics every Used cell (last column of each resource row) is "—". + const rows = container.querySelectorAll("[data-resource-row]") + expect(rows.length).toBeGreaterThan(0) + for (const row of rows) { + const cells = row.querySelectorAll("td") + expect(cells[cells.length - 1].textContent).toBe("—") + } }) }) diff --git a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx new file mode 100644 index 0000000..5bbeebb --- /dev/null +++ b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest" +import { screen, within } from "@testing-library/react" +import { Route, Routes } from "react-router" +import { K8sClient, type K8sList } from "@cozystack/k8s-client" +import { ClusterUsageResourcePage } from "./ClusterUsageResourcePage.tsx" +import { renderWithK8sProvider } from "../test-utils/render.tsx" + +function pod( + namespace: string, + name: string, + labels: Record, + requests: Record[], +) { + return { + apiVersion: "v1", + kind: "Pod", + metadata: { name, namespace, labels }, + spec: { + containers: requests.map((r, i) => ({ + name: `c${i}`, + resources: { requests: r }, + })), + }, + status: { phase: "Running" }, + } +} + +const GPU = "nvidia.com/gpu" + +function makeClient(pods: unknown[]): K8sClient { + const client = new K8sClient() + vi.spyOn(client, "list").mockResolvedValue({ + apiVersion: "v1", + kind: "PodList", + metadata: {}, + items: pods, + } as K8sList) + return client +} + +function renderResource(client: K8sClient, resource: string) { + return renderWithK8sProvider( + + } /> + , + { client, initialRoute: `/r/${resource}` }, + ) +} + +describe("ClusterUsageResourcePage", () => { + it("groups consumers of a resource by tenant namespace and owning app, summing requests", async () => { + const client = makeClient([ + pod( + "tenant-foo", + "vm1-abc", + { + "apps.cozystack.io/application.kind": "VMInstance", + "apps.cozystack.io/application.name": "vm1", + }, + [{ [GPU]: "2" }], + ), + pod( + "tenant-foo", + "vm1-def", + { + "apps.cozystack.io/application.kind": "VMInstance", + "apps.cozystack.io/application.name": "vm1", + }, + [{ [GPU]: "1" }], + ), + // No GPU request → must be excluded. + pod("tenant-bar", "web-1", { "app.kubernetes.io/instance": "web" }, [ + { cpu: "500m" }, + ]), + ]) + renderResource(client, GPU) + + const row = await screen.findByText("vm1") + const tr = row.closest("tr") as HTMLElement + expect(within(tr).getByText("tenant-foo")).toBeInTheDocument() + expect(within(tr).getByText("VMInstance")).toBeInTheDocument() + // Two pods, 2 + 1 = 3 GPUs requested. + const cells = tr.querySelectorAll("td") + expect(cells[cells.length - 2].textContent).toBe("2") + expect(cells[cells.length - 1].textContent).toBe("3") + + // The non-consuming tenant must not appear. + expect(screen.queryByText("tenant-bar")).toBeNull() + }) + + it("shows an empty state when nothing requests the resource", async () => { + const client = makeClient([ + pod("tenant-bar", "web-1", { "app.kubernetes.io/instance": "web" }, [ + { cpu: "500m" }, + ]), + ]) + renderResource(client, GPU) + expect( + await screen.findByText(/no workloads are requesting/i), + ).toBeInTheDocument() + }) + + it("renders the resource key as the page heading", async () => { + const client = makeClient([]) + renderResource(client, GPU) + expect(await screen.findByRole("heading", { name: GPU })).toBeInTheDocument() + }) +}) diff --git a/apps/console/src/routes/ClusterUsageResourcePage.tsx b/apps/console/src/routes/ClusterUsageResourcePage.tsx new file mode 100644 index 0000000..1f67d55 --- /dev/null +++ b/apps/console/src/routes/ClusterUsageResourcePage.tsx @@ -0,0 +1,177 @@ +import { useMemo } from "react" +import { Link, useParams } from "react-router" +import { Section, Spinner } from "@cozystack/ui" +import { useK8sList } from "@cozystack/k8s-client" +import { APPS_GROUP } from "@cozystack/types" +import { ChevronLeft } from "lucide-react" +import { parseQuantity, humanizeBytes, humanizeCpu } from "../lib/k8s-quantity.ts" +import type { Pod } from "../lib/cluster-usage/types.ts" + +/** + * Admin → Cluster Usage → per-resource drill-down. Given a resource key + * (e.g. `cpu`, `memory`, or an extended resource like + * `nvidia.com/GH100_H200_SXM_141GB`) this lists who consumes it across the + * whole cluster, grouped by tenant namespace and the owning application. + * + * Ownership is read from pod labels — Cozystack stamps + * `apps.cozystack.io/application.{kind,name}` on every workload pod; we + * fall back to the Helm `app.kubernetes.io/instance` label and finally to + * the bare pod name so nothing is silently dropped. + * + * The resource key arrives via a splat param (`cluster-usage/r/*`) so keys + * containing slashes (every `vendor.com/model` GPU name) survive routing + * without encoding. + */ + +interface UsageRow { + namespace: string + kind: string + name: string + pods: number + requested: number +} + +function formatResource(resource: string, value: number): string { + if (resource === "cpu") return humanizeCpu(value) + if (resource === "memory" || resource === "ephemeral-storage") { + return humanizeBytes(value) + } + return value % 1 === 0 ? `${value}` : value.toFixed(2) +} + +/** Sum a single resource across all of a pod's containers (requests, then limits). */ +function podResourceRequest(pod: Pod, resource: string): number { + let total = 0 + for (const container of pod.spec?.containers ?? []) { + const req = container.resources?.requests?.[resource] + const lim = container.resources?.limits?.[resource] + const value = req ?? lim + if (value !== undefined) total += parseQuantity(value) + } + return total +} + +/** Derive the owning application (kind + name) of a pod from its labels. */ +function podOwner(pod: Pod): { kind: string; name: string } { + const labels = pod.metadata.labels ?? {} + const kind = labels[`${APPS_GROUP}/application.kind`] + const name = + labels[`${APPS_GROUP}/application.name`] ?? + labels["app.kubernetes.io/instance"] ?? + labels["app.kubernetes.io/name"] + if (kind && name) return { kind, name } + if (name) return { kind: kind ?? "—", name } + return { kind: kind ?? "—", name: pod.metadata.name } +} + +export function ClusterUsageResourcePage() { + const params = useParams() + const resource = params["*"] ?? "" + + const { + data: podsList, + isLoading, + error, + } = useK8sList({ apiGroup: "", apiVersion: "v1", plural: "pods" }) + + const { rows, totalRequested, totalPods } = useMemo(() => { + const byKey = new Map() + let totalRequested = 0 + let totalPods = 0 + for (const pod of podsList?.items ?? []) { + const requested = podResourceRequest(pod, resource) + if (requested <= 0) continue + const namespace = pod.metadata.namespace ?? "—" + const { kind, name } = podOwner(pod) + const key = `${namespace}/${kind}/${name}` + const existing = byKey.get(key) + if (existing) { + existing.pods += 1 + existing.requested += requested + } else { + byKey.set(key, { namespace, kind, name, pods: 1, requested }) + } + totalRequested += requested + totalPods += 1 + } + const rows = [...byKey.values()].sort((a, b) => b.requested - a.requested) + return { rows, totalRequested, totalPods } + }, [podsList, resource]) + + return ( +
+
+ + Cluster Usage + +

+ {resource} +

+

+ Consumers of this resource across all tenants, grouped by namespace + and owning application (derived from pod labels). +

+
+ + {isLoading ? ( +
+ Loading… +
+ ) : error ? ( +
+
+ Failed to load pods: {error.message} +
+
+ ) : rows.length === 0 ? ( +
+

+ No workloads are requesting{" "} + {resource}. +

+
+ ) : ( +
+ + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + + + + + + + + +
Tenant (namespace)KindNamePodsRequested
{r.namespace}{r.kind}{r.name}{r.pods} + {formatResource(resource, r.requested)} +
+ Total · {rows.length} consumer{rows.length === 1 ? "" : "s"} + {totalPods} + {formatResource(resource, totalRequested)} +
+
+ )} +
+ ) +} From 777a048c20c82c9f3b80074b99077f193a2d3cda Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 21:28:47 +0200 Subject: [PATCH 02/19] feat(console): move Cluster Usage and Backup Classes into a gated Admin portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an Admin top-nav entry (Marketplace / Console / Admin) with its own sidebar and relocate the cluster-wide operator views out of the tenant-facing Console: Cluster Usage and the Backup Classes management added in cozystack-ui#21. Per-tenant backups (Plans / Backup Jobs / Backups / Restore Jobs) stay in Console. The Admin tab and /admin/* routes are gated by useAdminAccess — a user reaches the portal if they can use at least one area (nodes/list for Cluster Usage or backupclasses/update for Backup Classes); the tab is hidden otherwise and direct-URL access renders a 403. Each area keeps its own guard (the Cluster Usage page on nodes/list, the Backup Classes routes via BackupClassAdminGuard). Backup Classes internal links are repointed to /admin/backups/backupclasses. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- apps/console/src/App.tsx | 21 ++- .../src/routes/AdminPage.routing.test.tsx | 69 +++++++ apps/console/src/routes/AdminPage.tsx | 70 +++++++ .../src/routes/BackupClassCreatePage.tsx | 2 +- .../src/routes/BackupClassDetailPage.tsx | 6 +- .../src/routes/BackupClassEditPage.tsx | 2 +- .../src/routes/BackupClassListPage.tsx | 4 +- .../src/routes/ConsolePage.routing.test.tsx | 36 +++- apps/console/src/routes/ConsolePage.tsx | 13 -- .../src/routes/sidebar-sections.test.tsx | 174 ++++++++---------- apps/console/src/routes/sidebar-sections.tsx | 84 ++++++--- 11 files changed, 334 insertions(+), 147 deletions(-) create mode 100644 apps/console/src/routes/AdminPage.routing.test.tsx create mode 100644 apps/console/src/routes/AdminPage.tsx diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 73effc6..12f2118 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -4,10 +4,14 @@ import { TenantProvider } from "./lib/tenant-context.tsx" import { Breadcrumb } from "./components/Breadcrumb.tsx" import { MarketplacePage } from "./routes/MarketplacePage.tsx" import { ConsolePage } from "./routes/ConsolePage.tsx" +import { AdminPage } from "./routes/AdminPage.tsx" import { + useAdminSidebarSections, + useCanSeeAdmin, useConsoleSidebarSections, useMarketplaceSidebarSections, } from "./routes/sidebar-sections.tsx" +import type { HeaderTab } from "@cozystack/ui" import { CommandPaletteProvider, useCommandPalette } from "./components/command-palette/command-palette-provider.tsx" import { CommandPalette } from "./components/command-palette/command-palette.tsx" import type { AppConfig } from "./lib/config.ts" @@ -20,13 +24,27 @@ interface ShellProps { function Shell({ config, username }: ShellProps) { const { pathname } = useLocation() const inMarketplace = pathname.startsWith("/marketplace") + const inAdmin = pathname.startsWith("/admin") const marketplaceSections = useMarketplaceSidebarSections() const consoleSections = useConsoleSidebarSections() - const sections = inMarketplace ? marketplaceSections : consoleSections + const adminSections = useAdminSidebarSections() + const canSeeAdmin = useCanSeeAdmin() + const sections = inAdmin + ? adminSections + : inMarketplace + ? marketplaceSections + : consoleSections const { toggle } = useCommandPalette() + const tabs: HeaderTab[] = [ + { id: "marketplace", label: "Marketplace", to: "/marketplace", highlight: true }, + { id: "console", label: "Console", to: "/console" }, + ...(canSeeAdmin ? [{ id: "admin", label: "Admin", to: "/admin" }] : []), + ] + return ( } onSearchClick={toggle} @@ -40,6 +58,7 @@ function Shell({ config, username }: ShellProps) { } /> } /> } /> + } /> ) diff --git a/apps/console/src/routes/AdminPage.routing.test.tsx b/apps/console/src/routes/AdminPage.routing.test.tsx new file mode 100644 index 0000000..ad354c6 --- /dev/null +++ b/apps/console/src/routes/AdminPage.routing.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from "vitest" +import { screen } from "@testing-library/react" +import { + K8sClient, + type K8sList, + type APIGroupList, + type SelfSubjectAccessReview, +} from "@cozystack/k8s-client" +import { AdminPage } from "./AdminPage.tsx" +import { renderWithK8sProvider } from "../test-utils/render.tsx" + +/** + * Answer each SelfSubjectAccessReview by its requested resource so the two + * admin gates (nodes/list for Cluster Usage, backupclasses/update for Backup + * Classes) can be exercised independently. + */ +function makeClient(allow: Record): K8sClient { + const client = new K8sClient() + vi.spyOn(client, "list").mockImplementation(async (_g, _v, plural) => { + return { + apiVersion: "v1", + kind: `${plural}List`, + metadata: {}, + items: [], + } as K8sList + }) + vi.spyOn(client, "getApiGroups").mockResolvedValue({ + kind: "APIGroupList", + apiVersion: "v1", + groups: [], + } as APIGroupList) + vi.spyOn(client, "create").mockImplementation(async (_g, _v, _p, body) => { + const resource = + (body as SelfSubjectAccessReview).spec?.resourceAttributes?.resource ?? "" + return { + ...(body as object), + status: { allowed: allow[resource] === true }, + } as unknown + }) + return client +} + +describe("AdminPage routing & access gate", () => { + it("renders the Cluster Usage page at /cluster-usage for an operator", async () => { + renderWithK8sProvider(, { + client: makeClient({ nodes: true }), + initialRoute: "/cluster-usage", + }) + expect(await screen.findByText("Cluster Usage")).toBeInTheDocument() + }) + + it("redirects the index route to Cluster Usage for an operator", async () => { + renderWithK8sProvider(, { + client: makeClient({ nodes: true }), + initialRoute: "/", + }) + expect(await screen.findByText("Cluster Usage")).toBeInTheDocument() + }) + + it("blocks direct access with a 403 notice when the user has neither admin area", async () => { + renderWithK8sProvider(, { + client: makeClient({ nodes: false, backupclasses: false }), + initialRoute: "/cluster-usage", + }) + expect( + await screen.findByText(/you do not have permission to access the admin portal/i), + ).toBeInTheDocument() + }) +}) diff --git a/apps/console/src/routes/AdminPage.tsx b/apps/console/src/routes/AdminPage.tsx new file mode 100644 index 0000000..3c39751 --- /dev/null +++ b/apps/console/src/routes/AdminPage.tsx @@ -0,0 +1,70 @@ +import { Link, Navigate, Route, Routes } from "react-router" +import { Section, Spinner } from "@cozystack/ui" +import { useAdminAccess } from "./sidebar-sections.tsx" +import { ClusterUsagePage } from "./ClusterUsagePage.tsx" +import { ClusterUsageResourcePage } from "./ClusterUsageResourcePage.tsx" +import { BackupClassListPage } from "./BackupClassListPage.tsx" +import { BackupClassCreatePage } from "./BackupClassCreatePage.tsx" +import { BackupClassDetailPage } from "./BackupClassDetailPage.tsx" +import { BackupClassEditPage } from "./BackupClassEditPage.tsx" +import { BackupClassAdminGuard } from "./BackupClassAdminGuard.tsx" + +/** + * Admin portal: cluster-wide operator views moved out of the tenant-facing + * Console — Cluster Usage and the Backup Classes management added in + * cozystack-ui#21. Mounted at /admin/* and gated by useAdminAccess (a user + * reaches the portal if they can use at least one area). While the access + * review is in flight we show a spinner, and a fully-denied review renders a + * 403 notice instead of leaking any admin screen. Each area additionally + * guards itself (the Cluster Usage page on nodes/list, the Backup Classes + * routes via BackupClassAdminGuard on backupclasses/update). + */ +export function AdminPage() { + const { allowed, isLoading, canClusterUsage } = useAdminAccess() + + if (isLoading) { + return ( +
+ Loading… +
+ ) + } + + if (!allowed) { + return ( +
+
+
+ You do not have permission to access the Admin portal.{" "} + + Back to console + + . +
+
+
+ ) + } + + return ( + + + } + /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + + + ) +} diff --git a/apps/console/src/routes/BackupClassCreatePage.tsx b/apps/console/src/routes/BackupClassCreatePage.tsx index d58244b..09a8578 100644 --- a/apps/console/src/routes/BackupClassCreatePage.tsx +++ b/apps/console/src/routes/BackupClassCreatePage.tsx @@ -23,7 +23,7 @@ export function BackupClassCreatePage() { plural: "backupclasses", }) - const listPath = "/console/backups/backupclasses" + const listPath = "/admin/backups/backupclasses" const handleSubmit = async () => { if (!name.trim()) { diff --git a/apps/console/src/routes/BackupClassDetailPage.tsx b/apps/console/src/routes/BackupClassDetailPage.tsx index 2abd1c1..fb00fda 100644 --- a/apps/console/src/routes/BackupClassDetailPage.tsx +++ b/apps/console/src/routes/BackupClassDetailPage.tsx @@ -31,7 +31,7 @@ export function BackupClassDetailPage() { if (!confirm(`Delete Backup Class "${name}"? This cannot be undone.`)) return try { await deleteMutation.mutateAsync(name) - navigate("/console/backups/backupclasses") + navigate("/admin/backups/backupclasses") } catch (err) { alert(`Failed to delete Backup Class: ${(err as Error).message}`) } @@ -62,7 +62,7 @@ export function BackupClassDetailPage() { return (
Backups @@ -81,7 +81,7 @@ export function BackupClassDetailPage() {
- + diff --git a/apps/console/src/routes/BackupClassEditPage.tsx b/apps/console/src/routes/BackupClassEditPage.tsx index f24eacf..e33c0c8 100644 --- a/apps/console/src/routes/BackupClassEditPage.tsx +++ b/apps/console/src/routes/BackupClassEditPage.tsx @@ -53,7 +53,7 @@ export function BackupClassEditPage() { } }, [resource]) - const detailPath = `/console/backups/backupclasses/${name}` + const detailPath = `/admin/backups/backupclasses/${name}` const handleSubmit = async () => { if (!resource || !schema) return diff --git a/apps/console/src/routes/BackupClassListPage.tsx b/apps/console/src/routes/BackupClassListPage.tsx index aa22b51..d05dab6 100644 --- a/apps/console/src/routes/BackupClassListPage.tsx +++ b/apps/console/src/routes/BackupClassListPage.tsx @@ -184,7 +184,7 @@ export function BackupClassListPage() { {items.length} {items.length === 1 ? "item" : "items"}

- + @@ -211,7 +211,7 @@ export function BackupClassListPage() { > {item.metadata.name} diff --git a/apps/console/src/routes/ConsolePage.routing.test.tsx b/apps/console/src/routes/ConsolePage.routing.test.tsx index 7a4264c..6b882a3 100644 --- a/apps/console/src/routes/ConsolePage.routing.test.tsx +++ b/apps/console/src/routes/ConsolePage.routing.test.tsx @@ -1,11 +1,12 @@ -import { describe, it, expect, vi } from "vitest" -import { screen } from "@testing-library/react" +import { describe, it, expect, vi, beforeAll } from "vitest" +import { screen, waitFor } from "@testing-library/react" import { K8sClient, type K8sList, type APIGroupList, } from "@cozystack/k8s-client" import { ConsolePage } from "./ConsolePage.tsx" +import { TenantProvider } from "../lib/tenant-context.tsx" import { renderWithK8sProvider } from "../test-utils/render.tsx" function makeClient(): K8sClient { @@ -41,13 +42,32 @@ function makeClient(): K8sClient { return client } +// TenantProvider reads window.localStorage on mount; provide a minimal +// in-memory shim for the test environment when one is not present. +beforeAll(() => { + if (typeof globalThis.localStorage?.getItem !== "function") { + const store = new Map() + vi.stubGlobal("localStorage", { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + }) + } +}) + describe("ConsolePage routing", () => { - it("renders ClusterUsagePage at /cluster-usage", async () => { + it("no longer serves the Cluster Usage page under console (moved to /admin)", async () => { const client = makeClient() - renderWithK8sProvider(, { - client, - initialRoute: "/cluster-usage", - }) - expect(await screen.findByText("Cluster Usage")).toBeInTheDocument() + renderWithK8sProvider( + + + , + { client, initialRoute: "/cluster-usage" }, + ) + // "cluster-usage" now falls through to the generic :plural list route, so + // the Cluster Usage page's unique subtitle must not appear. + await waitFor(() => expect(client.list).toHaveBeenCalled()) + expect(screen.queryByText(/Cluster-scoped capacity/i)).toBeNull() }) }) diff --git a/apps/console/src/routes/ConsolePage.tsx b/apps/console/src/routes/ConsolePage.tsx index 3e9a750..dfb4781 100644 --- a/apps/console/src/routes/ConsolePage.tsx +++ b/apps/console/src/routes/ConsolePage.tsx @@ -4,16 +4,10 @@ import { TenantsPage } from "./TenantsPage.tsx" import { ModulesPage } from "./ModulesPage.tsx" import { ExternalIpsPage } from "./ExternalIpsPage.tsx" import { InfoRedirect } from "./InfoRedirect.tsx" -import { ClusterUsagePage } from "./ClusterUsagePage.tsx" import { ApplicationListPage } from "./ApplicationListPage.tsx" import { ApplicationDetailPage } from "./detail/ApplicationDetailPage.tsx" import { ApplicationEditRoute } from "./detail/ApplicationEditRoute.tsx" import { BackupResourceListPage } from "./BackupResourceListPage.tsx" -import { BackupClassListPage } from "./BackupClassListPage.tsx" -import { BackupClassDetailPage } from "./BackupClassDetailPage.tsx" -import { BackupClassEditPage } from "./BackupClassEditPage.tsx" -import { BackupClassCreatePage } from "./BackupClassCreatePage.tsx" -import { BackupClassAdminGuard } from "./BackupClassAdminGuard.tsx" import { BackupResourceEditPage } from "./BackupResourceEditPage.tsx" import { BackupPlanCreatePage } from "./BackupPlanCreatePage.tsx" import { BackupJobCreatePage } from "./BackupJobCreatePage.tsx" @@ -29,7 +23,6 @@ export function ConsolePage() { } /> } /> } /> - } /> } @@ -78,12 +71,6 @@ export function ConsolePage() { path="backups/restorejobs/:name/edit" element={} /> - }> - } /> - } /> - } /> - } /> - } /> } /> } /> diff --git a/apps/console/src/routes/sidebar-sections.test.tsx b/apps/console/src/routes/sidebar-sections.test.tsx index a553d70..e747e3a 100644 --- a/apps/console/src/routes/sidebar-sections.test.tsx +++ b/apps/console/src/routes/sidebar-sections.test.tsx @@ -4,12 +4,15 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { K8sClient, K8sProvider, - K8sApiError, type K8sList, type SelfSubjectAccessReview, } from "@cozystack/k8s-client" import type { ReactNode } from "react" -import { useConsoleSidebarSections } from "./sidebar-sections.tsx" +import { + useAdminSidebarSections, + useCanSeeAdmin, + useConsoleSidebarSections, +} from "./sidebar-sections.tsx" const emptyAppDefList: K8sList = { apiVersion: "cozystack.io/v1alpha1", @@ -18,27 +21,19 @@ const emptyAppDefList: K8sList = { items: [], } -function ssarResponse(allowed: boolean): SelfSubjectAccessReview { - return { - apiVersion: "authorization.k8s.io/v1", - kind: "SelfSubjectAccessReview", - metadata: { name: "" }, - spec: { resourceAttributes: { resource: "nodes", verb: "list" } }, - status: { allowed }, - } -} - -interface ClientConfig { - ssar?: SelfSubjectAccessReview | "pending" | K8sApiError -} - -function makeClient(config: ClientConfig = {}): K8sClient { +// The admin gates issue two SSARs (nodes/list for Cluster Usage, +// backupclasses/update for Backup Classes); answer each by requested resource. +function makeClient(allow: Record): K8sClient { const client = new K8sClient() vi.spyOn(client, "list").mockResolvedValue(emptyAppDefList as K8sList) - vi.spyOn(client, "create").mockImplementation(async () => { - if (config.ssar === "pending") return new Promise(() => ({})) as never - if (config.ssar instanceof K8sApiError) throw config.ssar - return (config.ssar ?? ssarResponse(false)) as unknown + vi.spyOn(client, "create").mockImplementation(async (_g, _v, _p, body) => { + const resource = + (body as SelfSubjectAccessReview).spec?.resourceAttributes?.resource ?? "" + if (allow[resource] === "pending") return new Promise(() => ({})) as never + return { + ...(body as object), + status: { allowed: allow[resource] === true }, + } as unknown }) return client } @@ -58,7 +53,10 @@ function makeWrapper(client: K8sClient) { } } -function findItem(sections: ReturnType, label: string) { +function findItem( + sections: { title: string; items: { label: string; to: string }[] }[], + label: string, +) { for (const section of sections) { const found = section.items.find((i) => i.label === label) if (found) return found @@ -66,107 +64,89 @@ function findItem(sections: ReturnType, label: return undefined } -describe("useConsoleSidebarSections — Cluster Usage gate", () => { - it("renders the Cluster Usage entry when SSAR allows nodes list", async () => { - const client = makeClient({ ssar: ssarResponse(true) }) +function hasItemTo( + sections: { items: { to: string }[] }[], + to: string, +) { + return sections.some((s) => s.items.some((i) => i.to === to)) +} + +describe("useConsoleSidebarSections — admin areas moved out", () => { + it("keeps the per-tenant Backups group but drops Cluster Usage and admin Backup Classes", async () => { + const client = makeClient({ nodes: true, backupclasses: true }) const { result } = renderHook(() => useConsoleSidebarSections(), { wrapper: makeWrapper(client), }) + await waitFor(() => expect(result.current.length).toBeGreaterThan(0)) + // Per-tenant backups stay in Console. + expect(findItem(result.current, "Plans")?.to).toBe("/console/backups/plans") + // Cluster-wide admin areas are gone from Console. + expect(findItem(result.current, "Cluster Usage")).toBeUndefined() + expect(hasItemTo(result.current, "/console/backups/backupclasses")).toBe(false) + }) +}) + +describe("useAdminSidebarSections", () => { + it("shows Cluster Usage and Backup Classes when both gates allow", async () => { + const client = makeClient({ nodes: true, backupclasses: true }) + const { result } = renderHook(() => useAdminSidebarSections(), { + wrapper: makeWrapper(client), + }) await waitFor(() => expect(findItem(result.current, "Cluster Usage")).toBeDefined(), ) - expect(findItem(result.current, "Cluster Usage")?.to).toBe( - "/console/cluster-usage", + expect(findItem(result.current, "Cluster Usage")?.to).toBe("/admin/cluster-usage") + expect(findItem(result.current, "Backup Classes")?.to).toBe( + "/admin/backups/backupclasses", ) }) - it("hides the Cluster Usage entry when SSAR denies nodes list", async () => { - const client = makeClient({ ssar: ssarResponse(false) }) - const { result } = renderHook(() => useConsoleSidebarSections(), { - wrapper: makeWrapper(client), - }) - // Wait until the SSAR request has actually fired (so the absence is the - // result of a deny, not of the query still being in flight) and the - // gated entry is not present. - await waitFor(() => { - expect(client.create).toHaveBeenCalled() - expect(findItem(result.current, "Cluster Usage")).toBeUndefined() - }) - }) - - it("hides the Cluster Usage entry while SSAR is still loading (no flicker)", () => { - const client = makeClient({ ssar: "pending" }) - const { result } = renderHook(() => useConsoleSidebarSections(), { + it("shows only Backup Classes when the user lacks nodes/list", async () => { + const client = makeClient({ nodes: false, backupclasses: true }) + const { result } = renderHook(() => useAdminSidebarSections(), { wrapper: makeWrapper(client), }) + await waitFor(() => + expect(findItem(result.current, "Backup Classes")).toBeDefined(), + ) expect(findItem(result.current, "Cluster Usage")).toBeUndefined() }) - it("hides the Cluster Usage entry on SSAR error", async () => { - const client = makeClient({ ssar: new K8sApiError(500, "boom") }) - const { result } = renderHook(() => useConsoleSidebarSections(), { + it("shows only Cluster Usage when the user cannot manage backup classes", async () => { + const client = makeClient({ nodes: true, backupclasses: false }) + const { result } = renderHook(() => useAdminSidebarSections(), { wrapper: makeWrapper(client), }) - // Wait until the failing SSAR request has fired and settled; the gated - // entry must stay absent rather than relying on an arbitrary delay. - await waitFor(() => { - expect(client.create).toHaveBeenCalled() - expect(findItem(result.current, "Cluster Usage")).toBeUndefined() - }) + await waitFor(() => + expect(findItem(result.current, "Cluster Usage")).toBeDefined(), + ) + expect(findItem(result.current, "Backup Classes")).toBeUndefined() }) }) -// The sidebar issues two SSARs (nodes/list for Cluster Usage, and -// backupclasses/update for Backup Classes); this client answers each by the -// requested resource so the two gates can be exercised independently. -function makeResourceClient(allow: Record): K8sClient { - const client = new K8sClient() - vi.spyOn(client, "list").mockResolvedValue(emptyAppDefList as K8sList) - vi.spyOn(client, "create").mockImplementation(async (_g, _v, _p, body) => { - const resource = - (body as SelfSubjectAccessReview).spec?.resourceAttributes?.resource ?? "" - return { - ...(body as object), - status: { allowed: allow[resource] ?? false }, - } as unknown +describe("useCanSeeAdmin", () => { + it("is true when nodes/list is allowed", async () => { + const client = makeClient({ nodes: true, backupclasses: false }) + const { result } = renderHook(() => useCanSeeAdmin(), { + wrapper: makeWrapper(client), + }) + await waitFor(() => expect(result.current).toBe(true)) }) - return client -} -// The admin "Backups" entry collides by label with the per-tenant "Backups" -// item in the Backups group, so locate the admin one by section + URL. -function findAdminBackupsItem( - sections: ReturnType, -) { - const admin = sections.find((s) => s.title === "Administration") - return admin?.items.find((i) => i.to === "/console/backups/backupclasses") -} - -describe("useConsoleSidebarSections — Backup Classes gate", () => { - it("shows the admin Backups entry when update on backupclasses is allowed", async () => { - const client = makeResourceClient({ backupclasses: true }) - const { result } = renderHook(() => useConsoleSidebarSections(), { + it("is true when only backupclasses/update is allowed", async () => { + const client = makeClient({ nodes: false, backupclasses: true }) + const { result } = renderHook(() => useCanSeeAdmin(), { wrapper: makeWrapper(client), }) - await waitFor(() => { - const item = findAdminBackupsItem(result.current) - expect(item).toBeDefined() - expect(item?.label).toBe("Backups") - }) + await waitFor(() => expect(result.current).toBe(true)) }) - it("hides the admin Backups entry when update on backupclasses is denied (read-only tenant)", async () => { - // list allowed, update denied — the read a tenant actually has must NOT - // be enough to surface the admin entry. - const client = makeResourceClient({ backupclasses: false }) - const { result } = renderHook(() => useConsoleSidebarSections(), { + it("is false when neither admin area is allowed", async () => { + const client = makeClient({ nodes: false, backupclasses: false }) + const { result } = renderHook(() => useCanSeeAdmin(), { wrapper: makeWrapper(client), }) - await waitFor(() => { - expect(client.create).toHaveBeenCalled() - expect(findAdminBackupsItem(result.current)).toBeUndefined() - }) - // The per-tenant "Backups" group item (different URL) remains visible. - expect(findItem(result.current, "Plans")).toBeDefined() + await waitFor(() => expect(client.create).toHaveBeenCalled()) + expect(result.current).toBe(false) }) }) diff --git a/apps/console/src/routes/sidebar-sections.tsx b/apps/console/src/routes/sidebar-sections.tsx index 4e7b69a..9cfa4d9 100644 --- a/apps/console/src/routes/sidebar-sections.tsx +++ b/apps/console/src/routes/sidebar-sections.tsx @@ -72,20 +72,6 @@ export function useMarketplaceSidebarSections(): SidebarSection[] { export function useConsoleSidebarSections(): SidebarSection[] { const { data } = useApplicationDefinitions() const grouped = useMemo(() => groupByCategory(data), [data]) - // Permission gate for the Cluster Usage entry: only operators with - // cluster-wide nodes/list see the menu item. Loading and error states - // resolve as "not allowed" so the entry never flickers in then out - // for users who can't see it. - const clusterUsageReview = useSelfSubjectAccessReview({ - resourceAttributes: { resource: "nodes", verb: "list" }, - }) - const canSeeClusterUsage = - !clusterUsageReview.isLoading && - !clusterUsageReview.error && - clusterUsageReview.allowed - // Backup Classes is admin-only: tenants have cluster-wide read on - // backupclasses, so the entry is gated on write (update), not list. - const { allowed: canManageBackupClasses } = useBackupClassAdminAccess() return useMemo(() => { const sorted = [...grouped] @@ -126,12 +112,6 @@ export function useConsoleSidebarSections(): SidebarSection[] { const administrationSection: SidebarSection = { title: "Administration", items: [ - ...(canSeeClusterUsage - ? [{ label: "Cluster Usage", to: "/console/cluster-usage", icon: Gauge }] - : []), - ...(canManageBackupClasses - ? [{ label: "Backups", to: "/console/backups/backupclasses", icon: Archive }] - : []), { label: "Info", to: "/console/info", icon: Info }, { label: "Modules", to: "/console/modules", icon: ToyBrick }, { label: "External IPs", to: "/console/external-ips", icon: Globe }, @@ -140,5 +120,67 @@ export function useConsoleSidebarSections(): SidebarSection[] { } return [...categorySections, backupsSection, administrationSection] - }, [grouped, canSeeClusterUsage, canManageBackupClasses]) + }, [grouped]) +} + +/** + * Access check for the Admin portal. The portal hosts two cluster-wide + * operator areas with independent permissions: Cluster Usage (proxied by + * `nodes/list`) and Backup Classes (`backupclasses/update`, via + * {@link useBackupClassAdminAccess}). A user sees the portal if they can use + * at least one. `isLoading` lets route guards wait instead of redirecting + * mid-flight; the per-area booleans gate the individual sidebar entries. + */ +export function useAdminAccess(): { + allowed: boolean + isLoading: boolean + canClusterUsage: boolean + canBackupClasses: boolean +} { + const nodesReview = useSelfSubjectAccessReview({ + resourceAttributes: { resource: "nodes", verb: "list" }, + }) + const backupClasses = useBackupClassAdminAccess() + const canClusterUsage = + !nodesReview.isLoading && !nodesReview.error && nodesReview.allowed + const canBackupClasses = backupClasses.allowed + return { + isLoading: nodesReview.isLoading || backupClasses.isLoading, + allowed: canClusterUsage || canBackupClasses, + canClusterUsage, + canBackupClasses, + } +} + +/** Boolean convenience wrapper around {@link useAdminAccess} for nav gating. */ +export function useCanSeeAdmin(): boolean { + return useAdminAccess().allowed +} + +/** + * Admin sidebar: cluster-wide operator views moved out of the tenant-facing + * Console — Cluster Usage and Backup Classes (the cluster-administration + * backups added in cozystack-ui#21). Each entry is gated by its own + * permission so the sidebar never shows an area the user cannot open. + */ +export function useAdminSidebarSections(): SidebarSection[] { + const { canClusterUsage, canBackupClasses } = useAdminAccess() + return useMemo(() => { + const sections: SidebarSection[] = [] + if (canClusterUsage) { + sections.push({ + title: "Cluster", + items: [{ label: "Cluster Usage", to: "/admin/cluster-usage", icon: Gauge }], + }) + } + if (canBackupClasses) { + sections.push({ + title: "Backups", + items: [ + { label: "Backup Classes", to: "/admin/backups/backupclasses", icon: Archive }, + ], + }) + } + return sections + }, [canClusterUsage, canBackupClasses]) } From f806239dc47a12df450b3ace52bf7475949dab1c Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 21:39:40 +0200 Subject: [PATCH 03/19] feat(console): link Cluster Usage consumers to their deployed application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each consumer row in the per-resource drill-down now deep-links to the deployed application's Console page (/console//), resolving the plural from the application kind via ApplicationDefinitions and switching the Console tenant context to the consumer's namespace on click. Only real app instances are linked — the row must live in a tenant namespace and its kind must resolve to a known application; everything else (system pods, unrecognised owners) stays plain text. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- .../routes/ClusterUsageResourcePage.test.tsx | 95 ++++++++++++++++--- .../src/routes/ClusterUsageResourcePage.tsx | 62 +++++++++--- 2 files changed, 132 insertions(+), 25 deletions(-) diff --git a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx index 5bbeebb..6c7a1ea 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx @@ -1,8 +1,9 @@ -import { describe, it, expect, vi } from "vitest" +import { describe, it, expect, vi, beforeAll } from "vitest" import { screen, within } from "@testing-library/react" import { Route, Routes } from "react-router" import { K8sClient, type K8sList } from "@cozystack/k8s-client" import { ClusterUsageResourcePage } from "./ClusterUsageResourcePage.tsx" +import { TenantProvider } from "../lib/tenant-context.tsx" import { renderWithK8sProvider } from "../test-utils/render.tsx" function pod( @@ -25,28 +26,60 @@ function pod( } } +function appDef(kind: string, plural: string) { + return { + apiVersion: "cozystack.io/v1alpha1", + kind: "ApplicationDefinition", + metadata: { name: plural }, + spec: { application: { kind, plural, singular: kind.toLowerCase() } }, + } +} + const GPU = "nvidia.com/gpu" -function makeClient(pods: unknown[]): K8sClient { +function makeClient(pods: unknown[], appDefs: unknown[] = []): K8sClient { const client = new K8sClient() - vi.spyOn(client, "list").mockResolvedValue({ - apiVersion: "v1", - kind: "PodList", - metadata: {}, - items: pods, - } as K8sList) + vi.spyOn(client, "list").mockImplementation(async (_g, _v, plural) => { + const items = + plural === "applicationdefinitions" + ? appDefs + : plural === "tenantnamespaces" + ? [] + : pods + return { + apiVersion: "v1", + kind: `${plural}List`, + metadata: {}, + items, + } as K8sList + }) return client } function renderResource(client: K8sClient, resource: string) { return renderWithK8sProvider( - - } /> - , + + + } /> + + , { client, initialRoute: `/r/${resource}` }, ) } +// TenantProvider reads window.localStorage on mount. +beforeAll(() => { + if (typeof globalThis.localStorage?.getItem !== "function") { + const store = new Map() + vi.stubGlobal("localStorage", { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + }) + } +}) + describe("ClusterUsageResourcePage", () => { it("groups consumers of a resource by tenant namespace and owning app, summing requests", async () => { const client = makeClient([ @@ -79,15 +112,49 @@ describe("ClusterUsageResourcePage", () => { const tr = row.closest("tr") as HTMLElement expect(within(tr).getByText("tenant-foo")).toBeInTheDocument() expect(within(tr).getByText("VMInstance")).toBeInTheDocument() - // Two pods, 2 + 1 = 3 GPUs requested. const cells = tr.querySelectorAll("td") expect(cells[cells.length - 2].textContent).toBe("2") expect(cells[cells.length - 1].textContent).toBe("3") - - // The non-consuming tenant must not appear. expect(screen.queryByText("tenant-bar")).toBeNull() }) + it("links a consumer to its deployed application page in the Console", async () => { + const client = makeClient( + [ + pod( + "tenant-root", + "demo-vm-launcher", + { + "apps.cozystack.io/application.kind": "VMInstance", + "apps.cozystack.io/application.name": "demo-vm", + }, + [{ [GPU]: "1" }], + ), + ], + [appDef("VMInstance", "vminstances")], + ) + renderResource(client, GPU) + + const link = await screen.findByRole("link", { name: "demo-vm" }) + expect(link).toHaveAttribute("href", "/console/vminstances/demo-vm") + }) + + it("does not link a consumer whose kind is not a known application", async () => { + const client = makeClient( + [ + pod("tenant-root", "rogue", { "app.kubernetes.io/instance": "rogue" }, [ + { [GPU]: "1" }, + ]), + ], + [appDef("VMInstance", "vminstances")], + ) + renderResource(client, GPU) + // Owner falls back to the Helm instance label; with no matching app + // definition it must render as plain text, not a link. + await screen.findByText("rogue") + expect(screen.queryByRole("link", { name: "rogue" })).toBeNull() + }) + it("shows an empty state when nothing requests the resource", async () => { const client = makeClient([ pod("tenant-bar", "web-1", { "app.kubernetes.io/instance": "web" }, [ diff --git a/apps/console/src/routes/ClusterUsageResourcePage.tsx b/apps/console/src/routes/ClusterUsageResourcePage.tsx index 1f67d55..df2e6cc 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.tsx @@ -5,6 +5,9 @@ import { useK8sList } from "@cozystack/k8s-client" import { APPS_GROUP } from "@cozystack/types" import { ChevronLeft } from "lucide-react" import { parseQuantity, humanizeBytes, humanizeCpu } from "../lib/k8s-quantity.ts" +import { useApplicationDefinitions } from "../lib/app-definitions.ts" +import { useTenantContext } from "../lib/tenant-context.tsx" +import { TENANT_NAMESPACE_PREFIX } from "../lib/constants.ts" import type { Pod } from "../lib/cluster-usage/types.ts" /** @@ -74,6 +77,20 @@ export function ClusterUsageResourcePage() { error, } = useK8sList({ apiGroup: "", apiVersion: "v1", plural: "pods" }) + // Map application kind → plural so a consumer row can deep-link to the + // deployed application's Console page (/console//). + const { data: appDefs } = useApplicationDefinitions() + const { selectTenant } = useTenantContext() + const kindToPlural = useMemo(() => { + const map = new Map() + for (const ad of appDefs?.items ?? []) { + const kind = ad.spec?.application.kind + const plural = ad.spec?.application.plural + if (kind && plural) map.set(kind, plural) + } + return map + }, [appDefs]) + const { rows, totalRequested, totalPods } = useMemo(() => { const byKey = new Map() let totalRequested = 0 @@ -146,17 +163,40 @@ export function ClusterUsageResourcePage() { - {rows.map((r) => ( - - {r.namespace} - {r.kind} - {r.name} - {r.pods} - - {formatResource(resource, r.requested)} - - - ))} + {rows.map((r) => { + // Deep-link the consumer to its deployed application in the + // Console, but only when it is a real app instance: the kind + // must resolve to a plural and it must live in a tenant + // namespace (so we can switch the Console's tenant context). + const plural = kindToPlural.get(r.kind) + const tenant = r.namespace.startsWith(TENANT_NAMESPACE_PREFIX) + ? r.namespace.slice(TENANT_NAMESPACE_PREFIX.length) + : null + const appHref = plural && tenant ? `/console/${plural}/${r.name}` : null + return ( + + {r.namespace} + {r.kind} + + {appHref ? ( + tenant && selectTenant(tenant)} + className="text-blue-700 hover:text-blue-800 hover:underline" + > + {r.name} + + ) : ( + {r.name} + )} + + {r.pods} + + {formatResource(resource, r.requested)} + + + ) + })} From 3e25995065c792d4c40c74fe0f2360e3571ec973 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 21:48:03 +0200 Subject: [PATCH 04/19] feat(console): split Nodes onto its own Resources tab with a transposed table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the Admin sidebar section Cluster → Resources and add a dedicated Nodes entry beside Cluster Usage. The per-node breakdown moves off the Cluster Usage page (now just the cluster-wide resources table) onto its own /admin/nodes page, and the node table is transposed: each node is a column and each attribute (Status, Roles, CPU, Memory, every extended resource, Age) is a row read top-to-bottom, with a sticky label column and a name/role filter that narrows the visible node columns. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- .../cluster-usage/ClusterUsageTable.test.tsx | 207 ++++++-------- .../cluster-usage/ClusterUsageTable.tsx | 269 ++++++------------ apps/console/src/routes/AdminPage.tsx | 2 + .../src/routes/ClusterUsagePage.test.tsx | 13 +- apps/console/src/routes/ClusterUsagePage.tsx | 23 +- apps/console/src/routes/NodesPage.test.tsx | 77 +++++ apps/console/src/routes/NodesPage.tsx | 60 ++++ apps/console/src/routes/sidebar-sections.tsx | 8 +- 8 files changed, 331 insertions(+), 328 deletions(-) create mode 100644 apps/console/src/routes/NodesPage.test.tsx create mode 100644 apps/console/src/routes/NodesPage.tsx diff --git a/apps/console/src/components/cluster-usage/ClusterUsageTable.test.tsx b/apps/console/src/components/cluster-usage/ClusterUsageTable.test.tsx index 51fc0be..9411ce0 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageTable.test.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageTable.test.tsx @@ -25,37 +25,57 @@ function row(name: string, overrides: Partial = {}): NodeRow { } } -describe("ClusterUsageTable", () => { - it("renders one tr per node, default-sorted by name ascending", () => { - render( +function attrRow(container: HTMLElement, key: string): HTMLElement { + return container.querySelector(`[data-attribute-row="${key}"]`) as HTMLElement +} + +describe("ClusterUsageTable (transposed: nodes are columns)", () => { + it("renders one column per node, sorted by name ascending", () => { + const { container } = render( , ) - const rows = screen.getAllByRole("row") - // First row is the header. - expect(rows).toHaveLength(3) - expect(within(rows[1]).getByText("worker-a")).toBeInTheDocument() - expect(within(rows[2]).getByText("worker-b")).toBeInTheDocument() + const headers = within(container.querySelector("thead")!) + .getAllByRole("columnheader") + .map((h) => h.textContent) + expect(headers).toEqual(["Node", "worker-a", "worker-b"]) }) - it("shows Ready / NotReady status text", () => { - render( + it("lays out attributes top-to-bottom: Status, Roles, CPU, Memory, extended…, Age", () => { + const { container } = render( , ) - expect(screen.getByText("Ready")).toBeInTheDocument() - expect(screen.getByText("NotReady")).toBeInTheDocument() + const order = Array.from(container.querySelectorAll("[data-attribute-row]")).map((el) => + el.getAttribute("data-attribute-row"), + ) + expect(order).toEqual([ + "status", + "roles", + "cpu", + "memory", + "nvidia.com/gpu", + "amd.com/gpu", + "age", + ]) }) - it("shows SchedulingDisabled when schedulable=false", () => { - render( + it("shows Ready / NotReady / SchedulingDisabled in the Status row", () => { + const { container } = render( , ) - expect(screen.getByText(/scheduling.?disabled/i)).toBeInTheDocument() + const status = attrRow(container, "status") + expect(within(status).getByText("Ready")).toBeInTheDocument() + expect(within(status).getByText("NotReady")).toBeInTheDocument() + expect(within(status).getByText(/scheduling.?disabled/i)).toBeInTheDocument() }) it("flags pressure conditions with a chip", () => { @@ -69,147 +89,87 @@ describe("ClusterUsageTable", () => { }) it("renders roles inline, em dash for nodes without roles", () => { - render( + const { container } = render( , ) - expect(screen.getByText("control-plane")).toBeInTheDocument() - const workerRow = screen.getByText("worker").closest("tr")! - expect(within(workerRow).getAllByText("—").length).toBeGreaterThan(0) + const roles = attrRow(container, "roles") + expect(within(roles).getByText("control-plane")).toBeInTheDocument() + expect(within(roles).getByText("—")).toBeInTheDocument() }) - it("adds one column per extended key, in extendedKeys order", () => { - render( - , + it("renders the Age row verbatim from row.age", () => { + const { container } = render( + , ) - const headers = screen.getAllByRole("columnheader").map((h) => h.textContent) - const nvidiaAt = headers.indexOf("nvidia.com/gpu") - const amdAt = headers.indexOf("amd.com/gpu") - expect(nvidiaAt).toBeGreaterThanOrEqual(0) - expect(amdAt).toBeGreaterThanOrEqual(0) - // Columns must follow extendedKeys order: nvidia before amd. - expect(nvidiaAt).toBeLessThan(amdAt) + expect(within(attrRow(container, "age")).getByText("21h")).toBeInTheDocument() }) - it("renders em dash in extended-resource cell when the node does not expose it", () => { - render( - , + it("renders em dash in an extended-resource row for a node that does not expose it", () => { + const { container } = render( + , ) - const tr = screen.getByText("plain").closest("tr")! - expect(within(tr).getAllByText("—").length).toBeGreaterThan(0) + expect(within(attrRow(container, "nvidia.com/gpu")).getByText("—")).toBeInTheDocument() }) it("collapses extended-resource cells to em dash for a NotReady node", () => { const gpu = { "nvidia.com/gpu": { capacity: 2, allocatable: 2, requested: 1 } } - render( + const { container } = render( , ) - const readyRow = screen.getByText("ready-gpu").closest("tr")! - const downRow = screen.getByText("down-gpu").closest("tr")! - // The Ready node surfaces its capacity-derived numbers... - expect(within(readyRow).getByText("capacity 2")).toBeInTheDocument() - // ...while the NotReady node must not render capacity for the extended cell. - expect(within(downRow).queryByText("capacity 2")).not.toBeInTheDocument() - }) - - it("renders the age column verbatim from row.age", () => { - render( - , - ) - expect(screen.getByText("21h")).toBeInTheDocument() - }) - - it("renders em dashes in cpu/memory cells when the node is NotReady", () => { - render( - , - ) - const tr = screen.getByText("dead").closest("tr")! - // CPU + Memory both render '—' when NotReady (4 dashes total for the - // two columns' two halves each — the assert just requires the row - // contains the em dashes, not the exact count). - expect(within(tr).getAllByText("—").length).toBeGreaterThan(0) + const gpuRow = attrRow(container, "nvidia.com/gpu") + // Only the Ready node surfaces its capacity-derived number. + expect(within(gpuRow).getAllByText("capacity 2")).toHaveLength(1) }) - it("toggles the sort direction on a second click of the same column", async () => { - const user = userEvent.setup() - render( - , + it("renders em dashes in the CPU and Memory rows when the node is NotReady", () => { + const { container } = render( + , ) - const nameHeader = screen.getByRole("button", { name: /name/i }) - // Default is asc — verify ordering, then click to flip. - let bodyRows = screen.getAllByRole("row").slice(1) - expect(within(bodyRows[0]).getByText("a")).toBeInTheDocument() - await user.click(nameHeader) - bodyRows = screen.getAllByRole("row").slice(1) - expect(within(bodyRows[0]).getByText("c")).toBeInTheDocument() - expect(within(bodyRows[2]).getByText("a")).toBeInTheDocument() + expect(within(attrRow(container, "cpu")).getByText("—")).toBeInTheDocument() + expect(within(attrRow(container, "memory")).getByText("—")).toBeInTheDocument() }) - it("filters rows by name substring (case-insensitive)", async () => { + it("hides a node column when filtered out by name (case-insensitive)", async () => { const user = userEvent.setup() - render( + const { container } = render( , ) - const filter = screen.getByLabelText("Filter nodes") - await user.type(filter, "GPU") - expect(screen.queryByText("worker-cpu-1")).toBeNull() - expect(screen.queryByText("ctrl-1")).toBeNull() - expect(screen.getByText("worker-gpu-1")).toBeInTheDocument() + await user.type(screen.getByLabelText("Filter nodes"), "GPU") + const headers = within(container.querySelector("thead")!) + .getAllByRole("columnheader") + .map((h) => h.textContent) + expect(headers).toEqual(["Node", "worker-gpu-1"]) }) - it("filters rows by role substring", async () => { + it("filters node columns by role substring", async () => { const user = userEvent.setup() - render( + const { container } = render( , ) - const filter = screen.getByLabelText("Filter nodes") - await user.type(filter, "control") - expect(screen.getByText("a")).toBeInTheDocument() - expect(screen.queryByText("b")).toBeNull() + await user.type(screen.getByLabelText("Filter nodes"), "control") + const headers = within(container.querySelector("thead")!) + .getAllByRole("columnheader") + .map((h) => h.textContent) + expect(headers).toEqual(["Node", "a"]) }) it("replaces the Requested line with an em-dash tooltip when podsUnavailable", () => { - render( + const { container } = render( { podsUnavailable />, ) - const tr = screen.getByText("loaded").closest("tr")! - const tooltipNodes = tr.querySelectorAll( - '[title="Requires cluster-wide pod read access"]', - ) - expect(tooltipNodes.length).toBeGreaterThan(0) - // The literal "4 / 8 req" (visible when pods are available) must not - // appear when podsUnavailable; the tooltip-bearing dash takes its place. - expect(within(tr).queryByText(/4 \/ 8 req/)).toBeNull() + const cpu = attrRow(container, "cpu") + expect( + cpu.querySelectorAll('[title="Requires cluster-wide pod read access"]').length, + ).toBeGreaterThan(0) + expect(within(cpu).queryByText(/4 \/ 8 req/)).toBeNull() }) }) diff --git a/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx b/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx index 71a8370..4d6cdfc 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx @@ -1,5 +1,4 @@ -import { useMemo, useState } from "react" -import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react" +import { useMemo, useState, type ReactNode } from "react" import { humanizeBytes, humanizeCpu } from "../../lib/k8s-quantity.ts" import type { NodeRow, ResourceTotals } from "../../lib/cluster-usage/types.ts" @@ -12,31 +11,15 @@ interface ClusterUsageTableProps { const REQUESTED_UNAVAILABLE_REASON = "Requires cluster-wide pod read access" -type SortColumn = "name" | "status" | "roles" | "cpu" | "memory" | "age" | string - -interface SortState { - column: SortColumn - direction: "asc" | "desc" -} - function statusLabel(row: NodeRow): string { if (!row.ready) return "NotReady" if (!row.schedulable) return "SchedulingDisabled" return "Ready" } -function requestedPct(totals: ResourceTotals): number { - if (totals.allocatable <= 0) return 0 - return totals.requested / totals.allocatable -} - function cpuCell(totals: ResourceTotals, ready: boolean, podsUnavailable: boolean) { if (!ready || totals.allocatable <= 0) { - return ( -
-
-
- ) + return
} const hasUsed = totals.used !== undefined return ( @@ -61,11 +44,7 @@ function cpuCell(totals: ResourceTotals, ready: boolean, podsUnavailable: boolea function memoryCell(totals: ResourceTotals, ready: boolean, podsUnavailable: boolean) { if (!ready || totals.allocatable <= 0) { - return ( -
-
-
- ) + return
} const hasUsed = totals.used !== undefined return ( @@ -93,7 +72,7 @@ function extendedCell( ready: boolean, podsUnavailable: boolean, ) { - if (!ready || !totals) return + if (!ready || !totals) return return (
@@ -111,37 +90,43 @@ function extendedCell( ) } -function compareRows(a: NodeRow, b: NodeRow, sort: SortState): number { - const direction = sort.direction === "asc" ? 1 : -1 - switch (sort.column) { - case "name": - return a.name.localeCompare(b.name) * direction - case "status": - return statusLabel(a).localeCompare(statusLabel(b)) * direction - case "roles": - return (a.roles[0] ?? "").localeCompare(b.roles[0] ?? "") * direction - case "cpu": - return (requestedPct(a.standard.cpu) - requestedPct(b.standard.cpu)) * direction - case "memory": - return (requestedPct(a.standard.memory) - requestedPct(b.standard.memory)) * direction - case "age": { - const ta = a.creationTimestamp ? new Date(a.creationTimestamp).getTime() : 0 - const tb = b.creationTimestamp ? new Date(b.creationTimestamp).getTime() : 0 - // Older nodes have smaller timestamps; sorting asc by timestamp shows - // oldest first, which matches the typical operator instinct for "Age asc". - return (ta - tb) * direction - } - default: { - // Dynamic extended-resource column: sort by requested %. - const va = requestedPct(a.extended[sort.column] ?? { capacity: 0, allocatable: 0, requested: 0 }) - const vb = requestedPct(b.extended[sort.column] ?? { capacity: 0, allocatable: 0, requested: 0 }) - return (va - vb) * direction - } - } +function statusContent(r: NodeRow) { + return ( +
+
{statusLabel(r)}
+ {r.pressureConditions.length > 0 ? ( +
+ {r.pressureConditions.map((p) => ( + + {p} + + ))} +
+ ) : null} + {r.taints.length > 0 ? ( +
+tainted {r.taints.length}
+ ) : null} +
+ ) +} + +function rolesContent(r: NodeRow) { + if (r.roles.length === 0) return + return ( +
+ {r.roles.map((role) => ( + + {role} + + ))} +
+ ) } function matchesFilter(row: NodeRow, q: string): boolean { - if (!q) return true const needle = q.trim().toLowerCase() if (!needle) return true if (row.name.toLowerCase().includes(needle)) return true @@ -149,74 +134,56 @@ function matchesFilter(row: NodeRow, q: string): boolean { return false } -interface SortableHeaderProps { - column: SortColumn - label: string - sort: SortState - onSort: (column: SortColumn) => void - className?: string -} - -function SortableHeader({ - column, - label, - sort, - onSort, - className, -}: SortableHeaderProps) { - const active = sort.column === column - const Icon = active ? (sort.direction === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown - return ( - - - - ) -} - /** - * Per-node table rendered below the aggregate panel. Fixed columns - * (Name, Status, Roles, CPU, Memory) plus one column per full - * extended-resource key found in the cluster, then Age. Headers click - * to sort; default sort is Name ascending. A filter input above the - * table filters by name and roles substring. + * Per-node table, transposed: each NODE is a column and each attribute + * (Status, Roles, CPU, Memory, every discovered extended-resource key, then + * Age) is a row, read top-to-bottom. The first column is a sticky label + * column; node columns scroll horizontally when they overflow. The filter + * input narrows which node columns are shown (by name or role). * * NotReady nodes show em dashes for CPU / Memory because status.capacity - * stops being authoritative; the rest of the row remains visible so the - * row remains a useful pointer for the operator. When pods-list failed - * cluster-wide, Requested values in every cell are replaced by an em - * dash with a tooltip explaining the missing permission. + * stops being authoritative. When the cluster-wide pods list failed, + * Requested figures are replaced by an em dash with an explanatory tooltip. */ export function ClusterUsageTable({ rows, extendedKeys, podsUnavailable = false, }: ClusterUsageTableProps) { - const [sort, setSort] = useState({ column: "name", direction: "asc" }) const [filter, setFilter] = useState("") - const onSort = (column: SortColumn) => { - setSort((s) => - s.column === column - ? { column, direction: s.direction === "asc" ? "desc" : "asc" } - : { column, direction: "asc" }, - ) - } - - const visibleRows = useMemo( + const visibleNodes = useMemo( () => rows .filter((r) => matchesFilter(r, filter)) - .sort((a, b) => compareRows(a, b, sort)), - [rows, sort, filter], + .sort((a, b) => a.name.localeCompare(b.name)), + [rows, filter], ) + const labelCell = "sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-slate-600" + const labelHeader = + "sticky left-0 z-10 bg-slate-50 px-4 py-3 text-xs font-medium uppercase tracking-wider text-slate-500" + + const attributeRows: { key: string; label: string; mono?: boolean; render: (r: NodeRow) => ReactNode }[] = [ + { key: "status", label: "Status", render: (r) => statusContent(r) }, + { key: "roles", label: "Roles", render: (r) => rolesContent(r) }, + { key: "cpu", label: "CPU", render: (r) => cpuCell(r.standard.cpu, r.ready, podsUnavailable) }, + { key: "memory", label: "Memory", render: (r) => memoryCell(r.standard.memory, r.ready, podsUnavailable) }, + ...extendedKeys.map((k) => ({ + key: k, + label: k, + mono: true, + render: (r: NodeRow) => extendedCell(r.extended[k], r.ready, podsUnavailable), + })), + { + key: "age", + label: "Age", + render: (r: NodeRow) => ( + {r.age} + ), + }, + ] + return (
@@ -229,92 +196,38 @@ export function ClusterUsageTable({ className="w-64 max-w-full rounded border border-slate-200 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none" /> - {visibleRows.length} of {rows.length} + {visibleNodes.length} of {rows.length}
- - - - - - {extendedKeys.map((k) => ( - + {visibleNodes.map((n) => ( + ))} - - {visibleRows.map((r) => ( - - - - - - - {extendedKeys.map((k) => ( - + + {visibleNodes.map((n) => ( + ))} - ))} diff --git a/apps/console/src/routes/AdminPage.tsx b/apps/console/src/routes/AdminPage.tsx index 3c39751..e1350f0 100644 --- a/apps/console/src/routes/AdminPage.tsx +++ b/apps/console/src/routes/AdminPage.tsx @@ -3,6 +3,7 @@ import { Section, Spinner } from "@cozystack/ui" import { useAdminAccess } from "./sidebar-sections.tsx" import { ClusterUsagePage } from "./ClusterUsagePage.tsx" import { ClusterUsageResourcePage } from "./ClusterUsageResourcePage.tsx" +import { NodesPage } from "./NodesPage.tsx" import { BackupClassListPage } from "./BackupClassListPage.tsx" import { BackupClassCreatePage } from "./BackupClassCreatePage.tsx" import { BackupClassDetailPage } from "./BackupClassDetailPage.tsx" @@ -59,6 +60,7 @@ export function AdminPage() { /> } /> } /> + } /> }> } /> } /> diff --git a/apps/console/src/routes/ClusterUsagePage.test.tsx b/apps/console/src/routes/ClusterUsagePage.test.tsx index fc1a871..b3b7329 100644 --- a/apps/console/src/routes/ClusterUsagePage.test.tsx +++ b/apps/console/src/routes/ClusterUsagePage.test.tsx @@ -83,19 +83,22 @@ describe("ClusterUsagePage", () => { expect(screen.getByText(/loading/i)).toBeInTheDocument() }) - it("renders both panels on a healthy cluster with metrics", async () => { + it("renders the aggregate resources table on a healthy cluster with metrics", async () => { const client = makeClient({ nodes: nodesListFixture, pods: podsListFixture, metrics: nodeMetricsListFixture, groups: groupsWithMetrics, }) - renderWithK8sProvider(, { client }) + const { container } = renderWithK8sProvider(, { client }) expect(await screen.findByText("Cluster Usage")).toBeInTheDocument() - // "CPU" appears in both the aggregate card and the table column header, - // so assert via the aggregate-specific "Allocatable" label instead. expect(await screen.findAllByText(/allocatable/i)).not.toHaveLength(0) - expect(await screen.findByText("worker-gpu-1")).toBeInTheDocument() + // The per-node table moved to its own Nodes page; this page now shows + // only the cluster-wide resources table. + await waitFor(() => + expect(container.querySelector('[data-resource-row="CPU"]')).not.toBeNull(), + ) + expect(screen.queryByText("worker-gpu-1")).toBeNull() }) it("renders the empty state when no nodes exist", async () => { diff --git a/apps/console/src/routes/ClusterUsagePage.tsx b/apps/console/src/routes/ClusterUsagePage.tsx index b59f877..dc03a15 100644 --- a/apps/console/src/routes/ClusterUsagePage.tsx +++ b/apps/console/src/routes/ClusterUsagePage.tsx @@ -2,7 +2,6 @@ import { Link } from "react-router" import { Section, Spinner } from "@cozystack/ui" import { useClusterUsageData } from "../hooks/useClusterUsageData.tsx" import { ClusterUsageAggregates } from "../components/cluster-usage/ClusterUsageAggregates.tsx" -import { ClusterUsageTable } from "../components/cluster-usage/ClusterUsageTable.tsx" /** * Administration → Cluster Usage. Single cluster-scoped page that @@ -20,7 +19,6 @@ import { ClusterUsageTable } from "../components/cluster-usage/ClusterUsageTable export function ClusterUsagePage() { const { nodes, - perNode, aggregates, nodeSummary, isLoading, @@ -28,7 +26,6 @@ export function ClusterUsagePage() { errorStatus, podsUnavailable, } = useClusterUsageData() - const extendedKeys = Object.keys(aggregates.extended).sort() return (
@@ -67,21 +64,11 @@ export function ClusterUsagePage() {

No nodes found.

) : ( - <> - -
-

Nodes

- -
- + )}
) diff --git a/apps/console/src/routes/NodesPage.test.tsx b/apps/console/src/routes/NodesPage.test.tsx new file mode 100644 index 0000000..df7b53e --- /dev/null +++ b/apps/console/src/routes/NodesPage.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from "vitest" +import { screen, waitFor } from "@testing-library/react" +import { + K8sClient, + K8sApiError, + type K8sList, + type APIGroupList, +} from "@cozystack/k8s-client" +import { NodesPage } from "./NodesPage.tsx" +import { renderWithK8sProvider } from "../test-utils/render.tsx" +import { nodesListFixture } from "../test-utils/fixtures/nodes.ts" +import { podsListFixture } from "../test-utils/fixtures/pods.ts" + +function makeClient( + config: { nodes?: K8sList | K8sApiError; pods?: K8sList } = {}, +): K8sClient { + const client = new K8sClient() + vi.spyOn(client, "list").mockImplementation(async (g, _v, plural) => { + if (g === "metrics.k8s.io") { + return { + apiVersion: "metrics.k8s.io/v1beta1", + kind: "NodeMetricsList", + metadata: {}, + items: [], + } as K8sList + } + if (plural === "nodes") { + if (config.nodes instanceof K8sApiError) throw config.nodes + return (config.nodes ?? nodesListFixture) as K8sList + } + if (plural === "pods") { + return (config.pods ?? podsListFixture) as K8sList + } + return { apiVersion: "v1", kind: `${plural}List`, metadata: {}, items: [] } + }) + vi.spyOn(client, "getApiGroups").mockResolvedValue({ + kind: "APIGroupList", + apiVersion: "v1", + groups: [], + } as APIGroupList) + return client +} + +describe("NodesPage", () => { + it("renders the transposed node table with nodes as columns", async () => { + const { container } = renderWithK8sProvider(, { client: makeClient() }) + expect(await screen.findByText("Nodes")).toBeInTheDocument() + // Node names appear as column headers, attributes as rows. + expect(await screen.findByText("worker-gpu-1")).toBeInTheDocument() + await waitFor(() => + expect(container.querySelector('[data-attribute-row="cpu"]')).not.toBeNull(), + ) + expect(container.querySelector('[data-attribute-row="memory"]')).not.toBeNull() + }) + + it("renders a permission-denied block with a back link on 403", async () => { + renderWithK8sProvider(, { + client: makeClient({ nodes: new K8sApiError(403, "forbidden") }), + }) + expect( + await screen.findByText(/you do not have permission to view cluster nodes/i), + ).toBeInTheDocument() + expect(screen.getByRole("link", { name: /back to console/i }).getAttribute("href")).toBe( + "/console", + ) + }) + + it("renders the empty state when no nodes exist", async () => { + renderWithK8sProvider(, { + client: makeClient({ + nodes: { apiVersion: "v1", kind: "NodeList", metadata: {}, items: [] } as K8sList, + pods: { apiVersion: "v1", kind: "PodList", metadata: {}, items: [] } as K8sList, + }), + }) + expect(await screen.findByText(/no nodes found/i)).toBeInTheDocument() + }) +}) diff --git a/apps/console/src/routes/NodesPage.tsx b/apps/console/src/routes/NodesPage.tsx new file mode 100644 index 0000000..d91d47e --- /dev/null +++ b/apps/console/src/routes/NodesPage.tsx @@ -0,0 +1,60 @@ +import { Link } from "react-router" +import { Section, Spinner } from "@cozystack/ui" +import { useClusterUsageData } from "../hooks/useClusterUsageData.tsx" +import { ClusterUsageTable } from "../components/cluster-usage/ClusterUsageTable.tsx" + +/** + * Admin → Resources → Nodes. The per-node breakdown, split out of the + * Cluster Usage page onto its own tab. Reads the same useClusterUsageData + * composite hook and renders the transposed node table (nodes as columns, + * resources/attributes as rows). Gated the same way as Cluster Usage: a + * direct hit without `nodes/list` shows a 403 notice with a link back. + */ +export function NodesPage() { + const { nodes, perNode, aggregates, isLoading, error, errorStatus, podsUnavailable } = + useClusterUsageData() + const extendedKeys = Object.keys(aggregates.extended).sort() + + return ( +
+
+

Nodes

+

+ Per-node capacity, allocation and usage across the cluster, including + any discovered extended resources. +

+
+ {isLoading ? ( +
+ Loading… +
+ ) : error ? ( +
+ {errorStatus === 403 ? ( +
+ You do not have permission to view cluster nodes.{" "} + + Back to console + + . +
+ ) : ( +
+ Failed to load cluster nodes: {error.message} +
+ )} +
+ ) : nodes.length === 0 ? ( +
+

No nodes found.

+
+ ) : ( + + )} +
+ ) +} diff --git a/apps/console/src/routes/sidebar-sections.tsx b/apps/console/src/routes/sidebar-sections.tsx index 9cfa4d9..dce9639 100644 --- a/apps/console/src/routes/sidebar-sections.tsx +++ b/apps/console/src/routes/sidebar-sections.tsx @@ -9,6 +9,7 @@ import { LayoutGrid, Layers, Network, + Server, ToyBrick, Users, type LucideIcon, @@ -169,8 +170,11 @@ export function useAdminSidebarSections(): SidebarSection[] { const sections: SidebarSection[] = [] if (canClusterUsage) { sections.push({ - title: "Cluster", - items: [{ label: "Cluster Usage", to: "/admin/cluster-usage", icon: Gauge }], + title: "Resources", + items: [ + { label: "Cluster Usage", to: "/admin/cluster-usage", icon: Gauge }, + { label: "Nodes", to: "/admin/nodes", icon: Server }, + ], }) } if (canBackupClasses) { From 2906f4b388ff968d538c18f4a506387a4dafdf48 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 21:59:09 +0200 Subject: [PATCH 05/19] feat(console): rename Cluster Usage to Resources and pivot resource drill-down to per-node usage Rename the admin Cluster Usage entry/page to Resources and move the routes to /admin/resources-usage and /admin/resources-nodes. Clicking a resource now opens a per-node usage page (same Capacity / Allocatable / Requested / Used columns as the Resources table, pivoted to one row per node that exposes the resource) instead of the consumer-by-tenant breakdown. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- .../ClusterUsageAggregates.test.tsx | 4 +- .../cluster-usage/ClusterUsageAggregates.tsx | 2 +- .../src/routes/AdminPage.routing.test.tsx | 8 +- apps/console/src/routes/AdminPage.tsx | 8 +- .../src/routes/ClusterUsagePage.test.tsx | 2 +- apps/console/src/routes/ClusterUsagePage.tsx | 2 +- .../routes/ClusterUsageResourcePage.test.tsx | 191 ++++--------- .../src/routes/ClusterUsageResourcePage.tsx | 260 ++++++++---------- .../src/routes/sidebar-sections.test.tsx | 10 +- apps/console/src/routes/sidebar-sections.tsx | 4 +- 10 files changed, 187 insertions(+), 304 deletions(-) diff --git a/apps/console/src/components/cluster-usage/ClusterUsageAggregates.test.tsx b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.test.tsx index d94fa07..e27fe24 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageAggregates.test.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.test.tsx @@ -78,11 +78,11 @@ describe("ClusterUsageAggregates", () => { renderAgg({ aggregates: agg, nodeSummary: summary() }) expect(screen.getByRole("link", { name: "CPU" })).toHaveAttribute( "href", - "/admin/cluster-usage/r/cpu", + "/admin/resources-usage/r/cpu", ) expect(screen.getByRole("link", { name: "nvidia.com/gpu" })).toHaveAttribute( "href", - "/admin/cluster-usage/r/nvidia.com/gpu", + "/admin/resources-usage/r/nvidia.com/gpu", ) }) diff --git a/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx index bb5ff1a..a670183 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx @@ -128,7 +128,7 @@ export function ClusterUsageAggregates({
{rows.map((r) => { - // Deep-link the workload to its deployed application in the - // Console, but only when it is a real app instance: the kind - // must resolve to a plural and it must live in a tenant - // namespace (so we can switch the Console's tenant context). - const plural = kindToPlural.get(r.kind) - const tenant = r.namespace.startsWith(TENANT_NAMESPACE_PREFIX) - ? r.namespace.slice(TENANT_NAMESPACE_PREFIX.length) - : null - const appHref = plural && tenant ? `/console/${plural}/${r.name}` : null return ( +
- + Node + {n.name}
{r.name} -
-
{statusLabel(r)}
- {r.pressureConditions.length > 0 ? ( -
- {r.pressureConditions.map((p) => ( - - {p} - - ))} -
- ) : null} - {r.taints.length > 0 ? ( -
- +tainted {r.taints.length} -
- ) : null} -
-
- {r.roles.length > 0 ? ( -
- {r.roles.map((role) => ( - - {role} - - ))} -
- ) : ( - - )} -
- {cpuCell(r.standard.cpu, r.ready, podsUnavailable)} - - {memoryCell(r.standard.memory, r.ready, podsUnavailable)} - - {extendedCell(r.extended[k], r.ready, podsUnavailable)} + {attributeRows.map((attr) => ( +
+ {attr.label} + + {attr.render(n)} {r.age}
{row.linkKey ? ( {row.label} diff --git a/apps/console/src/routes/AdminPage.routing.test.tsx b/apps/console/src/routes/AdminPage.routing.test.tsx index ad354c6..799c074 100644 --- a/apps/console/src/routes/AdminPage.routing.test.tsx +++ b/apps/console/src/routes/AdminPage.routing.test.tsx @@ -44,9 +44,9 @@ describe("AdminPage routing & access gate", () => { it("renders the Cluster Usage page at /cluster-usage for an operator", async () => { renderWithK8sProvider(, { client: makeClient({ nodes: true }), - initialRoute: "/cluster-usage", + initialRoute: "/resources-usage", }) - expect(await screen.findByText("Cluster Usage")).toBeInTheDocument() + expect(await screen.findByText("Resources")).toBeInTheDocument() }) it("redirects the index route to Cluster Usage for an operator", async () => { @@ -54,13 +54,13 @@ describe("AdminPage routing & access gate", () => { client: makeClient({ nodes: true }), initialRoute: "/", }) - expect(await screen.findByText("Cluster Usage")).toBeInTheDocument() + expect(await screen.findByText("Resources")).toBeInTheDocument() }) it("blocks direct access with a 403 notice when the user has neither admin area", async () => { renderWithK8sProvider(, { client: makeClient({ nodes: false, backupclasses: false }), - initialRoute: "/cluster-usage", + initialRoute: "/resources-usage", }) expect( await screen.findByText(/you do not have permission to access the admin portal/i), diff --git a/apps/console/src/routes/AdminPage.tsx b/apps/console/src/routes/AdminPage.tsx index e1350f0..9aa00cc 100644 --- a/apps/console/src/routes/AdminPage.tsx +++ b/apps/console/src/routes/AdminPage.tsx @@ -53,14 +53,14 @@ export function AdminPage() { index element={ } /> - } /> - } /> - } /> + } /> + } /> + } /> }> } /> } /> diff --git a/apps/console/src/routes/ClusterUsagePage.test.tsx b/apps/console/src/routes/ClusterUsagePage.test.tsx index b3b7329..3c5889a 100644 --- a/apps/console/src/routes/ClusterUsagePage.test.tsx +++ b/apps/console/src/routes/ClusterUsagePage.test.tsx @@ -91,7 +91,7 @@ describe("ClusterUsagePage", () => { groups: groupsWithMetrics, }) const { container } = renderWithK8sProvider(, { client }) - expect(await screen.findByText("Cluster Usage")).toBeInTheDocument() + expect(await screen.findByText("Resources")).toBeInTheDocument() expect(await screen.findAllByText(/allocatable/i)).not.toHaveLength(0) // The per-node table moved to its own Nodes page; this page now shows // only the cluster-wide resources table. diff --git a/apps/console/src/routes/ClusterUsagePage.tsx b/apps/console/src/routes/ClusterUsagePage.tsx index dc03a15..716fb6c 100644 --- a/apps/console/src/routes/ClusterUsagePage.tsx +++ b/apps/console/src/routes/ClusterUsagePage.tsx @@ -30,7 +30,7 @@ export function ClusterUsagePage() { return (
-

Cluster Usage

+

Resources

Cluster-scoped capacity, allocation and usage across all nodes, including any discovered extended resources. diff --git a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx index 6c7a1ea..fcaf98c 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx @@ -1,175 +1,92 @@ -import { describe, it, expect, vi, beforeAll } from "vitest" +import { describe, it, expect, vi } from "vitest" import { screen, within } from "@testing-library/react" import { Route, Routes } from "react-router" -import { K8sClient, type K8sList } from "@cozystack/k8s-client" +import { + K8sClient, + type K8sList, + type APIGroupList, +} from "@cozystack/k8s-client" import { ClusterUsageResourcePage } from "./ClusterUsageResourcePage.tsx" -import { TenantProvider } from "../lib/tenant-context.tsx" import { renderWithK8sProvider } from "../test-utils/render.tsx" -function pod( - namespace: string, - name: string, - labels: Record, - requests: Record[], -) { +function node(name: string, resources: Record) { return { apiVersion: "v1", - kind: "Pod", - metadata: { name, namespace, labels }, - spec: { - containers: requests.map((r, i) => ({ - name: `c${i}`, - resources: { requests: r }, - })), + kind: "Node", + metadata: { name, creationTimestamp: "2026-05-25T00:00:00Z" }, + spec: {}, + status: { + capacity: resources, + allocatable: resources, + conditions: [{ type: "Ready", status: "True" }], }, - status: { phase: "Running" }, - } -} - -function appDef(kind: string, plural: string) { - return { - apiVersion: "cozystack.io/v1alpha1", - kind: "ApplicationDefinition", - metadata: { name: plural }, - spec: { application: { kind, plural, singular: kind.toLowerCase() } }, } } const GPU = "nvidia.com/gpu" -function makeClient(pods: unknown[], appDefs: unknown[] = []): K8sClient { +function makeClient(nodes: unknown[]): K8sClient { const client = new K8sClient() - vi.spyOn(client, "list").mockImplementation(async (_g, _v, plural) => { - const items = - plural === "applicationdefinitions" - ? appDefs - : plural === "tenantnamespaces" - ? [] - : pods - return { - apiVersion: "v1", - kind: `${plural}List`, - metadata: {}, - items, - } as K8sList + vi.spyOn(client, "list").mockImplementation(async (g, _v, plural) => { + if (g === "metrics.k8s.io") { + return { apiVersion: "metrics.k8s.io/v1beta1", kind: "NodeMetricsList", metadata: {}, items: [] } as K8sList + } + if (plural === "nodes") { + return { apiVersion: "v1", kind: "NodeList", metadata: {}, items: nodes } as K8sList + } + // pods and anything else + return { apiVersion: "v1", kind: `${plural}List`, metadata: {}, items: [] } as K8sList }) + vi.spyOn(client, "getApiGroups").mockResolvedValue({ + kind: "APIGroupList", + apiVersion: "v1", + groups: [], + } as APIGroupList) return client } function renderResource(client: K8sClient, resource: string) { return renderWithK8sProvider( - - - } /> - - , + + } /> + , { client, initialRoute: `/r/${resource}` }, ) } -// TenantProvider reads window.localStorage on mount. -beforeAll(() => { - if (typeof globalThis.localStorage?.getItem !== "function") { - const store = new Map() - vi.stubGlobal("localStorage", { - getItem: (k: string) => store.get(k) ?? null, - setItem: (k: string, v: string) => void store.set(k, v), - removeItem: (k: string) => void store.delete(k), - clear: () => store.clear(), - }) - } -}) - -describe("ClusterUsageResourcePage", () => { - it("groups consumers of a resource by tenant namespace and owning app, summing requests", async () => { +describe("ClusterUsageResourcePage (per-node usage of a resource)", () => { + it("renders one row per node that exposes the resource, with its capacity", async () => { const client = makeClient([ - pod( - "tenant-foo", - "vm1-abc", - { - "apps.cozystack.io/application.kind": "VMInstance", - "apps.cozystack.io/application.name": "vm1", - }, - [{ [GPU]: "2" }], - ), - pod( - "tenant-foo", - "vm1-def", - { - "apps.cozystack.io/application.kind": "VMInstance", - "apps.cozystack.io/application.name": "vm1", - }, - [{ [GPU]: "1" }], - ), - // No GPU request → must be excluded. - pod("tenant-bar", "web-1", { "app.kubernetes.io/instance": "web" }, [ - { cpu: "500m" }, - ]), + node("cloud2", { [GPU]: "8" }), + node("srv", { [GPU]: "8" }), + // No GPU → must be excluded. + node("plain", { cpu: "16" }), ]) - renderResource(client, GPU) + const { container } = renderResource(client, GPU) - const row = await screen.findByText("vm1") - const tr = row.closest("tr") as HTMLElement - expect(within(tr).getByText("tenant-foo")).toBeInTheDocument() - expect(within(tr).getByText("VMInstance")).toBeInTheDocument() - const cells = tr.querySelectorAll("td") - expect(cells[cells.length - 2].textContent).toBe("2") - expect(cells[cells.length - 1].textContent).toBe("3") - expect(screen.queryByText("tenant-bar")).toBeNull() + const cloud2 = (await screen.findByText("cloud2")).closest("tr") as HTMLElement + // Capacity and Allocatable are both 8 for this node. + expect(within(cloud2).getAllByText("8").length).toBeGreaterThanOrEqual(2) + expect(container.querySelector('[data-node-row="srv"]')).not.toBeNull() + expect(container.querySelector('[data-node-row="plain"]')).toBeNull() }) - it("links a consumer to its deployed application page in the Console", async () => { - const client = makeClient( - [ - pod( - "tenant-root", - "demo-vm-launcher", - { - "apps.cozystack.io/application.kind": "VMInstance", - "apps.cozystack.io/application.name": "demo-vm", - }, - [{ [GPU]: "1" }], - ), - ], - [appDef("VMInstance", "vminstances")], - ) + it("shows an empty state when no node exposes the resource", async () => { + const client = makeClient([node("plain", { cpu: "16" })]) renderResource(client, GPU) - - const link = await screen.findByRole("link", { name: "demo-vm" }) - expect(link).toHaveAttribute("href", "/console/vminstances/demo-vm") + expect(await screen.findByText(/no node exposes/i)).toBeInTheDocument() }) - it("does not link a consumer whose kind is not a known application", async () => { - const client = makeClient( - [ - pod("tenant-root", "rogue", { "app.kubernetes.io/instance": "rogue" }, [ - { [GPU]: "1" }, - ]), - ], - [appDef("VMInstance", "vminstances")], - ) - renderResource(client, GPU) - // Owner falls back to the Helm instance label; with no matching app - // definition it must render as plain text, not a link. - await screen.findByText("rogue") - expect(screen.queryByRole("link", { name: "rogue" })).toBeNull() - }) - - it("shows an empty state when nothing requests the resource", async () => { - const client = makeClient([ - pod("tenant-bar", "web-1", { "app.kubernetes.io/instance": "web" }, [ - { cpu: "500m" }, - ]), - ]) + it("renders the resource key as the page heading", async () => { + const client = makeClient([node("cloud2", { [GPU]: "8" })]) renderResource(client, GPU) - expect( - await screen.findByText(/no workloads are requesting/i), - ).toBeInTheDocument() + expect(await screen.findByRole("heading", { name: GPU })).toBeInTheDocument() }) - it("renders the resource key as the page heading", async () => { - const client = makeClient([]) + it("links back to the Resources page", async () => { + const client = makeClient([node("cloud2", { [GPU]: "8" })]) renderResource(client, GPU) - expect(await screen.findByRole("heading", { name: GPU })).toBeInTheDocument() + const back = await screen.findByRole("link", { name: /resources/i }) + expect(back).toHaveAttribute("href", "/admin/resources-usage") }) }) diff --git a/apps/console/src/routes/ClusterUsageResourcePage.tsx b/apps/console/src/routes/ClusterUsageResourcePage.tsx index df2e6cc..3e59b96 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.tsx @@ -1,135 +1,94 @@ -import { useMemo } from "react" import { Link, useParams } from "react-router" import { Section, Spinner } from "@cozystack/ui" -import { useK8sList } from "@cozystack/k8s-client" -import { APPS_GROUP } from "@cozystack/types" import { ChevronLeft } from "lucide-react" -import { parseQuantity, humanizeBytes, humanizeCpu } from "../lib/k8s-quantity.ts" -import { useApplicationDefinitions } from "../lib/app-definitions.ts" -import { useTenantContext } from "../lib/tenant-context.tsx" -import { TENANT_NAMESPACE_PREFIX } from "../lib/constants.ts" -import type { Pod } from "../lib/cluster-usage/types.ts" +import { humanizeBytes, humanizeCpu } from "../lib/k8s-quantity.ts" +import { useClusterUsageData } from "../hooks/useClusterUsageData.tsx" +import { + STANDARD_RESOURCE_KEY_SET, + type ResourceTotals, + type StandardResourceKey, +} from "../lib/cluster-usage/types.ts" /** - * Admin → Cluster Usage → per-resource drill-down. Given a resource key - * (e.g. `cpu`, `memory`, or an extended resource like - * `nvidia.com/GH100_H200_SXM_141GB`) this lists who consumes it across the - * whole cluster, grouped by tenant namespace and the owning application. + * Admin → Resources → per-resource usage. Reached by clicking a resource on + * the Resources page (/admin/resources-usage/r/). Shows the same usage + * view as the Resources table, pivoted to nodes: one row per node with the + * selected resource's Capacity / Allocatable / Requested / Used. * - * Ownership is read from pod labels — Cozystack stamps - * `apps.cozystack.io/application.{kind,name}` on every workload pod; we - * fall back to the Helm `app.kubernetes.io/instance` label and finally to - * the bare pod name so nothing is silently dropped. - * - * The resource key arrives via a splat param (`cluster-usage/r/*`) so keys - * containing slashes (every `vendor.com/model` GPU name) survive routing - * without encoding. + * The resource key arrives via a splat param so keys containing slashes + * (every vendor.com/model GPU name) survive routing without encoding. */ -interface UsageRow { - namespace: string - kind: string - name: string - pods: number - requested: number -} +type ResourceFormat = "cpu" | "bytes" | "count" -function formatResource(resource: string, value: number): string { - if (resource === "cpu") return humanizeCpu(value) - if (resource === "memory" || resource === "ephemeral-storage") { - return humanizeBytes(value) - } - return value % 1 === 0 ? `${value}` : value.toFixed(2) +function formatFor(resource: string): ResourceFormat { + if (resource === "cpu") return "cpu" + if (resource === "memory" || resource === "ephemeral-storage") return "bytes" + return "count" } -/** Sum a single resource across all of a pod's containers (requests, then limits). */ -function podResourceRequest(pod: Pod, resource: string): number { - let total = 0 - for (const container of pod.spec?.containers ?? []) { - const req = container.resources?.requests?.[resource] - const lim = container.resources?.limits?.[resource] - const value = req ?? lim - if (value !== undefined) total += parseQuantity(value) +function formatValue(value: number, format: ResourceFormat): string { + switch (format) { + case "cpu": + return humanizeCpu(value) + case "bytes": + return humanizeBytes(value) + case "count": + default: + return value % 1 === 0 ? `${value}` : value.toFixed(2) } - return total -} - -/** Derive the owning application (kind + name) of a pod from its labels. */ -function podOwner(pod: Pod): { kind: string; name: string } { - const labels = pod.metadata.labels ?? {} - const kind = labels[`${APPS_GROUP}/application.kind`] - const name = - labels[`${APPS_GROUP}/application.name`] ?? - labels["app.kubernetes.io/instance"] ?? - labels["app.kubernetes.io/name"] - if (kind && name) return { kind, name } - if (name) return { kind: kind ?? "—", name } - return { kind: kind ?? "—", name: pod.metadata.name } } export function ClusterUsageResourcePage() { const params = useParams() const resource = params["*"] ?? "" + const isStandard = STANDARD_RESOURCE_KEY_SET.has(resource) + const format = formatFor(resource) + + const { perNode, nodes, isLoading, error, errorStatus, podsUnavailable } = + useClusterUsageData() - const { - data: podsList, - isLoading, - error, - } = useK8sList({ apiGroup: "", apiVersion: "v1", plural: "pods" }) + const rows = [...perNode] + .map((n) => ({ + name: n.name, + ready: n.ready, + totals: (isStandard + ? n.standard[resource as StandardResourceKey] + : n.extended[resource]) as ResourceTotals | undefined, + })) + .filter((r) => r.totals && r.totals.allocatable > 0) + .sort((a, b) => a.name.localeCompare(b.name)) - // Map application kind → plural so a consumer row can deep-link to the - // deployed application's Console page (/console//). - const { data: appDefs } = useApplicationDefinitions() - const { selectTenant } = useTenantContext() - const kindToPlural = useMemo(() => { - const map = new Map() - for (const ad of appDefs?.items ?? []) { - const kind = ad.spec?.application.kind - const plural = ad.spec?.application.plural - if (kind && plural) map.set(kind, plural) - } - return map - }, [appDefs]) + const totalsSum = rows.reduce( + (acc, r) => { + acc.capacity += r.totals?.capacity ?? 0 + acc.allocatable += r.totals?.allocatable ?? 0 + acc.requested += r.totals?.requested ?? 0 + acc.usedDefined = acc.usedDefined || r.totals?.used !== undefined + acc.used += r.totals?.used ?? 0 + return acc + }, + { capacity: 0, allocatable: 0, requested: 0, used: 0, usedDefined: false }, + ) - const { rows, totalRequested, totalPods } = useMemo(() => { - const byKey = new Map() - let totalRequested = 0 - let totalPods = 0 - for (const pod of podsList?.items ?? []) { - const requested = podResourceRequest(pod, resource) - if (requested <= 0) continue - const namespace = pod.metadata.namespace ?? "—" - const { kind, name } = podOwner(pod) - const key = `${namespace}/${kind}/${name}` - const existing = byKey.get(key) - if (existing) { - existing.pods += 1 - existing.requested += requested - } else { - byKey.set(key, { namespace, kind, name, pods: 1, requested }) - } - totalRequested += requested - totalPods += 1 - } - const rows = [...byKey.values()].sort((a, b) => b.requested - a.requested) - return { rows, totalRequested, totalPods } - }, [podsList, resource]) + const cell = (value: number | undefined) => + value === undefined ? "—" : formatValue(value, format) return (

- Cluster Usage + Resources

{resource}

- Consumers of this resource across all tenants, grouped by namespace - and owning application (derived from pod labels). + Per-node capacity, allocation and usage of this resource across the + cluster.

@@ -139,15 +98,28 @@ export function ClusterUsageResourcePage() {
) : error ? (
-
- Failed to load pods: {error.message} -
+ {errorStatus === 403 ? ( +
+ You do not have permission to view cluster nodes.{" "} + + Back to console + + . +
+ ) : ( +
+ Failed to load cluster nodes: {error.message} +
+ )} +
+ ) : nodes.length === 0 ? ( +
+

No nodes found.

) : rows.length === 0 ? (

- No workloads are requesting{" "} - {resource}. + No node exposes {resource}.

) : ( @@ -155,57 +127,51 @@ export function ClusterUsageResourcePage() { - - - - + + + + - {rows.map((r) => { - // Deep-link the consumer to its deployed application in the - // Console, but only when it is a real app instance: the kind - // must resolve to a plural and it must live in a tenant - // namespace (so we can switch the Console's tenant context). - const plural = kindToPlural.get(r.kind) - const tenant = r.namespace.startsWith(TENANT_NAMESPACE_PREFIX) - ? r.namespace.slice(TENANT_NAMESPACE_PREFIX.length) - : null - const appHref = plural && tenant ? `/console/${plural}/${r.name}` : null - return ( - - - - - - - - ) - })} + {rows.map((r) => ( + + + + + + + + ))} - + + + - diff --git a/apps/console/src/routes/sidebar-sections.test.tsx b/apps/console/src/routes/sidebar-sections.test.tsx index e747e3a..0edf87a 100644 --- a/apps/console/src/routes/sidebar-sections.test.tsx +++ b/apps/console/src/routes/sidebar-sections.test.tsx @@ -81,7 +81,7 @@ describe("useConsoleSidebarSections — admin areas moved out", () => { // Per-tenant backups stay in Console. expect(findItem(result.current, "Plans")?.to).toBe("/console/backups/plans") // Cluster-wide admin areas are gone from Console. - expect(findItem(result.current, "Cluster Usage")).toBeUndefined() + expect(findItem(result.current, "Resources")).toBeUndefined() expect(hasItemTo(result.current, "/console/backups/backupclasses")).toBe(false) }) }) @@ -93,9 +93,9 @@ describe("useAdminSidebarSections", () => { wrapper: makeWrapper(client), }) await waitFor(() => - expect(findItem(result.current, "Cluster Usage")).toBeDefined(), + expect(findItem(result.current, "Resources")).toBeDefined(), ) - expect(findItem(result.current, "Cluster Usage")?.to).toBe("/admin/cluster-usage") + expect(findItem(result.current, "Resources")?.to).toBe("/admin/resources-usage") expect(findItem(result.current, "Backup Classes")?.to).toBe( "/admin/backups/backupclasses", ) @@ -109,7 +109,7 @@ describe("useAdminSidebarSections", () => { await waitFor(() => expect(findItem(result.current, "Backup Classes")).toBeDefined(), ) - expect(findItem(result.current, "Cluster Usage")).toBeUndefined() + expect(findItem(result.current, "Resources")).toBeUndefined() }) it("shows only Cluster Usage when the user cannot manage backup classes", async () => { @@ -118,7 +118,7 @@ describe("useAdminSidebarSections", () => { wrapper: makeWrapper(client), }) await waitFor(() => - expect(findItem(result.current, "Cluster Usage")).toBeDefined(), + expect(findItem(result.current, "Resources")).toBeDefined(), ) expect(findItem(result.current, "Backup Classes")).toBeUndefined() }) diff --git a/apps/console/src/routes/sidebar-sections.tsx b/apps/console/src/routes/sidebar-sections.tsx index dce9639..fd9446e 100644 --- a/apps/console/src/routes/sidebar-sections.tsx +++ b/apps/console/src/routes/sidebar-sections.tsx @@ -172,8 +172,8 @@ export function useAdminSidebarSections(): SidebarSection[] { sections.push({ title: "Resources", items: [ - { label: "Cluster Usage", to: "/admin/cluster-usage", icon: Gauge }, - { label: "Nodes", to: "/admin/nodes", icon: Server }, + { label: "Resources", to: "/admin/resources-usage", icon: Gauge }, + { label: "Nodes", to: "/admin/resources-nodes", icon: Server }, ], }) } From 751448e0e497aae6383f66cef191b71958ef5907 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 22:04:12 +0200 Subject: [PATCH 06/19] feat(console): revert resource drill-down to the consumers-by-tenant view Clicking a resource on the Resources page again opens the consumer breakdown (which tenant namespace and owning application uses it, derived from pod labels, with a deep-link to the deployed application) rather than the per-node usage table. Keeps the Resources/resources-usage renames; the back-link points at /admin/resources-usage. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- .../routes/ClusterUsageResourcePage.test.tsx | 191 +++++++++---- .../src/routes/ClusterUsageResourcePage.tsx | 256 ++++++++++-------- 2 files changed, 282 insertions(+), 165 deletions(-) diff --git a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx index fcaf98c..6c7a1ea 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx @@ -1,92 +1,175 @@ -import { describe, it, expect, vi } from "vitest" +import { describe, it, expect, vi, beforeAll } from "vitest" import { screen, within } from "@testing-library/react" import { Route, Routes } from "react-router" -import { - K8sClient, - type K8sList, - type APIGroupList, -} from "@cozystack/k8s-client" +import { K8sClient, type K8sList } from "@cozystack/k8s-client" import { ClusterUsageResourcePage } from "./ClusterUsageResourcePage.tsx" +import { TenantProvider } from "../lib/tenant-context.tsx" import { renderWithK8sProvider } from "../test-utils/render.tsx" -function node(name: string, resources: Record) { +function pod( + namespace: string, + name: string, + labels: Record, + requests: Record[], +) { return { apiVersion: "v1", - kind: "Node", - metadata: { name, creationTimestamp: "2026-05-25T00:00:00Z" }, - spec: {}, - status: { - capacity: resources, - allocatable: resources, - conditions: [{ type: "Ready", status: "True" }], + kind: "Pod", + metadata: { name, namespace, labels }, + spec: { + containers: requests.map((r, i) => ({ + name: `c${i}`, + resources: { requests: r }, + })), }, + status: { phase: "Running" }, + } +} + +function appDef(kind: string, plural: string) { + return { + apiVersion: "cozystack.io/v1alpha1", + kind: "ApplicationDefinition", + metadata: { name: plural }, + spec: { application: { kind, plural, singular: kind.toLowerCase() } }, } } const GPU = "nvidia.com/gpu" -function makeClient(nodes: unknown[]): K8sClient { +function makeClient(pods: unknown[], appDefs: unknown[] = []): K8sClient { const client = new K8sClient() - vi.spyOn(client, "list").mockImplementation(async (g, _v, plural) => { - if (g === "metrics.k8s.io") { - return { apiVersion: "metrics.k8s.io/v1beta1", kind: "NodeMetricsList", metadata: {}, items: [] } as K8sList - } - if (plural === "nodes") { - return { apiVersion: "v1", kind: "NodeList", metadata: {}, items: nodes } as K8sList - } - // pods and anything else - return { apiVersion: "v1", kind: `${plural}List`, metadata: {}, items: [] } as K8sList + vi.spyOn(client, "list").mockImplementation(async (_g, _v, plural) => { + const items = + plural === "applicationdefinitions" + ? appDefs + : plural === "tenantnamespaces" + ? [] + : pods + return { + apiVersion: "v1", + kind: `${plural}List`, + metadata: {}, + items, + } as K8sList }) - vi.spyOn(client, "getApiGroups").mockResolvedValue({ - kind: "APIGroupList", - apiVersion: "v1", - groups: [], - } as APIGroupList) return client } function renderResource(client: K8sClient, resource: string) { return renderWithK8sProvider( - - } /> - , + + + } /> + + , { client, initialRoute: `/r/${resource}` }, ) } -describe("ClusterUsageResourcePage (per-node usage of a resource)", () => { - it("renders one row per node that exposes the resource, with its capacity", async () => { +// TenantProvider reads window.localStorage on mount. +beforeAll(() => { + if (typeof globalThis.localStorage?.getItem !== "function") { + const store = new Map() + vi.stubGlobal("localStorage", { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + }) + } +}) + +describe("ClusterUsageResourcePage", () => { + it("groups consumers of a resource by tenant namespace and owning app, summing requests", async () => { const client = makeClient([ - node("cloud2", { [GPU]: "8" }), - node("srv", { [GPU]: "8" }), - // No GPU → must be excluded. - node("plain", { cpu: "16" }), + pod( + "tenant-foo", + "vm1-abc", + { + "apps.cozystack.io/application.kind": "VMInstance", + "apps.cozystack.io/application.name": "vm1", + }, + [{ [GPU]: "2" }], + ), + pod( + "tenant-foo", + "vm1-def", + { + "apps.cozystack.io/application.kind": "VMInstance", + "apps.cozystack.io/application.name": "vm1", + }, + [{ [GPU]: "1" }], + ), + // No GPU request → must be excluded. + pod("tenant-bar", "web-1", { "app.kubernetes.io/instance": "web" }, [ + { cpu: "500m" }, + ]), ]) - const { container } = renderResource(client, GPU) + renderResource(client, GPU) - const cloud2 = (await screen.findByText("cloud2")).closest("tr") as HTMLElement - // Capacity and Allocatable are both 8 for this node. - expect(within(cloud2).getAllByText("8").length).toBeGreaterThanOrEqual(2) - expect(container.querySelector('[data-node-row="srv"]')).not.toBeNull() - expect(container.querySelector('[data-node-row="plain"]')).toBeNull() + const row = await screen.findByText("vm1") + const tr = row.closest("tr") as HTMLElement + expect(within(tr).getByText("tenant-foo")).toBeInTheDocument() + expect(within(tr).getByText("VMInstance")).toBeInTheDocument() + const cells = tr.querySelectorAll("td") + expect(cells[cells.length - 2].textContent).toBe("2") + expect(cells[cells.length - 1].textContent).toBe("3") + expect(screen.queryByText("tenant-bar")).toBeNull() }) - it("shows an empty state when no node exposes the resource", async () => { - const client = makeClient([node("plain", { cpu: "16" })]) + it("links a consumer to its deployed application page in the Console", async () => { + const client = makeClient( + [ + pod( + "tenant-root", + "demo-vm-launcher", + { + "apps.cozystack.io/application.kind": "VMInstance", + "apps.cozystack.io/application.name": "demo-vm", + }, + [{ [GPU]: "1" }], + ), + ], + [appDef("VMInstance", "vminstances")], + ) renderResource(client, GPU) - expect(await screen.findByText(/no node exposes/i)).toBeInTheDocument() + + const link = await screen.findByRole("link", { name: "demo-vm" }) + expect(link).toHaveAttribute("href", "/console/vminstances/demo-vm") }) - it("renders the resource key as the page heading", async () => { - const client = makeClient([node("cloud2", { [GPU]: "8" })]) + it("does not link a consumer whose kind is not a known application", async () => { + const client = makeClient( + [ + pod("tenant-root", "rogue", { "app.kubernetes.io/instance": "rogue" }, [ + { [GPU]: "1" }, + ]), + ], + [appDef("VMInstance", "vminstances")], + ) renderResource(client, GPU) - expect(await screen.findByRole("heading", { name: GPU })).toBeInTheDocument() + // Owner falls back to the Helm instance label; with no matching app + // definition it must render as plain text, not a link. + await screen.findByText("rogue") + expect(screen.queryByRole("link", { name: "rogue" })).toBeNull() }) - it("links back to the Resources page", async () => { - const client = makeClient([node("cloud2", { [GPU]: "8" })]) + it("shows an empty state when nothing requests the resource", async () => { + const client = makeClient([ + pod("tenant-bar", "web-1", { "app.kubernetes.io/instance": "web" }, [ + { cpu: "500m" }, + ]), + ]) renderResource(client, GPU) - const back = await screen.findByRole("link", { name: /resources/i }) - expect(back).toHaveAttribute("href", "/admin/resources-usage") + expect( + await screen.findByText(/no workloads are requesting/i), + ).toBeInTheDocument() + }) + + it("renders the resource key as the page heading", async () => { + const client = makeClient([]) + renderResource(client, GPU) + expect(await screen.findByRole("heading", { name: GPU })).toBeInTheDocument() }) }) diff --git a/apps/console/src/routes/ClusterUsageResourcePage.tsx b/apps/console/src/routes/ClusterUsageResourcePage.tsx index 3e59b96..cc31c52 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.tsx @@ -1,78 +1,119 @@ +import { useMemo } from "react" import { Link, useParams } from "react-router" import { Section, Spinner } from "@cozystack/ui" +import { useK8sList } from "@cozystack/k8s-client" +import { APPS_GROUP } from "@cozystack/types" import { ChevronLeft } from "lucide-react" -import { humanizeBytes, humanizeCpu } from "../lib/k8s-quantity.ts" -import { useClusterUsageData } from "../hooks/useClusterUsageData.tsx" -import { - STANDARD_RESOURCE_KEY_SET, - type ResourceTotals, - type StandardResourceKey, -} from "../lib/cluster-usage/types.ts" +import { parseQuantity, humanizeBytes, humanizeCpu } from "../lib/k8s-quantity.ts" +import { useApplicationDefinitions } from "../lib/app-definitions.ts" +import { useTenantContext } from "../lib/tenant-context.tsx" +import { TENANT_NAMESPACE_PREFIX } from "../lib/constants.ts" +import type { Pod } from "../lib/cluster-usage/types.ts" /** - * Admin → Resources → per-resource usage. Reached by clicking a resource on - * the Resources page (/admin/resources-usage/r/). Shows the same usage - * view as the Resources table, pivoted to nodes: one row per node with the - * selected resource's Capacity / Allocatable / Requested / Used. + * Admin → Resources → per-resource drill-down. Given a resource key + * (e.g. `cpu`, `memory`, or an extended resource like + * `nvidia.com/GH100_H200_SXM_141GB`) this lists who consumes it across the + * whole cluster, grouped by tenant namespace and the owning application. * - * The resource key arrives via a splat param so keys containing slashes - * (every vendor.com/model GPU name) survive routing without encoding. + * Ownership is read from pod labels — Cozystack stamps + * `apps.cozystack.io/application.{kind,name}` on every workload pod; we + * fall back to the Helm `app.kubernetes.io/instance` label and finally to + * the bare pod name so nothing is silently dropped. + * + * The resource key arrives via a splat param (`cluster-usage/r/*`) so keys + * containing slashes (every `vendor.com/model` GPU name) survive routing + * without encoding. */ -type ResourceFormat = "cpu" | "bytes" | "count" +interface UsageRow { + namespace: string + kind: string + name: string + pods: number + requested: number +} -function formatFor(resource: string): ResourceFormat { - if (resource === "cpu") return "cpu" - if (resource === "memory" || resource === "ephemeral-storage") return "bytes" - return "count" +function formatResource(resource: string, value: number): string { + if (resource === "cpu") return humanizeCpu(value) + if (resource === "memory" || resource === "ephemeral-storage") { + return humanizeBytes(value) + } + return value % 1 === 0 ? `${value}` : value.toFixed(2) } -function formatValue(value: number, format: ResourceFormat): string { - switch (format) { - case "cpu": - return humanizeCpu(value) - case "bytes": - return humanizeBytes(value) - case "count": - default: - return value % 1 === 0 ? `${value}` : value.toFixed(2) +/** Sum a single resource across all of a pod's containers (requests, then limits). */ +function podResourceRequest(pod: Pod, resource: string): number { + let total = 0 + for (const container of pod.spec?.containers ?? []) { + const req = container.resources?.requests?.[resource] + const lim = container.resources?.limits?.[resource] + const value = req ?? lim + if (value !== undefined) total += parseQuantity(value) } + return total +} + +/** Derive the owning application (kind + name) of a pod from its labels. */ +function podOwner(pod: Pod): { kind: string; name: string } { + const labels = pod.metadata.labels ?? {} + const kind = labels[`${APPS_GROUP}/application.kind`] + const name = + labels[`${APPS_GROUP}/application.name`] ?? + labels["app.kubernetes.io/instance"] ?? + labels["app.kubernetes.io/name"] + if (kind && name) return { kind, name } + if (name) return { kind: kind ?? "—", name } + return { kind: kind ?? "—", name: pod.metadata.name } } export function ClusterUsageResourcePage() { const params = useParams() const resource = params["*"] ?? "" - const isStandard = STANDARD_RESOURCE_KEY_SET.has(resource) - const format = formatFor(resource) - - const { perNode, nodes, isLoading, error, errorStatus, podsUnavailable } = - useClusterUsageData() - const rows = [...perNode] - .map((n) => ({ - name: n.name, - ready: n.ready, - totals: (isStandard - ? n.standard[resource as StandardResourceKey] - : n.extended[resource]) as ResourceTotals | undefined, - })) - .filter((r) => r.totals && r.totals.allocatable > 0) - .sort((a, b) => a.name.localeCompare(b.name)) + const { + data: podsList, + isLoading, + error, + } = useK8sList({ apiGroup: "", apiVersion: "v1", plural: "pods" }) - const totalsSum = rows.reduce( - (acc, r) => { - acc.capacity += r.totals?.capacity ?? 0 - acc.allocatable += r.totals?.allocatable ?? 0 - acc.requested += r.totals?.requested ?? 0 - acc.usedDefined = acc.usedDefined || r.totals?.used !== undefined - acc.used += r.totals?.used ?? 0 - return acc - }, - { capacity: 0, allocatable: 0, requested: 0, used: 0, usedDefined: false }, - ) + // Map application kind → plural so a consumer row can deep-link to the + // deployed application's Console page (/console//). + const { data: appDefs } = useApplicationDefinitions() + const { selectTenant } = useTenantContext() + const kindToPlural = useMemo(() => { + const map = new Map() + for (const ad of appDefs?.items ?? []) { + const kind = ad.spec?.application.kind + const plural = ad.spec?.application.plural + if (kind && plural) map.set(kind, plural) + } + return map + }, [appDefs]) - const cell = (value: number | undefined) => - value === undefined ? "—" : formatValue(value, format) + const { rows, totalRequested, totalPods } = useMemo(() => { + const byKey = new Map() + let totalRequested = 0 + let totalPods = 0 + for (const pod of podsList?.items ?? []) { + const requested = podResourceRequest(pod, resource) + if (requested <= 0) continue + const namespace = pod.metadata.namespace ?? "—" + const { kind, name } = podOwner(pod) + const key = `${namespace}/${kind}/${name}` + const existing = byKey.get(key) + if (existing) { + existing.pods += 1 + existing.requested += requested + } else { + byKey.set(key, { namespace, kind, name, pods: 1, requested }) + } + totalRequested += requested + totalPods += 1 + } + const rows = [...byKey.values()].sort((a, b) => b.requested - a.requested) + return { rows, totalRequested, totalPods } + }, [podsList, resource]) return (
@@ -87,8 +128,8 @@ export function ClusterUsageResourcePage() { {resource}

- Per-node capacity, allocation and usage of this resource across the - cluster. + Consumers of this resource across all tenants, grouped by namespace + and owning application (derived from pod labels).

@@ -98,28 +139,15 @@ export function ClusterUsageResourcePage() { ) : error ? (
- {errorStatus === 403 ? ( -
- You do not have permission to view cluster nodes.{" "} - - Back to console - - . -
- ) : ( -
- Failed to load cluster nodes: {error.message} -
- )} -
- ) : nodes.length === 0 ? ( -
-

No nodes found.

+
+ Failed to load pods: {error.message} +
) : rows.length === 0 ? (

- No node exposes {resource}. + No workloads are requesting{" "} + {resource}.

) : ( @@ -127,51 +155,57 @@ export function ClusterUsageResourcePage() {
Tenant (namespace)KindNamePodsNodeCapacityAllocatable RequestedUsed
{r.namespace}{r.kind} - {appHref ? ( - tenant && selectTenant(tenant)} - className="text-blue-700 hover:text-blue-800 hover:underline" - > - {r.name} - - ) : ( - {r.name} - )} - {r.pods} - {formatResource(resource, r.requested)} -
{r.name} + {cell(r.totals?.capacity)} + + {cell(r.totals?.allocatable)} + + {podsUnavailable ? "—" : cell(r.totals?.requested)} + + {r.totals?.used !== undefined ? cell(r.totals.used) : "—"} +
- Total · {rows.length} consumer{rows.length === 1 ? "" : "s"} + + Total · {rows.length} node{rows.length === 1 ? "" : "s"} + + {formatValue(totalsSum.capacity, format)} + + {formatValue(totalsSum.allocatable, format)} + + {podsUnavailable ? "—" : formatValue(totalsSum.requested, format)} {totalPods} - {formatResource(resource, totalRequested)} + {totalsSum.usedDefined ? formatValue(totalsSum.used, format) : "—"}
- - - + + + + - - {rows.map((r) => ( - - - - - - - - ))} + {rows.map((r) => { + // Deep-link the consumer to its deployed application in the + // Console, but only when it is a real app instance: the kind + // must resolve to a plural and it must live in a tenant + // namespace (so we can switch the Console's tenant context). + const plural = kindToPlural.get(r.kind) + const tenant = r.namespace.startsWith(TENANT_NAMESPACE_PREFIX) + ? r.namespace.slice(TENANT_NAMESPACE_PREFIX.length) + : null + const appHref = plural && tenant ? `/console/${plural}/${r.name}` : null + return ( + + + + + + + + ) + })} - - - - + From a814fa2cccc32b5d2835811c15c70a830b86faa3 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 22:21:40 +0200 Subject: [PATCH 07/19] feat(console): Capacity section, node resource links, tenant-only consumers, usage gauges - Rename the admin section to Capacity with Cluster (/admin/capacity/cluster) and Nodes (/admin/capacity/nodes) entries; drill-down at /admin/capacity/cluster/r/*. - Make resource row labels in the transposed Nodes table link to the same per-resource consumer drill-down as the Cluster resources table. - Restrict the resource consumer drill-down to tenant namespaces (skip system/control-plane namespaces). - Add cluster-wide allocation gauges (Requested vs Allocatable rings) atop the Cluster page, reusing the per-tenant quota GaugeCard for a consistent look. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- apps/console/src/components/QuotaDisplay.tsx | 4 +- .../ClusterUsageAggregates.test.tsx | 4 +- .../cluster-usage/ClusterUsageAggregates.tsx | 5 +- .../cluster-usage/ClusterUsageGauges.tsx | 71 +++++++++++++++++++ .../cluster-usage/ClusterUsageTable.test.tsx | 33 ++++++++- .../cluster-usage/ClusterUsageTable.tsx | 27 +++++-- .../src/routes/AdminPage.routing.test.tsx | 8 +-- apps/console/src/routes/AdminPage.tsx | 8 +-- .../src/routes/ClusterUsagePage.test.tsx | 2 +- apps/console/src/routes/ClusterUsagePage.tsx | 2 +- .../src/routes/ClusterUsageResourcePage.tsx | 7 +- .../src/routes/sidebar-sections.test.tsx | 10 +-- apps/console/src/routes/sidebar-sections.tsx | 6 +- 13 files changed, 157 insertions(+), 30 deletions(-) create mode 100644 apps/console/src/components/cluster-usage/ClusterUsageGauges.tsx diff --git a/apps/console/src/components/QuotaDisplay.tsx b/apps/console/src/components/QuotaDisplay.tsx index 4ac90df..aa0613d 100644 --- a/apps/console/src/components/QuotaDisplay.tsx +++ b/apps/console/src/components/QuotaDisplay.tsx @@ -16,7 +16,7 @@ export interface ResourceQuota extends K8sResource entry.hardNum diff --git a/apps/console/src/components/cluster-usage/ClusterUsageAggregates.test.tsx b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.test.tsx index e27fe24..ccaeb92 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageAggregates.test.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.test.tsx @@ -78,11 +78,11 @@ describe("ClusterUsageAggregates", () => { renderAgg({ aggregates: agg, nodeSummary: summary() }) expect(screen.getByRole("link", { name: "CPU" })).toHaveAttribute( "href", - "/admin/resources-usage/r/cpu", + "/admin/capacity/cluster/r/cpu", ) expect(screen.getByRole("link", { name: "nvidia.com/gpu" })).toHaveAttribute( "href", - "/admin/resources-usage/r/nvidia.com/gpu", + "/admin/capacity/cluster/r/nvidia.com/gpu", ) }) diff --git a/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx index a670183..10cce5d 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx @@ -6,6 +6,7 @@ import type { StandardResourceKey, } from "../../lib/cluster-usage/types.ts" import type { NodeSummary } from "../../hooks/useClusterUsageData.tsx" +import { ClusterUsageGauges } from "./ClusterUsageGauges.tsx" interface ClusterUsageAggregatesProps { aggregates: AggregateResources @@ -103,6 +104,8 @@ export function ClusterUsageAggregates({ + +
NodeCapacityAllocatableTenant (namespace)KindNamePods RequestedUsed
{r.name} - {cell(r.totals?.capacity)} - - {cell(r.totals?.allocatable)} - - {podsUnavailable ? "—" : cell(r.totals?.requested)} - - {r.totals?.used !== undefined ? cell(r.totals.used) : "—"} -
{r.namespace}{r.kind} + {appHref ? ( + tenant && selectTenant(tenant)} + className="text-blue-700 hover:text-blue-800 hover:underline" + > + {r.name} + + ) : ( + {r.name} + )} + {r.pods} + {formatResource(resource, r.requested)} +
- Total · {rows.length} node{rows.length === 1 ? "" : "s"} - - {formatValue(totalsSum.capacity, format)} - - {formatValue(totalsSum.allocatable, format)} - - {podsUnavailable ? "—" : formatValue(totalsSum.requested, format)} + + Total · {rows.length} consumer{rows.length === 1 ? "" : "s"} {totalPods} - {totalsSum.usedDefined ? formatValue(totalsSum.used, format) : "—"} + {formatResource(resource, totalRequested)}
@@ -128,7 +131,7 @@ export function ClusterUsageAggregates({ ))} From 00cf06d00c215d3ae19b87162bb980f57da8c37b Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 22:34:11 +0200 Subject: [PATCH 09/19] feat(console): present resource consumers as Workloads Collapse the Kind / Name / Pods columns in the per-resource drill-down into a single Workload column (owning application name with its kind as a subtitle, deep-linked to the Console app page), dropping the raw pod count. Consumers are grouped by the owning application (apps.cozystack.io/application labels), which the lineage controller also stamps on the workload's PVCs and Services. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- .../routes/ClusterUsageResourcePage.test.tsx | 3 ++- .../src/routes/ClusterUsageResourcePage.tsx | 26 ++++++++----------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx index 6c7a1ea..101580e 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx @@ -111,9 +111,10 @@ describe("ClusterUsageResourcePage", () => { const row = await screen.findByText("vm1") const tr = row.closest("tr") as HTMLElement expect(within(tr).getByText("tenant-foo")).toBeInTheDocument() + // Kind is shown as a subtitle within the Workload cell. expect(within(tr).getByText("VMInstance")).toBeInTheDocument() const cells = tr.querySelectorAll("td") - expect(cells[cells.length - 2].textContent).toBe("2") + // Columns: Tenant | Workload | Requested — last cell is the summed request. expect(cells[cells.length - 1].textContent).toBe("3") expect(screen.queryByText("tenant-bar")).toBeNull() }) diff --git a/apps/console/src/routes/ClusterUsageResourcePage.tsx b/apps/console/src/routes/ClusterUsageResourcePage.tsx index aa1152c..868e7c5 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.tsx @@ -91,10 +91,9 @@ export function ClusterUsageResourcePage() { return map }, [appDefs]) - const { rows, totalRequested, totalPods } = useMemo(() => { + const { rows, totalRequested } = useMemo(() => { const byKey = new Map() let totalRequested = 0 - let totalPods = 0 for (const pod of podsList?.items ?? []) { const requested = podResourceRequest(pod, resource) if (requested <= 0) continue @@ -112,10 +111,9 @@ export function ClusterUsageResourcePage() { byKey.set(key, { namespace, kind, name, pods: 1, requested }) } totalRequested += requested - totalPods += 1 } const rows = [...byKey.values()].sort((a, b) => b.requested - a.requested) - return { rows, totalRequested, totalPods } + return { rows, totalRequested } }, [podsList, resource]) return ( @@ -159,15 +157,13 @@ export function ClusterUsageResourcePage() { - - - + {rows.map((r) => { - // Deep-link the consumer to its deployed application in the + // Deep-link the workload to its deployed application in the // Console, but only when it is a real app instance: the kind // must resolve to a plural and it must live in a tenant // namespace (so we can switch the Console's tenant context). @@ -179,21 +175,22 @@ export function ClusterUsageResourcePage() { return ( - - @@ -203,10 +200,9 @@ export function ClusterUsageResourcePage() { - - From baa1fa892a0199e15d7e4fd31f142bfaf4c7583a Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 22:45:01 +0200 Subject: [PATCH 10/19] feat(console): Persistent Storage panel + per-StorageClass drill-down - Add a Persistent Storage panel to the Cluster page: tenant-namespace PVCs aggregated by StorageClass (claim count, requested and bound totals). Each StorageClass links to a per-class drill-down listing the consuming workloads (owning application) and their summed requested storage. - Extract a shared WorkloadCell component and workloadOwner helper so both the node-resource and storage drill-downs render the workload deep-link identically; refactor the resource drill-down to use them. - Give the transposed Nodes table a per-node min-width so it scrolls horizontally instead of squishing when there are many nodes. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- apps/console/src/components/WorkloadCell.tsx | 55 ++++++++ .../ClusterStorageSection.test.tsx | 66 +++++++++ .../cluster-usage/ClusterStorageSection.tsx | 97 ++++++++++++++ .../cluster-usage/ClusterUsageTable.tsx | 2 +- apps/console/src/lib/cluster-usage/types.ts | 12 ++ apps/console/src/lib/workload.ts | 23 ++++ apps/console/src/routes/AdminPage.tsx | 2 + apps/console/src/routes/ClusterUsagePage.tsx | 14 +- .../src/routes/ClusterUsageResourcePage.tsx | 58 +------- .../src/routes/StorageClassUsagePage.test.tsx | 124 +++++++++++++++++ .../src/routes/StorageClassUsagePage.tsx | 126 ++++++++++++++++++ 11 files changed, 519 insertions(+), 60 deletions(-) create mode 100644 apps/console/src/components/WorkloadCell.tsx create mode 100644 apps/console/src/components/cluster-usage/ClusterStorageSection.test.tsx create mode 100644 apps/console/src/components/cluster-usage/ClusterStorageSection.tsx create mode 100644 apps/console/src/lib/workload.ts create mode 100644 apps/console/src/routes/StorageClassUsagePage.test.tsx create mode 100644 apps/console/src/routes/StorageClassUsagePage.tsx diff --git a/apps/console/src/components/WorkloadCell.tsx b/apps/console/src/components/WorkloadCell.tsx new file mode 100644 index 0000000..fa40a90 --- /dev/null +++ b/apps/console/src/components/WorkloadCell.tsx @@ -0,0 +1,55 @@ +import { useMemo } from "react" +import { Link } from "react-router" +import { useApplicationDefinitions } from "../lib/app-definitions.ts" +import { useTenantContext } from "../lib/tenant-context.tsx" +import { TENANT_NAMESPACE_PREFIX } from "../lib/constants.ts" + +interface WorkloadCellProps { + /** Namespace the workload lives in (tenant- for tenant workloads). */ + namespace: string + /** Application kind (apps.cozystack.io/application.kind), or "—" when unknown. */ + kind: string + /** Application instance name. */ + name: string +} + +/** + * Renders a consuming workload (the owning application) as a deep-link to its + * Console page, with the kind shown as a subtitle. The link is only active for + * real app instances: the kind must resolve to a plural via ApplicationDefinitions + * and the workload must live in a tenant namespace (so the Console tenant + * context can be switched on click). Shared by every per-resource drill-down. + */ +export function WorkloadCell({ namespace, kind, name }: WorkloadCellProps) { + const { data: appDefs } = useApplicationDefinitions() + const { selectTenant } = useTenantContext() + + const plural = useMemo(() => { + for (const ad of appDefs?.items ?? []) { + if (ad.spec?.application.kind === kind) return ad.spec?.application.plural + } + return undefined + }, [appDefs, kind]) + + const tenant = namespace.startsWith(TENANT_NAMESPACE_PREFIX) + ? namespace.slice(TENANT_NAMESPACE_PREFIX.length) + : null + const href = plural && tenant ? `/console/${plural}/${name}` : null + + return ( + <> + {href ? ( + tenant && selectTenant(tenant)} + className="font-medium text-blue-700 hover:text-blue-800 hover:underline" + > + {name} + + ) : ( + {name} + )} + {kind !== "—" ?
{kind}
: null} + + ) +} diff --git a/apps/console/src/components/cluster-usage/ClusterStorageSection.test.tsx b/apps/console/src/components/cluster-usage/ClusterStorageSection.test.tsx new file mode 100644 index 0000000..6f1eb8b --- /dev/null +++ b/apps/console/src/components/cluster-usage/ClusterStorageSection.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from "vitest" +import { screen, within, waitFor } from "@testing-library/react" +import { K8sClient, type K8sList } from "@cozystack/k8s-client" +import { ClusterStorageSection } from "./ClusterStorageSection.tsx" +import { renderWithK8sProvider } from "../../test-utils/render.tsx" + +let pvcSeq = 0 +function pvc(namespace: string, storageClassName: string, requested: string, capacity?: string) { + return { + apiVersion: "v1", + kind: "PersistentVolumeClaim", + metadata: { name: `pvc-${pvcSeq++}`, namespace }, + spec: { storageClassName, resources: { requests: { storage: requested } } }, + status: { phase: "Bound", capacity: capacity ? { storage: capacity } : undefined }, + } +} + +function makeClient(pvcs: unknown[]): K8sClient { + const client = new K8sClient() + vi.spyOn(client, "list").mockImplementation(async (_g, _v, plural) => { + return { + apiVersion: "v1", + kind: `${plural}List`, + metadata: {}, + items: plural === "persistentvolumeclaims" ? pvcs : [], + } as K8sList + }) + return client +} + +describe("ClusterStorageSection", () => { + it("aggregates tenant PVCs by storage class and excludes non-tenant namespaces", async () => { + const client = makeClient([ + pvc("tenant-foo", "replicated", "5Gi"), + pvc("tenant-bar", "replicated", "10Gi"), + // System namespace must be excluded. + pvc("cozy-system", "replicated", "100Gi"), + ]) + const { container } = renderWithK8sProvider(, { client }) + const row = await waitForRow(container, "replicated") + // Two tenant PVCs (the cozy-system one is excluded). + expect(within(row).getByText("2")).toBeInTheDocument() + }) + + it("links a storage class to its per-class drill-down", async () => { + const client = makeClient([pvc("tenant-foo", "replicated", "5Gi")]) + renderWithK8sProvider(, { client }) + const link = await screen.findByRole("link", { name: "replicated" }) + expect(link).toHaveAttribute("href", "/admin/capacity/cluster/sc/replicated") + }) + + it("renders nothing when no tenant PVCs exist", async () => { + const client = makeClient([pvc("cozy-system", "replicated", "100Gi")]) + const { container } = renderWithK8sProvider(, { client }) + // Give the query a tick to settle, then assert the panel is absent. + await Promise.resolve() + expect(within(container).queryByText("Persistent Storage")).toBeNull() + }) +}) + +async function waitForRow(container: HTMLElement, sc: string): Promise { + await waitFor(() => + expect(container.querySelector(`[data-storageclass-row="${sc}"]`)).not.toBeNull(), + ) + return container.querySelector(`[data-storageclass-row="${sc}"]`) as HTMLElement +} diff --git a/apps/console/src/components/cluster-usage/ClusterStorageSection.tsx b/apps/console/src/components/cluster-usage/ClusterStorageSection.tsx new file mode 100644 index 0000000..d8fd309 --- /dev/null +++ b/apps/console/src/components/cluster-usage/ClusterStorageSection.tsx @@ -0,0 +1,97 @@ +import { useMemo } from "react" +import { Link } from "react-router" +import { Section } from "@cozystack/ui" +import { useK8sList } from "@cozystack/k8s-client" +import { parseQuantity, humanizeBytes } from "../../lib/k8s-quantity.ts" +import { TENANT_NAMESPACE_PREFIX } from "../../lib/constants.ts" +import type { Pvc } from "../../lib/cluster-usage/types.ts" + +interface StorageClassRow { + storageClass: string + pvcs: number + requested: number + bound: number +} + +const NO_CLASS = "(no class)" + +/** + * Persistent Storage panel on the Cluster page: PersistentVolumeClaims across + * tenant namespaces aggregated by StorageClass (claim count, total requested, + * total bound capacity). Unlike the node-allocatable resources above there is + * no fixed cap — dynamic provisioners have no cluster-wide allocatable — so + * this is a usage tally rather than a gauge. Each StorageClass links to a + * per-class drill-down of the consuming workloads. + */ +export function ClusterStorageSection() { + const { data, isLoading } = useK8sList({ + apiGroup: "", + apiVersion: "v1", + plural: "persistentvolumeclaims", + }) + + const rows = useMemo(() => { + const byClass = new Map() + for (const pvc of data?.items ?? []) { + const ns = pvc.metadata.namespace ?? "" + if (!ns.startsWith(TENANT_NAMESPACE_PREFIX)) continue + const sc = pvc.spec?.storageClassName || NO_CLASS + const requested = parseQuantity(pvc.spec?.resources?.requests?.storage ?? "0") + const bound = parseQuantity(pvc.status?.capacity?.storage ?? "0") + const existing = byClass.get(sc) + if (existing) { + existing.pvcs += 1 + existing.requested += requested + existing.bound += bound + } else { + byClass.set(sc, { storageClass: sc, pvcs: 1, requested, bound }) + } + } + return [...byClass.values()].sort((a, b) => a.storageClass.localeCompare(b.storageClass)) + }, [data]) + + if (isLoading || rows.length === 0) return null + + return ( +
+

Persistent Storage

+
+
{row.linkKey ? ( {row.label} diff --git a/apps/console/src/components/cluster-usage/ClusterUsageGauges.tsx b/apps/console/src/components/cluster-usage/ClusterUsageGauges.tsx new file mode 100644 index 0000000..1bf0cd1 --- /dev/null +++ b/apps/console/src/components/cluster-usage/ClusterUsageGauges.tsx @@ -0,0 +1,71 @@ +import { GaugeCard, type QuotaEntry } from "../QuotaDisplay.tsx" +import { humanizeBytes, humanizeCpu } from "../../lib/k8s-quantity.ts" +import type { + AggregateResources, + ResourceTotals, + StandardResourceKey, +} from "../../lib/cluster-usage/types.ts" + +interface ClusterUsageGaugesProps { + aggregates: AggregateResources + /** When true, Requested is unknown, so the request-vs-allocatable gauges are hidden. */ + podsUnavailable?: boolean +} + +const STANDARD: { key: StandardResourceKey; label: string; format: (n: number) => string }[] = [ + { key: "cpu", label: "CPU", format: humanizeCpu }, + { key: "memory", label: "Memory", format: humanizeBytes }, + { key: "ephemeral-storage", label: "Storage", format: humanizeBytes }, + { key: "pods", label: "Pods", format: (n) => String(n) }, +] + +/** Build a quota-style gauge entry from cluster totals (requested vs allocatable). */ +function entryFrom( + label: string, + totals: ResourceTotals | undefined, + format: (n: number) => string, +): QuotaEntry | null { + if (!totals || totals.allocatable <= 0) return null + const usedNum = totals.requested + const hardNum = totals.allocatable + const pctReal = (usedNum / hardNum) * 100 + return { + label, + usedRaw: String(usedNum), + hardRaw: String(hardNum), + usedNum, + hardNum, + pct: Math.min(100, pctReal), + pctReal, + display: `${format(usedNum)} / ${format(hardNum)}`, + } +} + +/** + * Cluster-wide allocation gauges: one ring per resource showing Requested vs + * Allocatable, reusing the quota GaugeCard so it matches the per-tenant quota + * rings. Hidden entirely when the cluster-wide pods list is unavailable + * (Requested would be unknown and every ring would read 0%). + */ +export function ClusterUsageGauges({ + aggregates, + podsUnavailable = false, +}: ClusterUsageGaugesProps) { + if (podsUnavailable) return null + + const extendedKeys = Object.keys(aggregates.extended).sort() + const entries: QuotaEntry[] = [ + ...STANDARD.map((s) => entryFrom(s.label, aggregates.standard[s.key], s.format)), + ...extendedKeys.map((k) => entryFrom(k, aggregates.extended[k], (n) => String(n))), + ].filter((e): e is QuotaEntry => e !== null) + + if (entries.length === 0) return null + + return ( +
+ {entries.map((entry, i) => ( + + ))} +
+ ) +} diff --git a/apps/console/src/components/cluster-usage/ClusterUsageTable.test.tsx b/apps/console/src/components/cluster-usage/ClusterUsageTable.test.tsx index 9411ce0..aea0032 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageTable.test.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageTable.test.tsx @@ -1,9 +1,16 @@ import { describe, it, expect } from "vitest" -import { render, screen, within } from "@testing-library/react" +import { render as rtlRender, screen, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" +import { MemoryRouter } from "react-router" import { ClusterUsageTable } from "./ClusterUsageTable.tsx" import type { NodeRow } from "../../lib/cluster-usage/types.ts" +// The table now renders s for resource row labels, so every render +// needs a router context. +function render(ui: Parameters[0]) { + return rtlRender({ui}) +} + function row(name: string, overrides: Partial = {}): NodeRow { return { name, @@ -61,6 +68,30 @@ describe("ClusterUsageTable (transposed: nodes are columns)", () => { ]) }) + it("links resource row labels (CPU, Memory, extended) to the per-resource drill-down", () => { + render( + , + ) + expect(screen.getByRole("link", { name: "CPU" })).toHaveAttribute( + "href", + "/admin/capacity/cluster/r/cpu", + ) + expect(screen.getByRole("link", { name: "Memory" })).toHaveAttribute( + "href", + "/admin/capacity/cluster/r/memory", + ) + expect(screen.getByRole("link", { name: "nvidia.com/gpu" })).toHaveAttribute( + "href", + "/admin/capacity/cluster/r/nvidia.com/gpu", + ) + // Non-resource attribute rows are not links. + expect(screen.queryByRole("link", { name: "Status" })).toBeNull() + expect(screen.queryByRole("link", { name: "Age" })).toBeNull() + }) + it("shows Ready / NotReady / SchedulingDisabled in the Status row", () => { const { container } = render( ReactNode }[] = [ + // `linkKey` marks a row as a requestable resource: its label deep-links to + // the per-resource consumer drill-down. Status / Roles / Age have none. + const attributeRows: { + key: string + label: string + mono?: boolean + linkKey?: string + render: (r: NodeRow) => ReactNode + }[] = [ { key: "status", label: "Status", render: (r) => statusContent(r) }, { key: "roles", label: "Roles", render: (r) => rolesContent(r) }, - { key: "cpu", label: "CPU", render: (r) => cpuCell(r.standard.cpu, r.ready, podsUnavailable) }, - { key: "memory", label: "Memory", render: (r) => memoryCell(r.standard.memory, r.ready, podsUnavailable) }, + { key: "cpu", label: "CPU", linkKey: "cpu", render: (r) => cpuCell(r.standard.cpu, r.ready, podsUnavailable) }, + { key: "memory", label: "Memory", linkKey: "memory", render: (r) => memoryCell(r.standard.memory, r.ready, podsUnavailable) }, ...extendedKeys.map((k) => ({ key: k, label: k, mono: true, + linkKey: k, render: (r: NodeRow) => extendedCell(r.extended[k], r.ready, podsUnavailable), })), { @@ -221,7 +231,16 @@ export function ClusterUsageTable({ scope="row" className={`${labelCell} text-left align-top ${attr.mono ? "font-mono" : ""}`} > - {attr.label} + {attr.linkKey ? ( + + {attr.label} + + ) : ( + attr.label + )} {visibleNodes.map((n) => (
diff --git a/apps/console/src/routes/AdminPage.routing.test.tsx b/apps/console/src/routes/AdminPage.routing.test.tsx index 799c074..70f3c87 100644 --- a/apps/console/src/routes/AdminPage.routing.test.tsx +++ b/apps/console/src/routes/AdminPage.routing.test.tsx @@ -44,9 +44,9 @@ describe("AdminPage routing & access gate", () => { it("renders the Cluster Usage page at /cluster-usage for an operator", async () => { renderWithK8sProvider(, { client: makeClient({ nodes: true }), - initialRoute: "/resources-usage", + initialRoute: "/capacity/cluster", }) - expect(await screen.findByText("Resources")).toBeInTheDocument() + expect(await screen.findByText("Cluster")).toBeInTheDocument() }) it("redirects the index route to Cluster Usage for an operator", async () => { @@ -54,13 +54,13 @@ describe("AdminPage routing & access gate", () => { client: makeClient({ nodes: true }), initialRoute: "/", }) - expect(await screen.findByText("Resources")).toBeInTheDocument() + expect(await screen.findByText("Cluster")).toBeInTheDocument() }) it("blocks direct access with a 403 notice when the user has neither admin area", async () => { renderWithK8sProvider(, { client: makeClient({ nodes: false, backupclasses: false }), - initialRoute: "/resources-usage", + initialRoute: "/capacity/cluster", }) expect( await screen.findByText(/you do not have permission to access the admin portal/i), diff --git a/apps/console/src/routes/AdminPage.tsx b/apps/console/src/routes/AdminPage.tsx index 9aa00cc..43a1646 100644 --- a/apps/console/src/routes/AdminPage.tsx +++ b/apps/console/src/routes/AdminPage.tsx @@ -53,14 +53,14 @@ export function AdminPage() { index element={ } /> - } /> - } /> - } /> + } /> + } /> + } /> }> } /> } /> diff --git a/apps/console/src/routes/ClusterUsagePage.test.tsx b/apps/console/src/routes/ClusterUsagePage.test.tsx index 3c5889a..4a2da18 100644 --- a/apps/console/src/routes/ClusterUsagePage.test.tsx +++ b/apps/console/src/routes/ClusterUsagePage.test.tsx @@ -91,7 +91,7 @@ describe("ClusterUsagePage", () => { groups: groupsWithMetrics, }) const { container } = renderWithK8sProvider(, { client }) - expect(await screen.findByText("Resources")).toBeInTheDocument() + expect(await screen.findByText("Cluster")).toBeInTheDocument() expect(await screen.findAllByText(/allocatable/i)).not.toHaveLength(0) // The per-node table moved to its own Nodes page; this page now shows // only the cluster-wide resources table. diff --git a/apps/console/src/routes/ClusterUsagePage.tsx b/apps/console/src/routes/ClusterUsagePage.tsx index 716fb6c..872bf65 100644 --- a/apps/console/src/routes/ClusterUsagePage.tsx +++ b/apps/console/src/routes/ClusterUsagePage.tsx @@ -30,7 +30,7 @@ export function ClusterUsagePage() { return (
-

Resources

+

Cluster

Cluster-scoped capacity, allocation and usage across all nodes, including any discovered extended resources. diff --git a/apps/console/src/routes/ClusterUsageResourcePage.tsx b/apps/console/src/routes/ClusterUsageResourcePage.tsx index cc31c52..aa1152c 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.tsx @@ -99,6 +99,9 @@ export function ClusterUsageResourcePage() { const requested = podResourceRequest(pod, resource) if (requested <= 0) continue const namespace = pod.metadata.namespace ?? "—" + // Only tenant namespaces are relevant here — skip system/control-plane + // namespaces (cozy-*, kube-system, …) that also consume the resource. + if (!namespace.startsWith(TENANT_NAMESPACE_PREFIX)) continue const { kind, name } = podOwner(pod) const key = `${namespace}/${kind}/${name}` const existing = byKey.get(key) @@ -119,10 +122,10 @@ export function ClusterUsageResourcePage() {

- Resources + Cluster

{resource} diff --git a/apps/console/src/routes/sidebar-sections.test.tsx b/apps/console/src/routes/sidebar-sections.test.tsx index 0edf87a..f3ecca3 100644 --- a/apps/console/src/routes/sidebar-sections.test.tsx +++ b/apps/console/src/routes/sidebar-sections.test.tsx @@ -81,7 +81,7 @@ describe("useConsoleSidebarSections — admin areas moved out", () => { // Per-tenant backups stay in Console. expect(findItem(result.current, "Plans")?.to).toBe("/console/backups/plans") // Cluster-wide admin areas are gone from Console. - expect(findItem(result.current, "Resources")).toBeUndefined() + expect(findItem(result.current, "Cluster")).toBeUndefined() expect(hasItemTo(result.current, "/console/backups/backupclasses")).toBe(false) }) }) @@ -93,9 +93,9 @@ describe("useAdminSidebarSections", () => { wrapper: makeWrapper(client), }) await waitFor(() => - expect(findItem(result.current, "Resources")).toBeDefined(), + expect(findItem(result.current, "Cluster")).toBeDefined(), ) - expect(findItem(result.current, "Resources")?.to).toBe("/admin/resources-usage") + expect(findItem(result.current, "Cluster")?.to).toBe("/admin/capacity/cluster") expect(findItem(result.current, "Backup Classes")?.to).toBe( "/admin/backups/backupclasses", ) @@ -109,7 +109,7 @@ describe("useAdminSidebarSections", () => { await waitFor(() => expect(findItem(result.current, "Backup Classes")).toBeDefined(), ) - expect(findItem(result.current, "Resources")).toBeUndefined() + expect(findItem(result.current, "Cluster")).toBeUndefined() }) it("shows only Cluster Usage when the user cannot manage backup classes", async () => { @@ -118,7 +118,7 @@ describe("useAdminSidebarSections", () => { wrapper: makeWrapper(client), }) await waitFor(() => - expect(findItem(result.current, "Resources")).toBeDefined(), + expect(findItem(result.current, "Cluster")).toBeDefined(), ) expect(findItem(result.current, "Backup Classes")).toBeUndefined() }) diff --git a/apps/console/src/routes/sidebar-sections.tsx b/apps/console/src/routes/sidebar-sections.tsx index fd9446e..ab510d0 100644 --- a/apps/console/src/routes/sidebar-sections.tsx +++ b/apps/console/src/routes/sidebar-sections.tsx @@ -170,10 +170,10 @@ export function useAdminSidebarSections(): SidebarSection[] { const sections: SidebarSection[] = [] if (canClusterUsage) { sections.push({ - title: "Resources", + title: "Capacity", items: [ - { label: "Resources", to: "/admin/resources-usage", icon: Gauge }, - { label: "Nodes", to: "/admin/resources-nodes", icon: Server }, + { label: "Cluster", to: "/admin/capacity/cluster", icon: Gauge }, + { label: "Nodes", to: "/admin/capacity/nodes", icon: Server }, ], }) } From 8bcac1952e144473ea9d27a1e1c3605e127c2f16 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 22:28:03 +0200 Subject: [PATCH 08/19] feat(console): node metadata in column header, clickable usage gauges - Move Status / Roles / Age into each node's column header in the transposed Nodes table, so the body rows are purely resources (CPU, Memory, extended). - Make the cluster usage gauges clickable: each ring links to the per-resource consumer drill-down (Pods excluded, matching the resources table). Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- .../cluster-usage/ClusterUsageGauges.tsx | 56 ++++++++--- .../cluster-usage/ClusterUsageTable.test.tsx | 93 ++++++++----------- .../cluster-usage/ClusterUsageTable.tsx | 24 +++-- 3 files changed, 90 insertions(+), 83 deletions(-) diff --git a/apps/console/src/components/cluster-usage/ClusterUsageGauges.tsx b/apps/console/src/components/cluster-usage/ClusterUsageGauges.tsx index 1bf0cd1..9a07cf1 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageGauges.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageGauges.tsx @@ -1,3 +1,4 @@ +import { Link } from "react-router" import { GaugeCard, type QuotaEntry } from "../QuotaDisplay.tsx" import { humanizeBytes, humanizeCpu } from "../../lib/k8s-quantity.ts" import type { @@ -12,11 +13,19 @@ interface ClusterUsageGaugesProps { podsUnavailable?: boolean } -const STANDARD: { key: StandardResourceKey; label: string; format: (n: number) => string }[] = [ - { key: "cpu", label: "CPU", format: humanizeCpu }, - { key: "memory", label: "Memory", format: humanizeBytes }, - { key: "ephemeral-storage", label: "Storage", format: humanizeBytes }, - { key: "pods", label: "Pods", format: (n) => String(n) }, +// `linkKey` is the resource key used to deep-link the gauge to the per-resource +// consumer drill-down. Pods is not a requestable container resource, so it has +// no drill-down (matching the resources table). +const STANDARD: { + key: StandardResourceKey + label: string + format: (n: number) => string + linkKey: string | null +}[] = [ + { key: "cpu", label: "CPU", format: humanizeCpu, linkKey: "cpu" }, + { key: "memory", label: "Memory", format: humanizeBytes, linkKey: "memory" }, + { key: "ephemeral-storage", label: "Storage", format: humanizeBytes, linkKey: "ephemeral-storage" }, + { key: "pods", label: "Pods", format: (n) => String(n), linkKey: null }, ] /** Build a quota-style gauge entry from cluster totals (requested vs allocatable). */ @@ -44,8 +53,9 @@ function entryFrom( /** * Cluster-wide allocation gauges: one ring per resource showing Requested vs * Allocatable, reusing the quota GaugeCard so it matches the per-tenant quota - * rings. Hidden entirely when the cluster-wide pods list is unavailable - * (Requested would be unknown and every ring would read 0%). + * rings. Each ring links to the per-resource consumer drill-down (except Pods, + * which is not a requestable resource). Hidden when the cluster-wide pods list + * is unavailable (Requested would be unknown and every ring would read 0%). */ export function ClusterUsageGauges({ aggregates, @@ -54,18 +64,34 @@ export function ClusterUsageGauges({ if (podsUnavailable) return null const extendedKeys = Object.keys(aggregates.extended).sort() - const entries: QuotaEntry[] = [ - ...STANDARD.map((s) => entryFrom(s.label, aggregates.standard[s.key], s.format)), - ...extendedKeys.map((k) => entryFrom(k, aggregates.extended[k], (n) => String(n))), - ].filter((e): e is QuotaEntry => e !== null) + const cards: { entry: QuotaEntry; linkKey: string | null }[] = [ + ...STANDARD.map((s) => ({ + entry: entryFrom(s.label, aggregates.standard[s.key], s.format), + linkKey: s.linkKey, + })), + ...extendedKeys.map((k) => ({ + entry: entryFrom(k, aggregates.extended[k], (n) => String(n)), + linkKey: k, + })), + ].filter((c): c is { entry: QuotaEntry; linkKey: string | null } => c.entry !== null) - if (entries.length === 0) return null + if (cards.length === 0) return null return (
- {entries.map((entry, i) => ( - - ))} + {cards.map(({ entry, linkKey }, i) => + linkKey ? ( + + + + ) : ( + + ), + )}
) } diff --git a/apps/console/src/components/cluster-usage/ClusterUsageTable.test.tsx b/apps/console/src/components/cluster-usage/ClusterUsageTable.test.tsx index aea0032..55a3877 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageTable.test.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageTable.test.tsx @@ -5,8 +5,8 @@ import { MemoryRouter } from "react-router" import { ClusterUsageTable } from "./ClusterUsageTable.tsx" import type { NodeRow } from "../../lib/cluster-usage/types.ts" -// The table now renders s for resource row labels, so every render -// needs a router context. +// The table renders s for resource row labels, so every render needs a +// router context. function render(ui: Parameters[0]) { return rtlRender({ui}) } @@ -36,18 +36,25 @@ function attrRow(container: HTMLElement, key: string): HTMLElement { return container.querySelector(`[data-attribute-row="${key}"]`) as HTMLElement } +function nodeCols(container: HTMLElement): (string | null)[] { + return Array.from(container.querySelectorAll("[data-node-col]")).map((el) => + el.getAttribute("data-node-col"), + ) +} + +function thead(container: HTMLElement): HTMLElement { + return container.querySelector("thead") as HTMLElement +} + describe("ClusterUsageTable (transposed: nodes are columns)", () => { it("renders one column per node, sorted by name ascending", () => { const { container } = render( , ) - const headers = within(container.querySelector("thead")!) - .getAllByRole("columnheader") - .map((h) => h.textContent) - expect(headers).toEqual(["Node", "worker-a", "worker-b"]) + expect(nodeCols(container)).toEqual(["worker-a", "worker-b"]) }) - it("lays out attributes top-to-bottom: Status, Roles, CPU, Memory, extended…, Age", () => { + it("lays out resource rows top-to-bottom: CPU, Memory, then extended in order", () => { const { container } = render( { const order = Array.from(container.querySelectorAll("[data-attribute-row]")).map((el) => el.getAttribute("data-attribute-row"), ) - expect(order).toEqual([ - "status", - "roles", - "cpu", - "memory", - "nvidia.com/gpu", - "amd.com/gpu", - "age", - ]) + expect(order).toEqual(["cpu", "memory", "nvidia.com/gpu", "amd.com/gpu"]) }) it("links resource row labels (CPU, Memory, extended) to the per-resource drill-down", () => { @@ -87,55 +86,47 @@ describe("ClusterUsageTable (transposed: nodes are columns)", () => { "href", "/admin/capacity/cluster/r/nvidia.com/gpu", ) - // Non-resource attribute rows are not links. - expect(screen.queryByRole("link", { name: "Status" })).toBeNull() - expect(screen.queryByRole("link", { name: "Age" })).toBeNull() + // The node name in the header is not a link. + expect(screen.queryByRole("link", { name: "n" })).toBeNull() }) - it("shows Ready / NotReady / SchedulingDisabled in the Status row", () => { + it("renders Status / Roles / Age inside each node's column header, not as rows", () => { const { container } = render( , ) - const status = attrRow(container, "status") - expect(within(status).getByText("Ready")).toBeInTheDocument() - expect(within(status).getByText("NotReady")).toBeInTheDocument() - expect(within(status).getByText(/scheduling.?disabled/i)).toBeInTheDocument() + const head = thead(container) + expect(within(head).getByText("Ready")).toBeInTheDocument() + expect(within(head).getByText("NotReady")).toBeInTheDocument() + expect(within(head).getByText(/scheduling.?disabled/i)).toBeInTheDocument() + expect(within(head).getByText("control-plane")).toBeInTheDocument() + expect(within(head).getByText(/21h/)).toBeInTheDocument() + // No Status/Roles/Age body rows remain. + expect(attrRow(container, "status")).toBeNull() + expect(attrRow(container, "age")).toBeNull() }) - it("flags pressure conditions with a chip", () => { - render( - , - ) - expect(screen.getByText("MemoryPressure")).toBeInTheDocument() - }) - - it("renders roles inline, em dash for nodes without roles", () => { + it("flags pressure conditions with a chip in the header", () => { const { container } = render( , ) - const roles = attrRow(container, "roles") - expect(within(roles).getByText("control-plane")).toBeInTheDocument() - expect(within(roles).getByText("—")).toBeInTheDocument() + expect(within(thead(container)).getByText("MemoryPressure")).toBeInTheDocument() }) - it("renders the Age row verbatim from row.age", () => { + it("renders an em dash for a node header without roles", () => { const { container } = render( - , + , ) - expect(within(attrRow(container, "age")).getByText("21h")).toBeInTheDocument() + expect(within(thead(container)).getByText("—")).toBeInTheDocument() }) it("renders em dash in an extended-resource row for a node that does not expose it", () => { @@ -153,9 +144,7 @@ describe("ClusterUsageTable (transposed: nodes are columns)", () => { extendedKeys={["nvidia.com/gpu"]} />, ) - const gpuRow = attrRow(container, "nvidia.com/gpu") - // Only the Ready node surfaces its capacity-derived number. - expect(within(gpuRow).getAllByText("capacity 2")).toHaveLength(1) + expect(within(attrRow(container, "nvidia.com/gpu")).getAllByText("capacity 2")).toHaveLength(1) }) it("renders em dashes in the CPU and Memory rows when the node is NotReady", () => { @@ -175,10 +164,7 @@ describe("ClusterUsageTable (transposed: nodes are columns)", () => { />, ) await user.type(screen.getByLabelText("Filter nodes"), "GPU") - const headers = within(container.querySelector("thead")!) - .getAllByRole("columnheader") - .map((h) => h.textContent) - expect(headers).toEqual(["Node", "worker-gpu-1"]) + expect(nodeCols(container)).toEqual(["worker-gpu-1"]) }) it("filters node columns by role substring", async () => { @@ -190,10 +176,7 @@ describe("ClusterUsageTable (transposed: nodes are columns)", () => { />, ) await user.type(screen.getByLabelText("Filter nodes"), "control") - const headers = within(container.querySelector("thead")!) - .getAllByRole("columnheader") - .map((h) => h.textContent) - expect(headers).toEqual(["Node", "a"]) + expect(nodeCols(container)).toEqual(["a"]) }) it("replaces the Requested line with an em-dash tooltip when podsUnavailable", () => { diff --git a/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx b/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx index a152b43..1b10596 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx @@ -165,8 +165,9 @@ export function ClusterUsageTable({ const labelHeader = "sticky left-0 z-10 bg-slate-50 px-4 py-3 text-xs font-medium uppercase tracking-wider text-slate-500" - // `linkKey` marks a row as a requestable resource: its label deep-links to - // the per-resource consumer drill-down. Status / Roles / Age have none. + // Resource rows only — node metadata (Status / Roles / Age) is rendered in + // each node's column header instead. Every row is a requestable resource, + // so its label deep-links to the per-resource consumer drill-down. const attributeRows: { key: string label: string @@ -174,8 +175,6 @@ export function ClusterUsageTable({ linkKey?: string render: (r: NodeRow) => ReactNode }[] = [ - { key: "status", label: "Status", render: (r) => statusContent(r) }, - { key: "roles", label: "Roles", render: (r) => rolesContent(r) }, { key: "cpu", label: "CPU", linkKey: "cpu", render: (r) => cpuCell(r.standard.cpu, r.ready, podsUnavailable) }, { key: "memory", label: "Memory", linkKey: "memory", render: (r) => memoryCell(r.standard.memory, r.ready, podsUnavailable) }, ...extendedKeys.map((k) => ({ @@ -185,13 +184,6 @@ export function ClusterUsageTable({ linkKey: k, render: (r: NodeRow) => extendedCell(r.extended[k], r.ready, podsUnavailable), })), - { - key: "age", - label: "Age", - render: (r: NodeRow) => ( - {r.age} - ), - }, ] return ( @@ -217,9 +209,15 @@ export function ClusterUsageTable({ {visibleNodes.map((n) => (

- {n.name} +
+
{n.name}
+ {statusContent(n)} + {rolesContent(n)} +
Age {n.age}
+
Tenant (namespace)KindNamePodsWorkload Requested
{r.namespace}{r.kind} {appHref ? ( tenant && selectTenant(tenant)} - className="text-blue-700 hover:text-blue-800 hover:underline" + className="font-medium text-blue-700 hover:text-blue-800 hover:underline" > {r.name} ) : ( - {r.name} + {r.name} )} + {r.kind !== "—" ? ( +
{r.kind}
+ ) : null}
{r.pods} {formatResource(resource, r.requested)}
- Total · {rows.length} consumer{rows.length === 1 ? "" : "s"} + + Total · {rows.length} workload{rows.length === 1 ? "" : "s"} {totalPods} {formatResource(resource, totalRequested)}
+ + + + + + + + + + {rows.map((r) => ( + + + + + + + ))} + +
Storage ClassPVCsRequestedBound
+ {r.storageClass === NO_CLASS ? ( + {r.storageClass} + ) : ( + + {r.storageClass} + + )} + {r.pvcs} + {humanizeBytes(r.requested)} + + {r.bound > 0 ? humanizeBytes(r.bound) : "—"} +
+ +
+ ) +} diff --git a/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx b/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx index 1b10596..e17143e 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx @@ -210,7 +210,7 @@ export function ClusterUsageTable({
{n.name}
diff --git a/apps/console/src/lib/cluster-usage/types.ts b/apps/console/src/lib/cluster-usage/types.ts index 5dd049c..b908715 100644 --- a/apps/console/src/lib/cluster-usage/types.ts +++ b/apps/console/src/lib/cluster-usage/types.ts @@ -56,6 +56,18 @@ export interface PodStatus { export type Pod = K8sResource +export interface PvcSpec { + storageClassName?: string + resources?: { requests?: Record } +} + +export interface PvcStatus { + phase?: string + capacity?: Record +} + +export type Pvc = K8sResource + export interface NodeMetricsUsage { cpu: string memory: string diff --git a/apps/console/src/lib/workload.ts b/apps/console/src/lib/workload.ts new file mode 100644 index 0000000..af7766c --- /dev/null +++ b/apps/console/src/lib/workload.ts @@ -0,0 +1,23 @@ +import { APPS_GROUP } from "@cozystack/types" + +/** + * Derive the owning application (kind + name) of a resource from its labels. + * Cozystack's lineage controller stamps apps.cozystack.io/application.{kind,name} + * on every workload object (Pods, PVCs, Services, …); we fall back to the Helm + * instance/name labels and finally to the resource's own name so nothing is + * silently dropped. + */ +export function workloadOwner( + labels: Record | undefined, + fallbackName: string, +): { kind: string; name: string } { + const l = labels ?? {} + const kind = l[`${APPS_GROUP}/application.kind`] + const name = + l[`${APPS_GROUP}/application.name`] ?? + l["app.kubernetes.io/instance"] ?? + l["app.kubernetes.io/name"] + if (kind && name) return { kind, name } + if (name) return { kind: kind ?? "—", name } + return { kind: kind ?? "—", name: fallbackName } +} diff --git a/apps/console/src/routes/AdminPage.tsx b/apps/console/src/routes/AdminPage.tsx index 43a1646..7b25ed5 100644 --- a/apps/console/src/routes/AdminPage.tsx +++ b/apps/console/src/routes/AdminPage.tsx @@ -3,6 +3,7 @@ import { Section, Spinner } from "@cozystack/ui" import { useAdminAccess } from "./sidebar-sections.tsx" import { ClusterUsagePage } from "./ClusterUsagePage.tsx" import { ClusterUsageResourcePage } from "./ClusterUsageResourcePage.tsx" +import { StorageClassUsagePage } from "./StorageClassUsagePage.tsx" import { NodesPage } from "./NodesPage.tsx" import { BackupClassListPage } from "./BackupClassListPage.tsx" import { BackupClassCreatePage } from "./BackupClassCreatePage.tsx" @@ -60,6 +61,7 @@ export function AdminPage() { /> } /> } /> + } /> } /> }> } /> diff --git a/apps/console/src/routes/ClusterUsagePage.tsx b/apps/console/src/routes/ClusterUsagePage.tsx index 872bf65..155be03 100644 --- a/apps/console/src/routes/ClusterUsagePage.tsx +++ b/apps/console/src/routes/ClusterUsagePage.tsx @@ -2,6 +2,7 @@ import { Link } from "react-router" import { Section, Spinner } from "@cozystack/ui" import { useClusterUsageData } from "../hooks/useClusterUsageData.tsx" import { ClusterUsageAggregates } from "../components/cluster-usage/ClusterUsageAggregates.tsx" +import { ClusterStorageSection } from "../components/cluster-usage/ClusterStorageSection.tsx" /** * Administration → Cluster Usage. Single cluster-scoped page that @@ -64,11 +65,14 @@ export function ClusterUsagePage() {

No nodes found.

) : ( - + <> + + + )}
) diff --git a/apps/console/src/routes/ClusterUsageResourcePage.tsx b/apps/console/src/routes/ClusterUsageResourcePage.tsx index 868e7c5..0810392 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.tsx @@ -2,12 +2,11 @@ import { useMemo } from "react" import { Link, useParams } from "react-router" import { Section, Spinner } from "@cozystack/ui" import { useK8sList } from "@cozystack/k8s-client" -import { APPS_GROUP } from "@cozystack/types" import { ChevronLeft } from "lucide-react" import { parseQuantity, humanizeBytes, humanizeCpu } from "../lib/k8s-quantity.ts" -import { useApplicationDefinitions } from "../lib/app-definitions.ts" -import { useTenantContext } from "../lib/tenant-context.tsx" +import { workloadOwner } from "../lib/workload.ts" import { TENANT_NAMESPACE_PREFIX } from "../lib/constants.ts" +import { WorkloadCell } from "../components/WorkloadCell.tsx" import type { Pod } from "../lib/cluster-usage/types.ts" /** @@ -54,19 +53,6 @@ function podResourceRequest(pod: Pod, resource: string): number { return total } -/** Derive the owning application (kind + name) of a pod from its labels. */ -function podOwner(pod: Pod): { kind: string; name: string } { - const labels = pod.metadata.labels ?? {} - const kind = labels[`${APPS_GROUP}/application.kind`] - const name = - labels[`${APPS_GROUP}/application.name`] ?? - labels["app.kubernetes.io/instance"] ?? - labels["app.kubernetes.io/name"] - if (kind && name) return { kind, name } - if (name) return { kind: kind ?? "—", name } - return { kind: kind ?? "—", name: pod.metadata.name } -} - export function ClusterUsageResourcePage() { const params = useParams() const resource = params["*"] ?? "" @@ -77,20 +63,6 @@ export function ClusterUsageResourcePage() { error, } = useK8sList({ apiGroup: "", apiVersion: "v1", plural: "pods" }) - // Map application kind → plural so a consumer row can deep-link to the - // deployed application's Console page (/console//). - const { data: appDefs } = useApplicationDefinitions() - const { selectTenant } = useTenantContext() - const kindToPlural = useMemo(() => { - const map = new Map() - for (const ad of appDefs?.items ?? []) { - const kind = ad.spec?.application.kind - const plural = ad.spec?.application.plural - if (kind && plural) map.set(kind, plural) - } - return map - }, [appDefs]) - const { rows, totalRequested } = useMemo(() => { const byKey = new Map() let totalRequested = 0 @@ -101,7 +73,7 @@ export function ClusterUsageResourcePage() { // Only tenant namespaces are relevant here — skip system/control-plane // namespaces (cozy-*, kube-system, …) that also consume the resource. if (!namespace.startsWith(TENANT_NAMESPACE_PREFIX)) continue - const { kind, name } = podOwner(pod) + const { kind, name } = workloadOwner(pod.metadata.labels, pod.metadata.name) const key = `${namespace}/${kind}/${name}` const existing = byKey.get(key) if (existing) { @@ -163,33 +135,11 @@ export function ClusterUsageResourcePage() {
{r.namespace} - {appHref ? ( - tenant && selectTenant(tenant)} - className="font-medium text-blue-700 hover:text-blue-800 hover:underline" - > - {r.name} - - ) : ( - {r.name} - )} - {r.kind !== "—" ? ( -
{r.kind}
- ) : null} +
{formatResource(resource, r.requested)} diff --git a/apps/console/src/routes/StorageClassUsagePage.test.tsx b/apps/console/src/routes/StorageClassUsagePage.test.tsx new file mode 100644 index 0000000..d5cf801 --- /dev/null +++ b/apps/console/src/routes/StorageClassUsagePage.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeAll } from "vitest" +import { screen, within } from "@testing-library/react" +import { Route, Routes } from "react-router" +import { K8sClient, type K8sList } from "@cozystack/k8s-client" +import { StorageClassUsagePage } from "./StorageClassUsagePage.tsx" +import { TenantProvider } from "../lib/tenant-context.tsx" +import { renderWithK8sProvider } from "../test-utils/render.tsx" + +let seq = 0 +function pvc( + namespace: string, + storageClassName: string, + requested: string, + labels: Record = {}, +) { + return { + apiVersion: "v1", + kind: "PersistentVolumeClaim", + metadata: { name: `pvc-${seq++}`, namespace, labels }, + spec: { storageClassName, resources: { requests: { storage: requested } } }, + status: { phase: "Bound" }, + } +} + +function appDef(kind: string, plural: string) { + return { + apiVersion: "cozystack.io/v1alpha1", + kind: "ApplicationDefinition", + metadata: { name: plural }, + spec: { application: { kind, plural, singular: kind.toLowerCase() } }, + } +} + +const VM_LABELS = { + "apps.cozystack.io/application.kind": "VMInstance", + "apps.cozystack.io/application.name": "vm1", +} + +function makeClient(pvcs: unknown[], appDefs: unknown[] = []): K8sClient { + const client = new K8sClient() + vi.spyOn(client, "list").mockImplementation(async (_g, _v, plural) => { + const items = + plural === "persistentvolumeclaims" + ? pvcs + : plural === "applicationdefinitions" + ? appDefs + : [] + return { apiVersion: "v1", kind: `${plural}List`, metadata: {}, items } as K8sList + }) + return client +} + +function renderPage(client: K8sClient, sc: string) { + return renderWithK8sProvider( + + + } /> + + , + { client, initialRoute: `/sc/${sc}` }, + ) +} + +beforeAll(() => { + if (typeof globalThis.localStorage?.getItem !== "function") { + const store = new Map() + vi.stubGlobal("localStorage", { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + }) + } +}) + +describe("StorageClassUsagePage", () => { + it("groups PVCs of the storage class by owning workload, summing requests", async () => { + const client = makeClient( + [ + pvc("tenant-foo", "replicated", "5Gi", VM_LABELS), + pvc("tenant-foo", "replicated", "5Gi", VM_LABELS), + // Different storage class — excluded. + pvc("tenant-foo", "fast", "20Gi", VM_LABELS), + // Non-tenant namespace — excluded. + pvc("cozy-system", "replicated", "100Gi"), + ], + [appDef("VMInstance", "vminstances")], + ) + const { container } = renderPage(client, "replicated") + + await screen.findByText("vm1") + expect(screen.getByText("tenant-foo")).toBeInTheDocument() + // 5Gi + 5Gi = 10Gi requested (the fast/system PVCs are excluded). The value + // appears in both the row and the total footer. + const tbody = container.querySelector("tbody") as HTMLElement + expect(within(tbody).getByText(/10(\.0)?Gi/)).toBeInTheDocument() + expect(screen.queryByText("cozy-system")).toBeNull() + }) + + it("links the workload to its application page", async () => { + const client = makeClient( + [pvc("tenant-root", "replicated", "5Gi", { + "apps.cozystack.io/application.kind": "VMInstance", + "apps.cozystack.io/application.name": "demo-vm", + })], + [appDef("VMInstance", "vminstances")], + ) + renderPage(client, "replicated") + const link = await screen.findByRole("link", { name: "demo-vm" }) + expect(link).toHaveAttribute("href", "/console/vminstances/demo-vm") + }) + + it("shows an empty state when nothing uses the class", async () => { + const client = makeClient([pvc("tenant-foo", "fast", "5Gi", VM_LABELS)]) + renderPage(client, "replicated") + expect(await screen.findByText(/no tenant workloads use/i)).toBeInTheDocument() + }) + + it("renders the storage class as the heading", async () => { + const client = makeClient([]) + renderPage(client, "replicated") + expect(await screen.findByRole("heading", { name: "replicated" })).toBeInTheDocument() + }) +}) diff --git a/apps/console/src/routes/StorageClassUsagePage.tsx b/apps/console/src/routes/StorageClassUsagePage.tsx new file mode 100644 index 0000000..259f2f4 --- /dev/null +++ b/apps/console/src/routes/StorageClassUsagePage.tsx @@ -0,0 +1,126 @@ +import { useMemo } from "react" +import { Link, useParams } from "react-router" +import { Section, Spinner } from "@cozystack/ui" +import { useK8sList } from "@cozystack/k8s-client" +import { ChevronLeft } from "lucide-react" +import { parseQuantity, humanizeBytes } from "../lib/k8s-quantity.ts" +import { workloadOwner } from "../lib/workload.ts" +import { TENANT_NAMESPACE_PREFIX } from "../lib/constants.ts" +import { WorkloadCell } from "../components/WorkloadCell.tsx" +import type { Pvc } from "../lib/cluster-usage/types.ts" + +interface UsageRow { + namespace: string + kind: string + name: string + requested: number +} + +/** + * Admin → Resources → per-StorageClass drill-down. Reached from the Persistent + * Storage panel (/admin/capacity/cluster/sc/). Lists the workloads that + * own PersistentVolumeClaims in that StorageClass across tenant namespaces, + * summing requested storage and grouping by the owning application (the same + * Workload abstraction used for the node-resource drill-down). The class name + * arrives via a splat param so names with slashes survive routing. + */ +export function StorageClassUsagePage() { + const params = useParams() + const storageClass = params["*"] ?? "" + + const { data, isLoading, error } = useK8sList({ + apiGroup: "", + apiVersion: "v1", + plural: "persistentvolumeclaims", + }) + + const { rows, totalRequested } = useMemo(() => { + const byKey = new Map() + let totalRequested = 0 + for (const pvc of data?.items ?? []) { + if ((pvc.spec?.storageClassName || "") !== storageClass) continue + const namespace = pvc.metadata.namespace ?? "—" + if (!namespace.startsWith(TENANT_NAMESPACE_PREFIX)) continue + const requested = parseQuantity(pvc.spec?.resources?.requests?.storage ?? "0") + const { kind, name } = workloadOwner(pvc.metadata.labels, pvc.metadata.name) + const key = `${namespace}/${kind}/${name}` + const existing = byKey.get(key) + if (existing) existing.requested += requested + else byKey.set(key, { namespace, kind, name, requested }) + totalRequested += requested + } + const rows = [...byKey.values()].sort((a, b) => b.requested - a.requested) + return { rows, totalRequested } + }, [data, storageClass]) + + return ( +
+
+ + Cluster + +

{storageClass}

+

+ Workloads with PersistentVolumeClaims in this StorageClass across all + tenants, with their total requested storage. +

+
+ + {isLoading ? ( +
+ Loading… +
+ ) : error ? ( +
+
+ Failed to load persistent volume claims: {error.message} +
+
+ ) : rows.length === 0 ? ( +
+

+ No tenant workloads use {storageClass}. +

+
+ ) : ( +
+ + + + + + + + + + {rows.map((r) => ( + + + + + + ))} + + + + + + + +
Tenant (namespace)WorkloadRequested
{r.namespace} + + + {humanizeBytes(r.requested)} +
+ Total · {rows.length} workload{rows.length === 1 ? "" : "s"} + + {humanizeBytes(totalRequested)} +
+
+ )} +
+ ) +} From 089162ed8b6d6a7c33f77ef5bf3dd577058fd5b4 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 22:50:00 +0200 Subject: [PATCH 11/19] feat(console): move Persistent Storage onto its own Storage tab Add a Storage entry to the Capacity section (/admin/capacity/storage) and move the PersistentVolumeClaim-by-StorageClass panel off the Cluster page onto its own page, with loading and empty states. The per-StorageClass drill-down is unchanged. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- .../ClusterStorageSection.test.tsx | 10 +++---- .../cluster-usage/ClusterStorageSection.tsx | 27 ++++++++++++++----- apps/console/src/routes/AdminPage.tsx | 2 ++ apps/console/src/routes/ClusterUsagePage.tsx | 14 ++++------ apps/console/src/routes/StoragePage.tsx | 20 ++++++++++++++ apps/console/src/routes/sidebar-sections.tsx | 2 ++ 6 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 apps/console/src/routes/StoragePage.tsx diff --git a/apps/console/src/components/cluster-usage/ClusterStorageSection.test.tsx b/apps/console/src/components/cluster-usage/ClusterStorageSection.test.tsx index 6f1eb8b..ebad720 100644 --- a/apps/console/src/components/cluster-usage/ClusterStorageSection.test.tsx +++ b/apps/console/src/components/cluster-usage/ClusterStorageSection.test.tsx @@ -49,12 +49,12 @@ describe("ClusterStorageSection", () => { expect(link).toHaveAttribute("href", "/admin/capacity/cluster/sc/replicated") }) - it("renders nothing when no tenant PVCs exist", async () => { + it("shows an empty state when no tenant PVCs exist", async () => { const client = makeClient([pvc("cozy-system", "replicated", "100Gi")]) - const { container } = renderWithK8sProvider(, { client }) - // Give the query a tick to settle, then assert the panel is absent. - await Promise.resolve() - expect(within(container).queryByText("Persistent Storage")).toBeNull() + renderWithK8sProvider(, { client }) + expect( + await screen.findByText(/no persistent volume claims found/i), + ).toBeInTheDocument() }) }) diff --git a/apps/console/src/components/cluster-usage/ClusterStorageSection.tsx b/apps/console/src/components/cluster-usage/ClusterStorageSection.tsx index d8fd309..184bf6b 100644 --- a/apps/console/src/components/cluster-usage/ClusterStorageSection.tsx +++ b/apps/console/src/components/cluster-usage/ClusterStorageSection.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react" import { Link } from "react-router" -import { Section } from "@cozystack/ui" +import { Section, Spinner } from "@cozystack/ui" import { useK8sList } from "@cozystack/k8s-client" import { parseQuantity, humanizeBytes } from "../../lib/k8s-quantity.ts" import { TENANT_NAMESPACE_PREFIX } from "../../lib/constants.ts" @@ -50,13 +50,27 @@ export function ClusterStorageSection() { return [...byClass.values()].sort((a, b) => a.storageClass.localeCompare(b.storageClass)) }, [data]) - if (isLoading || rows.length === 0) return null + if (isLoading) { + return ( +
+ Loading… +
+ ) + } - return ( -
-

Persistent Storage

+ if (rows.length === 0) { + return (
- +

+ No persistent volume claims found. +

+ + ) + } + + return ( +
+
@@ -92,6 +106,5 @@ export function ClusterStorageSection() {
Storage Class
-
) } diff --git a/apps/console/src/routes/AdminPage.tsx b/apps/console/src/routes/AdminPage.tsx index 7b25ed5..27937c0 100644 --- a/apps/console/src/routes/AdminPage.tsx +++ b/apps/console/src/routes/AdminPage.tsx @@ -4,6 +4,7 @@ import { useAdminAccess } from "./sidebar-sections.tsx" import { ClusterUsagePage } from "./ClusterUsagePage.tsx" import { ClusterUsageResourcePage } from "./ClusterUsageResourcePage.tsx" import { StorageClassUsagePage } from "./StorageClassUsagePage.tsx" +import { StoragePage } from "./StoragePage.tsx" import { NodesPage } from "./NodesPage.tsx" import { BackupClassListPage } from "./BackupClassListPage.tsx" import { BackupClassCreatePage } from "./BackupClassCreatePage.tsx" @@ -62,6 +63,7 @@ export function AdminPage() { } /> } /> } /> + } /> } /> }> } /> diff --git a/apps/console/src/routes/ClusterUsagePage.tsx b/apps/console/src/routes/ClusterUsagePage.tsx index 155be03..872bf65 100644 --- a/apps/console/src/routes/ClusterUsagePage.tsx +++ b/apps/console/src/routes/ClusterUsagePage.tsx @@ -2,7 +2,6 @@ import { Link } from "react-router" import { Section, Spinner } from "@cozystack/ui" import { useClusterUsageData } from "../hooks/useClusterUsageData.tsx" import { ClusterUsageAggregates } from "../components/cluster-usage/ClusterUsageAggregates.tsx" -import { ClusterStorageSection } from "../components/cluster-usage/ClusterStorageSection.tsx" /** * Administration → Cluster Usage. Single cluster-scoped page that @@ -65,14 +64,11 @@ export function ClusterUsagePage() {

No nodes found.

) : ( - <> - - - + )} ) diff --git a/apps/console/src/routes/StoragePage.tsx b/apps/console/src/routes/StoragePage.tsx new file mode 100644 index 0000000..08e7735 --- /dev/null +++ b/apps/console/src/routes/StoragePage.tsx @@ -0,0 +1,20 @@ +import { ClusterStorageSection } from "../components/cluster-usage/ClusterStorageSection.tsx" + +/** + * Admin → Capacity → Storage. PersistentVolumeClaims across tenant namespaces + * aggregated by StorageClass; each class drills down to the consuming + * workloads. Split out of the Cluster page onto its own tab. + */ +export function StoragePage() { + return ( +
+
+

Storage

+

+ PersistentVolumeClaims across all tenants, grouped by StorageClass. +

+
+ +
+ ) +} diff --git a/apps/console/src/routes/sidebar-sections.tsx b/apps/console/src/routes/sidebar-sections.tsx index ab510d0..6d0ed5e 100644 --- a/apps/console/src/routes/sidebar-sections.tsx +++ b/apps/console/src/routes/sidebar-sections.tsx @@ -5,6 +5,7 @@ import { Database, Gauge, Globe, + HardDrive, Info, LayoutGrid, Layers, @@ -174,6 +175,7 @@ export function useAdminSidebarSections(): SidebarSection[] { items: [ { label: "Cluster", to: "/admin/capacity/cluster", icon: Gauge }, { label: "Nodes", to: "/admin/capacity/nodes", icon: Server }, + { label: "Storage", to: "/admin/capacity/storage", icon: HardDrive }, ], }) } From d3fd9acb7885ba819c0db574c3331f83f530cf13 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Tue, 2 Jun 2026 01:27:02 +0200 Subject: [PATCH 12/19] fix(console): link tenant Kubernetes-cluster worker VMs to their app Worker-node VMs of a tenant Kubernetes cluster (and their virt-launcher pods) are created by Cluster API, not the cozystack lineage controller, so they carry only CAPI labels and lack the apps.cozystack.io/application.{kind,name} labels that workloadOwner keys on. As a result they showed as plain text in the resource-consumer Workload column instead of linking to the owning app. Map pods carrying cluster.x-k8s.io/cluster-name=kubernetes- back to the kubernetes app instance , so worker VMs of the same cluster group together and deep-link to its Console page, matching how a standalone VMInstance is linked today. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- apps/console/src/lib/workload.test.ts | 92 +++++++++++++++++++++++++++ apps/console/src/lib/workload.ts | 27 ++++++++ 2 files changed, 119 insertions(+) create mode 100644 apps/console/src/lib/workload.test.ts diff --git a/apps/console/src/lib/workload.test.ts b/apps/console/src/lib/workload.test.ts new file mode 100644 index 0000000..5b57d48 --- /dev/null +++ b/apps/console/src/lib/workload.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest" +import { workloadOwner } from "./workload.ts" + +describe("workloadOwner", () => { + it("uses cozystack lineage labels when present (VMInstance case)", () => { + expect( + workloadOwner( + { + "apps.cozystack.io/application.kind": "VMInstance", + "apps.cozystack.io/application.name": "demo-vm", + "app.kubernetes.io/instance": "vm-instance-demo-vm", + }, + "virt-launcher-vm-instance-demo-vm-kngcm", + ), + ).toEqual({ kind: "VMInstance", name: "demo-vm" }) + }) + + it("maps a tenant Kubernetes-cluster worker VM to its kubernetes app instance", () => { + // Worker-node VM pods carry only CAPI labels, no cozystack lineage labels. + const labels = { + "capk.cluster.x-k8s.io/kubevirt-machine-name": "kubernetes-test-gpu-g85nk-d79tb", + "capk.cluster.x-k8s.io/kubevirt-machine-namespace": "tenant-root", + "cluster.x-k8s.io/cluster-name": "kubernetes-test", + "cluster.x-k8s.io/role": "worker", + "kubevirt.io/vm": "kubernetes-test-gpu-g85nk-d79tb", + } + expect( + workloadOwner(labels, "virt-launcher-kubernetes-test-gpu-g85nk-d79tb-99qx7"), + ).toEqual({ kind: "Kubernetes", name: "test" }) + }) + + it("groups all worker VMs of the same cluster under one owner", () => { + const cluster = (machine: string) => ({ + "cluster.x-k8s.io/cluster-name": "kubernetes-test", + "cluster.x-k8s.io/role": "worker", + "kubevirt.io/vm": machine, + }) + const a = workloadOwner(cluster("kubernetes-test-gpu-g85nk-84hq7"), "virt-launcher-a") + const b = workloadOwner(cluster("kubernetes-test-md0-tmz5w-wv7v2"), "virt-launcher-b") + expect(a).toEqual(b) + expect(a).toEqual({ kind: "Kubernetes", name: "test" }) + }) + + it("falls back to lineage labels when the CAPI cluster name lacks the kubernetes prefix", () => { + // Defensive: a CAPI cluster not named kubernetes- should not be + // misattributed to a kubernetes app instance. + expect( + workloadOwner( + { + "cluster.x-k8s.io/cluster-name": "some-other-cluster", + "app.kubernetes.io/instance": "foo", + }, + "pod-foo", + ), + ).toEqual({ kind: "—", name: "foo" }) + }) + + it("ignores an empty instance after stripping the prefix", () => { + expect( + workloadOwner({ "cluster.x-k8s.io/cluster-name": "kubernetes-" }, "pod-x"), + ).toEqual({ kind: "—", name: "pod-x" }) + }) + + it("falls back to the Helm instance label", () => { + expect( + workloadOwner({ "app.kubernetes.io/instance": "my-postgres" }, "my-postgres-1"), + ).toEqual({ kind: "—", name: "my-postgres" }) + }) + + it("falls back to app.kubernetes.io/name when instance is absent", () => { + expect( + workloadOwner({ "app.kubernetes.io/name": "redis" }, "redis-abc"), + ).toEqual({ kind: "—", name: "redis" }) + }) + + it("keeps kind when only kind is present", () => { + expect( + workloadOwner( + { "apps.cozystack.io/application.kind": "Deployment" }, + "deploy-pod-1", + ), + ).toEqual({ kind: "Deployment", name: "deploy-pod-1" }) + }) + + it("falls back to the resource name when no useful labels are present", () => { + expect(workloadOwner({}, "lonely-pod")).toEqual({ kind: "—", name: "lonely-pod" }) + expect(workloadOwner(undefined, "lonely-pod")).toEqual({ + kind: "—", + name: "lonely-pod", + }) + }) +}) diff --git a/apps/console/src/lib/workload.ts b/apps/console/src/lib/workload.ts index af7766c..c09920a 100644 --- a/apps/console/src/lib/workload.ts +++ b/apps/console/src/lib/workload.ts @@ -1,17 +1,44 @@ import { APPS_GROUP } from "@cozystack/types" +/** + * Cluster API stamps `cluster.x-k8s.io/cluster-name` on every object it owns, + * including the KubeVirt worker-node VMs (and their virt-launcher pods) of a + * tenant Kubernetes cluster. Cozystack names that CAPI cluster after the + * `kubernetes` app instance with the chart name as a prefix, so the cluster + * `kubernetes-test` belongs to the `kubernetes` app instance `test`. + */ +const CAPI_CLUSTER_NAME_LABEL = "cluster.x-k8s.io/cluster-name" +const KUBERNETES_APP_KIND = "Kubernetes" +const KUBERNETES_CLUSTER_PREFIX = "kubernetes-" + /** * Derive the owning application (kind + name) of a resource from its labels. * Cozystack's lineage controller stamps apps.cozystack.io/application.{kind,name} * on every workload object (Pods, PVCs, Services, …); we fall back to the Helm * instance/name labels and finally to the resource's own name so nothing is * silently dropped. + * + * One class of object is *not* stamped with the lineage labels: the worker-node + * VMs of a tenant Kubernetes cluster. Those are created by Cluster API / the + * KubeVirt provider, so they only carry CAPI labels. We special-case them here + * and attribute them back to the owning `kubernetes` app instance, so they link + * to its Console page just like a standalone VMInstance does. */ export function workloadOwner( labels: Record | undefined, fallbackName: string, ): { kind: string; name: string } { const l = labels ?? {} + + // Tenant Kubernetes-cluster VMs carry only CAPI labels, not the cozystack + // lineage labels. Map them to the owning `kubernetes` app instance so worker + // VMs of the same cluster group together and deep-link to its app page. + const clusterName = l[CAPI_CLUSTER_NAME_LABEL] + if (clusterName && clusterName.startsWith(KUBERNETES_CLUSTER_PREFIX)) { + const instance = clusterName.slice(KUBERNETES_CLUSTER_PREFIX.length) + if (instance) return { kind: KUBERNETES_APP_KIND, name: instance } + } + const kind = l[`${APPS_GROUP}/application.kind`] const name = l[`${APPS_GROUP}/application.name`] ?? From 317d816de0a8eb7676e6ab6a4261ef2f7719a226 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Tue, 2 Jun 2026 01:59:36 +0200 Subject: [PATCH 13/19] revert(console): drop CAPI workload special-case (superseded by source fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cluster.x-k8s.io/cluster-name special-case in workloadOwner was a UI band-aid for worker-node VMs that lacked lineage labels. cozystack#2779 now stamps apps.cozystack.io/application.{group,kind,name} on the worker VM template, so those pods carry the standard lineage labels and the generic workloadOwner path resolves them — the special-case is dead code. This reverts commit d3fd9acb7885ba819c0db574c3331f83f530cf13. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- apps/console/src/lib/workload.test.ts | 92 --------------------------- apps/console/src/lib/workload.ts | 27 -------- 2 files changed, 119 deletions(-) delete mode 100644 apps/console/src/lib/workload.test.ts diff --git a/apps/console/src/lib/workload.test.ts b/apps/console/src/lib/workload.test.ts deleted file mode 100644 index 5b57d48..0000000 --- a/apps/console/src/lib/workload.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect } from "vitest" -import { workloadOwner } from "./workload.ts" - -describe("workloadOwner", () => { - it("uses cozystack lineage labels when present (VMInstance case)", () => { - expect( - workloadOwner( - { - "apps.cozystack.io/application.kind": "VMInstance", - "apps.cozystack.io/application.name": "demo-vm", - "app.kubernetes.io/instance": "vm-instance-demo-vm", - }, - "virt-launcher-vm-instance-demo-vm-kngcm", - ), - ).toEqual({ kind: "VMInstance", name: "demo-vm" }) - }) - - it("maps a tenant Kubernetes-cluster worker VM to its kubernetes app instance", () => { - // Worker-node VM pods carry only CAPI labels, no cozystack lineage labels. - const labels = { - "capk.cluster.x-k8s.io/kubevirt-machine-name": "kubernetes-test-gpu-g85nk-d79tb", - "capk.cluster.x-k8s.io/kubevirt-machine-namespace": "tenant-root", - "cluster.x-k8s.io/cluster-name": "kubernetes-test", - "cluster.x-k8s.io/role": "worker", - "kubevirt.io/vm": "kubernetes-test-gpu-g85nk-d79tb", - } - expect( - workloadOwner(labels, "virt-launcher-kubernetes-test-gpu-g85nk-d79tb-99qx7"), - ).toEqual({ kind: "Kubernetes", name: "test" }) - }) - - it("groups all worker VMs of the same cluster under one owner", () => { - const cluster = (machine: string) => ({ - "cluster.x-k8s.io/cluster-name": "kubernetes-test", - "cluster.x-k8s.io/role": "worker", - "kubevirt.io/vm": machine, - }) - const a = workloadOwner(cluster("kubernetes-test-gpu-g85nk-84hq7"), "virt-launcher-a") - const b = workloadOwner(cluster("kubernetes-test-md0-tmz5w-wv7v2"), "virt-launcher-b") - expect(a).toEqual(b) - expect(a).toEqual({ kind: "Kubernetes", name: "test" }) - }) - - it("falls back to lineage labels when the CAPI cluster name lacks the kubernetes prefix", () => { - // Defensive: a CAPI cluster not named kubernetes- should not be - // misattributed to a kubernetes app instance. - expect( - workloadOwner( - { - "cluster.x-k8s.io/cluster-name": "some-other-cluster", - "app.kubernetes.io/instance": "foo", - }, - "pod-foo", - ), - ).toEqual({ kind: "—", name: "foo" }) - }) - - it("ignores an empty instance after stripping the prefix", () => { - expect( - workloadOwner({ "cluster.x-k8s.io/cluster-name": "kubernetes-" }, "pod-x"), - ).toEqual({ kind: "—", name: "pod-x" }) - }) - - it("falls back to the Helm instance label", () => { - expect( - workloadOwner({ "app.kubernetes.io/instance": "my-postgres" }, "my-postgres-1"), - ).toEqual({ kind: "—", name: "my-postgres" }) - }) - - it("falls back to app.kubernetes.io/name when instance is absent", () => { - expect( - workloadOwner({ "app.kubernetes.io/name": "redis" }, "redis-abc"), - ).toEqual({ kind: "—", name: "redis" }) - }) - - it("keeps kind when only kind is present", () => { - expect( - workloadOwner( - { "apps.cozystack.io/application.kind": "Deployment" }, - "deploy-pod-1", - ), - ).toEqual({ kind: "Deployment", name: "deploy-pod-1" }) - }) - - it("falls back to the resource name when no useful labels are present", () => { - expect(workloadOwner({}, "lonely-pod")).toEqual({ kind: "—", name: "lonely-pod" }) - expect(workloadOwner(undefined, "lonely-pod")).toEqual({ - kind: "—", - name: "lonely-pod", - }) - }) -}) diff --git a/apps/console/src/lib/workload.ts b/apps/console/src/lib/workload.ts index c09920a..af7766c 100644 --- a/apps/console/src/lib/workload.ts +++ b/apps/console/src/lib/workload.ts @@ -1,44 +1,17 @@ import { APPS_GROUP } from "@cozystack/types" -/** - * Cluster API stamps `cluster.x-k8s.io/cluster-name` on every object it owns, - * including the KubeVirt worker-node VMs (and their virt-launcher pods) of a - * tenant Kubernetes cluster. Cozystack names that CAPI cluster after the - * `kubernetes` app instance with the chart name as a prefix, so the cluster - * `kubernetes-test` belongs to the `kubernetes` app instance `test`. - */ -const CAPI_CLUSTER_NAME_LABEL = "cluster.x-k8s.io/cluster-name" -const KUBERNETES_APP_KIND = "Kubernetes" -const KUBERNETES_CLUSTER_PREFIX = "kubernetes-" - /** * Derive the owning application (kind + name) of a resource from its labels. * Cozystack's lineage controller stamps apps.cozystack.io/application.{kind,name} * on every workload object (Pods, PVCs, Services, …); we fall back to the Helm * instance/name labels and finally to the resource's own name so nothing is * silently dropped. - * - * One class of object is *not* stamped with the lineage labels: the worker-node - * VMs of a tenant Kubernetes cluster. Those are created by Cluster API / the - * KubeVirt provider, so they only carry CAPI labels. We special-case them here - * and attribute them back to the owning `kubernetes` app instance, so they link - * to its Console page just like a standalone VMInstance does. */ export function workloadOwner( labels: Record | undefined, fallbackName: string, ): { kind: string; name: string } { const l = labels ?? {} - - // Tenant Kubernetes-cluster VMs carry only CAPI labels, not the cozystack - // lineage labels. Map them to the owning `kubernetes` app instance so worker - // VMs of the same cluster group together and deep-link to its app page. - const clusterName = l[CAPI_CLUSTER_NAME_LABEL] - if (clusterName && clusterName.startsWith(KUBERNETES_CLUSTER_PREFIX)) { - const instance = clusterName.slice(KUBERNETES_CLUSTER_PREFIX.length) - if (instance) return { kind: KUBERNETES_APP_KIND, name: instance } - } - const kind = l[`${APPS_GROUP}/application.kind`] const name = l[`${APPS_GROUP}/application.name`] ?? From cadafff516009cfb9014bba73f93a91aa76b1f5e Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Tue, 2 Jun 2026 03:00:16 +0200 Subject: [PATCH 14/19] feat(console): deep-link consumer workloads to the app's Workloads tab The resource-consumer drill-down linked each workload to its application's overview page. Point the link at the app's Workloads tab instead, so a click lands directly on the workloads of the selected resource. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- apps/console/src/components/WorkloadCell.tsx | 4 ++-- apps/console/src/routes/ClusterUsageResourcePage.test.tsx | 2 +- apps/console/src/routes/StorageClassUsagePage.test.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/console/src/components/WorkloadCell.tsx b/apps/console/src/components/WorkloadCell.tsx index fa40a90..87c0104 100644 --- a/apps/console/src/components/WorkloadCell.tsx +++ b/apps/console/src/components/WorkloadCell.tsx @@ -15,7 +15,7 @@ interface WorkloadCellProps { /** * Renders a consuming workload (the owning application) as a deep-link to its - * Console page, with the kind shown as a subtitle. The link is only active for + * Console Workloads tab, with the kind shown as a subtitle. The link is only active for * real app instances: the kind must resolve to a plural via ApplicationDefinitions * and the workload must live in a tenant namespace (so the Console tenant * context can be switched on click). Shared by every per-resource drill-down. @@ -34,7 +34,7 @@ export function WorkloadCell({ namespace, kind, name }: WorkloadCellProps) { const tenant = namespace.startsWith(TENANT_NAMESPACE_PREFIX) ? namespace.slice(TENANT_NAMESPACE_PREFIX.length) : null - const href = plural && tenant ? `/console/${plural}/${name}` : null + const href = plural && tenant ? `/console/${plural}/${name}/workloads` : null return ( <> diff --git a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx index 101580e..f47f9a6 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx @@ -137,7 +137,7 @@ describe("ClusterUsageResourcePage", () => { renderResource(client, GPU) const link = await screen.findByRole("link", { name: "demo-vm" }) - expect(link).toHaveAttribute("href", "/console/vminstances/demo-vm") + expect(link).toHaveAttribute("href", "/console/vminstances/demo-vm/workloads") }) it("does not link a consumer whose kind is not a known application", async () => { diff --git a/apps/console/src/routes/StorageClassUsagePage.test.tsx b/apps/console/src/routes/StorageClassUsagePage.test.tsx index d5cf801..430f5ad 100644 --- a/apps/console/src/routes/StorageClassUsagePage.test.tsx +++ b/apps/console/src/routes/StorageClassUsagePage.test.tsx @@ -107,7 +107,7 @@ describe("StorageClassUsagePage", () => { ) renderPage(client, "replicated") const link = await screen.findByRole("link", { name: "demo-vm" }) - expect(link).toHaveAttribute("href", "/console/vminstances/demo-vm") + expect(link).toHaveAttribute("href", "/console/vminstances/demo-vm/workloads") }) it("shows an empty state when nothing uses the class", async () => { From 508deaa3ad20b59e49b2f05a54936d039c719f83 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 2 Jun 2026 13:15:25 +0300 Subject: [PATCH 15/19] fix(console): guard the Capacity admin routes on nodes/list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Admin portal lets a user in if they can use either area, so a backup-only operator (backupclasses/update but not nodes/list) passed the portal gate and could open the Capacity pages by direct URL — some of which then issued cluster-wide pod/PVC lists they were never gated for. Wrap the capacity/* routes in a CapacityAdminGuard that mirrors the Backup Classes guard and gates on nodes/list, and route useAdminAccess through the shared useClusterUsageAccess hook so the gate and the guard agree. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- .../src/hooks/useClusterUsageAccess.ts | 16 +++++ .../src/routes/AdminPage.routing.test.tsx | 23 ++++++ apps/console/src/routes/AdminPage.tsx | 28 ++++---- .../src/routes/CapacityAdminGuard.test.tsx | 70 +++++++++++++++++++ .../console/src/routes/CapacityAdminGuard.tsx | 39 +++++++++++ apps/console/src/routes/sidebar-sections.tsx | 18 ++--- 6 files changed, 170 insertions(+), 24 deletions(-) create mode 100644 apps/console/src/hooks/useClusterUsageAccess.ts create mode 100644 apps/console/src/routes/CapacityAdminGuard.test.tsx create mode 100644 apps/console/src/routes/CapacityAdminGuard.tsx diff --git a/apps/console/src/hooks/useClusterUsageAccess.ts b/apps/console/src/hooks/useClusterUsageAccess.ts new file mode 100644 index 0000000..3679493 --- /dev/null +++ b/apps/console/src/hooks/useClusterUsageAccess.ts @@ -0,0 +1,16 @@ +import { useSelfSubjectAccessReview } from "@cozystack/k8s-client" + +// The Capacity area (Cluster / Nodes / Storage and the per-resource +// drill-downs) reads cluster-scoped objects — nodes, and pods/PVCs across every +// tenant namespace — that tenant users cannot list. Gate the whole area on +// `nodes/list` as the "cluster operator" proxy. Fail closed: loading and error +// resolve as "not allowed" so the sidebar entry never flickers in then out. +export function useClusterUsageAccess(): { allowed: boolean; isLoading: boolean } { + const review = useSelfSubjectAccessReview({ + resourceAttributes: { resource: "nodes", verb: "list" }, + }) + return { + isLoading: review.isLoading, + allowed: !review.isLoading && !review.error && review.allowed, + } +} diff --git a/apps/console/src/routes/AdminPage.routing.test.tsx b/apps/console/src/routes/AdminPage.routing.test.tsx index 70f3c87..14201f4 100644 --- a/apps/console/src/routes/AdminPage.routing.test.tsx +++ b/apps/console/src/routes/AdminPage.routing.test.tsx @@ -66,4 +66,27 @@ describe("AdminPage routing & access gate", () => { await screen.findByText(/you do not have permission to access the admin portal/i), ).toBeInTheDocument() }) + + it("guards capacity routes for a backup-only operator hitting a capacity URL", async () => { + // Passes the portal-level gate via backupclasses/update, but the capacity + // area must still be closed without nodes/list. + renderWithK8sProvider(, { + client: makeClient({ nodes: false, backupclasses: true }), + initialRoute: "/capacity/cluster", + }) + expect( + await screen.findByText(/you do not have permission to view cluster capacity/i), + ).toBeInTheDocument() + expect(screen.queryByText("Cluster")).not.toBeInTheDocument() + }) + + it("guards backup-class routes for a capacity-only operator hitting a backups URL", async () => { + renderWithK8sProvider(, { + client: makeClient({ nodes: true, backupclasses: false }), + initialRoute: "/backups/backupclasses", + }) + expect( + await screen.findByText(/you do not have permission to manage backup classes/i), + ).toBeInTheDocument() + }) }) diff --git a/apps/console/src/routes/AdminPage.tsx b/apps/console/src/routes/AdminPage.tsx index 27937c0..50c2437 100644 --- a/apps/console/src/routes/AdminPage.tsx +++ b/apps/console/src/routes/AdminPage.tsx @@ -11,16 +11,16 @@ import { BackupClassCreatePage } from "./BackupClassCreatePage.tsx" import { BackupClassDetailPage } from "./BackupClassDetailPage.tsx" import { BackupClassEditPage } from "./BackupClassEditPage.tsx" import { BackupClassAdminGuard } from "./BackupClassAdminGuard.tsx" +import { CapacityAdminGuard } from "./CapacityAdminGuard.tsx" /** - * Admin portal: cluster-wide operator views moved out of the tenant-facing - * Console — Cluster Usage and the Backup Classes management added in - * cozystack-ui#21. Mounted at /admin/* and gated by useAdminAccess (a user - * reaches the portal if they can use at least one area). While the access - * review is in flight we show a spinner, and a fully-denied review renders a - * 403 notice instead of leaking any admin screen. Each area additionally - * guards itself (the Cluster Usage page on nodes/list, the Backup Classes - * routes via BackupClassAdminGuard on backupclasses/update). + * Admin portal at /admin/*, hosting two cluster-wide operator areas with + * independent permissions: Capacity (nodes/list) and Backup Classes + * (backupclasses/update). useAdminAccess lets a user in if they hold either, + * so the portal-level gate alone would let a backup-only operator reach a + * Capacity URL — hence each area is wrapped in its own layout guard that closes + * the direct-URL hole the sidebar already hides. While the review is in flight + * we show a spinner; a user with neither area gets a 403 notice. */ export function AdminPage() { const { allowed, isLoading, canClusterUsage } = useAdminAccess() @@ -60,11 +60,13 @@ export function AdminPage() { /> } /> - } /> - } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + }> } /> } /> diff --git a/apps/console/src/routes/CapacityAdminGuard.test.tsx b/apps/console/src/routes/CapacityAdminGuard.test.tsx new file mode 100644 index 0000000..4956e48 --- /dev/null +++ b/apps/console/src/routes/CapacityAdminGuard.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from "vitest" +import { screen, waitFor } from "@testing-library/react" +import { Route, Routes } from "react-router" +import { K8sClient, K8sApiError } from "@cozystack/k8s-client" +import { renderWithK8sProvider } from "../test-utils/render.tsx" +import { CapacityAdminGuard } from "./CapacityAdminGuard.tsx" + +type SsarOutcome = { allowed: boolean } | "pending" | K8sApiError + +function makeClient(outcome: SsarOutcome): K8sClient { + const client = new K8sClient({ baseUrl: "/mock" }) + vi.spyOn(client, "create").mockImplementation(async (_g, _v, _p, body) => { + if (outcome === "pending") return new Promise(() => {}) as never + if (outcome instanceof K8sApiError) throw outcome + return { ...(body as object), status: { allowed: outcome.allowed } } + }) + return client +} + +function renderGuard(client: K8sClient) { + return renderWithK8sProvider( + + }> + CAPACITY CONTENT} /> + + , + { client, initialRoute: "/cap" }, + ) +} + +describe("CapacityAdminGuard", () => { + it("renders the child route when nodes/list is allowed", async () => { + renderGuard(makeClient({ allowed: true })) + await waitFor(() => + expect(screen.getByText("CAPACITY CONTENT")).toBeInTheDocument(), + ) + }) + + it("renders permission-denied instead of the child route when denied", async () => { + renderGuard(makeClient({ allowed: false })) + await waitFor(() => + expect( + screen.getByText(/do not have permission to view cluster capacity/i), + ).toBeInTheDocument(), + ) + expect(screen.queryByText("CAPACITY CONTENT")).not.toBeInTheDocument() + expect(screen.getByRole("link", { name: /back to console/i })).toHaveAttribute( + "href", + "/console", + ) + }) + + it("fails closed (denied) on SSAR error", async () => { + renderGuard(makeClient(new K8sApiError(500, "boom"))) + await waitFor(() => + expect( + screen.getByText(/do not have permission to view cluster capacity/i), + ).toBeInTheDocument(), + ) + expect(screen.queryByText("CAPACITY CONTENT")).not.toBeInTheDocument() + }) + + it("shows neither content nor denial while the review is loading", () => { + renderGuard(makeClient("pending")) + expect(screen.queryByText("CAPACITY CONTENT")).not.toBeInTheDocument() + expect( + screen.queryByText(/do not have permission to view cluster capacity/i), + ).not.toBeInTheDocument() + }) +}) diff --git a/apps/console/src/routes/CapacityAdminGuard.tsx b/apps/console/src/routes/CapacityAdminGuard.tsx new file mode 100644 index 0000000..7941aa3 --- /dev/null +++ b/apps/console/src/routes/CapacityAdminGuard.tsx @@ -0,0 +1,39 @@ +import { Link, Outlet } from "react-router" +import { Section, Spinner } from "@cozystack/ui" +import { useClusterUsageAccess } from "../hooks/useClusterUsageAccess.ts" + +/** + * Layout route guard for the Capacity pages (Cluster / Nodes / Storage and the + * per-resource drill-downs). Renders the matched child route only for users who + * may list cluster nodes; everyone else gets a permission-denied message + * instead of the page (and instead of a browser 403 on direct URL navigation). + */ +export function CapacityAdminGuard() { + const { allowed, isLoading } = useClusterUsageAccess() + + if (isLoading) { + return ( +
+ Loading… +
+ ) + } + + if (!allowed) { + return ( +
+
+
+ You do not have permission to view cluster capacity.{" "} + + Back to console + + . +
+
+
+ ) + } + + return +} diff --git a/apps/console/src/routes/sidebar-sections.tsx b/apps/console/src/routes/sidebar-sections.tsx index 6d0ed5e..b223b2e 100644 --- a/apps/console/src/routes/sidebar-sections.tsx +++ b/apps/console/src/routes/sidebar-sections.tsx @@ -16,8 +16,8 @@ import { type LucideIcon, } from "lucide-react" import type { SidebarSection } from "@cozystack/ui" -import { useSelfSubjectAccessReview } from "@cozystack/k8s-client" import { useBackupClassAdminAccess } from "../hooks/useBackupClassAdminAccess.ts" +import { useClusterUsageAccess } from "../hooks/useClusterUsageAccess.ts" import { useApplicationDefinitions, groupByCategory } from "../lib/app-definitions.ts" import { humanizeKind } from "../lib/humanize.ts" import { @@ -139,15 +139,12 @@ export function useAdminAccess(): { canClusterUsage: boolean canBackupClasses: boolean } { - const nodesReview = useSelfSubjectAccessReview({ - resourceAttributes: { resource: "nodes", verb: "list" }, - }) + const clusterUsage = useClusterUsageAccess() const backupClasses = useBackupClassAdminAccess() - const canClusterUsage = - !nodesReview.isLoading && !nodesReview.error && nodesReview.allowed + const canClusterUsage = clusterUsage.allowed const canBackupClasses = backupClasses.allowed return { - isLoading: nodesReview.isLoading || backupClasses.isLoading, + isLoading: clusterUsage.isLoading || backupClasses.isLoading, allowed: canClusterUsage || canBackupClasses, canClusterUsage, canBackupClasses, @@ -160,10 +157,9 @@ export function useCanSeeAdmin(): boolean { } /** - * Admin sidebar: cluster-wide operator views moved out of the tenant-facing - * Console — Cluster Usage and Backup Classes (the cluster-administration - * backups added in cozystack-ui#21). Each entry is gated by its own - * permission so the sidebar never shows an area the user cannot open. + * Admin sidebar: the cluster-wide operator areas (Capacity and Backup Classes). + * Each entry is gated by its own permission so the sidebar never shows an area + * the user cannot open. */ export function useAdminSidebarSections(): SidebarSection[] { const { canClusterUsage, canBackupClasses } = useAdminAccess() From 20e7aec1f6f9f220f2062657e1911f30349beb94 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 2 Jun 2026 13:15:34 +0300 Subject: [PATCH 16/19] fix(console): surface load errors in the cluster storage panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClusterStorageSection handled only loading and empty states, so a failed or forbidden PersistentVolumeClaim list rendered as "No persistent volume claims found" — indistinguishable from an actually-empty cluster. Add an error branch that shows a permission notice on 403 and the failure message otherwise. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- .../ClusterStorageSection.test.tsx | 26 +++++- .../cluster-usage/ClusterStorageSection.tsx | 85 +++++++++++-------- 2 files changed, 74 insertions(+), 37 deletions(-) diff --git a/apps/console/src/components/cluster-usage/ClusterStorageSection.test.tsx b/apps/console/src/components/cluster-usage/ClusterStorageSection.test.tsx index ebad720..f65ab0e 100644 --- a/apps/console/src/components/cluster-usage/ClusterStorageSection.test.tsx +++ b/apps/console/src/components/cluster-usage/ClusterStorageSection.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest" import { screen, within, waitFor } from "@testing-library/react" -import { K8sClient, type K8sList } from "@cozystack/k8s-client" +import { K8sClient, K8sApiError, type K8sList } from "@cozystack/k8s-client" import { ClusterStorageSection } from "./ClusterStorageSection.tsx" import { renderWithK8sProvider } from "../../test-utils/render.tsx" @@ -28,6 +28,12 @@ function makeClient(pvcs: unknown[]): K8sClient { return client } +function makeFailingClient(error: Error): K8sClient { + const client = new K8sClient() + vi.spyOn(client, "list").mockRejectedValue(error) + return client +} + describe("ClusterStorageSection", () => { it("aggregates tenant PVCs by storage class and excludes non-tenant namespaces", async () => { const client = makeClient([ @@ -56,6 +62,24 @@ describe("ClusterStorageSection", () => { await screen.findByText(/no persistent volume claims found/i), ).toBeInTheDocument() }) + + it("shows a permission notice when the PVC list is forbidden", async () => { + // A 403 must not be silently rendered as an empty "no PVCs found" state. + const client = makeFailingClient(new K8sApiError(403, { message: "forbidden" })) + renderWithK8sProvider(, { client }) + expect( + await screen.findByText(/you do not have permission to view persistent volume claims/i), + ).toBeInTheDocument() + expect(screen.queryByText(/no persistent volume claims found/i)).not.toBeInTheDocument() + }) + + it("shows a failure notice when the PVC list errors", async () => { + const client = makeFailingClient(new K8sApiError(500, { message: "boom" })) + renderWithK8sProvider(, { client }) + expect( + await screen.findByText(/failed to load persistent volume claims/i), + ).toBeInTheDocument() + }) }) async function waitForRow(container: HTMLElement, sc: string): Promise { diff --git a/apps/console/src/components/cluster-usage/ClusterStorageSection.tsx b/apps/console/src/components/cluster-usage/ClusterStorageSection.tsx index 184bf6b..6e9f1b9 100644 --- a/apps/console/src/components/cluster-usage/ClusterStorageSection.tsx +++ b/apps/console/src/components/cluster-usage/ClusterStorageSection.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react" import { Link } from "react-router" import { Section, Spinner } from "@cozystack/ui" -import { useK8sList } from "@cozystack/k8s-client" +import { useK8sList, K8sApiError } from "@cozystack/k8s-client" import { parseQuantity, humanizeBytes } from "../../lib/k8s-quantity.ts" import { TENANT_NAMESPACE_PREFIX } from "../../lib/constants.ts" import type { Pvc } from "../../lib/cluster-usage/types.ts" @@ -24,7 +24,7 @@ const NO_CLASS = "(no class)" * per-class drill-down of the consuming workloads. */ export function ClusterStorageSection() { - const { data, isLoading } = useK8sList({ + const { data, isLoading, error } = useK8sList({ apiGroup: "", apiVersion: "v1", plural: "persistentvolumeclaims", @@ -58,6 +58,19 @@ export function ClusterStorageSection() { ) } + if (error) { + const forbidden = error instanceof K8sApiError && error.status === 403 + return ( +
+

+ {forbidden + ? "You do not have permission to view persistent volume claims." + : `Failed to load persistent volume claims: ${error.message}`} +

+
+ ) + } + if (rows.length === 0) { return (
@@ -71,40 +84,40 @@ export function ClusterStorageSection() { return (
- - - - - - + + + + + + + + + + {rows.map((r) => ( + + + + + - - - {rows.map((r) => ( - - - - - - - ))} - -
Storage ClassPVCsRequestedBound
Storage ClassPVCsRequestedBound
+ {r.storageClass === NO_CLASS ? ( + {r.storageClass} + ) : ( + + {r.storageClass} + + )} + {r.pvcs} + {humanizeBytes(r.requested)} + + {r.bound > 0 ? humanizeBytes(r.bound) : "—"} +
- {r.storageClass === NO_CLASS ? ( - {r.storageClass} - ) : ( - - {r.storageClass} - - )} - {r.pvcs} - {humanizeBytes(r.requested)} - - {r.bound > 0 ? humanizeBytes(r.bound) : "—"} -
-
+ ))} +
+ ) } From 2b799fa6893fe46a5ca1a13b6d8cb8dec4c3450c Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 2 Jun 2026 13:15:35 +0300 Subject: [PATCH 17/19] refactor(console): drop the orphaned ResourceCard component ClusterUsageAggregates now renders the resources table plus ClusterUsageGauges and no longer uses ResourceCard, leaving the component and its test with no production consumer. Remove both. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- .../cluster-usage/ResourceCard.test.tsx | 76 --------- .../components/cluster-usage/ResourceCard.tsx | 144 ------------------ 2 files changed, 220 deletions(-) delete mode 100644 apps/console/src/components/cluster-usage/ResourceCard.test.tsx delete mode 100644 apps/console/src/components/cluster-usage/ResourceCard.tsx diff --git a/apps/console/src/components/cluster-usage/ResourceCard.test.tsx b/apps/console/src/components/cluster-usage/ResourceCard.test.tsx deleted file mode 100644 index 86f030b..0000000 --- a/apps/console/src/components/cluster-usage/ResourceCard.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect } from "vitest" -import { render, screen } from "@testing-library/react" -import { ResourceCard } from "./ResourceCard.tsx" - -describe("ResourceCard", () => { - it("renders the title verbatim", () => { - render( - , - ) - expect(screen.getByText("nvidia.com/gpu")).toBeInTheDocument() - }) - - it("renders capacity and allocatable for any resource", () => { - render( - , - ) - expect(screen.getByText(/capacity/i)).toBeInTheDocument() - expect(screen.getByText(/allocatable/i)).toBeInTheDocument() - }) - - it("omits the Used line when used is undefined", () => { - render( - , - ) - expect(screen.queryByText(/used/i)).toBeNull() - }) - - it("renders the Used line when used is defined", () => { - render( - , - ) - expect(screen.getByText(/used/i)).toBeInTheDocument() - }) - - it("renders an em dash for divide-by-zero (allocatable=0)", () => { - render( - , - ) - expect(screen.getAllByText("—").length).toBeGreaterThan(0) - }) - - it("clamps percentage display at 100% for over-committed resources", () => { - render( - , - ) - const bars = document.querySelectorAll('[role="progressbar"]') - const requestedBar = Array.from(bars).find( - (b) => b.getAttribute("data-resource-bar") === "requested", - ) - expect(requestedBar?.getAttribute("aria-valuenow")).toBe("100") - }) -}) diff --git a/apps/console/src/components/cluster-usage/ResourceCard.tsx b/apps/console/src/components/cluster-usage/ResourceCard.tsx deleted file mode 100644 index eba7e2d..0000000 --- a/apps/console/src/components/cluster-usage/ResourceCard.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { humanizeBytes, humanizeCpu } from "../../lib/k8s-quantity.ts" -import type { ResourceTotals } from "../../lib/cluster-usage/types.ts" - -export type ResourceFormat = "cpu" | "bytes" | "count" - -interface ResourceCardProps { - title: string - format: ResourceFormat - totals: ResourceTotals - /** - * When true, the Requested figure is treated as unknown (cluster-wide - * pod read access was denied or the request failed). The numeric value - * is replaced with an em dash and a tooltip explains why. - */ - requestedUnavailable?: boolean -} - -function formatValue(value: number, format: ResourceFormat): string { - switch (format) { - case "cpu": - return humanizeCpu(value) - case "bytes": - return humanizeBytes(value) - case "count": - default: - return value % 1 === 0 ? `${value}` : value.toFixed(2) - } -} - -function percent(value: number, allocatable: number): number | null { - if (allocatable <= 0) return null - return Math.min(100, Math.round((value / allocatable) * 100)) -} - -function barColorClass(pct: number | null): string { - if (pct === null) return "bg-slate-300" - if (pct > 90) return "bg-red-500" - if (pct > 70) return "bg-amber-500" - return "bg-blue-500" -} - -interface ProgressBarProps { - pct: number | null - resourceBar: "requested" | "used" - ariaLabel: string -} - -function ProgressBar({ pct, resourceBar, ariaLabel }: ProgressBarProps) { - return ( -
-
-
- ) -} - -/** - * A single aggregate-resource card showing capacity, allocatable, and - * up to two progress bars: requested (always rendered when allocatable - * is non-zero) and used (rendered only when totals.used is defined, - * which happens for cpu/memory when metrics.k8s.io is discovered). - * - * A zero-allocatable resource renders em dashes for every number and - * no progress bar — that combination is rare but represents nodes that - * have not yet reported their capacity, and crashing the panel is much - * worse than rendering placeholders. - */ -export function ResourceCard({ - title, - format, - totals, - requestedUnavailable = false, -}: ResourceCardProps) { - const allocatableZero = totals.allocatable <= 0 - const requestedPct = percent(totals.requested, totals.allocatable) - const usedDefined = totals.used !== undefined - const usedPct = usedDefined ? percent(totals.used ?? 0, totals.allocatable) : null - const REQUESTED_UNAVAILABLE_REASON = "Requires cluster-wide pod read access" - - return ( -
-
- {title} -
-
-
- Capacity - - {allocatableZero ? "—" : formatValue(totals.capacity, format)} - -
-
- Allocatable - - {allocatableZero ? "—" : formatValue(totals.allocatable, format)} - -
- {usedDefined ? ( -
-
- Used - - {allocatableZero ? "—" : formatValue(totals.used ?? 0, format)} - -
- {!allocatableZero ? ( - - ) : null} -
- ) : null} -
-
- Requested - - {requestedUnavailable || allocatableZero - ? "—" - : formatValue(totals.requested, format)} - -
- {!allocatableZero && !requestedUnavailable ? ( - - ) : null} -
-
-
- ) -} From 0937d759729e5a6064b2204f23f6aabfbc7c6588 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 2 Jun 2026 13:15:42 +0300 Subject: [PATCH 18/19] docs(console): correct the workload-ownership fallback comment workloadOwner also tries the app.kubernetes.io/name label between instance and the bare-name fallback; the ClusterUsageResourcePage comment omitted it. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- apps/console/src/routes/ClusterUsageResourcePage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/console/src/routes/ClusterUsageResourcePage.tsx b/apps/console/src/routes/ClusterUsageResourcePage.tsx index 0810392..395d45e 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.tsx @@ -17,8 +17,8 @@ import type { Pod } from "../lib/cluster-usage/types.ts" * * Ownership is read from pod labels — Cozystack stamps * `apps.cozystack.io/application.{kind,name}` on every workload pod; we - * fall back to the Helm `app.kubernetes.io/instance` label and finally to - * the bare pod name so nothing is silently dropped. + * fall back to the Helm `app.kubernetes.io/{instance,name}` labels and finally + * to the bare pod name so nothing is silently dropped. * * The resource key arrives via a splat param (`cluster-usage/r/*`) so keys * containing slashes (every `vendor.com/model` GPU name) survive routing From 5ffc781486b8e3c079aba8c1730fbb973375dc8b Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 2 Jun 2026 13:29:09 +0300 Subject: [PATCH 19/19] fix(console): reconcile the resource drill-down with the cluster aggregate Clicking a resource's cluster-wide Requested figure opened a drill-down that counted differently: it summed requests-or-limits, with no scheduled or terminal-phase filter, so a limits-only or terminal/unscheduled pod inflated the breakdown that was meant to itemize that number. Share the aggregate's inclusion rule (scheduled to a known node, non-terminal, requests only) via a podCountsTowardRequested helper and apply it in both places. The drill-down stays tenant-scoped, now stated in the copy as the tenant portion of the cluster figure rather than silently disagreeing with it. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- .../src/lib/cluster-usage/aggregate.ts | 22 ++++-- .../routes/ClusterUsageResourcePage.test.tsx | 75 ++++++++++++++++++- .../src/routes/ClusterUsageResourcePage.tsx | 52 +++++++------ 3 files changed, 117 insertions(+), 32 deletions(-) diff --git a/apps/console/src/lib/cluster-usage/aggregate.ts b/apps/console/src/lib/cluster-usage/aggregate.ts index fa5c4b5..5b6b40c 100644 --- a/apps/console/src/lib/cluster-usage/aggregate.ts +++ b/apps/console/src/lib/cluster-usage/aggregate.ts @@ -14,6 +14,21 @@ function emptyTotals(): ResourceTotals { return { capacity: 0, allocatable: 0, requested: 0 } } +/** + * Whether a pod contributes to requested totals: it must be scheduled to a + * known node and not terminal. Terminal pods (Succeeded/Failed) still appear in + * API lists but no longer hold schedulable requests, and unscheduled/orphaned + * pods aren't attributable to cluster capacity — counting either would inflate + * the totals. Shared with the per-resource drill-down so its "Requested" tally + * reconciles with this aggregate (only requests count, never limits). + */ +export function podCountsTowardRequested(pod: Pod, knownNodes: Set): boolean { + const nodeName = pod.spec?.nodeName + if (!nodeName || !knownNodes.has(nodeName)) return false + const phase = pod.status?.phase + return phase !== "Succeeded" && phase !== "Failed" +} + /** * Computes cluster-wide totals for every standard and extended resource. * @@ -57,12 +72,7 @@ export function aggregateNodeResources( } for (const pod of pods) { - const nodeName = pod.spec?.nodeName - if (!nodeName || !knownNodes.has(nodeName)) continue - // Terminal pods still appear in API lists but no longer hold schedulable - // requests; counting them would inflate the requested totals. - const phase = pod.status?.phase - if (phase === "Succeeded" || phase === "Failed") continue + if (!podCountsTowardRequested(pod, knownNodes)) continue for (const container of pod.spec?.containers ?? []) { const requests = container.resources?.requests if (!requests) continue diff --git a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx index f47f9a6..ef42c24 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx @@ -3,26 +3,47 @@ import { screen, within } from "@testing-library/react" import { Route, Routes } from "react-router" import { K8sClient, type K8sList } from "@cozystack/k8s-client" import { ClusterUsageResourcePage } from "./ClusterUsageResourcePage.tsx" +import { aggregateNodeResources } from "../lib/cluster-usage/aggregate.ts" +import { humanizeCpu } from "../lib/k8s-quantity.ts" +import type { Node, Pod } from "../lib/cluster-usage/types.ts" import { TenantProvider } from "../lib/tenant-context.tsx" import { renderWithK8sProvider } from "../test-utils/render.tsx" +interface PodOpts { + nodeName?: string | null + phase?: string + limits?: Record[] +} + function pod( namespace: string, name: string, labels: Record, requests: Record[], + opts: PodOpts = {}, ) { + const { nodeName = "node-1", phase = "Running", limits } = opts return { apiVersion: "v1", kind: "Pod", metadata: { name, namespace, labels }, spec: { + ...(nodeName ? { nodeName } : {}), containers: requests.map((r, i) => ({ name: `c${i}`, - resources: { requests: r }, + resources: { requests: r, ...(limits?.[i] ? { limits: limits[i] } : {}) }, })), }, - status: { phase: "Running" }, + status: { phase }, + } +} + +function node(name: string) { + return { + apiVersion: "v1", + kind: "Node", + metadata: { name }, + status: { capacity: {}, allocatable: {} }, } } @@ -36,8 +57,13 @@ function appDef(kind: string, plural: string) { } const GPU = "nvidia.com/gpu" +const DEFAULT_NODES = [node("node-1")] -function makeClient(pods: unknown[], appDefs: unknown[] = []): K8sClient { +function makeClient( + pods: unknown[], + appDefs: unknown[] = [], + nodes: unknown[] = DEFAULT_NODES, +): K8sClient { const client = new K8sClient() vi.spyOn(client, "list").mockImplementation(async (_g, _v, plural) => { const items = @@ -45,7 +71,9 @@ function makeClient(pods: unknown[], appDefs: unknown[] = []): K8sClient { ? appDefs : plural === "tenantnamespaces" ? [] - : pods + : plural === "nodes" + ? nodes + : pods return { apiVersion: "v1", kind: `${plural}List`, @@ -173,4 +201,43 @@ describe("ClusterUsageResourcePage", () => { renderResource(client, GPU) expect(await screen.findByRole("heading", { name: GPU })).toBeInTheDocument() }) + + it("counts requested like the aggregate: requests only, scheduled non-terminal pods", async () => { + const pods = [ + pod("tenant-foo", "alpha-0", { "app.kubernetes.io/instance": "alpha" }, [{ cpu: "500m" }]), + pod("tenant-foo", "beta-0", { "app.kubernetes.io/instance": "beta" }, [{ cpu: "250m" }]), + // limits-only → excluded (the aggregate counts requests, never limits) + pod("tenant-foo", "gamma-0", { "app.kubernetes.io/instance": "gamma" }, [{}], { + limits: [{ cpu: "1" }], + }), + // terminal → excluded + pod("tenant-foo", "delta-0", { "app.kubernetes.io/instance": "delta" }, [{ cpu: "1" }], { + phase: "Succeeded", + }), + // unscheduled → excluded + pod("tenant-foo", "epsilon-0", { "app.kubernetes.io/instance": "epsilon" }, [{ cpu: "1" }], { + nodeName: null, + }), + // scheduled to an unknown node → excluded + pod("tenant-foo", "zeta-0", { "app.kubernetes.io/instance": "zeta" }, [{ cpu: "1" }], { + nodeName: "ghost", + }), + ] + const nodes = [node("node-1")] + renderResource(makeClient(pods, [], nodes), "cpu") + + // The displayed total reconciles with aggregateNodeResources over the same + // input (all pods are tenant-scoped here, so the subset equals the whole). + const expected = aggregateNodeResources(nodes as Node[], pods as Pod[], undefined).standard.cpu + .requested + const totalRow = (await screen.findByText(/tenant total/i)).closest("tr") as HTMLElement + const totalCells = totalRow.querySelectorAll("td") + expect(totalCells[totalCells.length - 1].textContent).toBe(humanizeCpu(expected)) + + expect(screen.getByText("alpha")).toBeInTheDocument() + expect(screen.getByText("beta")).toBeInTheDocument() + for (const excluded of ["gamma", "delta", "epsilon", "zeta"]) { + expect(screen.queryByText(excluded)).toBeNull() + } + }) }) diff --git a/apps/console/src/routes/ClusterUsageResourcePage.tsx b/apps/console/src/routes/ClusterUsageResourcePage.tsx index 395d45e..4e3f4ae 100644 --- a/apps/console/src/routes/ClusterUsageResourcePage.tsx +++ b/apps/console/src/routes/ClusterUsageResourcePage.tsx @@ -5,15 +5,20 @@ import { useK8sList } from "@cozystack/k8s-client" import { ChevronLeft } from "lucide-react" import { parseQuantity, humanizeBytes, humanizeCpu } from "../lib/k8s-quantity.ts" import { workloadOwner } from "../lib/workload.ts" +import { podCountsTowardRequested } from "../lib/cluster-usage/aggregate.ts" import { TENANT_NAMESPACE_PREFIX } from "../lib/constants.ts" import { WorkloadCell } from "../components/WorkloadCell.tsx" -import type { Pod } from "../lib/cluster-usage/types.ts" +import type { Node, Pod } from "../lib/cluster-usage/types.ts" /** * Admin → Resources → per-resource drill-down. Given a resource key * (e.g. `cpu`, `memory`, or an extended resource like - * `nvidia.com/GH100_H200_SXM_141GB`) this lists who consumes it across the - * whole cluster, grouped by tenant namespace and the owning application. + * `nvidia.com/GH100_H200_SXM_141GB`) this lists the tenant workloads requesting + * it, grouped by namespace and owning application. To stay consistent with the + * Cluster page headline it counts the same way the aggregate does — requests + * only (never limits), scheduled non-terminal pods only — but scoped to tenant + * namespaces, so the Total is the tenant portion of the cluster-wide figure + * (system/control-plane usage is excluded). * * Ownership is read from pod labels — Cozystack stamps * `apps.cozystack.io/application.{kind,name}` on every workload pod; we @@ -41,13 +46,11 @@ function formatResource(resource: string, value: number): string { return value % 1 === 0 ? `${value}` : value.toFixed(2) } -/** Sum a single resource across all of a pod's containers (requests, then limits). */ -function podResourceRequest(pod: Pod, resource: string): number { +/** Sum a single resource's requests across a pod's containers (requests only). */ +function podRequestedResource(pod: Pod, resource: string): number { let total = 0 for (const container of pod.spec?.containers ?? []) { - const req = container.resources?.requests?.[resource] - const lim = container.resources?.limits?.[resource] - const value = req ?? lim + const value = container.resources?.requests?.[resource] if (value !== undefined) total += parseQuantity(value) } return total @@ -57,21 +60,24 @@ export function ClusterUsageResourcePage() { const params = useParams() const resource = params["*"] ?? "" - const { - data: podsList, - isLoading, - error, - } = useK8sList({ apiGroup: "", apiVersion: "v1", plural: "pods" }) + const pods = useK8sList({ apiGroup: "", apiVersion: "v1", plural: "pods" }) + const nodes = useK8sList({ apiGroup: "", apiVersion: "v1", plural: "nodes" }) + const isLoading = pods.isLoading || nodes.isLoading + const error = pods.error ?? nodes.error const { rows, totalRequested } = useMemo(() => { + const knownNodes = new Set((nodes.data?.items ?? []).map((n) => n.metadata.name)) const byKey = new Map() let totalRequested = 0 - for (const pod of podsList?.items ?? []) { - const requested = podResourceRequest(pod, resource) + for (const pod of pods.data?.items ?? []) { + // Match the aggregate's definition of "requested" so this breakdown + // reconciles with the headline number it drills into. + if (!podCountsTowardRequested(pod, knownNodes)) continue + const requested = podRequestedResource(pod, resource) if (requested <= 0) continue const namespace = pod.metadata.namespace ?? "—" - // Only tenant namespaces are relevant here — skip system/control-plane - // namespaces (cozy-*, kube-system, …) that also consume the resource. + // Tenant-scoped: skip system/control-plane namespaces (cozy-*, kube-system, + // …). The Total is therefore the tenant portion of the cluster figure. if (!namespace.startsWith(TENANT_NAMESPACE_PREFIX)) continue const { kind, name } = workloadOwner(pod.metadata.labels, pod.metadata.name) const key = `${namespace}/${kind}/${name}` @@ -86,7 +92,7 @@ export function ClusterUsageResourcePage() { } const rows = [...byKey.values()].sort((a, b) => b.requested - a.requested) return { rows, totalRequested } - }, [podsList, resource]) + }, [pods.data, nodes.data, resource]) return (
@@ -101,8 +107,10 @@ export function ClusterUsageResourcePage() { {resource}

- Consumers of this resource across all tenants, grouped by namespace - and owning application (derived from pod labels). + Tenant workloads requesting this resource, grouped by namespace and + owning application (derived from pod labels). System and control-plane + usage is excluded, so the total is the tenant portion of the + cluster-wide figure.

@@ -113,7 +121,7 @@ export function ClusterUsageResourcePage() { ) : error ? (
- Failed to load pods: {error.message} + Failed to load cluster usage: {error.message}
) : rows.length === 0 ? ( @@ -151,7 +159,7 @@ export function ClusterUsageResourcePage() { - Total · {rows.length} workload{rows.length === 1 ? "" : "s"} + Tenant total · {rows.length} workload{rows.length === 1 ? "" : "s"} {formatResource(resource, totalRequested)}