Phase 1b: SSH jump-host chaining, TOFU host-key verification, multi-host Settings UI
Terminal connections can now reference a jumpHostIntegrationId on the SSH integration config; the backend connects to the jump host first and tunnels to the real target via ssh2's forwardOut(), rather than connecting directly. Added an ssh_host_keys table and a hostVerifier callback that accepts and stores a host's fingerprint on first connect, then hard-rejects on any mismatch on subsequent connects (trust-on-first-use). Settings previously only ever showed/edited one integration per type, which silently prevented configuring more than one SSH host at all. Added a dedicated multi-host SSH section (per-host Save/Test/Delete, Add SSH Host, and a Jump Host dropdown) so jump-host chaining is actually usable from the UI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
parent
067bf16c04
commit
5d56a1d902
4 changed files with 391 additions and 35 deletions
|
|
@ -45,7 +45,11 @@ Rationale for splitting: 1a alone is a real, useful terminal (matches what `/ter
|
||||||
|
|
||||||
**Status:**
|
**Status:**
|
||||||
- ✅ **Phase 1a — done.** `/terminal` is a real interactive SSH terminal: `backend/src/routes/terminal.ts` (WebSocket, connect/input/resize/disconnect over `ssh2`), `backend/src/db/secrets.ts` (shared secret loader), `src/pages/Terminal.tsx` (xterm.js + host picker, reuses ArchNest's existing SSH integrations — no duplicate host table). Verified end-to-end against a real test SSH server. No jump hosts, no tabs/split panes, no OPKSSH, no tmux monitor yet — see 1b/1c below.
|
- ✅ **Phase 1a — done.** `/terminal` is a real interactive SSH terminal: `backend/src/routes/terminal.ts` (WebSocket, connect/input/resize/disconnect over `ssh2`), `backend/src/db/secrets.ts` (shared secret loader), `src/pages/Terminal.tsx` (xterm.js + host picker, reuses ArchNest's existing SSH integrations — no duplicate host table). Verified end-to-end against a real test SSH server. No jump hosts, no tabs/split panes, no OPKSSH, no tmux monitor yet — see 1b/1c below.
|
||||||
- ⬜ Phase 1b — not started.
|
- 🟡 **Phase 1b — in progress.** Done so far:
|
||||||
|
- **Jump-host chaining**: an SSH integration's config can carry `jumpHostIntegrationId` referencing another SSH integration. `backend/src/routes/terminal.ts` connects to the jump host first, opens a `forwardOut()` channel to the real target, and connects the target `Client` over that channel (single-hop; mirrors Termix's core mechanism without its multi-hop/credential-sharing complexity). Verified end-to-end with two real test SSH servers (one as jump, one as target).
|
||||||
|
- **Host-key verification (TOFU)**: new `ssh_host_keys` table (`backend/src/db/index.ts`) stores a SHA-256 fingerprint per SSH integration on first successful connect; subsequent connects are rejected if the fingerprint changes, via `ssh2`'s `hostVerifier` connect option. No interactive accept/reject-changed-key UI yet — first-use accept-and-store, hard-reject on mismatch. Verified both the accept-on-first-use and reject-on-mismatch paths against a real test server.
|
||||||
|
- **Settings UI for multiple SSH hosts**: `src/pages/Settings.tsx` previously could only show/edit one integration per type, which silently broke multi-host SSH. Added a dedicated `SshHostsSection` with its own per-host cards (Save/Test/Delete) and an "Add SSH Host" flow, including a `Jump Host` dropdown populated from the other configured SSH hosts.
|
||||||
|
- Not yet done: tab system + up-to-4 split panes and terminal theme/font customization in `src/pages/Terminal.tsx` — that page is still single-session only.
|
||||||
- ⬜ Phase 1c — not started.
|
- ⬜ Phase 1c — not started.
|
||||||
|
|
||||||
### Phase 2 — SSH Tunnels (NOT STARTED)
|
### Phase 2 — SSH Tunnels (NOT STARTED)
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,12 @@ db.exec(`
|
||||||
source TEXT,
|
source TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ssh_host_keys (
|
||||||
|
integration_id INTEGER PRIMARY KEY REFERENCES integrations(id) ON DELETE CASCADE,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
export function logEvent(type: string, title: string, source?: string | null) {
|
export function logEvent(type: string, title: string, source?: string | null) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { FastifyInstance } from 'fastify'
|
import type { FastifyInstance } from 'fastify'
|
||||||
import { Client } from 'ssh2'
|
import { Client, type ClientChannel, type ConnectConfig } from 'ssh2'
|
||||||
import type { ClientChannel } from 'ssh2'
|
|
||||||
import { db } from '../db/index.js'
|
import { db } from '../db/index.js'
|
||||||
import { loadSecrets } from '../db/secrets.js'
|
import { loadSecrets } from '../db/secrets.js'
|
||||||
|
|
||||||
|
|
@ -22,16 +21,56 @@ function send(socket: { send: (data: string) => void }, payload: Record<string,
|
||||||
socket.send(JSON.stringify(payload))
|
socket.send(JSON.stringify(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadSshHost(integrationId: number) {
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT id, type, config_json FROM integrations WHERE id = ?')
|
||||||
|
.get(integrationId) as IntegrationRow | undefined
|
||||||
|
if (!row || row.type !== 'ssh') return null
|
||||||
|
const config = JSON.parse(row.config_json) as Record<string, string>
|
||||||
|
const secrets = loadSecrets(row.id)
|
||||||
|
return { id: row.id, config, secrets }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier'] {
|
||||||
|
return (keyHash: string): boolean => {
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT fingerprint FROM ssh_host_keys WHERE integration_id = ?')
|
||||||
|
.get(integrationId) as { fingerprint: string } | undefined
|
||||||
|
if (!row) {
|
||||||
|
db.prepare('INSERT INTO ssh_host_keys (integration_id, fingerprint) VALUES (?, ?)').run(integrationId, keyHash)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return row.fingerprint === keyHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseConnectConfig(host: ReturnType<typeof loadSshHost> extends infer T ? NonNullable<T> : never): ConnectConfig {
|
||||||
|
return {
|
||||||
|
host: host.config.host,
|
||||||
|
port: Number(host.config.port) || 22,
|
||||||
|
username: host.config.username,
|
||||||
|
password: host.secrets.password || undefined,
|
||||||
|
privateKey: host.secrets.privateKey || undefined,
|
||||||
|
passphrase: host.secrets.passphrase || undefined,
|
||||||
|
readyTimeout: 8000,
|
||||||
|
hostHash: 'sha256',
|
||||||
|
hostVerifier: makeHostVerifier(host.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function terminalRoutes(app: FastifyInstance) {
|
export async function terminalRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/terminal', { websocket: true }, (socket, req) => {
|
app.get('/api/terminal', { websocket: true }, (socket, req) => {
|
||||||
let conn: Client | null = null
|
let conn: Client | null = null
|
||||||
|
let jumpConn: Client | null = null
|
||||||
let stream: ClientChannel | null = null
|
let stream: ClientChannel | null = null
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
stream?.end()
|
stream?.end()
|
||||||
conn?.end()
|
conn?.end()
|
||||||
|
jumpConn?.end()
|
||||||
stream = null
|
stream = null
|
||||||
conn = null
|
conn = null
|
||||||
|
jumpConn = null
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('close', cleanup)
|
socket.on('close', cleanup)
|
||||||
|
|
@ -55,19 +94,15 @@ export async function terminalRoutes(app: FastifyInstance) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = db
|
const target = msg.integrationId !== undefined ? loadSshHost(msg.integrationId) : null
|
||||||
.prepare('SELECT id, type, config_json FROM integrations WHERE id = ?')
|
if (!target) {
|
||||||
.get(msg.integrationId) as IntegrationRow | undefined
|
|
||||||
if (!row || row.type !== 'ssh') {
|
|
||||||
send(socket, { type: 'error', message: 'SSH integration not found' })
|
send(socket, { type: 'error', message: 'SSH integration not found' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const config = JSON.parse(row.config_json) as Record<string, string>
|
|
||||||
const secrets = loadSecrets(row.id)
|
|
||||||
|
|
||||||
conn = new Client()
|
const startShell = (client: Client) => {
|
||||||
conn.on('ready', () => {
|
conn = client
|
||||||
conn!.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => {
|
client.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
send(socket, { type: 'error', message: err.message })
|
send(socket, { type: 'error', message: err.message })
|
||||||
return
|
return
|
||||||
|
|
@ -81,19 +116,42 @@ export async function terminalRoutes(app: FastifyInstance) {
|
||||||
cleanup()
|
cleanup()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
conn.on('error', (err) => {
|
|
||||||
|
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 })
|
send(socket, { type: 'error', message: err.message })
|
||||||
})
|
})
|
||||||
conn.connect({
|
client.connect(baseConnectConfig(target))
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,18 +46,15 @@ const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[]
|
||||||
{ type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] },
|
{ type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] },
|
||||||
{ type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] },
|
{ type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] },
|
||||||
{ type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] },
|
{ type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] },
|
||||||
{
|
]
|
||||||
type: 'ssh',
|
|
||||||
name: 'SSH Host',
|
const sshFields: FieldDef[] = [
|
||||||
fields: [
|
{ key: 'host', label: 'Host / IP' },
|
||||||
{ key: 'host', label: 'Host / IP' },
|
{ key: 'port', label: 'Port (default 22)' },
|
||||||
{ key: 'port', label: 'Port (default 22)' },
|
{ key: 'username', label: 'Username' },
|
||||||
{ key: 'username', label: 'Username' },
|
{ key: 'password', label: 'Password', secret: true },
|
||||||
{ key: 'password', label: 'Password', secret: true },
|
{ key: 'privateKey', label: 'Private Key (PEM)', secret: true },
|
||||||
{ key: 'privateKey', label: 'Private Key (PEM)', secret: true },
|
{ key: 'passphrase', label: 'Key Passphrase', secret: true },
|
||||||
{ key: 'passphrase', label: 'Key Passphrase', secret: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const cardBase: React.CSSProperties = {
|
const cardBase: React.CSSProperties = {
|
||||||
|
|
@ -349,6 +346,290 @@ function AppearanceSection() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SshHostsSection() {
|
||||||
|
const [hosts, setHosts] = useState<Integration[] | null>(null)
|
||||||
|
const [revealed, setRevealed] = useState<Set<string>>(new Set())
|
||||||
|
const [drafts, setDrafts] = useState<Record<number, Record<string, string>>>({})
|
||||||
|
const [statusMsg, setStatusMsg] = useState<Record<number, string>>({})
|
||||||
|
const [busy, setBusy] = useState<Set<number>>(new Set())
|
||||||
|
const [newDrafts, setNewDrafts] = useState<{ key: number; values: Record<string, string> }[]>([])
|
||||||
|
const nextNewKey = useRef(-1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
api.listIntegrations().then(({ integrations }) => setHosts(integrations.filter((i) => i.type === 'ssh')))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReveal(key: string) {
|
||||||
|
setRevealed((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusyFlag(id: number, value: boolean) {
|
||||||
|
setBusy((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (value) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNewHost() {
|
||||||
|
const key = nextNewKey.current--
|
||||||
|
setNewDrafts((prev) => [...prev, { key, values: {} }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNewDraftField(key: number, fieldKey: string, value: string) {
|
||||||
|
setNewDrafts((prev) => prev.map((d) => (d.key === key ? { ...d, values: { ...d.values, [fieldKey]: value } } : d)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNewDraft(key: number) {
|
||||||
|
setNewDrafts((prev) => prev.filter((d) => d.key !== key))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDraftField(id: number, fieldKey: string, value: string) {
|
||||||
|
setDrafts((prev) => ({ ...prev, [id]: { ...prev[id], [fieldKey]: value } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldsWithJumpHost = (excludeId?: number): FieldDef[] => [
|
||||||
|
...sshFields,
|
||||||
|
{ key: 'jumpHostIntegrationId', label: 'Jump Host (optional)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function buildPayload(fields: FieldDef[], values: Record<string, string>) {
|
||||||
|
const config: Record<string, string> = {}
|
||||||
|
const secrets: Record<string, string> = {}
|
||||||
|
for (const f of fields) {
|
||||||
|
const value = values[f.key]
|
||||||
|
if (value === undefined) continue
|
||||||
|
if (f.secret) secrets[f.key] = value
|
||||||
|
else config[f.key] = value
|
||||||
|
}
|
||||||
|
return { config, secrets }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveExisting(host: Integration) {
|
||||||
|
setBusyFlag(host.id, true)
|
||||||
|
setStatusMsg((prev) => ({ ...prev, [host.id]: '' }))
|
||||||
|
try {
|
||||||
|
const draft = drafts[host.id] ?? {}
|
||||||
|
const { config, secrets } = buildPayload(fieldsWithJumpHost(host.id), draft)
|
||||||
|
const { integration } = await api.updateIntegration(host.id, { config, secrets })
|
||||||
|
setHosts((prev) => (prev ?? []).map((h) => (h.id === integration.id ? integration : h)))
|
||||||
|
setStatusMsg((prev) => ({ ...prev, [host.id]: 'Saved' }))
|
||||||
|
} catch (err) {
|
||||||
|
setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Save failed' }))
|
||||||
|
} finally {
|
||||||
|
setBusyFlag(host.id, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveNew(key: number, values: Record<string, string>) {
|
||||||
|
setBusyFlag(key, true)
|
||||||
|
try {
|
||||||
|
const { config, secrets } = buildPayload(fieldsWithJumpHost(), values)
|
||||||
|
const name = values.host ? `SSH: ${values.host}` : 'SSH Host'
|
||||||
|
await api.createIntegration({ type: 'ssh', name, config, secrets })
|
||||||
|
removeNewDraft(key)
|
||||||
|
refresh()
|
||||||
|
} catch (err) {
|
||||||
|
setStatusMsg((prev) => ({ ...prev, [key]: err instanceof ApiError ? err.message : 'Save failed' }))
|
||||||
|
} finally {
|
||||||
|
setBusyFlag(key, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest(host: Integration) {
|
||||||
|
setBusyFlag(host.id, true)
|
||||||
|
try {
|
||||||
|
const result = await api.testIntegration(host.id)
|
||||||
|
setStatusMsg((prev) => ({ ...prev, [host.id]: result.message }))
|
||||||
|
refresh()
|
||||||
|
} catch (err) {
|
||||||
|
setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Test failed' }))
|
||||||
|
} finally {
|
||||||
|
setBusyFlag(host.id, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(host: Integration) {
|
||||||
|
setBusyFlag(host.id, true)
|
||||||
|
try {
|
||||||
|
await api.deleteIntegration(host.id)
|
||||||
|
refresh()
|
||||||
|
} catch (err) {
|
||||||
|
setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Delete failed' }))
|
||||||
|
setBusyFlag(host.id, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFields(
|
||||||
|
fields: FieldDef[],
|
||||||
|
values: Record<string, string>,
|
||||||
|
onChange: (fieldKey: string, value: string) => void,
|
||||||
|
idForReveal: number,
|
||||||
|
existing: Integration | undefined,
|
||||||
|
excludeHostId?: number,
|
||||||
|
) {
|
||||||
|
return fields.map((f) => {
|
||||||
|
const key = `${idForReveal}-${f.key}`
|
||||||
|
if (f.key === 'jumpHostIntegrationId') {
|
||||||
|
const options = (hosts ?? []).filter((h) => h.id !== excludeHostId)
|
||||||
|
const savedValue = existing?.config[f.key] ?? ''
|
||||||
|
const value = values[f.key] ?? savedValue
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<label style={labelStyle}>{f.label}</label>
|
||||||
|
<select style={inputStyle} value={value} onChange={(e) => onChange(f.key, e.target.value)}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{options.map((h) => (
|
||||||
|
<option key={h.id} value={String(h.id)}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const isRevealed = revealed.has(key)
|
||||||
|
const savedValue = f.secret ? '' : existing?.config[f.key] ?? ''
|
||||||
|
const value = values[f.key] ?? savedValue
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<label style={labelStyle}>{f.label}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type={f.secret && !isRevealed ? 'password' : 'text'}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(f.key, e.target.value)}
|
||||||
|
placeholder={f.secret && existing ? '••••••••••••' : 'Not configured'}
|
||||||
|
/>
|
||||||
|
{f.secret && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleReveal(key)}
|
||||||
|
className="absolute cursor-pointer border-none bg-transparent"
|
||||||
|
style={{ right: '8px', top: '50%', transform: 'translateY(-50%)', color: '#7A7D85' }}
|
||||||
|
>
|
||||||
|
{isRevealed ? <EyeOff size={13} /> : <Eye size={13} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hosts) {
|
||||||
|
return (
|
||||||
|
<div style={cardBase}>
|
||||||
|
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading SSH hosts…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{hosts.map((host) => {
|
||||||
|
const online = host.status === 'connected'
|
||||||
|
const draft = drafts[host.id] ?? {}
|
||||||
|
return (
|
||||||
|
<div key={host.id} style={cardBase}>
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: online ? '#2ECC71' : '#4A4D55',
|
||||||
|
boxShadow: online ? '0 0 6px rgba(46,204,113,0.6)' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>{host.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{statusMsg[host.id] && <span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[host.id]}</span>}
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveExisting(host)}
|
||||||
|
disabled={busy.has(host.id)}
|
||||||
|
className="cursor-pointer border-none"
|
||||||
|
style={{ fontSize: '11px', fontWeight: 600, color: '#0A0B0D', backgroundColor: '#C8A434', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(host.id) ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTest(host)}
|
||||||
|
disabled={busy.has(host.id)}
|
||||||
|
className="cursor-pointer border-none"
|
||||||
|
style={{ fontSize: '11px', fontWeight: 600, color: '#C8A434', backgroundColor: 'rgba(200,164,52,0.08)', border: '1px solid rgba(200,164,52,0.2)', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(host.id) ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(host)}
|
||||||
|
disabled={busy.has(host.id)}
|
||||||
|
className="cursor-pointer border-none"
|
||||||
|
style={{ fontSize: '11px', fontWeight: 600, color: '#E74C3C', backgroundColor: 'transparent', border: '1px solid rgba(231,76,60,0.3)', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(host.id) ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{renderFields(fieldsWithJumpHost(host.id), draft, (k, v) => setDraftField(host.id, k, v), host.id, host, host.id)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{newDrafts.map((d) => (
|
||||||
|
<div key={d.key} style={cardBase}>
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
||||||
|
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>New SSH Host</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{statusMsg[d.key] && <span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[d.key]}</span>}
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveNew(d.key, d.values)}
|
||||||
|
disabled={busy.has(d.key)}
|
||||||
|
className="cursor-pointer border-none"
|
||||||
|
style={{ fontSize: '11px', fontWeight: 600, color: '#0A0B0D', backgroundColor: '#C8A434', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(d.key) ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeNewDraft(d.key)}
|
||||||
|
className="cursor-pointer border-none"
|
||||||
|
style={{ fontSize: '11px', fontWeight: 600, color: '#7A7D85', backgroundColor: 'transparent', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '6px', padding: '6px 12px' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{renderFields(fieldsWithJumpHost(), d.values, (k, v) => setNewDraftField(d.key, k, v), d.key, undefined)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={addNewHost}
|
||||||
|
className="cursor-pointer border-none self-start"
|
||||||
|
style={{ fontSize: '12px', fontWeight: 600, color: '#C8A434', backgroundColor: 'rgba(200,164,52,0.08)', border: '1px solid rgba(200,164,52,0.2)', borderRadius: '8px', padding: '9px 16px' }}
|
||||||
|
>
|
||||||
|
+ Add SSH Host
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function IntegrationsSection() {
|
function IntegrationsSection() {
|
||||||
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
||||||
const [revealed, setRevealed] = useState<Set<string>>(new Set())
|
const [revealed, setRevealed] = useState<Set<string>>(new Set())
|
||||||
|
|
@ -441,6 +722,13 @@ function IntegrationsSection() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionTitle}>SSH Hosts</h3>
|
||||||
|
<SshHostsSection />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={sectionTitle}>Other Integrations</h3>
|
||||||
|
</div>
|
||||||
{integrationTypeDefs.map((def) => {
|
{integrationTypeDefs.map((def) => {
|
||||||
const existing = integrations.find((i) => i.type === def.type)
|
const existing = integrations.find((i) => i.type === def.type)
|
||||||
const online = existing?.status === 'connected'
|
const online = existing?.status === 'connected'
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue