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(null) const [source, setSource] = useState(null) const [sel, setSel] = useState>({}) const [options, setOptions] = useState({ 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>({}) const [sources, setSources] = useState([]) const [selectedSource, setSelectedSource] = useState('local') const [sourceStatus, setSourceStatus] = useState(null) const [connections, setConnections] = useState([]) const [activeConn, setActiveConn] = useState('') const [targetInv, setTargetInv] = useState(null) const [jobs, setJobs] = useState([]) const [packages, setPackages] = useState([]) const [view, setView] = useState('containers') const [activeJob, setActiveJob] = useState('') const [error, setError] = useState('') 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 = {} 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(() => ({ 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 (
DockMV
{health && ( source {source?.inventory.host || sourceStatus?.name || health.dockerHost} {sourceStatus?.kind === 'ssh' && <> · over ssh} {sourceStatus?.kind === 'docker' && <> · {sourceStatus.endpoint}} {health.dockerVersion && <> · docker {health.dockerVersion}} )}
{error && (
{error}
)} {health && !health.ok && (
Cannot reach the source {health.source?.name ?? 'docker daemon'} {health.dockerHost && <> at {health.dockerHost}} {health.dockerError && <> — {health.dockerError}}
Pick another source in the panel on the right.
)}
{view === 'containers' && ( )} {view === 'jobs' && ( )} {view === 'packages' && }
{view === 'containers' && ( )}
) }