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