From ce6861203e111abe4133cc899eb9f3d39c092dd7 Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 21 Sep 2026 19:14:40 -0400 Subject: [PATCH] Use user timezone for maintenance and official Plutonium manifest --- README.md | 15 +++- src/lib/plutonium.ts | 59 +++++++++++--- src/lib/server/fleet/plutonium.ts | 24 +++--- .../ui/components/PlutoniumMaintenance.svelte | 24 ++++-- src/routes/api/plutonium/+server.ts | 7 +- tests/unit/plutonium.test.ts | 77 +++++++++++++++++-- 6 files changed, 161 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 9593b5e..010b657 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/lib/plutonium.ts b/src/lib/plutonium.ts index f90efde..e7f986a 100644 --- a/src/lib/plutonium.ts +++ b/src/lib/plutonium.ts @@ -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(/]*>([\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(); + // 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)); } diff --git a/src/lib/server/fleet/plutonium.ts b/src/lib/server/fleet/plutonium.ts index 9822e48..ab38172 100644 --- a/src/lib/server/fleet/plutonium.ts +++ b/src/lib/server/fleet/plutonium.ts @@ -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(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 { async function check(): Promise { 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 { 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(); diff --git a/src/lib/ui/components/PlutoniumMaintenance.svelte b/src/lib/ui/components/PlutoniumMaintenance.svelte index b54e8cc..46c16f0 100644 --- a/src/lib/ui/components/PlutoniumMaintenance.svelte +++ b/src/lib/ui/components/PlutoniumMaintenance.svelte @@ -1,7 +1,8 @@