Files
DockMV/web/src/App.tsx
T
kawaandClaude Haiku 4.5 832af4aaae Update branding with DockMV logo and assets
- 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>
2026-08-11 13:33:27 +02:00

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>
)
}