feat: web console page with protocol + key selection

This commit is contained in:
mrhid6 committed 2026-07-17 11:45:10 +01:00
1 parent 38c51e5a3e
commit 0962745bbc
3 files changed
+234

No files matched your search

+217
View File
@@ -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<HTMLDivElement>(null);
const connectionRef = useRef<{ disconnect: () => void } | null>(null);
const [protocol, setProtocol] = useState<string>(searchParams.get("protocol") || "");
const [keyId, setKeyId] = useState<string>("");
const [rdpUsername, setRdpUsername] = useState("");
const [rdpPassword, setRdpPassword] = useState("");
const [connecting, setConnecting] = useState(false);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(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<typeof api.connectConsole>[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 (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
if (!server) {
return (
<div className="p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Server not found or failed to load.</div>
</div>
);
}
return (
<div className="flex h-full flex-col p-8">
<div className="mb-4 flex items-center gap-3">
<Link href={`/servers/${serverId}`} className="text-text-secondary hover:text-text-primary text-sm">
← {server.hostname}
</Link>
</div>
<h1 className="mb-4 text-2xl font-bold text-text-primary">Console</h1>
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">{error}</div>}
{!connected ? (
<Card className="mb-4 max-w-xl">
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Protocol</label>
<select
value={protocol}
onChange={(e) => setProtocol(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"
>
{protocols.length === 0 && <option value="">No protocols available</option>}
{protocols.map((p) => (
<option key={p} value={p}>
{p.toUpperCase()}
</option>
))}
</select>
</div>
{protocol === "ssh" && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">SSH Key</label>
<select
value={keyId}
onChange={(e) => setKeyId(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"
>
<option value="">Select a key…</option>
{usableKeys.map((k) => (
<option key={k.key_id} value={k.key_id}>
{k.label}
</option>
))}
</select>
{usableKeys.length === 0 && (
<p className="mt-1.5 text-xs text-text-tertiary">No keys with stored private material are available.</p>
)}
</div>
)}
{protocol === "rdp" && (
<>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Username</label>
<input
type="text"
value={rdpUsername}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Password</label>
<input
type="password"
value={rdpPassword}
onChange={(e) => 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"
/>
</div>
</>
)}
<Button variant="primary" loading={connecting} disabled={!protocol} onClick={handleConnect}>
Connect
</Button>
</div>
</Card>
) : (
<div className="mb-4 flex items-center gap-3">
<Button variant="danger" onClick={handleDisconnect}>
Disconnect
</Button>
</div>
)}
<div
ref={containerRef}
className="min-h-[500px] flex-1 rounded-lg border border-border bg-black"
/>
</div>
);
}
+10
View File
@@ -338,6 +338,16 @@ export default function ServerDetailPage() {
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
</div>
<div className="flex gap-2">
{server.console_protocols?.map((p) => (
<Link key={p} href={`/servers/${serverId}/console?protocol=${p}`}>
<Button variant="secondary">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
</svg>
Connect {p.toUpperCase()}
</Button>
</Link>
))}
{server.available_updates && server.available_updates.length > 0 && (
<Button
variant="secondary"
+7
View File
@@ -0,0 +1,7 @@
/*
* guacamole-common-js v1.5.0 (Apache License 2.0)
* Vendored from npm package guacamole-common-js@1.5.0 (dist/cjs/guacamole-common.min.js).
* Trailing CommonJS "module.exports=Guacamole" replaced with "window.Guacamole=Guacamole"
* so it works as a plain browser <script> without a module loader. No CDN.
*/
var Guacamole;(Guacamole=Guacamole||{}).ArrayBufferReader=function(e){var t=this;e.onblob=function(e){for(var n=window.atob(e),a=new ArrayBuffer(n.length),o=new Uint8Array(a),i=0;i<n.length;i++)o[i]=n.charCodeAt(i);t.ondata&&t.ondata(a)},e.onend=function(){t.onend&&t.onend()},this.ondata=null,this.onend=null},(Guacamole=Guacamole||{}).ArrayBufferWriter=function(e){var t=this;function n(t){for(var n="",a=0;a<t.byteLength;a++)n+=String.fromCharCode(t[a]);e.sendBlob(window.btoa(n))}e.onack=function(e){t.onack&&t.onack(e)},this.blobLength=Guacamole.ArrayBufferWriter.DEFAULT_BLOB_LENGTH,this.sendData=function(e){var a=new Uint8Array(e);if(a.length<=t.blobLength)n(a);else for(var o=0;o<a.length;o+=t.blobLength)n(a.subarray(o,o+t.blobLength))},this.sendEnd=function(){e.sendEnd()},this.onack=null},Guacamole.ArrayBufferWriter.DEFAULT_BLOB_LENGTH=6048,(Guacamole=Guacamole||{}).AudioContextFactory={singleton:null,getAudioContext:function(){var e=window.AudioContext||window.webkitAudioContext;if(e)try{return Guacamole.AudioContextFactory.singleton||(Guacamole.AudioContextFactory.singleton=new e),Guacamole.AudioContextFactory.singleton}catch(e){}return null}},(Guacamole=Guacamole||{}).AudioPlayer=function(){this.sync=function(){}},Guacamole.AudioPlayer.isSupportedType=function(e){return Guacamole.RawAudioPlayer.isSupportedType(e)},Guacamole.AudioPlayer.getSupportedTypes=function(){return Guacamole.RawAudioPlayer.getSupportedTypes()},Guacamole.AudioPlayer.getInstance=function(e,t){return Guacamole.RawAudioPlayer.isSupportedType(t)?new Guacamole.RawAudioPlayer(e,t):null},Guacamole.RawAudioPlayer=function(e,t){var n=Guacamole.RawAudioFormat.parse(t),a=Guacamole.AudioContextFactory.getAudioContext(),o=a.currentTime,i=new Guacamole.ArrayBufferReader(e),r=1===n.bytesPerSample?window.Int8Array:window.Int16Array,s=1===n.bytesPerSample?128:32768,u=[],l=function(){var e=function(e){if(e.length<=1)return e[0];var t=0;e.forEach((function(e){t+=e.length}));var n=0,a=new r(t);return e.forEach((function(e){a.set(e,n),n+=e.length})),a}(u);return e?(u=function(e){for(var t=Number.MAX_VALUE,a=e.length,o=Math.floor(e.length/n.channels),i=Math.floor(.02*n.rate),s=Math.max(n.channels*i,n.channels*(o-i));s<e.length;s+=n.channels){for(var u=0,l=0;l<n.channels;l++)u+=Math.abs(e[s+l]);u<=t&&(a=s+n.channels,t=u)}return a===e.length?[e]:[new r(e.buffer.slice(0,a*n.bytesPerSample)),new r(e.buffer.slice(a*n.bytesPerSample))]}(e),e=u.shift()):null};i.ondata=function(e){!function(e){u.push(new r(e))}(new r(e));var t=l();if(t){var i=a.currentTime;o<i&&(o=i);var c=a.createBufferSource();c.connect(a.destination),c.start||(c.start=c.noteOn),c.buffer=function(e){var t=e.length/n.channels,i=a.currentTime;o<i&&(o=i);for(var r=a.createBuffer(n.channels,t,n.rate),u=0;u<n.channels;u++)for(var l=r.getChannelData(u),c=u,h=0;h<t;h++)l[h]=e[c]/s,c+=n.channels;return r}(t),c.start(o),o+=t.length/n.channels/n.rate}},this.sync=function(){var e=a.currentTime;o=Math.min(o,e+.3)}},Guacamole.RawAudioPlayer.prototype=new Guacamole.AudioPlayer,Guacamole.RawAudioPlayer.isSupportedType=function(e){return!!Guacamole.AudioContextFactory.getAudioContext()&&null!==Guacamole.RawAudioFormat.parse(e)},Guacamole.RawAudioPlayer.getSupportedTypes=function(){return Guacamole.AudioContextFactory.getAudioContext()?["audio/L8","audio/L16"]:[]},(Guacamole=Guacamole||{}).AudioRecorder=function(){this.onclose=null,this.onerror=null},Guacamole.AudioRecorder.isSupportedType=function(e){return Guacamole.RawAudioRecorder.isSupportedType(e)},Guacamole.AudioRecorder.getSupportedTypes=function(){return Guacamole.RawAudioRecorder.getSupportedTypes()},Guacamole.AudioRecorder.getInstance=function(e,t){return Guacamole.RawAudioRecorder.isSupportedType(t)?new Guacamole.RawAudioRecorder(e,t):null},Guacamole.RawAudioRecorder=function(e,t){var n=this,a=Guacamole.RawAudioFormat.parse(t),o=Guacamole.AudioContextFactory.getAudioContext();navigator.mediaDevices||(navigator.mediaDevices={}),navigator.mediaDevices.getUserMedia||(navigator.mediaDevices.getUserMedia=(navigator.getUserMedia||navigator.webkitGetUserMedia||navigator.mozGetUserMedia||navigator.msGetUserMedia).bind(navigator));var i=new Guacamole.ArrayBufferWriter(e),r=1===a.bytesPerSample?window.Int8Array:window.Int16Array,s=1===a.bytesPerSample?128:32768,u=0,l=0,c=null,h=null,d=null,f=function(e){if(0===e)return 1;var t=Math.PI*e;return Math.sin(t)/t},m=function(e,t){for(var n,a,o=(e.length-1)*t,i=Math.floor(o)-3+1,r=Math.floor(o)+3,s=0,u=i;u<=r;u++)s+=(e[u]||0)*(-(a=3)<(n=o-u)&&n<a?f(n)*f(n/a):0);return s},p=function(e){(d=o.createScriptProcessor(2048,a.channels,a.channels)).connect(o.destination),d.onaudioprocess=function(e){i.sendData(function(e){var t=e.length;u+=t;var n=Math.round(u*a.rate/e.sampleRate)-l;l+=n;for(var o=new r(n*a.channels),i=0;i<a.channels;i++)for(var c=e.getChannelData(i),h=i,d=0;d<n;d++)o[h]=m(c,d/(n-1))*s,h+=a.channels;return o}(e.inputBuffer).buffer)},(h=o.createMediaStreamSource(e)).connect(d),"suspended"===o.state&&o.resuLine truncated