dev_arc_aws/src/pages/Terminal.tsx

439 lines
15 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 { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash } from 'lucide-react'
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 { api, getToken, type Integration } from '../lib/api'
const GOLD = '#C8A434'
const TEXT_SECONDARY = '#7A7D85'
interface TermTheme {
name: string
background: string
foreground: string
cursor: string
}
const TERM_THEMES: TermTheme[] = [
{ name: 'ArchNest Dark', background: '#15161A', foreground: '#E8E6E0', cursor: GOLD },
{ name: 'Matrix', background: '#000000', foreground: '#33FF66', cursor: '#33FF66' },
{ name: 'Solarized', background: '#002B36', foreground: '#93A1A1', cursor: '#CB4B16' },
{ name: 'Midnight Blue', background: '#0B1021', foreground: '#C7D3F2', cursor: '#5FA8FF' },
]
const FONT_SIZES = [11, 12, 13, 14, 15, 16]
const FONT_FAMILIES = [
{ name: 'Monospace', value: 'ui-monospace, SFMono-Regular, Menlo, monospace' },
{ name: 'Fira Code', value: '"Fira Code", ui-monospace, monospace' },
{ name: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, monospace' },
]
interface TerminalPrefs {
themeName: string
fontSize: number
fontFamily: string
}
const PREFS_KEY = 'archnest-terminal-prefs'
function loadPrefs(): TerminalPrefs {
try {
const raw = localStorage.getItem(PREFS_KEY)
if (raw) return { ...defaultPrefs(), ...JSON.parse(raw) }
} catch {
/* ignore malformed local storage */
}
return defaultPrefs()
}
function defaultPrefs(): TerminalPrefs {
return { themeName: TERM_THEMES[0].name, fontSize: 13, fontFamily: FONT_FAMILIES[0].value }
}
function savePrefs(prefs: TerminalPrefs) {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs))
}
interface PaneState {
id: number
hostId: number | null
}
interface TabState {
id: number
name: string
panes: PaneState[]
}
let nextId = 1
const genId = () => nextId++
function newTab(): TabState {
return { id: genId(), name: 'Terminal', panes: [{ id: genId(), hostId: null }] }
}
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
export default function Terminal() {
const [hosts, setHosts] = useState<Integration[]>([])
const [tabs, setTabs] = useState<TabState[]>(() => [newTab()])
const [activeTabId, setActiveTabId] = useState(() => tabs[0].id)
const [activePaneId, setActivePaneId] = useState<number | null>(null)
const [prefs, setPrefs] = useState<TerminalPrefs>(loadPrefs)
const [showPrefs, setShowPrefs] = useState(false)
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
useEffect(() => {
api.listIntegrations().then(({ integrations }) => {
setHosts(integrations.filter((i) => i.type === 'ssh'))
})
}, [])
useEffect(() => savePrefs(prefs), [prefs])
const activeTab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]
function addTab() {
const tab = newTab()
setTabs((prev) => [...prev, tab])
setActiveTabId(tab.id)
}
function closeTab(id: number) {
setTabs((prev) => {
const next = prev.filter((t) => t.id !== id)
if (next.length === 0) {
const t = newTab()
setActiveTabId(t.id)
return [t]
}
if (id === activeTabId) setActiveTabId(next[0].id)
return next
})
}
function updateActiveTab(fn: (tab: TabState) => TabState) {
setTabs((prev) => prev.map((t) => (t.id === activeTabId ? fn(t) : t)))
}
function setPaneCount(count: number) {
updateActiveTab((tab) => {
const panes = [...tab.panes]
while (panes.length < count) panes.push({ id: genId(), hostId: null })
while (panes.length > count) panes.pop()
return { ...tab, panes }
})
}
function setPaneHost(paneId: number, hostId: number) {
updateActiveTab((tab) => ({
...tab,
panes: tab.panes.map((p) => (p.id === paneId ? { ...p, hostId } : p)),
}))
setActivePaneId(paneId)
}
const paneGridClass =
activeTab.panes.length === 1
? 'grid-cols-1 grid-rows-1'
: activeTab.panes.length === 2
? 'grid-cols-2 grid-rows-1'
: 'grid-cols-2 grid-rows-2'
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={() => activePaneId !== null && setPaneHost(activePaneId, h.id)}
className="rounded-md px-2 py-1.5 text-left text-sm transition-colors"
style={{
background: activeTab.panes.find((p) => p.id === activePaneId)?.hostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
color: activeTab.panes.find((p) => p.id === activePaneId)?.hostId === h.id ? GOLD : '#E8E6E0',
}}
>
{h.name}
</button>
))}
</div>
<p className="mt-3 text-[11px]" style={{ color: TEXT_SECONDARY }}>
Click a pane, then a host to connect it.
</p>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-2">
<div className="flex flex-1 items-center gap-1 overflow-x-auto">
{tabs.map((tab) => (
<div
key={tab.id}
onClick={() => setActiveTabId(tab.id)}
className="flex shrink-0 cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-xs"
style={{
background: tab.id === activeTabId ? 'rgba(200,164,52,0.15)' : 'rgba(255,255,255,0.04)',
color: tab.id === activeTabId ? GOLD : '#E8E6E0',
}}
>
{tab.name}
<X
size={12}
onClick={(e) => {
e.stopPropagation()
closeTab(tab.id)
}}
/>
</div>
))}
<button onClick={addTab} className="shrink-0 rounded-md p-1.5" style={{ color: TEXT_SECONDARY }} title="New tab">
<Plus size={14} />
</button>
</div>
<div className="flex items-center gap-1">
<button onClick={() => setPaneCount(1)} className="rounded-md p-1.5" style={{ color: activeTab.panes.length === 1 ? GOLD : TEXT_SECONDARY }} title="Single pane">
<SquareSlash size={14} />
</button>
<button onClick={() => setPaneCount(2)} className="rounded-md p-1.5" style={{ color: activeTab.panes.length === 2 ? GOLD : TEXT_SECONDARY }} title="Split 2">
<Columns2 size={14} />
</button>
<button onClick={() => setPaneCount(4)} className="rounded-md p-1.5" style={{ color: activeTab.panes.length === 4 ? GOLD : TEXT_SECONDARY }} title="Split 4">
<Grid2x2 size={14} />
</button>
<button onClick={() => setShowPrefs((v) => !v)} className="rounded-md p-1.5" style={{ color: showPrefs ? GOLD : TEXT_SECONDARY }} title="Terminal preferences">
<Settings2 size={14} />
</button>
</div>
</div>
{showPrefs && (
<div className="flex items-center gap-4 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs" style={{ color: '#E8E6E0' }}>
<label className="flex items-center gap-2">
Theme
<select
value={prefs.themeName}
onChange={(e) => setPrefs((p) => ({ ...p, themeName: e.target.value }))}
className="rounded-md border border-white/10 bg-transparent px-2 py-1"
>
{TERM_THEMES.map((t) => (
<option key={t.name} value={t.name}>
{t.name}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
Font Size
<select
value={prefs.fontSize}
onChange={(e) => setPrefs((p) => ({ ...p, fontSize: Number(e.target.value) }))}
className="rounded-md border border-white/10 bg-transparent px-2 py-1"
>
{FONT_SIZES.map((s) => (
<option key={s} value={s}>
{s}px
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
Font
<select
value={prefs.fontFamily}
onChange={(e) => setPrefs((p) => ({ ...p, fontFamily: e.target.value }))}
className="rounded-md border border-white/10 bg-transparent px-2 py-1"
>
{FONT_FAMILIES.map((f) => (
<option key={f.name} value={f.value}>
{f.name}
</option>
))}
</select>
</label>
</div>
)}
<div className={`grid min-h-0 flex-1 gap-2 ${paneGridClass}`}>
{activeTab.panes.map((pane) => (
<TerminalPane
key={pane.id}
hostId={pane.hostId}
hosts={hosts}
prefs={prefs}
active={pane.id === activePaneId}
onFocus={() => setActivePaneId(pane.id)}
/>
))}
</div>
</div>
</div>
)
}
function TerminalPane({
hostId,
hosts,
prefs,
active,
onFocus,
}: {
hostId: number | null
hosts: Integration[]
prefs: TerminalPrefs
active: boolean
onFocus: () => void
}) {
const [connected, setConnected] = useState(false)
const [tmuxSessions, setTmuxSessions] = useState<string[]>([])
const [selectedTmux, setSelectedTmux] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const termRef = useRef<XTerm | null>(null)
const fitRef = useRef<FitAddon | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const lastHostIdRef = useRef<number | null>(null)
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
useEffect(() => {
if (!containerRef.current) return
const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0]
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
const term = new XTerm({
cursorBlink: true,
fontSize: prefs.fontSize,
fontFamily: prefs.fontFamily,
theme: { background: theme.background, foreground: theme.foreground, cursor: theme.cursor },
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
})
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()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prefs.themeName, prefs.fontSize, prefs.fontFamily])
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
useEffect(() => {
fitRef.current?.fit()
})
useEffect(() => {
if (hostId === null || hostId === lastHostIdRef.current) return
lastHostIdRef.current = hostId
setSelectedTmux('')
fetchTmuxSessions(hostId)
connect(hostId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostId])
function fetchTmuxSessions(id: number) {
setTmuxSessions([])
const token = getToken()
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`)
ws.onopen = () => ws.send(JSON.stringify({ type: 'list_tmux', integrationId: id }))
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'tmux_sessions') {
setTmuxSessions(msg.sessions ?? [])
ws.close()
}
}
}
function connect(id: number, tmuxSession?: string) {
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
wsRef.current?.close()
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: id, cols: term.cols, rows: term.rows, tmuxSession }))
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
}
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 }))
})
}
const host = hosts.find((h) => h.id === hostId)
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
return (
<div
onClick={onFocus}
className="flex min-w-0 min-h-0 flex-col rounded-lg p-2"
style={{
backgroundColor: '#15161A',
border: active ? `1px solid ${GOLD}` : '1px solid rgba(255,255,255,0.1)',
}}
>
<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' }} />
{host ? (connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'}
{host && (
<select
value={selectedTmux}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const raw = e.target.value
const value = raw === '__new__' ? `archnest-${Date.now().toString(36)}` : raw
setSelectedTmux(raw)
connect(host.id, value || undefined)
}}
className="ml-auto rounded-md border border-white/10 bg-transparent px-1.5 py-0.5"
style={{ color: TEXT_SECONDARY, fontSize: '11px' }}
title="Attach to a tmux session on this host"
>
<option value="">Plain shell</option>
<option value="__new__">New tmux session</option>
{tmuxSessions.map((s) => (
<option key={s} value={s}>
tmux: {s}
</option>
))}
</select>
)}
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
</div>
<div ref={containerRef} className="min-h-0 flex-1" />
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
</div>
)
}