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
This commit is contained in:
parent
f2629a22f8
commit
71f49e0700
11 changed files with 347 additions and 10 deletions
61
backend/package-lock.json
generated
61
backend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"@aws-sdk/client-sts": "^3.1072.0",
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/jwt": "^10.1.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
|
|
@ -1017,6 +1018,27 @@
|
|||
"ipaddr.js": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/websocket": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
|
||||
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplexify": "^4.1.3",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lukeed/ms": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||
|
|
@ -1514,6 +1536,18 @@
|
|||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexify": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.4.1",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1",
|
||||
"stream-shift": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
|
|
@ -2395,6 +2429,12 @@
|
|||
"reusify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-shift": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
|
|
@ -2568,6 +2608,27 @@
|
|||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"@aws-sdk/client-sts": "^3.1072.0",
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/jwt": "^10.1.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
|
|
|
|||
11
backend/src/db/secrets.ts
Normal file
11
backend/src/db/secrets.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { db } from './index.js'
|
||||
import { decryptSecret } from './crypto.js'
|
||||
|
||||
export function loadSecrets(integrationId: number): Record<string, string> {
|
||||
const rows = db
|
||||
.prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?')
|
||||
.all(integrationId) as { key: string; value_encrypted: string }[]
|
||||
const out: Record<string, string> = {}
|
||||
for (const row of rows) out[row.key] = decryptSecret(row.value_encrypted)
|
||||
return out
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { db, logEvent } from '../db/index.js'
|
||||
import { encryptSecret, decryptSecret } from '../db/crypto.js'
|
||||
import { encryptSecret } from '../db/crypto.js'
|
||||
import { loadSecrets } from '../db/secrets.js'
|
||||
import { adapterRegistry } from '../integrations/registry.js'
|
||||
import type { IntegrationType, Resource } from '../integrations/types.js'
|
||||
|
||||
|
|
@ -47,15 +48,6 @@ function serialize(row: IntegrationRow) {
|
|||
}
|
||||
}
|
||||
|
||||
function loadSecrets(integrationId: number): Record<string, string> {
|
||||
const rows = db
|
||||
.prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?')
|
||||
.all(integrationId) as { key: string; value_encrypted: string }[]
|
||||
const out: Record<string, string> = {}
|
||||
for (const row of rows) out[row.key] = decryptSecret(row.value_encrypted)
|
||||
return out
|
||||
}
|
||||
|
||||
export async function integrationRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate)
|
||||
|
||||
|
|
|
|||
115
backend/src/routes/terminal.ts
Normal file
115
backend/src/routes/terminal.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import type { FastifyInstance } from 'fastify'
|
||||
import { Client } from 'ssh2'
|
||||
import type { ClientChannel } from 'ssh2'
|
||||
import { db } from '../db/index.js'
|
||||
import { loadSecrets } from '../db/secrets.js'
|
||||
|
||||
interface IntegrationRow {
|
||||
id: number
|
||||
type: string
|
||||
config_json: string
|
||||
}
|
||||
|
||||
interface ClientMessage {
|
||||
type: 'connect' | 'input' | 'resize' | 'disconnect'
|
||||
integrationId?: number
|
||||
cols?: number
|
||||
rows?: number
|
||||
data?: string
|
||||
}
|
||||
|
||||
function send(socket: { send: (data: string) => void }, payload: Record<string, unknown>) {
|
||||
socket.send(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
export async function terminalRoutes(app: FastifyInstance) {
|
||||
app.get('/api/terminal', { websocket: true }, (socket, req) => {
|
||||
let conn: Client | null = null
|
||||
let stream: ClientChannel | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
stream?.end()
|
||||
conn?.end()
|
||||
stream = null
|
||||
conn = null
|
||||
}
|
||||
|
||||
socket.on('close', cleanup)
|
||||
|
||||
socket.on('message', async (raw: Buffer) => {
|
||||
let msg: ClientMessage
|
||||
try {
|
||||
msg = JSON.parse(raw.toString())
|
||||
} catch {
|
||||
send(socket, { type: 'error', message: 'Invalid JSON' })
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'connect') {
|
||||
const query = req.query as { token?: string }
|
||||
try {
|
||||
await app.jwt.verify(query.token ?? '')
|
||||
} catch {
|
||||
send(socket, { type: 'error', message: 'Unauthorized' })
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT id, type, config_json FROM integrations WHERE id = ?')
|
||||
.get(msg.integrationId) as IntegrationRow | undefined
|
||||
if (!row || row.type !== 'ssh') {
|
||||
send(socket, { type: 'error', message: 'SSH integration not found' })
|
||||
return
|
||||
}
|
||||
const config = JSON.parse(row.config_json) as Record<string, string>
|
||||
const secrets = loadSecrets(row.id)
|
||||
|
||||
conn = new Client()
|
||||
conn.on('ready', () => {
|
||||
conn!.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => {
|
||||
if (err) {
|
||||
send(socket, { type: 'error', message: err.message })
|
||||
return
|
||||
}
|
||||
stream = ch
|
||||
send(socket, { type: 'connected' })
|
||||
ch.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
|
||||
ch.stderr.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
|
||||
ch.on('close', () => {
|
||||
send(socket, { type: 'closed' })
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
})
|
||||
conn.on('error', (err) => {
|
||||
send(socket, { type: 'error', message: err.message })
|
||||
})
|
||||
conn.connect({
|
||||
host: config.host,
|
||||
port: Number(config.port) || 22,
|
||||
username: config.username,
|
||||
password: secrets.password || undefined,
|
||||
privateKey: secrets.privateKey || undefined,
|
||||
passphrase: secrets.passphrase || undefined,
|
||||
readyTimeout: 8000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'input') {
|
||||
stream?.write(msg.data ?? '')
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'resize') {
|
||||
stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'disconnect') {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -2,10 +2,12 @@ import 'dotenv/config'
|
|||
import Fastify from 'fastify'
|
||||
import cors from '@fastify/cors'
|
||||
import jwt from '@fastify/jwt'
|
||||
import websocket from '@fastify/websocket'
|
||||
import { authRoutes } from './routes/auth.js'
|
||||
import { integrationRoutes } from './routes/integrations.js'
|
||||
import { bookmarkRoutes } from './routes/bookmarks.js'
|
||||
import { eventRoutes } from './routes/events.js'
|
||||
import { terminalRoutes } from './routes/terminal.js'
|
||||
|
||||
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
|
||||
if (!JWT_SECRET) {
|
||||
|
|
@ -16,6 +18,7 @@ const app = Fastify({ logger: true })
|
|||
|
||||
await app.register(cors, { origin: process.env.ARCHNEST_CORS_ORIGIN ?? true })
|
||||
await app.register(jwt, { secret: JWT_SECRET })
|
||||
await app.register(websocket)
|
||||
|
||||
app.decorate('authenticate', async function (req, reply) {
|
||||
try {
|
||||
|
|
@ -29,6 +32,7 @@ await app.register(authRoutes)
|
|||
await app.register(integrationRoutes)
|
||||
await app.register(bookmarkRoutes)
|
||||
await app.register(eventRoutes)
|
||||
await app.register(terminalRoutes)
|
||||
|
||||
app.get('/api/health', async () => ({ ok: true }))
|
||||
|
||||
|
|
|
|||
17
package-lock.json
generated
17
package-lock.json
generated
|
|
@ -9,6 +9,8 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
|
|
@ -1551,6 +1553,21 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import TopBar from './components/TopBar'
|
|||
import Glance from './pages/Glance'
|
||||
import Infrastructure from './pages/Infrastructure'
|
||||
import BookNest from './pages/BookNest'
|
||||
import Terminal from './pages/Terminal'
|
||||
import Settings from './pages/Settings'
|
||||
import Login from './pages/Login'
|
||||
import Enrollment from './pages/Enrollment'
|
||||
|
|
@ -80,6 +81,7 @@ function Dashboard() {
|
|||
<Route path="/" element={<Glance />} />
|
||||
<Route path="/infrastructure" element={<Infrastructure />} />
|
||||
<Route path="/booknest" element={<BookNest />} />
|
||||
<Route path="/terminal" element={<Terminal />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</section>
|
||||
|
|
|
|||
131
src/pages/Terminal.tsx
Normal file
131
src/pages/Terminal.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export default defineConfig({
|
|||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue