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..5a85a1a39 --- /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.integer("heartbeat_bar_days").notNullable().defaultTo(0); + }); +}; + +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..3a7e359aa 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.heartbeat_bar_days; + + // 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; + let uptime; + + 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 + 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..8b55bee87 100644 --- a/server/uptime-calculator.js +++ b/server/uptime-calculator.js @@ -845,6 +845,95 @@ 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 + // Since data is sorted, we can optimize by tracking current bucket index + let currentBucketIndex = 0; + + for (const [ timestamp, dataPoint ] of Object.entries(availableData)) { + const timestampNum = parseInt(timestamp); + + // Move to the correct bucket (since data is sorted, we only need to move forward) + while (currentBucketIndex < buckets.length && + timestampNum >= buckets[currentBucketIndex].end) { + currentBucketIndex++; + } + + // Check if we're within a valid bucket + if (currentBucketIndex < buckets.length) { + const bucket = buckets[currentBucketIndex]; + + if (timestampNum >= bucket.start && timestampNum < bucket.end) { + bucket.up += dataPoint.up || 0; + bucket.down += dataPoint.down || 0; + + if (days > 30) { + // Daily data includes maintenance and pending + bucket.maintenance += dataPoint.maintenance || 0; + bucket.pending += dataPoint.pending || 0; + } else { + // Minute/hourly data doesn't track maintenance/pending separately + bucket.maintenance += 0; + bucket.pending += 0; + } + } + } + } + + return buckets; + } } class UptimeDataResult { diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index 429ca9f91..f3e76dea5 100644 --- a/src/components/HeartbeatBar.vue +++ b/src/components/HeartbeatBar.vue @@ -46,6 +46,11 @@ export default { heartbeatList: { type: Array, default: null, + }, + /** Heartbeat bar days */ + heartbeatBarDays: { + type: Number, + default: 0 } }, data() { @@ -60,6 +65,14 @@ export default { }, computed: { + /** + * Normalized heartbeatBarDays as a number + * @returns {number} Number of days for heartbeat bar + */ + normalizedHeartbeatBarDays() { + return Math.max(0, Math.min(365, Math.floor(this.heartbeatBarDays || 0))); + }, + /** * If heartbeatList is null, get it from $root.heartbeatList * @returns {object} Heartbeat list @@ -80,6 +93,12 @@ export default { if (!this.beatList) { return 0; } + + // For configured ranges, no padding needed since we show all beats + if (this.normalizedHeartbeatBarDays > 0) { + return 0; + } + let num = this.beatList.length - this.maxBeat; if (this.move) { @@ -98,8 +117,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 +203,17 @@ export default { * @returns {string} The time elapsed in minutes or hours. */ timeSinceFirstBeat() { + // For configured days mode, show the configured range + if (this.normalizedHeartbeatBarDays > 0) { + return this.normalizedHeartbeatBarDays < 2 ? + (this.normalizedHeartbeatBarDays * 24) + "h" : + this.normalizedHeartbeatBarDays + "d"; + } + + // For auto mode, 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 +240,7 @@ export default { }, watch: { beatList: { - handler(val, oldVal) { + handler() { this.move = true; setTimeout(() => { @@ -256,7 +291,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.normalizedHeartbeatBarDays > 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 +318,12 @@ export default { * @returns {string} Beat title */ getBeatTitle(beat) { - return `${this.$root.datetime(beat.time)}` + ((beat.msg) ? ` - ${beat.msg}` : ""); + if (beat === 0) { + 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..a5f30e649 100644 --- a/src/components/Uptime.vue +++ b/src/components/Uptime.vue @@ -30,7 +30,7 @@ export default { return this.$t("statusMaintenance"); } - let key = this.monitor.id + "_" + this.type; + let key = this.monitor.id; if (this.$root.uptimeList[key] !== undefined) { let result = Math.round(this.$root.uptimeList[key] * 10000) / 100; @@ -90,6 +90,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 a979edcc2..552f454f0 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -375,6 +375,8 @@ "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", + "Number of days of heartbeat history to show (0 = auto)": "Number of days of heartbeat history to show (0 = auto)", "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 e0df74fde..db738e634 100644 --- a/src/pages/StatusPage.vue +++ b/src/pages/StatusPage.vue @@ -42,6 +42,14 @@ +
+ + +
+ {{ $t("Number of days of heartbeat history to show (0 = auto)") }} +
+
+