484 lines
13 KiB
JavaScript
484 lines
13 KiB
JavaScript
|
const dayjs = require("dayjs");
|
||
|
const { UP, MAINTENANCE, DOWN, PENDING } = require("../src/util");
|
||
|
const { LimitQueue } = require("./utils/limit-queue");
|
||
|
const { log } = require("../src/util");
|
||
|
const { R } = require("redbean-node");
|
||
|
|
||
|
/**
|
||
|
* Calculates the uptime of a monitor.
|
||
|
*/
|
||
|
class UptimeCalculator {
|
||
|
|
||
|
static list = {};
|
||
|
|
||
|
/**
|
||
|
* For testing purposes, we can set the current date to a specific date.
|
||
|
* @type {dayjs.Dayjs}
|
||
|
*/
|
||
|
static currentDate = null;
|
||
|
|
||
|
monitorID;
|
||
|
|
||
|
/**
|
||
|
* Recent 24-hour uptime, each item is a 1-minute interval
|
||
|
* Key: {number} DivisionKey
|
||
|
*/
|
||
|
minutelyUptimeDataList = new LimitQueue(24 * 60);
|
||
|
|
||
|
/**
|
||
|
* Daily uptime data,
|
||
|
* Key: {number} DailyKey
|
||
|
*/
|
||
|
dailyUptimeDataList = new LimitQueue(365);
|
||
|
|
||
|
lastDailyUptimeData = null;
|
||
|
lastUptimeData = null;
|
||
|
|
||
|
lastDailyStatBean = null;
|
||
|
lastMinutelyStatBean = null;
|
||
|
|
||
|
/**
|
||
|
* @param monitorID
|
||
|
* @returns {Promise<UptimeCalculator>}
|
||
|
*/
|
||
|
static async getUptimeCalculator(monitorID) {
|
||
|
if (!UptimeCalculator.list[monitorID]) {
|
||
|
UptimeCalculator.list[monitorID] = new UptimeCalculator();
|
||
|
await UptimeCalculator.list[monitorID].init(monitorID);
|
||
|
}
|
||
|
return UptimeCalculator.list[monitorID];
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param monitorID
|
||
|
*/
|
||
|
static async remove(monitorID) {
|
||
|
delete UptimeCalculator.list[monitorID];
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
*
|
||
|
*/
|
||
|
constructor() {
|
||
|
if (process.env.TEST_BACKEND) {
|
||
|
// Override the getCurrentDate() method to return a specific date
|
||
|
// Only for testing
|
||
|
this.getCurrentDate = () => {
|
||
|
if (UptimeCalculator.currentDate) {
|
||
|
return UptimeCalculator.currentDate;
|
||
|
} else {
|
||
|
return dayjs.utc();
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {number} monitorID
|
||
|
*/
|
||
|
async init(monitorID) {
|
||
|
this.monitorID = monitorID;
|
||
|
|
||
|
let now = this.getCurrentDate();
|
||
|
|
||
|
// Load minutely data from database (recent 24 hours only)
|
||
|
let minutelyStatBeans = await R.find("stat_minutely", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [
|
||
|
monitorID,
|
||
|
this.getMinutelyKey(now.subtract(24, "hour")),
|
||
|
]);
|
||
|
|
||
|
for (let bean of minutelyStatBeans) {
|
||
|
let key = bean.timestamp;
|
||
|
this.minutelyUptimeDataList.push(key, {
|
||
|
up: bean.up,
|
||
|
down: bean.down,
|
||
|
avgPing: bean.ping,
|
||
|
});
|
||
|
}
|
||
|
|
||
|
// Load daily data from database (recent 365 days only)
|
||
|
let dailyStatBeans = await R.find("stat_daily", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [
|
||
|
monitorID,
|
||
|
this.getDailyKey(now.subtract(365, "day").unix()),
|
||
|
]);
|
||
|
|
||
|
for (let bean of dailyStatBeans) {
|
||
|
let key = bean.timestamp;
|
||
|
this.dailyUptimeDataList.push(key, {
|
||
|
up: bean.up,
|
||
|
down: bean.down,
|
||
|
avgPing: bean.ping,
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {number} status status
|
||
|
* @param {number} ping Ping
|
||
|
* @returns {dayjs.Dayjs} date
|
||
|
* @throws {Error} Invalid status
|
||
|
*/
|
||
|
async update(status, ping = 0) {
|
||
|
let date = this.getCurrentDate();
|
||
|
|
||
|
// Don't count MAINTENANCE into uptime
|
||
|
if (status === MAINTENANCE) {
|
||
|
return date;
|
||
|
}
|
||
|
|
||
|
let flatStatus = this.flatStatus(status);
|
||
|
|
||
|
if (flatStatus === DOWN && ping > 0) {
|
||
|
log.warn("uptime-calc", "The ping is not effective when the status is DOWN");
|
||
|
}
|
||
|
|
||
|
let divisionKey = this.getMinutelyKey(date);
|
||
|
let dailyKey = this.getDailyKey(divisionKey);
|
||
|
|
||
|
let minutelyData = this.minutelyUptimeDataList[divisionKey];
|
||
|
let dailyData = this.dailyUptimeDataList[dailyKey];
|
||
|
|
||
|
if (flatStatus === UP) {
|
||
|
minutelyData.up += 1;
|
||
|
dailyData.up += 1;
|
||
|
|
||
|
// Only UP status can update the ping
|
||
|
if (!isNaN(ping)) {
|
||
|
// Add avg ping
|
||
|
// The first beat of the minute, the ping is the current ping
|
||
|
if (minutelyData.up === 1) {
|
||
|
minutelyData.avgPing = ping;
|
||
|
} else {
|
||
|
minutelyData.avgPing = (minutelyData.avgPing * (minutelyData.up - 1) + ping) / minutelyData.up;
|
||
|
}
|
||
|
|
||
|
// Add avg ping (daily)
|
||
|
// The first beat of the day, the ping is the current ping
|
||
|
if (minutelyData.up === 1) {
|
||
|
dailyData.avgPing = ping;
|
||
|
} else {
|
||
|
dailyData.avgPing = (dailyData.avgPing * (dailyData.up - 1) + ping) / dailyData.up;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
} else {
|
||
|
minutelyData.down += 1;
|
||
|
dailyData.down += 1;
|
||
|
}
|
||
|
|
||
|
if (dailyData !== this.lastDailyUptimeData) {
|
||
|
this.lastDailyUptimeData = dailyData;
|
||
|
}
|
||
|
|
||
|
if (minutelyData !== this.lastUptimeData) {
|
||
|
this.lastUptimeData = minutelyData;
|
||
|
}
|
||
|
|
||
|
// Don't store data in test mode
|
||
|
if (process.env.TEST_BACKEND) {
|
||
|
log.debug("uptime-calc", "Skip storing data in test mode");
|
||
|
return date;
|
||
|
}
|
||
|
|
||
|
let dailyStatBean = await this.getDailyStatBean(dailyKey);
|
||
|
dailyStatBean.up = dailyData.up;
|
||
|
dailyStatBean.down = dailyData.down;
|
||
|
dailyStatBean.ping = dailyData.ping;
|
||
|
await R.store(dailyStatBean);
|
||
|
|
||
|
let minutelyStatBean = await this.getMinutelyStatBean(divisionKey);
|
||
|
minutelyStatBean.up = minutelyData.up;
|
||
|
minutelyStatBean.down = minutelyData.down;
|
||
|
minutelyStatBean.ping = minutelyData.ping;
|
||
|
await R.store(minutelyStatBean);
|
||
|
|
||
|
// Remove the old data
|
||
|
log.debug("uptime-calc", "Remove old data");
|
||
|
await R.exec("DELETE FROM stat_minutely WHERE monitor_id = ? AND timestamp < ?", [
|
||
|
this.monitorID,
|
||
|
this.getMinutelyKey(date.subtract(24, "hour")),
|
||
|
]);
|
||
|
|
||
|
return date;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the daily stat bean
|
||
|
* @param {number} timestamp milliseconds
|
||
|
* @returns {Promise<import("redbean-node").Bean>} stat_daily bean
|
||
|
*/
|
||
|
async getDailyStatBean(timestamp) {
|
||
|
if (this.lastDailyStatBean && this.lastDailyStatBean.timestamp === timestamp) {
|
||
|
return this.lastDailyStatBean;
|
||
|
}
|
||
|
|
||
|
let bean = await R.findOne("stat_daily", " monitor_id = ? AND timestamp = ?", [
|
||
|
this.monitorID,
|
||
|
timestamp,
|
||
|
]);
|
||
|
|
||
|
if (!bean) {
|
||
|
bean = R.dispense("stat_daily");
|
||
|
bean.monitor_id = this.monitorID;
|
||
|
bean.timestamp = timestamp;
|
||
|
}
|
||
|
|
||
|
this.lastDailyStatBean = bean;
|
||
|
return this.lastDailyStatBean;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the minutely stat bean
|
||
|
* @param {number} timestamp milliseconds
|
||
|
* @returns {Promise<import("redbean-node").Bean>} stat_minutely bean
|
||
|
*/
|
||
|
async getMinutelyStatBean(timestamp) {
|
||
|
if (this.lastMinutelyStatBean && this.lastMinutelyStatBean.timestamp === timestamp) {
|
||
|
return this.lastMinutelyStatBean;
|
||
|
}
|
||
|
|
||
|
let bean = await R.findOne("stat_minutely", " monitor_id = ? AND timestamp = ?", [
|
||
|
this.monitorID,
|
||
|
timestamp,
|
||
|
]);
|
||
|
|
||
|
if (!bean) {
|
||
|
bean = R.dispense("stat_minutely");
|
||
|
bean.monitor_id = this.monitorID;
|
||
|
bean.timestamp = timestamp;
|
||
|
}
|
||
|
|
||
|
this.lastMinutelyStatBean = bean;
|
||
|
return this.lastMinutelyStatBean;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {dayjs.Dayjs} date The heartbeat date
|
||
|
* @returns {number} Timestamp
|
||
|
*/
|
||
|
getMinutelyKey(date) {
|
||
|
// Convert the current date to the nearest minute (e.g. 2021-01-01 12:34:56 -> 2021-01-01 12:34:00)
|
||
|
date = date.startOf("minute");
|
||
|
|
||
|
// Convert to timestamp in second
|
||
|
let divisionKey = date.unix();
|
||
|
|
||
|
if (! (divisionKey in this.minutelyUptimeDataList)) {
|
||
|
let last = this.minutelyUptimeDataList.getLastKey();
|
||
|
if (last && last > divisionKey) {
|
||
|
log.warn("uptime-calc", "The system time has been changed? The uptime data may be inaccurate.");
|
||
|
}
|
||
|
|
||
|
this.minutelyUptimeDataList.push(divisionKey, {
|
||
|
up: 0,
|
||
|
down: 0,
|
||
|
avgPing: 0,
|
||
|
});
|
||
|
}
|
||
|
|
||
|
return divisionKey;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Convert timestamp to daily key
|
||
|
* @param {number} timestamp Timestamp
|
||
|
* @returns {number} Timestamp
|
||
|
*/
|
||
|
getDailyKey(timestamp) {
|
||
|
let date = dayjs.unix(timestamp);
|
||
|
|
||
|
// Convert the date to the nearest day (e.g. 2021-01-01 12:34:56 -> 2021-01-01 00:00:00)
|
||
|
// Considering if the user keep changing could affect the calculation, so use UTC time to avoid this problem.
|
||
|
date = date.utc().startOf("day");
|
||
|
let dailyKey = date.unix();
|
||
|
|
||
|
if (!this.dailyUptimeDataList[dailyKey]) {
|
||
|
let last = this.dailyUptimeDataList.getLastKey();
|
||
|
if (last && last > dailyKey) {
|
||
|
log.warn("uptime-calc", "The system time has been changed? The uptime data may be inaccurate.");
|
||
|
}
|
||
|
|
||
|
this.dailyUptimeDataList.push(dailyKey, {
|
||
|
up: 0,
|
||
|
down: 0,
|
||
|
avgPing: 0,
|
||
|
});
|
||
|
}
|
||
|
|
||
|
return dailyKey;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Flat status to UP or DOWN
|
||
|
* @param {number} status
|
||
|
* @returns {number}
|
||
|
* @throws {Error} Invalid status
|
||
|
*/
|
||
|
flatStatus(status) {
|
||
|
switch (status) {
|
||
|
case UP:
|
||
|
// case MAINTENANCE:
|
||
|
return UP;
|
||
|
case DOWN:
|
||
|
case PENDING:
|
||
|
return DOWN;
|
||
|
}
|
||
|
throw new Error("Invalid status");
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {number} num
|
||
|
* @param {string} type "day" | "minute"
|
||
|
*/
|
||
|
getData(num, type = "day") {
|
||
|
let key;
|
||
|
|
||
|
if (type === "day") {
|
||
|
key = this.getDailyKey(this.getCurrentDate().unix());
|
||
|
} else {
|
||
|
if (num > 24 * 60) {
|
||
|
throw new Error("The maximum number of minutes is 1440");
|
||
|
}
|
||
|
key = this.getMinutelyKey(this.getCurrentDate());
|
||
|
}
|
||
|
|
||
|
let total = {
|
||
|
up: 0,
|
||
|
down: 0,
|
||
|
};
|
||
|
|
||
|
let totalPing = 0;
|
||
|
let endTimestamp;
|
||
|
|
||
|
if (type === "day") {
|
||
|
endTimestamp = key - 86400 * (num - 1);
|
||
|
} else {
|
||
|
endTimestamp = key - 60 * (num - 1);
|
||
|
}
|
||
|
|
||
|
// Sum up all data in the specified time range
|
||
|
while (key >= endTimestamp) {
|
||
|
let data;
|
||
|
|
||
|
if (type === "day") {
|
||
|
data = this.dailyUptimeDataList[key];
|
||
|
} else {
|
||
|
data = this.minutelyUptimeDataList[key];
|
||
|
}
|
||
|
|
||
|
if (data) {
|
||
|
total.up += data.up;
|
||
|
total.down += data.down;
|
||
|
totalPing += data.avgPing * data.up;
|
||
|
}
|
||
|
|
||
|
// Previous day
|
||
|
if (type === "day") {
|
||
|
key -= 86400;
|
||
|
} else {
|
||
|
key -= 60;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
let uptimeData = new UptimeDataResult();
|
||
|
|
||
|
if (total.up === 0 && total.down === 0) {
|
||
|
if (type === "day" && this.lastDailyUptimeData) {
|
||
|
total = this.lastDailyUptimeData;
|
||
|
totalPing = total.avgPing * total.up;
|
||
|
} else if (type === "minute" && this.lastUptimeData) {
|
||
|
total = this.lastUptimeData;
|
||
|
totalPing = total.avgPing * total.up;
|
||
|
} else {
|
||
|
uptimeData.uptime = 0;
|
||
|
uptimeData.avgPing = null;
|
||
|
return uptimeData;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
let avgPing;
|
||
|
|
||
|
if (total.up === 0) {
|
||
|
avgPing = null;
|
||
|
} else {
|
||
|
avgPing = totalPing / total.up;
|
||
|
}
|
||
|
|
||
|
uptimeData.uptime = total.up / (total.up + total.down);
|
||
|
uptimeData.avgPing = avgPing;
|
||
|
return uptimeData;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the uptime data by duration
|
||
|
* @param {'24h'|'30d'|'1y'} duration Only accept 24h, 30d, 1y
|
||
|
* @returns {UptimeDataResult} UptimeDataResult
|
||
|
* @throws {Error} Invalid duration
|
||
|
*/
|
||
|
getDataByDuration(duration) {
|
||
|
if (duration === "24h") {
|
||
|
return this.get24Hour();
|
||
|
} else if (duration === "30d") {
|
||
|
return this.get30Day();
|
||
|
} else if (duration === "1y") {
|
||
|
return this.get1Year();
|
||
|
} else {
|
||
|
throw new Error("Invalid duration");
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 1440 = 24 * 60mins
|
||
|
* @returns {UptimeDataResult} UptimeDataResult
|
||
|
*/
|
||
|
get24Hour() {
|
||
|
return this.getData(1440, "minute");
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @returns {UptimeDataResult} UptimeDataResult
|
||
|
*/
|
||
|
get7Day() {
|
||
|
return this.getData(7);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @returns {UptimeDataResult} UptimeDataResult
|
||
|
*/
|
||
|
get30Day() {
|
||
|
return this.getData(30);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @returns {UptimeDataResult} UptimeDataResult
|
||
|
*/
|
||
|
get1Year() {
|
||
|
return this.getData(365);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @returns {dayjs.Dayjs} Current date
|
||
|
*/
|
||
|
getCurrentDate() {
|
||
|
return dayjs.utc();
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
class UptimeDataResult {
|
||
|
/**
|
||
|
* @type {number} Uptime
|
||
|
*/
|
||
|
uptime;
|
||
|
|
||
|
/**
|
||
|
* @type {number} Average ping
|
||
|
*/
|
||
|
avgPing;
|
||
|
}
|
||
|
|
||
|
module.exports = {
|
||
|
UptimeCalculator,
|
||
|
UptimeDataResult,
|
||
|
};
|