diff --git a/dashboard/web/components/Sidebar.tsx b/dashboard/web/components/Sidebar.tsx
index 47c80634a5bff60bc22ea9118674ee0f17bcc933..93a9263c2c63927f2d249b19cbb100d1dc5055dc 100644
--- a/dashboard/web/components/Sidebar.tsx
+++ b/dashboard/web/components/Sidebar.tsx
@@ -157,7 +157,7 @@ function VMNav() {
diff --git a/dashboard/web/components/VMConsole.tsx b/dashboard/web/components/VMConsole.tsx
index 602099de40a6935c43359cf99f3ebaf389917cbc..1263edcf49ce81a097475716d40eb62406248690 100644
--- a/dashboard/web/components/VMConsole.tsx
+++ b/dashboard/web/components/VMConsole.tsx
@@ -1,5 +1,5 @@
import { VMMenu } from "./VMMenu.tsx";
-import { createEffect, createMemo, createSignal, type JSX, onCleanup, Show } from "solid-js";
+import { createEffect, createMemo, createSignal, type JSX, on, onCleanup, Show } from "solid-js";
import { reason } from "../api.ts";
import Keyboard from "lucide-solid/icons/keyboard";
import Clipboard from "lucide-solid/icons/clipboard";
@@ -7,6 +7,8 @@ import Maximize from "lucide-solid/icons/maximize";
import RefreshCw from "lucide-solid/icons/refresh-cw";
import Check from "lucide-solid/icons/check";
import RemoteKeyboard from "@novnc/keyboard";
+import { VM_SETUP, type Domain } from "../types/vms.ts";
+import { StatusLabel } from "./Status.tsx";
type WindowSurface = {
id: number; x: number; y: number; width: number; height: number; atlas_x: number; atlas_y: number;
@@ -14,8 +16,10 @@ type WindowSurface = {
};
type WindowFrame = { windows: WindowSurface[]; rtpTimestamp: number };
-export function VMConsole(props: { name: string; enabled: boolean; active: boolean; toolbar: JSX.Element; content: (toolbar: HTMLDivElement) => JSX.Element }) {
+export function VMConsole(props: { name: string; enabled: boolean; active: boolean; setup?: Domain["setup"]; toolbar: JSX.Element; content: (toolbar: HTMLDivElement) => JSX.Element }) {
const enabled = createMemo(() => props.enabled);
+ const preparing = () => !!props.setup && props.setup !== "ready";
+ let reconnectTimer: number | undefined;
let attempt = 0;
let video!: HTMLVideoElement;
let canvas!: HTMLCanvasElement;
@@ -121,6 +125,13 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole
const keyboard = new RemoteKeyboard(video);
const focus = () => { if (input?.readyState === "open") keyboard.grab(); };
const blur = () => { keyboard.ungrab(); release(); };
+ const releaseKeyboard = (event: KeyboardEvent) => {
+ if (event.key !== "Escape" || !event.ctrlKey || !event.altKey) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ tools()?.querySelector("summary")?.focus();
+ };
+ video.addEventListener("keydown", releaseKeyboard, true);
video.addEventListener("focus", focus);
video.addEventListener("blur", blur);
let peer: RTCPeerConnection | undefined;
@@ -171,13 +182,23 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole
const fail = (message: string) => {
if (current !== attempt || disposed) return;
setState("Disconnected");
- setProblem(message);
disconnect();
+ if (preparing() && props.setup !== "failed") {
+ setProblem("");
+ reconnectTimer = window.setTimeout(connect, 1000);
+ } else setProblem(message);
};
const timeout = window.setTimeout(() => fail("The screen couldn't connect. Reconnect to try again."), 20000);
+ const ready = () => {
+ if (current !== attempt || disposed || video.readyState < 2 || input?.readyState !== "open") return;
+ clearTimeout(timeout);
+ setState("Connected");
+ };
disconnect = () => {
+ clearTimeout(reconnectTimer);
clearTimeout(timeout);
blur();
+ video.removeEventListener("keydown", releaseKeyboard, true);
video.removeEventListener("focus", focus);
video.removeEventListener("blur", blur);
socket.onclose = null;
@@ -219,6 +240,7 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole
movement = peer.createDataChannel("pointer", { ordered: false, maxRetransmits: 0 });
keyboard.onkeyevent = (key, _code, down) => send({ type: "key", key, down });
input.onopen = () => {
+ ready();
if (document.activeElement === video) focus();
if (windowsAvailable()) send({ type: "experiment", enabled: separateWindows() });
};
@@ -308,10 +330,8 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole
};
video.onplaying = () => {
if (current !== attempt || disposed) return;
- clearTimeout(timeout);
- setState("Connected");
+ ready();
compose();
- if (props.active) video.focus();
};
} catch (failure) {
console.error("VM screen connection", failure);
@@ -330,7 +350,10 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole
setProblem("");
}
});
- createEffect(() => { if (props.active && connected()) video.focus(); else release(); });
+ createEffect(() => { if (props.active && connected() && !preparing()) video.focus(); else release(); });
+ createEffect(on(() => props.setup, setup => {
+ if (setup === "ready" && enabled() && state() === "Disconnected") void connect();
+ }, { defer: true }));
onCleanup(() => { disposed = true; disconnect(); });
const pointer = (event: PointerEvent | WheelEvent, mask = buttons) => {
if (!connected() || !width || !height) return;
@@ -380,7 +403,8 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole
- {props.enabled ? state() : "Off"}
+ Ctrl+Alt+Esc releases keyboard
+ {preparing() ? "" : props.enabled ? state() : "Off"}
@@ -390,7 +414,7 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole
{windowCapture() ? "Separate windows" : "Desktop fallback"}
- {problem()}
+ {problem()}
@@ -401,9 +425,15 @@ export function VMConsole(props: { name: string; enabled: boolean; active: boole
-
+
+
+
{VM_SETUP[props.setup!]}
+
{props.setup === "failed" ? "Restart this VM from the Machine menu to retry." : "Your desktop opens here when setup finishes."}
+
+
+
-