diff --git a/internal/api/dto.go b/internal/api/dto.go index 5293161..7601b6d 100644 --- a/internal/api/dto.go +++ b/internal/api/dto.go @@ -62,6 +62,8 @@ type JobResponse struct { StartedAt *string `json:"started_at"` FinishedAt *string `json:"finished_at"` LogFile *string `json:"log_file"` + ErrorMessage *string `json:"error_message,omitempty"` + ErrorCode *string `json:"error_code,omitempty"` DurationSeconds *int64 `json:"duration_seconds,omitempty"` LogLineCount *int64 `json:"log_line_count,omitempty"` } diff --git a/internal/api/handlers_jobs.go b/internal/api/handlers_jobs.go index ebf3118..47ba0d7 100644 --- a/internal/api/handlers_jobs.go +++ b/internal/api/handlers_jobs.go @@ -206,11 +206,13 @@ func (h *JobHandler) DownloadLog(w http.ResponseWriter, r *http.Request) { func jobToResp(j models.Job) JobResponse { resp := JobResponse{ - ID: j.ID, - SyncPairID: j.SyncPairID, - TriggerType: j.TriggerType, - Status: j.Status, - LogFile: j.LogFile, + ID: j.ID, + SyncPairID: j.SyncPairID, + TriggerType: j.TriggerType, + Status: j.Status, + LogFile: j.LogFile, + ErrorMessage: j.ErrorMessage, + ErrorCode: j.ErrorCode, } if j.StartedAt != nil { s := j.StartedAt.Format(time.RFC3339) diff --git a/internal/db/migrations/0003_job_error.sql b/internal/db/migrations/0003_job_error.sql new file mode 100644 index 0000000..6d24b55 --- /dev/null +++ b/internal/db/migrations/0003_job_error.sql @@ -0,0 +1,4 @@ +-- 0003_job_error.sql + +ALTER TABLE jobs ADD COLUMN error_message TEXT; +ALTER TABLE jobs ADD COLUMN error_code TEXT; diff --git a/internal/models/job.go b/internal/models/job.go index 4716ca3..c20d7c6 100644 --- a/internal/models/job.go +++ b/internal/models/job.go @@ -6,14 +6,16 @@ import ( ) type Job struct { - ID int64 `db:"id" json:"id"` - SyncPairID int64 `db:"sync_pair_id" json:"sync_pair_id"` - TriggerType string `db:"trigger_type" json:"trigger_type"` - Status string `db:"status" json:"status"` - StartedAt *time.Time `db:"started_at" json:"started_at"` - FinishedAt *time.Time `db:"finished_at" json:"finished_at"` - LogFile *string `db:"log_file" json:"log_file"` - CreatedAt time.Time `db:"created_at" json:"created_at"` + ID int64 `db:"id" json:"id"` + SyncPairID int64 `db:"sync_pair_id" json:"sync_pair_id"` + TriggerType string `db:"trigger_type" json:"trigger_type"` + Status string `db:"status" json:"status"` + StartedAt *time.Time `db:"started_at" json:"started_at"` + FinishedAt *time.Time `db:"finished_at" json:"finished_at"` + LogFile *string `db:"log_file" json:"log_file"` + ErrorMessage *string `db:"error_message" json:"error_message,omitempty"` + ErrorCode *string `db:"error_code" json:"error_code,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` } type JobRepository struct { @@ -38,12 +40,12 @@ func (r *JobRepository) Create(syncPairID int64, triggerType, status string) (in func (r *JobRepository) GetByID(id int64) (*Job, error) { var j Job var started, finished sql.NullTime - var logFile sql.NullString + var logFile, errMsg, errCode sql.NullString err := r.db.QueryRow(` SELECT id, sync_pair_id, trigger_type, status, started_at, finished_at, - log_file, created_at FROM jobs WHERE id = ?`, id).Scan( + log_file, error_message, error_code, created_at FROM jobs WHERE id = ?`, id).Scan( &j.ID, &j.SyncPairID, &j.TriggerType, &j.Status, &started, &finished, - &logFile, &j.CreatedAt) + &logFile, &errMsg, &errCode, &j.CreatedAt) if err != nil { return nil, err } @@ -56,13 +58,20 @@ func (r *JobRepository) GetByID(id int64) (*Job, error) { if logFile.Valid { j.LogFile = &logFile.String } + if errMsg.Valid { + j.ErrorMessage = &errMsg.String + } + if errCode.Valid { + j.ErrorCode = &errCode.String + } return &j, nil } func (r *JobRepository) GetAll(limit, offset int) ([]Job, error) { rows, err := r.db.Query(` SELECT id, sync_pair_id, trigger_type, status, started_at, finished_at, - log_file, created_at FROM jobs ORDER BY created_at DESC LIMIT ? OFFSET ?`, + log_file, error_message, error_code, created_at + FROM jobs ORDER BY created_at DESC LIMIT ? OFFSET ?`, limit, offset) if err != nil { return nil, err @@ -73,9 +82,9 @@ func (r *JobRepository) GetAll(limit, offset int) ([]Job, error) { for rows.Next() { var j Job var started, finished sql.NullTime - var logFile sql.NullString + var logFile, errMsg, errCode sql.NullString if err := rows.Scan(&j.ID, &j.SyncPairID, &j.TriggerType, &j.Status, - &started, &finished, &logFile, &j.CreatedAt); err != nil { + &started, &finished, &logFile, &errMsg, &errCode, &j.CreatedAt); err != nil { return nil, err } if started.Valid { @@ -87,6 +96,12 @@ func (r *JobRepository) GetAll(limit, offset int) ([]Job, error) { if logFile.Valid { j.LogFile = &logFile.String } + if errMsg.Valid { + j.ErrorMessage = &errMsg.String + } + if errCode.Valid { + j.ErrorCode = &errCode.String + } jobs = append(jobs, j) } return jobs, rows.Err() @@ -115,17 +130,25 @@ func (r *JobRepository) SetLogFile(id int64, path string) error { return err } +func (r *JobRepository) SetError(id int64, code, message string) error { + _, err := r.db.Exec( + "UPDATE jobs SET error_code = ?, error_message = ? WHERE id = ?", + code, message, id, + ) + return err +} + func (r *JobRepository) GetRunningBySyncPair(syncPairID int64) (*Job, error) { var j Job var started sql.NullTime - var logFile sql.NullString + var logFile, errMsg, errCode sql.NullString err := r.db.QueryRow(` SELECT id, sync_pair_id, trigger_type, status, started_at, finished_at, - log_file, created_at FROM jobs + log_file, error_message, error_code, created_at FROM jobs WHERE sync_pair_id = ? AND status IN ('queued','waking_up','running') ORDER BY created_at DESC LIMIT 1`, syncPairID).Scan( &j.ID, &j.SyncPairID, &j.TriggerType, &j.Status, &started, - &j.FinishedAt, &logFile, &j.CreatedAt) + &j.FinishedAt, &logFile, &errMsg, &errCode, &j.CreatedAt) if err != nil { return nil, err } @@ -135,6 +158,12 @@ func (r *JobRepository) GetRunningBySyncPair(syncPairID int64) (*Job, error) { if logFile.Valid { j.LogFile = &logFile.String } + if errMsg.Valid { + j.ErrorMessage = &errMsg.String + } + if errCode.Valid { + j.ErrorCode = &errCode.String + } return &j, nil } diff --git a/internal/models/job_log.go b/internal/models/job_log.go index c0bf307..4b6685d 100644 --- a/internal/models/job_log.go +++ b/internal/models/job_log.go @@ -124,7 +124,8 @@ func (r *JobLogRepository) GetAllFiltered(limit, offset int, syncPairID *int64, query := ` SELECT j.id, j.sync_pair_id, j.trigger_type, j.status, - j.started_at, j.finished_at, j.log_file, j.created_at, + j.started_at, j.finished_at, j.log_file, + j.error_message, j.error_code, j.created_at, CASE WHEN j.finished_at IS NOT NULL AND j.started_at IS NOT NULL THEN (j.finished_at - j.started_at) ELSE NULL END as duration_seconds, (SELECT COUNT(*) FROM job_logs WHERE job_id = j.id) as log_line_count @@ -143,10 +144,11 @@ func (r *JobLogRepository) GetAllFiltered(limit, offset int, syncPairID *int64, for rows.Next() { var j JobWithStats var started, finished sql.NullTime - var logFile sql.NullString + var logFile, errMsg, errCode sql.NullString var durationSeconds sql.NullInt64 if err := rows.Scan(&j.ID, &j.SyncPairID, &j.TriggerType, &j.Status, - &started, &finished, &logFile, &j.CreatedAt, + &started, &finished, &logFile, + &errMsg, &errCode, &j.CreatedAt, &durationSeconds, &j.LogLineCount); err != nil { return nil, 0, err } @@ -159,6 +161,12 @@ func (r *JobLogRepository) GetAllFiltered(limit, offset int, syncPairID *int64, if logFile.Valid { j.LogFile = &logFile.String } + if errMsg.Valid { + j.ErrorMessage = &errMsg.String + } + if errCode.Valid { + j.ErrorCode = &errCode.String + } if durationSeconds.Valid { j.DurationSeconds = &durationSeconds.Int64 } diff --git a/internal/syncengine/engine.go b/internal/syncengine/engine.go index 998b146..dd540b8 100644 --- a/internal/syncengine/engine.go +++ b/internal/syncengine/engine.go @@ -138,11 +138,12 @@ func (e *Engine) Run(ctx context.Context, jobID int64, pairID int64) error { timeout := time.Duration(targetMachine.WakeTimeoutSeconds) * time.Second interval := time.Duration(targetMachine.WakeCheckIntervalSeconds) * time.Second - if err := wol.WaitUntilReady(jobCtx, targetMachine.Host, remotePort, timeout, interval, false); err != nil { - e.setJobStatus(jobID, "failed") - e.emit(Event{Type: "status", JobID: jobID, Key: "status", Value: "failed", Line: err.Error()}) - return fmt.Errorf("machine not ready: %w", err) - } + if err := wol.WaitUntilReady(jobCtx, targetMachine.Host, remotePort, timeout, interval, false); err != nil { + e.setJobStatus(jobID, "failed") + e.setJobError(jobID, "wol_timeout", err.Error()) + e.emit(Event{Type: "status", JobID: jobID, Key: "status", Value: "failed", Line: err.Error()}) + return fmt.Errorf("machine not ready: %w", err) + } } } @@ -198,17 +199,19 @@ func (e *Engine) Run(ctx context.Context, jobID int64, pairID int64) error { if err != nil { if jobCtx.Err() != nil { - e.setJobStatus(jobID, "cancelled") + e.setJobStatus(jobID, "cancelled") e.emit(Event{Type: "status", JobID: jobID, Key: "status", Value: "cancelled"}) return jobCtx.Err() } e.setJobStatus(jobID, "failed") + e.setJobError(jobID, "rsync_error", err.Error()) e.emit(Event{Type: "status", JobID: jobID, Key: "status", Value: "failed", Line: err.Error()}) return fmt.Errorf("rsync error: %w", err) } if result.ExitCode != 0 { - e.setJobStatus(jobID, "failed") + e.setJobStatus(jobID, "failed") + e.setJobError(jobID, "exit_code", result.Stderr) e.emit(Event{Type: "status", JobID: jobID, Key: "status", Value: "failed", Line: result.Stderr}) return fmt.Errorf("rsync exited with code %d: %s", result.ExitCode, result.Stderr) } @@ -254,6 +257,11 @@ func (e *Engine) setJobLogFile(jobID int64, path string) { jobRepo.SetLogFile(jobID, path) } +func (e *Engine) setJobError(jobID int64, code, message string) { + jobRepo := models.NewJobRepository(e.db) + jobRepo.SetError(jobID, code, message) +} + func (e *Engine) emit(evt Event) { e.eventBus.Publish(evt) } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 654c9bb..ffd3650 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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; } diff --git a/web/src/components/ErrorDetailsModal.tsx b/web/src/components/ErrorDetailsModal.tsx new file mode 100644 index 0000000..15b259c --- /dev/null +++ b/web/src/components/ErrorDetailsModal.tsx @@ -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 ( + + + +
+ + Job Failed +
+ + Job #{job.id} finished with status{' '} + + +
+ + {errorInfo && ( +
+
+ + + {errorInfo.title} + +
+

{errorInfo.hint}

+
+ )} + + {job.error_message && ( +
+

+ Error Message +

+
+ {job.error_message} +
+
+ )} + +
+
+

+ Error Code +

+

+ {job.error_code ?? '—'} +

+
+
+

+ Trigger +

+

+ {job.trigger_type} +

+
+ {job.started_at && ( +
+

+ + Started +

+

+ {new Date(job.started_at).toLocaleString()} +

+
+ )} + {job.finished_at && ( +
+

+ + Finished +

+

+ {new Date(job.finished_at).toLocaleString()} +

+
+ )} +
+ + {fullLog && ( +
+

+ Stderr / Error Log +

+
+ {fullLog} +
+
+ )} +
+ + + + + +
+
+ ) +} diff --git a/web/src/lib/status.ts b/web/src/lib/status.ts index df92c49..9d7d208 100644 --- a/web/src/lib/status.ts +++ b/web/src/lib/status.ts @@ -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 = { + 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' } +} diff --git a/web/src/pages/JobDetail.tsx b/web/src/pages/JobDetail.tsx index ef667e2..16c3039 100644 --- a/web/src/pages/JobDetail.tsx +++ b/web/src/pages/JobDetail.tsx @@ -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 (
@@ -212,6 +227,43 @@ export default function JobDetail() { ))}
+ {job.status === 'failed' && job.error_message && ( +
+
+ +
+
+ + {errorInfo?.title ?? 'Job failed'} + + {job.error_code && ( + + [{job.error_code}] + + )} +
+ {errorInfo?.hint && ( +

+ {errorInfo.hint} +

+ )} +

+ {job.error_message} +

+
+ +
+
+ )} + {['queued', 'waking_up', 'running'].includes(job.status) && (
); }