diff --git a/Makefile b/Makefile index 6a668cc..6b530f7 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY=syncserver -VERSION?=1.0.7 +VERSION?=1.0.8 GO?=go LDFLAGS=-s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_FLAGS=CGO_ENABLED=0 diff --git a/cmd/server/main.go b/cmd/server/main.go index a671e13..bfda71a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -20,7 +20,7 @@ import ( "github.com/syncserver/internal/syncengine" ) -var version = "1.0.7" +var version = "1.0.8" func main() { cfgPath := flag.String("config", "", "Path to config.yaml") diff --git a/internal/api/handlers_jobs.go b/internal/api/handlers_jobs.go index 47ba0d7..9a21cd1 100644 --- a/internal/api/handlers_jobs.go +++ b/internal/api/handlers_jobs.go @@ -2,10 +2,12 @@ package api import ( "database/sql" + "encoding/json" "fmt" "net/http" "os" "strconv" + "strings" "time" "github.com/go-chi/chi/v5" @@ -109,11 +111,23 @@ func (h *JobHandler) Cancel(w http.ResponseWriter, r *http.Request) { return } + var body struct { + Reason string `json:"reason"` + } + if r.Body != nil && r.ContentLength > 0 { + _ = json.NewDecoder(r.Body).Decode(&body) + } + reason := strings.TrimSpace(body.Reason) + if reason == "" { + reason = "Job was cancelled by user" + } + if h.engine != nil { - h.engine.Cancel(id, j.SyncPairID) + h.engine.Cancel(id, j.SyncPairID, true) } repo.UpdateStatus(id, "cancelled") + repo.SetError(id, "cancelled_user", reason) writeJSON(w, map[string]string{"status": "cancelled"}) } diff --git a/internal/syncengine/engine.go b/internal/syncengine/engine.go index dd540b8..857acc6 100644 --- a/internal/syncengine/engine.go +++ b/internal/syncengine/engine.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "sync" + "sync/atomic" "time" "github.com/syncserver/internal/config" @@ -61,7 +62,12 @@ func (e *Engine) Run(ctx context.Context, jobID int64, pairID int64) error { } jobCtx, cancel := context.WithCancel(ctx) - enqueueErr := e.queue.Enqueue(pairID, jobID, cancel) + cancelledByUser := atomic.Bool{} + wrappedCancel := func() { + cancelledByUser.Store(true) + cancel() + } + enqueueErr := e.queue.Enqueue(pairID, jobID, wrappedCancel) if enqueueErr != nil { return enqueueErr } @@ -199,8 +205,15 @@ func (e *Engine) Run(ctx context.Context, jobID int64, pairID int64) error { if err != nil { if jobCtx.Err() != nil { + code := "cancelled_shutdown" + msg := "Job was cancelled due to server shutdown" + if cancelledByUser.Load() { + code = "cancelled_user" + msg = "Job was cancelled by user" + } + e.setJobError(jobID, code, msg) e.setJobStatus(jobID, "cancelled") - e.emit(Event{Type: "status", JobID: jobID, Key: "status", Value: "cancelled"}) + e.emit(Event{Type: "status", JobID: jobID, Key: "status", Value: "cancelled", Line: msg}) return jobCtx.Err() } e.setJobStatus(jobID, "failed") @@ -239,9 +252,9 @@ func (e *Engine) resolveSSHKey(machine *models.Machine) (string, error) { return sshKey.PrivateKeyPath, nil } -func (e *Engine) Cancel(jobID int64, syncPairID int64) bool { +func (e *Engine) Cancel(jobID int64, syncPairID int64, byUser bool) bool { if e.queue.IsRunning(syncPairID) { - e.queue.Cancel(syncPairID) + e.queue.Cancel(syncPairID, byUser) return true } return false diff --git a/internal/syncengine/queue.go b/internal/syncengine/queue.go index 6812e04..726082b 100644 --- a/internal/syncengine/queue.go +++ b/internal/syncengine/queue.go @@ -13,8 +13,9 @@ type Queue struct { } type RunInfo struct { - JobID int64 - Cancel func() + JobID int64 + Cancel func() + ByUser bool } func NewQueue() *Queue { @@ -54,10 +55,18 @@ func (q *Queue) GetJobID(syncPairID int64) (int64, bool) { return info.JobID, true } -func (q *Queue) Cancel(syncPairID int64) { +func (q *Queue) Cancel(syncPairID int64, byUser bool) { q.mu.Lock() defer q.mu.Unlock() if info, exists := q.runs[syncPairID]; exists && info.Cancel != nil { + info.ByUser = byUser info.Cancel() } } + +func (q *Queue) IsCancelledByUser(syncPairID int64) bool { + q.mu.Lock() + defer q.mu.Unlock() + info, exists := q.runs[syncPairID] + return exists && info.ByUser +} diff --git a/internal/syncengine/queue_test.go b/internal/syncengine/queue_test.go index 22db185..5711eed 100644 --- a/internal/syncengine/queue_test.go +++ b/internal/syncengine/queue_test.go @@ -33,10 +33,15 @@ func TestQueue(t *testing.T) { t.Errorf("Enqueue(1) again = %v, want ErrAlreadyRunning", err) } - q.Cancel(1) + q.Cancel(1, true) if !cancelCalled { t.Error("Cancel should have called the cancel func") } + if !q.IsCancelledByUser(1) { + t.Error("IsCancelledByUser should return true after Cancel(1, true)") + } + + q.Dequeue(1) q.Dequeue(1) if q.IsRunning(1) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ffd3650..1d0911f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -89,6 +89,10 @@ export interface LogLine { timestamp: string; } +export interface CancelJobRequest { + reason?: string; +} + export interface SSHKey { id: number; label: string; diff --git a/web/src/lib/status.ts b/web/src/lib/status.ts index 9d7d208..9f8b825 100644 --- a/web/src/lib/status.ts +++ b/web/src/lib/status.ts @@ -77,9 +77,17 @@ const ERROR_CODES: Record = { 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', }, } diff --git a/web/src/pages/JobDetail.tsx b/web/src/pages/JobDetail.tsx index 16c3039..ff6efdd 100644 --- a/web/src/pages/JobDetail.tsx +++ b/web/src/pages/JobDetail.tsx @@ -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() { ))} - {job.status === 'failed' && job.error_message && ( -
+ {(job.status === 'failed' || (job.status === 'cancelled' && job.error_message)) && ( +
- + {job.status === 'failed' + ? + : + }
- - {errorInfo?.title ?? 'Job failed'} + + {errorInfo?.title ?? (job.status === 'failed' ? 'Job failed' : 'Job cancelled')} {job.error_code && ( - + [{job.error_code}] )}
{errorInfo?.hint && ( -

+

{errorInfo.hint}

)} -

+

{job.error_message}

@@ -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" + )} > - + {job.status === 'failed' ? : } View error
@@ -356,14 +390,32 @@ export default function JobDetail() { be undone. - - - - +
{ e.preventDefault(); cancel(); }}> + +
+ +