diff --git a/web/app/(app)/servers/[id]/console/page.tsx b/web/app/(app)/servers/[id]/console/page.tsx index a9109e5..d64293e 100644 --- a/web/app/(app)/servers/[id]/console/page.tsx +++ b/web/app/(app)/servers/[id]/console/page.tsx @@ -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, 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, 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("idle"); const [error, setError] = useState(null); + const [failure, setFailure] = useState(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[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 (
@@ -269,9 +311,18 @@ export default function ServerConsolePage() { ) : (
+ + ]}`} /> + {PHASE_LABEL[phase as Exclude]} + + {(phase === "error" || phase === "disconnected") && ( + + )}