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:
vegard 2026-03-18 03:40:56 +00:00
parent 8242dd8360
commit 07fa10620b
10 changed files with 701 additions and 7 deletions

View file

@ -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

View file

@ -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

View file

@ -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 });
}

View file

@ -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>

View 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>

View file

@ -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
// ============================================================================= // =============================================================================

View file

@ -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.
/// ///

View file

@ -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))

View 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);

View file

@ -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.