Add cancellation reason tracking with user/system codes and UI
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -89,6 +89,10 @@ export interface LogLine {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface CancelJobRequest {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface SSHKey {
|
||||
id: number;
|
||||
label: string;
|
||||
|
||||
+10
-2
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+71
-19
@@ -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>
|
||||
<ModalFooter>
|
||||
<Button variant="secondary" onClick={() => setCancelModal(false)}>
|
||||
Keep Running
|
||||
</Button>
|
||||
<Button variant="danger-solid" onClick={cancel}>
|
||||
Cancel Job
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
<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" type="button" onClick={() => { setCancelModal(false); setCancelReason(''); }}>
|
||||
Keep Running
|
||||
</Button>
|
||||
<Button variant="danger-solid" type="submit">
|
||||
Cancel Job
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user