Add job error persistence and friendly error UI

Backend:
- Migration 0003_job_error: adds error_message and error_code columns to jobs table
- models/job.go: add ErrorMessage, ErrorCode fields to Job struct; SetError method; update all SELECT queries
- models/job_log.go: GetAllFiltered also reads error_message and error_code (via Job embed)
- syncengine/engine.go: setJobError() helper; capture errors at Wol timeout (wol_timeout), rsync error (rsync_error), and exit_code failure points
- api/dto.go: add ErrorMessage and ErrorCode to JobResponse
- api/handlers_jobs.go: jobToResp propagates error fields

Frontend:
- api/client.ts: add error_message? and error_code? to Job interface
- lib/status.ts: add ERROR_CODES map with friendly titles/hints; getErrorCodeInfo()
- components/ErrorDetailsModal.tsx: new modal showing error title, hint, full message, job metadata, and stderr log; copy-all and download-log buttons
- pages/JobDetail.tsx: error banner for failed jobs with title/hint; View error button opens ErrorDetailsModal; SSE updates error_message in real-time
This commit is contained in:
2026-07-08 09:04:47 -04:00
parent e0e94bd518
commit 9d32ef7fd6
10 changed files with 356 additions and 34 deletions
+2
View File
@@ -75,6 +75,8 @@ export interface Job {
started_at: string | null;
finished_at: string | null;
log_file: string | null;
error_message?: string | null;
error_code?: string | null;
duration_seconds?: number | null;
log_line_count?: number | null;
}
+175
View File
@@ -0,0 +1,175 @@
import * as React from 'react'
import { AlertCircle, Download, Clock, Zap, Copy } from 'lucide-react'
import {
Modal,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalBody,
ModalFooter,
} from '@/components/ui/Modal'
import { Button } from '@/components/ui/Button'
import { Badge } from '@/components/ui/Badge'
import { statusVariant } from '@/lib/status'
import { getErrorCodeInfo } from '@/lib/status'
import type { Job } from '@/api/client'
interface ErrorDetailsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
job: Job
fullLog?: string
}
export function ErrorDetailsModal({
open,
onOpenChange,
job,
fullLog,
}: ErrorDetailsModalProps) {
const [copied, setCopied] = React.useState(false)
const errorInfo = getErrorCodeInfo(job.error_code ?? null)
const handleCopy = async () => {
const text = [
`Job #${job.id}`,
`Sync Pair ID: ${job.sync_pair_id}`,
`Trigger: ${job.trigger_type}`,
`Status: ${job.status}`,
`Error Code: ${job.error_code ?? 'unknown'}`,
`Error: ${job.error_message ?? 'none'}`,
'',
'--- Full Log ---',
fullLog ?? '(no log available)',
].join('\n')
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size="lg">
<ModalHeader>
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-rose-400" />
<ModalTitle>Job Failed</ModalTitle>
</div>
<ModalDescription>
Job #{job.id} finished with status{' '}
<Badge variant={statusVariant(job.status)} label={job.status} />
</ModalDescription>
</ModalHeader>
<ModalBody className="space-y-4">
{errorInfo && (
<div className="rounded-card border border-rose-500/30 bg-rose-500/10 p-4 space-y-1">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-rose-400 shrink-0" />
<span className="text-sm font-semibold text-rose-300">
{errorInfo.title}
</span>
</div>
<p className="text-xs text-rose-400/70 pl-6">{errorInfo.hint}</p>
</div>
)}
{job.error_message && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-fg-muted uppercase tracking-wider">
Error Message
</p>
<div className="rounded-card border border-border bg-canvas-raised p-3 font-mono text-xs text-rose-300/80 whitespace-pre-wrap break-all max-h-40 overflow-y-auto">
{job.error_message}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<p className="text-xs font-medium text-fg-muted uppercase tracking-wider">
Error Code
</p>
<p className="text-sm font-mono text-fg">
{job.error_code ?? '—'}
</p>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-fg-muted uppercase tracking-wider">
Trigger
</p>
<p className="text-sm text-fg capitalize">
{job.trigger_type}
</p>
</div>
{job.started_at && (
<div className="space-y-1">
<p className="text-xs font-medium text-fg-muted uppercase tracking-wider flex items-center gap-1">
<Clock className="h-3 w-3" />
Started
</p>
<p className="text-sm text-fg">
{new Date(job.started_at).toLocaleString()}
</p>
</div>
)}
{job.finished_at && (
<div className="space-y-1">
<p className="text-xs font-medium text-fg-muted uppercase tracking-wider flex items-center gap-1">
<Zap className="h-3 w-3" />
Finished
</p>
<p className="text-sm text-fg">
{new Date(job.finished_at).toLocaleString()}
</p>
</div>
)}
</div>
{fullLog && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-fg-muted uppercase tracking-wider">
Stderr / Error Log
</p>
<div className="rounded-card border border-border bg-canvas-raised p-3 font-mono text-xs text-rose-400/70 whitespace-pre-wrap break-all max-h-48 overflow-y-auto">
{fullLog}
</div>
</div>
)}
</ModalBody>
<ModalFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
const a = document.createElement('a')
a.href = `/api/jobs/${job.id}/log/download`
a.download = `job-${job.id}.log`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}}
>
<Download className="h-4 w-4" />
Full Log
</Button>
<Button onClick={handleCopy}>
{copied ? (
<>
<span className="text-emerald-400">Copied</span>
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy All
</>
)}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
+33
View File
@@ -54,3 +54,36 @@ export function statusLabel(status: string): string {
}
return labels[status.toLowerCase()] ?? status
}
export interface ErrorCodeInfo {
title: string;
hint: string;
}
const ERROR_CODES: Record<string, ErrorCodeInfo> = {
wol_timeout: {
title: "Machine didn't wake up",
hint: 'Check Wake-on-LAN settings, MAC address, and network connectivity',
},
rsync_error: {
title: 'rsync failed',
hint: 'See the full log for rsync error details',
},
exit_code: {
title: 'rsync exited with errors',
hint: 'Check stderr output for details',
},
ssh_key_fallback: {
title: 'SSH key not found',
hint: 'Server fell back to its own key. Verify the machine SSH key configuration',
},
cancelled: {
title: 'Cancelled by user',
hint: 'The job was manually cancelled',
},
}
export function getErrorCodeInfo(code: string | null | undefined): ErrorCodeInfo | null {
if (!code) return null
return ERROR_CODES[code] ?? { title: code, hint: 'See full error details below' }
}
+61 -2
View File
@@ -16,15 +16,17 @@ import {
ModalDescription,
ModalFooter,
} from '@/components/ui/Modal';
import { ErrorDetailsModal } from '@/components/ErrorDetailsModal';
import {
ArrowLeft,
Download,
XCircle,
ScrollText,
Terminal,
AlertCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import { statusVariant, statusLabel } from '@/lib/status';
import { statusVariant, statusLabel, getErrorCodeInfo } from '@/lib/status';
import { formatDuration } from '@/lib/utils';
import { cn } from '@/lib/utils';
@@ -48,6 +50,7 @@ export default function JobDetail() {
const jobId = Number(id);
const [loading, setLoading] = useState(true);
const [cancelModal, setCancelModal] = useState(false);
const [errorModal, setErrorModal] = useState(false);
useEffect(() => {
loadJob();
@@ -63,7 +66,12 @@ export default function JobDetail() {
setLiveLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]);
}
if (evt.type === 'status') {
setJob(prev => prev ? { ...prev, status: evt.status! } : prev);
setJob(prev => {
if (!prev) return prev;
const updated = { ...prev, status: evt.status! };
if (evt.line) updated.error_message = evt.line;
return updated;
});
}
};
}
@@ -162,6 +170,13 @@ export default function JobDetail() {
const totalLines = logs.length + liveLines.length;
const fullErrorLog = [
...logs.filter(l => l.stream === 'stderr').map(l => l.content),
...liveLines.filter(l => l.stream === 'stderr').map(l => l.text),
].join('\n');
const errorInfo = getErrorCodeInfo(job.error_code ?? null);
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
@@ -212,6 +227,43 @@ 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">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-rose-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>
{job.error_code && (
<span className="text-xs font-mono text-rose-400/60">
[{job.error_code}]
</span>
)}
</div>
{errorInfo?.hint && (
<p className="text-xs text-rose-400/70 mt-0.5">
{errorInfo.hint}
</p>
)}
<p className="text-xs font-mono text-rose-300/80 mt-1 truncate max-w-2xl">
{job.error_message}
</p>
</div>
<Button
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"
>
<AlertCircle className="h-4 w-4" />
View error
</Button>
</div>
</div>
)}
{['queued', 'waking_up', 'running'].includes(job.status) && (
<div className="flex items-center gap-3">
<Button
@@ -314,6 +366,13 @@ export default function JobDetail() {
</ModalFooter>
</ModalContent>
</Modal>
<ErrorDetailsModal
open={errorModal}
onOpenChange={setErrorModal}
job={job}
fullLog={fullErrorLog}
/>
</div>
);
}