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

Commit 7c443a9

Browse files
kvapsclaudelexfrei
authored
feat(console): VM power controls (start/stop/restart) + VNC fixes (#27)
* feat(console): add VM power controls (start/stop/restart) Add Start/Restart/Stop buttons to the VMInstance detail page that call the KubeVirt subresources.kubevirt.io virtualmachines/{name}/start|stop| restart endpoints. The underlying VirtualMachine is resolved as <release.prefix><app-name> and its printableStatus drives which buttons are enabled. Adds a generic subresource() method to the k8s client and a useK8sSubresource() mutation hook. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io> * fix(console): use prefixed VMI name for VNC connection The VNC console targeted the VirtualMachineInstance by the cozystack app name (e.g. "demo-vm"), but KubeVirt names it "<release.prefix><name>" (e.g. "vm-instance-demo-vm"), so the websocket 404'd. Resolve the VMI name via release.prefix, the same way the VM power controls do. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io> * fix(k8s-client): handle empty 2xx response bodies KubeVirt action subresources (virtualmachines/{name}/start|stop|restart) return 202 Accepted with an empty body. request() called res.json() unconditionally, throwing "Unexpected end of JSON input" and surfacing a spurious error toast even though the action succeeded. Read the body as text and return undefined when empty. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io> * fix(console): don't open VNC when the VM is not running VncTab connected the websocket whenever the app was a VMInstance, regardless of power state, so a stopped VM showed a dead console. Poll the VirtualMachine printableStatus and only attach when it is Running; otherwise show a 'not running' notice. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io> * style(console): match cozyportal VNC console toolbar Restyle the VNC tab to mirror cozyportal-ui: a dark toolbar with a connection-status indicator (Monitor icon + Connected/Connecting/ Disconnected + green dot) and icon buttons for Ctrl+Alt+Del, fullscreen and reconnect; connecting/error overlays; aspect-ratio sizing with fullscreen support. Reconnect now runs through a connectionKey-driven effect instead of a duplicated RFB setup path. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io> * fix(console): proxy WebSockets in dev so the VNC console connects The dev server's /apis (and /api) proxy lacked ws: true, so the VNC console's WebSocket upgrade was never forwarded to kubectl proxy and the console hung on 'Connecting…'. Enable ws on both proxy entries. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io> * test(k8s-client): cover subresource calls and empty 2xx body handling KubeVirt action subresources (virtualmachines/{name}/start|stop|restart) answer 2xx with an empty body, so the request helper must return undefined instead of letting JSON.parse("") throw. Pin that, the 204 short-circuit, and the subresource path/method construction. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * fix(k8s-client): refresh the target resource after a subresource action A subresource action and the resource whose status it changes can live under different API groups: KubeVirt serves start/stop/restart under subresources.kubevirt.io but the VirtualMachine (with printableStatus) under kubevirt.io. The success handler built its invalidation key from the action ref, so it never matched the status query. Add an optional invalidate target ref and key off the resource prefix [k8s, group, version, plural, namespace], which React Query prefix-matches against the by-name GET and any field/label-selected LIST — so a watch-based status read is refreshed too. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * fix(console): correct VM power controls and stream status via watch Several fixes to the VM power controls: - Resolve the KubeVirt VirtualMachine name from releasePrefix(ad) instead of an empty-string prefix fallback, so it targets the same object the VNC tab does and never queries a non-existent name when release.prefix is unset. - Read status via useK8sList with a metadata.name field-selector instead of a polled useK8sGet, so the watch layer streams printableStatus transitions live (the architecture forbids refetchInterval); the action also invalidates the resource for an instant refresh. - Drop the dead "Halted" branch (not a KubeVirt printableStatus value) and enable stop/restart for a Paused VM, which still has a running instance. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * fix(console): stream VNC power state via watch and share VM-name resolution The VNC tab hardcoded the "vm-instance-" prefix and polled the VirtualMachine power state with refetchInterval. Resolve the name through releasePrefix(ad) so it agrees with the power controls on the target object, and read status via useK8sList with a metadata.name field-selector so the watch layer tracks the power state live without polling. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * fix(console): match Helm-managed resources by prefixed release name in events EventsTab derived the Helm release name with an empty-string prefix fallback, so when release.prefix was unset its app.kubernetes.io/instance selector queried the bare app name and matched none of the Helm-managed Pods/PVCs (which carry the "<singular>-<name>" instance label), silently dropping their events. Route it through releasePrefix(ad) like the VM tabs, and cover both releasePrefix and the resulting selector with tests. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * refactor(forms): type the additionalProperties walk without as-any The helper that binds AdditionalPropertiesField walked schema nodes through an as-any cast. Route it through a small structural interface instead, narrowing unknown values explicitly. No behaviour change — the existing array-items map test still passes. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> --------- Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io> Signed-off-by: Aleksei Sviridkin <f@lex.la> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Aleksei Sviridkin <f@lex.la>
1 parent 27e9b1b commit 7c443a9

15 files changed

Lines changed: 1065 additions & 83 deletions
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest"
2+
import { K8sClient } from "@cozystack/k8s-client"
3+
4+
function fakeResponse(opts: {
5+
ok?: boolean
6+
status?: number
7+
statusText?: string
8+
body?: string
9+
}): Response {
10+
return {
11+
ok: opts.ok ?? true,
12+
status: opts.status ?? 200,
13+
statusText: opts.statusText ?? "OK",
14+
text: async () => opts.body ?? "",
15+
json: async () => JSON.parse(opts.body ?? "null"),
16+
} as unknown as Response
17+
}
18+
19+
afterEach(() => {
20+
vi.unstubAllGlobals()
21+
})
22+
23+
describe("K8sClient.subresource", () => {
24+
it("PUTs to /{plural}/{name}/{subresource} under the aggregated API group", async () => {
25+
const fetchMock = vi.fn(async () => fakeResponse({ body: "" }))
26+
vi.stubGlobal("fetch", fetchMock)
27+
const client = new K8sClient({ baseUrl: "/base" })
28+
29+
await client.subresource(
30+
"subresources.kubevirt.io",
31+
"v1",
32+
"virtualmachines",
33+
"vm-instance-demo",
34+
"start",
35+
"tenant-root",
36+
)
37+
38+
expect(fetchMock).toHaveBeenCalledWith(
39+
"/base/apis/subresources.kubevirt.io/v1/namespaces/tenant-root/virtualmachines/vm-instance-demo/start",
40+
expect.objectContaining({ method: "PUT" }),
41+
)
42+
})
43+
44+
it("POSTs when method is POST", async () => {
45+
const fetchMock = vi.fn(async () => fakeResponse({ body: "" }))
46+
vi.stubGlobal("fetch", fetchMock)
47+
const client = new K8sClient()
48+
49+
await client.subresource(
50+
"subresources.kubevirt.io",
51+
"v1",
52+
"virtualmachines",
53+
"vm",
54+
"restart",
55+
"ns",
56+
{},
57+
"POST",
58+
)
59+
60+
expect(fetchMock).toHaveBeenCalledWith(
61+
expect.any(String),
62+
expect.objectContaining({ method: "POST" }),
63+
)
64+
})
65+
})
66+
67+
describe("K8sClient.request empty-body handling", () => {
68+
it("returns undefined for an empty 2xx body (KubeVirt action subresources)", async () => {
69+
// virtualmachines/{name}/start|stop|restart answer 2xx with no body;
70+
// JSON.parse("") would throw "Unexpected end of JSON input".
71+
vi.stubGlobal("fetch", vi.fn(async () => fakeResponse({ status: 200, body: "" })))
72+
const client = new K8sClient()
73+
74+
const result = await client.subresource(
75+
"subresources.kubevirt.io",
76+
"v1",
77+
"virtualmachines",
78+
"vm",
79+
"stop",
80+
)
81+
82+
expect(result).toBeUndefined()
83+
})
84+
85+
it("parses the JSON body when a 2xx response is non-empty", async () => {
86+
vi.stubGlobal(
87+
"fetch",
88+
vi.fn(async () => fakeResponse({ status: 200, body: JSON.stringify({ kind: "VirtualMachine" }) })),
89+
)
90+
const client = new K8sClient()
91+
92+
const result = await client.get("kubevirt.io", "v1", "virtualmachines", "vm", "ns")
93+
94+
expect(result).toEqual({ kind: "VirtualMachine" })
95+
})
96+
97+
it("short-circuits to undefined on 204 No Content without reading the body", async () => {
98+
const textSpy = vi.fn(async () => "should-not-be-read")
99+
vi.stubGlobal(
100+
"fetch",
101+
vi.fn(
102+
async () =>
103+
({
104+
ok: true,
105+
status: 204,
106+
statusText: "No Content",
107+
text: textSpy,
108+
}) as unknown as Response,
109+
),
110+
)
111+
const client = new K8sClient()
112+
113+
const result = await client.delete("", "v1", "configmaps", "cm", "ns")
114+
115+
expect(result).toBeUndefined()
116+
expect(textSpy).not.toHaveBeenCalled()
117+
})
118+
})
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { describe, it, expect, vi } from "vitest"
2+
import { renderHook, act } from "@testing-library/react"
3+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
4+
import { K8sClient, K8sProvider, useK8sSubresource } from "@cozystack/k8s-client"
5+
import type { ReactNode } from "react"
6+
7+
const actionRef = {
8+
apiGroup: "subresources.kubevirt.io",
9+
apiVersion: "v1",
10+
plural: "virtualmachines",
11+
name: "vm-instance-demo",
12+
namespace: "tenant-root",
13+
}
14+
15+
// The VirtualMachine (with its status) is served under kubevirt.io, not the
16+
// subresources.kubevirt.io aggregated API the action endpoint lives under.
17+
const statusRef = {
18+
apiGroup: "kubevirt.io",
19+
apiVersion: "v1",
20+
plural: "virtualmachines",
21+
namespace: "tenant-root",
22+
}
23+
24+
function setup(gcTime = 0) {
25+
const client = new K8sClient()
26+
vi.spyOn(client, "subresource").mockResolvedValue(undefined)
27+
const queryClient = new QueryClient({
28+
defaultOptions: { queries: { retry: false, gcTime } },
29+
})
30+
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries")
31+
function wrapper({ children }: { children: ReactNode }) {
32+
return (
33+
<QueryClientProvider client={queryClient}>
34+
<K8sProvider client={client} queryClient={queryClient}>
35+
{children}
36+
</K8sProvider>
37+
</QueryClientProvider>
38+
)
39+
}
40+
return { client, queryClient, invalidateSpy, wrapper }
41+
}
42+
43+
describe("useK8sSubresource", () => {
44+
it("calls the action subresource with a default empty body", async () => {
45+
const { client, wrapper } = setup()
46+
const { result } = renderHook(() => useK8sSubresource(actionRef), { wrapper })
47+
48+
await act(async () => {
49+
await result.current.mutateAsync({ subresource: "start" })
50+
})
51+
52+
expect(client.subresource).toHaveBeenCalledWith(
53+
"subresources.kubevirt.io",
54+
"v1",
55+
"virtualmachines",
56+
"vm-instance-demo",
57+
"start",
58+
"tenant-root",
59+
{},
60+
undefined,
61+
)
62+
})
63+
64+
it("invalidates the action ref's own resource key when no invalidate target is given", async () => {
65+
const { invalidateSpy, wrapper } = setup()
66+
const { result } = renderHook(() => useK8sSubresource(actionRef), { wrapper })
67+
68+
await act(async () => {
69+
await result.current.mutateAsync({ subresource: "start" })
70+
})
71+
72+
expect(invalidateSpy).toHaveBeenCalledWith({
73+
queryKey: ["k8s", "subresources.kubevirt.io", "v1", "virtualmachines", "tenant-root"],
74+
})
75+
})
76+
77+
it("invalidates the target resource (kubevirt.io) when invalidate is supplied", async () => {
78+
const { invalidateSpy, wrapper } = setup()
79+
const { result } = renderHook(
80+
() => useK8sSubresource(actionRef, { invalidate: statusRef }),
81+
{ wrapper },
82+
)
83+
84+
await act(async () => {
85+
await result.current.mutateAsync({ subresource: "start" })
86+
})
87+
88+
// Regression guard: the key must be built from the kubevirt.io status ref,
89+
// otherwise it never matches the query that holds printableStatus.
90+
expect(invalidateSpy).toHaveBeenCalledWith({
91+
queryKey: ["k8s", "kubevirt.io", "v1", "virtualmachines", "tenant-root"],
92+
})
93+
expect(invalidateSpy).not.toHaveBeenCalledWith({
94+
queryKey: ["k8s", "subresources.kubevirt.io", "v1", "virtualmachines", "tenant-root"],
95+
})
96+
})
97+
98+
it("invalidates a metadata.name field-selected list of the target resource", async () => {
99+
// gcTime Infinity so the manually seeded (observer-less) query isn't
100+
// garbage-collected before we read its post-invalidation state.
101+
const { queryClient, wrapper } = setup(Infinity)
102+
// The watch-based status read keys its list query with a field-selector.
103+
// The resource-prefix invalidation must reach it (React Query prefix match),
104+
// not just an unfiltered list — otherwise the post-action refresh is dead.
105+
const fieldSelectedListKey = [
106+
"k8s",
107+
"kubevirt.io",
108+
"v1",
109+
"virtualmachines",
110+
"tenant-root",
111+
"",
112+
"metadata.name=vm-instance-demo",
113+
]
114+
queryClient.setQueryData(fieldSelectedListKey, { items: [] })
115+
expect(queryClient.getQueryState(fieldSelectedListKey)?.isInvalidated).toBe(false)
116+
117+
const { result } = renderHook(
118+
() => useK8sSubresource(actionRef, { invalidate: statusRef }),
119+
{ wrapper },
120+
)
121+
await act(async () => {
122+
await result.current.mutateAsync({ subresource: "start" })
123+
})
124+
125+
expect(queryClient.getQueryState(fieldSelectedListKey)?.isInvalidated).toBe(true)
126+
})
127+
})

apps/console/src/components/SchemaForm.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,19 @@ function addAdditionalPropertiesWidgets(schema: RJSFSchema, uiSchema: UiSchema =
103103
return result
104104
}
105105

106+
/**
107+
* Minimal structural view of a JSON-schema node used by the walk below.
108+
* RJSFSchema is intersected with an `any` index signature, so reading fields
109+
* straight off it yields `any`; routing through this interface keeps the walk
110+
* typed without an `as any` cast.
111+
*/
112+
interface SchemaNode {
113+
type?: string | string[]
114+
properties?: Record<string, unknown>
115+
additionalProperties?: unknown
116+
items?: unknown
117+
}
118+
106119
/**
107120
* Resolve the uiSchema fragment for one schema node: bind the custom field to
108121
* an additionalProperties map, recurse into nested objects, or recurse into
@@ -112,30 +125,30 @@ function bindAdditionalProperties(
112125
fieldSchema: RJSFSchema,
113126
uiNode: UiSchema | undefined,
114127
): UiSchema | undefined {
115-
const node = fieldSchema as any
128+
const node: SchemaNode = fieldSchema
116129

117130
const isAdditionalPropertiesMap =
118131
node.type === "object" &&
119132
(!node.properties || Object.keys(node.properties).length === 0) &&
120133
typeof node.additionalProperties === "object" &&
121-
node.additionalProperties !== null &&
122-
node.additionalProperties !== true
134+
node.additionalProperties !== null
123135

124136
if (isAdditionalPropertiesMap) {
125137
return { ...uiNode, "ui:field": "AdditionalPropertiesField" }
126138
}
127139

128140
if (node.properties) {
129-
return addAdditionalPropertiesWidgets(fieldSchema, uiNode as UiSchema)
141+
return addAdditionalPropertiesWidgets(fieldSchema, uiNode)
130142
}
131143

144+
const items = node.items
132145
if (
133146
node.type === "array" &&
134-
node.items &&
135-
typeof node.items === "object" &&
136-
!Array.isArray(node.items)
147+
items &&
148+
typeof items === "object" &&
149+
!Array.isArray(items)
137150
) {
138-
const itemsUi = bindAdditionalProperties(node.items as RJSFSchema, (uiNode as any)?.items)
151+
const itemsUi = bindAdditionalProperties(items as RJSFSchema, uiNode?.items)
139152
if (itemsUi !== undefined) {
140153
return { ...uiNode, items: itemsUi }
141154
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, it, expect } from "vitest"
2+
import { releasePrefix } from "./app-definitions.ts"
3+
import type { ApplicationDefinition } from "@cozystack/types"
4+
5+
function ad(overrides: Partial<ApplicationDefinition> = {}): ApplicationDefinition {
6+
return {
7+
apiVersion: "cozystack.io/v1alpha1",
8+
kind: "ApplicationDefinition",
9+
metadata: { name: "virtual-machine" },
10+
spec: {
11+
application: {
12+
kind: "VMInstance",
13+
plural: "vminstances",
14+
singular: "vm-instance",
15+
openAPISchema: "{}",
16+
},
17+
},
18+
...overrides,
19+
}
20+
}
21+
22+
describe("releasePrefix", () => {
23+
it("returns the explicit release.prefix when set", () => {
24+
expect(
25+
releasePrefix(
26+
ad({
27+
spec: {
28+
application: {
29+
kind: "VMInstance",
30+
plural: "vminstances",
31+
singular: "vm-instance",
32+
openAPISchema: "{}",
33+
},
34+
release: { prefix: "custom-" },
35+
},
36+
}),
37+
),
38+
).toBe("custom-")
39+
})
40+
41+
it("falls back to '<singular>-' when release.prefix is unset", () => {
42+
expect(releasePrefix(ad())).toBe("vm-instance-")
43+
})
44+
45+
it("falls back to '<metadata.name>-' when neither prefix nor spec is present", () => {
46+
expect(releasePrefix(ad({ spec: undefined }))).toBe("virtual-machine-")
47+
})
48+
})

apps/console/src/routes/detail/ApplicationDetailPage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { IngressesTab } from "./IngressesTab.tsx"
3333
import { SecretsTab } from "./SecretsTab.tsx"
3434
import { EventsTab } from "./EventsTab.tsx"
3535
import { VncTab } from "./VncTab.tsx"
36+
import { VMPowerControls } from "./VMPowerControls.tsx"
3637

3738
export function ApplicationDetailPage() {
3839
const { plural, name } = useParams<{ plural: string; name: string }>()
@@ -155,6 +156,9 @@ export function ApplicationDetailPage() {
155156
) : null}
156157
</div>
157158
<div className="flex items-center gap-2">
159+
{kind === "VMInstance" && (
160+
<VMPowerControls ad={ad} instance={instance} />
161+
)}
158162
<Link to={`/console/${plural}/${name}/edit`}>
159163
<Button variant="outline" size="sm">
160164
<Edit className="size-3.5" /> Edit

0 commit comments

Comments
 (0)