synops/frontend/src/routes/admin/+page.svelte
vegard d8e44fe57e Fullfører oppgave 15.2: Graceful shutdown med vedlikeholdsmodus
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) <noreply@anthropic.com>
2026-03-18 03:31:32 +00:00

229 lines
7.1 KiB
Svelte

<script lang="ts">
/**
* Admin — Vedlikeholdsmodus (oppgave 15.2)
*
* Viser aktive sesjoner (kjørende jobber) og lar admin initiere
* eller avbryte planlagt vedlikehold med graceful shutdown.
*/
import { page } from '$app/stores';
import {
fetchMaintenanceStatus,
initiateMaintenance,
cancelMaintenance,
type MaintenanceStatus
} from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
let status = $state<MaintenanceStatus | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
// Vedlikeholds-tidspunkt: default 5 minutter fra nå
let scheduledMinutes = $state(5);
// Poll status every 5 seconds
$effect(() => {
if (!accessToken) return;
loadStatus();
const interval = setInterval(loadStatus, 5000);
return () => clearInterval(interval);
});
async function loadStatus() {
if (!accessToken) return;
try {
status = await fetchMaintenanceStatus(accessToken);
error = null;
} catch (e) {
error = String(e);
}
}
async function handleInitiate() {
if (!accessToken || loading) return;
loading = true;
error = null;
try {
const scheduledAt = new Date(Date.now() + scheduledMinutes * 60 * 1000).toISOString();
await initiateMaintenance(accessToken, { scheduled_at: scheduledAt });
await loadStatus();
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
async function handleCancel() {
if (!accessToken || loading) return;
loading = true;
error = null;
try {
await cancelMaintenance(accessToken);
await loadStatus();
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
// Countdown display
let now = $state(Date.now());
$effect(() => {
const interval = setInterval(() => { now = Date.now(); }, 1000);
return () => clearInterval(interval);
});
function countdown(isoDate: string): string {
const diff = new Date(isoDate).getTime() - now;
if (diff <= 0) return 'nå';
const s = Math.floor(diff / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
if (h > 0) return `${h}t ${m % 60}m ${s % 60}s`;
if (m > 0) return `${m}m ${s % 60}s`;
return `${s}s`;
}
</script>
<div class="min-h-screen bg-gray-50">
<header class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-500 hover:text-gray-700">Hjem</a>
<h1 class="text-lg font-semibold text-gray-900">Vedlikehold</h1>
</div>
</div>
</header>
<main class="mx-auto max-w-3xl px-4 py-6">
{#if !accessToken}
<p class="text-sm text-gray-400">Logg inn for tilgang.</p>
{:else if !status}
<p class="text-sm text-gray-400">Laster status...</p>
{:else}
<!-- Feilmelding -->
{#if error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
{/if}
<!-- Vedlikeholdsstatus -->
<section class="mb-6 rounded-lg border border-gray-200 bg-white p-5 shadow-sm">
<h2 class="mb-3 text-base font-semibold text-gray-800">Status</h2>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-600">Modus:</span>
{#if status.active}
<span class="rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-bold text-red-700">
AKTIV — stenger ned
</span>
{:else if status.initiated}
<span class="rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-bold text-amber-700">
Planlagt
</span>
{:else}
<span class="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-bold text-green-700">
Normal drift
</span>
{/if}
</div>
{#if status.scheduled_at}
<div class="flex items-center gap-2">
<span class="font-medium text-gray-600">Tidspunkt:</span>
<span class="text-gray-800">
{new Date(status.scheduled_at).toLocaleString('nb-NO')}
</span>
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-mono text-gray-600">
om {countdown(status.scheduled_at)}
</span>
</div>
{/if}
</div>
</section>
<!-- Aktive sesjoner / kjørende jobber -->
<section class="mb-6 rounded-lg border border-gray-200 bg-white p-5 shadow-sm">
<h2 class="mb-3 text-base font-semibold text-gray-800">
Aktive sesjoner
<span class="ml-1 text-sm font-normal text-gray-400">
({status.running_jobs.length} kjørende jobber)
</span>
</h2>
{#if status.running_jobs.length === 0}
<p class="text-sm text-gray-400">Ingen kjørende jobber.</p>
{:else}
<div class="space-y-2">
{#each status.running_jobs as job (job.id)}
<div class="flex items-center gap-3 rounded border border-gray-100 bg-gray-50 px-3 py-2 text-sm">
<span class="h-2 w-2 shrink-0 rounded-full bg-blue-500" title="Kjører"></span>
<span class="font-mono text-xs text-gray-500">{job.id.slice(0, 8)}</span>
<span class="font-medium text-gray-700">{job.job_type}</span>
{#if job.started_at}
<span class="ml-auto text-xs text-gray-400">
startet {new Date(job.started_at).toLocaleTimeString('nb-NO')}
</span>
{/if}
</div>
{/each}
</div>
{/if}
</section>
<!-- Handlinger -->
<section class="rounded-lg border border-gray-200 bg-white p-5 shadow-sm">
<h2 class="mb-3 text-base font-semibold text-gray-800">Handlinger</h2>
{#if !status.initiated}
<div class="flex items-end gap-3">
<div>
<label for="minutes" class="mb-1 block text-xs font-medium text-gray-600">
Minutter til vedlikehold
</label>
<input
id="minutes"
type="number"
min="1"
max="1440"
bind:value={scheduledMinutes}
class="w-24 rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
</div>
<button
onclick={handleInitiate}
disabled={loading}
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{loading ? 'Initierer...' : 'Start vedlikehold'}
</button>
</div>
<p class="mt-2 text-xs text-gray-400">
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.
</p>
{:else}
<div class="flex items-center gap-3">
<button
onclick={handleCancel}
disabled={loading || status.active}
class="rounded-lg bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
>
{loading ? 'Avbryter...' : 'Avbryt vedlikehold'}
</button>
{#if status.active}
<span class="text-sm text-red-600 font-medium">
Nedstengning pågår — kan ikke avbrytes
</span>
{/if}
</div>
{/if}
</section>
{/if}
</main>
</div>