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 <frank@elsinga.de>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Dmitry 2026-01-12 20:39:12 +03:00 committed by GitHub
parent 034b8641c8
commit 751fe1bbf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 322 additions and 6 deletions

View File

@ -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");
});
};

View File

@ -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");
});
};

View File

@ -87,10 +87,12 @@ async function sendImportantHeartbeatList(socket, monitorID, toUser = false, ove
timeLogger.print(`[Monitor: ${monitorID}] sendImportantHeartbeatList`); timeLogger.print(`[Monitor: ${monitorID}] sendImportantHeartbeatList`);
const result = list.map((bean) => bean.toJSON());
if (toUser) { if (toUser) {
io.to(socket.userID).emit("importantHeartbeatList", monitorID, list, overwrite); io.to(socket.userID).emit("importantHeartbeatList", monitorID, result, overwrite);
} else { } else {
socket.emit("importantHeartbeatList", monitorID, list, overwrite); socket.emit("importantHeartbeatList", monitorID, result, overwrite);
} }
} }

View File

@ -1,4 +1,7 @@
const { BeanModel } = require("redbean-node/dist/bean-model"); const { BeanModel } = require("redbean-node/dist/bean-model");
const zlib = require("node:zlib");
const { promisify } = require("node:util");
const gunzip = promisify(zlib.gunzip);
/** /**
* status: * status:
@ -36,8 +39,46 @@ class Heartbeat extends BeanModel {
important: this._important, important: this._important,
duration: this._duration, duration: this._duration,
retries: this._retries, 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>} 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; module.exports = Heartbeat;

View File

@ -24,6 +24,8 @@ const {
PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MIN,
PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_MAX,
PING_PER_REQUEST_TIMEOUT_DEFAULT, PING_PER_REQUEST_TIMEOUT_DEFAULT,
RESPONSE_BODY_LENGTH_DEFAULT,
RESPONSE_BODY_LENGTH_MAX,
} = require("../../src/util"); } = require("../../src/util");
const { const {
ping, ping,
@ -56,6 +58,9 @@ const { CookieJar } = require("tough-cookie");
const { HttpsCookieAgent } = require("http-cookie-agent/http"); const { HttpsCookieAgent } = require("http-cookie-agent/http");
const https = require("https"); const https = require("https");
const http = require("http"); 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 DomainExpiry = require("./domain_expiry");
const rootCertificates = rootCertificatesFingerprints(); const rootCertificates = rootCertificatesFingerprints();
@ -203,6 +208,11 @@ class Monitor extends BeanModel {
ping_numeric: this.isPingNumeric(), ping_numeric: this.isPingNumeric(),
ping_count: this.ping_count, ping_count: this.ping_count,
ping_per_request_timeout: this.ping_per_request_timeout, 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) { if (includeSensitiveData) {
@ -386,6 +396,22 @@ class Monitor extends BeanModel {
return Boolean(this.kafkaProducerAllowAutoTopicCreation); 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 * Start monitor
* @param {Server} io Socket server instance * @param {Server} io Socket server instance
@ -620,6 +646,11 @@ class Monitor extends BeanModel {
bean.msg = `${res.status} - ${res.statusText}`; bean.msg = `${res.status} - ${res.statusText}`;
bean.ping = dayjs().valueOf() - startTime; 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, // fallback for if kelog event is not emitted, but we may still have tlsInfo,
// e.g. if the connection is made through a proxy // e.g. if the connection is made through a proxy
if (this.getUrl()?.protocol === "https:" && tlsInfo.valid === undefined) { if (this.getUrl()?.protocol === "https:" && tlsInfo.valid === undefined) {
@ -931,6 +962,10 @@ class Monitor extends BeanModel {
bean.msg = error.message; 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 // If UP come in here, it must be upside down mode
// Just reset the retries // Just reset the retries
if (this.isUpsideDown() && bean.status === UP) { 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 * Make a request using axios
* @param {object} options Options for Axios * @param {object} options Options for Axios
@ -1417,7 +1481,7 @@ class Monitor extends BeanModel {
* Send a notification about a monitor * Send a notification about a monitor
* @param {boolean} isFirstBeat Is this beat the first of this monitor? * @param {boolean} isFirstBeat Is this beat the first of this monitor?
* @param {Monitor} monitor The monitor to send a notification about * @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<void>} * @returns {Promise<void>}
*/ */
static async sendNotification(isFirstBeat, monitor, bean) { static async sendNotification(isFirstBeat, monitor, bean) {
@ -1435,7 +1499,7 @@ class Monitor extends BeanModel {
for (let notification of notificationList) { for (let notification of notificationList) {
try { try {
const heartbeatJSON = bean.toJSON(); const heartbeatJSON = await bean.toJSONAsync({ decodeResponse: true });
const monitorData = [{ id: monitor.id, active: monitor.active, name: monitor.name }]; const monitorData = [{ id: monitor.id, active: monitor.active, name: monitor.name }];
const preloadData = await Monitor.preparePreloadData(monitorData); const preloadData = await Monitor.preparePreloadData(monitorData);
// Prevent if the msg is undefined, notifications such as Discord cannot send out. // 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`); 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") { if (this.type === "ping") {
// ping parameters validation // ping parameters validation
if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) { if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) {

View File

@ -863,6 +863,9 @@ let needSetup = false;
bean.packetSize = monitor.packetSize; bean.packetSize = monitor.packetSize;
bean.maxredirects = monitor.maxredirects; bean.maxredirects = monitor.maxredirects;
bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes); 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_type = monitor.dns_resolve_type;
bean.dns_resolve_server = monitor.dns_resolve_server; bean.dns_resolve_server = monitor.dns_resolve_server;
bean.pushToken = monitor.pushToken; bean.pushToken = monitor.pushToken;

View File

@ -100,6 +100,11 @@
"maxRedirectDescription": "Maximum number of redirects to follow. Set to 0 to disable redirects.", "maxRedirectDescription": "Maximum number of redirects to follow. Set to 0 to disable redirects.",
"Upside Down Mode": "Upside Down Mode", "Upside Down Mode": "Upside Down Mode",
"Max. Redirects": "Max. Redirects", "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", "Accepted Status Codes": "Accepted Status Codes",
"Push URL": "Push URL", "Push URL": "Push URL",
"needPushEvery": "You should call this URL every {0} seconds.", "needPushEvery": "You should call this URL every {0} seconds.",

View File

@ -1514,6 +1514,89 @@
</div> </div>
</div> </div>
<div
v-if="
monitor.type === 'http' ||
monitor.type === 'keyword' ||
monitor.type === 'json-query'
"
class="my-3"
>
<div class="form-check">
<input
id="saveErrorResponse"
v-model="monitor.saveErrorResponse"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label" for="saveErrorResponse">
{{ $t("saveErrorResponseForNotifications") }}
</label>
</div>
<div class="form-text">
<i18n-t keypath="saveResponseDescription" tag="div" class="form-text">
<template #templateVariable>
<code>heartbeatJSON.response</code>
</template>
</i18n-t>
</div>
</div>
<div
v-if="
(monitor.type === 'http' ||
monitor.type === 'keyword' ||
monitor.type === 'json-query') &&
monitor.saveErrorResponse
"
class="my-3"
>
<div class="form-check">
<input
id="saveResponse"
v-model="monitor.saveResponse"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label" for="saveResponse">
{{ $t("saveResponseForNotifications") }}
</label>
</div>
<div class="form-text">
<i18n-t keypath="saveResponseDescription" tag="div" class="form-text">
<template #templateVariable>
<code>heartbeatJSON.response</code>
</template>
</i18n-t>
</div>
</div>
<div
v-if="
(monitor.type === 'http' ||
monitor.type === 'keyword' ||
monitor.type === 'json-query') &&
(monitor.saveResponse || monitor.saveErrorResponse)
"
class="my-3"
>
<label for="responseMaxLength" class="form-label">
{{ $t("responseMaxLength") }}
</label>
<input
id="responseMaxLength"
v-model="monitor.responseMaxLength"
type="number"
class="form-control"
required
min="0"
step="1"
/>
<div class="form-text">
{{ $t("responseMaxLengthDescription") }}
</div>
</div>
<div class="my-3"> <div class="my-3">
<label for="acceptedStatusCodes" class="form-label"> <label for="acceptedStatusCodes" class="form-label">
{{ $t("Accepted Status Codes") }} {{ $t("Accepted Status Codes") }}
@ -2184,6 +2267,9 @@ const monitorDefaults = {
domainExpiryNotification: true, domainExpiryNotification: true,
maxredirects: 10, maxredirects: 10,
accepted_statuscodes: ["200-299"], accepted_statuscodes: ["200-299"],
saveResponse: false,
saveErrorResponse: true,
responseMaxLength: 10240,
dns_resolve_type: "A", dns_resolve_type: "A",
dns_resolve_server: "1.1.1.1", dns_resolve_server: "1.1.1.1",
docker_container: "", docker_container: "",

View File

@ -10,8 +10,8 @@
*/ */
var _a; var _a;
Object.defineProperty(exports, "__esModule", { value: true }); 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.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.RESPONSE_BODY_LENGTH_MAX = exports.RESPONSE_BODY_LENGTH_DEFAULT = 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.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 = exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = void 0;
const dayjs_1 = require("dayjs"); const dayjs_1 = require("dayjs");
const jsonata = require("jsonata"); const jsonata = require("jsonata");
exports.isDev = process.env.NODE_ENV === "development"; exports.isDev = process.env.NODE_ENV === "development";
@ -43,6 +43,8 @@ exports.PING_COUNT_DEFAULT = 1;
exports.PING_PER_REQUEST_TIMEOUT_MIN = 1; exports.PING_PER_REQUEST_TIMEOUT_MIN = 1;
exports.PING_PER_REQUEST_TIMEOUT_MAX = 60; exports.PING_PER_REQUEST_TIMEOUT_MAX = 60;
exports.PING_PER_REQUEST_TIMEOUT_DEFAULT = 2; exports.PING_PER_REQUEST_TIMEOUT_DEFAULT = 2;
exports.RESPONSE_BODY_LENGTH_DEFAULT = 1024 * 10;
exports.RESPONSE_BODY_LENGTH_MAX = 1024 * 1024;
exports.CONSOLE_STYLE_Reset = "\x1b[0m"; exports.CONSOLE_STYLE_Reset = "\x1b[0m";
exports.CONSOLE_STYLE_Bright = "\x1b[1m"; exports.CONSOLE_STYLE_Bright = "\x1b[1m";
exports.CONSOLE_STYLE_Dim = "\x1b[2m"; exports.CONSOLE_STYLE_Dim = "\x1b[2m";

View File

@ -66,6 +66,17 @@ export const PING_PER_REQUEST_TIMEOUT_MIN = 1;
export const PING_PER_REQUEST_TIMEOUT_MAX = 60; export const PING_PER_REQUEST_TIMEOUT_MAX = 60;
export const PING_PER_REQUEST_TIMEOUT_DEFAULT = 2; export const PING_PER_REQUEST_TIMEOUT_DEFAULT = 2;
/**
* Response body length cutoff used by default (10kb)
* (measured in bytes)
*/
export const RESPONSE_BODY_LENGTH_DEFAULT = 1024 * 10;
/**
* Maximum allowed response body length to store (1mb)
* (measured in bytes)
*/
export const RESPONSE_BODY_LENGTH_MAX = 1024 * 1024;
// Console colors // Console colors
// https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color // https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color
export const CONSOLE_STYLE_Reset = "\x1b[0m"; export const CONSOLE_STYLE_Reset = "\x1b[0m";

View File

@ -0,0 +1,36 @@
const { describe, test } = require("node:test");
const assert = require("node:assert");
const Monitor = require("../../server/model/monitor");
const Heartbeat = require("../../server/model/heartbeat");
const { RESPONSE_BODY_LENGTH_DEFAULT } = require("../../src/util");
describe("Monitor response saving", () => {
test("getSaveResponse and getSaveErrorResponse parse booleans", () => {
const monitor = Object.create(Monitor.prototype);
monitor.save_response = 1;
monitor.save_error_response = 0;
assert.strictEqual(monitor.getSaveResponse(), true);
assert.strictEqual(monitor.getSaveErrorResponse(), false);
});
test("saveResponseData stores and truncates response", async () => {
const monitor = Object.create(Monitor.prototype);
monitor.response_max_length = 5;
const bean = {};
await monitor.saveResponseData(bean, "abcdef");
assert.strictEqual(await Heartbeat.decodeResponseValue(bean.response), "abcde... (truncated)");
});
test("saveResponseData stringifies objects", async () => {
const monitor = Object.create(Monitor.prototype);
monitor.response_max_length = RESPONSE_BODY_LENGTH_DEFAULT;
const bean = {};
await monitor.saveResponseData(bean, { ok: true });
assert.strictEqual(await Heartbeat.decodeResponseValue(bean.response), JSON.stringify({ ok: true }));
});
});

View File

@ -105,4 +105,34 @@ test.describe("Monitor Form", () => {
await screenshot(testInfo, page); await screenshot(testInfo, page);
}); });
test("save response settings persist", async ({ page }, testInfo) => {
await page.goto("./add");
await login(page);
await selectMonitorType(page, "http");
const friendlyName = "Example HTTP Save Response";
await page.getByTestId("friendly-name-input").fill(friendlyName);
await page.getByTestId("url-input").fill("https://www.example.com/");
// Expect error response save enabled by default
await expect(page.getByLabel("Save HTTP Error Response for Notifications")).toBeChecked();
await page.getByLabel("Save HTTP Success Response for Notifications").check();
await page.getByLabel("Save HTTP Error Response for Notifications").uncheck();
await page.getByLabel("Response Max Length (bytes)").fill("2048");
await screenshot(testInfo, page);
await page.getByTestId("save-button").click();
await page.waitForURL("/dashboard/*");
await page.getByRole("link", { name: "Edit" }).click();
await page.waitForURL("/edit/*");
await expect(page.getByLabel("Save HTTP Success Response for Notifications")).toBeHidden();
await expect(page.getByLabel("Save HTTP Error Response for Notifications")).not.toBeChecked();
await expect(page.getByLabel("Response Max Length (bytes)")).toHaveValue("2048");
await screenshot(testInfo, page);
});
}); });