Merge branch 'master' into XMPP-retry-test

This commit is contained in:
Frank Elsinga 2026-01-06 06:42:44 +01:00 committed by GitHub
commit b4c2624c69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 230 additions and 70 deletions

View File

@ -22,6 +22,7 @@ jobs:
contents: read
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-22.04, windows-latest, ubuntu-22.04-arm]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
@ -41,13 +42,13 @@ jobs:
id: node-modules-cache
with:
path: node_modules
key: node-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
key: node-modules-${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: ${{ matrix.node }}
- run: npm install
- run: npm clean-install --no-fund
- name: Rebuild native modules for ARM64
if: matrix.os == 'ubuntu-22.04-arm'
@ -65,6 +66,7 @@ jobs:
permissions:
contents: read
strategy:
fail-fast: false
matrix:
node: [ 20, 22 ]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
@ -86,8 +88,8 @@ jobs:
docker run --rm --platform linux/arm/v7 \
-v $PWD:/workspace \
-w /workspace \
arm32v7/node:${{ matrix.node }}-slim \
bash -c "npm install --production"
arm32v7/node:${{ matrix.node }} \
npm clean-install --no-fund --production
check-linters:
runs-on: ubuntu-latest
@ -104,13 +106,13 @@ jobs:
id: node-modules-cache
with:
path: node_modules
key: node-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
key: node-modules-${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
- name: Use Node.js 20
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: 20
- run: npm install
- run: npm clean-install --no-fund
- run: npm run lint:prod
e2e-test:
@ -129,13 +131,13 @@ jobs:
id: node-modules-cache
with:
path: node_modules
key: node-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
key: node-modules-${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
- name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: 22
- run: npm install
- run: npm clean-install --no-fund
- name: Rebuild native modules for ARM64
run: npm rebuild @louislam/sqlite3

View File

@ -2,10 +2,22 @@ const { MonitorType } = require("./monitor-type");
const { log, UP } = require("../../src/util");
const mqtt = require("mqtt");
const jsonata = require("jsonata");
const { ConditionVariable } = require("../monitor-conditions/variables");
const { defaultStringOperators, defaultNumberOperators } = require("../monitor-conditions/operators");
const { ConditionExpressionGroup } = require("../monitor-conditions/expression");
const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator");
class MqttMonitorType extends MonitorType {
name = "mqtt";
supportsConditions = true;
conditionVariables = [
new ConditionVariable("topic", defaultStringOperators),
new ConditionVariable("message", defaultStringOperators),
new ConditionVariable("json_value", defaultStringOperators.concat(defaultNumberOperators)),
];
/**
* @inheritdoc
*/
@ -19,32 +31,98 @@ class MqttMonitorType extends MonitorType {
});
if (monitor.mqttCheckType == null || monitor.mqttCheckType === "") {
// use old default
monitor.mqttCheckType = "keyword";
}
if (monitor.mqttCheckType === "keyword") {
if (receivedMessage != null && receivedMessage.includes(monitor.mqttSuccessMessage)) {
heartbeat.msg = `Topic: ${messageTopic}; Message: ${receivedMessage}`;
heartbeat.status = UP;
} else {
throw Error(`Message Mismatch - Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`);
}
// Check if conditions are defined
const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null;
const hasConditions = conditions && conditions.children && conditions.children.length > 0;
if (hasConditions) {
await this.checkConditions(monitor, heartbeat, messageTopic, receivedMessage, conditions);
} else if (monitor.mqttCheckType === "keyword") {
this.checkKeyword(monitor, heartbeat, messageTopic, receivedMessage);
} else if (monitor.mqttCheckType === "json-query") {
const parsedMessage = JSON.parse(receivedMessage);
let expression = jsonata(monitor.jsonPath);
let result = await expression.evaluate(parsedMessage);
if (result?.toString() === monitor.expectedValue) {
heartbeat.msg = "Message received, expected value is found";
heartbeat.status = UP;
} else {
throw new Error("Message received but value is not equal to expected value, value was: [" + result + "]");
}
await this.checkJsonQuery(monitor, heartbeat, receivedMessage);
} else {
throw Error("Unknown MQTT Check Type");
throw new Error("Unknown MQTT Check Type");
}
}
/**
* Check using keyword matching
* @param {object} monitor Monitor object
* @param {object} heartbeat Heartbeat object
* @param {string} messageTopic Received MQTT topic
* @param {string} receivedMessage Received MQTT message
* @returns {void}
* @throws {Error} If keyword is not found in message
*/
checkKeyword(monitor, heartbeat, messageTopic, receivedMessage) {
if (receivedMessage != null && receivedMessage.includes(monitor.mqttSuccessMessage)) {
heartbeat.msg = `Topic: ${messageTopic}; Message: ${receivedMessage}`;
heartbeat.status = UP;
} else {
throw new Error(`Message Mismatch - Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`);
}
}
/**
* Check using JSONata query
* @param {object} monitor Monitor object
* @param {object} heartbeat Heartbeat object
* @param {string} receivedMessage Received MQTT message
* @returns {Promise<void>}
*/
async checkJsonQuery(monitor, heartbeat, receivedMessage) {
const parsedMessage = JSON.parse(receivedMessage);
const expression = jsonata(monitor.jsonPath);
const result = await expression.evaluate(parsedMessage);
if (result?.toString() === monitor.expectedValue) {
heartbeat.msg = "Message received, expected value is found";
heartbeat.status = UP;
} else {
throw new Error("Message received but value is not equal to expected value, value was: [" + result + "]");
}
}
/**
* Check using conditions system
* @param {object} monitor Monitor object
* @param {object} heartbeat Heartbeat object
* @param {string} messageTopic Received MQTT topic
* @param {string} receivedMessage Received MQTT message
* @param {ConditionExpressionGroup} conditions Parsed conditions
* @returns {Promise<void>}
*/
async checkConditions(monitor, heartbeat, messageTopic, receivedMessage, conditions) {
let jsonValue = null;
// Parse JSON and extract value if jsonPath is defined
if (monitor.jsonPath) {
try {
const parsedMessage = JSON.parse(receivedMessage);
const expression = jsonata(monitor.jsonPath);
jsonValue = await expression.evaluate(parsedMessage);
} catch (e) {
// JSON parsing failed, jsonValue remains null
}
}
const conditionData = {
topic: messageTopic,
message: receivedMessage,
json_value: jsonValue?.toString() ?? "",
};
const conditionsResult = evaluateExpressionGroup(conditions, conditionData);
if (conditionsResult) {
heartbeat.msg = `Topic: ${messageTopic}; Message: ${receivedMessage}`;
heartbeat.status = UP;
} else {
throw new Error(`Conditions not met - Topic: ${messageTopic}; Message: ${receivedMessage}`);
}
}

View File

@ -7,7 +7,7 @@
<h5 class="modal-title">
{{ $t("Add API Key") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body">
<!-- Name -->
@ -67,7 +67,7 @@
<h5 class="modal-title">
{{ $t("Key Added") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body">

View File

@ -4,11 +4,16 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ $t("Badge Generator", [monitor.name]) }}
{{ $t("Badge Link Generator", [monitor.name]) }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body">
<i18n-t keypath="Badge Link Generator Helptext" tag="p" class="form-text mb-3">
<template #documentation>
<a href="https://github.com/louislam/uptime-kuma/wiki/Badge" target="_blank" rel="noopener noreferrer">{{ $t("documentation") }}</a>
</template>
</i18n-t>
<div class="mb-3">
<label for="type" class="form-label">{{ $t("Badge Type") }}</label>
<select id="type" v-model="badge.type" class="form-select">

View File

@ -6,17 +6,17 @@
<h5 id="exampleModalLabel" class="modal-title">
{{ title || $t("Confirm") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body">
<slot />
</div>
<div class="modal-footer">
<button type="button" class="btn" :class="btnStyle" data-bs-dismiss="modal" @click="yes">
{{ yesText }}
{{ yesText || $t("Yes") }}
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @click="no">
{{ noText }}
{{ noText || $t("No") }}
</button>
</div>
</div>
@ -37,12 +37,12 @@ export default {
/** Text to use as yes */
yesText: {
type: String,
default: "Yes", // TODO: No idea what to translate this
default: null,
},
/** Text to use as no */
noText: {
type: String,
default: "No",
default: null,
},
/** Title to show on modal. Defaults to translated version of "Config" */
title: {

View File

@ -6,7 +6,7 @@
<h5 class="modal-title">
{{ $t("New Group") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body">
<form @submit.prevent="confirm">

View File

@ -7,7 +7,7 @@
<h5 id="exampleModalLabel" class="modal-title">
{{ $t("Setup Docker Host") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body">
<div class="mb-3">

View File

@ -6,7 +6,7 @@
<h5 class="modal-title">
{{ $t("Monitor Setting", [monitor.name]) }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body">
<div class="my-3 form-check">
@ -31,10 +31,10 @@
<button
class="btn btn-primary btn-add-group me-2"
@click="$refs.badgeGeneratorDialog.show(monitor.id, monitor.name)"
@click="$refs.badgeLinkGeneratorDialog.show(monitor.id, monitor.name)"
>
<font-awesome-icon icon="certificate" />
{{ $t("Open Badge Generator") }}
{{ $t("Open Badge Link Generator") }}
</button>
</div>
@ -46,16 +46,16 @@
</div>
</div>
</div>
<BadgeGeneratorDialog ref="badgeGeneratorDialog" />
<BadgeLinkGeneratorDialog ref="badgeLinkGeneratorDialog" />
</template>
<script lang="ts">
import { Modal } from "bootstrap";
import BadgeGeneratorDialog from "./BadgeGeneratorDialog.vue";
import BadgeLinkGeneratorDialog from "./BadgeLinkGeneratorDialog.vue";
export default {
components: {
BadgeGeneratorDialog
BadgeLinkGeneratorDialog
},
props: {},
emits: [],

View File

@ -7,7 +7,7 @@
<h5 id="exampleModalLabel" class="modal-title">
{{ $t("Setup Notification") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body">
<div class="mb-3">

View File

@ -7,7 +7,7 @@
<h5 id="exampleModalLabel" class="modal-title">
{{ $t("Setup Proxy") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body">
<div class="mb-3">

View File

@ -7,7 +7,7 @@
<h5 id="exampleModalLabel" class="modal-title">
{{ $t("Add a Remote Browser") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body">
<div class="mb-3">

View File

@ -6,10 +6,10 @@
<h5 class="modal-title">
{{ $t("Browser Screenshot") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body"></div>
<img :src="imageURL" alt="screenshot of the website">
<img :src="imageURL" :alt="$t('screenshot of the website')">
</div>
</div>
</div>

View File

@ -7,7 +7,7 @@
<h5 id="exampleModalLabel" class="modal-title">
{{ $t("Edit Tag") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body">
<div class="mb-3">

View File

@ -9,7 +9,7 @@
<span v-if="twoFAStatus == true" class="badge bg-primary">{{ $t("Active") }}</span>
<span v-if="twoFAStatus == false" class="badge bg-primary">{{ $t("Inactive") }}</span>
</h5>
<button :disabled="processing" type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
<button :disabled="processing" type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body">
<div class="mb-3">

View File

@ -85,12 +85,12 @@ export default {
title() {
if (this.type === "1y") {
return `1${this.$t("-year")}`;
return `1 ${this.$tc("year", 1)}`;
}
if (this.type === "720") {
return `30${this.$t("-day")}`;
return `30 ${this.$tc("day", 30)}`;
}
return `24${this.$t("-hour")}`;
return `24 ${this.$tc("hour", 24)}`;
}
},
};

View File

@ -20,7 +20,7 @@
<div
class="btn-group"
role="group"
aria-label="Basic checkbox toggle button group"
:aria-label="$t('Basic checkbox toggle button group')"
>
<input
id="btncheck1"
@ -69,7 +69,7 @@
<div
class="btn-group"
role="group"
aria-label="Basic checkbox toggle button group"
:aria-label="$t('Basic checkbox toggle button group')"
>
<input
id="btncheck4"

View File

@ -52,10 +52,8 @@
"now": "now",
"time ago": "{0} ago",
"day": "day | days",
"-day": "-day",
"hour": "hour",
"-hour": "-hour",
"-year": "-year",
"hour": "hour | hours",
"year": "year | years",
"Response": "Response",
"Ping": "Ping",
"Monitor Type": "Monitor Type",
@ -911,8 +909,9 @@
"Monitor Setting": "{0}'s Monitor Setting",
"Show Clickable Link": "Show Clickable Link",
"Show Clickable Link Description": "If checked everyone who have access to this status page can have access to monitor URL.",
"Open Badge Generator": "Open Badge Generator",
"Badge Generator": "{0}'s Badge Generator",
"Open Badge Link Generator": "Open Badge Link Generator",
"Badge Link Generator": "{0}'s Badge Link Generator",
"Badge Link Generator Helptext": "Badge links are available for all monitors assigned to public status pages. For more information, please see the {documentation}.",
"Badge Type": "Badge Type",
"Badge Duration (in hours)": "Badge Duration (in hours)",
"Badge Label": "Badge Label",
@ -1121,6 +1120,8 @@
"less than or equal to": "less than or equal to",
"greater than or equal to": "greater than or equal to",
"record": "record",
"message": "message",
"json_value": "JSON value",
"Notification Channel": "Notification Channel",
"Sound": "Sound",
"Alphanumerical string and hyphens only": "Alphanumerical string and hyphens only",
@ -1246,6 +1247,9 @@
"minimumIntervalWarning": "Intervals below 20 seconds may result in poor performance.",
"lowIntervalWarning": "Are you sure want to set the interval value below 20 seconds? Performance may be degraded, particularly if there are a large number of monitors.",
"imageResetConfirmation": "Image reset to default",
"screenshot of the website": "Screenshot of the website",
"Basic checkbox toggle button group": "Basic checkbox toggle button group",
"Basic radio toggle button group": "Basic radio toggle button group",
"mtls-auth-server-cert-label": "Cert",
"mtls-auth-server-cert-placeholder": "Cert body",
"mtls-auth-server-key-label": "Key",

View File

@ -237,7 +237,7 @@
>
<h4 class="col-4 col-sm-12">{{ pingTitle(true) }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(24{{ $t("-hour") }})
({{ 24 }} {{ $tc("hour", 24) }})
</p>
<span class="col-4 col-sm-12 num">
<CountUp :value="avgPing" />
@ -250,7 +250,7 @@
>
<h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(24{{ $t("-hour") }})
({{ 24 }} {{ $tc("hour", 24) }})
</p>
<span class="col-4 col-sm-12 num">
<Uptime :monitor="monitor" type="24" />
@ -263,7 +263,7 @@
>
<h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(30{{ $t("-day") }})
({{ 30 }} {{ $tc("day", 30) }})
</p>
<span class="col-4 col-sm-12 num">
<Uptime :monitor="monitor" type="720" />
@ -276,7 +276,7 @@
>
<h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(1{{ $t("-year") }})
({{ 1 }} {{ $tc("year", 1) }})
</p>
<span class="col-4 col-sm-12 num">
<Uptime :monitor="monitor" type="1y" />

View File

@ -33,7 +33,7 @@
{{ $t("setupDatabaseChooseDatabase") }}
</p>
<div class="btn-group" role="group" aria-label="Basic radio toggle button group">
<div class="btn-group" role="group" :aria-label="$t('Basic radio toggle button group')">
<template v-if="info.isEnabledEmbeddedMariaDB">
<input id="btnradio3" v-model="dbConfig.type" type="radio" class="btn-check" autocomplete="off" value="embedded-mariadb">

View File

@ -12,9 +12,10 @@ const { UP, PENDING } = require("../../../src/util");
* @param {string} receivedMessage what message is received from the mqtt channel
* @param {string} monitorTopic which MQTT topic is monitored (wildcards are allowed)
* @param {string} publishTopic to which MQTT topic the message is sent
* @param {string|null} conditions JSON string of conditions or null
* @returns {Promise<Heartbeat>} the heartbeat produced by the check
*/
async function testMqtt(mqttSuccessMessage, mqttCheckType, receivedMessage, monitorTopic = "test", publishTopic = "test") {
async function testMqtt(mqttSuccessMessage, mqttCheckType, receivedMessage, monitorTopic = "test", publishTopic = "test", conditions = null) {
const hiveMQContainer = await new HiveMQContainer().start();
const connectionString = hiveMQContainer.getConnectionString();
const mqttMonitorType = new MqttMonitorType();
@ -30,6 +31,7 @@ async function testMqtt(mqttSuccessMessage, mqttCheckType, receivedMessage, moni
mqttSuccessMessage: mqttSuccessMessage, // for keywords
expectedValue: mqttSuccessMessage, // for json-query
mqttCheckType: mqttCheckType,
conditions: conditions, // for conditions system
};
const heartbeat = {
msg: "",
@ -157,4 +159,67 @@ describe("MqttMonitorType", {
new Error("Message received but value is not equal to expected value, value was: [present]")
);
});
// Conditions system tests
test("check() sets status to UP when message condition matches (contains)", async () => {
const conditions = JSON.stringify([
{
type: "expression",
variable: "message",
operator: "contains",
value: "KEYWORD"
}
]);
const heartbeat = await testMqtt("", null, "-> KEYWORD <-", "test", "test", conditions);
assert.strictEqual(heartbeat.status, UP);
assert.strictEqual(heartbeat.msg, "Topic: test; Message: -> KEYWORD <-");
});
test("check() sets status to UP when topic condition matches (equals)", async () => {
const conditions = JSON.stringify([
{
type: "expression",
variable: "topic",
operator: "equals",
value: "sensors/temp"
}
]);
const heartbeat = await testMqtt("", null, "any message", "sensors/temp", "sensors/temp", conditions);
assert.strictEqual(heartbeat.status, UP);
});
test("check() rejects when message condition does not match", async () => {
const conditions = JSON.stringify([
{
type: "expression",
variable: "message",
operator: "contains",
value: "EXPECTED"
}
]);
await assert.rejects(
testMqtt("", null, "actual message without keyword", "test", "test", conditions),
new Error("Conditions not met - Topic: test; Message: actual message without keyword")
);
});
test("check() sets status to UP with multiple conditions (AND)", async () => {
const conditions = JSON.stringify([
{
type: "expression",
variable: "topic",
operator: "equals",
value: "test"
},
{
type: "expression",
variable: "message",
operator: "contains",
value: "success",
andOr: "and"
}
]);
const heartbeat = await testMqtt("", null, "operation success", "test", "test", conditions);
assert.strictEqual(heartbeat.status, UP);
});
});

View File

@ -178,6 +178,9 @@ test.describe("Status Page", () => {
await page.getByTestId("analytics-id-input").fill(plausibleAnalyticsDomainsUrls);
await page.getByTestId("save-button").click();
await screenshot(testInfo, page);
await page.waitForFunction((scriptUrl) => {
return document.head.innerHTML.includes(scriptUrl);
}, plausibleAnalyticsScriptUrl, { timeout: 5000 });
expect(await page.locator("head").innerHTML()).toContain(plausibleAnalyticsScriptUrl);
expect(await page.locator("head").innerHTML()).toContain(plausibleAnalyticsDomainsUrls);
@ -188,6 +191,9 @@ test.describe("Status Page", () => {
await page.getByTestId("analytics-id-input").fill(matomoSiteId);
await page.getByTestId("save-button").click();
await screenshot(testInfo, page);
await page.waitForFunction((url) => {
return document.head.innerHTML.includes(url);
}, matomoUrl, { timeout: 5000 });
expect(await page.locator("head").innerHTML()).toContain(matomoUrl);
expect(await page.locator("head").innerHTML()).toContain(matomoSiteId);
});