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:
264
src/routes/admin/jobs/+page.svelte
Normal file
264
src/routes/admin/jobs/+page.svelte
Normal 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>
|
||||
45
src/routes/api/v1/jobs/[id]/cancel/+server.ts
Normal file
45
src/routes/api/v1/jobs/[id]/cancel/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
45
src/routes/api/v1/jobs/[id]/pause/+server.ts
Normal file
45
src/routes/api/v1/jobs/[id]/pause/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
43
src/routes/api/v1/jobs/[id]/resume/+server.ts
Normal file
43
src/routes/api/v1/jobs/[id]/resume/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user