Skip to content
This repository was archived by the owner on Jun 26, 2026. It is now read-only.

Commit 4570d44

Browse files
authored
feat(console): add Cluster Usage admin page (#18)
Signed-off-by: Aleksei Sviridkin <f@lex.la>
1 parent cd25edf commit 4570d44

38 files changed

Lines changed: 3688 additions & 33 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect } from "vitest"
2+
import { render } from "@testing-library/react"
3+
import { K8sClient, K8sProvider, useK8sClient } from "@cozystack/k8s-client"
4+
5+
function ClientCapture({ onClient }: { onClient: (c: K8sClient) => void }) {
6+
const c = useK8sClient()
7+
onClient(c)
8+
return null
9+
}
10+
11+
describe("K8sProvider", () => {
12+
it("passes the injected client through to useK8sClient", () => {
13+
const injected = new K8sClient({ baseUrl: "/injected" })
14+
let captured: K8sClient | null = null
15+
render(
16+
<K8sProvider client={injected}>
17+
<ClientCapture onClient={(c) => (captured = c)} />
18+
</K8sProvider>,
19+
)
20+
expect(captured).toBe(injected)
21+
})
22+
23+
it("constructs its own client when none is injected", () => {
24+
let captured: K8sClient | null = null
25+
render(
26+
<K8sProvider>
27+
<ClientCapture onClient={(c) => (captured = c)} />
28+
</K8sProvider>,
29+
)
30+
expect(captured).toBeInstanceOf(K8sClient)
31+
})
32+
33+
it("constructs a client from the provided config when no client is injected", () => {
34+
let captured: K8sClient | null = null
35+
render(
36+
<K8sProvider config={{ baseUrl: "/from-config" }}>
37+
<ClientCapture onClient={(c) => (captured = c)} />
38+
</K8sProvider>,
39+
)
40+
expect(captured).toBeInstanceOf(K8sClient)
41+
})
42+
43+
it("prefers the injected client over the config when both are supplied", () => {
44+
const injected = new K8sClient({ baseUrl: "/injected" })
45+
let captured: K8sClient | null = null
46+
render(
47+
<K8sProvider client={injected} config={{ baseUrl: "/ignored" }}>
48+
<ClientCapture onClient={(c) => (captured = c)} />
49+
</K8sProvider>,
50+
)
51+
expect(captured).toBe(injected)
52+
})
53+
})
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, it, expect, vi } from "vitest"
2+
import { renderHook, waitFor } from "@testing-library/react"
3+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
4+
import {
5+
K8sClient,
6+
K8sProvider,
7+
useApiGroupAvailable,
8+
type APIGroupList,
9+
} from "@cozystack/k8s-client"
10+
import type { ReactNode } from "react"
11+
12+
function makeWrapper(client: K8sClient) {
13+
const queryClient = new QueryClient({
14+
defaultOptions: { queries: { retry: false, gcTime: 0 } },
15+
})
16+
return function Wrapper({ children }: { children: ReactNode }) {
17+
return (
18+
<QueryClientProvider client={queryClient}>
19+
<K8sProvider client={client} queryClient={queryClient}>
20+
{children}
21+
</K8sProvider>
22+
</QueryClientProvider>
23+
)
24+
}
25+
}
26+
27+
const sampleGroups: APIGroupList = {
28+
kind: "APIGroupList",
29+
apiVersion: "v1",
30+
groups: [
31+
{
32+
name: "metrics.k8s.io",
33+
versions: [{ groupVersion: "metrics.k8s.io/v1beta1", version: "v1beta1" }],
34+
preferredVersion: { groupVersion: "metrics.k8s.io/v1beta1", version: "v1beta1" },
35+
},
36+
{
37+
name: "apps",
38+
versions: [{ groupVersion: "apps/v1", version: "v1" }],
39+
preferredVersion: { groupVersion: "apps/v1", version: "v1" },
40+
},
41+
],
42+
}
43+
44+
describe("useApiGroupAvailable", () => {
45+
it("starts in loading state with available=false", () => {
46+
const client = new K8sClient()
47+
vi.spyOn(client, "getApiGroups").mockImplementation(
48+
() => new Promise(() => {}),
49+
)
50+
const { result } = renderHook(() => useApiGroupAvailable("metrics.k8s.io"), {
51+
wrapper: makeWrapper(client),
52+
})
53+
expect(result.current.isLoading).toBe(true)
54+
expect(result.current.available).toBe(false)
55+
})
56+
57+
it("reports available=true when the group is present", async () => {
58+
const client = new K8sClient()
59+
vi.spyOn(client, "getApiGroups").mockResolvedValue(sampleGroups)
60+
const { result } = renderHook(() => useApiGroupAvailable("metrics.k8s.io"), {
61+
wrapper: makeWrapper(client),
62+
})
63+
await waitFor(() => expect(result.current.isLoading).toBe(false))
64+
expect(result.current.available).toBe(true)
65+
})
66+
67+
it("reports available=false when the group is missing", async () => {
68+
const client = new K8sClient()
69+
vi.spyOn(client, "getApiGroups").mockResolvedValue(sampleGroups)
70+
const { result } = renderHook(() => useApiGroupAvailable("custom.metrics.k8s.io"), {
71+
wrapper: makeWrapper(client),
72+
})
73+
await waitFor(() => expect(result.current.isLoading).toBe(false))
74+
expect(result.current.available).toBe(false)
75+
})
76+
77+
it("fetches /apis once for multiple consumers", async () => {
78+
const client = new K8sClient()
79+
const spy = vi.spyOn(client, "getApiGroups").mockResolvedValue(sampleGroups)
80+
const Wrapper = makeWrapper(client)
81+
82+
function Twin() {
83+
const a = useApiGroupAvailable("metrics.k8s.io")
84+
const b = useApiGroupAvailable("apps")
85+
return (
86+
<p>
87+
{String(a.available)}-{String(b.available)}
88+
</p>
89+
)
90+
}
91+
92+
const { result: hookA } = renderHook(
93+
() => useApiGroupAvailable("metrics.k8s.io"),
94+
{ wrapper: Wrapper },
95+
)
96+
const { result: hookB } = renderHook(
97+
() => useApiGroupAvailable("apps"),
98+
{ wrapper: Wrapper },
99+
)
100+
101+
await waitFor(() => expect(hookA.current.isLoading).toBe(false))
102+
await waitFor(() => expect(hookB.current.isLoading).toBe(false))
103+
104+
// Both hooks share the same provider and cache, so /apis is called
105+
// exactly once for the lifetime of this provider tree. Twin is unused
106+
// here but kept declared to document the multi-consumer shape we
107+
// protect against.
108+
expect(spy).toHaveBeenCalledTimes(1)
109+
void Twin
110+
})
111+
112+
it("surfaces an error and reports available=false", async () => {
113+
const client = new K8sClient()
114+
vi.spyOn(client, "getApiGroups").mockRejectedValue(new Error("no /apis"))
115+
const { result } = renderHook(() => useApiGroupAvailable("metrics.k8s.io"), {
116+
wrapper: makeWrapper(client),
117+
})
118+
await waitFor(() => expect(result.current.isLoading).toBe(false))
119+
expect(result.current.available).toBe(false)
120+
})
121+
})
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { describe, it, expect, vi } from "vitest"
2+
import { renderHook, waitFor } from "@testing-library/react"
3+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
4+
import {
5+
K8sClient,
6+
K8sProvider,
7+
useSelfSubjectAccessReview,
8+
type SelfSubjectAccessReview,
9+
} from "@cozystack/k8s-client"
10+
import type { ReactNode } from "react"
11+
12+
function makeWrapper(client: K8sClient) {
13+
const queryClient = new QueryClient({
14+
defaultOptions: { queries: { retry: false, gcTime: 0 } },
15+
})
16+
return function Wrapper({ children }: { children: ReactNode }) {
17+
return (
18+
<QueryClientProvider client={queryClient}>
19+
<K8sProvider client={client} queryClient={queryClient}>
20+
{children}
21+
</K8sProvider>
22+
</QueryClientProvider>
23+
)
24+
}
25+
}
26+
27+
function ssarResult(allowed: boolean): SelfSubjectAccessReview {
28+
return {
29+
apiVersion: "authorization.k8s.io/v1",
30+
kind: "SelfSubjectAccessReview",
31+
metadata: { name: "" },
32+
spec: { resourceAttributes: { resource: "nodes", verb: "list" } },
33+
status: { allowed },
34+
}
35+
}
36+
37+
describe("useSelfSubjectAccessReview", () => {
38+
it("starts in loading state with allowed=false", () => {
39+
const client = new K8sClient()
40+
vi.spyOn(client, "create").mockImplementation(() => new Promise(() => {}))
41+
const { result } = renderHook(
42+
() =>
43+
useSelfSubjectAccessReview({
44+
resourceAttributes: { resource: "nodes", verb: "list" },
45+
}),
46+
{ wrapper: makeWrapper(client) },
47+
)
48+
expect(result.current.isLoading).toBe(true)
49+
expect(result.current.allowed).toBe(false)
50+
})
51+
52+
it("reports allowed=true when the API responds with status.allowed=true", async () => {
53+
const client = new K8sClient()
54+
vi.spyOn(client, "create").mockResolvedValue(ssarResult(true))
55+
const { result } = renderHook(
56+
() =>
57+
useSelfSubjectAccessReview({
58+
resourceAttributes: { resource: "nodes", verb: "list" },
59+
}),
60+
{ wrapper: makeWrapper(client) },
61+
)
62+
await waitFor(() => expect(result.current.isLoading).toBe(false))
63+
expect(result.current.allowed).toBe(true)
64+
})
65+
66+
it("reports allowed=false explicitly when status.allowed=false", async () => {
67+
const client = new K8sClient()
68+
vi.spyOn(client, "create").mockResolvedValue(ssarResult(false))
69+
const { result } = renderHook(
70+
() =>
71+
useSelfSubjectAccessReview({
72+
resourceAttributes: { resource: "nodes", verb: "list" },
73+
}),
74+
{ wrapper: makeWrapper(client) },
75+
)
76+
await waitFor(() => expect(result.current.isLoading).toBe(false))
77+
expect(result.current.allowed).toBe(false)
78+
})
79+
80+
it("POSTs once for two consumers asking the same question", async () => {
81+
const client = new K8sClient()
82+
const spy = vi.spyOn(client, "create").mockResolvedValue(ssarResult(true))
83+
const Wrapper = makeWrapper(client)
84+
const { result: a } = renderHook(
85+
() =>
86+
useSelfSubjectAccessReview({
87+
resourceAttributes: { resource: "nodes", verb: "list" },
88+
}),
89+
{ wrapper: Wrapper },
90+
)
91+
const { result: b } = renderHook(
92+
() =>
93+
useSelfSubjectAccessReview({
94+
resourceAttributes: { resource: "nodes", verb: "list" },
95+
}),
96+
{ wrapper: Wrapper },
97+
)
98+
await waitFor(() => expect(a.current.isLoading).toBe(false))
99+
await waitFor(() => expect(b.current.isLoading).toBe(false))
100+
expect(spy).toHaveBeenCalledTimes(1)
101+
})
102+
103+
it("POSTs twice when two consumers ask different questions", async () => {
104+
const client = new K8sClient()
105+
const spy = vi.spyOn(client, "create").mockResolvedValue(ssarResult(true))
106+
const Wrapper = makeWrapper(client)
107+
const { result: a } = renderHook(
108+
() =>
109+
useSelfSubjectAccessReview({
110+
resourceAttributes: { resource: "nodes", verb: "list" },
111+
}),
112+
{ wrapper: Wrapper },
113+
)
114+
const { result: b } = renderHook(
115+
() =>
116+
useSelfSubjectAccessReview({
117+
resourceAttributes: { resource: "pods", verb: "list" },
118+
}),
119+
{ wrapper: Wrapper },
120+
)
121+
await waitFor(() => expect(a.current.isLoading).toBe(false))
122+
await waitFor(() => expect(b.current.isLoading).toBe(false))
123+
expect(spy).toHaveBeenCalledTimes(2)
124+
})
125+
126+
it("surfaces the error and reports allowed=false on API failure", async () => {
127+
const client = new K8sClient()
128+
const err = new Error("server error")
129+
vi.spyOn(client, "create").mockRejectedValue(err)
130+
const { result } = renderHook(
131+
() =>
132+
useSelfSubjectAccessReview({
133+
resourceAttributes: { resource: "nodes", verb: "list" },
134+
}),
135+
{ wrapper: makeWrapper(client) },
136+
)
137+
await waitFor(() => expect(result.current.isLoading).toBe(false))
138+
expect(result.current.allowed).toBe(false)
139+
expect(result.current.error).toBeTruthy()
140+
})
141+
142+
it("sends the spec verbatim in the POST body", async () => {
143+
const client = new K8sClient()
144+
const spy = vi.spyOn(client, "create").mockResolvedValue(ssarResult(true))
145+
const { result } = renderHook(
146+
() =>
147+
useSelfSubjectAccessReview({
148+
resourceAttributes: {
149+
group: "metrics.k8s.io",
150+
resource: "nodes",
151+
verb: "list",
152+
},
153+
}),
154+
{ wrapper: makeWrapper(client) },
155+
)
156+
await waitFor(() => expect(result.current.isLoading).toBe(false))
157+
expect(spy).toHaveBeenCalledWith(
158+
"authorization.k8s.io",
159+
"v1",
160+
"selfsubjectaccessreviews",
161+
expect.objectContaining({
162+
kind: "SelfSubjectAccessReview",
163+
apiVersion: "authorization.k8s.io/v1",
164+
spec: {
165+
resourceAttributes: {
166+
group: "metrics.k8s.io",
167+
resource: "nodes",
168+
verb: "list",
169+
},
170+
},
171+
}),
172+
)
173+
})
174+
})

apps/console/src/components/QuotaDisplay.tsx

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useState } from "react"
22
import { useK8sList } from "@cozystack/k8s-client"
33
import type { K8sResource } from "@cozystack/k8s-client"
4+
import { parseQuantity, humanizeBytes, humanizeCpu } from "../lib/k8s-quantity.ts"
45

56
interface ResourceQuotaSpec {
67
hard?: Record<string, string>
@@ -15,35 +16,6 @@ export interface ResourceQuota extends K8sResource<ResourceQuotaSpec, ResourceQu
1516
kind: "ResourceQuota"
1617
}
1718

18-
function parseQuantity(s: string): number {
19-
if (!s) return 0
20-
if (s.endsWith("m")) return parseFloat(s) / 1000
21-
// Binary SI suffixes (powers of 1024)
22-
if (s.endsWith("Ki")) return parseFloat(s) * 1024
23-
if (s.endsWith("Mi")) return parseFloat(s) * 1024 ** 2
24-
if (s.endsWith("Gi")) return parseFloat(s) * 1024 ** 3
25-
if (s.endsWith("Ti")) return parseFloat(s) * 1024 ** 4
26-
if (s.endsWith("Pi")) return parseFloat(s) * 1024 ** 5
27-
if (s.endsWith("Ei")) return parseFloat(s) * 1024 ** 6
28-
// Decimal SI suffixes (powers of 1000) — Kubernetes uses lowercase k
29-
if (s.endsWith("k")) return parseFloat(s) * 1000
30-
if (s.endsWith("M")) return parseFloat(s) * 1000 ** 2
31-
if (s.endsWith("G")) return parseFloat(s) * 1000 ** 3
32-
return parseFloat(s) || 0
33-
}
34-
35-
function humanizeBytes(bytes: number): string {
36-
if (bytes >= 1024 ** 4) return `${(bytes / 1024 ** 4).toFixed(1)}Ti`
37-
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)}Gi`
38-
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(0)}Mi`
39-
return `${bytes}B`
40-
}
41-
42-
function humanizeCpu(val: number): string {
43-
if (val < 1) return `${Math.round(val * 1000)}m`
44-
return `${val % 1 === 0 ? val : val.toFixed(2)}`
45-
}
46-
4719
interface QuotaEntry {
4820
label: string
4921
usedRaw: string

0 commit comments

Comments
 (0)