From 07fa10620b8ed8e286f2e51afda1a29f4828db4d Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 03:40:56 +0000 Subject: [PATCH] =?UTF-8?q?Fullf=C3=B8rer=20oppgave=2015.3:=20Jobbk=C3=B8-?= =?UTF-8?q?oversikt=20med=20admin-UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/concepts/adminpanelet.md | 17 +- docs/infra/jobbkø.md | 19 +- frontend/src/lib/api.ts | 81 ++++++ frontend/src/routes/admin/+page.svelte | 7 +- frontend/src/routes/admin/jobs/+page.svelte | 306 ++++++++++++++++++++ maskinrommet/src/intentions.rs | 104 +++++++ maskinrommet/src/jobs.rs | 133 +++++++++ maskinrommet/src/main.rs | 4 + migrations/013_job_queue.sql | 34 +++ tasks.md | 3 +- 10 files changed, 701 insertions(+), 7 deletions(-) create mode 100644 frontend/src/routes/admin/jobs/+page.svelte create mode 100644 migrations/013_job_queue.sql diff --git a/docs/concepts/adminpanelet.md b/docs/concepts/adminpanelet.md index 290aba4..7a3d7e7 100644 --- a/docs/concepts/adminpanelet.md +++ b/docs/concepts/adminpanelet.md @@ -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 diff --git a/docs/infra/jobbkø.md b/docs/infra/jobbkø.md index c15f2a9..cf01547 100644 --- a/docs/infra/jobbkø.md +++ b/docs/infra/jobbkø.md @@ -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 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2243939..08aba3f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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; + status: string; + priority: number; + result: Record | 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 { + 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 }); +} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index f0c83c2..eee3ba1 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -94,8 +94,13 @@
Hjem -

Vedlikehold

+

Admin

+
diff --git a/frontend/src/routes/admin/jobs/+page.svelte b/frontend/src/routes/admin/jobs/+page.svelte new file mode 100644 index 0000000..37e7516 --- /dev/null +++ b/frontend/src/routes/admin/jobs/+page.svelte @@ -0,0 +1,306 @@ + + +
+
+
+
+ Admin + / +

Jobbkø

+
+
+
+ +
+ {#if !accessToken} +

Logg inn for tilgang.

+ {:else if !data} +

Laster jobber...

+ {:else} + + {#if error} +
+ {error} +
+ {/if} + + +
+ + {#each ['running', 'pending', 'retry', 'error', 'completed'] as s} + + {/each} +
+ + +
+
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + {#each data.jobs as job (job.id)} + + + + + + + + + + + {:else} + + + + {/each} + +
StatusIDTypeForsøkOpprettetStartetFeilHandlinger
+ + + {job.status} + + + {job.id.slice(0, 8)} + {job.job_type} + {job.attempts}/{job.max_attempts} + {formatTime(job.created_at)}{formatTime(job.started_at)} + {job.error_msg ?? ''} + +
+ {#if canRetry(job)} + + {/if} + {#if canCancel(job)} + + {/if} +
+
+ Ingen jobber funnet. +
+
+ + + {#if data.jobs.length >= PAGE_SIZE || currentOffset > 0} +
+ + + Viser {currentOffset + 1}–{currentOffset + data.jobs.length} + + +
+ {/if} + {/if} +
+
diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index 9117ea2..05e4c26 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -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, + _user: AuthUser, + axum::extract::Query(params): axum::extract::Query, +) -> Result, (StatusCode, Json)> { + 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, + pub r#type: Option, + pub collection_id: Option, + pub limit: Option, + pub offset: Option, +} + +#[derive(Serialize)] +pub struct ListJobsResponse { + pub jobs: Vec, + pub counts: Vec, + pub job_types: Vec, +} + +/// POST /intentions/retry_job +/// +/// Sett en feilet jobb tilbake til 'pending' for nytt forsøk. +pub async fn retry_job( + State(state): State, + _user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + 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, + _user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + 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 // ============================================================================= diff --git a/maskinrommet/src/jobs.rs b/maskinrommet/src/jobs.rs index 9a2956f..5933f3b 100644 --- a/maskinrommet/src/jobs.rs +++ b/maskinrommet/src/jobs.rs @@ -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, + pub job_type: String, + pub payload: serde_json::Value, + pub status: String, + pub priority: i16, + pub result: Option, + pub error_msg: Option, + pub attempts: i16, + pub max_attempts: i16, + pub created_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, + pub scheduled_for: DateTime, +} + +/// 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, + limit: i64, + offset: i64, +) -> Result, sqlx::Error> { + // Bygg dynamisk WHERE-klausul + let mut conditions: Vec = 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, 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, 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 { + 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 { + 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. /// diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index e95177a..cb1f185 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -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)) diff --git a/migrations/013_job_queue.sql b/migrations/013_job_queue.sql new file mode 100644 index 0000000..1c10b49 --- /dev/null +++ b/migrations/013_job_queue.sql @@ -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); diff --git a/tasks.md b/tasks.md index 24c46c4..a5db6cc 100644 --- a/tasks.md +++ b/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.