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 Open **Settings → Plutonium updates & restarts** to check the latest published
revision, inspect each server's running revision, and queue a server or fleet revision, inspect each server's running revision, and queue a server or fleet
restart. Release and running-version checks also run hourly. The published 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 running revisions come from RCON `version`. Unsupported replies and failed
queries appear as unknown rather than being treated as up to date. A 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 Daily fleet restarts are optional and disabled by default. Choose a queue time
time and save the schedule; the next run is also shown in your display timezone. 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. 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 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 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 { export interface MaintenanceSchedule {
enabled: boolean; enabled: boolean;
time: string; time: string;
timeZone: string;
nextAt: string | null; nextAt: string | null;
} }
export interface PlutoniumRelease { export interface PlutoniumRelease {
@@ -37,19 +40,51 @@ export function parsePlutoniumRevision(text: string): number | null {
return match ? Number(match[1]) : null; return match ? Number(match[1]) : null;
} }
export function parseChangelogRevision(html: string): number { export const PLUTONIUM_MANIFEST_URL = 'https://cdn.plutoniummod.com/updater/prod/info.json';
const headings = [...html.matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi)];
const revisions = headings.map(h => parsePlutoniumRevision(h[1].replace(/<[^>]*>/g, ' '))) export function parsePlutoniumManifest(value: unknown): number {
.filter((r): r is number => r !== null); const manifest = value as { product?: unknown; revision?: unknown } | null;
if (!revisions.length) throw new Error('The Plutonium changelog did not contain a release heading.'); if (!manifest || manifest.product !== 'plutonium-core-prod' || typeof manifest.revision !== 'number'
return Math.max(...revisions); || !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 { 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 UTC time (HH:mm).'); if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) throw new Error('Choose a valid time (HH:mm).');
const next = new Date(now); 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); const [hour, minute] = time.split(':').map(Number);
next.setUTCHours(hour, minute, 0, 0); const target = hour * 60 + minute;
if (next <= now) next.setUTCDate(next.getUTCDate() + 1); const formatter = new Intl.DateTimeFormat('en-CA', {
return next.toISOString(); 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 { getServer, listServers } from './servers.js';
import { rconGetPlayerCount, rconPlutoniumVersion } from '../integrations/rcon.js'; import { rconGetPlayerCount, rconPlutoniumVersion } from '../integrations/rcon.js';
import { refreshRestartRollout, restartInRollout, rolloutHealth, rolloutWaitReason } from '../pipeline/restart-rollout.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'; import type { MaintenanceJob, MaintenanceSchedule, PlutoniumRelease, PlutoniumStatus, PlutoniumVersion } from '../../plutonium.js';
const CHANGELOG = 'https://plutonium.pw/docs/changelog/';
const HOUR = 60 * 60 * 1000; const HOUR = 60 * 60 * 1000;
const nowIso = () => new Date().toISOString(); const nowIso = () => new Date().toISOString();
function readSetting<T>(key: string, fallback: T): T { 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 }); return readSetting('plutonium_release', { revision: null, checkedAt: null, error: null });
} }
function schedule(): MaintenanceSchedule { 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 { export function saveMaintenanceSchedule(enabled: boolean, time: string, timeZone: string): void {
const nextAt = nextMaintenanceAt(time); const nextAt = nextMaintenanceAt(time, new Date(), timeZone);
writeSetting('plutonium_schedule', { enabled, time, nextAt: enabled ? nextAt : null }); writeSetting('plutonium_schedule', { enabled, time, timeZone, nextAt: enabled ? nextAt : null });
} }
export function plutoniumStatus(): PlutoniumStatus { export function plutoniumStatus(): PlutoniumStatus {
const versions = db().prepare('SELECT * FROM plutonium_versions').all() as PlutoniumVersion[]; 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> { async function check(): Promise<void> {
const previous = release(); const previous = release();
try { try {
const response = await fetch(CHANGELOG, { signal: AbortSignal.timeout(config.httpTimeoutMs), redirect: 'error' }); const response = await fetch(PLUTONIUM_MANIFEST_URL, { signal: AbortSignal.timeout(config.httpTimeoutMs), redirect: 'error' });
if (!response.ok) throw new Error(`Changelog request failed (HTTP ${response.status}).`); if (!response.ok) throw new Error(`Updater manifest request failed (HTTP ${response.status}).`);
const html = await response.text(); const body = await response.text();
if (html.length > 2_000_000) throw new Error('Changelog response was too large.'); if (body.length > 2_000_000) throw new Error('Updater manifest response was too large.');
writeSetting('plutonium_release', { revision: parseChangelogRevision(html), checkedAt: nowIso(), error: null }); writeSetting('plutonium_release', { revision: parsePlutoniumManifest(JSON.parse(body)), checkedAt: nowIso(), error: null });
} catch (err) { } catch (err) {
writeSetting('plutonium_release', { ...previous, checkedAt: nowIso(), error: (err as Error).message }); 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); const names = listServers().map(s => s.name);
if (names.length) queueMaintenance(names); if (names.length) queueMaintenance(names);
// Coalesce missed days after downtime into one run; no restart backlog. // 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(); await refreshRestartRollout();
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { PlutoniumStatus } from '$lib/plutonium.js'; 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'; import Button from './primitives/Button.svelte';
let data = $state<PlutoniumStatus | null>(null); let data = $state<PlutoniumStatus | null>(null);
@@ -11,6 +12,13 @@
let enabled = $state(false); let enabled = $state(false);
let time = $state('06:00'); let time = $state('06:00');
let initialized = false; 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>) { async function refresh(action?: Record<string, unknown>) {
if (busy) return; if (busy) return;
busy = true; busy = true;
@@ -23,11 +31,12 @@
data = result; data = result;
error = ''; error = '';
if (!initialized) { 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.' if (action) notice = action.action === 'schedule' ? 'Schedule saved.'
: action.action === 'restart' ? 'Restart queued. Occupied servers will wait until empty.' : 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; } } catch (cause) { error = (cause as Error).message; }
finally { busy = false; } finally { busy = false; }
} }
@@ -50,15 +59,18 @@
{#if notice}<p role="status">{notice}</p>{/if} {#if notice}<p role="status">{notice}</p>{/if}
{#if data} {#if data}
<p>Latest published revision: <strong>{data.release.revision ? `r${data.release.revision}` : 'Unknown'}</strong> <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> · <a href="https://plutonium.pw/docs/changelog/" target="_blank" rel="noreferrer">Changelog</a>
{#if data.release.checkedAt} · Checked {$timeFormat.dateTime(data.release.checkedAt)}{/if} {#if data.release.checkedAt} · Checked {$timeFormat.dateTime(data.release.checkedAt)}{/if}
</p> </p>
{#if data.release.error}<p role="alert">Update check failed: {data.release.error} Any displayed release is cached.</p>{/if} {#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 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> <Button type="submit" disabled={busy}>Save schedule</Button>
</form> </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> <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} {#if data.schedule.nextAt}<p>Next run: {$timeFormat.dateTime(data.schedule.nextAt)}</p>{/if}
<div class="table-wrap"><table> <div class="table-wrap"><table>
@@ -70,7 +82,7 @@
{#if server.error}<small>{server.error}</small>{/if} {#if server.error}<small>{server.error}</small>{/if}
</td> </td>
<td>{server.revision === null || data.release.revision === null || data.release.error ? 'Unverified' <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] })}> <td><Button size="sm" disabled={busy || pending(server.server)} onclick={() => refresh({ action: 'restart', servers: [server.server] })}>
{pending(server.server) ? 'Restart pending' : 'Queue restart'} {pending(server.server) ? 'Restart pending' : 'Queue restart'}
</Button></td></tr> </Button></td></tr>
+4 -3
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { validTimeZone } from '$lib/time-zone.js';
import { cancelMaintenance, checkPlutonium, plutoniumStatus, queueMaintenance, saveMaintenanceSchedule } from '$lib/server/fleet/plutonium.js'; import { cancelMaintenance, checkPlutonium, plutoniumStatus, queueMaintenance, saveMaintenanceSchedule } from '$lib/server/fleet/plutonium.js';
export function GET() { return json(plutoniumStatus()); } export function GET() { return json(plutoniumStatus()); }
@@ -10,10 +11,10 @@ export async function POST({ request }: { request: Request }) {
if (body.action === 'check') { if (body.action === 'check') {
await checkPlutonium(); await checkPlutonium();
} else if (body.action === 'schedule') { } 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)) { 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 and a valid UTC time (HH:mm).' }, { status: 400 }); 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') { } else if (body.action === 'restart') {
if (!Array.isArray(body.servers) || !body.servers.length || body.servers.some((s: unknown) => typeof s !== 'string')) { 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 }); 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js'; 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/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() })); 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_ADDRESS', 'http://komodo.test');
vi.stubEnv('KOMODO_API_KEY', 'test'); vi.stubEnv('KOMODO_API_SECRET', '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.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'); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry(); await seedServerRegistry();
const k = await import('../../src/lib/server/integrations/komodo.js'); 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'); const api = () => import('../../src/lib/server/fleet/plutonium.js');
describe('Plutonium versions', () => { 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('print\nPlutonium T6 r5354 win-x86')).toBe(5354);
expect(parsePlutoniumRevision('version 1.0 build 20260921')).toBeNull(); 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 () => { it('preserves the cached release with a visible error and clears failed running-version observations', async () => {
const m = await api(); await m.checkPlutonium(); 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().release).toMatchObject({ revision: 5354, error: 'Offline' });
expect(m.plutoniumStatus().servers.every(s => s.revision === null && s.error === 'Timeout')).toBe(true); 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', () => { 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: 'schedule', enabled: true, time: '26:99' })).status).toBe(400);
expect((await post({ action: 'restart', servers: ['missing'] })).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: '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(); const m = await api();
expect(m.plutoniumStatus().jobs).toEqual([]); expect(m.plutoniumStatus().jobs).toEqual([]);
expect(m.plutoniumStatus().schedule.enabled).toBe(true); 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 () => { 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('06:00', new Date('2026-09-21T06:00:00Z'))).toBe('2026-09-22T06:00:00.000Z');
expect(() => nextMaintenanceAt('24:00')).toThrow(); expect(() => nextMaintenanceAt('24:00')).toThrow();
const m = await api(); await m.checkPlutonium(); const m = await api(); await m.checkPlutonium();
expect(m.plutoniumStatus().schedule.enabled).toBe(false); 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')); vi.setSystemTime(new Date('2026-09-24T07:00:00Z'));
await m.maintenanceTick(); await m.maintenanceTick(); await m.maintenanceTick(); await m.maintenanceTick();
expect(m.plutoniumStatus().jobs).toHaveLength(2); expect(m.plutoniumStatus().jobs).toHaveLength(2);
expect(m.plutoniumStatus().schedule.nextAt).toBe('2026-09-25T06:00:00.000Z'); 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(); expect(m.plutoniumStatus().schedule.nextAt).toBeNull();
}); });
}); });