From 01e0aee33b51fe60049f93ce16c5e26fd60858b1 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 20:08:31 +0200 Subject: [PATCH 01/12] 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 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 Signed-off-by: Andrei Kvapil --- .../routes/detail/ApplicationDetailPage.tsx | 4 + .../src/routes/detail/VMPowerControls.tsx | 112 ++++++++++++++++++ packages/k8s-client/src/client.ts | 23 ++++ packages/k8s-client/src/hooks.ts | 37 ++++++ packages/k8s-client/src/index.ts | 1 + 5 files changed, 177 insertions(+) create mode 100644 apps/console/src/routes/detail/VMPowerControls.tsx diff --git a/apps/console/src/routes/detail/ApplicationDetailPage.tsx b/apps/console/src/routes/detail/ApplicationDetailPage.tsx index c6b8d0d..6feb2f9 100644 --- a/apps/console/src/routes/detail/ApplicationDetailPage.tsx +++ b/apps/console/src/routes/detail/ApplicationDetailPage.tsx @@ -33,6 +33,7 @@ import { IngressesTab } from "./IngressesTab.tsx" import { SecretsTab } from "./SecretsTab.tsx" import { EventsTab } from "./EventsTab.tsx" import { VncTab } from "./VncTab.tsx" +import { VMPowerControls } from "./VMPowerControls.tsx" export function ApplicationDetailPage() { const { plural, name } = useParams<{ plural: string; name: string }>() @@ -153,6 +154,9 @@ export function ApplicationDetailPage() { )}
+ {kind === "VMInstance" && ( + + )} + + +
+ ) +} diff --git a/packages/k8s-client/src/client.ts b/packages/k8s-client/src/client.ts index ecfd2ed..1429bf7 100644 --- a/packages/k8s-client/src/client.ts +++ b/packages/k8s-client/src/client.ts @@ -175,6 +175,29 @@ export class K8sClient { return this.request(path, { method: "DELETE" }) } + /** + * Call a resource subresource (e.g. KubeVirt's + * subresources.kubevirt.io virtualmachines/{name}/start|stop|restart). + * Defaults to PUT, which is what the KubeVirt action subresources expect; + * pass an empty object as body when the subresource takes no options. + */ + subresource( + apiGroup: string, + apiVersion: string, + plural: string, + name: string, + subresource: string, + namespace?: string, + body?: unknown, + method: "PUT" | "POST" = "PUT", + ): Promise { + const path = `${this.buildPath(apiGroup, apiVersion, plural, namespace, name)}/${subresource}` + return this.request(path, { + method, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + } + dryRunCreate( apiGroup: string, apiVersion: string, diff --git a/packages/k8s-client/src/hooks.ts b/packages/k8s-client/src/hooks.ts index 071de2b..f2a0818 100644 --- a/packages/k8s-client/src/hooks.ts +++ b/packages/k8s-client/src/hooks.ts @@ -178,6 +178,43 @@ export function useK8sDelete(ref: ResourceRef) { }) } +/** + * Mutation hook for calling a resource subresource action (e.g. KubeVirt + * virtualmachines/{name}/start|stop|restart). On success it invalidates the + * GET and LIST caches for the referenced resource so status (e.g. + * printableStatus) refetches. + */ +export function useK8sSubresource(ref: ResourceRef & { name: string }) { + const client = useK8sClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ + subresource, + body, + method, + }: { + subresource: string + body?: unknown + method?: "PUT" | "POST" + }) => + client.subresource( + ref.apiGroup, + ref.apiVersion, + ref.plural, + ref.name, + subresource, + ref.namespace, + body ?? {}, + method, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: k8sGetKey(ref) }) + queryClient.invalidateQueries({ queryKey: k8sListKey(ref) }) + }, + }) +} + function k8sListKey(ref: ResourceRef, labelSelector?: string, fieldSelector?: string) { return [ "k8s", diff --git a/packages/k8s-client/src/index.ts b/packages/k8s-client/src/index.ts index eec4679..b6868d1 100644 --- a/packages/k8s-client/src/index.ts +++ b/packages/k8s-client/src/index.ts @@ -17,5 +17,6 @@ export { useK8sCreate, useK8sUpdate, useK8sDelete, + useK8sSubresource, } from "./hooks.ts" export type { ResourceRef } from "./hooks.ts" From 5a8ea581ea940ee173338fe39904c2f79b18f7c3 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 20:20:24 +0200 Subject: [PATCH 02/12] 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 "" (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 Signed-off-by: Andrei Kvapil --- apps/console/src/routes/detail/VncTab.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/console/src/routes/detail/VncTab.tsx b/apps/console/src/routes/detail/VncTab.tsx index 8f19986..a47a2d4 100644 --- a/apps/console/src/routes/detail/VncTab.tsx +++ b/apps/console/src/routes/detail/VncTab.tsx @@ -11,6 +11,10 @@ interface VncTabProps { export function VncTab({ ad, instance }: VncTabProps) { const ns = instance.metadata.namespace const appKind = ad.spec?.application.kind + // The cozystack app name (e.g. "demo-vm") maps to the KubeVirt + // VirtualMachineInstance named "" + // (e.g. "vm-instance-demo-vm"), which is what the subresource path needs. + const vmName = `${ad.spec?.release?.prefix ?? ""}${instance.metadata.name}` const [error, setError] = useState(null) const [connecting, setConnecting] = useState(true) const [connected, setConnected] = useState(false) @@ -24,7 +28,7 @@ export function VncTab({ ad, instance }: VncTabProps) { // Build WebSocket URL using current location const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:" - const wsUrl = `${wsProtocol}//${window.location.host}/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${instance.metadata.name}/vnc` + const wsUrl = `${wsProtocol}//${window.location.host}/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${vmName}/vnc` // Dynamically import RFB import("@novnc/novnc/lib/rfb").then((module) => { @@ -92,7 +96,7 @@ export function VncTab({ ad, instance }: VncTabProps) { rfbRef.current = null } } - }, [appKind, ns, instance.metadata.name]) + }, [appKind, ns, vmName]) if (appKind !== "VMInstance") { return ( @@ -124,7 +128,7 @@ export function VncTab({ ad, instance }: VncTabProps) { setError(null) const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:" - const wsUrl = `${wsProtocol}//${window.location.host}/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${instance.metadata.name}/vnc` + const wsUrl = `${wsProtocol}//${window.location.host}/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${vmName}/vnc` import("@novnc/novnc/lib/rfb").then((module) => { if (!vncContainerRef.current) return From d59b6256cf55ccd402b55eb7e419f06f5fa23ae4 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 20:33:53 +0200 Subject: [PATCH 03/12] 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 Signed-off-by: Andrei Kvapil --- packages/k8s-client/src/client.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/k8s-client/src/client.ts b/packages/k8s-client/src/client.ts index 1429bf7..63ad6b5 100644 --- a/packages/k8s-client/src/client.ts +++ b/packages/k8s-client/src/client.ts @@ -67,7 +67,12 @@ export class K8sClient { } if (res.status === 204) return undefined as T - return res.json() as Promise + // Some endpoints (e.g. KubeVirt action subresources like + // virtualmachines/{name}/restart) return 2xx with an empty body; + // res.json() would throw "Unexpected end of JSON input" on "". + const text = await res.text() + if (!text) return undefined as T + return JSON.parse(text) as T } private buildPath( From ca1543573371a25637c9ccb85b2c655b4544e79f Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 20:33:54 +0200 Subject: [PATCH 04/12] 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 Signed-off-by: Andrei Kvapil --- apps/console/src/routes/detail/VncTab.tsx | 54 ++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/apps/console/src/routes/detail/VncTab.tsx b/apps/console/src/routes/detail/VncTab.tsx index a47a2d4..dcd3110 100644 --- a/apps/console/src/routes/detail/VncTab.tsx +++ b/apps/console/src/routes/detail/VncTab.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react" import { Monitor } from "lucide-react" import { Section, Spinner } from "@cozystack/ui" +import { useK8sGet, type K8sResource } from "@cozystack/k8s-client" import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types" interface VncTabProps { @@ -15,6 +16,26 @@ export function VncTab({ ad, instance }: VncTabProps) { // VirtualMachineInstance named "" // (e.g. "vm-instance-demo-vm"), which is what the subresource path needs. const vmName = `${ad.spec?.release?.prefix ?? ""}${instance.metadata.name}` + // Don't open a VNC websocket unless the VM is actually running — there is no + // VirtualMachineInstance to attach to otherwise, and the socket would just + // error out. Poll the VirtualMachine power state. + const { data: vm, isLoading: vmLoading } = useK8sGet< + K8sResource + >( + { + apiGroup: "kubevirt.io", + apiVersion: "v1", + plural: "virtualmachines", + name: vmName, + namespace: ns ?? "", + }, + { + enabled: appKind === "VMInstance" && !!vmName && !!ns, + refetchInterval: 5000, + }, + ) + const powerStatus = vm?.status?.printableStatus + const isRunning = powerStatus === "Running" const [error, setError] = useState(null) const [connecting, setConnecting] = useState(true) const [connected, setConnected] = useState(false) @@ -22,7 +43,8 @@ export function VncTab({ ad, instance }: VncTabProps) { const rfbRef = useRef(null) useEffect(() => { - if (appKind !== "VMInstance" || !vncContainerRef.current) return + if (appKind !== "VMInstance" || !isRunning || !vncContainerRef.current) + return let mounted = true @@ -96,7 +118,7 @@ export function VncTab({ ad, instance }: VncTabProps) { rfbRef.current = null } } - }, [appKind, ns, vmName]) + }, [appKind, ns, vmName, isRunning]) if (appKind !== "VMInstance") { return ( @@ -108,6 +130,34 @@ export function VncTab({ ad, instance }: VncTabProps) { ) } + if (vmLoading) { + return ( +
+ Loading… +
+ ) + } + + if (!isRunning) { + return ( +
+
+ VNC Console + + } + > +

+ The virtual machine is not running + {powerStatus ? ` (status: ${powerStatus})` : ""}. Start it to use + the VNC console. +

+
+
+ ) + } + const handleCtrlAltDel = () => { if (rfbRef.current) { rfbRef.current.sendCtrlAltDel() From bd62ef717bc369501a8c68e7f5fd9da0e1f6ad36 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 21:05:28 +0200 Subject: [PATCH 05/12] 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 Signed-off-by: Andrei Kvapil --- apps/console/src/routes/detail/VncTab.tsx | 300 +++++++++++----------- 1 file changed, 157 insertions(+), 143 deletions(-) diff --git a/apps/console/src/routes/detail/VncTab.tsx b/apps/console/src/routes/detail/VncTab.tsx index dcd3110..5b3f021 100644 --- a/apps/console/src/routes/detail/VncTab.tsx +++ b/apps/console/src/routes/detail/VncTab.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react" -import { Monitor } from "lucide-react" +import { Monitor, Maximize2, Minimize2, RotateCcw, Power } from "lucide-react" import { Section, Spinner } from "@cozystack/ui" import { useK8sGet, type K8sResource } from "@cozystack/k8s-client" import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types" @@ -36,89 +36,114 @@ export function VncTab({ ad, instance }: VncTabProps) { ) const powerStatus = vm?.status?.printableStatus const isRunning = powerStatus === "Running" + const [error, setError] = useState(null) const [connecting, setConnecting] = useState(true) const [connected, setConnected] = useState(false) + const [fullscreen, setFullscreen] = useState(false) + // Bumped to force the connection effect to re-run on manual reconnect. + const [connectionKey, setConnectionKey] = useState(0) + const [desktopSize, setDesktopSize] = useState<{ + width: number + height: number + } | null>(null) const vncContainerRef = useRef(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- noVNC RFB has no bundled types const rfbRef = useRef(null) useEffect(() => { if (appKind !== "VMInstance" || !isRunning || !vncContainerRef.current) return + const el = vncContainerRef.current + while (el.firstChild) el.removeChild(el.firstChild) let mounted = true + setConnecting(true) + setConnected(false) + setError(null) + setDesktopSize(null) - // Build WebSocket URL using current location const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:" const wsUrl = `${wsProtocol}//${window.location.host}/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${vmName}/vnc` - // Dynamically import RFB - import("@novnc/novnc/lib/rfb").then((module) => { - if (!mounted || !vncContainerRef.current) return - - // The module has nested default: module.default.default is the RFB constructor - const RFB = (module as any).default?.default || module.default || module - - try { - // Initialize noVNC RFB client - const rfb = new RFB(vncContainerRef.current, wsUrl, { - credentials: {}, - }) + import("@novnc/novnc/lib/rfb") + .then((module) => { + if (!mounted || !vncContainerRef.current) return + // The module has nested default: module.default.default is the RFB constructor + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- noVNC RFB has no bundled types + const RFB = (module as any).default?.default || module.default || module - // Set scaling mode - rfb.scaleViewport = true - rfb.resizeSession = false + try { + const rfb = new RFB(vncContainerRef.current, wsUrl, { credentials: {} }) + rfb.scaleViewport = true + rfb.resizeSession = false - // Event handlers - rfb.addEventListener("connect", () => { - if (mounted) { + rfb.addEventListener("connect", () => { + if (!mounted) return setConnecting(false) setConnected(true) setError(null) - } - }) + // Read the desktop size from the canvas so the box keeps the + // guest's aspect ratio instead of letterboxing. + requestAnimationFrame(() => { + const canvas = el.querySelector("canvas") + if (canvas) { + setDesktopSize({ width: canvas.width, height: canvas.height }) + } + }) + }) - rfb.addEventListener("disconnect", (e: any) => { - if (mounted) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- noVNC event detail is untyped + rfb.addEventListener("disconnect", (e: any) => { + if (!mounted) return setConnecting(false) setConnected(false) - if (!e.detail.clean) { - setError(`Disconnected: ${e.detail.reason || "unknown reason"}`) + if (!e.detail?.clean) { + setError(`Disconnected: ${e.detail?.reason || "unknown reason"}`) } - } - }) + }) - rfb.addEventListener("securityfailure", (e: any) => { - if (mounted) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- noVNC event detail is untyped + rfb.addEventListener("securityfailure", (e: any) => { + if (!mounted) return setConnecting(false) setConnected(false) - setError(`Security failure: ${e.detail.status || "authentication failed"}`) - } - }) + setError(`Security failure: ${e.detail?.status || "authentication failed"}`) + }) - rfbRef.current = rfb - } catch (err) { + rfbRef.current = rfb + } catch (err) { + if (mounted) { + setConnecting(false) + setError(`Failed to initialize VNC: ${(err as Error).message}`) + } + } + }) + .catch((err) => { if (mounted) { setConnecting(false) - setError(`Failed to initialize VNC: ${(err as Error).message}`) + setError(`Failed to load VNC library: ${err.message}`) } - } - }).catch((err) => { - if (mounted) { - setConnecting(false) - setError(`Failed to load VNC library: ${err.message}`) - } - }) + }) - // Cleanup on unmount return () => { mounted = false if (rfbRef.current) { - rfbRef.current.disconnect() + try { + rfbRef.current.disconnect() + } catch { + /* ignore: socket may already be closed */ + } rfbRef.current = null } } - }, [appKind, ns, vmName, isRunning]) + }, [appKind, ns, vmName, isRunning, connectionKey]) + + useEffect(() => { + const handler = () => setFullscreen(!!document.fullscreenElement) + document.addEventListener("fullscreenchange", handler) + return () => document.removeEventListener("fullscreenchange", handler) + }, []) if (appKind !== "VMInstance") { return ( @@ -158,121 +183,110 @@ export function VncTab({ ad, instance }: VncTabProps) { ) } - const handleCtrlAltDel = () => { - if (rfbRef.current) { - rfbRef.current.sendCtrlAltDel() + const handleFullscreen = () => { + const wrapper = vncContainerRef.current?.parentElement + if (!wrapper) return + if (!document.fullscreenElement) { + wrapper.requestFullscreen() + } else { + document.exitFullscreen() } } - const handleDisconnect = () => { + const reconnect = () => { if (rfbRef.current) { - rfbRef.current.disconnect() + try { + rfbRef.current.disconnect() + } catch { + /* ignore: socket may already be closed */ + } + rfbRef.current = null } + setConnectionKey((k) => k + 1) } - const handleReconnect = () => { - // Clear container and reinitialize - if (vncContainerRef.current) { - vncContainerRef.current.innerHTML = "" - setConnecting(true) - setError(null) - - const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:" - const wsUrl = `${wsProtocol}//${window.location.host}/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${vmName}/vnc` - - import("@novnc/novnc/lib/rfb").then((module) => { - if (!vncContainerRef.current) return - - // The module has nested default: module.default.default is the RFB constructor - const RFB = (module as any).default?.default || module.default || module - - try { - const rfb = new RFB(vncContainerRef.current, wsUrl, { credentials: {} }) - rfb.scaleViewport = true - rfb.resizeSession = false - - rfb.addEventListener("connect", () => { - setConnecting(false) - setConnected(true) - setError(null) - }) - - rfb.addEventListener("disconnect", (e: any) => { - setConnecting(false) - setConnected(false) - if (!e.detail.clean) { - setError(`Disconnected: ${e.detail.reason || "unknown reason"}`) - } - }) - - rfb.addEventListener("securityfailure", (e: any) => { - setConnecting(false) - setConnected(false) - setError(`Security failure: ${e.detail.status || "authentication failed"}`) - }) - - rfbRef.current = rfb - } catch (err) { - setConnecting(false) - setError(`Failed to reconnect: ${(err as Error).message}`) - } - }).catch((err) => { - setConnecting(false) - setError(`Failed to load VNC library: ${err.message}`) - }) - } - } + const status = connected + ? "Connected" + : connecting + ? "Connecting…" + : "Disconnected" return ( -
-
- VNC Console - - } - > -
- {connecting && ( -
- Connecting to VNC... -
- )} - {error && ( -
- Connection Error: {error} -
- )} -
+
+
+ {/* Toolbar */} +
+ + {status} + {connected && } +
+
+ {connected && ( + + )} -
-
+ + {/* VNC canvas */} +
+ {connecting && ( +
+ Connecting to VNC… +
+ )} + {error && !connecting && ( +
+
+

{error}

+ +
+
+ )} +
+
+
) } From 8e80930173f153f32c569386affbd716d60c6263 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Mon, 1 Jun 2026 21:11:31 +0200 Subject: [PATCH 06/12] fix(console): proxy WebSockets in dev so the VNC console connects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Andrei Kvapil --- apps/console/vite.config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 03fa66a..212388b 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -6,7 +6,8 @@ import path from "path" /** * The dev server proxies `/api` and `/apis` to `kubectl proxy --port 8001` * (over HTTP, not HTTPS — kubectl proxy terminates TLS locally). - * Watch uses plain chunked-encoding streams, so no WebSocket upgrade is needed. + * The VNC console streams over a WebSocket, so `ws: true` is set on the + * proxies below to forward the upgrade to kubectl proxy. */ export default defineConfig({ plugins: [react(), tailwindcss()], @@ -21,10 +22,12 @@ export default defineConfig({ "/apis": { target: "http://localhost:8001", changeOrigin: true, + ws: true, }, "/api": { target: "http://localhost:8001", changeOrigin: true, + ws: true, }, }, }, From 3970b99a7cecc3ca1fffad2afa7722eea9ea7855 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 2 Jun 2026 12:18:56 +0300 Subject: [PATCH 07/12] 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 Signed-off-by: Aleksei Sviridkin --- .../k8s-client/client.subresource.test.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 apps/console/src/__tests__/k8s-client/client.subresource.test.ts diff --git a/apps/console/src/__tests__/k8s-client/client.subresource.test.ts b/apps/console/src/__tests__/k8s-client/client.subresource.test.ts new file mode 100644 index 0000000..219ef3c --- /dev/null +++ b/apps/console/src/__tests__/k8s-client/client.subresource.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, afterEach } from "vitest" +import { K8sClient } from "@cozystack/k8s-client" + +function fakeResponse(opts: { + ok?: boolean + status?: number + statusText?: string + body?: string +}): Response { + return { + ok: opts.ok ?? true, + status: opts.status ?? 200, + statusText: opts.statusText ?? "OK", + text: async () => opts.body ?? "", + json: async () => JSON.parse(opts.body ?? "null"), + } as unknown as Response +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("K8sClient.subresource", () => { + it("PUTs to /{plural}/{name}/{subresource} under the aggregated API group", async () => { + const fetchMock = vi.fn(async () => fakeResponse({ body: "" })) + vi.stubGlobal("fetch", fetchMock) + const client = new K8sClient({ baseUrl: "/base" }) + + await client.subresource( + "subresources.kubevirt.io", + "v1", + "virtualmachines", + "vm-instance-demo", + "start", + "tenant-root", + ) + + expect(fetchMock).toHaveBeenCalledWith( + "/base/apis/subresources.kubevirt.io/v1/namespaces/tenant-root/virtualmachines/vm-instance-demo/start", + expect.objectContaining({ method: "PUT" }), + ) + }) + + it("POSTs when method is POST", async () => { + const fetchMock = vi.fn(async () => fakeResponse({ body: "" })) + vi.stubGlobal("fetch", fetchMock) + const client = new K8sClient() + + await client.subresource( + "subresources.kubevirt.io", + "v1", + "virtualmachines", + "vm", + "restart", + "ns", + {}, + "POST", + ) + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ method: "POST" }), + ) + }) +}) + +describe("K8sClient.request empty-body handling", () => { + it("returns undefined for an empty 2xx body (KubeVirt action subresources)", async () => { + // virtualmachines/{name}/start|stop|restart answer 2xx with no body; + // JSON.parse("") would throw "Unexpected end of JSON input". + vi.stubGlobal("fetch", vi.fn(async () => fakeResponse({ status: 200, body: "" }))) + const client = new K8sClient() + + const result = await client.subresource( + "subresources.kubevirt.io", + "v1", + "virtualmachines", + "vm", + "stop", + ) + + expect(result).toBeUndefined() + }) + + it("parses the JSON body when a 2xx response is non-empty", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => fakeResponse({ status: 200, body: JSON.stringify({ kind: "VirtualMachine" }) })), + ) + const client = new K8sClient() + + const result = await client.get("kubevirt.io", "v1", "virtualmachines", "vm", "ns") + + expect(result).toEqual({ kind: "VirtualMachine" }) + }) + + it("short-circuits to undefined on 204 No Content without reading the body", async () => { + const textSpy = vi.fn(async () => "should-not-be-read") + vi.stubGlobal( + "fetch", + vi.fn( + async () => + ({ + ok: true, + status: 204, + statusText: "No Content", + text: textSpy, + }) as unknown as Response, + ), + ) + const client = new K8sClient() + + const result = await client.delete("", "v1", "configmaps", "cm", "ns") + + expect(result).toBeUndefined() + expect(textSpy).not.toHaveBeenCalled() + }) +}) From acc13ed871a8a62cf7bea34b96008f272905bfdc Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 2 Jun 2026 12:19:06 +0300 Subject: [PATCH 08/12] fix(k8s-client): refresh the target resource after a subresource action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Aleksei Sviridkin --- .../k8s-client/useK8sSubresource.test.tsx | 127 ++++++++++++++++++ packages/k8s-client/src/hooks.ts | 35 ++++- 2 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 apps/console/src/__tests__/k8s-client/useK8sSubresource.test.tsx diff --git a/apps/console/src/__tests__/k8s-client/useK8sSubresource.test.tsx b/apps/console/src/__tests__/k8s-client/useK8sSubresource.test.tsx new file mode 100644 index 0000000..b885b89 --- /dev/null +++ b/apps/console/src/__tests__/k8s-client/useK8sSubresource.test.tsx @@ -0,0 +1,127 @@ +import { describe, it, expect, vi } from "vitest" +import { renderHook, act } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { K8sClient, K8sProvider, useK8sSubresource } from "@cozystack/k8s-client" +import type { ReactNode } from "react" + +const actionRef = { + apiGroup: "subresources.kubevirt.io", + apiVersion: "v1", + plural: "virtualmachines", + name: "vm-instance-demo", + namespace: "tenant-root", +} + +// The VirtualMachine (with its status) is served under kubevirt.io, not the +// subresources.kubevirt.io aggregated API the action endpoint lives under. +const statusRef = { + apiGroup: "kubevirt.io", + apiVersion: "v1", + plural: "virtualmachines", + namespace: "tenant-root", +} + +function setup(gcTime = 0) { + const client = new K8sClient() + vi.spyOn(client, "subresource").mockResolvedValue(undefined) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime } }, + }) + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") + function wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ) + } + return { client, queryClient, invalidateSpy, wrapper } +} + +describe("useK8sSubresource", () => { + it("calls the action subresource with a default empty body", async () => { + const { client, wrapper } = setup() + const { result } = renderHook(() => useK8sSubresource(actionRef), { wrapper }) + + await act(async () => { + await result.current.mutateAsync({ subresource: "start" }) + }) + + expect(client.subresource).toHaveBeenCalledWith( + "subresources.kubevirt.io", + "v1", + "virtualmachines", + "vm-instance-demo", + "start", + "tenant-root", + {}, + undefined, + ) + }) + + it("invalidates the action ref's own resource key when no invalidate target is given", async () => { + const { invalidateSpy, wrapper } = setup() + const { result } = renderHook(() => useK8sSubresource(actionRef), { wrapper }) + + await act(async () => { + await result.current.mutateAsync({ subresource: "start" }) + }) + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ["k8s", "subresources.kubevirt.io", "v1", "virtualmachines", "tenant-root"], + }) + }) + + it("invalidates the target resource (kubevirt.io) when invalidate is supplied", async () => { + const { invalidateSpy, wrapper } = setup() + const { result } = renderHook( + () => useK8sSubresource(actionRef, { invalidate: statusRef }), + { wrapper }, + ) + + await act(async () => { + await result.current.mutateAsync({ subresource: "start" }) + }) + + // Regression guard: the key must be built from the kubevirt.io status ref, + // otherwise it never matches the query that holds printableStatus. + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ["k8s", "kubevirt.io", "v1", "virtualmachines", "tenant-root"], + }) + expect(invalidateSpy).not.toHaveBeenCalledWith({ + queryKey: ["k8s", "subresources.kubevirt.io", "v1", "virtualmachines", "tenant-root"], + }) + }) + + it("invalidates a metadata.name field-selected list of the target resource", async () => { + // gcTime Infinity so the manually seeded (observer-less) query isn't + // garbage-collected before we read its post-invalidation state. + const { queryClient, wrapper } = setup(Infinity) + // The watch-based status read keys its list query with a field-selector. + // The resource-prefix invalidation must reach it (React Query prefix match), + // not just an unfiltered list — otherwise the post-action refresh is dead. + const fieldSelectedListKey = [ + "k8s", + "kubevirt.io", + "v1", + "virtualmachines", + "tenant-root", + "", + "metadata.name=vm-instance-demo", + ] + queryClient.setQueryData(fieldSelectedListKey, { items: [] }) + expect(queryClient.getQueryState(fieldSelectedListKey)?.isInvalidated).toBe(false) + + const { result } = renderHook( + () => useK8sSubresource(actionRef, { invalidate: statusRef }), + { wrapper }, + ) + await act(async () => { + await result.current.mutateAsync({ subresource: "start" }) + }) + + expect(queryClient.getQueryState(fieldSelectedListKey)?.isInvalidated).toBe(true) + }) +}) diff --git a/packages/k8s-client/src/hooks.ts b/packages/k8s-client/src/hooks.ts index f2a0818..e88e73c 100644 --- a/packages/k8s-client/src/hooks.ts +++ b/packages/k8s-client/src/hooks.ts @@ -180,13 +180,30 @@ export function useK8sDelete(ref: ResourceRef) { /** * Mutation hook for calling a resource subresource action (e.g. KubeVirt - * virtualmachines/{name}/start|stop|restart). On success it invalidates the - * GET and LIST caches for the referenced resource so status (e.g. + * virtualmachines/{name}/start|stop|restart). On success it invalidates every + * GET and LIST cache for the target resource so its status (e.g. * printableStatus) refetches. + * + * The action endpoint and the resource whose status you want to refresh can + * live under different API groups — KubeVirt serves the actions under + * `subresources.kubevirt.io` but the VirtualMachine (with its status) under + * `kubevirt.io`. Pass `options.invalidate` with the target resource's ref so + * the invalidation hits the query that holds the status; without it the keys + * never match and the refresh does nothing. + * + * Invalidation keys off the resource prefix `["k8s", group, version, plural, + * namespace]`, which React Query prefix-matches against both the by-name GET + * key and any field/label-selected LIST key — so a status read via a + * `metadata.name` field-selected `useK8sList` (the watch-based, no-poll path) + * is refreshed too. */ -export function useK8sSubresource(ref: ResourceRef & { name: string }) { +export function useK8sSubresource( + ref: ResourceRef & { name: string }, + options?: { invalidate?: ResourceRef }, +) { const client = useK8sClient() const queryClient = useQueryClient() + const invalidateRef = options?.invalidate ?? ref return useMutation({ mutationFn: ({ @@ -209,12 +226,20 @@ export function useK8sSubresource(ref: ResourceRef & { name: string }) { method, ), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: k8sGetKey(ref) }) - queryClient.invalidateQueries({ queryKey: k8sListKey(ref) }) + queryClient.invalidateQueries({ queryKey: k8sResourceKey(invalidateRef) }) }, }) } +/** + * Prefix shared by every GET and LIST key for a resource in a namespace. + * React Query prefix-matches on this, so invalidating it refreshes the by-name + * GET and every selector-scoped LIST of that resource at once. + */ +function k8sResourceKey(ref: ResourceRef) { + return ["k8s", ref.apiGroup, ref.apiVersion, ref.plural, ref.namespace ?? ""] as const +} + function k8sListKey(ref: ResourceRef, labelSelector?: string, fieldSelector?: string) { return [ "k8s", From 9c6287b555c861cf28b46c9a114d4160e3de2f93 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 2 Jun 2026 12:19:15 +0300 Subject: [PATCH 09/12] 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 Signed-off-by: Aleksei Sviridkin --- .../routes/detail/VMPowerControls.test.tsx | 147 ++++++++++++++++++ .../src/routes/detail/VMPowerControls.tsx | 57 ++++--- 2 files changed, 182 insertions(+), 22 deletions(-) create mode 100644 apps/console/src/routes/detail/VMPowerControls.test.tsx diff --git a/apps/console/src/routes/detail/VMPowerControls.test.tsx b/apps/console/src/routes/detail/VMPowerControls.test.tsx new file mode 100644 index 0000000..e9b41ef --- /dev/null +++ b/apps/console/src/routes/detail/VMPowerControls.test.tsx @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, afterEach } from "vitest" +import { screen, waitFor, cleanup } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { K8sClient } from "@cozystack/k8s-client" +import { renderWithK8sProvider } from "../../test-utils/render.tsx" +import { VMPowerControls } from "./VMPowerControls.tsx" +import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types" + +function makeAd(release?: { prefix?: string }): ApplicationDefinition { + return { + apiVersion: "cozystack.io/v1alpha1", + kind: "ApplicationDefinition", + metadata: { name: "virtual-machine" }, + spec: { + application: { + kind: "VMInstance", + plural: "vminstances", + singular: "vm-instance", + openAPISchema: "{}", + }, + ...(release ? { release } : {}), + }, + } +} + +const instance: ApplicationInstance = { + apiVersion: "apps.cozystack.io/v1alpha1", + kind: "VMInstance", + metadata: { name: "demo-vm", namespace: "tenant-root" }, +} + +function makeClient(printableStatus?: string) { + const client = new K8sClient() + vi.spyOn(client, "list").mockResolvedValue({ + apiVersion: "kubevirt.io/v1", + kind: "VirtualMachineList", + metadata: {}, + items: [ + { + apiVersion: "kubevirt.io/v1", + kind: "VirtualMachine", + metadata: { name: "vm-instance-demo-vm" }, + status: { printableStatus }, + }, + ], + }) + // No resourceVersion above means useK8sList never upgrades to a watch, but + // stub it anyway so a future change can't make the test hit the network. + vi.spyOn(client, "watch").mockReturnValue(() => {}) + vi.spyOn(client, "subresource").mockResolvedValue(undefined) + return client +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe("VMPowerControls VM-name resolution", () => { + it("field-selects the VM name from the singular when release.prefix is unset", async () => { + const client = makeClient("Stopped") + renderWithK8sProvider(, { client }) + + await waitFor(() => + expect(client.list).toHaveBeenCalledWith( + "kubevirt.io", + "v1", + "virtualmachines", + "tenant-root", + expect.objectContaining({ fieldSelector: "metadata.name=vm-instance-demo-vm" }), + ), + ) + }) + + it("field-selects the VM name from an explicit release.prefix", async () => { + const client = makeClient("Stopped") + renderWithK8sProvider( + , + { client }, + ) + + await waitFor(() => + expect(client.list).toHaveBeenCalledWith( + "kubevirt.io", + "v1", + "virtualmachines", + "tenant-root", + expect.objectContaining({ fieldSelector: "metadata.name=custom-demo-vm" }), + ), + ) + }) +}) + +describe("VMPowerControls button state", () => { + it("enables Start and disables Stop/Restart when the VM is Stopped", async () => { + const client = makeClient("Stopped") + renderWithK8sProvider(, { client }) + + await waitFor(() => expect(screen.getByText("Stopped")).toBeInTheDocument()) + expect(screen.getByRole("button", { name: "Start" })).toBeEnabled() + expect(screen.getByRole("button", { name: "Restart" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Stop" })).toBeDisabled() + }) + + it("disables Start and enables Stop/Restart when the VM is Running", async () => { + const client = makeClient("Running") + renderWithK8sProvider(, { client }) + + await waitFor(() => expect(screen.getByText("Running")).toBeInTheDocument()) + expect(screen.getByRole("button", { name: "Start" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Restart" })).toBeEnabled() + expect(screen.getByRole("button", { name: "Stop" })).toBeEnabled() + }) + + it("treats a Paused VM like a running one for Stop/Restart (it still has a VMI)", async () => { + const client = makeClient("Paused") + renderWithK8sProvider(, { client }) + + await waitFor(() => expect(screen.getByText("Paused")).toBeInTheDocument()) + expect(screen.getByRole("button", { name: "Start" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Restart" })).toBeEnabled() + expect(screen.getByRole("button", { name: "Stop" })).toBeEnabled() + }) +}) + +describe("VMPowerControls actions", () => { + it("invokes the start action against the subresources.kubevirt.io API", async () => { + const client = makeClient("Stopped") + renderWithK8sProvider(, { client }) + + const user = userEvent.setup() + const startButton = await screen.findByRole("button", { name: "Start" }) + await waitFor(() => expect(startButton).toBeEnabled()) + await user.click(startButton) + + expect(client.subresource).toHaveBeenCalledWith( + "subresources.kubevirt.io", + "v1", + "virtualmachines", + "vm-instance-demo-vm", + "start", + "tenant-root", + {}, + undefined, + ) + }) +}) diff --git a/apps/console/src/routes/detail/VMPowerControls.tsx b/apps/console/src/routes/detail/VMPowerControls.tsx index 7cde538..62805b1 100644 --- a/apps/console/src/routes/detail/VMPowerControls.tsx +++ b/apps/console/src/routes/detail/VMPowerControls.tsx @@ -2,11 +2,12 @@ import { useState } from "react" import { Play, Square, RotateCw } from "lucide-react" import { Button, StatusBadge } from "@cozystack/ui" import { - useK8sGet, + useK8sList, useK8sSubresource, type K8sResource, } from "@cozystack/k8s-client" import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types" +import { releasePrefix } from "../../lib/app-definitions.ts" // KubeVirt serves the VirtualMachine object under kubevirt.io and the // start/stop/restart action endpoints under the subresources.kubevirt.io @@ -30,38 +31,50 @@ export function VMPowerControls({ instance: ApplicationInstance }) { const ns = instance.metadata.namespace ?? "" - // The cozystack app name (e.g. "demo-vm") maps to the KubeVirt - // VirtualMachine named "" (e.g. "vm-instance-demo-vm"). - const prefix = ad.spec?.release?.prefix ?? "" - const vmName = `${prefix}${instance.metadata.name}` + // The cozystack app name (e.g. "demo-vm") maps to the KubeVirt VirtualMachine + // named "" (e.g. "vm-instance-demo-vm"). releasePrefix() + // discovers the prefix from the ApplicationDefinition (falling back to + // "-") so this resolves identically to VncTab — never hardcode it. + const vmName = `${releasePrefix(ad)}${instance.metadata.name}` - const { data: vm } = useK8sGet>( + // The VirtualMachine object (and its status) is served under kubevirt.io. + // List it by a metadata.name field-selector rather than a one-shot get so the + // useK8sList watch layer streams printableStatus transitions live — no poll. + const vmListRef = { + apiGroup: KUBEVIRT_GROUP, + apiVersion: KUBEVIRT_VERSION, + plural: "virtualmachines", + namespace: ns, + } + const { data: vmList } = useK8sList>(vmListRef, { + enabled: !!vmName && !!ns, + fieldSelector: `metadata.name=${vmName}`, + }) + const vm = vmList?.items[0] + + const action = useK8sSubresource( { - apiGroup: KUBEVIRT_GROUP, + apiGroup: KUBEVIRT_SUBRESOURCE_GROUP, apiVersion: KUBEVIRT_VERSION, plural: "virtualmachines", name: vmName, namespace: ns, }, - { - enabled: !!vmName && !!ns, - refetchInterval: 5000, - }, + // The action endpoints live under subresources.kubevirt.io, but the status + // lives on the VirtualMachine under kubevirt.io. Invalidate that resource so + // the field-selected list above refetches immediately (the watch would catch + // up on its own, but this makes the button feedback instant). + { invalidate: vmListRef }, ) - const action = useK8sSubresource({ - apiGroup: KUBEVIRT_SUBRESOURCE_GROUP, - apiVersion: KUBEVIRT_VERSION, - plural: "virtualmachines", - name: vmName, - namespace: ns, - }) - const [pending, setPending] = useState(null) const status = vm?.status?.printableStatus const isRunning = status === "Running" - const isStopped = status === "Stopped" || status === "Halted" + const isStopped = status === "Stopped" + // A paused VM still has a running VirtualMachineInstance, so stop/restart + // apply to it just as they do to a running one. + const hasRunningInstance = isRunning || status === "Paused" const busy = action.isPending || pending !== null const run = async (sub: Power, confirmMsg?: string) => { @@ -92,7 +105,7 @@ export function VMPowerControls({