mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-06-19 10:46:48 +02:00
Merge dc74bb7e95
into 443d5cf554
This commit is contained in:
commit
300c51fa3b
11 changed files with 763 additions and 52 deletions
11
db/knex_migrations/2025-06-14-0000-heartbeat-range-config.js
Normal file
11
db/knex_migrations/2025-06-14-0000-heartbeat-range-config.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema.alterTable("status_page", function (table) {
|
||||||
|
table.integer("heartbeat_bar_days").notNullable().defaultTo(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema.alterTable("status_page", function (table) {
|
||||||
|
table.dropColumn("heartbeat_bar_days");
|
||||||
|
});
|
||||||
|
};
|
|
@ -409,6 +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,
|
||||||
|
heartbeatBarDays: this.heartbeat_bar_days,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -432,6 +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,
|
||||||
|
heartbeatBarDays: this.heartbeat_bar_days || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,9 +4,10 @@ const { UptimeKumaServer } = require("../uptime-kuma-server");
|
||||||
const StatusPage = require("../model/status_page");
|
const StatusPage = require("../model/status_page");
|
||||||
const { allowDevAllOrigin, sendHttpError } = require("../util-server");
|
const { allowDevAllOrigin, sendHttpError } = require("../util-server");
|
||||||
const { R } = require("redbean-node");
|
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 { makeBadge } = require("badge-maker");
|
||||||
const { UptimeCalculator } = require("../uptime-calculator");
|
const { UptimeCalculator } = require("../uptime-calculator");
|
||||||
|
const dayjs = require("dayjs");
|
||||||
|
|
||||||
let router = express.Router();
|
let router = express.Router();
|
||||||
|
|
||||||
|
@ -84,21 +85,75 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques
|
||||||
statusPageID
|
statusPageID
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (let monitorID of monitorIDList) {
|
// Get the status page to determine the heartbeat range
|
||||||
let list = await R.getAll(`
|
let statusPage = await R.findOne("status_page", " id = ? ", [ statusPageID ]);
|
||||||
|
let heartbeatBarDays = statusPage.heartbeat_bar_days;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
let uptime;
|
||||||
|
|
||||||
|
if (heartbeatBarDays === 0) {
|
||||||
|
// Auto mode - use original LIMIT 100 logic
|
||||||
|
let list = await R.getAll(`
|
||||||
SELECT * FROM heartbeat
|
SELECT * FROM heartbeat
|
||||||
WHERE monitor_id = ?
|
WHERE monitor_id = ?
|
||||||
ORDER BY time DESC
|
ORDER BY time DESC
|
||||||
LIMIT 100
|
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
|
||||||
|
if (heartbeatBarDays <= 1) {
|
||||||
|
uptime = uptimeCalculator.get24Hour().uptime;
|
||||||
|
} else {
|
||||||
|
uptime = uptimeCalculator.getData(heartbeatBarDays, "day").uptime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
monitorID,
|
monitorID,
|
||||||
]);
|
heartbeats,
|
||||||
|
uptime
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
list = R.convertToBeans("heartbeat", list);
|
// Wait for all monitors to be processed
|
||||||
heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());
|
const monitorResults = await Promise.all(monitorPromises);
|
||||||
|
|
||||||
const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
|
// Populate the response objects
|
||||||
uptimeList[`${monitorID}_24`] = uptimeCalculator.get24Hour().uptime;
|
for (const result of monitorResults) {
|
||||||
|
heartbeatList[result.monitorID] = result.heartbeats;
|
||||||
|
uptimeList[result.monitorID] = result.uptime;
|
||||||
}
|
}
|
||||||
|
|
||||||
response.json({
|
response.json({
|
||||||
|
|
|
@ -165,6 +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_days = config.heartbeatBarDays;
|
||||||
statusPage.modified_date = R.isoDateTime();
|
statusPage.modified_date = R.isoDateTime();
|
||||||
statusPage.google_analytics_tag_id = config.googleAnalyticsId;
|
statusPage.google_analytics_tag_id = config.googleAnalyticsId;
|
||||||
|
|
||||||
|
|
|
@ -845,6 +845,95 @@ class UptimeCalculator {
|
||||||
setMigrationMode(value) {
|
setMigrationMode(value) {
|
||||||
this.migrationMode = 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
|
||||||
|
// Since data is sorted, we can optimize by tracking current bucket index
|
||||||
|
let currentBucketIndex = 0;
|
||||||
|
|
||||||
|
for (const [ timestamp, dataPoint ] of Object.entries(availableData)) {
|
||||||
|
const timestampNum = parseInt(timestamp);
|
||||||
|
|
||||||
|
// Move to the correct bucket (since data is sorted, we only need to move forward)
|
||||||
|
while (currentBucketIndex < buckets.length &&
|
||||||
|
timestampNum >= buckets[currentBucketIndex].end) {
|
||||||
|
currentBucketIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're within a valid bucket
|
||||||
|
if (currentBucketIndex < buckets.length) {
|
||||||
|
const bucket = buckets[currentBucketIndex];
|
||||||
|
|
||||||
|
if (timestampNum >= bucket.start && timestampNum < bucket.end) {
|
||||||
|
bucket.up += dataPoint.up || 0;
|
||||||
|
bucket.down += dataPoint.down || 0;
|
||||||
|
|
||||||
|
if (days > 30) {
|
||||||
|
// Daily data includes maintenance and pending
|
||||||
|
bucket.maintenance += dataPoint.maintenance || 0;
|
||||||
|
bucket.pending += dataPoint.pending || 0;
|
||||||
|
} else {
|
||||||
|
// Minute/hourly data doesn't track maintenance/pending separately
|
||||||
|
bucket.maintenance += 0;
|
||||||
|
bucket.pending += 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UptimeDataResult {
|
class UptimeDataResult {
|
||||||
|
|
|
@ -46,6 +46,11 @@ export default {
|
||||||
heartbeatList: {
|
heartbeatList: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: null,
|
default: null,
|
||||||
|
},
|
||||||
|
/** Heartbeat bar days */
|
||||||
|
heartbeatBarDays: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
@ -60,6 +65,14 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized heartbeatBarDays as a number
|
||||||
|
* @returns {number} Number of days for heartbeat bar
|
||||||
|
*/
|
||||||
|
normalizedHeartbeatBarDays() {
|
||||||
|
return Math.max(0, Math.min(365, Math.floor(this.heartbeatBarDays || 0)));
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If heartbeatList is null, get it from $root.heartbeatList
|
* If heartbeatList is null, get it from $root.heartbeatList
|
||||||
* @returns {object} Heartbeat list
|
* @returns {object} Heartbeat list
|
||||||
|
@ -80,6 +93,12 @@ export default {
|
||||||
if (!this.beatList) {
|
if (!this.beatList) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For configured ranges, no padding needed since we show all beats
|
||||||
|
if (this.normalizedHeartbeatBarDays > 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
let num = this.beatList.length - this.maxBeat;
|
let num = this.beatList.length - this.maxBeat;
|
||||||
|
|
||||||
if (this.move) {
|
if (this.move) {
|
||||||
|
@ -98,8 +117,20 @@ export default {
|
||||||
return [];
|
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 = [];
|
let placeholders = [];
|
||||||
|
|
||||||
|
// Handle case where maxBeat is -1 (no limit)
|
||||||
|
if (this.maxBeat <= 0) {
|
||||||
|
return this.beatList;
|
||||||
|
}
|
||||||
|
|
||||||
let start = this.beatList.length - this.maxBeat;
|
let start = this.beatList.length - this.maxBeat;
|
||||||
|
|
||||||
if (this.move) {
|
if (this.move) {
|
||||||
|
@ -172,13 +203,17 @@ export default {
|
||||||
* @returns {string} The time elapsed in minutes or hours.
|
* @returns {string} The time elapsed in minutes or hours.
|
||||||
*/
|
*/
|
||||||
timeSinceFirstBeat() {
|
timeSinceFirstBeat() {
|
||||||
|
// For configured days mode, show the configured range
|
||||||
|
if (this.normalizedHeartbeatBarDays > 0) {
|
||||||
|
return this.normalizedHeartbeatBarDays < 2 ?
|
||||||
|
(this.normalizedHeartbeatBarDays * 24) + "h" :
|
||||||
|
this.normalizedHeartbeatBarDays + "d";
|
||||||
|
}
|
||||||
|
|
||||||
|
// For auto mode, calculate from actual data
|
||||||
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) {
|
return minutes > 60 ? Math.floor(minutes / 60) + "h" : minutes + "m";
|
||||||
return (minutes / 60).toFixed(0) + "h";
|
|
||||||
} else {
|
|
||||||
return minutes + "m";
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -205,7 +240,7 @@ export default {
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
beatList: {
|
beatList: {
|
||||||
handler(val, oldVal) {
|
handler() {
|
||||||
this.move = true;
|
this.move = true;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
@ -256,7 +291,23 @@ export default {
|
||||||
*/
|
*/
|
||||||
resize() {
|
resize() {
|
||||||
if (this.$refs.wrap) {
|
if (this.$refs.wrap) {
|
||||||
this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatHoverAreaPadding * 2));
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -267,7 +318,12 @@ export default {
|
||||||
* @returns {string} Beat title
|
* @returns {string} Beat title
|
||||||
*/
|
*/
|
||||||
getBeatTitle(beat) {
|
getBeatTitle(beat) {
|
||||||
return `${this.$root.datetime(beat.time)}` + ((beat.msg) ? ` - ${beat.msg}` : "");
|
if (beat === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show timestamp for all beats (both individual and aggregated)
|
||||||
|
return `${this.$root.datetime(beat.time)}${beat.msg ? ` - ${beat.msg}` : ""}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
|
@ -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="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)" />
|
<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
|
<a
|
||||||
v-if="showLink(monitor)"
|
v-if="showLink(monitor)"
|
||||||
:href="monitor.element.url"
|
:href="monitor.element.url"
|
||||||
|
@ -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-days="heartbeatBarDays" />
|
||||||
</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 days */
|
||||||
|
heartbeatBarDays: {
|
||||||
|
type: [ Number, String ],
|
||||||
|
default: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
@ -124,6 +129,19 @@ export default {
|
||||||
computed: {
|
computed: {
|
||||||
showGroupDrag() {
|
showGroupDrag() {
|
||||||
return (this.$root.publicGroupList.length >= 2);
|
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() {
|
created() {
|
||||||
|
|
|
@ -30,7 +30,7 @@ export default {
|
||||||
return this.$t("statusMaintenance");
|
return this.$t("statusMaintenance");
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = this.monitor.id + "_" + this.type;
|
let key = this.monitor.id;
|
||||||
|
|
||||||
if (this.$root.uptimeList[key] !== undefined) {
|
if (this.$root.uptimeList[key] !== undefined) {
|
||||||
let result = Math.round(this.$root.uptimeList[key] * 10000) / 100;
|
let result = Math.round(this.$root.uptimeList[key] * 10000) / 100;
|
||||||
|
@ -90,6 +90,14 @@ export default {
|
||||||
if (this.type === "720") {
|
if (this.type === "720") {
|
||||||
return `30${this.$t("-day")}`;
|
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")}`;
|
return `24${this.$t("-hour")}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -375,6 +375,8 @@
|
||||||
"Footer Text": "Footer Text",
|
"Footer Text": "Footer Text",
|
||||||
"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 Days": "Heartbeat Bar Days",
|
||||||
|
"Number of days of heartbeat history to show (0 = auto)": "Number of days of heartbeat history to show (0 = auto)",
|
||||||
"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}",
|
||||||
|
|
|
@ -42,6 +42,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="my-3">
|
||||||
|
<label for="heartbeat-bar-days" class="form-label">{{ $t("Heartbeat Bar Days") }}</label>
|
||||||
|
<input id="heartbeat-bar-days" v-model.number="config.heartbeatBarDays" type="number" class="form-control" min="0" max="365" data-testid="heartbeat-bar-days-input">
|
||||||
|
<div class="form-text">
|
||||||
|
{{ $t("Number of days of heartbeat history to show (0 = auto)") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
<label for="switch-theme" class="form-label">{{ $t("Theme") }}</label>
|
<label for="switch-theme" class="form-label">{{ $t("Theme") }}</label>
|
||||||
<select id="switch-theme" v-model="config.theme" class="form-select" data-testid="theme-select">
|
<select id="switch-theme" v-model="config.theme" class="form-select" data-testid="theme-select">
|
||||||
|
@ -328,7 +336,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-days="config.heartbeatBarDays" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="mt-5 mb-4">
|
<footer class="mt-5 mb-4">
|
||||||
|
@ -619,6 +627,12 @@ export default {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
this.config = res.config;
|
this.config = res.config;
|
||||||
|
|
||||||
|
if (this.config.heartbeatBarDays === undefined || this.config.heartbeatBarDays === null || this.config.heartbeatBarDays === "") {
|
||||||
|
this.config.heartbeatBarDays = 0;
|
||||||
|
} else {
|
||||||
|
this.config.heartbeatBarDays = parseInt(this.config.heartbeatBarDays, 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.config.customCSS) {
|
if (!this.config.customCSS) {
|
||||||
this.config.customCSS = "body {\n" +
|
this.config.customCSS = "body {\n" +
|
||||||
" \n" +
|
" \n" +
|
||||||
|
@ -700,20 +714,29 @@ 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.heartbeatBarDays === undefined || this.config.heartbeatBarDays === null || this.config.heartbeatBarDays === "") {
|
||||||
|
this.config.heartbeatBarDays = 0;
|
||||||
|
} else {
|
||||||
|
this.config.heartbeatBarDays = parseInt(this.config.heartbeatBarDays, 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
@ -730,8 +753,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) {
|
||||||
|
@ -764,6 +785,40 @@ export default {
|
||||||
return highlight(code, languages.css);
|
return highlight(code, languages.css);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load heartbeat data from API
|
||||||
|
* @param {number|null} maxBeats Maximum number of beats to request from server
|
||||||
|
* @returns {Promise} Promise that resolves when data is loaded
|
||||||
|
*/
|
||||||
|
loadHeartbeatData(maxBeats = null) {
|
||||||
|
return axios.get("/api/status-page/heartbeat/" + this.slug, {
|
||||||
|
params: { maxBeats }
|
||||||
|
}).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}
|
||||||
|
@ -771,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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -815,6 +847,19 @@ export default {
|
||||||
}, 1000);
|
}, 1000);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload heartbeat data with specific maxBeats count
|
||||||
|
* Called by child components when they determine optimal beat count
|
||||||
|
* @param {number} maxBeats Maximum number of beats that fit in container
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
reloadHeartbeatData(maxBeats) {
|
||||||
|
// Only reload if we have configured days (not auto mode)
|
||||||
|
if (this.config && this.config.heartbeatBarDays > 0) {
|
||||||
|
this.loadHeartbeatData(maxBeats);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable editing mode
|
* Enable editing mode
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
|
|
|
@ -363,6 +363,430 @@ function memoryUsage() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - Basic functionality", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
// Add some test data
|
||||||
|
await c.update(UP);
|
||||||
|
await c.update(DOWN);
|
||||||
|
await c.update(UP);
|
||||||
|
|
||||||
|
// Test basic 1-day aggregation
|
||||||
|
let buckets = c.getAggregatedBuckets(1, 10);
|
||||||
|
|
||||||
|
// Should return exactly 10 buckets
|
||||||
|
assert.strictEqual(buckets.length, 10);
|
||||||
|
|
||||||
|
// Each bucket should have required properties
|
||||||
|
buckets.forEach(bucket => {
|
||||||
|
assert.ok(typeof bucket.start === "number");
|
||||||
|
assert.ok(typeof bucket.end === "number");
|
||||||
|
assert.ok(typeof bucket.up === "number");
|
||||||
|
assert.ok(typeof bucket.down === "number");
|
||||||
|
assert.ok(typeof bucket.maintenance === "number");
|
||||||
|
assert.ok(typeof bucket.pending === "number");
|
||||||
|
assert.ok(bucket.start < bucket.end);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buckets should be contiguous
|
||||||
|
for (let i = 0; i < buckets.length - 1; i++) {
|
||||||
|
assert.strictEqual(buckets[i].end, buckets[i + 1].start);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - Time range accuracy", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
let buckets = c.getAggregatedBuckets(2, 48); // 2 days, 48 buckets = 1 hour per bucket
|
||||||
|
|
||||||
|
assert.strictEqual(buckets.length, 48);
|
||||||
|
|
||||||
|
// First bucket should start 2 days ago from current time
|
||||||
|
let currentTime = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let expectedStart = currentTime.subtract(2, "day").unix();
|
||||||
|
assert.strictEqual(buckets[0].start, expectedStart);
|
||||||
|
|
||||||
|
// Last bucket should end at current time
|
||||||
|
let expectedEnd = currentTime.unix();
|
||||||
|
assert.strictEqual(buckets[buckets.length - 1].end, expectedEnd);
|
||||||
|
|
||||||
|
// Each bucket should be exactly 1 hour (3600 seconds)
|
||||||
|
buckets.forEach(bucket => {
|
||||||
|
assert.strictEqual(bucket.end - bucket.start, 3600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - Different time ranges", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
// Test 1 day (should use minutely data)
|
||||||
|
let buckets1d = c.getAggregatedBuckets(1, 24);
|
||||||
|
assert.strictEqual(buckets1d.length, 24);
|
||||||
|
|
||||||
|
// Test 7 days (should use hourly data)
|
||||||
|
let buckets7d = c.getAggregatedBuckets(7, 50);
|
||||||
|
assert.strictEqual(buckets7d.length, 50);
|
||||||
|
|
||||||
|
// Test 60 days (should use daily data)
|
||||||
|
let buckets60d = c.getAggregatedBuckets(60, 60);
|
||||||
|
assert.strictEqual(buckets60d.length, 60);
|
||||||
|
|
||||||
|
// Test maximum days (365)
|
||||||
|
let buckets365d = c.getAggregatedBuckets(365, 100);
|
||||||
|
assert.strictEqual(buckets365d.length, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - Data aggregation", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
// Create test data - add heartbeats over the past hour
|
||||||
|
let currentTime = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
|
||||||
|
// Add some recent data (within the last hour) to ensure it's captured
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
UptimeCalculator.currentDate = currentTime.subtract(60 - (i * 5), "minute"); // Go back in time
|
||||||
|
if (i < 5) {
|
||||||
|
await c.update(UP);
|
||||||
|
} else {
|
||||||
|
await c.update(DOWN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to current time
|
||||||
|
UptimeCalculator.currentDate = currentTime;
|
||||||
|
|
||||||
|
// Get aggregated buckets for 1 hour with 6 buckets (10 minutes each)
|
||||||
|
let buckets = c.getAggregatedBuckets(1 / 24, 6); // 1/24 day = 1 hour
|
||||||
|
|
||||||
|
assert.strictEqual(buckets.length, 6);
|
||||||
|
|
||||||
|
// Check that we have bucket structure even if no data (should not crash)
|
||||||
|
buckets.forEach(bucket => {
|
||||||
|
assert.ok(typeof bucket.start === "number");
|
||||||
|
assert.ok(typeof bucket.end === "number");
|
||||||
|
assert.ok(typeof bucket.up === "number");
|
||||||
|
assert.ok(typeof bucket.down === "number");
|
||||||
|
assert.ok(bucket.start < bucket.end);
|
||||||
|
});
|
||||||
|
|
||||||
|
// For this test, we'll just verify the method works and returns proper structure
|
||||||
|
// The actual data aggregation depends on the complex internal storage which is tested separately
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - Edge cases", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
// Test with no data
|
||||||
|
let emptyBuckets = c.getAggregatedBuckets(1, 10);
|
||||||
|
assert.strictEqual(emptyBuckets.length, 10);
|
||||||
|
emptyBuckets.forEach(bucket => {
|
||||||
|
assert.strictEqual(bucket.up, 0);
|
||||||
|
assert.strictEqual(bucket.down, 0);
|
||||||
|
assert.strictEqual(bucket.maintenance, 0);
|
||||||
|
assert.strictEqual(bucket.pending, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with single bucket
|
||||||
|
let singleBucket = c.getAggregatedBuckets(1, 1);
|
||||||
|
assert.strictEqual(singleBucket.length, 1);
|
||||||
|
assert.strictEqual(singleBucket[0].end - singleBucket[0].start, 24 * 60 * 60); // 1 day in seconds
|
||||||
|
|
||||||
|
// Test with very small time range
|
||||||
|
let smallRange = c.getAggregatedBuckets(0.1, 5); // 0.1 days = 2.4 hours
|
||||||
|
assert.strictEqual(smallRange.length, 5);
|
||||||
|
smallRange.forEach(bucket => {
|
||||||
|
assert.strictEqual(bucket.end - bucket.start, 2.4 * 60 * 60 / 5); // 2.4 hours / 5 buckets
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - Bucket size calculation", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
// Test different bucket counts for same time range
|
||||||
|
let days = 3;
|
||||||
|
let buckets10 = c.getAggregatedBuckets(days, 10);
|
||||||
|
let buckets50 = c.getAggregatedBuckets(days, 50);
|
||||||
|
let buckets100 = c.getAggregatedBuckets(days, 100);
|
||||||
|
|
||||||
|
assert.strictEqual(buckets10.length, 10);
|
||||||
|
assert.strictEqual(buckets50.length, 50);
|
||||||
|
assert.strictEqual(buckets100.length, 100);
|
||||||
|
|
||||||
|
// Bucket sizes should be inversely proportional to bucket count
|
||||||
|
let bucket10Size = buckets10[0].end - buckets10[0].start;
|
||||||
|
let bucket50Size = buckets50[0].end - buckets50[0].start;
|
||||||
|
let bucket100Size = buckets100[0].end - buckets100[0].start;
|
||||||
|
|
||||||
|
assert.ok(bucket10Size > bucket50Size);
|
||||||
|
assert.ok(bucket50Size > bucket100Size);
|
||||||
|
|
||||||
|
// All buckets should cover the same total time range
|
||||||
|
assert.strictEqual(buckets10[buckets10.length - 1].end - buckets10[0].start, days * 24 * 60 * 60);
|
||||||
|
assert.strictEqual(buckets50[buckets50.length - 1].end - buckets50[0].start, days * 24 * 60 * 60);
|
||||||
|
assert.strictEqual(buckets100[buckets100.length - 1].end - buckets100[0].start, days * 24 * 60 * 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - Default parameters", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
// Test default targetBuckets (should be 100)
|
||||||
|
let defaultBuckets = c.getAggregatedBuckets(7);
|
||||||
|
assert.strictEqual(defaultBuckets.length, 100);
|
||||||
|
|
||||||
|
// Test explicit targetBuckets
|
||||||
|
let explicitBuckets = c.getAggregatedBuckets(7, 50);
|
||||||
|
assert.strictEqual(explicitBuckets.length, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - Rounding precision", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
// Test with non-integer bucket sizes (should not have rounding drift)
|
||||||
|
let buckets = c.getAggregatedBuckets(1, 7); // 1 day / 7 buckets = ~3.43 hours per bucket
|
||||||
|
|
||||||
|
assert.strictEqual(buckets.length, 7);
|
||||||
|
|
||||||
|
// Verify no gaps or overlaps between buckets
|
||||||
|
for (let i = 0; i < buckets.length - 1; i++) {
|
||||||
|
assert.strictEqual(buckets[i].end, buckets[i + 1].start, `Gap found between bucket ${i} and ${i + 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify total time range is exactly as requested
|
||||||
|
let totalTime = buckets[buckets.length - 1].end - buckets[0].start;
|
||||||
|
let expectedTime = 1 * 24 * 60 * 60; // 1 day in seconds
|
||||||
|
assert.strictEqual(totalTime, expectedTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - 31-63 day edge case (daily data)", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
// Create test data for 40 days ago to ensure daily data is used
|
||||||
|
let currentTime = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
|
||||||
|
// Add data for past 40 days
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
UptimeCalculator.currentDate = currentTime.subtract(i, "day").hour(10); // 10 AM each day
|
||||||
|
await c.update(i % 3 === 0 ? DOWN : UP); // Mix of UP/DOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to current time
|
||||||
|
UptimeCalculator.currentDate = currentTime;
|
||||||
|
|
||||||
|
// Test 35-day range (should use daily data, not hourly)
|
||||||
|
let buckets = c.getAggregatedBuckets(35, 70); // 35 days with 70 buckets
|
||||||
|
|
||||||
|
assert.strictEqual(buckets.length, 70);
|
||||||
|
|
||||||
|
// Count non-empty buckets - should have data distributed across buckets
|
||||||
|
let nonEmptyBuckets = buckets.filter(b => b.up > 0 || b.down > 0).length;
|
||||||
|
assert.ok(nonEmptyBuckets > 20, `Expected more than 20 non-empty buckets, got ${nonEmptyBuckets}`);
|
||||||
|
|
||||||
|
// Verify buckets cover the full time range without gaps
|
||||||
|
for (let i = 0; i < buckets.length - 1; i++) {
|
||||||
|
assert.strictEqual(buckets[i].end, buckets[i + 1].start, `Gap found between bucket ${i} and ${i + 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify total counts
|
||||||
|
let totalUp = buckets.reduce((sum, b) => sum + b.up, 0);
|
||||||
|
let totalDown = buckets.reduce((sum, b) => sum + b.down, 0);
|
||||||
|
|
||||||
|
// We added 35 days of data (within the range), with pattern: DOWN, UP, UP, DOWN, UP, UP...
|
||||||
|
// So roughly 1/3 DOWN and 2/3 UP
|
||||||
|
assert.ok(totalUp > 0, "Should have UP heartbeats");
|
||||||
|
assert.ok(totalDown > 0, "Should have DOWN heartbeats");
|
||||||
|
assert.ok(totalUp + totalDown <= 35, "Should not exceed 35 total heartbeats for 35 days");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - 31-day boundary transition", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
// Test exactly at 30/31 day boundary
|
||||||
|
let buckets30 = c.getAggregatedBuckets(30, 60);
|
||||||
|
let buckets31 = c.getAggregatedBuckets(31, 62);
|
||||||
|
|
||||||
|
assert.strictEqual(buckets30.length, 60);
|
||||||
|
assert.strictEqual(buckets31.length, 62);
|
||||||
|
|
||||||
|
// Both should work without errors
|
||||||
|
assert.ok(buckets30.every(b => typeof b.up === "number"));
|
||||||
|
assert.ok(buckets31.every(b => typeof b.up === "number"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - Large range with daily data (60 days)", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
let currentTime = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
|
||||||
|
// Add daily data for past 60 days
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
UptimeCalculator.currentDate = currentTime.subtract(i, "day").hour(14); // 2 PM each day
|
||||||
|
if (i < 30) {
|
||||||
|
await c.update(UP); // First 30 days all UP
|
||||||
|
} else {
|
||||||
|
await c.update(i % 2 === 0 ? UP : DOWN); // Last 30 days alternating
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to current time
|
||||||
|
UptimeCalculator.currentDate = currentTime;
|
||||||
|
|
||||||
|
// Test 60-day range
|
||||||
|
let buckets = c.getAggregatedBuckets(60, 60); // 1 bucket per day
|
||||||
|
|
||||||
|
assert.strictEqual(buckets.length, 60);
|
||||||
|
|
||||||
|
// Count buckets with data
|
||||||
|
let bucketsWithData = buckets.filter(b => b.up > 0 || b.down > 0).length;
|
||||||
|
assert.ok(bucketsWithData >= 55, `Expected at least 55 buckets with data, got ${bucketsWithData}`);
|
||||||
|
|
||||||
|
// Verify data distribution
|
||||||
|
let totalUp = buckets.reduce((sum, b) => sum + b.up, 0);
|
||||||
|
let totalDown = buckets.reduce((sum, b) => sum + b.down, 0);
|
||||||
|
|
||||||
|
assert.ok(totalUp >= 40, `Expected at least 40 UP beats, got ${totalUp}`);
|
||||||
|
assert.ok(totalDown >= 10, `Expected at least 10 DOWN beats, got ${totalDown}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - Daily data bucket assignment", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
let currentTime = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
|
||||||
|
// Add specific daily data points
|
||||||
|
const testDays = [ 1, 5, 10, 20, 35, 40 ]; // Days ago
|
||||||
|
for (const daysAgo of testDays) {
|
||||||
|
UptimeCalculator.currentDate = currentTime.subtract(daysAgo, "day").startOf("day").add(6, "hour"); // 6 AM
|
||||||
|
await c.update(UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to current time
|
||||||
|
UptimeCalculator.currentDate = currentTime;
|
||||||
|
|
||||||
|
// Test 45-day range with 45 buckets (1 per day)
|
||||||
|
let buckets = c.getAggregatedBuckets(45, 45);
|
||||||
|
|
||||||
|
assert.strictEqual(buckets.length, 45);
|
||||||
|
|
||||||
|
// Check that each test day has exactly one heartbeat in the correct bucket
|
||||||
|
for (const daysAgo of testDays) {
|
||||||
|
if (daysAgo <= 45) { // Only check days within our range
|
||||||
|
// Find the bucket that should contain this day
|
||||||
|
const targetTime = currentTime.subtract(daysAgo, "day").startOf("day");
|
||||||
|
const targetTimestamp = targetTime.unix();
|
||||||
|
|
||||||
|
let found = false;
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
// Check if this bucket's range includes our target day
|
||||||
|
if (targetTimestamp >= bucket.start && targetTimestamp < bucket.end) {
|
||||||
|
assert.ok(bucket.up > 0, `Bucket containing day ${daysAgo} should have UP count`);
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(found, `Should find bucket containing day ${daysAgo}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - No gaps in 31-63 day range", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
// Test various day ranges that were problematic
|
||||||
|
const testRanges = [
|
||||||
|
{ days: 31,
|
||||||
|
buckets: 100 },
|
||||||
|
{ days: 35,
|
||||||
|
buckets: 100 },
|
||||||
|
{ days: 40,
|
||||||
|
buckets: 100 },
|
||||||
|
{ days: 45,
|
||||||
|
buckets: 100 },
|
||||||
|
{ days: 50,
|
||||||
|
buckets: 100 },
|
||||||
|
{ days: 60,
|
||||||
|
buckets: 100 },
|
||||||
|
{ days: 63,
|
||||||
|
buckets: 100 }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { days, buckets: bucketCount } of testRanges) {
|
||||||
|
let buckets = c.getAggregatedBuckets(days, bucketCount);
|
||||||
|
|
||||||
|
assert.strictEqual(buckets.length, bucketCount, `Should have exactly ${bucketCount} buckets for ${days} days`);
|
||||||
|
|
||||||
|
// Verify no gaps between buckets
|
||||||
|
for (let i = 0; i < buckets.length - 1; i++) {
|
||||||
|
assert.strictEqual(buckets[i].end, buckets[i + 1].start,
|
||||||
|
`No gap should exist between buckets ${i} and ${i + 1} for ${days}-day range`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify total time coverage
|
||||||
|
const totalSeconds = buckets[buckets.length - 1].end - buckets[0].start;
|
||||||
|
const expectedSeconds = days * 24 * 60 * 60;
|
||||||
|
assert.strictEqual(totalSeconds, expectedSeconds,
|
||||||
|
`Total time should be exactly ${days} days for ${days}-day range`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test getAggregatedBuckets - Mixed data granularity", async (t) => {
|
||||||
|
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
let c = new UptimeCalculator();
|
||||||
|
|
||||||
|
let currentTime = dayjs.utc("2025-08-12 12:00:00");
|
||||||
|
|
||||||
|
// Add recent minute data (last hour)
|
||||||
|
for (let i = 0; i < 60; i += 5) {
|
||||||
|
UptimeCalculator.currentDate = currentTime.subtract(i, "minute");
|
||||||
|
await c.update(UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add hourly data (last 24 hours)
|
||||||
|
for (let i = 1; i < 24; i++) {
|
||||||
|
UptimeCalculator.currentDate = currentTime.subtract(i, "hour");
|
||||||
|
await c.update(i % 4 === 0 ? DOWN : UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add daily data (last 40 days)
|
||||||
|
for (let i = 2; i <= 40; i++) {
|
||||||
|
UptimeCalculator.currentDate = currentTime.subtract(i, "day").hour(12);
|
||||||
|
await c.update(i % 5 === 0 ? DOWN : UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to current time
|
||||||
|
UptimeCalculator.currentDate = currentTime;
|
||||||
|
|
||||||
|
// Test different ranges to ensure proper data selection
|
||||||
|
// 1-day range should use minute data
|
||||||
|
let buckets1d = c.getAggregatedBuckets(1, 24);
|
||||||
|
assert.strictEqual(buckets1d.length, 24);
|
||||||
|
|
||||||
|
// 7-day range should use hourly data
|
||||||
|
let buckets7d = c.getAggregatedBuckets(7, 50);
|
||||||
|
assert.strictEqual(buckets7d.length, 50);
|
||||||
|
|
||||||
|
// 35-day range should use daily data
|
||||||
|
let buckets35d = c.getAggregatedBuckets(35, 70);
|
||||||
|
assert.strictEqual(buckets35d.length, 70);
|
||||||
|
|
||||||
|
// All should have some data
|
||||||
|
assert.ok(buckets1d.some(b => b.up > 0));
|
||||||
|
assert.ok(buckets7d.some(b => b.up > 0 || b.down > 0));
|
||||||
|
assert.ok(buckets35d.some(b => b.up > 0 || b.down > 0));
|
||||||
|
});
|
||||||
|
|
||||||
test("Worst case", async (t) => {
|
test("Worst case", async (t) => {
|
||||||
|
|
||||||
// Disable on GitHub Actions, as it is not stable on it
|
// Disable on GitHub Actions, as it is not stable on it
|
||||||
|
|
Loading…
Add table
Reference in a new issue