mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-07-19 07:44:02 +02:00
feat: 3 months history in status page
This commit is contained in:
parent
f27811c394
commit
e9a59a68dc
12 changed files with 887 additions and 21 deletions
162
DAILY_VIEW_FEATURE.md
Normal file
162
DAILY_VIEW_FEATURE.md
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
# Daily View Feature for Status Pages
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds the ability to show 3-month history on Uptime Kuma status pages with daily aggregation instead of the standard 100 recent heartbeats. Each monitor can be individually configured to use either the regular heartbeat view or the new daily view.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### ✅ **Per-Monitor Configuration**
|
||||||
|
- Each monitor on a status page can individually be set to use daily view or regular view
|
||||||
|
- Settings are stored in the database and persist across restarts
|
||||||
|
- Easy toggle in the monitor settings dialog
|
||||||
|
- **Fixed**: Daily view checkbox now properly saves and persists between page reloads
|
||||||
|
|
||||||
|
### ✅ **Daily Aggregation**
|
||||||
|
- Fetches 3 months of heartbeat data and aggregates by day
|
||||||
|
- Each day shows the overall status based on the majority of heartbeats
|
||||||
|
- Maintenance status takes priority over other statuses
|
||||||
|
- Average ping time is calculated for each day
|
||||||
|
- Daily uptime percentage is displayed
|
||||||
|
|
||||||
|
### ✅ **Missing Data Visualization**
|
||||||
|
- **New**: Days with missing data are displayed as grey bars instead of empty space
|
||||||
|
- Ensures visual consistency across all monitors
|
||||||
|
- New monitors show complete 3-month timeline with grey bars for days before monitoring started
|
||||||
|
- Maintains consistent width and alignment with older monitors
|
||||||
|
|
||||||
|
### ✅ **Smart Data Routing**
|
||||||
|
- Mixed mode: Some monitors can use daily view while others use regular view on the same status page
|
||||||
|
- Backend automatically determines data type needed per monitor
|
||||||
|
- Frontend components dynamically render appropriate visualization
|
||||||
|
|
||||||
|
### ✅ **Enhanced Tooltips**
|
||||||
|
- Daily view shows date, status, uptime percentage, and average ping
|
||||||
|
- Missing days show "No data" with the date
|
||||||
|
- Special handling for "Today" and "Yesterday"
|
||||||
|
|
||||||
|
## Technical Implementation
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
|
||||||
|
1. **Database Migration**: Added `daily_view` boolean column to `monitor_group` table
|
||||||
|
2. **API Endpoint**: `/api/status-page/heartbeat-daily/:slug` returns mixed data based on monitor settings
|
||||||
|
3. **Data Aggregation**: SQL queries group by date and calculate daily statistics
|
||||||
|
4. **Monitor Model**: Updated to include `dailyView` property in public JSON
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
|
||||||
|
1. **DailyHeartbeatBar Component**: New component for 3-month daily timeline visualization
|
||||||
|
2. **Missing Data Generation**: Generates complete 3-month timeline with grey placeholders
|
||||||
|
3. **Conditional Rendering**: PublicGroupList dynamically chooses between components
|
||||||
|
4. **Settings Dialog**: Added toggle for daily view in monitor settings
|
||||||
|
5. **Persistence Fix**: Proper boolean conversion for database values
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### For Administrators
|
||||||
|
|
||||||
|
1. **Enable Daily View for a Monitor**:
|
||||||
|
- Open the status page in edit mode
|
||||||
|
- Click the settings icon next to any monitor
|
||||||
|
- Toggle "Daily View" checkbox
|
||||||
|
- Save the status page
|
||||||
|
|
||||||
|
2. **Visual Differences**:
|
||||||
|
- **Regular View**: Shows last 100 individual heartbeats as small dots
|
||||||
|
- **Daily View**: Shows up to 90 days as wider bars representing daily aggregates
|
||||||
|
- **Missing Days**: Grey bars maintain visual consistency
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
|
||||||
|
- Status pages automatically display the appropriate view for each monitor
|
||||||
|
- Daily view shows broader trends over months rather than minute-by-minute detail
|
||||||
|
- Hover over any day to see detailed statistics
|
||||||
|
- Grey bars indicate days when the monitor wasn't active or data is missing
|
||||||
|
|
||||||
|
## API Examples
|
||||||
|
|
||||||
|
### Daily Data Response
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"heartbeatList": {
|
||||||
|
"1": [
|
||||||
|
{
|
||||||
|
"status": 1,
|
||||||
|
"time": "2025-06-10 09:40:26.626",
|
||||||
|
"ping": 177,
|
||||||
|
"uptime": 1.0,
|
||||||
|
"date": "2025-06-10",
|
||||||
|
"dailyStats": {
|
||||||
|
"total": 19,
|
||||||
|
"up": 19,
|
||||||
|
"down": 0,
|
||||||
|
"pending": 0,
|
||||||
|
"maintenance": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"uptimeList": { "1": 1.0 },
|
||||||
|
"dailyViewSettings": { "1": 1 },
|
||||||
|
"hasMixedData": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Missing Day Placeholder
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": -1,
|
||||||
|
"time": null,
|
||||||
|
"ping": null,
|
||||||
|
"uptime": null,
|
||||||
|
"date": "2025-06-09",
|
||||||
|
"missing": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- **Database**: Migration adds `daily_view` column with default `false`
|
||||||
|
- **Performance**: Daily data is cached for 5 minutes vs 1 minute for regular heartbeats
|
||||||
|
- **Timeline**: Fixed 3-month window (90 days) regardless of monitor check frequency
|
||||||
|
- **Compatibility**: Fully backward compatible - existing monitors continue to use regular view
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Better Long-term Visibility**: See patterns and trends over months
|
||||||
|
2. **Performance**: Fewer data points to load and render for long time periods
|
||||||
|
3. **Consistency**: All monitors show same time scale regardless of check frequency
|
||||||
|
4. **Flexibility**: Per-monitor configuration allows mixed usage
|
||||||
|
5. **Complete Timeline**: Missing data visualization prevents confusing gaps
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Migration: 2025-06-10-0000-add-daily-view.js
|
||||||
|
ALTER TABLE monitor_group ADD COLUMN daily_view BOOLEAN DEFAULT FALSE;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `db/knex_migrations/2025-06-10-0000-add-daily-view.js` - Database migration
|
||||||
|
- `server/routers/status-page-router.js` - Mixed data API endpoint
|
||||||
|
- `server/socket-handlers/status-page-socket-handler.js` - Save daily view setting
|
||||||
|
- `server/model/group.js` - Include daily_view in SQL queries
|
||||||
|
- `server/model/monitor.js` - Add dailyView to public JSON
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `src/components/DailyHeartbeatBar.vue` - New daily timeline component
|
||||||
|
- `src/components/PublicGroupList.vue` - Conditional component rendering
|
||||||
|
- `src/components/MonitorSettingDialog.vue` - Daily view toggle UI
|
||||||
|
- `src/pages/StatusPage.vue` - Mixed data handling
|
||||||
|
- `src/mixins/socket.js` - Additional data properties
|
||||||
|
- `src/lang/en.json` - New translation keys
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
1. **Daily view not persisting**: Ensure the database migration has run successfully
|
||||||
|
2. **Missing grey bars**: Check that the monitor has `dailyView: true` in the API response
|
||||||
|
3. **Timeline not showing**: Verify the `/api/status-page/heartbeat-daily/:slug` endpoint returns data
|
||||||
|
4. **Performance issues**: Daily data is cached, but initial load may be slower for large datasets
|
13
db/knex_migrations/2025-06-10-0000-add-daily-view.js
Normal file
13
db/knex_migrations/2025-06-10-0000-add-daily-view.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
// Add column daily_view to monitor_group table
|
||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.alterTable("monitor_group", function (table) {
|
||||||
|
table.boolean("daily_view").defaultTo(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema.alterTable("monitor_group", function (table) {
|
||||||
|
table.dropColumn("daily_view");
|
||||||
|
});
|
||||||
|
};
|
|
@ -33,7 +33,7 @@ class Group extends BeanModel {
|
||||||
*/
|
*/
|
||||||
async getMonitorList() {
|
async getMonitorList() {
|
||||||
return R.convertToBeans("monitor", await R.getAll(`
|
return R.convertToBeans("monitor", await R.getAll(`
|
||||||
SELECT monitor.*, monitor_group.send_url, monitor_group.custom_url FROM monitor, monitor_group
|
SELECT monitor.*, monitor_group.send_url, monitor_group.custom_url, monitor_group.daily_view FROM monitor, monitor_group
|
||||||
WHERE monitor.id = monitor_group.monitor_id
|
WHERE monitor.id = monitor_group.monitor_id
|
||||||
AND group_id = ?
|
AND group_id = ?
|
||||||
ORDER BY monitor_group.weight
|
ORDER BY monitor_group.weight
|
||||||
|
|
|
@ -60,6 +60,11 @@ class Monitor extends BeanModel {
|
||||||
obj.url = this.customUrl ?? this.url;
|
obj.url = this.customUrl ?? this.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add daily_view field from monitor_group table
|
||||||
|
if (this.daily_view !== undefined) {
|
||||||
|
obj.dailyView = this.daily_view;
|
||||||
|
}
|
||||||
|
|
||||||
if (showTags) {
|
if (showTags) {
|
||||||
obj.tags = await this.getTags();
|
obj.tags = await this.getTags();
|
||||||
}
|
}
|
||||||
|
|
|
@ -111,6 +111,126 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Status Page Daily Aggregated Heartbeat Data (3 months)
|
||||||
|
// Can fetch only if published
|
||||||
|
router.get("/api/status-page/heartbeat-daily/:slug", cache("5 minutes"), async (request, response) => {
|
||||||
|
allowDevAllOrigin(response);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let heartbeatList = {};
|
||||||
|
let uptimeList = {};
|
||||||
|
let dailyViewSettings = {};
|
||||||
|
|
||||||
|
let slug = request.params.slug;
|
||||||
|
slug = slug.toLowerCase();
|
||||||
|
let statusPageID = await StatusPage.slugToID(slug);
|
||||||
|
|
||||||
|
// Get monitor data with daily view settings
|
||||||
|
let monitorData = await R.getAll(`
|
||||||
|
SELECT monitor_group.monitor_id, monitor_group.daily_view FROM monitor_group, \`group\`
|
||||||
|
WHERE monitor_group.group_id = \`group\`.id
|
||||||
|
AND public = 1
|
||||||
|
AND \`group\`.status_page_id = ?
|
||||||
|
`, [
|
||||||
|
statusPageID
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get 3 months of daily aggregated data
|
||||||
|
const threeMonthsAgo = new Date();
|
||||||
|
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||||
|
|
||||||
|
for (let monitor of monitorData) {
|
||||||
|
const monitorID = monitor.monitor_id;
|
||||||
|
const useDailyView = monitor.daily_view;
|
||||||
|
|
||||||
|
dailyViewSettings[monitorID] = useDailyView;
|
||||||
|
|
||||||
|
if (useDailyView) {
|
||||||
|
// Aggregate heartbeats by day over the last 3 months
|
||||||
|
let dailyData = await R.getAll(`
|
||||||
|
SELECT
|
||||||
|
DATE(time) as date,
|
||||||
|
COUNT(*) as total_beats,
|
||||||
|
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as up_beats,
|
||||||
|
SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) as down_beats,
|
||||||
|
SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) as pending_beats,
|
||||||
|
SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) as maintenance_beats,
|
||||||
|
AVG(CASE WHEN ping IS NOT NULL THEN ping END) as avg_ping,
|
||||||
|
MAX(time) as latest_time
|
||||||
|
FROM heartbeat
|
||||||
|
WHERE monitor_id = ?
|
||||||
|
AND time >= ?
|
||||||
|
GROUP BY DATE(time)
|
||||||
|
ORDER BY date ASC
|
||||||
|
`, [
|
||||||
|
monitorID,
|
||||||
|
threeMonthsAgo.toISOString()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Convert to daily heartbeat format
|
||||||
|
let processedData = dailyData.map(row => {
|
||||||
|
let status;
|
||||||
|
// Determine overall status for the day based on majority
|
||||||
|
if (row.maintenance_beats > 0) {
|
||||||
|
status = 3; // Maintenance
|
||||||
|
} else if (row.down_beats > row.up_beats / 2) {
|
||||||
|
status = 0; // Down if more than 50% down
|
||||||
|
} else if (row.up_beats > 0) {
|
||||||
|
status = 1; // Up
|
||||||
|
} else {
|
||||||
|
status = 2; // Pending
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: status,
|
||||||
|
time: row.latest_time,
|
||||||
|
ping: row.avg_ping ? Math.round(row.avg_ping) : null,
|
||||||
|
msg: null,
|
||||||
|
uptime: row.total_beats > 0 ? (row.up_beats / row.total_beats) : 0,
|
||||||
|
date: row.date,
|
||||||
|
// Additional daily stats
|
||||||
|
dailyStats: {
|
||||||
|
total: row.total_beats,
|
||||||
|
up: row.up_beats,
|
||||||
|
down: row.down_beats,
|
||||||
|
pending: row.pending_beats,
|
||||||
|
maintenance: row.maintenance_beats
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
heartbeatList[monitorID] = processedData;
|
||||||
|
} else {
|
||||||
|
// Use regular heartbeat data (last 100 beats)
|
||||||
|
let list = await R.getAll(`
|
||||||
|
SELECT * FROM heartbeat
|
||||||
|
WHERE monitor_id = ?
|
||||||
|
ORDER BY time DESC
|
||||||
|
LIMIT 100
|
||||||
|
`, [
|
||||||
|
monitorID,
|
||||||
|
]);
|
||||||
|
|
||||||
|
list = R.convertToBeans("heartbeat", list);
|
||||||
|
heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());
|
||||||
|
}
|
||||||
|
|
||||||
|
const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
|
||||||
|
uptimeList[`${monitorID}_24`] = uptimeCalculator.get24Hour().uptime;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
heartbeatList,
|
||||||
|
uptimeList,
|
||||||
|
dailyViewSettings,
|
||||||
|
hasMixedData: true
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
sendHttpError(response, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Status page's manifest.json
|
// Status page's manifest.json
|
||||||
router.get("/api/status-page/:slug/manifest.json", cache("1440 minutes"), async (request, response) => {
|
router.get("/api/status-page/:slug/manifest.json", cache("1440 minutes"), async (request, response) => {
|
||||||
allowDevAllOrigin(response);
|
allowDevAllOrigin(response);
|
||||||
|
|
|
@ -215,6 +215,10 @@ module.exports.statusPageSocketHandler = (socket) => {
|
||||||
relationBean.custom_url = monitor.url;
|
relationBean.custom_url = monitor.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (monitor.dailyView !== undefined) {
|
||||||
|
relationBean.daily_view = monitor.dailyView;
|
||||||
|
}
|
||||||
|
|
||||||
await R.store(relationBean);
|
await R.store(relationBean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
509
src/components/DailyHeartbeatBar.vue
Normal file
509
src/components/DailyHeartbeatBar.vue
Normal file
|
@ -0,0 +1,509 @@
|
||||||
|
<template>
|
||||||
|
<div ref="wrap" class="wrap" :style="wrapStyle">
|
||||||
|
<div class="hp-bar-big" :style="barStyle">
|
||||||
|
<div
|
||||||
|
v-for="(beat, index) in shortBeatList"
|
||||||
|
:key="index"
|
||||||
|
class="beat-hover-area"
|
||||||
|
:class="{ 'empty': (beat === 0) }"
|
||||||
|
:style="beatHoverAreaStyle"
|
||||||
|
:title="getBeatTitle(beat)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="beat daily-beat"
|
||||||
|
:class="{
|
||||||
|
'empty': (beat === 0),
|
||||||
|
'missing': (beat.missing || beat.status === -1),
|
||||||
|
'down': (beat.status === 0),
|
||||||
|
'pending': (beat.status === 2),
|
||||||
|
'maintenance': (beat.status === 3)
|
||||||
|
}"
|
||||||
|
:style="getBeatStyle(beat)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!$root.isMobile && size !== 'small' && beatList.length > 4 && $root.styleElapsedTime !== 'none'"
|
||||||
|
class="d-flex justify-content-between align-items-center word" :style="timeStyle"
|
||||||
|
>
|
||||||
|
<div>{{ timeSinceFirstBeat }}</div>
|
||||||
|
<div v-if="$root.styleElapsedTime === 'with-line'" class="connecting-line"></div>
|
||||||
|
<div>{{ timeSinceLastBeat }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
/** Size of the heartbeat bar */
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: "big",
|
||||||
|
},
|
||||||
|
/** ID of the monitor */
|
||||||
|
monitorId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
/** Array of the monitors daily heartbeats */
|
||||||
|
heartbeatList: {
|
||||||
|
type: Array,
|
||||||
|
default: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
beatWidth: 10,
|
||||||
|
beatHeight: 30,
|
||||||
|
hoverScale: 1.5,
|
||||||
|
beatHoverAreaPadding: 4,
|
||||||
|
move: false,
|
||||||
|
maxBeat: -1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
/**
|
||||||
|
* If heartbeat list is loaded
|
||||||
|
* @returns {boolean} True if loaded
|
||||||
|
*/
|
||||||
|
hasHeartbeat() {
|
||||||
|
return (this.$root.dailyHeartbeatList[this.monitorId] &&
|
||||||
|
this.$root.dailyHeartbeatList[this.monitorId].length > 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If heartbeatList is null, get it from $root.dailyHeartbeatList
|
||||||
|
* @returns {object} Daily heartbeat list
|
||||||
|
*/
|
||||||
|
beatList() {
|
||||||
|
if (this.heartbeatList === null) {
|
||||||
|
return this.$root.dailyHeartbeatList[this.monitorId];
|
||||||
|
} else {
|
||||||
|
return this.heartbeatList;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the amount of beats of padding needed to fill the length of shortBeatList.
|
||||||
|
* @returns {number} The amount of beats of padding needed to fill the length of shortBeatList.
|
||||||
|
*/
|
||||||
|
numPadding() {
|
||||||
|
if (!this.beatList) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let num = this.beatList.length - this.maxBeat;
|
||||||
|
|
||||||
|
if (this.move) {
|
||||||
|
num = num - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (num > 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1 * num;
|
||||||
|
},
|
||||||
|
|
||||||
|
shortBeatList() {
|
||||||
|
if (!this.$root.dailyHeartbeatList[this.monitorId]) {
|
||||||
|
return this.generateCompleteTimeline([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = this.$root.dailyHeartbeatList[this.monitorId].slice();
|
||||||
|
const completeTimeline = this.generateCompleteTimeline(result);
|
||||||
|
|
||||||
|
if (completeTimeline.length > this.maxBeat) {
|
||||||
|
return completeTimeline.slice(-this.maxBeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
return completeTimeline;
|
||||||
|
},
|
||||||
|
|
||||||
|
wrapStyle() {
|
||||||
|
let topBottom = (((this.beatHeight * this.hoverScale) - this.beatHeight) / 2);
|
||||||
|
let leftRight = (((this.beatWidth * this.hoverScale) - this.beatWidth) / 2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
padding: `${topBottom}px ${leftRight}px`,
|
||||||
|
width: "100%",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
barStyle() {
|
||||||
|
if (this.move && this.shortBeatList.length > this.maxBeat) {
|
||||||
|
let width = -(this.beatWidth + this.beatHoverAreaPadding * 2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
transition: "all ease-in-out 0.25s",
|
||||||
|
transform: `translateX(${width}px)`,
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
transform: "translateX(0)",
|
||||||
|
};
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
beatHoverAreaStyle() {
|
||||||
|
return {
|
||||||
|
padding: this.beatHoverAreaPadding + "px",
|
||||||
|
"--hover-scale": this.hoverScale,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
beatStyle() {
|
||||||
|
return {
|
||||||
|
width: this.beatWidth + "px",
|
||||||
|
height: this.beatHeight + "px",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the style object for positioning the time element.
|
||||||
|
* @returns {object} The style object containing the CSS properties for positioning the time element.
|
||||||
|
*/
|
||||||
|
timeStyle() {
|
||||||
|
return {
|
||||||
|
"margin-left": this.numPadding * (this.beatWidth + this.beatHoverAreaPadding * 2) + "px",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the time elapsed since the first valid beat.
|
||||||
|
* @returns {string} The time elapsed in days or months.
|
||||||
|
*/
|
||||||
|
timeSinceFirstBeat() {
|
||||||
|
const firstValidBeat = this.shortBeatList.at(this.numPadding);
|
||||||
|
if (!firstValidBeat || !firstValidBeat.date) return "";
|
||||||
|
|
||||||
|
const days = dayjs().diff(dayjs(firstValidBeat.date), "days");
|
||||||
|
if (days > 30) {
|
||||||
|
return Math.floor(days / 30) + "mo";
|
||||||
|
} else {
|
||||||
|
return days + "d";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the elapsed time since the last valid beat was registered.
|
||||||
|
* @returns {string} The elapsed time in days or "today".
|
||||||
|
*/
|
||||||
|
timeSinceLastBeat() {
|
||||||
|
const lastValidBeat = this.shortBeatList.at(-1);
|
||||||
|
if (!lastValidBeat || !lastValidBeat.date) return "";
|
||||||
|
|
||||||
|
const days = dayjs().diff(dayjs(lastValidBeat.date), "days");
|
||||||
|
|
||||||
|
if (days === 0) {
|
||||||
|
return this.$t("Today");
|
||||||
|
} else if (days === 1) {
|
||||||
|
return this.$t("Yesterday");
|
||||||
|
} else if (days < 7) {
|
||||||
|
return days + "d";
|
||||||
|
} else {
|
||||||
|
return Math.floor(days / 7) + "w";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
beatList: {
|
||||||
|
handler(val, oldVal) {
|
||||||
|
this.move = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.move = false;
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unmounted() {
|
||||||
|
window.removeEventListener("resize", this.resize);
|
||||||
|
},
|
||||||
|
beforeMount() {
|
||||||
|
if (this.heartbeatList === null) {
|
||||||
|
if (!(this.monitorId in this.$root.dailyHeartbeatList)) {
|
||||||
|
this.$root.dailyHeartbeatList[this.monitorId] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
if (this.size !== "big") {
|
||||||
|
this.beatWidth = 5;
|
||||||
|
this.beatHeight = 16;
|
||||||
|
this.beatHoverAreaPadding = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suddenly, have an idea how to handle it universally.
|
||||||
|
// If the pixel * ratio != Integer, then it causes render issue, round it to solve it!!
|
||||||
|
const actualWidth = this.beatWidth * window.devicePixelRatio;
|
||||||
|
const actualHoverAreaPadding = this.beatHoverAreaPadding * window.devicePixelRatio;
|
||||||
|
|
||||||
|
if (!Number.isInteger(actualWidth)) {
|
||||||
|
this.beatWidth = Math.round(actualWidth) / window.devicePixelRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(actualHoverAreaPadding)) {
|
||||||
|
this.beatHoverAreaPadding = Math.round(actualHoverAreaPadding) / window.devicePixelRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", this.resize);
|
||||||
|
this.resize();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Resize the heartbeat bar
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
resize() {
|
||||||
|
if (this.$refs.wrap) {
|
||||||
|
this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatHoverAreaPadding * 2));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the title of the beat for daily data.
|
||||||
|
* Used as the hover tooltip on the heartbeat bar.
|
||||||
|
* @param {object} beat Beat to get title from
|
||||||
|
* @returns {string} Beat title
|
||||||
|
*/
|
||||||
|
getBeatTitle(beat) {
|
||||||
|
if (!beat || beat === 0) return "";
|
||||||
|
|
||||||
|
// Handle missing data
|
||||||
|
if (beat.missing || beat.status === -1) {
|
||||||
|
const date = beat.date || beat.time.split(' ')[0];
|
||||||
|
return `${date}\nNo data available`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = beat.date || beat.time.split(' ')[0];
|
||||||
|
const uptime = Math.round(beat.uptime * 100);
|
||||||
|
const stats = beat.dailyStats;
|
||||||
|
|
||||||
|
let tooltip = `${date}\nUptime: ${uptime}%`;
|
||||||
|
|
||||||
|
if (stats) {
|
||||||
|
tooltip += `\nUp: ${stats.up}, Down: ${stats.down}`;
|
||||||
|
if (stats.pending > 0) tooltip += `, Pending: ${stats.pending}`;
|
||||||
|
if (stats.maintenance > 0) tooltip += `, Maintenance: ${stats.maintenance}`;
|
||||||
|
tooltip += `\nTotal checks: ${stats.total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (beat.ping) {
|
||||||
|
tooltip += `\nAvg ping: ${beat.ping}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tooltip;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the style for an individual beat, including opacity based on uptime
|
||||||
|
* @param {object} beat Beat object
|
||||||
|
* @returns {object} Style object
|
||||||
|
*/
|
||||||
|
getBeatStyle(beat) {
|
||||||
|
let style = {
|
||||||
|
width: this.beatWidth + "px",
|
||||||
|
height: this.beatHeight + "px",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't apply uptime opacity to missing data beats - they get CSS opacity instead
|
||||||
|
if (beat && beat.uptime !== undefined && !beat.missing && beat.status !== -1) {
|
||||||
|
// Ensure minimum opacity of 0.3 for visibility, max of 1.0
|
||||||
|
const opacity = Math.max(0.3, Math.min(1.0, beat.uptime));
|
||||||
|
style.opacity = opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a complete 3-month timeline with placeholders for missing data
|
||||||
|
* @param {Array} actualData Array of actual daily heartbeat data
|
||||||
|
* @returns {Array} Complete timeline with placeholders for missing dates
|
||||||
|
*/
|
||||||
|
generateCompleteTimeline(actualData) {
|
||||||
|
const timeline = [];
|
||||||
|
const today = dayjs().startOf('day');
|
||||||
|
const startDate = today.subtract(90, 'day'); // 3 months back
|
||||||
|
|
||||||
|
// Create a map of existing data by date for quick lookup
|
||||||
|
const dataMap = {};
|
||||||
|
actualData.forEach(beat => {
|
||||||
|
if (beat && beat.date) {
|
||||||
|
const dateKey = dayjs(beat.date).format('YYYY-MM-DD');
|
||||||
|
dataMap[dateKey] = beat;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate complete timeline from startDate to today
|
||||||
|
for (let i = 0; i <= 90; i++) {
|
||||||
|
const currentDate = startDate.add(i, 'day');
|
||||||
|
const dateKey = currentDate.format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
if (dataMap[dateKey]) {
|
||||||
|
// Use actual data if available
|
||||||
|
timeline.push(dataMap[dateKey]);
|
||||||
|
} else {
|
||||||
|
// Create placeholder for missing data
|
||||||
|
timeline.push({
|
||||||
|
status: -1, // Special status for missing data
|
||||||
|
date: dateKey,
|
||||||
|
time: dateKey + ' 00:00:00',
|
||||||
|
uptime: 0,
|
||||||
|
ping: 0,
|
||||||
|
missing: true,
|
||||||
|
dailyStats: {
|
||||||
|
total: 0,
|
||||||
|
up: 0,
|
||||||
|
down: 0,
|
||||||
|
pending: 0,
|
||||||
|
maintenance: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return timeline;
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/vars.scss";
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp-bar-big {
|
||||||
|
.beat-hover-area {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&:not(.empty):hover {
|
||||||
|
transition: all ease-in-out 0.15s;
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(var(--hover-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat {
|
||||||
|
background-color: $primary;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
|
||||||
|
/*
|
||||||
|
pointer-events needs to be changed because
|
||||||
|
tooltip momentarily disappears when crossing between .beat-hover-area and .beat
|
||||||
|
*/
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
background-color: aliceblue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.missing {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.down {
|
||||||
|
background-color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.pending {
|
||||||
|
background-color: $warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.maintenance {
|
||||||
|
background-color: $maintenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily beats get special styling
|
||||||
|
&.daily-beat {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&.down {
|
||||||
|
border-color: darken($danger, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.pending {
|
||||||
|
border-color: darken($warning, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.maintenance {
|
||||||
|
border-color: darken($maintenance, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.empty):not(.down):not(.pending):not(.maintenance):not(.missing) {
|
||||||
|
border-color: darken($primary, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.missing {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
.hp-bar-big .beat.empty {
|
||||||
|
background-color: #848484;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp-bar-big .beat.missing {
|
||||||
|
background-color: #555555;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp-bar-big .beat.daily-beat {
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
|
&.down {
|
||||||
|
border-color: lighten($danger, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.pending {
|
||||||
|
border-color: lighten($warning, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.maintenance {
|
||||||
|
border-color: lighten($maintenance, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.empty):not(.down):not(.pending):not(.maintenance):not(.missing) {
|
||||||
|
border-color: lighten($primary, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.missing {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.word {
|
||||||
|
color: $secondary-text;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connecting-line {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 1px;
|
||||||
|
background-color: #ededed;
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
margin-top: 2px;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -19,6 +19,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Daily View Toggle -->
|
||||||
|
<div class="my-3 form-check">
|
||||||
|
<input id="daily-view" v-model="monitor.dailyView" class="form-check-input" type="checkbox" data-testid="daily-view" @click="toggleDailyView(monitor.group_index, monitor.monitor_index)" />
|
||||||
|
<label class="form-check-label" for="daily-view">
|
||||||
|
{{ $t("Daily View") }}
|
||||||
|
</label>
|
||||||
|
<div class="form-text">
|
||||||
|
{{ $t("Daily View Description") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Custom URL -->
|
<!-- Custom URL -->
|
||||||
<template v-if="monitor.isClickAble">
|
<template v-if="monitor.isClickAble">
|
||||||
<label for="customUrl" class="form-label">{{ $t("Custom URL") }}</label>
|
<label for="customUrl" class="form-label">{{ $t("Custom URL") }}</label>
|
||||||
|
@ -89,6 +100,7 @@ export default {
|
||||||
group_index: group.index,
|
group_index: group.index,
|
||||||
isClickAble: this.showLink(monitor),
|
isClickAble: this.showLink(monitor),
|
||||||
url: monitor.element.url,
|
url: monitor.element.url,
|
||||||
|
dailyView: !!monitor.element.dailyView,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.MonitorSettingDialog.show();
|
this.MonitorSettingDialog.show();
|
||||||
|
@ -104,6 +116,16 @@ export default {
|
||||||
this.$root.publicGroupList[groupIndex].monitorList[index].sendUrl = !this.$root.publicGroupList[groupIndex].monitorList[index].sendUrl;
|
this.$root.publicGroupList[groupIndex].monitorList[index].sendUrl = !this.$root.publicGroupList[groupIndex].monitorList[index].sendUrl;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the daily view setting
|
||||||
|
* @param {number} groupIndex Index of group monitor is member of
|
||||||
|
* @param {number} index Index of monitor within group
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
toggleDailyView(groupIndex, index) {
|
||||||
|
this.$root.publicGroupList[groupIndex].monitorList[index].dailyView = !this.$root.publicGroupList[groupIndex].monitorList[index].dailyView;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Should a link to the monitor be shown?
|
* Should a link to the monitor be shown?
|
||||||
* Attempts to guess if a link should be shown based upon if
|
* Attempts to guess if a link should be shown based upon if
|
||||||
|
|
|
@ -73,7 +73,8 @@
|
||||||
</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" />
|
<DailyHeartbeatBar v-if="monitor.element.dailyView" size="mid" :monitor-id="monitor.element.id" />
|
||||||
|
<HeartbeatBar v-else size="mid" :monitor-id="monitor.element.id" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -89,17 +90,19 @@
|
||||||
<script>
|
<script>
|
||||||
import MonitorSettingDialog from "./MonitorSettingDialog.vue";
|
import MonitorSettingDialog from "./MonitorSettingDialog.vue";
|
||||||
import Draggable from "vuedraggable";
|
import Draggable from "vuedraggable";
|
||||||
import HeartbeatBar from "./HeartbeatBar.vue";
|
import DailyHeartbeatBar from "./DailyHeartbeatBar.vue";
|
||||||
import Uptime from "./Uptime.vue";
|
import Uptime from "./Uptime.vue";
|
||||||
import Tag from "./Tag.vue";
|
import Tag from "./Tag.vue";
|
||||||
|
import HeartbeatBar from "./HeartbeatBar.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
MonitorSettingDialog,
|
MonitorSettingDialog,
|
||||||
Draggable,
|
Draggable,
|
||||||
HeartbeatBar,
|
DailyHeartbeatBar,
|
||||||
Uptime,
|
Uptime,
|
||||||
Tag,
|
Tag,
|
||||||
|
HeartbeatBar,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
/** Are we in edit mode? */
|
/** Are we in edit mode? */
|
||||||
|
|
|
@ -1111,5 +1111,9 @@
|
||||||
"Phone numbers": "Phone numbers",
|
"Phone numbers": "Phone numbers",
|
||||||
"Sender name": "Sender name",
|
"Sender name": "Sender name",
|
||||||
"smsplanetNeedToApproveName": "Needs to be approved in the client panel",
|
"smsplanetNeedToApproveName": "Needs to be approved in the client panel",
|
||||||
"Disable URL in Notification": "Disable URL in Notification"
|
"Disable URL in Notification": "Disable URL in Notification",
|
||||||
|
"Today": "Today",
|
||||||
|
"Yesterday": "Yesterday",
|
||||||
|
"Daily View": "Daily View",
|
||||||
|
"Daily View Description": "Show 3-month history aggregated by day instead of recent individual checks"
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,8 +42,10 @@ export default {
|
||||||
maintenanceList: {},
|
maintenanceList: {},
|
||||||
apiKeyList: {},
|
apiKeyList: {},
|
||||||
heartbeatList: { },
|
heartbeatList: { },
|
||||||
|
dailyHeartbeatList: { },
|
||||||
avgPingList: { },
|
avgPingList: { },
|
||||||
uptimeList: { },
|
uptimeList: { },
|
||||||
|
isDailyData: false,
|
||||||
tlsInfoList: {},
|
tlsInfoList: {},
|
||||||
notificationList: [],
|
notificationList: [],
|
||||||
dockerHostList: [],
|
dockerHostList: [],
|
||||||
|
|
|
@ -771,29 +771,51 @@ export default {
|
||||||
updateHeartbeatList() {
|
updateHeartbeatList() {
|
||||||
// If editMode, it will use the data from websocket.
|
// If editMode, it will use the data from websocket.
|
||||||
if (! this.editMode) {
|
if (! this.editMode) {
|
||||||
axios.get("/api/status-page/heartbeat/" + this.slug).then((res) => {
|
// Fetch mixed data based on per-monitor daily view settings
|
||||||
const { heartbeatList, uptimeList } = res.data;
|
axios.get("/api/status-page/heartbeat-daily/" + this.slug).then((res) => {
|
||||||
|
const { heartbeatList, uptimeList, dailyViewSettings, hasMixedData } = res.data;
|
||||||
|
|
||||||
this.$root.heartbeatList = heartbeatList;
|
// Store both regular and daily data appropriately
|
||||||
|
this.$root.heartbeatList = {};
|
||||||
|
this.$root.dailyHeartbeatList = {};
|
||||||
this.$root.uptimeList = uptimeList;
|
this.$root.uptimeList = uptimeList;
|
||||||
|
|
||||||
const heartbeatIds = Object.keys(heartbeatList);
|
// Distribute data based on monitor's daily view setting
|
||||||
const downMonitors = heartbeatIds.reduce((downMonitorsAmount, currentId) => {
|
Object.keys(heartbeatList).forEach(monitorId => {
|
||||||
const monitorHeartbeats = heartbeatList[currentId];
|
if (dailyViewSettings[monitorId]) {
|
||||||
const lastHeartbeat = monitorHeartbeats.at(-1);
|
// This monitor uses daily view
|
||||||
|
this.$root.dailyHeartbeatList[monitorId] = heartbeatList[monitorId];
|
||||||
if (lastHeartbeat) {
|
|
||||||
return lastHeartbeat.status === 0 ? downMonitorsAmount + 1 : downMonitorsAmount;
|
|
||||||
} else {
|
} else {
|
||||||
return downMonitorsAmount;
|
// This monitor uses regular view
|
||||||
|
this.$root.heartbeatList[monitorId] = heartbeatList[monitorId];
|
||||||
}
|
}
|
||||||
}, 0);
|
});
|
||||||
|
|
||||||
favicon.badge(downMonitors);
|
const heartbeatIds = Object.keys(heartbeatList);
|
||||||
|
const favicon = new Favico({
|
||||||
|
animation: "none"
|
||||||
|
});
|
||||||
|
|
||||||
this.loadedData = true;
|
let count = 0;
|
||||||
this.lastUpdateTime = dayjs();
|
|
||||||
this.updateUpdateTimer();
|
for (let id of heartbeatIds) {
|
||||||
|
const list = heartbeatList[id];
|
||||||
|
if (list && list.length > 0) {
|
||||||
|
const beat = list[list.length - 1];
|
||||||
|
if (beat.status === 0) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
favicon.badge(count);
|
||||||
|
} else {
|
||||||
|
favicon.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
Loading…
Add table
Reference in a new issue