Files
electric-cherry/src/lib/plutonium.ts
T

123 lines
4.6 KiB
TypeScript

import { validTimeZone } from './time-zone.js';
export interface MaintenanceSchedule {
enabled: boolean;
time: string;
timeZone: string;
nextAt: string | null;
}
export interface PlutoniumRelease {
revision: number | null;
checkedAt: string | null;
error: string | null;
}
export interface PlutoniumVersion {
server: string;
revision: number | null;
checked_at: string | null;
error: string | null;
stopped?: boolean;
update_status?: 'outdated' | 'current' | 'ahead' | 'unknown' | 'stopped';
}
export interface MaintenanceJob {
id: number;
server: string;
server_deleted_at?: string | null;
state: 'queued' | 'restarting' | 'done' | 'failed' | 'cancelled';
target_revision: number | null;
requested_at: string;
restarted_at: string | null;
provisioning_id: string | null;
finished_at: string | null;
detail: string | null;
kind: 'legacy' | 'update' | 'schedule';
origin: 'manual' | 'automatic' | 'schedule';
operation_stage?: string | null;
operation_state?: string | null;
}
export interface PlutoniumStatus {
release: PlutoniumRelease;
schedule: MaintenanceSchedule;
servers: PlutoniumVersion[];
jobs: MaintenanceJob[];
}
/** Accept explicit revision tokens only, never engine versions or build dates. */
export function parsePlutoniumRevision(text: string): number | null {
const match = text.replace(/\^[0-9]/g, '').match(/\br([1-9]\d{2,8})\b/i);
return match ? Number(match[1]) : null;
}
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(), 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);
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));
}