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