Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
ad713ed
feat: Add configurable heartbeat bar range for status pages
peaktwilight Jun 13, 2025
493a5fd
added heartbeat range
peaktwilight Jun 14, 2025
e1cf10d
fetch aggregated data overengineering?
peaktwilight Jun 14, 2025
c0f09d9
simplify overengineered aggregated fetches
peaktwilight Jun 14, 2025
531faa9
fix linting
peaktwilight Jun 14, 2025
b441308
lang file fix
peaktwilight Jun 14, 2025
ebd47f1
refactor: Clean up heartbeat range handling in status page
peaktwilight Jun 14, 2025
2c6d410
modularized with a heartbeat range util
peaktwilight Jun 14, 2025
8d2e3f1
remove client-side aggregation and add server-side support for it in …
peaktwilight Jun 14, 2025
dbf58b8
try stat tables first, then add fallback if pre-aggregated data fails
peaktwilight Jun 14, 2025
f47f73d
fix timestamp format calculation according to db
peaktwilight Jun 14, 2025
e16b6cf
. Number of beats adapts to container width, just like auto mode :)
peaktwilight Jun 14, 2025
94adf2c
cleaned up debugging
peaktwilight Jun 14, 2025
b41d10e
cleaned up empty lines + linting
peaktwilight Jun 14, 2025
ace9ff2
simplified to day int instead of custom options accross all components.
peaktwilight Jun 14, 2025
0dde42e
added validate and other improvements
peaktwilight Jun 14, 2025
992bcdb
fix linting
peaktwilight Jun 14, 2025
75fc01c
simplifications
peaktwilight Jun 14, 2025
9402c0f
rebucketing
peaktwilight Jun 14, 2025
804bb39
time calculation & cleanup
peaktwilight Jun 14, 2025
bf7d6de
Remove unused client-side aggregation logic.
peaktwilight Jun 14, 2025
76b9680
bug fix, made beats bars smaller
peaktwilight Jun 14, 2025
05c9ec2
fix client-side aggregation as well for dynamic beat bar count
peaktwilight Jun 14, 2025
28eae65
change color suggestion to primary
peaktwilight Jun 14, 2025
8b2bf8a
fix 30-90 range
peaktwilight Jun 14, 2025
1f4c4a0
fix range 30-90 v2
peaktwilight Jun 14, 2025
1518fd5
address CommanderStorm's feedback + revert statuspage pr
peaktwilight Jun 14, 2025
adc362a
rethought beat aggregation system fully server-side
peaktwilight Jun 14, 2025
c023015
cleanup
peaktwilight Jun 14, 2025
6defb5d
refactor status page router into uptime calculator
peaktwilight Jun 14, 2025
eaace3e
added aggregation tests
peaktwilight Jun 14, 2025
b6227e7
linting fix
peaktwilight Jun 14, 2025
3a5bedd
fix distribute daily data across bucket ranges
peaktwilight Jun 15, 2025
100cd62
added more edge case tests to daily data bucketeting
peaktwilight Jun 15, 2025
24b6209
lint fix
peaktwilight Jun 15, 2025
6c26d32
update uptime component with dynamic range
peaktwilight Jun 17, 2025
9cc91c3
npm install timeout?
peaktwilight Jun 17, 2025
9e07862
npm install timeout?
peaktwilight Jun 17, 2025
5e64226
Update server/routers/status-page-router.js
peaktwilight Jun 17, 2025
eb47b1f
refactor _24 suffix
peaktwilight Jun 17, 2025
88b3cfc
maxbeats simplification
peaktwilight Jun 17, 2025
91221b5
keep track of original order instead of iterating through all buckets
peaktwilight Jun 17, 2025
5c6cf48
status page router ismplification getData
peaktwilight Jun 17, 2025
29e885b
fix minor issues, falsy check & unnecessary defaults & simplify to a …
peaktwilight Jun 17, 2025
dc74bb7
lint fix
peaktwilight Jun 17, 2025
28ddaf1
review
peaktwilight Jun 19, 2025
59043fb
Update src/components/HeartbeatBar.vue
peaktwilight Jun 19, 2025
0439566
Update src/pages/StatusPage.vue
peaktwilight Jun 19, 2025
4160045
Update src/components/HeartbeatBar.vue
peaktwilight Jun 19, 2025
a15e897
updated tests and various improvements
peaktwilight Jun 19, 2025
751c92b
lint fix
peaktwilight Jun 19, 2025
fd0fe64
fix test for scale factor
peaktwilight Jun 19, 2025
0028db4
boundaries test fix
peaktwilight Jun 19, 2025
b907d62
tests clean up
peaktwilight Jun 19, 2025
8a5202b
bring back snapshot test
peaktwilight Jun 19, 2025
3cecc67
lint fix
peaktwilight Jun 19, 2025
1514cbd
test fix
peaktwilight Jun 19, 2025
d07575b
smallint
peaktwilight Jun 25, 2025
2707aa5
remove normalized heartbeat days
peaktwilight Jun 25, 2025
b121cc7
update language strings w new format
peaktwilight Jun 25, 2025
71b73e9
uptime uptime variable location & unnecessary variables
peaktwilight Jun 25, 2025
38a4b60
added comment on when it can happen
peaktwilight Jun 25, 2025
60d879f
lint fix
peaktwilight Jun 25, 2025
49e181e
removed data pt size
peaktwilight Jun 25, 2025
c780c2a
reverted bucketing as requested & adapted tests
peaktwilight Jun 25, 2025
0d2c129
fix zoom out 1080p dissapear
peaktwilight Jun 25, 2025
c1b2f35
Merge remote-tracking branch 'upstream/master'
peaktwilight Oct 3, 2025
8589c63
fix: Ensure heartbeatBarDays defaults to 0 in StatusPage configuration
peaktwilight Oct 3, 2025
8d7e213
Simplified with slicing, because resizing and reloading data from the…
peaktwilight Oct 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions db/knex_migrations/2025-06-14-0000-heartbeat-range-config.js
Original file line number Diff line number Diff line change
@@ -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");
});
};
2 changes: 2 additions & 0 deletions server/model/status_page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand All @@ -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,
};
}

Expand Down
73 changes: 64 additions & 9 deletions server/routers/status-page-router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions server/socket-handlers/status-page-socket-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
87 changes: 87 additions & 0 deletions server/uptime-calculator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Comment on lines +905 to +909
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there was a misunderstanding here.
When I said, that we should likely use indexing instead of find for better algorithmic complexity, I wanted to avoid iterating over all buckets for every data point.

We don't actually need to do this, or am I missing something?
just set i=0 and increment if the next bucket is nessesary.

As far as I remember rawDataPoints, should be sorted in ascending or decending order, right?
This also inserting into availableData unnessesary

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optimised and nested loop replaced with a single pass alg that uses currentBucketIndex to track position and only moves forward since the data is sorted. The alg complexity O(n*m) -> O(n+m).

The availableData lookup map is still used because it's needed to efficiently access data points by timestamp from the rawDataPoints array.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The availableData lookup map is still used because it's needed to efficiently access data points by timestamp from the rawDataPoints array.

I don't think this is quite correct.
We never access data points by timestamp, we just iterate over them.

Something that we could also do with rawDataPoints
Am I missing something?


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 {
Expand Down
30 changes: 5 additions & 25 deletions src/components/HeartbeatBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -144,20 +144,15 @@ 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;
}

// For both configured days and auto mode, show only what fits on screen
// The server provides consistent data (300 buckets for configured days, 100 beats for auto)
// We slice to show only what fits in the current container width
let placeholders = [];
let start = this.beatList.length - this.maxBeat;

if (this.move) {
Expand Down Expand Up @@ -323,22 +318,7 @@ export default {
resize() {
if (this.$refs.wrap) {
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;
}
this.maxBeat = newMaxBeat;
}
},

Expand Down
22 changes: 20 additions & 2 deletions src/components/PublicGroupList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
<font-awesome-icon v-if="editMode" icon="arrows-alt-v" class="action drag me-3" />
<font-awesome-icon v-if="editMode" icon="times" class="action remove me-3" @click="removeMonitor(group.index, monitor.index)" />

<Uptime :monitor="monitor.element" type="24" :pill="true" />
<Uptime :monitor="monitor.element" :type="uptimeType" :pill="true" />
<a
v-if="showLink(monitor)"
:href="monitor.element.url"
Expand Down Expand Up @@ -73,7 +73,7 @@
</div>
</div>
<div :key="$root.userHeartbeatBar" class="col-6">
<HeartbeatBar size="mid" :monitor-id="monitor.element.id" />
<HeartbeatBar size="mid" :monitor-id="monitor.element.id" :heartbeat-bar-days="heartbeatBarDays" />
</div>
</div>
</div>
Expand Down Expand Up @@ -114,6 +114,11 @@ export default {
/** Should expiry be shown? */
showCertificateExpiry: {
type: Boolean,
},
/** Heartbeat bar days */
heartbeatBarDays: {
type: [ Number, String ],
default: 0
}
},
data() {
Expand All @@ -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() {
Expand Down
14 changes: 10 additions & 4 deletions src/components/Uptime.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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%";
Expand Down Expand Up @@ -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")}`;
}
},
Expand Down
3 changes: 3 additions & 0 deletions src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,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}",
Expand Down
Loading
Loading