Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
07f1a67
feat(k8s-client): allow injecting a custom K8sClient into K8sProvider
lexfrei May 26, 2026
3a23225
chore(console): scaffold K8s-driven test harness
lexfrei May 26, 2026
7181b00
refactor(console): extract K8s quantity helpers from QuotaDisplay
lexfrei May 26, 2026
1c810a1
feat(k8s-client): add getApiGroups and useApiGroupAvailable hook
lexfrei May 26, 2026
e3f1be8
feat(k8s-client): add useSelfSubjectAccessReview hook
lexfrei May 26, 2026
dcd5d91
feat(console): cluster-usage derivation utilities
lexfrei May 26, 2026
f2e65fe
feat(console): add useClusterUsageData composite hook
lexfrei May 26, 2026
9aa41b2
feat(console): ClusterUsageAggregates panel and ResourceCard
lexfrei May 26, 2026
851c98e
feat(console): ClusterUsageTable per-node table
lexfrei May 26, 2026
98f041d
feat(console): compose ClusterUsagePage from aggregates and per-node …
lexfrei May 26, 2026
3b05957
feat(console): permission-gated Cluster Usage sidebar entry and route
lexfrei May 26, 2026
c3e793e
feat(console): cluster-usage acceptance polish
lexfrei May 26, 2026
e229064
chore(console): post-review hardening pass
lexfrei May 26, 2026
da6961c
chore(console): tighten error narrowing and drop redundant slice
lexfrei May 27, 2026
ccbda27
fix(console): guard parseQuantity against NaN from malformed quantities
lexfrei May 27, 2026
6a81052
fix(console): exclude terminal pods from requested aggregation
lexfrei May 27, 2026
4c1e11b
fix(console): collapse extended-resource cells for NotReady nodes
lexfrei May 27, 2026
f90314b
test(console): assert actual ordering in cluster-usage card/column tests
lexfrei May 27, 2026
b06c208
test(console): replace setTimeout flushes with waitFor in sidebar gat…
lexfrei May 27, 2026
a4dc332
test(console): use getAllByTitle instead of direct DOM queries
lexfrei May 27, 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
53 changes: 53 additions & 0 deletions apps/console/src/__tests__/k8s-client/provider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest"
import { render } from "@testing-library/react"
import { K8sClient, K8sProvider, useK8sClient } from "@cozystack/k8s-client"

function ClientCapture({ onClient }: { onClient: (c: K8sClient) => void }) {
const c = useK8sClient()
onClient(c)
return null
}

describe("K8sProvider", () => {
it("passes the injected client through to useK8sClient", () => {
const injected = new K8sClient({ baseUrl: "/injected" })
let captured: K8sClient | null = null
render(
<K8sProvider client={injected}>
<ClientCapture onClient={(c) => (captured = c)} />
</K8sProvider>,
)
expect(captured).toBe(injected)
})

it("constructs its own client when none is injected", () => {
let captured: K8sClient | null = null
render(
<K8sProvider>
<ClientCapture onClient={(c) => (captured = c)} />
</K8sProvider>,
)
expect(captured).toBeInstanceOf(K8sClient)
})

it("constructs a client from the provided config when no client is injected", () => {
let captured: K8sClient | null = null
render(
<K8sProvider config={{ baseUrl: "/from-config" }}>
<ClientCapture onClient={(c) => (captured = c)} />
</K8sProvider>,
)
expect(captured).toBeInstanceOf(K8sClient)
})

it("prefers the injected client over the config when both are supplied", () => {
const injected = new K8sClient({ baseUrl: "/injected" })
let captured: K8sClient | null = null
render(
<K8sProvider client={injected} config={{ baseUrl: "/ignored" }}>
<ClientCapture onClient={(c) => (captured = c)} />
</K8sProvider>,
)
expect(captured).toBe(injected)
})
})
121 changes: 121 additions & 0 deletions apps/console/src/__tests__/k8s-client/useApiGroupAvailable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect, vi } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import {
K8sClient,
K8sProvider,
useApiGroupAvailable,
type APIGroupList,
} from "@cozystack/k8s-client"
import type { ReactNode } from "react"

function makeWrapper(client: K8sClient) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<K8sProvider client={client} queryClient={queryClient}>
{children}
</K8sProvider>
</QueryClientProvider>
)
}
}

const sampleGroups: APIGroupList = {
kind: "APIGroupList",
apiVersion: "v1",
groups: [
{
name: "metrics.k8s.io",
versions: [{ groupVersion: "metrics.k8s.io/v1beta1", version: "v1beta1" }],
preferredVersion: { groupVersion: "metrics.k8s.io/v1beta1", version: "v1beta1" },
},
{
name: "apps",
versions: [{ groupVersion: "apps/v1", version: "v1" }],
preferredVersion: { groupVersion: "apps/v1", version: "v1" },
},
],
}

describe("useApiGroupAvailable", () => {
it("starts in loading state with available=false", () => {
const client = new K8sClient()
vi.spyOn(client, "getApiGroups").mockImplementation(
() => new Promise(() => {}),
)
const { result } = renderHook(() => useApiGroupAvailable("metrics.k8s.io"), {
wrapper: makeWrapper(client),
})
expect(result.current.isLoading).toBe(true)
expect(result.current.available).toBe(false)
})

it("reports available=true when the group is present", async () => {
const client = new K8sClient()
vi.spyOn(client, "getApiGroups").mockResolvedValue(sampleGroups)
const { result } = renderHook(() => useApiGroupAvailable("metrics.k8s.io"), {
wrapper: makeWrapper(client),
})
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.available).toBe(true)
})

it("reports available=false when the group is missing", async () => {
const client = new K8sClient()
vi.spyOn(client, "getApiGroups").mockResolvedValue(sampleGroups)
const { result } = renderHook(() => useApiGroupAvailable("custom.metrics.k8s.io"), {
wrapper: makeWrapper(client),
})
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.available).toBe(false)
})

it("fetches /apis once for multiple consumers", async () => {
const client = new K8sClient()
const spy = vi.spyOn(client, "getApiGroups").mockResolvedValue(sampleGroups)
const Wrapper = makeWrapper(client)

function Twin() {
const a = useApiGroupAvailable("metrics.k8s.io")
const b = useApiGroupAvailable("apps")
return (
<p>
{String(a.available)}-{String(b.available)}
</p>
)
}

const { result: hookA } = renderHook(
() => useApiGroupAvailable("metrics.k8s.io"),
{ wrapper: Wrapper },
)
const { result: hookB } = renderHook(
() => useApiGroupAvailable("apps"),
{ wrapper: Wrapper },
)

await waitFor(() => expect(hookA.current.isLoading).toBe(false))
await waitFor(() => expect(hookB.current.isLoading).toBe(false))

// Both hooks share the same provider and cache, so /apis is called
// exactly once for the lifetime of this provider tree. Twin is unused
// here but kept declared to document the multi-consumer shape we
// protect against.
expect(spy).toHaveBeenCalledTimes(1)
void Twin
})

it("surfaces an error and reports available=false", async () => {
const client = new K8sClient()
vi.spyOn(client, "getApiGroups").mockRejectedValue(new Error("no /apis"))
const { result } = renderHook(() => useApiGroupAvailable("metrics.k8s.io"), {
wrapper: makeWrapper(client),
})
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.available).toBe(false)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { describe, it, expect, vi } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import {
K8sClient,
K8sProvider,
useSelfSubjectAccessReview,
type SelfSubjectAccessReview,
} from "@cozystack/k8s-client"
import type { ReactNode } from "react"

function makeWrapper(client: K8sClient) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<K8sProvider client={client} queryClient={queryClient}>
{children}
</K8sProvider>
</QueryClientProvider>
)
}
}

function ssarResult(allowed: boolean): SelfSubjectAccessReview {
return {
apiVersion: "authorization.k8s.io/v1",
kind: "SelfSubjectAccessReview",
metadata: { name: "" },
spec: { resourceAttributes: { resource: "nodes", verb: "list" } },
status: { allowed },
}
}

describe("useSelfSubjectAccessReview", () => {
it("starts in loading state with allowed=false", () => {
const client = new K8sClient()
vi.spyOn(client, "create").mockImplementation(() => new Promise(() => {}))
const { result } = renderHook(
() =>
useSelfSubjectAccessReview({
resourceAttributes: { resource: "nodes", verb: "list" },
}),
{ wrapper: makeWrapper(client) },
)
expect(result.current.isLoading).toBe(true)
expect(result.current.allowed).toBe(false)
})

it("reports allowed=true when the API responds with status.allowed=true", async () => {
const client = new K8sClient()
vi.spyOn(client, "create").mockResolvedValue(ssarResult(true))
const { result } = renderHook(
() =>
useSelfSubjectAccessReview({
resourceAttributes: { resource: "nodes", verb: "list" },
}),
{ wrapper: makeWrapper(client) },
)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.allowed).toBe(true)
})

it("reports allowed=false explicitly when status.allowed=false", async () => {
const client = new K8sClient()
vi.spyOn(client, "create").mockResolvedValue(ssarResult(false))
const { result } = renderHook(
() =>
useSelfSubjectAccessReview({
resourceAttributes: { resource: "nodes", verb: "list" },
}),
{ wrapper: makeWrapper(client) },
)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.allowed).toBe(false)
})

it("POSTs once for two consumers asking the same question", async () => {
const client = new K8sClient()
const spy = vi.spyOn(client, "create").mockResolvedValue(ssarResult(true))
const Wrapper = makeWrapper(client)
const { result: a } = renderHook(
() =>
useSelfSubjectAccessReview({
resourceAttributes: { resource: "nodes", verb: "list" },
}),
{ wrapper: Wrapper },
)
const { result: b } = renderHook(
() =>
useSelfSubjectAccessReview({
resourceAttributes: { resource: "nodes", verb: "list" },
}),
{ wrapper: Wrapper },
)
await waitFor(() => expect(a.current.isLoading).toBe(false))
await waitFor(() => expect(b.current.isLoading).toBe(false))
expect(spy).toHaveBeenCalledTimes(1)
})

it("POSTs twice when two consumers ask different questions", async () => {
const client = new K8sClient()
const spy = vi.spyOn(client, "create").mockResolvedValue(ssarResult(true))
const Wrapper = makeWrapper(client)
const { result: a } = renderHook(
() =>
useSelfSubjectAccessReview({
resourceAttributes: { resource: "nodes", verb: "list" },
}),
{ wrapper: Wrapper },
)
const { result: b } = renderHook(
() =>
useSelfSubjectAccessReview({
resourceAttributes: { resource: "pods", verb: "list" },
}),
{ wrapper: Wrapper },
)
await waitFor(() => expect(a.current.isLoading).toBe(false))
await waitFor(() => expect(b.current.isLoading).toBe(false))
expect(spy).toHaveBeenCalledTimes(2)
})

it("surfaces the error and reports allowed=false on API failure", async () => {
const client = new K8sClient()
const err = new Error("server error")
vi.spyOn(client, "create").mockRejectedValue(err)
const { result } = renderHook(
() =>
useSelfSubjectAccessReview({
resourceAttributes: { resource: "nodes", verb: "list" },
}),
{ wrapper: makeWrapper(client) },
)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.allowed).toBe(false)
expect(result.current.error).toBeTruthy()
})

it("sends the spec verbatim in the POST body", async () => {
const client = new K8sClient()
const spy = vi.spyOn(client, "create").mockResolvedValue(ssarResult(true))
const { result } = renderHook(
() =>
useSelfSubjectAccessReview({
resourceAttributes: {
group: "metrics.k8s.io",
resource: "nodes",
verb: "list",
},
}),
{ wrapper: makeWrapper(client) },
)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(spy).toHaveBeenCalledWith(
"authorization.k8s.io",
"v1",
"selfsubjectaccessreviews",
expect.objectContaining({
kind: "SelfSubjectAccessReview",
apiVersion: "authorization.k8s.io/v1",
spec: {
resourceAttributes: {
group: "metrics.k8s.io",
resource: "nodes",
verb: "list",
},
},
}),
)
})
})
30 changes: 1 addition & 29 deletions apps/console/src/components/QuotaDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"
import { useK8sList } from "@cozystack/k8s-client"
import type { K8sResource } from "@cozystack/k8s-client"
import { parseQuantity, humanizeBytes, humanizeCpu } from "../lib/k8s-quantity.ts"

interface ResourceQuotaSpec {
hard?: Record<string, string>
Expand All @@ -15,35 +16,6 @@ export interface ResourceQuota extends K8sResource<ResourceQuotaSpec, ResourceQu
kind: "ResourceQuota"
}

function parseQuantity(s: string): number {
if (!s) return 0
if (s.endsWith("m")) return parseFloat(s) / 1000
// Binary SI suffixes (powers of 1024)
if (s.endsWith("Ki")) return parseFloat(s) * 1024
if (s.endsWith("Mi")) return parseFloat(s) * 1024 ** 2
if (s.endsWith("Gi")) return parseFloat(s) * 1024 ** 3
if (s.endsWith("Ti")) return parseFloat(s) * 1024 ** 4
if (s.endsWith("Pi")) return parseFloat(s) * 1024 ** 5
if (s.endsWith("Ei")) return parseFloat(s) * 1024 ** 6
// Decimal SI suffixes (powers of 1000) — Kubernetes uses lowercase k
if (s.endsWith("k")) return parseFloat(s) * 1000
if (s.endsWith("M")) return parseFloat(s) * 1000 ** 2
if (s.endsWith("G")) return parseFloat(s) * 1000 ** 3
return parseFloat(s) || 0
}

function humanizeBytes(bytes: number): string {
if (bytes >= 1024 ** 4) return `${(bytes / 1024 ** 4).toFixed(1)}Ti`
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)}Gi`
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(0)}Mi`
return `${bytes}B`
}

function humanizeCpu(val: number): string {
if (val < 1) return `${Math.round(val * 1000)}m`
return `${val % 1 === 0 ? val : val.toFixed(2)}`
}

interface QuotaEntry {
label: string
usedRaw: string
Expand Down
Loading
Loading