feat: web console page with protocol + key selection
This commit is contained in:
1 parent
38c51e5a3e
commit
0962745bbc
3 files changed
+234
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user