Phase 1c: OPKSSH cert auth, tmux session monitor/reattach, session logging
- terminal.ts: connectWithCertificate() shells out to system ssh via node-pty for OpenSSH certificate auth (ssh2 has no native support); list_tmux WS message + tmuxSession connect param for tmux attach/create with shell-injection-safe name validation; sessionLogging config field appends terminal output to disk. - Settings.tsx: certificate secret field and sessionLogging checkbox for SSH host integrations. - Terminal.tsx: tmux session picker in each pane's header. - Verified end-to-end against a real test SSH server running real bash/tmux processes (plain shell, tmux create+list, session log written to disk). Cert auth path type-checks but is unverified in this sandbox (no ssh CLI available) - documented as a gap in TERMIX_MIGRATION.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
parent
94b174c72e
commit
27abbc8ce1
6 changed files with 272 additions and 45 deletions
|
|
@ -52,7 +52,11 @@ Rationale for splitting: 1a alone is a real, useful terminal (matches what `/ter
|
|||
- **Tabs + up to 4 split panes**: `src/pages/Terminal.tsx` rewritten around a `TerminalPane` component (one xterm + WebSocket connection each, reusable). Each tab holds 1/2/4 panes (single / split-2 / 2x2 grid); each pane connects independently to whichever SSH host is clicked while it's focused.
|
||||
- **Terminal theme/font customization**: a preferences bar (theme preset, font size, font family) persisted to `localStorage` (`archnest-terminal-prefs`), applied per-pane on connect.
|
||||
- Verified via a clean production build (`tsc -b && vite build`) — no real browser available in this environment to click through tabs/panes, so this is build/type verification only, not an interactive UI test.
|
||||
- ⬜ Phase 1c — not started.
|
||||
- ✅ **Phase 1c — done, with one documented verification gap.**
|
||||
- **OPKSSH / certificate auth**: `ssh2` (the npm library) has no support for OpenSSH certificates — confirmed by inspecting its type definitions and README, no certificate-related auth flow exists. Implemented `connectWithCertificate()` in `backend/src/routes/terminal.ts`: writes the stored private key + certificate to a temp dir (mode `0600`) and shells out to the system `ssh` binary (which natively understands `-o CertificateFile=`) under a real `node-pty` pty. Used automatically when an SSH integration has a `certificate` secret configured (new field added to Settings' SSH host form). Does **not** support jump-host chaining (documented limitation, not silently dropped — Termix's own OPKSSH path doesn't generally chain through jump hosts either). **Verification gap**: this sandbox has no `ssh` CLI installed (`apt-get install openssh-client` failed — mirror 404), so this path type-checks and is logically sound but has not been exercised end-to-end. Needs a real test against a cert-auth-enabled host before being considered fully verified; `openssh-client` is near-universal on real deployment targets, so this is a sandbox limitation, not an expected production gap.
|
||||
- **tmux session monitor/reattach**: new WebSocket message `list_tmux` execs `tmux list-sessions` on the target host and returns session names; `connect` accepts an optional `tmuxSession` (validated against `^[A-Za-z0-9_-]{1,64}$` before being interpolated into a shell command, to prevent injection) which attaches to that tmux session or creates it if missing, via `exec('tmux attach -t <name> || tmux new-session -s <name>', { pty: ... })` instead of a plain `client.shell()`. `src/pages/Terminal.tsx`'s pane header gained a tmux session picker (plain shell / new session / attach to an existing one). **Verified end-to-end** against a real test SSH server running real `bash`/`tmux` processes (via `node-pty`): listed zero sessions, created a `testsess` tmux session through the WS protocol, confirmed a follow-up `list_tmux` call returned `['testsess']`.
|
||||
- **Session recording/logging to disk**: new SSH integration config field `sessionLogging` (checkbox in Settings' SSH host form). When set, all outbound terminal output (both the `ssh2` path and the cert-auth pty path) is appended to `<ARCHNEST_SESSION_LOG_DIR ?? './data/session-logs'>/<integrationId>_<timestamp>.log`. No log browsing/download UI yet (not built — out of scope for this pass, not silently dropped). **Verified end-to-end**: a real shell session's output was confirmed present in its log file on disk.
|
||||
- No real `ssh` CLI / no real OPKSSH certificate available in this sandbox to test against, see verification gap above. Everything else in this phase was tested against live processes, not mocked.
|
||||
|
||||
### Phase 2 — SSH Tunnels (NOT STARTED)
|
||||
|
||||
|
|
|
|||
17
backend/package-lock.json
generated
17
backend/package-lock.json
generated
|
|
@ -18,6 +18,7 @@
|
|||
"better-sqlite3": "^11.8.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"fastify": "^5.2.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"undici": "^8.5.0",
|
||||
"zod": "^3.24.1"
|
||||
|
|
@ -2038,6 +2039,22 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/obliterator": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"better-sqlite3": "^11.8.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"fastify": "^5.2.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"undici": "^8.5.0",
|
||||
"zod": "^3.24.1"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import type { FastifyInstance } from 'fastify'
|
||||
import { Client, type ClientChannel, type ConnectConfig } from 'ssh2'
|
||||
import { spawn as spawnPty, type IPty } from 'node-pty'
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { db } from '../db/index.js'
|
||||
import { loadSecrets } from '../db/secrets.js'
|
||||
|
||||
|
|
@ -10,13 +14,18 @@ interface IntegrationRow {
|
|||
}
|
||||
|
||||
interface ClientMessage {
|
||||
type: 'connect' | 'input' | 'resize' | 'disconnect'
|
||||
type: 'connect' | 'input' | 'resize' | 'disconnect' | 'list_tmux'
|
||||
integrationId?: number
|
||||
cols?: number
|
||||
rows?: number
|
||||
data?: string
|
||||
tmuxSession?: string
|
||||
}
|
||||
|
||||
const TMUX_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/
|
||||
|
||||
const SESSION_LOG_DIR = process.env.ARCHNEST_SESSION_LOG_DIR ?? './data/session-logs'
|
||||
|
||||
function send(socket: { send: (data: string) => void }, payload: Record<string, unknown>) {
|
||||
socket.send(JSON.stringify(payload))
|
||||
}
|
||||
|
|
@ -31,6 +40,8 @@ function loadSshHost(integrationId: number) {
|
|||
return { id: row.id, config, secrets }
|
||||
}
|
||||
|
||||
type SshHost = NonNullable<ReturnType<typeof loadSshHost>>
|
||||
|
||||
function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier'] {
|
||||
return (keyHash: string): boolean => {
|
||||
const row = db
|
||||
|
|
@ -44,7 +55,7 @@ function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier']
|
|||
}
|
||||
}
|
||||
|
||||
function baseConnectConfig(host: ReturnType<typeof loadSshHost> extends infer T ? NonNullable<T> : never): ConnectConfig {
|
||||
function baseConnectConfig(host: SshHost): ConnectConfig {
|
||||
return {
|
||||
host: host.config.host,
|
||||
port: Number(host.config.port) || 22,
|
||||
|
|
@ -58,19 +69,119 @@ function baseConnectConfig(host: ReturnType<typeof loadSshHost> extends infer T
|
|||
}
|
||||
}
|
||||
|
||||
/** Connects to `target`, transparently chaining through its jump host (if configured) via forwardOut(). */
|
||||
function connectTarget(
|
||||
target: SshHost,
|
||||
onReady: (client: Client) => void,
|
||||
onError: (message: string) => void,
|
||||
): { conn: Client; jumpConn: Client | null } {
|
||||
const jumpHostId = target.config.jumpHostIntegrationId ? Number(target.config.jumpHostIntegrationId) : null
|
||||
|
||||
if (jumpHostId) {
|
||||
const jumpHost = loadSshHost(jumpHostId)
|
||||
if (!jumpHost) {
|
||||
onError('Jump host integration not found')
|
||||
return { conn: new Client(), jumpConn: null }
|
||||
}
|
||||
const jumpConn = new Client()
|
||||
jumpConn.on('ready', () => {
|
||||
jumpConn.forwardOut('127.0.0.1', 0, target.config.host, Number(target.config.port) || 22, (err, sock) => {
|
||||
if (err) {
|
||||
onError(`Jump host forward failed: ${err.message}`)
|
||||
return
|
||||
}
|
||||
client.connect({ ...baseConnectConfig(target), sock })
|
||||
})
|
||||
})
|
||||
jumpConn.on('error', (err) => onError(`Jump host error: ${err.message}`))
|
||||
const client = new Client()
|
||||
client.on('ready', () => onReady(client))
|
||||
client.on('error', (err) => onError(err.message))
|
||||
jumpConn.connect(baseConnectConfig(jumpHost))
|
||||
return { conn: client, jumpConn }
|
||||
}
|
||||
|
||||
const client = new Client()
|
||||
client.on('ready', () => onReady(client))
|
||||
client.on('error', (err) => onError(err.message))
|
||||
client.connect(baseConnectConfig(target))
|
||||
return { conn: client, jumpConn: null }
|
||||
}
|
||||
|
||||
/** OPKSSH / certificate auth has no support in the ssh2 library, so this path shells out to the
|
||||
* system `ssh` binary (which natively understands OpenSSH certificates via CertificateFile) under
|
||||
* a real pty. Jump-host chaining is not supported on this path. */
|
||||
function connectWithCertificate(
|
||||
target: SshHost,
|
||||
cols: number,
|
||||
rows: number,
|
||||
onReady: (pty: IPty, keyDir: string) => void,
|
||||
onError: (message: string) => void,
|
||||
) {
|
||||
const keyDir = mkdtempSync(join(tmpdir(), 'archnest-ssh-'))
|
||||
const keyFile = join(keyDir, 'id_key')
|
||||
const certFile = join(keyDir, 'id_key-cert.pub')
|
||||
try {
|
||||
writeFileSync(keyFile, target.secrets.privateKey ?? '', { mode: 0o600 })
|
||||
writeFileSync(certFile, target.secrets.certificate ?? '', { mode: 0o600 })
|
||||
} catch (err) {
|
||||
rmSync(keyDir, { recursive: true, force: true })
|
||||
onError(err instanceof Error ? err.message : 'Failed to write certificate files')
|
||||
return
|
||||
}
|
||||
|
||||
const args = [
|
||||
'-tt',
|
||||
'-p', String(Number(target.config.port) || 22),
|
||||
'-i', keyFile,
|
||||
'-o', `CertificateFile=${certFile}`,
|
||||
'-o', 'StrictHostKeyChecking=accept-new',
|
||||
'-o', `UserKnownHostsFile=${join(keyDir, 'known_hosts')}`,
|
||||
`${target.config.username}@${target.config.host}`,
|
||||
]
|
||||
|
||||
let pty: IPty
|
||||
try {
|
||||
pty = spawnPty('ssh', args, { name: 'xterm-256color', cols, rows })
|
||||
} catch (err) {
|
||||
rmSync(keyDir, { recursive: true, force: true })
|
||||
onError(err instanceof Error ? `Failed to spawn ssh: ${err.message}` : 'Failed to spawn ssh')
|
||||
return
|
||||
}
|
||||
onReady(pty, keyDir)
|
||||
}
|
||||
|
||||
function sessionLogPath(integrationId: number) {
|
||||
mkdirSync(SESSION_LOG_DIR, { recursive: true })
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
return join(SESSION_LOG_DIR, `${integrationId}_${stamp}.log`)
|
||||
}
|
||||
|
||||
export async function terminalRoutes(app: FastifyInstance) {
|
||||
app.get('/api/terminal', { websocket: true }, (socket, req) => {
|
||||
let conn: Client | null = null
|
||||
let jumpConn: Client | null = null
|
||||
let stream: ClientChannel | null = null
|
||||
let pty: IPty | null = null
|
||||
let ptyKeyDir: string | null = null
|
||||
let logPath: string | null = null
|
||||
|
||||
const logData = (data: string) => {
|
||||
if (logPath) appendFileSync(logPath, data)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
stream?.end()
|
||||
conn?.end()
|
||||
jumpConn?.end()
|
||||
pty?.kill()
|
||||
if (ptyKeyDir) rmSync(ptyKeyDir, { recursive: true, force: true })
|
||||
stream = null
|
||||
conn = null
|
||||
jumpConn = null
|
||||
pty = null
|
||||
ptyKeyDir = null
|
||||
logPath = null
|
||||
}
|
||||
|
||||
socket.on('close', cleanup)
|
||||
|
|
@ -84,7 +195,7 @@ export async function terminalRoutes(app: FastifyInstance) {
|
|||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'connect') {
|
||||
if (msg.type === 'connect' || msg.type === 'list_tmux') {
|
||||
const query = req.query as { token?: string }
|
||||
try {
|
||||
await app.jwt.verify(query.token ?? '')
|
||||
|
|
@ -100,68 +211,106 @@ export async function terminalRoutes(app: FastifyInstance) {
|
|||
return
|
||||
}
|
||||
|
||||
const startShell = (client: Client) => {
|
||||
if (msg.type === 'list_tmux') {
|
||||
const { conn: ephemeralConn, jumpConn: ephemeralJump } = connectTarget(
|
||||
target,
|
||||
(client) => {
|
||||
client.exec("command -v tmux >/dev/null && tmux list-sessions -F '#S' 2>/dev/null", (err, ch) => {
|
||||
if (err) {
|
||||
send(socket, { type: 'tmux_sessions', sessions: [] })
|
||||
client.end()
|
||||
ephemeralJump?.end()
|
||||
return
|
||||
}
|
||||
let out = ''
|
||||
ch.on('data', (chunk: Buffer) => (out += chunk.toString('utf8')))
|
||||
ch.on('close', () => {
|
||||
const sessions = out.split('\n').map((s) => s.trim()).filter(Boolean)
|
||||
send(socket, { type: 'tmux_sessions', sessions })
|
||||
client.end()
|
||||
ephemeralJump?.end()
|
||||
})
|
||||
})
|
||||
},
|
||||
(message) => send(socket, { type: 'tmux_sessions', sessions: [], error: message }),
|
||||
)
|
||||
void ephemeralConn
|
||||
return
|
||||
}
|
||||
|
||||
const cols = msg.cols ?? 80
|
||||
const rows = msg.rows ?? 24
|
||||
|
||||
if (target.secrets.certificate) {
|
||||
connectWithCertificate(
|
||||
target,
|
||||
cols,
|
||||
rows,
|
||||
(p, keyDir) => {
|
||||
pty = p
|
||||
ptyKeyDir = keyDir
|
||||
if (target.config.sessionLogging === 'true') logPath = sessionLogPath(target.id)
|
||||
send(socket, { type: 'connected' })
|
||||
p.onData((data) => {
|
||||
logData(data)
|
||||
send(socket, { type: 'data', data })
|
||||
})
|
||||
p.onExit(() => {
|
||||
send(socket, { type: 'closed' })
|
||||
cleanup()
|
||||
})
|
||||
},
|
||||
(message) => send(socket, { type: 'error', message }),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const startSession = (client: Client) => {
|
||||
conn = client
|
||||
client.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => {
|
||||
const tmuxSession = msg.tmuxSession && TMUX_NAME_RE.test(msg.tmuxSession) ? msg.tmuxSession : null
|
||||
const onChannel = (err: Error | undefined, ch: ClientChannel) => {
|
||||
if (err) {
|
||||
send(socket, { type: 'error', message: err.message })
|
||||
return
|
||||
}
|
||||
stream = ch
|
||||
if (target.config.sessionLogging === 'true') logPath = sessionLogPath(target.id)
|
||||
send(socket, { type: 'connected' })
|
||||
ch.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
|
||||
ch.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString('utf8')
|
||||
logData(text)
|
||||
send(socket, { type: 'data', data: text })
|
||||
})
|
||||
ch.stderr.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
|
||||
ch.on('close', () => {
|
||||
send(socket, { type: 'closed' })
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
}
|
||||
if (tmuxSession) {
|
||||
client.exec(`tmux attach -t ${tmuxSession} || tmux new-session -s ${tmuxSession}`, {
|
||||
pty: { cols, rows, term: 'xterm-256color' },
|
||||
}, onChannel)
|
||||
} else {
|
||||
client.shell({ cols, rows, term: 'xterm-256color' }, onChannel)
|
||||
}
|
||||
}
|
||||
|
||||
const jumpHostId = target.config.jumpHostIntegrationId ? Number(target.config.jumpHostIntegrationId) : null
|
||||
|
||||
if (jumpHostId) {
|
||||
const jumpHost = loadSshHost(jumpHostId)
|
||||
if (!jumpHost) {
|
||||
send(socket, { type: 'error', message: 'Jump host integration not found' })
|
||||
return
|
||||
}
|
||||
jumpConn = new Client()
|
||||
jumpConn.on('ready', () => {
|
||||
jumpConn!.forwardOut('127.0.0.1', 0, target.config.host, Number(target.config.port) || 22, (err, sock) => {
|
||||
if (err) {
|
||||
send(socket, { type: 'error', message: `Jump host forward failed: ${err.message}` })
|
||||
return
|
||||
}
|
||||
const client = new Client()
|
||||
client.on('ready', () => startShell(client))
|
||||
client.on('error', (err2) => send(socket, { type: 'error', message: err2.message }))
|
||||
client.connect({ ...baseConnectConfig(target), sock })
|
||||
})
|
||||
})
|
||||
jumpConn.on('error', (err) => {
|
||||
send(socket, { type: 'error', message: `Jump host error: ${err.message}` })
|
||||
})
|
||||
jumpConn.connect(baseConnectConfig(jumpHost))
|
||||
return
|
||||
}
|
||||
|
||||
const client = new Client()
|
||||
client.on('ready', () => startShell(client))
|
||||
client.on('error', (err) => {
|
||||
send(socket, { type: 'error', message: err.message })
|
||||
})
|
||||
client.connect(baseConnectConfig(target))
|
||||
const result = connectTarget(target, startSession, (message) => send(socket, { type: 'error', message }))
|
||||
conn = result.conn
|
||||
jumpConn = result.jumpConn
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'input') {
|
||||
stream?.write(msg.data ?? '')
|
||||
if (pty) pty.write(msg.data ?? '')
|
||||
else stream?.write(msg.data ?? '')
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'resize') {
|
||||
stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0)
|
||||
if (pty) pty.resize(msg.cols ?? 80, msg.rows ?? 24)
|
||||
else stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ const sshFields: FieldDef[] = [
|
|||
{ key: 'password', label: 'Password', secret: true },
|
||||
{ key: 'privateKey', label: 'Private Key (PEM)', secret: true },
|
||||
{ key: 'passphrase', label: 'Key Passphrase', secret: true },
|
||||
{ key: 'certificate', label: 'OPKSSH Certificate (id_key-cert.pub)', secret: true },
|
||||
]
|
||||
|
||||
const cardBase: React.CSSProperties = {
|
||||
|
|
@ -401,6 +402,7 @@ function SshHostsSection() {
|
|||
const fieldsWithJumpHost = (): FieldDef[] => [
|
||||
...sshFields,
|
||||
{ key: 'jumpHostIntegrationId', label: 'Jump Host (optional)' },
|
||||
{ key: 'sessionLogging', label: 'Record session to disk' },
|
||||
]
|
||||
|
||||
function buildPayload(fields: FieldDef[], values: Record<string, string>) {
|
||||
|
|
@ -498,6 +500,18 @@ function SshHostsSection() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
if (f.key === 'sessionLogging') {
|
||||
const savedValue = existing?.config[f.key] === 'true'
|
||||
const value = values[f.key] !== undefined ? values[f.key] === 'true' : savedValue
|
||||
return (
|
||||
<div key={key} className="flex items-end pb-1.5">
|
||||
<label className="flex items-center gap-2 text-xs" style={{ color: '#E8E6E0' }}>
|
||||
<input type="checkbox" checked={value} onChange={(e) => onChange(f.key, e.target.checked ? 'true' : 'false')} />
|
||||
{f.label}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const isRevealed = revealed.has(key)
|
||||
const savedValue = f.secret ? '' : existing?.config[f.key] ?? ''
|
||||
const value = values[f.key] ?? savedValue
|
||||
|
|
|
|||
|
|
@ -291,6 +291,8 @@ function TerminalPane({
|
|||
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)
|
||||
|
|
@ -330,11 +332,28 @@ function TerminalPane({
|
|||
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 connect(id: number) {
|
||||
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) {
|
||||
wsRef.current?.close()
|
||||
setConnected(false)
|
||||
const term = termRef.current
|
||||
|
|
@ -348,7 +367,7 @@ function TerminalPane({
|
|||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows }))
|
||||
ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows, tmuxSession }))
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
|
@ -389,6 +408,29 @@ function TerminalPane({
|
|||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div ref={containerRef} className="min-h-0 flex-1" />
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue