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
new file mode 100644
index 000000000..cbdb489d3
--- /dev/null
+++ b/db/knex_migrations/2025-06-14-0000-heartbeat-range-config.js
@@ -0,0 +1,11 @@
+exports.up = function (knex) {
+ return knex.schema.alterTable("status_page", function (table) {
+ table.smallint("heartbeat_bar_days").notNullable().defaultTo(0).checkBetween([ 0, 365 ]);
+ });
+};
+
+exports.down = function (knex) {
+ return knex.schema.alterTable("status_page", function (table) {
+ table.dropColumn("heartbeat_bar_days");
+ });
+};
diff --git a/server/model/status_page.js b/server/model/status_page.js
index 2f3511ec5..91a894dcb 100644
--- a/server/model/status_page.js
+++ b/server/model/status_page.js
@@ -409,6 +409,7 @@ class StatusPage extends BeanModel {
showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id,
showCertificateExpiry: !!this.show_certificate_expiry,
+ heartbeatBarDays: this.heartbeat_bar_days,
};
}
@@ -432,6 +433,7 @@ class StatusPage extends BeanModel {
showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id,
showCertificateExpiry: !!this.show_certificate_expiry,
+ heartbeatBarDays: this.heartbeat_bar_days || 0,
};
}
diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js
index 6e57451f1..27e84c59f 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();
@@ -84,21 +85,75 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques
statusPageID
]);
- for (let monitorID of monitorIDList) {
- let list = await R.getAll(`
+ // Get the status page to determine the heartbeat range
+ let statusPage = await R.findOne("status_page", " id = ? ", [ statusPageID ]);
+ let heartbeatBarDays = statusPage ? (statusPage.heartbeat_bar_days || 0) : 0;
+
+ // Get max beats parameter from query string (for client-side screen width constraints)
+ const maxBeats = Math.min(parseInt(request.query.maxBeats) || 100, 100);
+
+ // Process all monitors in parallel using Promise.all
+ const monitorPromises = monitorIDList.map(async (monitorID) => {
+ const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
+
+ let heartbeats;
+
+ if (heartbeatBarDays === 0) {
+ // Auto mode - use original LIMIT 100 logic
+ let list = await R.getAll(`
SELECT * FROM heartbeat
WHERE monitor_id = ?
ORDER BY time DESC
LIMIT 100
- `, [
+ `, [
+ monitorID,
+ ]);
+
+ list = R.convertToBeans("heartbeat", list);
+ heartbeats = list.reverse().map(row => row.toPublicJSON());
+ } else {
+ // For configured day ranges, use aggregated data from UptimeCalculator
+ const buckets = uptimeCalculator.getAggregatedBuckets(heartbeatBarDays, maxBeats);
+ heartbeats = buckets.map(bucket => {
+ // If bucket has no data, return 0 (empty beat) to match original behavior
+ if (bucket.up === 0 && bucket.down === 0 && bucket.maintenance === 0 && bucket.pending === 0) {
+ return 0;
+ }
+
+ return {
+ status: bucket.down > 0 ? DOWN :
+ bucket.maintenance > 0 ? MAINTENANCE :
+ bucket.pending > 0 ? PENDING :
+ bucket.up > 0 ? UP : 0,
+ time: dayjs.unix(bucket.end).toISOString(),
+ msg: "",
+ ping: null
+ };
+ });
+ }
+
+ // Calculate uptime based on the range
+ let uptime;
+ if (heartbeatBarDays <= 1) {
+ uptime = uptimeCalculator.get24Hour().uptime;
+ } else {
+ uptime = uptimeCalculator.getData(heartbeatBarDays, "day").uptime;
+ }
+
+ return {
monitorID,
- ]);
+ heartbeats,
+ uptime
+ };
+ });
- list = R.convertToBeans("heartbeat", list);
- heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());
+ // Wait for all monitors to be processed
+ const monitorResults = await Promise.all(monitorPromises);
- const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
- uptimeList[`${monitorID}_24`] = uptimeCalculator.get24Hour().uptime;
+ // Populate the response objects
+ for (const result of monitorResults) {
+ heartbeatList[result.monitorID] = result.heartbeats;
+ uptimeList[result.monitorID] = result.uptime;
}
response.json({
diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js
index 952ec2fa7..adf57a4fd 100644
--- a/server/socket-handlers/status-page-socket-handler.js
+++ b/server/socket-handlers/status-page-socket-handler.js
@@ -165,6 +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_days = config.heartbeatBarDays;
statusPage.modified_date = R.isoDateTime();
statusPage.google_analytics_tag_id = config.googleAnalyticsId;
diff --git a/server/uptime-calculator.js b/server/uptime-calculator.js
index 71d1d458c..2f915f2d1 100644
--- a/server/uptime-calculator.js
+++ b/server/uptime-calculator.js
@@ -845,6 +845,93 @@ class UptimeCalculator {
setMigrationMode(value) {
this.migrationMode = value;
}
+
+ /**
+ * Get aggregated heartbeat buckets for a specific time range
+ * @param {number} days Number of days to aggregate
+ * @param {number} targetBuckets Number of buckets to create (default 100)
+ * @returns {Array} Array of aggregated bucket data
+ */
+ getAggregatedBuckets(days, targetBuckets = 100) {
+ const now = this.getCurrentDate();
+ const startTime = now.subtract(days, "day");
+ const totalMinutes = days * 60 * 24;
+ const bucketSizeMinutes = totalMinutes / targetBuckets;
+
+ // Get available data from UptimeCalculator for lookup
+ const availableData = {};
+ let rawDataPoints;
+
+ if (days <= 1) {
+ const exactMinutes = Math.ceil(days * 24 * 60);
+ rawDataPoints = this.getDataArray(exactMinutes, "minute");
+ } else if (days <= 30) {
+ // For 1-30 days, use hourly data (up to 720 hours)
+ const exactHours = Math.min(Math.ceil(days * 24), 720);
+ rawDataPoints = this.getDataArray(exactHours, "hour");
+ } else {
+ // For > 30 days, use daily data
+ const requestDays = Math.min(days, 365);
+ rawDataPoints = this.getDataArray(requestDays, "day");
+ }
+
+ // Create lookup map for available data
+ for (const point of rawDataPoints) {
+ if (point && point.timestamp) {
+ availableData[point.timestamp] = point;
+ }
+ }
+
+ // Create exactly targetBuckets buckets spanning the full requested time range
+ const buckets = [];
+ for (let i = 0; i < targetBuckets; i++) {
+ const bucketStart = startTime.add(i * bucketSizeMinutes, "minute");
+ const bucketEnd = startTime.add((i + 1) * bucketSizeMinutes, "minute");
+
+ buckets.push({
+ start: bucketStart.unix(),
+ end: bucketEnd.unix(),
+ up: 0,
+ down: 0,
+ maintenance: 0,
+ pending: 0
+ });
+ }
+
+ // Aggregate available data into buckets
+ for (const [ timestamp, dataPoint ] of Object.entries(availableData)) {
+ const timestampNum = parseInt(timestamp);
+
+ // Find the appropriate bucket for this data point
+ // For daily data (> 30 days), timestamps are at start of day
+ // We need to find which bucket this day belongs to
+ for (let i = 0; i < buckets.length; i++) {
+ const bucket = buckets[i];
+
+ if (days > 30) {
+ // For daily data, check if the timestamp falls within the bucket's day range
+ if (timestampNum >= bucket.start && timestampNum < bucket.end) {
+ bucket.up += dataPoint.up || 0;
+ bucket.down += dataPoint.down || 0;
+ bucket.maintenance += dataPoint.maintenance || 0;
+ bucket.pending += dataPoint.pending || 0;
+ break;
+ }
+ } else {
+ // For minute/hourly data, use exact timestamp matching
+ if (timestampNum >= bucket.start && timestampNum < bucket.end && dataPoint) {
+ bucket.up += dataPoint.up || 0;
+ bucket.down += dataPoint.down || 0;
+ bucket.maintenance += 0; // UptimeCalculator treats maintenance as up
+ bucket.pending += 0; // UptimeCalculator doesn't track pending separately
+ break;
+ }
+ }
+ }
+ }
+
+ return buckets;
+ }
}
class UptimeDataResult {
diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue
index 429ca9f91..ef3df61f7 100644
--- a/src/components/HeartbeatBar.vue
+++ b/src/components/HeartbeatBar.vue
@@ -5,13 +5,13 @@
v-for="(beat, index) in shortBeatList"
:key="index"
class="beat-hover-area"
- :class="{ 'empty': (beat === 0) }"
+ :class="{ 'empty': (!beat) }"
:style="beatHoverAreaStyle"
:title="getBeatTitle(beat)"
>
@@ -46,6 +46,11 @@ export default {
heartbeatList: {
type: Array,
default: null,
+ },
+ /** Heartbeat bar days */
+ heartbeatBarDays: {
+ type: Number,
+ default: 0
}
},
data() {
@@ -80,6 +85,12 @@ export default {
if (!this.beatList) {
return 0;
}
+
+ // For configured ranges, no padding needed since we show all beats
+ if (this.heartbeatBarDays > 0) {
+ return 0;
+ }
+
let num = this.beatList.length - this.maxBeat;
if (this.move) {
@@ -98,8 +109,20 @@ export default {
return [];
}
+ // If heartbeat days is configured (not auto), data is already aggregated from server
+ if (this.normalizedHeartbeatBarDays > 0 && this.beatList.length > 0) {
+ // Show all beats from server - they are already properly aggregated
+ return this.beatList;
+ }
+
+ // Original logic for auto mode (heartbeatBarDays = 0)
let placeholders = [];
+ // Handle case where maxBeat is -1 (no limit)
+ if (this.maxBeat <= 0) {
+ return this.beatList;
+ }
+
let start = this.beatList.length - this.maxBeat;
if (this.move) {
@@ -172,13 +195,17 @@ export default {
* @returns {string} The time elapsed in minutes or hours.
*/
timeSinceFirstBeat() {
+ // For configured days mode, show the configured range
+ if (this.heartbeatBarDays >= 2) {
+ return this.heartbeatBarDays + "d";
+ } else if (this.heartbeatBarDays === 1) {
+ return (this.heartbeatBarDays * 24) + "h";
+ }
+
+ // Need to calculate from actual data
const firstValidBeat = this.shortBeatList.at(this.numPadding);
const minutes = dayjs().diff(dayjs.utc(firstValidBeat?.time), "minutes");
- if (minutes > 60) {
- return (minutes / 60).toFixed(0) + "h";
- } else {
- return minutes + "m";
- }
+ return minutes > 60 ? Math.floor(minutes / 60) + "h" : minutes + "m";
},
/**
@@ -205,7 +232,7 @@ export default {
},
watch: {
beatList: {
- handler(val, oldVal) {
+ handler() {
this.move = true;
setTimeout(() => {
@@ -256,7 +283,23 @@ export default {
*/
resize() {
if (this.$refs.wrap) {
- this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatHoverAreaPadding * 2));
+ const newMaxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatHoverAreaPadding * 2));
+
+ // If maxBeat changed and we're in configured days mode, notify parent to reload data
+ if (newMaxBeat !== this.maxBeat && this.heartbeatBarDays > 0) {
+ this.maxBeat = newMaxBeat;
+
+ // Find the closest parent with reloadHeartbeatData method (StatusPage)
+ let parent = this.$parent;
+ while (parent && !parent.reloadHeartbeatData) {
+ parent = parent.$parent;
+ }
+ if (parent && parent.reloadHeartbeatData) {
+ parent.reloadHeartbeatData(newMaxBeat);
+ }
+ } else {
+ this.maxBeat = newMaxBeat;
+ }
}
},
@@ -267,7 +310,12 @@ export default {
* @returns {string} Beat title
*/
getBeatTitle(beat) {
- return `${this.$root.datetime(beat.time)}` + ((beat.msg) ? ` - ${beat.msg}` : "");
+ if (!beat) {
+ return "";
+ }
+
+ // Show timestamp for all beats (both individual and aggregated)
+ return `${this.$root.datetime(beat.time)}${beat.msg ? ` - ${beat.msg}` : ""}`;
},
},
diff --git a/src/components/PublicGroupList.vue b/src/components/PublicGroupList.vue
index cb97ecdcd..c0af949a0 100644
--- a/src/components/PublicGroupList.vue
+++ b/src/components/PublicGroupList.vue
@@ -38,7 +38,7 @@
-
+
-
+
@@ -114,6 +114,11 @@ export default {
/** Should expiry be shown? */
showCertificateExpiry: {
type: Boolean,
+ },
+ /** Heartbeat bar days */
+ heartbeatBarDays: {
+ type: [ Number, String ],
+ default: 0
}
},
data() {
@@ -124,6 +129,19 @@ export default {
computed: {
showGroupDrag() {
return (this.$root.publicGroupList.length >= 2);
+ },
+ /**
+ * Get the uptime type based on heartbeatBarDays
+ * Returns the exact type for dynamic uptime calculation
+ * @returns {string} The uptime type
+ */
+ uptimeType() {
+ const days = Number(this.heartbeatBarDays);
+ if (days === 0 || days === 1) {
+ return "24"; // 24 hours (for compatibility)
+ } else {
+ return `${days}d`; // Dynamic days format (e.g., "7d", "14d", "30d")
+ }
}
},
created() {
diff --git a/src/components/Uptime.vue b/src/components/Uptime.vue
index 64bbd4e51..4caccb5d1 100644
--- a/src/components/Uptime.vue
+++ b/src/components/Uptime.vue
@@ -30,10 +30,8 @@ export default {
return this.$t("statusMaintenance");
}
- let key = this.monitor.id + "_" + this.type;
-
- if (this.$root.uptimeList[key] !== undefined) {
- let result = Math.round(this.$root.uptimeList[key] * 10000) / 100;
+ if (this.$root.uptimeList[this.monitor.id] !== undefined) {
+ let result = Math.round(this.$root.uptimeList[this.monitor.id] * 10000) / 100;
// Only perform sanity check on status page. See louislam/uptime-kuma#2628
if (this.$route.path.startsWith("/status") && result > 100) {
return "100%";
@@ -90,6 +88,14 @@ export default {
if (this.type === "720") {
return `30${this.$t("-day")}`;
}
+ if (this.type === "24") {
+ return `24${this.$t("-hour")}`;
+ }
+ // Handle dynamic day formats (e.g., "7d", "14d", "30d")
+ const dayMatch = this.type.match(/^(\d+)d$/);
+ if (dayMatch) {
+ return `${dayMatch[1]}${this.$t("-day")}`;
+ }
return `24${this.$t("-hour")}`;
}
},
diff --git a/src/lang/en.json b/src/lang/en.json
index b6449371b..42cc1ba91 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -375,6 +375,9 @@
"Footer Text": "Footer Text",
"Refresh Interval": "Refresh Interval",
"Refresh Interval Description": "The status page will do a full site refresh every {0} seconds",
+ "Heartbeat Bar Days": "Heartbeat Bar Days",
+ "Status page shows heartbeat history days": "Status page shows {0} days of heartbeats",
+ "Status page will show last beats": "Status page shows the last {0} heartbeats",
"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 0c9e97c2d..3aed47414 100644
--- a/src/pages/StatusPage.vue
+++ b/src/pages/StatusPage.vue
@@ -42,6 +42,17 @@
+
+
+
+
+ {{ $t("Status page will show last beats", [100]) }}
+
+
+ {{ $t("Status page shows heartbeat history days", [config.heartbeatBarDays]) }}
+
+
+
-
+