dev_arc_aws/src/pages/Terminal.tsx

132 lines
4.5 KiB
TypeScript
Raw Normal View History

Add Phase 1a: core SSH terminal (Termix migration) Implements the minimal-viable terminal described in TERMIX_MIGRATION.md Phase 1a: a real interactive SSH session in the browser over a WebSocket, using xterm.js on the frontend and ssh2 on the backend. Reuses ArchNest's existing SSH integrations (host/port/username/ password/privateKey/passphrase) instead of introducing a second, duplicate host-management system the way Termix has one. Backend: new /api/terminal WebSocket route (registered via @fastify/websocket) handling connect/input/resize/disconnect messages, authenticated via a JWT passed as a query param (browsers can't set custom headers on the WS handshake). Extracted the integration secret loader out of routes/integrations.ts into db/secrets.ts so the new terminal route can reuse it without duplicating the decrypt logic. Frontend: new Terminal.tsx page listing configured SSH hosts and rendering an xterm.js terminal wired to the WebSocket; wired into App.tsx at /terminal. vite.config.ts's dev proxy now forwards WebSocket upgrades (ws: true) so this works under `npm run dev`. Verified end-to-end against a real (test) ssh2-based SSH server: connect, shell banner, keystroke echo, and prompt redraw all worked correctly over the actual WebSocket protocol. Deliberately deferred to Phase 1b/1c per the migration doc: jump-host chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert auth, tmux session monitor, session recording. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
import { useEffect, useRef, useState } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { api, getToken, type Integration } from '../lib/api'
const GOLD = '#C8A434'
const TEXT_SECONDARY = '#7A7D85'
export default function Terminal() {
const [hosts, setHosts] = useState<Integration[]>([])
const [activeHostId, setActiveHostId] = useState<number | null>(null)
const [connected, setConnected] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const termRef = useRef<XTerm | null>(null)
const fitRef = useRef<FitAddon | null>(null)
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
api.listIntegrations().then(({ integrations }) => {
setHosts(integrations.filter((i) => i.type === 'ssh'))
})
}, [])
useEffect(() => {
if (!containerRef.current) return
const term = new XTerm({
cursorBlink: true,
fontSize: 13,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
theme: { background: '#15161A', foreground: '#E8E6E0', cursor: GOLD },
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(containerRef.current)
fit.fit()
termRef.current = term
fitRef.current = fit
const onResize = () => fit.fit()
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
term.dispose()
wsRef.current?.close()
}
}, [])
function connect(hostId: number) {
wsRef.current?.close()
setActiveHostId(hostId)
setConnected(false)
const term = termRef.current
if (!term) return
term.reset()
term.writeln('Connecting…')
const token = getToken()
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`)
wsRef.current = ws
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'connect', integrationId: hostId, cols: term.cols, rows: term.rows }))
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'connected') {
setConnected(true)
term.reset()
} else if (msg.type === 'data') {
term.write(msg.data)
} else if (msg.type === 'error') {
term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`)
setConnected(false)
} else if (msg.type === 'closed') {
term.writeln('\r\n\x1b[33mConnection closed.\x1b[0m')
setConnected(false)
}
}
ws.onclose = () => setConnected(false)
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }))
})
term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows }))
})
}
return (
<div className="flex h-full gap-4">
<div className="w-56 shrink-0 overflow-y-auto rounded-lg border border-white/10 bg-white/5 p-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide" style={{ color: TEXT_SECONDARY }}>
SSH Hosts
</p>
{hosts.length === 0 && (
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
No SSH integrations configured. Add one in Settings Integrations.
</p>
)}
<div className="flex flex-col gap-1">
{hosts.map((h) => (
<button
key={h.id}
onClick={() => connect(h.id)}
className="rounded-md px-2 py-1.5 text-left text-sm transition-colors"
style={{
background: activeHostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
color: activeHostId === h.id ? GOLD : '#E8E6E0',
}}
>
{h.name}
</button>
))}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col rounded-lg border border-white/10 bg-[#15161A] p-2">
<div className="mb-2 flex items-center gap-2 px-1 text-xs" style={{ color: TEXT_SECONDARY }}>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: connected ? '#2ECC71' : '#7A7D85' }}
/>
{activeHostId ? (connected ? 'Connected' : 'Disconnected') : 'Select a host to connect'}
</div>
<div ref={containerRef} className="min-h-0 flex-1" />
</div>
</div>
)
}