synops/frontend/src/lib/components/SystemAnnouncements.svelte
vegard e8a1a80652 Valider fase 22: STDB-migrering fullført, ingen rester i aktiv kode
Validering av fase 22 (SpacetimeDB-migrering) bekrefter:

1. WebSocket-sanntid fungerer:
   - maskinrommet lytter på PG NOTIFY-kanaler (node_changed, edge_changed,
     access_changed, mixer_channel_changed)
   - Enrichment av events med fulle rader fra PG
   - Broadcast via tokio::broadcast til WebSocket-klienter
   - Tilgangskontroll filtrerer events per bruker
   - Frontend kobler til /ws med JWT, mottar initial_sync + inkrementelle events

2. PG LISTEN/NOTIFY-triggere verifisert i database:
   - 4 notify-funksjoner: notify_node_change, notify_edge_change,
     notify_access_change, notify_mixer_channel_change
   - 4 triggere: nodes_notify, edges_notify, node_access_notify,
     mixer_channels_notify

3. Ingen STDB-rester i aktiv kode/konfig:
   - maskinrommet/src/: rent
   - Cargo.toml: ingen spacetimedb-avhengigheter
   - docker-compose.yml: ingen spacetimedb-tjeneste
   - Caddyfile: ingen spacetimedb-proxy
   - Eneste funn: frontend/src/lib/spacetime/ katalognavn —
     omdøpt til frontend/src/lib/realtime/ (32 filer oppdatert)
   - Historiske referanser i docs/arkiv og scripts/synops.md er OK
2026-03-18 16:31:16 +00:00

172 lines
5 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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/realtime';
import type { Node } from '$lib/realtime';
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}