added heartbeat range

This commit is contained in:
Doruk 2025-06-14 02:16:31 +02:00
parent ad713eda4b
commit 493a5fdf69
8 changed files with 253 additions and 63 deletions

View file

@ -1,12 +1,11 @@
exports.up = function (knex) { exports.up = function (knex) {
return knex.schema return knex.schema.alterTable("status_page", function (table) {
.alterTable("status_page", function (table) { table.string("heartbeat_bar_range").defaultTo("auto");
table.integer("heartbeat_bar_range_days").defaultTo(90).unsigned(); });
});
}; };
exports.down = function (knex) { exports.down = function (knex) {
return knex.schema.alterTable("status_page", function (table) { return knex.schema.alterTable("status_page", function (table) {
table.dropColumn("heartbeat_bar_range_days"); table.dropColumn("heartbeat_bar_range");
}); });
}; };

View file

@ -409,7 +409,7 @@ class StatusPage extends BeanModel {
showPoweredBy: !!this.show_powered_by, showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id, googleAnalyticsId: this.google_analytics_tag_id,
showCertificateExpiry: !!this.show_certificate_expiry, 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, showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id, googleAnalyticsId: this.google_analytics_tag_id,
showCertificateExpiry: !!this.show_certificate_expiry, showCertificateExpiry: !!this.show_certificate_expiry,
heartbeatBarRangeDays: this.heartbeat_bar_range_days || 90, heartbeatBarRange: this.heartbeat_bar_range || "auto",
}; };
} }

View file

@ -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 // Get the status page to determine the heartbeat range
let statusPage = await R.findOne("status_page", " id = ? ", [ statusPageID ]); 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(); 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) { 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 SELECT * FROM heartbeat
WHERE monitor_id = ? AND time >= ? WHERE monitor_id = ? AND time >= ?
ORDER BY time DESC ORDER BY time DESC
`, [ `, [
monitorID, monitorID,
dateFrom.toISOString(), dateFrom.toISOString(),
]); ]);
}
list = R.convertToBeans("heartbeat", list); list = R.convertToBeans("heartbeat", list);
heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON()); heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());

View file

@ -165,7 +165,7 @@ module.exports.statusPageSocketHandler = (socket) => {
statusPage.custom_css = config.customCSS; statusPage.custom_css = config.customCSS;
statusPage.show_powered_by = config.showPoweredBy; statusPage.show_powered_by = config.showPoweredBy;
statusPage.show_certificate_expiry = config.showCertificateExpiry; 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.modified_date = R.isoDateTime();
statusPage.google_analytics_tag_id = config.googleAnalyticsId; statusPage.google_analytics_tag_id = config.googleAnalyticsId;

View file

@ -5,19 +5,19 @@
v-for="(beat, index) in shortBeatList" v-for="(beat, index) in shortBeatList"
:key="index" :key="index"
class="beat-hover-area" class="beat-hover-area"
:class="{ 'empty': (beat === 0) }" :class="{ 'empty': (beat === 0 || beat === null || beat.status === null) }"
:style="beatHoverAreaStyle" :style="beatHoverAreaStyle"
:title="getBeatTitle(beat)" :title="getBeatTitle(beat)"
> >
<div <div
class="beat" 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) }"
:style="beatStyle" :style="beatStyle"
/> />
</div> </div>
</div> </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" class="d-flex justify-content-between align-items-center word" :style="timeStyle"
> >
<div>{{ timeSinceFirstBeat }}</div> <div>{{ timeSinceFirstBeat }}</div>
@ -46,6 +46,11 @@ export default {
heartbeatList: { heartbeatList: {
type: Array, type: Array,
default: null, default: null,
},
/** Heartbeat bar range */
heartbeatBarRange: {
type: String,
default: "auto",
} }
}, },
data() { data() {
@ -98,6 +103,12 @@ export default {
return []; 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 placeholders = [];
let start = this.beatList.length - this.maxBeat; let start = this.beatList.length - this.maxBeat;
@ -117,6 +128,101 @@ export default {
return placeholders.concat(this.beatList.slice(start)); 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() { wrapStyle() {
let topBottom = (((this.beatHeight * this.hoverScale) - this.beatHeight) / 2); let topBottom = (((this.beatHeight * this.hoverScale) - this.beatHeight) / 2);
let leftRight = (((this.beatWidth * this.hoverScale) - this.beatWidth) / 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. * @returns {object} The style object containing the CSS properties for positioning the time element.
*/ */
timeStyle() { 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 { return {
"margin-left": this.numPadding * (this.beatWidth + this.beatHoverAreaPadding * 2) + "px", "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. * @returns {string} The time elapsed in minutes or hours.
*/ */
timeSinceFirstBeat() { 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 firstValidBeat = this.shortBeatList.at(this.numPadding);
const minutes = dayjs().diff(dayjs.utc(firstValidBeat?.time), "minutes"); const minutes = dayjs().diff(dayjs.utc(firstValidBeat?.time), "minutes");
if (minutes > 60) { if (minutes > 60) {
@ -267,6 +397,18 @@ export default {
* @returns {string} Beat title * @returns {string} Beat title
*/ */
getBeatTitle(beat) { 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}` : ""); return `${this.$root.datetime(beat.time)}` + ((beat.msg) ? ` - ${beat.msg}` : "");
}, },

View file

@ -73,7 +73,7 @@
</div> </div>
</div> </div>
<div :key="$root.userHeartbeatBar" class="col-6"> <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> </div>
</div> </div>
@ -114,6 +114,11 @@ export default {
/** Should expiry be shown? */ /** Should expiry be shown? */
showCertificateExpiry: { showCertificateExpiry: {
type: Boolean, type: Boolean,
},
/** Heartbeat bar range */
heartbeatBarRange: {
type: String,
default: "auto",
} }
}, },
data() { data() {

View file

@ -376,13 +376,16 @@
"Refresh Interval": "Refresh Interval", "Refresh Interval": "Refresh Interval",
"Refresh Interval Description": "The status page will do a full site refresh every {0} seconds", "Refresh Interval Description": "The status page will do a full site refresh every {0} seconds",
"Heartbeat Bar Range": "Heartbeat Bar Range", "Heartbeat Bar Range": "Heartbeat Bar Range",
"6 hours": "6 hours",
"12 hours": "12 hours",
"24 hours": "24 hours",
"7 days": "7 days", "7 days": "7 days",
"30 days": "30 days", "30 days": "30 days",
"60 days": "60 days", "60 days": "60 days",
"90 days": "90 days", "90 days": "90 days",
"180 days": "180 days", "180 days": "180 days",
"365 days": "365 days", "365 days": "365 days",
"How many days of heartbeat history to show in the status page": "How many days of heartbeat history to show in the status page", "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", "Show Powered By": "Show Powered By",
"Domain Names": "Domain Names", "Domain Names": "Domain Names",
"signedInDisp": "Signed in as {0}", "signedInDisp": "Signed in as {0}",

View file

@ -44,16 +44,20 @@
<div class="my-3"> <div class="my-3">
<label for="heartbeat-bar-range" class="form-label">{{ $t("Heartbeat Bar Range") }}</label> <label for="heartbeat-bar-range" class="form-label">{{ $t("Heartbeat Bar Range") }}</label>
<select id="heartbeat-bar-range" v-model="config.heartbeatBarRangeDays" class="form-select" data-testid="heartbeat-bar-range-select"> <select id="heartbeat-bar-range" v-model="config.heartbeatBarRange" class="form-select" data-testid="heartbeat-bar-range-select">
<option value="7">{{ $t("7 days") }}</option> <option value="auto">{{ $t("Auto") }}</option>
<option value="30">{{ $t("30 days") }}</option> <option value="6h">{{ $t("6 hours") }}</option>
<option value="60">{{ $t("60 days") }}</option> <option value="12h">{{ $t("12 hours") }}</option>
<option value="90">{{ $t("90 days") }}</option> <option value="24h">{{ $t("24 hours") }}</option>
<option value="180">{{ $t("180 days") }}</option> <option value="7d">{{ $t("7 days") }}</option>
<option value="365">{{ $t("365 days") }}</option> <option value="30d">{{ $t("30 days") }}</option>
<option value="60d">{{ $t("60 days") }}</option>
<option value="90d">{{ $t("90 days") }}</option>
<option value="180d">{{ $t("180 days") }}</option>
<option value="365d">{{ $t("365 days") }}</option>
</select> </select>
<div class="form-text"> <div class="form-text">
{{ $t("How many days of heartbeat history to show in the status page") }} {{ $t("How much heartbeat history to show in the status page") }}
</div> </div>
</div> </div>
@ -343,7 +347,7 @@
👀 {{ $t("statusPageNothing") }} 👀 {{ $t("statusPageNothing") }}
</div> </div>
<PublicGroupList :edit-mode="enableEditMode" :show-tags="config.showTags" :show-certificate-expiry="config.showCertificateExpiry" /> <PublicGroupList :edit-mode="enableEditMode" :show-tags="config.showTags" :show-certificate-expiry="config.showCertificateExpiry" :heartbeat-bar-range="config.heartbeatBarRange" />
</div> </div>
<footer class="mt-5 mb-4"> <footer class="mt-5 mb-4">
@ -715,24 +719,27 @@ export default {
this.slug = "default"; this.slug = "default";
} }
this.getData().then((res) => { Promise.all([
this.config = res.data.config; this.getData(),
this.editMode ? Promise.resolve() : this.loadHeartbeatData()
]).then(([configRes]) => {
this.config = configRes.data.config;
if (!this.config.domainNameList) { if (!this.config.domainNameList) {
this.config.domainNameList = []; this.config.domainNameList = [];
} }
if (!this.config.heartbeatBarRangeDays) { if (!this.config.heartbeatBarRange) {
this.config.heartbeatBarRangeDays = 90; this.config.heartbeatBarRange = "auto";
} }
if (this.config.icon) { if (this.config.icon) {
this.imgDataUrl = this.config.icon; this.imgDataUrl = this.config.icon;
} }
this.incident = res.data.incident; this.incident = configRes.data.incident;
this.maintenanceList = res.data.maintenanceList; this.maintenanceList = configRes.data.maintenanceList;
this.$root.publicGroupList = res.data.publicGroupList; this.$root.publicGroupList = configRes.data.publicGroupList;
this.loading = false; this.loading = false;
@ -749,8 +756,6 @@ export default {
console.log(error); console.log(error);
}); });
this.updateHeartbeatList();
// Go to edit page if ?edit present // Go to edit page if ?edit present
// null means ?edit present, but no value // null means ?edit present, but no value
if (this.$route.query.edit || this.$route.query.edit === null) { if (this.$route.query.edit || this.$route.query.edit === null) {
@ -783,6 +788,37 @@ export default {
return highlight(code, languages.css); return highlight(code, languages.css);
}, },
/**
* Load heartbeat data from API
* @returns {Promise} Promise that resolves when data is loaded
*/
loadHeartbeatData() {
return axios.get("/api/status-page/heartbeat/" + this.slug).then((res) => {
const { heartbeatList, uptimeList } = res.data;
this.$root.heartbeatList = heartbeatList;
this.$root.uptimeList = uptimeList;
const heartbeatIds = Object.keys(heartbeatList);
const downMonitors = heartbeatIds.reduce((downMonitorsAmount, currentId) => {
const monitorHeartbeats = heartbeatList[currentId];
const lastHeartbeat = monitorHeartbeats.at(-1);
if (lastHeartbeat) {
return lastHeartbeat.status === 0 ? downMonitorsAmount + 1 : downMonitorsAmount;
} else {
return downMonitorsAmount;
}
}, 0);
favicon.badge(downMonitors);
this.loadedData = true;
this.lastUpdateTime = dayjs();
this.updateUpdateTimer();
});
},
/** /**
* Update the heartbeat list and update favicon if necessary * Update the heartbeat list and update favicon if necessary
* @returns {void} * @returns {void}
@ -790,30 +826,7 @@ export default {
updateHeartbeatList() { updateHeartbeatList() {
// If editMode, it will use the data from websocket. // If editMode, it will use the data from websocket.
if (! this.editMode) { if (! this.editMode) {
axios.get("/api/status-page/heartbeat/" + this.slug).then((res) => { this.loadHeartbeatData();
const { heartbeatList, uptimeList } = res.data;
this.$root.heartbeatList = heartbeatList;
this.$root.uptimeList = uptimeList;
const heartbeatIds = Object.keys(heartbeatList);
const downMonitors = heartbeatIds.reduce((downMonitorsAmount, currentId) => {
const monitorHeartbeats = heartbeatList[currentId];
const lastHeartbeat = monitorHeartbeats.at(-1);
if (lastHeartbeat) {
return lastHeartbeat.status === 0 ? downMonitorsAmount + 1 : downMonitorsAmount;
} else {
return downMonitorsAmount;
}
}, 0);
favicon.badge(downMonitors);
this.loadedData = true;
this.lastUpdateTime = dayjs();
this.updateUpdateTimer();
});
} }
}, },