feat(web): React SPA with realtime task detail over WebSocket

Vite + React 19 + TS console-style operator UI: hash-routed Login,
Endpoints, Tasks, and TaskDetail (realtime accounts table over /ws,
Run gated on all accounts testing ok on both sides).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd
This commit is contained in:
2026-07-01 19:01:05 +07:00
parent 4c57848c35
commit 1a451f9dbb
22 changed files with 3175 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useState, type FormEvent } from 'react'
import { createEndpoint, listEndpoints, type Endpoint, type TLSMode } from '../api'
const emptyForm = { role_label: '', host: '', port: '993', tls_mode: 'ssl' as TLSMode }
export function Endpoints() {
const [endpoints, setEndpoints] = useState<Endpoint[] | null>(null)
const [form, setForm] = useState(emptyForm)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
function reload() {
listEndpoints()
.then(setEndpoints)
.catch((e) => setError(String(e.message || e)))
}
useEffect(reload, [])
async function submit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
await createEndpoint({
role_label: form.role_label,
host: form.host,
port: Number(form.port),
tls_mode: form.tls_mode,
})
setForm(emptyForm)
reload()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create endpoint')
} finally {
setBusy(false)
}
}
return (
<>
<div className="page-head">
<h1 className="page-title">
Endpoints <span className="idx">/// mailbox servers</span>
</h1>
</div>
<div className="panel-grid">
<div className="panel">
<span className="panel-label">Register endpoint</span>
<form onSubmit={submit}>
<div className="field">
<label htmlFor="role_label">Role label</label>
<input
id="role_label"
placeholder="e.g. src-legacy, dst-office365"
value={form.role_label}
onChange={(e) => setForm({ ...form, role_label: e.target.value })}
required
/>
</div>
<div className="field">
<label htmlFor="host">Host</label>
<input
id="host"
placeholder="imap.example.com"
value={form.host}
onChange={(e) => setForm({ ...form, host: e.target.value })}
required
/>
</div>
<div className="field-row">
<div className="field">
<label htmlFor="port">Port</label>
<input
id="port"
type="number"
value={form.port}
onChange={(e) => setForm({ ...form, port: e.target.value })}
required
/>
</div>
<div className="field">
<label htmlFor="tls_mode">TLS mode</label>
<select
id="tls_mode"
value={form.tls_mode}
onChange={(e) => setForm({ ...form, tls_mode: e.target.value as TLSMode })}
>
<option value="ssl">ssl</option>
<option value="starttls">starttls</option>
<option value="plain">plain</option>
</select>
</div>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="btn-row">
<button className="btn btn-primary" disabled={busy}>
{busy ? 'Saving…' : 'Add endpoint'}
</button>
</div>
</form>
</div>
<div className="panel">
<span className="panel-label">Registered ({endpoints?.length ?? 0})</span>
<div className="tbl-wrap">
<table className="tbl">
<thead>
<tr>
<th>ID</th>
<th>Role</th>
<th>Host</th>
<th>Port</th>
<th>TLS</th>
</tr>
</thead>
<tbody>
{endpoints === null ? (
<tr className="empty-row">
<td colSpan={5}>loading</td>
</tr>
) : endpoints.length === 0 ? (
<tr className="empty-row">
<td colSpan={5}>no endpoints registered yet</td>
</tr>
) : (
endpoints.map((ep) => (
<tr key={ep.id}>
<td className="num-cell">{ep.id}</td>
<td>{ep.role_label}</td>
<td>{ep.host}</td>
<td className="num-cell">{ep.port}</td>
<td>{ep.tls_mode}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { useState, type FormEvent } from 'react'
import { login } from '../api'
export function Login({ onSuccess }: { onSuccess: () => void }) {
const [user, setUser] = useState('')
const [pass, setPass] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function submit(e: FormEvent) {
e.preventDefault()
if (!user || !pass) return
setBusy(true)
setError(null)
try {
await login(user, pass)
onSuccess()
} catch {
setError('Access denied — check operator id and passphrase.')
} finally {
setBusy(false)
}
}
return (
<div className="login-wrap">
<form className="login-card" onSubmit={submit}>
<h1 className="login-brand">
<span style={{ color: 'var(--accent)' }}>[</span>IMAP/COPIER<span style={{ color: 'var(--accent)' }}>]</span>
</h1>
<p className="login-sub">OPERATOR CONSOLE AUTHENTICATE TO CONTINUE</p>
<div className="field">
<label htmlFor="user">Operator ID</label>
<input
id="user"
autoFocus
value={user}
onChange={(e) => setUser(e.target.value)}
autoComplete="username"
spellCheck={false}
/>
</div>
<div className="field">
<label htmlFor="pass">Passphrase</label>
<input
id="pass"
type="password"
value={pass}
onChange={(e) => setPass(e.target.value)}
autoComplete="current-password"
/>
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={busy || !user || !pass}>
{busy ? 'Authenticating…' : 'Sign in'}
</button>
<div className="login-error">{error}</div>
</form>
</div>
)
}
+289
View File
@@ -0,0 +1,289 @@
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
import { createAccount, getTask, importCSV, runTask, testAccounts, type TaskDetail as TaskDetailData } from '../api'
import { connectTaskWS, type TaskEvent } from '../ws'
import { StatusBadge } from '../components/StatusBadge'
const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' }
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' | null>(null)
const [error, setError] = useState<string | null>(null)
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) => {
setLog((l) => [{ type: ev.type, text: JSON.stringify(ev.data) }, ...l].slice(0, 300))
if (['account_started', 'account_test', 'account_done', 'progress', 'run_started', 'run_done', 'error'].includes(ev.type)) {
reload()
}
}),
[id],
)
async function submitAccount(e: FormEvent) {
e.preventDefault()
setBusy('add')
setError(null)
try {
await createAccount(id, form)
setForm(emptyAccount)
reload()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add account')
} finally {
setBusy(null)
}
}
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 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')
const totals = accounts.reduce(
(acc, a) => ({ copied: acc.copied + a.copied, skipped: acc.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}>
{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 === '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>
<span className="hint">columns: src_login, src_pass, dst_login, dst_pass</span>
</div>
</div>
<div className="panel">
<span className="panel-label">Event log</span>
<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>Copied</th>
<th>Skipped</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
{accounts.length === 0 ? (
<tr className="empty-row">
<td colSpan={8}>no accounts yet add one or import a CSV above</td>
</tr>
) : (
accounts.map((a) => (
<tr key={a.id}>
<td>{a.src_login}</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="num-cell">{a.copied}</td>
<td className="num-cell">{a.skipped}</td>
<td className="num-cell">{a.errors}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</>
)
}
+156
View File
@@ -0,0 +1,156 @@
import { useEffect, useState, type FormEvent } from 'react'
import { createTask, listEndpoints, listTasks, type Endpoint, type Task } from '../api'
import { StatusBadge } from '../components/StatusBadge'
export function Tasks() {
const [tasks, setTasks] = useState<Task[] | null>(null)
const [endpoints, setEndpoints] = useState<Endpoint[]>([])
const [name, setName] = useState('')
const [srcId, setSrcId] = useState('')
const [dstId, setDstId] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
function reload() {
listTasks()
.then(setTasks)
.catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load tasks'))
}
useEffect(() => {
reload()
listEndpoints().then(setEndpoints).catch(() => {})
}, [])
async function submit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
const res = await createTask({
name,
src_endpoint_id: Number(srcId),
dst_endpoint_id: Number(dstId),
})
setName('')
setSrcId('')
setDstId('')
reload()
location.hash = `#/tasks/${res.id}`
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create task')
} finally {
setBusy(false)
}
}
const canSubmit = name.trim() !== '' && srcId !== '' && dstId !== '' && srcId !== dstId
return (
<>
<div className="page-head">
<h1 className="page-title">
Migration tasks <span className="idx">/// mailbox copy jobs</span>
</h1>
</div>
<div className="panel">
<span className="panel-label">New task</span>
{endpoints.length < 2 ? (
<p className="muted-note">
Register at least two endpoints (source &amp; destination) on the{' '}
<a className="crumb" href="#/endpoints">
Endpoints
</a>{' '}
screen before creating a task.
</p>
) : (
<form onSubmit={submit}>
<div className="field">
<label htmlFor="name">Task name</label>
<input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="q3-office365-migration" required />
</div>
<div className="field-row">
<div className="field">
<label htmlFor="src">Source endpoint</label>
<select id="src" value={srcId} onChange={(e) => setSrcId(e.target.value)} required>
<option value="" disabled>
select
</option>
{endpoints.map((ep) => (
<option key={ep.id} value={ep.id}>
{ep.role_label} {ep.host}:{ep.port}
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="dst">Destination endpoint</label>
<select id="dst" value={dstId} onChange={(e) => setDstId(e.target.value)} required>
<option value="" disabled>
select
</option>
{endpoints.map((ep) => (
<option key={ep.id} value={ep.id}>
{ep.role_label} {ep.host}:{ep.port}
</option>
))}
</select>
</div>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="btn-row">
<button className="btn btn-primary" disabled={busy || !canSubmit}>
{busy ? 'Creating…' : 'Create task'}
</button>
</div>
</form>
)}
</div>
<div className="panel">
<span className="panel-label">All tasks ({tasks?.length ?? 0})</span>
<div className="tbl-wrap">
<table className="tbl">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Route</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{tasks === null ? (
<tr className="empty-row">
<td colSpan={4}>loading</td>
</tr>
) : tasks.length === 0 ? (
<tr className="empty-row">
<td colSpan={4}>no tasks yet create one above</td>
</tr>
) : (
tasks.map((t) => (
<tr key={t.id}>
<td className="num-cell">{t.id}</td>
<td>
<a className="rowlink" href={`#/tasks/${t.id}`}>
{t.name}
</a>
</td>
<td>
#{t.src_endpoint_id} #{t.dst_endpoint_id}
</td>
<td>
<StatusBadge status={t.status} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</>
)
}