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
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
+1 -1
View File
@@ -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")
+15 -1
View File
@@ -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"})
}
+17 -4
View File
@@ -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
+10 -1
View File
@@ -15,6 +15,7 @@ type Queue struct {
type RunInfo struct {
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
}
+6 -1
View File
@@ -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) {
+4
View File
@@ -89,6 +89,10 @@ export interface LogLine {
timestamp: string;
}
export interface CancelJobRequest {
reason?: string;
}
export interface SSHKey {
id: number;
label: string;
+10 -2
View File
@@ -77,9 +77,17 @@ const ERROR_CODES: Record<string, ErrorCodeInfo> = {
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',
},
}
+65 -13
View File
@@ -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() {
))}
</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">
{(job.status === 'failed' || (job.status === 'cancelled' && job.error_message)) && (
<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">
<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 items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-rose-300">
{errorInfo?.title ?? 'Job failed'}
<span className={cn(
"text-sm font-semibold",
job.status === 'failed' ? "text-rose-300" : "text-zinc-300"
)}>
{errorInfo?.title ?? (job.status === 'failed' ? 'Job failed' : 'Job cancelled')}
</span>
{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}]
</span>
)}
</div>
{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}
</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}
</p>
</div>
@@ -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"
)}
>
<AlertCircle className="h-4 w-4" />
{job.status === 'failed' ? <AlertCircle className="h-4 w-4" /> : <Ban className="h-4 w-4" />}
View error
</Button>
</div>
@@ -356,14 +390,32 @@ export default function JobDetail() {
be undone.
</ModalDescription>
</ModalHeader>
<form onSubmit={e => { e.preventDefault(); cancel(); }}>
<ModalBody>
<div className="space-y-1.5">
<Label htmlFor="cancel-reason">Reason (optional)</Label>
<Textarea
id="cancel-reason"
value={cancelReason}
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" onClick={() => setCancelModal(false)}>
<Button variant="secondary" type="button" onClick={() => { setCancelModal(false); setCancelReason(''); }}>
Keep Running
</Button>
<Button variant="danger-solid" onClick={cancel}>
<Button variant="danger-solid" type="submit">
Cancel Job
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>