From 0962745bbcb95890183338962fd1a05dacd9680d Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 17 Jul 2026 11:45:10 +0100 Subject: [PATCH] feat: web console page with protocol + key selection --- web/app/servers/[id]/console/page.tsx | 217 ++++++++++++++++++++++++++ web/app/servers/[id]/page.tsx | 10 ++ web/public/lib/guacamole-common.js | 7 + 3 files changed, 234 insertions(+) create mode 100644 web/app/servers/[id]/console/page.tsx create mode 100644 web/public/lib/guacamole-common.js diff --git a/web/app/servers/[id]/console/page.tsx b/web/app/servers/[id]/console/page.tsx new file mode 100644 index 0000000..9a7ba0f --- /dev/null +++ b/web/app/servers/[id]/console/page.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +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"; + +export default function ServerConsolePage() { + const params = useParams(); + const router = useRouter(); + const searchParams = useSearchParams(); + const serverId = params.id as string; + + const containerRef = useRef(null); + const connectionRef = useRef<{ disconnect: () => void } | null>(null); + + const [protocol, setProtocol] = useState(searchParams.get("protocol") || ""); + const [keyId, setKeyId] = useState(""); + const [rdpUsername, setRdpUsername] = useState(""); + const [rdpPassword, setRdpPassword] = useState(""); + const [connecting, setConnecting] = useState(false); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(null); + + // Inject the vendored Guacamole client script once. + useEffect(() => { + const s = document.createElement("script"); + s.src = "/lib/guacamole-common.js"; + s.async = true; + document.body.appendChild(s); + return () => { + document.body.removeChild(s); + }; + }, []); + + // Disconnect on unmount. + useEffect(() => { + return () => { + connectionRef.current?.disconnect(); + }; + }, []); + + const { data: server, isLoading: serverLoading } = useQuery({ + queryKey: ["servers", serverId], + queryFn: () => api.getServer(serverId), + }); + + const { data: keys, isLoading: keysLoading } = useQuery({ + queryKey: ["keys"], + queryFn: () => api.listKeys(), + }); + + const usableKeys = useMemo(() => (keys ?? []).filter((k) => k.has_private_key === true), [keys]); + const protocols = server?.console_protocols ?? []; + + useEffect(() => { + if (!protocol && protocols.length > 0) { + setProtocol(protocols[0]); + } + }, [protocols, protocol]); + + async function handleConnect() { + setError(null); + setConnecting(true); + try { + const body: Parameters[0] = { + server_id: serverId, + protocol, + }; + if (protocol === "ssh") { + body.key_id = keyId || undefined; + } else if (protocol === "rdp") { + body.rdp_username = rdpUsername || undefined; + body.rdp_password = rdpPassword || undefined; + } + + const { token, ws_path } = await api.connectConsole(body); + + const wsProto = location.protocol === "https:" ? "wss" : "ws"; + const wsUrl = `${wsProto}://${location.host}${ws_path}?token=${encodeURIComponent(token)}`; + + if (containerRef.current) { + const conn = openConsole(containerRef.current, wsUrl); + connectionRef.current = conn; + setConnected(true); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to connect"); + } finally { + setConnecting(false); + } + } + + function handleDisconnect() { + connectionRef.current?.disconnect(); + connectionRef.current = null; + setConnected(false); + if (containerRef.current) { + containerRef.current.innerHTML = ""; + } + } + + if (serverLoading || keysLoading) { + return ( +
+
+
+ ); + } + + if (!server) { + return ( +
+
Server not found or failed to load.
+
+ ); + } + + return ( +
+
+ + ← {server.hostname} + +
+ +

Console

+ + {error &&
{error}
} + + {!connected ? ( + +
+
+ + +
+ + {protocol === "ssh" && ( +
+ + + {usableKeys.length === 0 && ( +

No keys with stored private material are available.

+ )} +
+ )} + + {protocol === "rdp" && ( + <> +
+ + setRdpUsername(e.target.value)} + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" + /> +
+
+ + setRdpPassword(e.target.value)} + autoComplete="new-password" + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" + /> +
+ + )} + + +
+
+ ) : ( +
+ +
+ )} + +
+
+ ); +} diff --git a/web/app/servers/[id]/page.tsx b/web/app/servers/[id]/page.tsx index 222eae4..e5b5acf 100644 --- a/web/app/servers/[id]/page.tsx +++ b/web/app/servers/[id]/page.tsx @@ -338,6 +338,16 @@ export default function ServerDetailPage() {

{server.ip_address}

+ {server.console_protocols?.map((p) => ( + + + + ))} {server.available_updates && server.available_updates.length > 0 && (