feat: Better debugging for console
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m22s

This commit is contained in:
2026-07-31 15:51:26 +01:00
parent 6f86496f10
commit 1eb98ef962
3 changed files with 231 additions and 32 deletions
+120 -10
View File
@@ -6,7 +6,27 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { openConsole } from "@/lib/guacConsole";
import { openConsole, type ConsoleFailure, type ConsoleState } from "@/lib/guacConsole";
// The session's own lifecycle, which is not the same as the tunnel's: "idle"
// means the form is showing, and everything else means a session has been
// started and the viewport owns the page.
type SessionPhase = "idle" | ConsoleState;
const PHASE_LABEL: Record<Exclude<SessionPhase, "idle">, string> = {
connecting: "Connecting",
connected: "Connected",
disconnected: "Disconnected",
error: "Failed",
};
// Shape as well as colour: state must never read by colour alone.
const PHASE_DOT: Record<Exclude<SessionPhase, "idle">, string> = {
connecting: "bg-warning animate-pulse",
connected: "bg-success",
disconnected: "bg-text-tertiary",
error: "bg-danger",
};
export default function ServerConsolePage() {
const params = useParams();
@@ -29,8 +49,10 @@ export default function ServerConsolePage() {
const [rdpPassword, setRdpPassword] = useState("");
const [vncPassword, setVncPassword] = useState("");
const [connecting, setConnecting] = useState(false);
const [connected, setConnected] = useState(false);
const [phase, setPhase] = useState<SessionPhase>("idle");
const [error, setError] = useState<string | null>(null);
const [failure, setFailure] = useState<ConsoleFailure | null>(null);
const connected = phase !== "idle";
const [pending, setPending] = useState<{ token: string; wsPath: string } | null>(null);
const [zoom, setZoom] = useState(1);
const dprRef = useRef(1);
@@ -74,6 +96,7 @@ export default function ServerConsolePage() {
async function handleConnect() {
setError(null);
setFailure(null);
setConnecting(true);
try {
const body: Parameters<typeof api.connectConsole>[0] = {
@@ -93,10 +116,18 @@ export default function ServerConsolePage() {
const { token, ws_path } = await api.connectConsole(body);
// "connecting", not "connected": all we have so far is a token. The
// real state now comes from the tunnel, which is the only thing that
// knows whether the far end ever answered.
setPending({ token, wsPath: ws_path });
setConnected(true);
setPhase("connecting");
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to connect");
const message = e instanceof Error ? e.message : "Failed to connect";
setError(
message.includes("agent_offline")
? "The agent on this server is not connected, so a console session cannot be opened."
: message
);
} finally {
setConnecting(false);
}
@@ -121,7 +152,10 @@ export default function ServerConsolePage() {
`&height=${Math.floor(rect.height * dpr)}` +
`&dpi=96`;
connectionRef.current = openConsole(containerRef.current, wsUrl, connectData);
connectionRef.current = openConsole(containerRef.current, wsUrl, connectData, {
onState: (s) => setPhase(s),
onFailure: (f) => setFailure(f),
});
connectionRef.current.setScale(zoom / dpr);
setPending(null);
}, [connected, pending]);
@@ -140,15 +174,23 @@ export default function ServerConsolePage() {
connectionRef.current.setScale(zoom / dpr);
}, [zoom]);
// Returns to the connection form. Used both by the Disconnect button and by
// Reconnect, which is the same teardown followed by a fresh dial.
function handleDisconnect() {
connectionRef.current?.disconnect();
connectionRef.current = null;
setConnected(false);
setPhase("idle");
setFailure(null);
if (containerRef.current) {
containerRef.current.innerHTML = "";
}
}
function handleReconnect() {
handleDisconnect();
void handleConnect();
}
if (serverLoading || keysLoading) {
return (
<div className="flex h-full items-center justify-center">
@@ -269,9 +311,18 @@ export default function ServerConsolePage() {
</Card>
) : (
<div className="mb-4 flex flex-wrap items-center gap-3">
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-text-secondary">
<span className={`h-2 w-2 rounded-full ${PHASE_DOT[phase as Exclude<SessionPhase, "idle">]}`} />
{PHASE_LABEL[phase as Exclude<SessionPhase, "idle">]}
</span>
<Button variant="danger" onClick={handleDisconnect}>
Disconnect
</Button>
{(phase === "error" || phase === "disconnected") && (
<Button variant="secondary" onClick={handleReconnect}>
Reconnect
</Button>
)}
<label className="text-sm text-text-secondary">Scale</label>
<select
value={zoom}
@@ -292,10 +343,69 @@ export default function ServerConsolePage() {
</div>
)}
<div
ref={containerRef}
className="min-h-[500px] flex-1 overflow-hidden rounded-lg border border-border bg-black"
/>
{/* The viewport is always mounted — Guacamole attaches its display
element to it on connect, so it cannot be conditionally rendered.
Anything the operator needs to be told is layered over it
instead, which is what a bare black rectangle never did. */}
<div className="relative min-h-[500px] flex-1">
<div
ref={containerRef}
className="absolute inset-0 overflow-hidden rounded-lg border border-border bg-well"
/>
{phase === "connecting" && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 rounded-lg bg-ground/70">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
<p className="text-sm text-text-secondary">
Opening {protocol.toUpperCase()} session on {server.hostname}
</p>
</div>
)}
{(phase === "error" || phase === "disconnected") && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-ground/80 p-6">
<Card className="max-w-md">
<div className="space-y-3">
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${
phase === "error" ? "bg-danger" : "bg-text-tertiary"
}`}
/>
<h2 className="text-sm font-semibold text-text-primary">
{phase === "error" ? "Console session failed" : "Console session ended"}
</h2>
</div>
<p className="text-sm text-text-secondary">
{failure?.message ??
(phase === "error"
? "The session ended without reporting a reason."
: "The remote host closed the session.")}
</p>
{/* The numeric status is what makes a support
ticket actionable, so it is shown rather than
folded into the sentence above. */}
{failure?.code !== undefined && (
<p className="font-mono text-xs text-text-tertiary">
{failure.source === "tunnel" ? "tunnel" : "session"} status {failure.code}
</p>
)}
<div className="flex gap-2 pt-1">
<Button variant="primary" onClick={handleReconnect}>
Reconnect
</Button>
<Button variant="secondary" onClick={handleDisconnect}>
Change settings
</Button>
</div>
</div>
</Card>
</div>
)}
</div>
</div>
);
}
+110 -21
View File
@@ -1,37 +1,135 @@
declare const Guacamole: any;
/**
* The lifecycle of a console session as the UI needs to talk about it.
*
* Deliberately not Guacamole's own state enum: "waiting" and "connecting" are
* one thing to an operator, and "disconnected because you clicked Disconnect"
* and "disconnected because the far end vanished" are two things that Guacamole
* reports identically.
*/
export type ConsoleState =
| "connecting"
| "connected"
| "disconnected"
| "error";
export type ConsoleFailure = {
/** Guacamole status code, when the failure came with one. */
code?: number;
/** Operator-facing sentence. Always set. */
message: string;
/** Where the failure was reported: the tunnel or the client session. */
source: "tunnel" | "client";
};
/**
* Guacamole status codes, as operator-facing sentences.
*
* These are the only diagnosis anyone gets from a failed console: the relay,
* guacd and the target daemon are all invisible from the browser, and guacd
* deliberately reports upstream failures as a bare number. Leaving them
* unmapped is what makes a broken console indistinguishable from a slow one.
*
* Source: Guacamole protocol status codes (guacamole-common-js).
*/
const STATUS_TEXT: Record<number, string> = {
256: "The server does not support this operation.",
512: "The remote desktop server encountered an error.",
513: "The remote desktop server is busy.",
514: "The remote host did not respond in time.",
515: "The remote host encountered an error.",
516: "The requested resource was not found.",
517: "The requested resource is already in use.",
518: "The remote connection was closed.",
519: "The remote host could not be reached.",
520: "The remote host is not currently available.",
521: "The session conflicts with another session.",
522: "The session timed out.",
523: "The session was closed.",
768: "The server rejected the connection request.",
769: "Authentication failed — check the credentials or SSH key.",
771: "Access to this connection was refused.",
776: "The session was closed after a period of inactivity.",
781: "The connection was closed because the client fell behind.",
782: "The server sent data the client could not understand.",
783: "Too many concurrent connections.",
};
/**
* describeStatus turns a Guacamole status into a sentence, always keeping the
* numeric code available separately. The code is what makes a support ticket
* actionable, so it is never discarded in favour of the prose.
*/
export function describeStatus(code: number | undefined, fallback: string): string {
if (code === undefined) return fallback;
return STATUS_TEXT[code] ?? fallback;
}
export type ConsoleHandlers = {
onState?: (state: ConsoleState) => void;
onFailure?: (failure: ConsoleFailure) => void;
};
export function openConsole(
container: HTMLElement,
wsUrl: string,
connectData = ""
connectData = "",
handlers: ConsoleHandlers = {}
): {
disconnect: () => void;
setScale: (scale: number) => void;
resize: (width: number, height: number) => void;
focus: () => void;
} {
const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
const client = new Guacamole.Client(tunnel);
// Set once the caller tears the session down deliberately. Guacamole reports
// a user-initiated disconnect through exactly the same callbacks as a far-end
// failure, so without this flag closing the tab raises an error banner.
let closing = false;
const fail = (source: "tunnel" | "client", status: any, fallback: string) => {
if (closing) return;
const code: number | undefined =
typeof status?.code === "number" ? status.code : undefined;
const message = describeStatus(
code,
typeof status?.message === "string" && status.message ? status.message : fallback
);
handlers.onFailure?.({ code, message, source });
handlers.onState?.("error");
};
tunnel.onerror = (status: any) =>
fail("tunnel", status, "The connection to the control plane was lost.");
client.onerror = (status: any) =>
fail("client", status, "The remote session ended unexpectedly.");
// Guacamole.Client.State: 0 IDLE, 1 CONNECTING, 2 WAITING, 3 CONNECTED,
// 4 DISCONNECTING, 5 DISCONNECTED. CONNECTING and WAITING are one state to an
// operator — both mean "not usable yet".
client.onstatechange = (state: number) => {
if (closing) return;
if (state === 1 || state === 2) handlers.onState?.("connecting");
else if (state === 3) handlers.onState?.("connected");
else if (state === 5) handlers.onState?.("disconnected");
};
container.innerHTML = "";
container.appendChild(client.getDisplay().getElement());
container.tabIndex = 0;
container.style.outline = "none";
handlers.onState?.("connecting");
client.connect(connectData);
const display = client.getDisplay();
let scale = 1;
const mouse = new Guacamole.Mouse(display.getElement());
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (state: any) => {
const s = new Guacamole.Mouse.State(
@@ -45,18 +143,10 @@ export function openConsole(
);
client.sendMouseState(s);
};
const keyboard = new Guacamole.Keyboard(container);
keyboard.onkeydown = (k: number) => client.sendKeyEvent(1, k);
keyboard.onkeyup = (k: number) => client.sendKeyEvent(0, k);
const refocus = () => {
if (document.activeElement !== container) container.focus({ preventScroll: true });
@@ -65,8 +155,6 @@ export function openConsole(
container.addEventListener("mousedown", refocus, true);
container.addEventListener("touchstart", refocus, true);
const onBlur = () => {
if (typeof keyboard.reset === "function") keyboard.reset();
};
@@ -77,6 +165,7 @@ export function openConsole(
return {
disconnect() {
closing = true;
container.removeEventListener("pointerdown", refocus, true);
container.removeEventListener("mousedown", refocus, true);
container.removeEventListener("touchstart", refocus, true);
File diff suppressed because one or more lines are too long