Fullfører oppgave 15.3: Jobbkø-oversikt med admin-UI
Admin kan nå se, filtrere, retrye og avbryte jobber via /admin/jobs. Backend: - jobs.rs: list_jobs(), count_by_status(), distinct_job_types(), retry_job(), cancel_job() for admin-spørringer - intentions.rs: GET /admin/jobs, POST retry_job/cancel_job handlers - main.rs: tre nye ruter Frontend: - /admin/jobs: statusoppsummering med antall per status, filter på type/status, paginert tabell med retry/avbryt-knapper, 5s polling - /admin: navigasjonslenke til jobbkø Migrasjon: - 013_job_queue.sql: formaliserer job_queue-tabellen med admin-indekser Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8242dd8360
commit
07fa10620b
10 changed files with 701 additions and 7 deletions
|
|
@ -124,13 +124,26 @@ eksisterer utenfor samlings-modellen. Tilgang styres via:
|
||||||
Vanlige brukere ser aldri adminpanelet. Ruten er skjult og
|
Vanlige brukere ser aldri adminpanelet. Ruten er skjult og
|
||||||
tilgangskontrollert server-side.
|
tilgangskontrollert server-side.
|
||||||
|
|
||||||
|
#### Implementert (oppgave 15.3)
|
||||||
|
|
||||||
|
- **Backend:** `maskinrommet/src/jobs.rs` — `list_jobs()`, `count_by_status()`,
|
||||||
|
`distinct_job_types()`, `retry_job()`, `cancel_job()`
|
||||||
|
- **API-endepunkter:**
|
||||||
|
- `GET /admin/jobs?status=&type=&collection_id=&limit=&offset=` — jobbliste med filtre
|
||||||
|
- `POST /intentions/retry_job` — sett feilet jobb tilbake til pending
|
||||||
|
- `POST /intentions/cancel_job` — avbryt ventende jobb
|
||||||
|
- **Frontend:** `/admin/jobs` — jobbkø-oversikt med statusoppsummering,
|
||||||
|
filtrer på status/type, paginering, retry/avbryt-knapper
|
||||||
|
- **Migrasjon:** `013_job_queue.sql` — formaliserer job_queue-tabellen med
|
||||||
|
indekser for admin-spørringer
|
||||||
|
|
||||||
## Implementeringsstrategi
|
## Implementeringsstrategi
|
||||||
|
|
||||||
Adminpanelet bygges inkrementelt. Første prioritet er det som trengs
|
Adminpanelet bygges inkrementelt. Første prioritet er det som trengs
|
||||||
for daglig drift:
|
for daglig drift:
|
||||||
|
|
||||||
1. **Systemvarsler** — kritisk for å unngå avbrudd
|
1. **Systemvarsler** — kritisk for å unngå avbrudd (implementert: 15.1)
|
||||||
2. **Jobbkø-oversikt** — nødvendig for feilsøking
|
2. **Jobbkø-oversikt** — nødvendig for feilsøking (implementert: 15.3)
|
||||||
3. **AI Gateway-konfigurasjon** — nødvendig når AI-features aktiveres
|
3. **AI Gateway-konfigurasjon** — nødvendig når AI-features aktiveres
|
||||||
4. **Serverhelse** — nyttig men ikke blokkerende
|
4. **Serverhelse** — nyttig men ikke blokkerende
|
||||||
5. **Ressursstyring** — optimalisering, kan vente
|
5. **Ressursstyring** — optimalisering, kan vente
|
||||||
|
|
|
||||||
|
|
@ -127,8 +127,23 @@ Alle jobber merkes med `collection_node_id`. Rust-workers kjører som superuser
|
||||||
* Per samlings-node config (AI-prompts, navnelister) hentes fra samlings-nodens JSONB-metadata
|
* Per samlings-node config (AI-prompts, navnelister) hentes fra samlings-nodens JSONB-metadata
|
||||||
* Feilede jobber vises kun for brukere med tilgang til samlings-noden (via node_access) i admin-visningen
|
* Feilede jobber vises kun for brukere med tilgang til samlings-noden (via node_access) i admin-visningen
|
||||||
|
|
||||||
## 7. Observabilitet
|
## 7. Observabilitet og admin-API
|
||||||
- Jobber med `status = 'error'` skal være synlige i admin-visningen (SvelteKit `/admin/jobs`)
|
|
||||||
|
### Implementert (oppgave 15.3)
|
||||||
|
|
||||||
|
Admin-oversikt over jobbkøen med filtrering og handlinger.
|
||||||
|
|
||||||
|
**API-endepunkter:**
|
||||||
|
- `GET /admin/jobs?status=&type=&collection_id=&limit=&offset=` — paginert jobbliste med filtre
|
||||||
|
- `POST /intentions/retry_job` `{ job_id }` — sett feilet/retry-jobb tilbake til pending
|
||||||
|
- `POST /intentions/cancel_job` `{ job_id }` — avbryt ventende/retry-jobb
|
||||||
|
|
||||||
|
**Frontend:** `/admin/jobs` — statusoppsummering (antall per status), filter på type/status,
|
||||||
|
tabell med alle felter, retry/avbryt-knapper. Poller hvert 5. sekund.
|
||||||
|
|
||||||
|
**Kode:** `jobs.rs` (`list_jobs`, `count_by_status`, `distinct_job_types`, `retry_job`, `cancel_job`),
|
||||||
|
`intentions.rs` (API-handlers), `frontend/src/routes/admin/jobs/+page.svelte`
|
||||||
|
|
||||||
- Valgfritt: SpacetimeDB-event ved statusendring slik at UI kan vise fremdrift i sanntid (f.eks. "Transkriberer... 2/3 forsøk")
|
- Valgfritt: SpacetimeDB-event ved statusendring slik at UI kan vise fremdrift i sanntid (f.eks. "Transkriberer... 2/3 forsøk")
|
||||||
|
|
||||||
## 8. Instruks for Claude Code
|
## 8. Instruks for Claude Code
|
||||||
|
|
|
||||||
|
|
@ -724,3 +724,84 @@ export function cancelMaintenance(
|
||||||
): Promise<{ cancelled: boolean }> {
|
): Promise<{ cancelled: boolean }> {
|
||||||
return post(accessToken, '/intentions/cancel_maintenance', {});
|
return post(accessToken, '/intentions/cancel_maintenance', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Jobbkø-oversikt (oppgave 15.3)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface JobDetail {
|
||||||
|
id: string;
|
||||||
|
collection_node_id: string | null;
|
||||||
|
job_type: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
priority: number;
|
||||||
|
result: Record<string, unknown> | null;
|
||||||
|
error_msg: string | null;
|
||||||
|
attempts: number;
|
||||||
|
max_attempts: number;
|
||||||
|
created_at: string;
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
scheduled_for: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobCountByStatus {
|
||||||
|
status: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListJobsResponse {
|
||||||
|
jobs: JobDetail[];
|
||||||
|
counts: JobCountByStatus[];
|
||||||
|
job_types: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListJobsParams {
|
||||||
|
status?: string;
|
||||||
|
type?: string;
|
||||||
|
collection_id?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hent jobbliste med valgfrie filtre. */
|
||||||
|
export async function fetchJobs(
|
||||||
|
accessToken: string,
|
||||||
|
params: ListJobsParams = {}
|
||||||
|
): Promise<ListJobsResponse> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params.status) searchParams.set('status', params.status);
|
||||||
|
if (params.type) searchParams.set('type', params.type);
|
||||||
|
if (params.collection_id) searchParams.set('collection_id', params.collection_id);
|
||||||
|
if (params.limit) searchParams.set('limit', String(params.limit));
|
||||||
|
if (params.offset) searchParams.set('offset', String(params.offset));
|
||||||
|
|
||||||
|
const qs = searchParams.toString();
|
||||||
|
const url = `${BASE_URL}/admin/jobs${qs ? `?${qs}` : ''}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`jobs failed (${res.status}): ${body}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sett en feilet jobb tilbake til pending for nytt forsøk. */
|
||||||
|
export function retryJob(
|
||||||
|
accessToken: string,
|
||||||
|
jobId: string
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
return post(accessToken, '/intentions/retry_job', { job_id: jobId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Avbryt en ventende jobb. */
|
||||||
|
export function cancelJob(
|
||||||
|
accessToken: string,
|
||||||
|
jobId: string
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
return post(accessToken, '/intentions/cancel_job', { job_id: jobId });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,13 @@
|
||||||
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<a href="/" class="text-sm text-gray-500 hover:text-gray-700">Hjem</a>
|
<a href="/" class="text-sm text-gray-500 hover:text-gray-700">Hjem</a>
|
||||||
<h1 class="text-lg font-semibold text-gray-900">Vedlikehold</h1>
|
<h1 class="text-lg font-semibold text-gray-900">Admin</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="flex gap-3">
|
||||||
|
<a href="/admin/jobs" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
|
||||||
|
Jobbkø
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
306
frontend/src/routes/admin/jobs/+page.svelte
Normal file
306
frontend/src/routes/admin/jobs/+page.svelte
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Admin — Jobbkø-oversikt (oppgave 15.3)
|
||||||
|
*
|
||||||
|
* Viser aktive, ventende og feilede jobber med filtrering
|
||||||
|
* på type, samling og status. Støtter manuell retry og avbryt.
|
||||||
|
*/
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import {
|
||||||
|
fetchJobs,
|
||||||
|
retryJob,
|
||||||
|
cancelJob,
|
||||||
|
type ListJobsResponse,
|
||||||
|
type JobDetail
|
||||||
|
} from '$lib/api';
|
||||||
|
|
||||||
|
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||||
|
const accessToken = $derived(session?.accessToken as string | undefined);
|
||||||
|
|
||||||
|
let data = $state<ListJobsResponse | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let actionLoading = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Filtre
|
||||||
|
let statusFilter = $state('');
|
||||||
|
let typeFilter = $state('');
|
||||||
|
let currentOffset = $state(0);
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
// Poll hvert 5. sekund
|
||||||
|
$effect(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
loadJobs();
|
||||||
|
const interval = setInterval(loadJobs, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset offset ved filterendring
|
||||||
|
$effect(() => {
|
||||||
|
// Avhenger av statusFilter og typeFilter
|
||||||
|
void statusFilter;
|
||||||
|
void typeFilter;
|
||||||
|
currentOffset = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadJobs() {
|
||||||
|
if (!accessToken) return;
|
||||||
|
try {
|
||||||
|
data = await fetchJobs(accessToken, {
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
type: typeFilter || undefined,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: currentOffset
|
||||||
|
});
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRetry(jobId: string) {
|
||||||
|
if (!accessToken || actionLoading) return;
|
||||||
|
actionLoading = jobId;
|
||||||
|
try {
|
||||||
|
await retryJob(accessToken, jobId);
|
||||||
|
await loadJobs();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
} finally {
|
||||||
|
actionLoading = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel(jobId: string) {
|
||||||
|
if (!accessToken || actionLoading) return;
|
||||||
|
actionLoading = jobId;
|
||||||
|
try {
|
||||||
|
await cancelJob(accessToken, jobId);
|
||||||
|
await loadJobs();
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
} finally {
|
||||||
|
actionLoading = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'running': return 'bg-blue-100 text-blue-700';
|
||||||
|
case 'pending': return 'bg-yellow-100 text-yellow-700';
|
||||||
|
case 'completed': return 'bg-green-100 text-green-700';
|
||||||
|
case 'error': return 'bg-red-100 text-red-700';
|
||||||
|
case 'retry': return 'bg-amber-100 text-amber-700';
|
||||||
|
default: return 'bg-gray-100 text-gray-700';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusDot(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'running': return 'bg-blue-500';
|
||||||
|
case 'pending': return 'bg-yellow-500';
|
||||||
|
case 'completed': return 'bg-green-500';
|
||||||
|
case 'error': return 'bg-red-500';
|
||||||
|
case 'retry': return 'bg-amber-500';
|
||||||
|
default: return 'bg-gray-500';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string | null): string {
|
||||||
|
if (!iso) return '-';
|
||||||
|
return new Date(iso).toLocaleString('nb-NO', {
|
||||||
|
day: '2-digit', month: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCount(status: string): number {
|
||||||
|
if (!data) return 0;
|
||||||
|
const entry = data.counts.find(c => c.status === status);
|
||||||
|
return entry ? entry.count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalCount(): number {
|
||||||
|
if (!data) return 0;
|
||||||
|
return data.counts.reduce((sum, c) => sum + c.count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRetry(job: JobDetail): boolean {
|
||||||
|
return job.status === 'error' || job.status === 'retry';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCancel(job: JobDetail): boolean {
|
||||||
|
return job.status === 'pending' || job.status === 'retry';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<header class="border-b border-gray-200 bg-white">
|
||||||
|
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/admin" class="text-sm text-gray-500 hover:text-gray-700">Admin</a>
|
||||||
|
<span class="text-gray-300">/</span>
|
||||||
|
<h1 class="text-lg font-semibold text-gray-900">Jobbkø</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-5xl px-4 py-6">
|
||||||
|
{#if !accessToken}
|
||||||
|
<p class="text-sm text-gray-400">Logg inn for tilgang.</p>
|
||||||
|
{:else if !data}
|
||||||
|
<p class="text-sm text-gray-400">Laster jobber...</p>
|
||||||
|
{:else}
|
||||||
|
<!-- Feilmelding -->
|
||||||
|
{#if error}
|
||||||
|
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Statusoppsummering -->
|
||||||
|
<section class="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-6">
|
||||||
|
<button
|
||||||
|
onclick={() => { statusFilter = ''; }}
|
||||||
|
class="rounded-lg border border-gray-200 bg-white p-3 text-center shadow-sm hover:border-gray-300 transition-colors"
|
||||||
|
class:ring-2={statusFilter === ''}
|
||||||
|
class:ring-gray-400={statusFilter === ''}
|
||||||
|
>
|
||||||
|
<div class="text-2xl font-bold text-gray-900">{totalCount()}</div>
|
||||||
|
<div class="text-xs text-gray-500">Totalt</div>
|
||||||
|
</button>
|
||||||
|
{#each ['running', 'pending', 'retry', 'error', 'completed'] as s}
|
||||||
|
<button
|
||||||
|
onclick={() => { statusFilter = statusFilter === s ? '' : s; }}
|
||||||
|
class="rounded-lg border border-gray-200 bg-white p-3 text-center shadow-sm hover:border-gray-300 transition-colors"
|
||||||
|
class:ring-2={statusFilter === s}
|
||||||
|
class:ring-gray-400={statusFilter === s}
|
||||||
|
>
|
||||||
|
<div class="text-2xl font-bold text-gray-900">{getCount(s)}</div>
|
||||||
|
<div class="flex items-center justify-center gap-1 text-xs text-gray-500">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full {statusDot(s)}"></span>
|
||||||
|
{s}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Filtre -->
|
||||||
|
<section class="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<label for="type-filter" class="mr-1 text-xs font-medium text-gray-600">Type:</label>
|
||||||
|
<select
|
||||||
|
id="type-filter"
|
||||||
|
bind:value={typeFilter}
|
||||||
|
class="rounded border border-gray-300 px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{#each data.job_types as jt}
|
||||||
|
<option value={jt}>{jt}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => { statusFilter = ''; typeFilter = ''; currentOffset = 0; }}
|
||||||
|
class="text-xs text-gray-500 hover:text-gray-700 underline"
|
||||||
|
>
|
||||||
|
Nullstill filtre
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Jobbtabell -->
|
||||||
|
<section class="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
|
||||||
|
<th class="px-3 py-2">Status</th>
|
||||||
|
<th class="px-3 py-2">ID</th>
|
||||||
|
<th class="px-3 py-2">Type</th>
|
||||||
|
<th class="px-3 py-2">Forsøk</th>
|
||||||
|
<th class="px-3 py-2">Opprettet</th>
|
||||||
|
<th class="px-3 py-2">Startet</th>
|
||||||
|
<th class="px-3 py-2">Feil</th>
|
||||||
|
<th class="px-3 py-2">Handlinger</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.jobs as job (job.id)}
|
||||||
|
<tr class="border-b border-gray-50 hover:bg-gray-50">
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium {statusColor(job.status)}">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full {statusDot(job.status)}"></span>
|
||||||
|
{job.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 font-mono text-xs text-gray-500" title={job.id}>
|
||||||
|
{job.id.slice(0, 8)}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 font-medium text-gray-700">{job.job_type}</td>
|
||||||
|
<td class="px-3 py-2 text-gray-500">
|
||||||
|
{job.attempts}/{job.max_attempts}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-xs text-gray-400">{formatTime(job.created_at)}</td>
|
||||||
|
<td class="px-3 py-2 text-xs text-gray-400">{formatTime(job.started_at)}</td>
|
||||||
|
<td class="max-w-[200px] truncate px-3 py-2 text-xs text-red-600" title={job.error_msg ?? ''}>
|
||||||
|
{job.error_msg ?? ''}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{#if canRetry(job)}
|
||||||
|
<button
|
||||||
|
onclick={() => handleRetry(job.id)}
|
||||||
|
disabled={actionLoading === job.id}
|
||||||
|
class="rounded bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 hover:bg-blue-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if canCancel(job)}
|
||||||
|
<button
|
||||||
|
onclick={() => handleCancel(job.id)}
|
||||||
|
disabled={actionLoading === job.id}
|
||||||
|
class="rounded bg-red-50 px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="px-3 py-8 text-center text-sm text-gray-400">
|
||||||
|
Ingen jobber funnet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Paginering -->
|
||||||
|
{#if data.jobs.length >= PAGE_SIZE || currentOffset > 0}
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onclick={() => { currentOffset = Math.max(0, currentOffset - PAGE_SIZE); loadJobs(); }}
|
||||||
|
disabled={currentOffset === 0}
|
||||||
|
class="rounded border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
Forrige
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
Viser {currentOffset + 1}–{currentOffset + data.jobs.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={() => { currentOffset += PAGE_SIZE; loadJobs(); }}
|
||||||
|
disabled={data.jobs.length < PAGE_SIZE}
|
||||||
|
class="rounded border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
Neste
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
@ -4001,6 +4001,110 @@ pub async fn maintenance_status(
|
||||||
Ok(Json(status))
|
Ok(Json(status))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Jobbkø-oversikt (oppgave 15.3)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// GET /admin/jobs
|
||||||
|
///
|
||||||
|
/// Hent jobbliste med valgfrie filtre: status, type, collection_id.
|
||||||
|
/// Query-params: ?status=error&type=whisper_transcribe&collection_id=...&limit=50&offset=0
|
||||||
|
pub async fn list_jobs(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthUser,
|
||||||
|
axum::extract::Query(params): axum::extract::Query<ListJobsParams>,
|
||||||
|
) -> Result<Json<ListJobsResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let limit = params.limit.unwrap_or(50).min(200);
|
||||||
|
let offset = params.offset.unwrap_or(0);
|
||||||
|
|
||||||
|
let jobs = crate::jobs::list_jobs(
|
||||||
|
&state.db,
|
||||||
|
params.status.as_deref(),
|
||||||
|
params.r#type.as_deref(),
|
||||||
|
params.collection_id,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved henting av jobber: {e}")))?;
|
||||||
|
|
||||||
|
let counts = crate::jobs::count_by_status(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved telling av jobber: {e}")))?;
|
||||||
|
|
||||||
|
let job_types = crate::jobs::distinct_job_types(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved henting av jobbtyper: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Json(ListJobsResponse { jobs, counts, job_types }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ListJobsParams {
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub r#type: Option<String>,
|
||||||
|
pub collection_id: Option<Uuid>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ListJobsResponse {
|
||||||
|
pub jobs: Vec<crate::jobs::JobDetail>,
|
||||||
|
pub counts: Vec<crate::jobs::JobCountByStatus>,
|
||||||
|
pub job_types: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /intentions/retry_job
|
||||||
|
///
|
||||||
|
/// Sett en feilet jobb tilbake til 'pending' for nytt forsøk.
|
||||||
|
pub async fn retry_job(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthUser,
|
||||||
|
Json(req): Json<JobIdRequest>,
|
||||||
|
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let retried = crate::jobs::retry_job(&state.db, req.job_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved retry: {e}")))?;
|
||||||
|
|
||||||
|
if !retried {
|
||||||
|
return Err(bad_request("Jobben finnes ikke eller har feil status for retry"));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(job_id = %req.job_id, user = %_user.node_id, "Admin: jobb restartet");
|
||||||
|
Ok(Json(JobActionResponse { success: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /intentions/cancel_job
|
||||||
|
///
|
||||||
|
/// Avbryt en ventende jobb.
|
||||||
|
pub async fn cancel_job(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthUser,
|
||||||
|
Json(req): Json<JobIdRequest>,
|
||||||
|
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let cancelled = crate::jobs::cancel_job(&state.db, req.job_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(&format!("Feil ved avbryt: {e}")))?;
|
||||||
|
|
||||||
|
if !cancelled {
|
||||||
|
return Err(bad_request("Jobben finnes ikke eller har feil status for avbryt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(job_id = %req.job_id, user = %_user.node_id, "Admin: jobb avbrutt");
|
||||||
|
Ok(Json(JobActionResponse { success: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct JobIdRequest {
|
||||||
|
pub job_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct JobActionResponse {
|
||||||
|
pub success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tester
|
// Tester
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
//
|
//
|
||||||
// Ref: docs/infra/jobbkø.md
|
// Ref: docs/infra/jobbkø.md
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::Serialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -230,6 +232,137 @@ async fn handle_render_index(
|
||||||
publishing::render_index_to_cas(db, cas, collection_id).await
|
publishing::render_index_to_cas(db, cas, collection_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Admin-API: spørring, retry og avbryt (oppgave 15.3)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Rad med alle felter for admin-oversikt.
|
||||||
|
#[derive(sqlx::FromRow, Debug, Serialize)]
|
||||||
|
pub struct JobDetail {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub collection_node_id: Option<Uuid>,
|
||||||
|
pub job_type: String,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
pub status: String,
|
||||||
|
pub priority: i16,
|
||||||
|
pub result: Option<serde_json::Value>,
|
||||||
|
pub error_msg: Option<String>,
|
||||||
|
pub attempts: i16,
|
||||||
|
pub max_attempts: i16,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub started_at: Option<DateTime<Utc>>,
|
||||||
|
pub completed_at: Option<DateTime<Utc>>,
|
||||||
|
pub scheduled_for: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hent jobber med valgfri filtrering på status, type og samling.
|
||||||
|
/// Returnerer nyeste først, begrenset til `limit` rader.
|
||||||
|
pub async fn list_jobs(
|
||||||
|
db: &PgPool,
|
||||||
|
status_filter: Option<&str>,
|
||||||
|
type_filter: Option<&str>,
|
||||||
|
collection_filter: Option<Uuid>,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<JobDetail>, sqlx::Error> {
|
||||||
|
// Bygg dynamisk WHERE-klausul
|
||||||
|
let mut conditions: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(s) = status_filter {
|
||||||
|
conditions.push(format!("status = '{s}'"));
|
||||||
|
}
|
||||||
|
if let Some(t) = type_filter {
|
||||||
|
// Sanitize: kun alfanumeriske + underscore
|
||||||
|
let safe: String = t.chars().filter(|c| c.is_alphanumeric() || *c == '_').collect();
|
||||||
|
conditions.push(format!("job_type = '{safe}'"));
|
||||||
|
}
|
||||||
|
if let Some(cid) = collection_filter {
|
||||||
|
conditions.push(format!("collection_node_id = '{cid}'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let where_clause = if conditions.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("WHERE {}", conditions.join(" AND "))
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = format!(
|
||||||
|
r#"SELECT id, collection_node_id, job_type, payload,
|
||||||
|
status::text as status, priority, result, error_msg,
|
||||||
|
attempts, max_attempts, created_at, started_at,
|
||||||
|
completed_at, scheduled_for
|
||||||
|
FROM job_queue
|
||||||
|
{where_clause}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT {limit} OFFSET {offset}"#
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query_as::<_, JobDetail>(&query)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell jobber per status (for dashboard-oppsummering).
|
||||||
|
#[derive(sqlx::FromRow, Serialize, Debug)]
|
||||||
|
pub struct JobCountByStatus {
|
||||||
|
pub status: String,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn count_by_status(db: &PgPool) -> Result<Vec<JobCountByStatus>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, JobCountByStatus>(
|
||||||
|
"SELECT status::text as status, count(*) as count FROM job_queue GROUP BY status"
|
||||||
|
)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hent distinkte jobbtyper (for filter-dropdown).
|
||||||
|
pub async fn distinct_job_types(db: &PgPool) -> Result<Vec<String>, sqlx::Error> {
|
||||||
|
sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT DISTINCT job_type FROM job_queue ORDER BY job_type"
|
||||||
|
)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sett en feilet jobb tilbake til 'pending' for manuell retry.
|
||||||
|
/// Kun jobber med status 'error' eller 'retry' kan retryes.
|
||||||
|
pub async fn retry_job(db: &PgPool, job_id: Uuid) -> Result<bool, sqlx::Error> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"UPDATE job_queue
|
||||||
|
SET status = 'pending',
|
||||||
|
error_msg = NULL,
|
||||||
|
scheduled_for = now(),
|
||||||
|
attempts = 0
|
||||||
|
WHERE id = $1
|
||||||
|
AND status IN ('error', 'retry')"#
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avbryt en ventende eller retry-jobb (sett til 'error' med melding).
|
||||||
|
/// Kjørende jobber kan ikke avbrytes via dette (de kjører allerede i en task).
|
||||||
|
pub async fn cancel_job(db: &PgPool, job_id: Uuid) -> Result<bool, sqlx::Error> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"UPDATE job_queue
|
||||||
|
SET status = 'error',
|
||||||
|
error_msg = 'Manuelt avbrutt av admin',
|
||||||
|
completed_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND status IN ('pending', 'retry')"#
|
||||||
|
)
|
||||||
|
.bind(job_id)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Starter worker-loopen som poller job_queue.
|
/// Starter worker-loopen som poller job_queue.
|
||||||
/// Kjører som en bakgrunnsoppgave i tokio.
|
/// Kjører som en bakgrunnsoppgave i tokio.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,10 @@ async fn main() {
|
||||||
.route("/intentions/initiate_maintenance", post(intentions::initiate_maintenance))
|
.route("/intentions/initiate_maintenance", post(intentions::initiate_maintenance))
|
||||||
.route("/intentions/cancel_maintenance", post(intentions::cancel_maintenance))
|
.route("/intentions/cancel_maintenance", post(intentions::cancel_maintenance))
|
||||||
.route("/admin/maintenance_status", get(intentions::maintenance_status))
|
.route("/admin/maintenance_status", get(intentions::maintenance_status))
|
||||||
|
// Jobbkø-oversikt (oppgave 15.3)
|
||||||
|
.route("/admin/jobs", get(intentions::list_jobs))
|
||||||
|
.route("/intentions/retry_job", post(intentions::retry_job))
|
||||||
|
.route("/intentions/cancel_job", post(intentions::cancel_job))
|
||||||
.route("/query/audio_info", get(intentions::audio_info))
|
.route("/query/audio_info", get(intentions::audio_info))
|
||||||
.route("/pub/{slug}/feed.xml", get(rss::generate_feed))
|
.route("/pub/{slug}/feed.xml", get(rss::generate_feed))
|
||||||
.route("/pub/{slug}", get(publishing::serve_index))
|
.route("/pub/{slug}", get(publishing::serve_index))
|
||||||
|
|
|
||||||
34
migrations/013_job_queue.sql
Normal file
34
migrations/013_job_queue.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
-- Oppgave 15.3: Jobbkø-tabell
|
||||||
|
--
|
||||||
|
-- Denne tabellen har vært referert fra jobs.rs og maintenance.rs
|
||||||
|
-- men manglet som formell migrasjon. Oppretter den nå med enum,
|
||||||
|
-- indekser og kolonner som matcher eksisterende Rust-kode.
|
||||||
|
|
||||||
|
CREATE TYPE job_status AS ENUM ('pending', 'running', 'completed', 'error', 'retry');
|
||||||
|
|
||||||
|
CREATE TABLE job_queue (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
collection_node_id UUID REFERENCES nodes(id) ON DELETE CASCADE,
|
||||||
|
job_type TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}',
|
||||||
|
status job_status NOT NULL DEFAULT 'pending',
|
||||||
|
priority SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
result JSONB,
|
||||||
|
error_msg TEXT,
|
||||||
|
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
max_attempts SMALLINT NOT NULL DEFAULT 3,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indeks for effektiv dequeue: henter ventende/retry-jobber sortert etter prioritet
|
||||||
|
CREATE INDEX idx_job_queue_pending ON job_queue (priority DESC, scheduled_for ASC)
|
||||||
|
WHERE status IN ('pending', 'retry');
|
||||||
|
|
||||||
|
-- Indeks for admin-oversikt: filtrer på status
|
||||||
|
CREATE INDEX idx_job_queue_status ON job_queue (status, created_at DESC);
|
||||||
|
|
||||||
|
-- Indeks for filtrering på jobbtype
|
||||||
|
CREATE INDEX idx_job_queue_type ON job_queue (job_type, created_at DESC);
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -165,8 +165,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
|
|
||||||
- [x] 15.1 Systemvarsler: varslingsnode (`node_kind='system_announcement'`) med type (info/warning/critical), nedtelling og utløp. Frontend viser banner/toast for alle aktive klienter via STDB. Ref: `docs/concepts/adminpanelet.md`.
|
- [x] 15.1 Systemvarsler: varslingsnode (`node_kind='system_announcement'`) med type (info/warning/critical), nedtelling og utløp. Frontend viser banner/toast for alle aktive klienter via STDB. Ref: `docs/concepts/adminpanelet.md`.
|
||||||
- [x] 15.2 Graceful shutdown: admin setter vedlikeholdstidspunkt → nedtelling i frontend → nye LiveKit-rom blokkeres → jobbkø stopper → vent på aktive jobber → restart. Vis aktive sesjoner før bekreftelse.
|
- [x] 15.2 Graceful shutdown: admin setter vedlikeholdstidspunkt → nedtelling i frontend → nye LiveKit-rom blokkeres → jobbkø stopper → vent på aktive jobber → restart. Vis aktive sesjoner før bekreftelse.
|
||||||
- [~] 15.3 Jobbkø-oversikt: admin-UI for aktive, ventende og feilede jobber. Filtrer på type/samling/status. Manuell retry og avbryt.
|
- [x] 15.3 Jobbkø-oversikt: admin-UI for aktive, ventende og feilede jobber. Filtrer på type/samling/status. Manuell retry og avbryt.
|
||||||
> Påbegynt: 2026-03-18T03:32
|
|
||||||
- [ ] 15.4 AI Gateway-konfigurasjon: admin-UI for modelloversikt, API-nøkler (kryptert), ruting-regler per jobbtype, fallback-kjeder, forbruksoversikt per samling. Ref: `docs/infra/ai_gateway.md`.
|
- [ ] 15.4 AI Gateway-konfigurasjon: admin-UI for modelloversikt, API-nøkler (kryptert), ruting-regler per jobbtype, fallback-kjeder, forbruksoversikt per samling. Ref: `docs/infra/ai_gateway.md`.
|
||||||
- [ ] 15.5 Ressursstyring: prioritetsregler mellom jobbtyper, ressursgrenser per worker, ressurs-governor for automatisk nedprioritering under aktive LiveKit-sesjoner, disk-status med varsling.
|
- [ ] 15.5 Ressursstyring: prioritetsregler mellom jobbtyper, ressursgrenser per worker, ressurs-governor for automatisk nedprioritering under aktive LiveKit-sesjoner, disk-status med varsling.
|
||||||
- [ ] 15.6 Serverhelse-dashboard: tjeneste-status (PG, STDB, Caddy, Authentik, LiteLLM, Whisper, LiveKit), metrikker (CPU, minne, disk), backup-status, logg-tilgang.
|
- [ ] 15.6 Serverhelse-dashboard: tjeneste-status (PG, STDB, Caddy, Authentik, LiteLLM, Whisper, LiveKit), metrikker (CPU, minne, disk), backup-status, logg-tilgang.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue