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,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'
}
});
};