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) {
return knex.schema
.alterTable("status_page", function (table) {
table.integer("heartbeat_bar_range_days").defaultTo(90).unsigned();
});
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_days");
table.dropColumn("heartbeat_bar_range");
});
};

View file

@ -409,7 +409,7 @@ class StatusPage extends BeanModel {
showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id,
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,
googleAnalyticsId: this.google_analytics_tag_id,
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
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();
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) {
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
WHERE monitor_id = ? AND time >= ?
ORDER BY time DESC
`, [
monitorID,
dateFrom.toISOString(),
]);
`, [
monitorID,
dateFrom.toISOString(),
]);
}
list = R.convertToBeans("heartbeat", list);
heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());

View file

@ -165,7 +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_days = config.heartbeatBarRangeDays || 90;
statusPage.heartbeat_bar_range = config.heartbeatBarRange || "auto";
statusPage.modified_date = R.isoDateTime();
statusPage.google_analytics_tag_id = config.googleAnalyticsId;

View file

@ -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) }"
: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>
@ -46,6 +46,11 @@ export default {
heartbeatList: {
type: Array,
default: null,
},
/** Heartbeat bar range */
heartbeatBarRange: {
type: String,
default: "auto",
}
},
data() {
@ -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;
@ -117,6 +128,101 @@ 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);
@ -162,6 +268,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",
};
@ -172,6 +286,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) {
@ -267,6 +397,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}` : "");
},

View file

@ -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>
@ -114,6 +114,11 @@ export default {
/** Should expiry be shown? */
showCertificateExpiry: {
type: Boolean,
},
/** Heartbeat bar range */
heartbeatBarRange: {
type: String,
default: "auto",
}
},
data() {

View file

@ -376,13 +376,16 @@
"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 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",
"Domain Names": "Domain Names",
"signedInDisp": "Signed in as {0}",

View file

@ -44,16 +44,20 @@
<div class="my-3">
<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">
<option value="7">{{ $t("7 days") }}</option>
<option value="30">{{ $t("30 days") }}</option>
<option value="60">{{ $t("60 days") }}</option>
<option value="90">{{ $t("90 days") }}</option>
<option value="180">{{ $t("180 days") }}</option>
<option value="365">{{ $t("365 days") }}</option>
<select id="heartbeat-bar-range" v-model="config.heartbeatBarRange" class="form-select" data-testid="heartbeat-bar-range-select">
<option value="auto">{{ $t("Auto") }}</option>
<option value="6h">{{ $t("6 hours") }}</option>
<option value="12h">{{ $t("12 hours") }}</option>
<option value="24h">{{ $t("24 hours") }}</option>
<option value="7d">{{ $t("7 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>
<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>
@ -343,7 +347,7 @@
👀 {{ $t("statusPageNothing") }}
</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>
<footer class="mt-5 mb-4">
@ -715,24 +719,27 @@ export default {
this.slug = "default";
}
this.getData().then((res) => {
this.config = res.data.config;
Promise.all([
this.getData(),
this.editMode ? Promise.resolve() : this.loadHeartbeatData()
]).then(([configRes]) => {
this.config = configRes.data.config;
if (!this.config.domainNameList) {
this.config.domainNameList = [];
}
if (!this.config.heartbeatBarRangeDays) {
this.config.heartbeatBarRangeDays = 90;
if (!this.config.heartbeatBarRange) {
this.config.heartbeatBarRange = "auto";
}
if (this.config.icon) {
this.imgDataUrl = this.config.icon;
}
this.incident = res.data.incident;
this.maintenanceList = res.data.maintenanceList;
this.$root.publicGroupList = res.data.publicGroupList;
this.incident = configRes.data.incident;
this.maintenanceList = configRes.data.maintenanceList;
this.$root.publicGroupList = configRes.data.publicGroupList;
this.loading = false;
@ -749,8 +756,6 @@ export default {
console.log(error);
});
this.updateHeartbeatList();
// Go to edit page if ?edit present
// null means ?edit present, but no value
if (this.$route.query.edit || this.$route.query.edit === null) {
@ -783,6 +788,37 @@ export default {
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
* @returns {void}
@ -790,30 +826,7 @@ export default {
updateHeartbeatList() {
// If editMode, it will use the data from websocket.
if (! this.editMode) {
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();
});
this.loadHeartbeatData();
}
},