This commit is contained in:
Peak Twilight 2025-07-11 21:41:58 +00:00 committed by GitHub
commit 13dab234b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 833 additions and 57 deletions

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

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,21 +85,75 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques
statusPageID
]);
for (let monitorID of monitorIDList) {
let list = await R.getAll(`
// 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 = ?
ORDER BY time DESC
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
let uptime;
if (heartbeatBarDays <= 1) {
uptime = uptimeCalculator.get24Hour().uptime;
} else {
uptime = uptimeCalculator.getData(heartbeatBarDays, "day").uptime;
}
return {
monitorID,
]);
heartbeats,
uptime
};
});
list = R.convertToBeans("heartbeat", list);
heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());
// Wait for all monitors to be processed
const monitorResults = await Promise.all(monitorPromises);
const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
uptimeList[`${monitorID}_24`] = uptimeCalculator.get24Hour().uptime;
// 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,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 {

View file

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

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

View file

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

View file

@ -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) {
@ -764,6 +788,40 @@ export default {
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
* @returns {void}
@ -771,30 +829,7 @@ export default {
updateHeartbeatList() {
// If editMode, it will use the data from websocket.
if (! this.editMode) {
axios.get("/api/status-page/heartbeat/" + this.slug).then((res) => {
const { heartbeatList, uptimeList } = res.data;
this.$root.heartbeatList = heartbeatList;
this.$root.uptimeList = uptimeList;
const heartbeatIds = Object.keys(heartbeatList);
const downMonitors = heartbeatIds.reduce((downMonitorsAmount, currentId) => {
const monitorHeartbeats = heartbeatList[currentId];
const lastHeartbeat = monitorHeartbeats.at(-1);
if (lastHeartbeat) {
return lastHeartbeat.status === 0 ? downMonitorsAmount + 1 : downMonitorsAmount;
} else {
return downMonitorsAmount;
}
}, 0);
favicon.badge(downMonitors);
this.loadedData = true;
this.lastUpdateTime = dayjs();
this.updateUpdateTimer();
});
this.loadHeartbeatData();
}
},
@ -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}

View file

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