feat: add retry/delete for jobs
All checks were successful
Build & Push Docker Image / build-and-push (push) Successful in 41s

- db.ts: add resetJob() and deleteJob() statements + exports
- pipeline.ts: export retryJob() — resets job state and re-runs pipeline
- DELETE /api/jobs/[id]: hard-delete terminal jobs (done/failed/cancelled);
  keep cancel-only behavior for active jobs
- POST /api/jobs/[id]/retry: new endpoint; validates failed/cancelled URL job,
  resets and re-runs via retryJob()
- jobs/[id]/+page.svelte: wire Cancel/Retry/Delete buttons with fetch calls;
  fix hardcoded ACCENT → accent store
- jobs/+page.svelte: per-row Retry+Delete icon buttons (visible on hover);
  fix hardcoded ACCENT → accent store

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Giancarmine Salucci
2026-05-06 17:42:54 +02:00
parent 37175ec791
commit d1295ce343
6 changed files with 207 additions and 15 deletions

View File

@@ -1,5 +1,6 @@
import { json, error } from '@sveltejs/kit';
import { getJob, setJobStatus } from '$lib/server/db.js';
import { getJob, setJobStatus, deleteJob } from '$lib/server/db.js';
import { rm } from 'fs/promises';
export async function GET({ params }) {
const job = getJob(params.id);
@@ -7,12 +8,19 @@ export async function GET({ params }) {
return json(job);
}
const ACTIVE = new Set(['pending', 'downloading', 'preparing', 'transcribing', 'processing']);
export async function DELETE({ params }) {
const job = getJob(params.id);
if (!job) throw error(404, 'Job not found');
if (job.status === 'done' || job.status === 'failed') {
throw error(409, 'Job already completed');
if (ACTIVE.has(job.status)) {
// Cancel active job (keeps DB record)
setJobStatus(params.id, 'cancelled', 0);
} else {
// Hard-delete terminal job + clean up output files
deleteJob(params.id);
if (job.outputDir) rm(job.outputDir, { recursive: true, force: true }).catch(() => {});
}
setJobStatus(params.id, 'cancelled', 0);
return new Response(null, { status: 204 });
}

View File

@@ -0,0 +1,16 @@
import { json, error } from '@sveltejs/kit';
import { getJob } from '$lib/server/db.js';
import { retryJob } from '$lib/server/pipeline.js';
export async function POST({ params }) {
const job = getJob(params.id);
if (!job) throw error(404, 'Job not found');
if (!['failed', 'cancelled'].includes(job.status)) {
throw error(409, 'Only failed or cancelled jobs can be retried');
}
if (!job.source.startsWith('http')) {
throw error(422, 'Cannot retry a file upload — please re-upload the file');
}
await retryJob(params.id);
return json({ ok: true });
}

View File

@@ -3,8 +3,9 @@
import type { Job } from '$lib/types.js';
import SourceIcon from '$lib/components/SourceIcon.svelte';
import Waveform from '$lib/components/Waveform.svelte';
import { accent } from '$lib/accent.js';
const ACCENT = '#cdf24e';
const ACCENT = $derived($accent.value);
let jobs = $state<Job[]>([]);
let loading = $state(true);
@@ -51,6 +52,30 @@
if (res.ok) jobs = await res.json();
loading = false;
});
async function deleteJob(e: MouseEvent, job: Job) {
e.preventDefault();
e.stopPropagation();
if (!confirm(`Delete "${job.title || job.id}"?`)) return;
const res = await fetch(`/api/jobs/${job.id}`, { method: 'DELETE' });
if (res.ok) jobs = jobs.filter((j) => j.id !== job.id);
}
async function retryJob(e: MouseEvent, job: Job) {
e.preventDefault();
e.stopPropagation();
const res = await fetch(`/api/jobs/${job.id}/retry`, { method: 'POST' });
if (res.ok) {
const updated = await fetch(`/api/jobs/${job.id}`);
if (updated.ok) {
const j = await updated.json();
jobs = jobs.map((x) => (x.id === job.id ? j : x));
}
} else {
const body = await res.json().catch(() => ({}));
alert(body.message ?? 'Retry failed');
}
}
</script>
<svelte:head>
@@ -106,6 +131,26 @@
{:else}
<div class="job-pct mono" style="color: {statusColor[job.status]}">{job.status}</div>
{/if}
<!-- Row actions -->
<div class="row-actions">
{#if ['failed', 'cancelled'].includes(job.status) && job.source?.startsWith('http')}
<button
class="row-btn accent"
onclick={(e) => retryJob(e, job)}
aria-label="Retry"
title="Retry"
></button>
{/if}
{#if ['done', 'failed', 'cancelled'].includes(job.status)}
<button
class="row-btn danger"
onclick={(e) => deleteJob(e, job)}
aria-label="Delete"
title="Delete"
></button>
{/if}
</div>
</a>
{/each}
</div>
@@ -201,6 +246,48 @@
flex-shrink: 0;
}
.row-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.job-row:hover .row-actions {
opacity: 1;
}
.row-btn {
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid transparent;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
transition: background 0.15s;
}
.row-btn.danger {
border-color: rgba(255, 90, 90, 0.3);
background: rgba(255, 90, 90, 0.08);
color: #ff8a8a;
}
.row-btn.danger:hover {
background: rgba(255, 90, 90, 0.2);
}
.row-btn.accent {
border-color: color-mix(in oklab, var(--accent) 40%, transparent);
background: color-mix(in oklab, var(--accent) 10%, transparent);
color: var(--accent);
}
.row-btn.accent:hover {
background: color-mix(in oklab, var(--accent) 20%, transparent);
}
@media (max-width: 768px) {
.page {
padding: 20px 16px;

View File

@@ -4,8 +4,9 @@
import type { Job, Segment } from '$lib/types.js';
import SourceIcon from '$lib/components/SourceIcon.svelte';
import Waveform from '$lib/components/Waveform.svelte';
import { accent } from '$lib/accent.js';
const ACCENT = '#cdf24e';
const ACCENT = $derived($accent.value);
const jobId = $derived($page.params.id);
let job = $state<Job | null>(null);
@@ -108,6 +109,36 @@
const formats = ['srt', 'txt', 'md', 'json'] as const;
const isActive = $derived(!job || !['done', 'failed', 'cancelled'].includes(job.status));
const isTerminal = $derived(job !== null && ['done', 'failed', 'cancelled'].includes(job.status));
const canRetry = $derived(
job !== null &&
['failed', 'cancelled'].includes(job.status) &&
(job.source?.startsWith('http') ?? false)
);
async function cancelJob() {
if (!job) return;
await fetch(`/api/jobs/${job.id}`, { method: 'DELETE' });
await loadJob();
}
async function deleteJob() {
if (!job || !confirm(`Delete job "${job.title || job.id}"?`)) return;
const res = await fetch(`/api/jobs/${job.id}`, { method: 'DELETE' });
if (res.ok) window.location.href = '/';
}
async function retryJobAction() {
if (!job) return;
const res = await fetch(`/api/jobs/${job.id}/retry`, { method: 'POST' });
if (res.ok) {
await loadJob();
openStream();
} else {
const body = await res.json().catch(() => ({}));
alert(body.message ?? 'Retry failed');
}
}
</script>
<svelte:head>
@@ -143,11 +174,17 @@
{#if job.meanVolume != null}· {job.meanVolume.toFixed(1)} dBFS{/if}
</div>
</div>
<div class="job-actions">
{#if isActive}
<form method="POST" action="/api/jobs/{job.id}?_method=DELETE">
<button type="button" class="btn-cancel" aria-label="Cancel job">Cancel</button>
</form>
<button class="btn-job-action danger" onclick={cancelJob} aria-label="Cancel job">Cancel</button>
{/if}
{#if canRetry}
<button class="btn-job-action accent" onclick={retryJobAction} aria-label="Retry job">↺ Retry</button>
{/if}
{#if isTerminal}
<button class="btn-job-action danger" onclick={deleteJob} aria-label="Delete job">Delete</button>
{/if}
</div>
</div>
<!-- ── Progress block ────────────────────────────────── -->
@@ -377,20 +414,37 @@
text-overflow: ellipsis;
}
.btn-cancel {
.job-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn-job-action {
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgba(255, 90, 90, 0.3);
background: rgba(255, 90, 90, 0.08);
color: #ff8a8a;
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.btn-cancel:hover {
.btn-job-action.danger {
border: 1px solid rgba(255, 90, 90, 0.3);
background: rgba(255, 90, 90, 0.08);
color: #ff8a8a;
}
.btn-job-action.danger:hover {
background: rgba(255, 90, 90, 0.15);
}
.btn-job-action.accent {
border: 1px solid color-mix(in oklab, var(--accent) 40%, transparent);
background: color-mix(in oklab, var(--accent) 10%, transparent);
color: var(--accent);
}
.btn-job-action.accent:hover {
background: color-mix(in oklab, var(--accent) 18%, transparent);
}
/* ── Progress card ──────────────────────────────────────── */
.progress-card {