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