This commit is contained in:
Peak Twilight 2025-06-17 19:30:12 +00:00 committed by GitHub
commit 300c51fa3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 763 additions and 52 deletions

View 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");
});
};

View file

@ -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,
};
}

View file

@ -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,22 @@ 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.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
WHERE monitor_id = ?
@ -95,10 +111,49 @@ 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
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({

View file

@ -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;

View file

@ -845,6 +845,95 @@ 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
// 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 {

View file

@ -46,6 +46,11 @@ export default {
heartbeatList: {
type: Array,
default: null,
},
/** Heartbeat bar days */
heartbeatBarDays: {
type: Number,
default: 0
}
},
data() {
@ -60,6 +65,14 @@ export default {
},
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
* @returns {object} Heartbeat list
@ -80,6 +93,12 @@ export default {
if (!this.beatList) {
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;
if (this.move) {
@ -98,8 +117,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 +203,17 @@ export default {
* @returns {string} The time elapsed in minutes or hours.
*/
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 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 +240,7 @@ export default {
},
watch: {
beatList: {
handler(val, oldVal) {
handler() {
this.move = true;
setTimeout(() => {
@ -256,7 +291,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.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
*/
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}` : ""}`;
},
},

View file

@ -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() {

View file

@ -30,7 +30,7 @@ export default {
return this.$t("statusMaintenance");
}
let key = this.monitor.id + "_" + this.type;
let key = this.monitor.id;
if (this.$root.uptimeList[key] !== undefined) {
let result = Math.round(this.$root.uptimeList[key] * 10000) / 100;
@ -90,6 +90,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")}`;
}
},

View file

@ -375,6 +375,8 @@
"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",
"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",
"Domain Names": "Domain Names",
"signedInDisp": "Signed in as {0}",

View file

@ -42,6 +42,14 @@
</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">
<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 +336,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 +627,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 +714,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 +753,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 +786,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 +817,16 @@ export default {
this.lastUpdateTime = dayjs();
this.updateUpdateTimer();
});
},
/**
* Update the heartbeat list and update favicon if necessary
* @returns {void}
*/
updateHeartbeatList() {
// If editMode, it will use the data from websocket.
if (! this.editMode) {
this.loadHeartbeatData();
}
},
@ -815,6 +847,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}

View file

@ -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) => {
// Disable on GitHub Actions, as it is not stable on it