From 999c09d818751886b18867d5f39edbba62ffebd0 Mon Sep 17 00:00:00 2001 From: Mohan <158349177+mohansinghi@users.noreply.github.com> Date: Sun, 18 Jan 2026 05:56:12 -0800 Subject: [PATCH] feat: Add enhanced Discord webhook alerts with timestamps and downtime (#6745) Co-authored-by: SID <158349177+0xsid0703@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Frank Elsinga --- server/model/monitor.js | 54 +++++++++++++++------ server/notification-providers/discord.js | 62 ++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 20 deletions(-) diff --git a/server/model/monitor.js b/server/model/monitor.js index 2a23abd2f..1ef42aa51 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -1505,24 +1505,46 @@ class Monitor extends BeanModel { let msg = `[${monitor.name}] [${text}] ${bean.msg}`; + const heartbeatJSON = await bean.toJSONAsync({ decodeResponse: true }); + const monitorData = [{ id: monitor.id, active: monitor.active, name: monitor.name }]; + const preloadData = await Monitor.preparePreloadData(monitorData); + // Prevent if the msg is undefined, notifications such as Discord cannot send out. + if (!heartbeatJSON["msg"]) { + heartbeatJSON["msg"] = "N/A"; + } + + // Also provide the time in server timezone + heartbeatJSON["timezone"] = await UptimeKumaServer.getInstance().getTimezone(); + heartbeatJSON["timezoneOffset"] = UptimeKumaServer.getInstance().getTimezoneOffset(); + heartbeatJSON["localDateTime"] = dayjs + .utc(heartbeatJSON["time"]) + .tz(heartbeatJSON["timezone"]) + .format(SQL_DATETIME_FORMAT); + + // Calculate downtime tracking information when service comes back up + // This makes downtime information available to all notification providers + if (bean.status === UP && monitor.id) { + try { + const lastDownHeartbeat = await R.getRow( + "SELECT time FROM heartbeat WHERE monitor_id = ? AND status = ? ORDER BY time DESC LIMIT 1", + [monitor.id, DOWN] + ); + + if (lastDownHeartbeat && lastDownHeartbeat.time) { + heartbeatJSON["lastDownTime"] = lastDownHeartbeat.time; + } + } catch (error) { + // If we can't calculate downtime, just continue without it + // Silently fail to avoid disrupting notification sending + log.debug( + "monitor", + `[${monitor.name}] Could not calculate downtime information: ${error.message}` + ); + } + } + for (let notification of notificationList) { try { - const heartbeatJSON = await bean.toJSONAsync({ decodeResponse: true }); - const monitorData = [{ id: monitor.id, active: monitor.active, name: monitor.name }]; - const preloadData = await Monitor.preparePreloadData(monitorData); - // Prevent if the msg is undefined, notifications such as Discord cannot send out. - if (!heartbeatJSON["msg"]) { - heartbeatJSON["msg"] = "N/A"; - } - - // Also provide the time in server timezone - heartbeatJSON["timezone"] = await UptimeKumaServer.getInstance().getTimezone(); - heartbeatJSON["timezoneOffset"] = UptimeKumaServer.getInstance().getTimezoneOffset(); - heartbeatJSON["localDateTime"] = dayjs - .utc(heartbeatJSON["time"]) - .tz(heartbeatJSON["timezone"]) - .format(SQL_DATETIME_FORMAT); - await Notification.send( JSON.parse(notification.config), msg, diff --git a/server/notification-providers/discord.js b/server/notification-providers/discord.js index 3ed509cea..21b9e07e4 100644 --- a/server/notification-providers/discord.js +++ b/server/notification-providers/discord.js @@ -56,6 +56,8 @@ class Discord extends NotificationProvider { // If heartbeatJSON is not null, we go into the normal alerting loop. let addess = this.extractAddress(monitorJSON); if (heartbeatJSON["status"] === DOWN) { + const wentOfflineTimestamp = Math.floor(new Date(heartbeatJSON["time"]).getTime() / 1000); + let discorddowndata = { username: discordDisplayName, embeds: [ @@ -76,6 +78,11 @@ class Discord extends NotificationProvider { }, ] : []), + { + name: "Went Offline", + // F for full date/time + value: ``, + }, { name: `Time (${heartbeatJSON["timezone"]})`, value: heartbeatJSON["localDateTime"], @@ -104,6 +111,14 @@ class Discord extends NotificationProvider { await axios.post(webhookUrl.toString(), discorddowndata, config); return okMsg; } else if (heartbeatJSON["status"] === UP) { + const backOnlineTimestamp = Math.floor(new Date(heartbeatJSON["time"]).getTime() / 1000); + let downtimeDuration = null; + let wentOfflineTimestamp = null; + if (heartbeatJSON["lastDownTime"]) { + wentOfflineTimestamp = Math.floor(new Date(heartbeatJSON["lastDownTime"]).getTime() / 1000); + downtimeDuration = this.formatDuration(backOnlineTimestamp - wentOfflineTimestamp); + } + let discordupdata = { username: discordDisplayName, embeds: [ @@ -124,10 +139,23 @@ class Discord extends NotificationProvider { }, ] : []), - { - name: `Time (${heartbeatJSON["timezone"]})`, - value: heartbeatJSON["localDateTime"], - }, + ...(wentOfflineTimestamp + ? [ + { + name: "Went Offline", + // F for full date/time + value: ``, + }, + ] + : []), + ...(downtimeDuration + ? [ + { + name: "Downtime Duration", + value: downtimeDuration, + }, + ] + : []), ...(heartbeatJSON["ping"] != null ? [ { @@ -162,6 +190,32 @@ class Discord extends NotificationProvider { this.throwGeneralAxiosError(error); } } + + /** + * Format duration as human-readable string (e.g., "1h 23m", "45m 30s") + * TODO: Update below to `Intl.DurationFormat("en", { style: "short" }).format(duration)` once we are on a newer node version + * @param {number} timeInSeconds The time in seconds to format a duration for + * @returns {string} The formatted duration + */ + formatDuration(timeInSeconds) { + const hours = Math.floor(timeInSeconds / 3600); + const minutes = Math.floor((timeInSeconds % 3600) / 60); + const seconds = timeInSeconds % 60; + + const durationParts = []; + if (hours > 0) { + durationParts.push(`${hours}h`); + } + if (minutes > 0) { + durationParts.push(`${minutes}m`); + } + if (seconds > 0 && hours === 0) { + // Only show seconds if less than an hour + durationParts.push(`${seconds}s`); + } + + return durationParts.length > 0 ? durationParts.join(" ") : "0s"; + } } module.exports = Discord;