An imported account with a wrong password could only be deleted and re-added, which loses its folder mapping and its migration journal. Clicking either FAIL badge now opens a dialog for both logins and both passwords. Passwords are never sent to the browser, so the password fields start empty and an empty field keeps the stored ciphertext — one side can be corrected without retyping the other. Saving resets both test verdicts to unknown: they described the previous credentials, and the run gate requires a passing test on both sides, so the account cannot start on an unverified password. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
862 lines
33 KiB
TypeScript
862 lines
33 KiB
TypeScript
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
|
import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, importKerioCSV, probeAccountFolders, probeFolders, runTask, setAccountFolderMapping, setTaskSchedule, testAccounts, updateAccountCredentials, type Account, 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'
|
|
import { RunLogModal } from '../components/RunLogModal'
|
|
import { AccountErrorsModal } from '../components/AccountErrorsModal'
|
|
import { KerioImportModal } from '../components/KerioImportModal'
|
|
import { AccountCredentialsModal } from '../components/AccountCredentialsModal'
|
|
|
|
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 [editMap, setEditMap] = useState<{
|
|
accId: number
|
|
label: string
|
|
src: string[]
|
|
dst: string[]
|
|
mapping: Record<string, string>
|
|
excluded: string[]
|
|
} | null>(null)
|
|
const confirm = useConfirm()
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [live, setLive] = useState<Record<number, LiveProgress>>({})
|
|
const [showRuns, setShowRuns] = useState(false)
|
|
const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null)
|
|
const [kerioOpen, setKerioOpen] = useState(false)
|
|
const [credsFor, setCredsFor] = useState<Account | null>(null)
|
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
|
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', 'task_broken'].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>, excluded: string[]) {
|
|
if (!mapState) return
|
|
setBusy('add')
|
|
setError(null)
|
|
try {
|
|
const { id: accId } = await createAccount(id, mapState.creds)
|
|
await setAccountFolderMapping(id, accId, mapping, excluded)
|
|
setForm(emptyAccount)
|
|
setMapState(null)
|
|
reload()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to add account')
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
async function onEditFolders(a: { id: number; src_login: string; dst_login: string; folder_mapping?: Record<string, string>; excluded_folders?: string[] }) {
|
|
setBusy('probe')
|
|
setError(null)
|
|
try {
|
|
const res = await probeAccountFolders(id, a.id)
|
|
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(`Folder probe failed — ${parts.join('; ')}`)
|
|
return
|
|
}
|
|
setEditMap({
|
|
accId: a.id,
|
|
label: a.dst_login && a.dst_login !== a.src_login ? `${a.src_login} → ${a.dst_login}` : a.src_login,
|
|
src: res.src.folders ?? [],
|
|
dst: res.dst.folders ?? [],
|
|
mapping: a.folder_mapping ?? {},
|
|
excluded: a.excluded_folders ?? [],
|
|
})
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to probe folders')
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
async function saveEditMapping(mapping: Record<string, string>, excluded: string[]) {
|
|
if (!editMap) return
|
|
setBusy('add')
|
|
setError(null)
|
|
try {
|
|
await setAccountFolderMapping(id, editMap.accId, mapping, excluded)
|
|
setEditMap(null)
|
|
reload()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to save mapping')
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
function downloadCSV(name: string, content: string) {
|
|
const url = URL.createObjectURL(new Blob([content], { type: 'text/csv' }))
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = name
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
// Plain import: comma-separated, no header, src_login,src_pass,dst_login,dst_pass.
|
|
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'
|
|
downloadCSV('imap-copier-accounts-example.csv', sample)
|
|
}
|
|
|
|
// Kerio Connect export: semicolon-separated with a Name;FullName;Description;Enable
|
|
// header. The password lives in Description; disabled rows and admin are skipped
|
|
// on import, and the domain is supplied in the import dialog.
|
|
function downloadKerioExampleCSV() {
|
|
const sample =
|
|
[
|
|
'Name;FullName;Description;Enable',
|
|
'alice;Alice Smith;SrcPass1;Yes',
|
|
'bob;Bob Jones;SrcPass2;Yes',
|
|
'carol;Carol White (disabled, skipped);SrcPass3;No',
|
|
].join('\n') + '\n'
|
|
downloadCSV('kerio-users-example.csv', sample)
|
|
}
|
|
|
|
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 onKerioImport(file: File, domain: string) {
|
|
setBusy('import')
|
|
setError(null)
|
|
try {
|
|
await importKerioCSV(id, file, domain)
|
|
setKerioOpen(false)
|
|
reload()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Kerio import failed')
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
async function saveCredentials(body: { src_login: string; src_pass: string; dst_login: string; dst_pass: string }) {
|
|
if (!credsFor) return
|
|
setBusy('add')
|
|
setError(null)
|
|
try {
|
|
await updateAccountCredentials(id, credsFor.id, body)
|
|
setCredsFor(null)
|
|
reload()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to save credentials')
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
const ids = accounts.filter((a) => selected.has(a.id)).map((a) => a.id)
|
|
await runTask(id, ids.length ? ids : undefined)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to start run')
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
async function onSchedule(intervalSeconds: number) {
|
|
setError(null)
|
|
try {
|
|
await setTaskSchedule(id, intervalSeconds)
|
|
reload()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to set schedule')
|
|
}
|
|
}
|
|
|
|
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 isRunning = task.status === 'running'
|
|
|
|
// A failed connection test is the entry point for fixing the credentials that
|
|
// caused it — an imported account is otherwise only deletable.
|
|
const testCell = (a: Account, status: string) =>
|
|
status === 'fail' && !isRunning && a.status !== 'running' ? (
|
|
<button
|
|
type="button"
|
|
className="badge-btn"
|
|
title="Edit credentials for this account"
|
|
onClick={() => setCredsFor(a)}
|
|
>
|
|
<StatusBadge status={status} />
|
|
</button>
|
|
) : (
|
|
<StatusBadge status={status} />
|
|
)
|
|
// A row is selectable only when both connection tests pass and no run is live.
|
|
const selectableIds = accounts
|
|
.filter((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok')
|
|
.map((a) => a.id)
|
|
const selectableSet = new Set(selectableIds)
|
|
// Effective set: the checked accounts, or all accounts when nothing is checked.
|
|
const effectiveSelected = accounts.filter((a) => selected.has(a.id))
|
|
const runSet = effectiveSelected.length > 0 ? effectiveSelected : accounts
|
|
const runReady =
|
|
runSet.length > 0 && runSet.every((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok')
|
|
const allSelectableChecked =
|
|
selectableIds.length > 0 && selectableIds.every((id) => selected.has(id))
|
|
const someSelectableChecked = selectableIds.some((id) => selected.has(id))
|
|
|
|
function toggleOne(accId: number, checked: boolean) {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
if (checked) next.add(accId)
|
|
else next.delete(accId)
|
|
return next
|
|
})
|
|
}
|
|
|
|
function toggleAll(checked: boolean) {
|
|
setSelected(checked ? new Set(selectableIds) : new Set())
|
|
}
|
|
// 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} />
|
|
{task.broken && <span className="badge badge-fail" style={{ marginLeft: 8 }}><span className="dot" />broken</span>}
|
|
</div>
|
|
|
|
<div className="panel">
|
|
<span className="panel-label">Run control</span>
|
|
<div className="stat-row" aria-live="polite">
|
|
<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" role="alert">{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 || !runReady || isRunning}>
|
|
{busy === 'run'
|
|
? 'Starting…'
|
|
: effectiveSelected.length > 0
|
|
? `Run selected (${effectiveSelected.length})`
|
|
: 'Run migration'}
|
|
</button>
|
|
{!runReady && accounts.length > 0 && (
|
|
<span className="hint">
|
|
{effectiveSelected.length > 0
|
|
? 'selected accounts must pass both connection tests'
|
|
: 'run unlocks once every account tests OK on both sides'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="sched-row">
|
|
<label htmlFor="sched">Schedule</label>
|
|
<select
|
|
id="sched"
|
|
value={task.schedule_interval_seconds ?? 0}
|
|
onChange={(e) => onSchedule(Number(e.target.value))}
|
|
>
|
|
<option value={0}>Off</option>
|
|
<option value={3600}>Every 1h</option>
|
|
<option value={10800}>Every 3h</option>
|
|
<option value={21600}>Every 6h</option>
|
|
<option value={43200}>Every 12h</option>
|
|
<option value={86400}>Every 24h</option>
|
|
</select>
|
|
{task.next_run_at && (
|
|
<span className="sched-next">Next run: {new Date(task.next_run_at).toLocaleString()}</span>
|
|
)}
|
|
{task.broken && <span className="badge badge-fail"><span className="dot" />broken</span>}
|
|
<button type="button" className="link-btn" onClick={() => setShowRuns(true)}>
|
|
runs
|
|
</button>
|
|
</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">
|
|
<div className="upload-item">
|
|
<label className={`btn file-btn${busy !== null ? ' is-disabled' : ''}`}>
|
|
{busy === 'import' ? 'Importing…' : 'Upload CSV'}
|
|
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} />
|
|
</label>
|
|
<button type="button" className="link-btn" onClick={downloadExampleCSV}>
|
|
download example.csv
|
|
</button>
|
|
</div>
|
|
<div className="upload-item">
|
|
<button type="button" className="btn" onClick={() => setKerioOpen(true)} disabled={busy !== null}>
|
|
Import from Kerio
|
|
</button>
|
|
<button type="button" className="link-btn" onClick={downloadKerioExampleCSV}>
|
|
download kerio example.csv
|
|
</button>
|
|
</div>
|
|
</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" role="log" aria-live="polite" aria-relevant="additions" aria-label="Event log">
|
|
{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 className="chk-col">
|
|
<input
|
|
type="checkbox"
|
|
aria-label="Select all accounts"
|
|
checked={allSelectableChecked}
|
|
ref={(el) => {
|
|
if (el) el.indeterminate = !allSelectableChecked && someSelectableChecked
|
|
}}
|
|
disabled={isRunning || selectableIds.length === 0}
|
|
onChange={(e) => toggleAll(e.target.checked)}
|
|
/>
|
|
</th>
|
|
<th>Account</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 className="chk-col">
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`Select ${a.src_login}`}
|
|
checked={selected.has(a.id)}
|
|
disabled={isRunning || !selectableSet.has(a.id)}
|
|
onChange={(e) => toggleOne(a.id, e.target.checked)}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<div className="acct-ident">
|
|
<span>{a.src_login}</span>
|
|
{a.dst_login !== a.src_login && (
|
|
<span className="acct-dst">→ {a.dst_login}</span>
|
|
)}
|
|
</div>
|
|
{a.last_error && (
|
|
<div className="acct-error" title={a.last_error}>
|
|
{a.last_error}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td>{testCell(a, a.test_src_status)}</td>
|
|
<td>{testCell(a, a.test_dst_status)}</td>
|
|
<td>
|
|
<StatusBadge status={a.status} />
|
|
</td>
|
|
<td className={`progress-cell${a.status === 'running' ? ' progress-cell--live' : ''}`}>
|
|
{(() => {
|
|
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"
|
|
role="progressbar"
|
|
aria-valuenow={pct}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-label={`Copy progress${lv.folder ? `: ${lv.folder}` : ''}`}
|
|
>
|
|
<span className="pbar-fill" style={{ transform: `scaleX(${pct / 100})` }} />
|
|
</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 > 0 ? (
|
|
<button
|
|
type="button"
|
|
className="link-btn danger"
|
|
onClick={() => setErrorsFor({ id: a.id, src_login: a.src_login })}
|
|
>
|
|
{a.errors}
|
|
</button>
|
|
) : (
|
|
a.errors
|
|
)}
|
|
</td>
|
|
<td className="num-cell">
|
|
<div className="row-actions">
|
|
{(() => {
|
|
const mapped =
|
|
Object.keys(a.folder_mapping ?? {}).length > 0 ||
|
|
(a.excluded_folders?.length ?? 0) > 0
|
|
return mapped ? (
|
|
<span
|
|
className="map-dot"
|
|
title="Folders already mapped for this account"
|
|
aria-label="Folders mapped"
|
|
/>
|
|
) : null
|
|
})()}
|
|
{a.status !== 'running' && data?.task.status !== 'running' && (
|
|
<button
|
|
type="button"
|
|
className="link-btn"
|
|
onClick={() => onEditFolders(a)}
|
|
disabled={busy !== null}
|
|
>
|
|
folders
|
|
</button>
|
|
)}
|
|
{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>
|
|
)}
|
|
</div>
|
|
</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 ?? {}}
|
|
initialExcluded={[]}
|
|
accountLabel={
|
|
mapState.creds.dst_login && mapState.creds.dst_login !== mapState.creds.src_login
|
|
? `${mapState.creds.src_login} → ${mapState.creds.dst_login}`
|
|
: mapState.creds.src_login
|
|
}
|
|
onCancel={() => setMapState(null)}
|
|
onConfirm={confirmMapping}
|
|
/>
|
|
)}
|
|
{editMap && (
|
|
<FolderMappingModal
|
|
key={`edit-${editMap.accId}`}
|
|
open
|
|
srcFolders={editMap.src}
|
|
dstFolders={editMap.dst}
|
|
initialMapping={editMap.mapping}
|
|
initialExcluded={editMap.excluded}
|
|
accountLabel={editMap.label}
|
|
onCancel={() => setEditMap(null)}
|
|
onConfirm={saveEditMapping}
|
|
/>
|
|
)}
|
|
<RunLogModal taskId={id} open={showRuns} onClose={() => setShowRuns(false)} />
|
|
<AccountErrorsModal taskId={id} account={errorsFor} onClose={() => setErrorsFor(null)} />
|
|
<KerioImportModal
|
|
open={kerioOpen}
|
|
busy={busy === 'import'}
|
|
onClose={() => setKerioOpen(false)}
|
|
onSubmit={onKerioImport}
|
|
/>
|
|
<AccountCredentialsModal
|
|
open={credsFor !== null}
|
|
busy={busy === 'add'}
|
|
account={credsFor}
|
|
onClose={() => setCredsFor(null)}
|
|
onSubmit={saveCredentials}
|
|
/>
|
|
</>
|
|
)
|
|
}
|