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 1/2] 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; From a86789be6c69a43ab6bebd49c91457ba3682fe78 Mon Sep 17 00:00:00 2001 From: Diego <11472973+UnwishingMoon@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:44:43 +0100 Subject: [PATCH 2/2] feat: Add path to socket for external mariadb database (#6670) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Frank Elsinga --- server/database.js | 6 ++++-- server/setup-database.js | 27 ++++++++++++++++++--------- src/lang/en.json | 1 + src/pages/SetupDatabase.vue | 15 +++++++++++++-- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/server/database.js b/server/database.js index 23923679f..a2a0dd20b 100644 --- a/server/database.js +++ b/server/database.js @@ -165,7 +165,7 @@ class Database { * Read the database config * @throws {Error} If the config is invalid * @typedef {string|undefined} envString - * @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} Database config + * @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString, socketPath:envString}} Database config */ static readDBConfig() { let dbConfig; @@ -185,7 +185,7 @@ class Database { /** * @typedef {string|undefined} envString - * @param {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} dbConfig the database configuration that should be written + * @param {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString, socketPath:envString}} dbConfig the database configuration that should be written * @returns {void} */ static writeDBConfig(dbConfig) { @@ -284,6 +284,7 @@ class Database { port: dbConfig.port, user: dbConfig.username, password: dbConfig.password, + socketPath: dbConfig.socketPath, ...(dbConfig.ssl ? { ssl: { @@ -309,6 +310,7 @@ class Database { user: dbConfig.username, password: dbConfig.password, database: dbConfig.dbName, + socketPath: dbConfig.socketPath, timezone: "Z", typeCast: function (field, next) { if (field.type === "DATETIME") { diff --git a/server/setup-database.js b/server/setup-database.js index 81554c160..4f1065307 100644 --- a/server/setup-database.js +++ b/server/setup-database.js @@ -102,6 +102,7 @@ class SetupDatabase { dbConfig.dbName = process.env.UPTIME_KUMA_DB_NAME; dbConfig.username = getEnvOrFile("UPTIME_KUMA_DB_USERNAME"); dbConfig.password = getEnvOrFile("UPTIME_KUMA_DB_PASSWORD"); + dbConfig.socketPath = process.env.UPTIME_KUMA_DB_SOCKET?.trim(); dbConfig.ssl = getEnvOrFile("UPTIME_KUMA_DB_SSL")?.toLowerCase() === "true"; dbConfig.ca = getEnvOrFile("UPTIME_KUMA_DB_CA"); Database.writeDBConfig(dbConfig); @@ -160,6 +161,7 @@ class SetupDatabase { runningSetup: this.runningSetup, needSetup: this.needSetup, isEnabledEmbeddedMariaDB: this.isEnabledEmbeddedMariaDB(), + isEnabledMariaDBSocket: process.env.UPTIME_KUMA_DB_SOCKET?.trim().length > 0, }); }); @@ -202,16 +204,22 @@ class SetupDatabase { // External MariaDB if (dbConfig.type === "mariadb") { - if (!dbConfig.hostname) { - response.status(400).json("Hostname is required"); - this.runningSetup = false; - return; - } + // If socketPath is provided and not empty, validate it + if (process.env.UPTIME_KUMA_DB_SOCKET?.trim().length > 0) { + dbConfig.socketPath = process.env.UPTIME_KUMA_DB_SOCKET.trim(); + } else { + // socketPath not provided, hostname and port are required + if (!dbConfig.hostname) { + response.status(400).json("Hostname is required"); + this.runningSetup = false; + return; + } - if (!dbConfig.port) { - response.status(400).json("Port is required"); - this.runningSetup = false; - return; + if (!dbConfig.port) { + response.status(400).json("Port is required"); + this.runningSetup = false; + return; + } } if (!dbConfig.dbName) { @@ -241,6 +249,7 @@ class SetupDatabase { user: dbConfig.username, password: dbConfig.password, database: dbConfig.dbName, + socketPath: dbConfig.socketPath, ...(dbConfig.ssl ? { ssl: { diff --git a/src/lang/en.json b/src/lang/en.json index 5bcc6b59a..1736167c8 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -1369,6 +1369,7 @@ "None (Successful Connection)": "None (Successful Connection)", "expectedTlsAlertDescription": "Select the TLS alert you expect the server to return. Use {code} to verify mTLS endpoints reject connections without client certificates. See {link} for details.", "TLS Alert Spec": "RFC 8446", + "mariadbSocketPathDetectedHelptext": "Connecting to the database as specified via the {0} environment variable.", "Expand All Groups": "Expand All Groups", "Collapse All Groups": "Collapse All Groups" } diff --git a/src/pages/SetupDatabase.vue b/src/pages/SetupDatabase.vue index 47c50a6e9..264bf8288 100644 --- a/src/pages/SetupDatabase.vue +++ b/src/pages/SetupDatabase.vue @@ -79,7 +79,7 @@