Add cancellation reason tracking with user/system codes and UI

This commit is contained in:
2026-07-08 17:31:22 -04:00
parent 3c1bbce9e7
commit 69c4898a56
9 changed files with 137 additions and 32 deletions
+4
View File
@@ -89,6 +89,10 @@ export interface LogLine {
timestamp: string;
}
export interface CancelJobRequest {
reason?: string;
}
export interface SSHKey {
id: number;
label: string;
+10 -2
View File
@@ -77,9 +77,17 @@ const ERROR_CODES: Record<string, ErrorCodeInfo> = {
title: 'SSH key not found',
hint: 'Server fell back to its own key. Verify the machine SSH key configuration',
},
cancelled: {
cancelled_user: {
title: 'Cancelled by user',
hint: 'The job was manually cancelled',
hint: 'The job was manually cancelled from the UI',
},
cancelled_shutdown: {
title: 'Cancelled — server shutdown',
hint: 'The job was interrupted because the syncserver process stopped',
},
unknown_cancelled: {
title: 'Cancelled (no reason recorded)',
hint: 'This cancellation happened before the upgrade that records reasons',
},
}
+71 -19
View File
@@ -8,12 +8,15 @@ import { Switch } from '@/components/ui/Switch';
import { Card } from '@/components/ui/Card';
import { Spinner } from '@/components/ui/Spinner';
import { CopyButton } from '@/components/ui/CopyButton';
import { Textarea } from '@/components/ui/Textarea';
import { Label } from '@/components/ui/Label';
import {
Modal,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalBody,
ModalFooter,
} from '@/components/ui/Modal';
import { ErrorDetailsModal } from '@/components/ErrorDetailsModal';
@@ -24,6 +27,7 @@ import {
ScrollText,
Terminal,
AlertCircle,
Ban,
} from 'lucide-react';
import { toast } from 'sonner';
import { statusVariant, statusLabel, getErrorCodeInfo } from '@/lib/status';
@@ -50,6 +54,7 @@ export default function JobDetail() {
const jobId = Number(id);
const [loading, setLoading] = useState(true);
const [cancelModal, setCancelModal] = useState(false);
const [cancelReason, setCancelReason] = useState('');
const [errorModal, setErrorModal] = useState(false);
useEffect(() => {
@@ -112,9 +117,13 @@ export default function JobDetail() {
async function cancel() {
try {
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
await api(`/api/jobs/${id}/cancel`, {
method: 'POST',
body: { reason: cancelReason.trim() || undefined },
});
toast.success('Job cancelled');
setCancelModal(false);
setCancelReason('');
loadJob();
} catch (e: unknown) {
toast.error((e as Error).message);
@@ -227,27 +236,47 @@ export default function JobDetail() {
))}
</div>
{job.status === 'failed' && job.error_message && (
<div className="rounded-card border border-rose-500/30 bg-rose-500/10 p-4 space-y-2">
{(job.status === 'failed' || (job.status === 'cancelled' && job.error_message)) && (
<div className={cn(
"rounded-card border p-4 space-y-2",
job.status === 'failed'
? "border-rose-500/30 bg-rose-500/10"
: "border-zinc-500/30 bg-zinc-500/10"
)}>
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-rose-400 shrink-0 mt-0.5" />
{job.status === 'failed'
? <AlertCircle className="h-4 w-4 text-rose-400 shrink-0 mt-0.5" />
: <Ban className="h-4 w-4 text-zinc-400 shrink-0 mt-0.5" />
}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-rose-300">
{errorInfo?.title ?? 'Job failed'}
<span className={cn(
"text-sm font-semibold",
job.status === 'failed' ? "text-rose-300" : "text-zinc-300"
)}>
{errorInfo?.title ?? (job.status === 'failed' ? 'Job failed' : 'Job cancelled')}
</span>
{job.error_code && (
<span className="text-xs font-mono text-rose-400/60">
<span className={cn(
"text-xs font-mono",
job.status === 'failed' ? "text-rose-400/60" : "text-zinc-400/60"
)}>
[{job.error_code}]
</span>
)}
</div>
{errorInfo?.hint && (
<p className="text-xs text-rose-400/70 mt-0.5">
<p className={cn(
"text-xs mt-0.5",
job.status === 'failed' ? "text-rose-400/70" : "text-zinc-400/70"
)}>
{errorInfo.hint}
</p>
)}
<p className="text-xs font-mono text-rose-300/80 mt-1 truncate max-w-2xl">
<p className={cn(
"text-xs font-mono mt-1 truncate max-w-2xl",
job.status === 'failed' ? "text-rose-300/80" : "text-zinc-300/80"
)}>
{job.error_message}
</p>
</div>
@@ -255,9 +284,14 @@ export default function JobDetail() {
variant="secondary"
size="sm"
onClick={() => setErrorModal(true)}
className="shrink-0 text-rose-300 hover:text-rose-200 border-rose-500/40 hover:border-rose-400/60"
className={cn(
"shrink-0 border",
job.status === 'failed'
? "text-rose-300 hover:text-rose-200 border-rose-500/40 hover:border-rose-400/60"
: "text-zinc-300 hover:text-zinc-200 border-zinc-500/40 hover:border-zinc-400/60"
)}
>
<AlertCircle className="h-4 w-4" />
{job.status === 'failed' ? <AlertCircle className="h-4 w-4" /> : <Ban className="h-4 w-4" />}
View error
</Button>
</div>
@@ -356,14 +390,32 @@ export default function JobDetail() {
be undone.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button variant="secondary" onClick={() => setCancelModal(false)}>
Keep Running
</Button>
<Button variant="danger-solid" onClick={cancel}>
Cancel Job
</Button>
</ModalFooter>
<form onSubmit={e => { e.preventDefault(); cancel(); }}>
<ModalBody>
<div className="space-y-1.5">
<Label htmlFor="cancel-reason">Reason (optional)</Label>
<Textarea
id="cancel-reason"
value={cancelReason}
onChange={e => setCancelReason(e.target.value)}
placeholder="e.g. wrong path, machine offline..."
rows={2}
className="font-mono text-xs"
/>
<p className="text-xs text-fg-subtle">
Adding a reason helps track why jobs are cancelled
</p>
</div>
</ModalBody>
<ModalFooter>
<Button variant="secondary" type="button" onClick={() => { setCancelModal(false); setCancelReason(''); }}>
Keep Running
</Button>
<Button variant="danger-solid" type="submit">
Cancel Job
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>