monitor/core/modules/FxScheduler.ts
2025-04-16 22:30:27 +07:00

320 lines
12 KiB
TypeScript

const modulename = 'FxScheduler';
import { parseSchedule } from '@lib/misc';
import consoleFactory from '@lib/console';
import { SYM_SYSTEM_AUTHOR } from '@lib/symbols';
import type { UpdateConfigKeySet } from './ConfigStore/utils';
const console = consoleFactory(modulename);
//Types
type RestartInfo = {
string: string;
minuteFloorTs: number;
}
type ParsedTime = {
string: string;
hours: number;
minutes: number;
}
//Consts
const scheduleWarnings = [30, 15, 10, 5, 4, 3, 2, 1];
/**
* Processes an array of HH:MM, gets the next timestamp (sorted by closest).
* When time matches, it will be dist: 0, distMins: 0, and nextTs likely in the
* past due to seconds and milliseconds being 0.
*/
const getNextScheduled = (parsedSchedule: ParsedTime[]): RestartInfo => {
const thisMinuteTs = new Date().setSeconds(0, 0);
const processed = parsedSchedule.map((t) => {
const nextDate = new Date();
let minuteFloorTs = nextDate.setHours(t.hours, t.minutes, 0, 0);
if (minuteFloorTs < thisMinuteTs) {
minuteFloorTs = nextDate.setHours(t.hours + 24, t.minutes, 0, 0);
}
return {
string: t.string,
minuteFloorTs,
};
});
return processed.sort((a, b) => a.minuteFloorTs - b.minuteFloorTs)[0];
};
/**
* Module responsible for restarting the FXServer on a schedule defined in the config,
* or a temporary schedule set by the user at runtime.
*/
export default class FxScheduler {
static readonly configKeysWatched = ['restarter.schedule'];
private nextTempSchedule: RestartInfo | false = false;
private calculatedNextRestartMinuteFloorTs: number | false = false;
private nextSkip: number | false = false;
constructor() {
//Initial check to update status
setImmediate(() => {
this.checkSchedule();
});
//Cron Function
setInterval(() => {
this.checkSchedule();
txCore.webServer.webSocket.pushRefresh('status');
}, 60 * 1000);
}
/**
* Refresh configs, resets skip and temp scheduled, runs checkSchedule.
*/
handleConfigUpdate(updatedConfigs: UpdateConfigKeySet) {
this.nextSkip = false;
this.nextTempSchedule = false;
this.checkSchedule();
txCore.webServer.webSocket.pushRefresh('status');
}
/**
* Updates state when server closes.
* Clear temp skips and skips next scheduled if it's in less than 2 hours.
*/
handleServerClose() {
//Clear temp schedule, recalculates next restart
if (this.nextTempSchedule) this.nextTempSchedule = false;
this.checkSchedule(true);
//Check if next scheduled restart is in less than 2 hours
const inTwoHours = Date.now() + 2 * 60 * 60 * 1000;
if (
this.calculatedNextRestartMinuteFloorTs
&& this.calculatedNextRestartMinuteFloorTs < inTwoHours
&& this.nextSkip !== this.calculatedNextRestartMinuteFloorTs
) {
console.warn('Server closed, skipping next scheduled restart because it\'s in less than 2 hours.');
this.nextSkip = this.calculatedNextRestartMinuteFloorTs;
}
this.checkSchedule(true);
//Push UI update
txCore.webServer.webSocket.pushRefresh('status');
}
/**
* Returns the current status of scheduler
* NOTE: sending relative because server might have clock skew
*/
getStatus() {
if (this.calculatedNextRestartMinuteFloorTs) {
const thisMinuteTs = new Date().setSeconds(0, 0);
return {
nextRelativeMs: this.calculatedNextRestartMinuteFloorTs - thisMinuteTs,
nextSkip: this.nextSkip === this.calculatedNextRestartMinuteFloorTs,
nextIsTemp: !!this.nextTempSchedule,
};
} else {
return {
nextRelativeMs: false,
nextSkip: false,
nextIsTemp: false,
} as const;
}
}
/**
* Sets this.nextSkip.
* Cancel scheduled button -> setNextSkip(true)
* Enable scheduled button -> setNextSkip(false)
*/
setNextSkip(enabled: boolean, author?: string) {
if (enabled) {
let prevMinuteFloorTs, temporary;
if (this.nextTempSchedule) {
prevMinuteFloorTs = this.nextTempSchedule.minuteFloorTs;
temporary = true;
this.nextTempSchedule = false;
} else if (this.calculatedNextRestartMinuteFloorTs) {
prevMinuteFloorTs = this.calculatedNextRestartMinuteFloorTs;
temporary = false;
this.nextSkip = this.calculatedNextRestartMinuteFloorTs;
}
if (prevMinuteFloorTs) {
//Dispatch `txAdmin:events:scheduledRestartSkipped`
txCore.fxRunner.sendEvent('scheduledRestartSkipped', {
secondsRemaining: Math.floor((prevMinuteFloorTs - Date.now()) / 1000),
temporary,
author,
});
//FIXME: deprecate
txCore.fxRunner.sendEvent('skippedNextScheduledRestart', {
secondsRemaining: Math.floor((prevMinuteFloorTs - Date.now()) / 1000),
temporary
});
}
} else {
this.nextSkip = false;
}
//This is needed to refresh this.calculatedNextRestartMinuteFloorTs
this.checkSchedule();
//Refresh UI
txCore.webServer.webSocket.pushRefresh('status');
}
/**
* Sets this.nextTempSchedule.
* The value MUST be before the next setting scheduled time.
*/
setNextTempSchedule(timeString: string) {
//Process input
if (typeof timeString !== 'string') throw new Error('expected string');
const thisMinuteTs = new Date().setSeconds(0, 0);
let scheduledString, scheduledMinuteFloorTs;
if (timeString.startsWith('+')) {
const minutes = parseInt(timeString.slice(1));
if (isNaN(minutes) || minutes < 1 || minutes >= 1440) {
throw new Error('invalid minutes');
}
const nextDate = new Date(thisMinuteTs + (minutes * 60 * 1000));
scheduledMinuteFloorTs = nextDate.getTime();
scheduledString = nextDate.getHours().toString().padStart(2, '0') + ':' + nextDate.getMinutes().toString().padStart(2, '0');
} else {
const [hours, minutes] = timeString.split(':', 2).map((x) => parseInt(x));
if (typeof hours === 'undefined' || isNaN(hours) || hours < 0 || hours > 23) throw new Error('invalid hours');
if (typeof minutes === 'undefined' || isNaN(minutes) || minutes < 0 || minutes > 59) throw new Error('invalid minutes');
const nextDate = new Date();
scheduledMinuteFloorTs = nextDate.setHours(hours, minutes, 0, 0);
if (scheduledMinuteFloorTs === thisMinuteTs) {
throw new Error('Due to the 1 minute precision of the restart scheduler, you cannot schedule a restart in the same minute.');
}
if (scheduledMinuteFloorTs < thisMinuteTs) {
scheduledMinuteFloorTs = nextDate.setHours(hours + 24, minutes, 0, 0);
}
scheduledString = hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0');
}
//Check validity
if (Array.isArray(txConfig.restarter.schedule) && txConfig.restarter.schedule.length) {
const { valid } = parseSchedule(txConfig.restarter.schedule);
const nextSettingRestart = getNextScheduled(valid);
if (nextSettingRestart.minuteFloorTs < scheduledMinuteFloorTs) {
throw new Error(`You already have one restart scheduled for ${nextSettingRestart.string}, which is before the time you specified.`);
}
}
// Set next temp schedule
this.nextTempSchedule = {
string: scheduledString,
minuteFloorTs: scheduledMinuteFloorTs,
};
//This is needed to refresh this.calculatedNextRestartMinuteFloorTs
this.checkSchedule();
//Refresh UI
txCore.webServer.webSocket.pushRefresh('status');
}
/**
* Checks the schedule to see if it's time to announce or restart the server
*/
async checkSchedule(calculateOnly = false) {
//Check settings and temp scheduled restart
let nextRestart: RestartInfo;
if (this.nextTempSchedule) {
nextRestart = this.nextTempSchedule;
} else if (Array.isArray(txConfig.restarter.schedule) && txConfig.restarter.schedule.length) {
const { valid } = parseSchedule(txConfig.restarter.schedule);
nextRestart = getNextScheduled(valid);
} else {
//nothing scheduled
this.calculatedNextRestartMinuteFloorTs = false;
return;
}
this.calculatedNextRestartMinuteFloorTs = nextRestart.minuteFloorTs;
if (calculateOnly) return;
//Checking if skipped
if (this.nextSkip === this.calculatedNextRestartMinuteFloorTs) {
console.verbose.log(`Skipping next scheduled restart`);
return;
}
//Calculating dist
const thisMinuteTs = new Date().setSeconds(0, 0);
const nextDistMs = nextRestart.minuteFloorTs - thisMinuteTs;
const nextDistMins = Math.floor(nextDistMs / 60_000);
//Checking if server restart or warning time
if (nextDistMins === 0) {
//restart server
this.triggerServerRestart(
`scheduled restart at ${nextRestart.string}`,
txCore.translator.t('restarter.schedule_reason', { time: nextRestart.string }),
);
//Check if server is in boot cooldown
const processUptime = Math.floor((txCore.fxRunner.child?.uptime ?? 0) / 1000);
if (processUptime < txConfig.restarter.bootGracePeriod) {
console.verbose.log(`Server is in boot cooldown, skipping scheduled restart.`);
return;
}
//reset next scheduled
this.nextTempSchedule = false;
} else if (scheduleWarnings.includes(nextDistMins)) {
const tOptions = {
smart_count: nextDistMins,
servername: txConfig.general.serverName,
};
//Send discord warning
txCore.discordBot.sendAnnouncement({
type: 'warning',
description: {
key: 'restarter.schedule_warn_discord',
data: tOptions
}
});
//Dispatch `txAdmin:events:scheduledRestart`
txCore.fxRunner.sendEvent('scheduledRestart', {
secondsRemaining: nextDistMins * 60,
translatedMessage: txCore.translator.t('restarter.schedule_warn', tOptions)
});
}
}
/**
* Triggers FXServer restart and logs the reason.
*/
async triggerServerRestart(reasonInternal: string, reasonTranslated: string) {
//Sanity check
if (txCore.fxRunner.isIdle || !txCore.fxRunner.child?.isAlive) {
console.verbose.warn('Server not running, skipping scheduled restart.');
return false;
}
//Restart server
const logMessage = `Restarting server: ${reasonInternal}`;
txCore.logger.admin.write('SCHEDULER', logMessage);
txCore.logger.fxserver.logInformational(logMessage); //just for better visibility
txCore.fxRunner.restartServer(reasonTranslated, SYM_SYSTEM_AUTHOR);
}
};