mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-04-02 14:32:21 +00:00
Merge e284ed72c9
into 30f82b9cb4
This commit is contained in:
commit
12b6929082
8 changed files with 327 additions and 40 deletions
db/knex_migrations
server
src
24
db/knex_migrations/2025-03-04-0000-ping-advanced-options.js
Normal file
24
db/knex_migrations/2025-03-04-0000-ping-advanced-options.js
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
/* SQL:
|
||||||
|
ALTER TABLE monitor ADD ping_count INTEGER default 1 not null;
|
||||||
|
ALTER TABLE monitor ADD ping_numeric BOOLEAN default true not null;
|
||||||
|
ALTER TABLE monitor ADD ping_per_request_timeout INTEGER default 2 not null;
|
||||||
|
*/
|
||||||
|
exports.up = function (knex) {
|
||||||
|
// Add new columns to table monitor
|
||||||
|
return knex.schema
|
||||||
|
.alterTable("monitor", function (table) {
|
||||||
|
table.integer("ping_count").defaultTo(1).notNullable();
|
||||||
|
table.boolean("ping_numeric").defaultTo(true).notNullable();
|
||||||
|
table.integer("ping_per_request_timeout").defaultTo(2).notNullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.alterTable("monitor", function (table) {
|
||||||
|
table.dropColumn("ping_count");
|
||||||
|
table.dropColumn("ping_numeric");
|
||||||
|
table.dropColumn("ping_per_request_timeout");
|
||||||
|
});
|
||||||
|
};
|
|
@ -2,7 +2,11 @@ const dayjs = require("dayjs");
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const { Prometheus } = require("../prometheus");
|
const { Prometheus } = require("../prometheus");
|
||||||
const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND,
|
const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND,
|
||||||
SQL_DATETIME_FORMAT, evaluateJsonQuery
|
SQL_DATETIME_FORMAT, evaluateJsonQuery,
|
||||||
|
PING_PACKET_SIZE_MIN, PING_PACKET_SIZE_MAX, PING_PACKET_SIZE_DEFAULT,
|
||||||
|
PING_GLOBAL_TIMEOUT_MIN, PING_GLOBAL_TIMEOUT_MAX, PING_GLOBAL_TIMEOUT_DEFAULT,
|
||||||
|
PING_COUNT_MIN, PING_COUNT_MAX, PING_COUNT_DEFAULT,
|
||||||
|
PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT
|
||||||
} = require("../../src/util");
|
} = require("../../src/util");
|
||||||
const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery,
|
const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery,
|
||||||
redisPingAsync, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal
|
redisPingAsync, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal
|
||||||
|
@ -155,6 +159,11 @@ class Monitor extends BeanModel {
|
||||||
snmpVersion: this.snmpVersion,
|
snmpVersion: this.snmpVersion,
|
||||||
rabbitmqNodes: JSON.parse(this.rabbitmqNodes),
|
rabbitmqNodes: JSON.parse(this.rabbitmqNodes),
|
||||||
conditions: JSON.parse(this.conditions),
|
conditions: JSON.parse(this.conditions),
|
||||||
|
|
||||||
|
// ping advanced options
|
||||||
|
ping_numeric: this.isPingNumeric(),
|
||||||
|
ping_count: this.ping_count,
|
||||||
|
ping_per_request_timeout: this.ping_per_request_timeout,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (includeSensitiveData) {
|
if (includeSensitiveData) {
|
||||||
|
@ -247,6 +256,14 @@ class Monitor extends BeanModel {
|
||||||
return Boolean(this.expiryNotification);
|
return Boolean(this.expiryNotification);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if ping should use numeric output only
|
||||||
|
* @returns {boolean} True if IP addresses will be output instead of symbolic hostnames
|
||||||
|
*/
|
||||||
|
isPingNumeric() {
|
||||||
|
return Boolean(this.ping_numeric);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse to boolean
|
* Parse to boolean
|
||||||
* @returns {boolean} Should TLS errors be ignored?
|
* @returns {boolean} Should TLS errors be ignored?
|
||||||
|
@ -617,7 +634,7 @@ class Monitor extends BeanModel {
|
||||||
bean.status = UP;
|
bean.status = UP;
|
||||||
|
|
||||||
} else if (this.type === "ping") {
|
} else if (this.type === "ping") {
|
||||||
bean.ping = await ping(this.hostname, this.packetSize);
|
bean.ping = await ping(this.hostname, this.ping_count, "", this.ping_numeric, this.packetSize, this.timeout, this.ping_per_request_timeout);
|
||||||
bean.msg = "";
|
bean.msg = "";
|
||||||
bean.status = UP;
|
bean.status = UP;
|
||||||
} else if (this.type === "push") { // Type: Push
|
} else if (this.type === "push") { // Type: Push
|
||||||
|
@ -689,7 +706,7 @@ class Monitor extends BeanModel {
|
||||||
bean.msg = res.data.response.servers[0].name;
|
bean.msg = res.data.response.servers[0].name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bean.ping = await ping(this.hostname, this.packetSize);
|
bean.ping = await ping(this.hostname, PING_COUNT_DEFAULT, "", true, this.packetSize, PING_GLOBAL_TIMEOUT_DEFAULT, PING_PER_REQUEST_TIMEOUT_DEFAULT);
|
||||||
} catch (_) { }
|
} catch (_) { }
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Server not found on Steam");
|
throw new Error("Server not found on Steam");
|
||||||
|
@ -1500,6 +1517,31 @@ class Monitor extends BeanModel {
|
||||||
if (this.interval < MIN_INTERVAL_SECOND) {
|
if (this.interval < MIN_INTERVAL_SECOND) {
|
||||||
throw new Error(`Interval cannot be less than ${MIN_INTERVAL_SECOND} seconds`);
|
throw new Error(`Interval cannot be less than ${MIN_INTERVAL_SECOND} seconds`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.type === "ping") {
|
||||||
|
// ping parameters validation
|
||||||
|
if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) {
|
||||||
|
throw new Error(`Packet size must be between ${PING_PACKET_SIZE_MIN} and ${PING_PACKET_SIZE_MAX} (default: ${PING_PACKET_SIZE_DEFAULT})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ping_per_request_timeout && (this.ping_per_request_timeout < PING_PER_REQUEST_TIMEOUT_MIN || this.ping_per_request_timeout > PING_PER_REQUEST_TIMEOUT_MAX)) {
|
||||||
|
throw new Error(`Per-ping timeout must be between ${PING_PER_REQUEST_TIMEOUT_MIN} and ${PING_PER_REQUEST_TIMEOUT_MAX} seconds (default: ${PING_PER_REQUEST_TIMEOUT_DEFAULT})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ping_count && (this.ping_count < PING_COUNT_MIN || this.ping_count > PING_COUNT_MAX)) {
|
||||||
|
throw new Error(`Echo requests count must be between ${PING_COUNT_MIN} and ${PING_COUNT_MAX} (default: ${PING_COUNT_DEFAULT})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.timeout) {
|
||||||
|
const pingGlobalTimeout = Math.round(Number(this.timeout));
|
||||||
|
|
||||||
|
if (pingGlobalTimeout < this.ping_per_request_timeout || pingGlobalTimeout < PING_GLOBAL_TIMEOUT_MIN || pingGlobalTimeout > PING_GLOBAL_TIMEOUT_MAX) {
|
||||||
|
throw new Error(`Timeout must be between ${PING_GLOBAL_TIMEOUT_MIN} and ${PING_GLOBAL_TIMEOUT_MAX} seconds (default: ${PING_GLOBAL_TIMEOUT_DEFAULT})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timeout = pingGlobalTimeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -875,6 +875,11 @@ let needSetup = false;
|
||||||
bean.rabbitmqPassword = monitor.rabbitmqPassword;
|
bean.rabbitmqPassword = monitor.rabbitmqPassword;
|
||||||
bean.conditions = JSON.stringify(monitor.conditions);
|
bean.conditions = JSON.stringify(monitor.conditions);
|
||||||
|
|
||||||
|
// ping advanced options
|
||||||
|
bean.ping_numeric = monitor.ping_numeric;
|
||||||
|
bean.ping_count = monitor.ping_count;
|
||||||
|
bean.ping_per_request_timeout = monitor.ping_per_request_timeout;
|
||||||
|
|
||||||
bean.validate();
|
bean.validate();
|
||||||
|
|
||||||
await R.store(bean);
|
await R.store(bean);
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
const tcpp = require("tcp-ping");
|
const tcpp = require("tcp-ping");
|
||||||
const ping = require("@louislam/ping");
|
const ping = require("@louislam/ping");
|
||||||
const { R } = require("redbean-node");
|
const { R } = require("redbean-node");
|
||||||
const { log, genSecret, badgeConstants } = require("../src/util");
|
const {
|
||||||
|
log, genSecret, badgeConstants,
|
||||||
|
PING_PACKET_SIZE_DEFAULT, PING_GLOBAL_TIMEOUT_DEFAULT,
|
||||||
|
PING_COUNT_DEFAULT, PING_PER_REQUEST_TIMEOUT_DEFAULT
|
||||||
|
} = require("../src/util");
|
||||||
const passwordHash = require("./password-hash");
|
const passwordHash = require("./password-hash");
|
||||||
const { Resolver } = require("dns");
|
const { Resolver } = require("dns");
|
||||||
const iconv = require("iconv-lite");
|
const iconv = require("iconv-lite");
|
||||||
|
@ -118,20 +122,33 @@ exports.tcping = function (hostname, port) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ping the specified machine
|
* Ping the specified machine
|
||||||
* @param {string} hostname Hostname / address of machine
|
* @param {string} destAddr Hostname / IP address of machine to ping
|
||||||
* @param {number} size Size of packet to send
|
* @param {number} count Number of packets to send before stopping
|
||||||
|
* @param {string} sourceAddr Source address for sending/receiving echo requests
|
||||||
|
* @param {boolean} numeric If true, IP addresses will be output instead of symbolic hostnames
|
||||||
|
* @param {number} size Size (in bytes) of echo request to send
|
||||||
|
* @param {number} deadline Maximum time in seconds before ping stops, regardless of packets sent
|
||||||
|
* @param {number} timeout Maximum time in seconds to wait for each response
|
||||||
* @returns {Promise<number>} Time for ping in ms rounded to nearest integer
|
* @returns {Promise<number>} Time for ping in ms rounded to nearest integer
|
||||||
*/
|
*/
|
||||||
exports.ping = async (hostname, size = 56) => {
|
exports.ping = async (
|
||||||
|
destAddr,
|
||||||
|
count = PING_COUNT_DEFAULT,
|
||||||
|
sourceAddr = "",
|
||||||
|
numeric = true,
|
||||||
|
size = PING_PACKET_SIZE_DEFAULT,
|
||||||
|
deadline = PING_GLOBAL_TIMEOUT_DEFAULT,
|
||||||
|
timeout = PING_PER_REQUEST_TIMEOUT_DEFAULT,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
return await exports.pingAsync(hostname, false, size);
|
return await exports.pingAsync(destAddr, false, count, sourceAddr, numeric, size, deadline, timeout);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If the host cannot be resolved, try again with ipv6
|
// If the host cannot be resolved, try again with ipv6
|
||||||
log.debug("ping", "IPv6 error message: " + e.message);
|
log.debug("ping", "IPv6 error message: " + e.message);
|
||||||
|
|
||||||
// As node-ping does not report a specific error for this, try again if it is an empty message with ipv6 no matter what.
|
// As node-ping does not report a specific error for this, try again if it is an empty message with ipv6 no matter what.
|
||||||
if (!e.message) {
|
if (!e.message) {
|
||||||
return await exports.pingAsync(hostname, true, size);
|
return await exports.pingAsync(destAddr, true, count, sourceAddr, numeric, size, deadline, timeout);
|
||||||
} else {
|
} else {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
@ -140,18 +157,35 @@ exports.ping = async (hostname, size = 56) => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ping the specified machine
|
* Ping the specified machine
|
||||||
* @param {string} hostname Hostname / address of machine to ping
|
* @param {string} destAddr Hostname / IP address of machine to ping
|
||||||
* @param {boolean} ipv6 Should IPv6 be used?
|
* @param {boolean} ipv6 Should IPv6 be used?
|
||||||
* @param {number} size Size of ping packet to send
|
* @param {number} count Number of packets to send before stopping
|
||||||
|
* @param {string} sourceAddr Source address for sending/receiving echo requests
|
||||||
|
* @param {boolean} numeric If true, IP addresses will be output instead of symbolic hostnames
|
||||||
|
* @param {number} size Size (in bytes) of echo request to send
|
||||||
|
* @param {number} deadline Maximum time in seconds before ping stops, regardless of packets sent
|
||||||
|
* @param {number} timeout Maximum time in seconds to wait for each response
|
||||||
* @returns {Promise<number>} Time for ping in ms rounded to nearest integer
|
* @returns {Promise<number>} Time for ping in ms rounded to nearest integer
|
||||||
*/
|
*/
|
||||||
exports.pingAsync = function (hostname, ipv6 = false, size = 56) {
|
exports.pingAsync = function (
|
||||||
|
destAddr,
|
||||||
|
ipv6 = false,
|
||||||
|
count = PING_COUNT_DEFAULT,
|
||||||
|
sourceAddr = "",
|
||||||
|
numeric = true,
|
||||||
|
size = PING_PACKET_SIZE_DEFAULT,
|
||||||
|
deadline = PING_GLOBAL_TIMEOUT_DEFAULT,
|
||||||
|
timeout = PING_PER_REQUEST_TIMEOUT_DEFAULT,
|
||||||
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
ping.promise.probe(hostname, {
|
ping.promise.probe(destAddr, {
|
||||||
v6: ipv6,
|
v6: ipv6,
|
||||||
min_reply: 1,
|
min_reply: count,
|
||||||
deadline: 10,
|
sourceAddr: sourceAddr,
|
||||||
|
numeric: numeric,
|
||||||
packetSize: size,
|
packetSize: size,
|
||||||
|
deadline: deadline,
|
||||||
|
timeout: timeout
|
||||||
}).then((res) => {
|
}).then((res) => {
|
||||||
// If ping failed, it will set field to unknown
|
// If ping failed, it will set field to unknown
|
||||||
if (res.alive) {
|
if (res.alive) {
|
||||||
|
|
|
@ -1057,6 +1057,15 @@
|
||||||
"rabbitmqHelpText": "To use the monitor, you will need to enable the Management Plugin in your RabbitMQ setup. For more information, please consult the {rabitmq_documentation}.",
|
"rabbitmqHelpText": "To use the monitor, you will need to enable the Management Plugin in your RabbitMQ setup. For more information, please consult the {rabitmq_documentation}.",
|
||||||
"SendGrid API Key": "SendGrid API Key",
|
"SendGrid API Key": "SendGrid API Key",
|
||||||
"Separate multiple email addresses with commas": "Separate multiple email addresses with commas",
|
"Separate multiple email addresses with commas": "Separate multiple email addresses with commas",
|
||||||
|
"pingCountLabel": "Max Packets",
|
||||||
|
"pingCountDescription": "Number of packets to send before stopping",
|
||||||
|
"pingNumericLabel": "Numeric Output",
|
||||||
|
"pingNumericDescription": "If checked, IP addresses will be output instead of symbolic hostnames",
|
||||||
|
"pingGlobalTimeoutLabel": "Global Timeout",
|
||||||
|
"pingGlobalTimeoutDescription": "Total time in seconds before ping stops, regardless of packets sent",
|
||||||
|
"pingPerRequestTimeoutLabel": "Per-Ping Timeout",
|
||||||
|
"pingPerRequestTimeoutDescription": "This is the maximum waiting time (in seconds) before considering a single ping packet lost",
|
||||||
|
"pingIntervalAdjustedInfo": "Interval adjusted based on packet count, global timeout and per-ping timeout",
|
||||||
"wahaSession": "Session",
|
"wahaSession": "Session",
|
||||||
"wahaChatId": "Chat ID (Phone Number / Contact ID / Group ID)",
|
"wahaChatId": "Chat ID (Phone Number / Contact ID / Group ID)",
|
||||||
"wayToGetWahaApiUrl": "Your WAHA Instance URL.",
|
"wayToGetWahaApiUrl": "Your WAHA Instance URL.",
|
||||||
|
|
|
@ -595,10 +595,14 @@
|
||||||
<input id="retry-interval" v-model="monitor.retryInterval" type="number" class="form-control" required :min="minInterval" step="1">
|
<input id="retry-interval" v-model="monitor.retryInterval" type="number" class="form-control" required :min="minInterval" step="1">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Timeout: HTTP / Keyword / SNMP only -->
|
<!-- Timeout: HTTP / JSON query / Keyword / Ping / RabbitMQ / SNMP only -->
|
||||||
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'snmp' || monitor.type === 'rabbitmq'" class="my-3">
|
<div v-if="monitor.type === 'http' || monitor.type === 'json-query' || monitor.type === 'keyword' || monitor.type === 'ping' || monitor.type === 'rabbitmq' || monitor.type === 'snmp'" class="my-3">
|
||||||
<label for="timeout" class="form-label">{{ $t("Request Timeout") }} ({{ $t("timeoutAfter", [ monitor.timeout || clampTimeout(monitor.interval) ]) }})</label>
|
<label for="timeout" class="form-label">
|
||||||
<input id="timeout" v-model="monitor.timeout" type="number" class="form-control" required min="0" step="0.1">
|
{{ monitor.type === 'ping' ? $t("pingGlobalTimeoutLabel") : $t("Request Timeout") }}
|
||||||
|
<span v-if="monitor.type !== 'ping'">({{ $t("timeoutAfter", [monitor.timeout || clampTimeout(monitor.interval)]) }})</span>
|
||||||
|
</label>
|
||||||
|
<input id="timeout" v-model="monitor.timeout" type="number" class="form-control" :min="timeoutMin" :max="timeoutMax" :step="timeoutStep" required>
|
||||||
|
<div v-if="monitor.type === 'ping'" class="form-text">{{ $t("pingGlobalTimeoutDescription") }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
|
@ -660,10 +664,39 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ping packet size -->
|
<!-- Max Packets / Count -->
|
||||||
|
<div v-if="monitor.type === 'ping'" class="my-3">
|
||||||
|
<label for="ping-count" class="form-label">{{ $t("pingCountLabel") }}</label>
|
||||||
|
<input id="ping-count" v-model="monitor.ping_count" type="number" class="form-control" required min="1" max="100" step="1">
|
||||||
|
<div class="form-text">
|
||||||
|
{{ $t("pingCountDescription") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Numeric Output -->
|
||||||
|
<div v-if="monitor.type === 'ping'" class="my-3 form-check">
|
||||||
|
<input id="ping_numeric" v-model="monitor.ping_numeric" type="checkbox" class="form-check-input" :checked="monitor.ping_numeric">
|
||||||
|
<label class="form-check-label" for="ping_numeric">
|
||||||
|
{{ $t("pingNumericLabel") }}
|
||||||
|
</label>
|
||||||
|
<div class="form-text">
|
||||||
|
{{ $t("pingNumericDescription") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Packet size -->
|
||||||
<div v-if="monitor.type === 'ping'" class="my-3">
|
<div v-if="monitor.type === 'ping'" class="my-3">
|
||||||
<label for="packet-size" class="form-label">{{ $t("Packet Size") }}</label>
|
<label for="packet-size" class="form-label">{{ $t("Packet Size") }}</label>
|
||||||
<input id="packet-size" v-model="monitor.packetSize" type="number" class="form-control" required min="1" max="65500" step="1">
|
<input id="packet-size" v-model="monitor.packetSize" type="number" class="form-control" required min="1" :max="65500" step="1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- per-request timeout -->
|
||||||
|
<div v-if="monitor.type === 'ping'" class="my-3">
|
||||||
|
<label for="ping_per_request_timeout" class="form-label">{{ $t("pingPerRequestTimeoutLabel") }}</label>
|
||||||
|
<input id="ping_per_request_timeout" v-model="monitor.ping_per_request_timeout" type="number" class="form-control" required min="0" max="300" step="1">
|
||||||
|
<div class="form-text">
|
||||||
|
{{ $t("pingPerRequestTimeoutDescription") }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- HTTP / Keyword only -->
|
<!-- HTTP / Keyword only -->
|
||||||
|
@ -1060,7 +1093,13 @@ import DockerHostDialog from "../components/DockerHostDialog.vue";
|
||||||
import RemoteBrowserDialog from "../components/RemoteBrowserDialog.vue";
|
import RemoteBrowserDialog from "../components/RemoteBrowserDialog.vue";
|
||||||
import ProxyDialog from "../components/ProxyDialog.vue";
|
import ProxyDialog from "../components/ProxyDialog.vue";
|
||||||
import TagsManager from "../components/TagsManager.vue";
|
import TagsManager from "../components/TagsManager.vue";
|
||||||
import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, sleep } from "../util.ts";
|
import {
|
||||||
|
genSecret,
|
||||||
|
isDev,
|
||||||
|
MAX_INTERVAL_SECOND,
|
||||||
|
MIN_INTERVAL_SECOND,
|
||||||
|
sleep,
|
||||||
|
} from "../util.ts";
|
||||||
import { hostNameRegexPattern } from "../util-frontend";
|
import { hostNameRegexPattern } from "../util-frontend";
|
||||||
import HiddenInput from "../components/HiddenInput.vue";
|
import HiddenInput from "../components/HiddenInput.vue";
|
||||||
import EditMonitorConditions from "../components/EditMonitorConditions.vue";
|
import EditMonitorConditions from "../components/EditMonitorConditions.vue";
|
||||||
|
@ -1082,7 +1121,6 @@ const monitorDefaults = {
|
||||||
notificationIDList: {},
|
notificationIDList: {},
|
||||||
ignoreTls: false,
|
ignoreTls: false,
|
||||||
upsideDown: false,
|
upsideDown: false,
|
||||||
packetSize: 56,
|
|
||||||
expiryNotification: false,
|
expiryNotification: false,
|
||||||
maxredirects: 10,
|
maxredirects: 10,
|
||||||
accepted_statuscodes: [ "200-299" ],
|
accepted_statuscodes: [ "200-299" ],
|
||||||
|
@ -1157,6 +1195,29 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
|
timeoutStep() {
|
||||||
|
return this.monitor.type === "ping" ? 1 : 0.1;
|
||||||
|
},
|
||||||
|
|
||||||
|
timeoutMin() {
|
||||||
|
return this.monitor.type === "ping" ? 1 : 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
timeoutMax() {
|
||||||
|
return this.monitor.type === "ping" ? 60 : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
timeoutLabel() {
|
||||||
|
return this.monitor.type === "ping" ? this.$t("pingTimeoutLabel") : this.$t("Request Timeout");
|
||||||
|
},
|
||||||
|
|
||||||
|
timeoutDescription() {
|
||||||
|
if (this.monitor.type === "ping") {
|
||||||
|
return this.$t("pingTimeoutDescription");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
|
||||||
ipRegex() {
|
ipRegex() {
|
||||||
|
|
||||||
// Allow to test with simple dns server with port (127.0.0.1:5300)
|
// Allow to test with simple dns server with port (127.0.0.1:5300)
|
||||||
|
@ -1415,9 +1476,25 @@ message HealthCheckResponse {
|
||||||
},
|
},
|
||||||
|
|
||||||
"monitor.timeout"(value, oldValue) {
|
"monitor.timeout"(value, oldValue) {
|
||||||
// keep timeout within 80% range
|
if (this.monitor.type === "ping") {
|
||||||
if (value && value !== oldValue) {
|
this.finishUpdateInterval();
|
||||||
this.monitor.timeout = this.clampTimeout(value);
|
} else {
|
||||||
|
// keep timeout within 80% range
|
||||||
|
if (value && value !== oldValue) {
|
||||||
|
this.monitor.timeout = this.clampTimeout(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"monitor.ping_count"() {
|
||||||
|
if (this.monitor.type === "ping") {
|
||||||
|
this.finishUpdateInterval();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"monitor.ping_per_request_timeout"() {
|
||||||
|
if (this.monitor.type === "ping") {
|
||||||
|
this.finishUpdateInterval();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -1446,8 +1523,10 @@ message HealthCheckResponse {
|
||||||
// Set a default timeout if the monitor type has changed or if it's a new monitor
|
// Set a default timeout if the monitor type has changed or if it's a new monitor
|
||||||
if (oldType || this.isAdd) {
|
if (oldType || this.isAdd) {
|
||||||
if (this.monitor.type === "snmp") {
|
if (this.monitor.type === "snmp") {
|
||||||
// snmp is not expected to be executed via the internet => we can choose a lower default timeout
|
// snmp is not expected to be executed via the internet => we can choose a lower default timeout
|
||||||
this.monitor.timeout = 5;
|
this.monitor.timeout = 5;
|
||||||
|
} else if (this.monitor.type === "ping") {
|
||||||
|
this.monitor.timeout = 10;
|
||||||
} else {
|
} else {
|
||||||
this.monitor.timeout = 48;
|
this.monitor.timeout = 48;
|
||||||
}
|
}
|
||||||
|
@ -1564,7 +1643,11 @@ message HealthCheckResponse {
|
||||||
if (this.isAdd) {
|
if (this.isAdd) {
|
||||||
|
|
||||||
this.monitor = {
|
this.monitor = {
|
||||||
...monitorDefaults
|
...monitorDefaults,
|
||||||
|
ping_count: 3,
|
||||||
|
ping_numeric: true,
|
||||||
|
packetSize: 56,
|
||||||
|
ping_per_request_timeout: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.$root.proxyList && !this.monitor.proxyId) {
|
if (this.$root.proxyList && !this.monitor.proxyId) {
|
||||||
|
@ -1627,7 +1710,12 @@ message HealthCheckResponse {
|
||||||
}
|
}
|
||||||
// Handling for monitors that are missing/zeroed timeout
|
// Handling for monitors that are missing/zeroed timeout
|
||||||
if (!this.monitor.timeout) {
|
if (!this.monitor.timeout) {
|
||||||
this.monitor.timeout = ~~(this.monitor.interval * 8) / 10;
|
if (this.monitor.type === "ping") {
|
||||||
|
// set to default
|
||||||
|
this.monitor.timeout = 10;
|
||||||
|
} else {
|
||||||
|
this.monitor.timeout = ~~(this.monitor.interval * 8) / 10;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.$root.toastError(res.msg);
|
this.$root.toastError(res.msg);
|
||||||
|
@ -1840,11 +1928,48 @@ message HealthCheckResponse {
|
||||||
return Number.isFinite(clamped) ? clamped : maxTimeout;
|
return Number.isFinite(clamped) ? clamped : maxTimeout;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
calculatePingInterval() {
|
||||||
|
// If monitor.type is not "ping", simply return the configured interval
|
||||||
|
if (this.monitor.type !== "ping") {
|
||||||
|
return this.monitor.interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the maximum theoretical time needed if every ping request times out
|
||||||
|
const theoreticalTotal = this.monitor.ping_count * this.monitor.ping_per_request_timeout;
|
||||||
|
|
||||||
|
// The global timeout (aka deadline) forces ping to terminate, so the effective limit
|
||||||
|
// is the smaller value between deadline and theoreticalTotal
|
||||||
|
const effectiveLimit = Math.min(this.monitor.timeout, theoreticalTotal);
|
||||||
|
|
||||||
|
// Add a 10% margin to the effective limit to ensure proper handling
|
||||||
|
const adjustedLimit = Math.ceil(effectiveLimit * 1.1);
|
||||||
|
|
||||||
|
// If the calculated limit is lower than the minimum allowed interval, use the minimum interval
|
||||||
|
if (adjustedLimit < this.minInterval) {
|
||||||
|
return this.minInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjustedLimit;
|
||||||
|
},
|
||||||
|
|
||||||
finishUpdateInterval() {
|
finishUpdateInterval() {
|
||||||
// Update timeout if it is greater than the clamp timeout
|
if (this.monitor.type === "ping") {
|
||||||
let clampedValue = this.clampTimeout(this.monitor.interval);
|
// Calculate the minimum required interval based on ping configuration
|
||||||
if (this.monitor.timeout > clampedValue) {
|
const calculatedPingInterval = this.calculatePingInterval();
|
||||||
this.monitor.timeout = clampedValue;
|
|
||||||
|
// If the configured interval is too small, adjust it to the minimum required value
|
||||||
|
if (this.monitor.interval < calculatedPingInterval) {
|
||||||
|
this.monitor.interval = calculatedPingInterval;
|
||||||
|
|
||||||
|
// Notify the user that the interval has been automatically adjusted
|
||||||
|
toast.info(this.$t("pingIntervalAdjustedInfo"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update timeout if it is greater than the clamp timeout
|
||||||
|
let clampedValue = this.clampTimeout(this.monitor.interval);
|
||||||
|
if (this.monitor.timeout > clampedValue) {
|
||||||
|
this.monitor.timeout = clampedValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
42
src/util.js
42
src/util.js
|
@ -8,17 +8,34 @@
|
||||||
// Backend uses the compiled file util.js
|
// Backend uses the compiled file util.js
|
||||||
// Frontend uses util.ts
|
// Frontend uses util.ts
|
||||||
*/
|
*/
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
};
|
};
|
||||||
var _a;
|
var _a;
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = exports.CONSOLE_STYLE_FgViolet = exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0;
|
exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = exports.CONSOLE_STYLE_FgViolet = exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.PING_PER_REQUEST_TIMEOUT_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_MAX = exports.PING_PER_REQUEST_TIMEOUT_MIN = exports.PING_COUNT_DEFAULT = exports.PING_COUNT_MAX = exports.PING_COUNT_MIN = exports.PING_GLOBAL_TIMEOUT_DEFAULT = exports.PING_GLOBAL_TIMEOUT_MAX = exports.PING_GLOBAL_TIMEOUT_MIN = exports.PING_PACKET_SIZE_DEFAULT = exports.PING_PACKET_SIZE_MAX = exports.PING_PACKET_SIZE_MIN = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0;
|
||||||
exports.evaluateJsonQuery = exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = void 0;
|
exports.evaluateJsonQuery = exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = void 0;
|
||||||
exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = void 0;
|
|
||||||
const dayjs_1 = __importDefault(require("dayjs"));
|
const dayjs_1 = __importDefault(require("dayjs"));
|
||||||
const dayjs = require("dayjs");
|
const jsonata = __importStar(require("jsonata"));
|
||||||
const jsonata = require("jsonata");
|
|
||||||
exports.isDev = process.env.NODE_ENV === "development";
|
exports.isDev = process.env.NODE_ENV === "development";
|
||||||
exports.isNode = typeof process !== "undefined" && ((_a = process === null || process === void 0 ? void 0 : process.versions) === null || _a === void 0 ? void 0 : _a.node);
|
exports.isNode = typeof process !== "undefined" && ((_a = process === null || process === void 0 ? void 0 : process.versions) === null || _a === void 0 ? void 0 : _a.node);
|
||||||
exports.appName = "Uptime Kuma";
|
exports.appName = "Uptime Kuma";
|
||||||
|
@ -35,6 +52,18 @@ exports.SQL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
|
||||||
exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
|
exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
|
||||||
exports.MAX_INTERVAL_SECOND = 2073600;
|
exports.MAX_INTERVAL_SECOND = 2073600;
|
||||||
exports.MIN_INTERVAL_SECOND = 20;
|
exports.MIN_INTERVAL_SECOND = 20;
|
||||||
|
exports.PING_PACKET_SIZE_MIN = 1;
|
||||||
|
exports.PING_PACKET_SIZE_MAX = 65500;
|
||||||
|
exports.PING_PACKET_SIZE_DEFAULT = 56;
|
||||||
|
exports.PING_GLOBAL_TIMEOUT_MIN = 1;
|
||||||
|
exports.PING_GLOBAL_TIMEOUT_MAX = 300;
|
||||||
|
exports.PING_GLOBAL_TIMEOUT_DEFAULT = 10;
|
||||||
|
exports.PING_COUNT_MIN = 1;
|
||||||
|
exports.PING_COUNT_MAX = 100;
|
||||||
|
exports.PING_COUNT_DEFAULT = 1;
|
||||||
|
exports.PING_PER_REQUEST_TIMEOUT_MIN = 1;
|
||||||
|
exports.PING_PER_REQUEST_TIMEOUT_MAX = 60;
|
||||||
|
exports.PING_PER_REQUEST_TIMEOUT_DEFAULT = 2;
|
||||||
exports.CONSOLE_STYLE_Reset = "\x1b[0m";
|
exports.CONSOLE_STYLE_Reset = "\x1b[0m";
|
||||||
exports.CONSOLE_STYLE_Bright = "\x1b[1m";
|
exports.CONSOLE_STYLE_Bright = "\x1b[1m";
|
||||||
exports.CONSOLE_STYLE_Dim = "\x1b[2m";
|
exports.CONSOLE_STYLE_Dim = "\x1b[2m";
|
||||||
|
@ -66,7 +95,6 @@ exports.CONSOLE_STYLE_BgMagenta = "\x1b[45m";
|
||||||
exports.CONSOLE_STYLE_BgCyan = "\x1b[46m";
|
exports.CONSOLE_STYLE_BgCyan = "\x1b[46m";
|
||||||
exports.CONSOLE_STYLE_BgWhite = "\x1b[47m";
|
exports.CONSOLE_STYLE_BgWhite = "\x1b[47m";
|
||||||
exports.CONSOLE_STYLE_BgGray = "\x1b[100m";
|
exports.CONSOLE_STYLE_BgGray = "\x1b[100m";
|
||||||
|
|
||||||
const consoleModuleColors = [
|
const consoleModuleColors = [
|
||||||
exports.CONSOLE_STYLE_FgCyan,
|
exports.CONSOLE_STYLE_FgCyan,
|
||||||
exports.CONSOLE_STYLE_FgGreen,
|
exports.CONSOLE_STYLE_FgGreen,
|
||||||
|
@ -458,4 +486,4 @@ async function evaluateJsonQuery(data, jsonPath, jsonPathOperator, expectedValue
|
||||||
throw new Error(`Error evaluating JSON query: ${err.message}. Response from server was: ${response}`);
|
throw new Error(`Error evaluating JSON query: ${err.message}. Response from server was: ${response}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
exports.evaluateJsonQuery = evaluateJsonQuery;
|
exports.evaluateJsonQuery = evaluateJsonQuery;
|
||||||
|
|
20
src/util.ts
20
src/util.ts
|
@ -39,6 +39,26 @@ export const SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
|
||||||
export const MAX_INTERVAL_SECOND = 2073600; // 24 days
|
export const MAX_INTERVAL_SECOND = 2073600; // 24 days
|
||||||
export const MIN_INTERVAL_SECOND = 20; // 20 seconds
|
export const MIN_INTERVAL_SECOND = 20; // 20 seconds
|
||||||
|
|
||||||
|
// Packet Size limits
|
||||||
|
export const PING_PACKET_SIZE_MIN = 1;
|
||||||
|
export const PING_PACKET_SIZE_MAX = 65500;
|
||||||
|
export const PING_PACKET_SIZE_DEFAULT = 56;
|
||||||
|
|
||||||
|
// Global timeout (aka deadline) limits in seconds
|
||||||
|
export const PING_GLOBAL_TIMEOUT_MIN = 1;
|
||||||
|
export const PING_GLOBAL_TIMEOUT_MAX = 300;
|
||||||
|
export const PING_GLOBAL_TIMEOUT_DEFAULT = 10;
|
||||||
|
|
||||||
|
// Ping count limits
|
||||||
|
export const PING_COUNT_MIN = 1;
|
||||||
|
export const PING_COUNT_MAX = 100;
|
||||||
|
export const PING_COUNT_DEFAULT = 1;
|
||||||
|
|
||||||
|
// per-request timeout (aka timeout) limits in seconds
|
||||||
|
export const PING_PER_REQUEST_TIMEOUT_MIN = 1;
|
||||||
|
export const PING_PER_REQUEST_TIMEOUT_MAX = 60;
|
||||||
|
export const PING_PER_REQUEST_TIMEOUT_DEFAULT = 2;
|
||||||
|
|
||||||
// Console colors
|
// Console colors
|
||||||
// https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color
|
// https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color
|
||||||
export const CONSOLE_STYLE_Reset = "\x1b[0m";
|
export const CONSOLE_STYLE_Reset = "\x1b[0m";
|
||||||
|
|
Loading…
Add table
Reference in a new issue