Files
DockMV/web/src/App.tsx
T
kawaandClaude Haiku 4.5 9b354636bb 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>
2026-08-13 10:19:47 +02:00

268 lines
9.5 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react'
import { api } from './api'
import type {
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'
type View = 'containers' | 'jobs' | 'packages'
export default function App() {
const [health, setHealth] = useState<Health | null>(null)
const [source, setSource] = useState<SourceResponse | null>(null)
const [sel, setSel] = useState<Record<string, ItemSelection>>({})
const [options, setOptions] = useState<Options>({
conflict: 'fail', renameSuffix: '-migrated', compress: true,
compressLevel: 1, dryRun: false, parallelism: 1, verifyAfter: true,
})
// 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)
const [jobs, setJobs] = useState<JobSnapshot[]>([])
const [packages, setPackages] = useState<PackageInfo[]>([])
const [view, setView] = useState<View>('containers')
const [activeJob, setActiveJob] = useState<string>('')
const [error, setError] = useState<string>('')
const [loading, setLoading] = useState(true)
const loadSource = useCallback(async () => {
setLoading(true)
try {
const s = await api.source()
setSource(s)
// Selections are re-seeded from the server defaults, but any choice the
// operator already made for a container that still exists is preserved.
setSel((prev) => {
const next: Record<string, ItemSelection> = {}
for (const c of s.inventory.containers) {
next[c.id] = prev[c.id] ?? s.defaults[c.id]
}
return next
})
setError('')
api.volumeSizes()
.then((r) => setSizes(r.volumes ?? {}))
.catch(() => undefined) // sizes are a nicety; the list works without them
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [])
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()
setConnections(list)
setActiveConn((cur) => (cur && list.some((c) => c.id === cur) ? cur : list[0]?.id ?? ''))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
const loadJobs = useCallback(async () => {
try {
setJobs(await api.jobs())
} catch { /* the jobs list is refreshed again on the next tick */ }
}, [])
const loadPackages = useCallback(async () => {
try {
setPackages(await api.packages())
} catch { /* likewise */ }
}, [])
useEffect(() => {
loadHealth()
loadSources()
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.
useEffect(() => {
const t = setInterval(loadJobs, 4000)
return () => clearInterval(t)
}, [loadJobs])
// connectTarget deliberately lets its error escape. The sidebar needs to see
// an untrusted host key so it can put the fingerprint in front of the
// operator; swallowing it here left the UI stuck on "not connected yet".
const connectTarget = useCallback(async (id: string) => {
setTargetInv(null)
if (!id) return
const inv = await api.targetInventory(id)
setTargetInv(inv)
setError('')
}, [])
const included = useMemo(() => Object.values(sel).filter((s) => s.include), [sel])
const plan = useMemo<Plan>(() => ({ items: Object.values(sel), options }), [sel, options])
const runningJobs = jobs.filter((j) => j.state === 'running' || j.state === 'pending').length
const onJobStarted = useCallback((j: JobSnapshot) => {
setJobs((prev) => [j, ...prev])
setActiveJob(j.id)
setView('jobs')
}, [])
return (
<div className="app">
<header className="topbar">
<div className="brand"><img src="/logo-icon.png" alt="" className="brand-logo" />DockMV</div>
<nav className="tabs">
<button className={`tab${view === 'containers' ? ' active' : ''}`} onClick={() => setView('containers')}>
Containers
<span className="count">{included.length}/{source?.inventory.containers.length ?? 0}</span>
</button>
<button className={`tab${view === 'jobs' ? ' active' : ''}`} onClick={() => setView('jobs')}>
Jobs
{runningJobs > 0 && <span className="count">{runningJobs} running</span>}
</button>
<button className={`tab${view === 'packages' ? ' active' : ''}`} onClick={() => setView('packages')}>
Packages
{packages.length > 0 && <span className="count">{packages.length}</span>}
</button>
</nav>
<div className="topbar-right">
{health && (
<span className="hostinfo">
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>
)}
<button className="btn tiny" onClick={loadSource} disabled={loading}>
{loading ? 'loading…' : 'refresh'}
</button>
</div>
</header>
{error && (
<div style={{ padding: '10px 16px' }}>
<Notice kind="err">
{error}
<button className="btn tiny ghost" style={{ marginLeft: 8 }} onClick={() => setError('')}>dismiss</button>
</Notice>
</div>
)}
{health && !health.ok && (
<div style={{ padding: '10px 16px' }}>
<Notice kind="err">
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>
)}
<div className="body">
<main className="main">
{view === 'containers' && (
<Containers
source={source}
sel={sel}
setSel={setSel}
targetInv={targetInv}
loading={loading}
sizes={sizes}
/>
)}
{view === 'jobs' && (
<Jobs
jobs={jobs}
activeJob={activeJob}
setActiveJob={setActiveJob}
reload={loadJobs}
reloadPackages={loadPackages}
/>
)}
{view === 'packages' && <Packages packages={packages} reload={loadPackages} />}
</main>
{view === 'containers' && (
<aside className="sidebar">
<SourcePanel
sources={sources}
selected={selectedSource}
status={sourceStatus}
selectSource={selectSource}
reload={loadSources}
onError={setError}
/>
<Sidebar
source={source}
plan={plan}
includedCount={included.length}
options={options}
setOptions={setOptions}
connections={connections}
activeConn={activeConn}
setActiveConn={setActiveConn}
reloadConnections={loadConnections}
targetInv={targetInv}
connectTarget={connectTarget}
onJobStarted={onJobStarted}
onError={setError}
/>
</aside>
)}
</div>
</div>
)
}