simplified to day int instead of custom options accross all components.

This commit is contained in:
Doruk 2025-06-14 12:49:02 +02:00
parent b41d10ec27
commit ace9ff20b5
9 changed files with 78 additions and 203 deletions

View file

@ -1,11 +1,11 @@
exports.up = function (knex) { exports.up = function (knex) {
return knex.schema.alterTable("status_page", function (table) { return knex.schema.alterTable("status_page", function (table) {
table.string("heartbeat_bar_range").defaultTo("auto"); table.integer("heartbeat_bar_days").notNullable().defaultTo(0);
}); });
}; };
exports.down = function (knex) { exports.down = function (knex) {
return knex.schema.alterTable("status_page", function (table) { return knex.schema.alterTable("status_page", function (table) {
table.dropColumn("heartbeat_bar_range"); table.dropColumn("heartbeat_bar_days");
}); });
}; };

View file

@ -409,7 +409,7 @@ class StatusPage extends BeanModel {
showPoweredBy: !!this.show_powered_by, showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id, googleAnalyticsId: this.google_analytics_tag_id,
showCertificateExpiry: !!this.show_certificate_expiry, showCertificateExpiry: !!this.show_certificate_expiry,
heartbeatBarRange: this.heartbeat_bar_range, heartbeatBarDays: this.heartbeat_bar_days,
}; };
} }

View file

@ -3,7 +3,7 @@ const apicache = require("../modules/apicache");
const { UptimeKumaServer } = require("../uptime-kuma-server"); const { UptimeKumaServer } = require("../uptime-kuma-server");
const StatusPage = require("../model/status_page"); const StatusPage = require("../model/status_page");
const { allowDevAllOrigin, sendHttpError } = require("../util-server"); const { allowDevAllOrigin, sendHttpError } = require("../util-server");
const { getAggregatedHeartbeatData, parseRangeHours } = require("../util/heartbeat-range"); const dayjs = require("dayjs");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { badgeConstants } = require("../../src/util"); const { badgeConstants } = require("../../src/util");
const { makeBadge } = require("badge-maker"); const { makeBadge } = require("badge-maker");
@ -87,22 +87,12 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques
// Get the status page to determine the heartbeat range // Get the status page to determine the heartbeat range
let statusPage = await R.findOne("status_page", " id = ? ", [ statusPageID ]); let statusPage = await R.findOne("status_page", " id = ? ", [ statusPageID ]);
let heartbeatRange = statusPage ? statusPage.heartbeat_bar_range : "auto"; let heartbeatBarDays = statusPage ? (statusPage.heartbeat_bar_days || 0) : 0;
for (let monitorID of monitorIDList) { for (let monitorID of monitorIDList) {
let list; if (heartbeatBarDays === 0) {
// Try to use aggregated data from stat tables for better performance
const aggregatedData = await getAggregatedHeartbeatData(monitorID, heartbeatRange);
if (aggregatedData) {
// Use pre-aggregated stat data
heartbeatList[monitorID] = aggregatedData;
} else {
// Fall back to raw heartbeat data (auto mode or no stat data)
if (heartbeatRange === "auto") {
// Auto mode - use original LIMIT 100 logic // Auto mode - use original LIMIT 100 logic
list = await R.getAll(` let list = await R.getAll(`
SELECT * FROM heartbeat SELECT * FROM heartbeat
WHERE monitor_id = ? WHERE monitor_id = ?
ORDER BY time DESC ORDER BY time DESC
@ -110,25 +100,44 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques
`, [ `, [
monitorID, monitorID,
]); ]);
} else {
// Non-auto range but no stat data - filter raw heartbeat data by time
const hours = parseRangeHours(heartbeatRange);
const date = new Date();
date.setHours(date.getHours() - hours);
const dateFrom = date.toISOString().slice(0, 19).replace("T", " ");
list = await R.getAll(`
SELECT * FROM heartbeat
WHERE monitor_id = ? AND time >= ?
ORDER BY time DESC
`, [
monitorID,
dateFrom
]);
}
list = R.convertToBeans("heartbeat", list); list = R.convertToBeans("heartbeat", list);
heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON()); heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());
} else {
// Use UptimeCalculator for configured day ranges
const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
if (heartbeatBarDays <= 1) {
// Use 24-hour data
const data = uptimeCalculator.get24Hour();
heartbeatList[monitorID] = Object.entries(data.minutelyUptimeDataList || {}).map(([ timestamp, uptimeData ]) => ({
time: dayjs(parseInt(timestamp)).format("YYYY-MM-DD HH:mm:ss"),
status: uptimeData.up > 0 ? 1 : (uptimeData.down > 0 ? 0 : 1),
up: uptimeData.up,
down: uptimeData.down,
ping: uptimeData.avgPing
}));
} else if (heartbeatBarDays <= 30) {
// Use 30-day hourly data
const data = uptimeCalculator.get30Day();
heartbeatList[monitorID] = Object.entries(data.hourlyUptimeDataList || {}).map(([ timestamp, uptimeData ]) => ({
time: dayjs(parseInt(timestamp)).format("YYYY-MM-DD HH:mm:ss"),
status: uptimeData.up > 0 ? 1 : (uptimeData.down > 0 ? 0 : 1),
up: uptimeData.up,
down: uptimeData.down,
ping: uptimeData.avgPing
}));
} else {
// Use daily data for longer ranges
const data = uptimeCalculator.getData();
heartbeatList[monitorID] = Object.entries(data.dailyUptimeDataList || {}).map(([ timestamp, uptimeData ]) => ({
time: dayjs(parseInt(timestamp)).format("YYYY-MM-DD HH:mm:ss"),
status: uptimeData.up > 0 ? 1 : (uptimeData.down > 0 ? 0 : 1),
up: uptimeData.up,
down: uptimeData.down,
ping: uptimeData.avgPing
}));
}
} }
const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID); const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);

View file

@ -165,7 +165,7 @@ module.exports.statusPageSocketHandler = (socket) => {
statusPage.custom_css = config.customCSS; statusPage.custom_css = config.customCSS;
statusPage.show_powered_by = config.showPoweredBy; statusPage.show_powered_by = config.showPoweredBy;
statusPage.show_certificate_expiry = config.showCertificateExpiry; statusPage.show_certificate_expiry = config.showCertificateExpiry;
statusPage.heartbeat_bar_range = config.heartbeatBarRange; statusPage.heartbeat_bar_days = config.heartbeatBarDays;
statusPage.modified_date = R.isoDateTime(); statusPage.modified_date = R.isoDateTime();
statusPage.google_analytics_tag_id = config.googleAnalyticsId; statusPage.google_analytics_tag_id = config.googleAnalyticsId;

View file

@ -1,101 +0,0 @@
const { R } = require("redbean-node");
const dayjs = require("dayjs");
/**
* Utility functions for heartbeat range handling
*/
/**
* Parse heartbeat range string and return hours
* @param {string} range - Range string like "6h", "7d", "auto"
* @returns {number|null} Hours or null for auto
*/
function parseRangeHours(range) {
if (!range || range === "auto") {
return null;
}
if (range.endsWith("h")) {
return parseInt(range);
} else if (range.endsWith("d")) {
return parseInt(range) * 24;
}
// Fallback
return 90 * 24;
}
/**
* Get aggregated heartbeat data using stat tables for better performance
* @param {number} monitorId - Monitor ID
* @param {string} range - Range string like "6h", "7d", "auto"
* @returns {Promise<Array>} Aggregated heartbeat data
*/
async function getAggregatedHeartbeatData(monitorId, range) {
if (!range || range === "auto") {
return null;
}
const now = dayjs();
const hours = parseRangeHours(range);
if (hours <= 24) {
// Use hourly stats for ranges up to 24 hours
const startTime = now.subtract(hours, "hours");
const timestampKey = Math.floor(startTime.valueOf() / (60 * 60 * 1000)); // Convert to seconds
const stats = await R.getAll(`
SELECT * FROM stat_hourly
WHERE monitor_id = ? AND timestamp >= ?
ORDER BY timestamp ASC
`, [ monitorId, timestampKey ]);
// If no stat data, fall back to raw heartbeat data
if (stats.length === 0) {
return null; // This will trigger fallback in router
}
// Convert stat data to simplified format for client-side aggregation
const result = stats.map(stat => ({
time: dayjs(stat.timestamp * 1000).format("YYYY-MM-DD HH:mm:ss"),
status: stat.up > 0 ? 1 : (stat.down > 0 ? 0 : 1),
up: stat.up,
down: stat.down,
ping: stat.ping
}));
return result;
} else {
// Use daily stats for ranges over 24 hours
const days = Math.ceil(hours / 24);
const startTime = now.subtract(days, "days");
const timestampKey = Math.floor(startTime.valueOf() / (24 * 60 * 60 * 1000)); // Convert to seconds
const stats = await R.getAll(`
SELECT * FROM stat_daily
WHERE monitor_id = ? AND timestamp >= ?
ORDER BY timestamp ASC
`, [ monitorId, timestampKey ]);
// If no stat data, fall back to raw heartbeat data
if (stats.length === 0) {
return null; // This will trigger fallback in router
}
// Convert stat data to simplified format for client-side aggregation
const result = stats.map(stat => ({
time: dayjs(stat.timestamp * 1000).format("YYYY-MM-DD HH:mm:ss"),
status: stat.up > 0 ? 1 : (stat.down > 0 ? 0 : 1),
up: stat.up,
down: stat.down,
ping: stat.ping
}));
return result;
}
}
module.exports = {
parseRangeHours,
getAggregatedHeartbeatData
};

View file

@ -47,10 +47,10 @@ export default {
type: Array, type: Array,
default: null, default: null,
}, },
/** Heartbeat bar range */ /** Heartbeat bar days */
heartbeatBarRange: { heartbeatBarDays: {
type: String, type: Number,
default: "auto", default: 0,
} }
}, },
data() { data() {
@ -103,8 +103,8 @@ export default {
return []; return [];
} }
// If heartbeat range is configured (not auto), aggregate by time periods // If heartbeat days is configured (not auto), aggregate by time periods
if (this.heartbeatBarRange && this.heartbeatBarRange !== "auto") { if (this.heartbeatBarDays > 0) {
return this.aggregatedBeatList; return this.aggregatedBeatList;
} }
@ -137,15 +137,8 @@ export default {
const now = dayjs(); const now = dayjs();
const buckets = []; const buckets = [];
// Parse range to get total hours // Calculate total hours from days
let totalHours; const totalHours = this.heartbeatBarDays * 24;
if (this.heartbeatBarRange.endsWith("h")) {
totalHours = parseInt(this.heartbeatBarRange);
} else if (this.heartbeatBarRange.endsWith("d")) {
totalHours = parseInt(this.heartbeatBarRange) * 24;
} else {
totalHours = 90 * 24; // Fallback
}
// Use dynamic maxBeat calculated from screen size // Use dynamic maxBeat calculated from screen size
const totalBuckets = this.maxBeat > 0 ? this.maxBeat : 50; const totalBuckets = this.maxBeat > 0 ? this.maxBeat : 50;
@ -252,7 +245,7 @@ export default {
*/ */
timeStyle() { timeStyle() {
// For aggregated mode, don't use padding-based positioning // For aggregated mode, don't use padding-based positioning
if (this.heartbeatBarRange && this.heartbeatBarRange !== "auto") { if (this.heartbeatBarDays > 0) {
return { return {
"margin-left": "0px", "margin-left": "0px",
}; };
@ -269,18 +262,12 @@ export default {
* @returns {string} The time elapsed in minutes or hours. * @returns {string} The time elapsed in minutes or hours.
*/ */
timeSinceFirstBeat() { timeSinceFirstBeat() {
// For aggregated beats, calculate from the configured range // For aggregated beats, calculate from the configured days
if (this.heartbeatBarRange && this.heartbeatBarRange !== "auto") { if (this.heartbeatBarDays > 0) {
if (this.heartbeatBarRange.endsWith("h")) { if (this.heartbeatBarDays < 2) {
const hours = parseInt(this.heartbeatBarRange); return (this.heartbeatBarDays * 24) + "h";
return hours + "h";
} else if (this.heartbeatBarRange.endsWith("d")) {
const days = parseInt(this.heartbeatBarRange);
if (days < 2) {
return (days * 24) + "h";
} else { } else {
return days + "d"; return this.heartbeatBarDays + "d";
}
} }
} }
@ -385,7 +372,7 @@ export default {
} }
// For aggregated beats, show time range and status // For aggregated beats, show time range and status
if (beat.beats !== undefined && this.heartbeatBarRange && this.heartbeatBarRange !== "auto") { if (beat.beats !== undefined && this.heartbeatBarDays > 0) {
const start = this.$root.datetime(beat.start); const start = this.$root.datetime(beat.start);
const end = this.$root.datetime(beat.end); const end = this.$root.datetime(beat.end);
const statusText = beat.status === 1 ? "Up" : beat.status === 0 ? "Down" : beat.status === 3 ? "Maintenance" : "No Data"; const statusText = beat.status === 1 ? "Up" : beat.status === 0 ? "Down" : beat.status === 3 ? "Maintenance" : "No Data";

View file

@ -73,7 +73,7 @@
</div> </div>
</div> </div>
<div :key="$root.userHeartbeatBar" class="col-6"> <div :key="$root.userHeartbeatBar" class="col-6">
<HeartbeatBar size="mid" :monitor-id="monitor.element.id" :heartbeat-bar-range="heartbeatBarRange" /> <HeartbeatBar size="mid" :monitor-id="monitor.element.id" :heartbeat-bar-days="heartbeatBarDays" />
</div> </div>
</div> </div>
</div> </div>
@ -115,10 +115,10 @@ export default {
showCertificateExpiry: { showCertificateExpiry: {
type: Boolean, type: Boolean,
}, },
/** Heartbeat bar range */ /** Heartbeat bar days */
heartbeatBarRange: { heartbeatBarDays: {
type: String, type: Number,
default: "auto", default: 0,
} }
}, },
data() { data() {

View file

@ -375,17 +375,8 @@
"Footer Text": "Footer Text", "Footer Text": "Footer Text",
"Refresh Interval": "Refresh Interval", "Refresh Interval": "Refresh Interval",
"Refresh Interval Description": "The status page will do a full site refresh every {0} seconds", "Refresh Interval Description": "The status page will do a full site refresh every {0} seconds",
"Heartbeat Bar Range": "Heartbeat Bar Range", "Heartbeat Bar Days": "Heartbeat Bar Days",
"6 hours": "6 hours", "Number of days of heartbeat history to show (0 = auto)": "Number of days of heartbeat history to show (0 = auto)",
"12 hours": "12 hours",
"24 hours": "24 hours",
"7 days": "7 days",
"30 days": "30 days",
"60 days": "60 days",
"90 days": "90 days",
"180 days": "180 days",
"365 days": "365 days",
"How much heartbeat history to show in the status page": "How much heartbeat history to show in the status page",
"Show Powered By": "Show Powered By", "Show Powered By": "Show Powered By",
"Domain Names": "Domain Names", "Domain Names": "Domain Names",
"signedInDisp": "Signed in as {0}", "signedInDisp": "Signed in as {0}",

View file

@ -43,21 +43,10 @@
</div> </div>
<div class="my-3"> <div class="my-3">
<label for="heartbeat-bar-range" class="form-label">{{ $t("Heartbeat Bar Range") }}</label> <label for="heartbeat-bar-days" class="form-label">{{ $t("Heartbeat Bar Days") }}</label>
<select id="heartbeat-bar-range" v-model="config.heartbeatBarRange" class="form-select" data-testid="heartbeat-bar-range-select"> <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">
<option value="auto">{{ $t("Auto") }}</option>
<option value="6h">{{ $t("6 hours") }}</option>
<option value="12h">{{ $t("12 hours") }}</option>
<option value="24h">{{ $t("24 hours") }}</option>
<option value="7d">{{ $t("7 days") }}</option>
<option value="30d">{{ $t("30 days") }}</option>
<option value="60d">{{ $t("60 days") }}</option>
<option value="90d">{{ $t("90 days") }}</option>
<option value="180d">{{ $t("180 days") }}</option>
<option value="365d">{{ $t("365 days") }}</option>
</select>
<div class="form-text"> <div class="form-text">
{{ $t("How much heartbeat history to show in the status page") }} {{ $t("Number of days of heartbeat history to show (0 = auto)") }}
</div> </div>
</div> </div>
@ -347,7 +336,7 @@
👀 {{ $t("statusPageNothing") }} 👀 {{ $t("statusPageNothing") }}
</div> </div>
<PublicGroupList :edit-mode="enableEditMode" :show-tags="config.showTags" :show-certificate-expiry="config.showCertificateExpiry" :heartbeat-bar-range="config.heartbeatBarRange" /> <PublicGroupList :edit-mode="enableEditMode" :show-tags="config.showTags" :show-certificate-expiry="config.showCertificateExpiry" :heartbeat-bar-days="config.heartbeatBarDays" />
</div> </div>
<footer class="mt-5 mb-4"> <footer class="mt-5 mb-4">