b88f88c1a3
- store: DeleteAccount, DeleteTask, UpdateEndpoint (+ cascade/update tests)
- httpapi: DELETE /tasks/{id}, DELETE /tasks/{id}/accounts/{accountId},
PUT /endpoints/{id}; delete guarded with 409 while task running
- orchestrator: enrich WS events with login/host/port/error (test + run + errors)
- web: delete buttons (task, account) with confirm, endpoint edit form,
human-readable event log (source/dest, host:port, error text)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd
179 lines
5.9 KiB
TypeScript
179 lines
5.9 KiB
TypeScript
import { useEffect, useState, type FormEvent } from 'react'
|
|
import { createTask, deleteTask, 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((t) => setTasks(t ?? []))
|
|
.catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load tasks'))
|
|
}
|
|
|
|
useEffect(() => {
|
|
reload()
|
|
listEndpoints().then((e) => setEndpoints(e ?? [])).catch(() => {})
|
|
}, [])
|
|
|
|
async function onDeleteTask(id: number, taskName: string) {
|
|
if (!confirm(`Delete task "${taskName}" and all its accounts?`)) return
|
|
setError(null)
|
|
try {
|
|
await deleteTask(id)
|
|
reload()
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : 'Failed to delete task')
|
|
}
|
|
}
|
|
|
|
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>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{tasks === null ? (
|
|
<tr className="empty-row">
|
|
<td colSpan={5}>loading…</td>
|
|
</tr>
|
|
) : tasks.length === 0 ? (
|
|
<tr className="empty-row">
|
|
<td colSpan={5}>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>
|
|
<td className="num-cell">
|
|
<button
|
|
type="button"
|
|
className="link-btn danger"
|
|
onClick={() => onDeleteTask(t.id, t.name)}
|
|
disabled={t.status === 'running'}
|
|
>
|
|
delete
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|