From 2cadc1ff66fc48d5b6c48793e76e8af9c5baed4b Mon Sep 17 00:00:00 2001 From: circlecrystalin Date: Fri, 16 Jan 2026 04:13:23 +0100 Subject: [PATCH] Added numeric history to monitors --- .../2026-01-27-0000-add-numeric-history.js | 23 + server/model/monitor.js | 36 ++ server/monitor-types/snmp.js | 2 + .../socket-handlers/chart-socket-handler.js | 43 ++ src/components/NumericChart.vue | 433 ++++++++++++++++++ src/lang/en.json | 3 + src/mixins/socket.js | 3 + src/pages/Details.vue | 15 + 8 files changed, 558 insertions(+) create mode 100644 db/knex_migrations/2026-01-27-0000-add-numeric-history.js create mode 100644 src/components/NumericChart.vue diff --git a/db/knex_migrations/2026-01-27-0000-add-numeric-history.js b/db/knex_migrations/2026-01-27-0000-add-numeric-history.js new file mode 100644 index 000000000..00a6d1418 --- /dev/null +++ b/db/knex_migrations/2026-01-27-0000-add-numeric-history.js @@ -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"); +}; + diff --git a/server/model/monitor.js b/server/model/monitor.js index e01977133..0b76599f3 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -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} + */ + 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; diff --git a/server/monitor-types/snmp.js b/server/monitor-types/snmp.js index d670450a1..dcb046263 100644 --- a/server/monitor-types/snmp.js +++ b/server/monitor-types/snmp.js @@ -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})` diff --git a/server/socket-handlers/chart-socket-handler.js b/server/socket-handlers/chart-socket-handler.js index 654db0e73..7f32cae22 100644 --- a/server/socket-handlers/chart-socket-handler.js +++ b/server/socket-handlers/chart-socket-handler.js @@ -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, + }); + } + }); }; diff --git a/src/components/NumericChart.vue b/src/components/NumericChart.vue new file mode 100644 index 000000000..38ed02b9e --- /dev/null +++ b/src/components/NumericChart.vue @@ -0,0 +1,433 @@ + + + + + + diff --git a/src/lang/en.json b/src/lang/en.json index ea1ea35a8..c4d301245 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -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)", diff --git a/src/mixins/socket.js b/src/mixins/socket.js index 00574af3b..dd665190a 100644 --- a/src/mixins/socket.js +++ b/src/mixins/socket.js @@ -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: { diff --git a/src/pages/Details.vue b/src/pages/Details.vue index 553504397..a8960350b 100644 --- a/src/pages/Details.vue +++ b/src/pages/Details.vue @@ -280,6 +280,18 @@ + +
+
+
+ +
+
+
+
@@ -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",