feat: unify recovery, updates, and schedules under Maintenance

This commit is contained in:
2026-09-27 16:00:18 -04:00
parent 8ea696417f
commit fc46cb9da4
42 changed files with 1256 additions and 833 deletions
+3 -3
View File
@@ -237,7 +237,7 @@
flash = body.error ?? `HTTP ${res.status}`;
return;
}
scopeComplete = { count: scopeImpact.affected.length, composed: scopeImpact.composed, detached: body.detached?.length ?? 0, href: scopeImpact.affected.length === 1 ? '/watchdog?kind=drift&server=' + encodeURIComponent(scopeImpact.affected[0]) : '/watchdog?kind=drift' };
scopeComplete = { count: scopeImpact.affected.length, composed: scopeImpact.composed, detached: body.detached?.length ?? 0, href: scopeImpact.affected.length === 1 ? '/maintenance?kind=drift&server=' + encodeURIComponent(scopeImpact.affected[0]) : '/maintenance?kind=drift' };
scopeReviewOpen = false; scopeDialog?.close();
await load();
} catch (error) { flash = (error as Error).message; } finally {
@@ -286,7 +286,7 @@
{/snippet}
</SectionHeader>
{#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Watchdog detects resulting drift and rebuilds eligible servers when automation is enabled. <a href={scopeComplete.href}>View drift recovery →</a></div>{/if}
{#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Maintenance detects resulting drift and rebuilds eligible servers when automation is enabled. <a href={scopeComplete.href}>View drift recovery →</a></div>{/if}
{#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={`/betas/${runningBetaId}`}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }])}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if}
{#if runningBetaId}
@@ -417,7 +417,7 @@
{/if}
</div>
</Field>
{#if scopePreviewLoading}<p role="status">Reviewing eligible servers…</p>{:else if scopePreviewError}<p role="alert">{scopePreviewError}</p>{:else if scopeImpact}<section class="scope-impact" aria-label="Scope change review"><strong>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added · {scopeImpact.removed.length} removed</strong>{#if scopeImpact.added.length}<p>Added: {scopeImpact.added.join(', ')}</p>{/if}{#if scopeImpact.removed.length}<p>Removed: {scopeImpact.removed.join(', ')}</p>{/if}<p>{scopeImpact.composed ? 'This changes intended configuration composition.' : scopeType === 'none' ? `${scopeImpact.detached.length} recorded installations will become untracked. Watchdog will detect orphaned files as drift.` : 'Scope describes eligibility for promoted files.'}</p><p>When automation is enabled, Watchdog rebuilds servers with drift after verifying a safe server state. Rebuilds delete the existing instance and all of its files.</p></section>{/if}
{#if scopePreviewLoading}<p role="status">Reviewing eligible servers…</p>{:else if scopePreviewError}<p role="alert">{scopePreviewError}</p>{:else if scopeImpact}<section class="scope-impact" aria-label="Scope change review"><strong>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added · {scopeImpact.removed.length} removed</strong>{#if scopeImpact.added.length}<p>Added: {scopeImpact.added.join(', ')}</p>{/if}{#if scopeImpact.removed.length}<p>Removed: {scopeImpact.removed.join(', ')}</p>{/if}<p>{scopeImpact.composed ? 'This changes intended configuration composition.' : scopeType === 'none' ? `${scopeImpact.detached.length} recorded installations will become untracked. Maintenance will detect orphaned files as drift.` : 'Scope describes eligibility for promoted files.'}</p><p>When automation is enabled, Maintenance rebuilds servers with drift after verifying a safe server state. Rebuilds delete the existing instance and all of its files.</p></section>{/if}
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}>Cancel</Button>
<Button type="submit" variant="primary" disabled={updatingScope || scopePreviewLoading || !scopeImpact} loading={updatingScope}>
@@ -1,121 +0,0 @@
<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.provisioning_id}<a href="/provisioning/{encodeURIComponent(job.provisioning_id)}">View provisioning</a>{/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>
@@ -0,0 +1,18 @@
<script lang="ts">
import type { PlutoniumVersion } from '$lib/plutonium.js';
import { uiNow } from '$ui/stores/servers.js';
let { name, version, after, ready = true }: { name: string; version?: PlutoniumVersion; after?: string | null; ready?: boolean } = $props();
const fresh = $derived(ready && version?.checked_at && $uiNow - Date.parse(version.checked_at) < 3_600_000 && (!after || version.checked_at > after));
const revision = $derived(fresh ? version?.revision : null);
const outdated = $derived(fresh && version?.update_status === 'outdated');
</script>
<a class:outdated href={`/maintenance?kind=updates&server=${encodeURIComponent(name)}`} title="View Plutonium updates and verification">
Plutonium {revision ? `r${revision}` : 'unverified'}{outdated ? ' · Update available' : ''}
</a>
<style>
a { font-size: var(--text-xs); color: var(--text-secondary); text-decoration: none; }
a:hover { color: var(--accent-primary-hover); text-decoration: underline; }
.outdated { color: var(--state-warning); }
</style>