Initial push
This commit is contained in:
+215
@@ -0,0 +1,215 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { api } from './api'
|
||||
import type {
|
||||
Connection, Health, ItemSelection, JobSnapshot, Options, PackageInfo, Plan, SourceResponse, TargetInventory,
|
||||
} from './types'
|
||||
import { Notice } from './ui'
|
||||
import { Containers } from './Containers'
|
||||
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 [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 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(() => {
|
||||
api.health().then(setHealth).catch(() => undefined)
|
||||
loadSource()
|
||||
loadConnections()
|
||||
loadJobs()
|
||||
loadPackages()
|
||||
}, [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"><span className="dot" />docker-migrate</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 || health.dockerHost}</b>
|
||||
{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 Docker daemon at <span className="mono">{health.dockerHost}</span>
|
||||
{health.dockerError && <> — {health.dockerError}</>}
|
||||
</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">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user