Files
imap-copier/web/src/pages/TaskDetail.tsx
T
vasyansk 477077d9e3 fix: consistent error reporting + reset mapping modal per account
Error consistency: folder-level failures were counted only toward the task's
done_with_errors status, not the account's error_count, so a task showed
DONE_WITH_ERRORS while its only account showed 0 errors / DONE. Now folder
errors increment the account counter and the account status becomes
done_with_errors when errs>0.

Visibility: persist accounts.last_error (migration 0002) so the failing folder
/ login error survives a page reload (shown red under the source login);
cleared at the start of each run.

Modal reset: the folder-mapping modal kept its selections across opens, so
adding a second account showed the first account's mapping. It now mounts
fresh per add (conditional render + key), reflecting the newly-probed folders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd
2026-07-02 15:14:11 +07:00

561 lines
21 KiB
TypeScript

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<string, unknown>
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<TaskDetailData | null>(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<string | null>(null)
const [live, setLive] = useState<Record<number, LiveProgress>>({})
const fileInputRef = useRef<HTMLInputElement>(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<string, number | string | undefined>
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<string, string>) {
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<HTMLInputElement>) {
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 (
<div className="panel">
<p>Task #{id} not found.</p>
<a className="crumb" href="#/">
back to tasks
</a>
</div>
)
}
if (!data) {
return <div className="muted-note">loading task #{id}</div>
}
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 (
<>
<div className="page-head">
<div>
<a className="crumb" href="#/">
all tasks
</a>
<h1 className="page-title" style={{ marginTop: 6 }}>
{task.name} <span className="idx">/// task #{task.id}</span>
</h1>
</div>
<StatusBadge status={task.status} />
</div>
<div className="panel">
<span className="panel-label">Run control</span>
<div className="stat-row">
<div className="stat ok">
<span className="val mono-num">{totals.copied}</span>
<span className="lbl">copied</span>
</div>
<div className="stat info">
<span className="val mono-num">{totals.skipped}</span>
<span className="lbl">skipped</span>
</div>
<div className="stat fail">
<span className="val mono-num">{totals.errors}</span>
<span className="lbl">errors</span>
</div>
<div className="stat">
<span className="val mono-num">{accounts.length}</span>
<span className="lbl">accounts</span>
</div>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="btn-row" style={{ marginTop: 16 }}>
<button className="btn" onClick={onTest} disabled={busy !== null || accounts.length === 0}>
{busy === 'test' ? 'Testing…' : 'Test connections'}
</button>
<button className="btn btn-primary" onClick={onRun} disabled={busy !== null || !allTested || task.status === 'running'}>
{busy === 'run' ? 'Starting…' : 'Run migration'}
</button>
{!allTested && accounts.length > 0 && <span className="hint">run unlocks once every account tests OK on both sides</span>}
</div>
</div>
<div className="panel-grid">
<div className="panel">
<span className="panel-label">Add account</span>
<form onSubmit={submitAccount}>
<div className="field-row">
<div className="field">
<label htmlFor="src_login">Source login</label>
<input
id="src_login"
value={form.src_login}
onChange={(e) => setForm({ ...form, src_login: e.target.value })}
required
/>
</div>
<div className="field">
<label htmlFor="src_pass">Source password</label>
<input
id="src_pass"
type="password"
value={form.src_pass}
onChange={(e) => setForm({ ...form, src_pass: e.target.value })}
required
/>
</div>
</div>
<div className="field-row">
<div className="field">
<label htmlFor="dst_login">Destination login</label>
<input
id="dst_login"
value={form.dst_login}
onChange={(e) => setForm({ ...form, dst_login: e.target.value })}
required
/>
</div>
<div className="field">
<label htmlFor="dst_pass">Destination password</label>
<input
id="dst_pass"
type="password"
value={form.dst_pass}
onChange={(e) => setForm({ ...form, dst_pass: e.target.value })}
required
/>
</div>
</div>
<div className="btn-row">
<button className="btn btn-primary" disabled={busy !== null}>
{busy === 'probe' ? 'Testing…' : busy === 'add' ? 'Adding…' : 'Add account'}
</button>
</div>
</form>
<div className="divider-label">or bulk import</div>
<div className="upload-row">
<button className="btn file-btn" disabled={busy !== null}>
{busy === 'import' ? 'Importing…' : 'Upload CSV'}
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} />
</button>
<button type="button" className="link-btn" onClick={downloadExampleCSV}>
download example.csv
</button>
</div>
</div>
<div className="panel">
<span className="panel-label">Event log</span>
{log.length > 0 && (
<button type="button" className="link-btn log-clear" onClick={() => setLog([])}>
clear log
</button>
)}
<div className="log-pane">
{log.length === 0 ? (
<div className="log-empty">awaiting events over websocket</div>
) : (
log.map((l, i) => (
<div className="log-line" key={i}>
<span className="tag">{l.type}</span>
<span className="payload">{l.text}</span>
</div>
))
)}
</div>
</div>
</div>
<div className="panel">
<span className="panel-label">Accounts ({accounts.length})</span>
<div className="tbl-wrap">
<table className="tbl">
<thead>
<tr>
<th>Source</th>
<th>Destination</th>
<th>Src test</th>
<th>Dst test</th>
<th>Status</th>
<th>Progress</th>
<th>Copied</th>
<th>Skipped</th>
<th>Errors</th>
<th></th>
</tr>
</thead>
<tbody>
{accounts.length === 0 ? (
<tr className="empty-row">
<td colSpan={10}>no accounts yet add one or import a CSV above</td>
</tr>
) : (
accounts.map((a) => (
<tr key={a.id}>
<td>
{a.src_login}
{a.last_error && (
<div className="acct-error" title={a.last_error}>
{a.last_error}
</div>
)}
</td>
<td>{a.dst_login}</td>
<td>
<StatusBadge status={a.test_src_status} />
</td>
<td>
<StatusBadge status={a.test_dst_status} />
</td>
<td>
<StatusBadge status={a.status} />
</td>
<td className="progress-cell">
{(() => {
const lv = live[a.id]
if (!lv || !lv.total) return <span className="muted-note"></span>
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 (
<div className="acct-progress">
<div className="pbar">
<span className="pbar-fill" style={{ width: `${pct}%` }} />
</div>
<span className="pmeta mono-num">
{done}/{lv.total} ({pct}%) · {lv.speed >= 1 ? Math.round(lv.speed) : lv.speed.toFixed(1)}/s · ETA {fmtDuration(eta)}
{lv.folder ? ` · ${lv.folder}` : ''}
</span>
{scanning && (
<span className="pmeta pscan mono-num">
scanning {lv.scanFolder}: {lv.scanned}/{lv.scanTotal}
</span>
)}
</div>
)
})()}
</td>
<td className="num-cell">{live[a.id]?.copied ?? a.copied}</td>
<td className="num-cell">{live[a.id]?.skipped ?? a.skipped}</td>
<td className="num-cell">{a.errors}</td>
<td className="num-cell">
{a.status === 'running' ? (
<button type="button" className="link-btn danger" onClick={() => onCancelAccount(a.id)}>
cancel
</button>
) : (
<button
type="button"
className="link-btn danger"
onClick={() => onDeleteAccount(a.id, a.src_login)}
disabled={busy !== null || data?.task.status === 'running'}
>
remove
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{mapState && (
<FolderMappingModal
key={`${mapState.creds.src_login}|${mapState.creds.dst_login}`}
open
srcFolders={mapState.src}
dstFolders={mapState.dst}
initialMapping={task.folder_mapping ?? {}}
onCancel={() => setMapState(null)}
onConfirm={confirmMapping}
/>
)}
</>
)
}