import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react' import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, probeFolders, runTask, setFolderMapping, testAccounts, type TaskDetail as TaskDetailData } from '../api' import { connectTaskWS, type TaskEvent } from '../ws' import { StatusBadge } from '../components/StatusBadge' import { useConfirm } from '../components/ConfirmProvider' import { FolderMappingModal } from '../components/FolderMappingModal' const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' } // Live per-account progress derived from throttled `progress` WS events. type LiveProgress = { copied: number skipped: number total: number // account-wide message total from the planning pass (0 if unknown) folder?: string startTs: number startCount: number speed: number // messages/sec, averaged since the account's run started scanFolder?: string scanned?: number scanTotal?: number } function fmtDuration(sec: number): string { if (!isFinite(sec) || sec < 0) return '—' const m = Math.floor(sec / 60) const s = Math.floor(sec % 60) return `${m}:${String(s).padStart(2, '0')}` } // Human-readable one-line description of a task event for the log panel. function describeEvent(ev: TaskEvent): string { const d = (ev.data ?? {}) as Record const at = d.host ? `${d.login ?? ''}@${d.host}:${d.port}` : '' switch (ev.type) { case 'account_test': { const where = d.side === 'src' ? 'SOURCE' : 'DEST' const base = `${where} test ${String(d.status).toUpperCase()} — ${at}` return d.error ? `${base} — ${d.error}` : base } case 'account_started': return `START #${d.account_id}: ${d.src_login}@${d.src_host}:${d.src_port} → ${d.dst_login}@${d.dst_host}:${d.dst_port}` case 'plan': return `PLAN #${d.account_id} (${d.src_login}): ${d.folders} folders, ${d.total} messages total` case 'account_done': return `DONE #${d.account_id} (${d.src_login} → ${d.dst_login}): copied ${d.copied}, skipped ${d.skipped}, errors ${d.errors}` case 'progress': { const pct = d.folder_total ? Math.floor((Number(d.folder_done) / Number(d.folder_total)) * 100) : 0 const loc = d.folder ? `"${d.folder}" ${d.folder_done}/${d.folder_total} (${pct}%) · ` : '' return `progress #${d.account_id}: ${loc}copied ${d.copied}, skipped ${d.skipped}` } case 'folder': { const route = d.dst_folder && d.dst_folder !== d.folder ? ` → "${d.dst_folder}"` : '' return `folder "${d.folder}"${route}: ${d.messages ?? 0} messages — fetching (#${d.account_id})` } case 'cancelled': return `CANCELLED #${d.account_id} (${d.src_login}): copied ${d.copied ?? 0}, skipped ${d.skipped ?? 0}` case 'error': { const where = d.folder ? ` folder "${d.folder}"` : d.side ? ` (${d.side} ${at})` : '' return `ERROR #${d.account_id}${where}: ${d.error}` } case 'run_started': return `RUN started (run #${d.run_id})` case 'run_done': return `RUN finished: copied ${d.copied}, skipped ${d.skipped}, errors ${d.errors}` default: return JSON.stringify(ev.data) } } export function TaskDetail({ id }: { id: number }) { const [data, setData] = useState(null) const [notFound, setNotFound] = useState(false) const [log, setLog] = useState<{ type: string; text: string }[]>([]) const [form, setForm] = useState(emptyAccount) const [busy, setBusy] = useState<'test' | 'run' | 'add' | 'import' | 'delete' | 'probe' | null>(null) const [mapState, setMapState] = useState<{ src: string[]; dst: string[]; creds: typeof emptyAccount } | null>(null) const confirm = useConfirm() const [error, setError] = useState(null) const [live, setLive] = useState>({}) const fileInputRef = useRef(null) function reload() { getTask(id) .then((d) => { setData(d) setNotFound(false) }) .catch(() => setNotFound(true)) } useEffect(reload, [id]) useEffect( () => connectTaskWS(id, (ev: TaskEvent) => { // `scan` is high-frequency and shown in the progress cell, not the log. if (ev.type !== 'scan') { setLog((l) => [{ type: ev.type, text: describeEvent(ev) }, ...l].slice(0, 300)) } const d = (ev.data ?? {}) as Record const accId = typeof d.account_id === 'number' ? d.account_id : undefined if (ev.type === 'scan' && accId != null) { setLive((prev) => { const cur = prev[accId] const base: LiveProgress = cur ?? { copied: 0, skipped: 0, total: 0, startTs: Date.now(), startCount: 0, speed: 0 } return { ...prev, [accId]: { ...base, scanFolder: d.folder as string | undefined, scanned: Number(d.scanned ?? 0), scanTotal: Number(d.folder_total ?? 0) } } }) } else if (ev.type === 'plan' && accId != null) { const total = Number(d.total ?? 0) const now = Date.now() setLive((prev) => ({ ...prev, [accId]: { copied: prev[accId]?.copied ?? 0, skipped: prev[accId]?.skipped ?? 0, total, folder: prev[accId]?.folder, startTs: prev[accId]?.startTs ?? now, startCount: prev[accId]?.startCount ?? 0, speed: prev[accId]?.speed ?? 0, }, })) } else if (ev.type === 'progress' && accId != null) { const now = Date.now() const copied = Number(d.copied ?? 0) const skipped = Number(d.skipped ?? 0) const processed = copied + skipped setLive((prev) => { const cur = prev[accId] const startTs = cur?.startTs ?? now const startCount = cur?.startCount ?? processed const dt = (now - startTs) / 1000 const speed = dt > 0.5 ? (processed - startCount) / dt : (cur?.speed ?? 0) return { ...prev, [accId]: { copied, skipped, total: Number(d.account_total ?? cur?.total ?? 0), folder: d.folder as string | undefined, startTs, startCount, speed, }, } }) } else if (accId != null && (ev.type === 'account_started' || ev.type === 'account_done' || ev.type === 'cancelled' || (ev.type === 'error' && d.folder == null))) { // terminal/reset for this account — drop live overlay, fall back to DB setLive((prev) => { if (!(accId in prev)) return prev const next = { ...prev } delete next[accId] return next }) } // Structural events refresh the persisted view; `progress` is covered by live state. if (['account_started', 'account_test', 'account_done', 'run_started', 'run_done', 'error', 'folder', 'cancelled', 'plan'].includes(ev.type)) { reload() } }), [id], ) async function submitAccount(e: FormEvent) { e.preventDefault() setBusy('probe') setError(null) try { const creds = { ...form } const res = await probeFolders(id, creds) if (!res.src.ok || !res.dst.ok) { const parts: string[] = [] if (!res.src.ok) parts.push(`source: ${res.src.error ?? 'login failed'}`) if (!res.dst.ok) parts.push(`destination: ${res.dst.error ?? 'login failed'}`) setError(`Connection test failed — ${parts.join('; ')}`) return } setMapState({ src: res.src.folders ?? [], dst: res.dst.folders ?? [], creds }) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to test connections') } finally { setBusy(null) } } async function confirmMapping(mapping: Record) { if (!mapState) return setBusy('add') setError(null) try { await createAccount(id, mapState.creds) await setFolderMapping(id, mapping) setForm(emptyAccount) setMapState(null) reload() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to add account') } finally { setBusy(null) } } function downloadExampleCSV() { const sample = [ 'alice@source.example,SrcPass1,alice@dest.example,DstPass1', 'bob@source.example,SrcPass2,bob@dest.example,DstPass2', 'carol@source.example,SrcPass3,carol@dest.example,DstPass3', ].join('\n') + '\n' const url = URL.createObjectURL(new Blob([sample], { type: 'text/csv' })) const a = document.createElement('a') a.href = url a.download = 'imap-copier-accounts-example.csv' a.click() URL.revokeObjectURL(url) } async function onFileChosen(e: ChangeEvent) { const file = e.target.files?.[0] if (!file) return setBusy('import') setError(null) try { await importCSV(id, file) reload() } catch (err) { setError(err instanceof Error ? err.message : 'CSV import failed') } finally { setBusy(null) if (fileInputRef.current) fileInputRef.current.value = '' } } async function onDeleteAccount(accId: number, login: string) { const ok = await confirm({ title: 'Remove account', message: `Remove account "${login}" from this task?`, confirmLabel: 'Remove', danger: true, }) if (!ok) return setBusy('delete') setError(null) try { await deleteAccount(id, accId) reload() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to remove account') } finally { setBusy(null) } } async function onCancelAccount(accId: number) { setError(null) try { await cancelAccount(id, accId) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to cancel account') } } async function onTest() { setBusy('test') setError(null) try { await testAccounts(id) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to start connection tests') } finally { setBusy(null) } } async function onRun() { setBusy('run') setError(null) try { await runTask(id) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to start run') } finally { setBusy(null) } } if (notFound) { return (

Task #{id} not found.

← back to tasks
) } if (!data) { return
loading task #{id}…
} const { task, accounts } = data const allTested = accounts.length > 0 && accounts.every((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok') // Prefer live (WS) copied/skipped over the DB values, which only advance per // folder — so the summary moves in real time during a large folder. const totals = accounts.reduce( (acc, a) => ({ copied: acc.copied + (live[a.id]?.copied ?? a.copied), skipped: acc.skipped + (live[a.id]?.skipped ?? a.skipped), errors: acc.errors + a.errors, }), { copied: 0, skipped: 0, errors: 0 }, ) return ( <>
← all tasks

{task.name} /// task #{task.id}

Run control
{totals.copied} copied
{totals.skipped} skipped
{totals.errors} errors
{accounts.length} accounts
{error &&
{error}
}
{!allTested && accounts.length > 0 && run unlocks once every account tests OK on both sides}
Add account
setForm({ ...form, src_login: e.target.value })} required />
setForm({ ...form, src_pass: e.target.value })} required />
setForm({ ...form, dst_login: e.target.value })} required />
setForm({ ...form, dst_pass: e.target.value })} required />
or bulk import
Event log {log.length > 0 && ( )}
{log.length === 0 ? (
awaiting events over websocket…
) : ( log.map((l, i) => (
{l.type} {l.text}
)) )}
Accounts ({accounts.length})
{accounts.length === 0 ? ( ) : ( accounts.map((a) => ( )) )}
Source Destination Src test Dst test Status Progress Copied Skipped Errors
no accounts yet — add one or import a CSV above
{a.src_login} {a.last_error && (
{a.last_error}
)}
{a.dst_login} {(() => { const lv = live[a.id] if (!lv || !lv.total) return const done = lv.copied + lv.skipped const pct = Math.min(100, Math.floor((done / lv.total) * 100)) const eta = lv.speed > 0 ? (lv.total - done) / lv.speed : Infinity const scanning = lv.scanned != null && lv.scanTotal != null && lv.scanned < lv.scanTotal return (
{done}/{lv.total} ({pct}%) · {lv.speed >= 1 ? Math.round(lv.speed) : lv.speed.toFixed(1)}/s · ETA {fmtDuration(eta)} {lv.folder ? ` · ${lv.folder}` : ''} {scanning && ( scanning {lv.scanFolder}: {lv.scanned}/{lv.scanTotal} )}
) })()}
{live[a.id]?.copied ?? a.copied} {live[a.id]?.skipped ?? a.skipped} {a.errors} {a.status === 'running' ? ( ) : ( )}
{mapState && ( setMapState(null)} onConfirm={confirmMapping} /> )} ) }