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() + }) +}) 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/apps/console/src/components/SchemaForm.tsx b/apps/console/src/components/SchemaForm.tsx index 39f40a6..b0dedc1 100644 --- a/apps/console/src/components/SchemaForm.tsx +++ b/apps/console/src/components/SchemaForm.tsx @@ -103,6 +103,19 @@ function addAdditionalPropertiesWidgets(schema: RJSFSchema, uiSchema: UiSchema = return result } +/** + * Minimal structural view of a JSON-schema node used by the walk below. + * RJSFSchema is intersected with an `any` index signature, so reading fields + * straight off it yields `any`; routing through this interface keeps the walk + * typed without an `as any` cast. + */ +interface SchemaNode { + type?: string | string[] + properties?: Record + additionalProperties?: unknown + items?: unknown +} + /** * Resolve the uiSchema fragment for one schema node: bind the custom field to * an additionalProperties map, recurse into nested objects, or recurse into @@ -112,30 +125,30 @@ function bindAdditionalProperties( fieldSchema: RJSFSchema, uiNode: UiSchema | undefined, ): UiSchema | undefined { - const node = fieldSchema as any + const node: SchemaNode = fieldSchema const isAdditionalPropertiesMap = node.type === "object" && (!node.properties || Object.keys(node.properties).length === 0) && typeof node.additionalProperties === "object" && - node.additionalProperties !== null && - node.additionalProperties !== true + node.additionalProperties !== null if (isAdditionalPropertiesMap) { return { ...uiNode, "ui:field": "AdditionalPropertiesField" } } if (node.properties) { - return addAdditionalPropertiesWidgets(fieldSchema, uiNode as UiSchema) + return addAdditionalPropertiesWidgets(fieldSchema, uiNode) } + const items = node.items if ( node.type === "array" && - node.items && - typeof node.items === "object" && - !Array.isArray(node.items) + items && + typeof items === "object" && + !Array.isArray(items) ) { - const itemsUi = bindAdditionalProperties(node.items as RJSFSchema, (uiNode as any)?.items) + const itemsUi = bindAdditionalProperties(items as RJSFSchema, uiNode?.items) if (itemsUi !== undefined) { return { ...uiNode, items: itemsUi } } diff --git a/apps/console/src/lib/app-definitions.test.ts b/apps/console/src/lib/app-definitions.test.ts new file mode 100644 index 0000000..70f800a --- /dev/null +++ b/apps/console/src/lib/app-definitions.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest" +import { releasePrefix } from "./app-definitions.ts" +import type { ApplicationDefinition } from "@cozystack/types" + +function ad(overrides: Partial = {}): ApplicationDefinition { + return { + apiVersion: "cozystack.io/v1alpha1", + kind: "ApplicationDefinition", + metadata: { name: "virtual-machine" }, + spec: { + application: { + kind: "VMInstance", + plural: "vminstances", + singular: "vm-instance", + openAPISchema: "{}", + }, + }, + ...overrides, + } +} + +describe("releasePrefix", () => { + it("returns the explicit release.prefix when set", () => { + expect( + releasePrefix( + ad({ + spec: { + application: { + kind: "VMInstance", + plural: "vminstances", + singular: "vm-instance", + openAPISchema: "{}", + }, + release: { prefix: "custom-" }, + }, + }), + ), + ).toBe("custom-") + }) + + it("falls back to '-' when release.prefix is unset", () => { + expect(releasePrefix(ad())).toBe("vm-instance-") + }) + + it("falls back to '-' when neither prefix nor spec is present", () => { + expect(releasePrefix(ad({ spec: undefined }))).toBe("virtual-machine-") + }) +}) diff --git a/apps/console/src/routes/detail/ApplicationDetailPage.tsx b/apps/console/src/routes/detail/ApplicationDetailPage.tsx index 7a398f0..10b82a8 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 }>() @@ -155,6 +156,9 @@ export function ApplicationDetailPage() { ) : null}
+ {kind === "VMInstance" && ( + + )} + + +
+ ) +} diff --git a/apps/console/src/routes/detail/VncTab.test.tsx b/apps/console/src/routes/detail/VncTab.test.tsx new file mode 100644 index 0000000..c6c884e --- /dev/null +++ b/apps/console/src/routes/detail/VncTab.test.tsx @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, afterEach } from "vitest" +import { screen, waitFor, cleanup } from "@testing-library/react" +import { K8sClient } from "@cozystack/k8s-client" +import { renderWithK8sProvider } from "../../test-utils/render.tsx" +import { VncTab } from "./VncTab.tsx" +import { VMPowerControls } from "./VMPowerControls.tsx" +import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types" + +function makeAd(kind: string, release?: { prefix?: string }): ApplicationDefinition { + return { + apiVersion: "cozystack.io/v1alpha1", + kind: "ApplicationDefinition", + metadata: { name: "virtual-machine" }, + spec: { + application: { + kind, + 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" }, +} + +// Stopped keeps VncTab off the noVNC code path (no websocket in jsdom) while +// still exercising the name resolution and the gating branches. +function makeClient(printableStatus = "Stopped") { + 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 }, + }, + ], + }) + vi.spyOn(client, "watch").mockReturnValue(() => {}) + return client +} + +function fieldSelector(client: K8sClient): string | undefined { + const calls = (client.list as unknown as { mock: { calls: unknown[][] } }).mock.calls + const search = calls[0]?.[4] as { fieldSelector?: string } | undefined + return search?.fieldSelector +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe("VncTab gating", () => { + it("shows the VMInstance-only notice and never queries for non-VM apps", () => { + const client = makeClient() + renderWithK8sProvider( + , + { client }, + ) + + expect(screen.getByText(/VNC is only available for VMInstance/i)).toBeInTheDocument() + expect(client.list).not.toHaveBeenCalled() + }) + + it("shows the not-running notice when the VM is stopped", async () => { + const client = makeClient("Stopped") + renderWithK8sProvider(, { client }) + + await waitFor(() => + expect(screen.getByText(/virtual machine is not running/i)).toBeInTheDocument(), + ) + }) +}) + +describe("VncTab VM-name resolution", () => { + it("field-selects the name from the singular when release.prefix is unset", async () => { + const client = makeClient() + 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 name from an explicit release.prefix", async () => { + const client = makeClient() + renderWithK8sProvider( + , + { client }, + ) + + await waitFor(() => + expect(client.list).toHaveBeenCalledWith( + "kubevirt.io", + "v1", + "virtualmachines", + "tenant-root", + expect.objectContaining({ fieldSelector: "metadata.name=custom-demo-vm" }), + ), + ) + }) +}) + +describe("VMPowerControls and VncTab agree on the VirtualMachine name", () => { + it.each([ + ["unset prefix", undefined], + ["explicit prefix", { prefix: "custom-" }], + ])("resolve the identical name (%s)", async (_label, release) => { + const pcClient = makeClient() + renderWithK8sProvider( + , + { client: pcClient }, + ) + await waitFor(() => expect(pcClient.list).toHaveBeenCalled()) + + const vncClient = makeClient() + renderWithK8sProvider( + , + { client: vncClient }, + ) + await waitFor(() => expect(vncClient.list).toHaveBeenCalled()) + + expect(fieldSelector(pcClient)).toBe(fieldSelector(vncClient)) + }) +}) diff --git a/apps/console/src/routes/detail/VncTab.tsx b/apps/console/src/routes/detail/VncTab.tsx index 43386af..b3c55e1 100644 --- a/apps/console/src/routes/detail/VncTab.tsx +++ b/apps/console/src/routes/detail/VncTab.tsx @@ -1,6 +1,8 @@ import { useEffect, useRef, useState } from "react" -import { Maximize2, Minimize2, Power, RotateCcw, Terminal } from "lucide-react" +import { Monitor, Maximize2, Minimize2, Power, RotateCcw, Terminal } from "lucide-react" +import { useK8sList, type K8sResource } from "@cozystack/k8s-client" import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types" +import { releasePrefix } from "../../lib/app-definitions.ts" interface VncTabProps { ad: ApplicationDefinition @@ -10,8 +12,36 @@ 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 VirtualMachine + // / VirtualMachineInstance named "". releasePrefix() + // discovers the prefix from the ApplicationDefinition (falling back to + // "-") so this resolves identically to VMPowerControls — both must + // target the same object, so neither may hardcode the prefix. + const vmName = `${releasePrefix(ad)}${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. List the VirtualMachine by a metadata.name field-selector so the + // useK8sList watch layer streams power-state transitions live — no poll. + const { data: vmList, isLoading: vmLoading } = useK8sList< + K8sResource + >( + { + apiGroup: "kubevirt.io", + apiVersion: "v1", + plural: "virtualmachines", + namespace: ns ?? "", + }, + { + enabled: appKind === "VMInstance" && !!ns, + fieldSelector: `metadata.name=${vmName}`, + }, + ) + const powerStatus = vmList?.items[0]?.status?.printableStatus + const isRunning = powerStatus === "Running" const containerRef = useRef(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- noVNC RFB has no bundled types const rfbRef = useRef(null) const [connected, setConnected] = useState(false) const [loading, setLoading] = useState(true) @@ -21,7 +51,7 @@ export function VncTab({ ad, instance }: VncTabProps) { const [desktopSize, setDesktopSize] = useState<{ width: number; height: number } | null>(null) useEffect(() => { - if (!containerRef.current || appKind !== "VMInstance") return + if (!containerRef.current || appKind !== "VMInstance" || !isRunning) return const el = containerRef.current while (el.firstChild) el.removeChild(el.firstChild) @@ -31,72 +61,76 @@ export function VncTab({ ad, instance }: VncTabProps) { setConnected(false) const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:" - const vmiName = `vm-instance-${instance.metadata.name}` - const wsUrl = `${wsProtocol}//${window.location.host}/k8s/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${vmiName}/vnc` + const wsUrl = `${wsProtocol}//${window.location.host}/k8s/apis/subresources.kubevirt.io/v1/namespaces/${ns}/virtualmachineinstances/${vmName}/vnc` // Prevent stale import resolutions from firing after cleanup or reconnect let cancelled = false - import("@novnc/novnc/lib/rfb").then((module) => { - if (cancelled || !containerRef.current) return - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const RFB = (module as any).default?.default ?? module.default - try { - const rfb = new RFB(el, wsUrl, { credentials: {} }) - rfb.scaleViewport = true - rfb.resizeSession = true - - // Guard each handler: if rfbRef was replaced by a newer session, ignore + import("@novnc/novnc/lib/rfb") + .then((module) => { + if (cancelled || !containerRef.current) return // eslint-disable-next-line @typescript-eslint/no-explicit-any - rfb.addEventListener("connect", () => { - if (rfbRef.current !== rfb) return - setLoading(false) - setConnected(true) - setError(null) - requestAnimationFrame(() => { - const canvas = el.querySelector("canvas") - if (canvas) setDesktopSize({ width: canvas.width, height: canvas.height }) + const RFB = (module as any).default?.default ?? module.default + try { + const rfb = new RFB(el, wsUrl, { credentials: {} }) + rfb.scaleViewport = true + rfb.resizeSession = true + + // Guard each handler: if rfbRef was replaced by a newer session, ignore + rfb.addEventListener("connect", () => { + if (rfbRef.current !== rfb) return + setLoading(false) + setConnected(true) + setError(null) + requestAnimationFrame(() => { + const canvas = el.querySelector("canvas") + if (canvas) setDesktopSize({ width: canvas.width, height: canvas.height }) + }) }) - }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rfb.addEventListener("disconnect", (e: any) => { - if (rfbRef.current !== rfb) return - setConnected(false) - setLoading(false) - if (!e.detail?.clean) setError(`Connection lost: ${e.detail?.reason ?? "unknown"}`) - }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rfb.addEventListener("disconnect", (e: any) => { + if (rfbRef.current !== rfb) return + setConnected(false) + setLoading(false) + if (!e.detail?.clean) setError(`Connection lost: ${e.detail?.reason ?? "unknown"}`) + }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rfb.addEventListener("securityfailure", (e: any) => { - if (rfbRef.current !== rfb) return - setConnected(false) - setLoading(false) - setError(`Security failure: ${e.detail?.status ?? "authentication failed"}`) - }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rfb.addEventListener("securityfailure", (e: any) => { + if (rfbRef.current !== rfb) return + setConnected(false) + setLoading(false) + setError(`Security failure: ${e.detail?.status ?? "authentication failed"}`) + }) - rfbRef.current = rfb - } catch (err) { + rfbRef.current = rfb + } catch (err) { + if (!cancelled) { + setLoading(false) + setError(`Failed to initialize VNC: ${(err as Error).message}`) + } + } + }) + .catch((err) => { if (!cancelled) { setLoading(false) - setError(`Failed to initialize VNC: ${(err as Error).message}`) + setError(`Failed to load VNC library: ${err.message}`) } - } - }).catch((err) => { - if (!cancelled) { - setLoading(false) - setError(`Failed to load VNC library: ${err.message}`) - } - }) + }) return () => { cancelled = true if (rfbRef.current) { - try { rfbRef.current.disconnect() } catch {} + try { + rfbRef.current.disconnect() + } catch { + /* ignore: socket may already be closed */ + } rfbRef.current = null } } - }, [appKind, ns, instance.metadata.name, connectionKey]) // eslint-disable-line react-hooks/exhaustive-deps + }, [appKind, ns, vmName, isRunning, connectionKey]) useEffect(() => { const handler = () => setFullscreen(!!document.fullscreenElement) @@ -115,6 +149,28 @@ export function VncTab({ ad, instance }: VncTabProps) { ) } + if (vmLoading) { + return ( +
+

Loading…

+
+ ) + } + + if (!isRunning) { + return ( +
+
+ +

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

+
+
+ ) + } + const handleFullscreen = () => { const wrapper = containerRef.current?.parentElement if (!wrapper) return @@ -127,33 +183,30 @@ export function VncTab({ ad, instance }: VncTabProps) { const handleReconnect = () => { if (rfbRef.current) { - try { rfbRef.current.disconnect() } catch {} + try { + rfbRef.current.disconnect() + } catch { + /* ignore: socket may already be closed */ + } rfbRef.current = null } setDesktopSize(null) setConnectionKey((k) => k + 1) } - const statusColor = connected - ? "bg-emerald-500" - : loading - ? "bg-amber-400" - : "bg-red-500" - + const statusColor = connected ? "bg-emerald-500" : loading ? "bg-amber-400" : "bg-red-500" const statusLabel = connected ? "Connected" : loading ? "Connecting…" : "Disconnected" return (
{/* Outer panel — shadow bridges the light page and dark terminal */}
- {/* ── Toolbar ── */}
- {/* Left: status pill + vm name */}
- + {connected && ( )} @@ -189,16 +242,14 @@ export function VncTab({ ad, instance }: VncTabProps) { onClick={handleFullscreen} title={fullscreen ? "Exit fullscreen" : "Fullscreen"} > - {fullscreen - ? - : } + {fullscreen ? ( + + ) : ( + + )} - +
@@ -207,9 +258,15 @@ export function VncTab({ ad, instance }: VncTabProps) { {/* ── Canvas area ── */}
{/* Loading overlay */} {loading && ( @@ -218,7 +275,7 @@ export function VncTab({ ad, instance }: VncTabProps) { {[0, 1, 2].map((i) => ( ))} @@ -275,7 +332,7 @@ function ToolbarButton({ onClick={onClick} disabled={disabled} title={title} - className="flex items-center gap-1 rounded px-1.5 py-1 text-slate-500 transition-colors hover:bg-slate-700/60 hover:text-slate-200 cursor-pointer disabled:cursor-not-allowed disabled:opacity-30" + className="flex cursor-pointer items-center gap-1 rounded px-1.5 py-1 text-slate-500 transition-colors hover:bg-slate-700/60 hover:text-slate-200 disabled:cursor-not-allowed disabled:opacity-30" > {children} {label && {label}} diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 6d39b26..1e33159 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()], @@ -27,10 +28,12 @@ export default defineConfig({ "/apis": { target: "http://localhost:8001", changeOrigin: true, + ws: true, }, "/api": { target: "http://localhost:8001", changeOrigin: true, + ws: true, }, }, }, diff --git a/packages/k8s-client/src/client.ts b/packages/k8s-client/src/client.ts index c8d7a8c..4639d9f 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( @@ -175,6 +180,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..e88e73c 100644 --- a/packages/k8s-client/src/hooks.ts +++ b/packages/k8s-client/src/hooks.ts @@ -178,6 +178,68 @@ 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 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 }, + options?: { invalidate?: ResourceRef }, +) { + const client = useK8sClient() + const queryClient = useQueryClient() + const invalidateRef = options?.invalidate ?? ref + + 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: 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", diff --git a/packages/k8s-client/src/index.ts b/packages/k8s-client/src/index.ts index 0865e8d..dbc579b 100644 --- a/packages/k8s-client/src/index.ts +++ b/packages/k8s-client/src/index.ts @@ -20,6 +20,7 @@ export { useK8sCreate, useK8sUpdate, useK8sDelete, + useK8sSubresource, } from "./hooks.ts" export type { ResourceRef } from "./hooks.ts"