Add pause and cancel to a running migration
A live run could only be stopped one account at a time, and stopping it at all meant losing the queue: the accounts that had not started yet stayed idle with no record that they were meant to run. A run now carries a handle holding the context that stops every account under it plus the reason it was stopped. Pause and cancel take the same path and differ only in the status left behind — paused accounts are what Resume re-runs, and the migration journal makes each one continue where it stopped instead of re-copying. Accounts still queued when the stop lands get the same status as the interrupted ones, so the whole remainder is resumable after a pause and cancelled after a cancel. Database writes keep using the uncancellable context, so statuses and counters survive the stop. The scheduler skips paused tasks: auto-starting a full run would defeat the pause. An operator stopping a run no longer trips the schedule breaker either — that is for failures, not for intent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -151,6 +151,14 @@ export const testAccounts = (id: number) => api(`/api/tasks/${id}/test`, { metho
|
||||
export const runTask = (id: number, accountIds?: number[]) =>
|
||||
api(`/api/tasks/${id}/run`, accountIds?.length ? jsonBody({ account_ids: accountIds }) : { method: 'POST' })
|
||||
|
||||
// Pause stops the run but leaves its unfinished accounts resumable; cancel ends
|
||||
// it and marks them cancelled. Resume re-runs exactly the paused accounts.
|
||||
export const pauseTask = (id: number) => api(`/api/tasks/${id}/pause`, { method: 'POST' })
|
||||
|
||||
export const cancelTask = (id: number) => api(`/api/tasks/${id}/cancel`, { method: 'POST' })
|
||||
|
||||
export const resumeTask = (id: number) => api<{ run_id: number }>(`/api/tasks/${id}/resume`, { method: 'POST' })
|
||||
|
||||
export interface Run {
|
||||
id: number
|
||||
task_id: number
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
||||
import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, importKerioCSV, probeAccountFolders, probeFolders, runTask, setAccountFolderMapping, setTaskSchedule, testAccounts, updateAccountCredentials, type Account, type TaskDetail as TaskDetailData } from '../api'
|
||||
import { cancelAccount, cancelTask, createAccount, deleteAccount, getTask, importCSV, importKerioCSV, pauseTask, probeAccountFolders, probeFolders, resumeTask, runTask, setAccountFolderMapping, setTaskSchedule, testAccounts, updateAccountCredentials, type Account, type TaskDetail as TaskDetailData } from '../api'
|
||||
import { connectTaskWS, type TaskEvent } from '../ws'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
import { useConfirm } from '../components/ConfirmProvider'
|
||||
@@ -59,6 +59,8 @@ function describeEvent(ev: TaskEvent): string {
|
||||
}
|
||||
case 'cancelled':
|
||||
return `CANCELLED #${d.account_id} (${d.src_login}): copied ${d.copied ?? 0}, skipped ${d.skipped ?? 0}`
|
||||
case 'paused':
|
||||
return `PAUSED #${d.account_id} (${d.src_login}): copied ${d.copied ?? 0}, skipped ${d.skipped ?? 0} — resumable`
|
||||
case 'error': {
|
||||
const where = d.folder ? ` folder "${d.folder}"` : d.side ? ` (${d.side} ${at})` : ''
|
||||
return `ERROR #${d.account_id}${where}: ${d.error}`
|
||||
@@ -66,7 +68,7 @@ function describeEvent(ev: TaskEvent): string {
|
||||
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}`
|
||||
return `RUN ${String(d.status ?? 'finished')}: copied ${d.copied}, skipped ${d.skipped}, errors ${d.errors}`
|
||||
default:
|
||||
return JSON.stringify(ev.data)
|
||||
}
|
||||
@@ -163,7 +165,7 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
},
|
||||
}
|
||||
})
|
||||
} else if (accId != null && (ev.type === 'account_started' || ev.type === 'account_done' || ev.type === 'cancelled' || (ev.type === 'error' && d.folder == null))) {
|
||||
} else if (accId != null && (ev.type === 'account_started' || ev.type === 'account_done' || ev.type === 'cancelled' || ev.type === 'paused' || (ev.type === 'error' && d.folder == null))) {
|
||||
// terminal/reset for this account — drop live overlay, fall back to DB
|
||||
setLive((prev) => {
|
||||
if (!(accId in prev)) return prev
|
||||
@@ -174,7 +176,7 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
}
|
||||
|
||||
// Structural events refresh the persisted view; `progress` is covered by live state.
|
||||
if (['account_started', 'account_test', 'account_done', 'run_started', 'run_done', 'error', 'folder', 'cancelled', 'plan', 'task_broken'].includes(ev.type)) {
|
||||
if (['account_started', 'account_test', 'account_done', 'run_started', 'run_done', 'error', 'folder', 'cancelled', 'paused', 'plan', 'task_broken'].includes(ev.type)) {
|
||||
reload()
|
||||
}
|
||||
}),
|
||||
@@ -395,6 +397,51 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onPause() {
|
||||
setBusy('run')
|
||||
setError(null)
|
||||
try {
|
||||
await pauseTask(id)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to pause the run')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancelRun() {
|
||||
const ok = await confirm({
|
||||
title: 'Cancel migration',
|
||||
message: 'Stop the run and mark every unfinished account as cancelled? Copied messages are kept.',
|
||||
confirmLabel: 'Cancel migration',
|
||||
cancelLabel: 'Keep running',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
setBusy('run')
|
||||
setError(null)
|
||||
try {
|
||||
await cancelTask(id)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to cancel the run')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function onResume() {
|
||||
setBusy('run')
|
||||
setError(null)
|
||||
try {
|
||||
await resumeTask(id)
|
||||
reload()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to resume the run')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSchedule(intervalSeconds: number) {
|
||||
setError(null)
|
||||
try {
|
||||
@@ -422,6 +469,8 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
|
||||
const { task, accounts } = data
|
||||
const isRunning = task.status === 'running'
|
||||
// Accounts a pause left unfinished — what Resume picks up.
|
||||
const pausedCount = accounts.filter((a) => a.status === 'paused').length
|
||||
|
||||
// A failed connection test is the entry point for fixing the credentials that
|
||||
// caused it — an imported account is otherwise only deletable.
|
||||
@@ -515,14 +564,37 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
<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 || !runReady || isRunning}>
|
||||
{busy === 'run'
|
||||
? 'Starting…'
|
||||
: effectiveSelected.length > 0
|
||||
? `Run selected (${effectiveSelected.length})`
|
||||
: 'Run migration'}
|
||||
</button>
|
||||
{!runReady && accounts.length > 0 && (
|
||||
{isRunning ? (
|
||||
<>
|
||||
<button className="btn" onClick={onPause} disabled={busy !== null}>
|
||||
{busy === 'run' ? 'Stopping…' : 'Pause'}
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={onCancelRun} disabled={busy !== null}>
|
||||
Cancel
|
||||
</button>
|
||||
<span className="hint">pause keeps the unfinished accounts resumable</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{pausedCount > 0 && (
|
||||
<button className="btn btn-primary" onClick={onResume} disabled={busy !== null}>
|
||||
{busy === 'run' ? 'Resuming…' : `Resume (${pausedCount})`}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={pausedCount > 0 ? 'btn' : 'btn btn-primary'}
|
||||
onClick={onRun}
|
||||
disabled={busy !== null || !runReady}
|
||||
>
|
||||
{busy === 'run'
|
||||
? 'Starting…'
|
||||
: effectiveSelected.length > 0
|
||||
? `Run selected (${effectiveSelected.length})`
|
||||
: 'Run migration'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!isRunning && !runReady && accounts.length > 0 && (
|
||||
<span className="hint">
|
||||
{effectiveSelected.length > 0
|
||||
? 'selected accounts must pass both connection tests'
|
||||
|
||||
Reference in New Issue
Block a user