mirror of
https://github.com/louislam/uptime-kuma.git
synced 2024-11-23 23:04:04 +00:00
Merge branch '1.23.X' into 1.23.13-to-2.0.0
# Conflicts: # .github/workflows/auto-test.yml # package-lock.json # package.json # server/database.js # server/model/monitor.js # server/monitor-types/real-browser-monitor-type.js # server/util-server.js # test/cypress/unit/i18n.spec.js
This commit is contained in:
commit
63a380326d
13 changed files with 152 additions and 66 deletions
2
.github/workflows/auto-test.yml
vendored
2
.github/workflows/auto-test.yml
vendored
|
@ -22,7 +22,7 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [macos-latest, ubuntu-latest, windows-latest, ARM64]
|
os: [macos-latest, ubuntu-latest, windows-latest, ARM64]
|
||||||
node: [ 18, 20.5 ]
|
node: [ 16, 20.5 ]
|
||||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
18
db/patch-monitor-tls-info-add-fk.sql
Normal file
18
db/patch-monitor-tls-info-add-fk.sql
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
PRAGMA writable_schema = TRUE;
|
||||||
|
|
||||||
|
UPDATE
|
||||||
|
SQLITE_MASTER
|
||||||
|
SET
|
||||||
|
sql = replace(sql,
|
||||||
|
'monitor_id INTEGER NOT NULL',
|
||||||
|
'monitor_id INTEGER NOT NULL REFERENCES [monitor] ([id]) ON DELETE CASCADE ON UPDATE CASCADE'
|
||||||
|
)
|
||||||
|
WHERE
|
||||||
|
name = 'monitor_tls_info'
|
||||||
|
AND type = 'table';
|
||||||
|
|
||||||
|
PRAGMA writable_schema = RESET;
|
||||||
|
|
||||||
|
COMMIT;
|
14
package.json
14
package.json
|
@ -49,7 +49,7 @@
|
||||||
"build-docker-nightly-local": "npm run build && docker build -f docker/dockerfile -t louislam/uptime-kuma:nightly2 --target nightly .",
|
"build-docker-nightly-local": "npm run build && docker build -f docker/dockerfile -t louislam/uptime-kuma:nightly2 --target nightly .",
|
||||||
"build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test2 --target pr-test2 . --push",
|
"build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test2 --target pr-test2 . --push",
|
||||||
"upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain",
|
"upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain",
|
||||||
"setup": "git checkout 1.23.11 && npm ci --production && npm run download-dist",
|
"setup": "git checkout 1.23.13 && npm ci --production && npm run download-dist",
|
||||||
"download-dist": "node extra/download-dist.js",
|
"download-dist": "node extra/download-dist.js",
|
||||||
"mark-as-nightly": "node extra/mark-as-nightly.js",
|
"mark-as-nightly": "node extra/mark-as-nightly.js",
|
||||||
"reset-password": "node extra/reset-password.js",
|
"reset-password": "node extra/reset-password.js",
|
||||||
|
@ -79,7 +79,7 @@
|
||||||
"@louislam/sqlite3": "15.1.6",
|
"@louislam/sqlite3": "15.1.6",
|
||||||
"@vvo/tzdb": "^6.125.0",
|
"@vvo/tzdb": "^6.125.0",
|
||||||
"args-parser": "~1.3.0",
|
"args-parser": "~1.3.0",
|
||||||
"axios": "~0.28.0",
|
"axios": "~0.28.1",
|
||||||
"axios-ntlm": "1.3.0",
|
"axios-ntlm": "1.3.0",
|
||||||
"badge-maker": "~3.3.1",
|
"badge-maker": "~3.3.1",
|
||||||
"bcryptjs": "~2.4.3",
|
"bcryptjs": "~2.4.3",
|
||||||
|
@ -117,7 +117,7 @@
|
||||||
"mongodb": "~4.17.1",
|
"mongodb": "~4.17.1",
|
||||||
"mqtt": "~4.3.7",
|
"mqtt": "~4.3.7",
|
||||||
"mssql": "~8.1.4",
|
"mssql": "~8.1.4",
|
||||||
"mysql2": "~3.6.2",
|
"mysql2": "~3.9.6",
|
||||||
"nanoid": "~3.3.4",
|
"nanoid": "~3.3.4",
|
||||||
"node-cloudflared-tunnel": "~1.0.9",
|
"node-cloudflared-tunnel": "~1.0.9",
|
||||||
"node-radius-client": "~1.0.0",
|
"node-radius-client": "~1.0.0",
|
||||||
|
@ -140,14 +140,14 @@
|
||||||
"socket.io": "~4.6.1",
|
"socket.io": "~4.6.1",
|
||||||
"socket.io-client": "~4.6.1",
|
"socket.io-client": "~4.6.1",
|
||||||
"socks-proxy-agent": "6.1.1",
|
"socks-proxy-agent": "6.1.1",
|
||||||
"tar": "~6.1.11",
|
"tar": "~6.2.1",
|
||||||
"tcp-ping": "~0.1.1",
|
"tcp-ping": "~0.1.1",
|
||||||
"thirty-two": "~1.0.2",
|
"thirty-two": "~1.0.2",
|
||||||
"tough-cookie": "~4.1.3",
|
"tough-cookie": "~4.1.3",
|
||||||
"ws": "^8.13.0"
|
"ws": "^8.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/github": "~5.0.1",
|
"@actions/github": "~5.1.1",
|
||||||
"@fortawesome/fontawesome-svg-core": "~1.2.36",
|
"@fortawesome/fontawesome-svg-core": "~1.2.36",
|
||||||
"@fortawesome/free-regular-svg-icons": "~5.15.4",
|
"@fortawesome/free-regular-svg-icons": "~5.15.4",
|
||||||
"@fortawesome/free-solid-svg-icons": "~5.15.4",
|
"@fortawesome/free-solid-svg-icons": "~5.15.4",
|
||||||
|
@ -171,7 +171,7 @@
|
||||||
"cross-env": "~7.0.3",
|
"cross-env": "~7.0.3",
|
||||||
"delay": "^5.0.0",
|
"delay": "^5.0.0",
|
||||||
"dns2": "~2.0.1",
|
"dns2": "~2.0.1",
|
||||||
"dompurify": "~2.4.3",
|
"dompurify": "~3.0.11",
|
||||||
"eslint": "~8.14.0",
|
"eslint": "~8.14.0",
|
||||||
"eslint-plugin-jsdoc": "~46.4.6",
|
"eslint-plugin-jsdoc": "~46.4.6",
|
||||||
"eslint-plugin-vue": "~8.7.1",
|
"eslint-plugin-vue": "~8.7.1",
|
||||||
|
@ -192,7 +192,7 @@
|
||||||
"test": "~3.3.0",
|
"test": "~3.3.0",
|
||||||
"typescript": "~4.4.4",
|
"typescript": "~4.4.4",
|
||||||
"v-pagination-3": "~0.1.7",
|
"v-pagination-3": "~0.1.7",
|
||||||
"vite": "~5.0.10",
|
"vite": "~5.2.8",
|
||||||
"vite-plugin-compression": "^0.5.1",
|
"vite-plugin-compression": "^0.5.1",
|
||||||
"vite-plugin-vue-devtools": "^7.0.15",
|
"vite-plugin-vue-devtools": "^7.0.15",
|
||||||
"vue": "~3.4.2",
|
"vue": "~3.4.2",
|
||||||
|
|
|
@ -245,12 +245,12 @@ class Monitor extends BeanModel {
|
||||||
/**
|
/**
|
||||||
* Encode user and password to Base64 encoding
|
* Encode user and password to Base64 encoding
|
||||||
* for HTTP "basic" auth, as per RFC-7617
|
* for HTTP "basic" auth, as per RFC-7617
|
||||||
* @param {string} user Username to encode
|
* @param {string|null} user - The username (nullable if not changed by a user)
|
||||||
* @param {string} pass Password to encode
|
* @param {string|null} pass - The password (nullable if not changed by a user)
|
||||||
* @returns {string} Encoded username:password
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
encodeBase64(user, pass) {
|
encodeBase64(user, pass) {
|
||||||
return Buffer.from(user + ":" + pass).toString("base64");
|
return Buffer.from(`${user || ""}:${pass || ""}`).toString("base64");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -533,6 +533,18 @@ class Monitor extends BeanModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tlsInfo = {};
|
||||||
|
// Store tlsInfo when secureConnect event is emitted
|
||||||
|
// The keylog event listener is a workaround to access the tlsSocket
|
||||||
|
options.httpsAgent.once("keylog", async (line, tlsSocket) => {
|
||||||
|
tlsSocket.once("secureConnect", async () => {
|
||||||
|
tlsInfo = checkCertificate(tlsSocket);
|
||||||
|
tlsInfo.valid = tlsSocket.authorized || false;
|
||||||
|
|
||||||
|
await this.handleTlsInfo(tlsInfo);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
log.debug("monitor", `[${this.name}] Axios Options: ${JSON.stringify(options)}`);
|
log.debug("monitor", `[${this.name}] Axios Options: ${JSON.stringify(options)}`);
|
||||||
log.debug("monitor", `[${this.name}] Axios Request`);
|
log.debug("monitor", `[${this.name}] Axios Request`);
|
||||||
|
|
||||||
|
@ -542,30 +554,18 @@ class Monitor extends BeanModel {
|
||||||
bean.msg = `${res.status} - ${res.statusText}`;
|
bean.msg = `${res.status} - ${res.statusText}`;
|
||||||
bean.ping = dayjs().valueOf() - startTime;
|
bean.ping = dayjs().valueOf() - startTime;
|
||||||
|
|
||||||
// Check certificate if https is used
|
// fallback for if kelog event is not emitted, but we may still have tlsInfo,
|
||||||
let certInfoStartTime = dayjs().valueOf();
|
// e.g. if the connection is made through a proxy
|
||||||
if (this.getUrl()?.protocol === "https:") {
|
if (this.getUrl()?.protocol === "https:" && tlsInfo.valid === undefined) {
|
||||||
log.debug("monitor", `[${this.name}] Check cert`);
|
const tlsSocket = res.request.res.socket;
|
||||||
try {
|
|
||||||
let tlsInfoObject = checkCertificate(res);
|
|
||||||
tlsInfo = await this.updateTlsInfo(tlsInfoObject);
|
|
||||||
|
|
||||||
if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) {
|
if (tlsSocket) {
|
||||||
log.debug("monitor", `[${this.name}] call checkCertExpiryNotifications`);
|
tlsInfo = checkCertificate(tlsSocket);
|
||||||
await this.checkCertExpiryNotifications(tlsInfoObject);
|
tlsInfo.valid = tlsSocket.authorized || false;
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e) {
|
await this.handleTlsInfo(tlsInfo);
|
||||||
if (e.message !== "No TLS certificate in response") {
|
|
||||||
log.error("monitor", "Caught error");
|
|
||||||
log.error("monitor", e.message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.TIMELOGGER === "1") {
|
|
||||||
log.debug("monitor", "Cert Info Query Time: " + (dayjs().valueOf() - certInfoStartTime) + "ms");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.UPTIME_KUMA_LOG_RESPONSE_BODY_MONITOR_ID === this.id) {
|
if (process.env.UPTIME_KUMA_LOG_RESPONSE_BODY_MONITOR_ID === this.id) {
|
||||||
log.info("monitor", res.data);
|
log.info("monitor", res.data);
|
||||||
|
@ -599,8 +599,12 @@ class Monitor extends BeanModel {
|
||||||
let data = res.data;
|
let data = res.data;
|
||||||
|
|
||||||
// convert data to object
|
// convert data to object
|
||||||
if (typeof data === "string") {
|
if (typeof data === "string" && res.headers["content-type"] !== "application/json") {
|
||||||
|
try {
|
||||||
data = JSON.parse(data);
|
data = JSON.parse(data);
|
||||||
|
} catch (_) {
|
||||||
|
// Failed to parse as JSON, just process it as a string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let expression = jsonata(this.jsonPath);
|
let expression = jsonata(this.jsonPath);
|
||||||
|
@ -1615,6 +1619,20 @@ class Monitor extends BeanModel {
|
||||||
return oAuthAccessToken;
|
return oAuthAccessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store TLS certificate information and check for expiry
|
||||||
|
* @param {Object} tlsInfo Information about the TLS connection
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async handleTlsInfo(tlsInfo) {
|
||||||
|
await this.updateTlsInfo(tlsInfo);
|
||||||
|
this.prometheus?.update(null, tlsInfo);
|
||||||
|
|
||||||
|
if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) {
|
||||||
|
log.debug("monitor", `[${this.name}] call checkCertExpiryNotifications`);
|
||||||
|
await this.checkCertExpiryNotifications(tlsInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Monitor;
|
module.exports = Monitor;
|
||||||
|
|
|
@ -10,6 +10,10 @@ const jwt = require("jsonwebtoken");
|
||||||
const config = require("../config");
|
const config = require("../config");
|
||||||
const { RemoteBrowser } = require("../remote-browser");
|
const { RemoteBrowser } = require("../remote-browser");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached instance of a browser
|
||||||
|
* @type {import ("playwright-core").Browser}
|
||||||
|
*/
|
||||||
let browser = null;
|
let browser = null;
|
||||||
|
|
||||||
let allowedList = [];
|
let allowedList = [];
|
||||||
|
@ -71,10 +75,12 @@ async function isAllowedChromeExecutable(executablePath) {
|
||||||
/**
|
/**
|
||||||
* Get the current instance of the browser. If there isn't one, create
|
* Get the current instance of the browser. If there isn't one, create
|
||||||
* it.
|
* it.
|
||||||
* @returns {Promise<Browser>} The browser
|
* @returns {Promise<import ("playwright-core").Browser>} The browser
|
||||||
*/
|
*/
|
||||||
async function getBrowser() {
|
async function getBrowser() {
|
||||||
if (!browser) {
|
if (browser && browser.isConnected()) {
|
||||||
|
return browser;
|
||||||
|
} else {
|
||||||
let executablePath = await Settings.get("chromeExecutable");
|
let executablePath = await Settings.get("chromeExecutable");
|
||||||
|
|
||||||
executablePath = await prepareChromeExecutable(executablePath);
|
executablePath = await prepareChromeExecutable(executablePath);
|
||||||
|
@ -83,9 +89,10 @@ async function getBrowser() {
|
||||||
//headless: false,
|
//headless: false,
|
||||||
executablePath,
|
executablePath,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
return browser;
|
return browser;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current instance of the browser. If there isn't one, create it
|
* Get the current instance of the browser. If there isn't one, create it
|
||||||
|
|
|
@ -62,7 +62,7 @@ class DingDing extends NotificationProvider {
|
||||||
if (result.data.errmsg === "ok") {
|
if (result.data.errmsg === "ok") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
throw new Error(result.data.errmsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -80,6 +80,7 @@ class Prometheus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (heartbeat) {
|
||||||
try {
|
try {
|
||||||
monitorStatus.set(this.monitorLabelValues, heartbeat.status);
|
monitorStatus.set(this.monitorLabelValues, heartbeat.status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -99,6 +100,7 @@ class Prometheus {
|
||||||
log.error("prometheus", e);
|
log.error("prometheus", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove monitor from prometheus
|
* Remove monitor from prometheus
|
||||||
|
|
|
@ -1319,6 +1319,12 @@ let needSetup = false;
|
||||||
await doubleCheckPassword(socket, currentPassword);
|
await doubleCheckPassword(socket, currentPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log out all clients if enabling auth
|
||||||
|
// GHSA-23q2-5gf8-gjpp
|
||||||
|
if (currentDisabledAuth && !data.disableAuth) {
|
||||||
|
server.disconnectAllSocketClients(socket.userID, socket.id);
|
||||||
|
}
|
||||||
|
|
||||||
const previousChromeExecutable = await Settings.get("chromeExecutable");
|
const previousChromeExecutable = await Settings.get("chromeExecutable");
|
||||||
const previousNSCDStatus = await Settings.get("nscd");
|
const previousNSCDStatus = await Settings.get("nscd");
|
||||||
|
|
||||||
|
|
|
@ -653,21 +653,27 @@ const parseCertificateInfo = function (info) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if certificate is valid
|
* Check if certificate is valid
|
||||||
* @param {object} res Response object from axios
|
* @param {tls.TLSSocket} socket TLSSocket, which may or may not be connected
|
||||||
* @returns {object} Object containing certificate information
|
* @returns {Object} Object containing certificate information
|
||||||
* @throws No socket was found to check certificate for
|
|
||||||
*/
|
*/
|
||||||
exports.checkCertificate = function (res) {
|
exports.checkCertificate = function (socket) {
|
||||||
if (!res.request.res.socket) {
|
let certInfoStartTime = dayjs().valueOf();
|
||||||
throw new Error("No socket found");
|
|
||||||
|
// Return null if there is no socket
|
||||||
|
if (socket === undefined || socket == null) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = res.request.res.socket.getPeerCertificate(true);
|
const info = socket.getPeerCertificate(true);
|
||||||
const valid = res.request.res.socket.authorized || false;
|
const valid = socket.authorized || false;
|
||||||
|
|
||||||
log.debug("cert", "Parsing Certificate Info");
|
log.debug("cert", "Parsing Certificate Info");
|
||||||
const parsedInfo = parseCertificateInfo(info);
|
const parsedInfo = parseCertificateInfo(info);
|
||||||
|
|
||||||
|
if (process.env.TIMELOGGER === "1") {
|
||||||
|
log.debug("monitor", "Cert Info Query Time: " + (dayjs().valueOf() - certInfoStartTime) + "ms");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: valid,
|
valid: valid,
|
||||||
certInfo: parsedInfo
|
certInfo: parsedInfo
|
||||||
|
|
|
@ -5,6 +5,14 @@
|
||||||
<input id="hostname" v-model="$parent.notification.smtpHost" type="text" class="form-control" required>
|
<input id="hostname" v-model="$parent.notification.smtpHost" type="text" class="form-control" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<i18n-t tag="div" keypath="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" class="form-text">
|
||||||
|
<template #localhost>
|
||||||
|
<code>localhost</code>
|
||||||
|
</template>
|
||||||
|
<template #local_mta>
|
||||||
|
<a href="https://wikipedia.org/wiki/Mail_Transfer_Agent" target="_blank">{{ $t("locally configured mail transfer agent") }}</a>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="port" class="form-label">{{ $t("Port") }}</label>
|
<label for="port" class="form-label">{{ $t("Port") }}</label>
|
||||||
<input id="port" v-model="$parent.notification.smtpPort" type="number" class="form-control" required min="0" max="65535" step="1">
|
<input id="port" v-model="$parent.notification.smtpPort" type="number" class="form-control" required min="0" max="65535" step="1">
|
||||||
|
|
27
src/i18n.js
27
src/i18n.js
|
@ -59,10 +59,29 @@ for (let lang in languageList) {
|
||||||
|
|
||||||
const rtlLangs = [ "he-IL", "fa", "ar-SY", "ur" ];
|
const rtlLangs = [ "he-IL", "fa", "ar-SY", "ur" ];
|
||||||
|
|
||||||
export const currentLocale = () => localStorage.locale
|
/**
|
||||||
|| languageList[navigator.language] && navigator.language
|
* Find the best matching locale to display
|
||||||
|| languageList[navigator.language.substring(0, 2)] && navigator.language.substring(0, 2)
|
* If no locale can be matched, the default is "en"
|
||||||
|| "en";
|
* @returns {string} the locale that should be displayed
|
||||||
|
*/
|
||||||
|
export function currentLocale() {
|
||||||
|
for (const locale of [ localStorage.locale, navigator.language, ...navigator.languages ]) {
|
||||||
|
// localstorage might not have a value or there might not be a language in `navigator.language`
|
||||||
|
if (!locale) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (locale in messages) {
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
// some locales are further specified such as "en-US".
|
||||||
|
// If we only have a generic locale for this, we can use it too
|
||||||
|
const genericLocale = locale.split("-")[0];
|
||||||
|
if (genericLocale in messages) {
|
||||||
|
return genericLocale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
export const localeDirection = () => {
|
export const localeDirection = () => {
|
||||||
return rtlLangs.includes(currentLocale()) ? "rtl" : "ltr";
|
return rtlLangs.includes(currentLocale()) ? "rtl" : "ltr";
|
||||||
|
|
|
@ -63,6 +63,8 @@
|
||||||
"Friendly Name": "Friendly Name",
|
"Friendly Name": "Friendly Name",
|
||||||
"URL": "URL",
|
"URL": "URL",
|
||||||
"Hostname": "Hostname",
|
"Hostname": "Hostname",
|
||||||
|
"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",
|
"Port": "Port",
|
||||||
"Heartbeat Interval": "Heartbeat Interval",
|
"Heartbeat Interval": "Heartbeat Interval",
|
||||||
"Request Timeout": "Request Timeout",
|
"Request Timeout": "Request Timeout",
|
||||||
|
|
0
test/cypress/unit/i18n.spec.js
Normal file
0
test/cypress/unit/i18n.spec.js
Normal file
Loading…
Reference in a new issue