Files
electric-cherry/src/lib/ui/components/PlutoniumMaintenance.svelte
T

121 lines
7.7 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">
import { onMount } from 'svelte';
import type { PlutoniumStatus } from '$lib/plutonium.js';
import { maintenanceTimeInZone, PLUTONIUM_MANIFEST_URL } from '$lib/plutonium.js';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import Button from './primitives/Button.svelte';
let data = $state<PlutoniumStatus | null>(null);
let busy = $state(false);
let error = $state('');
let notice = $state('');
let enabled = $state(false);
let time = $state('06:00');
let initialized = false;
let displayedZone = $state('');
$effect(() => {
if (data && displayedZone !== $timeZone) {
time = maintenanceTimeInZone(data.schedule, $timeZone);
displayedZone = $timeZone;
}
});
async function refresh(action?: Record<string, unknown>) {
if (busy) return;
busy = true;
try {
const response = await fetch('/api/plutonium', action ? {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(action)
} : undefined);
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? 'Could not load Plutonium status.');
data = result;
error = '';
if (!initialized) {
enabled = result.schedule.enabled; initialized = true;
}
if (action) notice = action.action === 'schedule' ? 'Schedule saved.'
: action.action === 'restart' ? 'Restart queued. Occupied servers will wait until empty.'
: action.action === 'cancel' ? 'Queued restart cancelled.'
: result.release.error ? 'Version checks finished with an update-source error.' : 'Version checks finished.';
} catch (cause) { error = (cause as Error).message; }
finally { busy = false; }
}
function pending(server: string) {
return data?.jobs.some(j => j.server === server && ['queued', 'restarting'].includes(j.state));
}
onMount(() => {
void refresh();
const timer = setInterval(() => { if (!document.hidden) void refresh(); }, 5000);
return () => clearInterval(timer);
});
</script>
<section id="plutonium" aria-labelledby="plutonium-heading">
<div class="heading"><h2 id="plutonium-heading">Plutonium updates & restarts</h2>
<Button disabled={busy} onclick={() => refresh({ action: 'check' })}>Check versions</Button>
</div>
<p>Plutainer applies updates on startup when auto-update is enabled. Restarts wait for a fresh player count of zero and share the fleet’s rolling restart limit.</p>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if data}
<p>Latest published revision: <strong>{data.release.revision ? `r${data.release.revision}` : 'Unknown'}</strong>
· <a href={PLUTONIUM_MANIFEST_URL} target="_blank" rel="noreferrer">Production manifest</a>
· <a href="https://plutonium.pw/docs/changelog/" target="_blank" rel="noreferrer">Changelog</a>
{#if data.release.checkedAt} · Checked {$timeFormat.dateTime(data.release.checkedAt)}{/if}
</p>
{#if data.release.error}<p role="alert">Update check failed: {data.release.error} Any displayed release is cached.</p>{/if}
<form onsubmit={(event) => { event.preventDefault(); void refresh({ action: 'schedule', enabled, time, timeZone: $timeZone }); }}>
<label class="toggle"><input type="checkbox" bind:checked={enabled} /> Daily fleet restart</label>
<label>Queue time ({$timeZone})<input type="time" bind:value={time} required /></label>
<Button type="submit" disabled={busy}>Save schedule</Button>
</form>
<p>Saving uses your selected timezone and follows its daylight saving changes. If the time is skipped, the queue starts at the next valid minute; repeated times run once.</p>
{#if data.schedule.enabled && data.schedule.timeZone !== $timeZone}<p>The saved schedule is {data.schedule.time} in {data.schedule.timeZone}. Save to use {$timeZone} instead.</p>{/if}
<p>This restarts all fleet servers daily, even without a new release. Busy servers may restart later. Disabling the schedule stops future runs; cancel existing queued restarts below.</p>
{#if data.schedule.nextAt}<p>Next run: {$timeFormat.dateTime(data.schedule.nextAt)}</p>{/if}
<div class="table-wrap"><table>
<thead><tr><th>Server</th><th>Running revision</th><th>Update status</th><th>Action</th></tr></thead>
<tbody>{#each data.servers as server (server.server)}
<tr><td><a href={`/servers/${encodeURIComponent(server.server)}`}>{server.server}</a></td>
<td>{server.revision ? `r${server.revision}` : 'Unknown'}
{#if server.checked_at}<small>Checked {$timeFormat.dateTime(server.checked_at)}</small>{/if}
{#if server.error}<small>{server.error}</small>{/if}
</td>
<td>{server.revision === null || data.release.revision === null || data.release.error ? 'Unverified'
: server.revision < data.release.revision ? 'Update available' : server.revision > data.release.revision ? 'Ahead of published release' : 'Up to date'}</td>
<td><Button size="sm" disabled={busy || pending(server.server)} onclick={() => refresh({ action: 'restart', servers: [server.server] })}>
{pending(server.server) ? 'Restart pending' : 'Queue restart'}
</Button></td></tr>
{:else}<tr><td colspan="4">No fleet servers yet.</td></tr>{/each}</tbody>
</table></div>
{#if data.servers.length}<Button disabled={busy || data.servers.every(s => pending(s.server))} onclick={() => refresh({ action: 'restart', servers: data!.servers.map(s => s.server) })}>Queue fleet restart</Button>{/if}
{#if data.jobs.length}
<h3>Restart activity</h3>
<ul>{#each data.jobs as job (job.id)}<li>
<strong>{job.server}</strong> · {job.state} · {$timeFormat.dateTime(job.requested_at)}
{#if job.target_revision} · Target r{job.target_revision}{/if}
{#if job.detail}<small>{job.detail}</small>{/if}
{#if job.state === 'queued'}<Button size="sm" disabled={busy} onclick={() => refresh({ action: 'cancel', id: job.id })}>Cancel</Button>{/if}
</li>{/each}</ul>
{/if}
{:else}<p role="status">{busy ? 'Loading maintenance status…' : 'Maintenance status unavailable.'}</p>{/if}
</section>
<style>
section { padding: 20px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); scroll-margin-top: 90px; }
.heading, form { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; justify-content: space-between; }
h2 { font-size: var(--text-md); margin: 0; }
h3 { font-size: var(--text-sm); margin-top: 24px; }
p, td, th, label, li { font-size: var(--text-sm); line-height: 1.6; }
p, small { color: var(--text-secondary); }
label { display: grid; gap: 6px; }
.toggle { display: flex; align-items: center; }
input[type='time'] { padding: 8px; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); }
.table-wrap { overflow-x: auto; margin: 16px 0; }
table { width: 100%; border-collapse: collapse; text-align: left; }
th, td { padding: 10px; border-bottom: 1px solid var(--border-strong); }
small { display: block; }
ul { padding-left: 20px; }
li { margin-bottom: 12px; }
</style>