diff --git a/docs/incident-management.md b/docs/incident-management.md index 9bf6fbc80..5a3624bca 100644 --- a/docs/incident-management.md +++ b/docs/incident-management.md @@ -77,4 +77,5 @@ To add monitors to the incident, you need to add the monitor tag to the incident ## Maintenance -You can also create a maintenance incident. This is similar to an incident but is used to notify users about maintenance activities. Both start and end date and time are required for maintenance incidents. +You can also create a maintenance incident. This is similar to an incident but is used to notify users about maintenance activities. Both start and end date and time are required for maintenance incidents. +You can active reminders for this maintenance incident. If you do, make sure you have enabled `Send Maintenance Reminders` in Subscription page. diff --git a/migrations/20250529122728_add_reminder_column.js b/migrations/20250529122728_add_reminder_column.js new file mode 100644 index 000000000..e68122047 --- /dev/null +++ b/migrations/20250529122728_add_reminder_column.js @@ -0,0 +1,21 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export function up(knex) { + return knex.schema.alterTable("incidents", (table) => { + table.string("reminder_time").nullable(); + table.string("reminders_sent_at").defaultTo("0;0;0").notNullable(); + }); +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export function down(knex) { + return knex.schema.alterTable("incidents", (table) => { + table.dropColumn("reminder_time"); + table.dropColumn("reminder_sent_at"); + }); +} diff --git a/src/lib/server/controllers/controller.js b/src/lib/server/controllers/controller.js index 6e3474d05..3c502a790 100644 --- a/src/lib/server/controllers/controller.js +++ b/src/lib/server/controllers/controller.js @@ -758,6 +758,7 @@ export const CreateIncident = async (data) => { state: !!data.state ? data.state : "INVESTIGATING", incident_type: !!data.incident_type ? data.incident_type : "INCIDENT", incident_source: !!data.incident_source ? data.incident_source : "DASHBOARD", + reminder_time: !!data.reminder_time ? data.reminder_time : null, }; //incident_type == INCIDENT delete endDateTime @@ -799,6 +800,7 @@ export const UpdateIncident = async (incident_id, data) => { status: data.status || incidentExists.status, state: data.state || incidentExists.state, end_date_time: data.end_date_time || incidentExists.end_date_time, + reminder_time: data.reminder_time === "" ? null : data.reminder_time || incidentExists.reminder_time, }; //check if updateObject same as incidentExists @@ -811,6 +813,7 @@ export const UpdateIncident = async (incident_id, data) => { status: incidentExists.status, state: incidentExists.state, end_date_time: incidentExists.end_date_time, + reminder_time: incidentExists.reminder_time, }) ) { PushDataToQueue(incident_id, "updateIncident", { @@ -820,6 +823,31 @@ export const UpdateIncident = async (incident_id, data) => { }); } + // Check if dates has changed and reset reminders if necessary. + const currentTime = Math.floor(Date.now() / 1000); + let reminders_sent_at = incidentExists.reminders_sent_at + ? incidentExists.reminders_sent_at.split(";") + : ["0", "0", "0"]; + + let resetReminders = false; + if ( + data.start_date_time && + data.start_date_time != incidentExists.start_date_time && + data.start_date_time > currentTime + ) { + reminders_sent_at[1] = "0"; + resetReminders = true; + } + + if (data.end_date_time && data.end_date_time != incidentExists.end_date_time && data.end_date_time > currentTime) { + reminders_sent_at[2] = "0"; + resetReminders = true; + } + + if (resetReminders) { + await db.updateMaintenanceRemindersSentAt(incident_id, reminders_sent_at.join(";")); + } + return await db.updateIncident(updateObject); }; diff --git a/src/lib/server/db/dbimpl.js b/src/lib/server/db/dbimpl.js index 77e12fd2c..c50e46c15 100644 --- a/src/lib/server/db/dbimpl.js +++ b/src/lib/server/db/dbimpl.js @@ -663,6 +663,7 @@ class DbImpl { updated_at: this.knex.fn.now(), incident_type: data.incident_type, incident_source: data.incident_source, + reminder_time: data.reminder_time, }; // PostgreSQL supports returning clause @@ -765,6 +766,7 @@ class DbImpl { status: data.status, state: data.state, updated_at: this.knex.fn.now(), + reminder_time: data.reminder_time, }); } @@ -797,6 +799,8 @@ class DbImpl { "status", "state", "incident_type", + "reminder_time", + "reminders_sent_at", ) .where("id", id) .first(); @@ -1272,6 +1276,21 @@ class DbImpl { async deleteSubscriptionTriggerById(id) { return await this.knex("subscription_triggers").where({ id }).del(); } + + async getActiveMaintenanceWithReminders() { + return await this.knex("incidents") + .where("incident_type", "MAINTENANCE") + .andWhere("status", "OPEN") + .andWhere("state", "RESOLVED") + .andWhereRaw("reminder_time IS NOT NULL") + .orderBy("start_date_time", "desc"); + } + + async updateMaintenanceRemindersSentAt(id, sent_at) { + return await this.knex("incidents").where({ id }).update({ + reminders_sent_at: sent_at, + }); + } } export default DbImpl; diff --git a/src/lib/server/startup.js b/src/lib/server/startup.js index aae26b2db..51edc5e5c 100644 --- a/src/lib/server/startup.js +++ b/src/lib/server/startup.js @@ -4,10 +4,12 @@ import figlet from "figlet"; import { Cron } from "croner"; import { Minuter } from "./cron-minute.js"; import db from "./db/db.js"; -import { GetAllSiteData, GetMonitorsParsed } from "./controllers/controller.js"; -import { HashString } from "./tool.js"; +import { GetAllSiteData, GetMonitorsParsed, GetSiteLogoURL, SendEmailWithTemplate } from "./controllers/controller.js"; +import { GetMinuteStartTimestampUTC, GetNowTimestampUTC, HashString } from "./tool.js"; import { fileURLToPath } from "url"; import { dirname, resolve } from "path"; +import Queue from "queue"; +import { join } from "path"; import fs from "fs"; import version from "../version.js"; @@ -15,6 +17,103 @@ const jobs = []; process.env.TZ = "UTC"; let isStartUP = true; +const reminderQueue = new Queue({ + concurrency: 10, + timeout: 10000, + autostart: true, +}); + +const PushReminderToQueue = async (eventID, eventName, eventData) => { + let subscription = await db.getSubscriptionTriggerByType("email"); + if (!subscription) return; + + let config; + try { + config = JSON.parse(subscription.config); + } catch (e) { + return; + } + if (!config.sendMaintenanceReminders) return; + + // Get all the monitors that are associated with this event. + let tags = ["_"]; + let monitors = await db.getIncidentMonitorsByIncidentID(eventID); + if (monitors) { + for (let i = 0; i < monitors.length; i++) { + const monitor = monitors[i]; + tags.push(monitor.monitor_tag); + } + } + + // Prepare email data. + const emailTemplate = fs.readFileSync(join(__dirname, "./templates/maintenance_reminder.html"), "utf8"); + const siteData = await GetAllSiteData(); + const base = !!process.env.KENER_BASE_PATH ? process.env.KENER_BASE_PATH : ""; + let emailData = { + brand_name: siteData.siteName, + logo_url: await GetSiteLogoURL(siteData.siteURL, siteData.logo, base), + incident_url: await GetSiteLogoURL(siteData.siteURL, `/view/events/maintenance-${eventID}`, base), + }; + const eventDate = new Date(eventData.date * 1000).toUTCString(); + + const eventTemplates = { + upcoming_maintenance: { + title: `Upcoming Maintenance: ${eventData.title}`, + message: `We would like to inform you that maintenance ${eventData.title} is scheduled to start on ${eventDate}, and + will last for ${eventData.duration?.toLowerCase()}.`, + services: monitors.length > 0 ? ` It will affect the following services:` : ` It will not affect any services.`, + }, + starting_maintenance: { + title: `Maintenance Starting: ${eventData.title}`, + message: `We would like to inform you that maintenance ${eventData.title} is about to start on ${eventDate}, and + will last for ${eventData.duration?.toLowerCase()}.`, + services: monitors.length > 0 ? ` It will affect the following services:` : ` It will not affect any services.`, + }, + ending_maintenance: { + title: `Maintenance Ended: ${eventData.title}`, + message: `We would like to inform you that maintenance ${eventData.title} has ended on ${eventDate}.`, + services: monitors.length > 0 ? ` It affected the following services:` : ` It did not affect any services.`, + }, + }; + + if (eventTemplates[eventName]) { + emailData.title = eventTemplates[eventName].title; + emailData.message = eventTemplates[eventName].message + eventTemplates[eventName].services; + } + + // Add monitors to the message. + if (monitors.length > 0) { + emailData.message += `
`; + for (const monitor of monitors) { + emailData.message += ` + + ${monitor.monitor_impact} + ${monitor.monitor_tag} + + `; + } + emailData.message += `
`; + } + + // Get all eligible emails for the reminder. + const eligibleEmails = await db.getSubscriberEmails(tags); + if (eligibleEmails) { + for (let i = 0; i < eligibleEmails.length; i++) { + let email = eligibleEmails[i]; + reminderQueue.push(async (cb) => { + await SendEmailWithTemplate( + emailTemplate, + emailData, + email.subscriber_send, + emailData.title, + eventData.message, + ); + cb(eventData); + }); + } + } +}; + const scheduleCronJobs = async () => { // Fetch and map all active monitors, creating a unique hash for each const activeMonitors = (await GetMonitorsParsed({ status: "ACTIVE" })).map((monitor) => ({ @@ -56,6 +155,69 @@ const scheduleCronJobs = async () => { jobs.push(newJob); } } + + // Fetch all active maintenance. + const currentTime = Math.floor(Date.now() / 1000); + const activeMaintenances = await db.getActiveMaintenanceWithReminders(); + for (const maintenance of activeMaintenances) { + const remindersSent = maintenance.reminders_sent_at.split(";").map(Number); + if (maintenance.reminder_time !== "0") { + // Parse reminder_time string, e.g. "10 MINUTES", "2 HOURS", "1 DAYS" + const [amountStr, unit] = maintenance.reminder_time.split(" "); + const amount = parseInt(amountStr, 10); + let minutes = 0; + if (unit === "MINUTES") { + minutes = amount; + } else if (unit === "HOURS") { + minutes = amount * 60; + } else if (unit === "DAYS") { + minutes = amount * 60 * 24; + } + + // Check if the reminder for upcoming maintenance has been sent. + const reminderTime = maintenance.start_date_time - minutes * 60; + if (remindersSent[0] === 0 && reminderTime <= currentTime && maintenance.start_date_time > currentTime) { + remindersSent[0] = currentTime; + PushReminderToQueue(maintenance.id, "upcoming_maintenance", { + title: maintenance.title, + date: GetMinuteStartTimestampUTC(reminderTime), + duration: maintenance.reminder_time, + message: "Maintenance is starting soon!", + }); + } + } + + // Check if the reminder for ongoing maintenance has been sent. + if ( + remindersSent[1] === 0 && + maintenance.start_date_time <= currentTime && + maintenance.end_date_time >= currentTime + ) { + remindersSent[1] = currentTime; + PushReminderToQueue(maintenance.id, "starting_maintenance", { + title: maintenance.title, + date: GetMinuteStartTimestampUTC(maintenance.start_date_time), + duration: maintenance.reminder_time, + message: "Maintenance is ongoing!", + }); + } + + // Check if the reminder for completed maintenance has been sent. + if (remindersSent[2] === 0 && maintenance.end_date_time <= currentTime) { + remindersSent[2] = currentTime; + PushReminderToQueue(maintenance.id, "ending_maintenance", { + title: maintenance.title, + date: GetMinuteStartTimestampUTC(maintenance.end_date_time), + message: "Maintenance has ended!", + }); + } + + // Check if we have sent reminders. + if (JSON.stringify(maintenance.reminders_sent_at) !== JSON.stringify(remindersSent.join(";"))) { + await db.updateMaintenanceRemindersSentAt(maintenance.id, remindersSent.join(";")); + } + } + isStartUP = false; }; diff --git a/src/lib/server/templates/maintenance_reminder.html b/src/lib/server/templates/maintenance_reminder.html new file mode 100644 index 000000000..2896d6e9b --- /dev/null +++ b/src/lib/server/templates/maintenance_reminder.html @@ -0,0 +1,232 @@ + + + + + + + + + + +
{{title}}
+ + + + + + + + + + diff --git a/src/routes/(manage)/manage/(app)/app/events/+page.svelte b/src/routes/(manage)/manage/(app)/app/events/+page.svelte index be8ee11c9..5bd6bb931 100644 --- a/src/routes/(manage)/manage/(app)/app/events/+page.svelte +++ b/src/routes/(manage)/manage/(app)/app/events/+page.svelte @@ -104,7 +104,11 @@ status: "OPEN", state: "INVESTIGATING", firstComment: "", - incident_type: "INCIDENT" + incident_type: "INCIDENT", + reminder_active: false, + reminder_before_start: false, + reminder_time: 0, + reminder_time_unit: "MINUTES" }; } let isMounted = false; @@ -169,7 +173,8 @@ status: newIncident.status, state: newIncident.state, id: newIncident.id, - incident_type: newIncident.incident_type + incident_type: newIncident.incident_type, + reminder_time: `${Number(newIncident.reminder_time)} ${newIncident.reminder_time_unit}` }; //convert data.start_date_time to timestamp if (!!!toPost.start_date_time) { @@ -183,6 +188,18 @@ if (toPost.incident_type == "MAINTENANCE") { toPost.state = "RESOLVED"; + if (newIncident.reminder_active) { + if (newIncident.reminder_before_start) { + if (!!!newIncident.reminder_time || isNaN(newIncident.reminder_time) || newIncident.reminder_time <= 0) { + invalidFormMessage = "Reminder time should be greater than 0"; + return; + } + } else { + toPost.reminder_time = "0"; + } + } else { + toPost.reminder_time = ""; + } } toPost.incident_source = "DASHBOARD"; formStateCreate = "loading"; @@ -408,6 +425,14 @@ if (!!i.end_date_time) { newIncident.endDatetime = new Date(Number(i.end_date_time) * 1000); } + newIncident.reminder_active = !!newIncident.reminder_time; + newIncident.reminder_before_start = newIncident.reminder_active && newIncident.reminder_time !== "0"; + newIncident.reminder_time_unit = newIncident.reminder_before_start + ? newIncident.reminder_time.split(" ")[1] + : "MINUTES"; + newIncident.reminder_time = newIncident.reminder_before_start + ? parseInt(newIncident.reminder_time.split(" ")[0]) + : 0; showModal = true; showComments(i); @@ -775,7 +800,7 @@ {/if} -
+
+
+ +
+ {#if newIncident.reminder_active} +
+ +
+ {#if newIncident.reminder_before_start} +
+ + +
+
+ + (newIncident.reminder_time_unit = e.value)}> + + + + + + Reminder time unit + MINUTES + HOURS + DAYS + + + +
+ {/if} + {/if} {/if}
@@ -819,7 +916,11 @@ newIncident.title.trim().length == 0 || !!!newIncident.startDatetime || (!!!newIncident.id && newIncident.firstComment.trim().length == 0) || - (!!!newIncident.endDatetime && newIncident.incident_type == "MAINTENANCE")} + (!!!newIncident.endDatetime && newIncident.incident_type == "MAINTENANCE") || + (newIncident.incident_type == "MAINTENANCE" && + newIncident.reminder_active && + newIncident.reminder_before_start && + (isNaN(newIncident.reminder_time) || newIncident.reminder_time <= 0))} > Save Event {#if formStateCreate === "loading"} diff --git a/src/routes/(manage)/manage/(app)/app/subscriptions/+page.svelte b/src/routes/(manage)/manage/(app)/app/subscriptions/+page.svelte index 023df6f41..febd271b2 100644 --- a/src/routes/(manage)/manage/(app)/app/subscriptions/+page.svelte +++ b/src/routes/(manage)/manage/(app)/app/subscriptions/+page.svelte @@ -24,7 +24,8 @@ updateIncident: false, insertIncidentMonitor: false, updateIncidentComment: false, - insertIncidentComment: false + insertIncidentComment: false, + sendMaintenanceReminders: false } }; let selectedTriggerId = null; @@ -223,7 +224,7 @@ Send notification when an event comment is updated.

-
+
+
+ +

+ Send notification when a maintenance reminder is sent.
You can customise reminders' option while + creating the event. +

+