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 @@