Uptime calculation improvement and 1-year uptime (#2750)

This commit is contained in:
Louis Lam
2023-09-01 05:19:21 +08:00
committed by GitHub
parent eec221247f
commit 076331bf00
22 changed files with 1306 additions and 264 deletions

View File

@@ -45,8 +45,6 @@ async function sendNotificationList(socket) {
* @returns {Promise<void>}
*/
async function sendHeartbeatList(socket, monitorID, toUser = false, overwrite = false) {
const timeLogger = new TimeLogger();
let list = await R.getAll(`
SELECT * FROM heartbeat
WHERE monitor_id = ?
@@ -63,8 +61,6 @@ async function sendHeartbeatList(socket, monitorID, toUser = false, overwrite =
} else {
socket.emit("heartbeatList", monitorID, result, overwrite);
}
timeLogger.print(`[Monitor: ${monitorID}] sendHeartbeatList`);
}
/**

View File

@@ -183,6 +183,12 @@ class Database {
let config = {};
let mariadbPoolConfig = {
afterCreate: function (conn, done) {
}
};
log.info("db", `Database Type: ${dbConfig.type}`);
if (dbConfig.type === "sqlite") {
@@ -233,7 +239,9 @@ class Database {
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.dbName,
}
timezone: "UTC",
},
pool: mariadbPoolConfig,
};
} else if (dbConfig.type === "embedded-mariadb") {
let embeddedMariaDB = EmbeddedMariaDB.getInstance();
@@ -245,7 +253,8 @@ class Database {
socketPath: embeddedMariaDB.socketPath,
user: "node",
database: "kuma",
}
},
pool: mariadbPoolConfig,
};
} else {
throw new Error("Unknown Database type: " + dbConfig.type);
@@ -350,6 +359,7 @@ class Database {
}
/**
* TODO
* @returns {Promise<void>}
*/
static async rollbackLatestPatch() {
@@ -582,14 +592,6 @@ class Database {
}
}
/**
* Aquire a direct connection to database
* @returns {any} Database connection
*/
static getBetterSQLite3Database() {
return R.knex.client.acquireConnection();
}
/**
* Special handle, because tarn.js throw a promise reject that cannot be caught
* @returns {Promise<void>}
@@ -603,7 +605,9 @@ class Database {
log.info("db", "Closing the database");
// Flush WAL to main database
await R.exec("PRAGMA wal_checkpoint(TRUNCATE)");
if (Database.dbConfig.type === "sqlite") {
await R.exec("PRAGMA wal_checkpoint(TRUNCATE)");
}
while (true) {
Database.noReject = true;
@@ -616,20 +620,23 @@ class Database {
log.info("db", "Waiting to close the database");
}
}
log.info("db", "SQLite closed");
log.info("db", "Database closed");
process.removeListener("unhandledRejection", listener);
}
/**
* Get the size of the database
* Get the size of the database (SQLite only)
* @returns {number} Size of database
*/
static getSize() {
log.debug("db", "Database.getSize()");
let stats = fs.statSync(Database.sqlitePath);
log.debug("db", stats);
return stats.size;
if (Database.dbConfig.type === "sqlite") {
log.debug("db", "Database.getSize()");
let stats = fs.statSync(Database.sqlitePath);
log.debug("db", stats);
return stats.size;
}
return 0;
}
/**
@@ -637,7 +644,9 @@ class Database {
* @returns {Promise<void>}
*/
static async shrink() {
await R.exec("VACUUM");
if (Database.dbConfig.type === "sqlite") {
await R.exec("VACUUM");
}
}
/**

View File

@@ -43,7 +43,9 @@ const clearOldData = async () => {
[ parsedPeriod * -24 ]
);
await R.exec("PRAGMA optimize;");
if (Database.dbConfig.type === "sqlite") {
await R.exec("PRAGMA optimize;");
}
} catch (e) {
log.error("clearOldData", `Failed to clear old data: ${e.message}`);
}

View File

@@ -18,11 +18,10 @@ const apicache = require("../modules/apicache");
const { UptimeKumaServer } = require("../uptime-kuma-server");
const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent");
const { DockerHost } = require("../docker");
const { UptimeCacheList } = require("../uptime-cache-list");
const Gamedig = require("gamedig");
const jsonata = require("jsonata");
const jwt = require("jsonwebtoken");
const Database = require("../database");
const { UptimeCalculator } = require("../uptime-calculator");
/**
* status:
@@ -346,13 +345,6 @@ class Monitor extends BeanModel {
bean.status = flipStatus(bean.status);
}
// Duration
if (!isFirstBeat) {
bean.duration = dayjs(bean.time).diff(dayjs(previousBeat.time), "second");
} else {
bean.duration = 0;
}
try {
if (await Monitor.isUnderMaintenance(this.id)) {
bean.msg = "Monitor under maintenance";
@@ -971,11 +963,17 @@ class Monitor extends BeanModel {
log.warn("monitor", `Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type} | Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`);
}
// Calculate uptime
let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(this.id);
let endTimeDayjs = await uptimeCalculator.update(bean.status, parseFloat(bean.ping));
bean.end_time = R.isoDateTimeMillis(endTimeDayjs);
// Send to frontend
log.debug("monitor", `[${this.name}] Send to socket`);
UptimeCacheList.clearCache(this.id);
io.to(this.user_id).emit("heartbeat", bean.toJSON());
Monitor.sendStats(io, this.id, this.user_id);
// Store to database
log.debug("monitor", `[${this.name}] Store`);
await R.store(bean);
@@ -1149,44 +1147,31 @@ class Monitor extends BeanModel {
*/
static async sendStats(io, monitorID, userID) {
const hasClients = getTotalClientInRoom(io, userID) > 0;
let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
if (hasClients) {
await Monitor.sendAvgPing(24, io, monitorID, userID);
await Monitor.sendUptime(24, io, monitorID, userID);
await Monitor.sendUptime(24 * 30, io, monitorID, userID);
// Send 24 hour average ping
let data24h = await uptimeCalculator.get24Hour();
io.to(userID).emit("avgPing", monitorID, (data24h.avgPing) ? data24h.avgPing.toFixed(2) : null);
// Send 24 hour uptime
io.to(userID).emit("uptime", monitorID, 24, data24h.uptime);
// Send 30 day uptime
let data30d = await uptimeCalculator.get30Day();
io.to(userID).emit("uptime", monitorID, 720, data30d.uptime);
// Send 1-year uptime
let data1y = await uptimeCalculator.get1Year();
io.to(userID).emit("uptime", monitorID, "1y", data1y.uptime);
// Send Cert Info
await Monitor.sendCertInfo(io, monitorID, userID);
} else {
log.debug("monitor", "No clients in the room, no need to send stats");
}
}
/**
* Send the average ping to user
* @param {number} duration Hours
* @param {Server} io Socket instance to send data to
* @param {number} monitorID ID of monitor to read
* @param {number} userID ID of user to send data to
* @returns {void}
*/
static async sendAvgPing(duration, io, monitorID, userID) {
const timeLogger = new TimeLogger();
const sqlHourOffset = Database.sqlHourOffset();
let avgPing = parseInt(await R.getCell(`
SELECT AVG(ping)
FROM heartbeat
WHERE time > ${sqlHourOffset}
AND ping IS NOT NULL
AND monitor_id = ? `, [
-duration,
monitorID,
]));
timeLogger.print(`[Monitor: ${monitorID}] avgPing`);
io.to(userID).emit("avgPing", monitorID, avgPing);
}
/**
* Send certificate information to client
* @param {Server} io Socket server instance
@@ -1203,101 +1188,6 @@ class Monitor extends BeanModel {
}
}
/**
* Uptime with calculation
* Calculation based on:
* https://www.uptrends.com/support/kb/reporting/calculation-of-uptime-and-downtime
* @param {number} duration Hours
* @param {number} monitorID ID of monitor to calculate
* @param {boolean} forceNoCache Should the uptime be recalculated?
* @returns {number} Uptime of monitor
*/
static async calcUptime(duration, monitorID, forceNoCache = false) {
if (!forceNoCache) {
let cachedUptime = UptimeCacheList.getUptime(monitorID, duration);
if (cachedUptime != null) {
return cachedUptime;
}
}
const timeLogger = new TimeLogger();
const startTime = R.isoDateTime(dayjs.utc().subtract(duration, "hour"));
// Handle if heartbeat duration longer than the target duration
// e.g. If the last beat's duration is bigger that the 24hrs window, it will use the duration between the (beat time - window margin) (THEN case in SQL)
let result = await R.getRow(`
SELECT
-- SUM all duration, also trim off the beat out of time window
SUM(
CASE
WHEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400 < duration
THEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400
ELSE duration
END
) AS total_duration,
-- SUM all uptime duration, also trim off the beat out of time window
SUM(
CASE
WHEN (status = 1 OR status = 3)
THEN
CASE
WHEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400 < duration
THEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400
ELSE duration
END
END
) AS uptime_duration
FROM heartbeat
WHERE time > ?
AND monitor_id = ?
`, [
startTime, startTime, startTime, startTime, startTime,
monitorID,
]);
timeLogger.print(`[Monitor: ${monitorID}][${duration}] sendUptime`);
let totalDuration = result.total_duration;
let uptimeDuration = result.uptime_duration;
let uptime = 0;
if (totalDuration > 0) {
uptime = uptimeDuration / totalDuration;
if (uptime < 0) {
uptime = 0;
}
} else {
// Handle new monitor with only one beat, because the beat's duration = 0
let status = parseInt(await R.getCell("SELECT `status` FROM heartbeat WHERE monitor_id = ?", [ monitorID ]));
if (status === UP) {
uptime = 1;
}
}
// Cache
UptimeCacheList.addUptime(monitorID, duration, uptime);
return uptime;
}
/**
* Send Uptime
* @param {number} duration Hours
* @param {Server} io Socket server instance
* @param {number} monitorID ID of monitor to send
* @param {number} userID ID of user to send to
* @returns {void}
*/
static async sendUptime(duration, io, monitorID, userID) {
const uptime = await this.calcUptime(duration, monitorID);
io.to(userID).emit("uptime", monitorID, duration, uptime);
}
/**
* Has status of monitor changed since last beat?
* @param {boolean} isFirstBeat Is this the first beat of this monitor?

View File

@@ -7,11 +7,11 @@ const dayjs = require("dayjs");
const { UP, MAINTENANCE, DOWN, PENDING, flipStatus, log } = require("../../src/util");
const StatusPage = require("../model/status_page");
const { UptimeKumaServer } = require("../uptime-kuma-server");
const { UptimeCacheList } = require("../uptime-cache-list");
const { makeBadge } = require("badge-maker");
const { badgeConstants } = require("../config");
const { Prometheus } = require("../prometheus");
const Database = require("../database");
const { UptimeCalculator } = require("../uptime-calculator");
let router = express.Router();
@@ -89,7 +89,7 @@ router.get("/api/push/:pushToken", async (request, response) => {
await R.store(bean);
io.to(monitor.user_id).emit("heartbeat", bean.toJSON());
UptimeCacheList.clearCache(monitor.id);
Monitor.sendStats(io, monitor.id, monitor.user_id);
new Prometheus(monitor).update(bean, undefined);
@@ -206,9 +206,13 @@ router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (reques
try {
const requestedMonitorId = parseInt(request.params.id, 10);
// if no duration is given, set value to 24 (h)
const requestedDuration = request.params.duration !== undefined ? parseInt(request.params.duration, 10) : 24;
let requestedDuration = request.params.duration !== undefined ? request.params.duration : "24h";
const overrideValue = value && parseFloat(value);
if (requestedDuration === "24") {
requestedDuration = "24h";
}
let publicMonitor = await R.getRow(`
SELECT monitor_group.monitor_id FROM monitor_group, \`group\`
WHERE monitor_group.group_id = \`group\`.id
@@ -225,10 +229,8 @@ router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (reques
badgeValues.message = "N/A";
badgeValues.color = badgeConstants.naColor;
} else {
const uptime = overrideValue ?? await Monitor.calcUptime(
requestedDuration,
requestedMonitorId
);
const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(requestedMonitorId);
const uptime = overrideValue ?? uptimeCalculator.getDataByDuration(requestedDuration).uptime;
// limit the displayed uptime percentage to four (two, when displayed as percent) decimal digits
const cleanUptime = (uptime * 100).toPrecision(4);
@@ -274,21 +276,19 @@ router.get("/api/badge/:id/ping/:duration?", cache("5 minutes"), async (request,
const requestedMonitorId = parseInt(request.params.id, 10);
// Default duration is 24 (h) if not defined in queryParam, limited to 720h (30d)
const requestedDuration = Math.min(request.params.duration ? parseInt(request.params.duration, 10) : 24, 720);
let requestedDuration = request.params.duration !== undefined ? request.params.duration : "24h";
const overrideValue = value && parseFloat(value);
if (requestedDuration === "24") {
requestedDuration = "24h";
}
const sqlHourOffset = Database.sqlHourOffset();
const publicAvgPing = parseInt(await R.getCell(`
SELECT AVG(ping) FROM monitor_group, \`group\`, heartbeat
WHERE monitor_group.group_id = \`group\`.id
AND heartbeat.time > ${sqlHourOffset}
AND heartbeat.ping IS NOT NULL
AND public = 1
AND heartbeat.monitor_id = ?
`,
[ -requestedDuration, requestedMonitorId ]
));
// Check if monitor is public
const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(requestedMonitorId);
const publicAvgPing = uptimeCalculator.getDataByDuration(requestedDuration).avgPing;
const badgeValues = { style };

View File

@@ -7,6 +7,7 @@ const { R } = require("redbean-node");
const Monitor = require("../model/monitor");
const { badgeConstants } = require("../config");
const { makeBadge } = require("badge-maker");
const { UptimeCalculator } = require("../uptime-calculator");
let router = express.Router();
@@ -92,8 +93,8 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques
list = R.convertToBeans("heartbeat", list);
heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());
const type = 24;
uptimeList[`${monitorID}_${type}`] = await Monitor.calcUptime(type, monitorID);
const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
uptimeList[`${monitorID}_24`] = uptimeCalculator.get24Hour().uptime;
}
response.json({

View File

@@ -84,7 +84,7 @@ log.info("server", "Importing this project modules");
log.debug("server", "Importing Monitor");
const Monitor = require("./model/monitor");
log.debug("server", "Importing Settings");
const { getSettings, setSettings, setting, initJWTSecret, checkLogin, startUnitTest, FBSD, doubleCheckPassword, startE2eTests,
const { getSettings, setSettings, setting, initJWTSecret, checkLogin, FBSD, doubleCheckPassword, startE2eTests,
allowDevAllOrigin
} = require("./util-server");
@@ -1659,10 +1659,6 @@ let needSetup = false;
startMonitors();
checkVersion.startInterval();
if (testMode) {
startUnitTest();
}
if (e2eTestMode) {
startE2eTests();
}

View File

@@ -1,51 +0,0 @@
const { log } = require("../src/util");
class UptimeCacheList {
/**
* list[monitorID][duration]
*/
static list = {};
/**
* Get the uptime for a specific period
* @param {number} monitorID ID of monitor to query
* @param {number} duration Duration to query
* @returns {(number|null)} Uptime for provided duration, if it exists
*/
static getUptime(monitorID, duration) {
if (UptimeCacheList.list[monitorID] && UptimeCacheList.list[monitorID][duration]) {
log.debug("UptimeCacheList", "getUptime: " + monitorID + " " + duration);
return UptimeCacheList.list[monitorID][duration];
} else {
return null;
}
}
/**
* Add uptime for specified monitor
* @param {number} monitorID ID of monitor to insert for
* @param {number} duration Duration to insert for
* @param {number} uptime Uptime to add
* @returns {void}
*/
static addUptime(monitorID, duration, uptime) {
log.debug("UptimeCacheList", "addUptime: " + monitorID + " " + duration);
if (!UptimeCacheList.list[monitorID]) {
UptimeCacheList.list[monitorID] = {};
}
UptimeCacheList.list[monitorID][duration] = uptime;
}
/**
* Clear cache for specified monitor
* @param {number} monitorID ID of monitor to clear
* @returns {void}
*/
static clearCache(monitorID) {
log.debug("UptimeCacheList", "clearCache: " + monitorID);
delete UptimeCacheList.list[monitorID];
}
}
module.exports = {
UptimeCacheList,
};

483
server/uptime-calculator.js Normal file
View File

@@ -0,0 +1,483 @@
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,
};

View File

@@ -847,29 +847,6 @@ exports.doubleCheckPassword = async (socket, currentPassword) => {
return user;
};
/**
* Start Unit tests
* @returns {void}
*/
exports.startUnitTest = async () => {
console.log("Starting unit test...");
const npm = /^win/.test(process.platform) ? "npm.cmd" : "npm";
const child = childProcess.spawn(npm, [ "run", "jest-backend" ]);
child.stdout.on("data", (data) => {
console.log(data.toString());
});
child.stderr.on("data", (data) => {
console.log(data.toString());
});
child.on("close", function (code) {
console.log("Jest exit code: " + code);
process.exit(code);
});
};
/**
* Start end-to-end tests
* @returns {void}

View File

@@ -0,0 +1,79 @@
/**
* An object that can be used as an array with a key
* Like PHP's array
*/
class ArrayWithKey {
__stack = [];
/**
*
*/
constructor() {
}
/**
* @param key
* @param value
*/
push(key, value) {
this[key] = value;
this.__stack.push(key);
}
/**
*
*/
pop() {
let key = this.__stack.pop();
let prop = this[key];
delete this[key];
return prop;
}
/**
*
*/
getLastKey() {
if (this.__stack.length === 0) {
return null;
}
return this.__stack[this.__stack.length - 1];
}
/**
*
*/
shift() {
let key = this.__stack.shift();
let value = this[key];
delete this[key];
return {
key,
value,
};
}
/**
*
*/
length() {
return this.__stack.length;
}
/**
* Get the last element
* @returns {*|null} The last element, or null if the array is empty
*/
last() {
let key = this.getLastKey();
if (key === null) {
return null;
}
return this[key];
}
}
module.exports = {
ArrayWithKey
};

View File

@@ -0,0 +1,37 @@
const { ArrayWithKey } = require("./array-with-key");
/**
* Limit Queue
* The first element will be removed when the length exceeds the limit
*/
class LimitQueue extends ArrayWithKey {
__limit;
__onExceed = null;
/**
* @param {number} limit
*/
constructor(limit) {
super();
this.__limit = limit;
}
/**
* @inheritDoc
*/
push(key, value) {
super.push(key, value);
if (this.length() > this.__limit) {
let item = this.shift();
if (this.__onExceed) {
this.__onExceed(item);
}
}
}
}
module.exports = {
LimitQueue
};