feat: Better debugging for console
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user