From 02291730fed212eb08d539d130b1e829e30e48e8 Mon Sep 17 00:00:00 2001 From: Louis Lam Date: Fri, 31 Mar 2023 04:04:17 +0800 Subject: [PATCH] WIP --- db/patch-maintenance-cron.sql | 9 + package-lock.json | 9 + package.json | 1 + server/database.js | 1 + server/model/maintenance.js | 232 ++++++++++-------- server/model/maintenance_timeslot.js | 67 ----- server/model/monitor.js | 26 +- .../maintenance-socket-handler.js | 41 ++-- server/uptime-kuma-server.js | 54 ++-- src/components/MaintenanceTime.vue | 6 +- src/lang/en.json | 4 + src/pages/EditMaintenance.vue | 76 ++++-- 12 files changed, 266 insertions(+), 260 deletions(-) create mode 100644 db/patch-maintenance-cron.sql diff --git a/db/patch-maintenance-cron.sql b/db/patch-maintenance-cron.sql new file mode 100644 index 000000000..55e45ddf5 --- /dev/null +++ b/db/patch-maintenance-cron.sql @@ -0,0 +1,9 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +-- 999 characters. https://stackoverflow.com/questions/46134830/maximum-length-for-cron-job +ALTER TABLE maintenance ADD cron TEXT; +ALTER TABLE maintenance ADD timezone VARCHAR(255); +ALTER TABLE maintenance ADD duration INTEGER; + +COMMIT; diff --git a/package-lock.json b/package-lock.json index d198105ec..27205042e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "command-exists": "~1.2.9", "compare-versions": "~3.6.0", "compression": "~1.7.4", + "croner": "^6.0.3", "dayjs": "~1.11.5", "dotenv": "~16.0.3", "express": "~4.17.3", @@ -7243,6 +7244,14 @@ "yup": "0.32.9" } }, + "node_modules/croner": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/croner/-/croner-6.0.3.tgz", + "integrity": "sha512-Go+s9AaI+MeZUDJ6Kp7OYXCbM3svJ0qZ3IpkGoPetZLnP5wpX8MBTEiJOTYDFokP0Ph85GFZEUTBL9fo1e4DtQ==", + "engines": { + "node": ">=6.0" + } + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", diff --git a/package.json b/package.json index a447b6a59..e92129258 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "command-exists": "~1.2.9", "compare-versions": "~3.6.0", "compression": "~1.7.4", + "croner": "^6.0.3", "dayjs": "~1.11.5", "dotenv": "~16.0.3", "express": "~4.17.3", diff --git a/server/database.js b/server/database.js index e52ae8bfc..b678714e2 100644 --- a/server/database.js +++ b/server/database.js @@ -74,6 +74,7 @@ class Database { "patch-add-description-monitor.sql": true, "patch-api-key-table.sql": true, "patch-monitor-tls.sql": true, + "patch-maintenance-cron.sql": true, }; /** diff --git a/server/model/maintenance.js b/server/model/maintenance.js index 45db63d13..8f3f322c2 100644 --- a/server/model/maintenance.js +++ b/server/model/maintenance.js @@ -3,9 +3,15 @@ const { parseTimeObject, parseTimeFromTimeObject, utcToLocal, localToUTC, log } const { timeObjectToUTC, timeObjectToLocal } = require("../util-server"); const { R } = require("redbean-node"); const dayjs = require("dayjs"); +const Cron = require("croner"); +const { UptimeKumaServer } = require("../uptime-kuma-server"); +const apicache = require("../modules/apicache"); class Maintenance extends BeanModel { + static statusList = {}; + static jobList = {}; + /** * Return an object that ready to parse to JSON for public * Only show necessary data to public @@ -15,16 +21,16 @@ class Maintenance extends BeanModel { let dateRange = []; if (this.start_date) { - dateRange.push(utcToLocal(this.start_date)); + dateRange.push(this.start_date); if (this.end_date) { - dateRange.push(utcToLocal(this.end_date)); + dateRange.push(this.end_date); } } let timeRange = []; - let startTime = timeObjectToLocal(parseTimeObject(this.start_time)); + let startTime = parseTimeObject(this.start_time); timeRange.push(startTime); - let endTime = timeObjectToLocal(parseTimeObject(this.end_time)); + let endTime = parseTimeObject(this.end_time); timeRange.push(endTime); let obj = { @@ -39,12 +45,18 @@ class Maintenance extends BeanModel { weekdays: (this.weekdays) ? JSON.parse(this.weekdays) : [], daysOfMonth: (this.days_of_month) ? JSON.parse(this.days_of_month) : [], timeslotList: [], + cron: this.cron, + duration: this.duration, + timezone: await this.getTimezone(), + timezoneOffset: await this.getTimezoneOffset(), + status: await this.getStatus(), }; - const timeslotList = await this.getTimeslotList(); - - for (let timeslot of timeslotList) { - obj.timeslotList.push(await timeslot.toPublicJSON()); + if (this.strategy === "single") { + obj.timeslotList.push({ + startDate: this.start_date, + endDate: this.end_date, + }); } if (!Array.isArray(obj.weekdays)) { @@ -55,54 +67,9 @@ class Maintenance extends BeanModel { obj.daysOfMonth = []; } - // Maintenance Status - if (!obj.active) { - obj.status = "inactive"; - } else if (obj.strategy === "manual") { - obj.status = "under-maintenance"; - } else if (obj.timeslotList.length > 0) { - let currentTimestamp = dayjs().unix(); - - for (let timeslot of obj.timeslotList) { - if (dayjs.utc(timeslot.startDate).unix() <= currentTimestamp && dayjs.utc(timeslot.endDate).unix() >= currentTimestamp) { - log.debug("timeslot", "Timeslot ID: " + timeslot.id); - log.debug("timeslot", "currentTimestamp:" + currentTimestamp); - log.debug("timeslot", "timeslot.start_date:" + dayjs.utc(timeslot.startDate).unix()); - log.debug("timeslot", "timeslot.end_date:" + dayjs.utc(timeslot.endDate).unix()); - - obj.status = "under-maintenance"; - break; - } - } - - if (!obj.status) { - obj.status = "scheduled"; - } - } else if (obj.timeslotList.length === 0) { - obj.status = "ended"; - } else { - obj.status = "unknown"; - } - return obj; } - /** - * Only get future or current timeslots only - * @returns {Promise<[]>} - */ - async getTimeslotList() { - return R.convertToBeans("maintenance_timeslot", await R.getAll(` - SELECT maintenance_timeslot.* - FROM maintenance_timeslot, maintenance - WHERE maintenance_timeslot.maintenance_id = maintenance.id - AND maintenance.id = ? - AND ${Maintenance.getActiveAndFutureMaintenanceSQLCondition()} - `, [ - this.id - ])); - } - /** * Return an object that ready to parse to JSON * @param {string} timezone If not specified, the timeRange will be in UTC @@ -135,26 +102,10 @@ class Maintenance extends BeanModel { } /** - * Get the start date and time for maintenance - * @returns {dayjs.Dayjs} Start date and time - */ - getStartDateTime() { - let startOfTheDay = dayjs.utc(this.start_date).format("HH:mm"); - log.debug("timeslot", "startOfTheDay: " + startOfTheDay); - - // Start Time - let startTimeSecond = dayjs.utc(this.start_time, "HH:mm").diff(dayjs.utc(startOfTheDay, "HH:mm"), "second"); - log.debug("timeslot", "startTime: " + startTimeSecond); - - // Bake StartDate + StartTime = Start DateTime - return dayjs.utc(this.start_date).add(startTimeSecond, "second"); - } - - /** - * Get the duraction of maintenance in seconds + * Get the duration of maintenance in seconds * @returns {number} Duration of maintenance */ - getDuration() { + calcDuration() { let duration = dayjs.utc(this.end_time, "HH:mm").diff(dayjs.utc(this.start_time, "HH:mm"), "second"); // Add 24hours if it is across day if (duration < 0) { @@ -169,30 +120,24 @@ class Maintenance extends BeanModel { * @param {Object} obj Data to fill bean with * @returns {Bean} Filled bean */ - static jsonToBean(bean, obj) { + static async jsonToBean(bean, obj) { if (obj.id) { bean.id = obj.id; } - // Apply timezone offset to timeRange, as it cannot apply automatically. - if (obj.timeRange[0]) { - timeObjectToUTC(obj.timeRange[0]); - if (obj.timeRange[1]) { - timeObjectToUTC(obj.timeRange[1]); - } - } - bean.title = obj.title; bean.description = obj.description; bean.strategy = obj.strategy; bean.interval_day = obj.intervalDay; + bean.timezone = obj.timezone; + bean.duration = obj.duration; bean.active = obj.active; if (obj.dateRange[0]) { - bean.start_date = localToUTC(obj.dateRange[0]); + bean.start_date = obj.dateRange[0]; if (obj.dateRange[1]) { - bean.end_date = localToUTC(obj.dateRange[1]); + bean.end_date = obj.dateRange[1]; } } @@ -202,38 +147,111 @@ class Maintenance extends BeanModel { bean.weekdays = JSON.stringify(obj.weekdays); bean.days_of_month = JSON.stringify(obj.daysOfMonth); + await bean.generateCron(); + return bean; } /** - * SQL conditions for active maintenance - * @returns {string} + * Run the cron */ - static getActiveMaintenanceSQLCondition() { - return ` - ( - (maintenance_timeslot.start_date <= DATETIME('now') - AND maintenance_timeslot.end_date >= DATETIME('now') - AND maintenance.active = 1) - OR - (maintenance.strategy = 'manual' AND active = 1) - ) - `; + async run() { + if (Maintenance.jobList[this.id]) { + log.debug("maintenance", "Maintenance is already running, stop it first. id: " + this.id); + this.stop(); + } + + log.debug("maintenance", "Run maintenance id: " + this.id); + + // 1.21.2 migration + if (!this.cron) { + //this.generateCron(); + //this.timezone = "UTC"; + // this.duration = + if (this.cron) { + //await R.store(this); + } + } + + if (this.strategy === "single") { + Maintenance.jobList[this.id] = new Cron(this.start_date, { timezone: await this.getTimezone() }, () => { + log.info("maintenance", "Maintenance id: " + this.id + " is under maintenance now"); + UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); + apicache.clear(); + }); + } + } - /** - * SQL conditions for active and future maintenance - * @returns {string} - */ - static getActiveAndFutureMaintenanceSQLCondition() { - return ` - ( - ((maintenance_timeslot.end_date >= DATETIME('now') - AND maintenance.active = 1) - OR - (maintenance.strategy = 'manual' AND active = 1)) - ) - `; + stop() { + if (Maintenance.jobList[this.id]) { + Maintenance.jobList[this.id].stop(); + delete Maintenance.jobList[this.id]; + } + } + + async isUnderMaintenance() { + return (await this.getStatus()) === "under-maintenance"; + } + + async getTimezone() { + if (!this.timezone) { + return await UptimeKumaServer.getInstance().getTimezone(); + } + return this.timezone; + } + + async getTimezoneOffset() { + return dayjs.tz(dayjs(), await this.getTimezone()).format("Z"); + } + + async getStatus() { + if (!this.active) { + return "inactive"; + } + + if (this.strategy === "manual") { + return "under-maintenance"; + } + + // Check if the maintenance is started + if (this.start_date && dayjs().isBefore(dayjs.tz(this.start_date, await this.getTimezone()))) { + return "scheduled"; + } + + // Check if the maintenance is ended + if (this.end_date && dayjs().isAfter(dayjs.tz(this.end_date, await this.getTimezone()))) { + return "ended"; + } + + if (this.strategy === "single") { + return "under-maintenance"; + } + + if (!Maintenance.statusList[this.id]) { + Maintenance.statusList[this.id] = "unknown"; + } + + return Maintenance.statusList[this.id]; + } + + setStatus(status) { + Maintenance.statusList[this.id] = status; + } + + async generateCron() { + log.info("maintenance", "Generate cron for maintenance id: " + this.id); + + if (this.strategy === "recurring-interval") { + let array = this.start_time.split(":"); + let hour = parseInt(array[0]); + let minute = parseInt(array[1]); + this.cron = minute + " " + hour + " */" + this.interval_day + " * *"; + this.duration = this.calcDuration(); + log.debug("maintenance", "Cron: " + this.cron); + log.debug("maintenance", "Duration: " + this.duration); + } + } } diff --git a/server/model/maintenance_timeslot.js b/server/model/maintenance_timeslot.js index dad719c74..b86c4c1be 100644 --- a/server/model/maintenance_timeslot.js +++ b/server/model/maintenance_timeslot.js @@ -151,73 +151,6 @@ class MaintenanceTimeslot extends BeanModel { } } - static async isDuplicateTimeslot(timeslot) { - let bean = await R.findOne("maintenance_timeslot", "maintenance_id = ? AND start_date = ? AND end_date = ?", [ - timeslot.maintenance_id, - timeslot.start_date, - timeslot.end_date - ]); - return bean !== null; - } - - /** - * Generate a next timeslot for all recurring types - * @param maintenance - * @param minDate - * @param {function} nextDayCallback The logic how to get the next possible day - * @param {function} isValidCallback Check the day whether is matched the current strategy - * @returns {Promise} - */ - static async handleRecurringType(maintenance, minDate, nextDayCallback, isValidCallback) { - let bean = R.dispense("maintenance_timeslot"); - - let duration = maintenance.getDuration(); - let startDateTime = maintenance.getStartDateTime(); - let endDateTime; - - // Keep generating from the first possible date, until it is ok - while (true) { - //log.debug("timeslot", "startDateTime: " + startDateTime.format()); - - // Handling out of effective date range - if (startDateTime.diff(dayjs.utc(maintenance.end_date)) > 0) { - log.debug("timeslot", "Out of effective date range"); - return null; - } - - endDateTime = startDateTime.add(duration, "second"); - - // If endDateTime is out of effective date range, use the end datetime from effective date range - if (endDateTime.diff(dayjs.utc(maintenance.end_date)) > 0) { - endDateTime = dayjs.utc(maintenance.end_date); - } - - // If minDate is set, the endDateTime must be bigger than it. - // And the endDateTime must be bigger current time - // Is valid under current recurring strategy - if ( - (!minDate || endDateTime.diff(minDate) > 0) && - endDateTime.diff(dayjs()) > 0 && - isValidCallback(startDateTime) - ) { - break; - } - startDateTime = nextDayCallback(startDateTime); - } - - bean.maintenance_id = maintenance.id; - bean.start_date = localToUTC(startDateTime); - bean.end_date = localToUTC(endDateTime); - bean.generated_next = false; - - if (!await this.isDuplicateTimeslot(bean)) { - await R.store(bean); - return bean; - } else { - log.debug("maintenance", "Duplicate timeslot, skip"); - return null; - } - } } module.exports = MaintenanceTimeslot; diff --git a/server/model/monitor.js b/server/model/monitor.js index 44460819e..b4a0ba2a3 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -16,7 +16,6 @@ const apicache = require("../modules/apicache"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent"); const { DockerHost } = require("../docker"); -const Maintenance = require("./maintenance"); const { UptimeCacheList } = require("../uptime-cache-list"); const Gamedig = require("gamedig"); @@ -1303,18 +1302,19 @@ class Monitor extends BeanModel { * @returns {Promise} */ static async isUnderMaintenance(monitorID) { - let activeCondition = Maintenance.getActiveMaintenanceSQLCondition(); - const maintenance = await R.getRow(` - SELECT COUNT(*) AS count - FROM monitor_maintenance mm - JOIN maintenance - ON mm.maintenance_id = maintenance.id - AND mm.monitor_id = ? - LEFT JOIN maintenance_timeslot - ON maintenance_timeslot.maintenance_id = maintenance.id - WHERE ${activeCondition} - LIMIT 1`, [ monitorID ]); - return maintenance.count !== 0; + const maintenanceIDList = await R.getCol(` + SELECT maintenance_id FROM monitor_maintenance + WHERE monitor_id = ? + `, [ monitorID ]); + + for (const maintenanceID of maintenanceIDList) { + const maintenance = await UptimeKumaServer.getInstance().getMaintenance(maintenanceID); + if (maintenance && await maintenance.isUnderMaintenance()) { + return true; + } + } + + return false; } /** Make sure monitor interval is between bounds */ diff --git a/server/socket-handlers/maintenance-socket-handler.js b/server/socket-handlers/maintenance-socket-handler.js index 929150cdd..2b377f2ff 100644 --- a/server/socket-handlers/maintenance-socket-handler.js +++ b/server/socket-handlers/maintenance-socket-handler.js @@ -5,7 +5,6 @@ const apicache = require("../modules/apicache"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const Maintenance = require("../model/maintenance"); const server = UptimeKumaServer.getInstance(); -const MaintenanceTimeslot = require("../model/maintenance_timeslot"); /** * Handlers for Maintenance @@ -19,10 +18,12 @@ module.exports.maintenanceSocketHandler = (socket) => { log.debug("maintenance", maintenance); - let bean = Maintenance.jsonToBean(R.dispense("maintenance"), maintenance); + let bean = await Maintenance.jsonToBean(R.dispense("maintenance"), maintenance); bean.user_id = socket.userID; let maintenanceID = await R.store(bean); - await MaintenanceTimeslot.generateTimeslot(bean); + + server.maintenanceList[maintenanceID] = bean; + bean.run(); await server.sendMaintenanceList(socket); @@ -45,17 +46,15 @@ module.exports.maintenanceSocketHandler = (socket) => { try { checkLogin(socket); - let bean = await R.findOne("maintenance", " id = ? ", [ maintenance.id ]); + let bean = server.getMaintenance(maintenance.id); if (bean.user_id !== socket.userID) { throw new Error("Permission denied."); } - Maintenance.jsonToBean(bean, maintenance); - + await Maintenance.jsonToBean(bean, maintenance); await R.store(bean); - await MaintenanceTimeslot.generateTimeslot(bean, null, true); - + await bean.run(); await server.sendMaintenanceList(socket); callback({ @@ -236,6 +235,7 @@ module.exports.maintenanceSocketHandler = (socket) => { log.debug("maintenance", `Delete Maintenance: ${maintenanceID} User ID: ${socket.userID}`); if (maintenanceID in server.maintenanceList) { + server.maintenanceList[maintenanceID].stop(); delete server.maintenanceList[maintenanceID]; } @@ -267,9 +267,16 @@ module.exports.maintenanceSocketHandler = (socket) => { log.debug("maintenance", `Pause Maintenance: ${maintenanceID} User ID: ${socket.userID}`); - await R.exec("UPDATE maintenance SET active = 0 WHERE id = ? ", [ - maintenanceID, - ]); + let maintenance = server.getMaintenance(maintenanceID); + + if (!maintenance) { + throw new Error("Maintenance not found"); + } + + maintenance.active = false; + maintenance.setStatus("inactive"); + await R.store(maintenance); + maintenance.stop(); apicache.clear(); @@ -294,9 +301,15 @@ module.exports.maintenanceSocketHandler = (socket) => { log.debug("maintenance", `Resume Maintenance: ${maintenanceID} User ID: ${socket.userID}`); - await R.exec("UPDATE maintenance SET active = 1 WHERE id = ? ", [ - maintenanceID, - ]); + let maintenance = server.getMaintenance(maintenanceID); + + if (!maintenance) { + throw new Error("Maintenance not found"); + } + + maintenance.active = true; + await R.store(maintenance); + await maintenance.run(); apicache.clear(); diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index d28f00a92..914e12e48 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -47,8 +47,6 @@ class UptimeKumaServer { */ indexHTML = ""; - generateMaintenanceTimeslotsInterval = undefined; - /** * Plugins Manager * @type {PluginsManager} @@ -112,8 +110,7 @@ class UptimeKumaServer { log.debug("DEBUG", "Timezone: " + process.env.TZ); log.debug("DEBUG", "Current Time: " + dayjs.tz().format()); - await this.generateMaintenanceTimeslots(); - this.generateMaintenanceTimeslotsInterval = setInterval(this.generateMaintenanceTimeslots, 60 * 1000); + await this.loadMaintenanceList(); } /** @@ -175,16 +172,33 @@ class UptimeKumaServer { */ async getMaintenanceJSONList(userID) { let result = {}; + for (let maintenanceID in this.maintenanceList) { + result[maintenanceID] = await this.maintenanceList[maintenanceID].toJSON(); + } + return result; + } + + /** + * Load maintenance list and run + * @param userID + * @returns {Promise} + */ + async loadMaintenanceList(userID) { + let maintenanceList = await R.findAll("maintenance", " ORDER BY end_date DESC, title", [ - let maintenanceList = await R.find("maintenance", " user_id = ? ORDER BY end_date DESC, title", [ - userID, ]); for (let maintenance of maintenanceList) { - result[maintenance.id] = await maintenance.toJSON(); + this.maintenanceList[maintenance.id] = maintenance; + maintenance.run(this); } + } - return result; + getMaintenance(maintenanceID) { + if (this.maintenanceList[maintenanceID]) { + return this.maintenanceList[maintenanceID]; + } + return null; } /** @@ -240,7 +254,7 @@ class UptimeKumaServer { * Attempt to get the current server timezone * If this fails, fall back to environment variables and then make a * guess. - * @returns {string} + * @returns {Promise} */ async getTimezone() { let timezone = await Settings.get("serverTimezone"); @@ -271,28 +285,9 @@ class UptimeKumaServer { dayjs.tz.setDefault(timezone); } - /** Load the timeslots for maintenance */ - async generateMaintenanceTimeslots() { - log.debug("maintenance", "Routine: Generating Maintenance Timeslots"); - - // Prevent #2776 - // Remove duplicate maintenance_timeslot with same start_date, end_date and maintenance_id - await R.exec("DELETE FROM maintenance_timeslot WHERE id NOT IN (SELECT MIN(id) FROM maintenance_timeslot GROUP BY start_date, end_date, maintenance_id)"); - - let list = await R.find("maintenance_timeslot", " generated_next = 0 AND start_date <= DATETIME('now') "); - - for (let maintenanceTimeslot of list) { - let maintenance = await maintenanceTimeslot.maintenance; - await MaintenanceTimeslot.generateTimeslot(maintenance, maintenanceTimeslot.end_date, false); - maintenanceTimeslot.generated_next = true; - await R.store(maintenanceTimeslot); - } - - } - /** Stop the server */ async stop() { - clearTimeout(this.generateMaintenanceTimeslotsInterval); + } loadPlugins() { @@ -341,5 +336,4 @@ module.exports = { }; // Must be at the end -const MaintenanceTimeslot = require("./model/maintenance_timeslot"); const { MonitorType } = require("./monitor-types/monitor-type"); diff --git a/src/components/MaintenanceTime.vue b/src/components/MaintenanceTime.vue index 07d657400..6da21fe01 100644 --- a/src/components/MaintenanceTime.vue +++ b/src/components/MaintenanceTime.vue @@ -4,10 +4,10 @@ {{ $t("Manual") }}
- {{ maintenance.timeslotList[0].startDateServerTimezone }} + {{ maintenance.timeslotList[0].startDate }} - - {{ maintenance.timeslotList[0].endDateServerTimezone }} - (UTC{{ maintenance.timeslotList[0].serverTimezoneOffset }}) + {{ maintenance.timeslotList[0].endDate }} + (UTC{{ maintenance.timezoneOffset }})
diff --git a/src/lang/en.json b/src/lang/en.json index e7656c474..cf7185a89 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -394,6 +394,10 @@ "backupRecommend": "Please backup the volume or the data folder (./data/) directly instead.", "Optional": "Optional", "or": "or", + "sameAsServerTimezone": "Same as Server Timezone", + "startDateTime": "Start Date/Time", + "endDateTime": "End Date/Time", + "cronExpression": "Cron Expression", "recurringInterval": "Interval", "Recurring": "Recurring", "strategyManual": "Active/Inactive Manually", diff --git a/src/pages/EditMaintenance.vue b/src/pages/EditMaintenance.vue index 00e649381..be2abe8cb 100644 --- a/src/pages/EditMaintenance.vue +++ b/src/pages/EditMaintenance.vue @@ -85,14 +85,13 @@

{{ $t("Date and Time") }}

-
⚠️ {{ $t("warningTimezone") }}: {{ $root.info.serverTimezone }} ({{ $root.info.serverTimezoneOffset }})
-