diff --git a/db/knex_migrations/2025-06-14-0000-heartbeat-range-config.js b/db/knex_migrations/2025-06-14-0000-heartbeat-range-config.js index 7d950f21f..6597b8363 100644 --- a/db/knex_migrations/2025-06-14-0000-heartbeat-range-config.js +++ b/db/knex_migrations/2025-06-14-0000-heartbeat-range-config.js @@ -1,12 +1,11 @@ exports.up = function (knex) { - return knex.schema - .alterTable("status_page", function (table) { - table.integer("heartbeat_bar_range_days").defaultTo(90).unsigned(); - }); + return knex.schema.alterTable("status_page", function (table) { + table.string("heartbeat_bar_range").defaultTo("auto"); + }); }; exports.down = function (knex) { return knex.schema.alterTable("status_page", function (table) { - table.dropColumn("heartbeat_bar_range_days"); + table.dropColumn("heartbeat_bar_range"); }); }; diff --git a/server/model/status_page.js b/server/model/status_page.js index 908cd43f5..0b7948d48 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -409,7 +409,7 @@ class StatusPage extends BeanModel { showPoweredBy: !!this.show_powered_by, googleAnalyticsId: this.google_analytics_tag_id, showCertificateExpiry: !!this.show_certificate_expiry, - heartbeatBarRangeDays: this.heartbeat_bar_range_days || 90, + heartbeatBarRange: this.heartbeat_bar_range || "auto", }; } @@ -433,7 +433,7 @@ class StatusPage extends BeanModel { showPoweredBy: !!this.show_powered_by, googleAnalyticsId: this.google_analytics_tag_id, showCertificateExpiry: !!this.show_certificate_expiry, - heartbeatBarRangeDays: this.heartbeat_bar_range_days || 90, + heartbeatBarRange: this.heartbeat_bar_range || "auto", }; } diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js index 92d3fb13b..a5819d811 100644 --- a/server/routers/status-page-router.js +++ b/server/routers/status-page-router.js @@ -86,21 +86,49 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques // Get the status page to determine the heartbeat range let statusPage = await R.findOne("status_page", " id = ? ", [ statusPageID ]); - let heartbeatRangeDays = (statusPage && statusPage.heartbeat_bar_range_days) ? statusPage.heartbeat_bar_range_days : 90; + let heartbeatRange = (statusPage && statusPage.heartbeat_bar_range) ? statusPage.heartbeat_bar_range : "auto"; - // Calculate the date range for heartbeats + // Calculate the date range for heartbeats based on range setting let dateFrom = new Date(); - dateFrom.setDate(dateFrom.getDate() - heartbeatRangeDays); + if (heartbeatRange === "auto") { + // Auto mode: limit to last 100 beats (original behavior) + dateFrom = null; + } else if (heartbeatRange.endsWith("h")) { + // Hours + let hours = parseInt(heartbeatRange); + dateFrom.setHours(dateFrom.getHours() - hours); + } else if (heartbeatRange.endsWith("d")) { + // Days + let days = parseInt(heartbeatRange); + dateFrom.setDate(dateFrom.getDate() - days); + } else { + // Fallback to 90 days + dateFrom.setDate(dateFrom.getDate() - 90); + } for (let monitorID of monitorIDList) { - let list = await R.getAll(` + let list; + if (dateFrom === null) { + // Auto mode: use original logic with LIMIT 100 + list = await R.getAll(` + SELECT * FROM heartbeat + WHERE monitor_id = ? + ORDER BY time DESC + LIMIT 100 + `, [ + monitorID, + ]); + } else { + // Time-based filtering + list = await R.getAll(` SELECT * FROM heartbeat WHERE monitor_id = ? AND time >= ? ORDER BY time DESC - `, [ - monitorID, - dateFrom.toISOString(), - ]); + `, [ + monitorID, + dateFrom.toISOString(), + ]); + } list = R.convertToBeans("heartbeat", list); heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON()); diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js index 57f2f9254..d7ce962cf 100644 --- a/server/socket-handlers/status-page-socket-handler.js +++ b/server/socket-handlers/status-page-socket-handler.js @@ -165,7 +165,7 @@ module.exports.statusPageSocketHandler = (socket) => { statusPage.custom_css = config.customCSS; statusPage.show_powered_by = config.showPoweredBy; statusPage.show_certificate_expiry = config.showCertificateExpiry; - statusPage.heartbeat_bar_range_days = config.heartbeatBarRangeDays || 90; + statusPage.heartbeat_bar_range = config.heartbeatBarRange || "auto"; statusPage.modified_date = R.isoDateTime(); statusPage.google_analytics_tag_id = config.googleAnalyticsId; diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index 429ca9f91..927ac166e 100644 --- a/src/components/HeartbeatBar.vue +++ b/src/components/HeartbeatBar.vue @@ -5,19 +5,19 @@ v-for="(beat, index) in shortBeatList" :key="index" class="beat-hover-area" - :class="{ 'empty': (beat === 0) }" + :class="{ 'empty': (beat === 0 || beat === null || beat.status === null) }" :style="beatHoverAreaStyle" :title="getBeatTitle(beat)" >
{{ timeSinceFirstBeat }}
@@ -46,6 +46,11 @@ export default { heartbeatList: { type: Array, default: null, + }, + /** Heartbeat bar range */ + heartbeatBarRange: { + type: String, + default: "auto", } }, data() { @@ -98,6 +103,12 @@ export default { return []; } + // If heartbeat range is configured (not auto), aggregate by time periods + if (this.heartbeatBarRange && this.heartbeatBarRange !== "auto") { + return this.aggregatedBeatList; + } + + // Original logic for short time ranges let placeholders = []; let start = this.beatList.length - this.maxBeat; @@ -117,6 +128,101 @@ export default { return placeholders.concat(this.beatList.slice(start)); }, + 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 + let totalHours; + let bucketSize; // in hours + let totalBuckets = this.maxBeat || 50; // Use maxBeat to determine bucket count + + if (this.heartbeatBarRange.endsWith("h")) { + totalHours = parseInt(this.heartbeatBarRange); + } else if (this.heartbeatBarRange.endsWith("d")) { + const days = parseInt(this.heartbeatBarRange); + totalHours = days * 24; + } else { + // Fallback + totalHours = 90 * 24; + } + + // Calculate bucket size to fit the desired number of buckets + 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; + }, + wrapStyle() { let topBottom = (((this.beatHeight * this.hoverScale) - this.beatHeight) / 2); let leftRight = (((this.beatWidth * this.hoverScale) - this.beatWidth) / 2); @@ -162,6 +268,14 @@ export default { * @returns {object} The style object containing the CSS properties for positioning the time element. */ timeStyle() { + // For aggregated mode, don't use padding-based positioning + if (this.heartbeatBarRange && this.heartbeatBarRange !== "auto") { + return { + "margin-left": "0px", + }; + } + + // Original logic for auto mode return { "margin-left": this.numPadding * (this.beatWidth + this.beatHoverAreaPadding * 2) + "px", }; @@ -172,6 +286,22 @@ export default { * @returns {string} The time elapsed in minutes or hours. */ timeSinceFirstBeat() { + // For aggregated beats, calculate from the configured range + if (this.heartbeatBarRange && this.heartbeatBarRange !== "auto") { + if (this.heartbeatBarRange.endsWith("h")) { + const hours = parseInt(this.heartbeatBarRange); + return hours + "h"; + } else if (this.heartbeatBarRange.endsWith("d")) { + const days = parseInt(this.heartbeatBarRange); + if (days < 2) { + return (days * 24) + "h"; + } else { + return days + "d"; + } + } + } + + // Original logic for auto mode const firstValidBeat = this.shortBeatList.at(this.numPadding); const minutes = dayjs().diff(dayjs.utc(firstValidBeat?.time), "minutes"); if (minutes > 60) { @@ -267,6 +397,18 @@ export default { * @returns {string} Beat title */ getBeatTitle(beat) { + if (beat === 0) { + return ""; + } + + // For aggregated beats, show time range and status + if (beat.beats !== undefined && this.heartbeatBarRange && this.heartbeatBarRange !== "auto") { + const start = this.$root.datetime(beat.start); + const end = this.$root.datetime(beat.end); + const statusText = beat.status === 1 ? "Up" : beat.status === 0 ? "Down" : beat.status === 3 ? "Maintenance" : "No Data"; + return `${start} - ${end}: ${statusText} (${beat.beats.length} checks)`; + } + return `${this.$root.datetime(beat.time)}` + ((beat.msg) ? ` - ${beat.msg}` : ""); }, diff --git a/src/components/PublicGroupList.vue b/src/components/PublicGroupList.vue index cb97ecdcd..608209389 100644 --- a/src/components/PublicGroupList.vue +++ b/src/components/PublicGroupList.vue @@ -73,7 +73,7 @@
- +
@@ -114,6 +114,11 @@ export default { /** Should expiry be shown? */ showCertificateExpiry: { type: Boolean, + }, + /** Heartbeat bar range */ + heartbeatBarRange: { + type: String, + default: "auto", } }, data() { diff --git a/src/lang/en.json b/src/lang/en.json index aca79ce16..8f40f758e 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -376,13 +376,16 @@ "Refresh Interval": "Refresh Interval", "Refresh Interval Description": "The status page will do a full site refresh every {0} seconds", "Heartbeat Bar Range": "Heartbeat Bar Range", + "6 hours": "6 hours", + "12 hours": "12 hours", + "24 hours": "24 hours", "7 days": "7 days", "30 days": "30 days", "60 days": "60 days", "90 days": "90 days", "180 days": "180 days", "365 days": "365 days", - "How many days of heartbeat history to show in the status page": "How many days of heartbeat history to show in the status page", + "How much heartbeat history to show in the status page": "How much heartbeat history to show in the status page", "Show Powered By": "Show Powered By", "Domain Names": "Domain Names", "signedInDisp": "Signed in as {0}", diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue index 2da07f356..eda54fba0 100644 --- a/src/pages/StatusPage.vue +++ b/src/pages/StatusPage.vue @@ -44,16 +44,20 @@
- + + + + + + + + + +
- {{ $t("How many days of heartbeat history to show in the status page") }} + {{ $t("How much heartbeat history to show in the status page") }}
@@ -343,7 +347,7 @@ 👀 {{ $t("statusPageNothing") }} - +