feat: add xterm frontend ui

This commit is contained in:
Aarnav Tale
2025-05-31 12:56:20 -04:00
parent 55eacb59e9
commit cb32637938
9 changed files with 325 additions and 16 deletions
+1
View File
@@ -25,6 +25,7 @@ export default [
route('/users', 'routes/users/overview.tsx'),
route('/acls', 'routes/acls/overview.tsx'),
route('/dns', 'routes/dns/overview.tsx'),
route('/ssh', 'routes/ssh/overview.tsx'),
...prefix('/settings', [
index('routes/settings/overview.tsx'),
+145
View File
@@ -0,0 +1,145 @@
import { decode } from 'cborg';
import { Loader2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { LoaderFunctionArgs, data, useLoaderData } from 'react-router';
import { ClientOnly } from 'remix-utils/client-only';
import {
Command,
SSHConnectData,
SSHConnectFailedData,
} from '~/server/agent/dispatcher';
import { useLiveData } from '~/utils/live-data';
import toast from '~/utils/toast';
import XTerm from './xterm.client';
export async function loader({ request }: LoaderFunctionArgs) {
const qp = new URL(request.url).searchParams;
const username = qp.get('username') || undefined;
const hostname = qp.get('hostname') || undefined;
const port = qp.get('port')
? Number.parseInt(qp.get('port')!, 10)
: undefined;
if (!username || !hostname || !port) {
throw data('Missing required parameters: username, hostname, port', 400);
}
const baseUrl = new URL(request.url).origin;
const wsUrl = new URL('/_ssh_plexer', baseUrl);
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl.searchParams.set('username', username);
wsUrl.searchParams.set('hostname', hostname);
wsUrl.searchParams.set('port', port.toString());
return {
socketUrl: wsUrl.toString(),
};
}
type SessionStatus = 'loading' | 'connected' | 'error';
export default function Page() {
const { pause } = useLiveData();
const { socketUrl } = useLoaderData<typeof loader>();
const [socket, setSocket] = useState<WebSocket | null>(null);
const [status, setStatus] = useState<SessionStatus>('loading');
const [sessionId, setSessionId] = useState<string | null>(null);
const queue = useRef<Array<Uint8Array>>([]);
const validated = useRef<boolean>(false);
useEffect(() => {
// SSH connections should not use stale while revalidate logic.
pause();
const ws = new WebSocket(socketUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
setSocket(ws);
setStatus('loading');
};
// We need to wait for the WebSocket to open and respond with the
// connection ID. Without a session ID, we do not have a mux.
const messageHandler = (event: MessageEvent) => {
if (!(event.data instanceof ArrayBuffer)) {
toast('Invalid message received from server');
return;
}
const data = new Uint8Array(event.data);
const obj = decode(data) as Command;
if (obj.op === 'ssh_conn_successful') {
const data = obj as SSHConnectData;
if (!validated.current) {
validated.current = true;
toast(
`SSH connection established with session ID: ${data.payload.sessionId}`,
);
setStatus('connected');
setSessionId(data.payload.sessionId);
}
return;
}
if (obj.op === 'ssh_conn_failed') {
const data = obj as SSHConnectFailedData;
if (!validated.current) {
validated.current = true;
toast(`SSH connection failed: ${data.payload.reason}`);
setStatus('error');
}
return;
}
if (obj.op === 'ssh_frame') {
queue.current.push(new Uint8Array(event.data));
return;
}
};
ws.addEventListener('message', messageHandler);
ws.onerror = (error) => {
setStatus('error');
toast(`WebSocket error: ${error}`);
};
ws.onclose = () => {
if (status !== 'error') {
toast('SSH connection closed');
}
setSocket(null);
setStatus('error');
};
return () => {
ws.removeEventListener('message', messageHandler);
ws.close();
};
}, [socketUrl]);
if (socket === null || !sessionId || status === 'loading') {
return (
<Loader2 className="animate-spin text-gray-500 dark:text-gray-400 w-6 h-6 mx-auto mt-4" />
);
}
return (
<div className="flex flex-col h-full">
<h1>Session ID: {sessionId}</h1>
{queue.current.length > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{queue.current.length} frames queued
</p>
)}
<XTerm ws={socket} sessionId={sessionId} queue={queue.current} />
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import * as xterm from '@xterm/xterm';
import { useEffect, useRef } from 'react';
import '@xterm/xterm/css/xterm.css';
import { decode } from 'cborg';
import type { SSHFrameData } from '~/server/agent/dispatcher';
import { useLiveData } from '~/utils/live-data';
interface XTermProps {
ws: WebSocket;
sessionId: string;
queue: Array<Uint8Array>;
}
const RED = new TextEncoder().encode('\x1b[31m');
const RESET = new TextEncoder().encode('\x1b[0m');
export default function XTerm({ ws, sessionId, queue }: XTermProps) {
const { pause } = useLiveData();
const container = useRef<HTMLDivElement>(null);
const term = useRef<xterm.Terminal>(null);
useEffect(() => {
pause();
const terminal = new xterm.Terminal({
convertEol: true,
fontSize: 14,
theme: {
background: '#1e1e1e',
foreground: '#ffffff',
},
});
terminal.open(container.current!);
terminal.focus();
term.current = terminal;
const handleFrame = (data: Uint8Array) => {
try {
const frame: SSHFrameData = decode(data);
if (frame.op !== 'ssh_frame') {
console.warn('Received unexpected frame type:', frame.op);
return;
}
// If this is stderr, color it red
if (frame.payload.channel === 2) {
terminal.write(
new Uint8Array([...RED, ...frame.payload.frame, ...RESET]),
);
} else {
terminal.write(frame.payload.frame);
}
} catch (err) {
console.error('Failed to decode CBOR frame:', err);
}
};
for (const buffer of queue) {
handleFrame(buffer);
}
terminal.onData((input) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(input);
} else {
console.warn('WebSocket is not open, cannot send data');
}
});
const onMessage = (event: MessageEvent) => {
if (!(event.data instanceof ArrayBuffer)) {
console.warn('Received non-binary message from WebSocket');
return;
}
const data = new Uint8Array(event.data);
handleFrame(data);
};
ws.addEventListener('message', onMessage);
return () => {
ws.removeEventListener('message', onMessage);
term.current?.dispose();
};
}, [ws, queue]);
return <div ref={container} style={{ height: '100%', width: '100%' }} />;
}
+12 -5
View File
@@ -3,7 +3,7 @@ import { encode } from 'cborg';
import { WSContext } from 'hono/ws';
import { ChannelType } from './encoder';
interface Command {
export interface Command {
op: string;
payload: unknown;
}
@@ -18,7 +18,14 @@ interface SSHConnectCommand extends Command {
};
}
type AgentCommand = SSHConnectCommand;
interface SSHCloseCommand extends Command {
op: 'ssh_term';
payload: {
sessionId: string;
};
}
type AgentCommand = SSHConnectCommand | SSHCloseCommand;
export async function dispatchCommand(
dispatcher: Writable,
@@ -36,21 +43,21 @@ export async function dispatchCommand(
});
}
interface SSHConnectData extends Command {
export interface SSHConnectData extends Command {
op: 'ssh_conn_successful';
payload: {
sessionId: string;
};
}
interface SSHConnectFailedData extends Command {
export interface SSHConnectFailedData extends Command {
op: 'ssh_conn_failed';
payload: {
reason: string;
};
}
interface SSHFrameData extends Command {
export interface SSHFrameData extends Command {
op: 'ssh_frame';
payload: {
channel: ChannelType;
+17 -3
View File
@@ -143,22 +143,36 @@ export class SSHMultiplexer {
this.sshInput.write(encodedFrame);
},
onClose: (_, ws) => {
onClose: async (_, ws) => {
const sessionId = ws.raw;
if (sessionId && this.connections.has(sessionId)) {
const session = this.connections.get(sessionId);
if (session) {
await dispatchCommand(this.control, {
op: 'ssh_term',
payload: {
sessionId,
},
});
session.connected = false;
this.connections.delete(sessionId);
}
}
},
onError: (event, ws) => {
onError: async (event, ws) => {
const sessionId = ws.raw;
if (sessionId && this.connections.has(sessionId)) {
const session = this.connections.get(sessionId);
if (session) {
await dispatchCommand(this.control, {
op: 'ssh_term',
payload: {
sessionId,
},
});
session.connected = false;
this.connections.delete(sessionId);
}
@@ -191,7 +205,7 @@ export class SSHMultiplexer {
op: 'ssh_frame',
payload: {
channel: frame.channel,
data: frame.payload,
frame: frame.payload,
},
});
});