Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 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.string("heartbeat_bar_range").defaultTo("auto");
});
};

exports.down = function (knex) {
return knex.schema.alterTable("status_page", function (table) {
table.dropColumn("heartbeat_bar_range");
});
};
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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,
heartbeatBarRange: this.heartbeat_bar_range || "auto",
};
}

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,
heartbeatBarRange: this.heartbeat_bar_range || "auto",
};
}

Expand Down
44 changes: 40 additions & 4 deletions server/routers/status-page-router.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,51 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques
statusPageID
]);

// Get the status page to determine the heartbeat range
let statusPage = await R.findOne("status_page", " id = ? ", [ statusPageID ]);
let heartbeatRange = (statusPage && statusPage.heartbeat_bar_range) ? statusPage.heartbeat_bar_range : "auto";

// Calculate the date range for heartbeats based on range setting
let dateFrom = new Date();
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,
]);
`, [
monitorID,
]);
} else {
// Time-based filtering
list = await R.getAll(`
SELECT * FROM heartbeat
WHERE monitor_id = ? AND time >= ?
ORDER BY time DESC
`, [
monitorID,
dateFrom.toISOString(),
]);
}

list = R.convertToBeans("heartbeat", list);
heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());
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_range = config.heartbeatBarRange || "auto";
statusPage.modified_date = R.isoDateTime();
statusPage.google_analytics_tag_id = config.googleAnalyticsId;

Expand Down
147 changes: 144 additions & 3 deletions src/components/HeartbeatBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
>
<div
class="beat"
:class="{ 'empty': (beat === 0), 'down': (beat.status === 0), 'pending': (beat.status === 2), 'maintenance': (beat.status === 3) }"
:class="{ 'empty': (beat === 0 || beat === null || beat.status === null), 'down': (beat.status === 0), 'pending': (beat.status === 2), 'maintenance': (beat.status === 3) }"
Copy link
Collaborator

Choose a reason for hiding this comment

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

I am a bit confused regarding #5916 (comment)

Coudld you claify this one? Are there multiple empty states, or not πŸ˜…

:style="beatStyle"
/>
</div>
</div>
<div
v-if="!$root.isMobile && size !== 'small' && beatList.length > 4 && $root.styleElapsedTime !== 'none'"
v-if="!$root.isMobile && size !== 'small' && shortBeatList.length > 4 && $root.styleElapsedTime !== 'none'"
class="d-flex justify-content-between align-items-center word" :style="timeStyle"
>
<div>{{ timeSinceFirstBeat }}</div>
Expand Down Expand Up @@ -46,6 +46,11 @@ export default {
heartbeatList: {
type: Array,
default: null,
},
/** Heartbeat bar range */
heartbeatBarRange: {
type: String,
default: "auto",
}
},
data() {
Expand Down Expand Up @@ -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;
Expand All @@ -117,6 +128,100 @@ 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);
Expand Down Expand Up @@ -162,6 +267,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",
};
Expand All @@ -172,6 +285,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) {
Expand Down Expand Up @@ -267,6 +396,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}` : "");
},

Expand Down
7 changes: 6 additions & 1 deletion src/components/PublicGroupList.vue
Original file line number Diff line number Diff line change
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-range="heartbeatBarRange" />
</div>
</div>
</div>
Expand Down Expand Up @@ -114,6 +114,11 @@ export default {
/** Should expiry be shown? */
showCertificateExpiry: {
type: Boolean,
},
/** Heartbeat bar range */
heartbeatBarRange: {
type: String,
default: "auto",
}
},
data() {
Expand Down
11 changes: 11 additions & 0 deletions src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,17 @@
"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 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 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}",
Expand Down
Loading
Loading