diff --git a/.dockerignore b/.dockerignore
index e7ad658b..0bc56885 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -18,6 +18,7 @@ README.md
.vscode
.eslint*
.stylelint*
+/.devcontainer
/.github
yarn.lock
app.json
@@ -35,6 +36,7 @@ tsconfig.json
/extra/healthcheck
extra/exe-builder
+
### .gitignore content (commented rules are duplicated)
#node_modules
diff --git a/.github/workflows/auto-test.yml b/.github/workflows/auto-test.yml
index e1e43ccf..161c5bc5 100644
--- a/.github/workflows/auto-test.yml
+++ b/.github/workflows/auto-test.yml
@@ -22,7 +22,7 @@ jobs:
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest, ARM64]
- node: [ 14, 18 ]
+ node: [ 14, 20 ]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
@@ -50,7 +50,7 @@ jobs:
strategy:
matrix:
os: [ ARMv7 ]
- node: [ 14.21.3, 18.16.1 ]
+ node: [ 14.21.3, 20.5.0 ]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
diff --git a/.stylelintrc b/.stylelintrc
index 00ddcaae..0bcdb7c2 100644
--- a/.stylelintrc
+++ b/.stylelintrc
@@ -10,6 +10,7 @@
"color-function-notation": "legacy",
"shorthand-property-no-redundant-values": null,
"color-hex-length": null,
- "declaration-block-no-redundant-longhand-properties": null
+ "declaration-block-no-redundant-longhand-properties": null,
+ "at-rule-no-unknown": null
}
}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a933a450..c91a483b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -34,19 +34,19 @@ Yes or no, it depends on what you will try to do. Since I don't want to waste yo
Here are some references:
-✅ Usually Accept:
+### ✅ Usually accepted:
- Bug fix
- Security fix
- Adding notification providers
-- Adding new language files (You should go to https://weblate.kuma.pet for existing languages)
+- Adding new language files (see [these instructions](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md))
- Adding new language keys: `$t("...")`
-⚠️ Discussion First
+### ⚠️ Discussion required:
- Large pull requests
- New features
-❌ Won't Merge
-- A dedicated pr for translating existing languages (You can now translate on https://weblate.kuma.pet)
+### ❌ Won't be merged:
+- A dedicated pr for translating existing languages (see [these instructions](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md))
- Do not pass the auto test
- Any breaking changes
- Duplicated pull requests
@@ -106,11 +106,11 @@ I personally do not like something that requires so many configurations before y
## Tools
-- Node.js >= 14
-- NPM >= 8.5
-- Git
-- IDE that supports ESLint and EditorConfig (I am using IntelliJ IDEA)
-- A SQLite GUI tool (SQLite Expert Personal is suggested)
+- [`Node.js`](https://nodejs.org/) >= 14
+- [`npm`](https://www.npmjs.com/) >= 8.5
+- [`git`](https://git-scm.com/)
+- IDE that supports [`ESLint`](https://eslint.org/) and EditorConfig (I am using [`IntelliJ IDEA`](https://www.jetbrains.com/idea/))
+- A SQLite GUI tool (f.ex. [`SQLite Expert Personal`](https://www.sqliteexpert.com/download.html) or [`DBeaver Community`](https://dbeaver.io/download/))
## Install Dependencies for Development
@@ -218,7 +218,17 @@ If for maybe security reasons, a library must be updated. Then you must need to
## Translations
-Please read: https://github.com/louislam/uptime-kuma/tree/master/src/languages
+Please add **all** the strings which are translatable to `src/lang/en.json` (If translation keys are ommited, they can not be translated).
+
+**Don't include any other languages in your inital Pull-Request** (even if this is your mother tounge), to avoid merge-conflicts between weblate and `master`.
+The translations can then (after merging a PR into `master`) be translated by awesome people donating their language-skills.
+
+If you want to help by translating Uptime Kuma into your language, please visit the [instructions on how to translate using weblate](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md).
+
+## Spelling & Grammar
+
+Feel free to correct the grammar in the documentation or code.
+My mother language is not english and my grammar is not that great.
## Wiki
diff --git a/README.md b/README.md
index 0e41652d..151e9a6e 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,16 @@
+
+
+
+
# Uptime Kuma
+Uptime Kuma is an easy-to-use self-hosted monitoring tool.
+
[![GitHub Sponsors](https://img.shields.io/github/sponsors/louislam?label=GitHub%20Sponsors)](https://github.com/sponsors/louislam)
-
-
-
-
-Uptime Kuma is an easy-to-use self-hosted monitoring tool.
-
## 🥔 Live Demo
@@ -184,7 +184,10 @@ If you want to report a bug or request a new feature, feel free to open a [new i
### Translations
If you want to translate Uptime Kuma into your language, please visit [Weblate Readme](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md).
-Feel free to correct my grammar in this README, source code, or wiki, as my mother language is not English and my grammar is not that great.
+## Spelling & Grammar
+
+Feel free to correct the grammar in the documentation or code.
+My mother language is not english and my grammar is not that great.
### Create Pull Requests
If you want to modify Uptime Kuma, please read this guide and follow the rules here: https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md
diff --git a/babel.config.js b/babel.config.js
index 6bb8a01a..d4c89547 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -4,8 +4,4 @@ if (process.env.TEST_FRONTEND) {
config.presets = [ "@babel/preset-env" ];
}
-if (process.env.TEST_BACKEND) {
- config.plugins = [ "babel-plugin-rewire" ];
-}
-
module.exports = config;
diff --git a/db/patch-add-certificate-expiry-status-page.sql b/db/patch-add-certificate-expiry-status-page.sql
new file mode 100644
index 00000000..63a20105
--- /dev/null
+++ b/db/patch-add-certificate-expiry-status-page.sql
@@ -0,0 +1,7 @@
+-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
+BEGIN TRANSACTION;
+
+ALTER TABLE status_page
+ ADD show_certificate_expiry BOOLEAN default 0 NOT NULL;
+
+COMMIT;
diff --git a/db/patch-add-gamedig-given-port.sql b/db/patch-add-gamedig-given-port.sql
new file mode 100644
index 00000000..897a9c72
--- /dev/null
+++ b/db/patch-add-gamedig-given-port.sql
@@ -0,0 +1,7 @@
+-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
+BEGIN TRANSACTION;
+
+ALTER TABLE monitor
+ ADD gamedig_given_port_only BOOLEAN default 1 not null;
+
+COMMIT;
diff --git a/db/patch-add-timeout-monitor.sql b/db/patch-add-timeout-monitor.sql
new file mode 100644
index 00000000..32d49d1e
--- /dev/null
+++ b/db/patch-add-timeout-monitor.sql
@@ -0,0 +1,6 @@
+-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
+BEGIN TRANSACTION;
+
+ALTER TABLE monitor
+ ADD timeout DOUBLE default 0 not null;
+COMMIT;
\ No newline at end of file
diff --git a/db/patch-monitor-oauth-cc.sql b/db/patch-monitor-oauth-cc.sql
new file mode 100644
index 00000000..f33e9529
--- /dev/null
+++ b/db/patch-monitor-oauth-cc.sql
@@ -0,0 +1,19 @@
+-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
+BEGIN TRANSACTION;
+
+ALTER TABLE monitor
+ ADD oauth_client_id TEXT default null;
+
+ALTER TABLE monitor
+ ADD oauth_client_secret TEXT default null;
+
+ALTER TABLE monitor
+ ADD oauth_token_url TEXT default null;
+
+ALTER TABLE monitor
+ ADD oauth_scopes TEXT default null;
+
+ALTER TABLE monitor
+ ADD oauth_auth_method TEXT default null;
+
+COMMIT;
diff --git a/package.json b/package.json
index d2c87b15..400f0782 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "uptime-kuma",
- "version": "1.22.1",
+ "version": "1.23.0-beta.1",
"license": "MIT",
"repository": {
"type": "git",
@@ -99,6 +99,7 @@
"http-proxy-agent": "~5.0.0",
"https-proxy-agent": "~5.0.1",
"iconv-lite": "~0.6.3",
+ "isomorphic-ws": "^5.0.0",
"jsesc": "~3.0.2",
"jsonata": "^2.0.3",
"jsonwebtoken": "~9.0.0",
@@ -115,7 +116,9 @@
"node-cloudflared-tunnel": "~1.0.9",
"node-radius-client": "~1.0.0",
"nodemailer": "~6.6.5",
+ "nostr-tools": "^1.13.1",
"notp": "~2.0.3",
+ "openid-client": "^5.4.2",
"password-hash": "~1.2.2",
"pg": "~8.8.0",
"pg-connection-string": "~2.5.0",
@@ -132,7 +135,8 @@
"socks-proxy-agent": "6.1.1",
"tar": "~6.1.11",
"tcp-ping": "~0.1.1",
- "thirty-two": "~1.0.2"
+ "thirty-two": "~1.0.2",
+ "ws": "^8.13.0"
},
"devDependencies": {
"@actions/github": "~5.0.1",
@@ -149,7 +153,6 @@
"@vue/compiler-sfc": "~3.3.4",
"@vuepic/vue-datepicker": "~3.4.8",
"aedes": "^0.46.3",
- "babel-plugin-rewire": "~1.2.0",
"bootstrap": "5.1.3",
"chart.js": "~4.2.1",
"chartjs-adapter-dayjs-4": "~1.0.4",
diff --git a/server/database.js b/server/database.js
index a770e29a..0af8a312 100644
--- a/server/database.js
+++ b/server/database.js
@@ -28,6 +28,8 @@ class Database {
static sqlitePath;
+ static dockerTLSDir;
+
/**
* @type {boolean}
*/
@@ -79,6 +81,10 @@ class Database {
"patch-add-invert-keyword.sql": true,
"patch-added-json-query.sql": true,
"patch-added-kafka-producer.sql": true,
+ "patch-add-certificate-expiry-status-page.sql": true,
+ "patch-monitor-oauth-cc.sql": true,
+ "patch-add-timeout-monitor.sql": true,
+ "patch-add-gamedig-given-port.sql": true,
};
/**
@@ -101,23 +107,28 @@ class Database {
// Data Directory (must be end with "/")
Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/";
- Database.sqlitePath = Database.dataDir + "kuma.db";
+ Database.sqlitePath = path.join(Database.dataDir, "kuma.db");
if (! fs.existsSync(Database.dataDir)) {
fs.mkdirSync(Database.dataDir, { recursive: true });
}
- Database.uploadDir = Database.dataDir + "upload/";
+ Database.uploadDir = path.join(Database.dataDir, "upload/");
if (! fs.existsSync(Database.uploadDir)) {
fs.mkdirSync(Database.uploadDir, { recursive: true });
}
// Create screenshot dir
- Database.screenshotDir = Database.dataDir + "screenshots/";
+ Database.screenshotDir = path.join(Database.dataDir, "screenshots/");
if (! fs.existsSync(Database.screenshotDir)) {
fs.mkdirSync(Database.screenshotDir, { recursive: true });
}
+ Database.dockerTLSDir = path.join(Database.dataDir, "docker-tls/");
+ if (! fs.existsSync(Database.dockerTLSDir)) {
+ fs.mkdirSync(Database.dockerTLSDir, { recursive: true });
+ }
+
log.info("db", `Data Dir: ${Database.dataDir}`);
}
diff --git a/server/docker.js b/server/docker.js
index ff231502..1a8c0a5d 100644
--- a/server/docker.js
+++ b/server/docker.js
@@ -2,8 +2,16 @@ const axios = require("axios");
const { R } = require("redbean-node");
const version = require("../package.json").version;
const https = require("https");
+const fs = require("fs");
+const path = require("path");
+const Database = require("./database");
class DockerHost {
+
+ static CertificateFileNameCA = "ca.pem";
+ static CertificateFileNameCert = "cert.pem";
+ static CertificateFileNameKey = "key.pem";
+
/**
* Save a docker host
* @param {Object} dockerHost Docker host to save
@@ -66,10 +74,6 @@ class DockerHost {
"Accept": "*/*",
"User-Agent": "Uptime-Kuma/" + version
},
- httpsAgent: new https.Agent({
- maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
- rejectUnauthorized: false,
- }),
};
if (dockerHost.dockerType === "socket") {
@@ -77,6 +81,7 @@ class DockerHost {
} else if (dockerHost.dockerType === "tcp") {
options.baseURL = DockerHost.patchDockerURL(dockerHost.dockerDaemon);
}
+ options.httpsAgent = new https.Agent(DockerHost.getHttpsAgentOptions(dockerHost.dockerType, options.baseURL));
let res = await axios.request(options);
@@ -111,6 +116,53 @@ class DockerHost {
}
return url;
}
+
+ /**
+ * Returns HTTPS agent options with client side TLS parameters if certificate files
+ * for the given host are available under a predefined directory path.
+ *
+ * The base path where certificates are looked for can be set with the
+ * 'DOCKER_TLS_DIR_PATH' environmental variable or defaults to 'data/docker-tls/'.
+ *
+ * If a directory in this path exists with a name matching the FQDN of the docker host
+ * (e.g. the FQDN of 'https://example.com:2376' is 'example.com' so the directory
+ * 'data/docker-tls/example.com/' would be searched for certificate files),
+ * then 'ca.pem', 'key.pem' and 'cert.pem' files are included in the agent options.
+ * File names can also be overridden via 'DOCKER_TLS_FILE_NAME_(CA|KEY|CERT)'.
+ *
+ * @param {String} dockerType i.e. "tcp" or "socket"
+ * @param {String} url The docker host URL rewritten to https://
+ * @return {Object}
+ * */
+ static getHttpsAgentOptions(dockerType, url) {
+ let baseOptions = {
+ maxCachedSessions: 0,
+ rejectUnauthorized: true
+ };
+ let certOptions = {};
+
+ let dirName = (new URL(url)).hostname;
+
+ let caPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameCA);
+ let certPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameCert);
+ let keyPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameKey);
+
+ if (dockerType === "tcp" && fs.existsSync(caPath) && fs.existsSync(certPath) && fs.existsSync(keyPath)) {
+ let ca = fs.readFileSync(caPath);
+ let key = fs.readFileSync(keyPath);
+ let cert = fs.readFileSync(certPath);
+ certOptions = {
+ ca,
+ key,
+ cert
+ };
+ }
+
+ return {
+ ...baseOptions,
+ ...certOptions
+ };
+ }
}
module.exports = {
diff --git a/server/model/group.js b/server/model/group.js
index 3f3b3b12..5b712ace 100644
--- a/server/model/group.js
+++ b/server/model/group.js
@@ -9,12 +9,12 @@ class Group extends BeanModel {
* @param {boolean} [showTags=false] Should the JSON include monitor tags
* @returns {Object}
*/
- async toPublicJSON(showTags = false) {
+ async toPublicJSON(showTags = false, certExpiry = false) {
let monitorBeanList = await this.getMonitorList();
let monitorList = [];
for (let bean of monitorBeanList) {
- monitorList.push(await bean.toPublicJSON(showTags));
+ monitorList.push(await bean.toPublicJSON(showTags, certExpiry));
}
return {
diff --git a/server/model/monitor.js b/server/model/monitor.js
index dfdb4316..237eb79e 100644
--- a/server/model/monitor.js
+++ b/server/model/monitor.js
@@ -6,7 +6,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger, MAX_INTERVA
SQL_DATETIME_FORMAT
} = require("../../src/util");
const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery,
- redisPingAsync, mongodbPing, kafkaProducerAsync
+ redisPingAsync, mongodbPing, kafkaProducerAsync, getOidcTokenClientCredentials,
} = require("../util-server");
const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model");
@@ -38,11 +38,12 @@ class Monitor extends BeanModel {
* Only show necessary data to public
* @returns {Object}
*/
- async toPublicJSON(showTags = false) {
+ async toPublicJSON(showTags = false, certExpiry = false) {
let obj = {
id: this.id,
name: this.name,
sendUrl: this.sendUrl,
+ type: this.type,
};
if (this.sendUrl) {
@@ -52,6 +53,13 @@ class Monitor extends BeanModel {
if (showTags) {
obj.tags = await this.getTags();
}
+
+ if (certExpiry && this.type === "http") {
+ const { certExpiryDaysRemaining, validCert } = await this.getCertExpiry(this.id);
+ obj.certExpiryDaysRemaining = certExpiryDaysRemaining;
+ obj.validCert = validCert;
+ }
+
return obj;
}
@@ -95,6 +103,7 @@ class Monitor extends BeanModel {
active: await this.isActive(),
forceInactive: !await Monitor.isParentActive(this.id),
type: this.type,
+ timeout: this.timeout,
interval: this.interval,
retryInterval: this.retryInterval,
resendInterval: this.resendInterval,
@@ -127,6 +136,7 @@ class Monitor extends BeanModel {
radiusCalledStationId: this.radiusCalledStationId,
radiusCallingStationId: this.radiusCallingStationId,
game: this.game,
+ gamedigGivenPortOnly: this.getGameDigGivenPortOnly(),
httpBodyEncoding: this.httpBodyEncoding,
jsonPath: this.jsonPath,
expectedValue: this.expectedValue,
@@ -147,6 +157,11 @@ class Monitor extends BeanModel {
grpcMetadata: this.grpcMetadata,
basic_auth_user: this.basic_auth_user,
basic_auth_pass: this.basic_auth_pass,
+ oauth_client_id: this.oauth_client_id,
+ oauth_client_secret: this.oauth_client_secret,
+ oauth_token_url: this.oauth_token_url,
+ oauth_scopes: this.oauth_scopes,
+ oauth_auth_method: this.oauth_auth_method,
pushToken: this.pushToken,
databaseConnectionString: this.databaseConnectionString,
radiusUsername: this.radiusUsername,
@@ -185,6 +200,31 @@ class Monitor extends BeanModel {
return await R.getAll("SELECT mt.*, tag.name, tag.color FROM monitor_tag mt JOIN tag ON mt.tag_id = tag.id WHERE mt.monitor_id = ? ORDER BY tag.name", [ this.id ]);
}
+ /**
+ * Gets certificate expiry for this monitor
+ * @param {number} monitorID ID of monitor to send
+ * @returns {Promise>}
+ */
+ async getCertExpiry(monitorID) {
+ let tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [
+ monitorID,
+ ]);
+ let tlsInfo;
+ if (tlsInfoBean) {
+ tlsInfo = JSON.parse(tlsInfoBean?.info_json);
+ if (tlsInfo?.valid && tlsInfo?.certInfo?.daysRemaining) {
+ return {
+ certExpiryDaysRemaining: tlsInfo.certInfo.daysRemaining,
+ validCert: true
+ };
+ }
+ }
+ return {
+ certExpiryDaysRemaining: "",
+ validCert: false
+ };
+ }
+
/**
* Encode user and password to Base64 encoding
* for HTTP "basic" auth, as per RFC-7617
@@ -242,6 +282,10 @@ class Monitor extends BeanModel {
return JSON.parse(this.accepted_statuscodes_json);
}
+ getGameDigGivenPortOnly() {
+ return Boolean(this.gamedigGivenPortOnly);
+ }
+
/**
* Start monitor
* @param {Server} io Socket server instance
@@ -314,7 +358,10 @@ class Monitor extends BeanModel {
const lastBeat = await Monitor.getPreviousHeartbeat(child.id);
// Only change state if the monitor is in worse conditions then the ones before
- if (bean.status === UP && (lastBeat.status === PENDING || lastBeat.status === DOWN)) {
+ // lastBeat.status could be null
+ if (!lastBeat) {
+ bean.status = PENDING;
+ } else if (bean.status === UP && (lastBeat.status === PENDING || lastBeat.status === DOWN)) {
bean.status = lastBeat.status;
} else if (bean.status === PENDING && lastBeat.status === DOWN) {
bean.status = lastBeat.status;
@@ -342,6 +389,24 @@ class Monitor extends BeanModel {
};
}
+ // OIDC: Basic client credential flow.
+ // Additional grants might be implemented in the future
+ let oauth2AuthHeader = {};
+ if (this.auth_method === "oauth2-cc") {
+ try {
+ if (this.oauthAccessToken === undefined || new Date(this.oauthAccessToken.expires_at * 1000) <= new Date()) {
+ log.debug("monitor", `[${this.name}] The oauth access-token undefined or expired. Requesting a new one`);
+ this.oauthAccessToken = await getOidcTokenClientCredentials(this.oauth_token_url, this.oauth_client_id, this.oauth_client_secret, this.oauth_scopes, this.oauth_auth_method);
+ log.debug("monitor", `[${this.name}] Obtained oauth access-token. Expires at ${new Date(this.oauthAccessToken.expires_at * 1000)}`);
+ }
+ oauth2AuthHeader = {
+ "Authorization": this.oauthAccessToken.token_type + " " + this.oauthAccessToken.access_token,
+ };
+ } catch (e) {
+ throw new Error("The oauth config is invalid. " + e.message);
+ }
+ }
+
const httpsAgentOptions = {
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
rejectUnauthorized: !this.getIgnoreTls(),
@@ -370,12 +435,13 @@ class Monitor extends BeanModel {
const options = {
url: this.url,
method: (this.method || "get").toLowerCase(),
- timeout: this.interval * 1000 * 0.8,
+ timeout: this.timeout * 1000,
headers: {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"User-Agent": "Uptime-Kuma/" + version,
...(contentType ? { "Content-Type": contentType } : {}),
...(basicAuthHeader),
+ ...(oauth2AuthHeader),
...(this.headers ? JSON.parse(this.headers) : {})
},
maxRedirects: this.maxredirects,
@@ -589,7 +655,7 @@ class Monitor extends BeanModel {
}
let res = await axios.get(steamApiUrl, {
- timeout: this.interval * 1000 * 0.8,
+ timeout: this.timeout * 1000,
headers: {
"Accept": "*/*",
"User-Agent": "Uptime-Kuma/" + version,
@@ -627,7 +693,7 @@ class Monitor extends BeanModel {
type: this.game,
host: this.hostname,
port: this.port,
- givenPortOnly: true,
+ givenPortOnly: this.getGameDigGivenPortOnly(),
});
bean.msg = state.name;
@@ -661,6 +727,9 @@ class Monitor extends BeanModel {
options.socketPath = dockerHost._dockerDaemon;
} else if (dockerHost._dockerType === "tcp") {
options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon);
+ options.httpsAgent = CacheableDnsHttpAgent.getHttpsAgent(
+ DockerHost.getHttpsAgentOptions(dockerHost._dockerType, options.baseURL)
+ );
}
log.debug("monitor", `[${this.name}] Axios Request`);
@@ -760,29 +829,19 @@ class Monitor extends BeanModel {
port = this.port;
}
- try {
- const resp = await radius(
- this.hostname,
- this.radiusUsername,
- this.radiusPassword,
- this.radiusCalledStationId,
- this.radiusCallingStationId,
- this.radiusSecret,
- port,
- this.interval * 1000 * 0.8,
- );
- if (resp.code) {
- bean.msg = resp.code;
- }
- bean.status = UP;
- } catch (error) {
- bean.status = DOWN;
- if (error.response?.code) {
- bean.msg = error.response.code;
- } else {
- bean.msg = error.message;
- }
- }
+ const resp = await radius(
+ this.hostname,
+ this.radiusUsername,
+ this.radiusPassword,
+ this.radiusCalledStationId,
+ this.radiusCallingStationId,
+ this.radiusSecret,
+ port,
+ this.interval * 1000 * 0.4,
+ );
+
+ bean.msg = resp.code;
+ bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "redis") {
let startTime = dayjs().valueOf();
diff --git a/server/model/status_page.js b/server/model/status_page.js
index 65b77367..e168acf2 100644
--- a/server/model/status_page.js
+++ b/server/model/status_page.js
@@ -90,6 +90,8 @@ class StatusPage extends BeanModel {
* @param {StatusPage} statusPage
*/
static async getStatusPageData(statusPage) {
+ const config = await statusPage.toPublicJSON();
+
// Incident
let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [
statusPage.id,
@@ -110,13 +112,13 @@ class StatusPage extends BeanModel {
]);
for (let groupBean of list) {
- let monitorGroup = await groupBean.toPublicJSON(showTags);
+ let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry);
publicGroupList.push(monitorGroup);
}
// Response
return {
- config: await statusPage.toPublicJSON(),
+ config,
incident,
publicGroupList,
maintenanceList,
@@ -234,6 +236,7 @@ class StatusPage extends BeanModel {
footerText: this.footer_text,
showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id,
+ showCertificateExpiry: !!this.show_certificate_expiry,
};
}
@@ -255,6 +258,7 @@ class StatusPage extends BeanModel {
footerText: this.footer_text,
showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id,
+ showCertificateExpiry: !!this.show_certificate_expiry,
};
}
diff --git a/server/notification-providers/flashduty.js b/server/notification-providers/flashduty.js
new file mode 100644
index 00000000..0d6f69e5
--- /dev/null
+++ b/server/notification-providers/flashduty.js
@@ -0,0 +1,98 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util");
+const { setting } = require("../util-server");
+const successMessage = "Sent Successfully.";
+
+class FlashDuty extends NotificationProvider {
+ name = "FlashDuty";
+
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ try {
+ if (heartbeatJSON == null) {
+ const title = "Uptime Kuma Alert";
+ const monitor = {
+ type: "ping",
+ url: msg,
+ name: "https://flashcat.cloud"
+ };
+ return this.postNotification(notification, title, msg, monitor);
+ }
+
+ if (heartbeatJSON.status === UP) {
+ const title = "Uptime Kuma Monitor ✅ Up";
+
+ return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, "Ok");
+ }
+
+ if (heartbeatJSON.status === DOWN) {
+ const title = "Uptime Kuma Monitor 🔴 Down";
+ return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, notification.flashdutySeverity);
+ }
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+ /**
+ * Generate a monitor url from the monitors infomation
+ * @param {Object} monitorInfo Monitor details
+ * @returns {string|undefined}
+ */
+
+ genMonitorUrl(monitorInfo) {
+ if (monitorInfo.type === "port" && monitorInfo.port) {
+ return monitorInfo.hostname + ":" + monitorInfo.port;
+ }
+ if (monitorInfo.hostname != null) {
+ return monitorInfo.hostname;
+ }
+ return monitorInfo.url;
+ }
+
+ /**
+ * Send the message
+ * @param {BeanModel} notification Message title
+ * @param {string} title Message
+ * @param {string} body Message
+ * @param {Object} monitorInfo Monitor details
+ * @param {string} eventStatus Monitor status (Info, Warning, Critical, Ok)
+ * @returns {string}
+ */
+ async postNotification(notification, title, body, monitorInfo, eventStatus) {
+ const options = {
+ method: "POST",
+ url: "https://api.flashcat.cloud/event/push/alert/standard?integration_key=" + notification.flashdutyIntegrationKey,
+ headers: { "Content-Type": "application/json" },
+ data: {
+ description: `[${title}] [${monitorInfo.name}] ${body}`,
+ title,
+ event_status: eventStatus || "Info",
+ alert_key: String(monitorInfo.id) || Math.random().toString(36).substring(7),
+ labels: monitorInfo?.tags?.reduce((acc, item) => ({ ...acc,
+ [item.name]: item.value
+ }), { resource: this.genMonitorUrl(monitorInfo) }),
+ }
+ };
+
+ const baseURL = await setting("primaryBaseURL");
+ if (baseURL && monitorInfo) {
+ options.client = "Uptime Kuma";
+ options.client_url = baseURL + getMonitorRelativeURL(monitorInfo.id);
+ }
+
+ let result = await axios.request(options);
+ if (result.status == null) {
+ throw new Error("FlashDuty notification failed with invalid response!");
+ }
+ if (result.status < 200 || result.status >= 300) {
+ throw new Error("FlashDuty notification failed with status code " + result.status);
+ }
+ if (result.statusText != null) {
+ return "FlashDuty notification succeed: " + result.statusText;
+ }
+
+ return successMessage;
+ }
+}
+
+module.exports = FlashDuty;
diff --git a/server/notification-providers/nostr.js b/server/notification-providers/nostr.js
new file mode 100644
index 00000000..2c17840d
--- /dev/null
+++ b/server/notification-providers/nostr.js
@@ -0,0 +1,119 @@
+const { log } = require("../../src/util");
+const NotificationProvider = require("./notification-provider");
+const {
+ relayInit,
+ getPublicKey,
+ getEventHash,
+ getSignature,
+ nip04,
+ nip19
+} = require("nostr-tools");
+
+// polyfills for node versions
+const semver = require("semver");
+const nodeVersion = process.version;
+if (semver.lt(nodeVersion, "16.0.0")) {
+ log.warn("monitor", "Node <= 16 is unsupported for nostr, sorry :(");
+} else if (semver.lt(nodeVersion, "18.0.0")) {
+ // polyfills for node 16
+ global.crypto = require("crypto");
+ global.WebSocket = require("isomorphic-ws");
+ if (typeof crypto !== "undefined" && !crypto.subtle && crypto.webcrypto) {
+ crypto.subtle = crypto.webcrypto.subtle;
+ }
+} else if (semver.lt(nodeVersion, "20.0.0")) {
+ // polyfills for node 18
+ global.crypto = require("crypto");
+ global.WebSocket = require("isomorphic-ws");
+} else {
+ // polyfills for node 20
+ global.WebSocket = require("isomorphic-ws");
+}
+
+class Nostr extends NotificationProvider {
+ name = "nostr";
+
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ // All DMs should have same timestamp
+ const createdAt = Math.floor(Date.now() / 1000);
+
+ const senderPrivateKey = await this.getPrivateKey(notification.sender);
+ const senderPublicKey = getPublicKey(senderPrivateKey);
+ const recipientsPublicKeys = await this.getPublicKeys(notification.recipients);
+
+ // Create NIP-04 encrypted direct message event for each recipient
+ const events = [];
+ for (const recipientPublicKey of recipientsPublicKeys) {
+ const ciphertext = await nip04.encrypt(senderPrivateKey, recipientPublicKey, msg);
+ let event = {
+ kind: 4,
+ pubkey: senderPublicKey,
+ created_at: createdAt,
+ tags: [[ "p", recipientPublicKey ]],
+ content: ciphertext,
+ };
+ event.id = getEventHash(event);
+ event.sig = getSignature(event, senderPrivateKey);
+ events.push(event);
+ }
+
+ // Publish events to each relay
+ const relays = notification.relays.split("\n");
+ let successfulRelays = 0;
+
+ // Connect to each relay
+ for (const relayUrl of relays) {
+ const relay = relayInit(relayUrl);
+ try {
+ await relay.connect();
+ successfulRelays++;
+
+ // Publish events
+ for (const event of events) {
+ relay.publish(event);
+ }
+ } catch (error) {
+ continue;
+ } finally {
+ relay.close();
+ }
+ }
+
+ // Report success or failure
+ if (successfulRelays === 0) {
+ throw Error("Failed to connect to any relays.");
+ }
+ return `${successfulRelays}/${relays.length} relays connected.`;
+ }
+
+ async getPrivateKey(sender) {
+ try {
+ const senderDecodeResult = await nip19.decode(sender);
+ const { data } = senderDecodeResult;
+ return data;
+ } catch (error) {
+ throw new Error(`Failed to get private key: ${error.message}`);
+ }
+ }
+
+ async getPublicKeys(recipients) {
+ const recipientsList = recipients.split("\n");
+ const publicKeys = [];
+ for (const recipient of recipientsList) {
+ try {
+ const recipientDecodeResult = await nip19.decode(recipient);
+ const { type, data } = recipientDecodeResult;
+ if (type === "npub") {
+ publicKeys.push(data);
+ } else {
+ throw new Error("not an npub");
+ }
+ } catch (error) {
+ throw new Error(`Error decoding recipient: ${error}`);
+ }
+ }
+ return publicKeys;
+ }
+}
+
+module.exports = Nostr;
diff --git a/server/notification-providers/pushdeer.js b/server/notification-providers/pushdeer.js
index bbd83f4b..288137d1 100644
--- a/server/notification-providers/pushdeer.js
+++ b/server/notification-providers/pushdeer.js
@@ -8,7 +8,9 @@ class PushDeer extends NotificationProvider {
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
let okMsg = "Sent Successfully.";
- let pushdeerlink = "https://api2.pushdeer.com/message/push";
+ let endpoint = "/message/push";
+ let serverUrl = notification.pushdeerServer || "https://api2.pushdeer.com";
+ let pushdeerlink = `${serverUrl.trim().replace(/\/*$/, "")}${endpoint}`;
let valid = msg != null && monitorJSON != null && heartbeatJSON != null;
diff --git a/server/notification.js b/server/notification.js
index ea5c8ee0..570c440d 100644
--- a/server/notification.js
+++ b/server/notification.js
@@ -21,11 +21,13 @@ const LineNotify = require("./notification-providers/linenotify");
const LunaSea = require("./notification-providers/lunasea");
const Matrix = require("./notification-providers/matrix");
const Mattermost = require("./notification-providers/mattermost");
+const Nostr = require("./notification-providers/nostr");
const Ntfy = require("./notification-providers/ntfy");
const Octopush = require("./notification-providers/octopush");
const OneBot = require("./notification-providers/onebot");
const Opsgenie = require("./notification-providers/opsgenie");
const PagerDuty = require("./notification-providers/pagerduty");
+const FlashDuty = require("./notification-providers/flashduty");
const PagerTree = require("./notification-providers/pagertree");
const PromoSMS = require("./notification-providers/promosms");
const Pushbullet = require("./notification-providers/pushbullet");
@@ -84,11 +86,13 @@ class Notification {
new LunaSea(),
new Matrix(),
new Mattermost(),
+ new Nostr(),
new Ntfy(),
new Octopush(),
new OneBot(),
new Opsgenie(),
new PagerDuty(),
+ new FlashDuty(),
new PagerTree(),
new PromoSMS(),
new Pushbullet(),
@@ -115,7 +119,6 @@ class Notification {
new GoAlert(),
new ZohoCliq()
];
-
for (let item of list) {
if (! item.name) {
throw new Error("Notification provider without name");
diff --git a/server/server.js b/server/server.js
index 644bc52a..7363955b 100644
--- a/server/server.js
+++ b/server/server.js
@@ -49,7 +49,7 @@ if (! process.env.NODE_ENV) {
}
log.info("server", "Node Env: " + process.env.NODE_ENV);
-log.info("server", "Inside Container: " + process.env.UPTIME_KUMA_IS_CONTAINER === "1");
+log.info("server", "Inside Container: " + (process.env.UPTIME_KUMA_IS_CONTAINER === "1"));
log.info("server", "Importing Node libraries");
const fs = require("fs");
@@ -670,6 +670,10 @@ let needSetup = false;
let notificationIDList = monitor.notificationIDList;
delete monitor.notificationIDList;
+ // Ensure status code ranges are strings
+ if (!monitor.accepted_statuscodes.every((code) => typeof code === "string")) {
+ throw new Error("Accepted status codes are not all strings");
+ }
monitor.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
delete monitor.accepted_statuscodes;
@@ -686,7 +690,10 @@ let needSetup = false;
await updateMonitorNotification(bean.id, notificationIDList);
await server.sendMonitorList(socket);
- await startMonitor(socket.userID, bean.id);
+
+ if (monitor.active !== false) {
+ await startMonitor(socket.userID, bean.id);
+ }
log.info("monitor", `Added Monitor: ${monitor.id} User ID: ${socket.userID}`);
@@ -732,6 +739,11 @@ let needSetup = false;
removeGroupChildren = true;
}
+ // Ensure status code ranges are strings
+ if (!monitor.accepted_statuscodes.every((code) => typeof code === "string")) {
+ throw new Error("Accepted status codes are not all strings");
+ }
+
bean.name = monitor.name;
bean.description = monitor.description;
bean.parent = monitor.parent;
@@ -742,6 +754,12 @@ let needSetup = false;
bean.headers = monitor.headers;
bean.basic_auth_user = monitor.basic_auth_user;
bean.basic_auth_pass = monitor.basic_auth_pass;
+ bean.timeout = monitor.timeout;
+ bean.oauth_client_id = monitor.oauth_client_id,
+ bean.oauth_client_secret = monitor.oauth_client_secret,
+ bean.oauth_auth_method = this.oauth_auth_method,
+ bean.oauth_token_url = monitor.oauth_token_url,
+ bean.oauth_scopes = monitor.oauth_scopes,
bean.tlsCa = monitor.tlsCa;
bean.tlsCert = monitor.tlsCert;
bean.tlsKey = monitor.tlsKey;
@@ -800,6 +818,7 @@ let needSetup = false;
bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation;
bean.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions);
bean.kafkaProducerMessage = monitor.kafkaProducerMessage;
+ bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly;
bean.validate();
@@ -1401,6 +1420,7 @@ let needSetup = false;
// Define default values
let retryInterval = 0;
+ let timeout = monitorListData[i].timeout || (monitorListData[i].interval * 0.8); // fallback to old value
/*
Only replace the default value with the backup file data for the specific version, where it appears the first time
@@ -1426,6 +1446,7 @@ let needSetup = false;
basic_auth_pass: monitorListData[i].basic_auth_pass,
authWorkstation: monitorListData[i].authWorkstation,
authDomain: monitorListData[i].authDomain,
+ timeout,
interval: monitorListData[i].interval,
retryInterval: retryInterval,
resendInterval: monitorListData[i].resendInterval || 0,
diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js
index 411bda55..eba40dae 100644
--- a/server/socket-handlers/status-page-socket-handler.js
+++ b/server/socket-handlers/status-page-socket-handler.js
@@ -162,6 +162,7 @@ module.exports.statusPageSocketHandler = (socket) => {
statusPage.footer_text = config.footerText;
statusPage.custom_css = config.customCSS;
statusPage.show_powered_by = config.showPoweredBy;
+ statusPage.show_certificate_expiry = config.showCertificateExpiry;
statusPage.modified_date = R.isoDateTime();
statusPage.google_analytics_tag_id = config.googleAnalyticsId;
diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js
index 6781488e..7817c9e1 100644
--- a/server/uptime-kuma-server.js
+++ b/server/uptime-kuma-server.js
@@ -11,6 +11,7 @@ const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
const { Settings } = require("./settings");
const dayjs = require("dayjs");
const childProcess = require("child_process");
+const path = require("path");
// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead.
/**
@@ -214,7 +215,7 @@ class UptimeKumaServer {
* @param {boolean} outputToConsole Should the error also be output to console?
*/
static errorLog(error, outputToConsole = true) {
- const errorLogStream = fs.createWriteStream(Database.dataDir + "/error.log", {
+ const errorLogStream = fs.createWriteStream(path.join(Database.dataDir, "/error.log"), {
flags: "a"
});
diff --git a/server/util-server.js b/server/util-server.js
index 7ae1d199..778e7c6f 100644
--- a/server/util-server.js
+++ b/server/util-server.js
@@ -21,6 +21,8 @@ const grpc = require("@grpc/grpc-js");
const protojs = require("protobufjs");
const radiusClient = require("node-radius-client");
const redis = require("redis");
+const oidc = require("openid-client");
+
const {
dictionaries: {
rfc2865: { file, attributes },
@@ -55,6 +57,43 @@ exports.initJWTSecret = async () => {
return jwtSecretBean;
};
+/**
+ * Decodes a jwt and returns the payload portion without verifying the jqt.
+ * @param {string} jwt The input jwt as a string
+ * @returns {Object} Decoded jwt payload object
+ */
+exports.decodeJwt = (jwt) => {
+ return JSON.parse(Buffer.from(jwt.split(".")[1], "base64").toString());
+};
+
+/**
+ * Gets a Access Token form a oidc/oauth2 provider
+ * @param {string} tokenEndpoint The token URI form the auth service provider
+ * @param {string} clientId The oidc/oauth application client id
+ * @param {string} clientSecret The oidc/oauth application client secret
+ * @param {string} scope The scope the for which the token should be issued for
+ * @param {string} authMethod The method on how to sent the credentials. Default client_secret_basic
+ * @returns {Promise} TokenSet promise if the token request was successful
+ */
+exports.getOidcTokenClientCredentials = async (tokenEndpoint, clientId, clientSecret, scope, authMethod = "client_secret_basic") => {
+ const oauthProvider = new oidc.Issuer({ token_endpoint: tokenEndpoint });
+ let client = new oauthProvider.Client({
+ client_id: clientId,
+ client_secret: clientSecret,
+ token_endpoint_auth_method: authMethod
+ });
+
+ // Increase default timeout and clock tolerance
+ client[oidc.custom.http_options] = () => ({ timeout: 10000 });
+ client[oidc.custom.clock_tolerance] = 5;
+
+ let grantParams = { grant_type: "client_credentials" };
+ if (scope) {
+ grantParams.scope = scope;
+ }
+ return await client.grant(grantParams);
+};
+
/**
* Send TCP request to specified hostname and port
* @param {string} hostname Hostname / address of machine
@@ -489,6 +528,7 @@ exports.radius = function (
host: hostname,
hostPort: port,
timeout: timeout,
+ retries: 1,
dictionaries: [ file ],
});
@@ -500,6 +540,12 @@ exports.radius = function (
[ attributes.CALLING_STATION_ID, callingStationId ],
[ attributes.CALLED_STATION_ID, calledStationId ],
],
+ }).catch((error) => {
+ if (error.response?.code) {
+ throw Error(error.response.code);
+ } else {
+ throw Error(error.message);
+ }
});
};
@@ -677,7 +723,6 @@ exports.checkCertificate = function (res) {
* @param {number} status The status code to check
* @param {string[]} acceptedCodes An array of accepted status codes
* @returns {boolean} True if status code within range, false otherwise
- * @throws {Error} Will throw an error if the provided status code is not a valid range string or code string
*/
exports.checkStatusCode = function (status, acceptedCodes) {
if (acceptedCodes == null || acceptedCodes.length === 0) {
@@ -685,6 +730,11 @@ exports.checkStatusCode = function (status, acceptedCodes) {
}
for (const codeRange of acceptedCodes) {
+ if (typeof codeRange !== "string") {
+ log.error("monitor", `Accepted status code not a string. ${codeRange} is of type ${typeof codeRange}`);
+ continue;
+ }
+
const codeRangeSplit = codeRange.split("-").map(string => parseInt(string));
if (codeRangeSplit.length === 1) {
if (status === codeRangeSplit[0]) {
@@ -695,7 +745,8 @@ exports.checkStatusCode = function (status, acceptedCodes) {
return true;
}
} else {
- throw new Error("Invalid status code range");
+ log.error("monitor", `${codeRange} is not a valid status code range`);
+ continue;
}
}
@@ -1007,3 +1058,13 @@ module.exports.grpcQuery = async (options) => {
};
module.exports.prompt = (query) => new Promise((resolve) => rl.question(query, resolve));
+
+// For unit test, export functions
+if (process.env.TEST_BACKEND) {
+ module.exports.__test = {
+ parseCertificateInfo,
+ };
+ module.exports.__getPrivateFunction = (functionName) => {
+ return module.exports.__test[functionName];
+ };
+}
diff --git a/src/assets/app.scss b/src/assets/app.scss
index 0eff9a06..0d3f0454 100644
--- a/src/assets/app.scss
+++ b/src/assets/app.scss
@@ -111,6 +111,10 @@ optgroup {
padding-right: 20px;
}
+.btn-sm {
+ border-radius: 25px;
+}
+
.btn-primary {
color: white;
@@ -158,6 +162,26 @@ optgroup {
background-color: #161B22;
}
+.btn-outline-normal {
+ padding: 4px 10px;
+ border: 1px solid #ced4da;
+ border-radius: 25px;
+ background-color: transparent;
+
+ .dark & {
+ color: $dark-font-color;
+ border: 1px solid $dark-font-color2;
+ }
+
+ &.active {
+ background-color: $highlight-white;
+
+ .dark & {
+ background-color: $dark-font-color2;
+ }
+ }
+}
+
@media (max-width: 550px) {
.table-shadow-box {
padding: 10px !important;
@@ -436,7 +460,6 @@ optgroup {
.monitor-list {
&.scrollbar {
overflow-y: auto;
- height: calc(100% - 107px);
}
@media (max-width: 770px) {
diff --git a/src/components/ActionSelect.vue b/src/components/ActionSelect.vue
new file mode 100644
index 00000000..ae09e656
--- /dev/null
+++ b/src/components/ActionSelect.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
diff --git a/src/components/CreateGroupDialog.vue b/src/components/CreateGroupDialog.vue
new file mode 100644
index 00000000..ed20610c
--- /dev/null
+++ b/src/components/CreateGroupDialog.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue
index 1f19180f..8323f7cf 100644
--- a/src/components/HeartbeatBar.vue
+++ b/src/components/HeartbeatBar.vue
@@ -5,15 +5,24 @@
v-for="(beat, index) in shortBeatList"
:key="index"
class="beat"
- :class="{ 'empty' : (beat === 0), 'down' : (beat.status === 0), 'pending' : (beat.status === 2), 'maintenance' : (beat.status === 3) }"
+ :class="{ 'empty': (beat === 0), 'down': (beat.status === 0), 'pending': (beat.status === 2), 'maintenance': (beat.status === 3) }"
:style="beatStyle"
:title="getBeatTitle(beat)"
/>
+
+
{{ timeSinceFirstBeat }} ago
+
+
{{ timeSinceLastBeat }}
+
@@ -271,4 +431,12 @@ export default {
padding-left: 67px;
margin-top: 5px;
}
+
+.selection-controls {
+ margin-top: 5px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
diff --git a/src/components/MonitorListFilterDropdown.vue b/src/components/MonitorListFilterDropdown.vue
index 01b9678f..fe8b3ea2 100644
--- a/src/components/MonitorListFilterDropdown.vue
+++ b/src/components/MonitorListFilterDropdown.vue
@@ -44,6 +44,7 @@ export default {
diff --git a/src/components/NotificationDialog.vue b/src/components/NotificationDialog.vue
index f977225f..f7fce0d3 100644
--- a/src/components/NotificationDialog.vue
+++ b/src/components/NotificationDialog.vue
@@ -126,6 +126,7 @@ export default {
"lunasea": "LunaSea",
"matrix": "Matrix",
"mattermost": "Mattermost",
+ "nostr": "Nostr",
"ntfy": "Ntfy",
"octopush": "Octopush",
"OneBot": "OneBot",
@@ -157,6 +158,7 @@ export default {
"AliyunSMS": "AliyunSMS (阿里云短信服务)",
"DingDing": "DingDing (钉钉自定义机器人)",
"Feishu": "Feishu (飞书)",
+ "FlashDuty": "FlashDuty (快猫星云)",
"FreeMobile": "FreeMobile (mobile.free.fr)",
"PushDeer": "PushDeer",
"promosms": "PromoSMS",
diff --git a/src/components/PublicGroupList.vue b/src/components/PublicGroupList.vue
index 4bd4a295..ba2230f0 100644
--- a/src/components/PublicGroupList.vue
+++ b/src/components/PublicGroupList.vue
@@ -61,12 +61,17 @@
/>
-
-
+
-
+
@@ -103,6 +108,10 @@ export default {
/** Should tags be shown? */
showTags: {
type: Boolean,
+ },
+ /** Should expiry be shown? */
+ showCertificateExpiry: {
+ type: Boolean,
}
},
data() {
@@ -154,6 +163,33 @@ export default {
}
return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://" && !this.editMode;
},
+
+ /**
+ * Returns formatted certificate expiry or Bad cert message
+ * @param {Object} monitor Monitor to show expiry for
+ * @returns {string}
+ */
+ formattedCertExpiryMessage(monitor) {
+ if (monitor?.element?.validCert && monitor?.element?.certExpiryDaysRemaining) {
+ return monitor.element.certExpiryDaysRemaining + " " + this.$tc("day", monitor.element.certExpiryDaysRemaining);
+ } else if (monitor?.element?.validCert === false) {
+ return this.$t("noOrBadCertificate");
+ } else {
+ return this.$t("Unknown") + " " + this.$tc("day", 2);
+ }
+ },
+
+ /**
+ * Returns certificate expiry based on days remaining
+ * @param {Object} monitor Monitor to show expiry for
+ * @returns {string}
+ */
+ certExpiryColor(monitor) {
+ if (monitor?.element?.validCert && monitor.element.certExpiryDaysRemaining > 7) {
+ return "#059669";
+ }
+ return "#DC2626";
+ },
}
};
@@ -161,6 +197,15 @@ export default {