mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-07-19 07:44:02 +02:00
simplified to day int instead of custom options accross all components.
This commit is contained in:
parent
b41d10ec27
commit
ace9ff20b5
9 changed files with 78 additions and 203 deletions
|
@ -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");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,48 +87,57 @@ 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) {
|
||||||
|
// Auto mode - use original LIMIT 100 logic
|
||||||
// Try to use aggregated data from stat tables for better performance
|
let list = await R.getAll(`
|
||||||
const aggregatedData = await getAggregatedHeartbeatData(monitorID, heartbeatRange);
|
SELECT * FROM heartbeat
|
||||||
|
WHERE monitor_id = ?
|
||||||
if (aggregatedData) {
|
ORDER BY time DESC
|
||||||
// Use pre-aggregated stat data
|
LIMIT 100
|
||||||
heartbeatList[monitorID] = aggregatedData;
|
`, [
|
||||||
} else {
|
monitorID,
|
||||||
// Fall back to raw heartbeat data (auto mode or no stat data)
|
]);
|
||||||
if (heartbeatRange === "auto") {
|
|
||||||
// Auto mode - use original LIMIT 100 logic
|
|
||||||
list = await R.getAll(`
|
|
||||||
SELECT * FROM heartbeat
|
|
||||||
WHERE monitor_id = ?
|
|
||||||
ORDER BY time DESC
|
|
||||||
LIMIT 100
|
|
||||||
`, [
|
|
||||||
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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
};
|
|
|
@ -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 {
|
||||||
} else if (this.heartbeatBarRange.endsWith("d")) {
|
return this.heartbeatBarDays + "d";
|
||||||
const days = parseInt(this.heartbeatBarRange);
|
|
||||||
if (days < 2) {
|
|
||||||
return (days * 24) + "h";
|
|
||||||
} else {
|
|
||||||
return days + "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";
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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}",
|
||||||
|
|
|
@ -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">
|
||||||
|
|
Loading…
Add table
Reference in a new issue