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/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/WorkloadCell.tsx b/apps/console/src/components/WorkloadCell.tsx new file mode 100644 index 0000000..87c0104 --- /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 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. + */ +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}/workloads` : 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..f65ab0e --- /dev/null +++ b/apps/console/src/components/cluster-usage/ClusterStorageSection.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from "vitest" +import { screen, within, waitFor } from "@testing-library/react" +import { K8sClient, K8sApiError, 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 +} + +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([ + 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("shows an empty state when no tenant PVCs exist", async () => { + const client = makeClient([pvc("cozy-system", "replicated", "100Gi")]) + renderWithK8sProvider(, { client }) + expect( + 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 { + 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..6e9f1b9 --- /dev/null +++ b/apps/console/src/components/cluster-usage/ClusterStorageSection.tsx @@ -0,0 +1,123 @@ +import { useMemo } from "react" +import { Link } from "react-router" +import { Section, Spinner } from "@cozystack/ui" +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" + +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, error } = 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) { + return ( +
+ Loading… +
+ ) + } + + 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 ( +
+

+ No persistent volume claims found. +

+
+ ) + } + + return ( +
+ + + + + + + + + + + {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/ClusterUsageAggregates.test.tsx b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.test.tsx index 17159ed..ccaeb92 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/capacity/cluster/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/capacity/cluster/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..10cce5d 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageAggregates.tsx @@ -1,6 +1,12 @@ -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" +import { ClusterUsageGauges } from "./ClusterUsageGauges.tsx" interface ClusterUsageAggregatesProps { aggregates: AggregateResources @@ -14,13 +20,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 +74,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 +103,70 @@ 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/components/cluster-usage/ClusterUsageGauges.tsx b/apps/console/src/components/cluster-usage/ClusterUsageGauges.tsx new file mode 100644 index 0000000..9a07cf1 --- /dev/null +++ b/apps/console/src/components/cluster-usage/ClusterUsageGauges.tsx @@ -0,0 +1,97 @@ +import { Link } from "react-router" +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 +} + +// `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). */ +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. 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, + podsUnavailable = false, +}: ClusterUsageGaugesProps) { + if (podsUnavailable) return null + + const extendedKeys = Object.keys(aggregates.extended).sort() + 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 (cards.length === 0) return null + + return ( +
+ {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 51fc0be..55a3877 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 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, @@ -25,191 +32,158 @@ 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 +} + +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 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() + expect(nodeCols(container)).toEqual(["worker-a", "worker-b"]) }) - it("shows Ready / NotReady status text", () => { - render( + it("lays out resource rows top-to-bottom: CPU, Memory, then extended in order", () => { + 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(["cpu", "memory", "nvidia.com/gpu", "amd.com/gpu"]) }) - it("shows SchedulingDisabled when schedulable=false", () => { + it("links resource row labels (CPU, Memory, extended) to the per-resource drill-down", () => { render( , ) - expect(screen.getByText(/scheduling.?disabled/i)).toBeInTheDocument() + 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", + ) + // The node name in the header is not a link. + expect(screen.queryByRole("link", { name: "n" })).toBeNull() }) - it("flags pressure conditions with a chip", () => { - render( + it("renders Status / Roles / Age inside each node's column header, not as rows", () => { + const { container } = render( , ) - expect(screen.getByText("MemoryPressure")).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("renders roles inline, em dash for nodes without roles", () => { - render( + it("flags pressure conditions with a chip in the header", () => { + const { container } = render( , ) - expect(screen.getByText("control-plane")).toBeInTheDocument() - const workerRow = screen.getByText("worker").closest("tr")! - expect(within(workerRow).getAllByText("—").length).toBeGreaterThan(0) + expect(within(thead(container)).getByText("MemoryPressure")).toBeInTheDocument() }) - it("adds one column per extended key, in extendedKeys order", () => { - render( - , + it("renders an em dash for a node header without roles", () => { + 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(thead(container)).getByText("—")).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() + expect(within(attrRow(container, "nvidia.com/gpu")).getAllByText("capacity 2")).toHaveLength(1) }) - it("renders em dashes in cpu/memory cells when the node is NotReady", () => { - render( - , + it("renders em dashes in the CPU and Memory rows when the node is NotReady", () => { + const { container } = 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) + expect(within(attrRow(container, "cpu")).getByText("—")).toBeInTheDocument() + expect(within(attrRow(container, "memory")).getByText("—")).toBeInTheDocument() }) - it("toggles the sort direction on a second click of the same column", async () => { + it("hides a node column when filtered out by name (case-insensitive)", async () => { const user = userEvent.setup() - 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() - }) - - it("filters rows by name substring (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") + expect(nodeCols(container)).toEqual(["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") + expect(nodeCols(container)).toEqual(["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..e17143e 100644 --- a/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx +++ b/apps/console/src/components/cluster-usage/ClusterUsageTable.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from "react" -import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react" +import { useMemo, useState, type ReactNode } from "react" +import { Link } from "react-router" import { humanizeBytes, humanizeCpu } from "../../lib/k8s-quantity.ts" import type { NodeRow, ResourceTotals } from "../../lib/cluster-usage/types.ts" @@ -12,31 +12,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 +45,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 +73,7 @@ function extendedCell( ready: boolean, podsUnavailable: boolean, ) { - if (!ready || !totals) return + if (!ready || !totals) return return (
@@ -111,37 +91,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 +135,57 @@ 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" + + // 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 + mono?: boolean + linkKey?: string + render: (r: NodeRow) => ReactNode + }[] = [ + { 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), + })), + ] + return (
@@ -229,92 +198,53 @@ 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) => ( - ))} - ))} 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} -
-
-
- ) -} 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/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/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.routing.test.tsx b/apps/console/src/routes/AdminPage.routing.test.tsx new file mode 100644 index 0000000..14201f4 --- /dev/null +++ b/apps/console/src/routes/AdminPage.routing.test.tsx @@ -0,0 +1,92 @@ +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: "/capacity/cluster", + }) + expect(await screen.findByText("Cluster")).toBeInTheDocument() + }) + + it("redirects the index route to Cluster Usage for an operator", async () => { + renderWithK8sProvider(, { + client: makeClient({ nodes: true }), + initialRoute: "/", + }) + 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: "/capacity/cluster", + }) + expect( + 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 new file mode 100644 index 0000000..50c2437 --- /dev/null +++ b/apps/console/src/routes/AdminPage.tsx @@ -0,0 +1,78 @@ +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 { StorageClassUsagePage } from "./StorageClassUsagePage.tsx" +import { StoragePage } from "./StoragePage.tsx" +import { NodesPage } from "./NodesPage.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" +import { CapacityAdminGuard } from "./CapacityAdminGuard.tsx" + +/** + * 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() + + 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() { >
- + Node +
+
{n.name}
+ {statusContent(n)} + {rolesContent(n)} +
Age {n.age}
+
{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} - - ))} -
+ {attributeRows.map((attr) => ( +
+ {attr.linkKey ? ( + + {attr.label} + ) : ( - + attr.label )} - - - {cpuCell(r.standard.cpu, r.ready, podsUnavailable)} - - {memoryCell(r.standard.memory, r.ready, podsUnavailable)} - - {extendedCell(r.extended[k], r.ready, podsUnavailable)} + + {visibleNodes.map((n) => ( + + {attr.render(n)} {r.age}
{item.metadata.name} 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/ClusterUsagePage.test.tsx b/apps/console/src/routes/ClusterUsagePage.test.tsx index 3275004..4a2da18 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 }) - 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. + const { container } = renderWithK8sProvider(, { client }) + expect(await screen.findByText("Cluster")).toBeInTheDocument() 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 () => { @@ -161,15 +164,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/ClusterUsagePage.tsx b/apps/console/src/routes/ClusterUsagePage.tsx index b59f877..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 { 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,12 +26,11 @@ export function ClusterUsagePage() { errorStatus, podsUnavailable, } = useClusterUsageData() - const extendedKeys = Object.keys(aggregates.extended).sort() return (
-

Cluster Usage

+

Cluster

Cluster-scoped capacity, allocation and usage across all nodes, including any discovered extended resources. @@ -67,21 +64,11 @@ export function ClusterUsagePage() {

No nodes found.

) : ( - <> - -
-

Nodes

- -
- + )}
) diff --git a/apps/console/src/routes/ClusterUsageResourcePage.test.tsx b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx new file mode 100644 index 0000000..ef42c24 --- /dev/null +++ b/apps/console/src/routes/ClusterUsageResourcePage.test.tsx @@ -0,0 +1,243 @@ +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 { 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, ...(limits?.[i] ? { limits: limits[i] } : {}) }, + })), + }, + status: { phase }, + } +} + +function node(name: string) { + return { + apiVersion: "v1", + kind: "Node", + metadata: { name }, + status: { capacity: {}, allocatable: {} }, + } +} + +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" +const DEFAULT_NODES = [node("node-1")] + +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 = + plural === "applicationdefinitions" + ? appDefs + : plural === "tenantnamespaces" + ? [] + : plural === "nodes" + ? nodes + : 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([ + 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() + // Kind is shown as a subtitle within the Workload cell. + expect(within(tr).getByText("VMInstance")).toBeInTheDocument() + const cells = tr.querySelectorAll("td") + // Columns: Tenant | Workload | Requested — last cell is the summed request. + expect(cells[cells.length - 1].textContent).toBe("3") + 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/workloads") + }) + + 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" }, + ]), + ]) + 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() + }) + + 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 new file mode 100644 index 0000000..4e3f4ae --- /dev/null +++ b/apps/console/src/routes/ClusterUsageResourcePage.tsx @@ -0,0 +1,174 @@ +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, 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 { 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 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 + * 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 + * 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'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 value = container.resources?.requests?.[resource] + if (value !== undefined) total += parseQuantity(value) + } + return total +} + +export function ClusterUsageResourcePage() { + const params = useParams() + const resource = params["*"] ?? "" + + 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 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 ?? "—" + // 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}` + 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 + } + const rows = [...byKey.values()].sort((a, b) => b.requested - a.requested) + return { rows, totalRequested } + }, [pods.data, nodes.data, resource]) + + return ( +
+
+ + Cluster + +

+ {resource} +

+

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

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

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

+
+ ) : ( +
+ + + + + + + + + + {rows.map((r) => { + return ( + + + + + + ) + })} + + + + + + + +
Tenant (namespace)WorkloadRequested
{r.namespace} + + + {formatResource(resource, r.requested)} +
+ Tenant total · {rows.length} workload{rows.length === 1 ? "" : "s"} + + {formatResource(resource, totalRequested)} +
+
+ )} +
+ ) +} 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/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/StorageClassUsagePage.test.tsx b/apps/console/src/routes/StorageClassUsagePage.test.tsx new file mode 100644 index 0000000..430f5ad --- /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/workloads") + }) + + 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)} +
+
+ )} +
+ ) +} 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.test.tsx b/apps/console/src/routes/sidebar-sections.test.tsx index a553d70..f3ecca3 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(findItem(result.current, "Cluster Usage")).toBeDefined(), - ) - expect(findItem(result.current, "Cluster Usage")?.to).toBe( - "/console/cluster-usage", - ) + 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")).toBeUndefined() + expect(hasItemTo(result.current, "/console/backups/backupclasses")).toBe(false) }) +}) - it("hides the Cluster Usage entry when SSAR denies nodes list", async () => { - const client = makeClient({ ssar: ssarResponse(false) }) - const { result } = renderHook(() => useConsoleSidebarSections(), { +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), }) - // 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() - }) + await waitFor(() => + expect(findItem(result.current, "Cluster")).toBeDefined(), + ) + expect(findItem(result.current, "Cluster")?.to).toBe("/admin/capacity/cluster") + expect(findItem(result.current, "Backup Classes")?.to).toBe( + "/admin/backups/backupclasses", + ) }) - 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), }) - expect(findItem(result.current, "Cluster Usage")).toBeUndefined() + await waitFor(() => + expect(findItem(result.current, "Backup Classes")).toBeDefined(), + ) + expect(findItem(result.current, "Cluster")).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")).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..b223b2e 100644 --- a/apps/console/src/routes/sidebar-sections.tsx +++ b/apps/console/src/routes/sidebar-sections.tsx @@ -5,17 +5,19 @@ import { Database, Gauge, Globe, + HardDrive, Info, LayoutGrid, Layers, Network, + Server, ToyBrick, Users, 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 { @@ -72,20 +74,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 +114,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 +122,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 clusterUsage = useClusterUsageAccess() + const backupClasses = useBackupClassAdminAccess() + const canClusterUsage = clusterUsage.allowed + const canBackupClasses = backupClasses.allowed + return { + isLoading: clusterUsage.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: 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() + return useMemo(() => { + const sections: SidebarSection[] = [] + if (canClusterUsage) { + sections.push({ + title: "Capacity", + 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 }, + ], + }) + } + if (canBackupClasses) { + sections.push({ + title: "Backups", + items: [ + { label: "Backup Classes", to: "/admin/backups/backupclasses", icon: Archive }, + ], + }) + } + return sections + }, [canClusterUsage, canBackupClasses]) }