Merge remote-tracking branch 'origin/master' into feat/monitor-list-improved-filtering

pull/3312/head
Louis Lam 1 year ago
commit 80927332cb

@ -1,4 +1,4 @@
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Auto Test name: Auto Test
@ -21,8 +21,8 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [macos-latest, ubuntu-latest, windows-latest] os: [macos-latest, ubuntu-latest, windows-latest, ARM64]
node: [ 14, 16, 18, 20 ] node: [ 14, 18 ]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/ # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps: steps:
@ -33,7 +33,7 @@ jobs:
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
cache: 'npm' - run: npm install npm@latest -g
- run: npm install - run: npm install
- run: npm run build - run: npm run build
- run: npm test - run: npm test
@ -41,6 +41,29 @@ jobs:
HEADLESS_TEST: 1 HEADLESS_TEST: 1
JUST_FOR_TEST: ${{ secrets.JUST_FOR_TEST }} JUST_FOR_TEST: ${{ secrets.JUST_FOR_TEST }}
# As a lot of dev dependencies are not supported on ARMv7, we have to test it separately and just test if `npm ci --production` works
armv7-simple-test:
needs: [ check-linters ]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
matrix:
os: [ ARMv7 ]
node: [ 14.21.3, 18.16.1 ]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- run: git config --global core.autocrlf false # Mainly for Windows
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- run: npm install npm@latest -g
- run: npm ci --production
check-linters: check-linters:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -52,7 +75,6 @@ jobs:
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14 node-version: 14
cache: 'npm'
- run: npm install - run: npm install
- run: npm run lint - run: npm run lint
@ -67,7 +89,6 @@ jobs:
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14 node-version: 14
cache: 'npm'
- run: npm install - run: npm install
- run: npm run build - run: npm run build
- run: npm run cy:test - run: npm run cy:test
@ -83,7 +104,6 @@ jobs:
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14 node-version: 14
cache: 'npm'
- run: npm install - run: npm install
- run: npm run build - run: npm run build
- run: npm run cy:run:unit - run: npm run cy:run:unit

@ -47,17 +47,17 @@ Here are some references:
❌ Won't Merge ❌ Won't Merge
- A dedicated pr for translating existing languages (You can now translate on https://weblate.kuma.pet) - A dedicated pr for translating existing languages (You can now translate on https://weblate.kuma.pet)
- Do not pass auto test - Do not pass the auto test
- Any breaking changes - Any breaking changes
- Duplicated pull request - Duplicated pull requests
- Buggy - Buggy
- UI/UX is not close to Uptime Kuma - UI/UX is not close to Uptime Kuma
- Existing logic is completely modified or deleted for no reason - Modifications or deletions of existing logic without a valid reason.
- A function that is completely out of scope - Adding functions that is completely out of scope
- Convert existing code into other programming languages - Converting existing code into other programming languages
- Unnecessary large code changes (Hard to review, causes code conflicts to other pull requests) - Unnecessarily large code changes that are hard to review and cause conflicts with other PRs.
The above cases cannot cover all situations. The above cases may not cover all possible situations.
I (@louislam) have the final say. If your pull request does not meet my expectations, I will reject it, no matter how much time you spend on it. Therefore, it is essential to have a discussion beforehand. I (@louislam) have the final say. If your pull request does not meet my expectations, I will reject it, no matter how much time you spend on it. Therefore, it is essential to have a discussion beforehand.

@ -23,7 +23,7 @@ It is a temporary live demo, all data will be deleted after 10 minutes. Use the
## ⭐ Features ## ⭐ Features
* Monitoring uptime for HTTP(s) / TCP / HTTP(s) Keyword / Ping / DNS Record / Push / Steam Game Server / Docker Containers * Monitoring uptime for HTTP(s) / TCP / HTTP(s) Keyword / HTTP(s) Json Query / Ping / DNS Record / Push / Steam Game Server / Docker Containers
* Fancy, Reactive, Fast UI/UX * Fancy, Reactive, Fast UI/UX
* Notifications via Telegram, Discord, Gotify, Slack, Pushover, Email (SMTP), and [90+ notification services, click here for the full list](https://github.com/louislam/uptime-kuma/tree/master/src/components/notifications) * Notifications via Telegram, Discord, Gotify, Slack, Pushover, Email (SMTP), and [90+ notification services, click here for the full list](https://github.com/louislam/uptime-kuma/tree/master/src/components/notifications)
* 20 second intervals * 20 second intervals
@ -93,7 +93,7 @@ pm2 save && pm2 startup
### Windows Portable (x64) ### Windows Portable (x64)
https://github.com/louislam/uptime-kuma/releases/download/1.21.0/uptime-kuma-win64-portable-1.0.0.zip https://github.com/louislam/uptime-kuma/files/11886108/uptime-kuma-win64-portable-1.0.1.zip
### Advanced Installation ### Advanced Installation

@ -3,6 +3,7 @@ import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import visualizer from "rollup-plugin-visualizer"; import visualizer from "rollup-plugin-visualizer";
import viteCompression from "vite-plugin-compression"; import viteCompression from "vite-plugin-compression";
import commonjs from "vite-plugin-commonjs";
const postCssScss = require("postcss-scss"); const postCssScss = require("postcss-scss");
const postcssRTLCSS = require("postcss-rtlcss"); const postcssRTLCSS = require("postcss-rtlcss");
@ -18,6 +19,7 @@ export default defineConfig({
"FRONTEND_VERSION": JSON.stringify(process.env.npm_package_version), "FRONTEND_VERSION": JSON.stringify(process.env.npm_package_version),
}, },
plugins: [ plugins: [
commonjs(),
vue(), vue(),
legacy({ legacy({
targets: [ "since 2015" ], targets: [ "since 2015" ],
@ -42,6 +44,9 @@ export default defineConfig({
} }
}, },
build: { build: {
commonjsOptions: {
include: [ /.js$/ ],
},
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks(id, { getModuleInfo, getModuleIds }) { manualChunks(id, { getModuleInfo, getModuleIds }) {

@ -0,0 +1,7 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
ALTER TABLE monitor
ADD invert_keyword BOOLEAN default 0 not null;
COMMIT;

@ -0,0 +1,10 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
ALTER TABLE monitor
ADD json_path TEXT;
ALTER TABLE monitor
ADD expected_value VARCHAR(255);
COMMIT;

@ -26,6 +26,8 @@ RUN chmod +x /app/extra/entrypoint.sh
FROM louislam/uptime-kuma:base-debian AS release FROM louislam/uptime-kuma:base-debian AS release
WORKDIR /app WORKDIR /app
ENV UPTIME_KUMA_IS_CONTAINER=1
# Copy app files from build layer # Copy app files from build layer
COPY --from=build /app /app COPY --from=build /app /app
@ -70,7 +72,6 @@ RUN git clone https://github.com/louislam/uptime-kuma.git .
RUN npm ci RUN npm ci
EXPOSE 3000 3001 EXPOSE 3000 3001
VOLUME ["/app/data"]
HEALTHCHECK --interval=60s --timeout=30s --start-period=180s --retries=5 CMD extra/healthcheck HEALTHCHECK --interval=60s --timeout=30s --start-period=180s --retries=5 CMD extra/healthcheck
CMD ["npm", "run", "start-pr-test"] CMD ["npm", "run", "start-pr-test"]

@ -1,3 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd"> <Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Costura /> <Costura DisableCompression='true' IncludeDebugSymbols='false' />
</Weavers> </Weavers>

@ -6,9 +6,9 @@ using System.Runtime.InteropServices;
// set of attributes. Change these attribute values to modify the information // set of attributes. Change these attribute values to modify the information
// associated with an assembly. // associated with an assembly.
[assembly: AssemblyTitle("Uptime Kuma")] [assembly: AssemblyTitle("Uptime Kuma")]
[assembly: AssemblyDescription("")] [assembly: AssemblyDescription("A portable executable for running Uptime Kuma")]
[assembly: AssemblyConfiguration("")] [assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")] [assembly: AssemblyCompany("Uptime Kuma")]
[assembly: AssemblyProduct("Uptime Kuma")] [assembly: AssemblyProduct("Uptime Kuma")]
[assembly: AssemblyCopyright("Copyright © 2023 Louis Lam")] [assembly: AssemblyCopyright("Copyright © 2023 Louis Lam")]
[assembly: AssemblyTrademark("")] [assembly: AssemblyTrademark("")]
@ -20,7 +20,7 @@ using System.Runtime.InteropServices;
[assembly: ComVisible(false)] [assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM // The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("2DB53988-1D93-4AC0-90C4-96ADEAAC5C04")] [assembly: Guid("86B40AFB-61FC-433D-8C31-650B0F32EA8F")]
// Version information for an assembly consists of the following four values: // Version information for an assembly consists of the following four values:
// //
@ -32,5 +32,5 @@ using System.Runtime.InteropServices;
// You can specify all the values or you can default the Build and Revision Numbers // You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below: // by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")] // [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")] [assembly: AssemblyVersion("1.0.1.0")]
[assembly: AssemblyFileVersion("1.0.0.0")] [assembly: AssemblyFileVersion("1.0.1.0")]

@ -0,0 +1,9 @@
// Check if docker is running
const { exec } = require("child_process");
exec("docker ps", (err, stdout, stderr) => {
if (err) {
console.error("Docker is not running. Please start docker and try again.");
process.exit(1);
}
});

13158
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,13 +1,13 @@
{ {
"name": "uptime-kuma", "name": "uptime-kuma",
"version": "1.22.0-beta.0", "version": "1.22.1",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/louislam/uptime-kuma.git" "url": "https://github.com/louislam/uptime-kuma.git"
}, },
"engines": { "engines": {
"node": "14.* || >=16.*" "node": "14.* || 16.* || 18.*"
}, },
"scripts": { "scripts": {
"install-legacy": "npm install", "install-legacy": "npm install",
@ -34,12 +34,12 @@
"build-docker-builder-go": "docker buildx build -f docker/builder-go.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:builder-go . --push", "build-docker-builder-go": "docker buildx build -f docker/builder-go.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:builder-go . --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-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-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": "node ./extra/test-docker.js && 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-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", "build-docker-nightly-amd64": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain",
"build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test --target pr-test . --push", "build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test --target pr-test . --push",
"upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain", "upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain",
"setup": "git checkout 1.21.3 && npm ci --production && npm run download-dist", "setup": "git checkout 1.22.1 && npm ci --production && npm run download-dist",
"download-dist": "node extra/download-dist.js", "download-dist": "node extra/download-dist.js",
"mark-as-nightly": "node extra/mark-as-nightly.js", "mark-as-nightly": "node extra/mark-as-nightly.js",
"reset-password": "node extra/reset-password.js", "reset-password": "node extra/reset-password.js",
@ -54,8 +54,8 @@
"simple-mqtt-server": "node extra/simple-mqtt-server.js", "simple-mqtt-server": "node extra/simple-mqtt-server.js",
"update-language-files": "cd extra/update-language-files && node index.js && cross-env-shell eslint ../../src/languages/$npm_config_language.js --fix", "update-language-files": "cd extra/update-language-files && node index.js && cross-env-shell eslint ../../src/languages/$npm_config_language.js --fix",
"ncu-patch": "npm-check-updates -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-final": "node ./extra/test-docker.js && 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", "release-beta": "node ./extra/test-docker.js && 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", "git-remove-tag": "git tag -d",
"build-dist-and-restart": "npm run build && npm run start-server-dev", "build-dist-and-restart": "npm run build && npm run start-server-dev",
"start-pr-test": "node extra/checkout-pr.js && npm install && npm run dev", "start-pr-test": "node extra/checkout-pr.js && npm install && npm run dev",
@ -84,27 +84,29 @@
"command-exists": "~1.2.9", "command-exists": "~1.2.9",
"compare-versions": "~3.6.0", "compare-versions": "~3.6.0",
"compression": "~1.7.4", "compression": "~1.7.4",
"croner": "^6.0.3", "croner": "~6.0.5",
"dayjs": "~1.11.5", "dayjs": "~1.11.5",
"dotenv": "~16.0.3", "dotenv": "~16.0.3",
"express": "~4.17.3", "express": "~4.17.3",
"express-basic-auth": "~1.2.1", "express-basic-auth": "~1.2.1",
"express-static-gzip": "~2.1.7", "express-static-gzip": "~2.1.7",
"form-data": "~4.0.0", "form-data": "~4.0.0",
"gamedig": "^4.0.5", "gamedig": "~4.0.5",
"http-graceful-shutdown": "~3.1.7", "http-graceful-shutdown": "~3.1.7",
"http-proxy-agent": "~5.0.0", "http-proxy-agent": "~5.0.0",
"https-proxy-agent": "~5.0.1", "https-proxy-agent": "~5.0.1",
"iconv-lite": "~0.6.3", "iconv-lite": "~0.6.3",
"jsesc": "~3.0.2", "jsesc": "~3.0.2",
"jsonata": "^2.0.3",
"jsonwebtoken": "~9.0.0", "jsonwebtoken": "~9.0.0",
"jwt-decode": "~3.1.2", "jwt-decode": "~3.1.2",
"limiter": "~2.1.0", "limiter": "~2.1.0",
"liquidjs": "^10.7.0",
"mongodb": "~4.14.0", "mongodb": "~4.14.0",
"mqtt": "~4.3.7", "mqtt": "~4.3.7",
"mssql": "~8.1.4", "mssql": "~8.1.4",
"mysql2": "~2.3.3", "mysql2": "~2.3.3",
"nanoid": "^3.3.4", "nanoid": "~3.3.4",
"node-cloudflared-tunnel": "~1.0.9", "node-cloudflared-tunnel": "~1.0.9",
"node-radius-client": "~1.0.0", "node-radius-client": "~1.0.0",
"nodemailer": "~6.6.5", "nodemailer": "~6.6.5",
@ -112,11 +114,12 @@
"password-hash": "~1.2.2", "password-hash": "~1.2.2",
"pg": "~8.8.0", "pg": "~8.8.0",
"pg-connection-string": "~2.5.0", "pg-connection-string": "~2.5.0",
"playwright-core": "~1.35.1",
"prom-client": "~13.2.0", "prom-client": "~13.2.0",
"prometheus-api-metrics": "~3.2.1", "prometheus-api-metrics": "~3.2.1",
"protobufjs": "~7.1.1", "protobufjs": "~7.2.4",
"qs": "~6.10.4", "qs": "~6.10.4",
"redbean-node": "~0.2.0", "redbean-node": "~0.3.0",
"redis": "~4.5.1", "redis": "~4.5.1",
"socket.io": "~4.6.1", "socket.io": "~4.6.1",
"socket.io-client": "~4.6.1", "socket.io-client": "~4.6.1",
@ -127,7 +130,7 @@
}, },
"devDependencies": { "devDependencies": {
"@actions/github": "~5.0.1", "@actions/github": "~5.0.1",
"@babel/eslint-parser": "~7.17.0", "@babel/eslint-parser": "^7.22.7",
"@babel/preset-env": "^7.15.8", "@babel/preset-env": "^7.15.8",
"@fortawesome/fontawesome-svg-core": "~1.2.36", "@fortawesome/fontawesome-svg-core": "~1.2.36",
"@fortawesome/free-regular-svg-icons": "~5.15.4", "@fortawesome/free-regular-svg-icons": "~5.15.4",
@ -135,9 +138,9 @@
"@fortawesome/vue-fontawesome": "~3.0.0-5", "@fortawesome/vue-fontawesome": "~3.0.0-5",
"@popperjs/core": "~2.10.2", "@popperjs/core": "~2.10.2",
"@types/bootstrap": "~5.1.9", "@types/bootstrap": "~5.1.9",
"@vitejs/plugin-legacy": "~2.1.0", "@vitejs/plugin-legacy": "~4.1.0",
"@vitejs/plugin-vue": "~3.1.0", "@vitejs/plugin-vue": "~4.2.3",
"@vue/compiler-sfc": "~3.2.36", "@vue/compiler-sfc": "~3.3.4",
"@vuepic/vue-datepicker": "~3.4.8", "@vuepic/vue-datepicker": "~3.4.8",
"aedes": "^0.46.3", "aedes": "^0.46.3",
"babel-plugin-rewire": "~1.2.0", "babel-plugin-rewire": "~1.2.0",
@ -148,16 +151,16 @@
"core-js": "~3.26.1", "core-js": "~3.26.1",
"cronstrue": "~2.24.0", "cronstrue": "~2.24.0",
"cross-env": "~7.0.3", "cross-env": "~7.0.3",
"cypress": "^10.1.0", "cypress": "^12.17.0",
"delay": "^5.0.0", "delay": "^5.0.0",
"dns2": "~2.0.1", "dns2": "~2.0.1",
"dompurify": "~2.4.3", "dompurify": "~2.4.3",
"eslint": "~8.14.0", "eslint": "~8.14.0",
"eslint-plugin-vue": "~8.7.1", "eslint-plugin-vue": "~8.7.1",
"favico.js": "~0.3.10", "favico.js": "~0.3.10",
"jest": "~27.2.5", "jest": "~29.6.1",
"marked": "~4.2.5", "marked": "~4.2.5",
"node-ssh": "~13.0.1", "node-ssh": "~13.1.0",
"postcss-html": "~1.5.0", "postcss-html": "~1.5.0",
"postcss-rtlcss": "~3.7.2", "postcss-rtlcss": "~3.7.2",
"postcss-scss": "~4.0.4", "postcss-scss": "~4.0.4",
@ -165,15 +168,16 @@
"qrcode": "~1.5.0", "qrcode": "~1.5.0",
"rollup-plugin-visualizer": "^5.6.0", "rollup-plugin-visualizer": "^5.6.0",
"sass": "~1.42.1", "sass": "~1.42.1",
"stylelint": "~14.7.1", "stylelint": "^15.10.1",
"stylelint-config-standard": "~25.0.0", "stylelint-config-standard": "~25.0.0",
"terser": "~5.15.0", "terser": "~5.15.0",
"timezones-list": "~3.0.1", "timezones-list": "~3.0.1",
"typescript": "~4.4.4", "typescript": "~4.4.4",
"v-pagination-3": "~0.1.7", "v-pagination-3": "~0.1.7",
"vite": "~3.2.7", "vite": "~4.4.1",
"vite-plugin-commonjs": "^0.8.0",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vue": "~3.2.47", "vue": "~3.3.4",
"vue-chartjs": "~5.2.0", "vue-chartjs": "~5.2.0",
"vue-confirm-dialog": "~1.0.2", "vue-confirm-dialog": "~1.0.2",
"vue-contenteditable": "~3.0.4", "vue-contenteditable": "~3.0.4",

@ -2,6 +2,7 @@ const basicAuth = require("express-basic-auth");
const passwordHash = require("./password-hash"); const passwordHash = require("./password-hash");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { setting } = require("./util-server"); const { setting } = require("./util-server");
const { log } = require("../src/util");
const { loginRateLimiter, apiRateLimiter } = require("./rate-limiter"); const { loginRateLimiter, apiRateLimiter } = require("./rate-limiter");
const { Settings } = require("./settings"); const { Settings } = require("./settings");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
@ -81,12 +82,16 @@ function apiAuthorizer(username, password, callback) {
apiRateLimiter.pass(null, 0).then((pass) => { apiRateLimiter.pass(null, 0).then((pass) => {
if (pass) { if (pass) {
verifyAPIKey(password).then((valid) => { verifyAPIKey(password).then((valid) => {
if (!valid) {
log.warn("api-auth", "Failed API auth attempt: invalid API Key");
}
callback(null, valid); callback(null, valid);
// Only allow a set number of api requests per minute // Only allow a set number of api requests per minute
// (currently set to 60) // (currently set to 60)
apiRateLimiter.removeTokens(1); apiRateLimiter.removeTokens(1);
}); });
} else { } else {
log.warn("api-auth", "Failed API auth attempt: rate limit exceeded");
callback(null, false); callback(null, false);
} }
}); });
@ -106,10 +111,12 @@ function userAuthorizer(username, password, callback) {
callback(null, user != null); callback(null, user != null);
if (user == null) { if (user == null) {
log.warn("basic-auth", "Failed basic auth attempt: invalid username/password");
loginRateLimiter.removeTokens(1); loginRateLimiter.removeTokens(1);
} }
}); });
} else { } else {
log.warn("basic-auth", "Failed basic auth attempt: rate limit exceeded");
callback(null, false); callback(null, false);
} }
}); });

@ -1,27 +1,33 @@
const { setSetting, setting } = require("./util-server"); const { setSetting, setting } = require("./util-server");
const axios = require("axios"); const axios = require("axios");
const compareVersions = require("compare-versions"); const compareVersions = require("compare-versions");
const { log } = require("../src/util");
exports.version = require("../package.json").version; exports.version = require("../package.json").version;
exports.latestVersion = null; exports.latestVersion = null;
// How much time in ms to wait between update checks
const UPDATE_CHECKER_INTERVAL_MS = 1000 * 60 * 60 * 48;
const UPDATE_CHECKER_LATEST_VERSION_URL = "https://uptime.kuma.pet/version";
let interval; let interval;
/** Start 48 hour check interval */
exports.startInterval = () => { exports.startInterval = () => {
let check = async () => { let check = async () => {
if (await setting("checkUpdate") === false) {
return;
}
log.debug("update-checker", "Retrieving latest versions");
try { try {
const res = await axios.get("https://uptime.kuma.pet/version"); const res = await axios.get(UPDATE_CHECKER_LATEST_VERSION_URL);
// For debug // For debug
if (process.env.TEST_CHECK_VERSION === "1") { if (process.env.TEST_CHECK_VERSION === "1") {
res.data.slow = "1000.0.0"; res.data.slow = "1000.0.0";
} }
if (await setting("checkUpdate") === false) {
return;
}
let checkBeta = await setting("checkBeta"); let checkBeta = await setting("checkBeta");
if (checkBeta && res.data.beta) { if (checkBeta && res.data.beta) {
@ -35,12 +41,14 @@ exports.startInterval = () => {
exports.latestVersion = res.data.slow; exports.latestVersion = res.data.slow;
} }
} catch (_) { } } catch (_) {
log.info("update-checker", "Failed to check for new versions");
}
}; };
check(); check();
interval = setInterval(check, 3600 * 1000 * 48); interval = setInterval(check, UPDATE_CHECKER_INTERVAL_MS);
}; };
/** /**

@ -141,12 +141,21 @@ async function sendAPIKeyList(socket) {
/** /**
* Emits the version information to the client. * Emits the version information to the client.
* @param {Socket} socket Socket.io socket instance * @param {Socket} socket Socket.io socket instance
* @param {boolean} hideVersion
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async function sendInfo(socket) { async function sendInfo(socket, hideVersion = false) {
let version;
let latestVersion;
if (!hideVersion) {
version = checkVersion.version;
latestVersion = checkVersion.latestVersion;
}
socket.emit("info", { socket.emit("info", {
version: checkVersion.version, version,
latestVersion: checkVersion.latestVersion, latestVersion,
primaryBaseURL: await setting("primaryBaseURL"), primaryBaseURL: await setting("primaryBaseURL"),
serverTimezone: await server.getTimezone(), serverTimezone: await server.getTimezone(),
serverTimezoneOffset: server.getTimezoneOffset(), serverTimezoneOffset: server.getTimezoneOffset(),

@ -1,4 +1,5 @@
const args = require("args-parser")(process.argv); // Interop with browser
const args = (typeof process !== "undefined") ? require("args-parser")(process.argv) : {};
const demoMode = args["demo"] || false; const demoMode = args["demo"] || false;
const badgeConstants = { const badgeConstants = {

@ -3,7 +3,6 @@ const { R } = require("redbean-node");
const { setSetting, setting } = require("./util-server"); const { setSetting, setting } = require("./util-server");
const { log, sleep } = require("../src/util"); const { log, sleep } = require("../src/util");
const knex = require("knex"); const knex = require("knex");
const { PluginsManager } = require("./plugins-manager");
/** /**
* Database & App Data Folder * Database & App Data Folder
@ -22,6 +21,8 @@ class Database {
*/ */
static uploadDir; static uploadDir;
static screenshotDir;
static path; static path;
/** /**
@ -70,6 +71,8 @@ class Database {
"patch-monitor-tls.sql": true, "patch-monitor-tls.sql": true,
"patch-maintenance-cron.sql": true, "patch-maintenance-cron.sql": true,
"patch-add-parent-monitor.sql": true, "patch-add-parent-monitor.sql": true,
"patch-add-invert-keyword.sql": true,
"patch-added-json-query.sql": true,
}; };
/** /**
@ -88,12 +91,6 @@ class Database {
// Data Directory (must be end with "/") // Data Directory (must be end with "/")
Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/"; Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/";
// Plugin feature is working only if the dataDir = "./data";
if (Database.dataDir !== "./data/") {
log.warn("PLUGIN", "Warning: In order to enable plugin feature, you need to use the default data directory: ./data/");
PluginsManager.disable = true;
}
Database.path = Database.dataDir + "kuma.db"; Database.path = Database.dataDir + "kuma.db";
if (! fs.existsSync(Database.dataDir)) { if (! fs.existsSync(Database.dataDir)) {
fs.mkdirSync(Database.dataDir, { recursive: true }); fs.mkdirSync(Database.dataDir, { recursive: true });
@ -105,6 +102,12 @@ class Database {
fs.mkdirSync(Database.uploadDir, { recursive: true }); fs.mkdirSync(Database.uploadDir, { recursive: true });
} }
// Create screenshot dir
Database.screenshotDir = Database.dataDir + "screenshots/";
if (! fs.existsSync(Database.screenshotDir)) {
fs.mkdirSync(Database.screenshotDir, { recursive: true });
}
log.info("db", `Data Dir: ${Database.dataDir}`); log.info("db", `Data Dir: ${Database.dataDir}`);
} }
@ -161,12 +164,12 @@ class Database {
await R.exec("PRAGMA journal_mode = WAL"); await R.exec("PRAGMA journal_mode = WAL");
} }
await R.exec("PRAGMA cache_size = -12000"); await R.exec("PRAGMA cache_size = -12000");
await R.exec("PRAGMA auto_vacuum = FULL"); await R.exec("PRAGMA auto_vacuum = INCREMENTAL");
// This ensures that an operating system crash or power failure will not corrupt the database. // This ensures that an operating system crash or power failure will not corrupt the database.
// FULL synchronous is very safe, but it is also slower. // FULL synchronous is very safe, but it is also slower.
// Read more: https://sqlite.org/pragma.html#pragma_synchronous // Read more: https://sqlite.org/pragma.html#pragma_synchronous
await R.exec("PRAGMA synchronous = FULL"); await R.exec("PRAGMA synchronous = NORMAL");
if (!noLog) { if (!noLog) {
log.info("db", "SQLite config:"); log.info("db", "SQLite config:");

@ -1,24 +0,0 @@
const childProcess = require("child_process");
class Git {
static clone(repoURL, cwd, targetDir = ".") {
let result = childProcess.spawnSync("git", [
"clone",
repoURL,
targetDir,
], {
cwd: cwd,
});
if (result.status !== 0) {
throw new Error(result.stderr.toString("utf-8"));
} else {
return result.stdout.toString("utf-8") + result.stderr.toString("utf-8");
}
}
}
module.exports = {
Git,
};

@ -1,5 +1,6 @@
const { UptimeKumaServer } = require("./uptime-kuma-server"); const { UptimeKumaServer } = require("./uptime-kuma-server");
const { clearOldData } = require("./jobs/clear-old-data"); const { clearOldData } = require("./jobs/clear-old-data");
const { incrementalVacuum } = require("./jobs/incremental-vacuum");
const Cron = require("croner"); const Cron = require("croner");
const jobs = [ const jobs = [
@ -9,6 +10,12 @@ const jobs = [
jobFunc: clearOldData, jobFunc: clearOldData,
croner: null, croner: null,
}, },
{
name: "incremental-vacuum",
interval: "*/5 * * * *",
jobFunc: incrementalVacuum,
croner: null,
}
]; ];
/** /**

@ -39,6 +39,8 @@ const clearOldData = async () => {
"DELETE FROM heartbeat WHERE time < DATETIME('now', '-' || ? || ' days') ", "DELETE FROM heartbeat WHERE time < DATETIME('now', '-' || ? || ' days') ",
[ parsedPeriod ] [ parsedPeriod ]
); );
await R.exec("PRAGMA optimize;");
} catch (e) { } catch (e) {
log.error("clearOldData", `Failed to clear old data: ${e.message}`); log.error("clearOldData", `Failed to clear old data: ${e.message}`);
} }

@ -0,0 +1,21 @@
const { R } = require("redbean-node");
const { log } = require("../../src/util");
/**
* Run incremental_vacuum and checkpoint the WAL.
* @return {Promise<void>} A promise that resolves when the process is finished.
*/
const incrementalVacuum = async () => {
try {
log.debug("incrementalVacuum", "Running incremental_vacuum and wal_checkpoint(PASSIVE)...");
await R.exec("PRAGMA incremental_vacuum(200)");
await R.exec("PRAGMA wal_checkpoint(PASSIVE)");
} catch (e) {
log.error("incrementalVacuum", `Failed: ${e.message}`);
}
};
module.exports = {
incrementalVacuum,
};

@ -20,6 +20,8 @@ const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent");
const { DockerHost } = require("../docker"); const { DockerHost } = require("../docker");
const { UptimeCacheList } = require("../uptime-cache-list"); const { UptimeCacheList } = require("../uptime-cache-list");
const Gamedig = require("gamedig"); const Gamedig = require("gamedig");
const jsonata = require("jsonata");
const jwt = require("jsonwebtoken");
/** /**
* status: * status:
@ -70,6 +72,12 @@ class Monitor extends BeanModel {
const tags = await this.getTags(); const tags = await this.getTags();
let screenshot = null;
if (this.type === "real-browser") {
screenshot = "/screenshots/" + jwt.sign(this.id, UptimeKumaServer.getInstance().jwtSecret) + ".png";
}
let data = { let data = {
id: this.id, id: this.id,
name: this.name, name: this.name,
@ -90,6 +98,7 @@ class Monitor extends BeanModel {
retryInterval: this.retryInterval, retryInterval: this.retryInterval,
resendInterval: this.resendInterval, resendInterval: this.resendInterval,
keyword: this.keyword, keyword: this.keyword,
invertKeyword: this.isInvertKeyword(),
expiryNotification: this.isEnabledExpiryNotification(), expiryNotification: this.isEnabledExpiryNotification(),
ignoreTls: this.getIgnoreTls(), ignoreTls: this.getIgnoreTls(),
upsideDown: this.isUpsideDown(), upsideDown: this.isUpsideDown(),
@ -117,7 +126,10 @@ class Monitor extends BeanModel {
radiusCalledStationId: this.radiusCalledStationId, radiusCalledStationId: this.radiusCalledStationId,
radiusCallingStationId: this.radiusCallingStationId, radiusCallingStationId: this.radiusCallingStationId,
game: this.game, game: this.game,
httpBodyEncoding: this.httpBodyEncoding httpBodyEncoding: this.httpBodyEncoding,
jsonPath: this.jsonPath,
expectedValue: this.expectedValue,
screenshot,
}; };
if (includeSensitiveData) { if (includeSensitiveData) {
@ -199,6 +211,14 @@ class Monitor extends BeanModel {
return Boolean(this.upsideDown); return Boolean(this.upsideDown);
} }
/**
* Parse to boolean
* @returns {boolean}
*/
isInvertKeyword() {
return Boolean(this.invertKeyword);
}
/** /**
* Parse to boolean * Parse to boolean
* @returns {boolean} * @returns {boolean}
@ -303,7 +323,7 @@ class Monitor extends BeanModel {
bean.msg = "Group empty"; bean.msg = "Group empty";
} }
} else if (this.type === "http" || this.type === "keyword") { } else if (this.type === "http" || this.type === "keyword" || this.type === "json-query") {
// Do not do any queries/high loading things before the "bean.ping" // Do not do any queries/high loading things before the "bean.ping"
let startTime = dayjs().valueOf(); let startTime = dayjs().valueOf();
@ -431,7 +451,7 @@ class Monitor extends BeanModel {
if (this.type === "http") { if (this.type === "http") {
bean.status = UP; bean.status = UP;
} else { } else if (this.type === "keyword") {
let data = res.data; let data = res.data;
@ -440,17 +460,37 @@ class Monitor extends BeanModel {
data = JSON.stringify(data); data = JSON.stringify(data);
} }
if (data.includes(this.keyword)) { let keywordFound = data.includes(this.keyword);
bean.msg += ", keyword is found"; if (keywordFound === !this.isInvertKeyword()) {
bean.msg += ", keyword " + (keywordFound ? "is" : "not") + " found";
bean.status = UP; bean.status = UP;
} else { } else {
data = data.replace(/<[^>]*>?|[\n\r]|\s+/gm, " ").trim(); data = data.replace(/<[^>]*>?|[\n\r]|\s+/gm, " ").trim();
if (data.length > 50) { if (data.length > 50) {
data = data.substring(0, 47) + "..."; data = data.substring(0, 47) + "...";
} }
throw new Error(bean.msg + ", but keyword is not in [" + data + "]"); throw new Error(bean.msg + ", but keyword is " +
(keywordFound ? "present" : "not") + " in [" + data + "]");
} }
} else if (this.type === "json-query") {
let data = res.data;
// convert data to object
if (typeof data === "string") {
data = JSON.parse(data);
}
let expression = jsonata(this.jsonPath);
let result = await expression.evaluate(data);
if (result.toString() === this.expectedValue) {
bean.msg += ", expected value is found";
bean.status = UP;
} else {
throw new Error(bean.msg + ", but value is not equal to expected value, value was: [" + result + "]");
}
} }
} else if (this.type === "port") { } else if (this.type === "port") {
@ -525,7 +565,7 @@ class Monitor extends BeanModel {
// No need to insert successful heartbeat for push type, so end here // No need to insert successful heartbeat for push type, so end here
retries = 0; retries = 0;
log.debug("monitor", `[${this.name}] timeout = ${timeout}`); log.debug("monitor", `[${this.name}] timeout = ${timeout}`);
this.heartbeatInterval = setTimeout(beat, timeout); this.heartbeatInterval = setTimeout(safeBeat, timeout);
return; return;
} }
} else { } else {
@ -618,9 +658,15 @@ class Monitor extends BeanModel {
log.debug("monitor", `[${this.name}] Axios Request`); log.debug("monitor", `[${this.name}] Axios Request`);
let res = await axios.request(options); let res = await axios.request(options);
if (res.data.State.Running) { if (res.data.State.Running) {
if (res.data.State.Health && res.data.State.Health.Status !== "healthy") {
bean.status = PENDING;
bean.msg = res.data.State.Health.Status;
} else {
bean.status = UP; bean.status = UP;
bean.msg = res.data.State.Status; bean.msg = res.data.State.Health ? res.data.State.Health.Status : res.data.State.Status;
}
} else { } else {
throw Error("Container State is " + res.data.State.Status); throw Error("Container State is " + res.data.State.Status);
} }
@ -649,7 +695,6 @@ class Monitor extends BeanModel {
grpcEnableTls: this.grpcEnableTls, grpcEnableTls: this.grpcEnableTls,
grpcMethod: this.grpcMethod, grpcMethod: this.grpcMethod,
grpcBody: this.grpcBody, grpcBody: this.grpcBody,
keyword: this.keyword
}; };
const response = await grpcQuery(options); const response = await grpcQuery(options);
bean.ping = dayjs().valueOf() - startTime; bean.ping = dayjs().valueOf() - startTime;
@ -662,13 +707,14 @@ class Monitor extends BeanModel {
bean.status = DOWN; bean.status = DOWN;
bean.msg = `Error in send gRPC ${response.code} ${response.errorMessage}`; bean.msg = `Error in send gRPC ${response.code} ${response.errorMessage}`;
} else { } else {
if (response.data.toString().includes(this.keyword)) { let keywordFound = response.data.toString().includes(this.keyword);
if (keywordFound === !this.isInvertKeyword()) {
bean.status = UP; bean.status = UP;
bean.msg = `${responseData}, keyword [${this.keyword}] is found`; bean.msg = `${responseData}, keyword [${this.keyword}] ${keywordFound ? "is" : "not"} found`;
} else { } else {
log.debug("monitor:", `GRPC response [${response.data}] + ", but keyword [${this.keyword}] is not in [" + ${response.data} + "]"`); log.debug("monitor:", `GRPC response [${response.data}] + ", but keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${response.data} + "]"`);
bean.status = DOWN; bean.status = DOWN;
bean.msg = `, but keyword [${this.keyword}] is not in [" + ${responseData} + "]`; bean.msg = `, but keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${responseData} + "]`;
} }
} }
} else if (this.type === "postgres") { } else if (this.type === "postgres") {
@ -715,7 +761,8 @@ class Monitor extends BeanModel {
this.radiusCalledStationId, this.radiusCalledStationId,
this.radiusCallingStationId, this.radiusCallingStationId,
this.radiusSecret, this.radiusSecret,
port port,
this.interval * 1000 * 0.8,
); );
if (resp.code) { if (resp.code) {
bean.msg = resp.code; bean.msg = resp.code;
@ -740,7 +787,7 @@ class Monitor extends BeanModel {
} else if (this.type in UptimeKumaServer.monitorTypeList) { } else if (this.type in UptimeKumaServer.monitorTypeList) {
let startTime = dayjs().valueOf(); let startTime = dayjs().valueOf();
const monitorType = UptimeKumaServer.monitorTypeList[this.type]; const monitorType = UptimeKumaServer.monitorTypeList[this.type];
await monitorType.check(this, bean); await monitorType.check(this, bean, UptimeKumaServer.getInstance());
if (!bean.ping) { if (!bean.ping) {
bean.ping = dayjs().valueOf() - startTime; bean.ping = dayjs().valueOf() - startTime;
} }
@ -1463,6 +1510,17 @@ class Monitor extends BeanModel {
return childrenIDs; return childrenIDs;
} }
/**
* Unlinks all children of the the group monitor
* @param {number} groupID ID of group to remove children of
* @returns {Promise<void>}
*/
static async unlinkAllChildren(groupID) {
return await R.exec("UPDATE `monitor` SET parent = ? WHERE parent = ? ", [
null, groupID
]);
}
/** /**
* Checks recursive if parent (ancestors) are active * Checks recursive if parent (ancestors) are active
* @param {number} monitorID ID of the monitor to get * @param {number} monitorID ID of the monitor to get

@ -6,9 +6,10 @@ class MonitorType {
* *
* @param {Monitor} monitor * @param {Monitor} monitor
* @param {Heartbeat} heartbeat * @param {Heartbeat} heartbeat
* @param {UptimeKumaServer} server
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async check(monitor, heartbeat) { async check(monitor, heartbeat, server) {
throw new Error("You need to override check()"); throw new Error("You need to override check()");
} }

@ -0,0 +1,212 @@
const { MonitorType } = require("./monitor-type");
const { chromium } = require("playwright-core");
const { UP, log } = require("../../src/util");
const { Settings } = require("../settings");
const commandExistsSync = require("command-exists").sync;
const childProcess = require("child_process");
const path = require("path");
const Database = require("../database");
const jwt = require("jsonwebtoken");
const config = require("../config");
let browser = null;
let allowedList = [];
let lastAutoDetectChromeExecutable = null;
if (process.platform === "win32") {
allowedList.push(process.env.LOCALAPPDATA + "\\Google\\Chrome\\Application\\chrome.exe");
allowedList.push(process.env.PROGRAMFILES + "\\Google\\Chrome\\Application\\chrome.exe");
allowedList.push(process.env["ProgramFiles(x86)"] + "\\Google\\Chrome\\Application\\chrome.exe");
// Allow Chromium too
allowedList.push(process.env.LOCALAPPDATA + "\\Chromium\\Application\\chrome.exe");
allowedList.push(process.env.PROGRAMFILES + "\\Chromium\\Application\\chrome.exe");
allowedList.push(process.env["ProgramFiles(x86)"] + "\\Chromium\\Application\\chrome.exe");
// For Loop A to Z
for (let i = 65; i <= 90; i++) {
let drive = String.fromCharCode(i);
allowedList.push(drive + ":\\Program Files\\Google\\Chrome\\Application\\chrome.exe");
allowedList.push(drive + ":\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe");
}
} else if (process.platform === "linux") {
allowedList = [
"chromium",
"chromium-browser",
"google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome",
];
} else if (process.platform === "darwin") {
// TODO: Generated by GitHub Copilot, but not sure if it's correct
allowedList = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
];
}
log.debug("chrome", allowedList);
async function isAllowedChromeExecutable(executablePath) {
console.log(config.args);
if (config.args["allow-all-chrome-exec"] || process.env.UPTIME_KUMA_ALLOW_ALL_CHROME_EXEC === "1") {
return true;
}
// Check if the executablePath is in the list of allowed executables
return allowedList.includes(executablePath);
}
async function getBrowser() {
if (!browser) {
let executablePath = await Settings.get("chromeExecutable");
executablePath = await prepareChromeExecutable(executablePath);
browser = await chromium.launch({
//headless: false,
executablePath,
});
}
return browser;
}
async function prepareChromeExecutable(executablePath) {
// Special code for using the playwright_chromium
if (typeof executablePath === "string" && executablePath.toLocaleLowerCase() === "#playwright_chromium") {
// Set to undefined = use playwright_chromium
executablePath = undefined;
} else if (!executablePath) {
if (process.env.UPTIME_KUMA_IS_CONTAINER) {
executablePath = "/usr/bin/chromium";
// Install chromium in container via apt install
if ( !commandExistsSync(executablePath)) {
await new Promise((resolve, reject) => {
log.info("Chromium", "Installing Chromium...");
let child = childProcess.exec("apt update && apt --yes --no-install-recommends install chromium fonts-indic fonts-noto fonts-noto-cjk");
// On exit
child.on("exit", (code) => {
log.info("Chromium", "apt install chromium exited with code " + code);
if (code === 0) {
log.info("Chromium", "Installed Chromium");
let version = childProcess.execSync(executablePath + " --version").toString("utf8");
log.info("Chromium", "Chromium version: " + version);
resolve();
} else if (code === 100) {
reject(new Error("Installing Chromium, please wait..."));
} else {
reject(new Error("apt install chromium failed with code " + code));
}
});
});
}
} else {
executablePath = findChrome(allowedList);
}
} else {
// User specified a path
// Check if the executablePath is in the list of allowed
if (!await isAllowedChromeExecutable(executablePath)) {
throw new Error("This Chromium executable path is not allowed by default. If you are sure this is safe, please add an environment variable UPTIME_KUMA_ALLOW_ALL_CHROME_EXEC=1 to allow it.");
}
}
return executablePath;
}
function findChrome(executables) {
// Use the last working executable, so we don't have to search for it again
if (lastAutoDetectChromeExecutable) {
if (commandExistsSync(lastAutoDetectChromeExecutable)) {
return lastAutoDetectChromeExecutable;
}
}
for (let executable of executables) {
if (commandExistsSync(executable)) {
lastAutoDetectChromeExecutable = executable;
return executable;
}
}
throw new Error("Chromium not found, please specify Chromium executable path in the settings page.");
}
async function resetChrome() {
if (browser) {
await browser.close();
browser = null;
}
}
/**
* Test if the chrome executable is valid and return the version
* @param executablePath
* @returns {Promise<string>}
*/
async function testChrome(executablePath) {
try {
executablePath = await prepareChromeExecutable(executablePath);
log.info("Chromium", "Testing Chromium executable: " + executablePath);
const browser = await chromium.launch({
executablePath,
});
const version = browser.version();
await browser.close();
return version;
} catch (e) {
throw new Error(e.message);
}
}
/**
* TODO: connect remote browser? https://playwright.dev/docs/api/class-browsertype#browser-type-connect
*
*/
class RealBrowserMonitorType extends MonitorType {
name = "real-browser";
async check(monitor, heartbeat, server) {
const browser = await getBrowser();
const context = await browser.newContext();
const page = await context.newPage();
const res = await page.goto(monitor.url, {
waitUntil: "networkidle",
timeout: monitor.interval * 1000 * 0.8,
});
let filename = jwt.sign(monitor.id, server.jwtSecret) + ".png";
await page.screenshot({
path: path.join(Database.screenshotDir, filename),
});
await context.close();
if (res.status() >= 200 && res.status() < 400) {
heartbeat.status = UP;
heartbeat.msg = res.status();
const timing = res.request().timing();
heartbeat.ping = timing.responseEnd;
} else {
throw new Error(res.status() + "");
}
}
}
module.exports = {
RealBrowserMonitorType,
testChrome,
resetChrome,
};

@ -11,7 +11,7 @@ class HomeAssistant extends NotificationProvider {
try { try {
await axios.post( await axios.post(
`${notification.homeAssistantUrl}/api/services/notify/${notificationService}`, `${notification.homeAssistantUrl.trim().replace(/\/*$/, "")}/api/services/notify/${notificationService}`,
{ {
title: "Uptime Kuma", title: "Uptime Kuma",
message, message,

@ -27,6 +27,11 @@ class Slack extends NotificationProvider {
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
let okMsg = "Sent Successfully."; let okMsg = "Sent Successfully.";
if (notification.slackchannelnotify) {
msg += " <!channel>";
}
try { try {
if (heartbeatJSON == null) { if (heartbeatJSON == null) {
let data = { let data = {
@ -53,7 +58,7 @@ class Slack extends NotificationProvider {
"type": "header", "type": "header",
"text": { "text": {
"type": "plain_text", "type": "plain_text",
"text": "Uptime Kuma Alert", "text": textMsg,
}, },
}, },
{ {

@ -0,0 +1,42 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class SMSC extends NotificationProvider {
name = "smsc";
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
let okMsg = "Sent Successfully.";
try {
let config = {
headers: {
"Content-Type": "application/json",
"Accept": "text/json",
}
};
let getArray = [
"fmt=3",
"translit=" + notification.smscTranslit,
"login=" + notification.smscLogin,
"psw=" + notification.smscPassword,
"phones=" + notification.smscToNumber,
"mes=" + encodeURIComponent(msg.replace(/[^\x00-\x7F]/g, "")),
];
if (notification.smscSenderName !== "") {
getArray.push("sender=" + notification.smscSenderName);
}
let resp = await axios.get("https://smsc.kz/sys/send.php?" + getArray.join("&"), config);
if (resp.data.id === undefined) {
let error = `Something gone wrong. Api returned code ${resp.data.error_code}: ${resp.data.error}`;
this.throwGeneralAxiosError(error);
}
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = SMSC;

@ -67,7 +67,7 @@ class SMTP extends NotificationProvider {
if (monitorJSON !== null) { if (monitorJSON !== null) {
monitorName = monitorJSON["name"]; monitorName = monitorJSON["name"];
if (monitorJSON["type"] === "http" || monitorJSON["type"] === "keyword") { if (monitorJSON["type"] === "http" || monitorJSON["type"] === "keyword" || monitorJSON["type"] === "json-query") {
monitorHostnameOrURL = monitorJSON["url"]; monitorHostnameOrURL = monitorJSON["url"];
} else { } else {
monitorHostnameOrURL = monitorJSON["hostname"]; monitorHostnameOrURL = monitorJSON["hostname"];

@ -10,6 +10,7 @@ class Twilio extends NotificationProvider {
let okMsg = "Sent Successfully."; let okMsg = "Sent Successfully.";
let accountSID = notification.twilioAccountSID; let accountSID = notification.twilioAccountSID;
let apiKey = notification.twilioApiKey ? notification.twilioApiKey : accountSID;
let authToken = notification.twilioAuthToken; let authToken = notification.twilioAuthToken;
try { try {
@ -17,7 +18,7 @@ class Twilio extends NotificationProvider {
let config = { let config = {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8", "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
"Authorization": "Basic " + Buffer.from(accountSID + ":" + authToken).toString("base64"), "Authorization": "Basic " + Buffer.from(apiKey + ":" + authToken).toString("base64"),
} }
}; };

@ -1,6 +1,7 @@
const NotificationProvider = require("./notification-provider"); const NotificationProvider = require("./notification-provider");
const axios = require("axios"); const axios = require("axios");
const FormData = require("form-data"); const FormData = require("form-data");
const { Liquid } = require("liquidjs");
class Webhook extends NotificationProvider { class Webhook extends NotificationProvider {
@ -15,17 +16,27 @@ class Webhook extends NotificationProvider {
monitor: monitorJSON, monitor: monitorJSON,
msg, msg,
}; };
let finalData;
let config = { let config = {
headers: {} headers: {}
}; };
if (notification.webhookContentType === "form-data") { if (notification.webhookContentType === "form-data") {
finalData = new FormData(); const formData = new FormData();
finalData.append("data", JSON.stringify(data)); formData.append("data", JSON.stringify(data));
config.headers = finalData.getHeaders(); config.headers = formData.getHeaders();
} else { data = formData;
finalData = data; } else if (notification.webhookContentType === "custom") {
// Initialize LiquidJS and parse the custom Body Template
const engine = new Liquid();
const tpl = engine.parse(notification.webhookCustomBody);
// Insert templated values into Body
data = await engine.render(tpl,
{
msg,
heartbeatJSON,
monitorJSON
});
} }
if (notification.webhookAdditionalHeaders) { if (notification.webhookAdditionalHeaders) {
@ -39,7 +50,7 @@ class Webhook extends NotificationProvider {
} }
} }
await axios.post(notification.webhookURL, finalData, config); await axios.post(notification.webhookURL, data, config);
return okMsg; return okMsg;
} catch (error) { } catch (error) {

@ -6,6 +6,7 @@ const AliyunSms = require("./notification-providers/aliyun-sms");
const Apprise = require("./notification-providers/apprise"); const Apprise = require("./notification-providers/apprise");
const Bark = require("./notification-providers/bark"); const Bark = require("./notification-providers/bark");
const ClickSendSMS = require("./notification-providers/clicksendsms"); const ClickSendSMS = require("./notification-providers/clicksendsms");
const SMSC = require("./notification-providers/smsc");
const DingDing = require("./notification-providers/dingding"); const DingDing = require("./notification-providers/dingding");
const Discord = require("./notification-providers/discord"); const Discord = require("./notification-providers/discord");
const Feishu = require("./notification-providers/feishu"); const Feishu = require("./notification-providers/feishu");
@ -68,6 +69,7 @@ class Notification {
new Apprise(), new Apprise(),
new Bark(), new Bark(),
new ClickSendSMS(), new ClickSendSMS(),
new SMSC(),
new DingDing(), new DingDing(),
new Discord(), new Discord(),
new Feishu(), new Feishu(),

@ -1,13 +0,0 @@
class Plugin {
async load() {
}
async unload() {
}
}
module.exports = {
Plugin,
};

@ -1,256 +0,0 @@
const fs = require("fs");
const { log } = require("../src/util");
const path = require("path");
const axios = require("axios");
const { Git } = require("./git");
const childProcess = require("child_process");
class PluginsManager {
static disable = false;
/**
* Plugin List
* @type {PluginWrapper[]}
*/
pluginList = [];
/**
* Plugins Dir
*/
pluginsDir;
server;
/**
*
* @param {UptimeKumaServer} server
*/
constructor(server) {
this.server = server;
if (!PluginsManager.disable) {
this.pluginsDir = "./data/plugins/";
if (! fs.existsSync(this.pluginsDir)) {
fs.mkdirSync(this.pluginsDir, { recursive: true });
}
log.debug("plugin", "Scanning plugin directory");
let list = fs.readdirSync(this.pluginsDir);
this.pluginList = [];
for (let item of list) {
this.loadPlugin(item);
}
} else {
log.warn("PLUGIN", "Skip scanning plugin directory");
}
}
/**
* Install a Plugin
*/
async loadPlugin(name) {
log.info("plugin", "Load " + name);
let plugin = new PluginWrapper(this.server, this.pluginsDir + name);
try {
await plugin.load();
this.pluginList.push(plugin);
} catch (e) {
log.error("plugin", "Failed to load plugin: " + this.pluginsDir + name);
log.error("plugin", "Reason: " + e.message);
}
}
/**
* Download a Plugin
* @param {string} repoURL Git repo url
* @param {string} name Directory name, also known as plugin unique name
*/
downloadPlugin(repoURL, name) {
if (fs.existsSync(this.pluginsDir + name)) {
log.info("plugin", "Plugin folder already exists? Removing...");
fs.rmSync(this.pluginsDir + name, {
recursive: true
});
}
log.info("plugin", "Installing plugin: " + name + " " + repoURL);
let result = Git.clone(repoURL, this.pluginsDir, name);
log.info("plugin", "Install result: " + result);
}
/**
* Remove a plugin
* @param {string} name
*/
async removePlugin(name) {
log.info("plugin", "Removing plugin: " + name);
for (let plugin of this.pluginList) {
if (plugin.info.name === name) {
await plugin.unload();
// Delete the plugin directory
fs.rmSync(this.pluginsDir + name, {
recursive: true
});
this.pluginList.splice(this.pluginList.indexOf(plugin), 1);
return;
}
}
log.warn("plugin", "Plugin not found: " + name);
throw new Error("Plugin not found: " + name);
}
/**
* TODO: Update a plugin
* Only available for plugins which were downloaded from the official list
* @param pluginID
*/
updatePlugin(pluginID) {
}
/**
* Get the plugin list from server + local installed plugin list
* Item will be merged if the `name` is the same.
* @returns {Promise<[]>}
*/
async fetchPluginList() {
let remotePluginList;
try {
const res = await axios.get("https://uptime.kuma.pet/c/plugins.json");
remotePluginList = res.data.pluginList;
} catch (e) {
log.error("plugin", "Failed to fetch plugin list: " + e.message);
remotePluginList = [];
}
for (let plugin of this.pluginList) {
let find = false;
// Try to merge
for (let remotePlugin of remotePluginList) {
if (remotePlugin.name === plugin.info.name) {
find = true;
remotePlugin.installed = true;
remotePlugin.name = plugin.info.name;
remotePlugin.fullName = plugin.info.fullName;
remotePlugin.description = plugin.info.description;
remotePlugin.version = plugin.info.version;
break;
}
}
// Local plugin
if (!find) {
plugin.info.local = true;
remotePluginList.push(plugin.info);
}
}
// Sort Installed first, then sort by name
return remotePluginList.sort((a, b) => {
if (a.installed === b.installed) {
if (a.fullName < b.fullName) {
return -1;
}
if (a.fullName > b.fullName) {
return 1;
}
return 0;
} else if (a.installed) {
return -1;
} else {
return 1;
}
});
}
}
class PluginWrapper {
server = undefined;
pluginDir = undefined;
/**
* Must be an `new-able` class.
* @type {function}
*/
pluginClass = undefined;
/**
*
* @type {Plugin}
*/
object = undefined;
info = {};
/**
*
* @param {UptimeKumaServer} server
* @param {string} pluginDir
*/
constructor(server, pluginDir) {
this.server = server;
this.pluginDir = pluginDir;
}
async load() {
let indexFile = this.pluginDir + "/index.js";
let packageJSON = this.pluginDir + "/package.json";
log.info("plugin", "Installing dependencies");
if (fs.existsSync(indexFile)) {
// Install dependencies
let result = childProcess.spawnSync("npm", [ "install" ], {
cwd: this.pluginDir,
env: {
...process.env,
PLAYWRIGHT_BROWSERS_PATH: "../../browsers", // Special handling for read-browser-monitor
}
});
if (result.stdout) {
log.info("plugin", "Install dependencies result: " + result.stdout.toString("utf-8"));
} else {
log.warn("plugin", "Install dependencies result: no output");
}
this.pluginClass = require(path.join(process.cwd(), indexFile));
let pluginClassType = typeof this.pluginClass;
if (pluginClassType === "function") {
this.object = new this.pluginClass(this.server);
await this.object.load();
} else {
throw new Error("Invalid plugin, it does not export a class");
}
if (fs.existsSync(packageJSON)) {
this.info = require(path.join(process.cwd(), packageJSON));
} else {
this.info.fullName = this.pluginDir;
this.info.name = "[unknown]";
this.info.version = "[unknown-version]";
}
this.info.installed = true;
log.info("plugin", `${this.info.fullName} v${this.info.version} loaded`);
}
}
async unload() {
await this.object.unload();
}
}
module.exports = {
PluginsManager,
PluginWrapper
};

@ -442,7 +442,7 @@ router.get("/api/badge/:id/cert-exp", cache("5 minutes"), async (request, respon
if (!tlsInfo.valid) { if (!tlsInfo.valid) {
// return a "Bad Cert" badge in naColor (grey), when cert is not valid // return a "Bad Cert" badge in naColor (grey), when cert is not valid
badgeValues.message = "Bad Cert"; badgeValues.message = "Bad Cert";
badgeValues.color = badgeConstants.downColor; badgeValues.color = downColor;
} else { } else {
const daysRemaining = parseInt(overrideValue ?? tlsInfo.certInfo.daysRemaining); const daysRemaining = parseInt(overrideValue ?? tlsInfo.certInfo.daysRemaining);

@ -5,6 +5,8 @@ const StatusPage = require("../model/status_page");
const { allowDevAllOrigin, sendHttpError } = require("../util-server"); const { allowDevAllOrigin, sendHttpError } = require("../util-server");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const Monitor = require("../model/monitor"); const Monitor = require("../model/monitor");
const { badgeConstants } = require("../config");
const { makeBadge } = require("badge-maker");
let router = express.Router(); let router = express.Router();
@ -139,4 +141,100 @@ router.get("/api/status-page/:slug/manifest.json", cache("1440 minutes"), async
} }
}); });
// overall status-page status badge
router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, response) => {
allowDevAllOrigin(response);
const slug = request.params.slug;
const statusPageID = await StatusPage.slugToID(slug);
const {
label,
upColor = badgeConstants.defaultUpColor,
downColor = badgeConstants.defaultDownColor,
partialColor = "#F6BE00",
maintenanceColor = "#808080",
style = badgeConstants.defaultStyle
} = request.query;
try {
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
]);
let hasUp = false;
let hasDown = false;
let hasMaintenance = false;
for (let monitorID of monitorIDList) {
// retrieve the latest heartbeat
let beat = await R.getAll(`
SELECT * FROM heartbeat
WHERE monitor_id = ?
ORDER BY time DESC
LIMIT 1
`, [
monitorID,
]);
// to be sure, when corresponding monitor not found
if (beat.length === 0) {
continue;
}
// handle status of beat
if (beat[0].status === 3) {
hasMaintenance = true;
} else if (beat[0].status === 2) {
// ignored
} else if (beat[0].status === 1) {
hasUp = true;
} else {
hasDown = true;
}
}
const badgeValues = { style };
if (!hasUp && !hasDown && !hasMaintenance) {
// return a "N/A" badge in naColor (grey), if monitor is not public / not available / non exsitant
badgeValues.message = "N/A";
badgeValues.color = badgeConstants.naColor;
} else {
if (hasMaintenance) {
badgeValues.label = label ? label : "";
badgeValues.color = maintenanceColor;
badgeValues.message = "Maintenance";
} else if (hasUp && !hasDown) {
badgeValues.label = label ? label : "";
badgeValues.color = upColor;
badgeValues.message = "Up";
} else if (hasUp && hasDown) {
badgeValues.label = label ? label : "";
badgeValues.color = partialColor;
badgeValues.message = "Degraded";
} else {
badgeValues.label = label ? label : "";
badgeValues.color = downColor;
badgeValues.message = "Down";
}
}
// build the svg based on given values
const svg = makeBadge(badgeValues);
response.type("image/svg+xml");
response.send(svg);
} catch (error) {
sendHttpError(response, error.message);
}
});
module.exports = router; module.exports = router;

@ -147,8 +147,8 @@ const { apiKeySocketHandler } = require("./socket-handlers/api-key-socket-handle
const { generalSocketHandler } = require("./socket-handlers/general-socket-handler"); const { generalSocketHandler } = require("./socket-handlers/general-socket-handler");
const { Settings } = require("./settings"); const { Settings } = require("./settings");
const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
const { pluginsHandler } = require("./socket-handlers/plugins-handler");
const apicache = require("./modules/apicache"); const apicache = require("./modules/apicache");
const { resetChrome } = require("./monitor-types/real-browser-monitor-type");
app.use(express.json()); app.use(express.json());
@ -161,12 +161,6 @@ app.use(function (req, res, next) {
next(); next();
}); });
/**
* Use for decode the auth object
* @type {null}
*/
let jwtSecret = null;
/** /**
* Show Setup Page * Show Setup Page
* @type {boolean} * @type {boolean}
@ -177,7 +171,6 @@ let needSetup = false;
Database.init(args); Database.init(args);
await initDatabase(testMode); await initDatabase(testMode);
await server.initAfterDatabaseReady(); await server.initAfterDatabaseReady();
server.loadPlugins();
server.entryPage = await Settings.get("entryPage"); server.entryPage = await Settings.get("entryPage");
await StatusPage.loadDomainMappingList(); await StatusPage.loadDomainMappingList();
@ -215,6 +208,7 @@ let needSetup = false;
}); });
if (isDev) { if (isDev) {
app.use(express.urlencoded({ extended: true }));
app.post("/test-webhook", async (request, response) => { app.post("/test-webhook", async (request, response) => {
log.debug("test", request.headers); log.debug("test", request.headers);
log.debug("test", request.body); log.debug("test", request.body);
@ -269,7 +263,7 @@ let needSetup = false;
log.info("server", "Adding socket handler"); log.info("server", "Adding socket handler");
io.on("connection", async (socket) => { io.on("connection", async (socket) => {
sendInfo(socket); sendInfo(socket, true);
if (needSetup) { if (needSetup) {
log.info("server", "Redirect to setup page"); log.info("server", "Redirect to setup page");
@ -286,7 +280,7 @@ let needSetup = false;
log.info("auth", `Login by token. IP=${clientIP}`); log.info("auth", `Login by token. IP=${clientIP}`);
try { try {
let decoded = jwt.verify(token, jwtSecret); let decoded = jwt.verify(token, server.jwtSecret);
log.info("auth", "Username from JWT: " + decoded.username); log.info("auth", "Username from JWT: " + decoded.username);
@ -357,7 +351,7 @@ let needSetup = false;
ok: true, ok: true,
token: jwt.sign({ token: jwt.sign({
username: data.username, username: data.username,
}, jwtSecret), }, server.jwtSecret),
}); });
} }
@ -387,7 +381,7 @@ let needSetup = false;
ok: true, ok: true,
token: jwt.sign({ token: jwt.sign({
username: data.username, username: data.username,
}, jwtSecret), }, server.jwtSecret),
}); });
} else { } else {
@ -676,6 +670,7 @@ let needSetup = false;
// Edit a monitor // Edit a monitor
socket.on("editMonitor", async (monitor, callback) => { socket.on("editMonitor", async (monitor, callback) => {
try { try {
let removeGroupChildren = false;
checkLogin(socket); checkLogin(socket);
let bean = await R.findOne("monitor", " id = ? ", [ monitor.id ]); let bean = await R.findOne("monitor", " id = ? ", [ monitor.id ]);
@ -684,7 +679,7 @@ let needSetup = false;
throw new Error("Permission denied."); throw new Error("Permission denied.");
} }
// Check if Parent is Decendant (would cause endless loop) // Check if Parent is Descendant (would cause endless loop)
if (monitor.parent !== null) { if (monitor.parent !== null) {
const childIDs = await Monitor.getAllChildrenIDs(monitor.id); const childIDs = await Monitor.getAllChildrenIDs(monitor.id);
if (childIDs.includes(monitor.parent)) { if (childIDs.includes(monitor.parent)) {
@ -692,6 +687,11 @@ let needSetup = false;
} }
} }
// Remove children if monitor type has changed (from group to non-group)
if (bean.type === "group" && monitor.type !== bean.type) {
removeGroupChildren = true;
}
bean.name = monitor.name; bean.name = monitor.name;
bean.description = monitor.description; bean.description = monitor.description;
bean.parent = monitor.parent; bean.parent = monitor.parent;
@ -713,6 +713,7 @@ let needSetup = false;
bean.maxretries = monitor.maxretries; bean.maxretries = monitor.maxretries;
bean.port = parseInt(monitor.port); bean.port = parseInt(monitor.port);
bean.keyword = monitor.keyword; bean.keyword = monitor.keyword;
bean.invertKeyword = monitor.invertKeyword;
bean.ignoreTls = monitor.ignoreTls; bean.ignoreTls = monitor.ignoreTls;
bean.expiryNotification = monitor.expiryNotification; bean.expiryNotification = monitor.expiryNotification;
bean.upsideDown = monitor.upsideDown; bean.upsideDown = monitor.upsideDown;
@ -747,11 +748,17 @@ let needSetup = false;
bean.radiusCallingStationId = monitor.radiusCallingStationId; bean.radiusCallingStationId = monitor.radiusCallingStationId;
bean.radiusSecret = monitor.radiusSecret; bean.radiusSecret = monitor.radiusSecret;
bean.httpBodyEncoding = monitor.httpBodyEncoding; bean.httpBodyEncoding = monitor.httpBodyEncoding;
bean.expectedValue = monitor.expectedValue;
bean.jsonPath = monitor.jsonPath;
bean.validate(); bean.validate();
await R.store(bean); await R.store(bean);
if (removeGroupChildren) {
await Monitor.unlinkAllChildren(monitor.id);
}
await updateMonitorNotification(bean.id, monitor.notificationIDList); await updateMonitorNotification(bean.id, monitor.notificationIDList);
if (bean.isActive()) { if (bean.isActive()) {
@ -897,6 +904,8 @@ let needSetup = false;
delete server.monitorList[monitorID]; delete server.monitorList[monitorID];
} }
const startTime = Date.now();
await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [ await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [
monitorID, monitorID,
socket.userID, socket.userID,
@ -905,6 +914,10 @@ let needSetup = false;
// Fix #2880 // Fix #2880
apicache.clear(); apicache.clear();
const endTime = Date.now();
log.info("DB", `Delete Monitor completed in : ${endTime - startTime} ms`);
callback({ callback({
ok: true, ok: true,
msg: "Deleted Successfully.", msg: "Deleted Successfully.",
@ -1148,6 +1161,8 @@ let needSetup = false;
await doubleCheckPassword(socket, currentPassword); await doubleCheckPassword(socket, currentPassword);
} }
const previousChromeExecutable = await Settings.get("chromeExecutable");
await setSettings("general", data); await setSettings("general", data);
server.entryPage = data.entryPage; server.entryPage = data.entryPage;
@ -1158,6 +1173,12 @@ let needSetup = false;
await server.setTimezone(data.serverTimezone); await server.setTimezone(data.serverTimezone);
} }
// If Chrome Executable is changed, need to reset the browser
if (previousChromeExecutable !== data.chromeExecutable) {
log.info("settings", "Chrome executable is changed. Resetting Chrome...");
await resetChrome();
}
callback({ callback({
ok: true, ok: true,
msg: "Saved" msg: "Saved"
@ -1359,13 +1380,14 @@ let needSetup = false;
maxretries: monitorListData[i].maxretries, maxretries: monitorListData[i].maxretries,
port: monitorListData[i].port, port: monitorListData[i].port,
keyword: monitorListData[i].keyword, keyword: monitorListData[i].keyword,
invertKeyword: monitorListData[i].invertKeyword,
ignoreTls: monitorListData[i].ignoreTls, ignoreTls: monitorListData[i].ignoreTls,
upsideDown: monitorListData[i].upsideDown, upsideDown: monitorListData[i].upsideDown,
maxredirects: monitorListData[i].maxredirects, maxredirects: monitorListData[i].maxredirects,
accepted_statuscodes: monitorListData[i].accepted_statuscodes, accepted_statuscodes: monitorListData[i].accepted_statuscodes,
dns_resolve_type: monitorListData[i].dns_resolve_type, dns_resolve_type: monitorListData[i].dns_resolve_type,
dns_resolve_server: monitorListData[i].dns_resolve_server, dns_resolve_server: monitorListData[i].dns_resolve_server,
notificationIDList: {}, notificationIDList: monitorListData[i].notificationIDList,
proxy_id: monitorListData[i].proxy_id || null, proxy_id: monitorListData[i].proxy_id || null,
}; };
@ -1527,7 +1549,6 @@ let needSetup = false;
maintenanceSocketHandler(socket); maintenanceSocketHandler(socket);
apiKeySocketHandler(socket); apiKeySocketHandler(socket);
generalSocketHandler(socket, server); generalSocketHandler(socket, server);
pluginsHandler(socket, server);
log.debug("server", "added all socket handlers"); log.debug("server", "added all socket handlers");
@ -1630,6 +1651,7 @@ async function afterLogin(socket, user) {
socket.join(user.id); socket.join(user.id);
let monitorList = await server.sendMonitorList(socket); let monitorList = await server.sendMonitorList(socket);
sendInfo(socket);
server.sendMaintenanceList(socket); server.sendMaintenanceList(socket);
sendNotificationList(socket); sendNotificationList(socket);
sendProxyList(socket); sendProxyList(socket);
@ -1697,7 +1719,7 @@ async function initDatabase(testMode = false) {
needSetup = true; needSetup = true;
} }
jwtSecret = jwtSecretBean.value; server.jwtSecret = jwtSecretBean.value;
} }
/** /**

@ -3,6 +3,7 @@ const { Settings } = require("../settings");
const { sendInfo } = require("../client"); const { sendInfo } = require("../client");
const { checkLogin } = require("../util-server"); const { checkLogin } = require("../util-server");
const GameResolver = require("gamedig/lib/GameResolver"); const GameResolver = require("gamedig/lib/GameResolver");
const { testChrome } = require("../monitor-types/real-browser-monitor-type");
let gameResolver = new GameResolver(); let gameResolver = new GameResolver();
let gameList = null; let gameList = null;
@ -47,4 +48,18 @@ module.exports.generalSocketHandler = (socket, server) => {
}); });
}); });
socket.on("testChrome", (executable, callback) => {
// Just noticed that await call could block the whole socket.io server!!! Use pure promise instead.
testChrome(executable).then((version) => {
callback({
ok: true,
msg: "Found Chromium/Chrome. Version: " + version,
});
}).catch((e) => {
callback({
ok: false,
msg: e.message,
});
});
});
}; };

@ -1,69 +0,0 @@
const { checkLogin } = require("../util-server");
const { PluginsManager } = require("../plugins-manager");
const { log } = require("../../src/util.js");
/**
* Handlers for plugins
* @param {Socket} socket Socket.io instance
* @param {UptimeKumaServer} server
*/
module.exports.pluginsHandler = (socket, server) => {
const pluginManager = server.getPluginManager();
// Get Plugin List
socket.on("getPluginList", async (callback) => {
try {
checkLogin(socket);
log.debug("plugin", "PluginManager.disable: " + PluginsManager.disable);
if (PluginsManager.disable) {
throw new Error("Plugin Disabled: In order to enable plugin feature, you need to use the default data directory: ./data/");
}
let pluginList = await pluginManager.fetchPluginList();
callback({
ok: true,
pluginList,
});
} catch (error) {
log.warn("plugin", "Error: " + error.message);
callback({
ok: false,
msg: error.message,
});
}
});
socket.on("installPlugin", async (repoURL, name, callback) => {
try {
checkLogin(socket);
pluginManager.downloadPlugin(repoURL, name);
await pluginManager.loadPlugin(name);
callback({
ok: true,
});
} catch (error) {
callback({
ok: false,
msg: error.message,
});
}
});
socket.on("uninstallPlugin", async (name, callback) => {
try {
checkLogin(socket);
await pluginManager.removePlugin(name);
callback({
ok: true,
});
} catch (error) {
callback({
ok: false,
msg: error.message,
});
}
});
};

@ -10,8 +10,7 @@ const util = require("util");
const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
const { Settings } = require("./settings"); const { Settings } = require("./settings");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const { PluginsManager } = require("./plugins-manager"); // DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead.
// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`
/** /**
* `module.exports` (alias: `server`) should be inside this class, in order to avoid circular dependency issue. * `module.exports` (alias: `server`) should be inside this class, in order to avoid circular dependency issue.
@ -47,12 +46,6 @@ class UptimeKumaServer {
*/ */
indexHTML = ""; indexHTML = "";
/**
* Plugins Manager
* @type {PluginsManager}
*/
pluginsManager = null;
/** /**
* *
* @type {{}} * @type {{}}
@ -61,6 +54,12 @@ class UptimeKumaServer {
}; };
/**
* Use for decode the auth object
* @type {null}
*/
jwtSecret = null;
static getInstance(args) { static getInstance(args) {
if (UptimeKumaServer.instance == null) { if (UptimeKumaServer.instance == null) {
UptimeKumaServer.instance = new UptimeKumaServer(args); UptimeKumaServer.instance = new UptimeKumaServer(args);
@ -98,11 +97,17 @@ class UptimeKumaServer {
} }
} }
// Set Monitor Types
UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType();
this.io = new Server(this.httpServer); this.io = new Server(this.httpServer);
} }
/** Initialise app after the database has been set up */ /** Initialise app after the database has been set up */
async initAfterDatabaseReady() { async initAfterDatabaseReady() {
// Static
this.app.use("/screenshots", express.static(Database.screenshotDir));
await CacheableDnsHttpAgent.update(); await CacheableDnsHttpAgent.update();
process.env.TZ = await this.getTimezone(); process.env.TZ = await this.getTimezone();
@ -244,9 +249,9 @@ class UptimeKumaServer {
return (typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : null) return (typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : null)
|| socket.client.conn.request.headers["x-real-ip"] || socket.client.conn.request.headers["x-real-ip"]
|| clientIP.replace(/^.*:/, ""); || clientIP.replace(/^::ffff:/, "");
} else { } else {
return clientIP.replace(/^.*:/, ""); return clientIP.replace(/^::ffff:/, "");
} }
} }
@ -257,13 +262,43 @@ class UptimeKumaServer {
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
async getTimezone() { async getTimezone() {
// From process.env.TZ
try {
if (process.env.TZ) {
this.checkTimezone(process.env.TZ);
return process.env.TZ;
}
} catch (e) {
log.warn("timezone", e.message + " in process.env.TZ");
}
let timezone = await Settings.get("serverTimezone"); let timezone = await Settings.get("serverTimezone");
// From Settings
try {
log.debug("timezone", "Using timezone from settings: " + timezone);
if (timezone) { if (timezone) {
this.checkTimezone(timezone);
return timezone; return timezone;
} else if (process.env.TZ) { }
return process.env.TZ; } catch (e) {
log.warn("timezone", e.message + " in settings");
}
// Guess
try {
let guess = dayjs.tz.guess();
log.debug("timezone", "Guessing timezone: " + guess);
if (guess) {
this.checkTimezone(guess);
return guess;
} else { } else {
return dayjs.tz.guess(); return "UTC";
}
} catch (e) {
// Guess failed, fall back to UTC
log.debug("timezone", "Guessed an invalid timezone. Use UTC as fallback");
return "UTC";
} }
} }
@ -275,11 +310,24 @@ class UptimeKumaServer {
return dayjs().format("Z"); return dayjs().format("Z");
} }
/**
* Throw an error if the timezone is invalid
* @param timezone
*/
checkTimezone(timezone) {
try {
dayjs.utc("2013-11-18 11:55").tz(timezone).format();
} catch (e) {
throw new Error("Invalid timezone:" + timezone);
}
}
/** /**
* Set the current server timezone and environment variables * Set the current server timezone and environment variables
* @param {string} timezone * @param {string} timezone
*/ */
async setTimezone(timezone) { async setTimezone(timezone) {
this.checkTimezone(timezone);
await Settings.set("serverTimezone", timezone, "general"); await Settings.set("serverTimezone", timezone, "general");
process.env.TZ = timezone; process.env.TZ = timezone;
dayjs.tz.setDefault(timezone); dayjs.tz.setDefault(timezone);
@ -289,51 +337,11 @@ class UptimeKumaServer {
async stop() { async stop() {
} }
loadPlugins() {
this.pluginsManager = new PluginsManager(this);
}
/**
*
* @returns {PluginsManager}
*/
getPluginManager() {
return this.pluginsManager;
}
/**
*
* @param {MonitorType} monitorType
*/
addMonitorType(monitorType) {
if (monitorType instanceof MonitorType && monitorType.name) {
if (monitorType.name in UptimeKumaServer.monitorTypeList) {
log.error("", "Conflict Monitor Type name");
}
UptimeKumaServer.monitorTypeList[monitorType.name] = monitorType;
} else {
log.error("", "Invalid Monitor Type: " + monitorType.name);
}
}
/**
*
* @param {MonitorType} monitorType
*/
removeMonitorType(monitorType) {
if (UptimeKumaServer.monitorTypeList[monitorType.name] === monitorType) {
delete UptimeKumaServer.monitorTypeList[monitorType.name];
} else {
log.error("", "Remove MonitorType failed: " + monitorType.name);
}
}
} }
module.exports = { module.exports = {
UptimeKumaServer UptimeKumaServer
}; };
// Must be at the end // Must be at the end to avoid circular dependencies
const { MonitorType } = require("./monitor-types/monitor-type"); const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type");

@ -378,6 +378,7 @@ exports.mongodbPing = async function (connectionString) {
* @param {string} callingStationId ID of calling station * @param {string} callingStationId ID of calling station
* @param {string} secret Secret to use * @param {string} secret Secret to use
* @param {number} [port=1812] Port to contact radius server on * @param {number} [port=1812] Port to contact radius server on
* @param {number} [timeout=2500] Timeout for connection to use
* @returns {Promise<any>} * @returns {Promise<any>}
*/ */
exports.radius = function ( exports.radius = function (
@ -388,10 +389,12 @@ exports.radius = function (
callingStationId, callingStationId,
secret, secret,
port = 1812, port = 1812,
timeout = 2500,
) { ) {
const client = new radiusClient({ const client = new radiusClient({
host: hostname, host: hostname,
hostPort: port, hostPort: port,
timeout: timeout,
dictionaries: [ file ], dictionaries: [ file ],
}); });
@ -413,12 +416,18 @@ exports.radius = function (
exports.redisPingAsync = function (dsn) { exports.redisPingAsync = function (dsn) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const client = redis.createClient({ const client = redis.createClient({
url: dsn, url: dsn
}); });
client.on("error", (err) => { client.on("error", (err) => {
if (client.isOpen) {
client.disconnect();
}
reject(err); reject(err);
}); });
client.connect().then(() => { client.connect().then(() => {
if (!client.isOpen) {
client.emit("error", new Error("connection isn't open"));
}
client.ping().then((res, err) => { client.ping().then((res, err) => {
if (client.isOpen) { if (client.isOpen) {
client.disconnect(); client.disconnect();
@ -428,7 +437,7 @@ exports.redisPingAsync = function (dsn) {
} else { } else {
resolve(res); resolve(res);
} }
}); }).catch(error => reject(error));
}); });
}); });
}; };

@ -69,6 +69,7 @@
.multiselect__content-wrapper { .multiselect__content-wrapper {
background-color: $dark-bg2; background-color: $dark-bg2;
border-color: $dark-border-color; border-color: $dark-border-color;
z-index: 150;
} }
.multiselect--above .multiselect__content-wrapper { .multiselect--above .multiselect__content-wrapper {

@ -22,78 +22,78 @@
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('duration') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('duration') " class="mb-3">
<label for="duration" class="form-label">{{ $t("Badge Duration") }}</label> <label for="duration" class="form-label">{{ $t("Badge Duration (in hours)") }}</label>
<input id="duration" v-model="badge.duration" type="number" min="0" class="form-control" required> <input id="duration" v-model="badge.duration" type="number" min="0" placeholder="24" class="form-control">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('label') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('label') " class="mb-3">
<label for="label" class="form-label">{{ $t("Badge Label") }}</label> <label for="label" class="form-label">{{ $t("Badge Label") }}</label>
<input id="label" v-model="badge.label" type="text" class="form-control" required> <input id="label" v-model="badge.label" type="text" class="form-control">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('prefix') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('prefix') " class="mb-3">
<label for="prefix" class="form-label">{{ $t("Badge Prefix") }}</label> <label for="prefix" class="form-label">{{ $t("Badge Prefix") }}</label>
<input id="prefix" v-model="badge.prefix" type="text" class="form-control" required> <input id="prefix" v-model="badge.prefix" type="text" class="form-control">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('suffix') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('suffix') " class="mb-3">
<label for="suffix" class="form-label">{{ $t("Badge Suffix") }}</label> <label for="suffix" class="form-label">{{ $t("Badge Suffix") }}</label>
<input id="suffix" v-model="badge.suffix" type="text" class="form-control" required> <input id="suffix" v-model="badge.suffix" type="text" placeholder="%" class="form-control">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelColor') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelColor') " class="mb-3">
<label for="labelColor" class="form-label">{{ $t("Badge Label Color") }}</label> <label for="labelColor" class="form-label">{{ $t("Badge Label Color") }}</label>
<input id="labelColor" v-model="badge.labelColor" type="text" class="form-control" required> <input id="labelColor" v-model="badge.labelColor" type="text" placeholder="#555" class="form-control">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('color') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('color') " class="mb-3">
<label for="color" class="form-label">{{ $t("Badge Color") }}</label> <label for="color" class="form-label">{{ $t("Badge Color") }}</label>
<input id="color" v-model="badge.color" type="text" class="form-control" required> <input id="color" v-model="badge.color" type="text" :placeholder="badgeConstants.defaultUpColor" class="form-control">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelPrefix') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelPrefix') " class="mb-3">
<label for="labelPrefix" class="form-label">{{ $t("Badge Label Prefix") }}</label> <label for="labelPrefix" class="form-label">{{ $t("Badge Label Prefix") }}</label>
<input id="labelPrefix" v-model="badge.labelPrefix" type="text" class="form-control" required> <input id="labelPrefix" v-model="badge.labelPrefix" type="text" class="form-control">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelSuffix') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelSuffix') " class="mb-3">
<label for="labelSuffix" class="form-label">{{ $t("Badge Label Suffix") }}</label> <label for="labelSuffix" class="form-label">{{ $t("Badge Label Suffix") }}</label>
<input id="labelSuffix" v-model="badge.labelSuffix" type="text" class="form-control" required> <input id="labelSuffix" v-model="badge.labelSuffix" type="text" placeholder="h" class="form-control">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('upColor') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('upColor') " class="mb-3">
<label for="upColor" class="form-label">{{ $t("Badge Up Color") }}</label> <label for="upColor" class="form-label">{{ $t("Badge Up Color") }}</label>
<input id="upColor" v-model="badge.upColor" type="text" class="form-control" required> <input id="upColor" v-model="badge.upColor" type="text" class="form-control" :placeholder="badgeConstants.defaultUpColor">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('downColor') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('downColor') " class="mb-3">
<label for="downColor" class="form-label">{{ $t("Badge Down Color") }}</label> <label for="downColor" class="form-label">{{ $t("Badge Down Color") }}</label>
<input id="downColor" v-model="badge.downColor" type="text" class="form-control" required> <input id="downColor" v-model="badge.downColor" type="text" class="form-control" :placeholder="badgeConstants.defaultDownColor">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('pendingColor') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('pendingColor') " class="mb-3">
<label for="pendingColor" class="form-label">{{ $t("Badge Pending Color") }}</label> <label for="pendingColor" class="form-label">{{ $t("Badge Pending Color") }}</label>
<input id="pendingColor" v-model="badge.pendingColor" type="text" class="form-control" required> <input id="pendingColor" v-model="badge.pendingColor" type="text" class="form-control" :placeholder="badgeConstants.defaultPendingColor">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('maintenanceColor') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('maintenanceColor') " class="mb-3">
<label for="maintenanceColor" class="form-label">{{ $t("Badge Maintenance Color") }}</label> <label for="maintenanceColor" class="form-label">{{ $t("Badge Maintenance Color") }}</label>
<input id="maintenanceColor" v-model="badge.maintenanceColor" type="text" class="form-control" required> <input id="maintenanceColor" v-model="badge.maintenanceColor" type="text" class="form-control" :placeholder="badgeConstants.defaultMaintenanceColor">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('warnColor') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('warnColor') " class="mb-3">
<label for="warnColor" class="form-label">{{ $t("Badge Warn Color") }}</label> <label for="warnColor" class="form-label">{{ $t("Badge Warn Color") }}</label>
<input id="warnColor" v-model="badge.warnColor" type="number" min="0" class="form-control" required> <input id="warnColor" v-model="badge.warnColor" type="text" class="form-control" :placeholder="badgeConstants.defaultMaintenanceColor">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('warnDays') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('warnDays') " class="mb-3">
<label for="warnDays" class="form-label">{{ $t("Badge Warn Days") }}</label> <label for="warnDays" class="form-label">{{ $t("Badge Warn Days") }}</label>
<input id="warnDays" v-model="badge.warnDays" type="number" min="0" class="form-control" required> <input id="warnDays" v-model="badge.warnDays" type="number" min="0" class="form-control" :placeholder="badgeConstants.defaultCertExpireWarnDays">
</div> </div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('downDays') " class="mb-3"> <div v-if=" (parameters[badge.type || 'null'] || [] ).includes('downDays') " class="mb-3">
<label for="downDays" class="form-label">{{ $t("Badge Down Days") }}</label> <label for="downDays" class="form-label">{{ $t("Badge Down Days") }}</label>
<input id="downDays" v-model="badge.downDays" type="number" min="0" class="form-control" required> <input id="downDays" v-model="badge.downDays" type="number" min="0" class="form-control" :placeholder="badgeConstants.defaultCertExpireDownDays">
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -109,12 +109,16 @@
<div class="mb-3"> <div class="mb-3">
<label for="value" class="form-label">{{ $t("Badge value (For Testing only.)") }}</label> <label for="value" class="form-label">{{ $t("Badge value (For Testing only.)") }}</label>
<input id="value" v-model="badge.value" type="text" class="form-control" required> <input id="value" v-model="badge.value" type="text" class="form-control">
</div>
<div class="mb-3 pt-3 d-flex justify-content-center">
<img :src="badgeURL" :alt="$t('Badge Preview')">
</div> </div>
<div class="my-3"> <div class="my-3">
<label for="push-url" class="form-label">{{ $t("Badge URL") }}</label> <label for="badge-url" class="form-label">{{ $t("Badge URL") }}</label>
<CopyableInput id="push-url" v-model="badgeURL" type="url" disabled="disabled" /> <CopyableInput id="badge-url" v-model="badgeURL" type="url" disabled="disabled" />
</div> </div>
</div> </div>
@ -131,6 +135,7 @@
<script lang="ts"> <script lang="ts">
import { Modal } from "bootstrap"; import { Modal } from "bootstrap";
import CopyableInput from "./CopyableInput.vue"; import CopyableInput from "./CopyableInput.vue";
import { default as serverConfig } from "../../server/config.js";
export default { export default {
components: { components: {
@ -224,7 +229,8 @@ export default {
"color", "color",
"labelColor", "labelColor",
], ],
} },
badgeConstants: serverConfig.badgeConstants,
}; };
}, },

@ -104,7 +104,7 @@ export default {
// We must check if there are any elements in monitorList to // We must check if there are any elements in monitorList to
// prevent undefined errors if it hasn't been loaded yet // prevent undefined errors if it hasn't been loaded yet
if (this.$parent.editMode && ignoreSendUrl && Object.keys(this.$root.monitorList).length) { if (this.$parent.editMode && ignoreSendUrl && Object.keys(this.$root.monitorList).length) {
return this.$root.monitorList[monitor.element.id].type === "http" || this.$root.monitorList[monitor.element.id].type === "keyword"; return this.$root.monitorList[monitor.element.id].type === "http" || this.$root.monitorList[monitor.element.id].type === "keyword" || this.$root.monitorList[monitor.element.id].type === "json-query";
} }
return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://" && !this.editMode; return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://" && !this.editMode;
}, },

@ -164,6 +164,7 @@ export default {
"SMSManager": "SmsManager (smsmanager.cz)", "SMSManager": "SmsManager (smsmanager.cz)",
"WeCom": "WeCom (企业微信群机器人)", "WeCom": "WeCom (企业微信群机器人)",
"ServerChan": "ServerChan (Server酱)", "ServerChan": "ServerChan (Server酱)",
"smsc": "SMSC",
}; };
// Sort by notification name // Sort by notification name

@ -1,102 +0,0 @@
<template>
<div v-if="! (!plugin.installed && plugin.local)" class="plugin-item pt-4 pb-2">
<div class="info">
<h5>{{ plugin.fullName }}</h5>
<p class="description">
{{ plugin.description }}
</p>
<span class="version">{{ $t("Version") }}: {{ plugin.version }} <a v-if="plugin.repo" :href="plugin.repo" target="_blank">Repo</a></span>
</div>
<div class="buttons">
<button v-if="status === 'installing'" class="btn btn-primary" disabled>{{ $t("installing") }}</button>
<button v-else-if="status === 'uninstalling'" class="btn btn-danger" disabled>{{ $t("uninstalling") }}</button>
<button v-else-if="plugin.installed || status === 'installed'" class="btn btn-danger" @click="deleteConfirm">{{ $t("uninstall") }}</button>
<button v-else class="btn btn-primary" @click="install">{{ $t("install") }}</button>
</div>
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="uninstall">
{{ $t("confirmUninstallPlugin") }}
</Confirm>
</div>
</template>
<script>
import Confirm from "./Confirm.vue";
export default {
components: {
Confirm,
},
props: {
plugin: {
type: Object,
required: true,
},
},
data() {
return {
status: "",
};
},
methods: {
/**
* Show confirmation for deleting a tag
*/
deleteConfirm() {
this.$refs.confirmDelete.show();
},
install() {
this.status = "installing";
this.$root.getSocket().emit("installPlugin", this.plugin.repo, this.plugin.name, (res) => {
if (res.ok) {
this.status = "";
// eslint-disable-next-line vue/no-mutating-props
this.plugin.installed = true;
} else {
this.$root.toastRes(res);
}
});
},
uninstall() {
this.status = "uninstalling";
this.$root.getSocket().emit("uninstallPlugin", this.plugin.name, (res) => {
if (res.ok) {
this.status = "";
// eslint-disable-next-line vue/no-mutating-props
this.plugin.installed = false;
} else {
this.$root.toastRes(res);
}
});
}
}
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.plugin-item {
display: flex;
justify-content: space-between;
align-content: center;
align-items: center;
.info {
margin-right: 10px;
}
.description {
font-size: 13px;
margin-bottom: 0;
}
.version {
font-size: 13px;
}
}
</style>

@ -150,7 +150,7 @@ export default {
// We must check if there are any elements in monitorList to // We must check if there are any elements in monitorList to
// prevent undefined errors if it hasn't been loaded yet // prevent undefined errors if it hasn't been loaded yet
if (this.$parent.editMode && ignoreSendUrl && Object.keys(this.$root.monitorList).length) { if (this.$parent.editMode && ignoreSendUrl && Object.keys(this.$root.monitorList).length) {
return this.$root.monitorList[monitor.element.id].type === "http" || this.$root.monitorList[monitor.element.id].type === "keyword"; return this.$root.monitorList[monitor.element.id].type === "http" || this.$root.monitorList[monitor.element.id].type === "keyword" || this.$root.monitorList[monitor.element.id].type === "json-query";
} }
return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://" && !this.editMode; return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://" && !this.editMode;
}, },

@ -0,0 +1,43 @@
<template>
<div class="mb-3">
<label for="smsc-login" class="form-label">{{ $t("API Username") }}</label>
<i18n-t tag="div" class="form-text" keypath="wayToGetClickSendSMSToken">
<a href="https://smsc.kz/" target="_blank">{{ $t("here") }}</a>
</i18n-t>
<input id="smsc-login" v-model="$parent.notification.smscLogin" type="text" class="form-control" required>
<label for="smsc-key" class="form-label">{{ $t("API Key") }}</label>
<HiddenInput id="smsc-key" v-model="$parent.notification.smscPassword" :required="true" autocomplete="new-password"></HiddenInput>
</div>
<div class="mb-3">
<div class="form-text">
{{ $t("checkPrice", ['СМСЦ']) }}
<a href="https://smsc.kz/tariffs/" target="_blank">https://smsc.kz/tariffs/</a>
</div>
</div>
<div class="mb-3">
<label for="smsc-to-number" class="form-label">{{ $t("Recipient Number") }}</label>
<input id="smsc-to-number" v-model="$parent.notification.smscToNumber" type="text" minlength="11" class="form-control" required>
</div>
<div class="mb-3">
<label for="smsc-sender-name" class="form-label">{{ $t("From Name/Number") }}</label>
<input id="smsc-sender-name" v-model="$parent.notification.smscSenderName" type="text" minlength="1" maxlength="15" class="form-control">
<div class="form-text">{{ $t("Leave blank to use a shared sender number.") }}</div>
</div>
<div class="mb-3">
<label for="smsc-platform" class="form-label">{{ $t("smscTranslit") }}</label><span style="color: red;"><sup>*</sup></span>
<select id="smsc-platform" v-model="$parent.notification.smscTranslit" class="form-select">
<option value="0">{{ $t("Default") }}</option>
<option value="1">Translit</option>
<option value="2">MpaHc/Ium</option>
</select>
</div>
</template>
<script>
import HiddenInput from "../HiddenInput.vue";
export default {
components: {
HiddenInput,
},
};
</script>

@ -24,5 +24,13 @@
<a href="https://www.webfx.com/tools/emoji-cheat-sheet/" target="_blank">https://www.webfx.com/tools/emoji-cheat-sheet/</a> <a href="https://www.webfx.com/tools/emoji-cheat-sheet/" target="_blank">https://www.webfx.com/tools/emoji-cheat-sheet/</a>
</i18n-t> </i18n-t>
</div> </div>
<div class="form-check form-switch">
<input id="slack-channel-notify" v-model="$parent.notification.slackchannelnotify" type="checkbox" class="form-check-input">
<label for="slack-channel-notify" class="form-label">{{ $t("Notify Channel") }}</label>
</div>
<div class="form-text">
{{ $t("aboutNotifyChannel") }}
</div>
</div> </div>
</template> </template>

@ -5,7 +5,18 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="twilio-auth-token" class="form-label">{{ $t("Auth Token") }}</label> <label for="twilio-apikey-token" class="form-label">{{ $t("Api Key (optional)") }}</label>
<input id="twilio-apikey-token" v-model="$parent.notification.twilioApiKey" type="text" class="form-control">
<div class="form-text">
<p>
The API key is optional but recommended. You can provide either Account SID and AuthToken
from the may TwilioConsole page or Account SID and the pair of Api Key and Api Key secret
</p>
</div>
</div>
<div class="mb-3">
<label for="twilio-auth-token" class="form-label">{{ $t("Auth Token / Api Key Secret") }}</label>
<input id="twilio-auth-token" v-model="$parent.notification.twilioAuthToken" type="text" class="form-control" required> <input id="twilio-auth-token" v-model="$parent.notification.twilioAuthToken" type="text" class="form-control" required>
</div> </div>

@ -12,61 +12,97 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="webhook-content-type" class="form-label">{{ <label for="webhook-request-body" class="form-label">{{
$t("Content Type") $t("Request Body")
}}</label> }}</label>
<select <select
id="webhook-content-type" id="webhook-request-body"
v-model="$parent.notification.webhookContentType" v-model="$parent.notification.webhookContentType"
class="form-select" class="form-select"
required required
> >
<option value="json">application/json</option> <option value="json">{{ $t("webhookBodyPresetOption", ["application/json"]) }}</option>
<option value="form-data">multipart/form-data</option> <option value="form-data">{{ $t("webhookBodyPresetOption", ["multipart/form-data"]) }}</option>
<option value="custom">{{ $t("webhookBodyCustomOption") }}</option>
</select> </select>
<div class="form-text"> <div class="form-text">
<div v-if="$parent.notification.webhookContentType == 'json'">
<p>{{ $t("webhookJsonDesc", ['"application/json"']) }}</p> <p>{{ $t("webhookJsonDesc", ['"application/json"']) }}</p>
</div>
<div v-if="$parent.notification.webhookContentType == 'form-data'">
<i18n-t tag="p" keypath="webhookFormDataDesc"> <i18n-t tag="p" keypath="webhookFormDataDesc">
<template #multipart>"multipart/form-data"</template> <template #multipart>multipart/form-data"</template>
<template #decodeFunction> <template #decodeFunction>
<strong>json_decode($_POST['data'])</strong> <strong>json_decode($_POST['data'])</strong>
</template> </template>
</i18n-t> </i18n-t>
</div> </div>
<div v-if="$parent.notification.webhookContentType == 'custom'">
<i18n-t tag="p" keypath="webhookCustomBodyDesc">
<template #msg>
<code>msg</code>
</template>
<template #heartbeat>
<code>heartbeatJSON</code>
</template>
<template #monitor>
<code>monitorJSON</code>
</template>
</i18n-t>
</div>
</div>
<textarea
v-if="$parent.notification.webhookContentType == 'custom'"
id="customBody"
v-model="$parent.notification.webhookCustomBody"
class="form-control"
:placeholder="customBodyPlaceholder"
></textarea>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<i18n-t <div class="form-check form-switch">
tag="label" <input v-model="showAdditionalHeadersField" class="form-check-input" type="checkbox">
class="form-label" <label class="form-check-label">{{ $t("webhookAdditionalHeadersTitle") }}</label>
for="additionalHeaders" </div>
keypath="webhookAdditionalHeadersTitle" <div class="form-text">
> <i18n-t tag="p" keypath="webhookAdditionalHeadersDesc"> </i18n-t>
</i18n-t> </div>
<textarea <textarea
v-if="showAdditionalHeadersField"
id="additionalHeaders" id="additionalHeaders"
v-model="$parent.notification.webhookAdditionalHeaders" v-model="$parent.notification.webhookAdditionalHeaders"
class="form-control" class="form-control"
:placeholder="headersPlaceholder" :placeholder="headersPlaceholder"
></textarea> ></textarea>
<div class="form-text">
<i18n-t tag="p" keypath="webhookAdditionalHeadersDesc"> </i18n-t>
</div>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
data() {
return {
showAdditionalHeadersField: this.$parent.notification.webhookAdditionalHeaders != null,
};
},
computed: { computed: {
headersPlaceholder() { headersPlaceholder() {
return this.$t("Example:", [ return this.$t("Example:", [
` `
{ {
"HeaderName": "HeaderValue" "Authorization": "Authorization Token"
}`, }`,
]); ]);
}, },
customBodyPlaceholder() {
return `Example:
{
"Title": "Uptime Kuma Alert - {{ monitorJSON['name'] }}",
"Body": "{{ msg }}"
}`;
}
}, },
}; };
</script> </script>

@ -4,6 +4,7 @@ import AliyunSMS from "./AliyunSms.vue";
import Apprise from "./Apprise.vue"; import Apprise from "./Apprise.vue";
import Bark from "./Bark.vue"; import Bark from "./Bark.vue";
import ClickSendSMS from "./ClickSendSMS.vue"; import ClickSendSMS from "./ClickSendSMS.vue";
import SMSC from "./SMSC.vue";
import DingDing from "./DingDing.vue"; import DingDing from "./DingDing.vue";
import Discord from "./Discord.vue"; import Discord from "./Discord.vue";
import Feishu from "./Feishu.vue"; import Feishu from "./Feishu.vue";
@ -61,6 +62,7 @@ const NotificationFormList = {
"apprise": Apprise, "apprise": Apprise,
"Bark": Bark, "Bark": Bark,
"clicksendsms": ClickSendSMS, "clicksendsms": ClickSendSMS,
"smsc": SMSC,
"DingDing": DingDing, "DingDing": DingDing,
"discord": Discord, "discord": Discord,
"Feishu": Feishu, "Feishu": Feishu,

@ -190,6 +190,30 @@
</div> </div>
</div> </div>
<!-- Chrome Executable -->
<div class="mb-4">
<label class="form-label" for="primaryBaseURL">
{{ $t("chromeExecutable") }}
</label>
<div class="input-group mb-3">
<input
id="primaryBaseURL"
v-model="settings.chromeExecutable"
class="form-control"
name="primaryBaseURL"
:placeholder="$t('chromeExecutableAutoDetect')"
/>
<button class="btn btn-outline-primary" type="button" @click="testChrome">
{{ $t("Test") }}
</button>
</div>
<div class="form-text">
{{ $t("chromeExecutableDescription") }}
</div>
</div>
<!-- Save Button --> <!-- Save Button -->
<div> <div>
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit">
@ -241,6 +265,12 @@ export default {
autoGetPrimaryBaseURL() { autoGetPrimaryBaseURL() {
this.settings.primaryBaseURL = location.protocol + "//" + location.host; this.settings.primaryBaseURL = location.protocol + "//" + location.host;
}, },
testChrome() {
this.$root.getSocket().emit("testChrome", this.settings.chromeExecutable, (res) => {
this.$root.toastRes(res);
});
},
}, },
}; };
</script> </script>

@ -1,57 +0,0 @@
<template>
<div>
<div class="mt-3">{{ remotePluginListMsg }}</div>
<PluginItem v-for="plugin in remotePluginList" :key="plugin.id" :plugin="plugin" />
</div>
</template>
<script>
import PluginItem from "../PluginItem.vue";
export default {
components: {
PluginItem
},
data() {
return {
remotePluginList: [],
remotePluginListMsg: "",
};
},
computed: {
pluginList() {
return this.$parent.$parent.$parent.pluginList;
},
settings() {
return this.$parent.$parent.$parent.settings;
},
saveSettings() {
return this.$parent.$parent.$parent.saveSettings;
},
settingsLoaded() {
return this.$parent.$parent.$parent.settingsLoaded;
},
},
async mounted() {
this.loadList();
},
methods: {
loadList() {
this.remotePluginListMsg = this.$t("Loading") + "...";
this.$root.getSocket().emit("getPluginList", (res) => {
if (res.ok) {
this.remotePluginList = res.pluginList;
this.remotePluginListMsg = "";
} else {
this.remotePluginListMsg = this.$t("loadingError") + " " + res.msg;
}
});
}
},
};
</script>

@ -776,5 +776,13 @@
"Badge Suffix": "Суфикс на баджа", "Badge Suffix": "Суфикс на баджа",
"Badge Label Prefix": "Префикс на етикета на значката", "Badge Label Prefix": "Префикс на етикета на значката",
"Badge Pending Color": "Цвят на баджа за изчакващ", "Badge Pending Color": "Цвят на баджа за изчакващ",
"Badge Down Days": "Колко дни баджът да не се показва" "Badge Down Days": "Колко дни баджът да не се показва",
"Group": "Група",
"Monitor Group": "Монитор група",
"Cannot connect to the socket server": "Не може да се свърже със сокет сървъра",
"Reconnecting...": "Повторно свързване...",
"Edit Maintenance": "Редактиране на поддръжка",
"Home": "Главна страница",
"noGroupMonitorMsg": "Не е налично. Първо създайте групов монитор.",
"Close": "Затвори"
} }

@ -0,0 +1,28 @@
{
"Settings": "Paràmetres",
"Dashboard": "Tauler",
"Help": "Ajuda",
"New Update": "Nova actualització",
"Language": "Idioma",
"Appearance": "Aparença",
"Theme": "Tema",
"General": "General",
"Game": "Joc",
"Version": "Versió",
"Check Update On GitHub": "Comprovar actualitzacions a GitHub",
"List": "Llista",
"Home": "Inici",
"Add": "Afegir",
"Add New Monitor": "Afegir nou monitor",
"Quick Stats": "Estadístiques ràpides",
"Up": "Funcional",
"Down": "Caigut",
"Pending": "Pendent",
"Maintenance": "Manteniment",
"Unknown": "Desconegut",
"Cannot connect to the socket server": "No es pot connectar al servidor socket",
"Reconnecting...": "S'està tornant a connectar...",
"languageName": "Català",
"Primary Base URL": "URL Base Primària",
"statusMaintenance": "Manteniment"
}

@ -1 +1,46 @@
{} {
"languageName": "کوردی",
"Settings": "ڕێکخستنەکان",
"Help": "یارمەتی",
"New Update": "وەشانی نوێ",
"Language": "زمان",
"Appearance": "ڕووکار",
"Theme": "شێوەی ڕووکار",
"General": "گشتی",
"Game": "یاری",
"Version": "وەشان",
"Check Update On GitHub": "سەیری وەشانی نوێ بکە لە Github",
"List": "لیست",
"Add": "زیادکردن",
"Quick Stats": "ئاماری خێرا",
"Up": "سەروو",
"Down": "خواروو",
"Pending": "هەڵپەسێردراو",
"statusMaintenance": "چاکردنەوە",
"Maintenance": "چاکردنەوە",
"Unknown": "نەزانراو",
"Passive Monitor Type": "جۆری مۆنیتەری پاسیڤ",
"Specific Monitor Type": "جۆری مۆنیتەری تایبەت",
"markdownSupported": "ڕستەسازی مارکداون پشتگیری دەکرێت",
"pauseDashboardHome": "وچان",
"Pause": "وچان",
"Name": "ناو",
"Status": "دۆخ",
"Message": "پەیام",
"No important events": "هیچ ڕووداوێکی گرنگ نییە",
"Resume": "‬دەستپێکردنەوە",
"Edit": "بژارکردن",
"Delete": "سڕینەوە",
"Uptime": "کاتی کارکردن",
"Cert Exp.": "بەسەرچوونی بڕوانامەی SSL.",
"day": "ڕۆژ | ڕۆژەکان",
"-day": "-ڕۆژ",
"hour": "کاتژمێر",
"Dashboard": "داشبۆرد",
"Primary Base URL": "بەستەری بنچینەیی سەرەکی",
"Add New Monitor": "مۆنیتەرێکی نوێ زیاد بکە",
"General Monitor Type": "جۆری مۆنیتەری گشتی",
"DateTime": "رێکەوت",
"Current": "هەنووکە",
"Monitor": "مۆنیتەر | مۆنیتەرەکان"
}

@ -757,11 +757,11 @@
"Show Clickable Link Description": "Pokud je zaškrtnuto, všichni, kdo mají přístup k této stavové stránce, mají přístup k adrese URL monitoru.", "Show Clickable Link Description": "Pokud je zaškrtnuto, všichni, kdo mají přístup k této stavové stránce, mají přístup k adrese URL monitoru.",
"Open Badge Generator": "Otevřít generátor odznaků", "Open Badge Generator": "Otevřít generátor odznaků",
"Badge Type": "Typ odznaku", "Badge Type": "Typ odznaku",
"Badge Duration": "Délka platnosti odznaku", "Badge Duration": "Platnost odznaku",
"Badge Label": "Štítek odznaku", "Badge Label": "Štítek odznaku",
"Badge Prefix": "Prefix odznaku", "Badge Prefix": "Prefix odznaku",
"Monitor Setting": "{0}'s Nastavení dohledu", "Monitor Setting": "{0}'s Nastavení dohledu",
"Badge Generator": "{0}'s Generátor odznaků", "Badge Generator": "Generátor odznaků pro {0}",
"Badge Label Color": "Barva štítku odznaku", "Badge Label Color": "Barva štítku odznaku",
"Badge Color": "Barva odznaku", "Badge Color": "Barva odznaku",
"Badge Style": "Styl odznaku", "Badge Style": "Styl odznaku",
@ -769,9 +769,20 @@
"Badge URL": "URL odznaku", "Badge URL": "URL odznaku",
"Badge Suffix": "Přípona odznaku", "Badge Suffix": "Přípona odznaku",
"Badge Label Prefix": "Prefix štítku odznaku", "Badge Label Prefix": "Prefix štítku odznaku",
"Badge Up Color": "Barva odzanaku při Běží", "Badge Up Color": "Barva odznaku při Běží",
"Badge Down Color": "Barva odznaku při Nedostupné", "Badge Down Color": "Barva odznaku při Nedostupné",
"Badge Pending Color": "Barva odznaku při Pauze", "Badge Pending Color": "Barva odznaku při Pauze",
"Badge Maintenance Color": "Barva odznaku při Údržbě", "Badge Maintenance Color": "Barva odznaku při Údržbě",
"Badge Warn Color": "Barva odznaku při Upozornění" "Badge Warn Color": "Barva odznaku při Upozornění",
"Reconnecting...": "Obnovení spojení...",
"Cannot connect to the socket server": "Nelze se připojit k soketovému serveru",
"Edit Maintenance": "Upravit Údržbu",
"Home": "Hlavní stránka",
"Badge Down Days": "Odznak nedostupných dní",
"Group": "Skupina",
"Monitor Group": "Sledovaná skupina",
"noGroupMonitorMsg": "Není k dispozici. Nejprve vytvořte skupin dohledů.",
"Close": "Zavřít",
"Badge value (For Testing only.)": "Hodnota odznaku (pouze pro testování)",
"Badge Warn Days": "Odznak dní s upozorněním"
} }

@ -776,5 +776,10 @@
"Badge Label Suffix": "Badge Label Suffix", "Badge Label Suffix": "Badge Label Suffix",
"Badge value (For Testing only.)": "Badge Wert (nur für Tests)", "Badge value (For Testing only.)": "Badge Wert (nur für Tests)",
"Show Clickable Link Description": "Wenn diese Option aktiviert ist, kann jeder, der Zugriff auf diese Statusseite hat, auf die Monitor URL zugreifen.", "Show Clickable Link Description": "Wenn diese Option aktiviert ist, kann jeder, der Zugriff auf diese Statusseite hat, auf die Monitor URL zugreifen.",
"Badge Down Color": "Badge Down Farbe" "Badge Down Color": "Badge Down Farbe",
"Edit Maintenance": "Wartung bearbeiten",
"Group": "Gruppe",
"Monitor Group": "Monitor Gruppe",
"noGroupMonitorMsg": "Nicht verfügbar. Erstelle zunächst einen Gruppenmonitor.",
"Close": "Schliessen"
} }

@ -782,5 +782,7 @@
"Badge Suffix": "Badge Suffix", "Badge Suffix": "Badge Suffix",
"Badge Warn Days": "Badge Warnung Tage", "Badge Warn Days": "Badge Warnung Tage",
"Group": "Gruppe", "Group": "Gruppe",
"Monitor Group": "Monitor Gruppe" "Monitor Group": "Monitor Gruppe",
"noGroupMonitorMsg": "Nicht verfügbar. Erstelle zunächst einen Gruppenmonitor.",
"Close": "Schließen"
} }

@ -51,6 +51,9 @@
"Ping": "Ping", "Ping": "Ping",
"Monitor Type": "Monitor Type", "Monitor Type": "Monitor Type",
"Keyword": "Keyword", "Keyword": "Keyword",
"Invert Keyword": "Invert Keyword",
"Expected Value": "Expected Value",
"Json Query": "Json Query",
"Friendly Name": "Friendly Name", "Friendly Name": "Friendly Name",
"URL": "URL", "URL": "URL",
"Hostname": "Hostname", "Hostname": "Hostname",
@ -195,8 +198,11 @@
"Content Type": "Content Type", "Content Type": "Content Type",
"webhookJsonDesc": "{0} is good for any modern HTTP servers such as Express.js", "webhookJsonDesc": "{0} is good for any modern HTTP servers such as Express.js",
"webhookFormDataDesc": "{multipart} is good for PHP. The JSON will need to be parsed with {decodeFunction}", "webhookFormDataDesc": "{multipart} is good for PHP. The JSON will need to be parsed with {decodeFunction}",
"webhookCustomBodyDesc": "Define a custom HTTP Body for the request. Template variables {msg}, {heartbeat}, {monitor} are accepted.",
"webhookAdditionalHeadersTitle": "Additional Headers", "webhookAdditionalHeadersTitle": "Additional Headers",
"webhookAdditionalHeadersDesc": "Sets additional headers sent with the webhook.", "webhookAdditionalHeadersDesc": "Sets additional headers sent with the webhook. Each header should be defined as a JSON key/value.",
"webhookBodyPresetOption": "Preset - {0}",
"webhookBodyCustomOption": "Custom Body",
"Webhook URL": "Webhook URL", "Webhook URL": "Webhook URL",
"Application Token": "Application Token", "Application Token": "Application Token",
"Server URL": "Server URL", "Server URL": "Server URL",
@ -435,6 +441,9 @@
"Enable DNS Cache": "Enable DNS Cache", "Enable DNS Cache": "Enable DNS Cache",
"Enable": "Enable", "Enable": "Enable",
"Disable": "Disable", "Disable": "Disable",
"chromeExecutable": "Chrome/Chromium Executable",
"chromeExecutableAutoDetect": "Auto Detect",
"chromeExecutableDescription": "For Docker users, if Chromium is not yet installed, it may take a few minutes to install and display the test result. It takes 1GB of disk space.",
"dnsCacheDescription": "It may be not working in some IPv6 environments, disable it if you encounter any issues.", "dnsCacheDescription": "It may be not working in some IPv6 environments, disable it if you encounter any issues.",
"Single Maintenance Window": "Single Maintenance Window", "Single Maintenance Window": "Single Maintenance Window",
"Maintenance Time Window of a Day": "Maintenance Time Window of a Day", "Maintenance Time Window of a Day": "Maintenance Time Window of a Day",
@ -515,6 +524,8 @@
"passwordNotMatchMsg": "The repeat password does not match.", "passwordNotMatchMsg": "The repeat password does not match.",
"notificationDescription": "Notifications must be assigned to a monitor to function.", "notificationDescription": "Notifications must be assigned to a monitor to function.",
"keywordDescription": "Search keyword in plain HTML or JSON response. The search is case-sensitive.", "keywordDescription": "Search keyword in plain HTML or JSON response. The search is case-sensitive.",
"invertKeywordDescription": "Look for the keyword to be absent rather than present.",
"jsonQueryDescription": "Do a json Query against the response and check for expected value (Return value will get converted into string for comparison). Check out <a href='https://jsonata.org/'>jsonata.org</a> for the documentation about the query language. A playground can be found <a href='https://try.jsonata.org/'>here</a>.",
"backupDescription": "You can backup all monitors and notifications into a JSON file.", "backupDescription": "You can backup all monitors and notifications into a JSON file.",
"backupDescription2": "Note: history and event data is not included.", "backupDescription2": "Note: history and event data is not included.",
"backupDescription3": "Sensitive data such as notification tokens are included in the export file; please store export securely.", "backupDescription3": "Sensitive data such as notification tokens are included in the export file; please store export securely.",
@ -629,6 +640,8 @@
"matrixDesc1": "You can find the internal room ID by looking in the advanced section of the room settings in your Matrix client. It should look like !QMdRCpUIfLwsfjxye6:home.server.", "matrixDesc1": "You can find the internal room ID by looking in the advanced section of the room settings in your Matrix client. It should look like !QMdRCpUIfLwsfjxye6:home.server.",
"matrixDesc2": "It is highly recommended you create a new user and do not use your own Matrix user's access token as it will allow full access to your account and all the rooms you joined. Instead, create a new user and only invite it to the room that you want to receive the notification in. You can get the access token by running {0}", "matrixDesc2": "It is highly recommended you create a new user and do not use your own Matrix user's access token as it will allow full access to your account and all the rooms you joined. Instead, create a new user and only invite it to the room that you want to receive the notification in. You can get the access token by running {0}",
"Channel Name": "Channel Name", "Channel Name": "Channel Name",
"Notify Channel": "Notify Channel",
"aboutNotifyChannel": "Notify channel will trigger a desktop or mobile notification for all members of the channel, whether their availability is set to active or away.",
"Uptime Kuma URL": "Uptime Kuma URL", "Uptime Kuma URL": "Uptime Kuma URL",
"Icon Emoji": "Icon Emoji", "Icon Emoji": "Icon Emoji",
"signalImportant": "IMPORTANT: You cannot mix groups and numbers in recipients!", "signalImportant": "IMPORTANT: You cannot mix groups and numbers in recipients!",
@ -722,7 +735,8 @@
"ntfyAuthenticationMethod": "Authentication Method", "ntfyAuthenticationMethod": "Authentication Method",
"ntfyUsernameAndPassword": "Username and Password", "ntfyUsernameAndPassword": "Username and Password",
"twilioAccountSID": "Account SID", "twilioAccountSID": "Account SID",
"twilioAuthToken": "Auth Token", "twilioApiKey": "Api Key (optional)",
"twilioAuthToken": "Auth Token / Api Key Secret",
"twilioFromNumber": "From Number", "twilioFromNumber": "From Number",
"twilioToNumber": "To Number", "twilioToNumber": "To Number",
"Monitor Setting": "{0}'s Monitor Setting", "Monitor Setting": "{0}'s Monitor Setting",
@ -731,13 +745,14 @@
"Open Badge Generator": "Open Badge Generator", "Open Badge Generator": "Open Badge Generator",
"Badge Generator": "{0}'s Badge Generator", "Badge Generator": "{0}'s Badge Generator",
"Badge Type": "Badge Type", "Badge Type": "Badge Type",
"Badge Duration": "Badge Duration", "Badge Duration (in hours)": "Badge Duration (in hours)",
"Badge Label": "Badge Label", "Badge Label": "Badge Label",
"Badge Prefix": "Badge Prefix", "Badge Prefix": "Badge Value Prefix",
"Badge Suffix": "Badge Suffix", "Badge Suffix": "Badge Value Suffix",
"Badge Label Color": "Badge Label Color", "Badge Label Color": "Badge Label Color",
"Badge Color": "Badge Color", "Badge Color": "Badge Color",
"Badge Label Prefix": "Badge Label Prefix", "Badge Label Prefix": "Badge Label Prefix",
"Badge Preview": "Badge Preview",
"Badge Label Suffix": "Badge Label Suffix", "Badge Label Suffix": "Badge Label Suffix",
"Badge Up Color": "Badge Up Color", "Badge Up Color": "Badge Up Color",
"Badge Down Color": "Badge Down Color", "Badge Down Color": "Badge Down Color",
@ -751,5 +766,7 @@
"Badge URL": "Badge URL", "Badge URL": "Badge URL",
"Group": "Group", "Group": "Group",
"Monitor Group": "Monitor Group", "Monitor Group": "Monitor Group",
"noGroupMonitorMsg": "Not Available. Create a Group Monitor First." "noGroupMonitorMsg": "Not Available. Create a Group Monitor First.",
"Close": "Close",
"Request Body": "Request Body"
} }

@ -751,5 +751,7 @@
"statusPageRefreshIn": "Reinicio en: {0}", "statusPageRefreshIn": "Reinicio en: {0}",
"twilioAuthToken": "Token de Autentificación", "twilioAuthToken": "Token de Autentificación",
"ntfyUsernameAndPassword": "Nombre de Usuario y Contraseña", "ntfyUsernameAndPassword": "Nombre de Usuario y Contraseña",
"ntfyAuthenticationMethod": "Método de Autentificación" "ntfyAuthenticationMethod": "Método de Autentificación",
"Cannot connect to the socket server": "No se puede conectar al servidor socket",
"Reconnecting...": "Reconectando..."
} }

@ -745,5 +745,13 @@
"Show Clickable Link Description": "اگر انتخاب شود، همه کسانی که به این صفحه وضعیت دسترسی دارند میتوانند به صفحه مانیتور نیز دسترسی داشته باشند.", "Show Clickable Link Description": "اگر انتخاب شود، همه کسانی که به این صفحه وضعیت دسترسی دارند میتوانند به صفحه مانیتور نیز دسترسی داشته باشند.",
"Badge Up Color": "رنگ نشان زمانی که مانیتور بدون مشکل و بالا است", "Badge Up Color": "رنگ نشان زمانی که مانیتور بدون مشکل و بالا است",
"Badge Pending Color": "رنگ نشان زمانی که مانیتور در حال انتظار است", "Badge Pending Color": "رنگ نشان زمانی که مانیتور در حال انتظار است",
"Badge Warn Days": "روزهایی که مانیتور در حالت هشدار است" "Badge Warn Days": "روزهایی که مانیتور در حالت هشدار است",
"noGroupMonitorMsg": "موجود نیست. ابتدا یک گروه مانیتور جدید ایجاد کنید.",
"Home": "خانه",
"Edit Maintenance": "ویرایش تعمیر و نگهداری",
"Cannot connect to the socket server": "عدم امکان ارتباط با سوکت سرور",
"Reconnecting...": "ارتباط مجدد...",
"Monitor Group": "گروه مانیتور",
"Group": "گروه",
"Close": "بستن"
} }

@ -59,7 +59,7 @@
"Add New Monitor": "Ajouter une nouvelle sonde", "Add New Monitor": "Ajouter une nouvelle sonde",
"Quick Stats": "Résumé", "Quick Stats": "Résumé",
"Up": "En ligne", "Up": "En ligne",
"Down": "Bas", "Down": "Hors ligne",
"Pending": "En attente", "Pending": "En attente",
"Unknown": "Inconnu", "Unknown": "Inconnu",
"Pause": "En pause", "Pause": "En pause",
@ -88,8 +88,8 @@
"Port": "Port", "Port": "Port",
"Heartbeat Interval": "Intervalle de vérification", "Heartbeat Interval": "Intervalle de vérification",
"Retries": "Essais", "Retries": "Essais",
"Heartbeat Retry Interval": "Réessayer l'intervalle de vérification", "Heartbeat Retry Interval": "Intervalle de ré-essaie",
"Resend Notification if Down X times consecutively": "Renvoyer la notification si en panne X fois consécutivement", "Resend Notification if Down X times consecutively": "Renvoyer la notification si hors ligne X fois consécutivement",
"Advanced": "Avancé", "Advanced": "Avancé",
"Upside Down Mode": "Mode inversé", "Upside Down Mode": "Mode inversé",
"Max. Redirects": "Nombre maximum de redirections", "Max. Redirects": "Nombre maximum de redirections",
@ -775,5 +775,14 @@
"Monitor Setting": "Réglage de la sonde {0}", "Monitor Setting": "Réglage de la sonde {0}",
"Badge Generator": "Générateur de badges {0}", "Badge Generator": "Générateur de badges {0}",
"Badge Label": "Étiquette de badge", "Badge Label": "Étiquette de badge",
"Badge URL": "URL du badge" "Badge URL": "URL du badge",
"Cannot connect to the socket server": "Impossible de se connecter au serveur de socket",
"Reconnecting...": "Reconnexion...",
"Edit Maintenance": "Modifier la maintenance",
"Monitor Group": "Groupe de sonde | Groupe de sondes",
"Badge Down Days": "Badge hors ligne",
"Group": "Groupe",
"Home": "Accueil",
"noGroupMonitorMsg": "Pas disponible. Créez d'abord une sonde de groupe.",
"Close": "Fermer"
} }

@ -0,0 +1,23 @@
{
"Settings": "Axustes",
"Dashboard": "Panel",
"Help": "Axuda",
"General": "Xeral",
"List": "Lista",
"Home": "Casa",
"Add": "Engadir",
"Up": "Arriba",
"Pending": "Pendente",
"statusMaintenance": "Mantemento",
"Maintenance": "Mantemento",
"Unknown": "Descoñecido",
"Reconnecting...": "Reconectando...",
"pauseDashboardHome": "Pausa",
"Pause": "Pausa",
"Name": "Nome",
"Status": "Estado",
"DateTime": "DataHora",
"Message": "Mensaxe",
"languageName": "Galego",
"Down": "Abaixo"
}

@ -724,5 +724,22 @@
"Edit Tag": "עריכת תגית", "Edit Tag": "עריכת תגית",
"Learn More": "לקריאה נוספת", "Learn More": "לקריאה נוספת",
"telegramSendSilently": "שליחה שקטה", "telegramSendSilently": "שליחה שקטה",
"telegramSendSilentlyDescription": "שליחת הודעות שקטה. משתמשים יקבלו ההתראה ללא צליל." "telegramSendSilentlyDescription": "שליחת הודעות שקטה. משתמשים יקבלו ההתראה ללא צליל.",
"Add New Tag": "הוסף תג חדש",
"Home": "ראשי",
"sameAsServerTimezone": "אותו איזור זמן כמו השרת",
"cronSchedule": "לו\"ז: ",
"twilioToNumber": "למספר",
"startDateTime": "תאריך\\זמן התחלה",
"pagertreeSilent": "שקט",
"Reconnecting...": "מתחבר מחדש...",
"statusPageRefreshIn": "רענון תוך: {0}",
"Edit Maintenance": "ערוך תחזוקה",
"pagertreeUrgency": "דחיפות",
"pagertreeLow": "נמוכה",
"pagertreeMedium": "בינונית",
"pagertreeHigh": "גבוהה",
"pagertreeCritical": "קריטי",
"pagertreeResolve": "הגדרה אוטומטית",
"ntfyUsernameAndPassword": "שם משתמש וסיסמא"
} }

@ -0,0 +1,43 @@
{
"Dashboard": "डैशबोर्ड",
"Help": "मदद",
"New Update": "नया अपडेट",
"Language": "भाषा",
"Appearance": "अपीयरेंस",
"Theme": "थीम",
"Game": "गेम",
"languageName": "हिंदी",
"Settings": "सेटिंग्स",
"General": "जनरल",
"List": "सूची",
"Add": "जोड़ें",
"Add New Monitor": "नया मॉनिटर जोड़ें",
"Pending": "लंबित",
"statusMaintenance": "रखरखाव",
"Maintenance": "रखरखाव",
"Unknown": "अज्ञात",
"Cannot connect to the socket server": "सॉकेट सर्वर से कनेक्ट नहीं हो सकता",
"pauseDashboardHome": "विराम",
"Resume": "फिर से शुरू करें",
"Delete": "हटाएं",
"Current": "मौजूदा",
"Up": "चालू",
"General Monitor Type": "सामान्य मॉनिटर प्रकार",
"Specific Monitor Type": "विशिष्ट मॉनिटर प्रकार",
"Pause": "विराम",
"Name": "नाम",
"Message": "संदेश",
"No important events": "कोई महत्वपूर्ण घटनाक्रम नहीं",
"Edit": "परिवर्तन",
"Ping": "पिंग",
"Monitor Type": "मॉनिटर प्रकार",
"Keyword": "कीवर्ड",
"Friendly Name": "दोस्ताना नाम",
"Version": "संस्करण",
"Home": "घर",
"Quick Stats": "शीघ्र आँकड़े",
"Reconnecting...": "पुनः कनेक्ट किया जा रहा है...",
"Down": "बंद",
"Passive Monitor Type": "निष्क्रिय मॉनिटर प्रकार",
"Status": "स्थिति"
}

@ -751,5 +751,13 @@
"endDateTime": "Data/godzina zakończenia", "endDateTime": "Data/godzina zakończenia",
"cronExpression": "Wyrażenie Cron", "cronExpression": "Wyrażenie Cron",
"ntfyAuthenticationMethod": "Metoda Uwierzytelnienia", "ntfyAuthenticationMethod": "Metoda Uwierzytelnienia",
"ntfyUsernameAndPassword": "Nazwa użytkownika i hasło" "ntfyUsernameAndPassword": "Nazwa użytkownika i hasło",
"noGroupMonitorMsg": "Niedostępna. Stwórz najpierw grupę monitorów.",
"Close": "Zamknij",
"pushoverMessageTtl": "TTL wiadomości (sekundy)",
"Home": "Strona główna",
"Group": "Grupa",
"Monitor Group": "Grupa monitora",
"Reconnecting...": "Ponowne łączenie...",
"Cannot connect to the socket server": "Nie można połączyć się z serwerem gniazda"
} }

@ -6,7 +6,7 @@
"upsideDownModeDescription": "Реверс статуса сервиса. Если сервис доступен, то он помечается как НЕДОСТУПНЫЙ.", "upsideDownModeDescription": "Реверс статуса сервиса. Если сервис доступен, то он помечается как НЕДОСТУПНЫЙ.",
"maxRedirectDescription": "Максимальное количество перенаправлений. Поставьте 0, чтобы отключить перенаправления.", "maxRedirectDescription": "Максимальное количество перенаправлений. Поставьте 0, чтобы отключить перенаправления.",
"acceptedStatusCodesDescription": "Выберите коды статусов для определения доступности сервиса.", "acceptedStatusCodesDescription": "Выберите коды статусов для определения доступности сервиса.",
"passwordNotMatchMsg": "Повтор пароля не совпадает.", "passwordNotMatchMsg": "Введёные пароли не совпадают",
"notificationDescription": "Привяжите уведомления к мониторам.", "notificationDescription": "Привяжите уведомления к мониторам.",
"keywordDescription": "Поиск слова в чистом HTML или в JSON-ответе (чувствительно к регистру).", "keywordDescription": "Поиск слова в чистом HTML или в JSON-ответе (чувствительно к регистру).",
"pauseDashboardHome": "Пауза", "pauseDashboardHome": "Пауза",
@ -43,7 +43,7 @@
"Delete": "Удалить", "Delete": "Удалить",
"Current": "Текущий", "Current": "Текущий",
"Uptime": "Аптайм", "Uptime": "Аптайм",
"Cert Exp.": "Сертификат истекает.", "Cert Exp.": "Сертификат истекает",
"day": "день | дней", "day": "день | дней",
"-day": "-дней", "-day": "-дней",
"hour": "час", "hour": "час",
@ -69,7 +69,7 @@
"Light": "Светлая", "Light": "Светлая",
"Dark": "Тёмная", "Dark": "Тёмная",
"Auto": "Авто", "Auto": "Авто",
"Theme - Heartbeat Bar": "Тема - Полоса частоты опроса", "Theme - Heartbeat Bar": "Полоса частоты опроса",
"Normal": "Обычный", "Normal": "Обычный",
"Bottom": "Снизу", "Bottom": "Снизу",
"None": "Отсутствует", "None": "Отсутствует",
@ -160,7 +160,7 @@
"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": "Оранжевый",
@ -175,9 +175,9 @@
"Entry Page": "Главная страница", "Entry Page": "Главная страница",
"statusPageNothing": "Здесь пусто. Добавьте группу или монитор.", "statusPageNothing": "Здесь пусто. Добавьте группу или монитор.",
"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": "Редактировать",
@ -212,7 +212,7 @@
"pushOptionalParams": "Опциональные параметры: {0}", "pushOptionalParams": "Опциональные параметры: {0}",
"defaultNotificationName": "Моё уведомление {notification} ({number})", "defaultNotificationName": "Моё уведомление {notification} ({number})",
"here": "здесь", "here": "здесь",
"Required": "Требуется", "Required": "Обязательно",
"Bot Token": "Токен бота", "Bot Token": "Токен бота",
"wayToGetTelegramToken": "Вы можете взять токен здесь - {0}.", "wayToGetTelegramToken": "Вы можете взять токен здесь - {0}.",
"Chat ID": "ID чата", "Chat ID": "ID чата",
@ -296,7 +296,7 @@
"promosmsPhoneNumber": "Номер телефона (для получателей из Польши можно пропустить код региона)", "promosmsPhoneNumber": "Номер телефона (для получателей из Польши можно пропустить код региона)",
"promosmsSMSSender": "Имя отправителя SMS: Зарегистрированное или одно из имён по умолчанию: InfoSMS, SMS Info, MaxSMS, INFO, SMS", "promosmsSMSSender": "Имя отправителя SMS: Зарегистрированное или одно из имён по умолчанию: InfoSMS, SMS Info, MaxSMS, INFO, SMS",
"Feishu WebHookUrl": "Feishu WebHookURL", "Feishu WebHookUrl": "Feishu WebHookURL",
"matrixHomeserverURL": "URL сервера (вместе с http(s):// и опционально порт)", "matrixHomeserverURL": "URL сервера (вместе с http(s):// и по желанию порт)",
"Internal Room Id": "Внутренний ID комнаты", "Internal Room Id": "Внутренний ID комнаты",
"matrixDesc1": "Внутренний ID комнаты можно найти в Подробностях в параметрах канала вашего Matrix клиента. Он должен выглядеть примерно как !QMdRCpUIfLwsfjxye6:home.server.", "matrixDesc1": "Внутренний ID комнаты можно найти в Подробностях в параметрах канала вашего Matrix клиента. Он должен выглядеть примерно как !QMdRCpUIfLwsfjxye6:home.server.",
"matrixDesc2": "Рекомендуется создать нового пользователя и не использовать токен доступа личного пользователя Matrix, т.к. это влечёт за собой полный доступ к аккаунту и к комнатам, в которых вы состоите. Вместо этого создайте нового пользователя и пригласите его только в ту комнату, в которой вы хотите получать уведомления. Токен доступа можно получить, выполнив команду {0}", "matrixDesc2": "Рекомендуется создать нового пользователя и не использовать токен доступа личного пользователя Matrix, т.к. это влечёт за собой полный доступ к аккаунту и к комнатам, в которых вы состоите. Вместо этого создайте нового пользователя и пригласите его только в ту комнату, в которой вы хотите получать уведомления. Токен доступа можно получить, выполнив команду {0}",
@ -335,9 +335,9 @@
"Current User": "Текущий пользователь", "Current User": "Текущий пользователь",
"About": "О программе", "About": "О программе",
"Description": "Описание", "Description": "Описание",
"Powered by": "Работает на основе скрипта от", "Powered by": "Работает на",
"shrinkDatabaseDescription": "Включает VACUUM для базы данных SQLite. Если ваша база данных была создана на версии 1.10.0 и более, AUTO_VACUUM уже включен и это действие не требуется.", "shrinkDatabaseDescription": "Включает VACUUM для базы данных SQLite. Если ваша база данных была создана на версии 1.10.0 и более, AUTO_VACUUM уже включен и это действие не требуется.",
"deleteStatusPageMsg": "Вы действительно хотите удалить эту страницу статуса сервисов?", "deleteStatusPageMsg": "Вы действительно хотите удалить эту страницу статуса?",
"Style": "Стиль", "Style": "Стиль",
"info": "ИНФО", "info": "ИНФО",
"warning": "ВНИМАНИЕ", "warning": "ВНИМАНИЕ",
@ -367,7 +367,7 @@
"Pick Accepted Status Codes...": "Выберите принятые коды состояния…", "Pick Accepted Status Codes...": "Выберите принятые коды состояния…",
"Default": "По умолчанию", "Default": "По умолчанию",
"Please input title and content": "Пожалуйста, введите название и содержание", "Please input title and content": "Пожалуйста, введите название и содержание",
"Last Updated": "Последнее Обновление", "Last Updated": "Последнее обновление",
"Untitled Group": "Группа без названия", "Untitled Group": "Группа без названия",
"Services": "Сервисы", "Services": "Сервисы",
"serwersms": "SerwerSMS.pl", "serwersms": "SerwerSMS.pl",
@ -379,11 +379,11 @@
"smtpDkimSettings": "DKIM Настройки", "smtpDkimSettings": "DKIM Настройки",
"smtpDkimDesc": "Пожалуйста ознакомьтесь с {0} Nodemailer DKIM для использования.", "smtpDkimDesc": "Пожалуйста ознакомьтесь с {0} Nodemailer DKIM для использования.",
"documentation": "документацией", "documentation": "документацией",
"smtpDkimDomain": "Имя Домена", "smtpDkimDomain": "Имя домена",
"smtpDkimKeySelector": "Ключ", "smtpDkimKeySelector": "Ключ",
"smtpDkimPrivateKey": "Приватный ключ", "smtpDkimPrivateKey": "Приватный ключ",
"smtpDkimHashAlgo": "Алгоритм хэша (опционально)", "smtpDkimHashAlgo": "Алгоритм хэша (необязательно)",
"smtpDkimheaderFieldNames": "Заголовок ключей для подписи (опционально)", "smtpDkimheaderFieldNames": "Заголовок ключей для подписи (необязательно)",
"smtpDkimskipFields": "Заголовок ключей не для подписи (опционально)", "smtpDkimskipFields": "Заголовок ключей не для подписи (опционально)",
"gorush": "Gorush", "gorush": "Gorush",
"alerta": "Alerta", "alerta": "Alerta",
@ -439,9 +439,9 @@
"Uptime Kuma": "Uptime Kuma", "Uptime Kuma": "Uptime Kuma",
"Slug": "Slug", "Slug": "Slug",
"Accept characters:": "Принимаемые символы:", "Accept characters:": "Принимаемые символы:",
"startOrEndWithOnly": "Начинается или кончается только {0}", "startOrEndWithOnly": "Начинается или заканчивается только на {0}",
"No consecutive dashes": "Без последовательных тире", "No consecutive dashes": "Без последовательных тире",
"The slug is already taken. Please choose another slug.": "Слово уже занято. Пожалуйста, выберите другой вариант.", "The slug is already taken. Please choose another slug.": "Этот slug уже занят. Пожалуйста, выберите другой.",
"Page Not Found": "Страница не найдена", "Page Not Found": "Страница не найдена",
"wayToGetCloudflaredURL": "(Скачать cloudflared с {0})", "wayToGetCloudflaredURL": "(Скачать cloudflared с {0})",
"cloudflareWebsite": "Веб-сайт Cloudflare", "cloudflareWebsite": "Веб-сайт Cloudflare",
@ -467,7 +467,7 @@
"onebotMessageType": "Тип сообщения OneBot", "onebotMessageType": "Тип сообщения OneBot",
"onebotGroupMessage": "Группа", "onebotGroupMessage": "Группа",
"onebotPrivateMessage": "Private", "onebotPrivateMessage": "Private",
"onebotUserOrGroupId": "ID группы или пользователя", "onebotUserOrGroupId": "ID группы/пользователя",
"onebotSafetyTips": "В целях безопасности необходимо установить токен доступа", "onebotSafetyTips": "В целях безопасности необходимо установить токен доступа",
"PushDeer Key": "ключ PushDeer", "PushDeer Key": "ключ PushDeer",
"Footer Text": "Текст нижнего колонтитула", "Footer Text": "Текст нижнего колонтитула",
@ -568,7 +568,7 @@
"goAlertInfo": "GoAlert — это приложение с открытым исходным кодом для составления расписания вызовов, автоматической эскалации и уведомлений (например, SMS или голосовых звонков). Автоматически привлекайте нужного человека, нужным способом и в нужное время! {0}", "goAlertInfo": "GoAlert — это приложение с открытым исходным кодом для составления расписания вызовов, автоматической эскалации и уведомлений (например, SMS или голосовых звонков). Автоматически привлекайте нужного человека, нужным способом и в нужное время! {0}",
"goAlertIntegrationKeyInfo": "Получить общий ключ интеграции API для сервиса в этом формате \"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\" обычно значение параметра токена скопированного URL.", "goAlertIntegrationKeyInfo": "Получить общий ключ интеграции API для сервиса в этом формате \"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\" обычно значение параметра токена скопированного URL.",
"goAlert": "GoAlert", "goAlert": "GoAlert",
"backupOutdatedWarning": "Устарело: поскольку добавлено множество функций, а эта функция резервного копирования немного не поддерживается, она не может создать или восстановить полную резервную копию.", "backupOutdatedWarning": "Устарело: эта функция резервного копирования более не поддерживается. Поскольку добавлено множество функций, она не может создать или восстановить полную резервную копию.",
"backupRecommend": "Сделайте резервную копию тома или папки с данными (./data/) напрямую.", "backupRecommend": "Сделайте резервную копию тома или папки с данными (./data/) напрямую.",
"Optional": "Необязательно", "Optional": "Необязательно",
"squadcast": "Squadcast", "squadcast": "Squadcast",
@ -578,24 +578,24 @@
"SMSManager": "SMSManager", "SMSManager": "SMSManager",
"You can divide numbers with": "Вы можете делить числа с", "You can divide numbers with": "Вы можете делить числа с",
"or": "или", "or": "или",
"Maintenance": "Обслуживание", "Maintenance": "Техобслуживание",
"Schedule maintenance": "Запланировать обслуживание", "Schedule maintenance": "Запланировать техобслуживание",
"affectedMonitorsDescription": "Выберите мониторы, которые будут затронуты во время обслуживания", "affectedMonitorsDescription": "Выберите мониторы, которые будут затронуты во время техбслуживания",
"affectedStatusPages": "Показывать уведомление об обслуживании на выбранных страницах статуса", "affectedStatusPages": "Показывать уведомление о техбслуживании на выбранных страницах статуса",
"atLeastOneMonitor": "Выберите больше одного затрагиваемого монитора", "atLeastOneMonitor": "Выберите больше одного затрагиваемого монитора",
"dnsPortDescription": "По умолчанию порт DNS сервера - 53. Мы можете изменить его в любое время.", "dnsPortDescription": "По умолчанию порт DNS сервера - 53. Мы можете изменить его в любое время.",
"Monitor": "Монитор | Мониторы", "Monitor": "Монитор | Мониторы",
"webhookAdditionalHeadersTitle": "Дополнительные Заголовки", "webhookAdditionalHeadersTitle": "Дополнительные Заголовки",
"recurringIntervalMessage": "Запускать 1 раз каждый день | Запускать 1 раз каждые {0} дней", "recurringIntervalMessage": "Запускать 1 раз каждый день | Запускать 1 раз каждые {0} дней",
"error": "ошибка", "error": "ошибка",
"statusMaintenance": "Обслуживание", "statusMaintenance": "Техобслуживание",
"Affected Monitors": "Затронутые мониторы", "Affected Monitors": "Затронутые мониторы",
"Start of maintenance": "Начало обслуживания", "Start of maintenance": "Начало техобслуживания",
"All Status Pages": "Все страницы статусов", "All Status Pages": "Все страницы статусов",
"Select status pages...": "Выберите страницу статуса…", "Select status pages...": "Выберите страницу статуса…",
"resendEveryXTimes": "Повтор каждые {0} раз", "resendEveryXTimes": "Повтор каждые {0} раз",
"resendDisabled": "Повторная отправка отключена", "resendDisabled": "Повторная отправка отключена",
"deleteMaintenanceMsg": "Вы действительно хотите удалить это обслуживание?", "deleteMaintenanceMsg": "Вы действительно хотите удалить это техбслуживание?",
"critical": "критично", "critical": "критично",
"Custom Monitor Type": "Собственный тип монитора", "Custom Monitor Type": "Собственный тип монитора",
"markdownSupported": "Поддерживает синтаксис Markdown", "markdownSupported": "Поддерживает синтаксис Markdown",
@ -630,7 +630,7 @@
"lastDay2": "Второй последний день месяца", "lastDay2": "Второй последний день месяца",
"lastDay3": "Третий последний день месяца", "lastDay3": "Третий последний день месяца",
"lastDay4": "Четвертый последний день месяца", "lastDay4": "Четвертый последний день месяца",
"No Maintenance": "Без обслуживания", "No Maintenance": "Нет техбслуживаний",
"pauseMaintenanceMsg": "Вы уверены что хотите поставить на паузу?", "pauseMaintenanceMsg": "Вы уверены что хотите поставить на паузу?",
"maintenanceStatus-under-maintenance": "На техобслуживании", "maintenanceStatus-under-maintenance": "На техобслуживании",
"maintenanceStatus-inactive": "Неактивен", "maintenanceStatus-inactive": "Неактивен",
@ -640,13 +640,13 @@
"Display Timezone": "Показать часовой пояс", "Display Timezone": "Показать часовой пояс",
"Server Timezone": "Часовой пояс сервера", "Server Timezone": "Часовой пояс сервера",
"statusPageMaintenanceEndDate": "Конец", "statusPageMaintenanceEndDate": "Конец",
"IconUrl": "URL Иконки", "IconUrl": "URL иконки",
"Enable DNS Cache": "Включить DNS кэш", "Enable DNS Cache": "Включить DNS кэш",
"Enable": "Включить", "Enable": "Включить",
"Disable": "Отключить", "Disable": "Отключить",
"Single Maintenance Window": "Единое Окно Обслуживания", "Single Maintenance Window": "Единое окно техбслуживания",
"Schedule Maintenance": "Запланировать обслуживание", "Schedule Maintenance": "Запланировать техбслуживание",
"Date and Time": "Дата и Время", "Date and Time": "Дата и время",
"DateTime Range": "Промежуток даты и времени", "DateTime Range": "Промежуток даты и времени",
"uninstalling": "Удаляется", "uninstalling": "Удаляется",
"dataRetentionTimeError": "Период хранения должен быть равен 0 или больше", "dataRetentionTimeError": "Период хранения должен быть равен 0 или больше",
@ -676,10 +676,10 @@
"Integration URL": "URL интеграции", "Integration URL": "URL интеграции",
"do nothing": "ничего не делать", "do nothing": "ничего не делать",
"smseagleTo": "Номер(а) телефона", "smseagleTo": "Номер(а) телефона",
"smseagleGroup": "Название(я) групп телефонной книги", "smseagleGroup": "Название(я) группы телефонной книги",
"smseagleContact": "Имена контактов из телефонной книжки", "smseagleContact": "Имена контактов телефонной книги",
"smseagleRecipientType": "Тип получателя", "smseagleRecipientType": "Тип получателя",
"smseagleRecipient": "Получатель(я) (через запятую, если необходимо указать несколько)", "smseagleRecipient": "Получатель(и) (если множество, должны быть разделены запятой)",
"smseagleToken": "Токен доступа API", "smseagleToken": "Токен доступа API",
"smseagleUrl": "URL вашего SMSEagle устройства", "smseagleUrl": "URL вашего SMSEagle устройства",
"smseagleEncoding": "Отправить в юникоде", "smseagleEncoding": "Отправить в юникоде",
@ -695,7 +695,7 @@
"telegramProtectContentDescription": "Если включено, сообщения бота в Telegram будут запрещены для пересылки и сохранения.", "telegramProtectContentDescription": "Если включено, сообщения бота в Telegram будут запрещены для пересылки и сохранения.",
"telegramSendSilently": "Отправить без звука", "telegramSendSilently": "Отправить без звука",
"telegramSendSilentlyDescription": "Пользователи получат уведомление без звука.", "telegramSendSilentlyDescription": "Пользователи получат уведомление без звука.",
"Maintenance Time Window of a Day": "Суточный интервал для обслуживания", "Maintenance Time Window of a Day": "Суточный интервал для техбслуживания",
"Clone Monitor": "Копия", "Clone Monitor": "Копия",
"Clone": "Копия", "Clone": "Копия",
"cloneOf": "Копия {0}", "cloneOf": "Копия {0}",
@ -703,31 +703,31 @@
"Add New Tag": "Добавить тег", "Add New Tag": "Добавить тег",
"Body Encoding": "Тип содержимого запроса.(JSON or XML)", "Body Encoding": "Тип содержимого запроса.(JSON or XML)",
"Strategy": "Стратегия", "Strategy": "Стратегия",
"Free Mobile User Identifier": "Бесплатный идентификатор мобильного пользователя", "Free Mobile User Identifier": "Бесплатный мобильный идентификатор пользователя",
"Auto resolve or acknowledged": "Автоматическое разрешение или подтверждение", "Auto resolve or acknowledged": "Автоматическое разрешение или подтверждение",
"auto acknowledged": "автоматическое подтверждение", "auto acknowledged": "автоматическое подтверждение",
"auto resolve": "автоматическое разрешение", "auto resolve": "автоматическое разрешение",
"API Keys": "Ключи API", "API Keys": "Ключи API",
"Expiry": "Истекает", "Expiry": "Срок действия",
"Expiry date": "Дата окончания действия", "Expiry date": "Дата истечения срока действия",
"Don't expire": "Не истекает", "Don't expire": "Не истекает",
"Continue": "Продолжать", "Continue": "Продолжить",
"Add Another": "Добавьте еще один", "Add Another": "Добавить еще",
"Key Added": "Ключ добавлен", "Key Added": "Ключ добавлен",
"Add API Key": "Добавить ключ API", "Add API Key": "Добавить API ключ",
"No API Keys": "Нет API ключей", "No API Keys": "Нет ключей API",
"apiKey-active": "Активный", "apiKey-active": "Активный",
"apiKey-expired": "Истёк", "apiKey-expired": "Истёк",
"apiKey-inactive": "Неактивный", "apiKey-inactive": "Неактивный",
"Expires": "Истекает", "Expires": "Истекает",
"disableAPIKeyMsg": "Вы уверены, что хотите отключить этот ключ?", "disableAPIKeyMsg": "Вы уверены, что хотите отключить этот API ключ?",
"Generate": "Сгенерировать", "Generate": "Сгенерировать",
"pagertreeResolve": "Автоматическое разрешение", "pagertreeResolve": "Автоматическое разрешение",
"pagertreeDoNothing": "ничего не делать", "pagertreeDoNothing": "Ничего не делать",
"lunaseaTarget": "Цель", "lunaseaTarget": "Цель",
"lunaseaDeviceID": "Идентификатор устройства", "lunaseaDeviceID": "Идентификатор устройства",
"lunaseaUserID": "Идентификатор пользователя", "lunaseaUserID": "Идентификатор пользователя",
"Lowcost": "Низкая стоимость", "Lowcost": "Бюджетный",
"pagertreeIntegrationUrl": "URL-адрес интеграции", "pagertreeIntegrationUrl": "URL-адрес интеграции",
"pagertreeUrgency": "Срочность", "pagertreeUrgency": "Срочность",
"pagertreeSilent": "Тихий", "pagertreeSilent": "Тихий",
@ -736,15 +736,15 @@
"pagertreeHigh": "Высокий", "pagertreeHigh": "Высокий",
"pagertreeCritical": "Критический", "pagertreeCritical": "Критический",
"high": "высокий", "high": "высокий",
"promosmsAllowLongSMS": "Разрешить длинные SMS-сообщения", "promosmsAllowLongSMS": "Разрешить длинные СМС",
"Economy": "Экономия", "Economy": "Экономия",
"wayToGetPagerDutyKey": "Вы можете получить это, перейдя в службу -> Каталог служб -> (Выберите службу) -> Интеграции -> Добавить интеграцию. Здесь вы можете выполнить поиск по \"Events API V2\". Дополнительная информация {0}", "wayToGetPagerDutyKey": "Вы можете это получить, перейдя в Сервис -> Каталог сервисов -> (Выберите сервис) -> Интеграции -> Добавить интеграцию. Здесь вы можете искать «Events API V2». Подробнее {0}",
"apiKeyAddedMsg": "Ваш API ключ был добавлен. Пожалуйста, запишите это, так как оно больше не будет показан.", "apiKeyAddedMsg": "Ваш ключ API добавлен. Пожалуйста, обратите внимание на это сообщение, так как оно отображается один раз.",
"deleteAPIKeyMsg": "Вы уверены, что хотите удалить этот ключ API?", "deleteAPIKeyMsg": "Вы уверены, что хотите удалить этот ключ API?",
"wayToGetPagerTreeIntegrationURL": "После создания интеграции Uptime Kuma в PagerTree, скопируйте конечную точку. Смотрите полную информацию {0}", "wayToGetPagerTreeIntegrationURL": "После создания интеграции Uptime Kuma в PagerTree скопируйте файл Endpoint. См. полную информацию {0}",
"telegramMessageThreadIDDescription": "Необязательный уникальный идентификатор для цепочки сообщений (темы) форума; только для форумов-супергрупп", "telegramMessageThreadIDDescription": "Необязательный уникальный идентификатор для цепочки сообщений (темы) форума; только для форумов-супергрупп",
"grpcMethodDescription": "Название метода - преобразовать в формат cammelCase, такой как sayHello, check и т.д.", "grpcMethodDescription": "Имя метода преобразуется в формат cammelCase, например, sayHello, check и т. д.",
"Proto Service Name": "название службы Proto", "Proto Service Name": "Название службы Proto",
"Proto Method": "Метод Proto", "Proto Method": "Метод Proto",
"Proto Content": "Содержание Proto", "Proto Content": "Содержание Proto",
"telegramMessageThreadID": "(Необязательно) ID цепочки сообщений", "telegramMessageThreadID": "(Необязательно) ID цепочки сообщений",
@ -758,5 +758,40 @@
"endDateTime": "Конечная дата и время", "endDateTime": "Конечная дата и время",
"cronExpression": "Выражение для Cron", "cronExpression": "Выражение для Cron",
"cronSchedule": "Расписание: ", "cronSchedule": "Расписание: ",
"invalidCronExpression": "Неверное выражение Cron: {0}" "invalidCronExpression": "Неверное выражение Cron: {0}",
"ntfyUsernameAndPassword": "Логин и пароль",
"ntfyAuthenticationMethod": "Способ входа",
"Monitor Setting": "Настройка монитора {0}",
"Show Clickable Link": "Показать кликабельную ссылку",
"Badge Generator": "Генератор значков для {0}",
"Badge Type": "Тип значка",
"Badge Duration": "Срок действия значка",
"Badge Label": "Надпись для значка",
"Badge Prefix": "Префикс значка",
"Badge Label Color": "Цвет надписи значка",
"Badge Color": "Цвет значка",
"Badge Label Prefix": "Префикс надписи для значка",
"Open Badge Generator": "Открыть генератор значка",
"Badge Up Color": "Цвет значка для статуса \"Доступен\"",
"Badge Pending Color": "Цвет значка для статуса \"Ожидание\"",
"Badge Maintenance Color": "Цвет значка для статуса \"Техобслуживание\"",
"Badge Style": "Стиль значка",
"Badge Suffix": "Суффикс значка",
"Badge value (For Testing only.)": "Значение значка (только для тестирования)",
"Badge URL": "URL значка",
"Group": "Группа",
"Monitor Group": "Группа мониторов",
"Show Clickable Link Description": "Если флажок установлен, все, кто имеет доступ к этой странице состояния, могут иметь доступ к URL-адресу монитора.",
"pushoverMessageTtl": "TTL сообщения (в секундах)",
"Badge Down Color": "Цвет значка для статуса \"Недоступен\"",
"Badge Label Suffix": "Суффикс надписи для значка",
"Edit Maintenance": "Редактировать техобсоуживание",
"Reconnecting...": "Переподключение...",
"Cannot connect to the socket server": "Невозможно подключиться к серверу",
"Badge Warn Color": "Цвет значка для предупреждения",
"Badge Warn Days": "Значок для \"дней предупреждения\"",
"Badge Down Days": "Значок для \"дней недоступности\"",
"Home": "Главная",
"noGroupMonitorMsg": "Не доступно. Создайте сначала группу мониторов.",
"Close": "Закрыть"
} }

@ -214,7 +214,7 @@
"smtpBCC": "BCC", "smtpBCC": "BCC",
"discord": "Discord", "discord": "Discord",
"Discord Webhook URL": "Discord Webhook URL", "Discord Webhook URL": "Discord Webhook URL",
"wayToGetDiscordURL": "คุณสามารถรับได้โดยการไปที่ Server Settings -> Integrations -> Create Webhook", "wayToGetDiscordURL": "คุณสามารถทำได้โดยการไปที่ Server Settings -> Integrations -> Create Webhook",
"Bot Display Name": "ชื่อบอท", "Bot Display Name": "ชื่อบอท",
"Prefix Custom Message": "คำนำหน้าข้อความที่กำหนดเอง", "Prefix Custom Message": "คำนำหน้าข้อความที่กำหนดเอง",
"Hello @everyone is...": "สวัสดี {'@'}everyone นี่…", "Hello @everyone is...": "สวัสดี {'@'}everyone นี่…",
@ -652,5 +652,23 @@
"Enable DNS Cache": "เปิดใช้งาน DNS Cache", "Enable DNS Cache": "เปิดใช้งาน DNS Cache",
"Enable": "เปิดใช้งาน", "Enable": "เปิดใช้งาน",
"Disable": "ปิดใช้งาน", "Disable": "ปิดใช้งาน",
"Single Maintenance Window": "หน้าการปรับปรุงเดี่ยว" "Single Maintenance Window": "หน้าการปรับปรุงเดี่ยว",
"Clone Monitor": "มอนิเตอร์",
"Clone": "โคลนมอนิเตอร์",
"cloneOf": "ชื่อเล่นมอนิเตอร์",
"wayToGetZohoCliqURL": "คุณสามารถดูวิธีการสร้าง Webhook URL {0}",
"Cannot connect to the socket server": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ Socket",
"Reconnecting...": "กำลังเชื่อมต่อใหม่",
"Home": "หน้าหลัก",
"Date and Time": "วันที่และเวลา",
"DateTime Range": "ช่วงวันที่และเวลา",
"loadingError": "ไม่สามารถดึงข้อมูลได้ โปรดลองอีกครั้งในภายหลัง",
"plugin": "ปลั้กอิน | ปลั้กอิน",
"install": "ติดตั้ง",
"installing": "กำลังติดตั้ง",
"uninstall": "ถอนการติดตั้ง",
"uninstalling": "กำลังถอนการติดตั้ง",
"confirmUninstallPlugin": "แน่ใจหรือไม่ว่าต้องการถอนการติดตั้งปลั้กอินนี้?",
"Schedule Maintenance": "กำหนดเวลาซ่อมแซม",
"Edit Maintenance": "แก้ใขการบำรุงรักษา"
} }

@ -776,5 +776,13 @@
"Badge value (For Testing only.)": "Rozet değeri (Yalnızca Test için.)", "Badge value (For Testing only.)": "Rozet değeri (Yalnızca Test için.)",
"Badge URL": "Rozet URL'i", "Badge URL": "Rozet URL'i",
"Monitor Setting": "{0}'nin Monitör Ayarı", "Monitor Setting": "{0}'nin Monitör Ayarı",
"Show Clickable Link Description": "Eğer işaretlenirse, bu durum sayfasına erişimi olan herkes monitor URL'ine erişebilir." "Show Clickable Link Description": "Eğer işaretlenirse, bu durum sayfasına erişimi olan herkes monitor URL'ine erişebilir.",
"Group": "Grup",
"Monitor Group": "Monitor Grup",
"Cannot connect to the socket server": "Soket sunucusuna bağlanılamıyor",
"Edit Maintenance": "Bakımı Düzenle",
"Reconnecting...": "Yeniden bağlanılıyor...",
"Home": "Anasayfa",
"noGroupMonitorMsg": "Uygun değil. Önce bir Grup Monitörü oluşturun.",
"Close": "Kapalı"
} }

@ -462,7 +462,7 @@
"onebotMessageType": "OneBot тип повідомлення", "onebotMessageType": "OneBot тип повідомлення",
"onebotGroupMessage": "Група", "onebotGroupMessage": "Група",
"onebotPrivateMessage": "Приватне", "onebotPrivateMessage": "Приватне",
"onebotUserOrGroupId": "Група/Користувач ID", "onebotUserOrGroupId": "Група/ID користувача",
"onebotSafetyTips": "Для безпеки необхідно встановити маркер доступу", "onebotSafetyTips": "Для безпеки необхідно встановити маркер доступу",
"PushDeer Key": "PushDeer ключ", "PushDeer Key": "PushDeer ключ",
"Footer Text": "Текст нижнього колонтитула", "Footer Text": "Текст нижнього колонтитула",
@ -782,5 +782,13 @@
"Badge Warn Color": "Колір бейджа \"Попередження\"", "Badge Warn Color": "Колір бейджа \"Попередження\"",
"Badge Warn Days": "Бейдж \"Днів попередження\"", "Badge Warn Days": "Бейдж \"Днів попередження\"",
"Badge Maintenance Color": "Колір бейджа \"Обслуговування\"", "Badge Maintenance Color": "Колір бейджа \"Обслуговування\"",
"Badge Down Days": "Бейдж \"Днів недоступний\"" "Badge Down Days": "Бейдж \"Днів недоступний\"",
"Group": "Група",
"Monitor Group": "Група моніторів",
"Edit Maintenance": "Редагувати обслуговування",
"Cannot connect to the socket server": "Не вдається підключитися до сервера сокетів",
"Reconnecting...": "Повторне підключення...",
"Home": "Головна",
"noGroupMonitorMsg": "Недоступно. Спочатку створіть групу моніторів.",
"Close": "Закрити"
} }

@ -778,5 +778,13 @@
"Badge Label Prefix": "徽章标签前缀", "Badge Label Prefix": "徽章标签前缀",
"Badge Label Color": "徽章标签颜色", "Badge Label Color": "徽章标签颜色",
"Show Clickable Link Description": "勾选后所有能访问本状态页的访客均可查看该监控项网址。", "Show Clickable Link Description": "勾选后所有能访问本状态页的访客均可查看该监控项网址。",
"Show Clickable Link": "显示可点击的监控项链接" "Show Clickable Link": "显示可点击的监控项链接",
"Group": "组",
"Monitor Group": "监控项组",
"Cannot connect to the socket server": "无法连接到后端服务器",
"Reconnecting...": "重连中……",
"Edit Maintenance": "编辑维护计划",
"Home": "首页",
"noGroupMonitorMsg": "暂无可用,请先创建一个监控项组。",
"Close": "关闭"
} }

@ -706,5 +706,43 @@
"wayToGetKookBotToken": "到 {0} 創建應用程式並取得 bot token", "wayToGetKookBotToken": "到 {0} 創建應用程式並取得 bot token",
"dataRetentionTimeError": "保留期限必須為 0 或正數", "dataRetentionTimeError": "保留期限必須為 0 或正數",
"infiniteRetention": "設定為 0 以作無限期保留。", "infiniteRetention": "設定為 0 以作無限期保留。",
"confirmDeleteTagMsg": "你確定你要刪除此標籤?相關的監測器不會被刪除。" "confirmDeleteTagMsg": "你確定你要刪除此標籤?相關的監測器不會被刪除。",
"twilioAuthToken": "認證 Token",
"twilioAccountSID": "帳號 SID",
"ntfyUsernameAndPassword": "使用者名稱和密碼",
"ntfyAuthenticationMethod": "認證類型",
"API Keys": "API 金鑰",
"Expiry": "到期",
"apiKey-inactive": "無效",
"apiKey-expired": "過期",
"Reconnecting...": "重新連線...",
"Expiry date": "到期時間",
"Don't expire": "不要過期",
"Continue": "繼續",
"Add Another": "新增作者",
"Add API Key": "新增 API 金鑰",
"Generate": "產生",
"lunaseaTarget": "目標",
"lunaseaDeviceID": "裝置 ID",
"lunaseaUserID": "使用者 ID",
"Cannot connect to the socket server": "無法連線到 Socket 伺服器",
"Edit Maintenance": "編輯維護",
"deleteAPIKeyMsg": "您確定要刪除這個 API 金鑰?",
"Custom Monitor Type": "自訂監視器類型",
"Google Analytics ID": "Google Analytics ID",
"Server Address": "伺服器位置",
"Edit Tag": "編輯標籤",
"pagertreeMedium": "中",
"pagertreeHigh": "高",
"pagertreeResolve": "自動解決",
"pagertreeLow": "低",
"Learn More": "閱讀更多",
"pushoverMessageTtl": "Message TTL (秒)",
"apiKeyAddedMsg": "您的 API 金鑰已建立。金鑰不會再次顯示,請將它放在安全的地方。",
"No API Keys": "無 API 金鑰",
"apiKey-active": "活躍",
"Expires": "過期",
"disableAPIKeyMsg": "您確定要停用這個 API 金鑰?",
"Monitor Setting": "{0} 的監視器設定",
"Guild ID": "Guild ID"
} }

@ -30,6 +30,9 @@ export default {
theme() { theme() {
// As entry can be status page now, set forceStatusPageTheme to true to use status page theme // As entry can be status page now, set forceStatusPageTheme to true to use status page theme
if (this.forceStatusPageTheme) { if (this.forceStatusPageTheme) {
if (this.statusPageTheme === "auto") {
return this.system;
}
return this.statusPageTheme; return this.statusPageTheme;
} }

@ -8,12 +8,20 @@
<Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" /> <Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" />
</div> </div>
<p class="url"> <p class="url">
<a v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'mp-health' " :href="monitor.url" target="_blank" rel="noopener noreferrer">{{ filterPassword(monitor.url) }}</a> <a v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'mp-health' " :href="monitor.url" target="_blank" rel="noopener noreferrer">{{ filterPassword(monitor.url) }}</a>
<span v-if="monitor.type === 'port'">TCP Port {{ monitor.hostname }}:{{ monitor.port }}</span> <span v-if="monitor.type === 'port'">TCP Port {{ monitor.hostname }}:{{ monitor.port }}</span>
<span v-if="monitor.type === 'ping'">Ping: {{ monitor.hostname }}</span> <span v-if="monitor.type === 'ping'">Ping: {{ monitor.hostname }}</span>
<span v-if="monitor.type === 'keyword'"> <span v-if="monitor.type === 'keyword'">
<br> <br>
<span>{{ $t("Keyword") }}:</span> <span class="keyword">{{ monitor.keyword }}</span> <span>{{ $t("Keyword") }}: </span>
<span class="keyword">{{ monitor.keyword }}</span>
<span v-if="monitor.invertKeyword" alt="Inverted keyword" class="keyword-inverted"> </span>
</span>
<span v-if="monitor.type === 'json-query'">
<br>
<span>{{ $t("Json Query") }}:</span> <span class="keyword">{{ monitor.jsonPath }}</span>
<br>
<span>{{ $t("Expected Value") }}:</span> <span class="keyword">{{ monitor.expectedValue }}</span>
</span> </span>
<span v-if="monitor.type === 'dns'">[{{ monitor.dns_resolve_type }}] {{ monitor.hostname }} <span v-if="monitor.type === 'dns'">[{{ monitor.dns_resolve_type }}] {{ monitor.hostname }}
<br> <br>
@ -68,6 +76,7 @@
</div> </div>
</div> </div>
<!-- Stats -->
<div class="shadow-box big-padding text-center stats"> <div class="shadow-box big-padding text-center stats">
<div class="row"> <div class="row">
<div v-if="monitor.type !== 'group'" class="col-12 col-sm col row d-flex align-items-center d-sm-block"> <div v-if="monitor.type !== 'group'" class="col-12 col-sm col row d-flex align-items-center d-sm-block">
@ -131,6 +140,15 @@
</div> </div>
</div> </div>
<!-- Screenshot -->
<div v-if="monitor.type === 'real-browser'" class="shadow-box">
<div class="row">
<div class="col-md-6">
<img :src="screenshotURL" alt style="width: 100%;">
</div>
</div>
</div>
<div class="shadow-box table-shadow-box"> <div class="shadow-box table-shadow-box">
<div class="dropdown dropdown-clear-data"> <div class="dropdown dropdown-clear-data">
<button class="btn btn-sm btn-outline-danger dropdown-toggle" type="button" data-bs-toggle="dropdown"> <button class="btn btn-sm btn-outline-danger dropdown-toggle" type="button" data-bs-toggle="dropdown">
@ -217,6 +235,7 @@ import Tag from "../components/Tag.vue";
import CertificateInfo from "../components/CertificateInfo.vue"; import CertificateInfo from "../components/CertificateInfo.vue";
import { getMonitorRelativeURL } from "../util.ts"; import { getMonitorRelativeURL } from "../util.ts";
import { URL } from "whatwg-url"; import { URL } from "whatwg-url";
import { getResBaseURL } from "../util-frontend";
export default { export default {
components: { components: {
@ -242,6 +261,7 @@ export default {
hideCount: true, hideCount: true,
chunksNavigation: "scroll", chunksNavigation: "scroll",
}, },
cacheTime: Date.now(),
}; };
}, },
computed: { computed: {
@ -251,6 +271,10 @@ export default {
}, },
lastHeartBeat() { lastHeartBeat() {
// Also trigger screenshot refresh here
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.cacheTime = Date.now();
if (this.monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[this.monitor.id]) { if (this.monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[this.monitor.id]) {
return this.$root.lastHeartbeatList[this.monitor.id]; return this.$root.lastHeartbeatList[this.monitor.id];
} }
@ -325,11 +349,16 @@ export default {
pushURL() { pushURL() {
return this.$root.baseURL + "/api/push/" + this.monitor.pushToken + "?status=up&msg=OK&ping="; return this.$root.baseURL + "/api/push/" + this.monitor.pushToken + "?status=up&msg=OK&ping=";
}, },
screenshotURL() {
return getResBaseURL() + this.monitor.screenshot + "?time=" + this.cacheTime;
}
}, },
mounted() { mounted() {
}, },
methods: { methods: {
getResBaseURL,
/** Request a test notification be sent for this monitor */ /** Request a test notification be sent for this monitor */
testNotification() { testNotification() {
this.$root.getSocket().emit("testNotification", this.monitor.id); this.$root.getSocket().emit("testNotification", this.monitor.id);
@ -411,7 +440,7 @@ export default {
translationPrefix = "Avg. "; translationPrefix = "Avg. ";
} }
if (this.monitor.type === "http" || this.monitor.type === "keyword") { if (this.monitor.type === "http" || this.monitor.type === "keyword" || this.monitor.type === "json-query") {
return this.$t(translationPrefix + "Response"); return this.$t(translationPrefix + "Response");
} }
@ -561,6 +590,10 @@ table {
color: $dark-font-color; color: $dark-font-color;
} }
.keyword-inverted {
color: $dark-font-color;
}
.dropdown-clear-data { .dropdown-clear-data {
ul { ul {
background-color: $dark-bg; background-color: $dark-bg;

@ -27,6 +27,9 @@
<option value="keyword"> <option value="keyword">
HTTP(s) - {{ $t("Keyword") }} HTTP(s) - {{ $t("Keyword") }}
</option> </option>
<option value="json-query">
HTTP(s) - {{ $t("Json Query") }}
</option>
<option value="grpc-keyword"> <option value="grpc-keyword">
gRPC(s) - {{ $t("Keyword") }} gRPC(s) - {{ $t("Keyword") }}
</option> </option>
@ -36,6 +39,10 @@
<option value="docker"> <option value="docker">
{{ $t("Docker Container") }} {{ $t("Docker Container") }}
</option> </option>
<option value="real-browser">
HTTP(s) - Browser Engine (Chrome/Chromium) (Beta)
</option>
</optgroup> </optgroup>
<optgroup :label="$t('Passive Monitor Type')"> <optgroup :label="$t('Passive Monitor Type')">
@ -73,16 +80,6 @@
Redis Redis
</option> </option>
</optgroup> </optgroup>
<!--
Hidden for now: Reason refer to Setting.vue
<optgroup :label="$t('Custom Monitor Type')">
<option value="browser">
(Beta) HTTP(s) - Browser Engine (Chrome/Firefox)
</option>
</optgroup>
</select>
-->
</select> </select>
</div> </div>
@ -103,7 +100,7 @@
</div> </div>
<!-- URL --> <!-- URL -->
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'browser' " class="my-3"> <div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'real-browser' " class="my-3">
<label for="url" class="form-label">{{ $t("URL") }}</label> <label for="url" class="form-label">{{ $t("URL") }}</label>
<input id="url" v-model="monitor.url" type="url" class="form-control" pattern="https?://.+" required> <input id="url" v-model="monitor.url" type="url" class="form-control" pattern="https?://.+" required>
</div> </div>
@ -133,6 +130,31 @@
</div> </div>
</div> </div>
<!-- Invert keyword -->
<div v-if="monitor.type === 'keyword' || monitor.type === 'grpc-keyword'" class="my-3 form-check">
<input id="invert-keyword" v-model="monitor.invertKeyword" class="form-check-input" type="checkbox">
<label class="form-check-label" for="invert-keyword">
{{ $t("Invert Keyword") }}
</label>
<div class="form-text">
{{ $t("invertKeywordDescription") }}
</div>
</div>
<!-- Json Query -->
<div v-if="monitor.type === 'json-query'" class="my-3">
<label for="jsonPath" class="form-label">{{ $t("Json Query") }}</label>
<input id="jsonPath" v-model="monitor.jsonPath" type="text" class="form-control" required>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="form-text" v-html="$t('jsonQueryDescription')">
</div>
<br>
<label for="expectedValue" class="form-label">{{ $t("Expected Value") }}</label>
<input id="expectedValue" v-model="monitor.expectedValue" type="text" class="form-control" required>
</div>
<!-- Game --> <!-- Game -->
<!-- GameDig only --> <!-- GameDig only -->
<div v-if="monitor.type === 'gamedig'" class="my-3"> <div v-if="monitor.type === 'gamedig'" class="my-3">
@ -362,7 +384,7 @@
<h2 v-if="monitor.type !== 'push'" class="mt-5 mb-2">{{ $t("Advanced") }}</h2> <h2 v-if="monitor.type !== 'push'" class="mt-5 mb-2">{{ $t("Advanced") }}</h2>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3 form-check"> <div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' " class="my-3 form-check">
<input id="expiry-notification" v-model="monitor.expiryNotification" class="form-check-input" type="checkbox"> <input id="expiry-notification" v-model="monitor.expiryNotification" class="form-check-input" type="checkbox">
<label class="form-check-label" for="expiry-notification"> <label class="form-check-label" for="expiry-notification">
{{ $t("Certificate Expiry Notification") }} {{ $t("Certificate Expiry Notification") }}
@ -371,7 +393,7 @@
</div> </div>
</div> </div>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3 form-check"> <div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' " class="my-3 form-check">
<input id="ignore-tls" v-model="monitor.ignoreTls" class="form-check-input" type="checkbox" value=""> <input id="ignore-tls" v-model="monitor.ignoreTls" class="form-check-input" type="checkbox" value="">
<label class="form-check-label" for="ignore-tls"> <label class="form-check-label" for="ignore-tls">
{{ $t("ignoreTLSError") }} {{ $t("ignoreTLSError") }}
@ -463,7 +485,7 @@
</button> </button>
<!-- Proxies --> <!-- Proxies -->
<div v-if="monitor.type === 'http' || monitor.type === 'keyword'"> <div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query'">
<h2 class="mt-5 mb-2">{{ $t("Proxy") }}</h2> <h2 class="mt-5 mb-2">{{ $t("Proxy") }}</h2>
<p v-if="$root.proxyList.length === 0"> <p v-if="$root.proxyList.length === 0">
{{ $t("Not available, please setup.") }} {{ $t("Not available, please setup.") }}
@ -491,7 +513,7 @@
</div> </div>
<!-- HTTP Options --> <!-- HTTP Options -->
<template v-if="monitor.type === 'http' || monitor.type === 'keyword' "> <template v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' ">
<h2 class="mt-5 mb-2">{{ $t("HTTP Options") }}</h2> <h2 class="mt-5 mb-2">{{ $t("HTTP Options") }}</h2>
<!-- Method --> <!-- Method -->
@ -1113,7 +1135,7 @@ message HealthCheckResponse {
this.monitor.body = JSON.stringify(JSON.parse(this.monitor.body), null, 4); this.monitor.body = JSON.stringify(JSON.parse(this.monitor.body), null, 4);
} }
if (this.monitor.type && this.monitor.type !== "http" && this.monitor.type !== "keyword") { if (this.monitor.type && this.monitor.type !== "http" && (this.monitor.type !== "keyword" || this.monitor.type !== "json-query")) {
this.monitor.httpBodyEncoding = null; this.monitor.httpBodyEncoding = null;
} }

@ -116,12 +116,6 @@ export default {
backup: { backup: {
title: this.$t("Backup"), title: this.$t("Backup"),
}, },
/*
Hidden for now: Unfortunately, after some test, I found that Playwright requires a lot of libraries to be installed on the Linux host in order to start Chrome or Firefox.
It will be hard to install, so I hide this feature for now. But it still accessible via URL: /settings/plugins.
plugins: {
title: this.$tc("plugin", 2),
},*/
about: { about: {
title: this.$t("About"), title: this.$t("About"),
}, },

@ -325,7 +325,7 @@
</p> </p>
<div class="refresh-info mb-2"> <div class="refresh-info mb-2">
<div>{{ $t("Last Updated") }}: <date-time :value="lastUpdateTime" /></div> <div>{{ $t("Last Updated") }}: {{ lastUpdateTimeDisplay }}</div>
<div>{{ $tc("statusPageRefreshIn", [ updateCountdownText]) }}</div> <div>{{ $tc("statusPageRefreshIn", [ updateCountdownText]) }}</div>
</div> </div>
</footer> </footer>
@ -360,7 +360,6 @@ import DOMPurify from "dompurify";
import Confirm from "../components/Confirm.vue"; import Confirm from "../components/Confirm.vue";
import PublicGroupList from "../components/PublicGroupList.vue"; import PublicGroupList from "../components/PublicGroupList.vue";
import MaintenanceTime from "../components/MaintenanceTime.vue"; import MaintenanceTime from "../components/MaintenanceTime.vue";
import DateTime from "../components/Datetime.vue";
import { getResBaseURL } from "../util-frontend"; import { getResBaseURL } from "../util-frontend";
import { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE } from "../util.ts"; import { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE } from "../util.ts";
import Tag from "../components/Tag.vue"; import Tag from "../components/Tag.vue";
@ -386,7 +385,6 @@ export default {
Confirm, Confirm,
PrismEditor, PrismEditor,
MaintenanceTime, MaintenanceTime,
DateTime,
Tag, Tag,
VueMultiselect VueMultiselect
}, },
@ -583,6 +581,10 @@ export default {
return ""; return "";
} }
}, },
lastUpdateTimeDisplay() {
return this.$root.datetime(this.lastUpdateTime);
}
}, },
watch: { watch: {

@ -19,7 +19,6 @@ import DockerHosts from "./components/settings/Docker.vue";
import MaintenanceDetails from "./pages/MaintenanceDetails.vue"; import MaintenanceDetails from "./pages/MaintenanceDetails.vue";
import ManageMaintenance from "./pages/ManageMaintenance.vue"; import ManageMaintenance from "./pages/ManageMaintenance.vue";
import APIKeys from "./components/settings/APIKeys.vue"; import APIKeys from "./components/settings/APIKeys.vue";
import Plugins from "./components/settings/Plugins.vue";
// Settings - Sub Pages // Settings - Sub Pages
import Appearance from "./components/settings/Appearance.vue"; import Appearance from "./components/settings/Appearance.vue";
@ -130,10 +129,6 @@ const routes = [
path: "backup", path: "backup",
component: Backup, component: Backup,
}, },
{
path: "plugins",
component: Plugins,
},
{ {
path: "about", path: "about",
component: About, component: About,

@ -306,6 +306,16 @@ describe("Test uptimeKumaServer.getClientIP()", () => {
ip = await server.getClientIP(fakeSocket); ip = await server.getClientIP(fakeSocket);
expect(ip).toBe("203.0.113.195"); expect(ip).toBe("203.0.113.195");
fakeSocket.client.conn.remoteAddress = "2001:db8::1";
fakeSocket.client.conn.request.headers = {};
ip = await server.getClientIP(fakeSocket);
expect(ip).toBe("2001:db8::1");
fakeSocket.client.conn.remoteAddress = "::ffff:127.0.0.1";
fakeSocket.client.conn.request.headers = {};
ip = await server.getClientIP(fakeSocket);
expect(ip).toBe("127.0.0.1");
await Database.close(); await Database.close();
}, 120000); }, 120000);
}); });

Loading…
Cancel
Save