feat: unify recovery, updates, and schedules under Maintenance
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user