From c7a8b7affdd408e96f3d4b545f9cc2ba015f1a49 Mon Sep 17 00:00:00 2001
From: SID <158349177+0xsid0703@users.noreply.github.com>
Date: Mon, 12 Jan 2026 07:49:43 -0800
Subject: [PATCH 01/32] feat: Add option to retry only on status code failure
for JSON Query monitors (#5693) (#6687)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
---
...0-add-json-query-retry-only-status-code.js | 12 +++++++
server/model/monitor.js | 31 ++++++++++++++++---
server/server.js | 5 +++
src/lang/en.json | 2 ++
src/pages/EditMonitor.vue | 19 ++++++++++++
5 files changed, 64 insertions(+), 5 deletions(-)
create mode 100644 db/knex_migrations/2026-01-15-0000-add-json-query-retry-only-status-code.js
diff --git a/db/knex_migrations/2026-01-15-0000-add-json-query-retry-only-status-code.js b/db/knex_migrations/2026-01-15-0000-add-json-query-retry-only-status-code.js
new file mode 100644
index 000000000..dd5f0955a
--- /dev/null
+++ b/db/knex_migrations/2026-01-15-0000-add-json-query-retry-only-status-code.js
@@ -0,0 +1,12 @@
+exports.up = function (knex) {
+ // Add new column to table monitor for json-query retry behavior
+ return knex.schema.alterTable("monitor", function (table) {
+ table.boolean("retry_only_on_status_code_failure").defaultTo(false).notNullable();
+ });
+};
+
+exports.down = function (knex) {
+ return knex.schema.alterTable("monitor", function (table) {
+ table.dropColumn("retry_only_on_status_code_failure");
+ });
+};
diff --git a/server/model/monitor.js b/server/model/monitor.js
index f05ddd744..600878936 100644
--- a/server/model/monitor.js
+++ b/server/model/monitor.js
@@ -144,6 +144,7 @@ class Monitor extends BeanModel {
timeout: this.timeout,
interval: this.interval,
retryInterval: this.retryInterval,
+ retryOnlyOnStatusCodeFailure: Boolean(this.retry_only_on_status_code_failure),
resendInterval: this.resendInterval,
keyword: this.keyword,
invertKeyword: this.isInvertKeyword(),
@@ -934,12 +935,32 @@ class Monitor extends BeanModel {
// Just reset the retries
if (this.isUpsideDown() && bean.status === UP) {
retries = 0;
- } else if (this.maxretries > 0 && retries < this.maxretries) {
- retries++;
- bean.status = PENDING;
+ } else if (this.type === "json-query" && this.retry_only_on_status_code_failure) {
+ // For json-query monitors with retry_only_on_status_code_failure enabled,
+ // only retry if the error is NOT from JSON query evaluation
+ // JSON query errors have the message "JSON query does not pass..."
+ const isJsonQueryError =
+ typeof error.message === "string" && error.message.includes("JSON query does not pass");
+
+ if (isJsonQueryError) {
+ // Don't retry on JSON query failures, mark as DOWN immediately
+ retries = 0;
+ } else if (this.maxretries > 0 && retries < this.maxretries) {
+ retries++;
+ bean.status = PENDING;
+ } else {
+ // Continue counting retries during DOWN
+ retries++;
+ }
} else {
- // Continue counting retries during DOWN
- retries++;
+ // General retry logic for all other monitor types
+ if (this.maxretries > 0 && retries < this.maxretries) {
+ retries++;
+ bean.status = PENDING;
+ } else {
+ // Continue counting retries during DOWN
+ retries++;
+ }
}
}
diff --git a/server/server.js b/server/server.js
index b683b7114..c6168b897 100644
--- a/server/server.js
+++ b/server/server.js
@@ -753,6 +753,10 @@ let needSetup = false;
}
bean.import(monitor);
+ // Map camelCase frontend property to snake_case database column
+ if (monitor.retryOnlyOnStatusCodeFailure !== undefined) {
+ bean.retry_only_on_status_code_failure = monitor.retryOnlyOnStatusCodeFailure;
+ }
bean.user_id = socket.userID;
bean.validate();
@@ -905,6 +909,7 @@ let needSetup = false;
bean.snmpVersion = monitor.snmpVersion;
bean.snmpOid = monitor.snmpOid;
bean.jsonPathOperator = monitor.jsonPathOperator;
+ bean.retry_only_on_status_code_failure = Boolean(monitor.retryOnlyOnStatusCodeFailure);
bean.timeout = monitor.timeout;
bean.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes);
bean.rabbitmqUsername = monitor.rabbitmqUsername;
diff --git a/src/lang/en.json b/src/lang/en.json
index 72f38f9d2..d7f7fb7b5 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -86,6 +86,8 @@
"resendEveryXTimes": "Resend every {0} times",
"resendDisabled": "Resend disabled",
"retriesDescription": "Maximum retries before the service is marked as down and a notification is sent",
+ "Only retry if status code check fails": "Only retry if status code check fails",
+ "retryOnlyOnStatusCodeFailureDescription": "If enabled, retries will only occur when the HTTP status code check fails (e.g., server is down). If the status code check passes but the JSON query fails, the monitor will be marked as down immediately without retries.",
"ignoredTLSError": "TLS/SSL errors have been ignored",
"ignoreTLSError": "Ignore TLS/SSL errors for HTTPS websites",
"ignoreTLSErrorGeneral": "Ignore TLS/SSL error for connection",
diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue
index 0a08c9a6f..3dad58dc8 100644
--- a/src/pages/EditMonitor.vue
+++ b/src/pages/EditMonitor.vue
@@ -1167,6 +1167,24 @@
+
+
+
+
+
+
+
+ {{ $t("retryOnlyOnStatusCodeFailureDescription") }}
+
+
+
Date: Mon, 12 Jan 2026 17:25:53 +0100
Subject: [PATCH 03/32] chore: autofix tsc compliation (#6689)
---
.github/workflows/autofix.yml | 4 +
src/util.js | 154 ++++++++--------------------------
2 files changed, 38 insertions(+), 120 deletions(-)
diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml
index 617f8bee1..a6844df60 100644
--- a/.github/workflows/autofix.yml
+++ b/.github/workflows/autofix.yml
@@ -42,4 +42,8 @@ jobs:
run: npm run fmt
continue-on-error: true
+ - name: Compile TypeScript
+ run: npm run tsc
+ continue-on-error: true
+
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27
diff --git a/src/util.js b/src/util.js
index 677b600cc..4f26ccc8e 100644
--- a/src/util.js
+++ b/src/util.js
@@ -10,97 +10,12 @@
*/
var _a;
Object.defineProperty(exports, "__esModule", { value: true });
-exports.CONSOLE_STYLE_FgPink =
- exports.CONSOLE_STYLE_FgBrown =
- exports.CONSOLE_STYLE_FgViolet =
- exports.CONSOLE_STYLE_FgLightBlue =
- exports.CONSOLE_STYLE_FgLightGreen =
- exports.CONSOLE_STYLE_FgOrange =
- exports.CONSOLE_STYLE_FgGray =
- exports.CONSOLE_STYLE_FgWhite =
- exports.CONSOLE_STYLE_FgCyan =
- exports.CONSOLE_STYLE_FgMagenta =
- exports.CONSOLE_STYLE_FgBlue =
- exports.CONSOLE_STYLE_FgYellow =
- exports.CONSOLE_STYLE_FgGreen =
- exports.CONSOLE_STYLE_FgRed =
- exports.CONSOLE_STYLE_FgBlack =
- exports.CONSOLE_STYLE_Hidden =
- exports.CONSOLE_STYLE_Reverse =
- exports.CONSOLE_STYLE_Blink =
- exports.CONSOLE_STYLE_Underscore =
- exports.CONSOLE_STYLE_Dim =
- exports.CONSOLE_STYLE_Bright =
- exports.CONSOLE_STYLE_Reset =
- exports.PING_PER_REQUEST_TIMEOUT_DEFAULT =
- exports.PING_PER_REQUEST_TIMEOUT_MAX =
- exports.PING_PER_REQUEST_TIMEOUT_MIN =
- exports.PING_COUNT_DEFAULT =
- exports.PING_COUNT_MAX =
- exports.PING_COUNT_MIN =
- exports.PING_GLOBAL_TIMEOUT_DEFAULT =
- exports.PING_GLOBAL_TIMEOUT_MAX =
- exports.PING_GLOBAL_TIMEOUT_MIN =
- exports.PING_PACKET_SIZE_DEFAULT =
- exports.PING_PACKET_SIZE_MAX =
- exports.PING_PACKET_SIZE_MIN =
- exports.MIN_INTERVAL_SECOND =
- exports.MAX_INTERVAL_SECOND =
- exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND =
- exports.SQL_DATETIME_FORMAT =
- exports.SQL_DATE_FORMAT =
- exports.STATUS_PAGE_MAINTENANCE =
- exports.STATUS_PAGE_PARTIAL_DOWN =
- exports.STATUS_PAGE_ALL_UP =
- exports.STATUS_PAGE_ALL_DOWN =
- exports.MAINTENANCE =
- exports.PENDING =
- exports.UP =
- exports.DOWN =
- exports.appName =
- exports.isNode =
- exports.isDev =
- void 0;
-exports.TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD =
- exports.evaluateJsonQuery =
- exports.intHash =
- exports.localToUTC =
- exports.utcToLocal =
- exports.utcToISODateTime =
- exports.isoToUTCDateTime =
- exports.parseTimeFromTimeObject =
- exports.parseTimeObject =
- exports.getMonitorRelativeURL =
- exports.genSecret =
- exports.getCryptoRandomInt =
- exports.getRandomInt =
- exports.getRandomArbitrary =
- exports.TimeLogger =
- exports.polyfill =
- exports.log =
- exports.debug =
- exports.ucfirst =
- exports.sleep =
- exports.flipStatus =
- exports.badgeConstants =
- exports.CONSOLE_STYLE_BgGray =
- exports.CONSOLE_STYLE_BgWhite =
- exports.CONSOLE_STYLE_BgCyan =
- exports.CONSOLE_STYLE_BgMagenta =
- exports.CONSOLE_STYLE_BgBlue =
- exports.CONSOLE_STYLE_BgYellow =
- exports.CONSOLE_STYLE_BgGreen =
- exports.CONSOLE_STYLE_BgRed =
- exports.CONSOLE_STYLE_BgBlack =
- void 0;
+exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = exports.CONSOLE_STYLE_FgViolet = exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.PING_PER_REQUEST_TIMEOUT_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_MAX = exports.PING_PER_REQUEST_TIMEOUT_MIN = exports.PING_COUNT_DEFAULT = exports.PING_COUNT_MAX = exports.PING_COUNT_MIN = exports.PING_GLOBAL_TIMEOUT_DEFAULT = exports.PING_GLOBAL_TIMEOUT_MAX = exports.PING_GLOBAL_TIMEOUT_MIN = exports.PING_PACKET_SIZE_DEFAULT = exports.PING_PACKET_SIZE_MAX = exports.PING_PACKET_SIZE_MIN = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0;
+exports.TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD = exports.evaluateJsonQuery = exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = void 0;
const dayjs_1 = require("dayjs");
const jsonata = require("jsonata");
exports.isDev = process.env.NODE_ENV === "development";
-exports.isNode =
- typeof process !== "undefined" &&
- ((_a = process === null || process === void 0 ? void 0 : process.versions) === null || _a === void 0
- ? void 0
- : _a.node);
+exports.isNode = typeof process !== "undefined" && ((_a = process === null || process === void 0 ? void 0 : process.versions) === null || _a === void 0 ? void 0 : _a.node);
const dayjs = exports.isNode ? require("dayjs") : dayjs_1.default;
exports.appName = "Uptime Kuma";
exports.DOWN = 0;
@@ -253,7 +168,8 @@ class Logger {
let now;
if (dayjs.tz) {
now = dayjs.tz(new Date()).format();
- } else {
+ }
+ else {
now = dayjs().format();
}
const levelColor = consoleLevelColors[level];
@@ -272,7 +188,8 @@ class Logger {
}
modulePart = "[" + moduleColor + module + exports.CONSOLE_STYLE_Reset + "]";
levelPart = levelColor + `${level}:` + exports.CONSOLE_STYLE_Reset;
- } else {
+ }
+ else {
timePart = now;
modulePart = `[${module}]`;
levelPart = `${level}:`;
@@ -346,21 +263,21 @@ function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
exports.getRandomInt = getRandomInt;
-const getRandomBytes = (
- typeof window !== "undefined" && window.crypto
- ? function () {
- return (numBytes) => {
- const randomBytes = new Uint8Array(numBytes);
- for (let i = 0; i < numBytes; i += 65536) {
- window.crypto.getRandomValues(randomBytes.subarray(i, i + Math.min(numBytes - i, 65536)));
- }
- return randomBytes;
- };
- }
- : function () {
- return require("crypto").randomBytes;
- }
-)();
+const getRandomBytes = (typeof window !== "undefined" && window.crypto
+ ?
+ function () {
+ return (numBytes) => {
+ const randomBytes = new Uint8Array(numBytes);
+ for (let i = 0; i < numBytes; i += 65536) {
+ window.crypto.getRandomValues(randomBytes.subarray(i, i + Math.min(numBytes - i, 65536)));
+ }
+ return randomBytes;
+ };
+ }
+ :
+ function () {
+ return require("crypto").randomBytes;
+ })();
function getCryptoRandomInt(min, max) {
const range = max - min;
if (range >= Math.pow(2, 32)) {
@@ -386,7 +303,8 @@ function getCryptoRandomInt(min, max) {
randomValue = randomValue & mask;
if (randomValue <= range) {
return min + randomValue;
- } else {
+ }
+ else {
return getCryptoRandomInt(min, max);
}
}
@@ -467,7 +385,8 @@ async function evaluateJsonQuery(data, jsonPath, jsonPathOperator, expectedValue
let response;
try {
response = JSON.parse(data);
- } catch (_a) {
+ }
+ catch (_a) {
response =
(typeof data === "object" || typeof data === "number") && !Buffer.isBuffer(data) ? data : data.toString();
}
@@ -479,17 +398,13 @@ async function evaluateJsonQuery(data, jsonPath, jsonPathOperator, expectedValue
if (Array.isArray(response)) {
const responseStr = JSON.stringify(response);
const truncatedResponse = responseStr.length > 25 ? responseStr.substring(0, 25) + "...]" : responseStr;
- throw new Error(
- "JSON query returned the array " +
- truncatedResponse +
- ", but a primitive value is required. " +
- "Modify your query to return a single value via [0] to get the first element or use an aggregation like $count(), $sum() or $boolean()."
- );
+ throw new Error("JSON query returned the array " +
+ truncatedResponse +
+ ", but a primitive value is required. " +
+ "Modify your query to return a single value via [0] to get the first element or use an aggregation like $count(), $sum() or $boolean().");
}
if (typeof response === "object" || response instanceof Date || typeof response === "function") {
- throw new Error(
- `The post-JSON query evaluated response from the server is of type ${typeof response}, which cannot be directly compared to the expected value`
- );
+ throw new Error(`The post-JSON query evaluated response from the server is of type ${typeof response}, which cannot be directly compared to the expected value`);
}
let jsonQueryExpression;
switch (jsonPathOperator) {
@@ -517,15 +432,14 @@ async function evaluateJsonQuery(data, jsonPath, jsonPathOperator, expectedValue
expected: expectedValue.toString(),
});
if (status === undefined) {
- throw new Error(
- "Query evaluation returned undefined. Check query syntax and the structure of the response data"
- );
+ throw new Error("Query evaluation returned undefined. Check query syntax and the structure of the response data");
}
return {
status,
response,
};
- } catch (err) {
+ }
+ catch (err) {
response = JSON.stringify(response);
response = response && response.length > 50 ? `${response.substring(0, 100)}… (truncated)` : response;
throw new Error(`Error evaluating JSON query: ${err.message}. Response from server was: ${response}`);
From 751fe1bbf5a4ecbf1fb7df4afcd351714b009e35 Mon Sep 17 00:00:00 2001
From: Dmitry
Date: Mon, 12 Jan 2026 20:39:12 +0300
Subject: [PATCH 04/32] feat: Add configurable response data storage for
notifications (#6684)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Frank Elsinga
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
...-10-15-0001-add-monitor-response-config.js | 15 ++++
...25-10-15-0002-add-response-to-heartbeat.js | 11 +++
server/client.js | 6 +-
server/model/heartbeat.js | 41 +++++++++
server/model/monitor.js | 78 ++++++++++++++++-
server/server.js | 3 +
src/lang/en.json | 5 ++
src/pages/EditMonitor.vue | 86 +++++++++++++++++++
src/util.js | 6 +-
src/util.ts | 11 +++
test/backend-test/test-monitor-response.js | 36 ++++++++
test/e2e/specs/monitor-form.spec.js | 30 +++++++
12 files changed, 322 insertions(+), 6 deletions(-)
create mode 100644 db/knex_migrations/2025-10-15-0001-add-monitor-response-config.js
create mode 100644 db/knex_migrations/2025-10-15-0002-add-response-to-heartbeat.js
create mode 100644 test/backend-test/test-monitor-response.js
diff --git a/db/knex_migrations/2025-10-15-0001-add-monitor-response-config.js b/db/knex_migrations/2025-10-15-0001-add-monitor-response-config.js
new file mode 100644
index 000000000..d1f4ccf09
--- /dev/null
+++ b/db/knex_migrations/2025-10-15-0001-add-monitor-response-config.js
@@ -0,0 +1,15 @@
+exports.up = function (knex) {
+ return knex.schema.alterTable("monitor", function (table) {
+ table.boolean("save_response").notNullable().defaultTo(false);
+ table.boolean("save_error_response").notNullable().defaultTo(true);
+ table.integer("response_max_length").notNullable().defaultTo(10240); // Default 10KB
+ });
+};
+
+exports.down = function (knex) {
+ return knex.schema.alterTable("monitor", function (table) {
+ table.dropColumn("save_response");
+ table.dropColumn("save_error_response");
+ table.dropColumn("response_max_length");
+ });
+};
diff --git a/db/knex_migrations/2025-10-15-0002-add-response-to-heartbeat.js b/db/knex_migrations/2025-10-15-0002-add-response-to-heartbeat.js
new file mode 100644
index 000000000..6f41ce1a0
--- /dev/null
+++ b/db/knex_migrations/2025-10-15-0002-add-response-to-heartbeat.js
@@ -0,0 +1,11 @@
+exports.up = function (knex) {
+ return knex.schema.alterTable("heartbeat", function (table) {
+ table.text("response").nullable().defaultTo(null);
+ });
+};
+
+exports.down = function (knex) {
+ return knex.schema.alterTable("heartbeat", function (table) {
+ table.dropColumn("response");
+ });
+};
diff --git a/server/client.js b/server/client.js
index 31f995f38..7ccbabbb2 100644
--- a/server/client.js
+++ b/server/client.js
@@ -87,10 +87,12 @@ async function sendImportantHeartbeatList(socket, monitorID, toUser = false, ove
timeLogger.print(`[Monitor: ${monitorID}] sendImportantHeartbeatList`);
+ const result = list.map((bean) => bean.toJSON());
+
if (toUser) {
- io.to(socket.userID).emit("importantHeartbeatList", monitorID, list, overwrite);
+ io.to(socket.userID).emit("importantHeartbeatList", monitorID, result, overwrite);
} else {
- socket.emit("importantHeartbeatList", monitorID, list, overwrite);
+ socket.emit("importantHeartbeatList", monitorID, result, overwrite);
}
}
diff --git a/server/model/heartbeat.js b/server/model/heartbeat.js
index 084060e8f..19dbd4a6a 100644
--- a/server/model/heartbeat.js
+++ b/server/model/heartbeat.js
@@ -1,4 +1,7 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
+const zlib = require("node:zlib");
+const { promisify } = require("node:util");
+const gunzip = promisify(zlib.gunzip);
/**
* status:
@@ -36,8 +39,46 @@ class Heartbeat extends BeanModel {
important: this._important,
duration: this._duration,
retries: this._retries,
+ response: this._response,
};
}
+
+ /**
+ * Return an object that ready to parse to JSON
+ * @param {{ decodeResponse?: boolean }} opts Options for JSON serialization
+ * @returns {Promise
+
+
+
+
+
+
+
+
+ heartbeatJSON.response
+
+
+
+
+
+
+
+
+
+
+
+
+
+ heartbeatJSON.response
+
+
+
+
+
+
+
+
+
+ {{ $t("responseMaxLengthDescription") }}
+
+
+
@@ -360,7 +360,7 @@ export default {
this.bulkActionInProgress = true;
activeMonitors.forEach((id) => this.$root.getSocket().emit("pauseMonitor", id, () => {}));
- this.$root.toastSuccess(this.$tc("pausedMonitorsMsg", activeMonitors.length));
+ this.$root.toastSuccess(this.$t("pausedMonitorsMsg", activeMonitors.length));
this.bulkActionInProgress = false;
this.cancelSelectMode();
},
@@ -384,7 +384,7 @@ export default {
this.bulkActionInProgress = true;
inactiveMonitors.forEach((id) => this.$root.getSocket().emit("resumeMonitor", id, () => {}));
- this.$root.toastSuccess(this.$tc("resumedMonitorsMsg", inactiveMonitors.length));
+ this.$root.toastSuccess(this.$t("resumedMonitorsMsg", inactiveMonitors.length));
this.bulkActionInProgress = false;
this.cancelSelectMode();
},
@@ -424,10 +424,10 @@ export default {
this.bulkActionInProgress = false;
if (successCount > 0) {
- this.$root.toastSuccess(this.$tc("deletedMonitorsMsg", successCount));
+ this.$root.toastSuccess(this.$t("deletedMonitorsMsg", successCount));
}
if (errorCount > 0) {
- this.$root.toastError(this.$tc("bulkDeleteErrorMsg", errorCount));
+ this.$root.toastError(this.$t("bulkDeleteErrorMsg", errorCount));
}
this.cancelSelectMode();
diff --git a/src/components/PublicGroupList.vue b/src/components/PublicGroupList.vue
index f42a42c7f..d9c13aa24 100644
--- a/src/components/PublicGroupList.vue
+++ b/src/components/PublicGroupList.vue
@@ -231,15 +231,11 @@ export default {
*/
formattedCertExpiryMessage(monitor) {
if (monitor?.element?.validCert && monitor?.element?.certExpiryDaysRemaining) {
- return (
- monitor.element.certExpiryDaysRemaining +
- " " +
- this.$tc("day", monitor.element.certExpiryDaysRemaining)
- );
+ return this.$t("days", monitor.element.certExpiryDaysRemaining);
} else if (monitor?.element?.validCert === false) {
return this.$t("noOrBadCertificate");
} else {
- return this.$t("Unknown") + " " + this.$tc("day", 2);
+ return this.$t("unknownDays");
}
},
diff --git a/src/components/TagEditDialog.vue b/src/components/TagEditDialog.vue
index 638a5e204..95fc091ca 100644
--- a/src/components/TagEditDialog.vue
+++ b/src/components/TagEditDialog.vue
@@ -68,7 +68,7 @@
- {{ $tc("Monitor", selectedMonitors.length) }}
+ {{ $t("Monitors", selectedMonitors.length) }}
- {{ day }} {{ $tc("day", day) }}
+ {{ $t("days", day) }}
-
{{ monitorsByTag(tag.id).length }} {{ $tc("Monitor", monitorsByTag(tag.id).length) }}
+
{{ $t("Monitors", monitorsByTag(tag.id).length) }}
{{ pingTitle(true) }}
- ({{ 24 }} {{ $tc("hour", 24) }})
+ ({{ $t("hours", 24) }})
@@ -203,7 +203,7 @@
{{ $t("Uptime") }}
-
({{ 24 }} {{ $tc("hour", 24) }})
+
({{ $t("hours", 24) }})
@@ -212,7 +212,7 @@
{{ $t("Uptime") }}
-
({{ 30 }} {{ $tc("day", 30) }})
+
({{ $t("days", 30) }})
@@ -221,7 +221,7 @@
@@ -374,7 +374,7 @@
type="checkbox"
/>
- {{ $t("deleteChildrenMonitors", childrenCount, { count: childrenCount }) }}
+ {{ $t("deleteChildrenMonitors", childrenCount) }}
diff --git a/src/pages/EditMaintenance.vue b/src/pages/EditMaintenance.vue
index 97eb03a00..f41f29923 100644
--- a/src/pages/EditMaintenance.vue
+++ b/src/pages/EditMaintenance.vue
@@ -164,7 +164,7 @@
({{
- $tc("recurringIntervalMessage", maintenance.intervalDay, [
+ $t("recurringIntervalMessage", maintenance.intervalDay, [
maintenance.intervalDay,
])
}})
@@ -341,7 +341,7 @@
:disabled="currentDurationMinutes === 15"
@click="setQuickDuration(15)"
>
- {{ $tc("minuteShort", 15, { n: 15 }) }}
+ {{ $t("minuteShort", 15) }}
- {{ $tc("minuteShort", 30, { n: 30 }) }}
+ {{ $t("minuteShort", 30) }}
- {{ $tc("hours", 1, { n: 1 }) }}
+ {{ $t("hours", 1) }}
- {{ $tc("hours", 2, { n: 2 }) }}
+ {{ $t("hours", 2) }}
- {{ $tc("hours", 4, { n: 4 }) }}
+ {{ $t("hours", 4) }}
- {{ $tc("hours", 8, { n: 8 }) }}
+ {{ $t("hours", 8) }}
- {{ $tc("hours", 12, { n: 12 }) }}
+ {{ $t("hours", 12) }}
- {{ $tc("hours", 24, { n: 24 }) }}
+ {{ $t("hours", 24) }}
{{ $t("Sets end time based on start time") }}
diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue
index c2d91f56e..1de5c5063 100644
--- a/src/pages/StatusPage.vue
+++ b/src/pages/StatusPage.vue
@@ -580,7 +580,7 @@
{{ $t("lastUpdatedAt", { date: lastUpdateTimeDisplay }) }}
- {{ $tc("statusPageRefreshIn", [updateCountdownText]) }}
+ {{ $t("statusPageRefreshIn", [updateCountdownText]) }}
From bb0c1b372397da00d99b016778d69b9c297626c3 Mon Sep 17 00:00:00 2001
From: Frank Elsinga
Date: Wed, 14 Jan 2026 11:22:11 +0100
Subject: [PATCH 24/32] chore: automate marking as draft on requesting changes
(#6729)
---
...flict_labeler.yml => conflict-labeler.yml} | 0
.../mark-as-draft-on-requesting-changes.yml | 60 +++++++++++++++++++
...tributor_pr.yml => new-contributor-pr.yml} | 0
3 files changed, 60 insertions(+)
rename .github/workflows/{conflict_labeler.yml => conflict-labeler.yml} (100%)
create mode 100644 .github/workflows/mark-as-draft-on-requesting-changes.yml
rename .github/workflows/{new_contributor_pr.yml => new-contributor-pr.yml} (100%)
diff --git a/.github/workflows/conflict_labeler.yml b/.github/workflows/conflict-labeler.yml
similarity index 100%
rename from .github/workflows/conflict_labeler.yml
rename to .github/workflows/conflict-labeler.yml
diff --git a/.github/workflows/mark-as-draft-on-requesting-changes.yml b/.github/workflows/mark-as-draft-on-requesting-changes.yml
new file mode 100644
index 000000000..70fcdebb2
--- /dev/null
+++ b/.github/workflows/mark-as-draft-on-requesting-changes.yml
@@ -0,0 +1,60 @@
+name: Mark PR as draft when changes are requested
+
+on:
+ pull_request_review:
+ types: [submitted]
+
+ pull_request:
+ types: [labeled]
+
+permissions: {}
+
+jobs:
+ mark-draft:
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ contents: read
+ if: |
+ (
+ github.event_name == 'pull_request_review' &&
+ github.event.review.state == 'changes_requested'
+ ) || (
+ github.event_name == 'pull_request' &&
+ github.event.label.name == 'pr:please address review comments'
+ )
+ steps:
+ - name: Add label on requested changes
+ if: github.event_name == 'pull_request_review'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ REPO: ${{ github.repository }}
+ run: |
+ gh issue edit "$PR_NUMBER" \
+ --repo "$REPO" \
+ --add-label "pr:please address review comments"
+
+ - name: Mark PR as draft
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_URL: ${{ github.event.pull_request.html_url }}
+ run: gh pr ready "$PR_URL" --undo || true
+ ready-for-review:
+ runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request' && github.event.action == 'ready_for_review'
+
+ steps:
+ - name: Update labels for review
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ REPO: ${{ github.repository }}
+ run: |
+ gh issue edit "$PR_NUMBER" \
+ --repo "$REPO" \
+ --remove-label "pr:please address review comments" || true
+
+ gh issue edit "$PR_NUMBER" \
+ --repo "$REPO" \
+ --add-label "pr:needs review"
diff --git a/.github/workflows/new_contributor_pr.yml b/.github/workflows/new-contributor-pr.yml
similarity index 100%
rename from .github/workflows/new_contributor_pr.yml
rename to .github/workflows/new-contributor-pr.yml
From a85868ba7ca2d89825e48b9785382e9999631c59 Mon Sep 17 00:00:00 2001
From: kota <52403688+yashikota@users.noreply.github.com>
Date: Wed, 14 Jan 2026 20:44:36 +0900
Subject: [PATCH 25/32] feat(notification): discord suppress notifications
(#6717)
---
server/notification-providers/discord.js | 14 ++++++++++++++
src/components/notifications/Discord.vue | 21 +++++++++++++++++++++
src/lang/en.json | 2 ++
3 files changed, 37 insertions(+)
diff --git a/server/notification-providers/discord.js b/server/notification-providers/discord.js
index 79768e3df..3ed509cea 100644
--- a/server/notification-providers/discord.js
+++ b/server/notification-providers/discord.js
@@ -11,6 +11,11 @@ class Discord extends NotificationProvider {
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
+ // Discord Message Flags
+ // @see https://discord.com/developers/docs/resources/message#message-object-message-flags
+ // This message will not trigger push and desktop notifications
+ const SUPPRESS_NOTIFICATIONS_FLAG = 1 << 12;
+
try {
let config = this.getAxiosConfigWithProxy({});
const discordDisplayName = notification.discordUsername || "Uptime Kuma";
@@ -41,6 +46,9 @@ class Discord extends NotificationProvider {
if (notification.discordChannelType === "createNewForumPost") {
discordtestdata.thread_name = notification.postName;
}
+ if (notification.discordSuppressNotifications) {
+ discordtestdata.flags = SUPPRESS_NOTIFICATIONS_FLAG;
+ }
await axios.post(webhookUrl.toString(), discordtestdata, config);
return okMsg;
}
@@ -89,6 +97,9 @@ class Discord extends NotificationProvider {
if (notification.discordPrefixMessage) {
discorddowndata.content = notification.discordPrefixMessage;
}
+ if (notification.discordSuppressNotifications) {
+ discorddowndata.flags = SUPPRESS_NOTIFICATIONS_FLAG;
+ }
await axios.post(webhookUrl.toString(), discorddowndata, config);
return okMsg;
@@ -140,6 +151,9 @@ class Discord extends NotificationProvider {
if (notification.discordPrefixMessage) {
discordupdata.content = notification.discordPrefixMessage;
}
+ if (notification.discordSuppressNotifications) {
+ discordupdata.flags = SUPPRESS_NOTIFICATIONS_FLAG;
+ }
await axios.post(webhookUrl.toString(), discordupdata, config);
return okMsg;
diff --git a/src/components/notifications/Discord.vue b/src/components/notifications/Discord.vue
index b65eb706a..6eff2a887 100644
--- a/src/components/notifications/Discord.vue
+++ b/src/components/notifications/Discord.vue
@@ -102,6 +102,24 @@
{{ $t("Disable URL in Notification") }}
+
+
+
+
+
+ {{ $t("Suppress Notifications") }}
+
+
+
+ {{ $t("discordSuppressNotificationsHelptext") }}
+
+
diff --git a/src/lang/en.json b/src/lang/en.json
index 8002bc2d7..19d259222 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -1265,6 +1265,8 @@
"Matomo": "Matomo",
"Umami": "Umami",
"Disable URL in Notification": "Disable URL in Notification",
+ "Suppress Notifications": "Suppress Notifications",
+ "discordSuppressNotificationsHelptext": "When enabled, messages will be posted to the channel but won't trigger push or desktop notifications for recipients.",
"Ip Family": "IP Family",
"ipFamilyDescriptionAutoSelect": "Uses the {happyEyeballs} for determining the IP family.",
"Happy Eyeballs algorithm": "Happy Eyeballs algorithm",
From e95bd6a6e08370dd32074b33330c2e0c8307860d Mon Sep 17 00:00:00 2001
From: Frank Elsinga
Date: Wed, 14 Jan 2026 13:51:46 +0100
Subject: [PATCH 26/32] fix: automate mark as draft (#6730)
---
.../workflows/mark-as-draft-on-requesting-changes.yml | 10 +++-------
1 file changed, 3 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/mark-as-draft-on-requesting-changes.yml b/.github/workflows/mark-as-draft-on-requesting-changes.yml
index 70fcdebb2..fea29ab86 100644
--- a/.github/workflows/mark-as-draft-on-requesting-changes.yml
+++ b/.github/workflows/mark-as-draft-on-requesting-changes.yml
@@ -12,9 +12,6 @@ permissions: {}
jobs:
mark-draft:
runs-on: ubuntu-latest
- permissions:
- pull-requests: write
- contents: read
if: |
(
github.event_name == 'pull_request_review' &&
@@ -27,7 +24,7 @@ jobs:
- name: Add label on requested changes
if: github.event_name == 'pull_request_review'
env:
- GH_TOKEN: ${{ github.token }}
+ GH_TOKEN: ${{ secrets.MARK_AS_DRAFT_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
@@ -37,17 +34,16 @@ jobs:
- name: Mark PR as draft
env:
- GH_TOKEN: ${{ github.token }}
+ GH_TOKEN: ${{ secrets.MARK_AS_DRAFT_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: gh pr ready "$PR_URL" --undo || true
ready-for-review:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.action == 'ready_for_review'
-
steps:
- name: Update labels for review
env:
- GH_TOKEN: ${{ github.token }}
+ GH_TOKEN: ${{ secrets.MARK_AS_DRAFT_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
From 0cdb63edd18114f9747005055a321d8e385ad249 Mon Sep 17 00:00:00 2001
From: Frank Elsinga
Date: Wed, 14 Jan 2026 14:00:55 +0100
Subject: [PATCH 27/32] chore(deps): bump postcss-* dependencys (#6731)
---
package-lock.json | 45 ++++++++++++++++++++++++---------------------
package.json | 4 ++--
2 files changed, 26 insertions(+), 23 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index f4ebd7098..db0a67846 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -132,8 +132,8 @@
"favico.js": "~0.3.10",
"get-port-please": "^3.1.1",
"node-ssh": "~13.1.0",
- "postcss-html": "~1.5.0",
- "postcss-rtlcss": "~3.7.2",
+ "postcss-html": "~1.8.1",
+ "postcss-rtlcss": "~5.7.1",
"postcss-scss": "~4.0.4",
"prettier": "^3.7.4",
"prismjs": "~1.30.0",
@@ -12661,9 +12661,9 @@
}
},
"node_modules/js-tokens": {
- "version": "8.0.3",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-8.0.3.tgz",
- "integrity": "sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==",
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
@@ -15166,15 +15166,15 @@
}
},
"node_modules/postcss-html": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/postcss-html/-/postcss-html-1.5.0.tgz",
- "integrity": "sha512-kCMRWJRHKicpA166kc2lAVUGxDZL324bkj/pVOb6RhjB0Z5Krl7mN0AsVkBhVIRZZirY0lyQXG38HCVaoKVNoA==",
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/postcss-html/-/postcss-html-1.8.1.tgz",
+ "integrity": "sha512-OLF6P7qctfAWayOhLpcVnTGqVeJzu2W3WpIYelfz2+JV5oGxfkcEvweN9U4XpeqE0P98dcD9ssusGwlF0TK0uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^8.0.0",
- "js-tokens": "^8.0.0",
- "postcss": "^8.4.0",
+ "js-tokens": "^9.0.0",
+ "postcss": "^8.5.0",
"postcss-safe-parser": "^6.0.0"
},
"engines": {
@@ -15209,19 +15209,19 @@
"license": "MIT"
},
"node_modules/postcss-rtlcss": {
- "version": "3.7.2",
- "resolved": "https://registry.npmjs.org/postcss-rtlcss/-/postcss-rtlcss-3.7.2.tgz",
- "integrity": "sha512-GurrGedCKvOTe1QrifI+XpDKXA3bJky1v8KiOa/TYYHs1bfJOxI53GIRvVSqLJLly7e1WcNMz8KMESTN01vbZQ==",
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/postcss-rtlcss/-/postcss-rtlcss-5.7.1.tgz",
+ "integrity": "sha512-zE68CuARv5StOG/UQLa0W1Y/raUTzgJlfjtas43yh3/G1BFmoPEaHxPRHgeowXRFFhW33FehrNgsljxRLmPVWw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "rtlcss": "^3.5.0"
+ "rtlcss": "4.3.0"
},
"engines": {
- "node": ">=12.0.0"
+ "node": ">=18.0.0"
},
"peerDependencies": {
- "postcss": "^8.0.0"
+ "postcss": "^8.4.21"
}
},
"node_modules/postcss-safe-parser": {
@@ -16508,19 +16508,22 @@
}
},
"node_modules/rtlcss": {
- "version": "3.5.0",
- "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-3.5.0.tgz",
- "integrity": "sha512-wzgMaMFHQTnyi9YOwsx9LjOxYXJPzS8sYnFaKm6R5ysvTkwzHiB0vxnbHwchHQT65PTdBjDG21/kQBWI7q9O7A==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz",
+ "integrity": "sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==",
"dev": true,
"license": "MIT",
"dependencies": {
- "find-up": "^5.0.0",
+ "escalade": "^3.1.1",
"picocolors": "^1.0.0",
- "postcss": "^8.3.11",
+ "postcss": "^8.4.21",
"strip-json-comments": "^3.1.1"
},
"bin": {
"rtlcss": "bin/rtlcss.js"
+ },
+ "engines": {
+ "node": ">=12.0.0"
}
},
"node_modules/run-applescript": {
diff --git a/package.json b/package.json
index 9b08a5e69..5574a67b0 100644
--- a/package.json
+++ b/package.json
@@ -194,8 +194,8 @@
"favico.js": "~0.3.10",
"get-port-please": "^3.1.1",
"node-ssh": "~13.1.0",
- "postcss-html": "~1.5.0",
- "postcss-rtlcss": "~3.7.2",
+ "postcss-html": "~1.8.1",
+ "postcss-rtlcss": "~5.7.1",
"postcss-scss": "~4.0.4",
"prettier": "^3.7.4",
"prismjs": "~1.30.0",
From 31d2417dde4b28c0e5875e5ff49923814dbb5c09 Mon Sep 17 00:00:00 2001
From: Frank Elsinga
Date: Wed, 14 Jan 2026 14:21:05 +0100
Subject: [PATCH 28/32] chore: fix permissions for the draft labeling
automation (#6732)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.../mark-as-draft-on-requesting-changes.yml | 56 ++++++++++---------
1 file changed, 31 insertions(+), 25 deletions(-)
diff --git a/.github/workflows/mark-as-draft-on-requesting-changes.yml b/.github/workflows/mark-as-draft-on-requesting-changes.yml
index fea29ab86..99e8384e4 100644
--- a/.github/workflows/mark-as-draft-on-requesting-changes.yml
+++ b/.github/workflows/mark-as-draft-on-requesting-changes.yml
@@ -1,56 +1,62 @@
name: Mark PR as draft when changes are requested
-on:
- pull_request_review:
- types: [submitted]
-
- pull_request:
- types: [labeled]
+# pull_request_target is safe here because:
+# 1. Does not use any external actions; only uses the GitHub CLI via run commands
+# 2. Has minimal permissions
+# 3. Doesn't checkout or execute any untrusted code from PRs
+# 4. Only adds/removes labels or changes the draft status
+on: # zizmor: ignore[dangerous-triggers]
+ pull_request_target:
+ types:
+ - review_submitted
+ - labeled
+ - ready_for_review
permissions: {}
jobs:
mark-draft:
runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
if: |
(
- github.event_name == 'pull_request_review' &&
+ github.event.action == 'review_submitted' &&
github.event.review.state == 'changes_requested'
) || (
- github.event_name == 'pull_request' &&
+ github.event.action == 'labeled' &&
github.event.label.name == 'pr:please address review comments'
)
steps:
- name: Add label on requested changes
- if: github.event_name == 'pull_request_review'
+ if: github.event.review.state == 'changes_requested'
env:
- GH_TOKEN: ${{ secrets.MARK_AS_DRAFT_TOKEN }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
- REPO: ${{ github.repository }}
+ GH_TOKEN: ${{ github.token }}
run: |
- gh issue edit "$PR_NUMBER" \
- --repo "$REPO" \
+ gh issue edit "${{ github.event.pull_request.number }}" \
+ --repo "${{ github.repository }}" \
--add-label "pr:please address review comments"
- name: Mark PR as draft
env:
- GH_TOKEN: ${{ secrets.MARK_AS_DRAFT_TOKEN }}
- PR_URL: ${{ github.event.pull_request.html_url }}
- run: gh pr ready "$PR_URL" --undo || true
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh pr ready "${{ github.event.pull_request.number }}" --undo || true
+
ready-for-review:
runs-on: ubuntu-latest
- if: github.event_name == 'pull_request' && github.event.action == 'ready_for_review'
+ permissions:
+ pull-requests: write
+ if: github.event.action == 'ready_for_review'
steps:
- name: Update labels for review
env:
- GH_TOKEN: ${{ secrets.MARK_AS_DRAFT_TOKEN }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
- REPO: ${{ github.repository }}
+ GH_TOKEN: ${{ github.token }}
run: |
- gh issue edit "$PR_NUMBER" \
- --repo "$REPO" \
+ gh issue edit "${{ github.event.pull_request.number }}" \
+ --repo "${{ github.repository }}" \
--remove-label "pr:please address review comments" || true
- gh issue edit "$PR_NUMBER" \
- --repo "$REPO" \
+ gh issue edit "${{ github.event.pull_request.number }}" \
+ --repo "${{ github.repository }}" \
--add-label "pr:needs review"
From 7306e7038ac0a436b582afb0d6c0741fc2929c64 Mon Sep 17 00:00:00 2001
From: Frank Elsinga
Date: Wed, 14 Jan 2026 16:49:37 +0100
Subject: [PATCH 29/32] chore(ci): fix a missing `--repo` in the labeling
automation (#6735)
---
.github/workflows/mark-as-draft-on-requesting-changes.yml | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/mark-as-draft-on-requesting-changes.yml b/.github/workflows/mark-as-draft-on-requesting-changes.yml
index 99e8384e4..61407184c 100644
--- a/.github/workflows/mark-as-draft-on-requesting-changes.yml
+++ b/.github/workflows/mark-as-draft-on-requesting-changes.yml
@@ -41,7 +41,10 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
- gh pr ready "${{ github.event.pull_request.number }}" --undo || true
+ gh pr ready "${{ github.event.pull_request.number }}" \
+ --repo "${{ github.repository }}" \
+ --undo || true
+ # || true to ignore the case where the pr is already a draft
ready-for-review:
runs-on: ubuntu-latest
From e022b5f976ea6d89b42922ecdb22127328dabd24 Mon Sep 17 00:00:00 2001
From: iotux <46082385+iotux@users.noreply.github.com>
Date: Thu, 15 Jan 2026 00:13:53 +0700
Subject: [PATCH 30/32] fix: allow for private domains like example.local and
others (#6711)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Frank Elsinga
---
server/model/domain_expiry.js | 13 +++---
src/lang/en.json | 3 +-
test/backend-test/test-domain.js | 74 +++++++++-----------------------
3 files changed, 28 insertions(+), 62 deletions(-)
diff --git a/server/model/domain_expiry.js b/server/model/domain_expiry.js
index b7992575f..3502a4b08 100644
--- a/server/model/domain_expiry.js
+++ b/server/model/domain_expiry.js
@@ -159,20 +159,19 @@ class DomainExpiry extends BeanModel {
const tld = parseTld(target);
// Avoid logging for incomplete/invalid input while editing monitors.
- if (!tld.domain) {
- throw new TranslatableError("domain_expiry_unsupported_invalid_domain", { hostname: tld.hostname });
- }
- if (!tld.publicSuffix) {
- throw new TranslatableError("domain_expiry_unsupported_public_suffix", { publicSuffix: tld.publicSuffix });
- }
if (tld.isIp) {
throw new TranslatableError("domain_expiry_unsupported_is_ip", { hostname: tld.hostname });
}
-
// No one-letter public suffix exists; treat this as an incomplete/invalid input while typing.
if (tld.publicSuffix.length < 2) {
throw new TranslatableError("domain_expiry_public_suffix_too_short", { publicSuffix: tld.publicSuffix });
}
+ if (!tld.isIcann) {
+ throw new TranslatableError("domain_expiry_unsupported_is_icann", {
+ domain: tld.domain,
+ publicSuffix: tld.publicSuffix,
+ });
+ }
const rdap = await getRdapServer(tld.publicSuffix);
if (!rdap) {
diff --git a/src/lang/en.json b/src/lang/en.json
index 19d259222..ea1ea35a8 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -1297,9 +1297,8 @@
"domainExpiryDescription": "Trigger notification when domain names expires in:",
"domain_expiry_unsupported_monitor_type": "Domain expiry monitoring is not supported for this monitor type",
"domain_expiry_unsupported_missing_target": "No valid domain or hostname is configured for this monitor",
- "domain_expiry_unsupported_invalid_domain": "The configured value \"{hostname}\" is not a valid domain name",
"domain_expiry_public_suffix_too_short": "\".{publicSuffix}\" is too short for a top level domain",
- "domain_expiry_unsupported_public_suffix": "The domain \"{domain}\" does not have a valid public suffix",
+ "domain_expiry_unsupported_is_icann": "The domain \"{domain}\" is not a candidate for domain expiry monitoring, because its public suffix \".{publicSuffix}\" is not ICAN",
"domain_expiry_unsupported_is_ip": "\"{hostname}\" is an IP address. Domain expiry monitoring requires a domain name",
"domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint": "Domain expiry monitoring is not available for \".{publicSuffix}\" because no RDAP service is listed by IANA",
"minimumIntervalWarning": "Intervals below 20 seconds may result in poor performance.",
diff --git a/test/backend-test/test-domain.js b/test/backend-test/test-domain.js
index e1c95cd5f..c00d94e24 100644
--- a/test/backend-test/test-domain.js
+++ b/test/backend-test/test-domain.js
@@ -96,58 +96,26 @@ describe("Domain Expiry", () => {
});
describe("Domain Parsing", () => {
- test("throws error for invalid domain (no domain part)", async () => {
+ test("throws error for IP address (isIp check)", async () => {
const monitor = {
type: "http",
- url: "https://",
+ url: "https://127.0.0.1",
domainExpiryNotification: true,
};
await assert.rejects(
async () => await DomainExpiry.checkSupport(monitor),
(error) => {
assert.strictEqual(error.constructor.name, "TranslatableError");
- assert.strictEqual(error.message, "domain_expiry_unsupported_invalid_domain");
+ assert.strictEqual(error.message, "domain_expiry_unsupported_is_ip");
return true;
}
);
});
- test("throws error for IPv4 address instead of domain", async () => {
+ test("throws error for too short suffix(example.a)", async () => {
const monitor = {
type: "http",
- url: "https://192.168.1.1",
- domainExpiryNotification: true,
- };
- await assert.rejects(
- async () => await DomainExpiry.checkSupport(monitor),
- (error) => {
- assert.strictEqual(error.constructor.name, "TranslatableError");
- assert.strictEqual(error.message, "domain_expiry_unsupported_invalid_domain");
- return true;
- }
- );
- });
-
- test("throws error for IPv6 address", async () => {
- const monitor = {
- type: "http",
- url: "https://[2001:db8::1]",
- domainExpiryNotification: true,
- };
- await assert.rejects(
- async () => await DomainExpiry.checkSupport(monitor),
- (error) => {
- assert.strictEqual(error.constructor.name, "TranslatableError");
- assert.strictEqual(error.message, "domain_expiry_unsupported_invalid_domain");
- return true;
- }
- );
- });
-
- test("throws error for single-letter TLD", async () => {
- const monitor = {
- type: "http",
- url: "https://example.x",
+ url: "https://example.a",
domainExpiryNotification: true,
};
await assert.rejects(
@@ -159,6 +127,22 @@ describe("Domain Expiry", () => {
}
);
});
+
+ test("throws error for non-ICANN TLD (e.g. .local)", async () => {
+ const monitor = {
+ type: "http",
+ url: "https://example.local",
+ domainExpiryNotification: true,
+ };
+ await assert.rejects(
+ async () => await DomainExpiry.checkSupport(monitor),
+ (error) => {
+ assert.strictEqual(error.constructor.name, "TranslatableError");
+ assert.strictEqual(error.message, "domain_expiry_unsupported_is_icann");
+ return true;
+ }
+ );
+ });
});
describe("Edge Cases & RDAP Support", () => {
@@ -205,22 +189,6 @@ describe("Domain Expiry", () => {
assert.strictEqual(supportInfo.domain, "example.com");
assert.strictEqual(supportInfo.tld, "com");
});
-
- test("throws error for unsupported TLD without RDAP endpoint", async () => {
- const monitor = {
- type: "http",
- url: "https://example.localhost",
- domainExpiryNotification: true,
- };
- await assert.rejects(
- async () => await DomainExpiry.checkSupport(monitor),
- (error) => {
- assert.strictEqual(error.constructor.name, "TranslatableError");
- assert.strictEqual(error.message, "domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint");
- return true;
- }
- );
- });
});
});
From d7296c66299f59ea716ed1817a39a59b2cdfca7a Mon Sep 17 00:00:00 2001
From: Dalton Pearson <32880838+daltonpearson@users.noreply.github.com>
Date: Wed, 14 Jan 2026 17:48:48 -0500
Subject: [PATCH 31/32] feat: added monitoring for postgres query result
(#6736)
Co-authored-by: Dalton Pearson
---
server/monitor-types/postgres.js | 116 +++++++++++-
test/backend-test/monitors/test-postgres.js | 198 ++++++++++++++++++++
2 files changed, 308 insertions(+), 6 deletions(-)
diff --git a/server/monitor-types/postgres.js b/server/monitor-types/postgres.js
index fb6cc9b0d..c9daf65f0 100644
--- a/server/monitor-types/postgres.js
+++ b/server/monitor-types/postgres.js
@@ -3,26 +3,61 @@ const { log, UP } = require("../../src/util");
const dayjs = require("dayjs");
const postgresConParse = require("pg-connection-string").parse;
const { Client } = require("pg");
+const { ConditionVariable } = require("../monitor-conditions/variables");
+const { defaultStringOperators } = require("../monitor-conditions/operators");
+const { ConditionExpressionGroup } = require("../monitor-conditions/expression");
+const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator");
class PostgresMonitorType extends MonitorType {
name = "postgres";
+ supportsConditions = true;
+ conditionVariables = [new ConditionVariable("result", defaultStringOperators)];
+
/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
- let startTime = dayjs().valueOf();
-
let query = monitor.databaseQuery;
// No query provided by user, use SELECT 1
if (!query || (typeof query === "string" && query.trim() === "")) {
query = "SELECT 1";
}
- await this.postgresQuery(monitor.databaseConnectionString, query);
- heartbeat.msg = "";
- heartbeat.status = UP;
- heartbeat.ping = dayjs().valueOf() - startTime;
+ const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null;
+ const hasConditions = conditions && conditions.children && conditions.children.length > 0;
+
+ const startTime = dayjs().valueOf();
+
+ try {
+ if (hasConditions) {
+ // When conditions are enabled, expect a single value result
+ const result = await this.postgresQuerySingleValue(monitor.databaseConnectionString, query);
+ heartbeat.ping = dayjs().valueOf() - startTime;
+
+ const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) });
+
+ if (!conditionsResult) {
+ throw new Error(`Query result did not meet the specified conditions (${result})`);
+ }
+
+ heartbeat.status = UP;
+ heartbeat.msg = "Query did meet specified conditions";
+ } else {
+ // Backwards compatible: just check connection and return row count
+ const result = await this.postgresQuery(monitor.databaseConnectionString, query);
+ heartbeat.ping = dayjs().valueOf() - startTime;
+ heartbeat.status = UP;
+ heartbeat.msg = result;
+ }
+ } catch (error) {
+ heartbeat.ping = dayjs().valueOf() - startTime;
+ // Re-throw condition errors as-is, wrap database errors
+ if (error.message.includes("did not meet the specified conditions")) {
+ throw error;
+ }
+ throw new Error(`Database connection/query failed: ${error.message}`);
+ }
}
/**
@@ -76,6 +111,75 @@ class PostgresMonitorType extends MonitorType {
});
});
}
+
+ /**
+ * Run a query on Postgres
+ * @param {string} connectionString The database connection string
+ * @param {string} query The query to validate the database with
+ * @returns {Promise<(string[] | object[] | object)>} Response from
+ * server
+ */
+ async postgresQuerySingleValue(connectionString, query) {
+ return new Promise((resolve, reject) => {
+ const config = postgresConParse(connectionString);
+
+ // Fix #3868, which true/false is not parsed to boolean
+ if (typeof config.ssl === "string") {
+ config.ssl = config.ssl === "true";
+ }
+
+ if (config.password === "") {
+ // See https://github.com/brianc/node-postgres/issues/1927
+ reject(new Error("Password is undefined."));
+ return;
+ }
+ const client = new Client(config);
+
+ client.on("error", (error) => {
+ log.debug(this.name, "Error caught in the error event handler.");
+ reject(error);
+ });
+
+ client.connect((err) => {
+ if (err) {
+ reject(err);
+ client.end();
+ } else {
+ // Connected here
+ try {
+ client.query(query, (err, res) => {
+ if (err) {
+ reject(err);
+ } else {
+ // Check if we have results
+ if (!res.rows || res.rows.length === 0) {
+ reject(new Error("Query returned no results"));
+ return;
+ }
+ // Check if we have multiple rows
+ if (res.rows.length > 1) {
+ reject(new Error("Multiple values were found, expected only one value"));
+ return;
+ }
+ const firstRow = res.rows[0];
+ const columnNames = Object.keys(firstRow);
+ // Check if we have multiple columns
+ if (columnNames.length > 1) {
+ reject(new Error("Multiple columns were found, expected only one value"));
+ return;
+ }
+ resolve(firstRow[columnNames[0]]);
+ }
+ client.end();
+ });
+ } catch (e) {
+ reject(e);
+ client.end();
+ }
+ }
+ });
+ });
+ }
}
module.exports = {
diff --git a/test/backend-test/monitors/test-postgres.js b/test/backend-test/monitors/test-postgres.js
index a633d9806..3a408b5a3 100644
--- a/test/backend-test/monitors/test-postgres.js
+++ b/test/backend-test/monitors/test-postgres.js
@@ -49,5 +49,203 @@ describe(
await assert.rejects(postgresMonitor.check(monitor, heartbeat, {}), regex);
});
+
+ test("check() sets status to UP when custom query returns single value", async () => {
+ // The default timeout of 30 seconds might not be enough for the container to start
+ const postgresContainer = await new PostgreSqlContainer("postgres:latest")
+ .withStartupTimeout(60000)
+ .start();
+
+ const postgresMonitor = new PostgresMonitorType();
+ const monitor = {
+ databaseConnectionString: postgresContainer.getConnectionUri(),
+ databaseQuery: "SELECT 42",
+ conditions: "[]",
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ try {
+ await postgresMonitor.check(monitor, heartbeat, {});
+ assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`);
+ } finally {
+ await postgresContainer.stop();
+ }
+ });
+ test("check() sets status to UP when custom query result meets condition", async () => {
+ const postgresContainer = await new PostgreSqlContainer("postgres:latest")
+ .withStartupTimeout(60000)
+ .start();
+
+ const postgresMonitor = new PostgresMonitorType();
+ const monitor = {
+ databaseConnectionString: postgresContainer.getConnectionUri(),
+ databaseQuery: "SELECT 42 AS value",
+ conditions: JSON.stringify([
+ {
+ type: "expression",
+ andOr: "and",
+ variable: "result",
+ operator: "equals",
+ value: "42",
+ },
+ ]),
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ try {
+ await postgresMonitor.check(monitor, heartbeat, {});
+ assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`);
+ } finally {
+ await postgresContainer.stop();
+ }
+ });
+ test("check() rejects when custom query result does not meet condition", async () => {
+ const postgresContainer = await new PostgreSqlContainer("postgres:latest")
+ .withStartupTimeout(60000)
+ .start();
+
+ const postgresMonitor = new PostgresMonitorType();
+ const monitor = {
+ databaseConnectionString: postgresContainer.getConnectionUri(),
+ databaseQuery: "SELECT 99 AS value",
+ conditions: JSON.stringify([
+ {
+ type: "expression",
+ andOr: "and",
+ variable: "result",
+ operator: "equals",
+ value: "42",
+ },
+ ]),
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ try {
+ await assert.rejects(
+ postgresMonitor.check(monitor, heartbeat, {}),
+ new Error("Query result did not meet the specified conditions (99)")
+ );
+ assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
+ } finally {
+ await postgresContainer.stop();
+ }
+ });
+ test("check() rejects when query returns no results with conditions", async () => {
+ const postgresContainer = await new PostgreSqlContainer("postgres:latest")
+ .withStartupTimeout(60000)
+ .start();
+
+ const postgresMonitor = new PostgresMonitorType();
+ const monitor = {
+ databaseConnectionString: postgresContainer.getConnectionUri(),
+ databaseQuery: "SELECT 1 WHERE 1 = 0",
+ conditions: JSON.stringify([
+ {
+ type: "expression",
+ andOr: "and",
+ variable: "result",
+ operator: "equals",
+ value: "1",
+ },
+ ]),
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ try {
+ await assert.rejects(
+ postgresMonitor.check(monitor, heartbeat, {}),
+ new Error("Database connection/query failed: Query returned no results")
+ );
+ assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
+ } finally {
+ await postgresContainer.stop();
+ }
+ });
+ test("check() rejects when query returns multiple rows with conditions", async () => {
+ const postgresContainer = await new PostgreSqlContainer("postgres:latest")
+ .withStartupTimeout(60000)
+ .start();
+
+ const postgresMonitor = new PostgresMonitorType();
+ const monitor = {
+ databaseConnectionString: postgresContainer.getConnectionUri(),
+ databaseQuery: "SELECT 1 UNION ALL SELECT 2",
+ conditions: JSON.stringify([
+ {
+ type: "expression",
+ andOr: "and",
+ variable: "result",
+ operator: "equals",
+ value: "1",
+ },
+ ]),
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ try {
+ await assert.rejects(
+ postgresMonitor.check(monitor, heartbeat, {}),
+ new Error("Database connection/query failed: Multiple values were found, expected only one value")
+ );
+ assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
+ } finally {
+ await postgresContainer.stop();
+ }
+ });
+ test("check() rejects when query returns multiple columns with conditions", async () => {
+ const postgresContainer = await new PostgreSqlContainer("postgres:latest")
+ .withStartupTimeout(60000)
+ .start();
+
+ const postgresMonitor = new PostgresMonitorType();
+ const monitor = {
+ databaseConnectionString: postgresContainer.getConnectionUri(),
+ databaseQuery: "SELECT 1 AS col1, 2 AS col2",
+ conditions: JSON.stringify([
+ {
+ type: "expression",
+ andOr: "and",
+ variable: "result",
+ operator: "equals",
+ value: "1",
+ },
+ ]),
+ };
+
+ const heartbeat = {
+ msg: "",
+ status: PENDING,
+ };
+
+ try {
+ await assert.rejects(
+ postgresMonitor.check(monitor, heartbeat, {}),
+ new Error("Database connection/query failed: Multiple columns were found, expected only one value")
+ );
+ assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
+ } finally {
+ await postgresContainer.stop();
+ }
+ });
}
);
From dd44342835e8ed7a048b7eeb939534e1885ad097 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 15 Jan 2026 19:59:33 +0800
Subject: [PATCH 32/32] chore: Add manual workflow for building Docker base
images (#6741)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: louislam <1336778+louislam@users.noreply.github.com>
---
.github/workflows/build-docker-base.yml | 48 +++++++++++++++++++++++++
1 file changed, 48 insertions(+)
create mode 100644 .github/workflows/build-docker-base.yml
diff --git a/.github/workflows/build-docker-base.yml b/.github/workflows/build-docker-base.yml
new file mode 100644
index 000000000..a4d98977c
--- /dev/null
+++ b/.github/workflows/build-docker-base.yml
@@ -0,0 +1,48 @@
+name: Build Docker Base Images
+
+on:
+ workflow_dispatch: # Allow manual trigger
+
+permissions: {}
+
+jobs:
+ build-docker-base:
+ runs-on: ubuntu-latest
+ timeout-minutes: 120
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with: { persist-credentials: false }
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
+ with:
+ registry: ghcr.io
+ username: ${{ secrets.GHCR_USERNAME }}
+ password: ${{ secrets.GHCR_TOKEN }}
+
+ - name: Use Node.js 20
+ uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
+ with:
+ node-version: 20
+
+ - name: Build and push base2-slim image
+ run: npm run build-docker-base-slim
+
+ - name: Build and push base2 image
+ run: npm run build-docker-base