summaryrefslogtreecommitdiffstats
path: root/server/model/maintenance.js
blob: 7111a18cb57ccf04b7a5be13cae39d0b15c13933 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
const { BeanModel } = require("redbean-node/dist/bean-model");
const { parseTimeObject, parseTimeFromTimeObject, log } = require("../../src/util");
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 {

    /**
     * Return an object that ready to parse to JSON for public
     * Only show necessary data to public
     * @returns {Promise<object>} Object ready to parse
     */
    async toPublicJSON() {

        let dateRange = [];
        if (this.start_date) {
            dateRange.push(this.start_date);
        } else {
            dateRange.push(null);
        }

        if (this.end_date) {
            dateRange.push(this.end_date);
        }

        let timeRange = [];
        let startTime = parseTimeObject(this.start_time);
        timeRange.push(startTime);
        let endTime = parseTimeObject(this.end_time);
        timeRange.push(endTime);

        let obj = {
            id: this.id,
            title: this.title,
            description: this.description,
            strategy: this.strategy,
            intervalDay: this.interval_day,
            active: !!this.active,
            dateRange: dateRange,
            timeRange: timeRange,
            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,
            durationMinutes: parseInt(this.duration / 60),
            timezone: await this.getTimezone(),         // Only valid timezone
            timezoneOption: this.timezone,               // Mainly for dropdown menu, because there is a option "SAME_AS_SERVER"
            timezoneOffset: await this.getTimezoneOffset(),
            status: await this.getStatus(),
        };

        if (this.strategy === "manual") {
            // Do nothing, no timeslots
        } else if (this.strategy === "single") {
            obj.timeslotList.push({
                startDate: this.start_date,
                endDate: this.end_date,
            });
        } else {
            // Should be cron or recurring here
            if (this.beanMeta.job) {
                let runningTimeslot = this.getRunningTimeslot();

                if (runningTimeslot) {
                    obj.timeslotList.push(runningTimeslot);
                }

                let nextRunDate = this.beanMeta.job.nextRun();
                if (nextRunDate) {
                    let startDateDayjs = dayjs(nextRunDate);

                    let startDate = startDateDayjs.toISOString();
                    let endDate = startDateDayjs.add(this.duration, "second").toISOString();

                    obj.timeslotList.push({
                        startDate,
                        endDate,
                    });
                }
            }
        }

        if (!Array.isArray(obj.weekdays)) {
            obj.weekdays = [];
        }

        if (!Array.isArray(obj.daysOfMonth)) {
            obj.daysOfMonth = [];
        }

        return obj;
    }

    /**
     * Return an object that ready to parse to JSON
     * @param {string} timezone If not specified, the timeRange will be in UTC
     * @returns {Promise<object>} Object ready to parse
     */
    async toJSON(timezone = null) {
        return this.toPublicJSON(timezone);
    }

    /**
     * Get a list of weekdays that the maintenance is active for
     * Monday=1, Tuesday=2 etc.
     * @returns {number[]} Array of active weekdays
     */
    getDayOfWeekList() {
        log.debug("timeslot", "List: " + this.weekdays);
        return JSON.parse(this.weekdays).sort(function (a, b) {
            return a - b;
        });
    }

    /**
     * Get a list of days in month that maintenance is active for
     * @returns {number[]|string[]} Array of active days in month
     */
    getDayOfMonthList() {
        return JSON.parse(this.days_of_month).sort(function (a, b) {
            return a - b;
        });
    }

    /**
     * Get the duration of maintenance in seconds
     * @returns {number} Duration of maintenance
     */
    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) {
            duration += 24 * 3600;
        }
        return duration;
    }

    /**
     * Convert data from socket to bean
     * @param {Bean} bean Bean to fill in
     * @param {object} obj Data to fill bean with
     * @returns {Promise<Bean>} Filled bean
     */
    static async jsonToBean(bean, obj) {
        if (obj.id) {
            bean.id = obj.id;
        }

        bean.title = obj.title;
        bean.description = obj.description;
        bean.strategy = obj.strategy;
        bean.interval_day = obj.intervalDay;
        bean.timezone = obj.timezoneOption;
        bean.active = obj.active;

        if (obj.dateRange[0]) {
            bean.start_date = obj.dateRange[0];
        } else {
            bean.start_date = null;
        }

        if (obj.dateRange[1]) {
            bean.end_date = obj.dateRange[1];
        } else {
            bean.end_date = null;
        }

        if (bean.strategy === "cron") {
            bean.duration = obj.durationMinutes * 60;
            bean.cron = obj.cron;
            this.validateCron(bean.cron);
        }

        if (bean.strategy.startsWith("recurring-")) {
            bean.start_time = parseTimeFromTimeObject(obj.timeRange[0]);
            bean.end_time = parseTimeFromTimeObject(obj.timeRange[1]);
            bean.weekdays = JSON.stringify(obj.weekdays);
            bean.days_of_month = JSON.stringify(obj.daysOfMonth);
            await bean.generateCron();
            this.validateCron(bean.cron);
        }
        return bean;
    }

    /**
     * Throw error if cron is invalid
     * @param {string|Date} cron Pattern or date
     * @returns {void}
     */
    static validateCron(cron) {
        let job = new Cron(cron, () => {});
        job.stop();
    }

    /**
     * Run the cron
     * @param {boolean} throwError Should an error be thrown on failure
     * @returns {Promise<void>}
     */
    async run(throwError = false) {
        if (this.beanMeta.job) {
            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) {
            await this.generateCron();
            if (!this.timezone) {
                this.timezone = "UTC";
            }
            if (this.cron) {
                await R.store(this);
            }
        }

        if (this.strategy === "manual") {
            // Do nothing, because it is controlled by the user
        } else if (this.strategy === "single") {
            this.beanMeta.job = 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();
            });
        } else if (this.cron != null) {
            // Here should be cron or recurring
            try {
                this.beanMeta.status = "scheduled";

                let startEvent = (customDuration = 0) => {
                    log.info("maintenance", "Maintenance id: " + this.id + " is under maintenance now");

                    this.beanMeta.status = "under-maintenance";
                    clearTimeout(this.beanMeta.durationTimeout);

                    let duration = this.inferDuration(customDuration);

                    UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);

                    this.beanMeta.durationTimeout = setTimeout(() => {
                        // End of maintenance for this timeslot
                        this.beanMeta.status = "scheduled";
                        UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);
                    }, duration);
                };

                // Create Cron
                if (this.strategy === "recurring-interval") {
                    // For recurring-interval, Croner needs to have interval and startAt
                    const startDate = dayjs(this.startDate);
                    const [ hour, minute ] = this.startTime.split(":");
                    const startDateTime = startDate.hour(hour).minute(minute);
                    this.beanMeta.job = new Cron(this.cron, {
                        timezone: await this.getTimezone(),
                        interval: this.interval_day * 24 * 60 * 60,
                        startAt: startDateTime.toISOString(),
                    }, startEvent);
                } else {
                    this.beanMeta.job = new Cron(this.cron, {
                        timezone: await this.getTimezone(),
                    }, startEvent);
                }

                // Continue if the maintenance is still in the window
                let runningTimeslot = this.getRunningTimeslot();
                let current = dayjs();

                if (runningTimeslot) {
                    let duration = dayjs(runningTimeslot.endDate).diff(current, "second") * 1000;
                    log.debug("maintenance", "Maintenance id: " + this.id + " Remaining duration: " + duration + "ms");
                    startEvent(duration);
                }

            } catch (e) {
                log.error("maintenance", "Error in maintenance id: " + this.id);
                log.error("maintenance", "Cron: " + this.cron);
                log.error("maintenance", e);

                if (throwError) {
                    throw e;
                }
            }

        } else {
            log.error("maintenance", "Maintenance id: " + this.id + " has no cron");
        }
    }

    /**
     * Get timeslots where maintenance is running
     * @returns {object|null} Maintenance time slot
     */
    getRunningTimeslot() {
        let start = dayjs(this.beanMeta.job.nextRun(dayjs().add(-this.duration, "second").toDate()));
        let end = start.add(this.duration, "second");
        let current = dayjs();

        if (current.isAfter(start) && current.isBefore(end)) {
            return {
                startDate: start.toISOString(),
                endDate: end.toISOString(),
            };
        } else {
            return null;
        }
    }

    /**
     * Calculate the maintenance duration
     * @param {number} customDuration - The custom duration in milliseconds.
     * @returns {number} The inferred duration in milliseconds.
     */
    inferDuration(customDuration) {
        // Check if duration is still in the window. If not, use the duration from the current time to the end of the window
        if (customDuration > 0) {
            return customDuration;
        } else if (this.end_date) {
            let d = dayjs(this.end_date).diff(dayjs(), "second");
            if (d < this.duration) {
                return d * 1000;
            }
        }
        return this.duration * 1000;
    }

    /**
     * Stop the maintenance
     * @returns {void}
     */
    stop() {
        if (this.beanMeta.job) {
            this.beanMeta.job.stop();
            delete this.beanMeta.job;
        }
    }

    /**
     * Is this maintenance currently active
     * @returns {Promise<boolean>} The maintenance is active?
     */
    async isUnderMaintenance() {
        return (await this.getStatus()) === "under-maintenance";
    }

    /**
     * Get the timezone of the maintenance
     * @returns {Promise<string>} timezone
     */
    async getTimezone() {
        if (!this.timezone || this.timezone === "SAME_AS_SERVER") {
            return await UptimeKumaServer.getInstance().getTimezone();
        }
        return this.timezone;
    }

    /**
     * Get offset for timezone
     * @returns {Promise<string>} offset
     */
    async getTimezoneOffset() {
        return dayjs.tz(dayjs(), await this.getTimezone()).format("Z");
    }

    /**
     * Get the current status of the maintenance
     * @returns {Promise<string>} Current status
     */
    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 (!this.beanMeta.status) {
            return "unknown";
        }

        return this.beanMeta.status;
    }

    /**
     * Generate Cron for recurring maintenance
     * @returns {Promise<void>}
     */
    async generateCron() {
        log.info("maintenance", "Generate cron for maintenance id: " + this.id);

        if (this.strategy === "cron") {
            // Do nothing for cron
        } else if (!this.strategy.startsWith("recurring-")) {
            this.cron = "";
        } else if (this.strategy === "recurring-interval") {
            // For intervals, the pattern is calculated in the run function as the interval-option is set
            this.cron = "* * * * *";
            this.duration = this.calcDuration();
            log.debug("maintenance", "Cron: " + this.cron);
            log.debug("maintenance", "Duration: " + this.duration);
        } else if (this.strategy === "recurring-weekday") {
            let list = this.getDayOfWeekList();
            let array = this.start_time.split(":");
            let hour = parseInt(array[0]);
            let minute = parseInt(array[1]);
            this.cron = minute + " " + hour + " * * " + list.join(",");
            this.duration = this.calcDuration();
        } else if (this.strategy === "recurring-day-of-month") {
            let list = this.getDayOfMonthList();
            let array = this.start_time.split(":");
            let hour = parseInt(array[0]);
            let minute = parseInt(array[1]);

            let dayList = [];

            for (let day of list) {
                if (typeof day === "string" && day.startsWith("lastDay")) {
                    if (day === "lastDay1") {
                        dayList.push("L");
                    }
                    // Unfortunately, lastDay2-4 is not supported by cron
                } else {
                    dayList.push(day);
                }
            }

            // Remove duplicate
            dayList = [ ...new Set(dayList) ];

            this.cron = minute + " " + hour + " " + dayList.join(",") + " * *";
            this.duration = this.calcDuration();
        }

    }
}

module.exports = Maintenance;