diff --git a/internal/httpapi/endpoints.go b/internal/httpapi/endpoints.go index 2bbf885..74a997e 100644 --- a/internal/httpapi/endpoints.go +++ b/internal/httpapi/endpoints.go @@ -31,6 +31,29 @@ func (s *Server) handleCreateEndpoint(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, map[string]int64{"id": id}) } +func (s *Server) handleUpdateEndpoint(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + var e store.Endpoint + if err := json.NewDecoder(r.Body).Decode(&e); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if e.TLSMode != "ssl" && e.TLSMode != "starttls" && e.TLSMode != "plain" { + http.Error(w, "tls_mode must be ssl|starttls|plain", http.StatusBadRequest) + return + } + e.ID = id + if err := s.store.UpdateEndpoint(r.Context(), e); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (s *Server) handleListEndpoints(w http.ResponseWriter, r *http.Request) { eps, err := s.store.ListEndpoints(r.Context()) if err != nil { diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index f33e774..437e51c 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -14,10 +14,13 @@ func (s *Server) Router() http.Handler { api := http.NewServeMux() api.HandleFunc("GET /api/endpoints", s.handleListEndpoints) api.HandleFunc("POST /api/endpoints", s.handleCreateEndpoint) + api.HandleFunc("PUT /api/endpoints/{id}", s.handleUpdateEndpoint) api.HandleFunc("GET /api/tasks", s.handleListTasks) api.HandleFunc("POST /api/tasks", s.handleCreateTask) api.HandleFunc("GET /api/tasks/{id}", s.handleGetTask) + api.HandleFunc("DELETE /api/tasks/{id}", s.handleDeleteTask) api.HandleFunc("POST /api/tasks/{id}/accounts", s.handleCreateAccount) + api.HandleFunc("DELETE /api/tasks/{id}/accounts/{accountId}", s.handleDeleteAccount) api.HandleFunc("POST /api/tasks/{id}/import", s.handleImportCSV) api.HandleFunc("POST /api/tasks/{id}/test", s.handleTestAccounts) api.HandleFunc("POST /api/tasks/{id}/run", s.handleRun) diff --git a/internal/httpapi/run.go b/internal/httpapi/run.go index b9b689a..88ed0f9 100644 --- a/internal/httpapi/run.go +++ b/internal/httpapi/run.go @@ -84,3 +84,52 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusAccepted, map[string]int64{"run_id": runID}) } + +func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + task, err := s.store.GetTask(r.Context(), id) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + if task.Status == "running" { + http.Error(w, "cannot delete a running task", http.StatusConflict) + return + } + if err := s.store.DeleteTask(r.Context(), id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) { + taskID, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + accID, err := pathID(r, "accountId") + if err != nil { + http.Error(w, "bad account id", http.StatusBadRequest) + return + } + task, err := s.store.GetTask(r.Context(), taskID) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + if task.Status == "running" { + http.Error(w, "cannot modify accounts while task is running", http.StatusConflict) + return + } + if err := s.store.DeleteAccount(r.Context(), accID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 5372f91..4fdfa20 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -75,17 +75,23 @@ func (o *Orchestrator) TestAccounts(ctx context.Context, taskID int64) error { func (o *Orchestrator) testSide(ctx context.Context, ep imapx.Endpoint, accID int64, side, login, passEnc string, taskID int64) { status := "ok" + errMsg := "" pass, err := crypto.Decrypt(o.encKey, passEnc) if err == nil { _, err = imapx.TestLogin(ctx, ep, login, string(pass)) } if err != nil { status = "fail" - slog.Warn("account test failed", "account", accID, "side", side, "err", err) + errMsg = err.Error() + slog.Warn("account test failed", "account", accID, "side", side, + "login", login, "host", ep.Host, "port", ep.Port, "err", err) } _ = o.store.SetAccountTestStatus(ctx, accID, side, status) o.hub.Publish(wshub.Event{Type: "account_test", TaskID: taskID, - Data: map[string]any{"account_id": accID, "side": side, "status": status}}) + Data: map[string]any{ + "account_id": accID, "side": side, "status": status, + "login": login, "host": ep.Host, "port": ep.Port, "error": errMsg, + }}) } func (o *Orchestrator) Run(ctx context.Context, taskID int64) (int64, error) { @@ -177,38 +183,42 @@ func (o *Orchestrator) runAll(ctx context.Context, task store.Task, runID int64, } func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID int64, a store.Account, srcEP, dstEP imapx.Endpoint) (int64, int64, int64) { - o.hub.Publish(wshub.Event{Type: "account_started", TaskID: task.ID, Data: map[string]any{"account_id": a.ID}}) + o.hub.Publish(wshub.Event{Type: "account_started", TaskID: task.ID, Data: map[string]any{ + "account_id": a.ID, + "src_login": a.SrcLogin, "src_host": srcEP.Host, "src_port": srcEP.Port, + "dst_login": a.DstLogin, "dst_host": dstEP.Host, "dst_port": dstEP.Port, + }}) _ = o.store.SetAccountStatus(ctx, a.ID, "running") srcPass, err := crypto.Decrypt(o.encKey, a.SrcPassEnc) if err != nil { - return o.accountFailed(ctx, task.ID, a.ID, err) + return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err) } dstPass, err := crypto.Decrypt(o.encKey, a.DstPassEnc) if err != nil { - return o.accountFailed(ctx, task.ID, a.ID, err) + return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "dst", err) } src, err := imapx.Connect(ctx, srcEP) if err != nil { - return o.accountFailed(ctx, task.ID, a.ID, err) + return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err) } defer func() { _ = src.Logout().Wait() }() if err := src.Login(a.SrcLogin, string(srcPass)).Wait(); err != nil { - return o.accountFailed(ctx, task.ID, a.ID, err) + return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err) } dst, err := imapx.Connect(ctx, dstEP) if err != nil { - return o.accountFailed(ctx, task.ID, a.ID, err) + return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "dst", err) } defer func() { _ = dst.Logout().Wait() }() if err := dst.Login(a.DstLogin, string(dstPass)).Wait(); err != nil { - return o.accountFailed(ctx, task.ID, a.ID, err) + return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "dst", err) } folders, err := imapx.ListFolders(src) if err != nil { - return o.accountFailed(ctx, task.ID, a.ID, err) + return o.accountFailed(ctx, task.ID, a, srcEP, dstEP, "src", err) } var copied, skipped, errs int64 @@ -227,8 +237,11 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in } res, err := imapx.CopyFolder(ctx, src, dst, folder, dstFolder, deps) if err != nil { - slog.Warn("folder copy error", "account", a.ID, "folder", folder, "err", err) + slog.Warn("folder copy error", "account", a.ID, "src_login", a.SrcLogin, "folder", folder, "err", err) errs++ + o.hub.Publish(wshub.Event{Type: "error", TaskID: task.ID, Data: map[string]any{ + "account_id": a.ID, "src_login": a.SrcLogin, "folder": folder, "error": err.Error(), + }}) } copied += int64(res.Copied) skipped += int64(res.Skipped) @@ -237,15 +250,20 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in } _ = o.store.SetAccountStatus(ctx, a.ID, "done") o.hub.Publish(wshub.Event{Type: "account_done", TaskID: task.ID, - Data: map[string]any{"account_id": a.ID, "copied": copied, "skipped": skipped, "errors": errs}}) - slog.Info("account copied", "account", a.ID, "copied", copied, "skipped", skipped, "errors", errs) + Data: map[string]any{"account_id": a.ID, "src_login": a.SrcLogin, "dst_login": a.DstLogin, + "copied": copied, "skipped": skipped, "errors": errs}}) + slog.Info("account copied", "account", a.ID, "src_login", a.SrcLogin, "copied", copied, "skipped", skipped, "errors", errs) return copied, skipped, errs } -func (o *Orchestrator) accountFailed(ctx context.Context, taskID, accID int64, err error) (int64, int64, int64) { - slog.Error("account failed", "account", accID, "err", err) - _ = o.store.SetAccountStatus(ctx, accID, "error") +func (o *Orchestrator) accountFailed(ctx context.Context, taskID int64, a store.Account, srcEP, dstEP imapx.Endpoint, side string, err error) (int64, int64, int64) { + login, host, port := a.SrcLogin, srcEP.Host, srcEP.Port + if side == "dst" { + login, host, port = a.DstLogin, dstEP.Host, dstEP.Port + } + slog.Error("account failed", "account", a.ID, "side", side, "login", login, "host", host, "port", port, "err", err) + _ = o.store.SetAccountStatus(ctx, a.ID, "error") o.hub.Publish(wshub.Event{Type: "error", TaskID: taskID, - Data: map[string]any{"account_id": accID, "error": err.Error()}}) + Data: map[string]any{"account_id": a.ID, "side": side, "login": login, "host": host, "port": port, "error": err.Error()}}) return 0, 0, 1 } diff --git a/internal/store/accounts.go b/internal/store/accounts.go index 86bdfd5..3727ad4 100644 --- a/internal/store/accounts.go +++ b/internal/store/accounts.go @@ -29,6 +29,12 @@ func (s *Store) CreateAccount(ctx context.Context, a Account) (int64, error) { return id, err } +// DeleteAccount removes one account (and its migrated_messages via ON DELETE CASCADE). +func (s *Store) DeleteAccount(ctx context.Context, id int64) error { + _, err := s.Pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, id) + return err +} + func (s *Store) ListAccountsByTask(ctx context.Context, taskID int64) ([]Account, error) { rows, err := s.Pool.Query(ctx, `SELECT id, task_id, src_login, src_pass_enc, dst_login, dst_pass_enc, diff --git a/internal/store/crud_test.go b/internal/store/crud_test.go new file mode 100644 index 0000000..1b865dc --- /dev/null +++ b/internal/store/crud_test.go @@ -0,0 +1,66 @@ +package store + +import ( + "context" + "testing" +) + +func TestUpdateEndpoint(t *testing.T) { + s := testStore(t) + ctx := context.Background() + id, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "old.host", Port: 143, TLSMode: "plain"}) + if err := s.UpdateEndpoint(ctx, Endpoint{ID: id, RoleLabel: "src2", Host: "new.host", Port: 993, TLSMode: "ssl"}); err != nil { + t.Fatalf("update: %v", err) + } + got, _ := s.GetEndpoint(ctx, id) + if got.Host != "new.host" || got.Port != 993 || got.TLSMode != "ssl" || got.RoleLabel != "src2" { + t.Fatalf("update not applied: %+v", got) + } +} + +func TestDeleteAccountCascadesJournal(t *testing.T) { + s := testStore(t) + ctx := context.Background() + ep1, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "s", Host: "a", Port: 993, TLSMode: "ssl"}) + ep2, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "d", Host: "b", Port: 993, TLSMode: "ssl"}) + taskID, _ := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: ep1, DstEndpointID: ep2}) + accID, _ := s.CreateAccount(ctx, Account{TaskID: taskID, SrcLogin: "u", SrcPassEnc: "x", DstLogin: "v", DstPassEnc: "y"}) + _ = s.MarkMigrated(ctx, accID, "INBOX", "") + + if err := s.DeleteAccount(ctx, accID); err != nil { + t.Fatalf("delete account: %v", err) + } + accs, _ := s.ListAccountsByTask(ctx, taskID) + if len(accs) != 0 { + t.Fatalf("account not deleted: %d remain", len(accs)) + } + // journal row must be gone via ON DELETE CASCADE + var n int + _ = s.Pool.QueryRow(ctx, `SELECT count(*) FROM migrated_messages WHERE account_id=$1`, accID).Scan(&n) + if n != 0 { + t.Fatalf("migrated_messages not cascaded: %d rows", n) + } +} + +func TestDeleteTaskCascades(t *testing.T) { + s := testStore(t) + ctx := context.Background() + ep1, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "s", Host: "a", Port: 993, TLSMode: "ssl"}) + ep2, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "d", Host: "b", Port: 993, TLSMode: "ssl"}) + taskID, _ := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: ep1, DstEndpointID: ep2}) + accID, _ := s.CreateAccount(ctx, Account{TaskID: taskID, SrcLogin: "u", SrcPassEnc: "x", DstLogin: "v", DstPassEnc: "y"}) + _, _ = s.CreateRun(ctx, taskID) + + if err := s.DeleteTask(ctx, taskID); err != nil { + t.Fatalf("delete task: %v", err) + } + tasks, _ := s.ListTasks(ctx) + if len(tasks) != 0 { + t.Fatalf("task not deleted: %d remain", len(tasks)) + } + accs, _ := s.ListAccountsByTask(ctx, taskID) + if len(accs) != 0 { + t.Fatalf("accounts not cascaded: %d remain", len(accs)) + } + _ = accID +} diff --git a/internal/store/endpoints.go b/internal/store/endpoints.go index e0b26ad..f09e52c 100644 --- a/internal/store/endpoints.go +++ b/internal/store/endpoints.go @@ -19,6 +19,13 @@ func (s *Store) CreateEndpoint(ctx context.Context, e Endpoint) (int64, error) { return id, err } +func (s *Store) UpdateEndpoint(ctx context.Context, e Endpoint) error { + _, err := s.Pool.Exec(ctx, + `UPDATE endpoints SET role_label=$2, host=$3, port=$4, tls_mode=$5 WHERE id=$1`, + e.ID, e.RoleLabel, e.Host, e.Port, e.TLSMode) + return err +} + func (s *Store) GetEndpoint(ctx context.Context, id int64) (Endpoint, error) { var e Endpoint err := s.Pool.QueryRow(ctx, diff --git a/internal/store/tasks.go b/internal/store/tasks.go index c6e07ba..eeb2a9c 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -32,6 +32,12 @@ func (s *Store) GetTask(ctx context.Context, id int64) (Task, error) { return t, err } +// DeleteTask removes a task and its accounts/runs/migrated_messages via ON DELETE CASCADE. +func (s *Store) DeleteTask(ctx context.Context, id int64) error { + _, err := s.Pool.Exec(ctx, `DELETE FROM tasks WHERE id=$1`, id) + return err +} + func (s *Store) ListTasks(ctx context.Context) ([]Task, error) { rows, err := s.Pool.Query(ctx, `SELECT id, name, src_endpoint_id, dst_endpoint_id, status, folder_mapping diff --git a/web/src/api.ts b/web/src/api.ts index 8cf3310..8abf0fb 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -71,6 +71,16 @@ export const listEndpoints = () => api('/api/endpoints') export const createEndpoint = (body: { role_label: string; host: string; port: number; tls_mode: TLSMode }) => api<{ id: number }>('/api/endpoints', jsonBody(body)) +export const updateEndpoint = ( + id: number, + body: { role_label: string; host: string; port: number; tls_mode: TLSMode }, +) => api(`/api/endpoints/${id}`, { ...jsonBody(body), method: 'PUT' }) + +export const deleteTask = (id: number) => api(`/api/tasks/${id}`, { method: 'DELETE' }) + +export const deleteAccount = (taskId: number, accountId: number) => + api(`/api/tasks/${taskId}/accounts/${accountId}`, { method: 'DELETE' }) + export const listTasks = () => api('/api/tasks') export const getTask = (id: number) => api(`/api/tasks/${id}`) diff --git a/web/src/app.css b/web/src/app.css index b48f7a4..9f7bc10 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -239,6 +239,21 @@ color: var(--accent-strong); } +.link-btn.danger { + color: var(--fg-faint); + border-bottom-color: transparent; +} + +.link-btn.danger:hover { + color: var(--danger, #ff5c5c); + border-bottom-color: var(--danger, #ff5c5c); +} + +.link-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + /* ---------- buttons ---------- */ .btn { diff --git a/web/src/pages/Endpoints.tsx b/web/src/pages/Endpoints.tsx index 3f2d2c4..fdd8699 100644 --- a/web/src/pages/Endpoints.tsx +++ b/web/src/pages/Endpoints.tsx @@ -1,14 +1,26 @@ import { useEffect, useState, type FormEvent } from 'react' -import { createEndpoint, listEndpoints, type Endpoint, type TLSMode } from '../api' +import { createEndpoint, listEndpoints, updateEndpoint, 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(null) const [form, setForm] = useState(emptyForm) + const [editingId, setEditingId] = useState(null) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) + function startEdit(ep: Endpoint) { + setEditingId(ep.id) + setForm({ role_label: ep.role_label, host: ep.host, port: String(ep.port), tls_mode: ep.tls_mode }) + setError(null) + } + + function cancelEdit() { + setEditingId(null) + setForm(emptyForm) + } + function reload() { listEndpoints() .then((e) => setEndpoints(e ?? [])) @@ -21,17 +33,23 @@ export function Endpoints() { e.preventDefault() setBusy(true) setError(null) + const body = { + role_label: form.role_label, + host: form.host, + port: Number(form.port), + tls_mode: form.tls_mode, + } try { - await createEndpoint({ - role_label: form.role_label, - host: form.host, - port: Number(form.port), - tls_mode: form.tls_mode, - }) + if (editingId !== null) { + await updateEndpoint(editingId, body) + } else { + await createEndpoint(body) + } setForm(emptyForm) + setEditingId(null) reload() } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create endpoint') + setError(err instanceof Error ? err.message : 'Failed to save endpoint') } finally { setBusy(false) } @@ -47,7 +65,7 @@ export function Endpoints() {
- Register endpoint + {editingId !== null ? `Edit endpoint #${editingId}` : 'Register endpoint'}
@@ -96,8 +114,13 @@ export function Endpoints() { {error &&
{error}
}
+ {editingId !== null && ( + + )}
@@ -113,16 +136,17 @@ export function Endpoints() { Host Port TLS + {endpoints === null ? ( - loading… + loading… ) : endpoints.length === 0 ? ( - no endpoints registered yet + no endpoints registered yet ) : ( endpoints.map((ep) => ( @@ -132,6 +156,11 @@ export function Endpoints() { {ep.host} {ep.port} {ep.tls_mode} + + + )) )} diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx index 814ff73..3e1cc05 100644 --- a/web/src/pages/TaskDetail.tsx +++ b/web/src/pages/TaskDetail.tsx @@ -1,16 +1,45 @@ import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react' -import { createAccount, getTask, importCSV, runTask, testAccounts, type TaskDetail as TaskDetailData } from '../api' +import { createAccount, deleteAccount, 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: '' } +// Human-readable one-line description of a task event for the log panel. +function describeEvent(ev: TaskEvent): string { + const d = (ev.data ?? {}) as Record + const at = d.host ? `${d.login ?? ''}@${d.host}:${d.port}` : '' + switch (ev.type) { + case 'account_test': { + const where = d.side === 'src' ? 'SOURCE' : 'DEST' + const base = `${where} test ${String(d.status).toUpperCase()} — ${at}` + return d.error ? `${base} — ${d.error}` : base + } + case 'account_started': + return `START #${d.account_id}: ${d.src_login}@${d.src_host}:${d.src_port} → ${d.dst_login}@${d.dst_host}:${d.dst_port}` + case 'account_done': + return `DONE #${d.account_id} (${d.src_login} → ${d.dst_login}): copied ${d.copied}, skipped ${d.skipped}, errors ${d.errors}` + case 'progress': + return `progress #${d.account_id}: copied ${d.copied}, skipped ${d.skipped}` + case 'error': { + const where = d.folder ? ` folder "${d.folder}"` : d.side ? ` (${d.side} ${at})` : '' + return `ERROR #${d.account_id}${where}: ${d.error}` + } + case 'run_started': + return `RUN started (run #${d.run_id})` + case 'run_done': + return `RUN finished: copied ${d.copied}, skipped ${d.skipped}, errors ${d.errors}` + default: + return JSON.stringify(ev.data) + } +} + export function TaskDetail({ id }: { id: number }) { const [data, setData] = useState(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 [busy, setBusy] = useState<'test' | 'run' | 'add' | 'import' | 'delete' | null>(null) const [error, setError] = useState(null) const fileInputRef = useRef(null) @@ -28,7 +57,7 @@ export function TaskDetail({ id }: { id: number }) { useEffect( () => connectTaskWS(id, (ev: TaskEvent) => { - setLog((l) => [{ type: ev.type, text: JSON.stringify(ev.data) }, ...l].slice(0, 300)) + setLog((l) => [{ type: ev.type, text: describeEvent(ev) }, ...l].slice(0, 300)) if (['account_started', 'account_test', 'account_done', 'progress', 'run_started', 'run_done', 'error'].includes(ev.type)) { reload() } @@ -81,6 +110,20 @@ export function TaskDetail({ id }: { id: number }) { } } + async function onDeleteAccount(accId: number, login: string) { + if (!confirm(`Remove account "${login}" from this task?`)) return + setBusy('delete') + setError(null) + try { + await deleteAccount(id, accId) + reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to remove account') + } finally { + setBusy(null) + } + } + async function onTest() { setBusy('test') setError(null) @@ -269,12 +312,13 @@ export function TaskDetail({ id }: { id: number }) { Copied Skipped Errors + {accounts.length === 0 ? ( - no accounts yet — add one or import a CSV above + no accounts yet — add one or import a CSV above ) : ( accounts.map((a) => ( @@ -293,6 +337,16 @@ export function TaskDetail({ id }: { id: number }) { {a.copied} {a.skipped} {a.errors} + + + )) )} diff --git a/web/src/pages/Tasks.tsx b/web/src/pages/Tasks.tsx index b972a70..83f2c7a 100644 --- a/web/src/pages/Tasks.tsx +++ b/web/src/pages/Tasks.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type FormEvent } from 'react' -import { createTask, listEndpoints, listTasks, type Endpoint, type Task } from '../api' +import { createTask, deleteTask, listEndpoints, listTasks, type Endpoint, type Task } from '../api' import { StatusBadge } from '../components/StatusBadge' export function Tasks() { @@ -22,6 +22,17 @@ export function Tasks() { 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) @@ -118,16 +129,17 @@ export function Tasks() { Name Route Status + {tasks === null ? ( - loading… + loading… ) : tasks.length === 0 ? ( - no tasks yet — create one above + no tasks yet — create one above ) : ( tasks.map((t) => ( @@ -144,6 +156,16 @@ export function Tasks() { + + + )) )}