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 });
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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. */
|
||||
export function resolveRetranscription(
|
||||
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 { stdb } from '$lib/spacetime';
|
||||
import { browser } from '$app/environment';
|
||||
import SystemAnnouncements from '$lib/components/SystemAnnouncements.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
|
|
@ -17,4 +18,5 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<SystemAnnouncements />
|
||||
{@render children()}
|
||||
|
|
|
|||
|
|
@ -3716,6 +3716,197 @@ pub async fn audio_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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -187,6 +187,9 @@ async fn main() {
|
|||
.route("/intentions/audio_analyze", post(intentions::audio_analyze))
|
||||
.route("/intentions/audio_process", post(intentions::audio_process))
|
||||
.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("/pub/{slug}/feed.xml", get(rss::generate_feed))
|
||||
.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
|
||||
|
||||
- [~] 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
|
||||
- [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.
|
||||
- [ ] 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`.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue