Use user timezone for maintenance and official Plutonium manifest
This commit is contained in:
@@ -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
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user