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
|
||||
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
|
||||
|
||||
Adminpanelet bygges inkrementelt. Første prioritet er det som trengs
|
||||
for daglig drift:
|
||||
|
||||
1. **Systemvarsler** — kritisk for å unngå avbrudd
|
||||
2. **Jobbkø-oversikt** — nødvendig for feilsøking
|
||||
1. **Systemvarsler** — kritisk for å unngå avbrudd (implementert: 15.1)
|
||||
2. **Jobbkø-oversikt** — nødvendig for feilsøking (implementert: 15.3)
|
||||
3. **AI Gateway-konfigurasjon** — nødvendig når AI-features aktiveres
|
||||
4. **Serverhelse** — nyttig men ikke blokkerende
|
||||
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
|
||||
* Feilede jobber vises kun for brukere med tilgang til samlings-noden (via node_access) i admin-visningen
|
||||
|
||||
## 7. Observabilitet
|
||||
- Jobber med `status = 'error'` skal være synlige i admin-visningen (SvelteKit `/admin/jobs`)
|
||||
## 7. Observabilitet og admin-API
|
||||
|
||||
### 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")
|
||||
|
||||
## 8. Instruks for Claude Code
|
||||
|
|
|
|||
|
|
@ -724,3 +724,84 @@ export function cancelMaintenance(
|
|||
): Promise<{ cancelled: boolean }> {
|
||||
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="flex items-center gap-3">
|
||||
<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>
|
||||
<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>
|
||||
</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))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
//
|
||||
// Ref: docs/infra/jobbkø.md
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -230,6 +232,137 @@ async fn handle_render_index(
|
|||
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.
|
||||
/// Kjører som en bakgrunnsoppgave i tokio.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -199,6 +199,10 @@ async fn main() {
|
|||
.route("/intentions/initiate_maintenance", post(intentions::initiate_maintenance))
|
||||
.route("/intentions/cancel_maintenance", post(intentions::cancel_maintenance))
|
||||
.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("/pub/{slug}/feed.xml", get(rss::generate_feed))
|
||||
.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.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.
|
||||
> Påbegynt: 2026-03-18T03:32
|
||||
- [x] 15.3 Jobbkø-oversikt: admin-UI for aktive, ventende og feilede jobber. Filtrer på type/samling/status. Manuell retry og avbryt.
|
||||
- [ ] 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.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