Added numeric history to monitors

This commit is contained in:
circlecrystalin 2026-01-16 04:13:23 +01:00
parent dd44342835
commit 2cadc1ff66
8 changed files with 558 additions and 0 deletions

View File

@ -0,0 +1,23 @@
exports.up = function (knex) {
return knex.schema.createTable("monitor_numeric_history", function (table) {
table.increments("id");
table.comment("This table contains the numeric value history for monitors (e.g., from JSON queries or SNMP)");
table
.integer("monitor_id")
.unsigned()
.notNullable()
.references("id")
.inTable("monitor")
.onDelete("CASCADE")
.onUpdate("CASCADE");
table.float("value").notNullable().comment("Numeric value from the monitor check");
table.datetime("time").notNullable().comment("Timestamp when the value was recorded");
table.index(["monitor_id", "time"]);
});
};
exports.down = function (knex) {
return knex.schema.dropTable("monitor_numeric_history");
};

View File

@ -714,6 +714,8 @@ class Monitor extends BeanModel {
if (status) {
bean.status = UP;
bean.msg = `JSON query passes (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`;
// Save numeric value if it's a number
await this.saveNumericValueIfApplicable(response);
} else {
throw new Error(
`JSON query does not pass (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`
@ -2065,6 +2067,40 @@ class Monitor extends BeanModel {
await this.checkCertExpiryNotifications(tlsInfo);
}
}
/**
* Save numeric value to history table if the value is numeric
* @param {any} value Value to check and potentially save
* @returns {Promise<void>}
*/
async saveNumericValueIfApplicable(value) {
// Check if value is numeric (number or string that can be converted to number)
let numericValue = null;
if (typeof value === "number") {
numericValue = value;
} else if (typeof value === "string") {
// Try to parse as number
const parsed = parseFloat(value);
if (!isNaN(parsed) && isFinite(parsed)) {
numericValue = parsed;
}
}
// Only save if we have a valid numeric value
if (numericValue !== null) {
try {
let numericHistoryBean = R.dispense("monitor_numeric_history");
numericHistoryBean.monitor_id = this.id;
numericHistoryBean.value = numericValue;
numericHistoryBean.time = R.isoDateTimeMillis(dayjs.utc());
await R.store(numericHistoryBean);
log.debug("monitor", `[${this.name}] Saved numeric value: ${numericValue}`);
} catch (e) {
log.error("monitor", `[${this.name}] Failed to save numeric value: ${e.message}`);
}
}
}
}
module.exports = Monitor;

View File

@ -55,6 +55,8 @@ class SNMPMonitorType extends MonitorType {
if (status) {
heartbeat.status = UP;
heartbeat.msg = `JSON query passes (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`;
// Save numeric value if it's a number
await monitor.saveNumericValueIfApplicable(response);
} else {
throw new Error(
`JSON query does not pass (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`

View File

@ -1,6 +1,8 @@
const { checkLogin } = require("../util-server");
const { UptimeCalculator } = require("../uptime-calculator");
const { log } = require("../../src/util");
const { R } = require("redbean-node");
const dayjs = require("dayjs");
module.exports.chartSocketHandler = (socket) => {
socket.on("getMonitorChartData", async (monitorID, period, callback) => {
@ -35,4 +37,45 @@ module.exports.chartSocketHandler = (socket) => {
});
}
});
socket.on("getMonitorNumericHistory", async (monitorID, period, callback) => {
try {
checkLogin(socket);
log.debug("monitor", `Get Monitor Numeric History: ${monitorID} User ID: ${socket.userID}`);
if (period == null) {
throw new Error("Invalid period.");
}
// Calculate the start time based on period (in hours)
const periodHours = parseInt(period);
const startTime = dayjs.utc().subtract(periodHours, "hour");
// Query numeric history data
const numericHistory = await R.getAll(
`SELECT value, time FROM monitor_numeric_history
WHERE monitor_id = ? AND time >= ?
ORDER BY time ASC`,
[monitorID, R.isoDateTimeMillis(startTime)]
);
// Convert to format expected by frontend
const data = numericHistory.map((row) => ({
value: parseFloat(row.value),
timestamp: dayjs.utc(row.time).unix(),
time: row.time,
}));
callback({
ok: true,
data,
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
};

View File

@ -0,0 +1,433 @@
<template>
<div>
<div class="period-options">
<button
type="button"
class="btn btn-light dropdown-toggle btn-period-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{{ chartPeriodOptions[chartPeriodHrs] }}&nbsp;
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li v-for="(item, key) in chartPeriodOptions" :key="key">
<button
type="button"
class="dropdown-item"
:class="{ active: chartPeriodHrs == key }"
@click="chartPeriodHrs = key"
>
{{ item }}
</button>
</li>
</ul>
</div>
<div class="chart-wrapper" :class="{ loading: loading }">
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script lang="js">
import {
BarController,
BarElement,
Chart,
Filler,
LinearScale,
LineController,
LineElement,
PointElement,
TimeScale,
Tooltip,
Legend,
} from "chart.js";
import "chartjs-adapter-dayjs-4";
import { Line } from "vue-chartjs";
Chart.register(
LineController,
BarController,
LineElement,
PointElement,
TimeScale,
BarElement,
LinearScale,
Tooltip,
Filler,
Legend
);
export default {
components: { Line },
props: {
/** ID of monitor */
monitorId: {
type: Number,
required: true,
},
},
data() {
return {
loading: false,
// Time period for the chart to display, in hours
chartPeriodHrs: "24",
chartPeriodOptions: {
3: "3h",
6: "6h",
24: "24h",
168: "1w",
},
chartRawData: null,
chartDataFetchInterval: null,
};
},
computed: {
chartOptions() {
return {
responsive: true,
maintainAspectRatio: false,
onResize: (chart) => {
chart.canvas.parentNode.style.position = "relative";
if (screen.width < 576) {
chart.canvas.parentNode.style.height = "275px";
} else if (screen.width < 768) {
chart.canvas.parentNode.style.height = "320px";
} else if (screen.width < 992) {
chart.canvas.parentNode.style.height = "300px";
} else {
chart.canvas.parentNode.style.height = "250px";
}
},
layout: {
padding: {
left: 10,
right: 30,
top: 30,
bottom: 10,
},
},
elements: {
point: {
radius: 0,
hitRadius: 100,
},
},
scales: {
x: {
type: "time",
time: {
minUnit: "minute",
round: "second",
tooltipFormat: "YYYY-MM-DD HH:mm:ss",
displayFormats: {
minute: "HH:mm",
hour: "MM-DD HH:mm",
},
},
ticks: {
sampleSize: 3,
maxRotation: 0,
autoSkipPadding: 30,
padding: 3,
},
grid: {
color: this.$root.theme === "light" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.1)",
offset: false,
},
},
y: {
title: {
display: true,
text: this.$t("value"),
},
offset: false,
grid: {
color: this.$root.theme === "light" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.1)",
},
},
},
bounds: "ticks",
plugins: {
tooltip: {
mode: "nearest",
intersect: false,
padding: 10,
backgroundColor: this.$root.theme === "light" ? "rgba(212,232,222,1.0)" : "rgba(32,42,38,1.0)",
bodyColor: this.$root.theme === "light" ? "rgba(12,12,18,1.0)" : "rgba(220,220,220,1.0)",
titleColor: this.$root.theme === "light" ? "rgba(12,12,18,1.0)" : "rgba(220,220,220,1.0)",
callbacks: {
label: (context) => {
const label = context.dataset.label;
return `${label} ${new Intl.NumberFormat().format(context.parsed.y)}`;
},
},
},
legend: {
display: true,
position: "top",
align: "start",
onHover: function (event, legendItem, legend) {
if (legend && legend.chart && legend.chart.canvas) {
legend.chart.canvas.style.cursor = "pointer";
}
},
onLeave: function (event, legendItem, legend) {
if (legend && legend.chart && legend.chart.canvas) {
legend.chart.canvas.style.cursor = "";
}
},
labels: {
color: this.$root.theme === "light" ? "rgba(12,12,18,1.0)" : "rgba(220,220,220,1.0)",
},
},
},
};
},
chartData() {
if (!this.chartRawData || this.chartRawData.length === 0) {
return {
datasets: [],
};
}
let valueData = [];
let minData = [];
let maxData = [];
// Find min and max values for highlighting
const values = this.chartRawData.map((d) => d.value);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
for (const datapoint of this.chartRawData) {
const x = this.$root.unixToDateTime(datapoint.timestamp);
valueData.push({
x,
y: datapoint.value,
});
// Mark min/max values
if (datapoint.value === minValue) {
minData.push({
x,
y: datapoint.value,
});
} else {
minData.push({
x,
y: null,
});
}
if (datapoint.value === maxValue) {
maxData.push({
x,
y: datapoint.value,
});
} else {
maxData.push({
x,
y: null,
});
}
}
return {
datasets: [
{
// Main value line
data: valueData,
fill: "origin",
tension: 0.2,
borderColor: "#4ABF74",
backgroundColor: "#4ABF7438",
yAxisID: "y",
label: this.$t("value"),
},
{
// Maximum values (red background)
data: maxData,
fill: false,
tension: 0,
borderColor: "#dc3545",
backgroundColor: "#dc354580",
pointBackgroundColor: "#dc3545",
pointBorderColor: "#dc3545",
pointRadius: 4,
pointHoverRadius: 6,
yAxisID: "y",
label: this.$t("maxValue"),
},
{
// Minimum values (red background)
data: minData,
fill: false,
tension: 0,
borderColor: "#dc3545",
backgroundColor: "#dc354580",
pointBackgroundColor: "#dc3545",
pointBorderColor: "#dc3545",
pointRadius: 4,
pointHoverRadius: 6,
yAxisID: "y",
label: this.$t("minValue"),
},
],
};
},
},
watch: {
chartPeriodHrs: function (newPeriod) {
if (this.chartDataFetchInterval) {
clearInterval(this.chartDataFetchInterval);
this.chartDataFetchInterval = null;
}
this.loading = true;
let period;
try {
period = parseInt(newPeriod);
} catch (e) {
period = 24;
}
this.$root.getMonitorNumericHistory(this.monitorId, period, (res) => {
if (!res.ok) {
this.$root.toastError(res.msg);
} else {
this.chartRawData = res.data;
}
this.loading = false;
});
this.chartDataFetchInterval = setInterval(
() => {
this.$root.getMonitorNumericHistory(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()["numeric-chart-period"];
if (period != null) {
if (typeof period !== "string") {
period = period.toString();
}
this.chartPeriodHrs = period;
} else {
this.chartPeriodHrs = "24";
}
},
mounted() {
// Trigger initial data fetch
this.loading = true;
const period = parseInt(this.chartPeriodHrs) || 24;
this.$root.getMonitorNumericHistory(this.monitorId, period, (res) => {
if (!res.ok) {
this.$root.toastError(res.msg);
} else {
this.chartRawData = res.data;
}
this.loading = false;
});
this.chartDataFetchInterval = setInterval(
() => {
this.$root.getMonitorNumericHistory(this.monitorId, period, (res) => {
if (res.ok) {
this.chartRawData = res.data;
}
});
},
5 * 60 * 1000
);
},
beforeUnmount() {
if (this.chartDataFetchInterval) {
clearInterval(this.chartDataFetchInterval);
}
},
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.period-options {
padding: 0.1em 1em;
margin-bottom: -1.2em;
float: right;
position: relative;
z-index: 10;
.dropdown-menu {
padding: 0;
min-width: 50px;
font-size: 0.9em;
.dark & {
background: $dark-bg;
}
.dropdown-item {
border-radius: 0.3rem;
padding: 2px 16px 4px;
.dark & {
background: $dark-bg;
color: $dark-font-color;
}
.dark &:hover {
background: $dark-font-color;
color: $dark-font-color2;
}
}
.dark & .dropdown-item.active {
background: $primary;
color: $dark-font-color2;
}
}
.btn-period-toggle {
padding: 2px 15px;
background: transparent;
border: 0;
color: $link-color;
opacity: 0.7;
font-size: 0.9em;
&::after {
vertical-align: 0.155em;
}
.dark & {
color: $dark-font-color;
}
}
}
.chart-wrapper {
margin-bottom: 0.5em;
&.loading {
filter: blur(10px);
}
}
</style>

View File

@ -1324,6 +1324,9 @@
"Uptime Kuma": "Uptime Kuma",
"maxPing": "Max Ping",
"minPing": "Min Ping",
"value": "Value",
"minValue": "Min Value",
"maxValue": "Max Value",
"Setup Instructions": "Setup Instructions",
"halopsa_setup_step1": "Create an Integration Runbook in HaloPSA (Configuration → Integrations → Integration Runbooks)",
"halopsa_setup_step2": "Configure runbook actions to process alerts (e.g., Create Ticket)",

View File

@ -730,6 +730,9 @@ export default {
getMonitorChartData(monitorID, period, callback) {
socket.emit("getMonitorChartData", monitorID, period, callback);
},
getMonitorNumericHistory(monitorID, period, callback) {
socket.emit("getMonitorNumericHistory", monitorID, period, callback);
},
},
computed: {

View File

@ -280,6 +280,18 @@
</div>
</div>
<!-- Numeric Value Chart (for json-query and SNMP monitors) -->
<div
v-if="showNumericChartBox && (monitor.type === 'json-query' || monitor.type === 'snmp')"
class="shadow-box big-padding text-center numeric-chart-wrapper"
>
<div class="row">
<div class="col">
<NumericChart :monitor-id="monitor.id" />
</div>
</div>
</div>
<!-- Screenshot -->
<div v-if="monitor.type === 'real-browser'" class="shadow-box">
<div class="row">
@ -418,6 +430,7 @@ import CountUp from "../components/CountUp.vue";
import Uptime from "../components/Uptime.vue";
import Pagination from "v-pagination-3";
const PingChart = defineAsyncComponent(() => import("../components/PingChart.vue"));
const NumericChart = defineAsyncComponent(() => import("../components/NumericChart.vue"));
import Tag from "../components/Tag.vue";
import CertificateInfo from "../components/CertificateInfo.vue";
import { getMonitorRelativeURL } from "../util.ts";
@ -443,6 +456,7 @@ export default {
Status,
Pagination,
PingChart,
NumericChart,
Tag,
CertificateInfo,
PrismEditor,
@ -455,6 +469,7 @@ export default {
heartBeatList: [],
toggleCertInfoBox: false,
showPingChartBox: true,
showNumericChartBox: true,
paginationConfig: {
hideCount: true,
chunksNavigation: "scroll",