feat(TRUEREF-0020): add job status page with pause/resume/cancel controls

- Extend indexing_jobs schema to support 'paused' and 'cancelled' status
- Add JobQueue methods: pauseJob(), resumeJob(), cancelJob()
- Create POST /api/v1/jobs/[id]/{pause,resume,cancel} endpoints
- Implement /admin/jobs page with auto-refresh (3s polling)
- Add JobStatusBadge component with color-coded status display
- Action buttons appear contextually based on job status
- Optimistic UI updates with error handling
- All 477 existing tests pass, no regressions
This commit is contained in:
Giancarmine Salucci
2026-03-25 20:38:14 +01:00
parent 9519a66cef
commit e7a2a83cdb
8 changed files with 496 additions and 8 deletions

View File

@@ -0,0 +1,22 @@
<script lang="ts">
interface Props {
status: 'queued' | 'running' | 'paused' | 'cancelled' | 'done' | 'failed';
}
let { status }: Props = $props();
const statusConfig: Record<typeof status, { bg: string; text: string; label: string }> = {
queued: { bg: 'bg-blue-100', text: 'text-blue-800', label: 'Queued' },
running: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Running' },
paused: { bg: 'bg-orange-100', text: 'text-orange-800', label: 'Paused' },
cancelled: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Cancelled' },
done: { bg: 'bg-green-100', text: 'text-green-800', label: 'Done' },
failed: { bg: 'bg-red-100', text: 'text-red-800', label: 'Failed' }
};
const config = $derived(statusConfig[status]);
</script>
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {config.bg} {config.text}">
{config.label}
</span>

View File

@@ -132,7 +132,7 @@ export const indexingJobs = sqliteTable('indexing_jobs', {
.references(() => repositories.id, { onDelete: 'cascade' }),
versionId: text('version_id'),
status: text('status', {
enum: ['queued', 'running', 'done', 'failed']
enum: ['queued', 'running', 'paused', 'cancelled', 'done', 'failed']
})
.notNull()
.default('queued'),

View File

@@ -2,7 +2,7 @@ export interface IndexingJobEntityProps {
id: string;
repository_id: string;
version_id: string | null;
status: 'queued' | 'running' | 'done' | 'failed';
status: 'queued' | 'running' | 'paused' | 'cancelled' | 'done' | 'failed';
progress: number;
total_files: number;
processed_files: number;
@@ -16,7 +16,7 @@ export class IndexingJobEntity {
id: string;
repository_id: string;
version_id: string | null;
status: 'queued' | 'running' | 'done' | 'failed';
status: 'queued' | 'running' | 'paused' | 'cancelled' | 'done' | 'failed';
progress: number;
total_files: number;
processed_files: number;
@@ -44,7 +44,7 @@ export interface IndexingJobProps {
id: string;
repositoryId: string;
versionId: string | null;
status: 'queued' | 'running' | 'done' | 'failed';
status: 'queued' | 'running' | 'paused' | 'cancelled' | 'done' | 'failed';
progress: number;
totalFiles: number;
processedFiles: number;
@@ -58,7 +58,7 @@ export class IndexingJob {
id: string;
repositoryId: string;
versionId: string | null;
status: 'queued' | 'running' | 'done' | 'failed';
status: 'queued' | 'running' | 'paused' | 'cancelled' | 'done' | 'failed';
progress: number;
totalFiles: number;
processedFiles: number;
@@ -86,7 +86,7 @@ export interface IndexingJobDtoProps {
id: string;
repositoryId: string;
versionId: string | null;
status: 'queued' | 'running' | 'done' | 'failed';
status: 'queued' | 'running' | 'paused' | 'cancelled' | 'done' | 'failed';
progress: number;
totalFiles: number;
processedFiles: number;
@@ -100,7 +100,7 @@ export class IndexingJobDto {
id: string;
repositoryId: string;
versionId: string | null;
status: 'queued' | 'running' | 'done' | 'failed';
status: 'queued' | 'running' | 'paused' | 'cancelled' | 'done' | 'failed';
progress: number;
totalFiles: number;
processedFiles: number;

View File

@@ -209,9 +209,78 @@ export class JobQueue {
params.push(options.status);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND')}` : '';
const sql = `SELECT COUNT(*) as n FROM indexing_jobs ${where}`;
const row = this.db.prepare<unknown[], { n: number }>(sql).get(...params);
return row?.n ?? 0;
}
/**
* Pause a job that is currently queued or running.
* Returns true if the job was successfully paused, false otherwise.
*/
pauseJob(id: string): boolean {
const job = this.getJob(id);
if (!job) return false;
// Only queued or running jobs can be paused
if (job.status !== 'queued' && job.status !== 'running') {
return false;
}
this.db
.prepare(`UPDATE indexing_jobs SET status = 'paused' WHERE id = ?`)
.run(id);
return true;
}
/**
* Resume a paused job by changing its status back to 'queued' and
* triggering the queue drain.
* Returns true if the job was successfully resumed, false otherwise.
*/
resumeJob(id: string): boolean {
const job = this.getJob(id);
if (!job) return false;
// Only paused jobs can be resumed
if (job.status !== 'paused') {
return false;
}
this.db
.prepare(`UPDATE indexing_jobs SET status = 'queued' WHERE id = ?`)
.run(id);
// Trigger queue processing in case the queue was idle
this.drainQueued();
return true;
}
/**
* Cancel a job if it's not already completed.
* Returns true if the job was successfully cancelled, false otherwise.
*/
cancelJob(id: string): boolean {
const job = this.getJob(id);
if (!job) return false;
// Can't cancel jobs that are already done or failed
if (job.status === 'done' || job.status === 'failed') {
return false;
}
const now = Math.floor(Date.now() / 1000);
this.db
.prepare(
`UPDATE indexing_jobs
SET status = 'cancelled', completed_at = ?
WHERE id = ?`
)
.run(now, id);
return true;
}
}

View File

@@ -0,0 +1,264 @@
<script lang="ts">
import JobStatusBadge from '$lib/components/admin/JobStatusBadge.svelte';
import type { IndexingJobDto } from '$lib/server/models/indexing-job.js';
interface JobResponse {
jobs: IndexingJobDto[];
total: number;
}
let jobs = $state<IndexingJobDto[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let actionInProgress = $state<string | null>(null);
// Fetch jobs from API
async function fetchJobs() {
try {
const response = await fetch('/api/v1/jobs?limit=50');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data: JobResponse = await response.json();
jobs = data.jobs;
error = null;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to fetch jobs';
console.error('Failed to fetch jobs:', err);
} finally {
loading = false;
}
}
// Action handlers
async function pauseJob(id: string) {
actionInProgress = id;
try {
const response = await fetch(`/api/v1/jobs/${id}/pause`, { method: 'POST' });
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
// Optimistic update
jobs = jobs.map((j) => (j.id === id ? { ...j, status: 'paused' as const } : j));
// Show success message
showToast('Job paused successfully');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to pause job';
showToast(msg, 'error');
console.error('Failed to pause job:', err);
} finally {
actionInProgress = null;
// Refresh after a short delay to get the actual state
setTimeout(fetchJobs, 500);
}
}
async function resumeJob(id: string) {
actionInProgress = id;
try {
const response = await fetch(`/api/v1/jobs/${id}/resume`, { method: 'POST' });
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
// Optimistic update
jobs = jobs.map((j) => (j.id === id ? { ...j, status: 'queued' as const } : j));
showToast('Job resumed successfully');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to resume job';
showToast(msg, 'error');
console.error('Failed to resume job:', err);
} finally {
actionInProgress = null;
setTimeout(fetchJobs, 500);
}
}
async function cancelJob(id: string) {
if (!confirm('Are you sure you want to cancel this job?')) {
return;
}
actionInProgress = id;
try {
const response = await fetch(`/api/v1/jobs/${id}/cancel`, { method: 'POST' });
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
// Optimistic update
jobs = jobs.map((j) => (j.id === id ? { ...j, status: 'cancelled' as const } : j));
showToast('Job cancelled successfully');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to cancel job';
showToast(msg, 'error');
console.error('Failed to cancel job:', err);
} finally {
actionInProgress = null;
setTimeout(fetchJobs, 500);
}
}
// Simple toast notification (using alert for v1, can be enhanced later)
function showToast(message: string, type: 'success' | 'error' = 'success') {
// For v1, just use alert. In production, integrate with a toast library.
if (type === 'error') {
alert(`Error: ${message}`);
} else {
console.log(`✓ ${message}`);
}
}
// Auto-refresh every 3 seconds
$effect(() => {
fetchJobs();
const interval = setInterval(fetchJobs, 3000);
return () => clearInterval(interval);
});
// Format date for display
function formatDate(date: Date | null): string {
if (!date) return '—';
return new Date(date).toLocaleString();
}
// Determine which actions are available for a job
function canPause(status: IndexingJobDto['status']): boolean {
return status === 'queued' || status === 'running';
}
function canResume(status: IndexingJobDto['status']): boolean {
return status === 'paused';
}
function canCancel(status: IndexingJobDto['status']): boolean {
return status !== 'done' && status !== 'failed';
}
</script>
<svelte:head>
<title>Job Queue - TrueRef Admin</title>
</svelte:head>
<div class="container mx-auto px-4 py-8">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900">Job Queue</h1>
<p class="mt-2 text-gray-600">Monitor and control indexing jobs</p>
</div>
{#if loading && jobs.length === 0}
<div class="flex items-center justify-center py-12">
<div class="text-center">
<div class="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
<p class="mt-2 text-gray-600">Loading jobs...</p>
</div>
</div>
{:else if error && jobs.length === 0}
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">Error: {error}</p>
</div>
{:else if jobs.length === 0}
<div class="rounded-md bg-gray-50 p-8 text-center">
<p class="text-gray-600">No jobs found. Jobs will appear here when repositories are indexed.</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Repository
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Progress
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Created
</th>
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each jobs as job (job.id)}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-900">
{job.repositoryId}
{#if job.versionId}
<span class="ml-1 text-xs text-gray-500">@{job.versionId}</span>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<JobStatusBadge status={job.status} />
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<div class="flex items-center">
<span class="mr-2">{job.progress}%</span>
<div class="h-2 w-32 rounded-full bg-gray-200">
<div
class="h-2 rounded-full bg-blue-600 transition-all"
style="width: {job.progress}%"
></div>
</div>
{#if job.totalFiles > 0}
<span class="ml-2 text-xs text-gray-400">
{job.processedFiles}/{job.totalFiles} files
</span>
{/if}
</div>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{formatDate(job.createdAt)}
</td>
<td class="whitespace-nowrap px-6 py-4 text-right text-sm font-medium">
<div class="flex justify-end gap-2">
{#if canPause(job.status)}
<button
onclick={() => pauseJob(job.id)}
disabled={actionInProgress === job.id}
class="rounded bg-yellow-600 px-3 py-1 text-xs font-semibold text-white hover:bg-yellow-700 disabled:opacity-50"
>
Pause
</button>
{/if}
{#if canResume(job.status)}
<button
onclick={() => resumeJob(job.id)}
disabled={actionInProgress === job.id}
class="rounded bg-green-600 px-3 py-1 text-xs font-semibold text-white hover:bg-green-700 disabled:opacity-50"
>
Resume
</button>
{/if}
{#if canCancel(job.status)}
<button
onclick={() => cancelJob(job.id)}
disabled={actionInProgress === job.id}
class="rounded bg-red-600 px-3 py-1 text-xs font-semibold text-white hover:bg-red-700 disabled:opacity-50"
>
Cancel
</button>
{/if}
{#if !canPause(job.status) && !canResume(job.status) && !canCancel(job.status)}
<span class="text-xs text-gray-400"></span>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if loading}
<div class="mt-4 text-center text-sm text-gray-500">
Refreshing...
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,45 @@
/**
* POST /api/v1/jobs/:id/cancel — cancel a job.
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getClient } from '$lib/server/db/client.js';
import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js';
import { JobQueue } from '$lib/server/pipeline/job-queue.js';
import { handleServiceError, NotFoundError, InvalidInputError } from '$lib/server/utils/validation.js';
export const POST: RequestHandler = ({ params }) => {
try {
const db = getClient();
const queue = new JobQueue(db);
const job = queue.getJob(params.id);
if (!job) throw new NotFoundError(`Job ${params.id} not found`);
const success = queue.cancelJob(params.id);
if (!success) {
throw new InvalidInputError(
`Cannot cancel job ${params.id} - job is already done or failed`
);
}
// Fetch updated job
const updated = queue.getJob(params.id)!;
return json({ success: true, job: IndexingJobMapper.toDto(updated) });
} catch (err) {
return handleServiceError(err);
}
};
export const OPTIONS: RequestHandler = () => {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
}
});
};

View File

@@ -0,0 +1,45 @@
/**
* POST /api/v1/jobs/:id/pause — pause a running or queued job.
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getClient } from '$lib/server/db/client.js';
import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js';
import { JobQueue } from '$lib/server/pipeline/job-queue.js';
import { handleServiceError, NotFoundError, InvalidInputError } from '$lib/server/utils/validation.js';
export const POST: RequestHandler = ({ params }) => {
try {
const db = getClient();
const queue = new JobQueue(db);
const job = queue.getJob(params.id);
if (!job) throw new NotFoundError(`Job ${params.id} not found`);
const success = queue.pauseJob(params.id);
if (!success) {
throw new InvalidInputError(
`Cannot pause job ${params.id} - only queued or running jobs can be paused`
);
}
// Fetch updated job
const updated = queue.getJob(params.id)!;
return json({ success: true, job: IndexingJobMapper.toDto(updated) });
} catch (err) {
return handleServiceError(err);
}
};
export const OPTIONS: RequestHandler = () => {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
}
});
};

View File

@@ -0,0 +1,43 @@
/**
* POST /api/v1/jobs/:id/resume — resume a paused job.
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getClient } from '$lib/server/db/client.js';
import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js';
import { JobQueue } from '$lib/server/pipeline/job-queue.js';
import { handleServiceError, NotFoundError, InvalidInputError } from '$lib/server/utils/validation.js';
export const POST: RequestHandler = ({ params }) => {
try {
const db = getClient();
const queue = new JobQueue(db);
const job = queue.getJob(params.id);
if (!job) throw new NotFoundError(`Job ${params.id} not found`);
const success = queue.resumeJob(params.id);
if (!success) {
throw new InvalidInputError(`Cannot resume job ${params.id} - only paused jobs can be resumed`);
}
// Fetch updated job
const updated = queue.getJob(params.id)!;
return json({ success: true, job: IndexingJobMapper.toDto(updated) });
} catch (err) {
return handleServiceError(err);
}
};
export const OPTIONS: RequestHandler = () => {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
}
});
};