Feat: Use UptimeCalculator for PingChart (#4264)

Co-authored-by: Frank Elsinga <frank@elsinga.de>
This commit is contained in:
Nelson Chan 2024-05-20 04:03:32 +08:00 committed by GitHub
parent a3ac954140
commit a581a85633
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 426 additions and 76 deletions

View file

@ -149,6 +149,7 @@ const apicache = require("./modules/apicache");
const { resetChrome } = require("./monitor-types/real-browser-monitor-type"); const { resetChrome } = require("./monitor-types/real-browser-monitor-type");
const { EmbeddedMariaDB } = require("./embedded-mariadb"); const { EmbeddedMariaDB } = require("./embedded-mariadb");
const { SetupDatabase } = require("./setup-database"); const { SetupDatabase } = require("./setup-database");
const { chartSocketHandler } = require("./socket-handlers/chart-socket-handler");
app.use(express.json()); app.use(express.json());
@ -1528,6 +1529,7 @@ let needSetup = false;
apiKeySocketHandler(socket); apiKeySocketHandler(socket);
remoteBrowserSocketHandler(socket); remoteBrowserSocketHandler(socket);
generalSocketHandler(socket, server); generalSocketHandler(socket, server);
chartSocketHandler(socket);
log.debug("server", "added all socket handlers"); log.debug("server", "added all socket handlers");

View file

@ -0,0 +1,38 @@
const { checkLogin } = require("../util-server");
const { UptimeCalculator } = require("../uptime-calculator");
const { log } = require("../../src/util");
module.exports.chartSocketHandler = (socket) => {
socket.on("getMonitorChartData", async (monitorID, period, callback) => {
try {
checkLogin(socket);
log.debug("monitor", `Get Monitor Chart Data: ${monitorID} User ID: ${socket.userID}`);
if (period == null) {
throw new Error("Invalid period.");
}
let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
let data;
if (period <= 24) {
data = uptimeCalculator.getDataArray(period * 60, "minute");
} else if (period <= 720) {
data = uptimeCalculator.getDataArray(period, "hour");
} else {
data = uptimeCalculator.getDataArray(period / 24, "day");
}
callback({
ok: true,
data,
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
};

View file

@ -290,7 +290,7 @@ class UptimeCalculator {
dailyStatBean.pingMax = dailyData.maxPing; dailyStatBean.pingMax = dailyData.maxPing;
{ {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const { up, down, avgPing, minPing, maxPing, ...extras } = dailyData; const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = dailyData;
if (Object.keys(extras).length > 0) { if (Object.keys(extras).length > 0) {
dailyStatBean.extras = JSON.stringify(extras); dailyStatBean.extras = JSON.stringify(extras);
} }
@ -305,7 +305,7 @@ class UptimeCalculator {
hourlyStatBean.pingMax = hourlyData.maxPing; hourlyStatBean.pingMax = hourlyData.maxPing;
{ {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const { up, down, avgPing, minPing, maxPing, ...extras } = hourlyData; const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = hourlyData;
if (Object.keys(extras).length > 0) { if (Object.keys(extras).length > 0) {
hourlyStatBean.extras = JSON.stringify(extras); hourlyStatBean.extras = JSON.stringify(extras);
} }
@ -320,7 +320,7 @@ class UptimeCalculator {
minutelyStatBean.pingMax = minutelyData.maxPing; minutelyStatBean.pingMax = minutelyData.maxPing;
{ {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const { up, down, avgPing, minPing, maxPing, ...extras } = minutelyData; const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = minutelyData;
if (Object.keys(extras).length > 0) { if (Object.keys(extras).length > 0) {
minutelyStatBean.extras = JSON.stringify(extras); minutelyStatBean.extras = JSON.stringify(extras);
} }

View file

@ -1,16 +1,24 @@
<template> <template>
<div> <div>
<div class="period-options"> <div class="period-options">
<button type="button" class="btn btn-light dropdown-toggle btn-period-toggle" data-bs-toggle="dropdown" aria-expanded="false"> <button
type="button" class="btn btn-light dropdown-toggle btn-period-toggle" data-bs-toggle="dropdown"
aria-expanded="false"
>
{{ chartPeriodOptions[chartPeriodHrs] }}&nbsp; {{ chartPeriodOptions[chartPeriodHrs] }}&nbsp;
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li v-for="(item, key) in chartPeriodOptions" :key="key"> <li v-for="(item, key) in chartPeriodOptions" :key="key">
<a class="dropdown-item" :class="{ active: chartPeriodHrs == key }" href="#" @click="chartPeriodHrs = key">{{ item }}</a> <button
type="button" class="dropdown-item" :class="{ active: chartPeriodHrs == key }"
@click="chartPeriodHrs = key"
>
{{ item }}
</button>
</li> </li>
</ul> </ul>
</div> </div>
<div class="chart-wrapper" :class="{ loading : loading}"> <div class="chart-wrapper" :class="{ loading: loading }">
<Line :data="chartData" :options="chartOptions" /> <Line :data="chartData" :options="chartOptions" />
</div> </div>
</div> </div>
@ -19,9 +27,8 @@
<script lang="js"> <script lang="js">
import { BarController, BarElement, Chart, Filler, LinearScale, LineController, LineElement, PointElement, TimeScale, Tooltip } from "chart.js"; import { BarController, BarElement, Chart, Filler, LinearScale, LineController, LineElement, PointElement, TimeScale, Tooltip } from "chart.js";
import "chartjs-adapter-dayjs-4"; import "chartjs-adapter-dayjs-4";
import dayjs from "dayjs";
import { Line } from "vue-chartjs"; import { Line } from "vue-chartjs";
import { DOWN, PENDING, MAINTENANCE, log } from "../util.ts"; import { UP, DOWN, PENDING, MAINTENANCE } from "../util.ts";
Chart.register(LineController, BarController, LineElement, PointElement, TimeScale, BarElement, LinearScale, Tooltip, Filler); Chart.register(LineController, BarController, LineElement, PointElement, TimeScale, BarElement, LinearScale, Tooltip, Filler);
@ -39,8 +46,9 @@ export default {
loading: false, loading: false,
// Configurable filtering on top of the returned data // Time period for the chart to display, in hours
chartPeriodHrs: 0, // Initial value is 0 as a workaround for triggering a data fetch on created()
chartPeriodHrs: "0",
chartPeriodOptions: { chartPeriodOptions: {
0: this.$t("recent"), 0: this.$t("recent"),
@ -50,9 +58,8 @@ export default {
168: "1w", 168: "1w",
}, },
// A heartbeatList for 3h, 6h, 24h, 1w chartRawData: null,
// Uses the $root.heartbeatList when value is null chartDataFetchInterval: null,
heartbeatList: null
}; };
}, },
computed: { computed: {
@ -157,34 +164,197 @@ export default {
}; };
}, },
chartData() { chartData() {
if (this.chartPeriodHrs === "0") {
return this.getChartDatapointsFromHeartbeatList();
} else {
return this.getChartDatapointsFromStats();
}
},
},
watch: {
// Update chart data when the selected chart period changes
chartPeriodHrs: function (newPeriod) {
if (this.chartDataFetchInterval) {
clearInterval(this.chartDataFetchInterval);
this.chartDataFetchInterval = null;
}
// eslint-disable-next-line eqeqeq
if (newPeriod == "0") {
this.heartbeatList = null;
this.$root.storage().removeItem(`chart-period-${this.monitorId}`);
} else {
this.loading = true;
let period;
try {
period = parseInt(newPeriod);
} catch (e) {
// Invalid period
period = 24;
}
this.$root.getMonitorChartData(this.monitorId, period, (res) => {
if (!res.ok) {
this.$root.toastError(res.msg);
} else {
this.chartRawData = res.data;
this.$root.storage()[`chart-period-${this.monitorId}`] = newPeriod;
}
this.loading = false;
});
this.chartDataFetchInterval = setInterval(() => {
this.$root.getMonitorChartData(this.monitorId, period, (res) => {
if (res.ok) {
this.chartRawData = res.data;
}
});
}, 5 * 60 * 1000);
}
}
},
created() {
// Load chart period from storage if saved
let period = this.$root.storage()[`chart-period-${this.monitorId}`];
if (period != null) {
// Has this ever been not a string?
if (typeof period !== "string") {
period = period.toString();
}
this.chartPeriodHrs = period;
} else {
this.chartPeriodHrs = "24";
}
},
beforeUnmount() {
if (this.chartDataFetchInterval) {
clearInterval(this.chartDataFetchInterval);
}
},
methods: {
// Get color of bar chart for this datapoint
getBarColorForDatapoint(datapoint) {
if (datapoint.maintenance != null) {
// Target is in maintenance
return "rgba(23,71,245,0.41)";
} else if (datapoint.down === 0) {
// Target is up, no need to display a bar
return "#000";
} else if (datapoint.up === 0) {
// Target is down
return "rgba(220, 53, 69, 0.41)";
} else {
// Show yellow for mixed status
return "rgba(245, 182, 23, 0.41)";
}
},
// push datapoint to chartData
pushDatapoint(datapoint, avgPingData, minPingData, maxPingData, downData, colorData) {
const x = this.$root.unixToDateTime(datapoint.timestamp);
// Show ping values if it was up in this period
avgPingData.push({
x,
y: datapoint.up > 0 && datapoint.avgPing > 0 ? datapoint.avgPing : null,
});
minPingData.push({
x,
y: datapoint.up > 0 && datapoint.avgPing > 0 ? datapoint.minPing : null,
});
maxPingData.push({
x,
y: datapoint.up > 0 && datapoint.avgPing > 0 ? datapoint.maxPing : null,
});
downData.push({
x,
y: datapoint.down + (datapoint.maintenance || 0),
});
colorData.push(this.getBarColorForDatapoint(datapoint));
},
// get the average of a set of datapoints
getAverage(datapoints) {
const totalUp = datapoints.reduce((total, current) => total + current.up, 0);
const totalDown = datapoints.reduce((total, current) => total + current.down, 0);
const totalMaintenance = datapoints.reduce((total, current) => total + (current.maintenance || 0), 0);
const totalPing = datapoints.reduce((total, current) => total + current.avgPing * current.up, 0);
const minPing = datapoints.reduce((min, current) => Math.min(min, current.minPing), Infinity);
const maxPing = datapoints.reduce((max, current) => Math.max(max, current.maxPing), 0);
// Find the middle timestamp to use
let midpoint = Math.floor(datapoints.length / 2);
return {
timestamp: datapoints[midpoint].timestamp,
up: totalUp,
down: totalDown,
maintenance: totalMaintenance > 0 ? totalMaintenance : undefined,
avgPing: totalUp > 0 ? totalPing / totalUp : 0,
minPing,
maxPing,
};
},
getChartDatapointsFromHeartbeatList() {
// Render chart using heartbeatList
let lastHeartbeatTime;
const monitorInterval = this.$root.monitorList[this.monitorId]?.interval;
let pingData = []; // Ping Data for Line Chart, y-axis contains ping time let pingData = []; // Ping Data for Line Chart, y-axis contains ping time
let downData = []; // Down Data for Bar Chart, y-axis is 1 if target is down (red color), under maintenance (blue color) or pending (orange color), 0 if target is up let downData = []; // Down Data for Bar Chart, y-axis is 1 if target is down (red color), under maintenance (blue color) or pending (orange color), 0 if target is up
let colorData = []; // Color Data for Bar Chart let colorData = []; // Color Data for Bar Chart
let heartbeatList = this.heartbeatList || let heartbeatList = (this.monitorId in this.$root.heartbeatList && this.$root.heartbeatList[this.monitorId]) || [];
(this.monitorId in this.$root.heartbeatList && this.$root.heartbeatList[this.monitorId]) ||
[];
heartbeatList for (const beat of heartbeatList) {
.filter( const beatTime = this.$root.toDayjs(beat.time);
// Filtering as data gets appended const x = beatTime.format("YYYY-MM-DD HH:mm:ss");
// not the most efficient, but works for now
(beat) => dayjs.utc(beat.time).tz(this.$root.timezone).isAfter( // Insert empty datapoint to separate big gaps
dayjs().subtract(Math.max(this.chartPeriodHrs, 6), "hours") if (lastHeartbeatTime && monitorInterval) {
) const diff = Math.abs(beatTime.diff(lastHeartbeatTime));
) if (diff > monitorInterval * 1000 * 10) {
.map((beat) => { // Big gap detected
const x = this.$root.datetime(beat.time); const gapX = [
lastHeartbeatTime.add(monitorInterval, "second").format("YYYY-MM-DD HH:mm:ss"),
beatTime.subtract(monitorInterval, "second").format("YYYY-MM-DD HH:mm:ss")
];
for (const x of gapX) {
pingData.push({ pingData.push({
x, x,
y: beat.ping, y: null,
});
downData.push({
x,
y: null,
});
colorData.push("#000");
}
}
}
pingData.push({
x,
y: beat.status === UP ? beat.ping : null,
}); });
downData.push({ downData.push({
x, x,
y: (beat.status === DOWN || beat.status === MAINTENANCE || beat.status === PENDING) ? 1 : 0, y: (beat.status === DOWN || beat.status === MAINTENANCE || beat.status === PENDING) ? 1 : 0,
}); });
colorData.push((beat.status === MAINTENANCE) ? "rgba(23,71,245,0.41)" : ((beat.status === PENDING) ? "rgba(245,182,23,0.41)" : "#DC354568")); switch (beat.status) {
}); case MAINTENANCE:
colorData.push("rgba(23 ,71, 245, 0.41)");
break;
case PENDING:
colorData.push("rgba(245, 182, 23, 0.41)");
break;
default:
colorData.push("rgba(220, 53, 69, 0.41)");
}
lastHeartbeatTime = beatTime;
}
return { return {
datasets: [ datasets: [
@ -214,54 +384,155 @@ export default {
], ],
}; };
}, },
}, getChartDatapointsFromStats() {
watch: { // Render chart using UptimeCalculator data
// Update chart data when the selected chart period changes let lastHeartbeatTime;
chartPeriodHrs: function (newPeriod) { const monitorInterval = this.$root.monitorList[this.monitorId]?.interval;
// eslint-disable-next-line eqeqeq let avgPingData = []; // Ping Data for Line Chart, y-axis contains ping time
if (newPeriod == "0") { let minPingData = []; // Ping Data for Line Chart, y-axis contains ping time
this.heartbeatList = null; let maxPingData = []; // Ping Data for Line Chart, y-axis contains ping time
this.$root.storage().removeItem(`chart-period-${this.monitorId}`); let downData = []; // Down Data for Bar Chart, y-axis is number of down datapoints in this period
} else { let colorData = []; // Color Data for Bar Chart
this.loading = true;
this.$root.getMonitorBeats(this.monitorId, newPeriod, (res) => { const period = parseInt(this.chartPeriodHrs);
if (!res.ok) { let aggregatePoints = period > 6 ? 12 : 4;
this.$root.toastError(res.msg);
} else { let aggregateBuffer = [];
this.heartbeatList = res.data;
this.$root.storage()[`chart-period-${this.monitorId}`] = newPeriod; if (this.chartRawData) {
for (const datapoint of this.chartRawData) {
// Empty datapoints are ignored
if (datapoint.up === 0 && datapoint.down === 0 && datapoint.maintenance === 0) {
continue;
} }
this.loading = false;
const beatTime = this.$root.unixToDayjs(datapoint.timestamp);
// Insert empty datapoint to separate big gaps
if (lastHeartbeatTime && monitorInterval) {
const diff = Math.abs(beatTime.diff(lastHeartbeatTime));
const oneSecond = 1000;
const oneMinute = oneSecond * 60;
const oneHour = oneMinute * 60;
if ((period <= 24 && diff > Math.max(oneMinute * 10, monitorInterval * oneSecond * 10)) ||
(period > 24 && diff > Math.max(oneHour * 10, monitorInterval * oneSecond * 10))) {
// Big gap detected
// Clear the aggregate buffer
if (aggregateBuffer.length > 0) {
const average = this.getAverage(aggregateBuffer);
this.pushDatapoint(average, avgPingData, minPingData, maxPingData, downData, colorData);
aggregateBuffer = [];
}
const gapX = [
lastHeartbeatTime.subtract(monitorInterval, "second").format("YYYY-MM-DD HH:mm:ss"),
this.$root.unixToDateTime(datapoint.timestamp + 60),
];
for (const x of gapX) {
avgPingData.push({
x,
y: null,
}); });
minPingData.push({
x,
y: null,
});
maxPingData.push({
x,
y: null,
});
downData.push({
x,
y: null,
});
colorData.push("#000");
}
} }
} }
if (datapoint.up > 0 && this.chartRawData.length > aggregatePoints * 2) {
// Aggregate Up data using a sliding window
aggregateBuffer.push(datapoint);
if (aggregateBuffer.length === aggregatePoints) {
const average = this.getAverage(aggregateBuffer);
this.pushDatapoint(average, avgPingData, minPingData, maxPingData, downData, colorData);
// Remove the first half of the buffer
aggregateBuffer = aggregateBuffer.slice(Math.floor(aggregatePoints / 2));
}
} else {
// datapoint is fully down or too few datapoints, no need to aggregate
// Clear the aggregate buffer
if (aggregateBuffer.length > 0) {
const average = this.getAverage(aggregateBuffer);
this.pushDatapoint(average, avgPingData, minPingData, maxPingData, downData, colorData);
aggregateBuffer = [];
}
this.pushDatapoint(datapoint, avgPingData, minPingData, maxPingData, downData, colorData);
}
lastHeartbeatTime = beatTime;
}
// Clear the aggregate buffer if there are still datapoints
if (aggregateBuffer.length > 0) {
const average = this.getAverage(aggregateBuffer);
this.pushDatapoint(average, avgPingData, minPingData, maxPingData, downData, colorData);
aggregateBuffer = [];
}
}
return {
datasets: [
{
// average ping chart
data: avgPingData,
fill: "origin",
tension: 0.2,
borderColor: "#5CDD8B",
backgroundColor: "#5CDD8B06",
yAxisID: "y",
label: "avg-ping",
}, },
created() { {
// Setup Watcher on the root heartbeatList, // minimum ping chart
// And mirror latest change to this.heartbeatList data: minPingData,
this.$watch(() => this.$root.heartbeatList[this.monitorId], fill: "origin",
(heartbeatList) => { tension: 0.2,
borderColor: "#3CBD6B38",
log.debug("ping_chart", `this.chartPeriodHrs type ${typeof this.chartPeriodHrs}, value: ${this.chartPeriodHrs}`); backgroundColor: "#5CDD8B06",
yAxisID: "y",
// eslint-disable-next-line eqeqeq label: "min-ping",
if (this.chartPeriodHrs != "0") { },
const newBeat = heartbeatList.at(-1); {
if (newBeat && dayjs.utc(newBeat.time) > dayjs.utc(this.heartbeatList.at(-1)?.time)) { // maximum ping chart
this.heartbeatList.push(heartbeatList.at(-1)); data: maxPingData,
} fill: "origin",
} tension: 0.2,
borderColor: "#7CBD6B38",
backgroundColor: "#5CDD8B06",
yAxisID: "y",
label: "max-ping",
},
{
// Bar Chart
type: "bar",
data: downData,
borderColor: "#00000000",
backgroundColor: colorData,
yAxisID: "y1",
barThickness: "flex",
barPercentage: 1,
categoryPercentage: 1,
inflateAmount: 0.05,
label: "status",
},
],
};
}, },
{ deep: true }
);
// Load chart period from storage if saved
let period = this.$root.storage()[`chart-period-${this.monitorId}`];
if (period != null) {
this.chartPeriodHrs = Math.min(period, 6);
}
} }
}; };
</script> </script>
@ -296,6 +567,7 @@ export default {
.dark & { .dark & {
background: $dark-bg; background: $dark-bg;
color: $dark-font-color;
} }
.dark &:hover { .dark &:hover {

View file

@ -41,6 +41,33 @@ export default {
return this.datetimeFormat(value, "YYYY-MM-DD HH:mm:ss"); return this.datetimeFormat(value, "YYYY-MM-DD HH:mm:ss");
}, },
/**
* Converts a Unix timestamp to a formatted date and time string.
* @param {number} value - The Unix timestamp to convert.
* @returns {string} The formatted date and time string.
*/
unixToDateTime(value) {
return dayjs.unix(value).tz(this.timezone).format("YYYY-MM-DD HH:mm:ss");
},
/**
* Converts a Unix timestamp to a dayjs object.
* @param {number} value - The Unix timestamp to convert.
* @returns {dayjs.Dayjs} The dayjs object representing the given timestamp.
*/
unixToDayjs(value) {
return dayjs.unix(value).tz(this.timezone);
},
/**
* Converts the given value to a dayjs object.
* @param {string} value - the value to be converted
* @returns {dayjs.Dayjs} a dayjs object in the timezone of this instance
*/
toDayjs(value) {
return dayjs.utc(value).tz(this.timezone);
},
/** /**
* Get time for maintenance * Get time for maintenance
* @param {string | number | Date | dayjs.Dayjs} value Time to * @param {string | number | Date | dayjs.Dayjs} value Time to

View file

@ -673,6 +673,17 @@ export default {
getMonitorBeats(monitorID, period, callback) { getMonitorBeats(monitorID, period, callback) {
socket.emit("getMonitorBeats", monitorID, period, callback); socket.emit("getMonitorBeats", monitorID, period, callback);
}, },
/**
* Retrieves monitor chart data.
* @param {string} monitorID - The ID of the monitor.
* @param {number} period - The time period for the chart data, in hours.
* @param {socketCB} callback - The callback function to handle the chart data.
* @returns {void}
*/
getMonitorChartData(monitorID, period, callback) {
socket.emit("getMonitorChartData", monitorID, period, callback);
}
}, },
computed: { computed: {