Add cancellation reason tracking with user/system codes and UI

This commit is contained in:
2026-07-08 17:31:22 -04:00
parent 3c1bbce9e7
commit 69c4898a56
9 changed files with 137 additions and 32 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
BINARY=syncserver BINARY=syncserver
VERSION?=1.0.7 VERSION?=1.0.8
GO?=go GO?=go
LDFLAGS=-s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) 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 BUILD_FLAGS=CGO_ENABLED=0
+1 -1
View File
@@ -20,7 +20,7 @@ import (
"github.com/syncserver/internal/syncengine" "github.com/syncserver/internal/syncengine"
) )
var version = "1.0.7" var version = "1.0.8"
func main() { func main() {
cfgPath := flag.String("config", "", "Path to config.yaml") cfgPath := flag.String("config", "", "Path to config.yaml")
+15 -1
View File
@@ -2,10 +2,12 @@ package api
import ( import (
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -109,11 +111,23 @@ func (h *JobHandler) Cancel(w http.ResponseWriter, r *http.Request) {
return 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 { if h.engine != nil {
h.engine.Cancel(id, j.SyncPairID) h.engine.Cancel(id, j.SyncPairID, true)
} }
repo.UpdateStatus(id, "cancelled") repo.UpdateStatus(id, "cancelled")
repo.SetError(id, "cancelled_user", reason)
writeJSON(w, map[string]string{"status": "cancelled"}) writeJSON(w, map[string]string{"status": "cancelled"})
} }
+17 -4
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/syncserver/internal/config" "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) 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 { if enqueueErr != nil {
return enqueueErr return enqueueErr
} }
@@ -199,8 +205,15 @@ func (e *Engine) Run(ctx context.Context, jobID int64, pairID int64) error {
if err != nil { if err != nil {
if jobCtx.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.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() return jobCtx.Err()
} }
e.setJobStatus(jobID, "failed") e.setJobStatus(jobID, "failed")
@@ -239,9 +252,9 @@ func (e *Engine) resolveSSHKey(machine *models.Machine) (string, error) {
return sshKey.PrivateKeyPath, nil 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) { if e.queue.IsRunning(syncPairID) {
e.queue.Cancel(syncPairID) e.queue.Cancel(syncPairID, byUser)
return true return true
} }
return false return false
+12 -3
View File
@@ -13,8 +13,9 @@ type Queue struct {
} }
type RunInfo struct { type RunInfo struct {
JobID int64 JobID int64
Cancel func() Cancel func()
ByUser bool
} }
func NewQueue() *Queue { func NewQueue() *Queue {
@@ -54,10 +55,18 @@ func (q *Queue) GetJobID(syncPairID int64) (int64, bool) {
return info.JobID, true return info.JobID, true
} }
func (q *Queue) Cancel(syncPairID int64) { func (q *Queue) Cancel(syncPairID int64, byUser bool) {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
if info, exists := q.runs[syncPairID]; exists && info.Cancel != nil { if info, exists := q.runs[syncPairID]; exists && info.Cancel != nil {
info.ByUser = byUser
info.Cancel() 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
}
+6 -1
View File
@@ -33,10 +33,15 @@ func TestQueue(t *testing.T) {
t.Errorf("Enqueue(1) again = %v, want ErrAlreadyRunning", err) t.Errorf("Enqueue(1) again = %v, want ErrAlreadyRunning", err)
} }
q.Cancel(1) q.Cancel(1, true)
if !cancelCalled { if !cancelCalled {
t.Error("Cancel should have called the cancel func") 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) q.Dequeue(1)
if q.IsRunning(1) { if q.IsRunning(1) {
+4
View File
@@ -89,6 +89,10 @@ export interface LogLine {
timestamp: string; timestamp: string;
} }
export interface CancelJobRequest {
reason?: string;
}
export interface SSHKey { export interface SSHKey {
id: number; id: number;
label: string; label: string;
+10 -2
View File
@@ -77,9 +77,17 @@ const ERROR_CODES: Record<string, ErrorCodeInfo> = {
title: 'SSH key not found', title: 'SSH key not found',
hint: 'Server fell back to its own key. Verify the machine SSH key configuration', hint: 'Server fell back to its own key. Verify the machine SSH key configuration',
}, },
cancelled: { cancelled_user: {
title: 'Cancelled by 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',
}, },
} }
+71 -19
View File
@@ -8,12 +8,15 @@ import { Switch } from '@/components/ui/Switch';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Spinner } from '@/components/ui/Spinner'; import { Spinner } from '@/components/ui/Spinner';
import { CopyButton } from '@/components/ui/CopyButton'; import { CopyButton } from '@/components/ui/CopyButton';
import { Textarea } from '@/components/ui/Textarea';
import { Label } from '@/components/ui/Label';
import { import {
Modal, Modal,
ModalContent, ModalContent,
ModalHeader, ModalHeader,
ModalTitle, ModalTitle,
ModalDescription, ModalDescription,
ModalBody,
ModalFooter, ModalFooter,
} from '@/components/ui/Modal'; } from '@/components/ui/Modal';
import { ErrorDetailsModal } from '@/components/ErrorDetailsModal'; import { ErrorDetailsModal } from '@/components/ErrorDetailsModal';
@@ -24,6 +27,7 @@ import {
ScrollText, ScrollText,
Terminal, Terminal,
AlertCircle, AlertCircle,
Ban,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { statusVariant, statusLabel, getErrorCodeInfo } from '@/lib/status'; import { statusVariant, statusLabel, getErrorCodeInfo } from '@/lib/status';
@@ -50,6 +54,7 @@ export default function JobDetail() {
const jobId = Number(id); const jobId = Number(id);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [cancelModal, setCancelModal] = useState(false); const [cancelModal, setCancelModal] = useState(false);
const [cancelReason, setCancelReason] = useState('');
const [errorModal, setErrorModal] = useState(false); const [errorModal, setErrorModal] = useState(false);
useEffect(() => { useEffect(() => {
@@ -112,9 +117,13 @@ export default function JobDetail() {
async function cancel() { async function cancel() {
try { 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'); toast.success('Job cancelled');
setCancelModal(false); setCancelModal(false);
setCancelReason('');
loadJob(); loadJob();
} catch (e: unknown) { } catch (e: unknown) {
toast.error((e as Error).message); toast.error((e as Error).message);
@@ -227,27 +236,47 @@ export default function JobDetail() {
))} ))}
</div> </div>
{job.status === 'failed' && job.error_message && ( {(job.status === 'failed' || (job.status === 'cancelled' && job.error_message)) && (
<div className="rounded-card border border-rose-500/30 bg-rose-500/10 p-4 space-y-2"> <div className={cn(
"rounded-card border p-4 space-y-2",
job.status === 'failed'
? "border-rose-500/30 bg-rose-500/10"
: "border-zinc-500/30 bg-zinc-500/10"
)}>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-rose-400 shrink-0 mt-0.5" /> {job.status === 'failed'
? <AlertCircle className="h-4 w-4 text-rose-400 shrink-0 mt-0.5" />
: <Ban className="h-4 w-4 text-zinc-400 shrink-0 mt-0.5" />
}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-rose-300"> <span className={cn(
{errorInfo?.title ?? 'Job failed'} "text-sm font-semibold",
job.status === 'failed' ? "text-rose-300" : "text-zinc-300"
)}>
{errorInfo?.title ?? (job.status === 'failed' ? 'Job failed' : 'Job cancelled')}
</span> </span>
{job.error_code && ( {job.error_code && (
<span className="text-xs font-mono text-rose-400/60"> <span className={cn(
"text-xs font-mono",
job.status === 'failed' ? "text-rose-400/60" : "text-zinc-400/60"
)}>
[{job.error_code}] [{job.error_code}]
</span> </span>
)} )}
</div> </div>
{errorInfo?.hint && ( {errorInfo?.hint && (
<p className="text-xs text-rose-400/70 mt-0.5"> <p className={cn(
"text-xs mt-0.5",
job.status === 'failed' ? "text-rose-400/70" : "text-zinc-400/70"
)}>
{errorInfo.hint} {errorInfo.hint}
</p> </p>
)} )}
<p className="text-xs font-mono text-rose-300/80 mt-1 truncate max-w-2xl"> <p className={cn(
"text-xs font-mono mt-1 truncate max-w-2xl",
job.status === 'failed' ? "text-rose-300/80" : "text-zinc-300/80"
)}>
{job.error_message} {job.error_message}
</p> </p>
</div> </div>
@@ -255,9 +284,14 @@ export default function JobDetail() {
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => setErrorModal(true)} 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"
)}
> >
<AlertCircle className="h-4 w-4" /> {job.status === 'failed' ? <AlertCircle className="h-4 w-4" /> : <Ban className="h-4 w-4" />}
View error View error
</Button> </Button>
</div> </div>
@@ -356,14 +390,32 @@ export default function JobDetail() {
be undone. be undone.
</ModalDescription> </ModalDescription>
</ModalHeader> </ModalHeader>
<ModalFooter> <form onSubmit={e => { e.preventDefault(); cancel(); }}>
<Button variant="secondary" onClick={() => setCancelModal(false)}> <ModalBody>
Keep Running <div className="space-y-1.5">
</Button> <Label htmlFor="cancel-reason">Reason (optional)</Label>
<Button variant="danger-solid" onClick={cancel}> <Textarea
Cancel Job id="cancel-reason"
</Button> value={cancelReason}
</ModalFooter> onChange={e => setCancelReason(e.target.value)}
placeholder="e.g. wrong path, machine offline..."
rows={2}
className="font-mono text-xs"
/>
<p className="text-xs text-fg-subtle">
Adding a reason helps track why jobs are cancelled
</p>
</div>
</ModalBody>
<ModalFooter>
<Button variant="secondary" type="button" onClick={() => { setCancelModal(false); setCancelReason(''); }}>
Keep Running
</Button>
<Button variant="danger-solid" type="submit">
Cancel Job
</Button>
</ModalFooter>
</form>
</ModalContent> </ModalContent>
</Modal> </Modal>