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>
229 lines
7.1 KiB
Svelte
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>
|