From d8e44fe57ea9145876ac940a16fce71022129af6 Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 03:31:32 +0000 Subject: [PATCH] =?UTF-8?q?Fullf=C3=B8rer=20oppgave=2015.2:=20Graceful=20s?= =?UTF-8?q?hutdown=20med=20vedlikeholdsmodus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementerer koordinert nedstenging der admin setter et vedlikeholdstidspunkt, brukere ser nedtelling, og systemet stenger ned trygt etter at aktive jobber er ferdige. Nye filer: - maskinrommet/src/maintenance.rs — MaintenanceState med atomiske flagg, shutdown-koordinator (vent på scheduled_at → blokker nye jobber/LiveKit → vent på kjørende jobber → exit) - frontend/src/routes/admin/+page.svelte — admin-panel for vedlikehold med statusvisning og aktive sesjoner Endringer: - jobs.rs: sjekker maintenance.is_active() før dequeue - intentions.rs: nye endepunkter (initiate/cancel/status), blokkerer join_communication under vedlikehold - main.rs: MaintenanceState i AppState, nye ruter - api.ts: klientfunksjoner for maintenance-API - adminpanelet.md: dokumenterer implementerte endepunkter Flyt: admin → GET /admin/maintenance_status (se aktive sesjoner) → POST /intentions/initiate_maintenance → varsel broadcast via STDB → frontend nedtelling → scheduled_at nådd → active=true → jobbkø pauset + LiveKit blokkert → vent maks 5 min → process::exit(0) → systemd restarter maskinrommet. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/concepts/adminpanelet.md | 15 ++ frontend/src/lib/api.ts | 56 ++++ frontend/src/routes/admin/+page.svelte | 229 +++++++++++++++++ maskinrommet/src/intentions.rs | 94 +++++++ maskinrommet/src/jobs.rs | 13 +- maskinrommet/src/main.rs | 13 +- maskinrommet/src/maintenance.rs | 340 +++++++++++++++++++++++++ tasks.md | 3 +- 8 files changed, 758 insertions(+), 5 deletions(-) create mode 100644 frontend/src/routes/admin/+page.svelte create mode 100644 maskinrommet/src/maintenance.rs diff --git a/docs/concepts/adminpanelet.md b/docs/concepts/adminpanelet.md index ff67ae6..290aba4 100644 --- a/docs/concepts/adminpanelet.md +++ b/docs/concepts/adminpanelet.md @@ -62,6 +62,21 @@ publisering. 5. Vent til aktive jobber fullføres (med timeout) 6. Restart +#### Implementert (oppgave 15.2) + +- **Backend:** `maskinrommet/src/maintenance.rs` — `MaintenanceState` med atomiske flagg + og bakgrunns-shutdown-koordinator +- **API-endepunkter:** + - `POST /intentions/initiate_maintenance` — starter nedtelling med tidspunkt + - `POST /intentions/cancel_maintenance` — avbryter planlagt vedlikehold + - `GET /admin/maintenance_status` — viser status + kjørende jobber +- **Frontend:** `/admin` — vedlikeholdspanel med statusvisning, aktive sesjoner + og initier/avbryt-knapper +- **Jobbkø:** `jobs.rs` sjekker `maintenance.is_active()` før dequeue +- **LiveKit:** `join_communication` blokkerer nye tokens under vedlikehold +- **Shutdown-flyt:** Vent til `scheduled_at` → sett `active` → vent på jobber + (5 min timeout) → `process::exit(0)` → systemd restarter + #### Varslingsnode ```jsonc diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9a585c8..2243939 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -668,3 +668,59 @@ export function resolveRetranscription( choices }); } + +// ============================================================================= +// Vedlikeholdsmodus (oppgave 15.2) +// ============================================================================= + +export interface RunningJob { + id: string; + job_type: string; + started_at: string | null; + collection_node_id: string | null; +} + +export interface MaintenanceStatus { + initiated: boolean; + active: boolean; + scheduled_at: string | null; + announcement_node_id: string | null; + initiated_by: string | null; + running_jobs: RunningJob[]; +} + +/** Hent vedlikeholdsstatus (aktive sesjoner, kjørende jobber). */ +export async function fetchMaintenanceStatus(accessToken: string): Promise { + const res = await fetch(`${BASE_URL}/admin/maintenance_status`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`maintenance_status failed (${res.status}): ${body}`); + } + return res.json(); +} + +export interface InitiateMaintenanceRequest { + scheduled_at: string; +} + +export interface InitiateMaintenanceResponse { + announcement_node_id: string; + scheduled_at: string; +} + +/** Initier planlagt vedlikehold med nedtelling. */ +export function initiateMaintenance( + accessToken: string, + data: InitiateMaintenanceRequest +): Promise { + return post(accessToken, '/intentions/initiate_maintenance', data); +} + +/** Avbryt planlagt vedlikehold. */ +export function cancelMaintenance( + accessToken: string +): Promise<{ cancelled: boolean }> { + return post(accessToken, '/intentions/cancel_maintenance', {}); +} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte new file mode 100644 index 0000000..f0c83c2 --- /dev/null +++ b/frontend/src/routes/admin/+page.svelte @@ -0,0 +1,229 @@ + + +
+
+
+
+ Hjem +

Vedlikehold

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

Logg inn for tilgang.

+ {:else if !status} +

Laster status...

+ {:else} + + {#if error} +
+ {error} +
+ {/if} + + +
+

Status

+
+
+ Modus: + {#if status.active} + + AKTIV — stenger ned + + {:else if status.initiated} + + Planlagt + + {:else} + + Normal drift + + {/if} +
+ {#if status.scheduled_at} +
+ Tidspunkt: + + {new Date(status.scheduled_at).toLocaleString('nb-NO')} + + + om {countdown(status.scheduled_at)} + +
+ {/if} +
+
+ + +
+

+ Aktive sesjoner + + ({status.running_jobs.length} kjørende jobber) + +

+ + {#if status.running_jobs.length === 0} +

Ingen kjørende jobber.

+ {:else} +
+ {#each status.running_jobs as job (job.id)} +
+ + {job.id.slice(0, 8)} + {job.job_type} + {#if job.started_at} + + startet {new Date(job.started_at).toLocaleTimeString('nb-NO')} + + {/if} +
+ {/each} +
+ {/if} +
+ + +
+

Handlinger

+ + {#if !status.initiated} +
+
+ + +
+ +
+

+ Dette sender et varsel til alle brukere, og stenger systemet ned etter nedtellingen. + Nye LiveKit-rom blokkeres og jobbkøen stoppes ved tidspunktet. + Kjørende jobber fullføres (maks 5 min timeout), deretter restarter maskinrommet. +

+ {:else} +
+ + {#if status.active} + + Nedstengning pågår — kan ikke avbrytes + + {/if} +
+ {/if} +
+ {/if} +
+
diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index 32abbe3..9117ea2 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -3319,6 +3319,11 @@ pub async fn join_communication( })? .unwrap_or_else(|| "Ukjent".to_string()); + // Blokkér nye LiveKit-rom under vedlikehold (oppgave 15.2) + if state.maintenance.is_active() { + return Err(bad_request("Nye rom er blokkert — vedlikehold pågår")); + } + // Bestem rolle let role_str = req.role.as_deref().unwrap_or("publisher"); let lk_role = match role_str { @@ -3907,6 +3912,95 @@ pub async fn expire_announcement( Ok(Json(ExpireAnnouncementResponse { expired: true })) } +// ============================================================================= +// Vedlikeholdsmodus (oppgave 15.2) +// ============================================================================= + +#[derive(Deserialize)] +pub struct InitiateMaintenanceRequest { + /// Vedlikeholdstidspunkt (ISO 8601 / RFC 3339). + pub scheduled_at: String, +} + +#[derive(Serialize)] +pub struct InitiateMaintenanceResponse { + pub announcement_node_id: Uuid, + pub scheduled_at: String, +} + +/// POST /intentions/initiate_maintenance +/// +/// Starter nedtellingen til vedlikehold. Oppretter et critical-varsel +/// som vises for alle klienter, og starter bakgrunnskoordinatoren som +/// blokkerer nye jobber/LiveKit-rom og til slutt restarter prosessen. +/// +/// Kall GET /admin/maintenance_status først for å se aktive sesjoner. +pub async fn initiate_maintenance( + State(state): State, + _user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let scheduled_at = chrono::DateTime::parse_from_rfc3339(&req.scheduled_at) + .map_err(|_| bad_request("scheduled_at må være gyldig ISO 8601 (RFC 3339)"))? + .with_timezone(&chrono::Utc); + + if scheduled_at < chrono::Utc::now() { + return Err(bad_request("scheduled_at kan ikke være i fortiden")); + } + + let user_id = _user.node_id; + + let announcement_id = state + .maintenance + .initiate(&state.db, &state.stdb, scheduled_at, user_id) + .await + .map_err(|e| bad_request(&e))?; + + Ok(Json(InitiateMaintenanceResponse { + announcement_node_id: announcement_id, + scheduled_at: scheduled_at.to_rfc3339(), + })) +} + +#[derive(Serialize)] +pub struct CancelMaintenanceResponse { + pub cancelled: bool, +} + +/// POST /intentions/cancel_maintenance +/// +/// Avbryter planlagt vedlikehold. Fjerner varselet og stopper +/// nedtellingstasken. +pub async fn cancel_maintenance( + State(state): State, + _user: AuthUser, +) -> Result, (StatusCode, Json)> { + state + .maintenance + .cancel(&state.db, &state.stdb) + .await + .map_err(|e| bad_request(&e))?; + + Ok(Json(CancelMaintenanceResponse { cancelled: true })) +} + +/// GET /admin/maintenance_status +/// +/// Returnerer vedlikeholdsstatus inkludert kjørende jobber. +/// Brukes av admin-panelet for å vise aktive sesjoner før bekreftelse. +pub async fn maintenance_status( + State(state): State, + _user: AuthUser, +) -> Result, (StatusCode, Json)> { + let status = state + .maintenance + .status(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved henting av vedlikeholdsstatus: {e}")))?; + + Ok(Json(status)) +} + // ============================================================================= // Tester // ============================================================================= diff --git a/maskinrommet/src/jobs.rs b/maskinrommet/src/jobs.rs index 2b3b1b9..9a2956f 100644 --- a/maskinrommet/src/jobs.rs +++ b/maskinrommet/src/jobs.rs @@ -12,6 +12,7 @@ use crate::agent; use crate::ai_edges; use crate::audio; use crate::cas::CasStore; +use crate::maintenance::MaintenanceState; use crate::publishing; use crate::stdb::StdbClient; use crate::summarize; @@ -231,7 +232,10 @@ async fn handle_render_index( /// Starter worker-loopen som poller job_queue. /// Kjører som en bakgrunnsoppgave i tokio. -pub fn start_worker(db: PgPool, stdb: StdbClient, cas: CasStore) { +/// +/// Respekterer vedlikeholdsmodus: når `maintenance.is_active()` er true, +/// slutter workeren å dequeue nye jobber (kjørende jobber fullføres). +pub fn start_worker(db: PgPool, stdb: StdbClient, cas: CasStore, maintenance: MaintenanceState) { let whisper_url = std::env::var("WHISPER_URL") .unwrap_or_else(|_| "http://faster-whisper:8000".to_string()); @@ -239,6 +243,13 @@ pub fn start_worker(db: PgPool, stdb: StdbClient, cas: CasStore) { tracing::info!("Jobbkø-worker startet (poll-intervall: 2s)"); loop { + // Sjekk vedlikeholdsmodus — ikke dequeue nye jobber + if maintenance.is_active() { + tracing::debug!("Vedlikeholdsmodus aktiv — jobbkø pauset"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + match dequeue(&db).await { Ok(Some(job)) => { tracing::info!( diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 995f646..e95177a 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -7,6 +7,7 @@ mod custom_domain; mod intentions; pub mod jobs; pub mod livekit; +pub mod maintenance; pub mod pruning; mod queries; pub mod publishing; @@ -38,6 +39,7 @@ pub struct AppState { pub cas: CasStore, pub index_cache: publishing::IndexCache, pub dynamic_page_cache: publishing::DynamicPageCache, + pub maintenance: maintenance::MaintenanceState, } #[derive(Serialize)] @@ -135,8 +137,11 @@ async fn main() { .expect("Kunne ikke opprette CAS-katalog"); tracing::info!(root = %cas_root, "CAS initialisert"); + // Vedlikeholdstilstand (oppgave 15.2) + let maintenance = maintenance::MaintenanceState::new(); + // Start jobbkø-worker i bakgrunnen - jobs::start_worker(db.clone(), stdb.clone(), cas.clone()); + jobs::start_worker(db.clone(), stdb.clone(), cas.clone(), maintenance.clone()); // Start periodisk CAS-pruning i bakgrunnen pruning::start_pruning_loop(db.clone(), cas.clone()); @@ -149,7 +154,7 @@ async fn main() { let index_cache = publishing::new_index_cache(); let dynamic_page_cache = publishing::new_dynamic_page_cache(); - let state = AppState { db, jwks, stdb, cas, index_cache, dynamic_page_cache }; + let state = AppState { db, jwks, stdb, cas, index_cache, dynamic_page_cache, maintenance }; // Ruter: /health er offentlig, /me krever gyldig JWT let app = Router::new() @@ -190,6 +195,10 @@ async fn main() { // Systemvarsler (oppgave 15.1) .route("/intentions/create_announcement", post(intentions::create_announcement)) .route("/intentions/expire_announcement", post(intentions::expire_announcement)) + // Vedlikeholdsmodus (oppgave 15.2) + .route("/intentions/initiate_maintenance", post(intentions::initiate_maintenance)) + .route("/intentions/cancel_maintenance", post(intentions::cancel_maintenance)) + .route("/admin/maintenance_status", get(intentions::maintenance_status)) .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/maskinrommet/src/maintenance.rs b/maskinrommet/src/maintenance.rs new file mode 100644 index 0000000..40baa9f --- /dev/null +++ b/maskinrommet/src/maintenance.rs @@ -0,0 +1,340 @@ +// Graceful shutdown — vedlikeholdsmodus med koordinert nedstengning. +// +// Flyt: +// 1. Admin kaller initiate_maintenance med tidspunkt +// 2. System oppretter systemvarsel → frontend viser nedtelling +// 3. Bakgrunnsoppgave venter til vedlikeholdstidspunkt +// 4. Setter maintenance_active → blokkerer nye LiveKit-rom + jobbkø stopper dequeue +// 5. Venter på at kjørende jobber fullføres (med timeout) +// 6. Avslutter prosessen → systemd restarter +// +// Ref: docs/concepts/adminpanelet.md, oppgave 15.2 + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::stdb::StdbClient; + +/// Delt vedlikeholdstilstand — klones inn i AppState. +#[derive(Clone)] +pub struct MaintenanceState { + /// Satt til true når vedlikeholdstidspunktet er nådd. + /// Når true: jobbkø slutter å dequeue, nye LiveKit-tokens avvises. + pub active: Arc, + + /// Satt til true når admin har initiert vedlikehold (men tidspunktet + /// trenger ikke være nådd ennå). Brukes for å vise status. + pub initiated: Arc, + + /// Vedlikeholdstidspunkt og varsel-node-id. + inner: Arc>, +} + +struct MaintenanceInner { + scheduled_at: Option>, + announcement_node_id: Option, + initiated_by: Option, + /// Handle for å avbryte den planlagte shutdown-tasken. + abort_handle: Option, +} + +/// Status-respons for admin-panelet. +#[derive(Serialize)] +pub struct MaintenanceStatus { + pub initiated: bool, + pub active: bool, + pub scheduled_at: Option, + pub announcement_node_id: Option, + pub initiated_by: Option, + pub running_jobs: Vec, +} + +#[derive(Serialize)] +pub struct RunningJob { + pub id: Uuid, + pub job_type: String, + pub started_at: Option, + pub collection_node_id: Option, +} + +impl MaintenanceState { + pub fn new() -> Self { + Self { + active: Arc::new(AtomicBool::new(false)), + initiated: Arc::new(AtomicBool::new(false)), + inner: Arc::new(Mutex::new(MaintenanceInner { + scheduled_at: None, + announcement_node_id: None, + initiated_by: None, + abort_handle: None, + })), + } + } + + /// Er vedlikeholdsmodus aktivert? (Tidspunktet er nådd.) + pub fn is_active(&self) -> bool { + self.active.load(Ordering::Relaxed) + } + + /// Er vedlikehold initiert? (Planlagt, men kanskje ikke nådd ennå.) + pub fn is_initiated(&self) -> bool { + self.initiated.load(Ordering::Relaxed) + } + + /// Hent full status inkludert kjørende jobber. + pub async fn status(&self, db: &PgPool) -> Result { + let inner = self.inner.lock().await; + let running_jobs = fetch_running_jobs(db).await?; + + Ok(MaintenanceStatus { + initiated: self.is_initiated(), + active: self.is_active(), + scheduled_at: inner.scheduled_at.map(|dt| dt.to_rfc3339()), + announcement_node_id: inner.announcement_node_id, + initiated_by: inner.initiated_by, + running_jobs, + }) + } + + /// Initier vedlikehold: sett tidspunkt, opprett varsel, start nedtelling. + /// + /// Oppretter en system_announcement med `critical`-type og `scheduled_at`. + /// Starter en bakgrunnsoppgave som venter til tidspunktet, aktiverer + /// maintenance mode, venter på jobber, og avslutter prosessen. + pub async fn initiate( + &self, + db: &PgPool, + stdb: &StdbClient, + scheduled_at: DateTime, + initiated_by: Uuid, + ) -> Result { + // Sjekk at vi ikke allerede er i vedlikeholdsmodus + if self.is_initiated() { + return Err("Vedlikehold er allerede initiert".to_string()); + } + + // Opprett systemvarsel + let node_id = Uuid::now_v7(); + let node_id_str = node_id.to_string(); + let created_by_str = initiated_by.to_string(); + + let metadata = serde_json::json!({ + "announcement_type": "critical", + "scheduled_at": scheduled_at.to_rfc3339(), + "blocks_new_sessions": true, + "maintenance_shutdown": true, + }); + let metadata_str = metadata.to_string(); + + // STDB — umiddelbar broadcast til alle klienter + stdb.create_node( + &node_id_str, + "system_announcement", + "Planlagt vedlikehold", + &format!("Systemet stenges for vedlikehold. Lagre arbeidet ditt."), + "open", + &metadata_str, + &created_by_str, + ) + .await + .map_err(|e| format!("STDB-feil: {e}"))?; + + // PG — persistent lagring + sqlx::query( + r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by) + VALUES ($1, 'system_announcement', 'Planlagt vedlikehold', + 'Systemet stenges for vedlikehold. Lagre arbeidet ditt.', + 'open', $2, $3)"#, + ) + .bind(node_id) + .bind(&metadata) + .bind(initiated_by) + .execute(db) + .await + .map_err(|e| format!("PG-feil: {e}"))?; + + tracing::info!( + announcement_id = %node_id, + scheduled_at = %scheduled_at, + initiated_by = %initiated_by, + "Vedlikehold initiert" + ); + + // Start bakgrunnsoppgave for shutdown-koordinering + let state = self.clone(); + let db2 = db.clone(); + let stdb2 = stdb.clone(); + let handle = tokio::spawn(async move { + shutdown_coordinator(state, db2, stdb2, scheduled_at, node_id).await; + }); + + // Lagre tilstand + let mut inner = self.inner.lock().await; + inner.scheduled_at = Some(scheduled_at); + inner.announcement_node_id = Some(node_id); + inner.initiated_by = Some(initiated_by); + inner.abort_handle = Some(handle.abort_handle()); + self.initiated.store(true, Ordering::Relaxed); + + Ok(node_id) + } + + /// Avbryt planlagt vedlikehold. + pub async fn cancel( + &self, + db: &PgPool, + stdb: &StdbClient, + ) -> Result<(), String> { + if !self.is_initiated() { + return Err("Ingen vedlikehold er initiert".to_string()); + } + + let mut inner = self.inner.lock().await; + + // Avbryt bakgrunnsoppgaven + if let Some(handle) = inner.abort_handle.take() { + handle.abort(); + } + + // Slett varselet fra STDB og PG + if let Some(nid) = inner.announcement_node_id.take() { + let nid_str = nid.to_string(); + if let Err(e) = stdb.delete_node(&nid_str).await { + tracing::warn!("Kunne ikke slette varsel fra STDB: {e}"); + } + if let Err(e) = sqlx::query("DELETE FROM nodes WHERE id = $1") + .bind(nid) + .execute(db) + .await + { + tracing::warn!("Kunne ikke slette varsel fra PG: {e}"); + } + } + + inner.scheduled_at = None; + inner.initiated_by = None; + + self.initiated.store(false, Ordering::Relaxed); + self.active.store(false, Ordering::Relaxed); + + tracing::info!("Vedlikehold avbrutt"); + Ok(()) + } +} + +/// Hent kjørende jobber fra job_queue. +async fn fetch_running_jobs(db: &PgPool) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, (Uuid, String, Option>, Option)>( + "SELECT id, job_type, started_at, collection_node_id FROM job_queue WHERE status = 'running'" + ) + .fetch_all(db) + .await?; + + Ok(rows.into_iter().map(|(id, job_type, started_at, collection_node_id)| { + RunningJob { + id, + job_type, + started_at: started_at.map(|dt| dt.to_rfc3339()), + collection_node_id, + } + }).collect()) +} + +/// Bakgrunnsoppgave som koordinerer nedstengningen. +/// +/// 1. Venter til scheduled_at +/// 2. Setter maintenance_active (blokkerer nye LiveKit-rom + jobbkø) +/// 3. Venter på at kjørende jobber fullføres (maks 5 min timeout) +/// 4. Avslutter prosessen (systemd restarter) +async fn shutdown_coordinator( + state: MaintenanceState, + db: PgPool, + stdb: StdbClient, + scheduled_at: DateTime, + announcement_id: Uuid, +) { + // Vent til vedlikeholdstidspunkt + let wait_duration = (scheduled_at - Utc::now()).to_std().unwrap_or_default(); + if !wait_duration.is_zero() { + tracing::info!( + seconds = wait_duration.as_secs(), + "Venter til vedlikeholdstidspunkt" + ); + tokio::time::sleep(wait_duration).await; + } + + // Aktiver vedlikeholdsmodus + state.active.store(true, Ordering::Relaxed); + tracing::warn!("Vedlikeholdsmodus AKTIV — nye jobber og LiveKit-rom blokkert"); + + // Oppdater varselet til å reflektere at vedlikehold er i gang + let active_meta = serde_json::json!({ + "announcement_type": "critical", + "scheduled_at": scheduled_at.to_rfc3339(), + "blocks_new_sessions": true, + "maintenance_shutdown": true, + "maintenance_active": true, + }); + let nid_str = announcement_id.to_string(); + let _ = stdb.update_node( + &nid_str, + "system_announcement", + "Vedlikehold pågår", + "Systemet stenger ned. Vent til vedlikeholdet er ferdig.", + "open", + &active_meta.to_string(), + ).await; + + // Vent på at kjørende jobber fullføres (maks 5 minutter) + let timeout = std::time::Duration::from_secs(300); + let start = std::time::Instant::now(); + + loop { + match fetch_running_jobs(&db).await { + Ok(jobs) if jobs.is_empty() => { + tracing::info!("Ingen kjørende jobber — klar for restart"); + break; + } + Ok(jobs) => { + tracing::info!( + count = jobs.len(), + "Venter på {} kjørende jobber", + jobs.len() + ); + } + Err(e) => { + tracing::error!("Feil ved sjekk av kjørende jobber: {e}"); + } + } + + if start.elapsed() > timeout { + tracing::warn!("Timeout (5 min) — tvinger nedstengning med kjørende jobber"); + break; + } + + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + + // Slett varselet (klienter vil se at tilkoblingen forsvinner) + let _ = stdb.delete_node(&nid_str).await; + if let Err(e) = sqlx::query("DELETE FROM nodes WHERE id = $1") + .bind(announcement_id) + .execute(&db) + .await + { + tracing::warn!("Kunne ikke slette varsel fra PG: {e}"); + } + + tracing::warn!("Avslutter prosessen for vedlikehold — systemd vil restarte"); + + // Gi litt tid til at siste logglinjer skrives + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + // Avslutt prosessen — systemd vil restarte maskinrommet + std::process::exit(0); +} diff --git a/tasks.md b/tasks.md index 92b4593..a5bd3ef 100644 --- a/tasks.md +++ b/tasks.md @@ -164,8 +164,7 @@ Uavhengige faser kan fortsatt plukkes. ## Fase 15: Adminpanel - [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`. -- [~] 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. - > Påbegynt: 2026-03-18T03:22 +- [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. - [ ] 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.