mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-06-19 18:56:48 +02:00
added heartbeat range
This commit is contained in:
parent
ad713eda4b
commit
493a5fdf69
8 changed files with 253 additions and 63 deletions
|
@ -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");
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -86,14 +86,41 @@ 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
|
||||
|
@ -101,6 +128,7 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques
|
|||
monitorID,
|
||||
dateFrom.toISOString(),
|
||||
]);
|
||||
}
|
||||
|
||||
list = R.convertToBeans("heartbeat", list);
|
||||
heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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}` : "");
|
||||
},
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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}",
|
||||
|
|
|
@ -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) {
|
||||
|
@ -784,13 +789,11 @@ export default {
|
|||
},
|
||||
|
||||
/**
|
||||
* Update the heartbeat list and update favicon if necessary
|
||||
* @returns {void}
|
||||
* Load heartbeat data from API
|
||||
* @returns {Promise} Promise that resolves when data is loaded
|
||||
*/
|
||||
updateHeartbeatList() {
|
||||
// If editMode, it will use the data from websocket.
|
||||
if (! this.editMode) {
|
||||
axios.get("/api/status-page/heartbeat/" + this.slug).then((res) => {
|
||||
loadHeartbeatData() {
|
||||
return axios.get("/api/status-page/heartbeat/" + this.slug).then((res) => {
|
||||
const { heartbeatList, uptimeList } = res.data;
|
||||
|
||||
this.$root.heartbeatList = heartbeatList;
|
||||
|
@ -814,6 +817,16 @@ export default {
|
|||
this.lastUpdateTime = dayjs();
|
||||
this.updateUpdateTimer();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the heartbeat list and update favicon if necessary
|
||||
* @returns {void}
|
||||
*/
|
||||
updateHeartbeatList() {
|
||||
// If editMode, it will use the data from websocket.
|
||||
if (! this.editMode) {
|
||||
this.loadHeartbeatData();
|
||||
}
|
||||
},
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue