diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 123cd049..ff47b90b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -196,14 +196,13 @@ https://github.com/louislam/uptime-kuma/issues?q=sort%3Aupdated-desc ### Release Procedures 1. Draft a release note -1. Make sure the repo is cleared -1. `npm run update-version 1.X.X` -1. `npm run build` -1. `npm run build-docker` -1. `git push` -1. Publish the release note as 1.X.X -1. `npm run upload-artifacts` with env vars VERSION=1.X.X;GITHUB_TOKEN=XXXX -1. SSH to demo site server and update to 1.X.X +2. Make sure the repo is cleared +3. `npm run release-final with env vars: `VERSION` and `GITHUB_TOKEN` +4. Wait until the `Press any key to continue` +5. `git push` +6. Publish the release note as 1.X.X +7. Press any key to continue +8. SSH to demo site server and update to 1.X.X Checking: @@ -211,6 +210,15 @@ Checking: - Try the Docker image with tag 1.X.X (Clean install / amd64 / arm64 / armv7) - Try clean installation with Node.js +### Release Beta Procedures + +1. Draft a release note, check "This is a pre-release" +2. Make sure the repo is cleared +3. `npm run release-beta` with env vars: `VERSION` and `GITHUB_TOKEN` +4. Wait until the `Press any key to continue` +5. Publish the release note as 1.X.X-beta.X +6. Press any key to continue + ### Release Wiki #### Setup Repo diff --git a/README.md b/README.md index 9e170761..3d32ac8c 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,14 @@ npm run setup node server/server.js # (Recommended) Option 2. Run in background using PM2 -# Install PM2 if you don't have it: npm install pm2 -g +# Install PM2 if you don't have it: +npm install pm2 -g && pm2 install pm2-logrotate + +# Start Server pm2 start server/server.js --name uptime-kuma + +# If you want to see the current console output +pm2 monit ``` Browse to http://localhost:3001 after starting. diff --git a/db/patch-status-page.sql b/db/patch-status-page.sql new file mode 100644 index 00000000..d23b75bc --- /dev/null +++ b/db/patch-status-page.sql @@ -0,0 +1,31 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +CREATE TABLE [status_page]( + [id] INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + [slug] VARCHAR(255) NOT NULL UNIQUE, + [title] VARCHAR(255) NOT NULL, + [description] TEXT, + [icon] VARCHAR(255) NOT NULL, + [theme] VARCHAR(30) NOT NULL, + [published] BOOLEAN NOT NULL DEFAULT 1, + [search_engine_index] BOOLEAN NOT NULL DEFAULT 1, + [show_tags] BOOLEAN NOT NULL DEFAULT 0, + [password] VARCHAR, + [created_date] DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + [modified_date] DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX [slug] ON [status_page]([slug]); + + +CREATE TABLE [status_page_cname]( + [id] INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + [status_page_id] INTEGER NOT NULL REFERENCES [status_page]([id]) ON DELETE CASCADE ON UPDATE CASCADE, + [domain] VARCHAR NOT NULL UNIQUE +); + +ALTER TABLE incident ADD status_page_id INTEGER; +ALTER TABLE [group] ADD status_page_id INTEGER; + +COMMIT; diff --git a/docker/alpine-base.dockerfile b/docker/alpine-base.dockerfile index 66a02686..a23c8108 100644 --- a/docker/alpine-base.dockerfile +++ b/docker/alpine-base.dockerfile @@ -1,5 +1,5 @@ # DON'T UPDATE TO alpine3.13, 1.14, see #41. -FROM node:14-alpine3.12 +FROM node:16-alpine3.12 WORKDIR /app # Install apprise, iputils for non-root ping, setpriv diff --git a/docker/debian-base.dockerfile b/docker/debian-base.dockerfile index a74ac0b2..62889dc9 100644 --- a/docker/debian-base.dockerfile +++ b/docker/debian-base.dockerfile @@ -1,8 +1,11 @@ # DON'T UPDATE TO node:14-bullseye-slim, see #372. # If the image changed, the second stage image should be changed too -FROM node:14-buster-slim +FROM node:16-buster-slim +ARG TARGETPLATFORM + WORKDIR /app +# Install Curl # Install Apprise, add sqlite3 cli for debugging in the future, iputils-ping for ping, util-linux for setpriv # Stupid python3 and python3-pip actually install a lot of useless things into Debian, specify --no-install-recommends to skip them, make the base even smaller than alpine! RUN apt update && \ @@ -10,3 +13,14 @@ RUN apt update && \ sqlite3 iputils-ping util-linux dumb-init && \ pip3 --no-cache-dir install apprise==0.9.7 && \ rm -rf /var/lib/apt/lists/* + +# Install cloudflared +# dpkg --add-architecture arm: cloudflared do not provide armhf, this is workaround. Read more: https://github.com/cloudflare/cloudflared/issues/583 +COPY extra/download-cloudflared.js ./extra/download-cloudflared.js +RUN node ./extra/download-cloudflared.js $TARGETPLATFORM && \ + dpkg --add-architecture arm && \ + apt update && \ + apt --yes --no-install-recommends install ./cloudflared.deb && \ + rm -rf /var/lib/apt/lists/* && \ + rm -f cloudflared.deb + diff --git a/extra/beta/update-version.js b/extra/beta/update-version.js new file mode 100644 index 00000000..aa75562d --- /dev/null +++ b/extra/beta/update-version.js @@ -0,0 +1,76 @@ +const pkg = require("../../package.json"); +const fs = require("fs"); +const child_process = require("child_process"); +const util = require("../../src/util"); + +util.polyfill(); + +const oldVersion = pkg.version; +const version = process.env.VERSION; + +console.log("Beta Version: " + version); + +if (!oldVersion || oldVersion.includes("-beta.")) { + console.error("Error: old version should not be a beta version?"); + process.exit(1); +} + +if (!version || !version.includes("-beta.")) { + console.error("invalid version, beta version only"); + process.exit(1); +} + +const exists = tagExists(version); + +if (! exists) { + // Process package.json + pkg.version = version; + fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n"); + commit(version); + tag(version); + +} else { + console.log("version tag exists, please delete the tag or use another tag"); + process.exit(1); +} + +function commit(version) { + let msg = "Update to " + version; + + let res = child_process.spawnSync("git", ["commit", "-m", msg, "-a"]); + let stdout = res.stdout.toString().trim(); + console.log(stdout); + + if (stdout.includes("no changes added to commit")) { + throw new Error("commit error"); + } + + res = child_process.spawnSync("git", ["push", "origin", "master"]); + console.log(res.stdout.toString().trim()); +} + +function tag(version) { + let res = child_process.spawnSync("git", ["tag", version]); + console.log(res.stdout.toString().trim()); + + res = child_process.spawnSync("git", ["push", "origin", version]); + console.log(res.stdout.toString().trim()); +} + +function tagExists(version) { + if (! version) { + throw new Error("invalid version"); + } + + let res = child_process.spawnSync("git", ["tag", "-l", version]); + + return res.stdout.toString().trim() === version; +} + +function safeDelete(dir) { + if (fs.existsSync(dir)) { + fs.rmdirSync(dir, { + recursive: true, + }); + } +} diff --git a/extra/download-cloudflared.js b/extra/download-cloudflared.js new file mode 100644 index 00000000..41519b7c --- /dev/null +++ b/extra/download-cloudflared.js @@ -0,0 +1,44 @@ +// + +const http = require("https"); // or 'https' for https:// URLs +const fs = require("fs"); + +const platform = process.argv[2]; + +if (!platform) { + console.error("No platform??"); + process.exit(1); +} + +let arch = null; + +if (platform === "linux/amd64") { + arch = "amd64"; +} else if (platform === "linux/arm64") { + arch = "arm64"; +} else if (platform === "linux/arm/v7") { + arch = "arm"; +} else { + console.error("Invalid platform?? " + platform); +} + +const file = fs.createWriteStream("cloudflared.deb"); +get("https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-" + arch + ".deb"); + +function get(url) { + http.get(url, function (res) { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + console.log("Redirect to " + res.headers.location); + get(res.headers.location); + } else if (res.statusCode >= 200 && res.statusCode < 300) { + res.pipe(file); + + res.on("end", function () { + console.log("Downloaded"); + }); + } else { + console.error(res.statusCode); + process.exit(1); + } + }); +} diff --git a/extra/env2arg.js b/extra/env2arg.js new file mode 100644 index 00000000..f89a91e4 --- /dev/null +++ b/extra/env2arg.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +const childProcess = require("child_process"); +let env = process.env; + +let cmd = process.argv[2]; +let args = process.argv.slice(3); +let replacedArgs = []; + +for (let arg of args) { + for (let key in env) { + arg = arg.replaceAll(`$${key}`, env[key]); + } + replacedArgs.push(arg); +} + +let child = childProcess.spawn(cmd, replacedArgs); +child.stdout.pipe(process.stdout); +child.stderr.pipe(process.stderr); diff --git a/extra/install.batsh b/extra/install.batsh index bca0b095..65e95cd0 100644 --- a/extra/install.batsh +++ b/extra/install.batsh @@ -189,7 +189,7 @@ if (type == "local") { bash("check=$(pm2 --version)"); if (check == "") { println("Installing PM2"); - bash("npm install pm2 -g"); + bash("npm install pm2 -g && pm2 install pm2-logrotate"); bash("pm2 startup"); } diff --git a/extra/press-any-key.js b/extra/press-any-key.js new file mode 100644 index 00000000..42fc363c --- /dev/null +++ b/extra/press-any-key.js @@ -0,0 +1,6 @@ +console.log("Git Push and Publish the release note on github, then press any key to continue"); + +process.stdin.setRawMode(true); +process.stdin.resume(); +process.stdin.on("data", process.exit.bind(process, 0)); + diff --git a/extra/update-version.js b/extra/update-version.js index 2e3b42da..8f3562a5 100644 --- a/extra/update-version.js +++ b/extra/update-version.js @@ -5,10 +5,8 @@ const util = require("../src/util"); util.polyfill(); -const oldVersion = pkg.version; -const newVersion = process.argv[2]; +const newVersion = process.env.VERSION; -console.log("Old Version: " + oldVersion); console.log("New Version: " + newVersion); if (! newVersion) { @@ -22,23 +20,20 @@ if (! exists) { // Process package.json pkg.version = newVersion; - pkg.scripts.setup = pkg.scripts.setup.replaceAll(oldVersion, newVersion); - pkg.scripts["build-docker"] = pkg.scripts["build-docker"].replaceAll(oldVersion, newVersion); - pkg.scripts["build-docker-alpine"] = pkg.scripts["build-docker-alpine"].replaceAll(oldVersion, newVersion); - pkg.scripts["build-docker-debian"] = pkg.scripts["build-docker-debian"].replaceAll(oldVersion, newVersion); + + // Replace the version: https://regex101.com/r/hmj2Bc/1 + pkg.scripts.setup = pkg.scripts.setup.replace(/(git checkout )([^\s]+)/, `$1${newVersion}`); fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n"); commit(newVersion); tag(newVersion); - updateWiki(oldVersion, newVersion); - } else { console.log("version exists"); } function commit(version) { - let msg = "update to " + version; + let msg = "Update to " + version; let res = child_process.spawnSync("git", ["commit", "-m", msg, "-a"]); let stdout = res.stdout.toString().trim(); @@ -64,37 +59,3 @@ function tagExists(version) { return res.stdout.toString().trim() === version; } -function updateWiki(oldVersion, newVersion) { - const wikiDir = "./tmp/wiki"; - const howToUpdateFilename = "./tmp/wiki/🆙-How-to-Update.md"; - - safeDelete(wikiDir); - - child_process.spawnSync("git", ["clone", "https://github.com/louislam/uptime-kuma.wiki.git", wikiDir]); - let content = fs.readFileSync(howToUpdateFilename).toString(); - content = content.replaceAll(`git checkout ${oldVersion}`, `git checkout ${newVersion}`); - fs.writeFileSync(howToUpdateFilename, content); - - child_process.spawnSync("git", ["add", "-A"], { - cwd: wikiDir, - }); - - child_process.spawnSync("git", ["commit", "-m", `Update to ${newVersion} from ${oldVersion}`], { - cwd: wikiDir, - }); - - console.log("Pushing to Github"); - child_process.spawnSync("git", ["push"], { - cwd: wikiDir, - }); - - safeDelete(wikiDir); -} - -function safeDelete(dir) { - if (fs.existsSync(dir)) { - fs.rmdirSync(dir, { - recursive: true, - }); - } -} diff --git a/extra/update-wiki-version.js b/extra/update-wiki-version.js new file mode 100644 index 00000000..10631c33 --- /dev/null +++ b/extra/update-wiki-version.js @@ -0,0 +1,48 @@ +const child_process = require("child_process"); +const fs = require("fs"); + +const newVersion = process.env.VERSION; + +if (!newVersion) { + console.log("Missing version"); + process.exit(1); +} + +updateWiki(newVersion); + +function updateWiki(newVersion) { + const wikiDir = "./tmp/wiki"; + const howToUpdateFilename = "./tmp/wiki/🆙-How-to-Update.md"; + + safeDelete(wikiDir); + + child_process.spawnSync("git", ["clone", "https://github.com/louislam/uptime-kuma.wiki.git", wikiDir]); + let content = fs.readFileSync(howToUpdateFilename).toString(); + + // Replace the version: https://regex101.com/r/hmj2Bc/1 + content = content.replace(/(git checkout )([^\s]+)/, `$1${newVersion}`); + fs.writeFileSync(howToUpdateFilename, content); + + child_process.spawnSync("git", ["add", "-A"], { + cwd: wikiDir, + }); + + child_process.spawnSync("git", ["commit", "-m", `Update to ${newVersion}`], { + cwd: wikiDir, + }); + + console.log("Pushing to Github"); + child_process.spawnSync("git", ["push"], { + cwd: wikiDir, + }); + + safeDelete(wikiDir); +} + +function safeDelete(dir) { + if (fs.existsSync(dir)) { + fs.rmdirSync(dir, { + recursive: true, + }); + } +} diff --git a/install.sh b/install.sh index 37d67531..27b30688 100644 --- a/install.sh +++ b/install.sh @@ -159,7 +159,7 @@ fi check=$(pm2 --version) if [ "$check" == "" ]; then "echo" "-e" "Installing PM2" - npm install pm2 -g + npm install pm2 -g && pm2 install pm2-logrotate pm2 startup fi mkdir -p $installPath diff --git a/package.json b/package.json index af194e2e..1e29c64e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uptime-kuma", - "version": "1.12.1", + "version": "1.14.0-beta.0", "license": "MIT", "repository": { "type": "git", @@ -30,15 +30,14 @@ "build-docker": "npm run build && npm run build-docker-debian && npm run build-docker-alpine", "build-docker-alpine-base": "docker buildx build -f docker/alpine-base.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:base-alpine . --push", "build-docker-debian-base": "docker buildx build -f docker/debian-base.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:base-debian . --push", - "build-docker-alpine": "docker buildx build -f docker/dockerfile-alpine --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:alpine -t louislam/uptime-kuma:1-alpine -t louislam/uptime-kuma:1.12.1-alpine --target release . --push", - "build-docker-debian": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:1.12.1 -t louislam/uptime-kuma:debian -t louislam/uptime-kuma:1-debian -t louislam/uptime-kuma:1.12.1-debian --target release . --push", + "build-docker-alpine": "node ./extra/env2arg.js docker buildx build -f docker/dockerfile-alpine --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:alpine -t louislam/uptime-kuma:1-alpine -t louislam/uptime-kuma:$VERSION-alpine --target release . --push", + "build-docker-debian": "node ./extra/env2arg.js docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:$VERSION -t louislam/uptime-kuma:debian -t louislam/uptime-kuma:1-debian -t louislam/uptime-kuma:$VERSION-debian --target release . --push", "build-docker-nightly": "npm run build && docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly --target nightly . --push", "build-docker-nightly-alpine": "docker buildx build -f docker/dockerfile-alpine --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly-alpine --target nightly . --push", "build-docker-nightly-amd64": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain", "upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain", - "setup": "git checkout 1.12.1 && npm ci --production && npm run download-dist", + "setup": "git checkout 1.13.1 && npm ci --production && npm run download-dist", "download-dist": "node extra/download-dist.js", - "update-version": "node extra/update-version.js", "mark-as-nightly": "node extra/mark-as-nightly.js", "reset-password": "node extra/reset-password.js", "remove-2fa": "node extra/remove-2fa.js", @@ -51,7 +50,10 @@ "simple-dns-server": "node extra/simple-dns-server.js", "update-language-files-with-base-lang": "cd extra/update-language-files && node index.js %npm_config_base_lang% && eslint ../../src/languages/**.js --fix", "update-language-files": "cd extra/update-language-files && node index.js && eslint ../../src/languages/**.js --fix", - "ncu-patch": "ncu -u -t patch" + "ncu-patch": "npm-check-updates -u -t patch", + "release-final": "node extra/update-version.js && npm run build-docker && node ./extra/press-any-key.js && npm run upload-artifacts && node ./extra/update-wiki-version.js", + "release-beta": "node extra/beta/update-version.js && npm run build && node ./extra/env2arg.js docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:$VERSION -t louislam/uptime-kuma:beta . --target release --push && node ./extra/press-any-key.js && npm run upload-artifacts", + "git-remove-tag": "git tag -d" }, "dependencies": { "@fortawesome/fontawesome-svg-core": "~1.2.36", @@ -61,34 +63,36 @@ "@louislam/sqlite3": "~6.0.1", "@popperjs/core": "~2.10.2", "args-parser": "~1.3.0", - "axios": "~0.26.0", + "axios": "~0.26.1", "bcryptjs": "~2.4.3", "bootstrap": "5.1.3", - "bree": "~7.1.0", + "bree": "~7.1.5", "chardet": "^1.3.0", - "chart.js": "~3.6.0", + "chart.js": "~3.6.2", "chartjs-adapter-dayjs": "~1.0.0", - "check-password-strength": "^2.0.3", + "check-password-strength": "^2.0.5", "command-exists": "~1.2.9", "compare-versions": "~3.6.0", - "dayjs": "~1.10.7", - "express": "~4.17.1", - "express-basic-auth": "~1.2.0", + "dayjs": "~1.10.8", + "express": "~4.17.3", + "express-basic-auth": "~1.2.1", + "favico.js": "^0.3.10", "form-data": "~4.0.0", - "http-graceful-shutdown": "~3.1.5", + "http-graceful-shutdown": "~3.1.7", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "iconv-lite": "^0.6.3", "jsonwebtoken": "~8.5.1", "jwt-decode": "^3.1.2", "limiter": "^2.1.0", + "node-cloudflared-tunnel": "~1.0.9", "nodemailer": "~6.6.5", "notp": "~2.0.3", "password-hash": "~1.2.2", "postcss-rtlcss": "~3.4.1", - "postcss-scss": "~4.0.2", + "postcss-scss": "~4.0.3", "prom-client": "~13.2.0", - "prometheus-api-metrics": "~3.2.0", + "prometheus-api-metrics": "~3.2.1", "qrcode": "~1.5.0", "redbean-node": "0.1.3", "socket.io": "~4.4.1", @@ -107,7 +111,7 @@ "vue-image-crop-upload": "~3.0.3", "vue-multiselect": "~3.0.0-alpha.2", "vue-qrcode": "~1.0.0", - "vue-router": "~4.0.12", + "vue-router": "~4.0.14", "vue-toastification": "~2.0.0-rc.5", "vuedraggable": "~4.1.0" }, @@ -115,10 +119,10 @@ "@actions/github": "~5.0.0", "@babel/eslint-parser": "~7.15.8", "@babel/preset-env": "^7.15.8", - "@types/bootstrap": "~5.1.6", - "@vitejs/plugin-legacy": "~1.6.3", + "@types/bootstrap": "~5.1.9", + "@vitejs/plugin-legacy": "~1.6.4", "@vitejs/plugin-vue": "~1.9.4", - "@vue/compiler-sfc": "~3.2.22", + "@vue/compiler-sfc": "~3.2.31", "babel-plugin-rewire": "~1.2.0", "core-js": "~3.18.3", "cross-env": "~7.0.3", @@ -126,7 +130,8 @@ "eslint": "~7.32.0", "eslint-plugin-vue": "~7.18.0", "jest": "~27.2.5", - "jest-puppeteer": "~6.0.0", + "jest-puppeteer": "~6.0.3", + "npm-check-updates": "^12.5.4", "puppeteer": "~13.1.3", "sass": "~1.42.1", "stylelint": "~14.2.0", diff --git a/server/auth.js b/server/auth.js index c476ea1e..c59d6549 100644 --- a/server/auth.js +++ b/server/auth.js @@ -12,6 +12,10 @@ const { loginRateLimiter } = require("./rate-limiter"); * @returns {Promise} */ exports.login = async function (username, password) { + if (typeof username !== "string" || typeof password !== "string") { + return null; + } + let user = await R.findOne("user", " username = ? AND active = 1 ", [ username, ]); @@ -31,31 +35,34 @@ exports.login = async function (username, password) { }; function myAuthorizer(username, password, callback) { - setting("disableAuth").then((result) => { - if (result) { - callback(null, true); - } else { - // Login Rate Limit - loginRateLimiter.pass(null, 0).then((pass) => { - if (pass) { - exports.login(username, password).then((user) => { - callback(null, user != null); - - if (user == null) { - loginRateLimiter.removeTokens(1); - } - }); - } else { - callback(null, false); + // Login Rate Limit + loginRateLimiter.pass(null, 0).then((pass) => { + if (pass) { + exports.login(username, password).then((user) => { + callback(null, user != null); + + if (user == null) { + loginRateLimiter.removeTokens(1); } }); - + } else { + callback(null, false); } }); } -exports.basicAuth = basicAuth({ - authorizer: myAuthorizer, - authorizeAsync: true, - challenge: true, -}); +exports.basicAuth = async function (req, res, next) { + const middleware = basicAuth({ + authorizer: myAuthorizer, + authorizeAsync: true, + challenge: true, + }); + + const disabledAuth = await setting("disableAuth"); + + if (!disabledAuth) { + middleware(req, res, next); + } else { + next(); + } +}; diff --git a/server/check-version.js b/server/check-version.js index a3465ddf..f3b15e84 100644 --- a/server/check-version.js +++ b/server/check-version.js @@ -1,5 +1,6 @@ -const { setSetting } = require("./util-server"); +const { setSetting, setting } = require("./util-server"); const axios = require("axios"); +const compareVersions = require("compare-versions"); exports.version = require("../package.json").version; exports.latestVersion = null; @@ -16,6 +17,19 @@ exports.startInterval = () => { res.data.slow = "1000.0.0"; } + if (!await setting("checkUpdate")) { + return; + } + + let checkBeta = await setting("checkBeta"); + + if (checkBeta && res.data.beta) { + if (compareVersions.compare(res.data.beta, res.data.beta, ">")) { + exports.latestVersion = res.data.beta; + return; + } + } + if (res.data.slow) { exports.latestVersion = res.data.slow; } diff --git a/server/database.js b/server/database.js index a7f7ae7d..26277e8d 100644 --- a/server/database.js +++ b/server/database.js @@ -53,6 +53,7 @@ class Database { "patch-2fa-invalidate-used-token.sql": true, "patch-notification_sent_history.sql": true, "patch-monitor-basic-auth.sql": true, + "patch-status-page.sql": true, "patch-proxy.sql": true, } @@ -171,6 +172,7 @@ class Database { } await this.patch2(); + await this.migrateNewStatusPage(); } /** @@ -212,6 +214,74 @@ class Database { await setSetting("databasePatchedFiles", databasePatchedFiles); } + /** + * Migrate status page value in setting to "status_page" table + * @returns {Promise} + */ + static async migrateNewStatusPage() { + + // 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"); + + if (title) { + console.log("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"); + 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.password = null; + + if (!statusPage.title) { + statusPage.title = "My Status Page"; + } + + if (!statusPage.icon) { + statusPage.icon = ""; + } + + if (!statusPage.theme) { + statusPage.theme = "light"; + } + + let id = await R.store(statusPage); + + await R.exec("UPDATE incident SET status_page_id = ? WHERE status_page_id IS NULL", [ + id + ]); + + await R.exec("UPDATE [group] SET status_page_id = ? WHERE status_page_id IS NULL", [ + id + ]); + + await R.exec("DELETE FROM setting WHERE type = 'statusPage'"); + + // Migrate Entry Page if it is status page + let entryPage = await setting("entryPage"); + + if (entryPage === "statusPage") { + await setSetting("entryPage", "statusPage-default", "general"); + } + + console.log("Migrating Status Page - Done"); + } + + } + /** * Used it patch2() only * @param sqlFilename diff --git a/server/model/group.js b/server/model/group.js index 567f3865..3e1f2d44 100644 --- a/server/model/group.js +++ b/server/model/group.js @@ -3,12 +3,12 @@ const { R } = require("redbean-node"); class Group extends BeanModel { - async toPublicJSON() { + async toPublicJSON(showTags = false) { let monitorBeanList = await this.getMonitorList(); let monitorList = []; for (let bean of monitorBeanList) { - monitorList.push(await bean.toPublicJSON()); + monitorList.push(await bean.toPublicJSON(showTags)); } return { diff --git a/server/model/monitor.js b/server/model/monitor.js index f9c025d1..10efc1a4 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -25,18 +25,22 @@ const apicache = require("../modules/apicache"); class Monitor extends BeanModel { /** - * Return a object that ready to parse to JSON for public + * Return an object that ready to parse to JSON for public * Only show necessary data to public */ - async toPublicJSON() { - return { + async toPublicJSON(showTags = false) { + let obj = { id: this.id, name: this.name, }; + if (showTags) { + obj.tags = await this.getTags(); + } + return obj; } /** - * Return a object that ready to parse to JSON + * Return an object that ready to parse to JSON */ async toJSON() { @@ -50,7 +54,7 @@ class Monitor extends BeanModel { notificationIDList[bean.notification_id] = true; } - const tags = await R.getAll("SELECT mt.*, tag.name, tag.color FROM monitor_tag mt JOIN tag ON mt.tag_id = tag.id WHERE mt.monitor_id = ?", [this.id]); + const tags = await this.getTags(); return { id: this.id, @@ -84,6 +88,10 @@ class Monitor extends BeanModel { }; } + async getTags() { + return await R.getAll("SELECT mt.*, tag.name, tag.color FROM monitor_tag mt JOIN tag ON mt.tag_id = tag.id WHERE mt.monitor_id = ?", [this.id]); + } + /** * Encode user and password to Base64 encoding * for HTTP "basic" auth, as per RFC-7617 @@ -492,6 +500,12 @@ class Monitor extends BeanModel { stop() { clearTimeout(this.heartbeatInterval); this.isStop = true; + + this.prometheus().remove(); + } + + prometheus() { + return new Prometheus(this); } /** diff --git a/server/model/status_page.js b/server/model/status_page.js new file mode 100644 index 00000000..6f763f58 --- /dev/null +++ b/server/model/status_page.js @@ -0,0 +1,60 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); +const { R } = require("redbean-node"); + +class StatusPage extends BeanModel { + + static async sendStatusPageList(io, socket) { + let result = {}; + + let list = await R.findAll("status_page", " ORDER BY title "); + + for (let item of list) { + result[item.id] = await item.toJSON(); + } + + io.to(socket.userID).emit("statusPageList", result); + return list; + } + + async toJSON() { + return { + id: this.id, + slug: this.slug, + title: this.title, + description: this.description, + icon: this.getIcon(), + theme: this.theme, + published: !!this.published, + showTags: !!this.show_tags, + }; + } + + async toPublicJSON() { + return { + slug: this.slug, + title: this.title, + description: this.description, + icon: this.getIcon(), + theme: this.theme, + published: !!this.published, + showTags: !!this.show_tags, + }; + } + + static async slugToID(slug) { + return await R.getCell("SELECT id FROM status_page WHERE slug = ? ", [ + slug + ]); + } + + getIcon() { + if (!this.icon) { + return "/icon.svg"; + } else { + return this.icon; + } + } + +} + +module.exports = StatusPage; diff --git a/server/notification-providers/pushover.js b/server/notification-providers/pushover.js index 52d13eef..ebcb88c4 100644 --- a/server/notification-providers/pushover.js +++ b/server/notification-providers/pushover.js @@ -9,36 +9,31 @@ class Pushover extends NotificationProvider { let okMsg = "Sent Successfully."; let pushoverlink = "https://api.pushover.net/1/messages.json"; + let data = { + "message": "Uptime Kuma Alert\n\nMessage:" + msg, + "user": notification.pushoveruserkey, + "token": notification.pushoverapptoken, + "sound": notification.pushoversounds, + "priority": notification.pushoverpriority, + "title": notification.pushovertitle, + "retry": "30", + "expire": "3600", + "html": 1, + }; + + if (notification.pushoverdevice) { + data.device = notification.pushoverdevice; + } + try { if (heartbeatJSON == null) { - let data = { - "message": msg, - "user": notification.pushoveruserkey, - "token": notification.pushoverapptoken, - "sound": notification.pushoversounds, - "priority": notification.pushoverpriority, - "title": notification.pushovertitle, - "retry": "30", - "expire": "3600", - "html": 1, - }; + await axios.post(pushoverlink, data); + return okMsg; + } else { + data.message += "\nTime (UTC):" + heartbeatJSON["time"]; await axios.post(pushoverlink, data); return okMsg; } - - let data = { - "message": "Uptime Kuma Alert\n\nMessage:" + msg + "\nTime (UTC):" + heartbeatJSON["time"], - "user": notification.pushoveruserkey, - "token": notification.pushoverapptoken, - "sound": notification.pushoversounds, - "priority": notification.pushoverpriority, - "title": notification.pushovertitle, - "retry": "30", - "expire": "3600", - "html": 1, - }; - await axios.post(pushoverlink, data); - return okMsg; } catch (error) { this.throwGeneralAxiosError(error); } diff --git a/server/prometheus.js b/server/prometheus.js index f91b0a13..86ad381b 100644 --- a/server/prometheus.js +++ b/server/prometheus.js @@ -86,6 +86,16 @@ class Prometheus { } } + remove() { + try { + monitor_cert_days_remaining.remove(this.monitorLabelValues); + monitor_cert_is_valid.remove(this.monitorLabelValues); + monitor_response_time.remove(this.monitorLabelValues); + monitor_status.remove(this.monitorLabelValues); + } catch (e) { + console.error(e); + } + } } module.exports = { diff --git a/server/rate-limiter.js b/server/rate-limiter.js index 0bacc14c..6422af8d 100644 --- a/server/rate-limiter.js +++ b/server/rate-limiter.js @@ -34,6 +34,14 @@ const loginRateLimiter = new KumaRateLimiter({ errorMessage: "Too frequently, try again later." }); +const twoFaRateLimiter = new KumaRateLimiter({ + tokensPerInterval: 30, + interval: "minute", + fireImmediately: true, + errorMessage: "Too frequently, try again later." +}); + module.exports = { - loginRateLimiter + loginRateLimiter, + twoFaRateLimiter, }; diff --git a/server/routers/api-router.js b/server/routers/api-router.js index 1920cef7..ad887084 100644 --- a/server/routers/api-router.js +++ b/server/routers/api-router.js @@ -6,6 +6,7 @@ const apicache = require("../modules/apicache"); const Monitor = require("../model/monitor"); const dayjs = require("dayjs"); const { UP, flipStatus, debug } = require("../../src/util"); +const StatusPage = require("../model/status_page"); let router = express.Router(); let cache = apicache.middleware; @@ -82,110 +83,80 @@ router.get("/api/push/:pushToken", async (request, response) => { } }); -// Status Page Config -router.get("/api/status-page/config", async (_request, response) => { +// Status page config, incident, monitor list +router.get("/api/status-page/:slug", cache("5 minutes"), async (request, response) => { allowDevAllOrigin(response); + let slug = request.params.slug; - let config = await getSettings("statusPage"); + // Get Status Page + let statusPage = await R.findOne("status_page", " slug = ? ", [ + slug + ]); - if (! config.statusPageTheme) { - config.statusPageTheme = "light"; - } - - if (! config.statusPagePublished) { - config.statusPagePublished = true; - } - - if (! config.statusPageTags) { - config.statusPageTags = false; - } - - if (! config.title) { - config.title = "Uptime Kuma"; + if (!statusPage) { + response.statusCode = 404; + response.json({ + msg: "Not Found" + }); + return; } - response.json(config); -}); - -// Status Page - Get the current Incident -// Can fetch only if published -router.get("/api/status-page/incident", async (_, response) => { - allowDevAllOrigin(response); - try { - await checkPublished(); - - let incident = await R.findOne("incident", " pin = 1 AND active = 1"); + // Incident + let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [ + statusPage.id, + ]); if (incident) { incident = incident.toPublicJSON(); } - response.json({ - ok: true, - incident, - }); - - } catch (error) { - send403(response, error.message); - } -}); - -// Status Page - Monitor List -// Can fetch only if published -router.get("/api/status-page/monitor-list", cache("5 minutes"), async (_request, response) => { - allowDevAllOrigin(response); - - try { - await checkPublished(); + // Public Group List const publicGroupList = []; - const tagsVisible = (await getSettings("statusPage")).statusPageTags; - const list = await R.find("group", " public = 1 ORDER BY weight "); - for (let groupBean of list) { - let monitorGroup = await groupBean.toPublicJSON(); - if (tagsVisible) { - monitorGroup.monitorList = await Promise.all(monitorGroup.monitorList.map(async (monitor) => { - // Includes tags as an array in response, allows for tags to be displayed on public status page - const tags = await R.getAll( - `SELECT monitor_tag.monitor_id, monitor_tag.value, tag.name, tag.color - FROM monitor_tag - JOIN tag - ON monitor_tag.tag_id = tag.id - WHERE monitor_tag.monitor_id = ?`, [monitor.id] - ); - return { - ...monitor, - tags: tags - }; - })); - } + const showTags = !!statusPage.show_tags; + debug("Show Tags???" + showTags); + const list = await R.find("group", " public = 1 AND status_page_id = ? ORDER BY weight ", [ + statusPage.id + ]); + for (let groupBean of list) { + let monitorGroup = await groupBean.toPublicJSON(showTags); publicGroupList.push(monitorGroup); } - response.json(publicGroupList); + // Response + response.json({ + config: await statusPage.toPublicJSON(), + incident, + publicGroupList + }); } catch (error) { send403(response, error.message); } + }); // Status Page Polling Data // Can fetch only if published -router.get("/api/status-page/heartbeat", cache("5 minutes"), async (_request, response) => { +router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (request, response) => { allowDevAllOrigin(response); try { - await checkPublished(); - let heartbeatList = {}; let uptimeList = {}; + let slug = request.params.slug; + let statusPageID = await StatusPage.slugToID(slug); + let monitorIDList = await R.getCol(` SELECT monitor_group.monitor_id FROM monitor_group, \`group\` WHERE monitor_group.group_id = \`group\`.id AND public = 1 - `); + AND \`group\`.status_page_id = ? + `, [ + statusPageID + ]); for (let monitorID of monitorIDList) { let list = await R.getAll(` @@ -214,22 +185,12 @@ router.get("/api/status-page/heartbeat", cache("5 minutes"), async (_request, re } }); -async function checkPublished() { - if (! await isPublished()) { - throw new Error("The status page is not published"); - } -} - /** * Default is published * @returns {Promise} */ async function isPublished() { - const value = await setting("statusPagePublished"); - if (value === null) { - return true; - } - return value; + return true; } function send403(res, msg = "") { diff --git a/server/server.js b/server/server.js index b713e4f7..48be4e2b 100644 --- a/server/server.js +++ b/server/server.js @@ -52,7 +52,7 @@ console.log("Importing this project modules"); debug("Importing Monitor"); const Monitor = require("./model/monitor"); debug("Importing Settings"); -const { getSettings, setSettings, setting, initJWTSecret, checkLogin, startUnitTest, FBSD, errorLog } = require("./util-server"); +const { getSettings, setSettings, setting, initJWTSecret, checkLogin, startUnitTest, FBSD, errorLog, doubleCheckPassword } = require("./util-server"); debug("Importing Notification"); const { Notification } = require("./notification"); @@ -66,7 +66,7 @@ const Database = require("./database"); debug("Importing Background Jobs"); const { initBackgroundJobs } = require("./jobs"); -const { loginRateLimiter } = require("./rate-limiter"); +const { loginRateLimiter, twoFaRateLimiter } = require("./rate-limiter"); const { basicAuth } = require("./auth"); const { login } = require("./auth"); @@ -94,6 +94,7 @@ const port = parseInt(process.env.UPTIME_KUMA_PORT || process.env.PORT || args.p const sslKey = process.env.UPTIME_KUMA_SSL_KEY || process.env.SSL_KEY || args["ssl-key"] || undefined; const sslCert = process.env.UPTIME_KUMA_SSL_CERT || process.env.SSL_CERT || args["ssl-cert"] || undefined; const disableFrameSameOrigin = !!process.env.UPTIME_KUMA_DISABLE_FRAME_SAMEORIGIN || args["disable-frame-sameorigin"] || false; +const cloudflaredToken = args["cloudflared-token"] || process.env.UPTIME_KUMA_CLOUDFLARED_TOKEN || undefined; // 2FA / notp verification defaults const twofa_verification_opts = { @@ -135,6 +136,8 @@ const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList, sen const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler"); const databaseSocketHandler = require("./socket-handlers/database-socket-handler"); const TwoFA = require("./2fa"); +const StatusPage = require("./model/status_page"); +const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart } = require("./socket-handlers/cloudflared-socket-handler"); app.use(express.json()); @@ -203,8 +206,8 @@ exports.entryPage = "dashboard"; // Entry Page app.get("/", async (_request, response) => { - if (exports.entryPage === "statusPage") { - response.redirect("/status"); + if (exports.entryPage && exports.entryPage.startsWith("statusPage-")) { + response.redirect("/status/" + exports.entryPage.replace("statusPage-", "")); } else { response.redirect("/dashboard"); } @@ -307,6 +310,15 @@ exports.entryPage = "dashboard"; socket.on("login", async (data, callback) => { console.log("Login"); + // Checking + if (typeof callback !== "function") { + return; + } + + if (!data) { + return; + } + // Login Rate Limit if (! await loginRateLimiter.pass(callback)) { return; @@ -365,14 +377,27 @@ exports.entryPage = "dashboard"; }); socket.on("logout", async (callback) => { + // Rate Limit + if (! await loginRateLimiter.pass(callback)) { + return; + } + socket.leave(socket.userID); socket.userID = null; - callback(); + + if (typeof callback === "function") { + callback(); + } }); - socket.on("prepare2FA", async (callback) => { + socket.on("prepare2FA", async (currentPassword, callback) => { try { + if (! await twoFaRateLimiter.pass(callback)) { + return; + } + checkLogin(socket); + await doubleCheckPassword(socket, currentPassword); let user = await R.findOne("user", " id = ? AND active = 1 ", [ socket.userID, @@ -407,14 +432,19 @@ exports.entryPage = "dashboard"; } catch (error) { callback({ ok: false, - msg: "Error while trying to prepare 2FA.", + msg: error.message, }); } }); - socket.on("save2FA", async (callback) => { + socket.on("save2FA", async (currentPassword, callback) => { try { + if (! await twoFaRateLimiter.pass(callback)) { + return; + } + checkLogin(socket); + await doubleCheckPassword(socket, currentPassword); await R.exec("UPDATE `user` SET twofa_status = 1 WHERE id = ? ", [ socket.userID, @@ -427,14 +457,19 @@ exports.entryPage = "dashboard"; } catch (error) { callback({ ok: false, - msg: "Error while trying to change 2FA.", + msg: error.message, }); } }); - socket.on("disable2FA", async (callback) => { + socket.on("disable2FA", async (currentPassword, callback) => { try { + if (! await twoFaRateLimiter.pass(callback)) { + return; + } + checkLogin(socket); + await doubleCheckPassword(socket, currentPassword); await TwoFA.disable2FA(socket.userID); callback({ @@ -444,36 +479,47 @@ exports.entryPage = "dashboard"; } catch (error) { callback({ ok: false, - msg: "Error while trying to change 2FA.", + msg: error.message, }); } }); - socket.on("verifyToken", async (token, callback) => { - let user = await R.findOne("user", " id = ? AND active = 1 ", [ - socket.userID, - ]); + socket.on("verifyToken", async (token, currentPassword, callback) => { + try { + checkLogin(socket); + await doubleCheckPassword(socket, currentPassword); - let verify = notp.totp.verify(token, user.twofa_secret, twofa_verification_opts); + let user = await R.findOne("user", " id = ? AND active = 1 ", [ + socket.userID, + ]); - if (user.twofa_last_token !== token && verify) { - callback({ - ok: true, - valid: true, - }); - } else { + let verify = notp.totp.verify(token, user.twofa_secret, twofa_verification_opts); + + if (user.twofa_last_token !== token && verify) { + callback({ + ok: true, + valid: true, + }); + } else { + callback({ + ok: false, + msg: "Invalid Token.", + valid: false, + }); + } + + } catch (error) { callback({ ok: false, - msg: "Invalid Token.", - valid: false, + msg: error.message, }); } }); socket.on("twoFAStatus", async (callback) => { - checkLogin(socket); - try { + checkLogin(socket); + let user = await R.findOne("user", " id = ? AND active = 1 ", [ socket.userID, ]); @@ -490,9 +536,10 @@ exports.entryPage = "dashboard"; }); } } catch (error) { + console.log(error); callback({ ok: false, - msg: "Error while trying to get 2FA status.", + msg: error.message, }); } }); @@ -581,6 +628,9 @@ exports.entryPage = "dashboard"; throw new Error("Permission denied."); } + // Reset Prometheus labels + monitorList[monitor.id]?.prometheus()?.remove(); + bean.name = monitor.name; bean.type = monitor.type; bean.url = monitor.url; @@ -939,21 +989,13 @@ exports.entryPage = "dashboard"; throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length."); } - let user = await R.findOne("user", " id = ? AND active = 1 ", [ - socket.userID, - ]); + let user = await doubleCheckPassword(socket, password.currentPassword); + await user.resetPassword(password.newPassword); - if (user && passwordHash.verify(password.currentPassword, user.password)) { - - user.resetPassword(password.newPassword); - - callback({ - ok: true, - msg: "Password has been updated successfully.", - }); - } else { - throw new Error("Incorrect current password"); - } + callback({ + ok: true, + msg: "Password has been updated successfully.", + }); } catch (e) { callback({ @@ -980,10 +1022,14 @@ exports.entryPage = "dashboard"; } }); - socket.on("setSettings", async (data, callback) => { + socket.on("setSettings", async (data, currentPassword, callback) => { try { checkLogin(socket); + if (data.disableAuth) { + await doubleCheckPassword(socket, currentPassword); + } + await setSettings("general", data); exports.entryPage = data.entryPage; @@ -1389,6 +1435,7 @@ exports.entryPage = "dashboard"; // Status Page Socket Handler for admin only statusPageSocketHandler(socket); + cloudflaredSocketHandler(socket); databaseSocketHandler(socket); debug("added all socket handlers"); @@ -1431,6 +1478,9 @@ exports.entryPage = "dashboard"; initBackgroundJobs(args); + // Start cloudflared at the end if configured + await cloudflaredAutoStart(cloudflaredToken); + })(); async function updateMonitorNotification(monitorID, notificationIDList) { @@ -1475,6 +1525,8 @@ async function afterLogin(socket, user) { await sleep(500); + await StatusPage.sendStatusPageList(io, socket); + for (let monitorID in monitorList) { await sendHeartbeatList(socket, monitorID); } diff --git a/server/socket-handlers/cloudflared-socket-handler.js b/server/socket-handlers/cloudflared-socket-handler.js new file mode 100644 index 00000000..3d65cda5 --- /dev/null +++ b/server/socket-handlers/cloudflared-socket-handler.js @@ -0,0 +1,85 @@ +const { checkLogin, setSetting, setting, doubleCheckPassword } = require("../util-server"); +const { CloudflaredTunnel } = require("node-cloudflared-tunnel"); +const { io } = require("../server"); + +const prefix = "cloudflared_"; +const cloudflared = new CloudflaredTunnel(); + +cloudflared.change = (running, message) => { + io.to("cloudflared").emit(prefix + "running", running); + io.to("cloudflared").emit(prefix + "message", message); +}; + +cloudflared.error = (errorMessage) => { + io.to("cloudflared").emit(prefix + "errorMessage", errorMessage); +}; + +module.exports.cloudflaredSocketHandler = (socket) => { + + socket.on(prefix + "join", async () => { + try { + checkLogin(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")); + } catch (error) { } + }); + + socket.on(prefix + "leave", async () => { + try { + checkLogin(socket); + socket.leave("cloudflared"); + } catch (error) { } + }); + + socket.on(prefix + "start", async (token) => { + try { + checkLogin(socket); + if (token && typeof token === "string") { + await setSetting("cloudflaredTunnelToken", token); + cloudflared.token = token; + } else { + cloudflared.token = null; + } + cloudflared.start(); + } catch (error) { } + }); + + socket.on(prefix + "stop", async (currentPassword, callback) => { + try { + checkLogin(socket); + await doubleCheckPassword(socket, currentPassword); + cloudflared.stop(); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on(prefix + "removeToken", async () => { + try { + checkLogin(socket); + await setSetting("cloudflaredTunnelToken", ""); + } catch (error) { } + }); + +}; + +module.exports.autoStart = async (token) => { + if (!token) { + token = await setting("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"); + } + + if (token) { + console.log("Start cloudflared"); + cloudflared.token = token; + cloudflared.start(); + } +}; diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js index 5826277c..55a70d71 100644 --- a/server/socket-handlers/status-page-socket-handler.js +++ b/server/socket-handlers/status-page-socket-handler.js @@ -1,25 +1,36 @@ const { R } = require("redbean-node"); -const { checkLogin, setSettings } = require("../util-server"); +const { checkLogin, setSettings, setSetting } = require("../util-server"); const dayjs = require("dayjs"); const { debug } = require("../../src/util"); const ImageDataURI = require("../image-data-uri"); const Database = require("../database"); const apicache = require("../modules/apicache"); +const StatusPage = require("../model/status_page"); +const server = require("../server"); module.exports.statusPageSocketHandler = (socket) => { // Post or edit incident - socket.on("postIncident", async (incident, callback) => { + socket.on("postIncident", async (slug, incident, callback) => { try { checkLogin(socket); - await R.exec("UPDATE incident SET pin = 0 "); + let statusPageID = await StatusPage.slugToID(slug); + + if (!statusPageID) { + throw new Error("slug is not found"); + } + + await R.exec("UPDATE incident SET pin = 0 WHERE status_page_id = ? ", [ + statusPageID + ]); let incidentBean; if (incident.id) { - incidentBean = await R.findOne("incident", " id = ?", [ - incident.id + incidentBean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [ + incident.id, + statusPageID ]); } @@ -31,6 +42,7 @@ module.exports.statusPageSocketHandler = (socket) => { incidentBean.content = incident.content; incidentBean.style = incident.style; incidentBean.pin = true; + incidentBean.status_page_id = statusPageID; if (incident.id) { incidentBean.lastUpdatedDate = R.isoDateTime(dayjs.utc()); @@ -52,11 +64,15 @@ module.exports.statusPageSocketHandler = (socket) => { } }); - socket.on("unpinIncident", async (callback) => { + socket.on("unpinIncident", async (slug, callback) => { try { checkLogin(socket); - await R.exec("UPDATE incident SET pin = 0 WHERE pin = 1"); + let statusPageID = await StatusPage.slugToID(slug); + + await R.exec("UPDATE incident SET pin = 0 WHERE pin = 1 AND status_page_id = ? ", [ + statusPageID + ]); callback({ ok: true, @@ -71,13 +87,23 @@ module.exports.statusPageSocketHandler = (socket) => { // Save Status Page // imgDataUrl Only Accept PNG! - socket.on("saveStatusPage", async (config, imgDataUrl, publicGroupList, callback) => { + socket.on("saveStatusPage", async (slug, config, imgDataUrl, publicGroupList, callback) => { try { - checkLogin(socket); + checkSlug(config.slug); + checkLogin(socket); apicache.clear(); + // Save Config + let statusPage = await R.findOne("status_page", " slug = ? ", [ + slug + ]); + + if (!statusPage) { + throw new Error("No slug?"); + } + const header = "data:image/png;base64,"; // Check logo format @@ -88,16 +114,28 @@ module.exports.statusPageSocketHandler = (socket) => { throw new Error("Only allowed PNG logo."); } + const filename = `logo${statusPage.id}.png`; + // Convert to file - await ImageDataURI.outputFile(imgDataUrl, Database.uploadDir + "logo.png"); - config.logo = "/upload/logo.png?t=" + Date.now(); + await ImageDataURI.outputFile(imgDataUrl, Database.uploadDir + filename); + config.logo = `/upload/${filename}?t=` + Date.now(); } else { config.icon = imgDataUrl; } - // Save Config - await setSettings("statusPage", config); + statusPage.slug = config.slug; + statusPage.title = config.title; + statusPage.description = config.description; + statusPage.icon = config.logo; + statusPage.theme = config.theme; + //statusPage.published = ; + //statusPage.search_engine_index = ; + statusPage.show_tags = config.showTags; + //statusPage.password = null; + statusPage.modified_date = R.isoDateTime(); + + await R.store(statusPage); // Save Public Group List const groupIDList = []; @@ -106,13 +144,15 @@ module.exports.statusPageSocketHandler = (socket) => { for (let group of publicGroupList) { let groupBean; if (group.id) { - groupBean = await R.findOne("group", " id = ? AND public = 1 ", [ - group.id + groupBean = await R.findOne("group", " id = ? AND public = 1 AND status_page_id = ? ", [ + group.id, + statusPage.id ]); } else { groupBean = R.dispense("group"); } + groupBean.status_page_id = statusPage.id; groupBean.name = group.name; groupBean.public = true; groupBean.weight = groupOrder++; @@ -124,7 +164,6 @@ module.exports.statusPageSocketHandler = (socket) => { ]); let monitorOrder = 1; - console.log(group.monitorList); for (let monitor of group.monitorList) { let relationBean = R.dispense("monitor_group"); @@ -141,7 +180,18 @@ module.exports.statusPageSocketHandler = (socket) => { // Delete groups that not in the list debug("Delete groups that not in the list"); const slots = groupIDList.map(() => "?").join(","); - await R.exec(`DELETE FROM \`group\` WHERE id NOT IN (${slots})`, groupIDList); + + const data = [ + ...groupIDList, + statusPage.id + ]; + await R.exec(`DELETE FROM \`group\` WHERE id NOT IN (${slots}) AND status_page_id = ?`, data); + + // 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"); + } callback({ ok: true, @@ -149,7 +199,7 @@ module.exports.statusPageSocketHandler = (socket) => { }); } catch (error) { - console.log(error); + console.error(error); callback({ ok: false, @@ -158,4 +208,115 @@ module.exports.statusPageSocketHandler = (socket) => { } }); + // Add a new status page + socket.on("addStatusPage", async (title, slug, callback) => { + try { + checkLogin(socket); + + title = title?.trim(); + slug = slug?.trim(); + + // Check empty + if (!title || !slug) { + throw new Error("Please input all fields"); + } + + // Make sure slug is string + if (typeof slug !== "string") { + throw new Error("Slug -Accept string only"); + } + + // lower case only + slug = slug.toLowerCase(); + + checkSlug(slug); + + let statusPage = R.dispense("status_page"); + statusPage.slug = slug; + statusPage.title = title; + statusPage.theme = "light"; + statusPage.icon = ""; + await R.store(statusPage); + + callback({ + ok: true, + msg: "OK!" + }); + + } catch (error) { + console.error(error); + callback({ + ok: false, + msg: error.message, + }); + } + }); + + // Delete a status page + socket.on("deleteStatusPage", async (slug, callback) => { + try { + checkLogin(socket); + + let statusPageID = await StatusPage.slugToID(slug); + + if (statusPageID) { + + // Reset entry page if it is the default one. + if (server.entryPage === "statusPage-" + slug) { + server.entryPage = "dashboard"; + await setSetting("entryPage", server.entryPage, "general"); + } + + // No need to delete records from `status_page_cname`, because it has cascade foreign key. + // But for incident & group, it is hard to add cascade foreign key during migration, so they have to be deleted manually. + + // Delete incident + await R.exec("DELETE FROM incident WHERE status_page_id = ? ", [ + statusPageID + ]); + + // Delete group + await R.exec("DELETE FROM `group` WHERE status_page_id = ? ", [ + statusPageID + ]); + + // Delete status_page + await R.exec("DELETE FROM status_page WHERE id = ? ", [ + statusPageID + ]); + + } else { + throw new Error("Status Page is not found"); + } + + callback({ + ok: true, + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); }; + +/** + * Check slug a-z, 0-9, - only + * Regex from: https://stackoverflow.com/questions/22454258/js-regex-string-validation-for-slug + */ +function checkSlug(slug) { + if (typeof slug !== "string") { + throw new Error("Slug must be string"); + } + + slug = slug.trim(); + + if (!slug) { + throw new Error("Slug cannot be empty"); + } + + if (!slug.match(/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/)) { + throw new Error("Invalid Slug"); + } +} diff --git a/server/util-server.js b/server/util-server.js index 2264ebea..b2c70d92 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -1,9 +1,8 @@ const tcpp = require("tcp-ping"); const Ping = require("./ping-lite"); const { R } = require("redbean-node"); -const { debug } = require("../src/util"); +const { debug, genSecret } = require("../src/util"); const passwordHash = require("./password-hash"); -const dayjs = require("dayjs"); const { Resolver } = require("dns"); const child_process = require("child_process"); const iconv = require("iconv-lite"); @@ -32,7 +31,7 @@ exports.initJWTSecret = async () => { jwtSecretBean.key = "jwtSecret"; } - jwtSecretBean.value = passwordHash.generate(dayjs() + ""); + jwtSecretBean.value = passwordHash.generate(genSecret()); await R.store(jwtSecretBean); return jwtSecretBean; }; @@ -321,6 +320,28 @@ exports.checkLogin = (socket) => { } }; +/** + * For logged-in users, double-check the password + * @param socket + * @param currentPassword + * @returns {Promise} + */ +exports.doubleCheckPassword = async (socket, currentPassword) => { + if (typeof currentPassword !== "string") { + throw new Error("Wrong data type?"); + } + + let user = await R.findOne("user", " id = ? AND active = 1 ", [ + socket.userID, + ]); + + if (!user || !passwordHash.verify(currentPassword, user.password)) { + throw new Error("Incorrect current password"); + } + + return user; +}; + exports.startUnitTest = async () => { console.log("Starting unit test..."); const npm = /^win/.test(process.platform) ? "npm.cmd" : "npm"; diff --git a/src/assets/app.scss b/src/assets/app.scss index f49ed4f2..9e37cc99 100644 --- a/src/assets/app.scss +++ b/src/assets/app.scss @@ -92,6 +92,10 @@ textarea.form-control { } } +.btn-dark { + background-color: #161B22; +} + @media (max-width: 550px) { .table-shadow-box { padding: 10px !important; @@ -144,6 +148,10 @@ textarea.form-control { background-color: #090c10; color: $dark-font-color; + mark, .mark { + background-color: #b6ad86; + } + &::-webkit-scrollbar-thumb, ::-webkit-scrollbar-thumb { background: $dark-border-color; } @@ -159,15 +167,21 @@ textarea.form-control { border-color: $dark-border-color; } + .input-group-text { + background-color: #282f39; + border-color: $dark-border-color; + color: $dark-font-color; + } + .form-check-input:checked { border-color: $primary; // Re-apply bootstrap border } - + .form-switch .form-check-input { background-color: #232f3b; } - a, + a:not(.btn), .table, .nav-link { color: $dark-font-color; @@ -334,11 +348,8 @@ textarea.form-control { .monitor-list { &.scrollbar { - min-height: calc(100vh - 240px); - max-height: calc(100vh - 30px); overflow-y: auto; - position: sticky; - top: 10px; + height: calc(100% - 65px); } .item { @@ -438,6 +449,10 @@ textarea.form-control { border-radius: 10px !important; } +.spinner { + color: $primary; +} + // Localization @import "localization.scss"; diff --git a/src/components/MonitorList.vue b/src/components/MonitorList.vue index ef51e89c..6171c0b3 100644 --- a/src/components/MonitorList.vue +++ b/src/components/MonitorList.vue @@ -1,5 +1,5 @@ + + - + - @@ -310,7 +329,12 @@ export default { disableAuth() { this.settings.disableAuth = true; - this.saveSettings(); + + // Need current password to disable auth + // Set it to empty if done + this.saveSettings(() => { + this.password.currentPassword = ""; + }, this.password.currentPassword); }, enableAuth() { diff --git a/src/i18n.js b/src/i18n.js index 8d9b2faf..5505e5c2 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -29,7 +29,8 @@ const languageList = { "pl": "Polski", "et-EE": "eesti", "vi-VN": "Tiếng Việt", - "zh-TW": "繁體中文 (台灣)" + "zh-TW": "繁體中文 (台灣)", + "uk-UA": "Український", }; let messages = { diff --git a/src/icon.js b/src/icon.js index 88b8a8ec..bbd816ea 100644 --- a/src/icon.js +++ b/src/icon.js @@ -34,6 +34,9 @@ import { faAward, faLink, faChevronDown, + faPen, + faExternalLinkSquareAlt, + faSpinner, } from "@fortawesome/free-solid-svg-icons"; library.add( @@ -67,6 +70,9 @@ library.add( faAward, faLink, faChevronDown, + faPen, + faExternalLinkSquareAlt, + faSpinner, ); export { FontAwesomeIcon }; diff --git a/src/languages/bg-BG.js b/src/languages/bg-BG.js index 762cfff7..4dd79bfc 100644 --- a/src/languages/bg-BG.js +++ b/src/languages/bg-BG.js @@ -197,6 +197,7 @@ export default { line: "Line Messenger", mattermost: "Mattermost", "Status Page": "Статус страница", + "Status Pages": "Статус страница", "Primary Base URL": "Основен базов URL адрес", "Push URL": "Генериран Push URL адрес", needPushEvery: "Необходимо е да извършвате заявка към този URL адрес на всеки {0} секунди", @@ -360,4 +361,14 @@ export default { smtpDkimHashAlgo: "Хеш алгоритъм (по желание)", smtpDkimheaderFieldNames: "Хедър ключове за подписване (по желание)", smtpDkimskipFields: "Хедър ключове, които да не се подписват (по желание)", + PushByTechulus: "Push от Techulus", + GoogleChat: "Google Chat (Само за работното пространство на Google)", + gorush: "Gorush", + alerta: "Alerta", + alertaApiEndpoint: "Крайна точка на API", + alertaEnvironment: "Среда", + alertaApiKey: "API Ключ", + alertaAlertState: "Състояние на тревога", + alertaRecoverState: "Състояние на възстановяване", + deleteStatusPageMsg: "Сигурни ли сте, че желаете да изтриете тази статус страница?", }; diff --git a/src/languages/cs-CZ.js b/src/languages/cs-CZ.js index 6d4f349a..e479b094 100644 --- a/src/languages/cs-CZ.js +++ b/src/languages/cs-CZ.js @@ -183,6 +183,7 @@ export default { "Edit Status Page": "Upravit stavovou stránku", "Go to Dashboard": "Přejít na nástěnku", "Status Page": "Stavová stránka", + "Status Pages": "Stavová stránka", defaultNotificationName: "Moje {notification} upozornění ({číslo})", here: "sem", Required: "Vyžadováno", diff --git a/src/languages/da-DK.js b/src/languages/da-DK.js index 6c295c77..b629a1ba 100644 --- a/src/languages/da-DK.js +++ b/src/languages/da-DK.js @@ -180,6 +180,7 @@ export default { "Edit Status Page": "Rediger Statusside", "Go to Dashboard": "Gå til Betjeningspanel", "Status Page": "Statusside", + "Status Pages": "Statusside", telegram: "Telegram", webhook: "Webhook", smtp: "Email (SMTP)", diff --git a/src/languages/de-DE.js b/src/languages/de-DE.js index 9e77214a..5514850b 100644 --- a/src/languages/de-DE.js +++ b/src/languages/de-DE.js @@ -179,6 +179,7 @@ export default { "Edit Status Page": "Bearbeite Status-Seite", "Go to Dashboard": "Gehe zum Dashboard", "Status Page": "Status-Seite", + "Status Pages": "Status-Seite", telegram: "Telegram", webhook: "Webhook", smtp: "E-Mail (SMTP)", diff --git a/src/languages/en.js b/src/languages/en.js index cd62de17..4ee696b9 100644 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -183,6 +183,7 @@ export default { "Edit Status Page": "Edit Status Page", "Go to Dashboard": "Go to Dashboard", "Status Page": "Status Page", + "Status Pages": "Status Pages", defaultNotificationName: "My {notification} Alert ({number})", here: "here", Required: "Required", @@ -330,21 +331,21 @@ export default { dark: "dark", Post: "Post", "Please input title and content": "Please input title and content", - Created: "Created", + "Created": "Created", "Last Updated": "Last Updated", - Unpin: "Unpin", + "Unpin": "Unpin", "Switch to Light Theme": "Switch to Light Theme", "Switch to Dark Theme": "Switch to Dark Theme", "Show Tags": "Show Tags", "Hide Tags": "Hide Tags", - Description: "Description", + "Description": "Description", "No monitors available.": "No monitors available.", "Add one": "Add one", "No Monitors": "No Monitors", "Untitled Group": "Untitled Group", - Services: "Services", - Discard: "Discard", - Cancel: "Cancel", + "Services": "Services", + "Discard": "Discard", + "Cancel": "Cancel", "Powered by": "Powered by", shrinkDatabaseDescription: "Trigger database VACUUM for SQLite. If your database is created after 1.10.0, AUTO_VACUUM is already enabled and this action is not needed.", serwersms: "SerwerSMS.pl", @@ -352,7 +353,7 @@ export default { serwersmsAPIPassword: "API Password", serwersmsPhoneNumber: "Phone number", serwersmsSenderName: "SMS Sender Name (registered via customer portal)", - "stackfield": "Stackfield", + stackfield: "Stackfield", smtpDkimSettings: "DKIM Settings", smtpDkimDesc: "Please refer to the Nodemailer DKIM {0} for usage.", documentation: "documentation", @@ -363,12 +364,13 @@ export default { smtpDkimheaderFieldNames: "Header Keys to sign (Optional)", smtpDkimskipFields: "Header Keys not to sign (Optional)", gorush: "Gorush", - alerta: 'Alerta', - alertaApiEndpoint: 'API Endpoint', - alertaEnvironment: 'Environment', - alertaApiKey: 'API Key', - alertaAlertState: 'Alert State', - alertaRecoverState: 'Recover State', + alerta: "Alerta", + alertaApiEndpoint: "API Endpoint", + alertaEnvironment: "Environment", + alertaApiKey: "API Key", + alertaAlertState: "Alert State", + alertaRecoverState: "Recover State", + deleteStatusPageMsg: "Are you sure want to delete this status page?", Proxies: "Proxies", default: "Default", enabled: "Enabled", diff --git a/src/languages/es-ES.js b/src/languages/es-ES.js index 088172bc..d8cdcc78 100644 --- a/src/languages/es-ES.js +++ b/src/languages/es-ES.js @@ -180,6 +180,7 @@ export default { "Edit Status Page": "Editar página de estado", "Go to Dashboard": "Ir al panel de control", "Status Page": "Página de estado", + "Status Pages": "Página de estado", telegram: "Telegram", webhook: "Webhook", smtp: "Email (SMTP)", diff --git a/src/languages/et-EE.js b/src/languages/et-EE.js index 224d9811..387740fc 100644 --- a/src/languages/et-EE.js +++ b/src/languages/et-EE.js @@ -17,6 +17,7 @@ export default { pauseMonitorMsg: "Kas soovid peatada seire?", Settings: "Seaded", "Status Page": "Ülevaade", + "Status Pages": "Ülevaated", Dashboard: "Töölaud", "New Update": "Uuem tarkvara versioon on saadaval.", Language: "Keel", @@ -197,4 +198,10 @@ export default { pushbullet: "Pushbullet", line: "LINE", mattermost: "Mattermost", + alerta: "Alerta", + alertaApiEndpoint: "API otsik", + alertaEnvironment: "Keskkond", + alertaApiKey: "API võti", + alertaAlertState: "Häireseisund", + alertaRecoverState: "Taasta algolek", }; diff --git a/src/languages/fa.js b/src/languages/fa.js index 8901c72a..7f873f99 100644 --- a/src/languages/fa.js +++ b/src/languages/fa.js @@ -178,6 +178,7 @@ export default { "Add a monitor": "اضافه کردن مانیتور", "Edit Status Page": "ویرایش صفحه وضعیت", "Status Page": "صفحه وضعیت", + "Status Pages": "صفحه وضعیت", "Go to Dashboard": "رفتن به پیشخوان", "Uptime Kuma": "آپتایم کوما", records: "مورد", diff --git a/src/languages/fr-FR.js b/src/languages/fr-FR.js index 054e16c1..0a1d2346 100644 --- a/src/languages/fr-FR.js +++ b/src/languages/fr-FR.js @@ -179,6 +179,7 @@ export default { "Edit Status Page": "Modifier la page de statut", "Go to Dashboard": "Accéder au tableau de bord", "Status Page": "Status Page", + "Status Pages": "Status Pages", defaultNotificationName: "Ma notification {notification} numéro ({number})", here: "ici", Required: "Requis", @@ -304,9 +305,9 @@ export default { steamApiKeyDescription: "Pour surveiller un serveur Steam, vous avez besoin d'une clé Steam Web-API. Vous pouvez enregistrer votre clé ici : ", "Current User": "Utilisateur actuel", recent: "Récent", - alertaApiEndpoint: 'API Endpoint', - alertaEnvironment: 'Environement', + alertaApiEndpoint: "API Endpoint", + alertaEnvironment: "Environement", alertaApiKey: "Clé de l'API", alertaAlertState: "État de l'Alerte", - alertaRecoverState: 'État de récupération', + alertaRecoverState: "État de récupération", }; diff --git a/src/languages/hr-HR.js b/src/languages/hr-HR.js index 6c7e4ea5..a033edb5 100644 --- a/src/languages/hr-HR.js +++ b/src/languages/hr-HR.js @@ -183,6 +183,7 @@ export default { "Edit Status Page": "Uredi Statusnu stranicu", "Go to Dashboard": "Na Kontrolnu ploču", "Status Page": "Statusna stranica", + "Status Pages": "Statusne stranice", defaultNotificationName: "Moja {number}. {notification} obavijest", here: "ovdje", Required: "Potrebno", @@ -346,4 +347,30 @@ export default { Cancel: "Otkaži", "Powered by": "Pokreće", Saved: "Spremljeno", + PushByTechulus: "Push by Techulus", + GoogleChat: "Google Chat (preko platforme Google Workspace)", + shrinkDatabaseDescription: "Pokreni VACUUM operaciju za SQLite. Ako je baza podataka kreirana nakon inačice 1.10.0, AUTO_VACUUM opcija već je uključena te ova akcija nije nužna.", + serwersms: "SerwerSMS.pl", + serwersmsAPIUser: "API korisničko ime (uključujući webapi_ prefiks)", + serwersmsAPIPassword: "API lozinka", + serwersmsPhoneNumber: "Broj telefona", + serwersmsSenderName: "Ime SMS pošiljatelja (registrirano preko korisničkog portala)", + stackfield: "Stackfield", + smtpDkimSettings: "DKIM postavke", + smtpDkimDesc: "Za više informacija, postoji Nodemailer DKIM {0}.", + documentation: "dokumentacija", + smtpDkimDomain: "Domena", + smtpDkimKeySelector: "Odabir ključa", + smtpDkimPrivateKey: "Privatni ključ", + smtpDkimHashAlgo: "Hash algoritam (neobavezno)", + smtpDkimheaderFieldNames: "Ključevi zaglavlja za potpis (neobavezno)", + smtpDkimskipFields: "Ključevi zaglavlja koji se neće potpisati (neobavezno)", + gorush: "Gorush", + alerta: "Alerta", + alertaApiEndpoint: "Krajnja točka API-ja (Endpoint)", + alertaEnvironment: "Okruženje (Environment)", + alertaApiKey: "API ključ", + alertaAlertState: "Stanje upozorenja", + alertaRecoverState: "Stanje oporavka", + deleteStatusPageMsg: "Sigurno želite obrisati ovu statusnu stranicu?", }; diff --git a/src/languages/hu.js b/src/languages/hu.js index b63bb506..06a324aa 100644 --- a/src/languages/hu.js +++ b/src/languages/hu.js @@ -197,6 +197,7 @@ export default { line: "Line Messenger", mattermost: "Mattermost", "Status Page": "Státusz oldal", + "Status Pages": "Státusz oldal", "Primary Base URL": "Elsődleges URL", "Push URL": "Meghívandó URL", needPushEvery: "Ezt az URL-t kell meghívni minden {0} másodpercben.", @@ -361,4 +362,12 @@ export default { smtpDkimHashAlgo: "Hash algoritmus (nem kötelező)", smtpDkimheaderFieldNames: "Fejléc kulcsok a bejelentkezéshez (nem kötelező)", smtpDkimskipFields: "Fejléc kulcsok egyéb esetben (nem kötelező)", + PushByTechulus: "Techulus push", + gorush: "Gorush", + alerta: "Alerta", + alertaApiEndpoint: "API végpont", + alertaEnvironment: "Környezet", + alertaApiKey: "API kulcs", + alertaAlertState: "Figyelmeztetési állapot", + alertaRecoverState: "Visszaállási állapot", }; diff --git a/src/languages/id-ID.js b/src/languages/id-ID.js index 9734e355..36d83075 100644 --- a/src/languages/id-ID.js +++ b/src/languages/id-ID.js @@ -179,6 +179,7 @@ export default { "Edit Status Page": "Edit Halaman Status", "Go to Dashboard": "Pergi ke Dasbor", "Status Page": "Halaman Status", + "Status Pages": "Halaman Status", defaultNotificationName: "{notification} saya Peringatan ({number})", here: "di sini", Required: "Dibutuhkan", diff --git a/src/languages/it-IT.js b/src/languages/it-IT.js index 7c2638ba..fc3cbe29 100644 --- a/src/languages/it-IT.js +++ b/src/languages/it-IT.js @@ -183,6 +183,7 @@ export default { "Edit Status Page": "Modifica pagina di stato", "Go to Dashboard": "Vai alla dashboard", "Status Page": "Pagina di stato", + "Status Pages": "Pagina di stato", defaultNotificationName: "Notifica {notification} ({number})", here: "qui", Required: "Obbligatorio", diff --git a/src/languages/ja.js b/src/languages/ja.js index 3a244d13..da388f03 100644 --- a/src/languages/ja.js +++ b/src/languages/ja.js @@ -180,6 +180,7 @@ export default { "Edit Status Page": "ステータスページ編集", "Go to Dashboard": "ダッシュボード", "Status Page": "ステータスページ", + "Status Pages": "ステータスページ", telegram: "Telegram", webhook: "Webhook", smtp: "Email (SMTP)", diff --git a/src/languages/ko-KR.js b/src/languages/ko-KR.js index fe943793..ccb5df1d 100644 --- a/src/languages/ko-KR.js +++ b/src/languages/ko-KR.js @@ -179,6 +179,7 @@ export default { "Edit Status Page": "상태 페이지 수정", "Go to Dashboard": "대시보드로 가기", "Status Page": "상태 페이지", + "Status Pages": "상태 페이지", defaultNotificationName: "내 {notification} 알림 ({number})", here: "여기", Required: "필수", @@ -188,7 +189,7 @@ export default { "Chat ID": "채팅 ID", supportTelegramChatID: "Direct Chat / Group / Channel's Chat ID를 지원해요.", wayToGetTelegramChatID: "봇에 메시지를 보내 채팅 ID를 얻고 밑에 URL로 이동해 chat_id를 볼 수 있어요.", - "YOUR BOT TOKEN HERE": "YOUR BOT TOKEN HERE", + "YOUR BOT TOKEN HERE": "여기에 BOT 토큰을 적어주세요.", chatIDNotFound: "채팅 ID를 찾을 수 없어요. 먼저 봇에게 메시지를 보내주세요.", webhook: "Webhook", "Post URL": "Post URL", @@ -281,15 +282,15 @@ export default { promosmsSMSSender: "SMS 보내는 사람 이름 : 미리 등록된 이름 혹은 기본값 중 하나예요: InfoSMS, SMS Info, MaxSMS, INFO, SMS", "Primary Base URL": "기본 URL", "Push URL": "Push URL", - needPushEvery: "You should call this URL every {0} seconds.", - pushOptionalParams: "Optional parameters: {0}", - emailCustomSubject: "Custom Subject", + needPushEvery: "이 URL을 {0} 초 마다 호출할 수 있어요.", + pushOptionalParams: "선택적 파라미터: {0}", + emailCustomSubject: "커스텀 주제", clicksendsms: "ClickSend SMS", checkPrice: "{0} 가격 확인:", - apiCredentials: "API credentials", + apiCredentials: "API 인증정보", octopushLegacyHint: "Octopush 레거시 버전 (2011-2020) 을 사용하시나요? 아니면 새 버전을 사용하시나요?", "Feishu WebHookUrl": "Feishu WebHookURL", - matrixHomeserverURL: "Homeserver URL (with http(s):// and optionally port)", + matrixHomeserverURL: "Homeserver URL (http(s):// 와 함께 적어주세요. 그리고 포트 번호는 선택적 입니다.)", "Internal Room Id": "내부 방 ID", matrixDesc1: "Matrix 클라이언트 방 설정의 고급 섹션에서 내부 방 ID를 찾을 수 있어요. 내부 방 ID는 이렇게 생겼답니다: !QMdRCpUIfLwsfjxye6:home.server.", matrixDesc2: "사용자의 모든 방에 대한 엑세스가 허용될 수 있어서 새로운 사용자를 만들고 원하는 방에만 초대한 후 엑세스 토큰을 사용하는 것이 좋아요. {0} 이 명령어를 통해 엑세스 토큰을 얻을 수 있어요.", @@ -349,6 +350,6 @@ export default { serwersmsAPIUser: "API Usename (webapi_ 접두사 포함)", serwersmsAPIPassword: "API 비밀번호", serwersmsPhoneNumber: "휴대전화 번호", - serwersmsSenderName: "보내는 사람 이름 (registered via customer portal)", + serwersmsSenderName: "보내는 사람 이름 (customer portal를 통해 가입된 정보)", stackfield: "Stackfield", }; diff --git a/src/languages/nb-NO.js b/src/languages/nb-NO.js index 10a48011..a02885bd 100644 --- a/src/languages/nb-NO.js +++ b/src/languages/nb-NO.js @@ -179,6 +179,7 @@ export default { "Edit Status Page": "Rediger statusside", "Go to Dashboard": "Gå til Dashboard", "Status Page": "Statusside", + "Status Pages": "Statusside", defaultNotificationName: "Min {notification} varsling ({number})", here: "her", Required: "Obligatorisk", diff --git a/src/languages/nl-NL.js b/src/languages/nl-NL.js index a652b795..d148ba36 100644 --- a/src/languages/nl-NL.js +++ b/src/languages/nl-NL.js @@ -180,6 +180,7 @@ export default { "Edit Status Page": "Wijzig status pagina", "Go to Dashboard": "Ga naar Dashboard", "Status Page": "Status Pagina", + "Status Pages": "Status Pagina", telegram: "Telegram", webhook: "Webhook", smtp: "Email (SMTP)", diff --git a/src/languages/pl.js b/src/languages/pl.js index c863e4e3..fb377148 100644 --- a/src/languages/pl.js +++ b/src/languages/pl.js @@ -179,6 +179,7 @@ export default { "Edit Status Page": "Edytuj stronę statusu", "Go to Dashboard": "Idź do panelu", "Status Page": "Strona statusu", + "Status Pages": "Strona statusu", defaultNotificationName: "Moje powiadomienie {notification} ({number})", here: "tutaj", Required: "Wymagane", diff --git a/src/languages/pt-BR.js b/src/languages/pt-BR.js index f3b1692d..d83c303a 100644 --- a/src/languages/pt-BR.js +++ b/src/languages/pt-BR.js @@ -169,6 +169,7 @@ export default { "Avg. Ping": "Ping Médio.", "Avg. Response": "Resposta Média. ", "Status Page": "Página de Status", + "Status Pages": "Página de Status", "Entry Page": "Página de entrada", statusPageNothing: "Nada aqui, por favor, adicione um grupo ou monitor.", "No Services": "Nenhum Serviço", diff --git a/src/languages/ru-RU.js b/src/languages/ru-RU.js index d0add482..c8212442 100644 --- a/src/languages/ru-RU.js +++ b/src/languages/ru-RU.js @@ -180,7 +180,8 @@ export default { "Add a monitor": "Добавить монитор", "Edit Status Page": "Редактировать", "Go to Dashboard": "Панель управления", - "Status Page": "Мониторинг", + "Status Page": "Страница статуса", + "Status Pages": "Страницы статуса", Discard: "Отмена", "Create Incident": "Создать инцидент", "Switch to Dark Theme": "Тёмная тема", @@ -310,28 +311,82 @@ export default { "One record": "Одна запись", steamApiKeyDescription: "Для мониторинга игрового сервера Steam вам необходим Web-API ключ Steam. Зарегистрировать его можно здесь: ", "Certificate Chain": "Цепочка сертификатов", - "Valid": "Действительный", + Valid: "Действительный", "Hide Tags": "Скрыть тэги", - "Title": "Название инцидента:", - "Content": "Содержание инцидента:", - "Post": "Опубликовать", - "Cancel": "Отмена", - "Created": "Создано", - "Unpin": "Открепить", + Title: "Название инцидента:", + Content: "Содержание инцидента:", + Post: "Опубликовать", + Cancel: "Отмена", + Created: "Создано", + Unpin: "Открепить", "Show Tags": "Показать тэги", - "recent": "Сейчас", + recent: "Сейчас", "3h": "3 часа", "6h": "6 часов", "24h": "24 часа", "1w": "1 неделя", "No monitors available.": "Нет доступных мониторов", "Add one": "Добавить новый", - "Backup": "Резервная копия", - "Security": "Безопасность", + Backup: "Резервная копия", + Security: "Безопасность", "Shrink Database": "Сжать Базу Данных", "Current User": "Текущий пользователь", - "About": "О программе", - "Description": "Описание", + About: "О программе", + Description: "Описание", "Powered by": "Работает на основе скрипта от", shrinkDatabaseDescription: "Включает VACUUM для базы данных SQLite. Если ваша база данных была создана на версии 1.10.0 и более, AUTO_VACUUM уже включен и это действие не требуется.", + deleteStatusPageMsg: "Вы действительно хотите удалить эту страницу статуса сервисов?", + Style: "Стиль", + info: "ИНФО", + warning: "ВНИМАНИЕ", + danger: "ОШИБКА", + primary: "ОСНОВНОЙ", + light: "СВЕТЛЫЙ", + dark: "ТЕМНЫЙ", + "New Status Page": "Новая страница статуса", + "Show update if available": "Показывать доступные обновления", + "Also check beta release": "Проверять обновления для бета версий", + "Add New Status Page": "Добавить страницу статуса", + Next: "Далее", + "Accept characters: a-z 0-9 -": "Разрешены символы: a-z 0-9 -", + "Start or end with a-z 0-9 only": "Начало и окончание имени только на символы: a-z 0-9", + "No consecutive dashes --": "Запрещено использовать тире --", + "HTTP Options": "HTTP Опции", + "Basic Auth": "HTTP Авторизация", + PushByTechulus: "Push by Techulus", + clicksendsms: "ClickSend SMS", + GoogleChat: "Google Chat (только Google Workspace)", + apiCredentials: "API реквизиты", + Done: "Готово", + Info: "Инфо", + "Steam API Key": "Steam API-Ключ", + "Pick a RR-Type...": "Выберите RR-Тип...", + "Pick Accepted Status Codes...": "Выберите принятые коды состояния...", + Default: "По умолчанию", + "Please input title and content": "Пожалуйста, введите название и содержание", + "Last Updated": "Последнее Обновление", + "Untitled Group": "Группа без названия", + Services: "Сервисы", + serwersms: "SerwerSMS.pl", + serwersmsAPIUser: "API Пользователь (включая префикс webapi_)", + serwersmsAPIPassword: "API Пароль", + serwersmsPhoneNumber: "Номер телефона", + serwersmsSenderName: "SMS Имя Отправителя (регистрированный через пользовательский портал)", + stackfield: "Stackfield", + smtpDkimSettings: "DKIM Настройки", + smtpDkimDesc: "Please refer to the Nodemailer DKIM {0} for usage.", + documentation: "документация", + smtpDkimDomain: "Имя Домена", + smtpDkimKeySelector: "Ключ", + smtpDkimPrivateKey: "Приватный ключ", + smtpDkimHashAlgo: "Алгоритм хэша (опционально)", + smtpDkimheaderFieldNames: "Заголовок ключей для подписи (опционально)", + smtpDkimskipFields: "Заколовок ключей не для подписи (опционально)", + gorush: "Gorush", + alerta: "Alerta", + alertaApiEndpoint: "Конечная точка API", + alertaEnvironment: "Среда", + alertaApiKey: "Ключ API", + alertaAlertState: "Состояние алерта", + alertaRecoverState: "Состояние восстановления", }; diff --git a/src/languages/sl-SI.js b/src/languages/sl-SI.js index fd34f8d2..4318fd28 100644 --- a/src/languages/sl-SI.js +++ b/src/languages/sl-SI.js @@ -182,7 +182,8 @@ export default { "Add a monitor": "Dodaj monitor", "Edit Status Page": "Uredi statusno stran", "Go to Dashboard": "Pojdi na nadzorno ploščo", - "Status Page": "Status", + "Status Page": "Página de Status", + "Status Pages": "Página de Status", defaultNotificationName: "Moje {notification} Obvestilo ({number})", here: "tukaj", Required: "Obvezno", diff --git a/src/languages/sr-latn.js b/src/languages/sr-latn.js index 39edde48..dd9c0017 100644 --- a/src/languages/sr-latn.js +++ b/src/languages/sr-latn.js @@ -180,6 +180,7 @@ export default { "Edit Status Page": "Edit Status Page", "Go to Dashboard": "Go to Dashboard", "Status Page": "Status Page", + "Status Pages": "Status Pages", telegram: "Telegram", webhook: "Webhook", smtp: "Email (SMTP)", diff --git a/src/languages/sr.js b/src/languages/sr.js index aac79e58..709b04cd 100644 --- a/src/languages/sr.js +++ b/src/languages/sr.js @@ -180,6 +180,7 @@ export default { "Edit Status Page": "Edit Status Page", "Go to Dashboard": "Go to Dashboard", "Status Page": "Status Page", + "Status Pages": "Status Pages", telegram: "Telegram", webhook: "Webhook", smtp: "Email (SMTP)", diff --git a/src/languages/sv-SE.js b/src/languages/sv-SE.js index e1fdadc9..0ba43682 100644 --- a/src/languages/sv-SE.js +++ b/src/languages/sv-SE.js @@ -108,94 +108,4 @@ export default { "Repeat Password": "Upprepa Lösenord", respTime: "Svarstid (ms)", notAvailableShort: "Ej Tillg.", - Create: "Create", - clearEventsMsg: "Are you sure want to delete all events for this monitor?", - clearHeartbeatsMsg: "Are you sure want to delete all heartbeats for this monitor?", - confirmClearStatisticsMsg: "Are you sure want to delete ALL statistics?", - "Clear Data": "Clear Data", - Events: "Events", - Heartbeats: "Heartbeats", - "Auto Get": "Auto Get", - enableDefaultNotificationDescription: "For every new monitor this notification will be enabled by default. You can still disable the notification separately for each monitor.", - "Default enabled": "Default enabled", - "Also apply to existing monitors": "Also apply to existing monitors", - Export: "Export", - Import: "Import", - backupDescription: "You can backup all monitors and all notifications into a JSON file.", - backupDescription2: "PS: History and event data is not included.", - backupDescription3: "Sensitive data such as notification tokens is included in the export file, please keep it carefully.", - alertNoFile: "Please select a file to import.", - alertWrongFileType: "Please select a JSON file.", - twoFAVerifyLabel: "Please type in your token to verify that 2FA is working", - tokenValidSettingsMsg: "Token is valid! You can now save the 2FA settings.", - confirmEnableTwoFAMsg: "Are you sure you want to enable 2FA?", - confirmDisableTwoFAMsg: "Are you sure you want to disable 2FA?", - "Apply on all existing monitors": "Apply on all existing monitors", - "Verify Token": "Verify Token", - "Setup 2FA": "Setup 2FA", - "Enable 2FA": "Enable 2FA", - "Disable 2FA": "Disable 2FA", - "2FA Settings": "2FA Settings", - "Two Factor Authentication": "Two Factor Authentication", - Active: "Active", - Inactive: "Inactive", - Token: "Token", - "Show URI": "Show URI", - "Clear all statistics": "Clear all Statistics", - retryCheckEverySecond: "Retry every {0} seconds.", - importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.", - confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.", - "Heartbeat Retry Interval": "Heartbeat Retry Interval", - "Import Backup": "Import Backup", - "Export Backup": "Export Backup", - "Skip existing": "Skip existing", - Overwrite: "Overwrite", - Options: "Options", - "Keep both": "Keep both", - Tags: "Tags", - "Add New below or Select...": "Add New below or Select...", - "Tag with this name already exist.": "Tag with this name already exist.", - "Tag with this value already exist.": "Tag with this value already exist.", - color: "color", - "value (optional)": "value (optional)", - Gray: "Gray", - Red: "Red", - Orange: "Orange", - Green: "Green", - Blue: "Blue", - Indigo: "Indigo", - Purple: "Purple", - Pink: "Pink", - "Search...": "Search...", - "Avg. Ping": "Avg. Ping", - "Avg. Response": "Avg. Response", - "Entry Page": "Entry Page", - statusPageNothing: "Nothing here, please add a group or a monitor.", - "No Services": "No Services", - "All Systems Operational": "All Systems Operational", - "Partially Degraded Service": "Partially Degraded Service", - "Degraded Service": "Degraded Service", - "Add Group": "Add Group", - "Add a monitor": "Add a monitor", - "Edit Status Page": "Edit Status Page", - "Go to Dashboard": "Go to Dashboard", - "Status Page": "Status Page", - telegram: "Telegram", - webhook: "Webhook", - smtp: "Email (SMTP)", - discord: "Discord", - teams: "Microsoft Teams", - signal: "Signal", - gotify: "Gotify", - slack: "Slack", - "rocket.chat": "Rocket.chat", - pushover: "Pushover", - pushy: "Pushy", - octopush: "Octopush", - promosms: "PromoSMS", - lunasea: "LunaSea", - apprise: "Apprise (Support 50+ Notification services)", - pushbullet: "Pushbullet", - line: "Line Messenger", - mattermost: "Mattermost", }; diff --git a/src/languages/tr-TR.js b/src/languages/tr-TR.js index 64fffd9d..0edd6020 100644 --- a/src/languages/tr-TR.js +++ b/src/languages/tr-TR.js @@ -124,7 +124,7 @@ export default { tokenValidSettingsMsg: "Token geçerli! Şimdi 2FA ayarlarını kaydedebilirsiniz. ", confirmEnableTwoFAMsg: "2FA'ı etkinleştirmek istediğinizden emin misiniz?", confirmDisableTwoFAMsg: "2FA'ı devre dışı bırakmak istediğinize emin misiniz?", - "Heartbeat Retry Interval": "Sağlık Dırımları Tekrar Deneme Sıklığı", + "Heartbeat Retry Interval": "Sağlık Durumları Tekrar Deneme Sıklığı", "Import Backup": "Yedeği içe aktar", "Export Backup": "Yedeği dışa aktar", Export: "Dışa aktar", @@ -149,52 +149,4 @@ export default { "Two Factor Authentication": "İki Faktörlü Kimlik Doğrulama (2FA)", Active: "Aktif", Inactive: "İnaktif", - Token: "Token", - "Show URI": "Show URI", - Tags: "Tags", - "Add New below or Select...": "Add New below or Select...", - "Tag with this name already exist.": "Tag with this name already exist.", - "Tag with this value already exist.": "Tag with this value already exist.", - color: "color", - "value (optional)": "value (optional)", - Gray: "Gray", - Red: "Red", - Orange: "Orange", - Green: "Green", - Blue: "Blue", - Indigo: "Indigo", - Purple: "Purple", - Pink: "Pink", - "Search...": "Search...", - "Avg. Ping": "Avg. Ping", - "Avg. Response": "Avg. Response", - "Entry Page": "Entry Page", - statusPageNothing: "Nothing here, please add a group or a monitor.", - "No Services": "No Services", - "All Systems Operational": "All Systems Operational", - "Partially Degraded Service": "Partially Degraded Service", - "Degraded Service": "Degraded Service", - "Add Group": "Add Group", - "Add a monitor": "Add a monitor", - "Edit Status Page": "Edit Status Page", - "Go to Dashboard": "Go to Dashboard", - "Status Page": "Status Page", - telegram: "Telegram", - webhook: "Webhook", - smtp: "Email (SMTP)", - discord: "Discord", - teams: "Microsoft Teams", - signal: "Signal", - gotify: "Gotify", - slack: "Slack", - "rocket.chat": "Rocket.chat", - pushover: "Pushover", - pushy: "Pushy", - octopush: "Octopush", - promosms: "PromoSMS", - lunasea: "LunaSea", - apprise: "Apprise (Support 50+ Notification services)", - pushbullet: "Pushbullet", - line: "Line Messenger", - mattermost: "Mattermost", }; diff --git a/src/languages/uk-UA.js b/src/languages/uk-UA.js new file mode 100644 index 00000000..ac18a29c --- /dev/null +++ b/src/languages/uk-UA.js @@ -0,0 +1,392 @@ +export default { + languageName: "Український", + checkEverySecond: "Перевірка кожні {0} секунд", + retriesDescription: "Максимальна кількість спроб перед позначенням сервісу як недоступного та надсиланням повідомлення", + ignoreTLSError: "Ігнорувати помилку TLS/SSL для сайтів HTTPS", + upsideDownModeDescription: "Реверс статусу сервісу. Якщо сервіс доступний, він позначається як НЕДОСТУПНИЙ.", + maxRedirectDescription: "Максимальна кількість перенаправлень. Поставте 0, щоб вимкнути перенаправлення.", + acceptedStatusCodesDescription: "Виберіть коди статусів для визначення доступності сервісу.", + passwordNotMatchMsg: "Повторення паролю не збігається.", + notificationDescription: "Прив'яжіть повідомлення до моніторів.", + keywordDescription: "Пошук слова в чистому HTML або JSON-відповіді (чутливо до регістру)", + pauseDashboardHome: "Пауза", + deleteMonitorMsg: "Ви дійсно хочете видалити цей монітор?", + deleteNotificationMsg: "Ви дійсно хочете видалити це повідомлення для всіх моніторів?", + resolverserverDescription: "Cloudflare є сервером за замовчуванням. Ви завжди можете змінити цей сервер.", + rrtypeDescription: "Виберіть тип ресурсного запису, який ви хочете відстежувати", + pauseMonitorMsg: "Ви дійсно хочете поставити на паузу?", + Settings: "Налаштування", + Dashboard: "Панель управління", + "New Update": "Оновлення", + Language: "Мова", + Appearance: "Зовнішній вигляд", + Theme: "Тема", + General: "Загальне", + Version: "Версія", + "Check Update On GitHub": "Перевірити оновлення на GitHub", + List: "Список", + Add: "Додати", + "Add New Monitor": "Новий монітор", + "Quick Stats": "Статистика", + Up: "Доступний", + Down: "Недоступний", + Pending: "Очікування", + Unknown: "Невідомо", + Pause: "Пауза", + Name: "Ім'я", + Status: "Статус", + DateTime: "Дата і час", + Message: "Повідомлення", + "No important events": "Важливих подій немає", + Resume: "Відновити", + Edit: "Змінити", + Delete: "Видалити", + Current: "Поточний", + Uptime: "Аптайм", + "Cert Exp.": "Сертифікат спливає", + days: "днів", + day: "день", + "-day": " днів", + hour: "година", + "-hour": " години", + Response: "Відповідь", + Ping: "Пінг", + "Monitor Type": "Тип монітора", + Keyword: "Ключове слово", + "Friendly Name": "Ім'я", + URL: "URL", + Hostname: "Ім'я хоста", + Port: "Порт", + "Heartbeat Interval": "Частота опитування", + Retries: "Спроб", + Advanced: "Додатково", + "Upside Down Mode": "Реверс статусу", + "Max. Redirects": "Макс. кількість перенаправлень", + "Accepted Status Codes": "Припустимі коди статусу", + Save: "Зберегти", + Notifications: "Повідомлення", + "Not available, please setup.": "Доступних сповіщень немає, необхідно створити.", + "Setup Notification": "Створити сповіщення", + Light: "Світла", + Dark: "Темна", + Auto: "Авто", + "Theme - Heartbeat Bar": "Тема - Смуга частоти опитування", + Normal: "Звичайний", + Bottom: "Знизу", + None: "Відсутня", + Timezone: "Часовий пояс", + "Search Engine Visibility": "Індексація пошуковими системами:", + "Allow indexing": "Дозволити індексування", + "Discourage search engines from indexing site": "Заборонити індексування", + "Change Password": "Змінити пароль", + "Current Password": "Поточний пароль", + "New Password": "Новий пароль", + "Repeat New Password": "Повтор нового пароля", + "Update Password": "Оновити пароль", + "Disable Auth": "Вимкнути авторизацію", + "Enable Auth": "Увімкнути авторизацію", + Logout: "Вийти", + Leave: "Відміна", + "I understand, please disable": "Я розумію, все одно відключити", + Confirm: "Підтвердити", + Yes: "Так", + No: "Ні", + Username: "Логін", + Password: "Пароль", + "Remember me": "Запам'ятати мене", + Login: "Вхід до системи", + "No Monitors, please": "Моніторів немає, будь ласка", + "No Monitors": "Монітори відсутні", + "add one": "створіть новий", + "Notification Type": "Тип повідомлення", + Email: "Пошта", + Test: "Перевірка", + "Certificate Info": "Інформація про сертифікат", + "Resolver Server": "DNS сервер", + "Resource Record Type": "Тип ресурсного запису", + "Last Result": "Останній результат", + "Create your admin account": "Створіть обліковий запис адміністратора", + "Repeat Password": "Повторіть пароль", + respTime: "Час відповіді (мс)", + notAvailableShort: "Н/д", + Create: "Створити", + clearEventsMsg: "Ви дійсно хочете видалити всю статистику подій цього монітора?", + clearHeartbeatsMsg: "Ви дійсно хочете видалити всю статистику опитувань цього монітора?", + confirmClearStatisticsMsg: "Ви дійсно хочете видалити ВСЮ статистику?", + "Clear Data": "Видалити статистику", + Events: "Події", + Heartbeats: "Опитування", + "Auto Get": "Авто-отримання", + enableDefaultNotificationDescription: "Для кожного нового монітора це повідомлення буде включено за замовчуванням. Ви все ще можете відключити повідомлення в кожному моніторі окремо.", + "Default enabled": "Використовувати за промовчанням", + "Also apply to existing monitors": "Застосувати до існуючих моніторів", + Export: "Експорт", + Import: "Імпорт", + backupDescription: "Ви можете зберегти резервну копію всіх моніторів та повідомлень у вигляді JSON-файлу", + backupDescription2: "P.S.: Історія та події збережені не будуть", + backupDescription3: "Важливі дані, такі як токени повідомлень, додаються під час експорту, тому зберігайте файли в безпечному місці", + alertNoFile: "Виберіть файл для імпорту.", + alertWrongFileType: "Виберіть JSON-файл.", + twoFAVerifyLabel: "Будь ласка, введіть свій токен, щоб перевірити роботу 2FA", + tokenValidSettingsMsg: "Токен дійсний! Тепер ви можете зберегти налаштування 2FA.", + confirmEnableTwoFAMsg: "Ви дійсно хочете увімкнути 2FA?", + confirmDisableTwoFAMsg: "Ви дійсно хочете вимкнути 2FA?", + "Apply on all existing monitors": "Застосувати до всіх існуючих моніторів", + "Verify Token": "Перевірити токен", + "Setup 2FA": "Налаштування 2FA", + "Enable 2FA": "Увімкнути 2FA", + "Disable 2FA": "Вимкнути 2FA", + "2FA Settings": "Налаштування 2FA", + "Two Factor Authentication": "Двофакторна аутентифікація", + Active: "Активно", + Inactive: "Неактивно", + Token: "Токен", + "Show URI": "Показати URI", + "Clear all statistics": "Очистити статистику", + retryCheckEverySecond: "Повтор кожні {0} секунд", + importHandleDescription: "Виберіть \"Пропустити існуючі\", якщо ви хочете пропустити кожен монітор або повідомлення з таким же ім'ям. \"Перезаписати\" видалить кожен існуючий монітор або повідомлення та додасть заново. Варіант \"Не перевіряти\" примусово відновлює всі монітори і повідомлення, навіть якщо вони вже існують.", + confirmImportMsg: "Ви дійсно хочете відновити резервну копію? Переконайтеся, що ви вибрали відповідний варіант імпорту.", + "Heartbeat Retry Interval": "Інтервал повтору опитування", + "Import Backup": "Імпорт", + "Export Backup": "Експорт", + "Skip existing": "Пропустити існуючі", + Overwrite: "Перезаписати", + Options: "Опції", + "Keep both": "Не перевіряти", + Tags: "Теги", + "Add New below or Select...": "Додати новий або вибрати...", + "Tag with this name already exist.": "Такий тег вже існує.", + "Tag with this value already exist.": "Тег із таким значенням вже існує.", + color: "колір", + "value (optional)": "значення (опціонально)", + Gray: "Сірий", + Red: "Червоний", + Orange: "Помаранчевий", + Green: "Зелений", + Blue: "Синій", + Indigo: "Індиго", + Purple: "Пурпурний", + Pink: "Рожевий", + "Search...": "Пошук...", + "Avg. Ping": "Середнє значення пінгу", + "Avg. Response": "Середній час відповіді", + "Entry Page": "Головна сторінка", + statusPageNothing: "Тут порожньо. Додайте групу або монітор.", + "No Services": "Немає сервісів", + "All Systems Operational": "Всі системи працюють у штатному режимі", + "Partially Degraded Service": "Сервіси працюють частково", + "Degraded Service": "Всі сервіси не працюють", + "Add Group": "Додати групу", + "Add a monitor": "Додати монітор", + "Edit Status Page": "Редагувати", + "Go to Dashboard": "Панель управління", + "Status Page": "Сторінка статусу", + "Status Pages": "Сторінки статусу", + Discard: "Скасування", + "Create Incident": "Створити інцидент", + "Switch to Dark Theme": "Темна тема", + "Switch to Light Theme": "Світла тема", + telegram: "Telegram", + webhook: "Вебхук", + smtp: "Email (SMTP)", + discord: "Discord", + teams: "Microsoft Teams", + signal: "Signal", + gotify: "Gotify", + slack: "Slack", + "rocket.chat": "Rocket.chat", + pushover: "Pushover", + pushy: "Pushy", + octopush: "Octopush", + promosms: "PromoSMS", + lunasea: "LunaSea", + apprise: "Apprise (Підтримка 50+ сервісів повідомлень)", + pushbullet: "Pushbullet", + line: "Line Messenger", + mattermost: "Mattermost", + "Primary Base URL": "Основна URL", + "Push URL": "URL пуша", + needPushEvery: "До цієї URL необхідно звертатися кожні {0} секунд", + pushOptionalParams: "Опціональні параметри: {0}", + defaultNotificationName: "Моє повідомлення {notification} ({number})", + here: "тут", + Required: "Потрібно", + "Bot Token": "Токен бота", + wayToGetTelegramToken: "Ви можете взяти токен тут - {0}.", + "Chat ID": "ID чату", + supportTelegramChatID: "Підтримуються ID чатів, груп та каналів", + wayToGetTelegramChatID: "Ви можете взяти ID вашого чату, відправивши повідомлення боту і перейшовши по цьому URL для перегляду chat_id:", + "YOUR BOT TOKEN HERE": "ВАШ ТОКЕН БОТА ТУТ", + chatIDNotFound: "ID чату не знайдено; будь ласка, відправте спочатку повідомлення боту", + "Post URL": "Post URL", + "Content Type": "Тип контенту", + webhookJsonDesc: "{0} підходить для будь-яких сучасних HTTP-серверів, наприклад Express.js", + webhookFormDataDesc: "{multipart} підходить для PHP. JSON-вивід необхідно буде обробити за допомогою {decodeFunction}", + secureOptionNone: "Ні / STARTTLS (25, 587)", + secureOptionTLS: "TLS (465)", + "Ignore TLS Error": "Ігнорувати помилки TLS", + "From Email": "Від кого", + emailCustomSubject: "Своя тема", + "To Email": "Кому", + smtpCC: "Копія", + smtpBCC: "Прихована копія", + "Discord Webhook URL": "Discord Вебхук URL", + wayToGetDiscordURL: "Ви можете створити його в Параметрах сервера -> Інтеграції -> Створити вебхук", + "Bot Display Name": "Ім'я бота, що відображається", + "Prefix Custom Message": "Свій префікс повідомлення", + "Hello @everyone is...": "Привіт {'@'}everyone це...", + "Webhook URL": "URL вебхука", + wayToGetTeamsURL: "Як створити URL вебхука ви можете дізнатися тут - {0}.", + Номер: "Номер", + Recipients: "Одержувачі", + needSignalAPI: "Вам необхідний клієнт Signal із підтримкою REST API.", + wayToCheckSignalURL: "Пройдіть по цьому URL, щоб дізнатися як налаштувати такий клієнт:", + signalImportant: "ВАЖЛИВО: Не можна змішувати в Одержувачах групи та номери!", + "Application Token": "Токен програми", + "Server URL": "URL сервера", + Priority: "Пріоритет", + "Icon Emoji": "Іконка Emoji", + "Channel Name": "Ім'я каналу", + "Uptime Kuma URL": "Uptime Kuma URL", + aboutWebhooks: "Більше інформації про вебхуки: {0}", + aboutChannelName: "Введіть ім'я каналу в поле {0} Ім'я каналу, якщо ви хочете обійти канал вебхука. Наприклад: #other-channel", + aboutKumaURL: "Якщо поле Uptime Kuma URL в налаштуваннях залишиться порожнім, за замовчуванням буде використовуватися посилання на проект на GitHub.", + emojiCheatSheet: "Шпаргалка по Emoji: {0}", + "User Key": "Ключ користувача", + Device: "Пристрій", + "Message Title": "Заголовок повідомлення", + "Notification Sound": "Звук повідомлення", + "More info on:": "Більше інформації: {0}", + pushoverDesc1: "Екстренний пріоритет (2) має таймуут повтору за замовчуванням 30 секунд і закінчується через 1 годину.", + pushoverDesc2: "Якщо ви бажаєте надсилати повідомлення різним пристроям, необхідно заповнити поле Пристрій.", + "SMS Type": "Тип SMS", + octopushTypePremium: "Преміум (Швидкий - рекомендується для алертів)", + octopushTypeLowCost: "Дешевий (Повільний - іноді блокується операторами)", + checkPrice: "Тарифи {0}:", + octopushLegacyHint: "Ви використовуєте стару версію Octopush (2011-2020) або нову?", + "Check octopush prices": "Тарифи Octopush {0}.", + octopushPhoneNumber: "Номер телефону (між. формат, наприклад: +380123456789)", + octopushSMSSender: "Ім'я відправника SMS: 3-11 символів алвафіту, цифр та пробілів (a-zA-Z0-9)", + "LunaSea Device ID": "ID пристрою LunaSea", + "Apprise URL": "Apprise URL", + "Example:": "Приклад: {0}", + "Read more:": "Докладніше: {0}", + "Status:": "Статус: {0}", + "Read more": "Докладніше", + appriseInstalled: "Apprise встановлено.", + appriseNotInstalled: "Apprise не встановлено. {0}", + "Access Token": "Токен доступу", + "Channel access token": "Токен доступу каналу", + "Line Developers Console": "Консоль розробників Line", + lineDevConsoleTo: "Консоль розробників Line - {0}", + "Basic Settings": "Базові налаштування", + "User ID": "ID користувача", + "Messaging API": "API повідомлень", + wayToGetLineChannelToken: "Спочатку зайдіть в {0}, створіть провайдера та канал (API повідомлень), потім ви зможете отримати токен доступу каналу та ID користувача з вищезгаданих пунктів меню.", + "Icon URL": "URL іконки", + aboutIconURL: "Ви можете надати посилання на іконку в полі \"URL іконки\", щоб перевизначити картинку профілю за замовчуванням. Не використовується, якщо задана іконка Emoji.", + aboutMattermostChannelName: "Ви можете перевизначити канал за замовчуванням, в який пише вебхук, ввівши ім'я каналу в полі \"Ім'я каналу\". Це необхідно включити в налаштуваннях вебхука Mattermost. Наприклад: #other-channel", + matrix: "Matrix", + promosmsTypeEco: "SMS ECO - дешево та повільно, часто перевантажений. Тільки для одержувачів з Польщі.", + promosmsTypeFlash: "SMS FLASH - повідомлення автоматично з'являться на пристрої одержувача. Тільки для одержувачів з Польщі.", + promosmsTypeFull: "SMS FULL - преміум-рівень SMS, можна використовувати своє ім'я відправника (попередньо зареєструвавши його). Надійно для алертів.", + promosmsTypeSpeed: "SMS SPEED - найвищий пріоритет у системі. Дуже швидко і надійно, але дуже дорого (вдвічі дорожче, ніж SMS FULL).", + promosmsPhoneNumber: "Номер телефону (для одержувачів з Польщі можна пропустити код регіону)", + promosmsSMSSender: "Ім'я відправника SMS: Зареєстроване або одне з імен за замовчуванням: InfoSMS, SMS Info, MaxSMS, INFO, SMS", + "Feishu WebHookURL": "Feishu WebHookURL", + matrixHomeserverURL: "URL сервера (разом з http(s):// і опціонально порт)", + "Internal Room Id": "Внутрішній ID кімнати", + matrixDesc1: "Внутрішній ID кімнати можна знайти в Подробицях у параметрах каналу вашого Matrix клієнта. Він повинен виглядати приблизно як !QMdRCpUIfLwsfjxye6:home.server.", + matrixDesc2: "Рекомендується створити нового користувача і не використовувати токен доступу особистого користувача Matrix, тому що це спричиняє повний доступ до облікового запису та до кімнат, в яких ви є. Замість цього створіть нового користувача і запросіть його тільки в ту кімнату, в якій ви хочете отримувати повідомлення.Токен доступу можна отримати, виконавши команду {0}", + Method: "Метод", + Body: "Тіло", + Headers: "Заголовки", + PushUrl: "URL пуша", + HeadersInvalidFormat: "Заголовки запиту некоректні JSON: ", + BodyInvalidFormat: "Тіло запиту некоректне JSON: ", + "Monitor History": "Статистика", + clearDataOlderThan: "Зберігати статистику за {0} днів.", + PasswordsDoNotMatch: "Паролі не співпадають.", + records: "записів", + "One record": "Один запис", + steamApiKeyDescription: "Для моніторингу ігрового сервера Steam вам потрібен Web-API ключ Steam. Зареєструвати його можна тут: ", + "Certificate Chain": "Ланцюжок сертифікатів", + Valid: "Дійсний", + "Hide Tags": "Приховати теги", + Title: "Назва інциденту:", + Content: "Зміст інциденту:", + Post: "Опублікувати", + Cancel: "Скасувати", + Created: "Створено", + Unpin: "Відкріпити", + "Show Tags": "Показати теги", + recent: "Зараз", + "3h": "3 години", + "6h": "6 годин", + "24h": "24 години", + "1w": "1 тиждень", + "No monitors available.": "Немає доступних моніторів", + "Add one": "Додати новий", + Backup: "Резервна копія", + Security: "Безпека", + "Shrink Database": "Стиснути базу даних", + "Current User": "Поточний користувач", + About: "Про програму", + Description: "Опис", + "Powered by": "Працює на основі скрипту від", + shrinkDatabaseDescription: "Включає VACUUM для бази даних SQLite. Якщо база даних була створена на версії 1.10.0 і більше, AUTO_VACUUM вже включений і ця дія не потрібна.", + Style: "Стиль", + info: "ІНФО", + warning: "УВАГА", + danger: "ПОМИЛКА", + primary: "ОСНОВНИЙ", + light: "СВІТЛИЙ", + dark: "ТЕМНИЙ", + "New Status Page": "Нова сторінка статусу", + "Show update if available": "Показувати доступні оновлення", + "Also check beta release": "Перевіряти оновлення для бета версій", + "Add New Status Page": "Додати сторінку статусу", + Next: "Далі", + "Acz characters: a-z 0-9 -": "Дозволені символи: a-z 0-9 -", + "Start or end with a-z 0-9 only": "Початок та закінчення імені лише на символи: a-z 0-9", + "No consecutive dashes --": "Заборонено використовувати тире --", + "HTTP Options": "HTTP Опції", + "Basic Auth": "HTTP Авторизація", + PushByTechulus: "Push by Techulus", + clicksendsms: "ClickSend SMS", + GoogleChat: "Google Chat (тільки Google Workspace)", + apiCredentials: "API реквізити", + Done: "Готово", + Info: "Інфо", + "Steam API Key": "Steam API-Ключ", + "Pick a RR-Type...": "Виберіть RR-тип...", + "Pick Accepted Status Codes...": "Виберіть прийняті коди стану...", + Default: "За замовчуванням", + "Please input title and content": "Будь ласка, введіть назву та зміст", + "Last Updated": "Останнє Оновлення", + "Untitled Group": "Група без назви", + Services: "Сервіси", + serwersms: "SerwerSMS.pl", + serwersmsAPIUser: "API Користувач (включаючи префікс webapi_)", + serwersmsAPIPassword: "API Пароль", + serwersmsPhoneNumber: "Номер телефону", + serwersmsSenderName: "SMS ім'я відправника (реєстрований через портал користувача)", + stackfield: "Stackfield", + smtpDkimSettings: "DKIM Налаштування", + smtpDkimDesc: "Повернутися до Nodemailer DKIM {0} для використання.", + documentation: "документація", + smtpDkimDomain: "Ім'я домена", + smtpDkimKeySelector: "Ключ", + smtpDkimPrivateKey: "Приватний ключ", + smtpDkimHashAlgo: "Алгоритм хеша (опціонально)", + smtpDkimheaderFieldNames: "Заголовок ключів для підпису (опціонально)", + smtpDkimskipFields: "Заколовок ключів не для підпису (опціонально)", + gorush: "Gorush", + alerta: "Alerta", + alertaApiEndpoint: "Кінцева точка API", + alertaEnvironment: "Середовище", + alertaApiKey: "Ключ API", + alertaAlertState: "Стан алерту", + alertaRecoverState: "Стан відновлення", + deleteStatusPageMsg: "Дійсно хочете видалити цю сторінку статусів?", +}; diff --git a/src/languages/vi-VN.js b/src/languages/vi-VN.js index c97c3c6c..4ef5a54e 100644 --- a/src/languages/vi-VN.js +++ b/src/languages/vi-VN.js @@ -183,6 +183,7 @@ export default { "Edit Status Page": "Sửa trang trạng thái", "Go to Dashboard": "Đi tới Dashboard", "Status Page": "Trang trạng thái", + "Status Pages": "Trang trạng thái", defaultNotificationName: "My {notification} Alerts ({number})", here: "tại đây", Required: "Bắt buộc", diff --git a/src/languages/zh-CN.js b/src/languages/zh-CN.js index 9133f5d0..1f2439b0 100644 --- a/src/languages/zh-CN.js +++ b/src/languages/zh-CN.js @@ -185,6 +185,7 @@ export default { "Edit Status Page": "编辑状态页面", "Go to Dashboard": "前往仪表盘", "Status Page": "状态页面", + "Status Pages": "状态页面", defaultNotificationName: "{notification} 通知({number})", here: "这里", Required: "必填", diff --git a/src/languages/zh-HK.js b/src/languages/zh-HK.js index cd574970..4def65d3 100644 --- a/src/languages/zh-HK.js +++ b/src/languages/zh-HK.js @@ -96,7 +96,7 @@ export default { Test: "測試", keywordDescription: "搜索 HTML 或 JSON 裡是否有出現關鍵字(注意英文大細階)", "Certificate Info": "憑證詳細資料", - deleteMonitorMsg: "是否確定刪除這個監測器", + deleteMonitorMsg: "是否確定刪除這個監測器?", deleteNotificationMsg: "是否確定刪除這個通知設定?如監測器啟用了這個通知,將會收不到通知。", "Resolver Server": "DNS 伺服器", "Resource Record Type": "DNS 記錄類型", @@ -180,6 +180,7 @@ export default { "Edit Status Page": "編輯 Status Page", "Go to Dashboard": "前往主控台", "Status Page": "Status Page", + "Status Pages": "Status Pages", telegram: "Telegram", webhook: "Webhook", smtp: "電郵 (SMTP)", @@ -198,4 +199,5 @@ export default { pushbullet: "Pushbullet", line: "Line Messenger", mattermost: "Mattermost", + deleteStatusPageMsg: "是否確定刪除這個 Status Page?", }; diff --git a/src/languages/zh-TW.js b/src/languages/zh-TW.js index 6f5bc099..ec730b0f 100644 --- a/src/languages/zh-TW.js +++ b/src/languages/zh-TW.js @@ -183,6 +183,7 @@ export default { "Edit Status Page": "編輯狀態頁", "Go to Dashboard": "前往儀表板", "Status Page": "狀態頁", + "Status Pages": "狀態頁", defaultNotificationName: "我的 {notification} 通知 ({number})", here: "此處", Required: "必填", diff --git a/src/layouts/Layout.vue b/src/layouts/Layout.vue index 1a769a0d..524fd8f2 100644 --- a/src/layouts/Layout.vue +++ b/src/layouts/Layout.vue @@ -3,6 +3,9 @@
{{ $root.connectionErrorMsg }} +
+ Using a Reverse Proxy? Check how to config it for WebSocket +
@@ -18,10 +21,10 @@