Add SSH source support with dialstdio and UI components

- Implement SSH dial via stdio for remote connections
- Add sources API and storage layer for managing connection sources
- Add SourcePanel and SshFields web components for SSH configuration
- Update app structure to support source-based connections
- Update handlers and server for new sources endpoint

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 10:19:47 +02:00
co-authored by Claude Haiku 4.5
parent 05db8bfeb9
commit 9b354636bb
19 changed files with 1857 additions and 174 deletions
+57 -5
View File
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api } from './api'
import type {
Connection, Health, ItemSelection, JobSnapshot, Options, PackageInfo, Plan, SourceResponse, TargetInventory,
Connection, Health, ItemSelection, JobSnapshot, Options, PackageInfo, Plan, Source, SourceResponse,
SourceStatus, TargetInventory,
} from './types'
import { Notice } from './ui'
import { Containers } from './Containers'
import { SourcePanel } from './SourcePanel'
import { Sidebar } from './Sidebar'
import { Jobs } from './Jobs'
import { Packages } from './Packages'
@@ -22,6 +24,9 @@ export default function App() {
// Volume sizes come from a separate, slower endpoint so the container list
// can render immediately; they are merged into the rows when they arrive.
const [sizes, setSizes] = useState<Record<string, number>>({})
const [sources, setSources] = useState<Source[]>([])
const [selectedSource, setSelectedSource] = useState<string>('local')
const [sourceStatus, setSourceStatus] = useState<SourceStatus | null>(null)
const [connections, setConnections] = useState<Connection[]>([])
const [activeConn, setActiveConn] = useState<string>('')
const [targetInv, setTargetInv] = useState<TargetInventory | null>(null)
@@ -57,6 +62,40 @@ export default function App() {
}
}, [])
const loadHealth = useCallback(async () => {
try {
const h = await api.health()
setHealth(h)
if (h.source) setSourceStatus(h.source)
} catch { /* the panel shows the source error on its own */ }
}, [])
const loadSources = useCallback(async () => {
try {
const r = await api.sources()
setSources(r.sources)
setSelectedSource(r.selected)
if (r.current) setSourceStatus(r.current)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
// selectSource lets its error escape so the panel can turn an untrusted host
// key into a fingerprint prompt, the same way the target does.
const selectSource = useCallback(async (id: string) => {
const st = await api.selectSource(id)
setSelectedSource(id)
setSourceStatus(st)
// The selections describe containers on the host that was selected before,
// so they are dropped rather than carried over to a different inventory.
setSel({})
setSizes({})
setError('')
await loadSource()
await loadHealth()
}, [loadSource, loadHealth])
const loadConnections = useCallback(async () => {
try {
const list = await api.connections()
@@ -80,12 +119,13 @@ export default function App() {
}, [])
useEffect(() => {
api.health().then(setHealth).catch(() => undefined)
loadHealth()
loadSources()
loadSource()
loadConnections()
loadJobs()
loadPackages()
}, [loadSource, loadConnections, loadJobs, loadPackages])
}, [loadHealth, loadSources, loadSource, loadConnections, loadJobs, loadPackages])
// A slow poll keeps the job list current without holding a stream open for
// every job; the detail view subscribes to its own live stream.
@@ -138,7 +178,9 @@ export default function App() {
<div className="topbar-right">
{health && (
<span className="hostinfo">
source <b>{source?.inventory.host || health.dockerHost}</b>
source <b>{source?.inventory.host || sourceStatus?.name || health.dockerHost}</b>
{sourceStatus?.kind === 'ssh' && <> · over ssh</>}
{sourceStatus?.kind === 'docker' && <> · {sourceStatus.endpoint}</>}
{health.dockerVersion && <> · docker {health.dockerVersion}</>}
</span>
)}
@@ -160,8 +202,10 @@ export default function App() {
{health && !health.ok && (
<div style={{ padding: '10px 16px' }}>
<Notice kind="err">
Cannot reach the source Docker daemon at <span className="mono">{health.dockerHost}</span>
Cannot reach the source <b>{health.source?.name ?? 'docker daemon'}</b>
{health.dockerHost && <> at <span className="mono">{health.dockerHost}</span></>}
{health.dockerError && <> {health.dockerError}</>}
<div className="small">Pick another source in the panel on the right.</div>
</Notice>
</div>
)}
@@ -192,6 +236,14 @@ export default function App() {
{view === 'containers' && (
<aside className="sidebar">
<SourcePanel
sources={sources}
selected={selectedSource}
status={sourceStatus}
selectSource={selectSource}
reload={loadSources}
onError={setError}
/>
<Sidebar
source={source}
plan={plan}