From 8d2e3f130afba7baf259da671326f907b3c6e212 Mon Sep 17 00:00:00 2001 From: Doruk Date: Sat, 14 Jun 2025 12:16:28 +0200 Subject: [PATCH] remove client-side aggregation and add server-side support for it in new util --- server/routers/status-page-router.js | 32 ++++------ server/util/heartbeat-range.js | 62 ++++++++++++++++--- src/components/HeartbeatBar.vue | 91 +--------------------------- 3 files changed, 69 insertions(+), 116 deletions(-) diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js index cf190596a..bb6049234 100644 --- a/server/routers/status-page-router.js +++ b/server/routers/status-page-router.js @@ -3,7 +3,7 @@ const apicache = require("../modules/apicache"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const StatusPage = require("../model/status_page"); const { allowDevAllOrigin, sendHttpError } = require("../util-server"); -const { rangeToDatabaseDate } = require("../util/heartbeat-range"); +const { getAggregatedHeartbeatData } = require("../util/heartbeat-range"); const { R } = require("redbean-node"); const { badgeConstants } = require("../../src/util"); const { makeBadge } = require("badge-maker"); @@ -89,13 +89,17 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques let statusPage = await R.findOne("status_page", " id = ? ", [ statusPageID ]); let heartbeatRange = statusPage ? statusPage.heartbeat_bar_range : "auto"; - // Calculate the date range for heartbeats based on range setting - let dateFrom = rangeToDatabaseDate(heartbeatRange); - for (let monitorID of monitorIDList) { let list; - if (dateFrom === null) { - // Auto mode: use original logic with LIMIT 100 + + // Try to use aggregated data from stat tables for better performance + const aggregatedData = await getAggregatedHeartbeatData(monitorID, heartbeatRange); + + if (aggregatedData) { + // Use pre-aggregated stat data + heartbeatList[monitorID] = aggregatedData; + } else { + // Fall back to raw heartbeat data for auto mode list = await R.getAll(` SELECT * FROM heartbeat WHERE monitor_id = ? @@ -104,20 +108,10 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques `, [ monitorID, ]); - } else { - // Time-based filtering - list = await R.getAll(` - SELECT * FROM heartbeat - WHERE monitor_id = ? AND time >= ? - ORDER BY time DESC - `, [ - monitorID, - dateFrom, - ]); - } - list = R.convertToBeans("heartbeat", list); - heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON()); + list = R.convertToBeans("heartbeat", list); + heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON()); + } const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID); uptimeList[`${monitorID}_24`] = uptimeCalculator.get24Hour().uptime; diff --git a/server/util/heartbeat-range.js b/server/util/heartbeat-range.js index a55a7f00e..7ab4ad7bd 100644 --- a/server/util/heartbeat-range.js +++ b/server/util/heartbeat-range.js @@ -1,3 +1,6 @@ +const { R } = require("redbean-node"); +const dayjs = require("dayjs"); + /** * Utility functions for heartbeat range handling */ @@ -23,22 +26,63 @@ function parseRangeHours(range) { } /** - * Convert range to database-compatible date string + * Get aggregated heartbeat data using stat tables for better performance + * @param {number} monitorId - Monitor ID * @param {string} range - Range string like "6h", "7d", "auto" - * @returns {string|null} Date string or null for auto + * @returns {Promise} Aggregated heartbeat data */ -function rangeToDatabaseDate(range) { - const hours = parseRangeHours(range); - if (hours === null) { +async function getAggregatedHeartbeatData(monitorId, range) { + if (!range || range === "auto") { + // Fall back to regular heartbeat query for auto mode return null; } - const date = new Date(); - date.setHours(date.getHours() - hours); - return date.toISOString().slice(0, 19).replace('T', ' '); + const now = dayjs(); + const hours = parseRangeHours(range); + + if (hours <= 24) { + // Use hourly stats for ranges up to 24 hours + const startTime = now.subtract(hours, "hours"); + const timestampKey = Math.floor(startTime.valueOf() / (60 * 60 * 1000)) * (60 * 60 * 1000); + + const stats = await R.getAll(` + SELECT * FROM stat_hourly + WHERE monitor_id = ? AND timestamp >= ? + ORDER BY timestamp ASC + `, [monitorId, timestampKey]); + + // Convert to heartbeat-like format + return stats.map(stat => ({ + time: dayjs(stat.timestamp).format("YYYY-MM-DD HH:mm:ss"), + status: stat.up > 0 ? 1 : (stat.down > 0 ? 0 : 1), // Simplified status logic + up: stat.up, + down: stat.down, + ping: stat.ping + })); + } else { + // Use daily stats for ranges over 24 hours + const days = Math.ceil(hours / 24); + const startTime = now.subtract(days, "days"); + const timestampKey = Math.floor(startTime.valueOf() / (24 * 60 * 60 * 1000)) * (24 * 60 * 60 * 1000); + + const stats = await R.getAll(` + SELECT * FROM stat_daily + WHERE monitor_id = ? AND timestamp >= ? + ORDER BY timestamp ASC + `, [monitorId, timestampKey]); + + // Convert to heartbeat-like format + return stats.map(stat => ({ + time: dayjs(stat.timestamp).format("YYYY-MM-DD HH:mm:ss"), + status: stat.up > 0 ? 1 : (stat.down > 0 ? 0 : 1), // Simplified status logic + up: stat.up, + down: stat.down, + ping: stat.ping + })); + } } module.exports = { parseRangeHours, - rangeToDatabaseDate + getAggregatedHeartbeatData }; \ No newline at end of file diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index c6464480e..14b8b07fb 100644 --- a/src/components/HeartbeatBar.vue +++ b/src/components/HeartbeatBar.vue @@ -129,94 +129,9 @@ export default { }, aggregatedBeatList() { - if (!this.beatList || !this.heartbeatBarRange || this.heartbeatBarRange === "auto") { - return []; - } - - const now = dayjs(); - const buckets = []; - - // Parse the range to get total time and determine bucket size - // Parse range to get total hours - let totalHours; - if (this.heartbeatBarRange.endsWith("h")) { - totalHours = parseInt(this.heartbeatBarRange); - } else if (this.heartbeatBarRange.endsWith("d")) { - totalHours = parseInt(this.heartbeatBarRange) * 24; - } else { - totalHours = 90 * 24; // Fallback - } - - // Calculate bucket size and count - const totalBuckets = this.maxBeat || 50; - const bucketSize = totalHours / totalBuckets; - - // Create time buckets from oldest to newest - const startTime = now.subtract(totalHours, "hours"); - for (let i = 0; i < totalBuckets; i++) { - let bucketStart; - let bucketEnd; - if (bucketSize < 1) { - // Handle sub-hour buckets (minutes) - const minutes = bucketSize * 60; - bucketStart = startTime.add(i * minutes, "minutes"); - bucketEnd = bucketStart.add(minutes, "minutes"); - } else { - // Handle hour+ buckets - bucketStart = startTime.add(i * bucketSize, "hours"); - bucketEnd = bucketStart.add(bucketSize, "hours"); - } - - buckets.push({ - start: bucketStart, - end: bucketEnd, - beats: [], - status: 1, // default to up - time: bucketEnd.toISOString() - }); - } - - // Group heartbeats into buckets - this.beatList.forEach(beat => { - const beatTime = dayjs.utc(beat.time).local(); - const bucket = buckets.find(b => - (beatTime.isAfter(b.start) || beatTime.isSame(b.start)) && - (beatTime.isBefore(b.end) || beatTime.isSame(b.end)) - ); - if (bucket) { - bucket.beats.push(beat); - } - }); - - // Calculate status for each bucket - buckets.forEach(bucket => { - if (bucket.beats.length === 0) { - bucket.status = null; // no data - will be rendered as empty/grey - bucket.time = bucket.end.toISOString(); - } else { - // If any beat is down, bucket is down - // If any beat is maintenance, bucket is maintenance - // Otherwise bucket is up - const hasDown = bucket.beats.some(b => b.status === 0); - const hasMaintenance = bucket.beats.some(b => b.status === 3); - - if (hasDown) { - bucket.status = 0; - } else if (hasMaintenance) { - bucket.status = 3; - } else { - bucket.status = 1; - } - - // Use the latest beat time in the bucket - const latestBeat = bucket.beats.reduce((latest, beat) => - dayjs(beat.time).isAfter(dayjs(latest.time)) ? beat : latest - ); - bucket.time = latestBeat.time; - } - }); - - return buckets; + // Data is now pre-aggregated by the server using stat tables + // No client-side processing needed for non-auto ranges + return this.beatList || []; }, wrapStyle() {