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:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user