320 lines
12 KiB
TypeScript
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);
|
|
}
|
|
};
|