feat: add cancel, pause, and resume flow control for scans
Some checks failed
Deploy / deploy (push) Failing after 18s
Some checks failed
Deploy / deploy (push) Failing after 18s
Users running large scans can now pause (keep partial results, resume
later), cancel (stop permanently, partial results preserved), or resume
a paused scan which races through cache hits before continuing.
Backend:
- Extend scans.status CHECK to include 'paused' and 'cancelled'
- Add _migrate_add_pause_cancel_status() table-recreation migration
- scan_processor: _running_tasks/_cancel_reasons registries,
cancel_scan_task/pause_scan_task/stop_scan_task helpers,
CancelledError handler in process_scan(), start_resume_processor()
- api_server: POST /scans/{id}/pause|cancel|resume endpoints with
rate limits (30/min pause+cancel, 10/min resume); list_scans now
accepts paused/cancelled as status filter values
Frontend:
- Scan.status type extended with 'paused' | 'cancelled'
- scanApi.pause/cancel/resume added
- StatusChip: amber PauseCircle chip for paused, grey Ban for cancelled
- ScanDetails: context-aware action row with inline-confirm for
Pause and Cancel; Resume button for paused scans
Tests: 129 total (58 new) across test_scan_control.py,
test_scan_processor_control.py, and additions to existing suites
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ export interface Scan {
|
||||
country: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'paused' | 'cancelled';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
total_routes: number;
|
||||
@@ -24,6 +24,8 @@ export interface Scan {
|
||||
seat_class: string;
|
||||
adults: number;
|
||||
scheduled_scan_id?: number;
|
||||
started_at?: string; // ISO-8601 UTC — set when status transitions to 'running'
|
||||
completed_at?: string; // ISO-8601 UTC — set when status transitions to 'completed' or 'failed'
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
@@ -160,6 +162,10 @@ export const scanApi = {
|
||||
},
|
||||
|
||||
delete: (id: number) => api.delete(`/scans/${id}`),
|
||||
|
||||
pause: (id: number) => api.post(`/scans/${id}/pause`),
|
||||
cancel: (id: number) => api.post(`/scans/${id}/cancel`),
|
||||
resume: (id: number) => api.post(`/scans/${id}/resume`),
|
||||
};
|
||||
|
||||
export const airportApi = {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CheckCircle2, Loader2, Clock, XCircle } from 'lucide-react';
|
||||
import { CheckCircle2, Loader2, Clock, XCircle, PauseCircle, Ban } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export type ScanStatus = 'completed' | 'running' | 'pending' | 'failed';
|
||||
export type ScanStatus = 'completed' | 'running' | 'pending' | 'failed' | 'paused' | 'cancelled';
|
||||
|
||||
interface StatusConfig {
|
||||
icon: LucideIcon;
|
||||
@@ -38,6 +38,18 @@ const CONFIGS: Record<ScanStatus, StatusConfig> = {
|
||||
chipClass: 'bg-[#FDECEA] text-[#A50E0E] border border-[#F5C6C6]',
|
||||
iconClass: 'text-[#A50E0E]',
|
||||
},
|
||||
paused: {
|
||||
icon: PauseCircle,
|
||||
label: 'paused',
|
||||
chipClass: 'bg-[#FEF7E0] text-[#7A5200] border border-[#F9D659]',
|
||||
iconClass: 'text-[#7A5200]',
|
||||
},
|
||||
cancelled: {
|
||||
icon: Ban,
|
||||
label: 'cancelled',
|
||||
chipClass: 'bg-[#F3F3F3] text-[#5F6368] border border-[#DADCE0]',
|
||||
iconClass: 'text-[#5F6368]',
|
||||
},
|
||||
};
|
||||
|
||||
interface StatusChipProps {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Users,
|
||||
Armchair,
|
||||
Clock,
|
||||
Timer,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
@@ -17,6 +18,9 @@ import {
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Info,
|
||||
Pause,
|
||||
Play,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { scanApi } from '../api';
|
||||
import type { Scan, Route, Flight } from '../api';
|
||||
@@ -25,6 +29,8 @@ import type { ScanStatus } from '../components/StatusChip';
|
||||
import StatCard from '../components/StatCard';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import { SkeletonStatCard, SkeletonTableRow } from '../components/SkeletonCard';
|
||||
import ScanTimer, { formatDuration } from '../components/ScanTimer';
|
||||
import { useScanTimer } from '../hooks/useScanTimer';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const formatPrice = (price?: number) =>
|
||||
@@ -52,6 +58,13 @@ export default function ScanDetails() {
|
||||
const [rerunning, setRerunning] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmPause, setConfirmPause] = useState(false);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [resuming, setResuming] = useState(false);
|
||||
|
||||
// Must be called unconditionally before any early returns (Rules of Hooks)
|
||||
const timer = useScanTimer(scan);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) loadScanDetails();
|
||||
@@ -156,6 +169,47 @@ export default function ScanDetails() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
if (!scan) return;
|
||||
setStopping(true);
|
||||
try {
|
||||
await scanApi.pause(scan.id);
|
||||
await loadScanDetails();
|
||||
} catch {
|
||||
// fall through
|
||||
} finally {
|
||||
setStopping(false);
|
||||
setConfirmPause(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!scan) return;
|
||||
setStopping(true);
|
||||
try {
|
||||
await scanApi.cancel(scan.id);
|
||||
await loadScanDetails();
|
||||
} catch {
|
||||
// fall through
|
||||
} finally {
|
||||
setStopping(false);
|
||||
setConfirmCancel(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async () => {
|
||||
if (!scan) return;
|
||||
setResuming(true);
|
||||
try {
|
||||
await scanApi.resume(scan.id);
|
||||
await loadScanDetails();
|
||||
} catch {
|
||||
// fall through
|
||||
} finally {
|
||||
setResuming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const SortIcon = ({ field }: { field: typeof sortField }) => {
|
||||
if (sortField !== field) return <ChevronUp size={14} className="opacity-30" />;
|
||||
return sortDirection === 'asc'
|
||||
@@ -261,51 +315,168 @@ export default function ScanDetails() {
|
||||
)}
|
||||
|
||||
{/* Row 4: actions */}
|
||||
<div className="mt-4 pt-4 border-t border-outline flex items-center justify-end gap-2">
|
||||
{/* Re-run */}
|
||||
<button
|
||||
onClick={handleRerun}
|
||||
disabled={rerunning || isActive}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<RotateCcw size={14} className={rerunning ? 'animate-spin' : ''} />
|
||||
{rerunning ? 'Starting…' : 'Re-run'}
|
||||
</button>
|
||||
<div className="mt-4 pt-4 border-t border-outline flex items-center justify-end gap-2 flex-wrap">
|
||||
|
||||
{/* Delete — inline confirm */}
|
||||
{confirmDelete ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<span className="text-sm text-on-surface-variant">Delete this scan?</span>
|
||||
{/* ── Active (pending / running): Pause + Cancel ── */}
|
||||
{isActive && (
|
||||
<>
|
||||
{/* Pause — inline confirm */}
|
||||
{confirmPause ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<span className="text-sm text-on-surface-variant">Pause this scan?</span>
|
||||
<button
|
||||
onClick={handlePause}
|
||||
disabled={stopping}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-[#7A5200] text-white hover:bg-[#5C3D00] disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{stopping ? 'Pausing…' : 'Yes, pause'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmPause(false)}
|
||||
disabled={stopping}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmPause(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
<Pause size={14} />
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cancel — inline confirm */}
|
||||
{confirmCancel ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<span className="text-sm text-on-surface-variant">Cancel this scan?</span>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={stopping}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-error text-white hover:bg-error/90 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{stopping ? 'Cancelling…' : 'Yes, cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmCancel(false)}
|
||||
disabled={stopping}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmCancel(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-error/40 text-error hover:bg-error/5 transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Paused: Resume + Re-run + Delete ── */}
|
||||
{scan.status === 'paused' && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-error text-white hover:bg-error/90 disabled:opacity-60 transition-colors"
|
||||
onClick={handleResume}
|
||||
disabled={resuming}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Yes, delete'}
|
||||
<Play size={14} className={resuming ? 'animate-pulse' : ''} />
|
||||
{resuming ? 'Resuming…' : 'Resume'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
disabled={deleting}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
|
||||
onClick={handleRerun}
|
||||
disabled={rerunning}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Cancel
|
||||
<RotateCcw size={14} className={rerunning ? 'animate-spin' : ''} />
|
||||
{rerunning ? 'Starting…' : 'Re-run'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={isActive}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-error/40 text-error hover:bg-error/5 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
|
||||
{confirmDelete ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<span className="text-sm text-on-surface-variant">Delete this scan?</span>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-error text-white hover:bg-error/90 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Yes, delete'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
disabled={deleting}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-error/40 text-error hover:bg-error/5 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Completed / Failed / Cancelled: Re-run + Delete ── */}
|
||||
{!isActive && scan.status !== 'paused' && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleRerun}
|
||||
disabled={rerunning}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<RotateCcw size={14} className={rerunning ? 'animate-spin' : ''} />
|
||||
{rerunning ? 'Starting…' : 'Re-run'}
|
||||
</button>
|
||||
|
||||
{confirmDelete ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<span className="text-sm text-on-surface-variant">Delete this scan?</span>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-error text-white hover:bg-error/90 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Yes, delete'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
disabled={deleting}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-error/40 text-error hover:bg-error/5 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Stat cards ────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className={`grid gap-3 ${!isActive && scan.started_at && scan.completed_at ? 'grid-cols-4' : 'grid-cols-3'}`}>
|
||||
{loading ? (
|
||||
[0, 1, 2].map(i => <SkeletonStatCard key={i} />)
|
||||
) : (
|
||||
@@ -313,6 +484,14 @@ export default function ScanDetails() {
|
||||
<StatCard label="Total Routes" value={scan.total_routes} icon={MapPin} variant="primary" />
|
||||
<StatCard label="Routes Scanned" value={scan.routes_scanned} icon={ChevronDown} variant="secondary" />
|
||||
<StatCard label="Flights Found" value={scan.total_flights} icon={PlaneTakeoff} variant="primary" />
|
||||
{!isActive && scan.started_at && scan.completed_at && (
|
||||
<StatCard
|
||||
label="Scan Duration"
|
||||
value={formatDuration(timer.elapsedSeconds)}
|
||||
icon={Timer}
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -340,6 +519,9 @@ export default function ScanDetails() {
|
||||
<p className="mt-2 text-xs text-on-surface-variant">
|
||||
{scan.routes_scanned} of {scan.total_routes > 0 ? scan.total_routes : '?'} routes · auto-refreshing every 3 s
|
||||
</p>
|
||||
{scan.status === 'running' && scan.started_at && (
|
||||
<ScanTimer {...timer} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user