Merge branch 'master' into feature/5934

This commit is contained in:
Mário Garrido 2025-07-26 09:47:07 +01:00 committed by GitHub
commit f525b54825
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 531 additions and 110 deletions

View File

@ -0,0 +1,15 @@
exports.up = function (knex) {
// Add new column monitor.mqtt_websocket_path
return knex.schema
.alterTable("monitor", function (table) {
table.string("mqtt_websocket_path", 255).nullable();
});
};
exports.down = function (knex) {
// Drop column monitor.mqtt_websocket_path
return knex.schema
.alterTable("monitor", function (table) {
table.dropColumn("mqtt_websocket_path");
});
};

View File

@ -189,6 +189,7 @@ class Monitor extends BeanModel {
radiusSecret: this.radiusSecret,
mqttUsername: this.mqttUsername,
mqttPassword: this.mqttPassword,
mqttWebsocketPath: this.mqttWebsocketPath,
authWorkstation: this.authWorkstation,
authDomain: this.authDomain,
tlsCa: this.tlsCa,

View File

@ -15,6 +15,7 @@ class MqttMonitorType extends MonitorType {
username: monitor.mqttUsername,
password: monitor.mqttPassword,
interval: monitor.interval,
websocketPath: monitor.mqttWebsocketPath,
});
if (monitor.mqttCheckType == null || monitor.mqttCheckType === "") {
@ -52,12 +53,12 @@ class MqttMonitorType extends MonitorType {
* @param {string} hostname Hostname / address of machine to test
* @param {string} topic MQTT topic
* @param {object} options MQTT options. Contains port, username,
* password and interval (interval defaults to 20)
* password, websocketPath and interval (interval defaults to 20)
* @returns {Promise<string>} Received MQTT message
*/
mqttAsync(hostname, topic, options = {}) {
return new Promise((resolve, reject) => {
const { port, username, password, interval = 20 } = options;
const { port, username, password, websocketPath, interval = 20 } = options;
// Adds MQTT protocol to the hostname if not already present
if (!/^(?:http|mqtt|ws)s?:\/\//.test(hostname)) {
@ -70,7 +71,15 @@ class MqttMonitorType extends MonitorType {
reject(new Error("Timeout, Message not received"));
}, interval * 1000 * 0.8);
const mqttUrl = `${hostname}:${port}`;
// Construct the URL based on protocol
let mqttUrl = `${hostname}:${port}`;
if (hostname.startsWith("ws://") || hostname.startsWith("wss://")) {
if (websocketPath && !websocketPath.startsWith("/")) {
mqttUrl = `${hostname}:${port}/${websocketPath || ""}`;
} else {
mqttUrl = `${hostname}:${port}${websocketPath || ""}`;
}
}
log.debug("mqtt", `MQTT connecting to ${mqttUrl}`);

View File

@ -720,6 +720,17 @@ let needSetup = false;
monitor.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes);
/*
* List of frontend-only properties that should not be saved to the database.
* Should clean up before saving to the database.
*/
const frontendOnlyProperties = [ "humanReadableInterval" ];
for (const prop of frontendOnlyProperties) {
if (prop in monitor) {
delete monitor[prop];
}
}
bean.import(monitor);
bean.user_id = socket.userID;
@ -837,6 +848,7 @@ let needSetup = false;
bean.mqttTopic = monitor.mqttTopic;
bean.mqttSuccessMessage = monitor.mqttSuccessMessage;
bean.mqttCheckType = monitor.mqttCheckType;
bean.mqttWebsocketPath = monitor.mqttWebsocketPath;
bean.databaseConnectionString = monitor.databaseConnectionString;
bean.databaseQuery = monitor.databaseQuery;
bean.authMethod = monitor.authMethod;

View File

@ -71,6 +71,7 @@
"locally configured mail transfer agent": "locally configured mail transfer agent",
"Either enter the hostname of the server you want to connect to or localhost if you intend to use a locally configured mail transfer agent": "Either enter the hostname of the server you want to connect to or {localhost} if you intend to use a {local_mta}",
"Port": "Port",
"Path": "Path",
"Heartbeat Interval": "Heartbeat Interval",
"Request Timeout": "Request Timeout",
"timeoutAfter": "Timeout after {0} seconds",
@ -266,6 +267,10 @@
"Current User": "Current User",
"topic": "Topic",
"topicExplanation": "MQTT topic to monitor",
"mqttWebSocketPath": "MQTT WebSocket Path",
"mqttWebsocketPathExplanation": "WebSocket path for MQTT over WebSocket connections (e.g., /mqtt)",
"mqttWebsocketPathInvalid": "Please use a valid WebSocket Path format",
"mqttHostnameTip": "Please use this format {hostnameFormat}",
"successKeyword": "Success Keyword",
"successKeywordExplanation": "MQTT Keyword that will be considered as success",
"recent": "Recent",

View File

@ -1,5 +1,5 @@
import { currentLocale } from "../i18n";
import { setPageLocale } from "../util-frontend";
import { setPageLocale, relativeTimeFormatter } from "../util-frontend";
const langModules = import.meta.glob("../lang/*.json");
export default {
@ -28,11 +28,13 @@ export default {
* @returns {Promise<void>}
*/
async changeLang(lang) {
let message = (await langModules["../lang/" + lang + ".json"]()).default;
let message = (await langModules["../lang/" + lang + ".json"]())
.default;
this.$i18n.setLocaleMessage(lang, message);
this.$i18n.locale = lang;
localStorage.locale = lang;
setPageLocale();
}
}
relativeTimeFormatter.updateLocale(lang);
},
},
};

View File

@ -1,7 +1,9 @@
<template>
<transition name="slide-fade" appear>
<div v-if="monitor">
<router-link v-if="group !== ''" :to="monitorURL(monitor.parent)"> {{ group }}</router-link>
<router-link v-if="group !== ''" :to="monitorURL(monitor.parent)">
{{ group }}
</router-link>
<h1>
{{ monitor.name }}
<div class="monitor-id">
@ -13,61 +15,124 @@
<p v-if="monitor.description" v-html="descriptionHTML"></p>
<div class="d-flex">
<div class="tags">
<Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" />
<Tag
v-for="tag in monitor.tags"
:key="tag.id"
:item="tag"
:size="'sm'"
/>
</div>
</div>
<p class="url">
<a v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'mp-health' || monitor.type === 'real-browser' " :href="monitor.url" target="_blank" rel="noopener noreferrer">{{ filterPassword(monitor.url) }}</a>
<a
v-if="
monitor.type === 'http' ||
monitor.type === 'keyword' ||
monitor.type === 'json-query' ||
monitor.type === 'mp-health' ||
monitor.type === 'real-browser'
"
:href="monitor.url"
target="_blank"
rel="noopener noreferrer"
>{{ filterPassword(monitor.url) }}</a>
<span v-if="monitor.type === 'port'">TCP Port {{ monitor.hostname }}:{{ monitor.port }}</span>
<span v-if="monitor.type === 'ping'">Ping: {{ monitor.hostname }}</span>
<span v-if="monitor.type === 'keyword'">
<br>
<br />
<span>{{ $t("Keyword") }}: </span>
<span class="keyword">{{ monitor.keyword }}</span>
<span v-if="monitor.invertKeyword" alt="Inverted keyword" class="keyword-inverted"> </span>
<span
v-if="monitor.invertKeyword"
alt="Inverted keyword"
class="keyword-inverted"
>
</span>
</span>
<span v-if="monitor.type === 'json-query'">
<br>
<span>{{ $t("Json Query") }}:</span> <span class="keyword">{{ monitor.jsonPath }}</span>
<br>
<span>{{ $t("Expected Value") }}:</span> <span class="keyword">{{ monitor.expectedValue }}</span>
<br />
<span>{{ $t("Json Query") }}:</span>
<span class="keyword">{{ monitor.jsonPath }}</span>
<br />
<span>{{ $t("Expected Value") }}:</span>
<span class="keyword">{{ monitor.expectedValue }}</span>
</span>
<span v-if="monitor.type === 'dns'">[{{ monitor.dns_resolve_type }}] {{ monitor.hostname }}
<br>
<span>{{ $t("Last Result") }}:</span> <span class="keyword">{{ monitor.dns_last_result }}</span>
<br />
<span>{{ $t("Last Result") }}:</span>
<span class="keyword">{{ monitor.dns_last_result }}</span>
</span>
<span v-if="monitor.type === 'docker'">Docker container: {{ monitor.docker_container }}</span>
<span v-if="monitor.type === 'gamedig'">Gamedig - {{ monitor.game }}: {{ monitor.hostname }}:{{ monitor.port }}</span>
<span v-if="monitor.type === 'gamedig'">Gamedig - {{ monitor.game }}: {{ monitor.hostname }}:{{
monitor.port
}}</span>
<span v-if="monitor.type === 'grpc-keyword'">gRPC - {{ filterPassword(monitor.grpcUrl) }}
<br>
<span>{{ $t("Keyword") }}:</span> <span class="keyword">{{ monitor.keyword }}</span>
<br />
<span>{{ $t("Keyword") }}:</span>
<span class="keyword">{{ monitor.keyword }}</span>
</span>
<span v-if="monitor.type === 'mongodb'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'mqtt'">MQTT: {{ monitor.hostname }}:{{ monitor.port }}/{{ monitor.mqttTopic }}</span>
<span v-if="monitor.type === 'mysql'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'postgres'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'push'">Push: <a :href="pushURL" target="_blank" rel="noopener noreferrer">{{ pushURL }}</a></span>
<span v-if="monitor.type === 'mongodb'">{{
filterPassword(monitor.databaseConnectionString)
}}</span>
<span v-if="monitor.type === 'mqtt'">MQTT: {{ monitor.hostname }}:{{ monitor.port }}/{{
monitor.mqttTopic
}}</span>
<span v-if="monitor.type === 'mysql'">{{
filterPassword(monitor.databaseConnectionString)
}}</span>
<span v-if="monitor.type === 'postgres'">{{
filterPassword(monitor.databaseConnectionString)
}}</span>
<span v-if="monitor.type === 'push'">Push:
<a
:href="pushURL"
target="_blank"
rel="noopener noreferrer"
>{{ pushURL }}</a></span>
<span v-if="monitor.type === 'radius'">Radius: {{ filterPassword(monitor.hostname) }}</span>
<span v-if="monitor.type === 'redis'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'sqlserver'">SQL Server: {{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'steam'">Steam Game Server: {{ monitor.hostname }}:{{ monitor.port }}</span>
<span v-if="monitor.type === 'redis'">{{
filterPassword(monitor.databaseConnectionString)
}}</span>
<span v-if="monitor.type === 'sqlserver'">SQL Server:
{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'steam'">Steam Game Server: {{ monitor.hostname }}:{{
monitor.port
}}</span>
</p>
<div class="functions">
<div class="btn-group" role="group">
<button v-if="monitor.active" class="btn btn-normal" @click="pauseDialog">
<button
v-if="monitor.active"
class="btn btn-normal"
@click="pauseDialog"
>
<font-awesome-icon icon="pause" /> {{ $t("Pause") }}
</button>
<button v-if="! monitor.active" class="btn btn-primary" :disabled="monitor.forceInactive" @click="resumeMonitor">
<button
v-if="!monitor.active"
class="btn btn-primary"
:disabled="monitor.forceInactive"
@click="resumeMonitor"
>
<font-awesome-icon icon="play" /> {{ $t("Resume") }}
</button>
<router-link :to=" '/edit/' + monitor.id " class="btn btn-normal">
<router-link
:to="'/edit/' + monitor.id"
class="btn btn-normal"
>
<font-awesome-icon icon="edit" /> {{ $t("Edit") }}
</router-link>
<router-link :to=" '/clone/' + monitor.id " class="btn btn-normal">
<router-link
:to="'/clone/' + monitor.id"
class="btn btn-normal"
>
<font-awesome-icon icon="clone" /> {{ $t("Clone") }}
</router-link>
<button class="btn btn-normal text-danger" @click="deleteDialog">
<button
class="btn btn-normal text-danger"
@click="deleteDialog"
>
<font-awesome-icon icon="trash" /> {{ $t("Delete") }}
</button>
</div>
@ -77,29 +142,53 @@
<div class="row">
<div class="col-md-8">
<HeartbeatBar :monitor-id="monitor.id" />
<span class="word">{{ $t("checkEverySecond", [ monitor.interval ]) }}</span>
<span class="word">{{
$t("checkEverySecond", [monitor.interval])
}}
({{
secondsToHumanReadableFormat(monitor.interval)
}})</span>
</div>
<div class="col-md-4 text-center">
<span class="badge rounded-pill" :class=" 'bg-' + status.color " style="font-size: 30px;" data-testid="monitor-status">{{ status.text }}</span>
<span
class="badge rounded-pill"
:class="'bg-' + status.color"
style="font-size: 30px;"
data-testid="monitor-status"
>{{ status.text }}</span>
</div>
</div>
</div>
<!-- Push Examples -->
<div v-if="monitor.type === 'push'" class="shadow-box big-padding">
<a href="#" @click="pushMonitor.showPushExamples = !pushMonitor.showPushExamples">{{ $t("pushViewCode") }}</a>
<a
href="#"
@click="
pushMonitor.showPushExamples =
!pushMonitor.showPushExamples
"
>{{ $t("pushViewCode") }}</a>
<transition name="slide-fade" appear>
<div v-if="pushMonitor.showPushExamples" class="mt-3">
<select id="push-current-example" v-model="pushMonitor.currentExample" class="form-select">
<select
id="push-current-example"
v-model="pushMonitor.currentExample"
class="form-select"
>
<optgroup :label="$t('programmingLanguages')">
<option value="csharp">C#</option>
<option value="go">Go</option>
<option value="java">Java</option>
<option value="javascript-fetch">JavaScript (fetch)</option>
<option value="javascript-fetch">
JavaScript (fetch)
</option>
<option value="php">PHP</option>
<option value="python">Python</option>
<option value="typescript-fetch">TypeScript (fetch)</option>
<option value="typescript-fetch">
TypeScript (fetch)
</option>
</optgroup>
<optgroup :label="$t('pushOthers')">
<option value="bash-curl">Bash (curl)</option>
@ -108,7 +197,13 @@
</optgroup>
</select>
<prism-editor v-model="pushMonitor.code" class="css-editor mt-3" :highlight="pushExampleHighlighter" line-numbers readonly></prism-editor>
<prism-editor
v-model="pushMonitor.code"
class="css-editor mt-3"
:highlight="pushExampleHighlighter"
line-numbers
readonly
></prism-editor>
</div>
</transition>
</div>
@ -116,55 +211,98 @@
<!-- Stats -->
<div class="shadow-box big-padding text-center stats">
<div class="row">
<div v-if="monitor.type !== 'group'" class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<div
v-if="monitor.type !== 'group'"
class="col-12 col-sm col row d-flex align-items-center d-sm-block"
>
<h4 class="col-4 col-sm-12">{{ pingTitle() }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ $t("Current") }})</p>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
({{ $t("Current") }})
</p>
<span class="col-4 col-sm-12 num">
<a href="#" @click.prevent="showPingChartBox = !showPingChartBox">
<a
href="#"
@click.prevent="
showPingChartBox = !showPingChartBox
"
>
<CountUp :value="ping" />
</a>
</span>
</div>
<div v-if="monitor.type !== 'group'" class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<div
v-if="monitor.type !== 'group'"
class="col-12 col-sm col row d-flex align-items-center d-sm-block"
>
<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") }})</p>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(24{{ $t("-hour") }})
</p>
<span class="col-4 col-sm-12 num">
<CountUp :value="avgPing" />
</span>
</div>
<!-- Uptime (24-hour) -->
<div class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<div
class="col-12 col-sm col row d-flex align-items-center d-sm-block"
>
<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") }})</p>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(24{{ $t("-hour") }})
</p>
<span class="col-4 col-sm-12 num">
<Uptime :monitor="monitor" type="24" />
</span>
</div>
<!-- Uptime (30-day) -->
<div class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<div
class="col-12 col-sm col row d-flex align-items-center d-sm-block"
>
<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") }})</p>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(30{{ $t("-day") }})
</p>
<span class="col-4 col-sm-12 num">
<Uptime :monitor="monitor" type="720" />
</span>
</div>
<!-- Uptime (1-year) -->
<div class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<div
class="col-12 col-sm col row d-flex align-items-center d-sm-block"
>
<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") }})</p>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(1{{ $t("-year") }})
</p>
<span class="col-4 col-sm-12 num">
<Uptime :monitor="monitor" type="1y" />
</span>
</div>
<div v-if="tlsInfo" class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<div
v-if="tlsInfo"
class="col-12 col-sm col row d-flex align-items-center d-sm-block"
>
<h4 class="col-4 col-sm-12">{{ $t("Cert Exp.") }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">(<Datetime :value="tlsInfo.certInfo.validTo" date-only />)</p>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(<Datetime
:value="tlsInfo.certInfo.validTo"
date-only
/>)
</p>
<span class="col-4 col-sm-12 num">
<a href="#" @click.prevent="toggleCertInfoBox = !toggleCertInfoBox">{{ tlsInfo.certInfo.daysRemaining }} {{ $tc("day", tlsInfo.certInfo.daysRemaining) }}</a>
<a
href="#"
@click.prevent="
toggleCertInfoBox = !toggleCertInfoBox
"
>{{ tlsInfo.certInfo.daysRemaining }}
{{
$tc("day", tlsInfo.certInfo.daysRemaining)
}}</a>
</span>
</div>
</div>
@ -172,17 +310,26 @@
<!-- Cert Info Box -->
<transition name="slide-fade" appear>
<div v-if="showCertInfoBox" class="shadow-box big-padding text-center">
<div
v-if="showCertInfoBox"
class="shadow-box big-padding text-center"
>
<div class="row">
<div class="col">
<certificate-info :certInfo="tlsInfo.certInfo" :valid="tlsInfo.valid" />
<certificate-info
:certInfo="tlsInfo.certInfo"
:valid="tlsInfo.valid"
/>
</div>
</div>
</div>
</transition>
<!-- Ping Chart -->
<div v-if="showPingChartBox" class="shadow-box big-padding text-center ping-chart-wrapper">
<div
v-if="showPingChartBox"
class="shadow-box big-padding text-center ping-chart-wrapper"
>
<div class="row">
<div class="col">
<PingChart :monitor-id="monitor.id" />
@ -194,25 +341,46 @@
<div v-if="monitor.type === 'real-browser'" class="shadow-box">
<div class="row">
<div class="col-md-6 zoom-cursor">
<img :src="screenshotURL" style="width: 100%;" alt="screenshot of the website" @click="showScreenshotDialog">
<img
:src="screenshotURL"
style="width: 100%;"
alt="screenshot of the website"
@click="showScreenshotDialog"
/>
</div>
<ScreenshotDialog ref="screenshotDialog" :imageURL="screenshotURL" />
<ScreenshotDialog
ref="screenshotDialog"
:imageURL="screenshotURL"
/>
</div>
</div>
<div class="shadow-box table-shadow-box">
<div class="dropdown dropdown-clear-data">
<button class="btn btn-sm btn-outline-danger dropdown-toggle" type="button" data-bs-toggle="dropdown">
<font-awesome-icon icon="trash" /> {{ $t("Clear Data") }}
<button
class="btn btn-sm btn-outline-danger dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
>
<font-awesome-icon icon="trash" />
{{ $t("Clear Data") }}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button type="button" class="dropdown-item" @click="clearEventsDialog">
<button
type="button"
class="dropdown-item"
@click="clearEventsDialog"
>
{{ $t("Events") }}
</button>
</li>
<li>
<button type="button" class="dropdown-item" @click="clearHeartbeatsDialog">
<button
type="button"
class="dropdown-item"
@click="clearHeartbeatsDialog"
>
{{ $t("Heartbeats") }}
</button>
</li>
@ -227,9 +395,15 @@
</tr>
</thead>
<tbody>
<tr v-for="(beat, index) in displayedRecords" :key="index" style="padding: 10px;">
<tr
v-for="(beat, index) in displayedRecords"
:key="index"
style="padding: 10px;"
>
<td><Status :status="beat.status" /></td>
<td :class="{ 'border-0':! beat.msg}"><Datetime :value="beat.time" /></td>
<td :class="{ 'border-0': !beat.msg }">
<Datetime :value="beat.time" />
</td>
<td class="border-0">{{ beat.msg }}</td>
</tr>
@ -251,19 +425,42 @@
</div>
</div>
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseMonitor">
<Confirm
ref="confirmPause"
:yes-text="$t('Yes')"
:no-text="$t('No')"
@yes="pauseMonitor"
>
{{ $t("pauseMonitorMsg") }}
</Confirm>
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteMonitor">
<Confirm
ref="confirmDelete"
btn-style="btn-danger"
:yes-text="$t('Yes')"
:no-text="$t('No')"
@yes="deleteMonitor"
>
{{ $t("deleteMonitorMsg") }}
</Confirm>
<Confirm ref="confirmClearEvents" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="clearEvents">
<Confirm
ref="confirmClearEvents"
btn-style="btn-danger"
:yes-text="$t('Yes')"
:no-text="$t('No')"
@yes="clearEvents"
>
{{ $t("clearEventsMsg") }}
</Confirm>
<Confirm ref="confirmClearHeartbeats" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="clearHeartbeats">
<Confirm
ref="confirmClearHeartbeats"
btn-style="btn-danger"
:yes-text="$t('Yes')"
:no-text="$t('No')"
@yes="clearHeartbeats"
>
{{ $t("clearHeartbeatsMsg") }}
</Confirm>
</div>
@ -281,14 +478,16 @@ import Datetime from "../components/Datetime.vue";
import CountUp from "../components/CountUp.vue";
import Uptime from "../components/Uptime.vue";
import Pagination from "v-pagination-3";
const PingChart = defineAsyncComponent(() => import("../components/PingChart.vue"));
const PingChart = defineAsyncComponent(() =>
import("../components/PingChart.vue")
);
import Tag from "../components/Tag.vue";
import CertificateInfo from "../components/CertificateInfo.vue";
import { getMonitorRelativeURL } from "../util.ts";
import { URL } from "whatwg-url";
import DOMPurify from "dompurify";
import { marked } from "marked";
import { getResBaseURL } from "../util-frontend";
import { getResBaseURL, relativeTimeFormatter } from "../util-frontend";
import { highlight, languages } from "prismjs/components/prism-core";
import "prismjs/components/prism-clike";
import "prismjs/components/prism-javascript";
@ -310,7 +509,7 @@ export default {
Tag,
CertificateInfo,
PrismEditor,
ScreenshotDialog
ScreenshotDialog,
},
data() {
return {
@ -344,7 +543,10 @@ export default {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.cacheTime = Date.now();
if (this.monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[this.monitor.id]) {
if (
this.monitor.id in this.$root.lastHeartbeatList &&
this.$root.lastHeartbeatList[this.monitor.id]
) {
return this.$root.lastHeartbeatList[this.monitor.id];
}
@ -362,7 +564,10 @@ export default {
},
avgPing() {
if (this.$root.avgPingList[this.monitor.id] || this.$root.avgPingList[this.monitor.id] === 0) {
if (
this.$root.avgPingList[this.monitor.id] ||
this.$root.avgPingList[this.monitor.id] === 0
) {
return this.$root.avgPingList[this.monitor.id];
}
@ -374,14 +579,17 @@ export default {
return this.$root.statusList[this.monitor.id];
}
return { };
return {};
},
tlsInfo() {
// Add: this.$root.tlsInfoList[this.monitor.id].certInfo
// Fix: TypeError: Cannot read properties of undefined (reading 'validTo')
// Reason: TLS Info object format is changed in 1.8.0, if for some reason, it cannot connect to the site after update to 1.8.0, the object is still in the old format.
if (this.$root.tlsInfoList[this.monitor.id] && this.$root.tlsInfoList[this.monitor.id].certInfo) {
if (
this.$root.tlsInfoList[this.monitor.id] &&
this.$root.tlsInfoList[this.monitor.id].certInfo
) {
return this.$root.tlsInfoList[this.monitor.id];
}
@ -397,11 +605,21 @@ export default {
},
pushURL() {
return this.$root.baseURL + "/api/push/" + this.monitor.pushToken + "?status=up&msg=OK&ping=";
return (
this.$root.baseURL +
"/api/push/" +
this.monitor.pushToken +
"?status=up&msg=OK&ping="
);
},
screenshotURL() {
return getResBaseURL() + this.monitor.screenshot + "?time=" + this.cacheTime;
return (
getResBaseURL() +
this.monitor.screenshot +
"?time=" +
this.cacheTime
);
},
descriptionHTML() {
@ -410,7 +628,7 @@ export default {
} else {
return "";
}
}
},
},
watch: {
@ -434,7 +652,10 @@ export default {
mounted() {
this.getImportantHeartbeatListLength();
this.$root.emitter.on("newImportantHeartbeat", this.onNewImportantHeartbeat);
this.$root.emitter.on(
"newImportantHeartbeat",
this.onNewImportantHeartbeat
);
if (this.monitor && this.monitor.type === "push") {
if (this.lastHeartBeat.status === -1) {
@ -445,7 +666,10 @@ export default {
},
beforeUnmount() {
this.$root.emitter.off("newImportantHeartbeat", this.onNewImportantHeartbeat);
this.$root.emitter.off(
"newImportantHeartbeat",
this.onNewImportantHeartbeat
);
},
methods: {
@ -472,9 +696,11 @@ export default {
* @returns {void}
*/
resumeMonitor() {
this.$root.getSocket().emit("resumeMonitor", this.monitor.id, (res) => {
this.$root.toastRes(res);
});
this.$root
.getSocket()
.emit("resumeMonitor", this.monitor.id, (res) => {
this.$root.toastRes(res);
});
},
/**
@ -482,9 +708,11 @@ export default {
* @returns {void}
*/
pauseMonitor() {
this.$root.getSocket().emit("pauseMonitor", this.monitor.id, (res) => {
this.$root.toastRes(res);
});
this.$root
.getSocket()
.emit("pauseMonitor", this.monitor.id, (res) => {
this.$root.toastRes(res);
});
},
/**
@ -552,7 +780,7 @@ export default {
*/
clearHeartbeats() {
this.$root.clearHeartbeats(this.monitor.id, (res) => {
if (! res.ok) {
if (!res.ok) {
toast.error(res.msg);
}
});
@ -569,7 +797,11 @@ export default {
translationPrefix = "Avg. ";
}
if (this.monitor.type === "http" || this.monitor.type === "keyword" || this.monitor.type === "json-query") {
if (
this.monitor.type === "http" ||
this.monitor.type === "keyword" ||
this.monitor.type === "json-query"
) {
return this.$t(translationPrefix + "Response");
}
@ -599,7 +831,10 @@ export default {
return parsedUrl.toString();
} catch (e) {
// Handle SQL Server
return urlString.replaceAll(/Password=(.+);/ig, "Password=******;");
return urlString.replaceAll(
/Password=(.+);/gi,
"Password=******;"
);
}
},
@ -609,12 +844,18 @@ export default {
*/
getImportantHeartbeatListLength() {
if (this.monitor) {
this.$root.getSocket().emit("monitorImportantHeartbeatListCount", this.monitor.id, (res) => {
if (res.ok) {
this.importantHeartBeatListLength = res.count;
this.getImportantHeartbeatListPaged();
}
});
this.$root
.getSocket()
.emit(
"monitorImportantHeartbeatListCount",
this.monitor.id,
(res) => {
if (res.ok) {
this.importantHeartBeatListLength = res.count;
this.getImportantHeartbeatListPaged();
}
}
);
}
},
@ -625,11 +866,19 @@ export default {
getImportantHeartbeatListPaged() {
if (this.monitor) {
const offset = (this.page - 1) * this.perPage;
this.$root.getSocket().emit("monitorImportantHeartbeatListPaged", this.monitor.id, offset, this.perPage, (res) => {
if (res.ok) {
this.displayedRecords = res.data;
}
});
this.$root
.getSocket()
.emit(
"monitorImportantHeartbeatListPaged",
this.monitor.id,
offset,
this.perPage,
(res) => {
if (res.ok) {
this.displayedRecords = res.data;
}
}
);
}
},
@ -661,13 +910,26 @@ export default {
loadPushExample() {
this.pushMonitor.code = "";
this.$root.getSocket().emit("getPushExample", this.pushMonitor.currentExample, (res) => {
let code = res.code
.replace("60", this.monitor.interval)
.replace("https://example.com/api/push/key?status=up&msg=OK&ping=", this.pushURL);
this.pushMonitor.code = code;
});
}
this.$root
.getSocket()
.emit(
"getPushExample",
this.pushMonitor.currentExample,
(res) => {
let code = res.code
.replace("60", this.monitor.interval)
.replace(
"https://example.com/api/push/key?status=up&msg=OK&ping=",
this.pushURL
);
this.pushMonitor.code = code;
}
);
},
secondsToHumanReadableFormat(seconds) {
return relativeTimeFormatter.secondsToHumanReadableFormat(seconds);
},
},
};
</script>

View File

@ -311,6 +311,13 @@
required
data-testid="hostname-input"
>
<div v-if="monitor.type === 'mqtt'" class="form-text">
<i18n-t tag="p" keypath="mqttHostnameTip">
<template #hostnameFormat>
<code>[mqtt,ws,wss]://hostname</code>
</template>
</i18n-t>
</div>
</div>
<!-- Port -->
@ -483,6 +490,21 @@
</div>
</div>
<div class="my-3">
<label for="mqttWebsocketPath" class="form-label">{{ $t("mqttWebSocketPath") }}</label>
<input
v-if="/wss?:\/\/.+/.test(monitor.hostname)"
id="mqttWebsocketPath"
v-model="monitor.mqttWebsocketPath"
type="text"
class="form-control"
>
<input v-else type="text" class="form-control" disabled>
<div class="form-text">
{{ $t("mqttWebsocketPathExplanation") }}
</div>
</div>
<div class="my-3">
<label for="mqttCheckType" class="form-label">MQTT {{ $t("Check Type") }}</label>
<select id="mqttCheckType" v-model="monitor.mqttCheckType" class="form-select" required>
@ -607,6 +629,9 @@
<div class="my-3">
<label for="interval" class="form-label">{{ $t("Heartbeat Interval") }} ({{ $t("checkEverySecond", [ monitor.interval ]) }})</label>
<input id="interval" v-model="monitor.interval" type="number" class="form-control" required :min="minInterval" step="1" :max="maxInterval" @blur="finishUpdateInterval">
<div class="form-text">
{{ monitor.humanReadableInterval }}
</div>
</div>
<div class="my-3">
@ -1148,7 +1173,7 @@ import {
MIN_INTERVAL_SECOND,
sleep,
} from "../util.ts";
import { hostNameRegexPattern } from "../util-frontend";
import { hostNameRegexPattern, relativeTimeFormatter } from "../util-frontend";
import HiddenInput from "../components/HiddenInput.vue";
import EditMonitorConditions from "../components/EditMonitorConditions.vue";
@ -1164,6 +1189,7 @@ const monitorDefaults = {
method: "GET",
ipFamily: null,
interval: 60,
humanReadableInterval: relativeTimeFormatter.secondsToHumanReadableFormat(60),
retryInterval: 60,
resendInterval: 0,
maxretries: 0,
@ -1181,6 +1207,7 @@ const monitorDefaults = {
mqttUsername: "",
mqttPassword: "",
mqttTopic: "",
mqttWebsocketPath: "",
mqttSuccessMessage: "",
mqttCheckType: "keyword",
authMethod: null,
@ -1545,6 +1572,8 @@ message HealthCheckResponse {
if (this.monitor.retryInterval === oldValue) {
this.monitor.retryInterval = value;
}
// Converting monitor.interval to human readable format.
this.monitor.humanReadableInterval = relativeTimeFormatter.secondsToHumanReadableFormat(value);
},
"monitor.timeout"(value, oldValue) {
@ -1845,6 +1874,16 @@ message HealthCheckResponse {
return false;
}
}
// Validate MQTT WebSocket Path pattern if present
if (this.monitor.type === "mqtt" && this.monitor.mqttWebsocketPath) {
const pattern = /^\/[A-Za-z0-9-_&()*+]*$/;
if (!pattern.test(this.monitor.mqttWebsocketPath)) {
toast.error(this.$t("mqttWebsocketPathInvalid"));
return false;
}
}
return true;
},

View File

@ -213,3 +213,78 @@ export function getToastErrorTimeout() {
return errorTimeout;
}
class RelativeTimeFormatter {
/**
* Default locale and options for Relative Time Formatter
*/
constructor() {
this.options = { numeric: "auto" };
this.instance = new Intl.RelativeTimeFormat(currentLocale(), this.options);
}
/**
* Method to update the instance locale and options
* @param {string} locale Localization identifier (e.g., "en", "ar-sy") to update the instance with.
* @returns {void} No return value.
*/
updateLocale(locale) {
this.instance = new Intl.RelativeTimeFormat(locale, this.options);
}
/**
* Method to convert seconds into Human readable format
* @param {number} seconds Receive value in seconds.
* @returns {string} String converted to Days Mins Seconds Format
*/
secondsToHumanReadableFormat(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor(((seconds % 86400) % 3600) / 60);
const secs = ((seconds % 86400) % 3600) % 60;
const parts = [];
/**
* Build the formatted string from parts
* 1. Get the relative time formatted parts from the instance.
* 2. Filter out the relevant parts literal (unit of time) or integer (value).
* 3. Map out the required values.
* @param {number} value Receives value in seconds.
* @param {string} unitOfTime Expected unit of time after conversion.
* @returns {void}
*/
const toFormattedPart = (value, unitOfTime) => {
const partsArray = this.instance.formatToParts(value, unitOfTime);
const filteredParts = partsArray
.filter(
(part, index) =>
(part.type === "literal" || part.type === "integer") &&
index > 0
)
.map((part) => part.value);
const formattedString = filteredParts.join("").trim();
parts.push(formattedString);
};
if (days > 0) {
toFormattedPart(days, "days");
}
if (hours > 0) {
toFormattedPart(hours, "hour");
}
if (minutes > 0) {
toFormattedPart(minutes, "minute");
}
if (secs > 0) {
toFormattedPart(secs, "second");
}
if (parts.length > 0) {
return `${parts.join(" ")}`;
}
return this.instance.format(0, "second"); // Handle case for 0 seconds
}
}
export const relativeTimeFormatter = new RelativeTimeFormatter();

View File

@ -23,6 +23,7 @@ async function testMqtt(mqttSuccessMessage, mqttCheckType, receivedMessage) {
port: connectionString.split(":")[2],
mqttUsername: null,
mqttPassword: null,
mqttWebsocketPath: null, // for WebSocket connections
interval: 20, // controls the timeout
mqttSuccessMessage: mqttSuccessMessage, // for keywords
expectedValue: mqttSuccessMessage, // for json-query