Add cancellation reason tracking with user/system codes and UI
This commit is contained in:
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+65
-13
@@ -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>
|
||||||
|
<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>
|
<ModalFooter>
|
||||||
<Button variant="secondary" onClick={() => setCancelModal(false)}>
|
<Button variant="secondary" type="button" onClick={() => { setCancelModal(false); setCancelReason(''); }}>
|
||||||
Keep Running
|
Keep Running
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger-solid" onClick={cancel}>
|
<Button variant="danger-solid" type="submit">
|
||||||
Cancel Job
|
Cancel Job
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user