Initial push
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>docker-migrate</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1883
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "docker-migrate-ui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^7.1.0"
|
||||
}
|
||||
}
|
||||
+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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type {
|
||||
Container, ImageMode, ItemSelection, Mount, MountAction, SourceResponse, TargetInventory,
|
||||
} from './types'
|
||||
import { Check, Field, humanBytes, StateDot } from './ui'
|
||||
|
||||
type SelMap = Record<string, ItemSelection>
|
||||
type SizeMap = Record<string, number>
|
||||
|
||||
/** mountSize prefers the size carried on the mount, falling back to the
|
||||
separately measured volume sizes. Bind mount sizes are not measured. */
|
||||
function mountSize(m: Mount, sizes: SizeMap): number {
|
||||
if (m.kind === 'tmpfs') return 0
|
||||
if (m.sizeBytes >= 0) return m.sizeBytes
|
||||
if (m.name && sizes[m.name] !== undefined) return sizes[m.name]
|
||||
return -1
|
||||
}
|
||||
|
||||
export function Containers({
|
||||
source, sel, setSel, targetInv, loading, sizes,
|
||||
}: {
|
||||
source: SourceResponse | null
|
||||
sel: SelMap
|
||||
setSel: React.Dispatch<React.SetStateAction<SelMap>>
|
||||
targetInv: TargetInventory | null
|
||||
loading: boolean
|
||||
sizes: SizeMap
|
||||
}) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [hideStopped, setHideStopped] = useState(false)
|
||||
|
||||
const containers = source?.inventory.containers ?? []
|
||||
|
||||
const targetNames = useMemo(
|
||||
() => new Set((targetInv?.containers ?? []).map((c) => c.name)),
|
||||
[targetInv],
|
||||
)
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return containers.filter((c) => {
|
||||
if (hideStopped && c.state !== 'running') return false
|
||||
if (!q) return true
|
||||
return (
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.image.toLowerCase().includes(q) ||
|
||||
(c.composeProject ?? '').toLowerCase().includes(q) ||
|
||||
(c.mounts ?? []).some((m) => m.destination.toLowerCase().includes(q) || (m.name ?? '').toLowerCase().includes(q))
|
||||
)
|
||||
})
|
||||
}, [containers, query, hideStopped])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, Container[]>()
|
||||
for (const c of visible) {
|
||||
const key = c.composeProject || ''
|
||||
const list = map.get(key)
|
||||
if (list) list.push(c)
|
||||
else map.set(key, [c])
|
||||
}
|
||||
return [...map.entries()].sort((a, b) => {
|
||||
if (a[0] === '') return 1
|
||||
if (b[0] === '') return -1
|
||||
return a[0].localeCompare(b[0])
|
||||
})
|
||||
}, [visible])
|
||||
|
||||
function update(id: string, patch: Partial<ItemSelection>) {
|
||||
setSel((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }))
|
||||
}
|
||||
|
||||
function setInclude(ids: string[], include: boolean) {
|
||||
setSel((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const id of ids) if (next[id]) next[id] = { ...next[id], include }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/** applyToSelected edits every included container at once, which is what
|
||||
makes a 40-container migration a few clicks rather than forty. */
|
||||
function applyToSelected(fn: (s: ItemSelection, c: Container) => ItemSelection) {
|
||||
setSel((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const c of containers) {
|
||||
const s = next[c.id]
|
||||
if (s?.include) next[c.id] = fn(s, c)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function setAllMounts(action: MountAction, kinds: Mount['kind'][]) {
|
||||
applyToSelected((s, c) => {
|
||||
const mounts = { ...s.mounts }
|
||||
for (const m of c.mounts ?? []) {
|
||||
if (m.kind === 'tmpfs') continue
|
||||
if (kinds.includes(m.kind)) mounts[m.destination] = { ...mounts[m.destination], action }
|
||||
}
|
||||
return { ...s, mounts }
|
||||
})
|
||||
}
|
||||
|
||||
const visibleIds = visible.map((c) => c.id)
|
||||
const selectedCount = visible.filter((c) => sel[c.id]?.include).length
|
||||
const anySelected = Object.values(sel).some((s) => s.include)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="search"
|
||||
type="text"
|
||||
placeholder="filter by name, image, mount…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button className="btn tiny" onClick={() => setInclude(visibleIds, true)}>select all</button>
|
||||
<button className="btn tiny" onClick={() => setInclude(visibleIds, false)}>clear</button>
|
||||
<button
|
||||
className="btn tiny"
|
||||
onClick={() => setInclude(visible.filter((c) => c.state === 'running').map((c) => c.id), true)}
|
||||
>
|
||||
select running
|
||||
</button>
|
||||
<Check checked={hideStopped} onChange={setHideStopped} label={<span className="small muted">running only</span>} />
|
||||
|
||||
<span className="spacer" />
|
||||
|
||||
<span className="small faint nowrap">apply to {selectedCount ? `${selectedCount} selected` : 'selection'}:</span>
|
||||
<button className="btn tiny" disabled={!anySelected} onClick={() => setAllMounts('copy', ['volume', 'anonymous', 'bind'])}>
|
||||
copy all data
|
||||
</button>
|
||||
<button className="btn tiny" disabled={!anySelected} onClick={() => setAllMounts('skip', ['bind'])}>
|
||||
skip binds
|
||||
</button>
|
||||
<button className="btn tiny" disabled={!anySelected} onClick={() => setAllMounts('structure', ['volume', 'anonymous', 'bind'])}>
|
||||
structure only
|
||||
</button>
|
||||
<select
|
||||
className="btn tiny"
|
||||
style={{ width: 'auto' }}
|
||||
disabled={!anySelected}
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (!v) return
|
||||
if (v === 'start') applyToSelected((s) => ({ ...s, startAfter: true }))
|
||||
if (v === 'nostart') applyToSelected((s) => ({ ...s, startAfter: false }))
|
||||
if (v === 'live') applyToSelected((s) => ({ ...s, stopSourceDuringCopy: false }))
|
||||
if (v === 'quiesce') applyToSelected((s) => ({ ...s, stopSourceDuringCopy: true }))
|
||||
if (v === 'keepsource') applyToSelected((s) => ({ ...s, stopSourceAfter: false }))
|
||||
if (v === 'stopsource') applyToSelected((s) => ({ ...s, stopSourceAfter: true }))
|
||||
if (v.startsWith('img:')) {
|
||||
const mode = v.slice(4) as ImageMode
|
||||
applyToSelected((s) => ({ ...s, migrateImage: mode !== 'skip', imageMode: mode }))
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
>
|
||||
<option value="">more…</option>
|
||||
<option value="start">start after migration</option>
|
||||
<option value="nostart">leave stopped on target</option>
|
||||
<option value="quiesce">stop source while copying</option>
|
||||
<option value="live">copy while running (hot)</option>
|
||||
<option value="stopsource">stop source after migration</option>
|
||||
<option value="keepsource">leave source running</option>
|
||||
<option value="img:auto">image: auto</option>
|
||||
<option value="img:pull">image: pull on target</option>
|
||||
<option value="img:stream">image: transfer layers</option>
|
||||
<option value="img:skip">image: already on target</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading && containers.length === 0 && <div className="empty">reading the source daemon…</div>}
|
||||
{!loading && containers.length === 0 && <div className="empty">no containers on this host</div>}
|
||||
{!loading && containers.length > 0 && visible.length === 0 && <div className="empty">nothing matches the filter</div>}
|
||||
|
||||
<div className="clist">
|
||||
{groups.map(([project, list]) => (
|
||||
<div key={project || '__none'}>
|
||||
{groups.length > 1 && (
|
||||
<div className="group-head">
|
||||
<Check
|
||||
checked={list.every((c) => sel[c.id]?.include)}
|
||||
onChange={(v) => setInclude(list.map((c) => c.id), v)}
|
||||
label={project ? `compose: ${project}` : 'standalone'}
|
||||
/>
|
||||
<span className="line" />
|
||||
<span>{list.length}</span>
|
||||
</div>
|
||||
)}
|
||||
{list.map((c) => (
|
||||
<Row
|
||||
key={c.id}
|
||||
c={c}
|
||||
s={sel[c.id]}
|
||||
onChange={(patch) => update(c.id, patch)}
|
||||
expanded={expanded.has(c.id)}
|
||||
toggleExpanded={() =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(c.id)) next.delete(c.id)
|
||||
else next.add(c.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
conflicts={targetNames.has(sel[c.id]?.nameOverride || c.name)}
|
||||
sizes={sizes}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({
|
||||
c, s, onChange, expanded, toggleExpanded, conflicts, sizes,
|
||||
}: {
|
||||
c: Container
|
||||
s: ItemSelection | undefined
|
||||
onChange: (patch: Partial<ItemSelection>) => void
|
||||
expanded: boolean
|
||||
toggleExpanded: () => void
|
||||
conflicts: boolean
|
||||
sizes: SizeMap
|
||||
}) {
|
||||
if (!s) return null
|
||||
|
||||
const mounts = c.mounts ?? []
|
||||
const dataMounts = mounts.filter((m) => m.kind !== 'tmpfs')
|
||||
const copying = dataMounts.filter((m) => (s.mounts[m.destination]?.action ?? 'copy') === 'copy')
|
||||
const knownBytes = copying.reduce((a, m) => {
|
||||
const n = mountSize(m, sizes)
|
||||
return a + (n > 0 ? n : 0)
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`crow${s.include ? ' selected' : ''}`}>
|
||||
<Check checked={s.include} onChange={(v) => onChange({ include: v })} label="" />
|
||||
|
||||
<button className="expander" onClick={toggleExpanded} title="per-item options">
|
||||
{expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="name truncate" title={c.name}>{c.name}</div>
|
||||
<div className="sub row" style={{ gap: 6 }}>
|
||||
<StateDot state={c.state} />
|
||||
{c.composeService && <span className="faint">· {c.composeService}</span>}
|
||||
{conflicts && <span className="badge" style={{ borderColor: '#5c4520', color: '#e0b556' }}>on target</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="image truncate" title={c.image}>{c.image}</div>
|
||||
|
||||
<div className="tags">
|
||||
{dataMounts.map((m) => (
|
||||
<span
|
||||
key={m.destination}
|
||||
className={`badge ${m.kind === 'bind' ? 'bind' : m.kind === 'anonymous' ? 'anon' : 'vol'}`}
|
||||
title={`${m.kind} → ${m.destination}${m.readOnly ? ' (read-only)' : ''}`}
|
||||
style={{ opacity: (s.mounts[m.destination]?.action ?? 'copy') === 'skip' ? 0.35 : 1 }}
|
||||
>
|
||||
{m.kind === 'bind' ? (m.source ?? '').split('/').pop() || '/' : m.kind === 'anonymous' ? 'anon' : m.name}
|
||||
</span>
|
||||
))}
|
||||
{(c.endpoints ?? []).filter((e) => !['bridge', 'host', 'none'].includes(e.network)).map((e) => (
|
||||
<span key={e.network} className="badge net" title={`network ${e.network}`}>{e.network}</span>
|
||||
))}
|
||||
{(c.ports ?? []).slice(0, 3).map((p, i) => (
|
||||
<span key={i} className="badge port">{p.hostPort}:{p.containerPort.split('/')[0]}</span>
|
||||
))}
|
||||
{(c.ports ?? []).length > 3 && <span className="badge port">+{(c.ports ?? []).length - 3}</span>}
|
||||
</div>
|
||||
|
||||
<div className="small faint nowrap" style={{ textAlign: 'right' }}>
|
||||
{copying.length > 0 ? `${copying.length} to copy` : 'no data'}
|
||||
{knownBytes > 0 && <> · {humanBytes(knownBytes)}</>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && <Detail c={c} s={s} onChange={onChange} sizes={sizes} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Detail({
|
||||
c, s, onChange, sizes,
|
||||
}: {
|
||||
c: Container
|
||||
s: ItemSelection
|
||||
onChange: (patch: Partial<ItemSelection>) => void
|
||||
sizes: SizeMap
|
||||
}) {
|
||||
const mounts = c.mounts ?? []
|
||||
|
||||
function setMount(dest: string, patch: Partial<{ action: MountAction; targetName: string; targetSource: string }>) {
|
||||
onChange({ mounts: { ...s.mounts, [dest]: { ...s.mounts[dest], ...patch } } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="detail">
|
||||
{(c.warnings ?? []).map((w, i) => (
|
||||
<div key={i} className="notice warn">{w}</div>
|
||||
))}
|
||||
|
||||
<div className="grid2">
|
||||
<Field label="name on target">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={c.name}
|
||||
value={s.nameOverride ?? ''}
|
||||
onChange={(e) => onChange({ nameOverride: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="image">
|
||||
<select
|
||||
value={s.migrateImage ? s.imageMode : 'skip'}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as ImageMode
|
||||
onChange({ migrateImage: v !== 'skip', imageMode: v })
|
||||
}}
|
||||
>
|
||||
<option value="auto">auto — reuse, pull, or transfer</option>
|
||||
<option value="pull">pull on the target</option>
|
||||
<option value="stream">transfer the layers</option>
|
||||
<option value="skip">already on the target</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<Check
|
||||
checked={s.migrateNetworks}
|
||||
onChange={(v) => onChange({ migrateNetworks: v })}
|
||||
label="recreate networks and reattach"
|
||||
/>
|
||||
<Check
|
||||
checked={s.keepStaticIps}
|
||||
onChange={(v) => onChange({ keepStaticIps: v })}
|
||||
disabled={!s.migrateNetworks}
|
||||
label="keep static IP addresses"
|
||||
title="Only works when the target networks use the same subnets"
|
||||
/>
|
||||
<Check
|
||||
checked={s.migratePorts}
|
||||
onChange={(v) => onChange({ migratePorts: v })}
|
||||
label="publish the same host ports"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<Check checked={s.startAfter} onChange={(v) => onChange({ startAfter: v })} label="start on the target" />
|
||||
<Check
|
||||
checked={s.stopSourceDuringCopy}
|
||||
onChange={(v) => onChange({ stopSourceDuringCopy: v })}
|
||||
label="stop the source while copying"
|
||||
title="Recommended: databases and other writers produce inconsistent copies while running"
|
||||
/>
|
||||
<Check
|
||||
checked={s.stopSourceAfter}
|
||||
onChange={(v) => onChange({ stopSourceAfter: v })}
|
||||
label="leave the source stopped afterwards"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mounts.length === 0 ? (
|
||||
<div className="small faint">this container has no mounts</div>
|
||||
) : (
|
||||
<table className="mount-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 74 }}>kind</th>
|
||||
<th>in the container</th>
|
||||
<th>on the source</th>
|
||||
<th style={{ width: 130 }}>action</th>
|
||||
<th>on the target</th>
|
||||
<th style={{ width: 70, textAlign: 'right' }}>size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mounts.map((m) => {
|
||||
const ms = s.mounts[m.destination] ?? { action: 'copy' as MountAction }
|
||||
const isTmpfs = m.kind === 'tmpfs'
|
||||
return (
|
||||
<tr key={m.destination}>
|
||||
<td>
|
||||
<span className={`badge ${m.kind === 'bind' ? 'bind' : m.kind === 'anonymous' ? 'anon' : m.kind === 'tmpfs' ? 'tmpfs' : 'vol'}`}>
|
||||
{m.kind}
|
||||
</span>
|
||||
</td>
|
||||
<td className="mono truncate" title={m.destination}>
|
||||
{m.destination}
|
||||
{m.readOnly && <span className="faint"> :ro</span>}
|
||||
</td>
|
||||
<td className="mono truncate faint" title={m.source || m.name}>
|
||||
{m.kind === 'bind' ? m.source : m.kind === 'anonymous' ? '(generated)' : m.name}
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={ms.action}
|
||||
disabled={isTmpfs}
|
||||
onChange={(e) => setMount(m.destination, { action: e.target.value as MountAction })}
|
||||
>
|
||||
<option value="copy">copy data</option>
|
||||
<option value="structure">create empty</option>
|
||||
<option value="skip">do not mount</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{m.kind === 'bind' && ms.action !== 'skip' && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder={m.source}
|
||||
value={ms.targetSource ?? ''}
|
||||
onChange={(e) => setMount(m.destination, { targetSource: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
{m.kind === 'volume' && ms.action !== 'skip' && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder={m.name}
|
||||
value={ms.targetName ?? ''}
|
||||
onChange={(e) => setMount(m.destination, { targetName: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
{m.kind === 'anonymous' && <span className="small faint">a fresh volume is created</span>}
|
||||
{isTmpfs && <span className="small faint">in memory, nothing to copy</span>}
|
||||
</td>
|
||||
<td className="small faint nowrap" style={{ textAlign: 'right' }}>
|
||||
{isTmpfs ? '–' : humanBytes(mountSize(m, sizes))}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api } from './api'
|
||||
import type { JobSnapshot } from './types'
|
||||
import { duration, humanBytes, Notice, Progress, StateDot } from './ui'
|
||||
|
||||
export function Jobs({
|
||||
jobs, activeJob, setActiveJob, reload, reloadPackages,
|
||||
}: {
|
||||
jobs: JobSnapshot[]
|
||||
activeJob: string
|
||||
setActiveJob: (id: string) => void
|
||||
reload: () => void
|
||||
reloadPackages: () => void
|
||||
}) {
|
||||
const selected = activeJob || jobs[0]?.id || ''
|
||||
|
||||
if (jobs.length === 0) {
|
||||
return <div className="empty">no migrations yet — select containers and start one</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: 0, height: '100%' }}>
|
||||
<div className="joblist" style={{ width: 320, flex: '0 0 320px', overflow: 'auto' }}>
|
||||
{jobs.map((j) => (
|
||||
<div
|
||||
key={j.id}
|
||||
className={`jobcard${j.id === selected ? ' active' : ''}`}
|
||||
onClick={() => setActiveJob(j.id)}
|
||||
>
|
||||
<div className="row">
|
||||
<StateDot state={j.state} />
|
||||
<span className="spacer" />
|
||||
<span className="small faint">{j.kind === 'ssh' ? 'ssh' : 'package'}</span>
|
||||
</div>
|
||||
<div className="truncate" style={{ marginTop: 2 }}>{j.title}</div>
|
||||
<div className="small faint">
|
||||
{new Date(j.createdAt).toLocaleTimeString()} · {duration(j.startedAt, j.endedAt)}
|
||||
{j.dryRun && ' · dry run'}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<Progress done={j.bytesDone} total={j.bytesTotal} state={j.state} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'auto', borderLeft: '1px solid var(--border)' }}>
|
||||
{selected && <JobDetail id={selected} reload={reload} reloadPackages={reloadPackages} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function JobDetail({
|
||||
id, reload, reloadPackages,
|
||||
}: {
|
||||
id: string
|
||||
reload: () => void
|
||||
reloadPackages: () => void
|
||||
}) {
|
||||
const [job, setJob] = useState<JobSnapshot | null>(null)
|
||||
const [showCmds, setShowCmds] = useState(true)
|
||||
const logRef = useRef<HTMLDivElement>(null)
|
||||
const stick = useRef(true)
|
||||
|
||||
// Each job detail holds one server-sent events stream, which pushes a full
|
||||
// snapshot whenever anything changes.
|
||||
useEffect(() => {
|
||||
setJob(null)
|
||||
let closed = false
|
||||
api.job(id).then((j) => !closed && setJob(j)).catch(() => undefined)
|
||||
|
||||
const es = api.jobEvents(id)
|
||||
es.onmessage = (ev) => {
|
||||
try {
|
||||
setJob(JSON.parse(ev.data) as JobSnapshot)
|
||||
} catch { /* a truncated frame is replaced by the next one */ }
|
||||
}
|
||||
es.addEventListener('done', () => {
|
||||
es.close()
|
||||
reload()
|
||||
reloadPackages()
|
||||
})
|
||||
es.onerror = () => es.close()
|
||||
return () => {
|
||||
closed = true
|
||||
es.close()
|
||||
}
|
||||
}, [id, reload, reloadPackages])
|
||||
|
||||
const lines = useMemo(
|
||||
() => (job?.log ?? []).filter((l) => showCmds || l.level !== 'cmd'),
|
||||
[job, showCmds],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const el = logRef.current
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight
|
||||
}, [lines])
|
||||
|
||||
if (!job) return <div className="empty">loading…</div>
|
||||
|
||||
const running = job.state === 'running' || job.state === 'pending'
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div className="row">
|
||||
<StateDot state={job.state} />
|
||||
<b>{job.title}</b>
|
||||
{job.dryRun && <span className="badge">dry run</span>}
|
||||
<span className="spacer" />
|
||||
<span className="small faint">
|
||||
{humanBytes(job.bytesDone)}
|
||||
{job.bytesTotal > 0 && <> of {humanBytes(job.bytesTotal)}</>} · {duration(job.startedAt, job.endedAt)}
|
||||
</span>
|
||||
{running ? (
|
||||
<button className="btn tiny danger" onClick={() => api.cancelJob(job.id).then(reload)}>cancel</button>
|
||||
) : (
|
||||
<button className="btn tiny ghost" onClick={() => api.deleteJob(job.id).then(reload)}>remove</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Progress done={job.bytesDone} total={job.bytesTotal} state={job.state} />
|
||||
|
||||
{job.error && <Notice kind="err">{job.error}</Notice>}
|
||||
{job.state === 'succeeded' && job.artifact && (
|
||||
<Notice kind="ok">
|
||||
Package ready at <span className="mono">{job.artifact}</span> ({humanBytes(job.artifactBytes ?? 0)}).
|
||||
{' '}Open the Packages tab to download it.
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{job.items.map((it) => (
|
||||
<div key={it.id} style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px' }}>
|
||||
<div className="row">
|
||||
<StateDot state={it.state} />
|
||||
<b>{it.name}</b>
|
||||
<span className="spacer" />
|
||||
<span className="small faint">{it.steps.filter((s) => s.state === 'succeeded').length}/{it.steps.length} steps</span>
|
||||
</div>
|
||||
{it.error && <div className="small" style={{ color: 'var(--err)' }}>{it.error}</div>}
|
||||
{(it.warnings ?? []).map((w, i) => (
|
||||
<div key={i} className="small" style={{ color: 'var(--warn)' }}>! {w}</div>
|
||||
))}
|
||||
<div className="steps">
|
||||
{it.steps.map((s) => (
|
||||
<div key={s.id} className={`step ${s.state}`}>
|
||||
<StateDot state={s.state} label="" />
|
||||
<span className="label truncate" title={s.error || s.label}>{s.label}</span>
|
||||
<span>
|
||||
{s.bytesTotal > 0 || s.bytesDone > 0 ? (
|
||||
<Progress done={s.bytesDone} total={s.bytesTotal} state={s.state} />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="faint nowrap" style={{ textAlign: 'right' }}>
|
||||
{s.bytesDone > 0 ? humanBytes(s.bytesDone) : s.state === 'skipped' ? 'skipped' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="row">
|
||||
<h3 style={{ margin: 0, fontSize: 12 }}>log</h3>
|
||||
<span className="spacer" />
|
||||
<label className="check small">
|
||||
<input type="checkbox" checked={showCmds} onChange={(e) => setShowCmds(e.target.checked)} />
|
||||
<span>show commands</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="log"
|
||||
ref={logRef}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
||||
}}
|
||||
>
|
||||
{lines.map((l) => (
|
||||
<div key={l.seq} className={`l-${l.level}`}>
|
||||
<span className="ts">{new Date(l.at).toLocaleTimeString()} </span>
|
||||
{l.message}
|
||||
</div>
|
||||
))}
|
||||
{lines.length === 0 && <span className="faint">nothing logged yet</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { api } from './api'
|
||||
import type { PackageInfo } from './types'
|
||||
import { humanBytes, Notice } from './ui'
|
||||
|
||||
export function Packages({ packages, reload }: { packages: PackageInfo[]; reload: () => void }) {
|
||||
if (packages.length === 0) {
|
||||
return <div className="empty">no packages built yet</div>
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Notice kind="info">
|
||||
Copy a package to the target host, then run <span className="mono">./install.sh --dry-run</span> to review it and{' '}
|
||||
<span className="mono">./install.sh</span> to restore. The target needs only bash, gzip and docker.
|
||||
</Notice>
|
||||
|
||||
<table className="mount-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
<th style={{ width: 110 }}>kind</th>
|
||||
<th style={{ width: 110, textAlign: 'right' }}>size</th>
|
||||
<th style={{ width: 170 }}>built</th>
|
||||
<th style={{ width: 190 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{packages.map((p) => (
|
||||
<tr key={p.name}>
|
||||
<td className="mono truncate" title={p.path}>{p.name}</td>
|
||||
<td><span className="badge">{p.isDir ? 'directory' : 'tar'}</span></td>
|
||||
<td className="nowrap" style={{ textAlign: 'right' }}>{humanBytes(p.bytes)}</td>
|
||||
<td className="small faint">{new Date(p.createdAt).toLocaleString()}</td>
|
||||
<td>
|
||||
<div className="row" style={{ justifyContent: 'flex-end', gap: 6 }}>
|
||||
{p.isDir ? (
|
||||
<span className="small faint" title={p.path}>copy it from disk</span>
|
||||
) : (
|
||||
<a className="btn tiny" href={api.downloadUrl(p.name)} download>download</a>
|
||||
)}
|
||||
<button
|
||||
className="btn tiny danger"
|
||||
onClick={() => {
|
||||
if (!confirm(`Delete package "${p.name}"? This cannot be undone.`)) return
|
||||
api.deletePackage(p.name).then(reload)
|
||||
}}
|
||||
>
|
||||
delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, ApiError } from './api'
|
||||
import type {
|
||||
Connection, HostKeyInfo, JobSnapshot, Options, Plan, PreviewResponse, SourceResponse, TargetInventory,
|
||||
} from './types'
|
||||
import { Check, Field, humanBytes, Modal, Notice } from './ui'
|
||||
|
||||
export function Sidebar({
|
||||
source, plan, includedCount, options, setOptions,
|
||||
connections, activeConn, setActiveConn, reloadConnections,
|
||||
targetInv, connectTarget, onJobStarted, onError,
|
||||
}: {
|
||||
source: SourceResponse | null
|
||||
plan: Plan
|
||||
includedCount: number
|
||||
options: Options
|
||||
setOptions: React.Dispatch<React.SetStateAction<Options>>
|
||||
connections: Connection[]
|
||||
activeConn: string
|
||||
setActiveConn: (id: string) => void
|
||||
reloadConnections: () => void
|
||||
targetInv: TargetInventory | null
|
||||
connectTarget: (id: string) => Promise<void>
|
||||
onJobStarted: (j: JobSnapshot) => void
|
||||
onError: (msg: string) => void
|
||||
}) {
|
||||
const [editing, setEditing] = useState<Partial<Connection> | null>(null)
|
||||
const [hostKey, setHostKey] = useState<HostKeyInfo | null>(null)
|
||||
const [preview, setPreview] = useState<PreviewResponse | null>(null)
|
||||
const [busy, setBusy] = useState('')
|
||||
const [packageName, setPackageName] = useState('')
|
||||
const [packageFormat, setPackageFormat] = useState<'tar' | 'dir'>('tar')
|
||||
|
||||
const conn = connections.find((c) => c.id === activeConn)
|
||||
const pre = targetInv?.preflight
|
||||
const targetReady = !!pre?.serverVersion
|
||||
|
||||
async function withBusy(what: string, fn: () => Promise<void>) {
|
||||
setBusy(what)
|
||||
try {
|
||||
await fn()
|
||||
} catch (e) {
|
||||
// An untrusted host key is not an error to report but a decision to
|
||||
// put in front of the operator, so it opens the fingerprint dialog.
|
||||
if (e instanceof ApiError && e.needsTrust) {
|
||||
await checkHostKey()
|
||||
} else {
|
||||
onError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!activeConn) return
|
||||
void withBusy('test', () => connectTarget(activeConn))
|
||||
// withBusy and connectTarget are stable for the lifetime of a selection.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeConn])
|
||||
|
||||
// Selecting a target connects to it straight away; if its key is not trusted
|
||||
// yet the dialog opens by itself rather than leaving the panel blank.
|
||||
useEffect(() => {
|
||||
connect()
|
||||
}, [connect])
|
||||
|
||||
async function checkHostKey() {
|
||||
if (!activeConn) return
|
||||
try {
|
||||
setHostKey(await api.probe(activeConn))
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function trustHostKey() {
|
||||
if (!activeConn || !hostKey) return
|
||||
await withBusy('trust', async () => {
|
||||
await api.trust(activeConn, hostKey.fingerprint)
|
||||
setHostKey(null)
|
||||
await connectTarget(activeConn)
|
||||
})
|
||||
}
|
||||
|
||||
const canMigrate = includedCount > 0 && targetReady && !busy
|
||||
const canPackage = includedCount > 0 && !busy
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section">
|
||||
<h3>target host</h3>
|
||||
<div className="stack">
|
||||
<div className="row">
|
||||
<select value={activeConn} onChange={(e) => setActiveConn(e.target.value)}>
|
||||
<option value="">— no target selected —</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name} ({c.user}@{c.host})</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn tiny" onClick={() => setEditing({ port: 22, auth: 'password', saveSecrets: false, sudo: false })}>
|
||||
new
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{conn && (
|
||||
<div className="row wrap" style={{ gap: 6 }}>
|
||||
<button className="btn tiny" disabled={!!busy} onClick={connect}>
|
||||
{busy === 'test' ? 'connecting…' : 'connect'}
|
||||
</button>
|
||||
<button className="btn tiny" onClick={() => setEditing(conn)}>edit</button>
|
||||
<button className="btn tiny" onClick={checkHostKey}>host key</button>
|
||||
<button
|
||||
className="btn tiny danger"
|
||||
onClick={() => {
|
||||
if (!confirm(`Delete connection "${conn.name}"?`)) return
|
||||
withBusy('del', async () => {
|
||||
await api.deleteConnection(conn.id)
|
||||
reloadConnections()
|
||||
})
|
||||
}}
|
||||
>
|
||||
delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conn && !targetInv && <div className="small faint">not connected yet</div>}
|
||||
|
||||
{pre && (
|
||||
<>
|
||||
{(pre.problems ?? []).map((p, i) => (
|
||||
<Notice key={i} kind="warn">{p}</Notice>
|
||||
))}
|
||||
{targetReady && (
|
||||
<dl className="kv">
|
||||
<dt>host</dt><dd>{targetInv?.host || conn?.host}</dd>
|
||||
<dt>docker</dt><dd>{pre.serverVersion} · {pre.os}/{pre.arch}</dd>
|
||||
<dt>free space</dt><dd>{humanBytes(pre.diskFreeBytes)} on {pre.dockerRoot}</dd>
|
||||
<dt>existing</dt>
|
||||
<dd>
|
||||
{(targetInv?.containers ?? []).length} containers ·{' '}
|
||||
{(targetInv?.volumes ?? []).length} volumes
|
||||
</dd>
|
||||
<dt>gzip</dt><dd>{pre.hasGzip ? 'yes' : 'missing'}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3>options</h3>
|
||||
<div className="stack">
|
||||
<Field label="if the name already exists on the target">
|
||||
<select
|
||||
value={options.conflict}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, conflict: e.target.value as Options['conflict'] }))}
|
||||
>
|
||||
<option value="fail">stop with an error</option>
|
||||
<option value="skip">skip that container</option>
|
||||
<option value="rename">create it under a new name</option>
|
||||
<option value="replace">remove the target's container first</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{options.conflict === 'rename' && (
|
||||
<Field label="suffix">
|
||||
<input
|
||||
type="text"
|
||||
value={options.renameSuffix ?? ''}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, renameSuffix: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{options.conflict === 'replace' && (
|
||||
<Notice kind="warn">
|
||||
Existing containers and volumes with the same name are deleted on the target before the copy.
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<Check
|
||||
checked={options.compress}
|
||||
onChange={(v) => setOptions((o) => ({ ...o, compress: v }))}
|
||||
label="compress transfers (gzip)"
|
||||
/>
|
||||
<Check
|
||||
checked={options.verifyAfter}
|
||||
onChange={(v) => setOptions((o) => ({ ...o, verifyAfter: v }))}
|
||||
label="verify each container after migrating"
|
||||
/>
|
||||
<Check
|
||||
checked={options.dryRun}
|
||||
onChange={(v) => setOptions((o) => ({ ...o, dryRun: v }))}
|
||||
label="dry run — show every command, change nothing"
|
||||
/>
|
||||
|
||||
<Field label={`containers at a time: ${options.parallelism}`}>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={6}
|
||||
value={options.parallelism}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, parallelism: Number(e.target.value) }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3>migrate over ssh</h3>
|
||||
<div className="stack">
|
||||
<button
|
||||
className="btn primary"
|
||||
disabled={!canMigrate}
|
||||
onClick={() =>
|
||||
withBusy('ssh', async () => {
|
||||
const j = await api.migrateSSH(activeConn, plan)
|
||||
onJobStarted(j)
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === 'ssh' ? 'starting…' : `migrate ${includedCount} container${includedCount === 1 ? '' : 's'} to target`}
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={includedCount === 0 || !!busy}
|
||||
onClick={() =>
|
||||
withBusy('preview', async () => {
|
||||
setPreview(await api.preview(plan))
|
||||
})
|
||||
}
|
||||
>
|
||||
preview the commands
|
||||
</button>
|
||||
{includedCount === 0 && <div className="small faint">select at least one container</div>}
|
||||
{includedCount > 0 && !targetReady && <div className="small faint">connect to a target first</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3>migration package</h3>
|
||||
<div className="stack">
|
||||
<div className="small muted">
|
||||
Builds a self-contained folder with the data, the images and an <span className="mono">install.sh</span> to
|
||||
run on the target. No network between the hosts required.
|
||||
</div>
|
||||
<Field label="package name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="auto (timestamped)"
|
||||
value={packageName}
|
||||
onChange={(e) => setPackageName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="format">
|
||||
<select value={packageFormat} onChange={(e) => setPackageFormat(e.target.value as 'tar' | 'dir')}>
|
||||
<option value="tar">single .tar file (downloadable)</option>
|
||||
<option value="dir">directory on this host</option>
|
||||
</select>
|
||||
</Field>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!canPackage}
|
||||
onClick={() =>
|
||||
withBusy('pkg', async () => {
|
||||
const j = await api.buildPackage({ ...plan, packageName }, packageFormat)
|
||||
onJobStarted(j)
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === 'pkg' ? 'starting…' : 'build package'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{source?.inventory.warnings?.length ? (
|
||||
<div className="section">
|
||||
<h3>source warnings</h3>
|
||||
<div className="stack">
|
||||
{source.inventory.warnings.map((w, i) => (
|
||||
<Notice key={i} kind="warn">{w}</Notice>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editing && (
|
||||
<ConnectionDialog
|
||||
initial={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={(c) => {
|
||||
setEditing(null)
|
||||
reloadConnections()
|
||||
setActiveConn(c.id)
|
||||
}}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hostKey && (
|
||||
<Modal
|
||||
title="SSH host key"
|
||||
onClose={() => setHostKey(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={() => setHostKey(null)}>cancel</button>
|
||||
<button className="btn primary" onClick={trustHostKey}>
|
||||
{hostKey.changed ? 'replace the stored key and trust' : 'trust this host'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="stack">
|
||||
{hostKey.changed && (
|
||||
<Notice kind="err">
|
||||
The key presented by this host is <b>different</b> from the one recorded earlier. This happens after a
|
||||
reinstall — but it is also what a machine-in-the-middle looks like. Only continue if you know why it
|
||||
changed.
|
||||
</Notice>
|
||||
)}
|
||||
{hostKey.trusted && !hostKey.changed && <Notice kind="ok">This host key is already trusted.</Notice>}
|
||||
<div className="small muted">
|
||||
Compare this with the output of <span className="mono">ssh-keyscan -t {hostKey.keyType} {hostKey.host}</span>{' '}
|
||||
run on the target itself, or with <span className="mono">ssh-keygen -lf /etc/ssh/ssh_host_*_key.pub</span>.
|
||||
</div>
|
||||
<div className="fingerprint">
|
||||
{hostKey.keyType}<br />
|
||||
{hostKey.fingerprint}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{preview && <PreviewModal data={preview} onClose={() => setPreview(null)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionDialog({
|
||||
initial, onClose, onSaved, onError,
|
||||
}: {
|
||||
initial: Partial<Connection>
|
||||
onClose: () => void
|
||||
onSaved: (c: Connection) => void
|
||||
onError: (m: string) => void
|
||||
}) {
|
||||
const [c, setC] = useState<Partial<Connection>>(initial)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function set<K extends keyof Connection>(k: K, v: Connection[K]) {
|
||||
setC((prev) => ({ ...prev, [k]: v }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={initial.id ? `Edit ${initial.name}` : 'New target host'}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={onClose}>cancel</button>
|
||||
<button
|
||||
className="btn primary"
|
||||
disabled={saving || !c.host || !c.user}
|
||||
onClick={async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
onSaved(await api.saveConnection(c))
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{saving ? 'saving…' : 'save'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="stack" style={{ gap: 12 }}>
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<Field label="label"><input type="text" value={c.name ?? ''} onChange={(e) => set('name', e.target.value)} /></Field>
|
||||
<Field label="host"><input type="text" value={c.host ?? ''} onChange={(e) => set('host', e.target.value)} /></Field>
|
||||
<div style={{ width: 90 }}>
|
||||
<Field label="port">
|
||||
<input type="number" value={c.port ?? 22} onChange={(e) => set('port', Number(e.target.value))} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
<Field label="user"><input type="text" value={c.user ?? ''} onChange={(e) => set('user', e.target.value)} /></Field>
|
||||
<Field label="authentication">
|
||||
<select value={c.auth ?? 'password'} onChange={(e) => set('auth', e.target.value as Connection['auth'])}>
|
||||
<option value="password">password</option>
|
||||
<option value="key">private key</option>
|
||||
<option value="agent">ssh agent</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{c.auth === 'password' && (
|
||||
<Field label="password">
|
||||
<input type="password" value={c.password ?? ''} onChange={(e) => set('password', e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{c.auth === 'key' && (
|
||||
<>
|
||||
<Field label="private key path on this machine (leave empty to paste the key below)">
|
||||
<input type="text" placeholder="/root/.ssh/id_ed25519" value={c.privateKeyPath ?? ''} onChange={(e) => set('privateKeyPath', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="or paste the private key">
|
||||
<textarea rows={5} value={c.privateKey ?? ''} onChange={(e) => set('privateKey', e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" />
|
||||
</Field>
|
||||
<Field label="passphrase (if the key is encrypted)">
|
||||
<input type="password" value={c.passphrase ?? ''} onChange={(e) => set('passphrase', e.target.value)} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{c.auth === 'agent' && (
|
||||
<div className="small muted">
|
||||
Uses the agent at <span className="mono">$SSH_AUTH_SOCK</span> of the process running docker-migrate.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Check
|
||||
checked={c.sudo ?? false}
|
||||
onChange={(v) => set('sudo', v)}
|
||||
label="run docker through sudo -n on the target"
|
||||
title="Needed when the login user is not in the docker group. sudo must not ask for a password."
|
||||
/>
|
||||
|
||||
<Field label="docker command on the target (optional)">
|
||||
<input type="text" placeholder="docker" value={c.dockerCmd ?? ''} onChange={(e) => set('dockerCmd', e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Check
|
||||
checked={c.saveSecrets ?? false}
|
||||
onChange={(v) => set('saveSecrets', v)}
|
||||
label="remember the password / key on disk"
|
||||
/>
|
||||
{c.saveSecrets ? (
|
||||
<Notice kind="warn">
|
||||
Credentials are stored in plain text in the connections file, readable only by this user. Leave this off to
|
||||
keep them in memory for this session only.
|
||||
</Notice>
|
||||
) : (
|
||||
<div className="small faint">
|
||||
Credentials stay in memory and are lost when docker-migrate restarts.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewModal({ data, onClose }: { data: PreviewResponse; onClose: () => void }) {
|
||||
const total = data.items.reduce((a, i) => a + i.totalBytes, 0)
|
||||
return (
|
||||
<Modal
|
||||
title="What this migration will run"
|
||||
wide
|
||||
onClose={onClose}
|
||||
footer={<button className="btn" onClick={onClose}>close</button>}
|
||||
>
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<div className="small muted">
|
||||
{data.items.length} container(s){total > 0 && <> · about {humanBytes(total)} of known volume data</>}. These are
|
||||
the commands that run on the target; data is streamed into <span className="mono">docker cp</span> rather than
|
||||
written to a file.
|
||||
</div>
|
||||
|
||||
{(data.networkCommands ?? []).length > 0 && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 6px', fontSize: 12 }}>shared networks</h3>
|
||||
<pre className="cmdblock">{(data.networkCommands ?? []).join('\n')}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.items.map((it) => (
|
||||
<div key={it.containerId}>
|
||||
<h3 style={{ margin: '0 0 6px', fontSize: 12 }}>
|
||||
{it.name}
|
||||
{it.targetName !== it.name && <span className="faint"> → {it.targetName}</span>}
|
||||
</h3>
|
||||
{(it.warnings ?? []).map((w, i) => <Notice key={`w${i}`} kind="warn">{w}</Notice>)}
|
||||
{(it.notes ?? []).map((w, i) => <div key={`n${i}`} className="small faint">· {w}</div>)}
|
||||
<pre className="cmdblock">{(it.commands ?? []).join('\n')}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
Connection, Health, HostKeyInfo, JobSnapshot, PackageInfo, Plan,
|
||||
Preflight, PreviewResponse, SourceResponse, TargetInventory,
|
||||
} from './types'
|
||||
|
||||
// The token, when the server requires one, arrives as a query parameter the
|
||||
// first time and is kept for the tab afterwards.
|
||||
function readToken(): string {
|
||||
const fromUrl = new URLSearchParams(location.search).get('token')
|
||||
if (fromUrl) {
|
||||
sessionStorage.setItem('dm.token', fromUrl)
|
||||
const clean = location.pathname + location.hash
|
||||
history.replaceState(null, '', clean)
|
||||
return fromUrl
|
||||
}
|
||||
return sessionStorage.getItem('dm.token') ?? ''
|
||||
}
|
||||
|
||||
const token = readToken()
|
||||
|
||||
/** ApiError carries the server's message plus anything it attached to it. */
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
body: Record<string, unknown>
|
||||
constructor(status: number, message: string, body: Record<string, unknown> = {}) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
/** True when the target's SSH host key still has to be approved. */
|
||||
get needsTrust(): boolean {
|
||||
return this.body.needsTrust === true
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = { ...(init?.headers as Record<string, string>) }
|
||||
if (token) headers['X-Auth-Token'] = token
|
||||
if (init?.body) headers['Content-Type'] = 'application/json'
|
||||
|
||||
const res = await fetch(path, { ...init, headers })
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const text = await res.text()
|
||||
let body: Record<string, unknown> = {}
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
if (!res.ok) throw new ApiError(res.status, text.slice(0, 400))
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg = typeof body.error === 'string' ? body.error : `request failed (${res.status})`
|
||||
throw new ApiError(res.status, msg, body)
|
||||
}
|
||||
return body as T
|
||||
}
|
||||
|
||||
const post = <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) })
|
||||
|
||||
export const api = {
|
||||
health: () => request<Health>('/api/health'),
|
||||
source: () => request<SourceResponse>('/api/source'),
|
||||
volumeSizes: () => request<{ volumes: Record<string, number> }>('/api/source/sizes'),
|
||||
|
||||
connections: () => request<Connection[]>('/api/connections'),
|
||||
saveConnection: (c: Partial<Connection>) => post<Connection>('/api/connections', c),
|
||||
deleteConnection: (id: string) => request<void>(`/api/connections/${id}`, { method: 'DELETE' }),
|
||||
probe: (id: string) => post<HostKeyInfo>(`/api/connections/${id}/probe`),
|
||||
trust: (id: string, fingerprint: string) => post<{ trusted: boolean }>(`/api/connections/${id}/trust`, { fingerprint }),
|
||||
testConnection: (id: string) => post<Preflight>(`/api/connections/${id}/test`),
|
||||
targetInventory: (id: string) => request<TargetInventory>(`/api/connections/${id}/inventory`),
|
||||
|
||||
preview: (plan: Plan) => post<PreviewResponse>('/api/plan/preview', plan),
|
||||
migrateSSH: (connectionId: string, plan: Plan) => post<JobSnapshot>('/api/migrate/ssh', { connectionId, plan }),
|
||||
buildPackage: (plan: Plan, format: 'tar' | 'dir') => post<JobSnapshot>('/api/migrate/package', { plan, format }),
|
||||
|
||||
jobs: () => request<JobSnapshot[]>('/api/jobs'),
|
||||
job: (id: string) => request<JobSnapshot>(`/api/jobs/${id}`),
|
||||
cancelJob: (id: string) => post<{ canceled: boolean }>(`/api/jobs/${id}/cancel`),
|
||||
deleteJob: (id: string) => request<void>(`/api/jobs/${id}`, { method: 'DELETE' }),
|
||||
|
||||
packages: () => request<PackageInfo[]>('/api/packages'),
|
||||
deletePackage: (name: string) => request<void>(`/api/packages/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
downloadUrl: (name: string) =>
|
||||
`/api/packages/${encodeURIComponent(name)}/download` + (token ? `?token=${encodeURIComponent(token)}` : ''),
|
||||
|
||||
/** Opens the live progress stream for a job. */
|
||||
jobEvents: (id: string) =>
|
||||
new EventSource(`/api/jobs/${id}/events` + (token ? `?token=${encodeURIComponent(token)}` : '')),
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,313 @@
|
||||
/* docker-migrate UI.
|
||||
An operations tool: dense, monospace-leaning, dark by default. Colour is
|
||||
reserved for state, never for decoration. */
|
||||
|
||||
:root {
|
||||
--bg: #0e1116;
|
||||
--bg-raised: #161b22;
|
||||
--bg-sunken: #0a0d12;
|
||||
--bg-hover: #1c232d;
|
||||
--border: #262d38;
|
||||
--border-strong: #38414f;
|
||||
--text: #dfe6ef;
|
||||
--text-dim: #8b97a8;
|
||||
--text-faint: #5d6878;
|
||||
|
||||
--accent: #4c9aff;
|
||||
--accent-dim: #1b3a63;
|
||||
--ok: #3fb950;
|
||||
--warn: #d29922;
|
||||
--err: #f85149;
|
||||
--run: #58a6ff;
|
||||
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
--radius: 6px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
|
||||
.app { display: flex; flex-direction: column; height: 100%; }
|
||||
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 0 16px; height: 48px; flex: 0 0 auto;
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: var(--mono); font-weight: 600; letter-spacing: -0.3px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.brand .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||
|
||||
.tabs { display: flex; gap: 2px; margin-left: 8px; }
|
||||
.tab {
|
||||
background: none; border: 0; padding: 6px 12px; border-radius: var(--radius);
|
||||
color: var(--text-dim); cursor: pointer;
|
||||
}
|
||||
.tab:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tab.active { background: var(--accent-dim); color: #cfe3ff; }
|
||||
.tab .count {
|
||||
font-family: var(--mono); font-size: 11px; margin-left: 6px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.hostinfo { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
|
||||
.hostinfo b { color: var(--text); font-weight: 600; }
|
||||
|
||||
.body { flex: 1; display: flex; min-height: 0; }
|
||||
.main { flex: 1; min-width: 0; overflow: auto; }
|
||||
.sidebar {
|
||||
width: 340px; flex: 0 0 340px; overflow: auto;
|
||||
background: var(--bg-raised); border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Below this width the sidebar moves under the list. The two panes must then
|
||||
stop scrolling independently, or the list collapses to a sliver while the
|
||||
sidebar takes the whole viewport. */
|
||||
@media (max-width: 1100px) {
|
||||
.body { flex-direction: column; overflow: auto; }
|
||||
.main { overflow: visible; flex: 0 0 auto; }
|
||||
.sidebar {
|
||||
width: auto; flex: 0 0 auto; overflow: visible;
|
||||
border-left: 0; border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- generic bits ---------- */
|
||||
|
||||
.section { padding: 14px 16px; border-bottom: 1px solid var(--border); }
|
||||
.section h3 {
|
||||
margin: 0 0 10px; font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; color: var(--text-faint); font-weight: 600;
|
||||
}
|
||||
|
||||
.row { display: flex; align-items: center; gap: 8px; }
|
||||
.row.wrap { flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
.stack { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.muted { color: var(--text-dim); }
|
||||
.faint { color: var(--text-faint); }
|
||||
.mono { font-family: var(--mono); }
|
||||
.small { font-size: 11px; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.btn {
|
||||
background: var(--bg-hover); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius); padding: 6px 12px; cursor: pointer;
|
||||
color: var(--text); white-space: nowrap;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #232c38; border-color: #4a5566; }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.btn.primary { background: #1f6feb; border-color: #2f7ef5; color: #fff; font-weight: 500; }
|
||||
.btn.primary:hover:not(:disabled) { background: #2b7bf3; }
|
||||
.btn.danger { border-color: #6b2a28; color: #ff8a80; }
|
||||
.btn.danger:hover:not(:disabled) { background: #351c1b; }
|
||||
.btn.tiny { padding: 2px 7px; font-size: 11px; }
|
||||
.btn.ghost { background: none; border-color: transparent; color: var(--text-dim); }
|
||||
.btn.ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
input[type=text], input[type=number], input[type=password], select, textarea {
|
||||
background: var(--bg-sunken); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius); padding: 5px 8px; width: 100%;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent-dim); border-color: var(--accent); }
|
||||
textarea { font-family: var(--mono); font-size: 11px; resize: vertical; }
|
||||
|
||||
label.field { display: block; }
|
||||
label.field > span { display: block; font-size: 11px; color: var(--text-dim); margin-bottom: 3px; }
|
||||
|
||||
.check { display: flex; align-items: center; gap: 7px; cursor: pointer; user-select: none; }
|
||||
.check input { accent-color: var(--accent); width: 14px; height: 14px; margin: 0; cursor: pointer; }
|
||||
.check.disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
.badge {
|
||||
font-family: var(--mono); font-size: 10px; padding: 1px 5px;
|
||||
border-radius: 3px; border: 1px solid var(--border-strong);
|
||||
color: var(--text-dim); white-space: nowrap;
|
||||
}
|
||||
.badge.vol { border-color: #2d4a6b; color: #83b8f0; }
|
||||
.badge.bind { border-color: #5c4520; color: #e0b556; }
|
||||
.badge.anon { border-color: #46375e; color: #b294e0; }
|
||||
.badge.tmpfs { border-color: #33404d; color: #9aa7b6; }
|
||||
.badge.net { border-color: #2b5040; color: #6cc79b; }
|
||||
.badge.port { border-color: #3a3f52; color: #a5aec9; }
|
||||
|
||||
.state { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; }
|
||||
.state .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-faint); flex: 0 0 auto; }
|
||||
.state.running .dot { background: var(--ok); }
|
||||
.state.exited .dot, .state.dead .dot { background: var(--text-faint); }
|
||||
.state.paused .dot, .state.restarting .dot { background: var(--warn); }
|
||||
.state.succeeded .dot { background: var(--ok); }
|
||||
.state.failed .dot { background: var(--err); }
|
||||
.state.canceled .dot { background: var(--warn); }
|
||||
.state.pending .dot { background: var(--text-faint); }
|
||||
.state.skipped .dot { background: var(--text-faint); }
|
||||
.state.running .dot { animation: pulse 1.4s ease-in-out infinite; }
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
|
||||
.notice {
|
||||
padding: 8px 10px; border-radius: var(--radius); font-size: 12px;
|
||||
border: 1px solid var(--border-strong); background: var(--bg-sunken);
|
||||
}
|
||||
.notice.warn { border-color: #5c4520; background: #221a0c; color: #f0cd82; }
|
||||
.notice.err { border-color: #6b2a28; background: #2a1413; color: #ffb3ad; }
|
||||
.notice.ok { border-color: #23543a; background: #0f2318; color: #97e0ac; }
|
||||
|
||||
/* ---------- container list ---------- */
|
||||
|
||||
.toolbar {
|
||||
position: sticky; top: 0; z-index: 5;
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 10px 16px; background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.toolbar .search { width: 220px; }
|
||||
|
||||
.group-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 16px 4px; color: var(--text-faint);
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
}
|
||||
.group-head .line { flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
.clist { display: flex; flex-direction: column; }
|
||||
|
||||
.crow {
|
||||
display: grid;
|
||||
grid-template-columns: 26px 22px minmax(180px, 1.4fr) minmax(140px, 1.2fr) minmax(200px, 2fr) auto;
|
||||
align-items: center; gap: 10px;
|
||||
padding: 7px 16px; border-bottom: 1px solid var(--border);
|
||||
cursor: default;
|
||||
}
|
||||
.crow:hover { background: var(--bg-hover); }
|
||||
.crow.selected { background: #11213a; }
|
||||
.crow.selected:hover { background: #16294a; }
|
||||
|
||||
.crow .name { font-weight: 500; }
|
||||
.crow .sub { font-size: 11px; color: var(--text-faint); }
|
||||
.crow .image { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
|
||||
.crow .tags { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
|
||||
.expander {
|
||||
background: none; border: 0; color: var(--text-faint); cursor: pointer;
|
||||
padding: 2px; line-height: 1; border-radius: 3px;
|
||||
}
|
||||
.expander:hover { color: var(--text); background: var(--bg-hover); }
|
||||
|
||||
.detail {
|
||||
padding: 12px 16px 16px 52px; background: var(--bg-sunken);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: grid; gap: 14px;
|
||||
}
|
||||
.detail .grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px; }
|
||||
|
||||
.mount-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.mount-table th {
|
||||
text-align: left; font-weight: 500; color: var(--text-faint);
|
||||
font-size: 11px; padding: 4px 8px 4px 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mount-table td { padding: 5px 8px 5px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
.mount-table tr:last-child td { border-bottom: 0; }
|
||||
.mount-table select { width: auto; min-width: 110px; }
|
||||
.mount-table input[type=text] { min-width: 150px; }
|
||||
|
||||
/* ---------- jobs ---------- */
|
||||
|
||||
.joblist { padding: 12px 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.jobcard {
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--bg-raised); padding: 10px 12px; cursor: pointer;
|
||||
}
|
||||
.jobcard:hover { border-color: var(--border-strong); }
|
||||
.jobcard.active { border-color: var(--accent); }
|
||||
|
||||
.progress { height: 4px; background: var(--bg-sunken); border-radius: 2px; overflow: hidden; }
|
||||
.progress > div { height: 100%; background: var(--accent); transition: width 0.25s ease; }
|
||||
.progress.done > div { background: var(--ok); }
|
||||
.progress.failed > div { background: var(--err); }
|
||||
|
||||
.steps { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; }
|
||||
.step {
|
||||
display: grid; grid-template-columns: 14px 1fr 130px 90px;
|
||||
align-items: center; gap: 8px; font-size: 11px;
|
||||
}
|
||||
.step .label { color: var(--text-dim); }
|
||||
.step.failed .label { color: #ff9b95; }
|
||||
|
||||
.log {
|
||||
font-family: var(--mono); font-size: 11px; line-height: 1.55;
|
||||
background: var(--bg-sunken); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 8px 10px;
|
||||
max-height: 340px; overflow: auto; white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.log .l-warn { color: var(--warn); }
|
||||
.log .l-error { color: var(--err); }
|
||||
.log .l-cmd { color: #7ee0b8; }
|
||||
.log .l-info { color: var(--text-dim); }
|
||||
.log .ts { color: var(--text-faint); }
|
||||
|
||||
/* ---------- modal ---------- */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(3, 6, 10, 0.72);
|
||||
display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 50;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-strong);
|
||||
border-radius: 10px; width: min(760px, 100%); max-height: 100%;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.modal header {
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 10px; font-weight: 600;
|
||||
}
|
||||
.modal .content { padding: 16px; overflow: auto; }
|
||||
.modal footer {
|
||||
padding: 12px 16px; border-top: 1px solid var(--border);
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cmdblock {
|
||||
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 8px 10px; overflow-x: auto; white-space: pre; margin: 0;
|
||||
}
|
||||
|
||||
.empty { padding: 48px 16px; text-align: center; color: var(--text-faint); }
|
||||
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; font-size: 12px; }
|
||||
.kv dt { color: var(--text-faint); }
|
||||
.kv dd { margin: 0; font-family: var(--mono); font-size: 11px; }
|
||||
|
||||
.fingerprint {
|
||||
font-family: var(--mono); font-size: 12px; word-break: break-all;
|
||||
background: var(--bg-sunken); border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius); padding: 8px 10px;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// Mirrors the Go types in internal/spec, internal/dkr and internal/job.
|
||||
|
||||
export type MountKind = 'volume' | 'anonymous' | 'bind' | 'tmpfs' | string
|
||||
|
||||
export interface Mount {
|
||||
kind: MountKind
|
||||
name?: string
|
||||
source?: string
|
||||
destination: string
|
||||
readOnly: boolean
|
||||
propagation?: string
|
||||
tmpfsOpts?: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface Endpoint {
|
||||
network: string
|
||||
aliases?: string[]
|
||||
ipv4Address?: string
|
||||
ipv6Address?: string
|
||||
macAddress?: string
|
||||
}
|
||||
|
||||
export interface PortBinding {
|
||||
containerPort: string
|
||||
hostIp?: string
|
||||
hostPort?: string
|
||||
}
|
||||
|
||||
export interface Container {
|
||||
id: string
|
||||
name: string
|
||||
state: string
|
||||
image: string
|
||||
imageId: string
|
||||
imageDigest?: string
|
||||
composeProject?: string
|
||||
composeService?: string
|
||||
env?: string[]
|
||||
labels?: Record<string, string>
|
||||
cmd?: string[]
|
||||
entrypoint?: string[]
|
||||
restartPolicy?: string
|
||||
privileged?: boolean
|
||||
networkMode?: string
|
||||
endpoints?: Endpoint[]
|
||||
ports?: PortBinding[]
|
||||
mounts?: Mount[]
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface Volume {
|
||||
name: string
|
||||
driver: string
|
||||
driverOpts?: Record<string, string>
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface NetworkSpec {
|
||||
name: string
|
||||
driver: string
|
||||
internal?: boolean
|
||||
attachable?: boolean
|
||||
}
|
||||
|
||||
export interface Inventory {
|
||||
host: string
|
||||
dockerVersion: string
|
||||
containers: Container[]
|
||||
volumes: Volume[]
|
||||
networks: NetworkSpec[]
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export type ImageMode = 'auto' | 'pull' | 'stream' | 'skip'
|
||||
export type MountAction = 'copy' | 'structure' | 'skip'
|
||||
export type ConflictPolicy = 'fail' | 'skip' | 'replace' | 'rename'
|
||||
|
||||
export interface MountSelection {
|
||||
action: MountAction
|
||||
targetSource?: string
|
||||
targetName?: string
|
||||
}
|
||||
|
||||
export interface ItemSelection {
|
||||
containerId: string
|
||||
include: boolean
|
||||
nameOverride?: string
|
||||
migrateImage: boolean
|
||||
imageMode: ImageMode
|
||||
migrateNetworks: boolean
|
||||
keepStaticIps: boolean
|
||||
migratePorts: boolean
|
||||
mounts: Record<string, MountSelection>
|
||||
startAfter: boolean
|
||||
stopSourceDuringCopy: boolean
|
||||
stopSourceAfter: boolean
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
conflict: ConflictPolicy
|
||||
renameSuffix?: string
|
||||
compress: boolean
|
||||
compressLevel: number
|
||||
dryRun: boolean
|
||||
parallelism: number
|
||||
verifyAfter: boolean
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
items: ItemSelection[]
|
||||
options: Options
|
||||
target?: string
|
||||
packageName?: string
|
||||
}
|
||||
|
||||
export interface SourceResponse {
|
||||
inventory: Inventory
|
||||
defaults: Record<string, ItemSelection>
|
||||
options: Options
|
||||
}
|
||||
|
||||
export type AuthMethod = 'password' | 'key' | 'agent'
|
||||
|
||||
export interface Connection {
|
||||
id: string
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
auth: AuthMethod
|
||||
password?: string
|
||||
privateKey?: string
|
||||
privateKeyPath?: string
|
||||
passphrase?: string
|
||||
sudo: boolean
|
||||
dockerCmd?: string
|
||||
saveSecrets: boolean
|
||||
}
|
||||
|
||||
export interface Preflight {
|
||||
dockerVersion: string
|
||||
serverVersion: string
|
||||
os: string
|
||||
arch: string
|
||||
hasGzip: boolean
|
||||
diskFreeBytes: number
|
||||
dockerRoot: string
|
||||
problems?: string[]
|
||||
}
|
||||
|
||||
export interface TargetContainer {
|
||||
id: string
|
||||
name: string
|
||||
image: string
|
||||
state: string
|
||||
status: string
|
||||
ports: string
|
||||
}
|
||||
|
||||
export interface TargetInventory {
|
||||
host: string
|
||||
containers: TargetContainer[] | null
|
||||
volumes: string[] | null
|
||||
networks: string[] | null
|
||||
preflight: Preflight
|
||||
}
|
||||
|
||||
export interface HostKeyInfo {
|
||||
host: string
|
||||
keyType: string
|
||||
fingerprint: string
|
||||
trusted: boolean
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
export type JobState = 'pending' | 'running' | 'succeeded' | 'failed' | 'skipped' | 'canceled'
|
||||
|
||||
export interface Step {
|
||||
id: string
|
||||
label: string
|
||||
state: JobState
|
||||
bytesDone: number
|
||||
bytesTotal: number
|
||||
error?: string
|
||||
startedAt?: string
|
||||
endedAt?: string
|
||||
}
|
||||
|
||||
export interface JobItem {
|
||||
id: string
|
||||
name: string
|
||||
state: JobState
|
||||
error?: string
|
||||
steps: Step[]
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
seq: number
|
||||
at: string
|
||||
level: 'info' | 'warn' | 'error' | 'cmd'
|
||||
item?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface JobSnapshot {
|
||||
id: string
|
||||
kind: 'ssh' | 'package' | 'restore'
|
||||
title: string
|
||||
state: JobState
|
||||
dryRun: boolean
|
||||
error?: string
|
||||
createdAt: string
|
||||
startedAt?: string
|
||||
endedAt?: string
|
||||
items: JobItem[]
|
||||
log: LogEntry[]
|
||||
bytesDone: number
|
||||
bytesTotal: number
|
||||
artifact?: string
|
||||
artifactBytes?: number
|
||||
revision: number
|
||||
}
|
||||
|
||||
export interface PreviewItem {
|
||||
containerId: string
|
||||
name: string
|
||||
targetName: string
|
||||
image: string
|
||||
commands: string[] | null
|
||||
transfers: string[] | null
|
||||
notes: string[] | null
|
||||
warnings: string[] | null
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
export interface PreviewResponse {
|
||||
networkCommands: string[] | null
|
||||
items: PreviewItem[]
|
||||
}
|
||||
|
||||
export interface PackageInfo {
|
||||
name: string
|
||||
path: string
|
||||
bytes: number
|
||||
isDir: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Health {
|
||||
ok: boolean
|
||||
dockerHost: string
|
||||
dockerVersion?: string
|
||||
dockerError?: string
|
||||
packageDir: string
|
||||
dataDir: string
|
||||
knownHosts: string
|
||||
authRequired: boolean
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
|
||||
/** Bytes renders a byte count, or a dash when the size is unknown. */
|
||||
export function humanBytes(n: number | undefined | null): string {
|
||||
if (n === undefined || n === null || n < 0) return '–'
|
||||
if (n === 0) return '0 B'
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${i === 0 ? v : v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
export function duration(from?: string, to?: string): string {
|
||||
if (!from) return ''
|
||||
const start = new Date(from).getTime()
|
||||
const end = to ? new Date(to).getTime() : Date.now()
|
||||
const s = Math.max(0, Math.round((end - start) / 1000))
|
||||
if (s < 60) return `${s}s`
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m ${s % 60}s`
|
||||
return `${Math.floor(m / 60)}h ${m % 60}m`
|
||||
}
|
||||
|
||||
export function StateDot({ state, label }: { state: string; label?: string }) {
|
||||
return (
|
||||
<span className={`state ${state}`}>
|
||||
<span className="dot" />
|
||||
{label ?? state}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Check({
|
||||
checked, onChange, label, disabled, title,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
label: ReactNode
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
}) {
|
||||
return (
|
||||
<label className={`check${disabled ? ' disabled' : ''}`} title={title}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
title, onClose, children, footer, wide,
|
||||
}: {
|
||||
title: ReactNode
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
wide?: boolean
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={wide ? { width: 'min(1000px, 100%)' } : undefined}>
|
||||
<header>
|
||||
{title}
|
||||
<span className="spacer" />
|
||||
<button className="btn ghost tiny" onClick={onClose}>close</button>
|
||||
</header>
|
||||
<div className="content">{children}</div>
|
||||
{footer && <footer>{footer}</footer>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Notice({ kind, children }: { kind: 'warn' | 'err' | 'ok' | 'info'; children: ReactNode }) {
|
||||
return <div className={`notice ${kind === 'info' ? '' : kind}`}>{children}</div>
|
||||
}
|
||||
|
||||
export function Progress({ done, total, state }: { done: number; total: number; state: string }) {
|
||||
const pct = total > 0 ? Math.min(100, (done / total) * 100) : state === 'succeeded' ? 100 : 0
|
||||
const cls = state === 'succeeded' ? 'done' : state === 'failed' ? 'failed' : ''
|
||||
return (
|
||||
<div className={`progress ${cls}`}>
|
||||
<div style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// The build output goes straight into the Go package that embeds it, so
|
||||
// `go build` always picks up the latest UI.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: '../internal/webui/dist',
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 900,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user