diff --git a/.devcontainer/README.md b/.devcontainer/README.md deleted file mode 100644 index 81b8b0fa0..000000000 --- a/.devcontainer/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Codespaces - -You can modifiy Uptime Kuma in your browser without setting up a local development. - -![image](https://github.com/louislam/uptime-kuma/assets/1336778/31d9f06d-dd0b-4405-8e0d-a96586ee4595) - -1. Click `Code` -> `Create codespace on master` -2. Wait a few minutes until you see there are two exposed ports -3. Go to the `3000` url, see if it is working - -![image](https://github.com/louislam/uptime-kuma/assets/1336778/909b2eb4-4c5e-44e4-ac26-6d20ed856e7f) - -## Frontend - -Since the frontend is using [Vite.js](https://vitejs.dev/), all changes in this area will be hot-reloaded. -You don't need to restart the frontend, unless you try to add a new frontend dependency. - -## Backend - -The backend does not automatically hot-reload. -You will need to restart the backend after changing something using these steps: - -1. Click `Terminal` -2. Click `Codespaces: server-dev` in the right panel -3. Press `Ctrl + C` to stop the server -4. Press `Up` to run `npm run start-server-dev` - -![image](https://github.com/louislam/uptime-kuma/assets/1336778/e0c0a350-fe46-4588-9f37-e053c85834d1) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 6e3282dc8..000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "image": "mcr.microsoft.com/devcontainers/javascript-node:dev-18-bookworm", - "features": { - "ghcr.io/devcontainers/features/github-cli:1": {} - }, - "updateContentCommand": "npm ci", - "postCreateCommand": "", - "postAttachCommand": { - "frontend-dev": "npm run start-frontend-devcontainer", - "server-dev": "npm run start-server-dev", - "open-port": "gh codespace ports visibility 3001:public -c $CODESPACE_NAME" - }, - "customizations": { - "vscode": { - "extensions": [ - "streetsidesoftware.code-spell-checker", - "dbaeumer.vscode-eslint", - "GitHub.copilot-chat" - ] - } - }, - "forwardPorts": [3000, 3001] -} diff --git a/.dockerignore b/.dockerignore index 4ef25f5eb..5db08b7bf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,6 @@ README.md .vscode .eslint* .stylelint* -/.devcontainer /.github yarn.lock app.json diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index 6acd303fb..60eca6403 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -9,7 +9,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: stale-issue-message: |- We are clearing up our old `help`-issues and your issue has been open for 60 days with no activity. @@ -21,7 +21,7 @@ jobs: exempt-issue-labels: 'News,Medium,High,discussion,bug,doc,feature-request' exempt-issue-assignees: 'louislam' operations-per-run: 200 - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: stale-issue-message: |- This issue was marked as `cannot-reproduce` by a maintainer. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 916a4b934..69f98c0e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,7 +127,7 @@ Different guidelines exist for different types of pull requests (PRs): - `server/monitor-types/MONITORING_TYPE.js` is the core of each monitor. the `async check(...)`-function should: - throw an error for each fault that is detected with an actionable error message - - in the happy-path, you should set `heartbeat.msg` to a successfull message and set `heartbeat.status = UP` + - in the happy-path, you should set `heartbeat.msg` to a successful message and set `heartbeat.status = UP` - `server/uptime-kuma-server.js` is where the monitoring backend needs to be registered. *If you have an idea how we can skip this step, we would love to hear about it ^^* - `src/pages/EditMonitor.vue` is the shared frontend users interact with. @@ -236,12 +236,6 @@ The goal is to make the Uptime Kuma installation as easy as installing a mobile - 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/)) -### GitHub Codespaces - -If you don't want to setup an local environment, you can now develop on GitHub Codespaces, read more: - -https://github.com/louislam/uptime-kuma/tree/master/.devcontainer - ## Git Branches - `master`: 2.X.X development. If you want to add a new feature, your pull request should base on this. diff --git a/config/playwright.config.js b/config/playwright.config.js index 94239d2dd..5c574eecc 100644 --- a/config/playwright.config.js +++ b/config/playwright.config.js @@ -1,11 +1,11 @@ import { defineConfig, devices } from "@playwright/test"; const port = 30001; -const url = `http://localhost:${port}`; +export const url = `http://localhost:${port}`; export default defineConfig({ // Look for test files in the "tests" directory, relative to this configuration file. - testDir: "../test/e2e", + testDir: "../test/e2e/specs", outputDir: "../private/playwright-test-results", fullyParallel: false, locale: "en-US", @@ -40,9 +40,15 @@ export default defineConfig({ // Configure projects for major browsers. projects: [ { - name: "chromium", + name: "run-once setup", + testMatch: /setup-process\.once\.js/, use: { ...devices["Desktop Chrome"] }, }, + { + name: "specs", + use: { ...devices["Desktop Chrome"] }, + dependencies: [ "run-once setup" ], + }, /* { name: "firefox", @@ -52,7 +58,7 @@ export default defineConfig({ // Run your local dev server before starting the tests. webServer: { - command: `node extra/remove-playwright-test-data.js && node server/server.js --port=${port} --data-dir=./data/playwright-test`, + command: `node extra/remove-playwright-test-data.js && cross-env NODE_ENV=development node server/server.js --port=${port} --data-dir=./data/playwright-test`, url, reuseExistingServer: false, cwd: "../", diff --git a/db/knex_migrations/2024-04-26-0000-snmp-monitor.js b/db/knex_migrations/2024-04-26-0000-snmp-monitor.js new file mode 100644 index 000000000..24752f2dd --- /dev/null +++ b/db/knex_migrations/2024-04-26-0000-snmp-monitor.js @@ -0,0 +1,16 @@ +exports.up = function (knex) { + return knex.schema + .alterTable("monitor", function (table) { + table.string("snmp_oid").defaultTo(null); + table.enum("snmp_version", [ "1", "2c", "3" ]).defaultTo("2c"); + table.string("json_path_operator").defaultTo(null); + }); +}; + +exports.down = function (knex) { + return knex.schema.alterTable("monitor", function (table) { + table.dropColumn("snmp_oid"); + table.dropColumn("snmp_version"); + table.dropColumn("json_path_operator"); + }); +}; diff --git a/db/knex_migrations/2024-08-24-000-add-cache-bust.js b/db/knex_migrations/2024-08-24-000-add-cache-bust.js new file mode 100644 index 000000000..3644377c4 --- /dev/null +++ b/db/knex_migrations/2024-08-24-000-add-cache-bust.js @@ -0,0 +1,13 @@ +exports.up = function (knex) { + return knex.schema + .alterTable("monitor", function (table) { + table.boolean("cache_bust").notNullable().defaultTo(false); + }); +}; + +exports.down = function (knex) { + return knex.schema + .alterTable("monitor", function (table) { + table.dropColumn("cache_bust"); + }); +}; diff --git a/db/knex_migrations/2024-08-24-0000-conditions.js b/db/knex_migrations/2024-08-24-0000-conditions.js new file mode 100644 index 000000000..96352fdc4 --- /dev/null +++ b/db/knex_migrations/2024-08-24-0000-conditions.js @@ -0,0 +1,12 @@ +exports.up = function (knex) { + return knex.schema + .alterTable("monitor", function (table) { + table.text("conditions").notNullable().defaultTo("[]"); + }); +}; + +exports.down = function (knex) { + return knex.schema.alterTable("monitor", function (table) { + table.dropColumn("conditions"); + }); +}; diff --git a/package.json b/package.json index 36b767f81..cf6348cbd 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,7 @@ "build": "vite build --config ./config/vite.config.js", "test": "npm run test-backend && npm run test-e2e", "test-with-build": "npm run build && npm test", - "test-backend": "node test/backend-test-entry.js", - "test-backend:14": "cross-env TEST_BACKEND=1 NODE_OPTIONS=\"--experimental-abortcontroller --no-warnings\" node--test test/backend-test", - "test-backend:18": "cross-env TEST_BACKEND=1 node --test test/backend-test", + "test-backend": "cross-env TEST_BACKEND=1 node --test test/backend-test", "test-e2e": "playwright test --config ./config/playwright.config.js", "test-e2e-ui": "playwright test --config ./config/playwright.config.js --ui --ui-port=51063", "playwright-codegen": "playwright codegen localhost:3000 --save-storage=./private/e2e-auth.json", @@ -89,13 +87,14 @@ "command-exists": "~1.2.9", "compare-versions": "~3.6.0", "compression": "~1.7.4", - "croner": "~6.0.5", + "croner": "~8.1.0", "dayjs": "~1.11.5", "dev-null": "^0.1.1", "dotenv": "~16.0.3", "express": "~4.21.0", "express-basic-auth": "~1.2.1", "express-static-gzip": "~2.1.7", + "feed": "^4.2.2", "form-data": "~4.0.0", "gamedig": "^4.2.0", "html-escaper": "^3.0.3", @@ -113,12 +112,14 @@ "knex": "^2.4.2", "limiter": "~2.1.0", "liquidjs": "^10.7.0", + "marked": "^14.0.0", "mitt": "~3.0.1", "mongodb": "~4.17.1", "mqtt": "~4.3.7", "mssql": "~11.0.0", "mysql2": "~3.9.6", "nanoid": "~3.3.4", + "net-snmp": "^3.11.2", "node-cloudflared-tunnel": "~1.0.9", "node-radius-client": "~1.0.0", "nodemailer": "~6.9.13", @@ -177,7 +178,6 @@ "eslint-plugin-vue": "~8.7.1", "favico.js": "~0.3.10", "get-port-please": "^3.1.1", - "marked": "~4.2.5", "node-ssh": "~13.1.0", "postcss-html": "~1.5.0", "postcss-rtlcss": "~3.7.2", diff --git a/server/auth.js b/server/auth.js index 597cf3d75..36316241c 100644 --- a/server/auth.js +++ b/server/auth.js @@ -1,7 +1,6 @@ const basicAuth = require("express-basic-auth"); const passwordHash = require("./password-hash"); const { R } = require("redbean-node"); -const { setting } = require("./util-server"); const { log } = require("../src/util"); const { loginRateLimiter, apiRateLimiter } = require("./rate-limiter"); const { Settings } = require("./settings"); @@ -139,7 +138,7 @@ exports.basicAuth = async function (req, res, next) { challenge: true, }); - const disabledAuth = await setting("disableAuth"); + const disabledAuth = await Settings.get("disableAuth"); if (!disabledAuth) { middleware(req, res, next); diff --git a/server/check-version.js b/server/check-version.js index c6d5cfb92..154ebe373 100644 --- a/server/check-version.js +++ b/server/check-version.js @@ -1,7 +1,7 @@ -const { setSetting, setting } = require("./util-server"); const axios = require("axios"); const compareVersions = require("compare-versions"); const { log } = require("../src/util"); +const { Settings } = require("./settings"); exports.version = require("../package.json").version; exports.latestVersion = null; @@ -14,7 +14,7 @@ let interval; exports.startInterval = () => { let check = async () => { - if (await setting("checkUpdate") === false) { + if (await Settings.get("checkUpdate") === false) { return; } @@ -28,7 +28,7 @@ exports.startInterval = () => { res.data.slow = "1000.0.0"; } - let checkBeta = await setting("checkBeta"); + let checkBeta = await Settings.get("checkBeta"); if (checkBeta && res.data.beta) { if (compareVersions.compare(res.data.beta, res.data.slow, ">")) { @@ -57,7 +57,7 @@ exports.startInterval = () => { * @returns {Promise} */ exports.enableCheckUpdate = async (value) => { - await setSetting("checkUpdate", value); + await Settings.set("checkUpdate", value); clearInterval(interval); diff --git a/server/client.js b/server/client.js index 58ed8f956..6929f81aa 100644 --- a/server/client.js +++ b/server/client.js @@ -6,8 +6,8 @@ const { R } = require("redbean-node"); const { UptimeKumaServer } = require("./uptime-kuma-server"); const server = UptimeKumaServer.getInstance(); const io = server.io; -const { setting } = require("./util-server"); const checkVersion = require("./check-version"); +const { Settings } = require("./settings"); const Database = require("./database"); /** @@ -158,8 +158,8 @@ async function sendInfo(socket, hideVersion = false) { version, latestVersion, isContainer, + primaryBaseURL: await Settings.get("primaryBaseURL"), dbType, - primaryBaseURL: await setting("primaryBaseURL"), serverTimezone: await server.getTimezone(), serverTimezoneOffset: server.getTimezoneOffset(), }); @@ -213,6 +213,32 @@ async function sendRemoteBrowserList(socket) { return list; } +/** + * Send list of monitor types to client + * @param {Socket} socket Socket.io socket instance + * @returns {Promise} + */ +async function sendMonitorTypeList(socket) { + const result = Object.entries(UptimeKumaServer.monitorTypeList).map(([ key, type ]) => { + return [ key, { + supportsConditions: type.supportsConditions, + conditionVariables: type.conditionVariables.map(v => { + return { + id: v.id, + operators: v.operators.map(o => { + return { + id: o.id, + caption: o.caption, + }; + }), + }; + }), + }]; + }); + + io.to(socket.userID).emit("monitorTypeList", Object.fromEntries(result)); +} + module.exports = { sendNotificationList, sendImportantHeartbeatList, @@ -222,4 +248,5 @@ module.exports = { sendInfo, sendDockerHostList, sendRemoteBrowserList, + sendMonitorTypeList, }; diff --git a/server/database.js b/server/database.js index 3374aff9e..46e6ecaf3 100644 --- a/server/database.js +++ b/server/database.js @@ -1,11 +1,11 @@ const fs = require("fs"); const { R } = require("redbean-node"); -const { setSetting, setting } = require("./util-server"); const { log, sleep } = require("../src/util"); const knex = require("knex"); const path = require("path"); const { EmbeddedMariaDB } = require("./embedded-mariadb"); const mysql = require("mysql2/promise"); +const { Settings } = require("./settings"); /** * Database & App Data Folder @@ -420,7 +420,7 @@ class Database { * @deprecated */ static async patchSqlite() { - let version = parseInt(await setting("database_version")); + let version = parseInt(await Settings.get("database_version")); if (! version) { version = 0; @@ -445,7 +445,7 @@ class Database { log.info("db", `Patching ${sqlFile}`); await Database.importSQLFile(sqlFile); log.info("db", `Patched ${sqlFile}`); - await setSetting("database_version", i); + await Settings.set("database_version", i); } } catch (ex) { await Database.close(); @@ -471,7 +471,7 @@ class Database { */ static async patchSqlite2() { log.debug("db", "Database Patch 2.0 Process"); - let databasePatchedFiles = await setting("databasePatchedFiles"); + let databasePatchedFiles = await Settings.get("databasePatchedFiles"); if (! databasePatchedFiles) { databasePatchedFiles = {}; @@ -499,7 +499,7 @@ class Database { process.exit(1); } - await setSetting("databasePatchedFiles", databasePatchedFiles); + await Settings.set("databasePatchedFiles", databasePatchedFiles); } /** @@ -512,27 +512,27 @@ class Database { // Fix 1.13.0 empty slug bug await R.exec("UPDATE status_page SET slug = 'empty-slug-recover' WHERE TRIM(slug) = ''"); - let title = await setting("title"); + let title = await Settings.get("title"); if (title) { - console.log("Migrating Status Page"); + log.info("database", "Migrating Status Page"); let statusPageCheck = await R.findOne("status_page", " slug = 'default' "); if (statusPageCheck !== null) { - console.log("Migrating Status Page - Skip, default slug record is already existing"); + log.info("database", "Migrating Status Page - Skip, default slug record is already existing"); return; } let statusPage = R.dispense("status_page"); statusPage.slug = "default"; statusPage.title = title; - statusPage.description = await setting("description"); - statusPage.icon = await setting("icon"); - statusPage.theme = await setting("statusPageTheme"); - statusPage.published = !!await setting("statusPagePublished"); - statusPage.search_engine_index = !!await setting("searchEngineIndex"); - statusPage.show_tags = !!await setting("statusPageTags"); + statusPage.description = await Settings.get("description"); + statusPage.icon = await Settings.get("icon"); + statusPage.theme = await Settings.get("statusPageTheme"); + statusPage.published = !!await Settings.get("statusPagePublished"); + statusPage.search_engine_index = !!await Settings.get("searchEngineIndex"); + statusPage.show_tags = !!await Settings.get("statusPageTags"); statusPage.password = null; if (!statusPage.title) { @@ -560,13 +560,13 @@ class Database { await R.exec("DELETE FROM setting WHERE type = 'statusPage'"); // Migrate Entry Page if it is status page - let entryPage = await setting("entryPage"); + let entryPage = await Settings.get("entryPage"); if (entryPage === "statusPage") { - await setSetting("entryPage", "statusPage-default", "general"); + await Settings.set("entryPage", "statusPage-default", "general"); } - console.log("Migrating Status Page - Done"); + log.info("database", "Migrating Status Page - Done"); } } diff --git a/server/jobs/clear-old-data.js b/server/jobs/clear-old-data.js index 248a4d409..b3a1676dc 100644 --- a/server/jobs/clear-old-data.js +++ b/server/jobs/clear-old-data.js @@ -1,7 +1,7 @@ const { R } = require("redbean-node"); const { log } = require("../../src/util"); -const { setSetting, setting } = require("../util-server"); const Database = require("../database"); +const { Settings } = require("../settings"); const DEFAULT_KEEP_PERIOD = 180; @@ -11,11 +11,11 @@ const DEFAULT_KEEP_PERIOD = 180; */ const clearOldData = async () => { - let period = await setting("keepDataPeriodDays"); + let period = await Settings.get("keepDataPeriodDays"); // Set Default Period if (period == null) { - await setSetting("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general"); + await Settings.set("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general"); period = DEFAULT_KEEP_PERIOD; } @@ -25,7 +25,7 @@ const clearOldData = async () => { parsedPeriod = parseInt(period); } catch (_) { log.warn("clearOldData", "Failed to parse setting, resetting to default.."); - await setSetting("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general"); + await Settings.set("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general"); parsedPeriod = DEFAULT_KEEP_PERIOD; } diff --git a/server/model/maintenance.js b/server/model/maintenance.js index 516c03777..7111a18cb 100644 --- a/server/model/maintenance.js +++ b/server/model/maintenance.js @@ -239,19 +239,7 @@ class Maintenance extends BeanModel { this.beanMeta.status = "under-maintenance"; clearTimeout(this.beanMeta.durationTimeout); - // Check if duration is still in the window. If not, use the duration from the current time to the end of the window - let duration; - - if (customDuration > 0) { - duration = customDuration; - } else if (this.end_date) { - let d = dayjs(this.end_date).diff(dayjs(), "second"); - if (d < this.duration) { - duration = d * 1000; - } - } else { - duration = this.duration * 1000; - } + let duration = this.inferDuration(customDuration); UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id); @@ -263,9 +251,21 @@ class Maintenance extends BeanModel { }; // Create Cron - this.beanMeta.job = new Cron(this.cron, { - timezone: await this.getTimezone(), - }, startEvent); + if (this.strategy === "recurring-interval") { + // For recurring-interval, Croner needs to have interval and startAt + const startDate = dayjs(this.startDate); + const [ hour, minute ] = this.startTime.split(":"); + const startDateTime = startDate.hour(hour).minute(minute); + this.beanMeta.job = new Cron(this.cron, { + timezone: await this.getTimezone(), + interval: this.interval_day * 24 * 60 * 60, + startAt: startDateTime.toISOString(), + }, startEvent); + } else { + this.beanMeta.job = new Cron(this.cron, { + timezone: await this.getTimezone(), + }, startEvent); + } // Continue if the maintenance is still in the window let runningTimeslot = this.getRunningTimeslot(); @@ -311,6 +311,24 @@ class Maintenance extends BeanModel { } } + /** + * Calculate the maintenance duration + * @param {number} customDuration - The custom duration in milliseconds. + * @returns {number} The inferred duration in milliseconds. + */ + inferDuration(customDuration) { + // Check if duration is still in the window. If not, use the duration from the current time to the end of the window + if (customDuration > 0) { + return customDuration; + } else if (this.end_date) { + let d = dayjs(this.end_date).diff(dayjs(), "second"); + if (d < this.duration) { + return d * 1000; + } + } + return this.duration * 1000; + } + /** * Stop the maintenance * @returns {void} @@ -395,10 +413,8 @@ class Maintenance extends BeanModel { } else if (!this.strategy.startsWith("recurring-")) { this.cron = ""; } else if (this.strategy === "recurring-interval") { - let array = this.start_time.split(":"); - let hour = parseInt(array[0]); - let minute = parseInt(array[1]); - this.cron = minute + " " + hour + " */" + this.interval_day + " * *"; + // For intervals, the pattern is calculated in the run function as the interval-option is set + this.cron = "* * * * *"; this.duration = this.calcDuration(); log.debug("maintenance", "Cron: " + this.cron); log.debug("maintenance", "Duration: " + this.duration); diff --git a/server/model/monitor.js b/server/model/monitor.js index 1b11c614e..4beeb0036 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -2,9 +2,9 @@ const dayjs = require("dayjs"); const axios = require("axios"); const { Prometheus } = require("../prometheus"); const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, - SQL_DATETIME_FORMAT + SQL_DATETIME_FORMAT, evaluateJsonQuery } = require("../../src/util"); -const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery, +const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, mssqlQuery, postgresQuery, mysqlQuery, httpNtlm, radius, grpcQuery, redisPingAsync, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal } = require("../util-server"); const { R } = require("redbean-node"); @@ -17,7 +17,6 @@ const apicache = require("../modules/apicache"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const { DockerHost } = require("../docker"); const Gamedig = require("gamedig"); -const jsonata = require("jsonata"); const jwt = require("jsonwebtoken"); const crypto = require("crypto"); const { UptimeCalculator } = require("../uptime-calculator"); @@ -25,6 +24,7 @@ const { CookieJar } = require("tough-cookie"); const { HttpsCookieAgent } = require("http-cookie-agent/http"); const https = require("https"); const http = require("http"); +const { Settings } = require("../settings"); const rootCertificates = rootCertificatesFingerprints(); @@ -160,7 +160,12 @@ class Monitor extends BeanModel { kafkaProducerAllowAutoTopicCreation: this.getKafkaProducerAllowAutoTopicCreation(), kafkaProducerMessage: this.kafkaProducerMessage, screenshot, + cacheBust: this.getCacheBust(), remote_browser: this.remote_browser, + snmpOid: this.snmpOid, + jsonPathOperator: this.jsonPathOperator, + snmpVersion: this.snmpVersion, + conditions: JSON.parse(this.conditions), }; if (includeSensitiveData) { @@ -293,6 +298,14 @@ class Monitor extends BeanModel { return Boolean(this.grpcEnableTls); } + /** + * Parse to boolean + * @returns {boolean} if cachebusting is enabled + */ + getCacheBust() { + return Boolean(this.cacheBust); + } + /** * Get accepted status codes * @returns {object} Accepted status codes @@ -334,7 +347,7 @@ class Monitor extends BeanModel { let previousBeat = null; let retries = 0; - this.prometheus = new Prometheus(this); + this.prometheus = await Prometheus.createAndInitMetrics(this); const beat = async () => { @@ -498,6 +511,14 @@ class Monitor extends BeanModel { options.data = bodyValue; } + if (this.cacheBust) { + const randomFloatString = Math.random().toString(36); + const cacheBust = randomFloatString.substring(2); + options.params = { + uptime_kuma_cachebuster: cacheBust, + }; + } + if (this.proxy_id) { const proxy = await R.load("proxy", this.proxy_id); @@ -598,25 +619,15 @@ class Monitor extends BeanModel { } else if (this.type === "json-query") { let data = res.data; - // convert data to object - if (typeof data === "string" && res.headers["content-type"] !== "application/json") { - try { - data = JSON.parse(data); - } catch (_) { - // Failed to parse as JSON, just process it as a string - } - } + const { status, response } = await evaluateJsonQuery(data, this.jsonPath, this.jsonPathOperator, this.expectedValue); - let expression = jsonata(this.jsonPath); - - let result = await expression.evaluate(data); - - if (result.toString() === this.expectedValue) { - bean.msg += ", expected value is found"; + if (status) { bean.status = UP; + bean.msg = `JSON query passes (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`; } else { - throw new Error(bean.msg + ", but value is not equal to expected value, value was: [" + result + "]"); + throw new Error(`JSON query does not pass (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`); } + } } else if (this.type === "port") { @@ -662,7 +673,7 @@ class Monitor extends BeanModel { } else if (this.type === "steam") { const steamApiUrl = "https://api.steampowered.com/IGameServersService/GetServerList/v1/"; - const steamAPIKey = await setting("steamAPIKey"); + const steamAPIKey = await Settings.get("steamAPIKey"); const filter = `addr\\${this.hostname}:${this.port}`; if (!steamAPIKey) { @@ -988,7 +999,7 @@ class Monitor extends BeanModel { await R.store(bean); log.debug("monitor", `[${this.name}] prometheus.update`); - this.prometheus?.update(bean, tlsInfo); + await this.prometheus?.update(bean, tlsInfo); previousBeat = bean; @@ -1369,11 +1380,12 @@ class Monitor extends BeanModel { return; } - let notifyDays = await setting("tlsExpiryNotifyDays"); + let notifyDays = await Settings.get("tlsExpiryNotifyDays"); if (notifyDays == null || !Array.isArray(notifyDays)) { // Reset Default - await setSetting("tlsExpiryNotifyDays", [ 7, 14, 21 ], "general"); + await Settings.set("tlsExpiryNotifyDays", [ 7, 14, 21 ], "general"); notifyDays = [ 7, 14, 21 ]; + await Settings.set("tlsExpiryNotifyDays", notifyDays, "general"); } if (Array.isArray(notifyDays)) { diff --git a/server/model/status_page.js b/server/model/status_page.js index 528d1dd49..38f548ebb 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -4,6 +4,11 @@ const cheerio = require("cheerio"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const jsesc = require("jsesc"); const googleAnalytics = require("../google-analytics"); +const { marked } = require("marked"); +const { Feed } = require("feed"); +const config = require("../config"); + +const { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE, DOWN } = require("../../src/util"); class StatusPage extends BeanModel { @@ -13,6 +18,24 @@ class StatusPage extends BeanModel { */ static domainMappingList = { }; + /** + * Handle responses to RSS pages + * @param {Response} response Response object + * @param {string} slug Status page slug + * @returns {Promise} + */ + static async handleStatusPageRSSResponse(response, slug) { + let statusPage = await R.findOne("status_page", " slug = ? ", [ + slug + ]); + + if (statusPage) { + response.send(await StatusPage.renderRSS(statusPage, slug)); + } else { + response.status(404).send(UptimeKumaServer.getInstance().indexHTML); + } + } + /** * Handle responses to status page * @param {Response} response Response object @@ -38,6 +61,38 @@ class StatusPage extends BeanModel { } } + /** + * SSR for RSS feed + * @param {statusPage} statusPage object + * @param {slug} slug from router + * @returns {Promise} the rendered html + */ + static async renderRSS(statusPage, slug) { + const { heartbeats, statusDescription } = await StatusPage.getRSSPageData(statusPage); + + let proto = config.isSSL ? "https" : "http"; + let host = `${proto}://${config.hostname || "localhost"}:${config.port}/status/${slug}`; + + const feed = new Feed({ + title: "uptime kuma rss feed", + description: `current status: ${statusDescription}`, + link: host, + language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes + updated: new Date(), // optional, default = today + }); + + heartbeats.forEach(heartbeat => { + feed.addItem({ + title: `${heartbeat.name} is down`, + description: `${heartbeat.name} has been down since ${heartbeat.time}`, + id: heartbeat.monitorID, + date: new Date(heartbeat.time), + }); + }); + + return feed.rss2(); + } + /** * SSR for status pages * @param {string} indexHTML HTML page to render @@ -46,7 +101,11 @@ class StatusPage extends BeanModel { */ static async renderHTML(indexHTML, statusPage) { const $ = cheerio.load(indexHTML); - const description155 = statusPage.description?.substring(0, 155) ?? ""; + + const description155 = marked(statusPage.description ?? "") + .replace(/<[^>]+>/gm, "") + .trim() + .substring(0, 155); $("title").text(statusPage.title); $("meta[name=description]").attr("content", description155); @@ -93,6 +152,109 @@ class StatusPage extends BeanModel { return $.root().html(); } + /** + * @param {heartbeats} heartbeats from getRSSPageData + * @returns {number} status_page constant from util.ts + */ + static overallStatus(heartbeats) { + if (heartbeats.length === 0) { + return -1; + } + + let status = STATUS_PAGE_ALL_UP; + let hasUp = false; + + for (let beat of heartbeats) { + if (beat.status === MAINTENANCE) { + return STATUS_PAGE_MAINTENANCE; + } else if (beat.status === UP) { + hasUp = true; + } else { + status = STATUS_PAGE_PARTIAL_DOWN; + } + } + + if (! hasUp) { + status = STATUS_PAGE_ALL_DOWN; + } + + return status; + } + + /** + * @param {number} status from overallStatus + * @returns {string} description + */ + static getStatusDescription(status) { + if (status === -1) { + return "No Services"; + } + + if (status === STATUS_PAGE_ALL_UP) { + return "All Systems Operational"; + } + + if (status === STATUS_PAGE_PARTIAL_DOWN) { + return "Partially Degraded Service"; + } + + if (status === STATUS_PAGE_ALL_DOWN) { + return "Degraded Service"; + } + + // TODO: show the real maintenance information: title, description, time + if (status === MAINTENANCE) { + return "Under maintenance"; + } + + return "?"; + } + + /** + * Get all data required for RSS + * @param {StatusPage} statusPage Status page to get data for + * @returns {object} Status page data + */ + static async getRSSPageData(statusPage) { + // get all heartbeats that correspond to this statusPage + const config = await statusPage.toPublicJSON(); + + // Public Group List + const showTags = !!statusPage.show_tags; + + const list = await R.find("group", " public = 1 AND status_page_id = ? ORDER BY weight ", [ + statusPage.id + ]); + + let heartbeats = []; + + for (let groupBean of list) { + let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry); + for (const monitor of monitorGroup.monitorList) { + const heartbeat = await R.findOne("heartbeat", "monitor_id = ? ORDER BY time DESC", [ monitor.id ]); + if (heartbeat) { + heartbeats.push({ + ...monitor, + status: heartbeat.status, + time: heartbeat.time + }); + } + } + } + + // calculate RSS feed description + let status = StatusPage.overallStatus(heartbeats); + let statusDescription = StatusPage.getStatusDescription(status); + + // keep only DOWN heartbeats in the RSS feed + heartbeats = heartbeats.filter(heartbeat => heartbeat.status === DOWN); + + return { + heartbeats, + statusDescription + }; + } + /** * Get all status page data in one call * @param {StatusPage} statusPage Status page to get data for diff --git a/server/modules/apicache/apicache.js b/server/modules/apicache/apicache.js index 41930b24d..804fa93ed 100644 --- a/server/modules/apicache/apicache.js +++ b/server/modules/apicache/apicache.js @@ -1,5 +1,6 @@ let url = require("url"); let MemoryCache = require("./memory-cache"); +const { log } = require("../../../src/util"); let t = { ms: 1, @@ -90,24 +91,6 @@ function ApiCache() { instances.push(this); this.id = instances.length; - /** - * Logs a message to the console if the `DEBUG` environment variable is set. - * @param {string} a The first argument to log. - * @param {string} b The second argument to log. - * @param {string} c The third argument to log. - * @param {string} d The fourth argument to log, and so on... (optional) - * - * Generated by Trelent - */ - function debug(a, b, c, d) { - let arr = ["\x1b[36m[apicache]\x1b[0m", a, b, c, d].filter(function (arg) { - return arg !== undefined; - }); - let debugEnv = process.env.DEBUG && process.env.DEBUG.split(",").indexOf("apicache") !== -1; - - return (globalOptions.debug || debugEnv) && console.log.apply(null, arr); - } - /** * Returns true if the given request and response should be logged. * @param {Object} request The HTTP request object. @@ -146,7 +129,7 @@ function ApiCache() { let groupName = req.apicacheGroup; if (groupName) { - debug("group detected \"" + groupName + "\""); + log.debug("apicache", `group detected "${groupName}"`); let group = (index.groups[groupName] = index.groups[groupName] || []); group.unshift(key); } @@ -212,7 +195,7 @@ function ApiCache() { redis.hset(key, "duration", duration); redis.expire(key, duration / 1000, expireCallback || function () {}); } catch (err) { - debug("[apicache] error in redis.hset()"); + log.debug("apicache", `error in redis.hset(): ${err}`); } } else { memCache.add(key, value, duration, expireCallback); @@ -320,10 +303,10 @@ function ApiCache() { // display log entry let elapsed = new Date() - req.apicacheTimer; - debug("adding cache entry for \"" + key + "\" @ " + strDuration, logDuration(elapsed)); - debug("_apicache.headers: ", res._apicache.headers); - debug("res.getHeaders(): ", getSafeHeaders(res)); - debug("cacheObject: ", cacheObject); + log.debug("apicache", `adding cache entry for "${key}" @ ${strDuration} ${logDuration(elapsed)}`); + log.debug("apicache", `_apicache.headers: ${JSON.stringify(res._apicache.headers)}`); + log.debug("apicache", `res.getHeaders(): ${JSON.stringify(getSafeHeaders(res))}`); + log.debug("apicache", `cacheObject: ${JSON.stringify(cacheObject)}`); } } @@ -402,10 +385,10 @@ function ApiCache() { let redis = globalOptions.redisClient; if (group) { - debug("clearing group \"" + target + "\""); + log.debug("apicache", `clearing group "${target}"`); group.forEach(function (key) { - debug("clearing cached entry for \"" + key + "\""); + log.debug("apicache", `clearing cached entry for "${key}"`); clearTimeout(timers[key]); delete timers[key]; if (!globalOptions.redisClient) { @@ -414,7 +397,7 @@ function ApiCache() { try { redis.del(key); } catch (err) { - console.log("[apicache] error in redis.del(\"" + key + "\")"); + log.info("apicache", "error in redis.del(\"" + key + "\")"); } } index.all = index.all.filter(doesntMatch(key)); @@ -422,7 +405,7 @@ function ApiCache() { delete index.groups[target]; } else if (target) { - debug("clearing " + (isAutomatic ? "expired" : "cached") + " entry for \"" + target + "\""); + log.debug("apicache", `clearing ${isAutomatic ? "expired" : "cached"} entry for "${target}"`); clearTimeout(timers[target]); delete timers[target]; // clear actual cached entry @@ -432,7 +415,7 @@ function ApiCache() { try { redis.del(target); } catch (err) { - console.log("[apicache] error in redis.del(\"" + target + "\")"); + log.error("apicache", "error in redis.del(\"" + target + "\")"); } } @@ -449,7 +432,7 @@ function ApiCache() { } }); } else { - debug("clearing entire index"); + log.debug("apicache", "clearing entire index"); if (!redis) { memCache.clear(); @@ -461,7 +444,7 @@ function ApiCache() { try { redis.del(key); } catch (err) { - console.log("[apicache] error in redis.del(\"" + key + "\")"); + log.error("apicache", `error in redis.del("${key}"): ${err}`); } }); } @@ -528,7 +511,7 @@ function ApiCache() { /** * Get index of a group - * @param {string} group + * @param {string} group * @returns {number} */ this.getIndex = function (group) { @@ -543,9 +526,9 @@ function ApiCache() { * Express middleware * @param {(string|number)} strDuration Duration to cache responses * for. - * @param {function(Object, Object):boolean} middlewareToggle + * @param {function(Object, Object):boolean} middlewareToggle * @param {Object} localOptions Options for APICache - * @returns + * @returns */ this.middleware = function cache(strDuration, middlewareToggle, localOptions) { let duration = instance.getDuration(strDuration); @@ -752,7 +735,7 @@ function ApiCache() { */ let cache = function (req, res, next) { function bypass() { - debug("bypass detected, skipping cache."); + log.debug("apicache", "bypass detected, skipping cache."); return next(); } @@ -805,7 +788,7 @@ function ApiCache() { // send if cache hit from memory-cache if (cached) { let elapsed = new Date() - req.apicacheTimer; - debug("sending cached (memory-cache) version of", key, logDuration(elapsed)); + log.debug("apicache", `sending cached (memory-cache) version of ${key} ${logDuration(elapsed)}`); perf.hit(key); return sendCachedResponse(req, res, cached, middlewareToggle, next, duration); @@ -817,7 +800,7 @@ function ApiCache() { redis.hgetall(key, function (err, obj) { if (!err && obj && obj.response) { let elapsed = new Date() - req.apicacheTimer; - debug("sending cached (redis) version of", key, logDuration(elapsed)); + log.debug("apicache", "sending cached (redis) version of "+ key+" "+ logDuration(elapsed)); perf.hit(key); return sendCachedResponse( @@ -859,7 +842,7 @@ function ApiCache() { /** * Process options - * @param {Object} options + * @param {Object} options * @returns {Object} */ this.options = function (options) { @@ -873,7 +856,7 @@ function ApiCache() { } if (globalOptions.trackPerformance) { - debug("WARNING: using trackPerformance flag can cause high memory usage!"); + log.debug("apicache", "WARNING: using trackPerformance flag can cause high memory usage!"); } return this; diff --git a/server/monitor-conditions/evaluator.js b/server/monitor-conditions/evaluator.js new file mode 100644 index 000000000..3860a3325 --- /dev/null +++ b/server/monitor-conditions/evaluator.js @@ -0,0 +1,71 @@ +const { ConditionExpressionGroup, ConditionExpression, LOGICAL } = require("./expression"); +const { operatorMap } = require("./operators"); + +/** + * @param {ConditionExpression} expression Expression to evaluate + * @param {object} context Context to evaluate against; These are values for variables in the expression + * @returns {boolean} Whether the expression evaluates true or false + * @throws {Error} + */ +function evaluateExpression(expression, context) { + /** + * @type {import("./operators").ConditionOperator|null} + */ + const operator = operatorMap.get(expression.operator) || null; + if (operator === null) { + throw new Error("Unexpected expression operator ID '" + expression.operator + "'. Expected one of [" + operatorMap.keys().join(",") + "]"); + } + + if (!Object.prototype.hasOwnProperty.call(context, expression.variable)) { + throw new Error("Variable missing in context: " + expression.variable); + } + + return operator.test(context[expression.variable], expression.value); +} + +/** + * @param {ConditionExpressionGroup} group Group of expressions to evaluate + * @param {object} context Context to evaluate against; These are values for variables in the expression + * @returns {boolean} Whether the group evaluates true or false + * @throws {Error} + */ +function evaluateExpressionGroup(group, context) { + if (!group.children.length) { + throw new Error("ConditionExpressionGroup must contain at least one child."); + } + + let result = null; + + for (const child of group.children) { + let childResult; + + if (child instanceof ConditionExpression) { + childResult = evaluateExpression(child, context); + } else if (child instanceof ConditionExpressionGroup) { + childResult = evaluateExpressionGroup(child, context); + } else { + throw new Error("Invalid child type in ConditionExpressionGroup. Expected ConditionExpression or ConditionExpressionGroup"); + } + + if (result === null) { + result = childResult; // Initialize result with the first child's result + } else if (child.andOr === LOGICAL.OR) { + result = result || childResult; + } else if (child.andOr === LOGICAL.AND) { + result = result && childResult; + } else { + throw new Error("Invalid logical operator in child of ConditionExpressionGroup. Expected 'and' or 'or'. Got '" + group.andOr + "'"); + } + } + + if (result === null) { + throw new Error("ConditionExpressionGroup did not result in a boolean."); + } + + return result; +} + +module.exports = { + evaluateExpression, + evaluateExpressionGroup, +}; diff --git a/server/monitor-conditions/expression.js b/server/monitor-conditions/expression.js new file mode 100644 index 000000000..1e7036959 --- /dev/null +++ b/server/monitor-conditions/expression.js @@ -0,0 +1,111 @@ +/** + * @readonly + * @enum {string} + */ +const LOGICAL = { + AND: "and", + OR: "or", +}; + +/** + * Recursively processes an array of raw condition objects and populates the given parent group with + * corresponding ConditionExpression or ConditionExpressionGroup instances. + * @param {Array} conditions Array of raw condition objects, where each object represents either a group or an expression. + * @param {ConditionExpressionGroup} parentGroup The parent group to which the instantiated ConditionExpression or ConditionExpressionGroup objects will be added. + * @returns {void} + */ +function processMonitorConditions(conditions, parentGroup) { + conditions.forEach(condition => { + const andOr = condition.andOr === LOGICAL.OR ? LOGICAL.OR : LOGICAL.AND; + + if (condition.type === "group") { + const group = new ConditionExpressionGroup([], andOr); + + // Recursively process the group's children + processMonitorConditions(condition.children, group); + + parentGroup.children.push(group); + } else if (condition.type === "expression") { + const expression = new ConditionExpression(condition.variable, condition.operator, condition.value, andOr); + parentGroup.children.push(expression); + } + }); +} + +class ConditionExpressionGroup { + /** + * @type {ConditionExpressionGroup[]|ConditionExpression[]} Groups and/or expressions to test + */ + children = []; + + /** + * @type {LOGICAL} Connects group result with previous group/expression results + */ + andOr; + + /** + * @param {ConditionExpressionGroup[]|ConditionExpression[]} children Groups and/or expressions to test + * @param {LOGICAL} andOr Connects group result with previous group/expression results + */ + constructor(children = [], andOr = LOGICAL.AND) { + this.children = children; + this.andOr = andOr; + } + + /** + * @param {Monitor} monitor Monitor instance + * @returns {ConditionExpressionGroup|null} A ConditionExpressionGroup with the Monitor's conditions + */ + static fromMonitor(monitor) { + const conditions = JSON.parse(monitor.conditions); + if (conditions.length === 0) { + return null; + } + + const root = new ConditionExpressionGroup(); + processMonitorConditions(conditions, root); + + return root; + } +} + +class ConditionExpression { + /** + * @type {string} ID of variable + */ + variable; + + /** + * @type {string} ID of operator + */ + operator; + + /** + * @type {string} Value to test with the operator + */ + value; + + /** + * @type {LOGICAL} Connects expression result with previous group/expression results + */ + andOr; + + /** + * @param {string} variable ID of variable to test against + * @param {string} operator ID of operator to test the variable with + * @param {string} value Value to test with the operator + * @param {LOGICAL} andOr Connects expression result with previous group/expression results + */ + constructor(variable, operator, value, andOr = LOGICAL.AND) { + this.variable = variable; + this.operator = operator; + this.value = value; + this.andOr = andOr; + } +} + +module.exports = { + LOGICAL, + ConditionExpressionGroup, + ConditionExpression, +}; diff --git a/server/monitor-conditions/operators.js b/server/monitor-conditions/operators.js new file mode 100644 index 000000000..d900dff9d --- /dev/null +++ b/server/monitor-conditions/operators.js @@ -0,0 +1,318 @@ +class ConditionOperator { + id = undefined; + caption = undefined; + + /** + * @type {mixed} variable + * @type {mixed} value + */ + test(variable, value) { + throw new Error("You need to override test()"); + } +} + +const OP_STR_EQUALS = "equals"; + +const OP_STR_NOT_EQUALS = "not_equals"; + +const OP_CONTAINS = "contains"; + +const OP_NOT_CONTAINS = "not_contains"; + +const OP_STARTS_WITH = "starts_with"; + +const OP_NOT_STARTS_WITH = "not_starts_with"; + +const OP_ENDS_WITH = "ends_with"; + +const OP_NOT_ENDS_WITH = "not_ends_with"; + +const OP_NUM_EQUALS = "num_equals"; + +const OP_NUM_NOT_EQUALS = "num_not_equals"; + +const OP_LT = "lt"; + +const OP_GT = "gt"; + +const OP_LTE = "lte"; + +const OP_GTE = "gte"; + +/** + * Asserts a variable is equal to a value. + */ +class StringEqualsOperator extends ConditionOperator { + id = OP_STR_EQUALS; + caption = "equals"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable === value; + } +} + +/** + * Asserts a variable is not equal to a value. + */ +class StringNotEqualsOperator extends ConditionOperator { + id = OP_STR_NOT_EQUALS; + caption = "not equals"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable !== value; + } +} + +/** + * Asserts a variable contains a value. + * Handles both Array and String variable types. + */ +class ContainsOperator extends ConditionOperator { + id = OP_CONTAINS; + caption = "contains"; + + /** + * @inheritdoc + */ + test(variable, value) { + if (Array.isArray(variable)) { + return variable.includes(value); + } + + return variable.indexOf(value) !== -1; + } +} + +/** + * Asserts a variable does not contain a value. + * Handles both Array and String variable types. + */ +class NotContainsOperator extends ConditionOperator { + id = OP_NOT_CONTAINS; + caption = "not contains"; + + /** + * @inheritdoc + */ + test(variable, value) { + if (Array.isArray(variable)) { + return !variable.includes(value); + } + + return variable.indexOf(value) === -1; + } +} + +/** + * Asserts a variable starts with a value. + */ +class StartsWithOperator extends ConditionOperator { + id = OP_STARTS_WITH; + caption = "starts with"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable.startsWith(value); + } +} + +/** + * Asserts a variable does not start with a value. + */ +class NotStartsWithOperator extends ConditionOperator { + id = OP_NOT_STARTS_WITH; + caption = "not starts with"; + + /** + * @inheritdoc + */ + test(variable, value) { + return !variable.startsWith(value); + } +} + +/** + * Asserts a variable ends with a value. + */ +class EndsWithOperator extends ConditionOperator { + id = OP_ENDS_WITH; + caption = "ends with"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable.endsWith(value); + } +} + +/** + * Asserts a variable does not end with a value. + */ +class NotEndsWithOperator extends ConditionOperator { + id = OP_NOT_ENDS_WITH; + caption = "not ends with"; + + /** + * @inheritdoc + */ + test(variable, value) { + return !variable.endsWith(value); + } +} + +/** + * Asserts a numeric variable is equal to a value. + */ +class NumberEqualsOperator extends ConditionOperator { + id = OP_NUM_EQUALS; + caption = "equals"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable === Number(value); + } +} + +/** + * Asserts a numeric variable is not equal to a value. + */ +class NumberNotEqualsOperator extends ConditionOperator { + id = OP_NUM_NOT_EQUALS; + caption = "not equals"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable !== Number(value); + } +} + +/** + * Asserts a variable is less than a value. + */ +class LessThanOperator extends ConditionOperator { + id = OP_LT; + caption = "less than"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable < Number(value); + } +} + +/** + * Asserts a variable is greater than a value. + */ +class GreaterThanOperator extends ConditionOperator { + id = OP_GT; + caption = "greater than"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable > Number(value); + } +} + +/** + * Asserts a variable is less than or equal to a value. + */ +class LessThanOrEqualToOperator extends ConditionOperator { + id = OP_LTE; + caption = "less than or equal to"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable <= Number(value); + } +} + +/** + * Asserts a variable is greater than or equal to a value. + */ +class GreaterThanOrEqualToOperator extends ConditionOperator { + id = OP_GTE; + caption = "greater than or equal to"; + + /** + * @inheritdoc + */ + test(variable, value) { + return variable >= Number(value); + } +} + +const operatorMap = new Map([ + [ OP_STR_EQUALS, new StringEqualsOperator ], + [ OP_STR_NOT_EQUALS, new StringNotEqualsOperator ], + [ OP_CONTAINS, new ContainsOperator ], + [ OP_NOT_CONTAINS, new NotContainsOperator ], + [ OP_STARTS_WITH, new StartsWithOperator ], + [ OP_NOT_STARTS_WITH, new NotStartsWithOperator ], + [ OP_ENDS_WITH, new EndsWithOperator ], + [ OP_NOT_ENDS_WITH, new NotEndsWithOperator ], + [ OP_NUM_EQUALS, new NumberEqualsOperator ], + [ OP_NUM_NOT_EQUALS, new NumberNotEqualsOperator ], + [ OP_LT, new LessThanOperator ], + [ OP_GT, new GreaterThanOperator ], + [ OP_LTE, new LessThanOrEqualToOperator ], + [ OP_GTE, new GreaterThanOrEqualToOperator ], +]); + +const defaultStringOperators = [ + operatorMap.get(OP_STR_EQUALS), + operatorMap.get(OP_STR_NOT_EQUALS), + operatorMap.get(OP_CONTAINS), + operatorMap.get(OP_NOT_CONTAINS), + operatorMap.get(OP_STARTS_WITH), + operatorMap.get(OP_NOT_STARTS_WITH), + operatorMap.get(OP_ENDS_WITH), + operatorMap.get(OP_NOT_ENDS_WITH) +]; + +const defaultNumberOperators = [ + operatorMap.get(OP_NUM_EQUALS), + operatorMap.get(OP_NUM_NOT_EQUALS), + operatorMap.get(OP_LT), + operatorMap.get(OP_GT), + operatorMap.get(OP_LTE), + operatorMap.get(OP_GTE) +]; + +module.exports = { + OP_STR_EQUALS, + OP_STR_NOT_EQUALS, + OP_CONTAINS, + OP_NOT_CONTAINS, + OP_STARTS_WITH, + OP_NOT_STARTS_WITH, + OP_ENDS_WITH, + OP_NOT_ENDS_WITH, + OP_NUM_EQUALS, + OP_NUM_NOT_EQUALS, + OP_LT, + OP_GT, + OP_LTE, + OP_GTE, + operatorMap, + defaultStringOperators, + defaultNumberOperators, + ConditionOperator, +}; diff --git a/server/monitor-conditions/variables.js b/server/monitor-conditions/variables.js new file mode 100644 index 000000000..af98d2f29 --- /dev/null +++ b/server/monitor-conditions/variables.js @@ -0,0 +1,31 @@ +/** + * Represents a variable used in a condition and the set of operators that can be applied to this variable. + * + * A `ConditionVariable` holds the ID of the variable and a list of operators that define how this variable can be evaluated + * in conditions. For example, if the variable is a request body or a specific field in a request, the operators can include + * operations such as equality checks, comparisons, or other custom evaluations. + */ +class ConditionVariable { + /** + * @type {string} + */ + id; + + /** + * @type {import("./operators").ConditionOperator[]} + */ + operators = {}; + + /** + * @param {string} id ID of variable + * @param {import("./operators").ConditionOperator[]} operators Operators the condition supports + */ + constructor(id, operators = []) { + this.id = id; + this.operators = operators; + } +} + +module.exports = { + ConditionVariable, +}; diff --git a/server/monitor-types/dns.js b/server/monitor-types/dns.js index d038b6805..8b87932fe 100644 --- a/server/monitor-types/dns.js +++ b/server/monitor-types/dns.js @@ -1,12 +1,22 @@ const { MonitorType } = require("./monitor-type"); -const { UP } = require("../../src/util"); +const { UP, DOWN } = require("../../src/util"); const dayjs = require("dayjs"); const { dnsResolve } = require("../util-server"); const { R } = require("redbean-node"); +const { ConditionVariable } = require("../monitor-conditions/variables"); +const { defaultStringOperators } = require("../monitor-conditions/operators"); +const { ConditionExpressionGroup } = require("../monitor-conditions/expression"); +const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator"); class DnsMonitorType extends MonitorType { name = "dns"; + supportsConditions = true; + + conditionVariables = [ + new ConditionVariable("record", defaultStringOperators ), + ]; + /** * @inheritdoc */ @@ -17,28 +27,48 @@ class DnsMonitorType extends MonitorType { let dnsRes = await dnsResolve(monitor.hostname, monitor.dns_resolve_server, monitor.port, monitor.dns_resolve_type); heartbeat.ping = dayjs().valueOf() - startTime; - if (monitor.dns_resolve_type === "A" || monitor.dns_resolve_type === "AAAA" || monitor.dns_resolve_type === "TXT" || monitor.dns_resolve_type === "PTR") { - dnsMessage += "Records: "; - dnsMessage += dnsRes.join(" | "); - } else if (monitor.dns_resolve_type === "CNAME" || monitor.dns_resolve_type === "PTR") { - dnsMessage += dnsRes[0]; - } else if (monitor.dns_resolve_type === "CAA") { - dnsMessage += dnsRes[0].issue; - } else if (monitor.dns_resolve_type === "MX") { - dnsRes.forEach(record => { - dnsMessage += `Hostname: ${record.exchange} - Priority: ${record.priority} | `; - }); - dnsMessage = dnsMessage.slice(0, -2); - } else if (monitor.dns_resolve_type === "NS") { - dnsMessage += "Servers: "; - dnsMessage += dnsRes.join(" | "); - } else if (monitor.dns_resolve_type === "SOA") { - dnsMessage += `NS-Name: ${dnsRes.nsname} | Hostmaster: ${dnsRes.hostmaster} | Serial: ${dnsRes.serial} | Refresh: ${dnsRes.refresh} | Retry: ${dnsRes.retry} | Expire: ${dnsRes.expire} | MinTTL: ${dnsRes.minttl}`; - } else if (monitor.dns_resolve_type === "SRV") { - dnsRes.forEach(record => { - dnsMessage += `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight} | `; - }); - dnsMessage = dnsMessage.slice(0, -2); + const conditions = ConditionExpressionGroup.fromMonitor(monitor); + let conditionsResult = true; + const handleConditions = (data) => conditions ? evaluateExpressionGroup(conditions, data) : true; + + switch (monitor.dns_resolve_type) { + case "A": + case "AAAA": + case "TXT": + case "PTR": + dnsMessage = `Records: ${dnsRes.join(" | ")}`; + conditionsResult = dnsRes.some(record => handleConditions({ record })); + break; + + case "CNAME": + dnsMessage = dnsRes[0]; + conditionsResult = handleConditions({ record: dnsRes[0] }); + break; + + case "CAA": + dnsMessage = dnsRes[0].issue; + conditionsResult = handleConditions({ record: dnsRes[0].issue }); + break; + + case "MX": + dnsMessage = dnsRes.map(record => `Hostname: ${record.exchange} - Priority: ${record.priority}`).join(" | "); + conditionsResult = dnsRes.some(record => handleConditions({ record: record.exchange })); + break; + + case "NS": + dnsMessage = `Servers: ${dnsRes.join(" | ")}`; + conditionsResult = dnsRes.some(record => handleConditions({ record })); + break; + + case "SOA": + dnsMessage = `NS-Name: ${dnsRes.nsname} | Hostmaster: ${dnsRes.hostmaster} | Serial: ${dnsRes.serial} | Refresh: ${dnsRes.refresh} | Retry: ${dnsRes.retry} | Expire: ${dnsRes.expire} | MinTTL: ${dnsRes.minttl}`; + conditionsResult = handleConditions({ record: dnsRes.nsname }); + break; + + case "SRV": + dnsMessage = dnsRes.map(record => `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight}`).join(" | "); + conditionsResult = dnsRes.some(record => handleConditions({ record: record.name })); + break; } if (monitor.dns_last_result !== dnsMessage && dnsMessage !== undefined) { @@ -46,7 +76,7 @@ class DnsMonitorType extends MonitorType { } heartbeat.msg = dnsMessage; - heartbeat.status = UP; + heartbeat.status = conditionsResult ? UP : DOWN; } } diff --git a/server/monitor-types/monitor-type.js b/server/monitor-types/monitor-type.js index 8290bdd76..8f3cbcac4 100644 --- a/server/monitor-types/monitor-type.js +++ b/server/monitor-types/monitor-type.js @@ -1,6 +1,19 @@ class MonitorType { name = undefined; + /** + * Whether or not this type supports monitor conditions. Controls UI visibility in monitor form. + * @type {boolean} + */ + supportsConditions = false; + + /** + * Variables supported by this type. e.g. an HTTP type could have a "response_code" variable to test against. + * This property controls the choices displayed in the monitor edit form. + * @type {import("../monitor-conditions/variables").ConditionVariable[]} + */ + conditionVariables = []; + /** * Run the monitoring check on the given monitor * @param {Monitor} monitor Monitor to check diff --git a/server/monitor-types/real-browser-monitor-type.js b/server/monitor-types/real-browser-monitor-type.js index f1219af18..ce3abcb2d 100644 --- a/server/monitor-types/real-browser-monitor-type.js +++ b/server/monitor-types/real-browser-monitor-type.js @@ -63,7 +63,7 @@ if (process.platform === "win32") { * @returns {Promise} The executable is allowed? */ async function isAllowedChromeExecutable(executablePath) { - console.log(config.args); + log.info("Chromium", config.args); if (config.args["allow-all-chrome-exec"] || process.env.UPTIME_KUMA_ALLOW_ALL_CHROME_EXEC === "1") { return true; } @@ -102,7 +102,8 @@ async function getBrowser() { */ async function getRemoteBrowser(remoteBrowserID, userId) { let remoteBrowser = await RemoteBrowser.get(remoteBrowserID, userId); - log.debug("MONITOR", `Using remote browser: ${remoteBrowser.name} (${remoteBrowser.id})`); + log.debug("Chromium", `Using remote browser: ${remoteBrowser.name} (${remoteBrowser.id})`); + browser = chromium.connect(remoteBrowser.url); browser = await chromium.connect(remoteBrowser.url); return browser; } diff --git a/server/monitor-types/snmp.js b/server/monitor-types/snmp.js new file mode 100644 index 000000000..a1760fa3d --- /dev/null +++ b/server/monitor-types/snmp.js @@ -0,0 +1,63 @@ +const { MonitorType } = require("./monitor-type"); +const { UP, log, evaluateJsonQuery } = require("../../src/util"); +const snmp = require("net-snmp"); + +class SNMPMonitorType extends MonitorType { + name = "snmp"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + let session; + try { + const sessionOptions = { + port: monitor.port || "161", + retries: monitor.maxretries, + timeout: monitor.timeout * 1000, + version: snmp.Version[monitor.snmpVersion], + }; + session = snmp.createSession(monitor.hostname, monitor.radiusPassword, sessionOptions); + + // Handle errors during session creation + session.on("error", (error) => { + throw new Error(`Error creating SNMP session: ${error.message}`); + }); + + const varbinds = await new Promise((resolve, reject) => { + session.get([ monitor.snmpOid ], (error, varbinds) => { + error ? reject(error) : resolve(varbinds); + }); + }); + log.debug("monitor", `SNMP: Received varbinds (Type: ${snmp.ObjectType[varbinds[0].type]} Value: ${varbinds[0].value})`); + + if (varbinds.length === 0) { + throw new Error(`No varbinds returned from SNMP session (OID: ${monitor.snmpOid})`); + } + + if (varbinds[0].type === snmp.ObjectType.NoSuchInstance) { + throw new Error(`The SNMP query returned that no instance exists for OID ${monitor.snmpOid}`); + } + + // We restrict querying to one OID per monitor, therefore `varbinds[0]` will always contain the value we're interested in. + const value = varbinds[0].value; + + const { status, response } = await evaluateJsonQuery(value, monitor.jsonPath, monitor.jsonPathOperator, monitor.expectedValue); + + if (status) { + heartbeat.status = UP; + heartbeat.msg = `JSON query passes (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`; + } else { + throw new Error(`JSON query does not pass (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`); + } + } finally { + if (session) { + session.close(); + } + } + } +} + +module.exports = { + SNMPMonitorType, +}; diff --git a/server/notification-providers/alertnow.js b/server/notification-providers/alertnow.js index 4257ca9cd..ecc03e84b 100644 --- a/server/notification-providers/alertnow.js +++ b/server/notification-providers/alertnow.js @@ -1,7 +1,7 @@ const NotificationProvider = require("./notification-provider"); const axios = require("axios"); -const { setting } = require("../util-server"); const { getMonitorRelativeURL, UP, DOWN } = require("../../src/util"); +const { Settings } = require("../settings"); class AlertNow extends NotificationProvider { name = "AlertNow"; @@ -29,7 +29,7 @@ class AlertNow extends NotificationProvider { textMsg += ` - ${msg}`; - const baseURL = await setting("primaryBaseURL"); + const baseURL = await Settings.get("primaryBaseURL"); if (baseURL && monitorJSON) { textMsg += ` >> ${baseURL + getMonitorRelativeURL(monitorJSON.id)}`; } diff --git a/server/notification-providers/flashduty.js b/server/notification-providers/flashduty.js index c340ed06f..09ee8913a 100644 --- a/server/notification-providers/flashduty.js +++ b/server/notification-providers/flashduty.js @@ -1,7 +1,7 @@ const NotificationProvider = require("./notification-provider"); const axios = require("axios"); const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util"); -const { setting } = require("../util-server"); +const { Settings } = require("../settings"); const successMessage = "Sent Successfully."; class FlashDuty extends NotificationProvider { @@ -84,7 +84,7 @@ class FlashDuty extends NotificationProvider { } }; - const baseURL = await setting("primaryBaseURL"); + const baseURL = await Settings.get("primaryBaseURL"); if (baseURL && monitorInfo) { options.client = "Uptime Kuma"; options.client_url = baseURL + getMonitorRelativeURL(monitorInfo.id); diff --git a/server/notification-providers/google-chat.js b/server/notification-providers/google-chat.js index 0b72fea95..e683a2070 100644 --- a/server/notification-providers/google-chat.js +++ b/server/notification-providers/google-chat.js @@ -1,7 +1,7 @@ const NotificationProvider = require("./notification-provider"); const axios = require("axios"); -const { setting } = require("../util-server"); const { getMonitorRelativeURL, UP } = require("../../src/util"); +const { Settings } = require("../settings"); class GoogleChat extends NotificationProvider { name = "GoogleChat"; @@ -45,7 +45,7 @@ class GoogleChat extends NotificationProvider { } // add button for monitor link if available - const baseURL = await setting("primaryBaseURL"); + const baseURL = await Settings.get("primaryBaseURL"); if (baseURL) { const urlPath = monitorJSON ? getMonitorRelativeURL(monitorJSON.id) : "/"; sectionWidgets.push({ diff --git a/server/notification-providers/nostr.js b/server/notification-providers/nostr.js index 453b86d0a..87847382e 100644 --- a/server/notification-providers/nostr.js +++ b/server/notification-providers/nostr.js @@ -1,4 +1,3 @@ -const { log } = require("../../src/util"); const NotificationProvider = require("./notification-provider"); const { relayInit, @@ -12,16 +11,7 @@ const { // 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")) { +if (semver.lt(nodeVersion, "20.0.0")) { // polyfills for node 18 global.crypto = require("crypto"); global.WebSocket = require("isomorphic-ws"); diff --git a/server/notification-providers/onesender.js b/server/notification-providers/onesender.js new file mode 100644 index 000000000..4a33931a2 --- /dev/null +++ b/server/notification-providers/onesender.js @@ -0,0 +1,47 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +class Onesender extends NotificationProvider { + name = "Onesender"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let data = { + heartbeat: heartbeatJSON, + monitor: monitorJSON, + msg, + to: notification.onesenderReceiver, + type: "text", + recipient_type: "individual", + text: { + body: msg + } + }; + if (notification.onesenderTypeReceiver === "private") { + data.to = notification.onesenderReceiver + "@s.whatsapp.net"; + } else { + data.recipient_type = "group"; + data.to = notification.onesenderReceiver + "@g.us"; + } + let config = { + headers: { + "Authorization": "Bearer " + notification.onesenderToken, + } + }; + await axios.post(notification.onesenderURL, data, config); + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + + } + +} + +module.exports = Onesender; diff --git a/server/notification-providers/pagerduty.js b/server/notification-providers/pagerduty.js index c60d782e7..7aa19bb4b 100644 --- a/server/notification-providers/pagerduty.js +++ b/server/notification-providers/pagerduty.js @@ -1,7 +1,7 @@ const NotificationProvider = require("./notification-provider"); const axios = require("axios"); const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util"); -const { setting } = require("../util-server"); +const { Settings } = require("../settings"); let successMessage = "Sent Successfully."; class PagerDuty extends NotificationProvider { @@ -95,7 +95,7 @@ class PagerDuty extends NotificationProvider { } }; - const baseURL = await setting("primaryBaseURL"); + const baseURL = await Settings.get("primaryBaseURL"); if (baseURL && monitorInfo) { options.client = "Uptime Kuma"; options.client_url = baseURL + getMonitorRelativeURL(monitorInfo.id); diff --git a/server/notification-providers/pagertree.js b/server/notification-providers/pagertree.js index c7a5338d1..62f229ace 100644 --- a/server/notification-providers/pagertree.js +++ b/server/notification-providers/pagertree.js @@ -1,7 +1,7 @@ const NotificationProvider = require("./notification-provider"); const axios = require("axios"); const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util"); -const { setting } = require("../util-server"); +const { Settings } = require("../settings"); let successMessage = "Sent Successfully."; class PagerTree extends NotificationProvider { @@ -74,7 +74,7 @@ class PagerTree extends NotificationProvider { } }; - const baseURL = await setting("primaryBaseURL"); + const baseURL = await Settings.get("primaryBaseURL"); if (baseURL && monitorJSON) { options.client = "Uptime Kuma"; options.client_url = baseURL + getMonitorRelativeURL(monitorJSON.id); diff --git a/server/notification-providers/pushover.js b/server/notification-providers/pushover.js index 304aa3519..8422b64c2 100644 --- a/server/notification-providers/pushover.js +++ b/server/notification-providers/pushover.js @@ -1,3 +1,6 @@ +const { getMonitorRelativeURL } = require("../../src/util"); +const { setting } = require("../util-server"); + const NotificationProvider = require("./notification-provider"); const axios = require("axios"); @@ -23,6 +26,12 @@ class Pushover extends NotificationProvider { "html": 1, }; + const baseURL = await setting("primaryBaseURL"); + if (baseURL && monitorJSON) { + data["url"] = baseURL + getMonitorRelativeURL(monitorJSON.id); + data["url_title"] = "Link to Monitor"; + } + if (notification.pushoverdevice) { data.device = notification.pushoverdevice; } diff --git a/server/notification-providers/rocket-chat.js b/server/notification-providers/rocket-chat.js index 690e33a86..174a94950 100644 --- a/server/notification-providers/rocket-chat.js +++ b/server/notification-providers/rocket-chat.js @@ -1,8 +1,8 @@ const NotificationProvider = require("./notification-provider"); const axios = require("axios"); const Slack = require("./slack"); -const { setting } = require("../util-server"); const { getMonitorRelativeURL, DOWN } = require("../../src/util"); +const { Settings } = require("../settings"); class RocketChat extends NotificationProvider { name = "rocket.chat"; @@ -49,7 +49,7 @@ class RocketChat extends NotificationProvider { await Slack.deprecateURL(notification.rocketbutton); } - const baseURL = await setting("primaryBaseURL"); + const baseURL = await Settings.get("primaryBaseURL"); if (baseURL) { data.attachments[0].title_link = baseURL + getMonitorRelativeURL(monitorJSON.id); diff --git a/server/notification-providers/signl4.js b/server/notification-providers/signl4.js new file mode 100644 index 000000000..e48983f59 --- /dev/null +++ b/server/notification-providers/signl4.js @@ -0,0 +1,52 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { UP, DOWN } = require("../../src/util"); + +class SIGNL4 extends NotificationProvider { + name = "SIGNL4"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + let data = { + heartbeat: heartbeatJSON, + monitor: monitorJSON, + msg, + // Source system + "X-S4-SourceSystem": "UptimeKuma", + monitorUrl: this.extractAdress(monitorJSON), + }; + + const config = { + headers: { + "Content-Type": "application/json" + } + }; + + if (heartbeatJSON == null) { + // Test alert + data.title = "Uptime Kuma Alert"; + data.message = msg; + } else if (heartbeatJSON.status === UP) { + data.title = "Uptime Kuma Monitor ✅ Up"; + data["X-S4-ExternalID"] = "UptimeKuma-" + monitorJSON.monitorID; + data["X-S4-Status"] = "resolved"; + } else if (heartbeatJSON.status === DOWN) { + data.title = "Uptime Kuma Monitor 🔴 Down"; + data["X-S4-ExternalID"] = "UptimeKuma-" + monitorJSON.monitorID; + data["X-S4-Status"] = "new"; + } + + await axios.post(notification.webhookURL, data, config); + return okMsg; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = SIGNL4; diff --git a/server/notification-providers/slack.js b/server/notification-providers/slack.js index 439f5e905..98ed1a908 100644 --- a/server/notification-providers/slack.js +++ b/server/notification-providers/slack.js @@ -1,7 +1,8 @@ const NotificationProvider = require("./notification-provider"); const axios = require("axios"); -const { setSettings, setting } = require("../util-server"); const { getMonitorRelativeURL, UP } = require("../../src/util"); +const { Settings } = require("../settings"); +const { log } = require("../../src/util"); class Slack extends NotificationProvider { name = "slack"; @@ -14,15 +15,13 @@ class Slack extends NotificationProvider { * @returns {Promise} */ static async deprecateURL(url) { - let currentPrimaryBaseURL = await setting("primaryBaseURL"); + let currentPrimaryBaseURL = await Settings.get("primaryBaseURL"); if (!currentPrimaryBaseURL) { - console.log("Move the url to be the primary base URL"); - await setSettings("general", { - primaryBaseURL: url, - }); + log.error("notification", "Move the url to be the primary base URL"); + await Settings.set("primaryBaseURL", url, "general"); } else { - console.log("Already there, no need to move the primary base URL"); + log.debug("notification", "Already there, no need to move the primary base URL"); } } @@ -48,7 +47,8 @@ class Slack extends NotificationProvider { } - if (monitorJSON.url) { + const address = this.extractAdress(monitorJSON); + if (address) { actions.push({ "type": "button", "text": { @@ -56,7 +56,7 @@ class Slack extends NotificationProvider { "text": "Visit site", }, "value": "Site", - "url": monitorJSON.url, + "url": address, }); } @@ -135,7 +135,7 @@ class Slack extends NotificationProvider { return okMsg; } - const baseURL = await setting("primaryBaseURL"); + const baseURL = await Settings.get("primaryBaseURL"); const title = "Uptime Kuma Alert"; let data = { diff --git a/server/notification-providers/splunk.js b/server/notification-providers/splunk.js index e07c51039..ad4dc6b39 100644 --- a/server/notification-providers/splunk.js +++ b/server/notification-providers/splunk.js @@ -1,7 +1,7 @@ const NotificationProvider = require("./notification-provider"); const axios = require("axios"); const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util"); -const { setting } = require("../util-server"); +const { Settings } = require("../settings"); let successMessage = "Sent Successfully."; class Splunk extends NotificationProvider { @@ -95,7 +95,7 @@ class Splunk extends NotificationProvider { } }; - const baseURL = await setting("primaryBaseURL"); + const baseURL = await Settings.get("primaryBaseURL"); if (baseURL && monitorInfo) { options.client = "Uptime Kuma"; options.client_url = baseURL + getMonitorRelativeURL(monitorInfo.id); diff --git a/server/notification-providers/stackfield.js b/server/notification-providers/stackfield.js index 65a92459a..e4e31a9ab 100644 --- a/server/notification-providers/stackfield.js +++ b/server/notification-providers/stackfield.js @@ -1,7 +1,7 @@ const NotificationProvider = require("./notification-provider"); const axios = require("axios"); -const { setting } = require("../util-server"); const { getMonitorRelativeURL } = require("../../src/util"); +const { Settings } = require("../settings"); class Stackfield extends NotificationProvider { name = "stackfield"; @@ -23,7 +23,7 @@ class Stackfield extends NotificationProvider { textMsg += `\n${msg}`; - const baseURL = await setting("primaryBaseURL"); + const baseURL = await Settings.get("primaryBaseURL"); if (baseURL) { textMsg += `\n${baseURL + getMonitorRelativeURL(monitorJSON.id)}`; } diff --git a/server/notification-providers/wpush.js b/server/notification-providers/wpush.js new file mode 100644 index 000000000..db043f9c5 --- /dev/null +++ b/server/notification-providers/wpush.js @@ -0,0 +1,51 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { DOWN, UP } = require("../../src/util"); + +class WPush extends NotificationProvider { + name = "WPush"; + + /** + * @inheritdoc + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent Successfully."; + + try { + const context = { + "title": this.checkStatus(heartbeatJSON, monitorJSON), + "content": msg, + "apikey": notification.wpushAPIkey, + "channel": notification.wpushChannel + }; + const result = await axios.post("https://api.wpush.cn/api/v1/send", context); + if (result.data.code !== 0) { + throw result.data.message; + } + + return okMsg; + + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + + /** + * Get the formatted title for message + * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only) + * @param {?object} monitorJSON Monitor details (For Up/Down only) + * @returns {string} Formatted title + */ + checkStatus(heartbeatJSON, monitorJSON) { + let title = "UptimeKuma Message"; + if (heartbeatJSON != null && heartbeatJSON["status"] === UP) { + title = "UptimeKuma Monitor Up " + monitorJSON["name"]; + } + if (heartbeatJSON != null && heartbeatJSON["status"] === DOWN) { + title = "UptimeKuma Monitor Down " + monitorJSON["name"]; + } + return title; + } +} + +module.exports = WPush; diff --git a/server/notification.js b/server/notification.js index 28b0db758..60eae4d73 100644 --- a/server/notification.js +++ b/server/notification.js @@ -42,6 +42,7 @@ const Pushy = require("./notification-providers/pushy"); const RocketChat = require("./notification-providers/rocket-chat"); const SerwerSMS = require("./notification-providers/serwersms"); const Signal = require("./notification-providers/signal"); +const SIGNL4 = require("./notification-providers/signl4"); const Slack = require("./notification-providers/slack"); const SMSPartner = require("./notification-providers/smspartner"); const SMSEagle = require("./notification-providers/smseagle"); @@ -64,6 +65,8 @@ const SevenIO = require("./notification-providers/sevenio"); const Whapi = require("./notification-providers/whapi"); const GtxMessaging = require("./notification-providers/gtx-messaging"); const Cellsynt = require("./notification-providers/cellsynt"); +const Onesender = require("./notification-providers/onesender"); +const Wpush = require("./notification-providers/wpush"); class Notification { @@ -111,6 +114,7 @@ class Notification { new Ntfy(), new Octopush(), new OneBot(), + new Onesender(), new Opsgenie(), new PagerDuty(), new FlashDuty(), @@ -124,6 +128,7 @@ class Notification { new ServerChan(), new SerwerSMS(), new Signal(), + new SIGNL4(), new SMSManager(), new SMSPartner(), new Slack(), @@ -145,6 +150,7 @@ class Notification { new Whapi(), new GtxMessaging(), new Cellsynt(), + new Wpush(), ]; for (let item of list) { if (! item.name) { diff --git a/server/prometheus.js b/server/prometheus.js index f26125d2c..05a028397 100644 --- a/server/prometheus.js +++ b/server/prometheus.js @@ -1,3 +1,4 @@ +const { R } = require("redbean-node"); const PrometheusClient = require("prom-client"); const { log } = require("../src/util"); @@ -9,36 +10,102 @@ const commonLabels = [ "monitor_port", ]; -const monitorCertDaysRemaining = new PrometheusClient.Gauge({ - name: "monitor_cert_days_remaining", - help: "The number of days remaining until the certificate expires", - labelNames: commonLabels -}); - -const monitorCertIsValid = new PrometheusClient.Gauge({ - name: "monitor_cert_is_valid", - help: "Is the certificate still valid? (1 = Yes, 0= No)", - labelNames: commonLabels -}); -const monitorResponseTime = new PrometheusClient.Gauge({ - name: "monitor_response_time", - help: "Monitor Response Time (ms)", - labelNames: commonLabels -}); - -const monitorStatus = new PrometheusClient.Gauge({ - name: "monitor_status", - help: "Monitor Status (1 = UP, 0= DOWN, 2= PENDING, 3= MAINTENANCE)", - labelNames: commonLabels -}); - class Prometheus { - monitorLabelValues = {}; /** - * @param {object} monitor Monitor object to monitor + * Metric: monitor_cert_days_remaining + * @type {PrometheusClient.Gauge | null} */ - constructor(monitor) { + static monitorCertDaysRemaining = null; + + /** + * Metric: monitor_cert_is_valid + * @type {PrometheusClient.Gauge | null} + */ + static monitorCertIsValid = null; + + /** + * Metric: monitor_response_time + * @type {PrometheusClient.Gauge | null} + */ + static monitorResponseTime = null; + + /** + * Metric: monitor_status + * @type {PrometheusClient.Gauge | null} + */ + static monitorStatus = null; + + /** + * All registered metric labels. + * @type {string[] | null} + */ + static monitorLabelNames = null; + + /** + * Monitor labels/values combination. + * @type {{}} + */ + monitorLabelValues; + + /** + * Initialize metrics and get all label names the first time called. + * @returns {void} + */ + static async initMetrics() { + if (!this.monitorLabelNames) { + let labelNames = await R.getCol("SELECT name FROM tag"); + this.monitorLabelNames = [ ...commonLabels, ...labelNames ]; + } + if (!this.monitorCertDaysRemaining) { + this.monitorCertDaysRemaining = new PrometheusClient.Gauge({ + name: "monitor_cert_days_remaining", + help: "The number of days remaining until the certificate expires", + labelNames: this.monitorLabelNames + }); + } + if (!this.monitorCertIsValid) { + this.monitorCertIsValid = new PrometheusClient.Gauge({ + name: "monitor_cert_is_valid", + help: "Is the certificate still valid? (1 = Yes, 0 = No)", + labelNames: this.monitorLabelNames + }); + } + if (!this.monitorResponseTime) { + this.monitorResponseTime = new PrometheusClient.Gauge({ + name: "monitor_response_time", + help: "Monitor Response Time (ms)", + labelNames: this.monitorLabelNames + }); + } + if (!this.monitorStatus) { + this.monitorStatus = new PrometheusClient.Gauge({ + name: "monitor_status", + help: "Monitor Status (1 = UP, 0 = DOWN, 2 = PENDING, 3 = MAINTENANCE)", + labelNames: this.monitorLabelNames + }); + } + } + + /** + * Wrapper to create a `Prometheus` instance and ensure metrics are initialized. + * @param {Monitor} monitor Monitor object to monitor + * @returns {Promise} `Prometheus` instance + */ + static async createAndInitMetrics(monitor) { + await Prometheus.initMetrics(); + let tags = await monitor.getTags(); + return new Prometheus(monitor, tags); + } + + /** + * Creates a prometheus metric instance. + * + * Note: Make sure to call `Prometheus.initMetrics()` once prior creating Prometheus instances. + * @param {Monitor} monitor Monitor object to monitor + * @param {Promise[]>} tags Tags of the monitor + */ + constructor(monitor, tags) { this.monitorLabelValues = { monitor_name: monitor.name, monitor_type: monitor.type, @@ -46,6 +113,12 @@ class Prometheus { monitor_hostname: monitor.hostname, monitor_port: monitor.port }; + Object.values(tags) + // only label names that were known at first metric creation. + .filter(tag => Prometheus.monitorLabelNames.includes(tag.name)) + .forEach(tag => { + this.monitorLabelValues[tag.name] = tag.value; + }); } /** @@ -55,7 +128,6 @@ class Prometheus { * @returns {void} */ update(heartbeat, tlsInfo) { - if (typeof tlsInfo !== "undefined") { try { let isValid; @@ -64,7 +136,7 @@ class Prometheus { } else { isValid = 0; } - monitorCertIsValid.set(this.monitorLabelValues, isValid); + Prometheus.monitorCertIsValid.set(this.monitorLabelValues, isValid); } catch (e) { log.error("prometheus", "Caught error"); log.error("prometheus", e); @@ -72,7 +144,7 @@ class Prometheus { try { if (tlsInfo.certInfo != null) { - monitorCertDaysRemaining.set(this.monitorLabelValues, tlsInfo.certInfo.daysRemaining); + Prometheus.monitorCertDaysRemaining.set(this.monitorLabelValues, tlsInfo.certInfo.daysRemaining); } } catch (e) { log.error("prometheus", "Caught error"); @@ -82,7 +154,7 @@ class Prometheus { if (heartbeat) { try { - monitorStatus.set(this.monitorLabelValues, heartbeat.status); + Prometheus.monitorStatus.set(this.monitorLabelValues, heartbeat.status); } catch (e) { log.error("prometheus", "Caught error"); log.error("prometheus", e); @@ -90,10 +162,10 @@ class Prometheus { try { if (typeof heartbeat.ping === "number") { - monitorResponseTime.set(this.monitorLabelValues, heartbeat.ping); + Prometheus.monitorResponseTime.set(this.monitorLabelValues, heartbeat.ping); } else { // Is it good? - monitorResponseTime.set(this.monitorLabelValues, -1); + Prometheus.monitorResponseTime.set(this.monitorLabelValues, -1); } } catch (e) { log.error("prometheus", "Caught error"); @@ -108,10 +180,10 @@ class Prometheus { */ remove() { try { - monitorCertDaysRemaining.remove(this.monitorLabelValues); - monitorCertIsValid.remove(this.monitorLabelValues); - monitorResponseTime.remove(this.monitorLabelValues); - monitorStatus.remove(this.monitorLabelValues); + Prometheus.monitorCertDaysRemaining?.remove(this.monitorLabelValues); + Prometheus.monitorCertIsValid?.remove(this.monitorLabelValues); + Prometheus.monitorResponseTime?.remove(this.monitorLabelValues); + Prometheus.monitorStatus?.remove(this.monitorLabelValues); } catch (e) { console.error(e); } diff --git a/server/proxy.js b/server/proxy.js index 3f3771ab9..3f1215b25 100644 --- a/server/proxy.js +++ b/server/proxy.js @@ -2,7 +2,7 @@ const { R } = require("redbean-node"); const HttpProxyAgent = require("http-proxy-agent"); const HttpsProxyAgent = require("https-proxy-agent"); const SocksProxyAgent = require("socks-proxy-agent"); -const { debug } = require("../src/util"); +const { log } = require("../src/util"); const { UptimeKumaServer } = require("./uptime-kuma-server"); const { CookieJar } = require("tough-cookie"); const { createCookieAgent } = require("http-cookie-agent/http"); @@ -110,9 +110,9 @@ class Proxy { proxyOptions.auth = `${proxy.username}:${proxy.password}`; } - debug(`Proxy Options: ${JSON.stringify(proxyOptions)}`); - debug(`HTTP Agent Options: ${JSON.stringify(httpAgentOptions)}`); - debug(`HTTPS Agent Options: ${JSON.stringify(httpsAgentOptions)}`); + log.debug("update-proxy", `Proxy Options: ${JSON.stringify(proxyOptions)}`); + log.debug("update-proxy", `HTTP Agent Options: ${JSON.stringify(httpAgentOptions)}`); + log.debug("update-proxy", `HTTPS Agent Options: ${JSON.stringify(httpsAgentOptions)}`); switch (proxy.protocol) { case "http": diff --git a/server/routers/api-router.js b/server/routers/api-router.js index 9c572f686..9e7c9348c 100644 --- a/server/routers/api-router.js +++ b/server/routers/api-router.js @@ -1,6 +1,5 @@ let express = require("express"); const { - setting, allowDevAllOrigin, allowAllOrigin, percentageToColor, @@ -18,6 +17,7 @@ const { makeBadge } = require("badge-maker"); const { Prometheus } = require("../prometheus"); const Database = require("../database"); const { UptimeCalculator } = require("../uptime-calculator"); +const { Settings } = require("../settings"); let router = express.Router(); @@ -30,7 +30,7 @@ router.get("/api/entry-page", async (request, response) => { let result = { }; let hostname = request.hostname; - if ((await setting("trustProxy")) && request.headers["x-forwarded-host"]) { + if ((await Settings.get("trustProxy")) && request.headers["x-forwarded-host"]) { hostname = request.headers["x-forwarded-host"]; } @@ -232,8 +232,8 @@ router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (reques let requestedDuration = request.params.duration !== undefined ? request.params.duration : "24h"; const overrideValue = value && parseFloat(value); - if (requestedDuration === "24") { - requestedDuration = "24h"; + if (/^[0-9]+$/.test(requestedDuration)) { + requestedDuration = `${requestedDuration}h`; } let publicMonitor = await R.getRow(` @@ -265,7 +265,7 @@ router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (reques // build a label string. If a custom label is given, override the default one (requestedDuration) badgeValues.label = filterAndJoin([ labelPrefix, - label ?? `Uptime (${requestedDuration}${labelSuffix})`, + label ?? `Uptime (${requestedDuration.slice(0, -1)}${labelSuffix})`, ]); badgeValues.message = filterAndJoin([ prefix, cleanUptime, suffix ]); } @@ -302,8 +302,8 @@ router.get("/api/badge/:id/ping/:duration?", cache("5 minutes"), async (request, let requestedDuration = request.params.duration !== undefined ? request.params.duration : "24h"; const overrideValue = value && parseFloat(value); - if (requestedDuration === "24") { - requestedDuration = "24h"; + if (/^[0-9]+$/.test(requestedDuration)) { + requestedDuration = `${requestedDuration}h`; } // Check if monitor is public @@ -325,7 +325,7 @@ router.get("/api/badge/:id/ping/:duration?", cache("5 minutes"), async (request, // use a given, custom labelColor or use the default badge label color (defined by badge-maker) badgeValues.labelColor = labelColor ?? ""; // build a lable string. If a custom label is given, override the default one (requestedDuration) - badgeValues.label = filterAndJoin([ labelPrefix, label ?? `Avg. Ping (${requestedDuration}${labelSuffix})` ]); + badgeValues.label = filterAndJoin([ labelPrefix, label ?? `Avg. Ping (${requestedDuration.slice(0, -1)}${labelSuffix})` ]); badgeValues.message = filterAndJoin([ prefix, avgPing, suffix ]); } diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js index 42cccc942..b209d33d1 100644 --- a/server/routers/status-page-router.js +++ b/server/routers/status-page-router.js @@ -18,6 +18,11 @@ router.get("/status/:slug", cache("5 minutes"), async (request, response) => { await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug); }); +router.get("/status/:slug/rss", cache("5 minutes"), async (request, response) => { + let slug = request.params.slug; + await StatusPage.handleStatusPageRSSResponse(response, slug); +}); + router.get("/status", cache("5 minutes"), async (request, response) => { let slug = "default"; await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug); diff --git a/server/server.js b/server/server.js index 38158c546..d040d6e87 100644 --- a/server/server.js +++ b/server/server.js @@ -19,7 +19,7 @@ const nodeVersion = process.versions.node; // Get the required Node.js version from package.json const requiredNodeVersions = require("../package.json").engines.node; -const bannedNodeVersions = " < 14 || 20.0.* || 20.1.* || 20.2.* || 20.3.* "; +const bannedNodeVersions = " < 18 || 20.0.* || 20.1.* || 20.2.* || 20.3.* "; console.log(`Your Node.js version: ${nodeVersion}`); const semver = require("semver"); @@ -90,8 +90,7 @@ const Monitor = require("./model/monitor"); const User = require("./model/user"); log.debug("server", "Importing Settings"); -const { getSettings, setSettings, setting, initJWTSecret, checkLogin, doubleCheckPassword, shake256, SHAKE256_LENGTH, allowDevAllOrigin, -} = require("./util-server"); +const { initJWTSecret, checkLogin, doubleCheckPassword, shake256, SHAKE256_LENGTH, allowDevAllOrigin } = require("./util-server"); log.debug("server", "Importing Notification"); const { Notification } = require("./notification"); @@ -132,9 +131,9 @@ const twoFAVerifyOptions = { const testMode = !!args["test"] || false; // Must be after io instantiation -const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList, sendRemoteBrowserList } = require("./client"); +const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList, sendRemoteBrowserList, sendMonitorTypeList } = require("./client"); const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler"); -const databaseSocketHandler = require("./socket-handlers/database-socket-handler"); +const { databaseSocketHandler } = require("./socket-handlers/database-socket-handler"); const { remoteBrowserSocketHandler } = require("./socket-handlers/remote-browser-socket-handler"); const TwoFA = require("./2fa"); const StatusPage = require("./model/status_page"); @@ -201,7 +200,7 @@ let needSetup = false; // Entry Page app.get("/", async (request, response) => { let hostname = request.hostname; - if (await setting("trustProxy")) { + if (await Settings.get("trustProxy")) { const proxy = request.headers["x-forwarded-host"]; if (proxy) { hostname = proxy; @@ -246,12 +245,42 @@ let needSetup = false; log.debug("test", request.body); response.send("OK"); }); + + const fs = require("fs"); + + app.get("/_e2e/take-sqlite-snapshot", async (request, response) => { + await Database.close(); + try { + fs.cpSync(Database.sqlitePath, `${Database.sqlitePath}.e2e-snapshot`); + } catch (err) { + throw new Error("Unable to copy SQLite DB."); + } + await Database.connect(); + + response.send("Snapshot taken."); + }); + + app.get("/_e2e/restore-sqlite-snapshot", async (request, response) => { + if (!fs.existsSync(`${Database.sqlitePath}.e2e-snapshot`)) { + throw new Error("Snapshot doesn't exist."); + } + + await Database.close(); + try { + fs.cpSync(`${Database.sqlitePath}.e2e-snapshot`, Database.sqlitePath); + } catch (err) { + throw new Error("Unable to copy snapshot file."); + } + await Database.connect(); + + response.send("Snapshot restored."); + }); } // Robots.txt app.get("/robots.txt", async (_request, response) => { let txt = "User-agent: *\nDisallow:"; - if (!await setting("searchEngineIndex")) { + if (!await Settings.get("searchEngineIndex")) { txt += " /"; } response.setHeader("Content-Type", "text/plain"); @@ -686,6 +715,8 @@ let needSetup = false; monitor.kafkaProducerBrokers = JSON.stringify(monitor.kafkaProducerBrokers); monitor.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions); + monitor.conditions = JSON.stringify(monitor.conditions); + bean.import(monitor); bean.user_id = socket.userID; @@ -701,7 +732,7 @@ let needSetup = false; await startMonitor(socket.userID, bean.id); } - log.info("monitor", `Added Monitor: ${monitor.id} User ID: ${socket.userID}`); + log.info("monitor", `Added Monitor: ${bean.id} User ID: ${socket.userID}`); callback({ ok: true, @@ -826,11 +857,17 @@ let needSetup = false; bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation; bean.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions); bean.kafkaProducerMessage = monitor.kafkaProducerMessage; + bean.cacheBust = monitor.cacheBust; bean.kafkaProducerSsl = monitor.kafkaProducerSsl; bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation; bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly; bean.remote_browser = monitor.remote_browser; + bean.snmpVersion = monitor.snmpVersion; + bean.snmpOid = monitor.snmpOid; + bean.jsonPathOperator = monitor.jsonPathOperator; + bean.timeout = monitor.timeout; + bean.conditions = JSON.stringify(monitor.conditions); bean.validate(); @@ -1287,7 +1324,7 @@ let needSetup = false; socket.on("getSettings", async (callback) => { try { checkLogin(socket); - const data = await getSettings("general"); + const data = await Settings.getSettings("general"); if (!data.serverTimezone) { data.serverTimezone = await server.getTimezone(); @@ -1315,7 +1352,7 @@ let needSetup = false; // Disabled Auth + Want to Enable Auth => No Check // Enabled Auth + Want to Disable Auth => Check!! // Enabled Auth + Want to Enable Auth => No Check - const currentDisabledAuth = await setting("disableAuth"); + const currentDisabledAuth = await Settings.get("disableAuth"); if (!currentDisabledAuth && data.disableAuth) { await doubleCheckPassword(socket, currentPassword); } @@ -1329,7 +1366,7 @@ let needSetup = false; const previousChromeExecutable = await Settings.get("chromeExecutable"); const previousNSCDStatus = await Settings.get("nscd"); - await setSettings("general", data); + await Settings.setSettings("general", data); server.entryPage = data.entryPage; // Also need to apply timezone globally @@ -1425,7 +1462,7 @@ let needSetup = false; }); } catch (e) { - console.error(e); + log.error("server", e); callback({ ok: false, @@ -1538,7 +1575,7 @@ let needSetup = false; // *************************** log.debug("auth", "check auto login"); - if (await setting("disableAuth")) { + if (await Settings.get("disableAuth")) { log.info("auth", "Disabled Auth: auto login to admin"); await afterLogin(socket, await R.findOne("user")); socket.emit("autoLogin"); @@ -1636,6 +1673,7 @@ async function afterLogin(socket, user) { sendDockerHostList(socket), sendAPIKeyList(socket), sendRemoteBrowserList(socket), + sendMonitorTypeList(socket), ]); await StatusPage.sendStatusPageList(io, socket); diff --git a/server/socket-handlers/api-key-socket-handler.js b/server/socket-handlers/api-key-socket-handler.js index f76b90991..1394f8044 100644 --- a/server/socket-handlers/api-key-socket-handler.js +++ b/server/socket-handlers/api-key-socket-handler.js @@ -60,7 +60,7 @@ module.exports.apiKeySocketHandler = (socket) => { ok: true, }); } catch (e) { - console.error(e); + log.error("apikeys", e); callback({ ok: false, msg: e.message, diff --git a/server/socket-handlers/cloudflared-socket-handler.js b/server/socket-handlers/cloudflared-socket-handler.js index 809191fe8..1cd0c53d0 100644 --- a/server/socket-handlers/cloudflared-socket-handler.js +++ b/server/socket-handlers/cloudflared-socket-handler.js @@ -1,7 +1,8 @@ -const { checkLogin, setSetting, setting, doubleCheckPassword } = require("../util-server"); +const { checkLogin, doubleCheckPassword } = require("../util-server"); const { CloudflaredTunnel } = require("node-cloudflared-tunnel"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const { log } = require("../../src/util"); +const { Settings } = require("../settings"); const io = UptimeKumaServer.getInstance().io; const prefix = "cloudflared_"; @@ -40,7 +41,7 @@ module.exports.cloudflaredSocketHandler = (socket) => { socket.join("cloudflared"); io.to(socket.userID).emit(prefix + "installed", cloudflared.checkInstalled()); io.to(socket.userID).emit(prefix + "running", cloudflared.running); - io.to(socket.userID).emit(prefix + "token", await setting("cloudflaredTunnelToken")); + io.to(socket.userID).emit(prefix + "token", await Settings.get("cloudflaredTunnelToken")); } catch (error) { } }); @@ -55,7 +56,7 @@ module.exports.cloudflaredSocketHandler = (socket) => { try { checkLogin(socket); if (token && typeof token === "string") { - await setSetting("cloudflaredTunnelToken", token); + await Settings.set("cloudflaredTunnelToken", token); cloudflared.token = token; } else { cloudflared.token = null; @@ -67,7 +68,7 @@ module.exports.cloudflaredSocketHandler = (socket) => { socket.on(prefix + "stop", async (currentPassword, callback) => { try { checkLogin(socket); - const disabledAuth = await setting("disableAuth"); + const disabledAuth = await Settings.get("disableAuth"); if (!disabledAuth) { await doubleCheckPassword(socket, currentPassword); } @@ -83,7 +84,7 @@ module.exports.cloudflaredSocketHandler = (socket) => { socket.on(prefix + "removeToken", async () => { try { checkLogin(socket); - await setSetting("cloudflaredTunnelToken", ""); + await Settings.set("cloudflaredTunnelToken", ""); } catch (error) { } }); @@ -96,15 +97,15 @@ module.exports.cloudflaredSocketHandler = (socket) => { */ module.exports.autoStart = async (token) => { if (!token) { - token = await setting("cloudflaredTunnelToken"); + token = await Settings.get("cloudflaredTunnelToken"); } else { // Override the current token via args or env var - await setSetting("cloudflaredTunnelToken", token); - console.log("Use cloudflared token from args or env var"); + await Settings.set("cloudflaredTunnelToken", token); + log.info("cloudflare", "Use cloudflared token from args or env var"); } if (token) { - console.log("Start cloudflared"); + log.info("cloudflare", "Start cloudflared"); cloudflared.token = token; cloudflared.start(); } diff --git a/server/socket-handlers/database-socket-handler.js b/server/socket-handlers/database-socket-handler.js index bcf34c906..ee2394bf6 100644 --- a/server/socket-handlers/database-socket-handler.js +++ b/server/socket-handlers/database-socket-handler.js @@ -6,7 +6,7 @@ const Database = require("../database"); * @param {Socket} socket Socket.io instance * @returns {void} */ -module.exports = (socket) => { +module.exports.databaseSocketHandler = (socket) => { // Post or edit incident socket.on("getDatabaseSize", async (callback) => { diff --git a/server/socket-handlers/general-socket-handler.js b/server/socket-handlers/general-socket-handler.js index 68e1f814c..50dcd946e 100644 --- a/server/socket-handlers/general-socket-handler.js +++ b/server/socket-handlers/general-socket-handler.js @@ -29,8 +29,13 @@ function getGameList() { return gameList; } +/** + * Handler for general events + * @param {Socket} socket Socket.io instance + * @param {UptimeKumaServer} server Uptime Kuma server + * @returns {void} + */ module.exports.generalSocketHandler = (socket, server) => { - socket.on("initServerTimezone", async (timezone) => { try { checkLogin(socket); diff --git a/server/socket-handlers/maintenance-socket-handler.js b/server/socket-handlers/maintenance-socket-handler.js index 7de13fe57..201014c22 100644 --- a/server/socket-handlers/maintenance-socket-handler.js +++ b/server/socket-handlers/maintenance-socket-handler.js @@ -67,7 +67,7 @@ module.exports.maintenanceSocketHandler = (socket) => { }); } catch (e) { - console.error(e); + log.error("maintenance", e); callback({ ok: false, msg: e.message, @@ -177,7 +177,7 @@ module.exports.maintenanceSocketHandler = (socket) => { ok: true, }); } catch (e) { - console.error(e); + log.error("maintenance", e); callback({ ok: false, msg: e.message, @@ -201,7 +201,7 @@ module.exports.maintenanceSocketHandler = (socket) => { }); } catch (e) { - console.error(e); + log.error("maintenance", e); callback({ ok: false, msg: e.message, @@ -225,7 +225,7 @@ module.exports.maintenanceSocketHandler = (socket) => { }); } catch (e) { - console.error(e); + log.error("maintenance", e); callback({ ok: false, msg: e.message, diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js index 0804da15d..cf0accc23 100644 --- a/server/socket-handlers/status-page-socket-handler.js +++ b/server/socket-handlers/status-page-socket-handler.js @@ -1,5 +1,5 @@ const { R } = require("redbean-node"); -const { checkLogin, setSetting } = require("../util-server"); +const { checkLogin } = require("../util-server"); const dayjs = require("dayjs"); const { log } = require("../../src/util"); const ImageDataURI = require("../image-data-uri"); @@ -7,6 +7,7 @@ const Database = require("../database"); const apicache = require("../modules/apicache"); const StatusPage = require("../model/status_page"); const { UptimeKumaServer } = require("../uptime-kuma-server"); +const { Settings } = require("../settings"); /** * Socket handlers for status page @@ -233,7 +234,7 @@ module.exports.statusPageSocketHandler = (socket) => { // Also change entry page to new slug if it is the default one, and slug is changed. if (server.entryPage === "statusPage-" + slug && statusPage.slug !== slug) { server.entryPage = "statusPage-" + statusPage.slug; - await setSetting("entryPage", server.entryPage, "general"); + await Settings.set("entryPage", server.entryPage, "general"); } apicache.clear(); @@ -291,7 +292,7 @@ module.exports.statusPageSocketHandler = (socket) => { }); } catch (error) { - console.error(error); + log.error("socket", error); callback({ ok: false, msg: error.message, @@ -313,7 +314,7 @@ module.exports.statusPageSocketHandler = (socket) => { // Reset entry page if it is the default one. if (server.entryPage === "statusPage-" + slug) { server.entryPage = "dashboard"; - await setSetting("entryPage", server.entryPage, "general"); + await Settings.set("entryPage", server.entryPage, "general"); } // No need to delete records from `status_page_cname`, because it has cascade foreign key. diff --git a/server/uptime-calculator.js b/server/uptime-calculator.js index 55059e960..f2738b96a 100644 --- a/server/uptime-calculator.js +++ b/server/uptime-calculator.js @@ -543,7 +543,9 @@ class UptimeCalculator { if (type === "minute" && num > 24 * 60) { throw new Error("The maximum number of minutes is 1440"); } - + if (type === "day" && num > 365) { + throw new Error("The maximum number of days is 365"); + } // Get the current time period key based on the type let key = this.getKey(this.getCurrentDate(), type); @@ -741,20 +743,36 @@ class UptimeCalculator { } /** - * Get the uptime data by duration - * @param {'24h'|'30d'|'1y'} duration Only accept 24h, 30d, 1y + * Get the uptime data for given duration. + * @param {string} duration A string with a number and a unit (m,h,d,w,M,y), such as 24h, 30d, 1y. * @returns {UptimeDataResult} UptimeDataResult - * @throws {Error} Invalid duration + * @throws {Error} Invalid duration / Unsupported unit */ getDataByDuration(duration) { - if (duration === "24h") { - return this.get24Hour(); - } else if (duration === "30d") { - return this.get30Day(); - } else if (duration === "1y") { - return this.get1Year(); - } else { - throw new Error("Invalid duration"); + const durationNumStr = duration.slice(0, -1); + + if (!/^[0-9]+$/.test(durationNumStr)) { + throw new Error(`Invalid duration: ${duration}`); + } + const num = Number(durationNumStr); + const unit = duration.slice(-1); + + switch (unit) { + case "m": + return this.getData(num, "minute"); + case "h": + return this.getData(num, "hour"); + case "d": + return this.getData(num, "day"); + case "w": + return this.getData(7 * num, "day"); + case "M": + return this.getData(30 * num, "day"); + case "y": + return this.getData(365 * num, "day"); + default: + throw new Error(`Unsupported unit (${unit}) for badge duration ${duration}` + ); } } diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 6ab5f6c26..573d791a6 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -113,6 +113,7 @@ class UptimeKumaServer { UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing(); UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType(); UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType(); + UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType(); UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType(); // Allow all CORS origins (polling) in development @@ -517,4 +518,5 @@ const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor const { TailscalePing } = require("./monitor-types/tailscale-ping"); const { DnsMonitorType } = require("./monitor-types/dns"); const { MqttMonitorType } = require("./monitor-types/mqtt"); +const { SNMPMonitorType } = require("./monitor-types/snmp"); const { MongodbMonitorType } = require("./monitor-types/mongodb"); diff --git a/server/util-server.js b/server/util-server.js index 5ebc62ac5..f4a4a67fe 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -12,7 +12,6 @@ const { Client } = require("pg"); const postgresConParse = require("pg-connection-string").parse; const mysql = require("mysql2"); const { NtlmClient } = require("./modules/axios-ntlm/lib/ntlmClient.js"); -const { Settings } = require("./settings"); const grpc = require("@grpc/grpc-js"); const protojs = require("protobufjs"); const radiusClient = require("node-radius-client"); @@ -521,46 +520,6 @@ exports.redisPingAsync = function (dsn, rejectUnauthorized) { }); }; -/** - * Retrieve value of setting based on key - * @param {string} key Key of setting to retrieve - * @returns {Promise} Value - * @deprecated Use await Settings.get(key) - */ -exports.setting = async function (key) { - return await Settings.get(key); -}; - -/** - * Sets the specified setting to specified value - * @param {string} key Key of setting to set - * @param {any} value Value to set to - * @param {?string} type Type of setting - * @returns {Promise} - */ -exports.setSetting = async function (key, value, type = null) { - await Settings.set(key, value, type); -}; - -/** - * Get settings based on type - * @param {string} type The type of setting - * @returns {Promise} Settings of requested type - */ -exports.getSettings = async function (type) { - return await Settings.getSettings(type); -}; - -/** - * Set settings based on type - * @param {string} type Type of settings to set - * @param {object} data Values of settings - * @returns {Promise} - */ -exports.setSettings = async function (type, data) { - await Settings.setSettings(type, data); -}; - // ssl-checker by @dyaa //https://github.com/dyaa/ssl-checker/blob/master/src/index.ts diff --git a/src/assets/app.scss b/src/assets/app.scss index c7e56ba74..28eeca87c 100644 --- a/src/assets/app.scss +++ b/src/assets/app.scss @@ -576,6 +576,12 @@ optgroup { outline: none !important; } +.prism-editor__container { + .important { + font-weight: var(--bs-body-font-weight) !important; + } +} + h5.settings-subheading::after { content: ""; display: block; diff --git a/src/components/EditMonitorCondition.vue b/src/components/EditMonitorCondition.vue new file mode 100644 index 000000000..ac1b02dd2 --- /dev/null +++ b/src/components/EditMonitorCondition.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/components/EditMonitorConditionGroup.vue b/src/components/EditMonitorConditionGroup.vue new file mode 100644 index 000000000..910b41508 --- /dev/null +++ b/src/components/EditMonitorConditionGroup.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/src/components/EditMonitorConditions.vue b/src/components/EditMonitorConditions.vue new file mode 100644 index 000000000..60f7c6589 --- /dev/null +++ b/src/components/EditMonitorConditions.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index fc044fe54..96a62cf61 100644 --- a/src/components/HeartbeatBar.vue +++ b/src/components/HeartbeatBar.vue @@ -14,7 +14,7 @@ v-if="!$root.isMobile && size !== 'small' && beatList.length > 4 && $root.styleElapsedTime !== 'none'" class="d-flex justify-content-between align-items-center word" :style="timeStyle" > -
{{ timeSinceFirstBeat }} ago
+
{{ timeSinceFirstBeat }}
{{ timeSinceLastBeat }}
@@ -184,11 +184,11 @@ export default { } if (seconds < tolerance) { - return "now"; + return this.$t("now"); } else if (seconds < 60 * 60) { - return (seconds / 60).toFixed(0) + "m ago"; + return this.$t("time ago", [ (seconds / 60).toFixed(0) + "m" ]); } else { - return (seconds / 60 / 60).toFixed(0) + "h ago"; + return this.$t("time ago", [ (seconds / 60 / 60).toFixed(0) + "h" ]); } } }, diff --git a/src/components/MonitorList.vue b/src/components/MonitorList.vue index b9d42048b..a579316b3 100644 --- a/src/components/MonitorList.vue +++ b/src/components/MonitorList.vue @@ -45,7 +45,7 @@ -
+
{{ $t("No Monitors, please") }} {{ $t("add one") }}
diff --git a/src/components/MonitorListItem.vue b/src/components/MonitorListItem.vue index 9b45ae9f2..74ba4835c 100644 --- a/src/components/MonitorListItem.vue +++ b/src/components/MonitorListItem.vue @@ -43,12 +43,15 @@
diff --git a/src/components/NotificationDialog.vue b/src/components/NotificationDialog.vue index 427366619..864cbf5f4 100644 --- a/src/components/NotificationDialog.vue +++ b/src/components/NotificationDialog.vue @@ -135,6 +135,7 @@ export default { "ntfy": "Ntfy", "octopush": "Octopush", "OneBot": "OneBot", + "Onesender": "Onesender", "Opsgenie": "Opsgenie", "PagerDuty": "PagerDuty", "PagerTree": "PagerTree", @@ -144,6 +145,7 @@ export default { "pushy": "Pushy", "rocket.chat": "Rocket.Chat", "signal": "Signal", + "SIGNL4": "SIGNL4", "slack": "Slack", "squadcast": "SquadCast", "SMSEagle": "SMSEagle", @@ -178,6 +180,7 @@ export default { "WeCom": "WeCom (企业微信群机器人)", "ServerChan": "ServerChan (Server酱)", "smsc": "SMSC", + "WPush": "WPush(wpush.cn)", }; // Sort by notification name diff --git a/src/components/PublicGroupList.vue b/src/components/PublicGroupList.vue index d1c1f4c52..c5d7d4500 100644 --- a/src/components/PublicGroupList.vue +++ b/src/components/PublicGroupList.vue @@ -7,12 +7,12 @@ :animation="100" >