Merge remote-tracking branch 'origin/master' into 2.0.X

# Conflicts:
#	docker/debian-base.dockerfile
#	package-lock.json
#	package.json
#	server/database.js
#	src/router.js
This commit is contained in:
Louis Lam 2023-07-30 19:15:09 +08:00
commit a0bd4b248b
115 changed files with 2008 additions and 890 deletions

28
.devcontainer/README.md Normal file
View file

@ -0,0 +1,28 @@
# Codespaces
You can modifiy Uptime Kuma in your browser without setting up a local development.
![image](https://github.com/louislam/uptime-kuma/assets/1336778/31d9f06d-dd0b-4405-8e0d-a96586ee4595)
1. Click `Code` -> `Create codespace on master`
2. Wait a few minutes until you see there are two exposed ports
3. Go to the `3000` url, see if it is working
![image](https://github.com/louislam/uptime-kuma/assets/1336778/909b2eb4-4c5e-44e4-ac26-6d20ed856e7f)
## Frontend
Since the frontend is using [Vite.js](https://vitejs.dev/), all changes in this area will be hot-reloaded.
You don't need to restart the frontend, unless you try to add a new frontend dependency.
## Backend
The backend does not automatically hot-reload.
You will need to restart the backend after changing something using these steps:
1. Click `Terminal`
2. Click `Codespaces: server-dev` in the right panel
3. Press `Ctrl + C` to stop the server
4. Press `Up` to run `npm run start-server-dev`
![image](https://github.com/louislam/uptime-kuma/assets/1336778/e0c0a350-fe46-4588-9f37-e053c85834d1)

View file

@ -0,0 +1,22 @@
{
"image": "mcr.microsoft.com/devcontainers/javascript-node:dev-18-bookworm",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"updateContentCommand": "npm ci",
"postCreateCommand": "",
"postAttachCommand": {
"frontend-dev": "npm run start-frontend-devcontainer",
"server-dev": "npm run start-server-dev",
"open-port": "gh codespace ports visibility 3001:public -c $CODESPACE_NAME"
},
"customizations": {
"vscode": {
"extensions": [
"streetsidesoftware.code-spell-checker",
"dbaeumer.vscode-eslint"
]
}
},
"forwardPorts": [3000, 3001]
}

View file

@ -44,7 +44,7 @@ body:
id: operating-system
attributes:
label: "💻 Operating System and Arch"
description: "Which OS is your server/device running on?"
description: "Which OS is your server/device running on? (For Replit, please do not report this bug)"
placeholder: "Ex. Ubuntu 20.04 x86"
validations:
required: true
@ -52,7 +52,7 @@ body:
id: browser-vendor
attributes:
label: "🌐 Browser"
description: "Which browser are you running on?"
description: "Which browser are you running on? (For Replit, please do not report this bug)"
placeholder: "Ex. Google Chrome 95.0.4638.69"
validations:
required: true

View file

@ -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
name: Auto Test
@ -33,7 +33,6 @@ jobs:
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm install npm@latest -g
- run: npm install
- run: npm run build
@ -51,7 +50,7 @@ jobs:
strategy:
matrix:
os: [ ARMv7 ]
node: [ 14, 18 ]
node: [ 14.21.3, 18.16.1 ]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
@ -62,7 +61,6 @@ jobs:
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm install npm@latest -g
- run: npm ci --production
@ -77,7 +75,6 @@ jobs:
uses: actions/setup-node@v3
with:
node-version: 14
cache: 'npm'
- run: npm install
- run: npm run lint
@ -92,7 +89,6 @@ jobs:
uses: actions/setup-node@v3
with:
node-version: 14
cache: 'npm'
- run: npm install
- run: npm run build
- run: npm run cy:test
@ -108,7 +104,6 @@ jobs:
uses: actions/setup-node@v3
with:
node-version: 14
cache: 'npm'
- run: npm install
- run: npm run build
- run: npm run cy:run:unit

View file

@ -23,7 +23,7 @@ It is a temporary live demo, all data will be deleted after 10 minutes. Use the
## ⭐ 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
* 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
@ -54,7 +54,7 @@ Requirements:
- ✅ Major Linux distros such as Debian, Ubuntu, CentOS, Fedora and ArchLinux etc.
- ✅ Windows 10 (x64), Windows Server 2012 R2 (x64) or higher
- ❌ Replit / Heroku
- [Node.js](https://nodejs.org/en/download/) 14 / 16 / 18 (20 is not supported)
- [Node.js](https://nodejs.org/en/download/) 14 / 16 / 18 / 20.4
- [npm](https://docs.npmjs.com/cli/) >= 7
- [Git](https://git-scm.com/downloads)
- [pm2](https://pm2.keymetrics.io/) - For running Uptime Kuma in the background

View file

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

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,22 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
ALTER TABLE monitor
ADD kafka_producer_topic VARCHAR(255);
ALTER TABLE monitor
ADD kafka_producer_brokers TEXT;
ALTER TABLE monitor
ADD kafka_producer_ssl INTEGER;
ALTER TABLE monitor
ADD kafka_producer_allow_auto_topic_creation VARCHAR(255);
ALTER TABLE monitor
ADD kafka_producer_sasl_options TEXT;
ALTER TABLE monitor
ADD kafka_producer_message TEXT;
COMMIT;

View file

@ -2,13 +2,35 @@
FROM node:18-bullseye-slim AS base2-slim
ARG TARGETPLATFORM
RUN apt update && \
apt --yes --no-install-recommends install python3 python3-pip python3-cryptography python3-six python3-yaml python3-click python3-markdown python3-requests python3-requests-oauthlib \
sqlite3 iputils-ping util-linux dumb-init git curl ca-certificates && \
pip3 --no-cache-dir install apprise==1.4.0 && \
WORKDIR /app
# Specify --no-install-recommends to skip unused dependencies, make the base much smaller!
# python3* = apprise's dependencies
# sqlite3 = for debugging
# iputils-ping = for ping
# util-linux = for setpriv (Should be dropped in 2.0.0?)
# dumb-init = avoid zombie processes (#480)
# curl = for debugging
# ca-certificates = keep the cert up-to-date
# sudo = for start service nscd with non-root user
# nscd = for better DNS caching
# (pip) apprise = for notifications
RUN apt-get update && \
apt-get --yes --no-install-recommends install \
python3 python3-pip python3-cryptography python3-six python3-yaml python3-click python3-markdown python3-requests python3-requests-oauthlib \
sqlite3 \
iputils-ping \
util-linux \
dumb-init \
curl \
ca-certificates \
sudo \
nscd && \
pip3 --no-cache-dir install apprise==1.4.5 && \
rm -rf /var/lib/apt/lists/* && \
apt --yes autoremove
# Install cloudflared
RUN curl https://pkg.cloudflare.com/cloudflare-main.gpg --output /usr/share/keyrings/cloudflare-main.gpg && \
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bullseye main' | tee /etc/apt/sources.list.d/cloudflared.list && \
@ -18,6 +40,11 @@ RUN curl https://pkg.cloudflare.com/cloudflare-main.gpg --output /usr/share/keyr
rm -rf /var/lib/apt/lists/* && \
apt --yes autoremove
# For nscd
COPY ./docker/etc/nscd.conf /etc/nscd.conf
COPY ./docker/etc/sudoers /etc/sudoers
# Full Base Image
# MariaDB, Chromium and fonts
# Not working for armv7, so use the older version (10.5) of MariaDB from the debian repo
@ -30,5 +57,3 @@ RUN apt update && \
rm -rf /var/lib/apt/lists/* && \
apt --yes autoremove && \
chown -R node:node /var/lib/mysql

90
docker/etc/nscd.conf Normal file
View file

@ -0,0 +1,90 @@
#
# /etc/nscd.conf
#
# An example Name Service Cache config file. This file is needed by nscd.
#
# Legal entries are:
#
# logfile <file>
# debug-level <level>
# threads <initial #threads to use>
# max-threads <maximum #threads to use>
# server-user <user to run server as instead of root>
# server-user is ignored if nscd is started with -S parameters
# stat-user <user who is allowed to request statistics>
# reload-count unlimited|<number>
# paranoia <yes|no>
# restart-interval <time in seconds>
#
# enable-cache <service> <yes|no>
# positive-time-to-live <service> <time in seconds>
# negative-time-to-live <service> <time in seconds>
# suggested-size <service> <prime number>
# check-files <service> <yes|no>
# persistent <service> <yes|no>
# shared <service> <yes|no>
# max-db-size <service> <number bytes>
# auto-propagate <service> <yes|no>
#
# Currently supported cache names (services): passwd, group, hosts, services
#
# logfile /var/log/nscd.log
# threads 4
# max-threads 32
# server-user node
# stat-user somebody
debug-level 0
# reload-count 5
paranoia no
# restart-interval 3600
enable-cache passwd no
positive-time-to-live passwd 600
negative-time-to-live passwd 20
suggested-size passwd 211
check-files passwd yes
persistent passwd yes
shared passwd yes
max-db-size passwd 33554432
auto-propagate passwd yes
enable-cache group no
positive-time-to-live group 3600
negative-time-to-live group 60
suggested-size group 211
check-files group yes
persistent group yes
shared group yes
max-db-size group 33554432
auto-propagate group yes
enable-cache hosts yes
positive-time-to-live hosts 3600
negative-time-to-live hosts 20
suggested-size hosts 211
check-files hosts yes
persistent hosts yes
# Set shared to "no" to display stats in `nscd -g`
# Read more: https://stackoverflow.com/questions/40429245/nscdcentos7curl-0-dns-cache-hit-rate
shared hosts no
max-db-size hosts 33554432
enable-cache services no
positive-time-to-live services 28800
negative-time-to-live services 20
suggested-size services 211
check-files services yes
persistent services yes
shared services yes
max-db-size services 33554432
enable-cache netgroup no
positive-time-to-live netgroup 28800
negative-time-to-live netgroup 20
suggested-size netgroup 211
check-files netgroup yes
persistent netgroup yes
shared netgroup yes
max-db-size netgroup 33554432

31
docker/etc/sudoers Normal file
View file

@ -0,0 +1,31 @@
#
# This file MUST be edited with the 'visudo' command as root.
#
# Please consider adding local content in /etc/sudoers.d/ instead of
# directly modifying this file.
#
# See the man page for details on how to write a sudoers file.
#
Defaults env_reset
Defaults mail_badpass
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# Host alias specification
# User alias specification
# Cmnd alias specification
# User privilege specification
root ALL=(ALL:ALL) ALL
# Allow members of group sudo to execute any command
%sudo ALL=(ALL:ALL) ALL
# See sudoers(5) for more information on "#include" directives:
#includedir /etc/sudoers.d
# Allow `node` to control service (mainly for nscd)
node ALL=(root) NOPASSWD: /usr/sbin/nscdservice
node ALL=(root) NOPASSWD: /usr/sbin/service

View file

@ -5,15 +5,15 @@
// curl -o kuma_install.sh https://raw.githubusercontent.com/louislam/uptime-kuma/master/install.sh && sudo bash kuma_install.sh
println("=====================");
println("Uptime Kuma Installer");
println("Uptime Kuma Install Script");
println("=====================");
println("Supported OS: CentOS 7/8, Ubuntu >= 16.04 and Debian");
println("Supported OS: Ubuntu >= 16.04, Debian and CentOS/RHEL 7/8");
println("---------------------------------------");
println("This script is designed for Linux and basic usage.");
println("For advanced usage, please go to https://github.com/louislam/uptime-kuma/wiki/Installation");
println("---------------------------------------");
println("");
println("Local - Install Uptime Kuma in your current machine with git, Node.js 14 and pm2");
println("Local - Install Uptime Kuma on your current machine with git, Node.js and pm2");
println("Docker - Install Uptime Kuma Docker container");
println("");
@ -29,14 +29,10 @@ function checkNode() {
bash("nodeVersion=$(node -e 'console.log(process.versions.node.split(`.`)[0])')");
println("Node Version: " ++ nodeVersion);
if (nodeVersion < "12") {
if (nodeVersion <= "12") {
println("Error: Required Node.js 14");
call("exit", "1");
}
if (nodeVersion == "12") {
println("Warning: NodeJS " ++ nodeVersion ++ " is not tested.");
}
}
function deb() {
@ -60,8 +56,8 @@ function deb() {
bash("apt --yes install curl");
}
println("Installing Node.js 14");
bash("curl -sL https://deb.nodesource.com/setup_14.x | bash - > log.txt");
println("Installing Node.js 16");
bash("curl -sL https://deb.nodesource.com/setup_16.x | bash - > log.txt");
bash("apt --yes install nodejs");
bash("node -v");
@ -91,6 +87,10 @@ if (type == "local") {
bash("os=$(head -n1 /etc/issue | cut -f 1 -d ' ')");
if (os == "Ubuntu") {
distribution = "ubuntu";
// Get ubuntu version
bash(". /etc/lsb-release");
version = DISTRIB_RELEASE;
}
if (os == "Debian") {
distribution = "debian";
@ -101,6 +101,7 @@ if (type == "local") {
println("Your OS: " ++ os);
println("Distribution: " ++ distribution);
println("Version: " ++ version);
println("Arch: " ++ arch);
if ("$3" != "") {
@ -131,15 +132,32 @@ if (type == "local") {
checkNode();
} else {
bash("curlCheck=$(curl --version)");
if (curlCheck == "") {
println("Installing Curl");
bash("yum -y -q install curl");
bash("dnfCheck=$(dnf --version)");
// Use yum
if (dnfCheck == "") {
bash("curlCheck=$(curl --version)");
if (curlCheck == "") {
println("Installing Curl");
bash("yum -y -q install curl");
}
println("Installing Node.js 16");
bash("curl -sL https://rpm.nodesource.com/setup_16.x | bash - > log.txt");
bash("yum install -y -q nodejs");
} else {
bash("curlCheck=$(curl --version)");
if (curlCheck == "") {
println("Installing Curl");
bash("dnf -y install curl");
}
println("Installing Node.js 16");
bash("curl -sL https://rpm.nodesource.com/setup_16.x | bash - > log.txt");
bash("dnf install -y nodejs");
}
println("Installing Node.js 14");
bash("curl -sL https://rpm.nodesource.com/setup_14.x | bash - > log.txt");
bash("yum install -y -q nodejs");
bash("node -v");
bash("nodeCheckAgain=$(node -v)");
@ -193,6 +211,14 @@ if (type == "local") {
bash("pm2 startup");
}
// Check again
bash("check=$(pm2 --version)");
if (check == "") {
println("Error: pm2 is not found!");
bash("exit 1");
}
bash("mkdir -p $installPath");
bash("cd $installPath");
bash("git clone https://github.com/louislam/uptime-kuma.git .");

9
extra/test-docker.js Normal file
View file

@ -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);
}
});

View file

@ -3,15 +3,15 @@
# The command is working on Windows PowerShell and Docker for Windows only.
# curl -o kuma_install.sh https://raw.githubusercontent.com/louislam/uptime-kuma/master/install.sh && sudo bash kuma_install.sh
"echo" "-e" "====================="
"echo" "-e" "Uptime Kuma Installer"
"echo" "-e" "Uptime Kuma Install Script"
"echo" "-e" "====================="
"echo" "-e" "Supported OS: CentOS 7/8, Ubuntu >= 16.04 and Debian"
"echo" "-e" "Supported OS: Ubuntu >= 16.04, Debian and CentOS/RHEL 7/8"
"echo" "-e" "---------------------------------------"
"echo" "-e" "This script is designed for Linux and basic usage."
"echo" "-e" "For advanced usage, please go to https://github.com/louislam/uptime-kuma/wiki/Installation"
"echo" "-e" "---------------------------------------"
"echo" "-e" ""
"echo" "-e" "Local - Install Uptime Kuma in your current machine with git, Node.js 14 and pm2"
"echo" "-e" "Local - Install Uptime Kuma on your current machine with git, Node.js and pm2"
"echo" "-e" "Docker - Install Uptime Kuma Docker container"
"echo" "-e" ""
if [ "$1" != "" ]; then
@ -25,12 +25,9 @@ function checkNode {
nodeVersion=$(node -e 'console.log(process.versions.node.split(`.`)[0])')
"echo" "-e" "Node Version: ""$nodeVersion"
_0="12"
if [ $(($nodeVersion < $_0)) == 1 ]; then
if [ $(($nodeVersion <= $_0)) == 1 ]; then
"echo" "-e" "Error: Required Node.js 14"
"exit" "1"
fi
if [ "$nodeVersion" == "12" ]; then
"echo" "-e" "Warning: NodeJS ""$nodeVersion"" is not tested."
fi
}
function deb {
@ -50,8 +47,8 @@ fi
"echo" "-e" "Installing Curl"
apt --yes install curl
fi
"echo" "-e" "Installing Node.js 14"
curl -sL https://deb.nodesource.com/setup_14.x | bash - > log.txt
"echo" "-e" "Installing Node.js 16"
curl -sL https://deb.nodesource.com/setup_16.x | bash - > log.txt
apt --yes install nodejs
node -v
nodeCheckAgain=$(node -v)
@ -76,6 +73,9 @@ if [ "$type" == "local" ]; then
os=$(head -n1 /etc/issue | cut -f 1 -d ' ')
if [ "$os" == "Ubuntu" ]; then
distribution="ubuntu"
# Get ubuntu version
. /etc/lsb-release
version="$DISTRIB_RELEASE"
fi
if [ "$os" == "Debian" ]; then
distribution="debian"
@ -85,6 +85,7 @@ fi
arch=$(uname -i)
"echo" "-e" "Your OS: ""$os"
"echo" "-e" "Distribution: ""$distribution"
"echo" "-e" "Version: ""$version"
"echo" "-e" "Arch: ""$arch"
if [ "$3" != "" ]; then
port="$3"
@ -108,14 +109,27 @@ fi
if [ "$nodeCheck" != "" ]; then
"checkNode"
else
curlCheck=$(curl --version)
if [ "$curlCheck" == "" ]; then
"echo" "-e" "Installing Curl"
yum -y -q install curl
dnfCheck=$(dnf --version)
# Use yum
if [ "$dnfCheck" == "" ]; then
curlCheck=$(curl --version)
if [ "$curlCheck" == "" ]; then
"echo" "-e" "Installing Curl"
yum -y -q install curl
fi
"echo" "-e" "Installing Node.js 14"
curl -sL https://rpm.nodesource.com/setup_14.x | bash - > log.txt
yum install -y -q nodejs
"echo" "-e" "Installing Node.js 16"
curl -sL https://rpm.nodesource.com/setup_16.x | bash - > log.txt
yum install -y -q nodejs
else
curlCheck=$(curl --version)
if [ "$curlCheck" == "" ]; then
"echo" "-e" "Installing Curl"
dnf -y install curl
fi
"echo" "-e" "Installing Node.js 16"
curl -sL https://rpm.nodesource.com/setup_16.x | bash - > log.txt
dnf install -y nodejs
fi
node -v
nodeCheckAgain=$(node -v)
if [ "$nodeCheckAgain" == "" ]; then
@ -161,6 +175,12 @@ fi
"echo" "-e" "Installing PM2"
npm install pm2 -g && pm2 install pm2-logrotate
pm2 startup
fi
# Check again
check=$(pm2 --version)
if [ "$check" == "" ]; then
"echo" "-e" "Error: pm2 is not found!"
exit 1
fi
mkdir -p $installPath
cd $installPath

View file

@ -1,13 +1,13 @@
{
"name": "uptime-kuma",
"version": "1.22.0",
"version": "1.22.1",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/louislam/uptime-kuma.git"
},
"engines": {
"node": "14.* || 16.* || 18.*"
"node": "14 || 16 || 18 || >= 20.4.0"
},
"scripts": {
"install-legacy": "npm install",
@ -19,6 +19,7 @@
"lint": "npm run lint:js && npm run lint:style",
"dev": "concurrently -k -r \"wait-on tcp:3000 && npm run start-server-dev \" \"npm run start-frontend-dev\"",
"start-frontend-dev": "cross-env NODE_ENV=development vite --host --config ./config/vite.config.js",
"start-frontend-devcontainer": "cross-env NODE_ENV=development DEVCONTAINER=1 vite --host --config ./config/vite.config.js",
"start": "npm run start-server",
"start-server": "node server/server.js",
"start-server-dev": "cross-env NODE_ENV=development node server/server.js",
@ -34,24 +35,28 @@
"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-slim": "node ./extra/env2arg.js docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:2-slim -t louislam/uptime-kuma:$VERSION-slim --target release --build-arg BASE_IMAGE=louislam/uptime-kuma:base2-slim . --push",
"build-docker-full": "node ./extra/env2arg.js docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:2 -t louislam/uptime-kuma:$VERSION --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:nightly2 --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:nightly2 --target nightly . --push",
"build-docker-nightly-local": "npm run build && docker build -f docker/dockerfile -t louislam/uptime-kuma:nightly2 --target nightly .",
"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",
"setup": "git checkout 1.22.0 && 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",
"mark-as-nightly": "node extra/mark-as-nightly.js",
"reset-password": "node extra/reset-password.js",
"remove-2fa": "node extra/remove-2fa.js",
"compile-install-script": "@powershell -NoProfile -ExecutionPolicy Unrestricted -Command ./extra/compile-install-script.ps1",
"test-install-script-rockylinux": "npm run compile-install-script && docker build --progress plain -f test/test_install_script/rockylinux.dockerfile .",
"test-install-script-centos7": "npm run compile-install-script && docker build --progress plain -f test/test_install_script/centos7.dockerfile .",
"test-install-script-debian": "npm run compile-install-script && docker build --progress plain -f test/test_install_script/debian.dockerfile .",
"test-install-script-debian-buster": "npm run compile-install-script && docker build --progress plain -f test/test_install_script/debian-buster.dockerfile .",
"test-install-script-ubuntu": "npm run compile-install-script && docker build --progress plain -f test/test_install_script/ubuntu.dockerfile .",
"test-install-script-ubuntu1804": "npm run compile-install-script && docker build --progress plain -f test/test_install_script/ubuntu1804.dockerfile .",
"test-install-script-ubuntu1604": "npm run compile-install-script && docker build --progress plain -f test/test_install_script/ubuntu1604.dockerfile .",
"simple-dns-server": "node extra/simple-dns-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",
"release-final": "node extra/update-version.js && npm run build-docker && node ./extra/press-any-key.js && npm run upload-artifacts && node ./extra/update-wiki-version.js",
"release-beta": "node extra/beta/update-version.js && npm run build && node ./extra/env2arg.js docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:$VERSION -t louislam/uptime-kuma:beta . --target release --push && node ./extra/press-any-key.js && npm run upload-artifacts",
"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/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",
"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",
@ -67,7 +72,7 @@
},
"dependencies": {
"@grpc/grpc-js": "~1.7.3",
"@louislam/ping": "~0.4.4-mod.0",
"@louislam/ping": "~0.4.4-mod.1",
"@louislam/sqlite3": "15.1.6",
"args-parser": "~1.3.0",
"axios": "~0.27.0",
@ -95,10 +100,13 @@
"https-proxy-agent": "~5.0.1",
"iconv-lite": "~0.6.3",
"jsesc": "~3.0.2",
"jsonata": "^2.0.3",
"jsonwebtoken": "~9.0.0",
"jwt-decode": "~3.1.2",
"kafkajs": "^2.2.4",
"knex": "^2.4.2",
"limiter": "~2.1.0",
"liquidjs": "^10.7.0",
"mongodb": "~4.14.0",
"mqtt": "~4.3.7",
"mssql": "~8.1.4",
@ -114,10 +122,11 @@
"playwright-core": "~1.35.1",
"prom-client": "~13.2.0",
"prometheus-api-metrics": "~3.2.1",
"protobufjs": "~7.1.1",
"protobufjs": "~7.2.4",
"qs": "~6.10.4",
"redbean-node": "~0.3.0",
"redis": "~4.5.1",
"semver": "~7.5.4",
"socket.io": "~4.6.1",
"socket.io-client": "~4.6.1",
"socks-proxy-agent": "6.1.1",
@ -127,7 +136,7 @@
},
"devDependencies": {
"@actions/github": "~5.0.1",
"@babel/eslint-parser": "~7.17.0",
"@babel/eslint-parser": "^7.22.7",
"@babel/preset-env": "^7.15.8",
"@fortawesome/fontawesome-svg-core": "~1.2.36",
"@fortawesome/free-regular-svg-icons": "~5.15.4",
@ -135,9 +144,9 @@
"@fortawesome/vue-fontawesome": "~3.0.0-5",
"@popperjs/core": "~2.10.2",
"@types/bootstrap": "~5.1.9",
"@vitejs/plugin-legacy": "~2.1.0",
"@vitejs/plugin-vue": "~3.1.0",
"@vue/compiler-sfc": "~3.2.36",
"@vitejs/plugin-legacy": "~4.1.0",
"@vitejs/plugin-vue": "~4.2.3",
"@vue/compiler-sfc": "~3.3.4",
"@vuepic/vue-datepicker": "~3.4.8",
"aedes": "^0.46.3",
"babel-plugin-rewire": "~1.2.0",
@ -148,16 +157,16 @@
"core-js": "~3.26.1",
"cronstrue": "~2.24.0",
"cross-env": "~7.0.3",
"cypress": "^10.1.0",
"cypress": "^12.17.0",
"delay": "^5.0.0",
"dns2": "~2.0.1",
"dompurify": "~2.4.3",
"eslint": "~8.14.0",
"eslint-plugin-vue": "~8.7.1",
"favico.js": "~0.3.10",
"jest": "~27.2.5",
"jest": "~29.6.1",
"marked": "~4.2.5",
"node-ssh": "~13.0.1",
"node-ssh": "~13.1.0",
"postcss-html": "~1.5.0",
"postcss-rtlcss": "~3.7.2",
"postcss-scss": "~4.0.4",
@ -165,15 +174,16 @@
"qrcode": "~1.5.0",
"rollup-plugin-visualizer": "^5.6.0",
"sass": "~1.42.1",
"stylelint": "~15.9.0",
"stylelint": "^15.10.1",
"stylelint-config-standard": "~25.0.0",
"terser": "~5.15.0",
"timezones-list": "~3.0.1",
"typescript": "~4.4.4",
"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",
"vue": "~3.2.47",
"vue": "~3.3.4",
"vue-chartjs": "~5.2.0",
"vue-confirm-dialog": "~1.0.2",
"vue-contenteditable": "~3.0.4",

View file

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

View file

@ -141,12 +141,21 @@ async function sendAPIKeyList(socket) {
/**
* Emits the version information to the client.
* @param {Socket} socket Socket.io socket instance
* @param {boolean} hideVersion
* @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", {
version: checkVersion.version,
latestVersion: checkVersion.latestVersion,
version,
latestVersion,
primaryBaseURL: await setting("primaryBaseURL"),
serverTimezone: await server.getTimezone(),
serverTimezoneOffset: server.getTimezoneOffset(),

View file

@ -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 badgeConstants = {

View file

@ -3,7 +3,6 @@ const { R } = require("redbean-node");
const { setSetting, setting } = require("./util-server");
const { log, sleep } = require("../src/util");
const knex = require("knex");
const { PluginsManager } = require("./plugins-manager");
const path = require("path");
const { EmbeddedMariaDB } = require("./embedded-mariadb");
const mysql = require("mysql2/promise");
@ -77,6 +76,9 @@ class Database {
"patch-monitor-tls.sql": true,
"patch-maintenance-cron.sql": true,
"patch-add-parent-monitor.sql": true, // The last file so far converted to a knex migration file
"patch-add-invert-keyword.sql": true,
"patch-added-json-query.sql": true,
"patch-added-kafka-producer.sql": true,
};
/**
@ -99,12 +101,6 @@ class Database {
// Data Directory (must be end with "/")
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.sqlitePath = Database.dataDir + "kuma.db";
if (! fs.existsSync(Database.dataDir)) {
fs.mkdirSync(Database.dataDir, { recursive: true });

View file

@ -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,
};

View file

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

View file

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

View file

@ -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,
};

View file

@ -6,7 +6,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger, MAX_INTERVA
SQL_DATETIME_FORMAT
} = require("../../src/util");
const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery,
redisPingAsync, mongodbPing,
redisPingAsync, mongodbPing, kafkaProducerAsync
} = require("../util-server");
const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model");
@ -20,6 +20,7 @@ const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent");
const { DockerHost } = require("../docker");
const { UptimeCacheList } = require("../uptime-cache-list");
const Gamedig = require("gamedig");
const jsonata = require("jsonata");
const jwt = require("jsonwebtoken");
const Database = require("../database");
@ -98,6 +99,7 @@ class Monitor extends BeanModel {
retryInterval: this.retryInterval,
resendInterval: this.resendInterval,
keyword: this.keyword,
invertKeyword: this.isInvertKeyword(),
expiryNotification: this.isEnabledExpiryNotification(),
ignoreTls: this.getIgnoreTls(),
upsideDown: this.isUpsideDown(),
@ -126,6 +128,13 @@ class Monitor extends BeanModel {
radiusCallingStationId: this.radiusCallingStationId,
game: this.game,
httpBodyEncoding: this.httpBodyEncoding,
jsonPath: this.jsonPath,
expectedValue: this.expectedValue,
kafkaProducerTopic: this.kafkaProducerTopic,
kafkaProducerBrokers: JSON.parse(this.kafkaProducerBrokers),
kafkaProducerSsl: this.kafkaProducerSsl === "1" && true || false,
kafkaProducerAllowAutoTopicCreation: this.kafkaProducerAllowAutoTopicCreation === "1" && true || false,
kafkaProducerMessage: this.kafkaProducerMessage,
screenshot,
};
@ -150,6 +159,7 @@ class Monitor extends BeanModel {
tlsCa: this.tlsCa,
tlsCert: this.tlsCert,
tlsKey: this.tlsKey,
kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions),
};
}
@ -164,7 +174,7 @@ class Monitor extends BeanModel {
async isActive() {
const parentActive = await Monitor.isParentActive(this.id);
return this.active && parentActive;
return (this.active === 1) && parentActive;
}
/**
@ -208,6 +218,14 @@ class Monitor extends BeanModel {
return Boolean(this.upsideDown);
}
/**
* Parse to boolean
* @returns {boolean}
*/
isInvertKeyword() {
return Boolean(this.invertKeyword);
}
/**
* Parse to boolean
* @returns {boolean}
@ -312,7 +330,7 @@ class Monitor extends BeanModel {
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"
let startTime = dayjs().valueOf();
@ -440,7 +458,7 @@ class Monitor extends BeanModel {
if (this.type === "http") {
bean.status = UP;
} else {
} else if (this.type === "keyword") {
let data = res.data;
@ -449,17 +467,37 @@ class Monitor extends BeanModel {
data = JSON.stringify(data);
}
if (data.includes(this.keyword)) {
bean.msg += ", keyword is found";
let keywordFound = data.includes(this.keyword);
if (keywordFound === !this.isInvertKeyword()) {
bean.msg += ", keyword " + (keywordFound ? "is" : "not") + " found";
bean.status = UP;
} else {
data = data.replace(/<[^>]*>?|[\n\r]|\s+/gm, " ").trim();
if (data.length > 50) {
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") {
@ -534,7 +572,7 @@ class Monitor extends BeanModel {
// No need to insert successful heartbeat for push type, so end here
retries = 0;
log.debug("monitor", `[${this.name}] timeout = ${timeout}`);
this.heartbeatInterval = setTimeout(beat, timeout);
this.heartbeatInterval = setTimeout(safeBeat, timeout);
return;
}
} else {
@ -627,9 +665,15 @@ class Monitor extends BeanModel {
log.debug("monitor", `[${this.name}] Axios Request`);
let res = await axios.request(options);
if (res.data.State.Running) {
bean.status = UP;
bean.msg = res.data.State.Status;
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.msg = res.data.State.Health ? res.data.State.Health.Status : res.data.State.Status;
}
} else {
throw Error("Container State is " + res.data.State.Status);
}
@ -658,7 +702,6 @@ class Monitor extends BeanModel {
grpcEnableTls: this.grpcEnableTls,
grpcMethod: this.grpcMethod,
grpcBody: this.grpcBody,
keyword: this.keyword
};
const response = await grpcQuery(options);
bean.ping = dayjs().valueOf() - startTime;
@ -671,13 +714,14 @@ class Monitor extends BeanModel {
bean.status = DOWN;
bean.msg = `Error in send gRPC ${response.code} ${response.errorMessage}`;
} else {
if (response.data.toString().includes(this.keyword)) {
let keywordFound = response.data.toString().includes(this.keyword);
if (keywordFound === !this.isInvertKeyword()) {
bean.status = UP;
bean.msg = `${responseData}, keyword [${this.keyword}] is found`;
bean.msg = `${responseData}, keyword [${this.keyword}] ${keywordFound ? "is" : "not"} found`;
} 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.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") {
@ -724,7 +768,8 @@ class Monitor extends BeanModel {
this.radiusCalledStationId,
this.radiusCallingStationId,
this.radiusSecret,
port
port,
this.interval * 1000 * 0.8,
);
if (resp.code) {
bean.msg = resp.code;
@ -754,6 +799,24 @@ class Monitor extends BeanModel {
bean.ping = dayjs().valueOf() - startTime;
}
} else if (this.type === "kafka-producer") {
let startTime = dayjs().valueOf();
bean.msg = await kafkaProducerAsync(
JSON.parse(this.kafkaProducerBrokers),
this.kafkaProducerTopic,
this.kafkaProducerMessage,
{
allowAutoTopicCreation: this.kafkaProducerAllowAutoTopicCreation,
ssl: this.kafkaProducerSsl,
clientId: `Uptime-Kuma/${version}`,
interval: this.interval,
},
JSON.parse(this.kafkaProducerSaslOptions),
);
bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
} else {
throw new Error("Unknown Monitor Type");
}

View file

@ -1,5 +1,5 @@
const { MonitorType } = require("./monitor-type");
const { chromium, Browser } = require("playwright-core");
const { chromium } = require("playwright-core");
const { UP, log } = require("../../src/util");
const { Settings } = require("../settings");
const commandExistsSync = require("command-exists").sync;
@ -7,13 +7,60 @@ const childProcess = require("child_process");
const path = require("path");
const Database = require("../database");
const jwt = require("jsonwebtoken");
const config = require("../config");
/**
*
* @type {Browser}
*/
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");
@ -31,6 +78,7 @@ async function getBrowser() {
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) {
@ -60,30 +108,30 @@ async function prepareChromeExecutable(executablePath) {
});
}
} else if (process.platform === "win32") {
executablePath = findChrome([
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"D:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"D:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"E:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"E:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
]);
} else if (process.platform === "linux") {
executablePath = findChrome([
"chromium-browser",
"chromium",
"google-chrome",
]);
} 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.");
}
// TODO: Mac??
}
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;
}
}

View file

@ -0,0 +1,95 @@
const { MonitorType } = require("./monitor-type");
const { UP, log } = require("../../src/util");
const exec = require("child_process").exec;
/**
* A TailscalePing class extends the MonitorType.
* It runs Tailscale ping to monitor the status of a specific node.
*/
class TailscalePing extends MonitorType {
name = "tailscale-ping";
/**
* Checks the ping status of the URL associated with the monitor.
* It then parses the Tailscale ping command output to update the heatrbeat.
*
* @param {Object} monitor - The monitor object associated with the check.
* @param {Object} heartbeat - The heartbeat object to update.
* @throws Will throw an error if checking Tailscale ping encounters any error
*/
async check(monitor, heartbeat) {
try {
let tailscaleOutput = await this.runTailscalePing(monitor.hostname, monitor.interval);
this.parseTailscaleOutput(tailscaleOutput, heartbeat);
} catch (err) {
log.debug("Tailscale", err);
// trigger log function somewhere to display a notification or alert to the user (but how?)
throw new Error(`Error checking Tailscale ping: ${err}`);
}
}
/**
* Runs the Tailscale ping command to the given URL.
*
* @param {string} hostname - The hostname to ping.
* @returns {Promise<string>} - A Promise that resolves to the output of the Tailscale ping command
* @throws Will throw an error if the command execution encounters any error.
*/
async runTailscalePing(hostname, interval) {
let cmd = `tailscale ping ${hostname}`;
log.debug("Tailscale", cmd);
return new Promise((resolve, reject) => {
let timeout = interval * 1000 * 0.8;
exec(cmd, { timeout: timeout }, (error, stdout, stderr) => {
// we may need to handle more cases if tailscale reports an error that isn't necessarily an error (such as not-logged in or DERP health-related issues)
if (error) {
reject(`Execution error: ${error.message}`);
return;
}
if (stderr) {
reject(`Error in output: ${stderr}`);
return;
}
resolve(stdout);
});
});
}
/**
* Parses the output of the Tailscale ping command to update the heartbeat.
*
* @param {string} tailscaleOutput - The output of the Tailscale ping command.
* @param {Object} heartbeat - The heartbeat object to update.
* @throws Will throw an eror if the output contains any unexpected string.
*/
parseTailscaleOutput(tailscaleOutput, heartbeat) {
let lines = tailscaleOutput.split("\n");
for (let line of lines) {
if (line.includes("pong from")) {
heartbeat.status = UP;
let time = line.split(" in ")[1].split(" ")[0];
heartbeat.ping = parseInt(time);
heartbeat.msg = line;
break;
} else if (line.includes("timed out")) {
throw new Error(`Ping timed out: "${line}"`);
// Immediately throws upon "timed out" message, the server is expected to re-call the check function
} else if (line.includes("no matching peer")) {
throw new Error(`Nonexistant or inaccessible due to ACLs: "${line}"`);
} else if (line.includes("is local Tailscale IP")) {
throw new Error(`Tailscale only works if used on other machines: "${line}"`);
} else if (line !== "") {
throw new Error(`Unexpected output: "${line}"`);
}
}
}
}
module.exports = {
TailscalePing,
};

View file

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

View file

@ -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;

View file

@ -13,7 +13,7 @@ class SMTP extends NotificationProvider {
port: notification.smtpPort,
secure: notification.smtpSecure,
tls: {
rejectUnauthorized: notification.smtpIgnoreTLSError || false,
rejectUnauthorized: !notification.smtpIgnoreTLSError || false,
}
};
@ -67,7 +67,7 @@ class SMTP extends NotificationProvider {
if (monitorJSON !== null) {
monitorName = monitorJSON["name"];
if (monitorJSON["type"] === "http" || monitorJSON["type"] === "keyword") {
if (monitorJSON["type"] === "http" || monitorJSON["type"] === "keyword" || monitorJSON["type"] === "json-query") {
monitorHostnameOrURL = monitorJSON["url"];
} else {
monitorHostnameOrURL = monitorJSON["hostname"];

View file

@ -10,6 +10,7 @@ class Twilio extends NotificationProvider {
let okMsg = "Sent Successfully.";
let accountSID = notification.twilioAccountSID;
let apiKey = notification.twilioApiKey ? notification.twilioApiKey : accountSID;
let authToken = notification.twilioAuthToken;
try {
@ -17,7 +18,7 @@ class Twilio extends NotificationProvider {
let config = {
headers: {
"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"),
}
};

View file

@ -1,6 +1,7 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
const FormData = require("form-data");
const { Liquid } = require("liquidjs");
class Webhook extends NotificationProvider {
@ -15,17 +16,27 @@ class Webhook extends NotificationProvider {
monitor: monitorJSON,
msg,
};
let finalData;
let config = {
headers: {}
};
if (notification.webhookContentType === "form-data") {
finalData = new FormData();
finalData.append("data", JSON.stringify(data));
config.headers = finalData.getHeaders();
} else {
finalData = data;
const formData = new FormData();
formData.append("data", JSON.stringify(data));
config.headers = formData.getHeaders();
data = formData;
} 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) {
@ -39,7 +50,7 @@ class Webhook extends NotificationProvider {
}
}
await axios.post(notification.webhookURL, finalData, config);
await axios.post(notification.webhookURL, data, config);
return okMsg;
} catch (error) {

View file

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

View file

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

View file

@ -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
};

View file

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

View file

@ -5,6 +5,8 @@ const StatusPage = require("../model/status_page");
const { allowDevAllOrigin, sendHttpError } = require("../util-server");
const { R } = require("redbean-node");
const Monitor = require("../model/monitor");
const { badgeConstants } = require("../config");
const { makeBadge } = require("badge-maker");
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;

View file

@ -15,18 +15,25 @@ dayjs.extend(require("dayjs/plugin/customParseFormat"));
require("dotenv").config();
// Check Node.js Version
const nodeVersion = parseInt(process.versions.node.split(".")[0]);
const requiredVersion = 14;
const nodeVersion = process.versions.node;
// Get the required Node.js version from package.json
const requiredNodeVersions = require("../package.json").engines.node;
const bannedNodeVersions = " < 14 || 20.0.* || 20.1.* || 20.2.* || 20.3.* ";
console.log(`Your Node.js version: ${nodeVersion}`);
// See more: https://github.com/louislam/uptime-kuma/issues/3138
if (nodeVersion >= 20) {
console.warn("\x1b[31m%s\x1b[0m", "Warning: Uptime Kuma is currently not stable on Node.js >= 20, please use Node.js 18.");
const semver = require("semver");
const requiredNodeVersionsComma = requiredNodeVersions.split("||").map((version) => version.trim()).join(", ");
// Exit Uptime Kuma immediately if the Node.js version is banned
if (semver.satisfies(nodeVersion, bannedNodeVersions)) {
console.error("\x1b[31m%s\x1b[0m", `Error: Your Node.js version: ${nodeVersion} is not supported, please upgrade your Node.js to ${requiredNodeVersionsComma}.`);
process.exit(-1);
}
if (nodeVersion < requiredVersion) {
console.error(`Error: Your Node.js version is not supported, please upgrade to Node.js >= ${requiredVersion}.`);
process.exit(-1);
// Warning if the Node.js version is not in the support list, but it maybe still works
if (!semver.satisfies(nodeVersion, requiredNodeVersions)) {
console.warn("\x1b[31m%s\x1b[0m", `Warning: Your Node.js version: ${nodeVersion} is not officially supported, please upgrade your Node.js to ${requiredNodeVersionsComma}.`);
}
const args = require("args-parser")(process.argv);
@ -42,6 +49,7 @@ if (! process.env.NODE_ENV) {
}
log.info("server", "Node Env: " + process.env.NODE_ENV);
log.info("server", "Inside Container: " + process.env.UPTIME_KUMA_IS_CONTAINER === "1");
log.info("server", "Importing Node libraries");
const fs = require("fs");
@ -149,7 +157,6 @@ const { apiKeySocketHandler } = require("./socket-handlers/api-key-socket-handle
const { generalSocketHandler } = require("./socket-handlers/general-socket-handler");
const { Settings } = require("./settings");
const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
const { pluginsHandler } = require("./socket-handlers/plugins-handler");
const apicache = require("./modules/apicache");
const { resetChrome } = require("./monitor-types/real-browser-monitor-type");
const { EmbeddedMariaDB } = require("./embedded-mariadb");
@ -193,7 +200,6 @@ let needSetup = false;
// Database should be ready now
await server.initAfterDatabaseReady();
server.loadPlugins();
server.entryPage = await Settings.get("entryPage");
await StatusPage.loadDomainMappingList();
@ -239,6 +245,7 @@ let needSetup = false;
});
if (isDev) {
app.use(express.urlencoded({ extended: true }));
app.post("/test-webhook", async (request, response) => {
log.debug("test", request.headers);
log.debug("test", request.body);
@ -293,7 +300,7 @@ let needSetup = false;
log.info("server", "Adding socket handler");
io.on("connection", async (socket) => {
sendInfo(socket);
sendInfo(socket, true);
if (needSetup) {
log.info("server", "Redirect to setup page");
@ -666,6 +673,9 @@ let needSetup = false;
monitor.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
delete monitor.accepted_statuscodes;
monitor.kafkaProducerBrokers = JSON.stringify(monitor.kafkaProducerBrokers);
monitor.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions);
bean.import(monitor);
bean.user_id = socket.userID;
@ -748,6 +758,7 @@ let needSetup = false;
}
bean.keyword = monitor.keyword;
bean.invertKeyword = monitor.invertKeyword;
bean.ignoreTls = monitor.ignoreTls;
bean.expiryNotification = monitor.expiryNotification;
bean.upsideDown = monitor.upsideDown;
@ -782,6 +793,13 @@ let needSetup = false;
bean.radiusCallingStationId = monitor.radiusCallingStationId;
bean.radiusSecret = monitor.radiusSecret;
bean.httpBodyEncoding = monitor.httpBodyEncoding;
bean.expectedValue = monitor.expectedValue;
bean.jsonPath = monitor.jsonPath;
bean.kafkaProducerTopic = monitor.kafkaProducerTopic;
bean.kafkaProducerBrokers = JSON.stringify(monitor.kafkaProducerBrokers);
bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation;
bean.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions);
bean.kafkaProducerMessage = monitor.kafkaProducerMessage;
bean.validate();
@ -1415,6 +1433,7 @@ let needSetup = false;
maxretries: monitorListData[i].maxretries,
port: monitorListData[i].port,
keyword: monitorListData[i].keyword,
invertKeyword: monitorListData[i].invertKeyword,
ignoreTls: monitorListData[i].ignoreTls,
upsideDown: monitorListData[i].upsideDown,
maxredirects: monitorListData[i].maxredirects,
@ -1583,7 +1602,6 @@ let needSetup = false;
maintenanceSocketHandler(socket);
apiKeySocketHandler(socket);
generalSocketHandler(socket, server);
pluginsHandler(socket, server);
log.debug("server", "added all socket handlers");
@ -1609,6 +1627,8 @@ let needSetup = false;
await shutdownFunction();
});
server.start();
server.httpServer.listen(port, hostname, () => {
if (hostname) {
log.info("server", `Listening on ${hostname}:${port}`);
@ -1686,6 +1706,7 @@ async function afterLogin(socket, user) {
socket.join(user.id);
let monitorList = await server.sendMonitorList(socket);
sendInfo(socket);
server.sendMaintenanceList(socket);
sendNotificationList(socket);
sendProxyList(socket);

View file

@ -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,
});
}
});
};

View file

@ -10,8 +10,8 @@ const util = require("util");
const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
const { Settings } = require("./settings");
const dayjs = require("dayjs");
const { PluginsManager } = require("./plugins-manager");
// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`
const childProcess = require("child_process");
// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead.
/**
* `module.exports` (alias: `server`) should be inside this class, in order to avoid circular dependency issue.
@ -47,12 +47,6 @@ class UptimeKumaServer {
*/
indexHTML = "";
/**
* Plugins Manager
* @type {PluginsManager}
*/
pluginsManager = null;
/**
*
* @type {{}}
@ -106,6 +100,7 @@ class UptimeKumaServer {
// Set Monitor Types
UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType();
UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing();
this.io = new Server(this.httpServer);
}
@ -256,9 +251,9 @@ class UptimeKumaServer {
return (typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : null)
|| socket.client.conn.request.headers["x-real-ip"]
|| clientIP.replace(/^.*:/, "");
|| clientIP.replace(/^::ffff:/, "");
} else {
return clientIP.replace(/^.*:/, "");
return clientIP.replace(/^::ffff:/, "");
}
}
@ -269,13 +264,43 @@ class UptimeKumaServer {
* @returns {Promise<string>}
*/
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");
if (timezone) {
return timezone;
} else if (process.env.TZ) {
return process.env.TZ;
} else {
return dayjs.tz.guess();
// From Settings
try {
log.debug("timezone", "Using timezone from settings: " + timezone);
if (timezone) {
this.checkTimezone(timezone);
return timezone;
}
} 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 {
return "UTC";
}
} catch (e) {
// Guess failed, fall back to UTC
log.debug("timezone", "Guessed an invalid timezone. Use UTC as fallback");
return "UTC";
}
}
@ -287,66 +312,79 @@ class UptimeKumaServer {
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
* @param {string} timezone
*/
async setTimezone(timezone) {
this.checkTimezone(timezone);
await Settings.set("serverTimezone", timezone, "general");
process.env.TZ = timezone;
dayjs.tz.setDefault(timezone);
}
/** Stop the server */
/**
* TODO: Listen logic should be moved to here
* @returns {Promise<void>}
*/
async start() {
this.startServices();
}
/**
* Stop the server
* @returns {Promise<void>}
*/
async stop() {
}
loadPlugins() {
this.pluginsManager = new PluginsManager(this);
this.stopServices();
}
/**
*
* @returns {PluginsManager}
* Start all system services (e.g. nscd)
* For now, only used in Docker
*/
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");
startServices() {
if (process.env.UPTIME_KUMA_IS_CONTAINER) {
try {
log.info("services", "Starting nscd");
childProcess.execSync("sudo service nscd start", { stdio: "pipe" });
} catch (e) {
log.info("services", "Failed to start nscd");
}
UptimeKumaServer.monitorTypeList[monitorType.name] = monitorType;
} else {
log.error("", "Invalid Monitor Type: " + monitorType.name);
}
}
/**
*
* @param {MonitorType} monitorType
* Stop all system services
*/
removeMonitorType(monitorType) {
if (UptimeKumaServer.monitorTypeList[monitorType.name] === monitorType) {
delete UptimeKumaServer.monitorTypeList[monitorType.name];
} else {
log.error("", "Remove MonitorType failed: " + monitorType.name);
stopServices() {
if (process.env.UPTIME_KUMA_IS_CONTAINER) {
try {
log.info("services", "Stopping nscd");
childProcess.execSync("sudo service nscd stop");
} catch (e) {
log.info("services", "Failed to stop nscd");
}
}
}
}
module.exports = {
UptimeKumaServer
};
// Must be at the end
const { MonitorType } = require("./monitor-types/monitor-type");
// Must be at the end to avoid circular dependencies
const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type");
const { TailscalePing } = require("./monitor-types/tailscale-ping");

View file

@ -31,8 +31,11 @@ const readline = require("readline");
const rl = readline.createInterface({ input: process.stdin,
output: process.stdout });
const isWindows = process.platform === /^win/.test(process.platform);
// SASLOptions used in JSDoc
// eslint-disable-next-line no-unused-vars
const { Kafka, SASLOptions } = require("kafkajs");
const isWindows = process.platform === /^win/.test(process.platform);
/**
* Init or reset JWT secret
* @returns {Promise<Bean>}
@ -199,6 +202,94 @@ exports.mqttAsync = function (hostname, topic, okMessage, options = {}) {
});
};
/**
* Monitor Kafka using Producer
* @param {string} topic Topic name to produce into
* @param {string} message Message to produce
* @param {Object} [options={interval = 20, allowAutoTopicCreation = false, ssl = false, clientId = "Uptime-Kuma"}]
* Kafka client options. Contains ssl, clientId, allowAutoTopicCreation and
* interval (interval defaults to 20, allowAutoTopicCreation defaults to false, clientId defaults to "Uptime-Kuma"
* and ssl defaults to false)
* @param {string[]} brokers List of kafka brokers to connect, host and port joined by ':'
* @param {SASLOptions} [saslOptions={}] Options for kafka client Authentication (SASL) (defaults to
* {})
* @returns {Promise<string>}
*/
exports.kafkaProducerAsync = function (brokers, topic, message, options = {}, saslOptions = {}) {
return new Promise((resolve, reject) => {
const { interval = 20, allowAutoTopicCreation = false, ssl = false, clientId = "Uptime-Kuma" } = options;
let connectedToKafka = false;
const timeoutID = setTimeout(() => {
log.debug("kafkaProducer", "KafkaProducer timeout triggered");
connectedToKafka = true;
reject(new Error("Timeout"));
}, interval * 1000 * 0.8);
if (saslOptions.mechanism === "None") {
saslOptions = undefined;
}
let client = new Kafka({
brokers: brokers,
clientId: clientId,
sasl: saslOptions,
retry: {
retries: 0,
},
ssl: ssl,
});
let producer = client.producer({
allowAutoTopicCreation: allowAutoTopicCreation,
retry: {
retries: 0,
}
});
producer.connect().then(
() => {
try {
producer.send({
topic: topic,
messages: [{
value: message,
}],
});
connectedToKafka = true;
clearTimeout(timeoutID);
resolve("Message sent successfully");
} catch (e) {
connectedToKafka = true;
producer.disconnect();
clearTimeout(timeoutID);
reject(new Error("Error sending message: " + e.message));
}
}
).catch(
(e) => {
connectedToKafka = true;
producer.disconnect();
clearTimeout(timeoutID);
reject(new Error("Error in producer connection: " + e.message));
}
);
producer.on("producer.network.request_timeout", (_) => {
clearTimeout(timeoutID);
reject(new Error("producer.network.request_timeout"));
});
producer.on("producer.disconnect", (_) => {
if (!connectedToKafka) {
clearTimeout(timeoutID);
reject(new Error("producer.disconnect"));
}
});
});
};
/**
* Use NTLM Auth for a http request.
* @param {Object} options The http request options
@ -381,6 +472,7 @@ exports.mongodbPing = async function (connectionString) {
* @param {string} callingStationId ID of calling station
* @param {string} secret Secret to use
* @param {number} [port=1812] Port to contact radius server on
* @param {number} [timeout=2500] Timeout for connection to use
* @returns {Promise<any>}
*/
exports.radius = function (
@ -391,10 +483,12 @@ exports.radius = function (
callingStationId,
secret,
port = 1812,
timeout = 2500,
) {
const client = new radiusClient({
host: hostname,
hostPort: port,
timeout: timeout,
dictionaries: [ file ],
});

View file

@ -436,12 +436,12 @@ optgroup {
.monitor-list {
&.scrollbar {
overflow-y: auto;
height: calc(100% - 65px);
height: calc(100% - 107px);
}
@media (max-width: 770px) {
&.scrollbar {
height: calc(100% - 40px);
height: calc(100% - 97px);
}
}

View file

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

View file

@ -22,78 +22,78 @@
</div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('duration') " class="mb-3">
<label for="duration" class="form-label">{{ $t("Badge Duration") }}</label>
<input id="duration" v-model="badge.duration" type="number" min="0" class="form-control" required>
<label for="duration" class="form-label">{{ $t("Badge Duration (in hours)") }}</label>
<input id="duration" v-model="badge.duration" type="number" min="0" placeholder="24" class="form-control">
</div>
<div v-if=" (parameters[badge.type || 'null'] || [] ).includes('label') " class="mb-3">
<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 v-if=" (parameters[badge.type || 'null'] || [] ).includes('prefix') " class="mb-3">
<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 v-if=" (parameters[badge.type || 'null'] || [] ).includes('suffix') " class="mb-3">
<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 v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelColor') " class="mb-3">
<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 v-if=" (parameters[badge.type || 'null'] || [] ).includes('color') " class="mb-3">
<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 v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelPrefix') " class="mb-3">
<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 v-if=" (parameters[badge.type || 'null'] || [] ).includes('labelSuffix') " class="mb-3">
<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 v-if=" (parameters[badge.type || 'null'] || [] ).includes('upColor') " class="mb-3">
<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 v-if=" (parameters[badge.type || 'null'] || [] ).includes('downColor') " class="mb-3">
<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 v-if=" (parameters[badge.type || 'null'] || [] ).includes('pendingColor') " class="mb-3">
<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 v-if=" (parameters[badge.type || 'null'] || [] ).includes('maintenanceColor') " class="mb-3">
<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 v-if=" (parameters[badge.type || 'null'] || [] ).includes('warnColor') " class="mb-3">
<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 v-if=" (parameters[badge.type || 'null'] || [] ).includes('warnDays') " class="mb-3">
<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 v-if=" (parameters[badge.type || 'null'] || [] ).includes('downDays') " class="mb-3">
<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 class="mb-3">
@ -109,12 +109,16 @@
<div class="mb-3">
<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 class="my-3">
<label for="push-url" class="form-label">{{ $t("Badge URL") }}</label>
<CopyableInput id="push-url" v-model="badgeURL" type="url" disabled="disabled" />
<label for="badge-url" class="form-label">{{ $t("Badge URL") }}</label>
<CopyableInput id="badge-url" v-model="badgeURL" type="url" disabled="disabled" />
</div>
</div>
@ -131,6 +135,7 @@
<script lang="ts">
import { Modal } from "bootstrap";
import CopyableInput from "./CopyableInput.vue";
import { default as serverConfig } from "../../server/config.js";
export default {
components: {
@ -224,7 +229,8 @@ export default {
"color",
"labelColor",
],
}
},
badgeConstants: serverConfig.badgeConstants,
};
},

View file

@ -1,17 +1,25 @@
<template>
<div class="shadow-box mb-3" :style="boxStyle">
<div class="list-header">
<div class="placeholder"></div>
<div class="search-wrapper">
<a v-if="searchText == ''" class="search-icon">
<font-awesome-icon icon="search" />
</a>
<a v-if="searchText != ''" class="search-icon" @click="clearSearchText">
<font-awesome-icon icon="times" />
</a>
<form>
<input v-model="searchText" class="form-control search-input" :placeholder="$t('Search...')" autocomplete="off" />
</form>
<div class="header-top">
<div class="placeholder"></div>
<div class="search-wrapper">
<a v-if="searchText == ''" class="search-icon">
<font-awesome-icon icon="search" />
</a>
<a v-if="searchText != ''" class="search-icon" @click="clearSearchText">
<font-awesome-icon icon="times" />
</a>
<form>
<input
v-model="searchText" class="form-control search-input" :placeholder="$t('Search...')"
autocomplete="off"
/>
</form>
</div>
</div>
<div class="header-filter">
<MonitorListFilter :filterState="filterState" @update-filter="updateFilter" />
</div>
</div>
<div class="monitor-list" :class="{ scrollbar: scrollbar }">
@ -19,18 +27,23 @@
{{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link>
</div>
<MonitorListItem v-for="(item, index) in sortedMonitorList" :key="index" :monitor="item" :isSearch="searchText !== ''" />
<MonitorListItem
v-for="(item, index) in sortedMonitorList" :key="index" :monitor="item"
:isSearch="searchText !== ''"
/>
</div>
</div>
</template>
<script>
import MonitorListItem from "../components/MonitorListItem.vue";
import MonitorListFilter from "./MonitorListFilter.vue";
import { getMonitorRelativeURL } from "../util.ts";
export default {
components: {
MonitorListItem,
MonitorListFilter,
},
props: {
/** Should the scrollbar be shown */
@ -42,6 +55,11 @@ export default {
return {
searchText: "",
windowTop: 0,
filterState: {
status: null,
active: null,
tags: null,
}
};
},
computed: {
@ -72,8 +90,8 @@ export default {
const loweredSearchText = this.searchText.toLowerCase();
result = result.filter(monitor => {
return monitor.name.toLowerCase().includes(loweredSearchText)
|| monitor.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
|| tag.value?.toLowerCase().includes(loweredSearchText));
|| monitor.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
|| tag.value?.toLowerCase().includes(loweredSearchText));
});
} else {
result = result.filter(monitor => monitor.parent === null);
@ -105,6 +123,27 @@ export default {
return m1.name.localeCompare(m2.name);
});
if (this.filterState.status != null && this.filterState.status.length > 0) {
result.map(monitor => {
if (monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[monitor.id]) {
monitor.status = this.$root.lastHeartbeatList[monitor.id].status;
}
});
result = result.filter(monitor => this.filterState.status.includes(monitor.status));
}
if (this.filterState.active != null && this.filterState.active.length > 0) {
result = result.filter(monitor => this.filterState.active.includes(monitor.active));
}
if (this.filterState.tags != null && this.filterState.tags.length > 0) {
result = result.filter(monitor => {
return monitor.tags.map(tag => tag.tag_id) // convert to array of tag IDs
.filter(monitorTagId => this.filterState.tags.includes(monitorTagId)) // perform Array Intersaction between filter and monitor's tags
.length > 0;
});
}
return result;
},
},
@ -134,7 +173,14 @@ export default {
/** Clear the search bar */
clearSearchText() {
this.searchText = "";
}
},
/**
* Update the MonitorList Filter
* @param {object} newFilter Object with new filter
*/
updateFilter(newFilter) {
this.filterState = newFilter;
},
},
};
</script>
@ -159,8 +205,6 @@ export default {
margin: -10px;
margin-bottom: 10px;
padding: 10px;
display: flex;
justify-content: space-between;
.dark & {
background-color: $dark-header-bg;
@ -168,6 +212,17 @@ export default {
}
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-filter {
display: flex;
align-items: center;
}
@media (max-width: 770px) {
.list-header {
margin: -20px;
@ -216,5 +271,4 @@ export default {
padding-left: 67px;
margin-top: 5px;
}
</style>

View file

@ -0,0 +1,284 @@
<template>
<div class="px-2 pt-2 d-flex">
<button
type="button"
:title="$t('Clear current filters')"
class="clear-filters-btn btn"
:class="{ 'active': numFiltersActive > 0}"
tabindex="0"
:disabled="numFiltersActive === 0"
@click="clearFilters"
>
<font-awesome-icon icon="stream" />
<span v-if="numFiltersActive > 0" class="px-1 fw-bold">{{ numFiltersActive }}</span>
<font-awesome-icon v-if="numFiltersActive > 0" icon="times" />
</button>
<MonitorListFilterDropdown
:filterActive="filterState.status?.length > 0"
>
<template #status>
<Status v-if="filterState.status?.length === 1" :status="filterState.status[0]" />
<span v-else>
{{ $t('Status') }}
</span>
</template>
<template #dropdown>
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(1)">
<div class="d-flex align-items-center justify-content-between">
<Status :status="1" />
<span class="ps-3">
{{ $root.stats.up }}
<span v-if="filterState.status?.includes(1)" class="px-1 filter-active">
<font-awesome-icon icon="check" />
</span>
</span>
</div>
</div>
</li>
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(0)">
<div class="d-flex align-items-center justify-content-between">
<Status :status="0" />
<span class="ps-3">
{{ $root.stats.down }}
<span v-if="filterState.status?.includes(0)" class="px-1 filter-active">
<font-awesome-icon icon="check" />
</span>
</span>
</div>
</div>
</li>
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(2)">
<div class="d-flex align-items-center justify-content-between">
<Status :status="2" />
<span class="ps-3">
{{ $root.stats.pending }}
<span v-if="filterState.status?.includes(2)" class="px-1 filter-active">
<font-awesome-icon icon="check" />
</span>
</span>
</div>
</div>
</li>
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleStatusFilter(3)">
<div class="d-flex align-items-center justify-content-between">
<Status :status="3" />
<span class="ps-3">
{{ $root.stats.maintenance }}
<span v-if="filterState.status?.includes(3)" class="px-1 filter-active">
<font-awesome-icon icon="check" />
</span>
</span>
</div>
</div>
</li>
</template>
</MonitorListFilterDropdown>
<MonitorListFilterDropdown :filterActive="filterState.active?.length > 0">
<template #status>
<span v-if="filterState.active?.length === 1">
<span v-if="filterState.active[0]">{{ $t("Running") }}</span>
<span v-else>{{ $t("filterActivePaused") }}</span>
</span>
<span v-else>
{{ $t("filterActive") }}
</span>
</template>
<template #dropdown>
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(true)">
<div class="d-flex align-items-center justify-content-between">
<span>{{ $t("Running") }}</span>
<span class="ps-3">
{{ $root.stats.active }}
<span v-if="filterState.active?.includes(true)" class="px-1 filter-active">
<font-awesome-icon icon="check" />
</span>
</span>
</div>
</div>
</li>
<li>
<div class="dropdown-item" tabindex="0" @click.stop="toggleActiveFilter(false)">
<div class="d-flex align-items-center justify-content-between">
<span>{{ $t("filterActivePaused") }}</span>
<span class="ps-3">
{{ $root.stats.pause }}
<span v-if="filterState.active?.includes(false)" class="px-1 filter-active">
<font-awesome-icon icon="check" />
</span>
</span>
</div>
</div>
</li>
</template>
</MonitorListFilterDropdown>
<MonitorListFilterDropdown :filterActive="filterState.tags?.length > 0">
<template #status>
<Tag
v-if="filterState.tags?.length === 1"
:item="tagsList.find(tag => tag.id === filterState.tags[0])"
:size="'sm'"
/>
<span v-else>
{{ $t('Tags') }}
</span>
</template>
<template #dropdown>
<li v-for="tag in tagsList" :key="tag.id">
<div class="dropdown-item" tabindex="0" @click.stop="toggleTagFilter(tag)">
<div class="d-flex align-items-center justify-content-between">
<span><Tag :item="tag" :size="'sm'" /></span>
<span class="ps-3">
{{ getTaggedMonitorCount(tag) }}
<span v-if="filterState.tags?.includes(tag.id)" class="px-1 filter-active">
<font-awesome-icon icon="check" />
</span>
</span>
</div>
</div>
</li>
</template>
</MonitorListFilterDropdown>
</div>
</template>
<script>
import MonitorListFilterDropdown from "./MonitorListFilterDropdown.vue";
import Status from "./Status.vue";
import Tag from "./Tag.vue";
export default {
components: {
MonitorListFilterDropdown,
Status,
Tag,
},
props: {
filterState: {
type: Object,
required: true,
}
},
emits: [ "updateFilter" ],
data() {
return {
tagsList: [],
};
},
computed: {
numFiltersActive() {
let num = 0;
Object.values(this.filterState).forEach(item => {
if (item != null && item.length > 0) {
num += 1;
}
});
return num;
}
},
mounted() {
this.getExistingTags();
},
methods: {
toggleStatusFilter(status) {
let newFilter = {
...this.filterState
};
if (newFilter.status == null) {
newFilter.status = [ status ];
} else {
if (newFilter.status.includes(status)) {
newFilter.status = newFilter.status.filter(item => item !== status);
} else {
newFilter.status.push(status);
}
}
this.$emit("updateFilter", newFilter);
},
toggleActiveFilter(active) {
let newFilter = {
...this.filterState
};
if (newFilter.active == null) {
newFilter.active = [ active ];
} else {
if (newFilter.active.includes(active)) {
newFilter.active = newFilter.active.filter(item => item !== active);
} else {
newFilter.active.push(active);
}
}
this.$emit("updateFilter", newFilter);
},
toggleTagFilter(tag) {
let newFilter = {
...this.filterState
};
if (newFilter.tags == null) {
newFilter.tags = [ tag.id ];
} else {
if (newFilter.tags.includes(tag.id)) {
newFilter.tags = newFilter.tags.filter(item => item !== tag.id);
} else {
newFilter.tags.push(tag.id);
}
}
this.$emit("updateFilter", newFilter);
},
clearFilters() {
this.$emit("updateFilter", {
status: null,
});
},
getExistingTags() {
this.$root.getSocket().emit("getTags", (res) => {
if (res.ok) {
this.tagsList = res.tags;
}
});
},
getTaggedMonitorCount(tag) {
return Object.values(this.$root.monitorList).filter(monitor => {
return monitor.tags.find(monitorTag => monitorTag.tag_id === tag.id);
}).length;
}
}
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.clear-filters-btn {
font-size: 0.8em;
margin-right: 5px;
display: flex;
align-items: center;
padding: 2px 10px;
border-radius: 16px;
background-color: transparent;
.dark & {
color: $dark-font-color;
border: 1px solid $dark-font-color2;
}
&.active {
border: 1px solid $highlight;
background-color: $highlight-white;
.dark & {
background-color: $dark-font-color2;
}
}
}
</style>

View file

@ -0,0 +1,131 @@
<template>
<div class="dropdown" @focusin="open = true" @focusout="handleFocusOut">
<button type="button" class="filter-dropdown-status" :class="{ 'active': filterActive }" tabindex="0">
<div class="px-1 d-flex align-items-center">
<slot name="status"></slot>
</div>
<span class="px-1">
<font-awesome-icon icon="angle-down" />
</span>
</button>
<ul class="filter-dropdown-menu" :class="{ 'open': open }">
<slot name="dropdown"></slot>
</ul>
</div>
</template>
<script>
export default {
components: {
},
props: {
filterActive: {
type: Boolean,
required: true,
}
},
data() {
return {
open: false
};
},
methods: {
handleFocusOut(e) {
if (e.relatedTarget != null && this.$el.contains(e.relatedTarget)) {
return;
}
this.open = false;
}
}
};
</script>
<style lang="scss">
@import "../assets/vars.scss";
.filter-dropdown-menu {
z-index: 100;
transition: all 0.2s;
padding: 5px 0 !important;
border-radius: 16px;
overflow: hidden;
position: absolute;
inset: 0 auto auto 0;
margin: 0;
transform: translate(0, 36px);
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1);
visibility: hidden;
list-style: none;
height: 0;
opacity: 0;
background: white;
&.open {
height: unset;
visibility: inherit;
opacity: 1;
}
.dropdown-item {
padding: 5px 15px;
}
.dropdown-item:focus {
background: $highlight-white;
.dark & {
background: $dark-bg2;
}
}
.dark & {
background-color: $dark-bg;
color: $dark-font-color;
border-color: $dark-border-color;
.dropdown-item {
color: $dark-font-color;
&.active {
color: $dark-font-color2;
background-color: $highlight !important;
}
&:hover {
background-color: $dark-bg2;
}
}
}
}
.filter-dropdown-status {
display: flex;
align-items: center;
padding: 4px 10px;
margin-left: 5px;
border: 1px solid #ced4da;
border-radius: 25px;
background-color: transparent;
.dark & {
color: $dark-font-color;
border: 1px solid $dark-font-color2;
}
&.active {
border: 1px solid $highlight;
background-color: $highlight-white;
.dark & {
background-color: $dark-font-color2;
}
}
}
.filter-active {
color: $highlight;
}
</style>

View file

@ -104,7 +104,7 @@ export default {
// We must check if there are any elements in monitorList to
// prevent undefined errors if it hasn't been loaded yet
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;
},

View file

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

View file

@ -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>

View file

@ -150,7 +150,7 @@ export default {
// We must check if there are any elements in monitorList to
// prevent undefined errors if it hasn't been loaded yet
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;
},

View file

@ -99,7 +99,7 @@
</div>
<div class="modal-footer">
<button v-if="tag" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
<button v-if="tag && tag.id !== null" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
{{ $t("Delete") }}
</button>
<button type="submit" class="btn btn-primary" :disabled="processing">

View file

@ -17,7 +17,7 @@
<label for="gorush-platform" class="form-label">{{ $t("Platform") }}</label><span style="color: red;"><sup>*</sup></span>
<select id="gorush-platform" v-model="$parent.notification.gorushPlatform" class="form-select">
<option value="ios">iOS</option>
<option value="android">{{ $t("Android") }}</option>
<option value="android">Android</option>
<option value="huawei">{{ $t("Huawei") }}</option>
</select>
</div>

View file

@ -13,7 +13,7 @@
<div class="form-text">
<span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}
<i18n-t tag="p" keypath="aboutWebhooks" style="margin-top: 8px;">
<a href="https://docs.mattermost.com/developer/webhooks-incoming.html" target="_blank">https://docs.mattermost.com/developer/webhooks-incoming.html</a>
<a href="https://developers.mattermost.com/integrate/webhooks/incoming/" target="_blank">https://developers.mattermost.com/integrate/webhooks/incoming/</a>
</i18n-t>
<p style="margin-top: 8px;">
{{ $t("aboutMattermostChannelName") }}

View file

@ -7,8 +7,9 @@
</div>
<div class="mb-3">
<label for="ntfy-server-url" class="form-label">{{ $t("Server URL") }}</label>
<div class="input-group mb-3">
<input id="ntfy-server-url" v-model="$parent.notification.ntfyserverurl" type="text" class="form-control" required>
<input id="ntfy-server-url" v-model="$parent.notification.ntfyserverurl" type="text" class="form-control" required>
<div class="form-text">
{{ $t("Server URL should not contain the nfty topic") }}
</div>
</div>
<div class="mb-3">

View file

@ -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>

View file

@ -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>
</i18n-t>
</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>
</template>

View file

@ -5,7 +5,18 @@
</div>
<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>
</div>

View file

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

View file

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

View file

@ -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>

View file

@ -455,8 +455,6 @@
"For safety, must use secret key": "للسلامة يجب استخدام المفتاح السري",
"Device Token": "رمز الجهاز",
"Platform": "منصة",
"iOS": "iOS",
"Android": "ذكري المظهر",
"Huawei": "هواوي",
"High": "عالٍ",
"Retry": "إعادة المحاولة",

View file

@ -592,7 +592,6 @@
"For safety, must use secret key": "للسلامة يجب استخدام المفتاح السري",
"Device Token": "رمز الجهاز",
"Platform": "منصة",
"Android": "ذكري المظهر",
"Huawei": "هواوي",
"High": "عالٍ",
"Retry": "إعادة المحاولة",

View file

@ -396,8 +396,6 @@
"For safety, must use secret key": "За сигурност, трябва да се използва таен ключ",
"Device Token": "Токен за устройство",
"Platform": "Платформа",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "Висок",
"Retry": "Повтори",

View file

@ -454,8 +454,6 @@
"For safety, must use secret key": "Z důvodu bezpečnosti použijte secret key",
"Device Token": "Token zařízení",
"Platform": "Platforma",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "Vysoký",
"Retry": "Opakovat",

View file

@ -558,7 +558,6 @@
"high": "høj",
"Base URL": "Base URL",
"Platform": "Platform",
"Android": "Android",
"Huawei": "Huawei",
"Retry": "Forsøg igen",
"Topic": "Emne",

View file

@ -403,8 +403,6 @@
"For safety, must use secret key": "Zur Sicherheit muss ein geheimer Schlüssel verwendet werden",
"Device Token": "Gerätetoken",
"Platform": "Platform",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "Hoch",
"Retry": "Wiederholungen",

View file

@ -403,8 +403,6 @@
"For safety, must use secret key": "Zur Sicherheit muss ein geheimer Schlüssel verwendet werden",
"Device Token": "Gerätetoken",
"Platform": "Platform",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "Hoch",
"Retry": "Wiederholungen",

View file

@ -420,8 +420,6 @@
"For safety, must use secret key": "Για ασφάλεια, πρέπει να χρησιμοποιήσετε secret key",
"Device Token": "Device Token",
"Platform": "Platform",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "High",
"Retry": "Ξαναδοκιμάσετε",

View file

@ -56,6 +56,9 @@
"Ping": "Ping",
"Monitor Type": "Monitor Type",
"Keyword": "Keyword",
"Invert Keyword": "Invert Keyword",
"Expected Value": "Expected Value",
"Json Query": "Json Query",
"Friendly Name": "Friendly Name",
"URL": "URL",
"Hostname": "Hostname",
@ -157,6 +160,8 @@
"Disable 2FA": "Disable 2FA",
"2FA Settings": "2FA Settings",
"Two Factor Authentication": "Two Factor Authentication",
"filterActive": "Active",
"filterActivePaused": "Paused",
"Active": "Active",
"Inactive": "Inactive",
"Token": "Token",
@ -200,8 +205,11 @@
"Content Type": "Content Type",
"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}",
"webhookCustomBodyDesc": "Define a custom HTTP Body for the request. Template variables {msg}, {heartbeat}, {monitor} are accepted.",
"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",
"Application Token": "Application Token",
"Server URL": "Server URL",
@ -361,6 +369,7 @@
"deleteDockerHostMsg": "Are you sure want to delete this docker host for all monitors?",
"socket": "Socket",
"tcp": "TCP / HTTP",
"tailscalePingWarning": "In order to use the Tailscale Ping monitor, you need to install Uptime Kuma without Docker and also install Tailscale client on your server.",
"Docker Container": "Docker Container",
"Container Name / ID": "Container Name / ID",
"Docker Host": "Docker Host",
@ -523,6 +532,8 @@
"passwordNotMatchMsg": "The repeat password does not match.",
"notificationDescription": "Notifications must be assigned to a monitor to function.",
"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.",
"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.",
@ -614,7 +625,6 @@
"For safety, must use secret key": "For safety, must use secret key",
"Device Token": "Device Token",
"Platform": "Platform",
"Android": "Android",
"Huawei": "Huawei",
"High": "High",
"Retry": "Retry",
@ -637,6 +647,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.",
"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",
"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",
"Icon Emoji": "Icon Emoji",
"signalImportant": "IMPORTANT: You cannot mix groups and numbers in recipients!",
@ -683,6 +695,7 @@
"Octopush API Version": "Octopush API Version",
"Legacy Octopush-DM": "Legacy Octopush-DM",
"ntfy Topic": "ntfy Topic",
"Server URL should not contain the nfty topic": "Server URL should not contain the nfty topic",
"onebotHttpAddress": "OneBot HTTP Address",
"onebotMessageType": "OneBot Message Type",
"onebotGroupMessage": "Group",
@ -730,7 +743,8 @@
"ntfyAuthenticationMethod": "Authentication Method",
"ntfyUsernameAndPassword": "Username and Password",
"twilioAccountSID": "Account SID",
"twilioAuthToken": "Auth Token",
"twilioApiKey": "Api Key (optional)",
"twilioAuthToken": "Auth Token / Api Key Secret",
"twilioFromNumber": "From Number",
"twilioToNumber": "To Number",
"Monitor Setting": "{0}'s Monitor Setting",
@ -739,13 +753,14 @@
"Open Badge Generator": "Open Badge Generator",
"Badge Generator": "{0}'s Badge Generator",
"Badge Type": "Badge Type",
"Badge Duration": "Badge Duration",
"Badge Duration (in hours)": "Badge Duration (in hours)",
"Badge Label": "Badge Label",
"Badge Prefix": "Badge Prefix",
"Badge Suffix": "Badge Suffix",
"Badge Prefix": "Badge Value Prefix",
"Badge Suffix": "Badge Value Suffix",
"Badge Label Color": "Badge Label Color",
"Badge Color": "Badge Color",
"Badge Label Prefix": "Badge Label Prefix",
"Badge Preview": "Badge Preview",
"Badge Label Suffix": "Badge Label Suffix",
"Badge Up Color": "Badge Up Color",
"Badge Down Color": "Badge Down Color",
@ -759,6 +774,21 @@
"Badge URL": "Badge URL",
"Group": "Group",
"Monitor Group": "Monitor Group",
"Kafka Brokers": "Kafka Brokers",
"Enter the list of brokers": "Enter the list of brokers",
"Press Enter to add broker": "Press Enter to add broker",
"Kafka Topic Name": "Kafka Topic Name",
"Kafka Producer Message": "Kafka Producer Message",
"Enable Kafka SSL": "Enable Kafka SSL",
"Enable Kafka Producer Auto Topic Creation": "Enable Kafka Producer Auto Topic Creation",
"Kafka SASL Options": "Kafka SASL Options",
"Mechanism": "Mechanism",
"Pick a SASL Mechanism...": "Pick a SASL Mechanism...",
"Authorization Identity": "Authorization Identity",
"AccessKey Id": "AccessKey Id",
"Secret AccessKey": "Secret AccessKey",
"Session Token": "Session Token",
"noGroupMonitorMsg": "Not Available. Create a Group Monitor First.",
"Close": "Close"
"Close": "Close",
"Request Body": "Request Body"
}

View file

@ -497,8 +497,6 @@
"Proto Method": "Método Proto",
"Proto Content": "Contenido Proto",
"Economy": "Económico",
"iOS": "iOS",
"Android": "Android",
"Platform": "Plataforma",
"onebotPrivateMessage": "Privado",
"onebotMessageType": "Tipo de Mensaje OneBot",

View file

@ -415,8 +415,6 @@
"For safety, must use secret key": "For safety, must use secret key",
"Device Token": "Gailu tokena",
"Platform": "Plataforma",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "Altua",
"Retry": "Errepikatu",

View file

@ -568,7 +568,6 @@
"SendKey": "کلید ارسال (SendKey)",
"SecretAccessKey": "کلید دسترسی مخفی (AccessKey Secret)",
"SignName": "نام امضا (SignName)",
"Android": "اندروید",
"Huawei": "هواوی",
"WeCom Bot Key": "کلید ربات WeCom",
"Setup Proxy": "تنظیم پروکسی",

View file

@ -547,7 +547,6 @@
"For safety, must use secret key": "Turvallisuuden vuoksi on käytettävä salaista avainta",
"Device Token": "Laitteen tunnus",
"Platform": "Alusta",
"iOS": "iOS",
"Bark Endpoint": "Bark päätepiste",
"Huawei": "Huawei",
"High": "Korkea",
@ -564,7 +563,6 @@
"promosmsAllowLongSMS": "Salli pitkät tekstiviestit",
"Feishu WebHookUrl": "Feishu WebHookURL-osoite",
"Internal Room Id": "Huoneen sisäinen tunnus",
"Android": "Android",
"Channel Name": "Kanavan nimi",
"Uptime Kuma URL": "Uptime Kuma URL-osoite",
"Icon Emoji": "Ikoni Emoji",

View file

@ -451,8 +451,6 @@
"For safety, must use secret key": "Par sécurité, utilisation obligatoire de la clé secrète",
"Device Token": "Jeton d'appareil",
"Platform": "Plateforme",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "Haute",
"Retry": "Recommencez",

View file

@ -445,8 +445,6 @@
"For safety, must use secret key": "לבטיחות, חייב להשתמש במפתח סודיy",
"Device Token": "אסימון מכשיר",
"Platform": "פּלַטפוֹרמָה",
"iOS": "iOS",
"Android": "דְמוּי אָדָם",
"Huawei": "huawei",
"High": "High",
"Retry": "נסה שוב",

View file

@ -420,8 +420,6 @@
"For safety, must use secret key": "Korištenje tajnog ključa je obavezno",
"Device Token": "Token uređaja",
"Platform": "Platforma",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "Visoko",
"Retry": "Ponovnih pokušaja",

View file

@ -418,8 +418,6 @@
"For safety, must use secret key": "Untuk keamaan Anda harus menggunakan kunci rahasia",
"Device Token": "Token Perangkat",
"Platform": "Platform",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "Tinggi",
"Retry": "Ulang",

View file

@ -507,7 +507,6 @@
"lineDevConsoleTo": "Line Developers Console - {0}",
"Basic Settings": "基本設定",
"User ID": "User ID",
"Android": "Android",
"Huawei": "Huawei",
"Device Token": "デバイストークン",
"recurringIntervalMessage": "毎日1回実行する{0} 日に1回実行する",

View file

@ -413,8 +413,6 @@
"For safety, must use secret key": "안전을 위해 꼭 Secret Key를 사용하세요.",
"Device Token": "기기 Token",
"Platform": "플랫폼",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "High",
"Retry": "재시도",

View file

@ -404,8 +404,6 @@
"For safety, must use secret key": "Voor de veiligheid moet je de secret key gebruiken",
"Device Token": "Apparaat Token",
"Platform": "Platform",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "Hoog",
"Retry": "Opnieuw",

View file

@ -414,8 +414,6 @@
"For safety, must use secret key": "Ze względów bezpieczeństwa musisz użyć tajnego klucza",
"Device Token": "Token urządzenia",
"Platform": "Platforma",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "Wysoki",
"Retry": "Ponów",

View file

@ -523,7 +523,6 @@
"Example:": "Exemplo: {0}",
"Read more:": "Leia mais em: {0}",
"promosmsAllowLongSMS": "Permitir SMS grandes",
"Android": "Android",
"Huawei": "Huawei",
"smseagleTo": "Números Dos Telefones",
"smseaglePriority": "Prioridade da mensagem (0-9, padrão=0)",

View file

@ -421,8 +421,6 @@
"For safety, must use secret key": "В целях безопасности необходимо использовать секретный ключ",
"Device Token": "Токен устройства",
"Platform": "Платформа",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "High",
"Retry": "Повторить",

View file

@ -404,8 +404,6 @@
"For safety, must use secret key": "เพื่อความปลอดภัย จำเป็นต้องตั้งค่ากุญแจการเข้าถึง",
"Device Token": "Device Token",
"Platform": "แพลตฟอร์ม",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "สูง",
"Retry": "ลองใหม่",

View file

@ -408,8 +408,6 @@
"For safety, must use secret key": "Güvenlik için gizli anahtar kullanılmalıdır",
"Device Token": "Cihaz Tokeni",
"Platform": "Platform",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "High",
"Retry": "Tekrar",

View file

@ -413,8 +413,6 @@
"For safety, must use secret key": "Для безпеки необхідно використовувати секретний ключ",
"Device Token": "Токен пристрою",
"Platform": "Платформа",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "Високий",
"Retry": "Повтор",

View file

@ -403,8 +403,6 @@
"For safety, must use secret key": "Để an toàn, hãy dùng secret key",
"Device Token": "Device Token",
"Platform": "Platform",
"iOS": "iOS",
"Android": "Android",
"Huawei": "Huawei",
"High": "High",
"Retry": "Retry",

View file

@ -452,8 +452,6 @@
"For safety, must use secret key": "出于安全考虑,必须使用加签密钥",
"Device Token": "Apple Device Token",
"Platform": "平台",
"iOS": "iOS",
"Android": "Android",
"Huawei": "华为",
"High": "高",
"Retry": "重试次数",

View file

@ -139,6 +139,8 @@
"Disable 2FA": "關閉 2FA",
"2FA Settings": "2FA 設定",
"Two Factor Authentication": "雙重認證",
"filterActive": "執行狀態",
"filterActivePaused": "已暫停",
"Active": "生效",
"Inactive": "未生效",
"Token": "Token",
@ -692,7 +694,6 @@
"Retry": "重試",
"High": "高",
"Huawei": "華為",
"Android": "Android",
"For safety, must use secret key": "為安全起見,必須使用 Secret Key",
"SecretKey": "SecretKey",
"WebHookUrl": "WebHookUrl",

View file

@ -445,8 +445,6 @@
"For safety, must use secret key": "為了安全起見,必須使用秘密金鑰",
"Device Token": "裝置權杖",
"Platform": "平台",
"iOS": "iOS",
"Android": "Android",
"Huawei": "華為",
"High": "高",
"Retry": "重試",

View file

@ -1,9 +1,12 @@
import axios from "axios";
import { getDevContainerServerHostname, isDevContainer } from "../util-frontend";
const env = process.env.NODE_ENV || "production";
// change the axios base url for development
if (env === "development" || localStorage.dev === "dev") {
if (env === "development" && isDevContainer()) {
axios.defaults.baseURL = location.protocol + "//" + getDevContainerServerHostname();
} else if (env === "development" || localStorage.dev === "dev") {
axios.defaults.baseURL = location.protocol + "//" + location.hostname + ":3001";
}

View file

@ -4,6 +4,7 @@ import jwtDecode from "jwt-decode";
import Favico from "favico.js";
import dayjs from "dayjs";
import { DOWN, MAINTENANCE, PENDING, UP } from "../util.ts";
import { getDevContainerServerHostname, isDevContainer } from "../util-frontend.js";
const toast = useToast();
let socket;
@ -98,7 +99,9 @@ export default {
let wsHost;
const env = process.env.NODE_ENV || "production";
if (env === "development" || localStorage.dev === "dev") {
if (env === "development" && isDevContainer()) {
wsHost = protocol + getDevContainerServerHostname();
} else if (env === "development" || localStorage.dev === "dev") {
wsHost = protocol + location.hostname + ":3001";
} else {
wsHost = protocol + location.host;
@ -698,9 +701,11 @@ export default {
stats() {
let result = {
active: 0,
up: 0,
down: 0,
maintenance: 0,
pending: 0,
unknown: 0,
pause: 0,
};
@ -712,12 +717,13 @@ export default {
if (monitor && ! monitor.active) {
result.pause++;
} else if (beat) {
result.active++;
if (beat.status === UP) {
result.up++;
} else if (beat.status === DOWN) {
result.down++;
} else if (beat.status === PENDING) {
result.up++;
result.pending++;
} else if (beat.status === MAINTENANCE) {
result.maintenance++;
} else {

View file

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

View file

@ -8,12 +8,20 @@
<Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" />
</div>
<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 === 'ping'">Ping: {{ monitor.hostname }}</span>
<span v-if="monitor.type === 'keyword'">
<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 v-if="monitor.type === 'dns'">[{{ monitor.dns_resolve_type }}] {{ monitor.hostname }}
<br>
@ -432,7 +440,7 @@ export default {
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");
}
@ -582,6 +590,10 @@ table {
color: $dark-font-color;
}
.keyword-inverted {
color: $dark-font-color;
}
.dropdown-clear-data {
ul {
background-color: $dark-bg;

View file

@ -27,6 +27,9 @@
<option value="keyword">
HTTP(s) - {{ $t("Keyword") }}
</option>
<option value="json-query">
HTTP(s) - {{ $t("Json Query") }}
</option>
<option value="grpc-keyword">
gRPC(s) - {{ $t("Keyword") }}
</option>
@ -58,6 +61,9 @@
<option value="mqtt">
MQTT
</option>
<option value="kafka-producer">
Kafka Producer
</option>
<option value="sqlserver">
Microsoft SQL Server
</option>
@ -76,10 +82,17 @@
<option value="redis">
Redis
</option>
<option value="tailscale-ping">
Tailscale Ping
</option>
</optgroup>
</select>
</div>
<div v-if="monitor.type === 'tailscale-ping'" class="alert alert-warning" role="alert">
{{ $t("tailscalePingWarning") }}
</div>
<!-- Friendly Name -->
<div class="my-3">
<label for="name" class="form-label">{{ $t("Friendly Name") }}</label>
@ -97,7 +110,7 @@
</div>
<!-- URL -->
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'real-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>
<input id="url" v-model="monitor.url" type="url" class="form-control" pattern="https?://.+" required>
</div>
@ -127,6 +140,31 @@
</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 -->
<!-- GameDig only -->
<div v-if="monitor.type === 'gamedig'" class="my-3">
@ -138,9 +176,60 @@
</select>
</div>
<template v-if="monitor.type === 'kafka-producer'">
<!-- Kafka Brokers List -->
<div class="my-3">
<label for="kafkaProducerBrokers" class="form-label">{{ $t("Kafka Brokers") }}</label>
<VueMultiselect
id="kafkaProducerBrokers"
v-model="monitor.kafkaProducerBrokers"
:multiple="true"
:options="[]"
:placeholder="$t('Enter the list of brokers')"
:tag-placeholder="$t('Press Enter to add broker')"
:max-height="500"
:taggable="true"
:show-no-options="false"
:close-on-select="false"
:clear-on-select="false"
:preserve-search="false"
:preselect-first="false"
@tag="addKafkaProducerBroker"
></VueMultiselect>
</div>
<!-- Kafka Topic Name -->
<div class="my-3">
<label for="kafkaProducerTopic" class="form-label">{{ $t("Kafka Topic Name") }}</label>
<input id="kafkaProducerTopic" v-model="monitor.kafkaProducerTopic" type="text" class="form-control" required>
</div>
<!-- Kafka Producer Message -->
<div class="my-3">
<label for="kafkaProducerMessage" class="form-label">{{ $t("Kafka Producer Message") }}</label>
<input id="kafkaProducerMessage" v-model="monitor.kafkaProducerMessage" type="text" class="form-control" required>
</div>
<!-- Kafka SSL -->
<div class="my-3 form-check">
<input id="kafkaProducerSsl" v-model="monitor.kafkaProducerSsl" class="form-check-input" type="checkbox">
<label class="form-check-label" for="kafkaProducerSsl">
{{ $t("Enable Kafka SSL") }}
</label>
</div>
<!-- Kafka SSL -->
<div class="my-3 form-check">
<input id="kafkaProducerAllowAutoTopicCreation" v-model="monitor.kafkaProducerAllowAutoTopicCreation" class="form-check-input" type="checkbox">
<label class="form-check-label" for="kafkaProducerAllowAutoTopicCreation">
{{ $t("Enable Kafka Producer Auto Topic Creation") }}
</label>
</div>
</template>
<!-- Hostname -->
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius only -->
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' ||monitor.type === 'mqtt' || monitor.type === 'radius'" class="my-3">
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping only -->
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' ||monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping'" class="my-3">
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
<input id="hostname" v-model="monitor.hostname" type="text" class="form-control" :pattern="`${monitor.type === 'mqtt' ? mqttIpOrHostnameRegexPattern : ipOrHostnameRegexPattern}`" required>
</div>
@ -356,7 +445,7 @@
<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">
<label class="form-check-label" for="expiry-notification">
{{ $t("Certificate Expiry Notification") }}
@ -365,7 +454,7 @@
</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="">
<label class="form-check-label" for="ignore-tls">
{{ $t("ignoreTLSError") }}
@ -457,7 +546,7 @@
</button>
<!-- 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>
<p v-if="$root.proxyList.length === 0">
{{ $t("Not available, please setup.") }}
@ -484,8 +573,58 @@
</button>
</div>
<!-- Kafka SASL Options -->
<!-- Kafka Producer only -->
<template v-if="monitor.type === 'kafka-producer'">
<h2 class="mt-5 mb-2">{{ $t("Kafka SASL Options") }}</h2>
<div class="my-3">
<label class="form-label" for="kafkaProducerSaslMechanism">
{{ $t("Mechanism") }}
</label>
<VueMultiselect
id="kafkaProducerSaslMechanism"
v-model="monitor.kafkaProducerSaslOptions.mechanism"
:options="kafkaSaslMechanismOptions"
:multiple="false"
:clear-on-select="false"
:preserve-search="false"
:placeholder="$t('Pick a SASL Mechanism...')"
:preselect-first="false"
:max-height="500"
:allow-empty="false"
:taggable="false"
></VueMultiselect>
</div>
<div v-if="monitor.kafkaProducerSaslOptions.mechanism !== 'None'">
<div v-if="monitor.kafkaProducerSaslOptions.mechanism !== 'aws'" class="my-3">
<label for="kafkaProducerSaslUsername" class="form-label">{{ $t("Username") }}</label>
<input id="kafkaProducerSaslUsername" v-model="monitor.kafkaProducerSaslOptions.username" type="text" autocomplete="kafkaProducerSaslUsername" class="form-control">
</div>
<div v-if="monitor.kafkaProducerSaslOptions.mechanism !== 'aws'" class="my-3">
<label for="kafkaProducerSaslPassword" class="form-label">{{ $t("Password") }}</label>
<input id="kafkaProducerSaslPassword" v-model="monitor.kafkaProducerSaslOptions.password" type="password" autocomplete="kafkaProducerSaslPassword" class="form-control">
</div>
<div v-if="monitor.kafkaProducerSaslOptions.mechanism === 'aws'" class="my-3">
<label for="kafkaProducerSaslAuthorizationIdentity" class="form-label">{{ $t("Authorization Identity") }}</label>
<input id="kafkaProducerSaslAuthorizationIdentity" v-model="monitor.kafkaProducerSaslOptions.authorizationIdentity" type="text" autocomplete="kafkaProducerSaslAuthorizationIdentity" class="form-control" required>
</div>
<div v-if="monitor.kafkaProducerSaslOptions.mechanism === 'aws'" class="my-3">
<label for="kafkaProducerSaslAccessKeyId" class="form-label">{{ $t("AccessKey Id") }}</label>
<input id="kafkaProducerSaslAccessKeyId" v-model="monitor.kafkaProducerSaslOptions.accessKeyId" type="text" autocomplete="kafkaProducerSaslAccessKeyId" class="form-control" required>
</div>
<div v-if="monitor.kafkaProducerSaslOptions.mechanism === 'aws'" class="my-3">
<label for="kafkaProducerSaslSecretAccessKey" class="form-label">{{ $t("Secret AccessKey") }}</label>
<input id="kafkaProducerSaslSecretAccessKey" v-model="monitor.kafkaProducerSaslOptions.secretAccessKey" type="password" autocomplete="kafkaProducerSaslSecretAccessKey" class="form-control" required>
</div>
<div v-if="monitor.kafkaProducerSaslOptions.mechanism === 'aws'" class="my-3">
<label for="kafkaProducerSaslSessionToken" class="form-label">{{ $t("Session Token") }}</label>
<input id="kafkaProducerSaslSessionToken" v-model="monitor.kafkaProducerSaslOptions.sessionToken" type="password" autocomplete="kafkaProducerSaslSessionToken" class="form-control">
</div>
</div>
</template>
<!-- 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>
<!-- Method -->
@ -696,6 +835,7 @@ export default {
},
acceptedStatusCodeOptions: [],
dnsresolvetypeOptions: [],
kafkaSaslMechanismOptions: [],
ipOrHostnameRegexPattern: hostNameRegexPattern(),
mqttIpOrHostnameRegexPattern: hostNameRegexPattern(true),
gameList: null,
@ -959,12 +1099,21 @@ message HealthCheckResponse {
"TXT",
];
let kafkaSaslMechanismOptions = [
"None",
"plain",
"scram-sha-256",
"scram-sha-512",
"aws",
];
for (let i = 100; i <= 999; i++) {
acceptedStatusCodeOptions.push(i.toString());
}
this.acceptedStatusCodeOptions = acceptedStatusCodeOptions;
this.dnsresolvetypeOptions = dnsresolvetypeOptions;
this.kafkaSaslMechanismOptions = kafkaSaslMechanismOptions;
},
methods: {
/** Initialize the edit monitor form */
@ -998,7 +1147,11 @@ message HealthCheckResponse {
mqttTopic: "",
mqttSuccessMessage: "",
authMethod: null,
httpBodyEncoding: "json"
httpBodyEncoding: "json",
kafkaProducerBrokers: [],
kafkaProducerSaslOptions: {
mechanism: "None",
},
};
if (this.$root.proxyList && !this.monitor.proxyId) {
@ -1039,6 +1192,7 @@ message HealthCheckResponse {
this.monitor.childrenIDs = undefined;
this.monitor.forceInactive = undefined;
this.monitor.pathName = undefined;
this.monitor.screenshot = undefined;
this.monitor.name = this.$t("cloneOf", [ this.monitor.name ]);
this.$refs.tagsManager.newTags = this.monitor.tags.map((monitorTag) => {
@ -1065,6 +1219,10 @@ message HealthCheckResponse {
},
addKafkaProducerBroker(newBroker) {
this.monitor.kafkaProducerBrokers.push(newBroker);
},
/**
* Validate form input
* @returns {boolean} Is the form input valid?
@ -1107,7 +1265,7 @@ message HealthCheckResponse {
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;
}

View file

@ -116,12 +116,6 @@ export default {
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: {
title: this.$t("About"),
},

View file

@ -325,7 +325,7 @@
</p>
<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>
</footer>
@ -360,7 +360,6 @@ import DOMPurify from "dompurify";
import Confirm from "../components/Confirm.vue";
import PublicGroupList from "../components/PublicGroupList.vue";
import MaintenanceTime from "../components/MaintenanceTime.vue";
import DateTime from "../components/Datetime.vue";
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 Tag from "../components/Tag.vue";
@ -386,7 +385,6 @@ export default {
Confirm,
PrismEditor,
MaintenanceTime,
DateTime,
Tag,
VueMultiselect
},
@ -583,6 +581,10 @@ export default {
return "";
}
},
lastUpdateTimeDisplay() {
return this.$root.datetime(this.lastUpdateTime);
}
},
watch: {

View file

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

View file

@ -72,13 +72,32 @@ export function setPageLocale() {
*/
export function getResBaseURL() {
const env = process.env.NODE_ENV;
if (env === "development" || localStorage.dev === "dev") {
if (env === "development" && isDevContainer()) {
return location.protocol + "//" + getDevContainerServerHostname();
} else if (env === "development" || localStorage.dev === "dev") {
return location.protocol + "//" + location.hostname + ":3001";
} else {
return "";
}
}
export function isDevContainer() {
// eslint-disable-next-line no-undef
return (typeof DEVCONTAINER === "string" && DEVCONTAINER === "1");
}
/**
* Supports GitHub Codespaces only currently
*/
export function getDevContainerServerHostname() {
if (!isDevContainer()) {
return "";
}
// eslint-disable-next-line no-undef
return CODESPACE_NAME + "-3001." + GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN;
}
/**
*
* @param {} mqtt wheather or not the regex should take into account the fact that it is an mqtt uri

Some files were not shown because too many files have changed in this diff Show more