121 lines
7.7 KiB
Svelte
121 lines
7.7 KiB
Svelte
<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>
|