Files
imap-copier/web/src/pages/Tasks.tsx
T
vasyansk b88f88c1a3 feat: delete task/account, edit endpoint, richer event log
- 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
2026-07-02 09:23:14 +07:00

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 &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>
<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>
</>
)
}