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} Object ready to parse + */ + async toJSONAsync(opts) { + return { + monitorID: this._monitorId, + status: this._status, + time: this._time, + msg: this._msg, + ping: this._ping, + important: this._important, + duration: this._duration, + retries: this._retries, + response: opts?.decodeResponse ? await Heartbeat.decodeResponseValue(this._response) : undefined, + }; + } + + /** + * Decode compressed response payload stored in database. + * @param {string|null} response Encoded response payload. + * @returns {string|null} Decoded response payload. + */ + static async decodeResponseValue(response) { + if (!response) { + return response; + } + + try { + // Offload gzip decode from main event loop to libuv thread pool + return (await gunzip(Buffer.from(response, "base64"))).toString("utf8"); + } catch (error) { + return response; + } + } } module.exports = Heartbeat; diff --git a/server/model/monitor.js b/server/model/monitor.js index 600878936..50fdb2a2e 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -24,6 +24,8 @@ const { PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT, + RESPONSE_BODY_LENGTH_DEFAULT, + RESPONSE_BODY_LENGTH_MAX, } = require("../../src/util"); const { ping, @@ -56,6 +58,9 @@ const { CookieJar } = require("tough-cookie"); const { HttpsCookieAgent } = require("http-cookie-agent/http"); const https = require("https"); const http = require("http"); +const zlib = require("node:zlib"); +const { promisify } = require("node:util"); +const gzip = promisify(zlib.gzip); const DomainExpiry = require("./domain_expiry"); const rootCertificates = rootCertificatesFingerprints(); @@ -203,6 +208,11 @@ class Monitor extends BeanModel { ping_numeric: this.isPingNumeric(), ping_count: this.ping_count, ping_per_request_timeout: this.ping_per_request_timeout, + + // response saving options + saveResponse: this.getSaveResponse(), + saveErrorResponse: this.getSaveErrorResponse(), + responseMaxLength: this.response_max_length ?? RESPONSE_BODY_LENGTH_DEFAULT, }; if (includeSensitiveData) { @@ -386,6 +396,22 @@ class Monitor extends BeanModel { return Boolean(this.kafkaProducerAllowAutoTopicCreation); } + /** + * Parse to boolean + * @returns {boolean} Should save response data on success? + */ + getSaveResponse() { + return Boolean(this.save_response); + } + + /** + * Parse to boolean + * @returns {boolean} Should save response data on error? + */ + getSaveErrorResponse() { + return Boolean(this.save_error_response); + } + /** * Start monitor * @param {Server} io Socket server instance @@ -620,6 +646,11 @@ class Monitor extends BeanModel { bean.msg = `${res.status} - ${res.statusText}`; bean.ping = dayjs().valueOf() - startTime; + // in the frontend, the save response is only shown if the saveErrorResponse is set + if (this.getSaveResponse() && this.getSaveErrorResponse()) { + await this.saveResponseData(bean, res.data); + } + // fallback for if kelog event is not emitted, but we may still have tlsInfo, // e.g. if the connection is made through a proxy if (this.getUrl()?.protocol === "https:" && tlsInfo.valid === undefined) { @@ -931,6 +962,10 @@ class Monitor extends BeanModel { bean.msg = error.message; } + if (this.getSaveErrorResponse() && error?.response?.data !== undefined) { + await this.saveResponseData(bean, error.response.data); + } + // If UP come in here, it must be upside down mode // Just reset the retries if (this.isUpsideDown() && bean.status === UP) { @@ -1114,6 +1149,35 @@ class Monitor extends BeanModel { } } + /** + * Save response body to a heartbeat if response saving is enabled. + * @param {import("redbean-node").Bean} bean Heartbeat bean to populate. + * @param {unknown} data Response payload. + * @returns {void} + */ + async saveResponseData(bean, data) { + if (data === undefined) { + return; + } + + let responseData = data; + if (typeof responseData !== "string") { + try { + responseData = JSON.stringify(responseData); + } catch (error) { + responseData = String(responseData); + } + } + + const maxSize = this.response_max_length ?? RESPONSE_BODY_LENGTH_DEFAULT; + if (responseData.length > maxSize) { + responseData = responseData.substring(0, maxSize) + "... (truncated)"; + } + + // Offload gzip compression from main event loop to libuv thread pool + bean.response = (await gzip(Buffer.from(responseData, "utf8"))).toString("base64"); + } + /** * Make a request using axios * @param {object} options Options for Axios @@ -1417,7 +1481,7 @@ class Monitor extends BeanModel { * Send a notification about a monitor * @param {boolean} isFirstBeat Is this beat the first of this monitor? * @param {Monitor} monitor The monitor to send a notification about - * @param {Bean} bean Status information about monitor + * @param {import("./heartbeat")} bean Status information about monitor * @returns {Promise} */ static async sendNotification(isFirstBeat, monitor, bean) { @@ -1435,7 +1499,7 @@ class Monitor extends BeanModel { for (let notification of notificationList) { try { - const heartbeatJSON = bean.toJSON(); + 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. @@ -1642,6 +1706,16 @@ class Monitor extends BeanModel { throw new Error(`Retry interval cannot be less than ${MIN_INTERVAL_SECOND} seconds`); } + if (this.response_max_length !== undefined) { + if (this.response_max_length < 0) { + throw new Error(`Response max length cannot be less than 0`); + } + + if (this.response_max_length > RESPONSE_BODY_LENGTH_MAX) { + throw new Error(`Response max length cannot be more than ${RESPONSE_BODY_LENGTH_MAX} bytes`); + } + } + if (this.type === "ping") { // ping parameters validation if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) { diff --git a/server/server.js b/server/server.js index c6168b897..885e88340 100644 --- a/server/server.js +++ b/server/server.js @@ -863,6 +863,9 @@ let needSetup = false; bean.packetSize = monitor.packetSize; bean.maxredirects = monitor.maxredirects; bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes); + bean.save_response = monitor.saveResponse; + bean.save_error_response = monitor.saveErrorResponse; + bean.response_max_length = monitor.responseMaxLength; bean.dns_resolve_type = monitor.dns_resolve_type; bean.dns_resolve_server = monitor.dns_resolve_server; bean.pushToken = monitor.pushToken; diff --git a/src/lang/en.json b/src/lang/en.json index d7f7fb7b5..e68cc6be9 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -100,6 +100,11 @@ "maxRedirectDescription": "Maximum number of redirects to follow. Set to 0 to disable redirects.", "Upside Down Mode": "Upside Down Mode", "Max. Redirects": "Max. Redirects", + "saveResponseForNotifications": "Save HTTP Success Response for Notifications", + "saveErrorResponseForNotifications": "Save HTTP Error Response for Notifications", + "saveResponseDescription": "Stores the HTTP response and makes it available to notification templates as {templateVariable}", + "responseMaxLength": "Response Max Length (bytes)", + "responseMaxLengthDescription": "Maximum size of response data to store. Set to 0 for unlimited. Larger responses will be truncated. Default: 10240 (10KB)", "Accepted Status Codes": "Accepted Status Codes", "Push URL": "Push URL", "needPushEvery": "You should call this URL every {0} seconds.", diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 3dad58dc8..b3d09d1b4 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -1514,6 +1514,89 @@ +
+
+ + +
+
+ + + +
+
+ +
+
+ + +
+
+ + + +
+
+ +
+ + +
+ {{ $t("responseMaxLengthDescription") }} +
+
+