Use user timezone for maintenance and official Plutonium manifest

This commit is contained in:
2026-09-21 19:14:40 -04:00
parent 47d6dc07b7
commit ce6861203e
6 changed files with 161 additions and 45 deletions
+11 -4
View File
@@ -67,13 +67,20 @@ See [Plugin releases](docs/plugin-releases.md) for the import and beta workflow.
Open **Settings → Plutonium updates & restarts** to check the latest published
revision, inspect each server's running revision, and queue a server or fleet
restart. Release and running-version checks also run hourly. The published
revision comes from the [official changelog](https://plutonium.pw/docs/changelog/);
revision comes from the [official production updater manifest](https://cdn.plutoniummod.com/updater/prod/info.json),
which exposes the game revision directly and avoids the changelog website's browser challenge;
running revisions come from RCON `version`. Unsupported replies and failed
queries appear as unknown rather than being treated as up to date. A failed
changelog request retains the last release with a visible error.
manifest request retains the last release with a visible error.
Daily fleet restarts are optional and disabled by default. Choose a **UTC** queue
time and save the schedule; the next run is also shown in your display timezone.
Daily fleet restarts are optional and disabled by default. Choose a queue time
in your **selected timezone** and save the schedule. The timezone is saved with
the schedule so the daily time follows daylight saving changes even when your
browser is closed. Skipped times run at the first valid minute after the gap;
repeated times run once on their first occurrence. Existing UTC schedules retain
their original timing until saved again. The form displays their next occurrence
in your timezone and identifies the saved zone if it differs. Changing a browser's
display preference does not silently change a fleet schedule; save to apply it.
Every due run queues the current fleet, regardless of whether an update exists.
The schedule and jobs survive app restarts. Missed days become one run, and a
server can have only one pending maintenance restart. Disabling the schedule
+47 -12
View File
@@ -1,6 +1,9 @@
import { validTimeZone } from './time-zone.js';
export interface MaintenanceSchedule {
enabled: boolean;
time: string;
timeZone: string;
nextAt: string | null;
}
export interface PlutoniumRelease {
@@ -37,19 +40,51 @@ export function parsePlutoniumRevision(text: string): number | null {
return match ? Number(match[1]) : null;
}
export function parseChangelogRevision(html: string): number {
const headings = [...html.matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi)];
const revisions = headings.map(h => parsePlutoniumRevision(h[1].replace(/<[^>]*>/g, ' ')))
.filter((r): r is number => r !== null);
if (!revisions.length) throw new Error('The Plutonium changelog did not contain a release heading.');
return Math.max(...revisions);
export const PLUTONIUM_MANIFEST_URL = 'https://cdn.plutoniummod.com/updater/prod/info.json';
export function parsePlutoniumManifest(value: unknown): number {
const manifest = value as { product?: unknown; revision?: unknown } | null;
if (!manifest || manifest.product !== 'plutonium-core-prod' || typeof manifest.revision !== 'number'
|| !Number.isSafeInteger(manifest.revision) || manifest.revision < 1) {
throw new Error('The production updater manifest did not contain a valid Plutonium revision.');
}
return manifest.revision;
}
export function nextMaintenanceAt(time: string, now = new Date()): string {
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) throw new Error('Choose a valid UTC time (HH:mm).');
const next = new Date(now);
export function nextMaintenanceAt(time: string, now = new Date(), timeZone = 'UTC'): string {
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) throw new Error('Choose a valid time (HH:mm).');
if (!validTimeZone(timeZone)) throw new Error('Choose a valid timezone.');
if (!Number.isFinite(now.getTime())) throw new Error('Invalid schedule date.');
const [hour, minute] = time.split(':').map(Number);
next.setUTCHours(hour, minute, 0, 0);
if (next <= now) next.setUTCDate(next.getUTCDate() + 1);
return next.toISOString();
const target = hour * 60 + minute;
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
});
function local(at: number) {
const parts = Object.fromEntries(formatter.formatToParts(at).map(p => [p.type, p.value]));
return { day: `${parts.year}-${parts.month}-${parts.day}`, minute: Number(parts.hour) * 60 + Number(parts.minute) };
}
const today = local(now.getTime()).day;
const start = Math.floor(now.getTime() / 60_000) * 60_000;
const days = new Map<string, { at: number; minute: number }>();
// Bounded minute search avoids assuming whole-hour offsets or 24-hour days.
// A missing wall time runs at the first valid minute after the DST gap;
// a repeated wall time runs only on its first occurrence that local day.
for (let at = start - 26 * 3_600_000; at <= start + 50 * 3_600_000; at += 60_000) {
const wall = local(at);
if (wall.day < today || wall.minute < target) continue;
const previous = days.get(wall.day);
if (!previous || wall.minute < previous.minute) days.set(wall.day, { at, minute: wall.minute });
}
const next = [...days.values()].map(d => d.at).filter(at => at > now.getTime()).sort((a, b) => a - b)[0];
if (next === undefined) throw new Error('Could not calculate the next maintenance time.');
return new Date(next).toISOString();
}
/** Present an existing schedule in the viewing user's zone without rewriting it. */
export function maintenanceTimeInZone(schedule: MaintenanceSchedule, timeZone: string): string {
if (schedule.timeZone === timeZone) return schedule.time;
const at = schedule.nextAt ?? nextMaintenanceAt(schedule.time, new Date(), schedule.timeZone);
return new Intl.DateTimeFormat('en-GB', { timeZone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(new Date(at));
}
+12 -12
View File
@@ -3,10 +3,9 @@ import { config, komodoConfigured, rconConfigured } from '../config.js';
import { getServer, listServers } from './servers.js';
import { rconGetPlayerCount, rconPlutoniumVersion } from '../integrations/rcon.js';
import { refreshRestartRollout, restartInRollout, rolloutHealth, rolloutWaitReason } from '../pipeline/restart-rollout.js';
import { nextMaintenanceAt, parseChangelogRevision } from '../../plutonium.js';
import { nextMaintenanceAt, parsePlutoniumManifest, PLUTONIUM_MANIFEST_URL } from '../../plutonium.js';
import type { MaintenanceJob, MaintenanceSchedule, PlutoniumRelease, PlutoniumStatus, PlutoniumVersion } from '../../plutonium.js';
const CHANGELOG = 'https://plutonium.pw/docs/changelog/';
const HOUR = 60 * 60 * 1000;
const nowIso = () => new Date().toISOString();
function readSetting<T>(key: string, fallback: T): T {
@@ -21,11 +20,12 @@ function release(): PlutoniumRelease {
return readSetting('plutonium_release', { revision: null, checkedAt: null, error: null });
}
function schedule(): MaintenanceSchedule {
return readSetting('plutonium_schedule', { enabled: false, time: '06:00', nextAt: null });
// Legacy schedules were explicitly UTC. Preserve their timing until saved.
return { timeZone: 'UTC', ...readSetting('plutonium_schedule', { enabled: false, time: '06:00', nextAt: null }) };
}
export function saveMaintenanceSchedule(enabled: boolean, time: string): void {
const nextAt = nextMaintenanceAt(time);
writeSetting('plutonium_schedule', { enabled, time, nextAt: enabled ? nextAt : null });
export function saveMaintenanceSchedule(enabled: boolean, time: string, timeZone: string): void {
const nextAt = nextMaintenanceAt(time, new Date(), timeZone);
writeSetting('plutonium_schedule', { enabled, time, timeZone, nextAt: enabled ? nextAt : null });
}
export function plutoniumStatus(): PlutoniumStatus {
const versions = db().prepare('SELECT * FROM plutonium_versions').all() as PlutoniumVersion[];
@@ -58,11 +58,11 @@ export function checkPlutonium(): Promise<void> {
async function check(): Promise<void> {
const previous = release();
try {
const response = await fetch(CHANGELOG, { signal: AbortSignal.timeout(config.httpTimeoutMs), redirect: 'error' });
if (!response.ok) throw new Error(`Changelog request failed (HTTP ${response.status}).`);
const html = await response.text();
if (html.length > 2_000_000) throw new Error('Changelog response was too large.');
writeSetting('plutonium_release', { revision: parseChangelogRevision(html), checkedAt: nowIso(), error: null });
const response = await fetch(PLUTONIUM_MANIFEST_URL, { signal: AbortSignal.timeout(config.httpTimeoutMs), redirect: 'error' });
if (!response.ok) throw new Error(`Updater manifest request failed (HTTP ${response.status}).`);
const body = await response.text();
if (body.length > 2_000_000) throw new Error('Updater manifest response was too large.');
writeSetting('plutonium_release', { revision: parsePlutoniumManifest(JSON.parse(body)), checkedAt: nowIso(), error: null });
} catch (err) {
writeSetting('plutonium_release', { ...previous, checkedAt: nowIso(), error: (err as Error).message });
}
@@ -122,7 +122,7 @@ async function runTick(): Promise<void> {
const names = listServers().map(s => s.name);
if (names.length) queueMaintenance(names);
// Coalesce missed days after downtime into one run; no restart backlog.
writeSetting('plutonium_schedule', { ...plan, nextAt: nextMaintenanceAt(plan.time) });
writeSetting('plutonium_schedule', { ...plan, nextAt: nextMaintenanceAt(plan.time, new Date(), plan.timeZone) });
}
})();
await refreshRestartRollout();
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { PlutoniumStatus } from '$lib/plutonium.js';
import { timeFormat } from '$ui/stores/time-zone.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);
@@ -11,6 +12,13 @@
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;
@@ -23,11 +31,12 @@
data = result;
error = '';
if (!initialized) {
enabled = result.schedule.enabled; time = result.schedule.time; initialized = true;
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.' : 'Version checks finished.';
: 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; }
}
@@ -50,15 +59,18 @@
{#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 }); }}>
<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 (UTC)<input type="time" bind:value={time} required /></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>
@@ -70,7 +82,7 @@
{#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 changelog' : 'Up to date'}</td>
: 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>
+4 -3
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit';
import { validTimeZone } from '$lib/time-zone.js';
import { cancelMaintenance, checkPlutonium, plutoniumStatus, queueMaintenance, saveMaintenanceSchedule } from '$lib/server/fleet/plutonium.js';
export function GET() { return json(plutoniumStatus()); }
@@ -10,10 +11,10 @@ export async function POST({ request }: { request: Request }) {
if (body.action === 'check') {
await checkPlutonium();
} else if (body.action === 'schedule') {
if (typeof body.enabled !== 'boolean' || typeof body.time !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(body.time)) {
return json({ error: 'Provide enabled and a valid UTC time (HH:mm).' }, { status: 400 });
if (typeof body.enabled !== 'boolean' || typeof body.time !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(body.time) || !validTimeZone(body.timeZone)) {
return json({ error: 'Provide enabled, a valid time (HH:mm), and a valid timezone.' }, { status: 400 });
}
saveMaintenanceSchedule(body.enabled, body.time);
saveMaintenanceSchedule(body.enabled, body.time, body.timeZone);
} else if (body.action === 'restart') {
if (!Array.isArray(body.servers) || !body.servers.length || body.servers.some((s: unknown) => typeof s !== 'string')) {
return json({ error: 'Choose servers to restart.' }, { status: 400 });
+69 -8
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { nextMaintenanceAt, parseChangelogRevision, parsePlutoniumRevision } from '../../src/lib/plutonium.js';
import { maintenanceTimeInZone, nextMaintenanceAt, parsePlutoniumManifest, parsePlutoniumRevision, PLUTONIUM_MANIFEST_URL } from '../../src/lib/plutonium.js';
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({ komodoRestart: vi.fn(), komodoInspectHealthy: vi.fn() }));
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(), rconStatusResult: vi.fn(), rconPlutoniumVersion: vi.fn() }));
@@ -15,7 +15,7 @@ beforeEach(async () => {
vi.stubEnv('KOMODO_ADDRESS', 'http://komodo.test');
vi.stubEnv('KOMODO_API_KEY', 'test'); vi.stubEnv('KOMODO_API_SECRET', 'test');
vi.stubEnv('RCON_PASSWORD', 'test'); vi.stubEnv('DEPLOYMENT_RESTART_CONCURRENCY', '1');
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => new Response('<h2 id="r5354">r5354</h2><h2>r5300</h2>')));
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => Response.json({ product: 'plutonium-core-prod', revision: 5354, files: [] })));
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const k = await import('../../src/lib/server/integrations/komodo.js');
@@ -30,11 +30,20 @@ afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.unstubAl
const api = () => import('../../src/lib/server/fleet/plutonium.js');
describe('Plutonium versions', () => {
it('parses explicit revisions and release headings without interpreting engine numbers or links as releases', () => {
it('uses the official production manifest without requesting the challenged changelog page', async () => {
const m = await api(); await m.checkPlutonium();
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(PLUTONIUM_MANIFEST_URL, expect.any(Object));
expect(m.plutoniumStatus().release).toMatchObject({ revision: 5354, error: null });
expect(parsePlutoniumManifest({ product: 'plutonium-core-prod', revision: 5354 })).toBe(5354);
for (const value of [null, {}, { ref: '1.0.221' }, { product: 'plutonium-core-dev', revision: 6000 },
{ product: 'plutonium-core-prod', revision: '5354' }, { product: 'plutonium-core-prod', revision: -1 }]) {
expect(() => parsePlutoniumManifest(value)).toThrow();
}
});
it('parses explicit running revisions without interpreting engine numbers as releases', () => {
expect(parsePlutoniumRevision('print\nPlutonium T6 r5354 win-x86')).toBe(5354);
expect(parsePlutoniumRevision('version 1.0 build 20260921')).toBeNull();
expect(parseChangelogRevision('<a href="#r9999">r9999</a><h2><a>r5354</a></h2>')).toBe(5354);
expect(() => parseChangelogRevision('<h1>Service unavailable</h1>')).toThrow();
});
it('preserves the cached release with a visible error and clears failed running-version observations', async () => {
const m = await api(); await m.checkPlutonium();
@@ -45,6 +54,55 @@ describe('Plutonium versions', () => {
expect(m.plutoniumStatus().release).toMatchObject({ revision: 5354, error: 'Offline' });
expect(m.plutoniumStatus().servers.every(s => s.revision === null && s.error === 'Timeout')).toBe(true);
});
it.each([
() => new Response('Blocked', { status: 403 }),
() => new Response('<html>Browser challenge</html>'),
() => Response.json({ product: 'plutonium-core-dev', revision: 9999 })
])('keeps the cached revision when the manifest is unavailable or invalid', async response => {
const m = await api(); await m.checkPlutonium();
vi.mocked(fetch).mockImplementation(async () => response());
await m.checkPlutonium();
expect(m.plutoniumStatus().release.revision).toBe(5354);
expect(m.plutoniumStatus().release.error).toBeTruthy();
m.queueMaintenance([servers[0]]);
expect(m.plutoniumStatus().jobs[0].target_revision).toBeNull();
});
});
describe('maintenance timezones', () => {
it.each([
['06:00', '2026-09-21T05:59:00Z', 'America/New_York', '2026-09-21T10:00:00.000Z'],
['06:00', '2026-12-21T05:59:00Z', 'America/New_York', '2026-12-21T11:00:00.000Z'],
['06:00', '2026-09-21T00:00:00Z', 'Asia/Kathmandu', '2026-09-21T00:15:00.000Z'],
['06:00', '2026-09-21T00:15:01Z', 'Asia/Kathmandu', '2026-09-22T00:15:00.000Z'],
['02:30', '2026-03-08T05:00:00Z', 'America/New_York', '2026-03-08T07:00:00.000Z'],
['01:30', '2026-11-01T04:00:00Z', 'America/New_York', '2026-11-01T05:30:00.000Z'],
['01:30', '2026-11-01T05:31:00Z', 'America/New_York', '2026-11-02T06:30:00.000Z'],
['02:15', '2026-10-03T13:00:00Z', 'Australia/Lord_Howe', '2026-10-03T15:30:00.000Z']
])('schedules %s after %s in %s', (time, now, zone, expected) => {
expect(nextMaintenanceAt(time, new Date(now), zone)).toBe(expected);
});
it('preserves legacy UTC schedules and displays their next occurrence in the user timezone', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('INSERT INTO settings (key, value) VALUES (?, ?)').run('plutonium_schedule', JSON.stringify({
enabled: true, time: '06:00', nextAt: '2026-09-21T06:00:00.000Z'
}));
const m = await api();
const schedule = m.plutoniumStatus().schedule;
expect(schedule.timeZone).toBe('UTC');
expect(maintenanceTimeInZone(schedule, 'America/New_York')).toBe('02:00');
expect(m.plutoniumStatus().schedule.nextAt).toBe('2026-09-21T06:00:00.000Z');
});
it('persists the zone and advances in local calendar days across DST', async () => {
vi.setSystemTime(new Date('2026-03-07T10:59:00Z'));
const m = await api(); await m.checkPlutonium();
m.saveMaintenanceSchedule(true, '06:00', 'America/New_York');
const { closeDb } = await import('../../src/lib/server/db/index.js'); closeDb();
expect(m.plutoniumStatus().schedule.nextAt).toBe('2026-03-07T11:00:00.000Z');
vi.setSystemTime(new Date('2026-03-07T11:00:00Z'));
await m.maintenanceTick();
expect(m.plutoniumStatus().schedule).toMatchObject({ timeZone: 'America/New_York', nextAt: '2026-03-08T10:00:00.000Z' });
});
});
describe('persistent maintenance', () => {
@@ -130,22 +188,25 @@ describe('persistent maintenance', () => {
expect((await post({ action: 'schedule', enabled: true, time: '26:99' })).status).toBe(400);
expect((await post({ action: 'restart', servers: ['missing'] })).status).toBe(400);
expect((await post({ action: 'cancel', id: 100 })).status).toBe(409);
expect((await post({ action: 'schedule', enabled: true, time: '06:00' })).status).toBe(200);
expect((await post({ action: 'schedule', enabled: true, time: '06:00' })).status).toBe(400);
expect((await post({ action: 'schedule', enabled: true, time: '06:00', timeZone: 'invalid' })).status).toBe(400);
expect((await post({ action: 'schedule', enabled: true, time: '06:00', timeZone: 'America/New_York' })).status).toBe(200);
const m = await api();
expect(m.plutoniumStatus().jobs).toEqual([]);
expect(m.plutoniumStatus().schedule.enabled).toBe(true);
expect(m.plutoniumStatus().schedule).toMatchObject({ timeZone: 'America/New_York', nextAt: '2026-09-21T10:00:00.000Z' });
});
it('runs a daily UTC schedule once and coalesces missed days', async () => {
expect(nextMaintenanceAt('06:00', new Date('2026-09-21T06:00:00Z'))).toBe('2026-09-22T06:00:00.000Z');
expect(() => nextMaintenanceAt('24:00')).toThrow();
const m = await api(); await m.checkPlutonium();
expect(m.plutoniumStatus().schedule.enabled).toBe(false);
m.saveMaintenanceSchedule(true, '06:00');
m.saveMaintenanceSchedule(true, '06:00', 'UTC');
vi.setSystemTime(new Date('2026-09-24T07:00:00Z'));
await m.maintenanceTick(); await m.maintenanceTick();
expect(m.plutoniumStatus().jobs).toHaveLength(2);
expect(m.plutoniumStatus().schedule.nextAt).toBe('2026-09-25T06:00:00.000Z');
m.saveMaintenanceSchedule(false, '06:00');
m.saveMaintenanceSchedule(false, '06:00', 'UTC');
expect(m.plutoniumStatus().schedule.nextAt).toBeNull();
});
});