mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-07-18 15:24:03 +02:00
Merge 0d2c12944a
into 487cb8fdc5
This commit is contained in:
commit
13dab234b2
11 changed files with 833 additions and 57 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.smallint("heartbeat_bar_days").notNullable().defaultTo(0).checkBetween([ 0, 365 ]);
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
googleAnalyticsId: this.google_analytics_tag_id,
|
||||
showCertificateExpiry: !!this.show_certificate_expiry,
|
||||
heartbeatBarDays: this.heartbeat_bar_days,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -432,6 +433,7 @@ class StatusPage extends BeanModel {
|
|||
showPoweredBy: !!this.show_powered_by,
|
||||
googleAnalyticsId: this.google_analytics_tag_id,
|
||||
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 { allowDevAllOrigin, sendHttpError } = require("../util-server");
|
||||
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 { UptimeCalculator } = require("../uptime-calculator");
|
||||
const dayjs = require("dayjs");
|
||||
|
||||
let router = express.Router();
|
||||
|
||||
|
@ -84,7 +85,21 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques
|
|||
statusPageID
|
||||
]);
|
||||
|
||||
for (let monitorID of monitorIDList) {
|
||||
// Get the status page to determine the heartbeat range
|
||||
let statusPage = await R.findOne("status_page", " id = ? ", [ statusPageID ]);
|
||||
let heartbeatBarDays = statusPage ? (statusPage.heartbeat_bar_days || 0) : 0;
|
||||
|
||||
// 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;
|
||||
|
||||
if (heartbeatBarDays === 0) {
|
||||
// Auto mode - use original LIMIT 100 logic
|
||||
let list = await R.getAll(`
|
||||
SELECT * FROM heartbeat
|
||||
WHERE monitor_id = ?
|
||||
|
@ -95,10 +110,50 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques
|
|||
]);
|
||||
|
||||
list = R.convertToBeans("heartbeat", list);
|
||||
heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());
|
||||
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;
|
||||
}
|
||||
|
||||
const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
|
||||
uptimeList[`${monitorID}_24`] = uptimeCalculator.get24Hour().uptime;
|
||||
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
|
||||
let uptime;
|
||||
if (heartbeatBarDays <= 1) {
|
||||
uptime = uptimeCalculator.get24Hour().uptime;
|
||||
} else {
|
||||
uptime = uptimeCalculator.getData(heartbeatBarDays, "day").uptime;
|
||||
}
|
||||
|
||||
return {
|
||||
monitorID,
|
||||
heartbeats,
|
||||
uptime
|
||||
};
|
||||
});
|
||||
|
||||
// Wait for all monitors to be processed
|
||||
const monitorResults = await Promise.all(monitorPromises);
|
||||
|
||||
// Populate the response objects
|
||||
for (const result of monitorResults) {
|
||||
heartbeatList[result.monitorID] = result.heartbeats;
|
||||
uptimeList[result.monitorID] = result.uptime;
|
||||
}
|
||||
|
||||
response.json({
|
||||
|
|
|
@ -165,6 +165,7 @@ module.exports.statusPageSocketHandler = (socket) => {
|
|||
statusPage.custom_css = config.customCSS;
|
||||
statusPage.show_powered_by = config.showPoweredBy;
|
||||
statusPage.show_certificate_expiry = config.showCertificateExpiry;
|
||||
statusPage.heartbeat_bar_days = config.heartbeatBarDays;
|
||||
statusPage.modified_date = R.isoDateTime();
|
||||
statusPage.google_analytics_tag_id = config.googleAnalyticsId;
|
||||
|
||||
|
|
|
@ -845,6 +845,93 @@ class UptimeCalculator {
|
|||
setMigrationMode(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
|
||||
for (const [ timestamp, dataPoint ] of Object.entries(availableData)) {
|
||||
const timestampNum = parseInt(timestamp);
|
||||
|
||||
// Find the appropriate bucket for this data point
|
||||
// For daily data (> 30 days), timestamps are at start of day
|
||||
// We need to find which bucket this day belongs to
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
const bucket = buckets[i];
|
||||
|
||||
if (days > 30) {
|
||||
// For daily data, check if the timestamp falls within the bucket's day range
|
||||
if (timestampNum >= bucket.start && timestampNum < bucket.end) {
|
||||
bucket.up += dataPoint.up || 0;
|
||||
bucket.down += dataPoint.down || 0;
|
||||
bucket.maintenance += dataPoint.maintenance || 0;
|
||||
bucket.pending += dataPoint.pending || 0;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// For minute/hourly data, use exact timestamp matching
|
||||
if (timestampNum >= bucket.start && timestampNum < bucket.end && dataPoint) {
|
||||
bucket.up += dataPoint.up || 0;
|
||||
bucket.down += dataPoint.down || 0;
|
||||
bucket.maintenance += 0; // UptimeCalculator treats maintenance as up
|
||||
bucket.pending += 0; // UptimeCalculator doesn't track pending separately
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buckets;
|
||||
}
|
||||
}
|
||||
|
||||
class UptimeDataResult {
|
||||
|
|
|
@ -5,13 +5,13 @@
|
|||
v-for="(beat, index) in shortBeatList"
|
||||
:key="index"
|
||||
class="beat-hover-area"
|
||||
:class="{ 'empty': (beat === 0) }"
|
||||
:class="{ 'empty': (!beat) }"
|
||||
: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), 'down': (beat.status === 0), 'pending': (beat.status === 2), 'maintenance': (beat.status === 3) }"
|
||||
:style="beatStyle"
|
||||
/>
|
||||
</div>
|
||||
|
@ -46,6 +46,11 @@ export default {
|
|||
heartbeatList: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
/** Heartbeat bar days */
|
||||
heartbeatBarDays: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
@ -80,6 +85,12 @@ export default {
|
|||
if (!this.beatList) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// For configured ranges, no padding needed since we show all beats
|
||||
if (this.heartbeatBarDays > 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let num = this.beatList.length - this.maxBeat;
|
||||
|
||||
if (this.move) {
|
||||
|
@ -98,8 +109,20 @@ export default {
|
|||
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 = [];
|
||||
|
||||
// Handle case where maxBeat is -1 (no limit)
|
||||
if (this.maxBeat <= 0) {
|
||||
return this.beatList;
|
||||
}
|
||||
|
||||
let start = this.beatList.length - this.maxBeat;
|
||||
|
||||
if (this.move) {
|
||||
|
@ -172,13 +195,17 @@ export default {
|
|||
* @returns {string} The time elapsed in minutes or hours.
|
||||
*/
|
||||
timeSinceFirstBeat() {
|
||||
// For configured days mode, show the configured range
|
||||
if (this.heartbeatBarDays >= 2) {
|
||||
return this.heartbeatBarDays + "d";
|
||||
} else if (this.heartbeatBarDays === 1) {
|
||||
return (this.heartbeatBarDays * 24) + "h";
|
||||
}
|
||||
|
||||
// Need to calculate from actual data
|
||||
const firstValidBeat = this.shortBeatList.at(this.numPadding);
|
||||
const minutes = dayjs().diff(dayjs.utc(firstValidBeat?.time), "minutes");
|
||||
if (minutes > 60) {
|
||||
return (minutes / 60).toFixed(0) + "h";
|
||||
} else {
|
||||
return minutes + "m";
|
||||
}
|
||||
return minutes > 60 ? Math.floor(minutes / 60) + "h" : minutes + "m";
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -205,7 +232,7 @@ export default {
|
|||
},
|
||||
watch: {
|
||||
beatList: {
|
||||
handler(val, oldVal) {
|
||||
handler() {
|
||||
this.move = true;
|
||||
|
||||
setTimeout(() => {
|
||||
|
@ -256,7 +283,23 @@ export default {
|
|||
*/
|
||||
resize() {
|
||||
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.heartbeatBarDays > 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 +310,12 @@ export default {
|
|||
* @returns {string} Beat title
|
||||
*/
|
||||
getBeatTitle(beat) {
|
||||
return `${this.$root.datetime(beat.time)}` + ((beat.msg) ? ` - ${beat.msg}` : "");
|
||||
if (!beat) {
|
||||
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="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
|
||||
v-if="showLink(monitor)"
|
||||
:href="monitor.element.url"
|
||||
|
@ -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-days="heartbeatBarDays" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -114,6 +114,11 @@ export default {
|
|||
/** Should expiry be shown? */
|
||||
showCertificateExpiry: {
|
||||
type: Boolean,
|
||||
},
|
||||
/** Heartbeat bar days */
|
||||
heartbeatBarDays: {
|
||||
type: [ Number, String ],
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
@ -124,6 +129,19 @@ export default {
|
|||
computed: {
|
||||
showGroupDrag() {
|
||||
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() {
|
||||
|
|
|
@ -30,10 +30,8 @@ export default {
|
|||
return this.$t("statusMaintenance");
|
||||
}
|
||||
|
||||
let key = this.monitor.id + "_" + this.type;
|
||||
|
||||
if (this.$root.uptimeList[key] !== undefined) {
|
||||
let result = Math.round(this.$root.uptimeList[key] * 10000) / 100;
|
||||
if (this.$root.uptimeList[this.monitor.id] !== undefined) {
|
||||
let result = Math.round(this.$root.uptimeList[this.monitor.id] * 10000) / 100;
|
||||
// Only perform sanity check on status page. See louislam/uptime-kuma#2628
|
||||
if (this.$route.path.startsWith("/status") && result > 100) {
|
||||
return "100%";
|
||||
|
@ -90,6 +88,14 @@ export default {
|
|||
if (this.type === "720") {
|
||||
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")}`;
|
||||
}
|
||||
},
|
||||
|
|
|
@ -375,6 +375,9 @@
|
|||
"Footer Text": "Footer Text",
|
||||
"Refresh Interval": "Refresh Interval",
|
||||
"Refresh Interval Description": "The status page will do a full site refresh every {0} seconds",
|
||||
"Heartbeat Bar Days": "Heartbeat Bar Days",
|
||||
"Status page shows heartbeat history days": "Status page shows {0} days of heartbeats",
|
||||
"Status page will show last beats": "Status page shows the last {0} heartbeats",
|
||||
"Show Powered By": "Show Powered By",
|
||||
"Domain Names": "Domain Names",
|
||||
"signedInDisp": "Signed in as {0}",
|
||||
|
|
|
@ -42,6 +42,17 @@
|
|||
</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 v-if="config.heartbeatBarDays === 0" class="form-text">
|
||||
{{ $t("Status page will show last beats", [100]) }}
|
||||
</div>
|
||||
<div v-else class="form-text">
|
||||
{{ $t("Status page shows heartbeat history days", [config.heartbeatBarDays]) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<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">
|
||||
|
@ -328,7 +339,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-days="config.heartbeatBarDays" />
|
||||
</div>
|
||||
|
||||
<footer class="mt-5 mb-4">
|
||||
|
@ -619,6 +630,12 @@ export default {
|
|||
if (res.ok) {
|
||||
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) {
|
||||
this.config.customCSS = "body {\n" +
|
||||
" \n" +
|
||||
|
@ -700,20 +717,29 @@ 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.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) {
|
||||
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;
|
||||
|
||||
|
@ -730,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) {
|
||||
|
@ -765,13 +789,14 @@ export default {
|
|||
},
|
||||
|
||||
/**
|
||||
* Update the heartbeat list and update favicon if necessary
|
||||
* @returns {void}
|
||||
* 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
|
||||
*/
|
||||
updateHeartbeatList() {
|
||||
// If editMode, it will use the data from websocket.
|
||||
if (! this.editMode) {
|
||||
axios.get("/api/status-page/heartbeat/" + this.slug).then((res) => {
|
||||
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;
|
||||
|
@ -795,6 +820,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();
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -823,6 +858,19 @@ export default {
|
|||
}, 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
|
||||
* @returns {void}
|
||||
|
|
|
@ -363,6 +363,503 @@ 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 - Data granularity selection", 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 minutely data (recent hour)
|
||||
for (let i = 0; i < 60; i += 5) {
|
||||
UptimeCalculator.currentDate = currentTime.subtract(i, "minute");
|
||||
await c.update(UP);
|
||||
}
|
||||
|
||||
// Add hourly data (past 24 hours)
|
||||
for (let i = 1; i < 24; i++) {
|
||||
UptimeCalculator.currentDate = currentTime.subtract(i, "hour");
|
||||
await c.update(i % 3 === 0 ? DOWN : UP);
|
||||
}
|
||||
|
||||
// Add daily data (past 60 days)
|
||||
for (let i = 2; i <= 60; i++) {
|
||||
UptimeCalculator.currentDate = currentTime.subtract(i, "day").hour(12);
|
||||
await c.update(i % 4 === 0 ? DOWN : UP);
|
||||
}
|
||||
|
||||
// Reset to current time
|
||||
UptimeCalculator.currentDate = currentTime;
|
||||
|
||||
// Test 1 day range - should use minutely data and have data points
|
||||
let buckets1d = c.getAggregatedBuckets(1, 24);
|
||||
assert.strictEqual(buckets1d.length, 24);
|
||||
let hasMinutelyData = buckets1d.some(b => b.up > 0 || b.down > 0);
|
||||
assert.ok(hasMinutelyData, "1-day range should access minutely data and contain heartbeats");
|
||||
|
||||
// Test 7 day range - should use hourly data and have data points
|
||||
let buckets7d = c.getAggregatedBuckets(7, 50);
|
||||
assert.strictEqual(buckets7d.length, 50);
|
||||
let hasHourlyData = buckets7d.some(b => b.up > 0 || b.down > 0);
|
||||
assert.ok(hasHourlyData, "7-day range should access hourly data and contain heartbeats");
|
||||
|
||||
// Test 60 day range - should use daily data and have data points
|
||||
let buckets60d = c.getAggregatedBuckets(60, 60);
|
||||
assert.strictEqual(buckets60d.length, 60);
|
||||
let hasDailyData = buckets60d.some(b => b.up > 0 || b.down > 0);
|
||||
assert.ok(hasDailyData, "60-day range should access daily data and contain heartbeats");
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// Snapshot test - verify deterministic bucket structure
|
||||
const snapshot = JSON.stringify(buckets);
|
||||
const secondCall = c.getAggregatedBuckets(1 / 24, 6);
|
||||
const secondSnapshot = JSON.stringify(secondCall);
|
||||
assert.strictEqual(snapshot, secondSnapshot, "Bucket structure should be deterministic between calls");
|
||||
});
|
||||
|
||||
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 with buckets that match data granularity
|
||||
let buckets = c.getAggregatedBuckets(35, 35); // 35 days with 35 buckets = 1 day per bucket
|
||||
|
||||
assert.strictEqual(buckets.length, 35);
|
||||
|
||||
// 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 > 30, `Expected more than 30 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: i % 3 === 0 ? DOWN : UP
|
||||
// Days 0,3,6,9,12,15,18,21,24,27,30,33 = 12 DOWN days
|
||||
// Days 1,2,4,5,7,8,10,11,13,14,16,17,19,20,22,23,25,26,28,29,31,32,34 = 23 UP days
|
||||
const expectedDown = 12;
|
||||
const expectedUp = 23;
|
||||
assert.strictEqual(totalDown, expectedDown, `Should have exactly ${expectedDown} DOWN heartbeats`);
|
||||
assert.strictEqual(totalUp, expectedUp, `Should have exactly ${expectedUp} UP heartbeats`);
|
||||
});
|
||||
|
||||
test("Test getAggregatedBuckets - Daily data includes downtime after uptime", 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");
|
||||
|
||||
// Simulate a monitor that was up for a long time, then went down
|
||||
// Add 30 days of UP data
|
||||
for (let i = 2; i <= 31; i++) {
|
||||
UptimeCalculator.currentDate = currentTime.subtract(i, "day").hour(10);
|
||||
await c.update(UP);
|
||||
}
|
||||
|
||||
// Then add 5 days of DOWN data (more recent)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
UptimeCalculator.currentDate = currentTime.subtract(i, "day").hour(10);
|
||||
await c.update(DOWN);
|
||||
}
|
||||
|
||||
// Reset to current time
|
||||
UptimeCalculator.currentDate = currentTime;
|
||||
|
||||
// Test 35-day range to ensure daily data includes the downtime
|
||||
let buckets = c.getAggregatedBuckets(35, 35);
|
||||
|
||||
assert.strictEqual(buckets.length, 35);
|
||||
|
||||
// Count total UP and DOWN beats
|
||||
let totalUp = buckets.reduce((sum, b) => sum + b.up, 0);
|
||||
let totalDown = buckets.reduce((sum, b) => sum + b.down, 0);
|
||||
|
||||
// We should have exactly 30 UP and 5 DOWN beats
|
||||
assert.strictEqual(totalUp, 30, "Should have 30 UP beats from the long uptime period");
|
||||
assert.strictEqual(totalDown, 5, "Should have 5 DOWN beats from the recent downtime");
|
||||
|
||||
// Verify the recent buckets contain DOWN data
|
||||
let recentDownCount = buckets.slice(-5).reduce((sum, b) => sum + b.down, 0);
|
||||
assert.strictEqual(recentDownCount, 5, "Recent 5 buckets should contain all DOWN beats");
|
||||
});
|
||||
|
||||
test("Test getAggregatedBuckets - Basic functionality", async (t) => {
|
||||
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||
let c = new UptimeCalculator();
|
||||
|
||||
// Test basic bucket creation without complex data
|
||||
let buckets = c.getAggregatedBuckets(7, 14); // 7 days, 14 buckets
|
||||
|
||||
assert.strictEqual(buckets.length, 14, "Should create requested number of buckets");
|
||||
|
||||
// Verify bucket structure
|
||||
buckets.forEach(bucket => {
|
||||
assert.ok(typeof bucket.up === "number", "Bucket should have up count");
|
||||
assert.ok(typeof bucket.down === "number", "Bucket should have down count");
|
||||
assert.ok(bucket.start < bucket.end, "Bucket should have valid time range");
|
||||
});
|
||||
});
|
||||
|
||||
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 - Data granularity transitions", async (t) => {
|
||||
UptimeCalculator.currentDate = dayjs.utc("2025-08-12 12:00:00");
|
||||
let c = new UptimeCalculator();
|
||||
|
||||
// This test verifies the critical transition from hourly data (≤30 days) to daily data (>30 days)
|
||||
// This boundary is important because it changes the data granularity and aggregation logic
|
||||
|
||||
// Test various day ranges around the 30-day boundary
|
||||
const testRanges = [
|
||||
{ days: 30,
|
||||
buckets: 100,
|
||||
expectedDataType: "hourly" },
|
||||
{ days: 31,
|
||||
buckets: 100,
|
||||
expectedDataType: "daily" },
|
||||
{ days: 35,
|
||||
buckets: 100,
|
||||
expectedDataType: "daily" },
|
||||
{ days: 60,
|
||||
buckets: 100,
|
||||
expectedDataType: "daily" }
|
||||
];
|
||||
|
||||
for (const { days, buckets: bucketCount, expectedDataType } of testRanges) {
|
||||
let buckets = c.getAggregatedBuckets(days, bucketCount);
|
||||
|
||||
assert.strictEqual(buckets.length, bucketCount,
|
||||
`Should have exactly ${bucketCount} buckets for ${days} days (${expectedDataType} data)`);
|
||||
|
||||
// Verify no gaps between buckets - critical for UI display
|
||||
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 is exact
|
||||
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`);
|
||||
|
||||
// Verify bucket structure is consistent regardless of data type
|
||||
buckets.forEach((bucket, i) => {
|
||||
assert.ok(typeof bucket.up === "number", `Bucket ${i} should have numeric up count`);
|
||||
assert.ok(typeof bucket.down === "number", `Bucket ${i} should have numeric down count`);
|
||||
assert.ok(bucket.start < bucket.end, `Bucket ${i} should have valid time range`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("Test getAggregatedBuckets - Break statements prevent double-counting", 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 some daily data
|
||||
for (let i = 0; i < 4; i++) {
|
||||
UptimeCalculator.currentDate = currentTime.subtract(i, "day").hour(12);
|
||||
await c.update(UP);
|
||||
}
|
||||
UptimeCalculator.currentDate = currentTime;
|
||||
|
||||
// Test: Each data point should only be counted in one bucket (using break statements)
|
||||
// Use the same time range for both tests to ensure fair comparison
|
||||
let smallBuckets = c.getAggregatedBuckets(4, 8); // Creates smaller buckets within same 4-day range
|
||||
let smallTotal = smallBuckets.reduce((sum, b) => sum + b.up, 0);
|
||||
|
||||
// Test: When buckets match data granularity, each data point is counted once
|
||||
let normalBuckets = c.getAggregatedBuckets(4, 4); // 1 bucket per day
|
||||
let normalTotal = normalBuckets.reduce((sum, b) => sum + b.up, 0);
|
||||
|
||||
// With proper break statements, each data point is counted exactly once regardless of bucket size
|
||||
// when using the same time range
|
||||
assert.strictEqual(smallTotal, normalTotal, "Data points should be counted exactly once regardless of bucket size within same time range");
|
||||
assert.ok(normalTotal >= 3, "Should capture most of the data points");
|
||||
});
|
||||
|
||||
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) => {
|
||||
|
||||
// Disable on GitHub Actions, as it is not stable on it
|
||||
|
|
Loading…
Add table
Reference in a new issue