-
+
{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"