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
172 lines
5 KiB
Svelte
172 lines
5 KiB
Svelte
<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}
|