diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js index bb6049234..2f9d05ad4 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 { getAggregatedHeartbeatData } = require("../util/heartbeat-range"); +const { getAggregatedHeartbeatData, parseRangeHours } = require("../util/heartbeat-range"); const { R } = require("redbean-node"); const { badgeConstants } = require("../../src/util"); const { makeBadge } = require("badge-maker"); @@ -89,6 +89,8 @@ 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"; + console.log(`[STATUS-PAGE] Processing ${monitorIDList.length} monitors with range: ${heartbeatRange}`); + for (let monitorID of monitorIDList) { let list; @@ -97,20 +99,44 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques if (aggregatedData) { // Use pre-aggregated stat data + console.log(`[STATUS-PAGE] Using aggregated data for monitor ${monitorID}: ${aggregatedData.length} records`); heartbeatList[monitorID] = aggregatedData; } else { - // Fall back to raw heartbeat data for auto mode - list = await R.getAll(` - SELECT * FROM heartbeat - WHERE monitor_id = ? - ORDER BY time DESC - LIMIT 100 - `, [ - monitorID, - ]); + // Fall back to raw heartbeat data (auto mode or no stat data) + console.log(`[STATUS-PAGE] Using raw heartbeat data for monitor ${monitorID} (range: ${heartbeatRange})`); + + if (heartbeatRange === "auto") { + // Auto mode - use original LIMIT 100 logic + list = await R.getAll(` + SELECT * FROM heartbeat + WHERE monitor_id = ? + ORDER BY time DESC + LIMIT 100 + `, [ + monitorID, + ]); + } else { + // Non-auto range but no stat data - filter raw heartbeat data by time + const hours = parseRangeHours(heartbeatRange); + const date = new Date(); + date.setHours(date.getHours() - hours); + const dateFrom = date.toISOString().slice(0, 19).replace('T', ' '); + + console.log(`[STATUS-PAGE] Filtering heartbeat data from ${dateFrom} for ${hours} hours`); + + 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()); + console.log(`[STATUS-PAGE] Raw heartbeat data for monitor ${monitorID}: ${heartbeatList[monitorID].length} records`); } const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID); diff --git a/server/util/heartbeat-range.js b/server/util/heartbeat-range.js index 7ab4ad7bd..a497f35c8 100644 --- a/server/util/heartbeat-range.js +++ b/server/util/heartbeat-range.js @@ -32,53 +32,82 @@ function parseRangeHours(range) { * @returns {Promise} Aggregated heartbeat data */ async function getAggregatedHeartbeatData(monitorId, range) { + console.log(`[HEARTBEAT-RANGE] Getting aggregated data for monitor ${monitorId}, range: ${range}`); + if (!range || range === "auto") { - // Fall back to regular heartbeat query for auto mode + console.log(`[HEARTBEAT-RANGE] Auto mode - returning null to use regular heartbeat query`); return null; } const now = dayjs(); const hours = parseRangeHours(range); + console.log(`[HEARTBEAT-RANGE] Parsed ${range} to ${hours} hours`); 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); + console.log(`[HEARTBEAT-RANGE] Using hourly stats from timestamp ${timestampKey} (${dayjs(timestampKey).format()})`); + const stats = await R.getAll(` SELECT * FROM stat_hourly WHERE monitor_id = ? AND timestamp >= ? ORDER BY timestamp ASC `, [monitorId, timestampKey]); + console.log(`[HEARTBEAT-RANGE] Found ${stats.length} hourly stat records`); + + // If no stat data, fall back to raw heartbeat data + if (stats.length === 0) { + console.log(`[HEARTBEAT-RANGE] No stat data found, falling back to raw heartbeat data`); + return null; // This will trigger fallback in router + } + // Convert to heartbeat-like format - return stats.map(stat => ({ + const result = 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 })); + + console.log(`[HEARTBEAT-RANGE] Returning ${result.length} converted records`); + return result; } 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); + console.log(`[HEARTBEAT-RANGE] Using daily stats from timestamp ${timestampKey} (${dayjs(timestampKey).format()})`); + const stats = await R.getAll(` SELECT * FROM stat_daily WHERE monitor_id = ? AND timestamp >= ? ORDER BY timestamp ASC `, [monitorId, timestampKey]); + console.log(`[HEARTBEAT-RANGE] Found ${stats.length} daily stat records`); + + // If no stat data, fall back to raw heartbeat data + if (stats.length === 0) { + console.log(`[HEARTBEAT-RANGE] No stat data found, falling back to raw heartbeat data`); + return null; // This will trigger fallback in router + } + // Convert to heartbeat-like format - return stats.map(stat => ({ + const result = 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 })); + + console.log(`[HEARTBEAT-RANGE] Returning ${result.length} converted records`); + return result; } } diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index 14b8b07fb..561f55382 100644 --- a/src/components/HeartbeatBar.vue +++ b/src/components/HeartbeatBar.vue @@ -99,12 +99,16 @@ export default { }, shortBeatList() { + console.log(`[HEARTBEAT-BAR] shortBeatList called with range: ${this.heartbeatBarRange}, beatList: ${this.beatList ? this.beatList.length : 'null'} items`); + if (!this.beatList) { + console.log(`[HEARTBEAT-BAR] No beatList - returning empty array`); return []; } // If heartbeat range is configured (not auto), aggregate by time periods if (this.heartbeatBarRange && this.heartbeatBarRange !== "auto") { + console.log(`[HEARTBEAT-BAR] Using aggregated beat list for range: ${this.heartbeatBarRange}`); return this.aggregatedBeatList; } @@ -129,9 +133,92 @@ export default { }, aggregatedBeatList() { - // Data is now pre-aggregated by the server using stat tables - // No client-side processing needed for non-auto ranges - return this.beatList || []; + console.log(`[HEARTBEAT-BAR] aggregatedBeatList called with range: ${this.heartbeatBarRange}, beatList length: ${this.beatList ? this.beatList.length : 'null'}`); + + if (!this.beatList || this.beatList.length === 0) { + console.log(`[HEARTBEAT-BAR] No beatList data`); + return []; + } + + // If data is already aggregated from server (has 'up'/'down' fields), use as-is + if (this.beatList[0] && typeof this.beatList[0].up !== 'undefined') { + console.log(`[HEARTBEAT-BAR] Using pre-aggregated server data`); + return this.beatList; + } + + // Otherwise, do client-side aggregation for raw heartbeat data + console.log(`[HEARTBEAT-BAR] Performing client-side aggregation`); + const now = dayjs(); + const buckets = []; + + // 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 = startTime.add(i * bucketSize, "hours"); + let 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 + } else { + // Priority: DOWN (0) > MAINTENANCE (3) > UP (1) + 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; + } + }); + + console.log(`[HEARTBEAT-BAR] Generated ${buckets.length} aggregated buckets`); + return buckets; }, wrapStyle() {