- Replace text-based branding with logo icon in header - Update README.md with full DockMV logo image - Add favicon and logo-icon assets - Add dockmv-icon to assets directory - Update styles for logo display - Rebuild web UI distribution Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
216 lines
7.6 KiB
TypeScript
216 lines
7.6 KiB
TypeScript
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"><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 || 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>
|
|
)
|
|
}
|