Files
DockMV/web/src/Sidebar.tsx
T
kawaandClaude Haiku 4.5 9b354636bb Add SSH source support with dialstdio and UI components
- Implement SSH dial via stdio for remote connections
- Add sources API and storage layer for managing connection sources
- Add SourcePanel and SshFields web components for SSH configuration
- Update app structure to support source-based connections
- Update handlers and server for new sources endpoint

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-13 10:19:47 +02:00

401 lines
14 KiB
TypeScript

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<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 && (
<HostKeyDialog info={hostKey} onClose={() => setHostKey(null)} onTrust={trustHostKey} />
)}
{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 }}>
<Field label="label"><input type="text" value={c.name ?? ''} onChange={(e) => set('name', e.target.value)} /></Field>
<SshFields value={c} set={set} where="target" />
</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>
)
}