Fullfører oppgave 15.1: Systemvarsler via STDB
Implementerer systemvarsler (system_announcement-noder) som vises som banner/toast for alle aktive klienter i sanntid via SpacetimeDB. Backend (maskinrommet): - POST /intentions/create_announcement: oppretter varslingsnode med validering av announcement_type (info/warning/critical), datoer (scheduled_at, expires_at) og blocks_new_sessions. Visibility settes automatisk til 'open' for broadcast til alle klienter. - POST /intentions/expire_announcement: fjerner varslingsnode med tilgangskontroll (kun eier kan slette). Frontend: - SystemAnnouncements.svelte: filtrerer nodeStore for aktive system_announcement-noder, skjuler utløpte (expires_at), viser nedtelling for planlagte hendelser (scheduled_at). Fargekoding: rød (critical), gul (warning), blå (info). Critical kan ikke dismisses. Oppdaterer nedtelling hvert sekund. - Komponent lagt til i +layout.svelte for global synlighet. - API-funksjoner (createAnnouncement, expireAnnouncement) i api.ts. Ref: docs/concepts/adminpanelet.md § "Systemvarsler og vedlikeholdsmodus" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1c313f3857
commit
831666012a
6 changed files with 410 additions and 2 deletions
|
|
@ -612,6 +612,47 @@ export function deleteNode(
|
||||||
return post(accessToken, '/intentions/delete_node', { node_id: nodeId });
|
return post(accessToken, '/intentions/delete_node', { node_id: nodeId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Systemvarsler (oppgave 15.1)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface CreateAnnouncementRequest {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
announcement_type: 'info' | 'warning' | 'critical';
|
||||||
|
scheduled_at?: string;
|
||||||
|
expires_at?: string;
|
||||||
|
blocks_new_sessions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAnnouncementResponse {
|
||||||
|
node_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opprett et systemvarsel (vises for alle klienter umiddelbart via STDB). */
|
||||||
|
export function createAnnouncement(
|
||||||
|
accessToken: string,
|
||||||
|
data: CreateAnnouncementRequest
|
||||||
|
): Promise<CreateAnnouncementResponse> {
|
||||||
|
return post(accessToken, '/intentions/create_announcement', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpireAnnouncementRequest {
|
||||||
|
node_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpireAnnouncementResponse {
|
||||||
|
expired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fjern/utløp et systemvarsel. */
|
||||||
|
export function expireAnnouncement(
|
||||||
|
accessToken: string,
|
||||||
|
data: ExpireAnnouncementRequest
|
||||||
|
): Promise<ExpireAnnouncementResponse> {
|
||||||
|
return post(accessToken, '/intentions/expire_announcement', data);
|
||||||
|
}
|
||||||
|
|
||||||
/** Anvend brukerens per-segment-valg etter re-transkripsjon. */
|
/** Anvend brukerens per-segment-valg etter re-transkripsjon. */
|
||||||
export function resolveRetranscription(
|
export function resolveRetranscription(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
|
|
|
||||||
172
frontend/src/lib/components/SystemAnnouncements.svelte
Normal file
172
frontend/src/lib/components/SystemAnnouncements.svelte
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* SystemAnnouncements — viser aktive systemvarsler som banner/toast.
|
||||||
|
*
|
||||||
|
* Filtrerer nodeStore for noder med node_kind='system_announcement',
|
||||||
|
* skjuler utløpte varsler (expires_at), og viser nedtelling for
|
||||||
|
* planlagte hendelser (scheduled_at).
|
||||||
|
*
|
||||||
|
* Varslingstyper:
|
||||||
|
* - info: blå banner (generell melding)
|
||||||
|
* - warning: gul banner (planlagt vedlikehold med nedtelling)
|
||||||
|
* - critical: rød banner (umiddelbar handling kreves)
|
||||||
|
*
|
||||||
|
* Ref: docs/concepts/adminpanelet.md § "Systemvarsler og vedlikeholdsmodus"
|
||||||
|
* Oppgave 15.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nodeStore, connectionState } from '$lib/spacetime';
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
|
||||||
|
const connected = $derived(connectionState.current === 'connected');
|
||||||
|
|
||||||
|
// Tick every second for countdown updates
|
||||||
|
let now = $state(Date.now());
|
||||||
|
$effect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
now = Date.now();
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AnnouncementMeta {
|
||||||
|
announcement_type: 'info' | 'warning' | 'critical';
|
||||||
|
scheduled_at?: string | null;
|
||||||
|
expires_at?: string | null;
|
||||||
|
blocks_new_sessions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMeta(node: Node): AnnouncementMeta | null {
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(node.metadata ?? '{}');
|
||||||
|
if (!meta.announcement_type) return null;
|
||||||
|
return meta as AnnouncementMeta;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active (non-expired) system announcements, sorted by type priority. */
|
||||||
|
const announcements = $derived.by(() => {
|
||||||
|
if (!connected) return [];
|
||||||
|
|
||||||
|
const typePriority: Record<string, number> = { critical: 0, warning: 1, info: 2 };
|
||||||
|
const result: Array<{ node: Node; meta: AnnouncementMeta }> = [];
|
||||||
|
|
||||||
|
for (const node of nodeStore.byKind('system_announcement')) {
|
||||||
|
const meta = parseMeta(node);
|
||||||
|
if (!meta) continue;
|
||||||
|
|
||||||
|
// Skip expired announcements
|
||||||
|
if (meta.expires_at) {
|
||||||
|
const expiresAt = new Date(meta.expires_at).getTime();
|
||||||
|
if (expiresAt <= now) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({ node, meta });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: critical first, then warning, then info
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const pa = typePriority[a.meta.announcement_type] ?? 9;
|
||||||
|
const pb = typePriority[b.meta.announcement_type] ?? 9;
|
||||||
|
return pa - pb;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Format countdown string from now to a target date. */
|
||||||
|
function countdown(targetIso: string): string {
|
||||||
|
const target = new Date(targetIso).getTime();
|
||||||
|
const diff = target - now;
|
||||||
|
|
||||||
|
if (diff <= 0) return 'nå';
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
const remainMinutes = minutes % 60;
|
||||||
|
return `${hours}t ${remainMinutes}m`;
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
const remainSeconds = seconds % 60;
|
||||||
|
return `${minutes}m ${remainSeconds}s`;
|
||||||
|
}
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismissed announcements (per session, not persisted)
|
||||||
|
let dismissed = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
function dismiss(nodeId: string) {
|
||||||
|
dismissed = new Set([...dismissed, nodeId]);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if announcements.length > 0}
|
||||||
|
<div class="fixed top-0 left-0 right-0 z-50 flex flex-col gap-0">
|
||||||
|
{#each announcements as { node, meta } (node.id)}
|
||||||
|
{#if !dismissed.has(node.id)}
|
||||||
|
{@const isCritical = meta.announcement_type === 'critical'}
|
||||||
|
{@const isWarning = meta.announcement_type === 'warning'}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm shadow-sm {
|
||||||
|
isCritical
|
||||||
|
? 'bg-red-600 text-white'
|
||||||
|
: isWarning
|
||||||
|
? 'bg-amber-500 text-amber-950'
|
||||||
|
: 'bg-blue-500 text-white'
|
||||||
|
}"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div class="mx-auto flex max-w-4xl flex-1 items-center gap-3">
|
||||||
|
<!-- Icon -->
|
||||||
|
<span class="shrink-0 text-base" aria-hidden="true">
|
||||||
|
{#if isCritical}⚠{:else if isWarning}🔧{:else}ℹ{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="font-semibold">{node.title}</span>
|
||||||
|
{#if node.content}
|
||||||
|
<span class="ml-1 opacity-90">— {node.content}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Countdown (for scheduled events) -->
|
||||||
|
{#if meta.scheduled_at}
|
||||||
|
{@const remaining = countdown(meta.scheduled_at)}
|
||||||
|
<span class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-bold {
|
||||||
|
isCritical
|
||||||
|
? 'bg-red-800 text-red-100'
|
||||||
|
: isWarning
|
||||||
|
? 'bg-amber-700 text-amber-100'
|
||||||
|
: 'bg-blue-700 text-blue-100'
|
||||||
|
}">
|
||||||
|
om {remaining}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Dismiss button (not for critical) -->
|
||||||
|
{#if !isCritical}
|
||||||
|
<button
|
||||||
|
onclick={() => dismiss(node.id)}
|
||||||
|
class="shrink-0 rounded p-1 opacity-70 hover:opacity-100 {
|
||||||
|
isWarning ? 'hover:bg-amber-600' : 'hover:bg-blue-600'
|
||||||
|
}"
|
||||||
|
aria-label="Lukk varsel"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { stdb } from '$lib/spacetime';
|
import { stdb } from '$lib/spacetime';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import SystemAnnouncements from '$lib/components/SystemAnnouncements.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
|
@ -17,4 +18,5 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<SystemAnnouncements />
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
|
||||||
|
|
@ -3716,6 +3716,197 @@ pub async fn audio_info(
|
||||||
Ok(Json(info))
|
Ok(Json(info))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Systemvarsler (oppgave 15.1)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Gyldige varslingstyper for system_announcement-noder.
|
||||||
|
const VALID_ANNOUNCEMENT_TYPES: &[&str] = &["info", "warning", "critical"];
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateAnnouncementRequest {
|
||||||
|
/// Tittel på varselet.
|
||||||
|
pub title: String,
|
||||||
|
/// Innhold/meldingstekst.
|
||||||
|
pub content: String,
|
||||||
|
/// Type varsel: info, warning, critical.
|
||||||
|
pub announcement_type: String,
|
||||||
|
/// Tidspunkt varselet gjelder (f.eks. vedlikeholdstidspunkt). Valgfritt.
|
||||||
|
pub scheduled_at: Option<String>,
|
||||||
|
/// Når varselet automatisk utløper (ISO 8601). Valgfritt.
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
/// Om nye sesjoner skal blokkeres (vedlikeholdsmodus). Default: false.
|
||||||
|
pub blocks_new_sessions: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CreateAnnouncementResponse {
|
||||||
|
pub node_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /intentions/create_announcement
|
||||||
|
///
|
||||||
|
/// Oppretter en systemvarslingsnode med `visibility: open` slik at alle
|
||||||
|
/// aktive klienter ser varselet via STDB umiddelbart.
|
||||||
|
///
|
||||||
|
/// Kun autentiserte brukere kan opprette varsler (MVP — full admin-sjekk
|
||||||
|
/// legges til når admin-rollesystemet er på plass).
|
||||||
|
///
|
||||||
|
/// Ref: docs/concepts/adminpanelet.md § "Systemvarsler og vedlikeholdsmodus"
|
||||||
|
pub async fn create_announcement(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Json(req): Json<CreateAnnouncementRequest>,
|
||||||
|
) -> Result<Json<CreateAnnouncementResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
// -- Valider announcement_type --
|
||||||
|
if !VALID_ANNOUNCEMENT_TYPES.contains(&req.announcement_type.as_str()) {
|
||||||
|
return Err(bad_request(&format!(
|
||||||
|
"Ugyldig announcement_type: '{}'. Gyldige verdier: {:?}",
|
||||||
|
req.announcement_type, VALID_ANNOUNCEMENT_TYPES
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.title.trim().is_empty() {
|
||||||
|
return Err(bad_request("title kan ikke være tom"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Valider datoer hvis satt --
|
||||||
|
if let Some(ref s) = req.scheduled_at {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(s)
|
||||||
|
.map_err(|_| bad_request("scheduled_at må være gyldig ISO 8601 (RFC 3339)"))?;
|
||||||
|
}
|
||||||
|
if let Some(ref s) = req.expires_at {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(s)
|
||||||
|
.map_err(|_| bad_request("expires_at må være gyldig ISO 8601 (RFC 3339)"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Bygg metadata --
|
||||||
|
let metadata = serde_json::json!({
|
||||||
|
"announcement_type": req.announcement_type,
|
||||||
|
"scheduled_at": req.scheduled_at,
|
||||||
|
"expires_at": req.expires_at,
|
||||||
|
"blocks_new_sessions": req.blocks_new_sessions.unwrap_or(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
let node_id = Uuid::now_v7();
|
||||||
|
let node_id_str = node_id.to_string();
|
||||||
|
let created_by_str = user.node_id.to_string();
|
||||||
|
let metadata_str = metadata.to_string();
|
||||||
|
|
||||||
|
// -- Skriv til SpacetimeDB (instant broadcast til alle klienter) --
|
||||||
|
state
|
||||||
|
.stdb
|
||||||
|
.create_node(
|
||||||
|
&node_id_str,
|
||||||
|
"system_announcement",
|
||||||
|
&req.title,
|
||||||
|
&req.content,
|
||||||
|
"open",
|
||||||
|
&metadata_str,
|
||||||
|
&created_by_str,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| stdb_error("create_node (announcement)", e))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
node_id = %node_id,
|
||||||
|
announcement_type = %req.announcement_type,
|
||||||
|
created_by = %user.node_id,
|
||||||
|
"Systemvarsel opprettet i STDB"
|
||||||
|
);
|
||||||
|
|
||||||
|
// -- Persister til PostgreSQL asynkront --
|
||||||
|
spawn_pg_insert_node(
|
||||||
|
state.db.clone(),
|
||||||
|
node_id,
|
||||||
|
"system_announcement".to_string(),
|
||||||
|
req.title,
|
||||||
|
req.content,
|
||||||
|
"open".to_string(),
|
||||||
|
metadata,
|
||||||
|
user.node_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(CreateAnnouncementResponse { node_id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ExpireAnnouncementRequest {
|
||||||
|
/// ID-en til varslingsnoden som skal utløpe/fjernes.
|
||||||
|
pub node_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ExpireAnnouncementResponse {
|
||||||
|
pub expired: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /intentions/expire_announcement
|
||||||
|
///
|
||||||
|
/// Fjerner (sletter) en systemvarslingsnode. Sletter fra STDB først
|
||||||
|
/// (umiddelbar fjerning fra alle klienter), deretter fra PG.
|
||||||
|
///
|
||||||
|
/// Kun eier (created_by) eller admin kan fjerne varsler.
|
||||||
|
pub async fn expire_announcement(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Json(req): Json<ExpireAnnouncementRequest>,
|
||||||
|
) -> Result<Json<ExpireAnnouncementResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
// -- Sjekk at noden eksisterer og er en system_announcement --
|
||||||
|
let node = sqlx::query_as::<_, NodeRow>(
|
||||||
|
"SELECT node_kind, title, content, visibility, metadata FROM nodes WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(req.node_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("PG-feil ved oppslag: {e}");
|
||||||
|
internal_error("Databasefeil")
|
||||||
|
})?
|
||||||
|
.ok_or_else(|| bad_request("Noden finnes ikke"))?;
|
||||||
|
|
||||||
|
if node.node_kind != "system_announcement" {
|
||||||
|
return Err(bad_request("Noden er ikke en systemvarslingsnode"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Tilgangskontroll --
|
||||||
|
let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
||||||
|
internal_error("Databasefeil ved tilgangssjekk")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !can_modify {
|
||||||
|
return Err(forbidden("Kun eier kan fjerne systemvarsler"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Slett fra STDB (umiddelbar fjerning fra alle klienter) --
|
||||||
|
let node_id_str = req.node_id.to_string();
|
||||||
|
state
|
||||||
|
.stdb
|
||||||
|
.delete_node(&node_id_str)
|
||||||
|
.await
|
||||||
|
.map_err(|e| stdb_error("delete_node (announcement)", e))?;
|
||||||
|
|
||||||
|
// -- Slett fra PG --
|
||||||
|
let db = state.db.clone();
|
||||||
|
let nid = req.node_id;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = sqlx::query("DELETE FROM nodes WHERE id = $1")
|
||||||
|
.bind(nid)
|
||||||
|
.execute(&db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(node_id = %nid, error = %e, "Kunne ikke slette varsel fra PG");
|
||||||
|
} else {
|
||||||
|
tracing::info!(node_id = %nid, "Systemvarsel slettet fra PG");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(ExpireAnnouncementResponse { expired: true }))
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tester
|
// Tester
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,9 @@ async fn main() {
|
||||||
.route("/intentions/audio_analyze", post(intentions::audio_analyze))
|
.route("/intentions/audio_analyze", post(intentions::audio_analyze))
|
||||||
.route("/intentions/audio_process", post(intentions::audio_process))
|
.route("/intentions/audio_process", post(intentions::audio_process))
|
||||||
.route("/intentions/ab_override", post(publishing::ab_override))
|
.route("/intentions/ab_override", post(publishing::ab_override))
|
||||||
|
// Systemvarsler (oppgave 15.1)
|
||||||
|
.route("/intentions/create_announcement", post(intentions::create_announcement))
|
||||||
|
.route("/intentions/expire_announcement", post(intentions::expire_announcement))
|
||||||
.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))
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -163,8 +163,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
|
|
||||||
## Fase 15: Adminpanel
|
## Fase 15: Adminpanel
|
||||||
|
|
||||||
- [~] 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`.
|
||||||
> Påbegynt: 2026-03-18T03:15
|
|
||||||
- [ ] 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.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.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.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`.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue