Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b2dd4f5
feat(console): vertical Cluster Usage table with per-resource drill-down
kvaps Jun 1, 2026
777a048
feat(console): move Cluster Usage and Backup Classes into a gated Adm…
kvaps Jun 1, 2026
f806239
feat(console): link Cluster Usage consumers to their deployed applica…
kvaps Jun 1, 2026
3e25995
feat(console): split Nodes onto its own Resources tab with a transpos…
kvaps Jun 1, 2026
2906f4b
feat(console): rename Cluster Usage to Resources and pivot resource d…
kvaps Jun 1, 2026
751448e
feat(console): revert resource drill-down to the consumers-by-tenant …
kvaps Jun 1, 2026
a814fa2
feat(console): Capacity section, node resource links, tenant-only con…
kvaps Jun 1, 2026
8bcac19
feat(console): node metadata in column header, clickable usage gauges
kvaps Jun 1, 2026
00cf06d
feat(console): present resource consumers as Workloads
kvaps Jun 1, 2026
baa1fa8
feat(console): Persistent Storage panel + per-StorageClass drill-down
kvaps Jun 1, 2026
089162e
feat(console): move Persistent Storage onto its own Storage tab
kvaps Jun 1, 2026
d3fd9ac
fix(console): link tenant Kubernetes-cluster worker VMs to their app
kvaps Jun 1, 2026
317d816
revert(console): drop CAPI workload special-case (superseded by sourc…
kvaps Jun 1, 2026
cadafff
feat(console): deep-link consumer workloads to the app's Workloads tab
kvaps Jun 2, 2026
508deaa
fix(console): guard the Capacity admin routes on nodes/list
lexfrei Jun 2, 2026
20e7aec
fix(console): surface load errors in the cluster storage panel
lexfrei Jun 2, 2026
2b799fa
refactor(console): drop the orphaned ResourceCard component
lexfrei Jun 2, 2026
0937d75
docs(console): correct the workload-ownership fallback comment
lexfrei Jun 2, 2026
5ffc781
fix(console): reconcile the resource drill-down with the cluster aggr…
lexfrei Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 (
<AppShell
tabs={tabs}
sections={sections}
subtitle={<Breadcrumb />}
onSearchClick={toggle}
Expand All @@ -40,6 +58,7 @@ function Shell({ config, username }: ShellProps) {
<Route path="/" element={<Navigate to="/marketplace" replace />} />
<Route path="/marketplace/*" element={<MarketplacePage />} />
<Route path="/console/*" element={<ConsolePage />} />
<Route path="/admin/*" element={<AdminPage />} />
</Routes>
</AppShell>
)
Expand Down
4 changes: 2 additions & 2 deletions apps/console/src/components/QuotaDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface ResourceQuota extends K8sResource<ResourceQuotaSpec, ResourceQu
kind: "ResourceQuota"
}

interface QuotaEntry {
export interface QuotaEntry {
label: string
usedRaw: string
hardRaw: string
Expand Down Expand Up @@ -162,7 +162,7 @@ interface GaugeCardProps {
index: number
}

function GaugeCard({ entry, index }: GaugeCardProps) {
export function GaugeCard({ entry, index }: GaugeCardProps) {
const stroke = gaugeStrokeColor(entry.pct)
const isOver = entry.usedNum > entry.hardNum

Expand Down
55 changes: 55 additions & 0 deletions apps/console/src/components/WorkloadCell.tsx
Original file line number Diff line number Diff line change
@@ -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-<name> 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 ? (
<Link
to={href}
onClick={() => tenant && selectTenant(tenant)}
className="font-medium text-blue-700 hover:text-blue-800 hover:underline"
>
{name}
</Link>
) : (
<span className="font-medium text-slate-700">{name}</span>
)}
{kind !== "—" ? <div className="text-xs text-slate-400">{kind}</div> : null}
</>
)
}
Original file line number Diff line number Diff line change
@@ -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<unknown>
})
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(<ClusterStorageSection />, { 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(<ClusterStorageSection />, { 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(<ClusterStorageSection />, { 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(<ClusterStorageSection />, { 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(<ClusterStorageSection />, { client })
expect(
await screen.findByText(/failed to load persistent volume claims/i),
).toBeInTheDocument()
})
})

async function waitForRow(container: HTMLElement, sc: string): Promise<HTMLElement> {
await waitFor(() =>
expect(container.querySelector(`[data-storageclass-row="${sc}"]`)).not.toBeNull(),
)
return container.querySelector(`[data-storageclass-row="${sc}"]`) as HTMLElement
}
123 changes: 123 additions & 0 deletions apps/console/src/components/cluster-usage/ClusterStorageSection.tsx
Original file line number Diff line number Diff line change
@@ -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<Pvc>({
apiGroup: "",
apiVersion: "v1",
plural: "persistentvolumeclaims",
})

const rows = useMemo<StorageClassRow[]>(() => {
const byClass = new Map<string, StorageClassRow>()
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 (
<div className="flex items-center gap-2 text-sm text-slate-500">
<Spinner /> Loading…
</div>
)
}

if (error) {
const forbidden = error instanceof K8sApiError && error.status === 403
return (
<Section>
<p className="px-2 py-4 text-sm text-red-700">
{forbidden
? "You do not have permission to view persistent volume claims."
: `Failed to load persistent volume claims: ${error.message}`}
</p>
</Section>
)
}

if (rows.length === 0) {
return (
<Section>
<p className="py-6 text-center text-sm text-slate-500">
No persistent volume claims found.
</p>
</Section>
)
}

return (
<Section>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-left">
<th className="px-3 py-2 font-medium text-slate-600">Storage Class</th>
<th className="px-3 py-2 text-right font-medium text-slate-600">PVCs</th>
<th className="px-3 py-2 text-right font-medium text-slate-600">Requested</th>
<th className="px-3 py-2 text-right font-medium text-slate-600">Bound</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((r) => (
<tr key={r.storageClass} data-storageclass-row={r.storageClass} className="hover:bg-slate-50">
<td className="px-3 py-2">
{r.storageClass === NO_CLASS ? (
<span className="font-medium text-slate-700">{r.storageClass}</span>
) : (
<Link
to={`/admin/capacity/cluster/sc/${r.storageClass}`}
className="font-medium break-all text-blue-700 hover:text-blue-800 hover:underline"
>
{r.storageClass}
</Link>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{r.pvcs}</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-700">
{humanizeBytes(r.requested)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">
{r.bound > 0 ? humanizeBytes(r.bound) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</Section>
)
}
Loading
Loading