From f71787eac19fb9f69888317f61cadf69a9e03119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20HONORE?= Date: Fri, 9 Jan 2026 07:30:23 +0100 Subject: [PATCH 01/50] feat: add `monitor_uptime_ratio` and `monitor_response_time_seconds` prometheus metric (#5506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Franรงois HONORE Co-authored-by: Frank Elsinga Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- server/model/monitor.js | 7 ++-- server/prometheus.js | 68 ++++++++++++++++++++++++++++++++++--- server/uptime-calculator.js | 2 +- 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/server/model/monitor.js b/server/model/monitor.js index 33256fbfd..f05ddd744 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -1044,7 +1044,10 @@ class Monitor extends BeanModel { await R.store(bean); log.debug("monitor", `[${this.name}] prometheus.update`); - this.prometheus?.update(bean, tlsInfo); + const data24h = uptimeCalculator.get24Hour(); + const data30d = uptimeCalculator.get30Day(); + const data1y = uptimeCalculator.get1Year(); + this.prometheus?.update(bean, tlsInfo, { data24h, data30d, data1y }); previousBeat = bean; @@ -1952,7 +1955,7 @@ class Monitor extends BeanModel { */ async handleTlsInfo(tlsInfo) { await this.updateTlsInfo(tlsInfo); - this.prometheus?.update(null, tlsInfo); + this.prometheus?.update(null, tlsInfo, null); if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) { log.debug("monitor", `[${this.name}] call checkCertExpiryNotifications`); diff --git a/server/prometheus.js b/server/prometheus.js index d446915d3..adc1872e0 100644 --- a/server/prometheus.js +++ b/server/prometheus.js @@ -4,6 +4,8 @@ const { R } = require("redbean-node"); let monitorCertDaysRemaining = null; let monitorCertIsValid = null; +let monitorUptimeRatio = null; +let monitorAverageResponseTimeSeconds = null; let monitorResponseTime = null; let monitorStatus = null; @@ -69,6 +71,18 @@ class Prometheus { labelNames: commonLabels, }); + monitorUptimeRatio = new PrometheusClient.Gauge({ + name: "monitor_uptime_ratio", + help: "Uptime ratio calculated over sliding window specified by the 'window' label. (0.0 - 1.0)", + labelNames: [...commonLabels, "window"], + }); + + monitorAverageResponseTimeSeconds = new PrometheusClient.Gauge({ + name: "monitor_response_time_seconds", + help: "Average response time in seconds calculated over sliding window specified by the 'window' label", + labelNames: [...commonLabels, "window"], + }); + monitorResponseTime = new PrometheusClient.Gauge({ name: "monitor_response_time", help: "Monitor Response Time (ms)", @@ -130,11 +144,13 @@ class Prometheus { /** * Update the metrics page + * @typedef {import("./uptime-calculator").UptimeDataResult} UptimeDataResult * @param {object} heartbeat Heartbeat details * @param {object} tlsInfo TLS details + * @param {{data24h: UptimeDataResult, data30d: UptimeDataResult, data1y:UptimeDataResult} | null} uptime the uptime and average response rate over a variety of fixed windows * @returns {void} */ - update(heartbeat, tlsInfo) { + update(heartbeat, tlsInfo, uptime) { if (typeof tlsInfo !== "undefined") { try { let isValid; @@ -145,8 +161,7 @@ class Prometheus { } monitorCertIsValid.set(this.monitorLabelValues, isValid); } catch (e) { - log.error("prometheus", "Caught error"); - log.error("prometheus", e); + log.error("prometheus", "Caught error", e); } try { @@ -154,8 +169,49 @@ class Prometheus { monitorCertDaysRemaining.set(this.monitorLabelValues, tlsInfo.certInfo.daysRemaining); } } catch (e) { - log.error("prometheus", "Caught error"); - log.error("prometheus", e); + log.error("prometheus", "Caught error", e); + } + } + + if (uptime) { + try { + monitorAverageResponseTimeSeconds.set( + { ...this.monitorLabelValues, window: "1d" }, + uptime.data24h.avgPing / 1000 + ); + } catch (e) { + log.error("prometheus", "Caught error", e); + } + try { + monitorAverageResponseTimeSeconds.set( + { ...this.monitorLabelValues, window: "30d" }, + uptime.data30d.avgPing / 1000 + ); + } catch (e) { + log.error("prometheus", "Caught error", e); + } + try { + monitorAverageResponseTimeSeconds.set( + { ...this.monitorLabelValues, window: "365d" }, + uptime.data1y.avgPing / 1000 + ); + } catch (e) { + log.error("prometheus", "Caught error", e); + } + try { + monitorUptimeRatio.set({ ...this.monitorLabelValues, window: "1d" }, uptime.data24h.uptime); + } catch (e) { + log.error("prometheus", "Caught error", e); + } + try { + monitorUptimeRatio.set({ ...this.monitorLabelValues, window: "30d" }, uptime.data30d.uptime); + } catch (e) { + log.error("prometheus", "Caught error", e); + } + try { + monitorUptimeRatio.set({ ...this.monitorLabelValues, window: "365d" }, uptime.data1y.uptime); + } catch (e) { + log.error("prometheus", "Caught error", e); } } @@ -189,6 +245,8 @@ class Prometheus { try { monitorCertDaysRemaining.remove(this.monitorLabelValues); monitorCertIsValid.remove(this.monitorLabelValues); + monitorUptimeRatio.remove(this.monitorLabelValues); + monitorAverageResponseTimeSeconds.remove(this.monitorLabelValues); monitorResponseTime.remove(this.monitorLabelValues); monitorStatus.remove(this.monitorLabelValues); } catch (e) { diff --git a/server/uptime-calculator.js b/server/uptime-calculator.js index 1039c3b42..94d7e7733 100644 --- a/server/uptime-calculator.js +++ b/server/uptime-calculator.js @@ -206,7 +206,7 @@ class UptimeCalculator { * @param {number} status status * @param {number} ping Ping * @param {dayjs.Dayjs} date Date (Only for migration) - * @returns {dayjs.Dayjs} date + * @returns {Promise} date * @throws {Error} Invalid status */ async update(status, ping = 0, date) { From 50ab0b8d52c1a2bf46212153b9e4945063053317 Mon Sep 17 00:00:00 2001 From: Vishal Vignesh Date: Thu, 8 Jan 2026 22:43:47 -0800 Subject: [PATCH 02/50] fix: Allow setting heartbeat interval below 20 seconds (#6658) Co-authored-by: Frank Elsinga --- src/pages/EditMonitor.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index b1b1d565f..84626eb60 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -1119,7 +1119,7 @@ :max="maxInterval" step="1" @focus="lowIntervalConfirmation.editedValue = true" - @blur="checkIntervalValue" + @blur="finishUpdateInterval" />
From 0511686f8af6d635efb51f7734bcc94eb11bdcc1 Mon Sep 17 00:00:00 2001 From: IsayIsee Date: Fri, 9 Jan 2026 14:53:50 +0800 Subject: [PATCH 03/50] fix: make including `msg` optional for alyun and clairify the carrier restrictions (#6636) Co-authored-by: IsayIsee <1091921+Solin@user.noreply.gitee.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Frank Elsinga --- server/notification-providers/aliyun-sms.js | 8 ++- src/components/notifications/AliyunSms.vue | 59 ++++++++++++++++----- src/lang/en.json | 5 +- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/server/notification-providers/aliyun-sms.js b/server/notification-providers/aliyun-sms.js index e91d47b11..52eb2198e 100644 --- a/server/notification-providers/aliyun-sms.js +++ b/server/notification-providers/aliyun-sms.js @@ -19,7 +19,9 @@ class AliyunSMS extends NotificationProvider { name: monitorJSON["name"], time: heartbeatJSON["localDateTime"], status: this.statusToString(heartbeatJSON["status"]), - msg: this.removeIpAndDomain(heartbeatJSON["msg"]), + ...(notification.optionalParameters && { + msg: this.removeIpAndDomain(heartbeatJSON["msg"]), + }), }); if (await this.sendSms(notification, msgBody)) { return okMsg; @@ -29,7 +31,9 @@ class AliyunSMS extends NotificationProvider { name: "", time: "", status: "", - msg: this.removeIpAndDomain(msg), + ...(notification.optionalParameters && { + msg: this.removeIpAndDomain(msg), + }), }); if (await this.sendSms(notification, msgBody)) { return okMsg; diff --git a/src/components/notifications/AliyunSms.vue b/src/components/notifications/AliyunSms.vue index c03325872..a031fdd0e 100644 --- a/src/components/notifications/AliyunSms.vue +++ b/src/components/notifications/AliyunSms.vue @@ -4,25 +4,34 @@ {{ $t("AccessKeyId") }} * - + - + :required="true" + autocomplete="new-password" + > - + - +
+ + +
{{ $t("aliyun_enable_optional_variables_at_the_risk_of_non_delivery") }}
+
+
-

- {{ $t("Sms template must contain parameters: ") }} -
- ${name} ${time} ${status} ${msg} -

+ + + + + + https://help.aliyun.com/document_detail/101414.html @@ -56,3 +80,12 @@
+ diff --git a/src/lang/en.json b/src/lang/en.json index 7463c89d1..94a980ac7 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -737,7 +737,10 @@ "PhoneNumbers": "PhoneNumbers", "TemplateCode": "TemplateCode", "SignName": "SignName", - "Sms template must contain parameters: ": "Sms template must contain parameters: ", + "OptionalParameters": "Optional Parameters", + "aliyun_enable_optional_variables_at_the_risk_of_non_delivery": "Due to carrier restrictions, enable optional variables at the risk of non-delivery", + "aliyun-template-requirements-and-parameters": "The aliyun SMS template must contain parameters: {parameters}", + "aliyun-template-optional-parameters": "Optional parameters: {parameters}", "Bark API Version": "Bark API Version", "Bark Endpoint": "Bark Endpoint", "Bark Group": "Bark Group", From e0b22d204e411ee792c4bdbb0f252cae19865947 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 08:16:39 +0000 Subject: [PATCH 04/50] Initial plan From 6430ebec3ce4f457d2301d8a737a710d3d66c4b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 08:18:54 +0000 Subject: [PATCH 05/50] Add nightly release workflow Co-authored-by: louislam <1336778+louislam@users.noreply.github.com> --- .github/workflows/nightly-release.yml | 58 +++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/nightly-release.yml diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml new file mode 100644 index 000000000..7aff7785d --- /dev/null +++ b/.github/workflows/nightly-release.yml @@ -0,0 +1,58 @@ +name: Nightly Release + +on: + schedule: + # Runs at 2:00 AM UTC every day + - cron: "0 2 * * *" + workflow_dispatch: # Allow manual trigger + +permissions: {} + +jobs: + release-nightly: + 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: Cache/Restore node_modules + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + id: node-modules-cache + with: + path: node_modules + key: node-modules-${{ runner.os }}-node20-${{ hashFiles('**/package-lock.json') }} + + - name: Install dependencies + run: npm clean-install --no-fund + + - name: Run release-nightly + run: npm run release-nightly From c9f9b26cf723c3e32844f545e4e2f448d2b84b94 Mon Sep 17 00:00:00 2001 From: Louis Lam Date: Sat, 10 Jan 2026 16:31:45 +0800 Subject: [PATCH 06/50] Update .github/workflows/nightly-release.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/nightly-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 7aff7785d..42a419e60 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -36,8 +36,8 @@ jobs: uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 with: registry: ghcr.io - username: ${{ secrets.GHCR_USERNAME }} - password: ${{ secrets.GHCR_TOKEN }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Use Node.js 20 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 From 3fa4d87186430e6e7c01062d215bc18b085cf740 Mon Sep 17 00:00:00 2001 From: Louis Lam Date: Sat, 10 Jan 2026 16:44:37 +0800 Subject: [PATCH 07/50] fix: nightly release (#6666) --- .github/workflows/nightly-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 42a419e60..7aff7785d 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -36,8 +36,8 @@ jobs: uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 with: registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: ${{ secrets.GHCR_USERNAME }} + password: ${{ secrets.GHCR_TOKEN }} - name: Use Node.js 20 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 From 0c9354d5f403c286d121b183659b8dc2af6a4f35 Mon Sep 17 00:00:00 2001 From: Anurag Ekkati Date: Sat, 10 Jan 2026 16:59:04 -0800 Subject: [PATCH 08/50] fix: Expand the logging around AggregateError (#6664) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../notification-provider.js | 43 +++++++++++++++++-- .../test-notification-provider.js | 34 +++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 test/backend-test/notification-providers/test-notification-provider.js diff --git a/server/notification-providers/notification-provider.js b/server/notification-providers/notification-provider.js index ee04b8ee4..5360007d1 100644 --- a/server/notification-providers/notification-provider.js +++ b/server/notification-providers/notification-provider.js @@ -108,16 +108,51 @@ class NotificationProvider { * @throws {any} The error specified */ throwGeneralAxiosError(error) { - let msg = "Error: " + error + " "; + let msg = error && error.message ? error.message : String(error); - if (error.response && error.response.data) { + if (error && error.code) { + msg += ` (code=${error.code})`; + } + + if (error && error.response && error.response.status) { + msg += ` (HTTP ${error.response.status}${error.response.statusText ? " " + error.response.statusText : ""})`; + } + + if (error && error.response && error.response.data) { if (typeof error.response.data === "string") { - msg += error.response.data; + msg += " " + error.response.data; } else { - msg += JSON.stringify(error.response.data); + try { + msg += " " + JSON.stringify(error.response.data); + } catch (e) { + msg += " " + String(error.response.data); + } } } + // Expand AggregateError to show underlying causes + let agg = null; + if (error && error.name === "AggregateError" && Array.isArray(error.errors)) { + agg = error; + } else if (error && error.cause && error.cause.name === "AggregateError" && Array.isArray(error.cause.errors)) { + agg = error.cause; + } + + if (agg) { + let causes = agg.errors + .map((e) => { + let m = e && e.message ? e.message : String(e); + if (e && e.code) { + m += ` (code=${e.code})`; + } + return m; + }) + .join("; "); + msg += " - caused by: " + causes; + } else if (error && error.cause && error.cause.message) { + msg += " - cause: " + error.cause.message; + } + throw new Error(msg); } diff --git a/test/backend-test/notification-providers/test-notification-provider.js b/test/backend-test/notification-providers/test-notification-provider.js new file mode 100644 index 000000000..06ebab8e6 --- /dev/null +++ b/test/backend-test/notification-providers/test-notification-provider.js @@ -0,0 +1,34 @@ +const { describe, test } = require("node:test"); +const assert = require("node:assert"); + +const NotificationProvider = require("../../../server/notification-providers/notification-provider"); + +describe("NotificationProvider.throwGeneralAxiosError()", () => { + const provider = new NotificationProvider(); + + test("expands AggregateError causes", () => { + let err1 = new Error("connect ECONNREFUSED 127.0.0.1:443"); + err1.code = "ECONNREFUSED"; + let err2 = new Error("connect ECONNREFUSED ::1:443"); + err2.code = "ECONNREFUSED"; + + let aggErr = new AggregateError([err1, err2], "AggregateError"); + + assert.throws(() => provider.throwGeneralAxiosError(aggErr), { + message: /^AggregateError - caused by: .+/, + }); + }); + + test("expands AggregateError wrapped in error.cause", () => { + let innerErr = new Error("connect ETIMEDOUT 10.0.0.1:443"); + innerErr.code = "ETIMEDOUT"; + + let aggErr = new AggregateError([innerErr], "AggregateError"); + let outerErr = new Error("Request failed"); + outerErr.cause = aggErr; + + assert.throws(() => provider.throwGeneralAxiosError(outerErr), { + message: /^Request failed - caused by: .+/, + }); + }); +}); From e90b982687fc6e10e073888d3c470a04415d25c4 Mon Sep 17 00:00:00 2001 From: Frank Elsinga Date: Sun, 11 Jan 2026 05:05:43 +0100 Subject: [PATCH 09/50] chore: add a comment on first time contributors PRs instead of bloating the PR template (#6672) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/PULL_REQUEST_TEMPLATE.md | 19 +++++------- .github/workflows/new_contributor_pr.yml | 38 ++++++++++++++++++++++++ README.md | 4 +-- 3 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/new_contributor_pr.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c617f99a9..5aac1e29d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,36 +1,31 @@ -โ„น๏ธ To keep reviews fast and effective, please make sure youโ€™ve [read our pull request guidelines](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma) +# Summary -## ๐Ÿ“ Summary of changes done and why they are done +In this pull request, the following changes are made: - - -## ๐Ÿ“‹ Related issues +- Foobar was changed to FooFoo, because ... - Relates to #issue-number - Resolves #issue-number -## ๐Ÿ“„ Checklist -
Please follow this checklist to avoid unnecessary back and forth (click to expand) - [ ] โš ๏ธ If there are Breaking change (a fix or feature that alters existing functionality in a way that could cause issues) I have called them out - [ ] ๐Ÿง  I have disclosed any use of LLMs/AI in this contribution and reviewed all generated content. I understand that I am responsible for and able to explain every line of code I submit. -- [ ] ๐Ÿ” My code adheres to the style guidelines of this project. -- [ ] โš ๏ธ My changes generate no new warnings. -- [ ] ๐Ÿ› ๏ธ I have reviewed and tested my code. +- [ ] ๐Ÿ” Any UI changes adhere to visual style of this project. +- [ ] ๐Ÿ› ๏ธ I have self-reviewed and self-tested my code to ensure it works as expected. - [ ] ๐Ÿ“ I have commented my code, especially in hard-to-understand areas (e.g., using JSDoc for methods). - [ ] ๐Ÿค– I added or updated automated tests where appropriate. - [ ] ๐Ÿ“„ Documentation updates are included (if applicable). -- [ ] ๐Ÿ”’ I have considered potential security impacts and mitigated risks. - [ ] ๐Ÿงฐ Dependency updates are listed and explained. +- [ ] โš ๏ธ CI passes and is green.
-## ๐Ÿ“ท Screenshots or Visual Changes +## Screenshots for Visual Changes -
@@ -81,6 +116,10 @@ {{ $t("pauseMonitorMsg") }} + + + {{ $t("deleteMonitorsMsg") }} + 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 45/50] 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 46/50] 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 47/50] 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 48/50] 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 49/50] 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 50/50] 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(); + } + }); } );