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' import { HostKeyDialog, SshFields } from './SshFields' 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> connections: Connection[] activeConn: string setActiveConn: (id: string) => void reloadConnections: () => void targetInv: TargetInventory | null connectTarget: (id: string) => Promise onJobStarted: (j: JobSnapshot) => void onError: (msg: string) => void }) { const [editing, setEditing] = useState | null>(null) const [hostKey, setHostKey] = useState(null) const [preview, setPreview] = useState(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) { 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 ( <>

target host

{conn && (
)} {conn && !targetInv &&
not connected yet
} {pre && ( <> {(pre.problems ?? []).map((p, i) => ( {p} ))} {targetReady && (
host
{targetInv?.host || conn?.host}
docker
{pre.serverVersion} · {pre.os}/{pre.arch}
free space
{humanBytes(pre.diskFreeBytes)} on {pre.dockerRoot}
existing
{(targetInv?.containers ?? []).length} containers ·{' '} {(targetInv?.volumes ?? []).length} volumes
gzip
{pre.hasGzip ? 'yes' : 'missing'}
)} )}

options

{options.conflict === 'rename' && ( setOptions((o) => ({ ...o, renameSuffix: e.target.value }))} /> )} {options.conflict === 'replace' && ( Existing containers and volumes with the same name are deleted on the target before the copy. )} setOptions((o) => ({ ...o, compress: v }))} label="compress transfers (gzip)" /> setOptions((o) => ({ ...o, verifyAfter: v }))} label="verify each container after migrating" /> setOptions((o) => ({ ...o, dryRun: v }))} label="dry run — show every command, change nothing" /> setOptions((o) => ({ ...o, parallelism: Number(e.target.value) }))} style={{ width: '100%' }} />

migrate over ssh

{includedCount === 0 &&
select at least one container
} {includedCount > 0 && !targetReady &&
connect to a target first
}

migration package

Builds a self-contained folder with the data, the images and an install.sh to run on the target. No network between the hosts required.
setPackageName(e.target.value)} />
{source?.inventory.warnings?.length ? (

source warnings

{source.inventory.warnings.map((w, i) => ( {w} ))}
) : null} {editing && ( setEditing(null)} onSaved={(c) => { setEditing(null) reloadConnections() setActiveConn(c.id) }} onError={onError} /> )} {hostKey && ( setHostKey(null)} onTrust={trustHostKey} /> )} {preview && setPreview(null)} />} ) } function ConnectionDialog({ initial, onClose, onSaved, onError, }: { initial: Partial onClose: () => void onSaved: (c: Connection) => void onError: (m: string) => void }) { const [c, setC] = useState>(initial) const [saving, setSaving] = useState(false) function set(k: K, v: Connection[K]) { setC((prev) => ({ ...prev, [k]: v })) } return ( } >
set('name', e.target.value)} />
) } function PreviewModal({ data, onClose }: { data: PreviewResponse; onClose: () => void }) { const total = data.items.reduce((a, i) => a + i.totalBytes, 0) return ( close} >
{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 docker cp rather than written to a file.
{(data.networkCommands ?? []).length > 0 && (

shared networks

{(data.networkCommands ?? []).join('\n')}
)} {data.items.map((it) => (

{it.name} {it.targetName !== it.name && → {it.targetName}}

{(it.warnings ?? []).map((w, i) => {w})} {(it.notes ?? []).map((w, i) =>
· {w}
)}
{(it.commands ?? []).join('\n')}
))}
) }