Initial push

This commit is contained in:
2026-08-11 09:00:01 +02:00
commit fe8b354adc
54 changed files with 12640 additions and 0 deletions
+215
View File
@@ -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>
)
}
+447
View File
@@ -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>
)
}
+189
View File
@@ -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>
)
}
+57
View File
@@ -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>
)
}
+500
View File
@@ -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>
)
}
+93
View File
@@ -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)}` : '')),
}
+10
View File
@@ -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>,
)
+313
View File
@@ -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;
}
+260
View File
@@ -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
View File
@@ -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>
)
}