- 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>
268 lines
9.5 KiB
TypeScript
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>
|
|
)
|
|
}
|