Initial push
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user