From 9402c0fa00a54d1cd2ccbf98dee09789fe527250 Mon Sep 17 00:00:00 2001 From: Doruk Date: Sat, 14 Jun 2025 18:37:40 +0200 Subject: [PATCH] rebucketing --- server/routers/status-page-router.js | 169 ++++++++++++++++++++++++--- src/components/HeartbeatBar.vue | 113 ++++++++++++++---- 2 files changed, 239 insertions(+), 43 deletions(-) diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js index 6964f5a1d..a350a1ab8 100644 --- a/server/routers/status-page-router.js +++ b/server/routers/status-page-router.js @@ -4,9 +4,10 @@ const { UptimeKumaServer } = require("../uptime-kuma-server"); const StatusPage = require("../model/status_page"); const { allowDevAllOrigin, sendHttpError } = require("../util-server"); const { R } = require("redbean-node"); -const { badgeConstants } = require("../../src/util"); +const { badgeConstants, UP, DOWN, MAINTENANCE, PENDING } = require("../../src/util"); const { makeBadge } = require("badge-maker"); const { UptimeCalculator } = require("../uptime-calculator"); +const dayjs = require("dayjs"); let router = express.Router(); @@ -89,6 +90,8 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques let heartbeatBarDays = statusPage ? (statusPage.heartbeat_bar_days || 0) : 0; for (let monitorID of monitorIDList) { + const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID); + if (heartbeatBarDays === 0) { // Auto mode - use original LIMIT 100 logic let list = await R.getAll(` @@ -103,26 +106,10 @@ 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()); } else { - // For configured day ranges, always use raw heartbeat data for client-side aggregation - // This ensures consistent behavior between edit mode and published mode - const date = new Date(); - date.setDate(date.getDate() - heartbeatBarDays); - const dateFrom = date.toISOString().slice(0, 19).replace("T", " "); - - let 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()); + // For configured day ranges, use aggregated data from UptimeCalculator + heartbeatList[monitorID] = await getAggregatedHeartbeats(uptimeCalculator, heartbeatBarDays); } - const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID); uptimeList[`${monitorID}_24`] = uptimeCalculator.get24Hour().uptime; } @@ -269,4 +256,148 @@ router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, r } }); +/** + * Get aggregated heartbeats for status page display + * @param {UptimeCalculator} uptimeCalculator The uptime calculator instance + * @param {number} days Number of days to show + * @returns {Promise} Array of aggregated heartbeat data + */ +async function getAggregatedHeartbeats(uptimeCalculator, days) { + const targetBuckets = 100; // Always show ~100 buckets for consistent display + const now = dayjs.utc(); + const result = []; + + // Determine data granularity based on days + let dataPoints; + let granularity; + let bucketSizeMinutes; + + if (days <= 1) { + // For 1 day or less, use minutely data + granularity = "minute"; + dataPoints = uptimeCalculator.getDataArray(days * 24 * 60, granularity); + bucketSizeMinutes = Math.max(1, Math.floor((days * 24 * 60) / targetBuckets)); + } else if (days <= 30) { + // For 2-30 days, use hourly data + granularity = "hour"; + dataPoints = uptimeCalculator.getDataArray(days * 24, granularity); + bucketSizeMinutes = Math.max(60, Math.floor((days * 24 * 60) / targetBuckets)); + } else { + // For 31+ days, use daily data + granularity = "day"; + dataPoints = uptimeCalculator.getDataArray(days, granularity); + bucketSizeMinutes = Math.max(1440, Math.floor((days * 24 * 60) / targetBuckets)); + } + + // Create time buckets + const startTime = now.subtract(days, "day").startOf("minute"); + const endTime = now; + const buckets = []; + + for (let i = 0; i < targetBuckets; i++) { + const bucketStart = startTime.add(i * bucketSizeMinutes, "minute"); + let bucketEnd = bucketStart.add(bucketSizeMinutes, "minute"); + + // Don't create buckets that start after current time + if (bucketStart.isAfter(endTime)) { + break; + } + + // Ensure bucket doesn't extend beyond current time + if (bucketEnd.isAfter(endTime)) { + bucketEnd = endTime; + } + + buckets.push({ + start: bucketStart.unix(), + end: bucketEnd.unix(), + up: 0, + down: 0, + maintenance: 0, + pending: 0, + hasData: false + }); + } + + // Aggregate data points into buckets + for (const dataPoint of dataPoints) { + if (!dataPoint || !dataPoint.timestamp) { + continue; + } + + // Find the appropriate bucket for this data point + const bucket = buckets.find(b => + dataPoint.timestamp >= b.start && dataPoint.timestamp < b.end + ); + + if (bucket) { + bucket.up += dataPoint.up || 0; + bucket.down += dataPoint.down || 0; + bucket.maintenance += dataPoint.maintenance || 0; + bucket.pending += dataPoint.pending || 0; + bucket.hasData = true; + } + } + + // Convert buckets to heartbeat format + for (const bucket of buckets) { + let status = null; // No data + + if (bucket.hasData) { + // Determine status based on priority: DOWN > MAINTENANCE > PENDING > UP + if (bucket.down > 0) { + status = DOWN; + } else if (bucket.maintenance > 0) { + status = MAINTENANCE; + } else if (bucket.pending > 0) { + status = PENDING; + } else if (bucket.up > 0) { + status = UP; + } + } + + result.push({ + status: status, + time: dayjs.unix(bucket.end).toISOString(), + msg: "", + ping: null, + // Include aggregation info for client-side display + _aggregated: true, + _startTime: dayjs.unix(bucket.start).toISOString(), + _endTime: dayjs.unix(bucket.end).toISOString(), + _counts: { + up: bucket.up, + down: bucket.down, + maintenance: bucket.maintenance, + pending: bucket.pending + } + }); + } + + // Ensure we always return targetBuckets number of items by padding at the start + while (result.length < targetBuckets) { + const firstStartTime = result.length > 0 ? dayjs(result[0]._startTime || result[0].time) : now.subtract(days, "day"); + const paddedStart = firstStartTime.subtract(bucketSizeMinutes, "minute"); + const paddedEnd = firstStartTime; + + result.unshift({ + status: null, + time: paddedEnd.toISOString(), + msg: "", + ping: null, + _aggregated: true, + _startTime: paddedStart.toISOString(), + _endTime: paddedEnd.toISOString(), + _counts: { + up: 0, + down: 0, + maintenance: 0, + pending: 0 + } + }); + } + + return result; +} + module.exports = router; diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index 1ed2f647b..dacf26ac6 100644 --- a/src/components/HeartbeatBar.vue +++ b/src/components/HeartbeatBar.vue @@ -112,10 +112,20 @@ export default { return []; } - // If heartbeat days is configured (not auto), always use client-side aggregation - // This ensures consistent behavior between edit mode and published mode + // If heartbeat days is configured (not auto), check if data is already aggregated if (this.normalizedHeartbeatBarDays > 0) { - return this.aggregatedBeatList; + // Check if the data is already aggregated from the server + if (this.beatList.length > 0 && this.beatList[0]._aggregated) { + // Data is already aggregated from server, use it directly + // But still limit to maxBeat for display + if (this.beatList.length > this.maxBeat) { + return this.beatList.slice(this.beatList.length - this.maxBeat); + } + return this.beatList; + } else { + // Fallback to client-side aggregation for edit mode + return this.aggregatedBeatList; + } } // Original logic for auto mode (heartbeatBarDays = 0) @@ -143,28 +153,38 @@ export default { return []; } - // Always do client-side aggregation using dynamic maxBeat for proper screen sizing + // Always do client-side aggregation using fixed time buckets const now = dayjs(); const buckets = []; - // Calculate total hours from days - const totalHours = this.normalizedHeartbeatBarDays * 24; - - // Use dynamic maxBeat calculated from screen size - const totalBuckets = this.maxBeat > 0 ? this.maxBeat : 50; - const bucketSize = totalHours / totalBuckets; + // Use same logic as server-side: 100 buckets + const targetBuckets = 100; + const days = this.normalizedHeartbeatBarDays; + const bucketSizeMinutes = Math.max(1, Math.floor((days * 24 * 60) / targetBuckets)); // Create time buckets from oldest to newest - const startTime = now.subtract(totalHours, "hours"); - for (let i = 0; i < totalBuckets; i++) { - let bucketStart = startTime.add(i * bucketSize, "hours"); - let bucketEnd = bucketStart.add(bucketSize, "hours"); + const startTime = now.subtract(days, "day").startOf("minute"); + const endTime = now; + + for (let i = 0; i < targetBuckets; i++) { + let bucketStart = startTime.add(i * bucketSizeMinutes, "minute"); + let bucketEnd = bucketStart.add(bucketSizeMinutes, "minute"); + + // Don't create buckets that start after current time + if (bucketStart.isAfter(endTime)) { + break; + } + + // Ensure bucket doesn't extend beyond current time + if (bucketEnd.isAfter(endTime)) { + bucketEnd = endTime; + } buckets.push({ start: bucketStart, end: bucketEnd, beats: [], - status: 1, // default to up + status: null, // default to no data time: bucketEnd.toISOString() }); } @@ -173,8 +193,7 @@ export default { 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)) + beatTime.unix() >= b.start.unix() && beatTime.unix() < b.end.unix() ); if (bucket) { bucket.beats.push(beat); @@ -186,26 +205,46 @@ export default { if (bucket.beats.length === 0) { bucket.status = null; // no data - will be rendered as empty/grey } else { - // Priority: DOWN (0) > MAINTENANCE (3) > UP (1) + // Priority: DOWN (0) > MAINTENANCE (3) > PENDING (2) > UP (1) const hasDown = bucket.beats.some(b => b.status === 0); const hasMaintenance = bucket.beats.some(b => b.status === 3); + const hasPending = bucket.beats.some(b => b.status === 2); if (hasDown) { bucket.status = 0; } else if (hasMaintenance) { bucket.status = 3; + } else if (hasPending) { + bucket.status = 2; } 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; + // Use the bucket end time for consistency + bucket.time = bucket.end.toISOString(); } }); + // Ensure we always return targetBuckets number of items by padding at the start + while (buckets.length < targetBuckets) { + const firstStart = buckets.length > 0 ? buckets[0].start : now.subtract(days, "day"); + const paddedStart = firstStart.subtract(bucketSizeMinutes, "minute"); + const paddedEnd = firstStart; + + buckets.unshift({ + start: paddedStart, + end: paddedEnd, + beats: [], + status: null, + time: paddedEnd.toISOString() + }); + } + + // Limit to maxBeat for display + if (buckets.length > this.maxBeat) { + return buckets.slice(buckets.length - this.maxBeat); + } + return buckets; }, @@ -375,7 +414,7 @@ export default { return ""; } - // For aggregated beats, show time range and status + // For client-side aggregated beats (edit mode) if (beat.beats !== undefined) { const start = this.$root.datetime(beat.start); const end = this.$root.datetime(beat.end); @@ -383,6 +422,32 @@ export default { return `${start} - ${end}: ${statusText} (${beat.beats.length} checks)`; } + // For server-side aggregated beats + if (beat._aggregated && beat._counts) { + const counts = beat._counts; + const total = counts.up + counts.down + counts.maintenance + counts.pending; + + // Use start time for display if available + const displayTime = beat._startTime ? beat._startTime : beat.time; + + if (total === 0) { + return `${this.$root.datetime(displayTime)}: No Data`; + } + + let statusText = ""; + if (counts.down > 0) { + statusText = "Down"; + } else if (counts.maintenance > 0) { + statusText = "Maintenance"; + } else if (counts.pending > 0) { + statusText = "Pending"; + } else if (counts.up > 0) { + statusText = "Up"; + } + + return `${this.$root.datetime(displayTime)}: ${statusText} (${total} checks)`; + } + // For individual beats, show timestamp return `${this.$root.datetime(beat.time)}${beat.msg ? ` - ${beat.msg}` : ""}`; },