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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user