You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
uptime-kuma/server/uptime-calculator.js

474 lines
13 KiB

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.avgPing;
await R.store(dailyStatBean);
let minutelyStatBean = await this.getMinutelyStatBean(divisionKey);
minutelyStatBean.up = minutelyData.up;
minutelyStatBean.down = minutelyData.down;
minutelyStatBean.ping = minutelyData.avgPing;
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)) {
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]) {
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,
};