Merge branch 'master' into task/ignore-redis-tls

pull/3878/head
Frank Elsinga 11 months ago committed by GitHub
commit a79bcb4165
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -84,7 +84,7 @@ module.exports = {
"checkLoops": false, "checkLoops": false,
}], }],
"space-before-blocks": "warn", "space-before-blocks": "warn",
//'no-console': 'warn', //"no-console": "warn",
"no-extra-boolean-cast": "off", "no-extra-boolean-cast": "off",
"no-multiple-empty-lines": [ "warn", { "no-multiple-empty-lines": [ "warn", {
"max": 1, "max": 1,
@ -96,7 +96,8 @@ module.exports = {
"no-unneeded-ternary": "error", "no-unneeded-ternary": "error",
"array-bracket-newline": [ "error", "consistent" ], "array-bracket-newline": [ "error", "consistent" ],
"eol-last": [ "error", "always" ], "eol-last": [ "error", "always" ],
//'prefer-template': 'error', //"prefer-template": "error",
"template-curly-spacing": [ "warn", "never" ],
"comma-dangle": [ "warn", "only-multiline" ], "comma-dangle": [ "warn", "only-multiline" ],
"no-empty": [ "error", { "no-empty": [ "error", {
"allowEmptyCatch": true "allowEmptyCatch": true

@ -15,7 +15,7 @@ Please delete any options that are not relevant.
- Bug fix (non-breaking change which fixes an issue) - Bug fix (non-breaking change which fixes an issue)
- User interface (UI) - User interface (UI)
- New feature (non-breaking change which adds functionality) - New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to not work as expected) - Breaking change (a fix or feature that would cause existing functionality to not work as expected)
- Other - Other
- This change requires a documentation update - This change requires a documentation update
@ -24,9 +24,8 @@ Please delete any options that are not relevant.
- [ ] My code follows the style guidelines of this project - [ ] My code follows the style guidelines of this project
- [ ] I ran ESLint and other linters for modified files - [ ] I ran ESLint and other linters for modified files
- [ ] I have performed a self-review of my own code and tested it - [ ] I have performed a self-review of my own code and tested it
- [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have commented my code, particularly in hard-to-understand areas (including JSDoc for methods)
(including JSDoc for methods) - [ ] My changes generates no new warnings
- [ ] My changes generate no new warnings
- [ ] My code needed automated testing. I have added them (this is optional task) - [ ] My code needed automated testing. I have added them (this is optional task)
## Screenshots (if any) ## Screenshots (if any)

@ -27,10 +27,10 @@ jobs:
steps: steps:
- run: git config --global core.autocrlf false # Mainly for Windows - run: git config --global core.autocrlf false # Mainly for Windows
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node }} - name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- run: npm install npm@9 -g - run: npm install npm@9 -g
@ -55,10 +55,10 @@ jobs:
steps: steps:
- run: git config --global core.autocrlf false # Mainly for Windows - run: git config --global core.autocrlf false # Mainly for Windows
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node }} - name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
- run: npm install npm@9 -g - run: npm install npm@9 -g
@ -69,14 +69,14 @@ jobs:
steps: steps:
- run: git config --global core.autocrlf false # Mainly for Windows - run: git config --global core.autocrlf false # Mainly for Windows
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Use Node.js 20 - name: Use Node.js 20
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
- run: npm install - run: npm install
- run: npm run lint - run: npm run lint:prod
# TODO: Temporarily disable, as it cannot pass the test in 2.0.0 yet # TODO: Temporarily disable, as it cannot pass the test in 2.0.0 yet
# e2e-tests: # e2e-tests:
@ -84,10 +84,10 @@ jobs:
# runs-on: ubuntu-latest # runs-on: ubuntu-latest
# steps: # steps:
# - run: git config --global core.autocrlf false # Mainly for Windows # - run: git config --global core.autocrlf false # Mainly for Windows
# - uses: actions/checkout@v3 # - uses: actions/checkout@v4
# #
# - name: Use Node.js 14 # - name: Use Node.js 14
# uses: actions/setup-node@v3 # uses: actions/setup-node@v4
# with: # with:
# node-version: 14 # node-version: 14
# - run: npm install # - run: npm install
@ -99,10 +99,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- run: git config --global core.autocrlf false # Mainly for Windows - run: git config --global core.autocrlf false # Mainly for Windows
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Use Node.js 14 - name: Use Node.js 14
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 14 node-version: 14
- run: npm install - run: npm install

@ -14,10 +14,10 @@ jobs:
node-version: [16] node-version: [16]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'npm' cache: 'npm'

@ -6,7 +6,7 @@ on:
pull_request: pull_request:
branches: branches:
- master - master
- 2.0.X - 1.23.X
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@ -17,11 +17,11 @@ jobs:
json-yaml-validate: json-yaml-validate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: json-yaml-validate - name: json-yaml-validate
id: json-yaml-validate id: json-yaml-validate
uses: GrantBirki/json-yaml-validate@v1.3.0 uses: GrantBirki/json-yaml-validate@v2.4.0
with: with:
comment: "true" # enable comment mode comment: "true" # enable comment mode
exclude_file: ".github/config/exclude.txt" # gitignore style file for exclusions exclude_file: ".github/config/exclude.txt" # gitignore style file for exclusions

@ -1,4 +1,4 @@
name: 'Automatically close stale issues and PRs' name: 'Automatically close stale issues'
on: on:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
@ -9,14 +9,14 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v7 - uses: actions/stale@v8
with: with:
stale-issue-message: 'We are clearing up our old issues and your ticket has been open for 3 months with no activity. Remove stale label or comment or this will be closed in 2 days.' stale-issue-message: 'We are clearing up our old issues and your ticket has been open for 3 months with no activity. Remove stale label or comment or this will be closed in 2 days.'
close-issue-message: 'This issue was closed because it has been stalled for 2 days with no activity.' close-issue-message: 'This issue was closed because it has been stalled for 2 days with no activity.'
days-before-stale: 90 days-before-stale: 90
days-before-close: 2 days-before-close: 2
days-before-pr-stale: 999999999 days-before-pr-stale: -1
days-before-pr-close: 1 days-before-pr-close: -1
exempt-issue-labels: 'News,Medium,High,discussion,bug,doc,feature-request' exempt-issue-labels: 'News,Medium,High,discussion,bug,doc,feature-request'
exempt-issue-assignees: 'louislam' exempt-issue-assignees: 'louislam'
operations-per-run: 200 operations-per-run: 200

@ -1,14 +1,14 @@
# Project Info # Project Info
First of all, I want to thank everyone who made pull requests for Uptime Kuma. I never thought the GitHub Community would be so nice! Because of this, I also never thought that other people would actually read and edit my code. It is not very well structured or commented, sorry about that. First of all, I want to thank everyone who have made pull requests for Uptime Kuma. I never thought the GitHub community would be so nice! Because of this, I also never thought that other people would actually read and edit my code. It is not very well structured or commented, sorry about that.
The project was created with vite.js (vue3). Then I created a subdirectory called "server" for the server part. Both frontend and backend share the same package.json. The project was created with vite.js (vue3). Then I created a subdirectory called "server" for the server part. Both frontend and backend share the same `package.json`.
The frontend code builds into "dist" directory. The server (express.js) exposes the "dist" directory as the root of the endpoint. This is how production is working. The frontend code builds into "dist" directory. The server (express.js) exposes the "dist" directory as the root of the endpoint. This is how production is working.
## Key Technical Skills ## Key Technical Skills
- Node.js (You should know about promise, async/await and arrow function etc.) - Node.js (You should know about promises, async/await, arrow functions, etc.)
- Socket.io - Socket.io
- SCSS - SCSS
- Vue.js - Vue.js
@ -62,7 +62,7 @@ Here are some references:
The above cases may not cover all possible situations. The above cases may not cover all possible situations.
I (@louislam) have the final say. If your pull request does not meet my expectations, I will reject it, no matter how much time you spend on it. Therefore, it is essential to have a discussion beforehand. I ([@louislam](https://github.com/louislam)) have the final say. If your pull request does not meet my expectations, I will reject it, no matter how much time you spent on it. Therefore, it is essential to have a discussion beforehand.
I will assign your pull request to a [milestone](https://github.com/louislam/uptime-kuma/milestones), if I plan to review and merge it. I will assign your pull request to a [milestone](https://github.com/louislam/uptime-kuma/milestones), if I plan to review and merge it.
@ -73,15 +73,14 @@ Also, please don't rush or ask for an ETA, because I have to understand the pull
Before deep into coding, discussion first is preferred. Creating an empty pull request for discussion would be recommended. Before deep into coding, discussion first is preferred. Creating an empty pull request for discussion would be recommended.
1. Fork the project 1. Fork the project
1. Clone your fork repo to local 2. Clone your fork repo to local
1. Create a new branch 3. Create a new branch
1. Create an empty commit 4. Create an empty commit: `git commit -m "<YOUR TASK NAME>" --allow-empty`
`git commit -m "[empty commit] pull request for <YOUR TASK NAME>" --allow-empty` 5. Push to your fork repo
1. Push to your fork repo 6. Prepare a pull request: https://github.com/louislam/uptime-kuma/compare
1. Create a pull request: https://github.com/louislam/uptime-kuma/compare 7. Write a proper description. You can mention @louislam in it, so @louislam will get the notification.
1. Write a proper description 8. Create your pull request as a Draft
1. Click "Change to draft" 9. Wait for the discussion
1. Discussion
## Project Styles ## Project Styles
@ -114,9 +113,9 @@ I personally do not like something that requires so many configurations before y
- IDE that supports [`ESLint`](https://eslint.org/) and EditorConfig (I am using [`IntelliJ IDEA`](https://www.jetbrains.com/idea/)) - IDE that supports [`ESLint`](https://eslint.org/) and EditorConfig (I am using [`IntelliJ IDEA`](https://www.jetbrains.com/idea/))
- A SQLite GUI tool (f.ex. [`SQLite Expert Personal`](https://www.sqliteexpert.com/download.html) or [`DBeaver Community`](https://dbeaver.io/download/)) - A SQLite GUI tool (f.ex. [`SQLite Expert Personal`](https://www.sqliteexpert.com/download.html) or [`DBeaver Community`](https://dbeaver.io/download/))
### GitHub Codespace ### GitHub Codespaces
If you don't want to setup an local environment, you can now develop on GitHub Codespace, read more: If you don't want to setup an local environment, you can now develop on GitHub Codespaces, read more:
https://github.com/louislam/uptime-kuma/tree/master/.devcontainer https://github.com/louislam/uptime-kuma/tree/master/.devcontainer
@ -231,9 +230,9 @@ If for security / bug / other reasons, a library must be updated, breaking chang
## Translations ## Translations
Please add **all** the strings which are translatable to `src/lang/en.json` (If translation keys are omitted, they can not be translated). Please add **all** the strings which are translatable to `src/lang/en.json` (if translation keys are omitted, they can not be translated.)
**Don't include any other languages in your initial Pull-Request** (even if this is your mother tongue), to avoid merge-conflicts between weblate and `master`. **Don't include any other languages in your initial pull request** (even if this is your mother tongue), to avoid merge-conflicts between weblate and `master`.
The translations can then (after merging a PR into `master`) be translated by awesome people donating their language skills. The translations can then (after merging a PR into `master`) be translated by awesome people donating their language skills.
If you want to help by translating Uptime Kuma into your language, please visit the [instructions on how to translate using weblate](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md). If you want to help by translating Uptime Kuma into your language, please visit the [instructions on how to translate using weblate](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md).
@ -245,7 +244,7 @@ My mother language is not English and my grammar is not that great.
## Wiki ## Wiki
Since there is no way to make a pull request to wiki's repo, I have set up another repo to do that. Since there is no way to make a pull request to the wiki, I have set up another repo to do that.
https://github.com/louislam/uptime-kuma-wiki https://github.com/louislam/uptime-kuma-wiki

@ -136,28 +136,33 @@ Telegram Notification Sample:
## Motivation ## Motivation
- I was looking for a self-hosted monitoring tool like "Uptime Robot", but it is hard to find a suitable one. One of the close ones is statping. Unfortunately, it is not stable and no longer maintained. - I was looking for a self-hosted monitoring tool like "Uptime Robot", but it is hard to find a suitable one. One of the closest ones is statping. Unfortunately, it is not stable and no longer maintained.
- Want to build a fancy UI. - Wanted to build a fancy UI.
- Learn Vue 3 and vite.js. - Learn Vue 3 and vite.js.
- Show the power of Bootstrap 5. - Show the power of Bootstrap 5.
- Try to use WebSocket with SPA instead of REST API. - Try to use WebSocket with SPA instead of a REST API.
- Deploy my first Docker image to Docker Hub. - Deploy my first Docker image to Docker Hub.
If you love this project, please consider giving me a ⭐. If you love this project, please consider giving it a ⭐.
## 🗣️ Discussion / Ask for Help ## 🗣️ Discussion / Ask for Help
⚠️ For any general or technical questions, please don't send me an email, as I am unable to provide support in that manner. I will not respond if you ask such questions. ⚠️ For any general or technical questions, please don't send me an email, as I am unable to provide support in that manner. I will not respond if you ask questions there.
I recommend using Google, GitHub Issues, or Uptime Kuma's Subreddit for finding answers to your question. If you cannot find the information you need, feel free to ask: I recommend using Google, GitHub Issues, or Uptime Kuma's subreddit for finding answers to your question. If you cannot find the information you need, feel free to ask:
- [GitHub Issues](https://github.com/louislam/uptime-kuma/issues) - [GitHub Issues](https://github.com/louislam/uptime-kuma/issues)
- [Subreddit r/Uptime kuma](https://www.reddit.com/r/UptimeKuma/) - [Subreddit (r/UptimeKuma)](https://www.reddit.com/r/UptimeKuma/)
My Reddit account: [u/louislamlam](https://reddit.com/u/louislamlam). My Reddit account: [u/louislamlam](https://reddit.com/u/louislamlam)
You can mention me if you ask a question on Reddit. You can mention me if you ask a question on the subreddit.
## Contribute ## Contributions
### Create Pull Requests
We DO NOT accept all types of pull requests and do not want to waste your time. Please be sure that you have read and follow pull request rules:
[CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma)
### Test Pull Requests ### Test Pull Requests
@ -181,8 +186,6 @@ If you want to translate Uptime Kuma into your language, please visit [Weblate R
### Spelling & Grammar ### Spelling & Grammar
Feel free to correct the grammar in the documentation or code. Feel free to correct the grammar in the documentation or code.
My mother language is not english and my grammar is not that great. My mother language is not English and my grammar is not that great.
### Create Pull Requests
If you want to modify Uptime Kuma, please read this guide and follow the rules here: https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md

@ -3,7 +3,7 @@
## Reporting a Vulnerability ## Reporting a Vulnerability
1. Please report security issues to https://github.com/louislam/uptime-kuma/security/advisories/new. 1. Please report security issues to https://github.com/louislam/uptime-kuma/security/advisories/new.
1. Please also create an empty security issue to alert me, as GitHub Advisories do not send a notification, I probably will miss it without this. https://github.com/louislam/uptime-kuma/issues/new?assignees=&labels=help&template=security.md 2. Please also create an empty security issue to alert me, as GitHub Advisories do not send a notification, I probably will miss it without this. https://github.com/louislam/uptime-kuma/issues/new?assignees=&labels=help&template=security.md
Do not use the public issue tracker or discuss it in public as it will cause more damage. Do not use the public issue tracker or discuss it in public as it will cause more damage.
@ -19,12 +19,12 @@ You should use or upgrade to the latest version of Uptime Kuma. All `1.X.X` vers
### Upgradable Docker Tags ### Upgradable Docker Tags
| Tag | Supported | | Tag | Supported |
| ------- | ------------------ | |-|-|
| 1 | :white_check_mark: | | 1 | :white_check_mark: |
| 1-debian | :white_check_mark: | | 1-debian | :white_check_mark: |
| latest | :white_check_mark: | | latest | :white_check_mark: |
| debian | :white_check_mark: | | debian | :white_check_mark: |
| 1-alpine | ⚠️ Deprecated | | 1-alpine | ⚠️ Deprecated |
| alpine | ⚠️ Deprecated | | alpine | ⚠️ Deprecated |
| All other tags | ❌ | | All other tags | ❌ |

@ -2,7 +2,6 @@ import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import visualizer from "rollup-plugin-visualizer"; import visualizer from "rollup-plugin-visualizer";
import viteCompression from "vite-plugin-compression"; import viteCompression from "vite-plugin-compression";
import commonjs from "vite-plugin-commonjs";
const postCssScss = require("postcss-scss"); const postCssScss = require("postcss-scss");
const postcssRTLCSS = require("postcss-rtlcss"); const postcssRTLCSS = require("postcss-rtlcss");
@ -21,7 +20,6 @@ export default defineConfig({
"CODESPACE_NAME": JSON.stringify(process.env.CODESPACE_NAME), "CODESPACE_NAME": JSON.stringify(process.env.CODESPACE_NAME),
}, },
plugins: [ plugins: [
commonjs(),
vue(), vue(),
visualizer({ visualizer({
filename: "tmp/dist-stats.html" filename: "tmp/dist-stats.html"

@ -493,8 +493,11 @@ ALTER TABLE monitor
await knex.schema.table("monitor", function (table) { await knex.schema.table("monitor", function (table) {
table.string("kafka_producer_topic", 255); table.string("kafka_producer_topic", 255);
table.text("kafka_producer_brokers"); table.text("kafka_producer_brokers");
table.integer("kafka_producer_ssl");
table.string("kafka_producer_allow_auto_topic_creation", 255); // patch-fix-kafka-producer-booleans.sql
table.boolean("kafka_producer_ssl").defaultTo(0).notNullable();
table.boolean("kafka_producer_allow_auto_topic_creation").defaultTo(0).notNullable();
table.text("kafka_producer_sasl_options"); table.text("kafka_producer_sasl_options");
table.text("kafka_producer_message"); table.text("kafka_producer_message");
}); });

@ -0,0 +1,15 @@
exports.up = function (knex) {
// Add new column heartbeat.retries
return knex.schema
.alterTable("heartbeat", function (table) {
table.integer("retries").notNullable().defaultTo(0);
});
};
exports.down = function (knex) {
return knex.schema
.alterTable("heartbeat", function (table) {
table.dropColumn("retries");
});
};

@ -0,0 +1,16 @@
exports.up = function (knex) {
// Add new column monitor.mqtt_check_type
return knex.schema
.alterTable("monitor", function (table) {
table.string("mqtt_check_type", 255).notNullable().defaultTo("keyword");
});
};
exports.down = function (knex) {
// Drop column monitor.mqtt_check_type
return knex.schema
.alterTable("monitor", function (table) {
table.dropColumn("mqtt_check_type");
});
};

@ -0,0 +1,21 @@
exports.up = function (knex) {
return knex.schema
.createTable("remote_browser", function (table) {
table.increments("id");
table.string("name", 255).notNullable();
table.string("url", 255).notNullable();
table.integer("user_id").unsigned();
}).alterTable("monitor", function (table) {
// Add new column monitor.remote_browser
table.integer("remote_browser").nullable().defaultTo(null).unsigned()
.index()
.references("id")
.inTable("remote_browser");
});
};
exports.down = function (knex) {
return knex.schema.dropTable("remote_browser").alterTable("monitor", function (table) {
table.dropColumn("remote_browser");
});
};

@ -0,0 +1,34 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
-- Rename COLUMNs to another one (suffixed by `_old`)
ALTER TABLE monitor
RENAME COLUMN kafka_producer_ssl TO kafka_producer_ssl_old;
ALTER TABLE monitor
RENAME COLUMN kafka_producer_allow_auto_topic_creation TO kafka_producer_allow_auto_topic_creation_old;
-- Add correct COLUMNs
ALTER TABLE monitor
ADD COLUMN kafka_producer_ssl BOOLEAN default 0 NOT NULL;
ALTER TABLE monitor
ADD COLUMN kafka_producer_allow_auto_topic_creation BOOLEAN default 0 NOT NULL;
-- These SQL is still not fully safe. See https://github.com/louislam/uptime-kuma/issues/4039.
-- Set bring old values from `_old` COLUMNs to correct ones
-- UPDATE monitor SET kafka_producer_allow_auto_topic_creation = monitor.kafka_producer_allow_auto_topic_creation_old
-- WHERE monitor.kafka_producer_allow_auto_topic_creation_old IS NOT NULL;
-- UPDATE monitor SET kafka_producer_ssl = monitor.kafka_producer_ssl_old
-- WHERE monitor.kafka_producer_ssl_old IS NOT NULL;
-- Remove old COLUMNs
ALTER TABLE monitor
DROP COLUMN kafka_producer_allow_auto_topic_creation_old;
ALTER TABLE monitor
DROP COLUMN kafka_producer_ssl_old;
COMMIT;

@ -0,0 +1,7 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
UPDATE monitor SET timeout = (interval * 0.8)
WHERE timeout IS NULL OR timeout <= 0;
COMMIT;

@ -42,13 +42,20 @@ HEALTHCHECK --interval=60s --timeout=30s --start-period=180s --retries=5 CMD ext
ENTRYPOINT ["/usr/bin/dumb-init", "--"] ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "server/server.js"] CMD ["node", "server/server.js"]
############################################
# Rootless Image
############################################
FROM release AS rootless
############################################ ############################################
# Mark as Nightly # Mark as Nightly
############################################ ############################################
FROM release AS nightly FROM release AS nightly
USER node
RUN npm run mark-as-nightly RUN npm run mark-as-nightly
FROM nightly AS nightly-rootless
USER node
############################################ ############################################
# Build an image for testing pr # Build an image for testing pr
############################################ ############################################

@ -5,7 +5,7 @@ const fs = require("fs");
* or the `recursive` property removing completely in the future Node.js version. * or the `recursive` property removing completely in the future Node.js version.
* See the link below. * See the link below.
* @todo Once we drop the support for Node.js v14 (or at least versions before v14.14.0), we can safely replace this function with `fs.rmSync`, since `fs.rmSync` was add in Node.js v14.14.0 and currently we supports all the Node.js v14 versions that include the versions before the v14.14.0, and this function have almost the same signature with `fs.rmSync`. * @todo Once we drop the support for Node.js v14 (or at least versions before v14.14.0), we can safely replace this function with `fs.rmSync`, since `fs.rmSync` was add in Node.js v14.14.0 and currently we supports all the Node.js v14 versions that include the versions before the v14.14.0, and this function have almost the same signature with `fs.rmSync`.
* @link https://nodejs.org/docs/latest-v16.x/api/deprecations.html#dep0147-fsrmdirpath--recursive-true- the deprecation infomation of `fs.rmdirSync` * @link https://nodejs.org/docs/latest-v16.x/api/deprecations.html#dep0147-fsrmdirpath--recursive-true- the deprecation information of `fs.rmdirSync`
* @link https://nodejs.org/docs/latest-v16.x/api/fs.html#fsrmsyncpath-options the document of `fs.rmSync` * @link https://nodejs.org/docs/latest-v16.x/api/fs.html#fsrmsyncpath-options the document of `fs.rmSync`
* @param {fs.PathLike} path Valid types for path values in "fs". * @param {fs.PathLike} path Valid types for path values in "fs".
* @param {fs.RmDirOptions} options options for `fs.rmdirSync`, if `fs.rmSync` is available and property `recursive` is true, it will automatically have property `force` with value `true`. * @param {fs.RmDirOptions} options options for `fs.rmdirSync`, if `fs.rmSync` is available and property `recursive` is true, it will automatically have property `force` with value `true`.

@ -6,7 +6,7 @@
* Deprecated: Changed to healthcheck.go, it will be deleted in the future. * Deprecated: Changed to healthcheck.go, it will be deleted in the future.
* This script should be run after a period of time (180s), because the server may need some time to prepare. * This script should be run after a period of time (180s), because the server may need some time to prepare.
*/ */
const { FBSD } = require("../server/util-server"); const FBSD = /^freebsd/.test(process.platform);
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";

@ -0,0 +1,44 @@
// Generate on GitHub
const input = `
* Add Korean translation by @Alanimdeo in https://github.com/louislam/dockge/pull/86
`;
const template = `
### 🆕 New Features
### 💇 Improvements
### 🐞 Bug Fixes
### Security Fixes
### 🦎 Translation Contributions
### Others
- Other small changes, code refactoring and comment/doc updates in this repo:
`;
const lines = input.split("\n").filter((line) => line.trim() !== "");
for (const line of lines) {
// Split the last " by "
const usernamePullRequesURL = line.split(" by ").pop();
if (!usernamePullRequesURL) {
console.log("Unable to parse", line);
continue;
}
const [ username, pullRequestURL ] = usernamePullRequesURL.split(" in ");
const pullRequestID = "#" + pullRequestURL.split("/").pop();
let message = line.split(" by ").shift();
if (!message) {
console.log("Unable to parse", line);
continue;
}
message = message.split("* ").pop();
console.log("-", pullRequestID, message, `(Thanks ${username})`);
}
console.log(template);

@ -5,6 +5,8 @@ const { R } = require("redbean-node");
const readline = require("readline"); const readline = require("readline");
const { initJWTSecret } = require("../server/util-server"); const { initJWTSecret } = require("../server/util-server");
const User = require("../server/model/user"); const User = require("../server/model/user");
const { io } = require("socket.io-client");
const { localWebSocketURL } = require("../server/config");
const args = require("args-parser")(process.argv); const args = require("args-parser")(process.argv);
const rl = readline.createInterface({ const rl = readline.createInterface({
input: process.stdin, input: process.stdin,
@ -12,6 +14,10 @@ const rl = readline.createInterface({
}); });
const main = async () => { const main = async () => {
if ("dry-run" in args) {
console.log("Dry run mode, no changes will be made.");
}
console.log("Connecting the database"); console.log("Connecting the database");
Database.initDataDir(args); Database.initDataDir(args);
await Database.connect(false, false, true); await Database.connect(false, false, true);
@ -27,21 +33,36 @@ const main = async () => {
console.log("Found user: " + user.username); console.log("Found user: " + user.username);
while (true) { while (true) {
let password = await question("New Password: "); let password;
let confirmPassword = await question("Confirm New Password: "); let confirmPassword;
// When called with "--new-password" argument for unattended modification (e.g. npm run reset-password -- --new_password=secret)
if ("new-password" in args) {
console.log("Using password from argument");
console.warn("\x1b[31m%s\x1b[0m", "Warning: the password might be stored, in plain text, in your shell's history");
password = confirmPassword = args["new-password"] + "";
} else {
password = await question("New Password: ");
confirmPassword = await question("Confirm New Password: ");
}
if (password === confirmPassword) { if (password === confirmPassword) {
await User.resetPassword(user.id, password); if (!("dry-run" in args)) {
await User.resetPassword(user.id, password);
// Reset all sessions by reset jwt secret // Reset all sessions by reset jwt secret
await initJWTSecret(); await initJWTSecret();
// Disconnect all other socket clients of the user
await disconnectAllSocketClients(user.username, password);
}
break; break;
} else { } else {
console.log("Passwords do not match, please try again."); console.log("Passwords do not match, please try again.");
} }
} }
console.log("Password reset successfully."); console.log("Password reset successfully.");
} }
} catch (e) { } catch (e) {
console.error("Error: " + e.message); console.error("Error: " + e.message);
@ -66,6 +87,51 @@ function question(question) {
}); });
} }
/**
* Disconnect all socket clients of the user
* @param {string} username Username
* @param {string} password Password
* @returns {Promise<void>} Promise
*/
function disconnectAllSocketClients(username, password) {
return new Promise((resolve) => {
console.log("Connecting to " + localWebSocketURL + " to disconnect all other socket clients");
// Disconnect all socket connections
const socket = io(localWebSocketURL, {
transports: [ "websocket" ],
reconnection: false,
timeout: 5000,
});
socket.on("connect", () => {
socket.emit("login", {
username,
password,
}, (res) => {
if (res.ok) {
console.log("Logged in.");
socket.emit("disconnectOtherSocketClients");
} else {
console.warn("Login failed.");
console.warn("Please restart the server to disconnect all sessions.");
}
socket.close();
});
});
socket.on("connect_error", function () {
// The localWebSocketURL is not guaranteed to be working for some complicated Uptime Kuma setup
// Ask the user to restart the server manually
console.warn("Failed to connect to " + localWebSocketURL);
console.warn("Please restart the server to disconnect all sessions manually.");
resolve();
});
socket.on("disconnect", () => {
resolve();
});
});
}
if (!process.env.TEST_BACKEND) { if (!process.env.TEST_BACKEND) {
main(); main();
} }

5408
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
{ {
"name": "uptime-kuma", "name": "uptime-kuma",
"version": "1.23.3", "version": "2.0.0-dev",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
@ -11,10 +11,12 @@
}, },
"scripts": { "scripts": {
"lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .", "lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .",
"lint:js-prod": "npm run lint:js -- --max-warnings 0",
"lint-fix:js": "eslint --ext \".js,.vue\" --fix --ignore-path .gitignore .", "lint-fix:js": "eslint --ext \".js,.vue\" --fix --ignore-path .gitignore .",
"lint:style": "stylelint \"**/*.{vue,css,scss}\" --ignore-path .gitignore", "lint:style": "stylelint \"**/*.{vue,css,scss}\" --ignore-path .gitignore",
"lint-fix:style": "stylelint \"**/*.{vue,css,scss}\" --fix --ignore-path .gitignore", "lint-fix:style": "stylelint \"**/*.{vue,css,scss}\" --fix --ignore-path .gitignore",
"lint": "npm run lint:js && npm run lint:style", "lint": "npm run lint:js && npm run lint:style",
"lint:prod": "npm run lint:js-prod && npm run lint:style",
"dev": "concurrently -k -r \"wait-on tcp:3000 && npm run start-server-dev \" \"npm run start-frontend-dev\"", "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-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-frontend-devcontainer": "cross-env NODE_ENV=development DEVCONTAINER=1 vite --host --config ./config/vite.config.js",
@ -38,10 +40,13 @@
"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-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-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": "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": "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-slim-rootless": "node ./extra/env2arg.js docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:2-slim-rootless -t louislam/uptime-kuma:$VERSION-slim-rootless --target rootless --build-arg BASE_IMAGE=louislam/uptime-kuma:base2-slim . --push",
"build-docker-full-rootless": "node ./extra/env2arg.js docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:2-rootless -t louislam/uptime-kuma:$VERSION-rootless --target rootless . --push",
"build-docker-nightly-rootless": "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-rootless --target nightly-rootless . --push",
"build-docker-nightly-local": "npm run build && docker build -f docker/dockerfile -t louislam/uptime-kuma:nightly2 --target nightly .", "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-test2 --target pr-test2 . --push", "build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test2 --target pr-test2 . --push",
"upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain", "upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain",
"setup": "git checkout 1.23.3 && npm ci --production && npm run download-dist", "setup": "git checkout 1.23.9 && npm ci --production && npm run download-dist",
"download-dist": "node extra/download-dist.js", "download-dist": "node extra/download-dist.js",
"mark-as-nightly": "node extra/mark-as-nightly.js", "mark-as-nightly": "node extra/mark-as-nightly.js",
"reset-password": "node extra/reset-password.js", "reset-password": "node extra/reset-password.js",
@ -49,6 +54,8 @@
"simple-dns-server": "node extra/simple-dns-server.js", "simple-dns-server": "node extra/simple-dns-server.js",
"simple-mqtt-server": "node extra/simple-mqtt-server.js", "simple-mqtt-server": "node extra/simple-mqtt-server.js",
"simple-mongo": "docker run --rm -p 27017:27017 mongo", "simple-mongo": "docker run --rm -p 27017:27017 mongo",
"simple-postgres": "docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres",
"simple-mariadb": "docker run --rm -p 3306:3306 -e MYSQL_ROOT_PASSWORD=mariadb# mariadb",
"update-language-files": "cd extra/update-language-files && node index.js && cross-env-shell eslint ../../src/languages/$npm_config_language.js --fix", "update-language-files": "cd extra/update-language-files && node index.js && cross-env-shell eslint ../../src/languages/$npm_config_language.js --fix",
"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-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", "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",
@ -64,7 +71,8 @@
"sort-contributors": "node extra/sort-contributors.js", "sort-contributors": "node extra/sort-contributors.js",
"quick-run-nightly": "docker run --rm --env NODE_ENV=development -p 3001:3001 louislam/uptime-kuma:nightly2", "quick-run-nightly": "docker run --rm --env NODE_ENV=development -p 3001:3001 louislam/uptime-kuma:nightly2",
"start-dev-container": "cd docker && docker-compose -f docker-compose-dev.yml up --force-recreate", "start-dev-container": "cd docker && docker-compose -f docker-compose-dev.yml up --force-recreate",
"rebase-pr-to-1.23.X": "node extra/rebase-pr.js 1.23.X" "rebase-pr-to-1.23.X": "node extra/rebase-pr.js 1.23.X",
"start-server-node14-win": "private\\node14\\node.exe server/server.js"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "~1.7.3", "@grpc/grpc-js": "~1.7.3",
@ -75,7 +83,6 @@
"axios-ntlm": "1.3.0", "axios-ntlm": "1.3.0",
"badge-maker": "~3.3.1", "badge-maker": "~3.3.1",
"bcryptjs": "~2.4.3", "bcryptjs": "~2.4.3",
"cacheable-lookup": "~6.0.4",
"chardet": "~1.4.0", "chardet": "~1.4.0",
"check-password-strength": "^2.0.5", "check-password-strength": "^2.0.5",
"cheerio": "~1.0.0-rc.12", "cheerio": "~1.0.0-rc.12",
@ -90,7 +97,9 @@
"express-basic-auth": "~1.2.1", "express-basic-auth": "~1.2.1",
"express-static-gzip": "~2.1.7", "express-static-gzip": "~2.1.7",
"form-data": "~4.0.0", "form-data": "~4.0.0",
"gamedig": "~4.1.0", "gamedig": "^4.2.0",
"http-cookie-agent": "~5.0.4",
"html-escaper": "^3.0.3",
"http-graceful-shutdown": "~3.1.7", "http-graceful-shutdown": "~3.1.7",
"http-proxy-agent": "~5.0.0", "http-proxy-agent": "~5.0.0",
"https-proxy-agent": "~5.0.1", "https-proxy-agent": "~5.0.1",
@ -108,7 +117,7 @@
"mongodb": "~4.17.1", "mongodb": "~4.17.1",
"mqtt": "~4.3.7", "mqtt": "~4.3.7",
"mssql": "~8.1.4", "mssql": "~8.1.4",
"mysql2": "~2.3.3", "mysql2": "~3.6.2",
"nanoid": "~3.3.4", "nanoid": "~3.3.4",
"node-cloudflared-tunnel": "~1.0.9", "node-cloudflared-tunnel": "~1.0.9",
"node-radius-client": "~1.0.0", "node-radius-client": "~1.0.0",
@ -117,11 +126,12 @@
"notp": "~2.0.3", "notp": "~2.0.3",
"openid-client": "^5.4.2", "openid-client": "^5.4.2",
"password-hash": "~1.2.2", "password-hash": "~1.2.2",
"pg": "~8.8.0", "pg": "~8.11.3",
"pg-connection-string": "~2.5.0", "pg-connection-string": "~2.6.2",
"playwright-core": "~1.35.1", "playwright-core": "~1.35.1",
"prom-client": "~13.2.0", "prom-client": "~13.2.0",
"prometheus-api-metrics": "~3.2.1", "prometheus-api-metrics": "~3.2.1",
"promisify-child-process": "~4.1.2",
"protobufjs": "~7.2.4", "protobufjs": "~7.2.4",
"qs": "~6.10.4", "qs": "~6.10.4",
"redbean-node": "~0.3.0", "redbean-node": "~0.3.0",
@ -133,6 +143,7 @@
"tar": "~6.1.11", "tar": "~6.1.11",
"tcp-ping": "~0.1.1", "tcp-ping": "~0.1.1",
"thirty-two": "~1.0.2", "thirty-two": "~1.0.2",
"tough-cookie": "~4.1.3",
"ws": "^8.13.0" "ws": "^8.13.0"
}, },
"devDependencies": { "devDependencies": {
@ -182,7 +193,6 @@
"typescript": "~4.4.4", "typescript": "~4.4.4",
"v-pagination-3": "~0.1.7", "v-pagination-3": "~0.1.7",
"vite": "~4.4.1", "vite": "~4.4.1",
"vite-plugin-commonjs": "^0.8.0",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vue": "~3.3.4", "vue": "~3.3.4",
"vue-chartjs": "~5.2.0", "vue-chartjs": "~5.2.0",
@ -196,7 +206,7 @@
"vue-router": "~4.0.14", "vue-router": "~4.0.14",
"vue-toastification": "~2.0.0-rc.5", "vue-toastification": "~2.0.0-rc.5",
"vuedraggable": "~4.1.0", "vuedraggable": "~4.1.0",
"wait-on": "^6.0.1", "wait-on": "^7.2.0",
"whatwg-url": "~12.0.1" "whatwg-url": "~12.0.1"
} }
} }

@ -1,10 +1,9 @@
<svg width="640" height="640" viewBox="0 0 640 640" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="640" height="640" viewBox="0 0 640 640" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<path d="M490.4 235.64C544.09 358.38 544.09 435.34 490.4 466.5C409.85 513.24 199.96 527.49 139.54 455.64C99.2601 407.74 99.2601 334.4 139.54 235.64C180.5 168.18 238.71 134.45 314.17 134.45C389.64 134.45 448.38 168.18 490.4 235.64Z" fill="url(#paint0_linear_381_799)"/> <g transform="matrix(1 0 0 1 320 320)">
<path d="M490.4 235.64C544.09 358.38 544.09 435.34 490.4 466.5C409.85 513.24 199.96 527.49 139.54 455.64C99.2601 407.74 99.2601 334.4 139.54 235.64C180.5 168.18 238.71 134.45 314.17 134.45C389.64 134.45 448.38 168.18 490.4 235.64Z" stroke="#F2F2F2" stroke-opacity="0.51" stroke-width="200"/> <linearGradient id="S3" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1 0 0 1 -319.99875 -320.0001577393)" x1="259.78" y1="261.15" x2="463.85" y2="456.49">
<defs>
<linearGradient id="paint0_linear_381_799" x1="259.78" y1="261.15" x2="463.85" y2="456.49" gradientUnits="userSpaceOnUse">
<stop stop-color="#5CDD8B"/> <stop stop-color="#5CDD8B"/>
<stop offset="1" stop-color="#86E6A9"/> <stop offset="1" stop-color="#86E6A9"/>
</linearGradient> </linearGradient>
</defs> <path style="stroke: rgb(242,242,242); stroke-opacity: 0.51; stroke-width: 200; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: url(#S3); fill-rule: nonzero; opacity: 1;" transform=" translate(0, 0)" d="M 170.40125 -84.36016 C 224.09125 38.37984 224.09125 115.33984 170.40125 146.49984 C 89.85125000000001 193.23984000000002 -120.03875 207.48984000000002 -180.45875 135.63984 C -220.73875 87.73983999999999 -220.73875 14.399839999999998 -180.45875 -84.36016000000001 C -139.49875 -151.82016 -81.28875000000001 -185.55016 -5.828750000000014 -185.55016 C 69.64124999999999 -185.55016 128.38125 -151.82016000000002 170.40124999999998 -84.36016000000001 z" stroke-linecap="round" />
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 893 B

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -1,88 +0,0 @@
const https = require("https");
const http = require("http");
const CacheableLookup = require("cacheable-lookup");
const { Settings } = require("./settings");
const { log } = require("../src/util");
class CacheableDnsHttpAgent {
static cacheable = new CacheableLookup();
static httpAgentList = {};
static httpsAgentList = {};
static enable = false;
/**
* Register/Disable cacheable to global agents
* @returns {void}
*/
static async update() {
log.debug("CacheableDnsHttpAgent", "update");
let isEnable = await Settings.get("dnsCache");
if (isEnable !== this.enable) {
log.debug("CacheableDnsHttpAgent", "value changed");
if (isEnable) {
log.debug("CacheableDnsHttpAgent", "enable");
this.cacheable.install(http.globalAgent);
this.cacheable.install(https.globalAgent);
} else {
log.debug("CacheableDnsHttpAgent", "disable");
this.cacheable.uninstall(http.globalAgent);
this.cacheable.uninstall(https.globalAgent);
}
}
this.enable = isEnable;
}
/**
* Attach cacheable to HTTP agent
* @param {http.Agent} agent Agent to install
* @returns {void}
*/
static install(agent) {
this.cacheable.install(agent);
}
/**
* @param {https.AgentOptions} agentOptions Options to pass to HTTPS agent
* @returns {https.Agent} The new HTTPS agent
*/
static getHttpsAgent(agentOptions) {
if (!this.enable) {
return new https.Agent(agentOptions);
}
let key = JSON.stringify(agentOptions);
if (!(key in this.httpsAgentList)) {
this.httpsAgentList[key] = new https.Agent(agentOptions);
this.cacheable.install(this.httpsAgentList[key]);
}
return this.httpsAgentList[key];
}
/**
* @param {http.AgentOptions} agentOptions Options to pass to the HTTP agent
* @returns {https.Agents} The new HTTP agent
*/
static getHttpAgent(agentOptions) {
if (!this.enable) {
return new http.Agent(agentOptions);
}
let key = JSON.stringify(agentOptions);
if (!(key in this.httpAgentList)) {
this.httpAgentList[key] = new http.Agent(agentOptions);
this.cacheable.install(this.httpAgentList[key]);
}
return this.httpAgentList[key];
}
}
module.exports = {
CacheableDnsHttpAgent,
};

@ -185,6 +185,30 @@ async function sendDockerHostList(socket) {
return list; return list;
} }
/**
* Send list of docker hosts to client
* @param {Socket} socket Socket.io socket instance
* @returns {Promise<Bean[]>} List of docker hosts
*/
async function sendRemoteBrowserList(socket) {
const timeLogger = new TimeLogger();
let result = [];
let list = await R.find("remote_browser", " user_id = ? ", [
socket.userID,
]);
for (let bean of list) {
result.push(bean.toJSON());
}
io.to(socket.userID).emit("remoteBrowserList", result);
timeLogger.print("Send Remote Browser List");
return list;
}
module.exports = { module.exports = {
sendNotificationList, sendNotificationList,
sendImportantHeartbeatList, sendImportantHeartbeatList,
@ -192,5 +216,6 @@ module.exports = {
sendProxyList, sendProxyList,
sendAPIKeyList, sendAPIKeyList,
sendInfo, sendInfo,
sendDockerHostList sendDockerHostList,
sendRemoteBrowserList,
}; };

@ -1,29 +1,46 @@
const isFreeBSD = /^freebsd/.test(process.platform);
// Interop with browser // Interop with browser
const args = (typeof process !== "undefined") ? require("args-parser")(process.argv) : {}; const args = (typeof process !== "undefined") ? require("args-parser")(process.argv) : {};
const demoMode = args["demo"] || false;
const badgeConstants = { // If host is omitted, the server will accept connections on the unspecified IPv6 address (::) when IPv6 is available and the unspecified IPv4 address (0.0.0.0) otherwise.
naColor: "#999", // Dual-stack support for (::)
defaultUpColor: "#66c20a", // Also read HOST if not FreeBSD, as HOST is a system environment variable in FreeBSD
defaultWarnColor: "#eed202", let hostEnv = isFreeBSD ? null : process.env.HOST;
defaultDownColor: "#c2290a", const hostname = args.host || process.env.UPTIME_KUMA_HOST || hostEnv;
defaultPendingColor: "#f8a306",
defaultMaintenanceColor: "#1747f5", const port = [ args.port, process.env.UPTIME_KUMA_PORT, process.env.PORT, 3001 ]
defaultPingColor: "blue", // as defined by badge-maker / shields.io .map(portValue => parseInt(portValue))
defaultStyle: "flat", .find(portValue => !isNaN(portValue));
defaultPingValueSuffix: "ms",
defaultPingLabelSuffix: "h", const sslKey = args["ssl-key"] || process.env.UPTIME_KUMA_SSL_KEY || process.env.SSL_KEY || undefined;
defaultUptimeValueSuffix: "%", const sslCert = args["ssl-cert"] || process.env.UPTIME_KUMA_SSL_CERT || process.env.SSL_CERT || undefined;
defaultUptimeLabelSuffix: "h", const sslKeyPassphrase = args["ssl-key-passphrase"] || process.env.UPTIME_KUMA_SSL_KEY_PASSPHRASE || process.env.SSL_KEY_PASSPHRASE || undefined;
defaultCertExpValueSuffix: " days",
defaultCertExpLabelSuffix: "h", const isSSL = sslKey && sslCert;
// Values Come From Default Notification Times
defaultCertExpireWarnDays: "14", /**
defaultCertExpireDownDays: "7" * Get the local WebSocket URL
}; * @returns {string} The local WebSocket URL
*/
function getLocalWebSocketURL() {
const protocol = isSSL ? "wss" : "ws";
const host = hostname || "localhost";
return `${protocol}://${host}:${port}`;
}
const localWebSocketURL = getLocalWebSocketURL();
const demoMode = args["demo"] || false;
module.exports = { module.exports = {
args, args,
hostname,
port,
sslKey,
sslCert,
sslKeyPassphrase,
isSSL,
localWebSocketURL,
demoMode, demoMode,
badgeConstants,
}; };

@ -103,7 +103,9 @@ class Database {
"patch-monitor-oauth-cc.sql": true, "patch-monitor-oauth-cc.sql": true,
"patch-add-timeout-monitor.sql": true, "patch-add-timeout-monitor.sql": true,
"patch-add-gamedig-given-port.sql": true, "patch-add-gamedig-given-port.sql": true,
"patch-notification-config.sql": true, // The last file so far converted to a knex migration file "patch-notification-config.sql": true,
"patch-fix-kafka-producer-booleans.sql": true,
"patch-timeout.sql": true, // The last file so far converted to a knex migration file
}; };
/** /**
@ -261,7 +263,14 @@ class Database {
user: dbConfig.username, user: dbConfig.username,
password: dbConfig.password, password: dbConfig.password,
database: dbConfig.dbName, database: dbConfig.dbName,
timezone: "+00:00", timezone: "Z",
typeCast: function (field, next) {
if (field.type === "DATETIME") {
// Do not perform timezone conversion
return field.string();
}
return next();
},
}, },
pool: mariadbPoolConfig, pool: mariadbPoolConfig,
}; };
@ -275,6 +284,14 @@ class Database {
socketPath: embeddedMariaDB.socketPath, socketPath: embeddedMariaDB.socketPath,
user: "node", user: "node",
database: "kuma", database: "kuma",
timezone: "Z",
typeCast: function (field, next) {
if (field.type === "DATETIME") {
// Do not perform timezone conversion
return field.string();
}
return next();
},
}, },
pool: mariadbPoolConfig, pool: mariadbPoolConfig,
}; };

@ -1,10 +1,10 @@
const axios = require("axios"); const axios = require("axios");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const version = require("../package.json").version;
const https = require("https"); const https = require("https");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const Database = require("./database"); const Database = require("./database");
const { axiosAbortSignal } = require("./util-server");
class DockerHost { class DockerHost {
@ -70,9 +70,11 @@ class DockerHost {
static async testDockerHost(dockerHost) { static async testDockerHost(dockerHost) {
const options = { const options = {
url: "/containers/json?all=true", url: "/containers/json?all=true",
timeout: 5000,
headers: { headers: {
"Accept": "*/*", "Accept": "*/*",
}, },
signal: axiosAbortSignal(6000),
}; };
if (dockerHost.dockerType === "socket") { if (dockerHost.dockerType === "socket") {
@ -82,26 +84,33 @@ class DockerHost {
options.httpsAgent = new https.Agent(DockerHost.getHttpsAgentOptions(dockerHost.dockerType, options.baseURL)); options.httpsAgent = new https.Agent(DockerHost.getHttpsAgentOptions(dockerHost.dockerType, options.baseURL));
} }
let res = await axios.request(options); try {
let res = await axios.request(options);
if (Array.isArray(res.data)) { if (Array.isArray(res.data)) {
if (res.data.length > 1) { if (res.data.length > 1) {
if ("ImageID" in res.data[0]) {
return res.data.length;
} else {
throw new Error("Invalid Docker response, is it Docker really a daemon?");
}
if ("ImageID" in res.data[0]) {
return res.data.length;
} else { } else {
throw new Error("Invalid Docker response, is it Docker really a daemon?"); return res.data.length;
} }
} else { } else {
return res.data.length; throw new Error("Invalid Docker response, is it Docker really a daemon?");
}
} catch (e) {
if (e.code === "ECONNABORTED" || e.name === "CanceledError") {
throw new Error("Connection to Docker daemon timed out.");
} else {
throw e;
} }
} else {
throw new Error("Invalid Docker response, is it Docker really a daemon?");
} }
} }
/** /**

@ -1,4 +1,5 @@
const jsesc = require("jsesc"); const jsesc = require("jsesc");
const { escape } = require("html-escaper");
/** /**
* Returns a string that represents the javascript that is required to insert the Google Analytics scripts * Returns a string that represents the javascript that is required to insert the Google Analytics scripts
@ -7,15 +8,18 @@ const jsesc = require("jsesc");
* @returns {string} HTML script tags to inject into page * @returns {string} HTML script tags to inject into page
*/ */
function getGoogleAnalyticsScript(tagId) { function getGoogleAnalyticsScript(tagId) {
let escapedTagId = jsesc(tagId, { isScriptContext: true }); let escapedTagIdJS = jsesc(tagId, { isScriptContext: true });
if (escapedTagId) { if (escapedTagIdJS) {
escapedTagId = escapedTagId.trim(); escapedTagIdJS = escapedTagIdJS.trim();
} }
// Escape the tag ID for use in an HTML attribute.
let escapedTagIdHTMLAttribute = escape(tagId);
return ` return `
<script async src="https://www.googletagmanager.com/gtag/js?id=${escapedTagId}"></script> <script async src="https://www.googletagmanager.com/gtag/js?id=${escapedTagIdHTMLAttribute}"></script>
<script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date());gtag('config', '${escapedTagId}'); </script> <script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date());gtag('config', '${escapedTagIdJS}'); </script>
`; `;
} }

@ -29,13 +29,14 @@ class Heartbeat extends BeanModel {
*/ */
toJSON() { toJSON() {
return { return {
monitorID: this.monitor_id, monitorID: this._monitorId,
status: this.status, status: this._status,
time: this.time, time: this._time,
msg: this.msg, msg: this._msg,
ping: this.ping, ping: this._ping,
important: this.important, important: this._important,
duration: this.duration, duration: this._duration,
retries: this._retries,
}; };
} }

@ -1,12 +1,11 @@
const https = require("https");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const axios = require("axios"); const axios = require("axios");
const { Prometheus } = require("../prometheus"); const { Prometheus } = require("../prometheus");
const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND,
SQL_DATETIME_FORMAT SQL_DATETIME_FORMAT
} = require("../../src/util"); } = require("../../src/util");
const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery, const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery,
redisPingAsync, mongodbPing, kafkaProducerAsync, getOidcTokenClientCredentials, axiosAbortSignal redisPingAsync, mongodbPing, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal
} = require("../util-server"); } = require("../util-server");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model"); const { BeanModel } = require("redbean-node/dist/bean-model");
@ -16,12 +15,18 @@ const { demoMode } = require("../config");
const version = require("../../package.json").version; const version = require("../../package.json").version;
const apicache = require("../modules/apicache"); const apicache = require("../modules/apicache");
const { UptimeKumaServer } = require("../uptime-kuma-server"); const { UptimeKumaServer } = require("../uptime-kuma-server");
const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent");
const { DockerHost } = require("../docker"); const { DockerHost } = require("../docker");
const Gamedig = require("gamedig"); const Gamedig = require("gamedig");
const jsonata = require("jsonata"); const jsonata = require("jsonata");
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const { UptimeCalculator } = require("../uptime-calculator"); const { UptimeCalculator } = require("../uptime-calculator");
const { CookieJar } = require("tough-cookie");
const { HttpsCookieAgent } = require("http-cookie-agent/http");
const https = require("https");
const http = require("http");
const rootCertificates = rootCertificatesFingerprints();
/** /**
* status: * status:
@ -130,6 +135,7 @@ class Monitor extends BeanModel {
maintenance: await Monitor.isUnderMaintenance(this.id), maintenance: await Monitor.isUnderMaintenance(this.id),
mqttTopic: this.mqttTopic, mqttTopic: this.mqttTopic,
mqttSuccessMessage: this.mqttSuccessMessage, mqttSuccessMessage: this.mqttSuccessMessage,
mqttCheckType: this.mqttCheckType,
databaseQuery: this.databaseQuery, databaseQuery: this.databaseQuery,
authMethod: this.authMethod, authMethod: this.authMethod,
grpcUrl: this.grpcUrl, grpcUrl: this.grpcUrl,
@ -146,10 +152,11 @@ class Monitor extends BeanModel {
expectedValue: this.expectedValue, expectedValue: this.expectedValue,
kafkaProducerTopic: this.kafkaProducerTopic, kafkaProducerTopic: this.kafkaProducerTopic,
kafkaProducerBrokers: JSON.parse(this.kafkaProducerBrokers), kafkaProducerBrokers: JSON.parse(this.kafkaProducerBrokers),
kafkaProducerSsl: this.kafkaProducerSsl === "1" && true || false, kafkaProducerSsl: this.getKafkaProducerSsl(),
kafkaProducerAllowAutoTopicCreation: this.kafkaProducerAllowAutoTopicCreation === "1" && true || false, kafkaProducerAllowAutoTopicCreation: this.getKafkaProducerAllowAutoTopicCreation(),
kafkaProducerMessage: this.kafkaProducerMessage, kafkaProducerMessage: this.kafkaProducerMessage,
screenshot, screenshot,
remote_browser: this.remote_browser,
}; };
if (includeSensitiveData) { if (includeSensitiveData) {
@ -298,6 +305,22 @@ class Monitor extends BeanModel {
return Boolean(this.gamedigGivenPortOnly); return Boolean(this.gamedigGivenPortOnly);
} }
/**
* Parse to boolean
* @returns {boolean} Kafka Producer Ssl enabled?
*/
getKafkaProducerSsl() {
return Boolean(this.kafkaProducerSsl);
}
/**
* Parse to boolean
* @returns {boolean} Kafka Producer Allow Auto Topic Creation Enabled?
*/
getKafkaProducerAllowAutoTopicCreation() {
return Boolean(this.kafkaProducerAllowAutoTopicCreation);
}
/** /**
* Start monitor * Start monitor
* @param {Server} io Socket server instance * @param {Server} io Socket server instance
@ -332,6 +355,9 @@ class Monitor extends BeanModel {
previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [ previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [
this.id, this.id,
]); ]);
if (previousBeat) {
retries = previousBeat.retries;
}
} }
const isFirstBeat = !previousBeat; const isFirstBeat = !previousBeat;
@ -346,6 +372,12 @@ class Monitor extends BeanModel {
bean.status = flipStatus(bean.status); bean.status = flipStatus(bean.status);
} }
// Runtime patch timeout if it is 0
// See https://github.com/louislam/uptime-kuma/pull/3961#issuecomment-1804149144
if (!this.timeout || this.timeout <= 0) {
this.timeout = this.interval * 1000 * 0.8;
}
try { try {
if (await Monitor.isUnderMaintenance(this.id)) { if (await Monitor.isUnderMaintenance(this.id)) {
bean.msg = "Monitor under maintenance"; bean.msg = "Monitor under maintenance";
@ -416,6 +448,7 @@ class Monitor extends BeanModel {
const httpsAgentOptions = { const httpsAgentOptions = {
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
rejectUnauthorized: !this.getIgnoreTls(), rejectUnauthorized: !this.getIgnoreTls(),
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
}; };
log.debug("monitor", `[${this.name}] Prepare Options for axios`); log.debug("monitor", `[${this.name}] Prepare Options for axios`);
@ -456,7 +489,7 @@ class Monitor extends BeanModel {
validateStatus: (status) => { validateStatus: (status) => {
return checkStatusCode(status, this.getAcceptedStatuscodes()); return checkStatusCode(status, this.getAcceptedStatuscodes());
}, },
signal: axiosAbortSignal(this.timeout * 1000), signal: axiosAbortSignal((this.timeout + 10) * 1000),
}; };
if (bodyValue) { if (bodyValue) {
@ -478,7 +511,12 @@ class Monitor extends BeanModel {
} }
if (!options.httpsAgent) { if (!options.httpsAgent) {
options.httpsAgent = new https.Agent(httpsAgentOptions); let jar = new CookieJar();
let httpsCookieAgentOptions = {
...httpsAgentOptions,
cookies: { jar }
};
options.httpsAgent = new HttpsCookieAgent(httpsCookieAgentOptions);
} }
if (this.auth_method === "mtls") { if (this.auth_method === "mtls") {
@ -596,6 +634,7 @@ class Monitor extends BeanModel {
// If the previous beat was down or pending we use the regular // If the previous beat was down or pending we use the regular
// beatInterval/retryInterval in the setTimeout further below // beatInterval/retryInterval in the setTimeout further below
if (previousBeat.status !== (this.isUpsideDown() ? DOWN : UP) || msSinceLastBeat > beatInterval * 1000 + bufferTime) { if (previousBeat.status !== (this.isUpsideDown() ? DOWN : UP) || msSinceLastBeat > beatInterval * 1000 + bufferTime) {
bean.duration = Math.round(msSinceLastBeat / 1000);
throw new Error("No heartbeat in the time window"); throw new Error("No heartbeat in the time window");
} else { } else {
let timeout = beatInterval * 1000 - msSinceLastBeat; let timeout = beatInterval * 1000 - msSinceLastBeat;
@ -611,6 +650,7 @@ class Monitor extends BeanModel {
return; return;
} }
} else { } else {
bean.duration = beatInterval;
throw new Error("No heartbeat in the time window"); throw new Error("No heartbeat in the time window");
} }
@ -628,11 +668,12 @@ class Monitor extends BeanModel {
headers: { headers: {
"Accept": "*/*", "Accept": "*/*",
}, },
httpsAgent: CacheableDnsHttpAgent.getHttpsAgent({ httpsAgent: new https.Agent({
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
rejectUnauthorized: !this.getIgnoreTls(), rejectUnauthorized: !this.getIgnoreTls(),
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
}), }),
httpAgent: CacheableDnsHttpAgent.getHttpAgent({ httpAgent: new http.Agent({
maxCachedSessions: 0, maxCachedSessions: 0,
}), }),
maxRedirects: this.maxredirects, maxRedirects: this.maxredirects,
@ -679,11 +720,12 @@ class Monitor extends BeanModel {
headers: { headers: {
"Accept": "*/*", "Accept": "*/*",
}, },
httpsAgent: CacheableDnsHttpAgent.getHttpsAgent({ httpsAgent: new https.Agent({
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
rejectUnauthorized: !this.getIgnoreTls(), rejectUnauthorized: !this.getIgnoreTls(),
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
}), }),
httpAgent: CacheableDnsHttpAgent.getHttpAgent({ httpAgent: new http.Agent({
maxCachedSessions: 0, maxCachedSessions: 0,
}), }),
}; };
@ -698,7 +740,7 @@ class Monitor extends BeanModel {
options.socketPath = dockerHost._dockerDaemon; options.socketPath = dockerHost._dockerDaemon;
} else if (dockerHost._dockerType === "tcp") { } else if (dockerHost._dockerType === "tcp") {
options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon); options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon);
options.httpsAgent = CacheableDnsHttpAgent.getHttpsAgent( options.httpsAgent = new https.Agent(
DockerHost.getHttpsAgentOptions(dockerHost._dockerType, options.baseURL) DockerHost.getHttpsAgentOptions(dockerHost._dockerType, options.baseURL)
); );
} }
@ -717,18 +759,10 @@ class Monitor extends BeanModel {
} else { } else {
throw Error("Container State is " + res.data.State.Status); throw Error("Container State is " + res.data.State.Status);
} }
} else if (this.type === "mqtt") {
bean.msg = await mqttAsync(this.hostname, this.mqttTopic, this.mqttSuccessMessage, {
port: this.port,
username: this.mqttUsername,
password: this.mqttPassword,
interval: this.interval,
});
bean.status = UP;
} else if (this.type === "sqlserver") { } else if (this.type === "sqlserver") {
let startTime = dayjs().valueOf(); let startTime = dayjs().valueOf();
await mssqlQuery(this.databaseConnectionString, this.databaseQuery); await mssqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1");
bean.msg = ""; bean.msg = "";
bean.status = UP; bean.status = UP;
@ -767,7 +801,7 @@ class Monitor extends BeanModel {
} else if (this.type === "postgres") { } else if (this.type === "postgres") {
let startTime = dayjs().valueOf(); let startTime = dayjs().valueOf();
await postgresQuery(this.databaseConnectionString, this.databaseQuery); await postgresQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1");
bean.msg = ""; bean.msg = "";
bean.status = UP; bean.status = UP;
@ -775,7 +809,11 @@ class Monitor extends BeanModel {
} else if (this.type === "mysql") { } else if (this.type === "mysql") {
let startTime = dayjs().valueOf(); let startTime = dayjs().valueOf();
bean.msg = await mysqlQuery(this.databaseConnectionString, this.databaseQuery); // Use `radius_password` as `password` field, since there are too many unnecessary fields
// TODO: rename `radius_password` to `password` later for general use
let mysqlPassword = this.radiusPassword;
bean.msg = await mysqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1", mysqlPassword);
bean.status = UP; bean.status = UP;
bean.ping = dayjs().valueOf() - startTime; bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "mongodb") { } else if (this.type === "mongodb") {
@ -863,7 +901,11 @@ class Monitor extends BeanModel {
} catch (error) { } catch (error) {
bean.msg = error.message; if (error?.name === "CanceledError") {
bean.msg = `timeout by AbortSignal (${this.timeout}s)`;
} else {
bean.msg = error.message;
}
// If UP come in here, it must be upside down mode // If UP come in here, it must be upside down mode
// Just reset the retries // Just reset the retries
@ -873,9 +915,14 @@ class Monitor extends BeanModel {
} else if ((this.maxretries > 0) && (retries < this.maxretries)) { } else if ((this.maxretries > 0) && (retries < this.maxretries)) {
retries++; retries++;
bean.status = PENDING; bean.status = PENDING;
} else {
// Continue counting retries during DOWN
retries++;
} }
} }
bean.retries = retries;
log.debug("monitor", `[${this.name}] Check isImportant`); log.debug("monitor", `[${this.name}] Check isImportant`);
let isImportant = Monitor.isImportantBeat(isFirstBeat, previousBeat?.status, bean.status); let isImportant = Monitor.isImportantBeat(isFirstBeat, previousBeat?.status, bean.status);
@ -1320,7 +1367,10 @@ class Monitor extends BeanModel {
let certInfo = tlsInfoObject.certInfo; let certInfo = tlsInfoObject.certInfo;
while (certInfo) { while (certInfo) {
let subjectCN = certInfo.subject["CN"]; let subjectCN = certInfo.subject["CN"];
if (certInfo.daysRemaining > targetDays) { if (rootCertificates.has(certInfo.fingerprint256)) {
log.debug("monitor", `Known root cert: ${certInfo.certType} certificate "${subjectCN}" (${certInfo.daysRemaining} days valid) on ${targetDays} deadline.`);
break;
} else if (certInfo.daysRemaining > targetDays) {
log.debug("monitor", `No need to send cert notification for ${certInfo.certType} certificate "${subjectCN}" (${certInfo.daysRemaining} days valid) on ${targetDays} deadline.`); log.debug("monitor", `No need to send cert notification for ${certInfo.certType} certificate "${subjectCN}" (${certInfo.daysRemaining} days valid) on ${targetDays} deadline.`);
} else { } else {
log.debug("monitor", `call sendCertNotificationByTargetDays for ${targetDays} deadline on certificate ${subjectCN}.`); log.debug("monitor", `call sendCertNotificationByTargetDays for ${targetDays} deadline on certificate ${subjectCN}.`);
@ -1386,10 +1436,7 @@ class Monitor extends BeanModel {
* @returns {Promise<LooseObject<any>>} Previous heartbeat * @returns {Promise<LooseObject<any>>} Previous heartbeat
*/ */
static async getPreviousHeartbeat(monitorID) { static async getPreviousHeartbeat(monitorID) {
return await R.getRow(` return await R.findOne("heartbeat", " id = (select MAX(id) from heartbeat where monitor_id = ?)", [
SELECT ping, status, time FROM heartbeat
WHERE id = (select MAX(id) from heartbeat where monitor_id = ?)
`, [
monitorID monitorID
]); ]);
} }

@ -0,0 +1,17 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
class RemoteBrowser extends BeanModel {
/**
* Returns an object that ready to parse to JSON
* @returns {object} Object ready to parse
*/
toJSON() {
return {
id: this.id,
url: this.url,
name: this.name,
};
}
}
module.exports = RemoteBrowser;

@ -21,6 +21,12 @@ class StatusPage extends BeanModel {
* @returns {void} * @returns {void}
*/ */
static async handleStatusPageResponse(response, indexHTML, slug) { static async handleStatusPageResponse(response, indexHTML, slug) {
// Handle url with trailing slash (http://localhost:3001/status/)
// The slug comes from the route "/status/:slug". If the slug is empty, express converts it to "index.html"
if (slug === "index.html") {
slug = "default";
}
let statusPage = await R.findOne("status_page", " slug = ? ", [ let statusPage = await R.findOne("status_page", " slug = ? ", [
slug slug
]); ]);

@ -0,0 +1,121 @@
const { MonitorType } = require("./monitor-type");
const { log, UP } = require("../../src/util");
const mqtt = require("mqtt");
const jsonata = require("jsonata");
class MqttMonitorType extends MonitorType {
name = "mqtt";
/**
* Run the monitoring check on the MQTT monitor
* @param {Monitor} monitor Monitor to check
* @param {Heartbeat} heartbeat Monitor heartbeat to update
* @param {UptimeKumaServer} server Uptime Kuma server
* @returns {Promise<void>}
*/
async check(monitor, heartbeat, server) {
const receivedMessage = await this.mqttAsync(monitor.hostname, monitor.mqttTopic, {
port: monitor.port,
username: monitor.mqttUsername,
password: monitor.mqttPassword,
interval: monitor.interval,
});
if (monitor.mqttCheckType == null || monitor.mqttCheckType === "") {
// use old default
monitor.mqttCheckType = "keyword";
}
if (monitor.mqttCheckType === "keyword") {
if (receivedMessage != null && receivedMessage.includes(monitor.mqttSuccessMessage)) {
heartbeat.msg = `Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`;
heartbeat.status = UP;
} else {
throw Error(`Message Mismatch - Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`);
}
} else if (monitor.mqttCheckType === "json-query") {
const parsedMessage = JSON.parse(receivedMessage);
let expression = jsonata(monitor.jsonPath);
let result = await expression.evaluate(parsedMessage);
if (result?.toString() === monitor.expectedValue) {
heartbeat.msg = "Message received, expected value is found";
heartbeat.status = UP;
} else {
throw new Error("Message received but value is not equal to expected value, value was: [" + result + "]");
}
} else {
throw Error("Unknown MQTT Check Type");
}
}
/**
* Connect to MQTT Broker, subscribe to topic and receive message as String
* @param {string} hostname Hostname / address of machine to test
* @param {string} topic MQTT topic
* @param {object} options MQTT options. Contains port, username,
* password and interval (interval defaults to 20)
* @returns {Promise<string>} Received MQTT message
*/
mqttAsync(hostname, topic, options = {}) {
return new Promise((resolve, reject) => {
const { port, username, password, interval = 20 } = options;
// Adds MQTT protocol to the hostname if not already present
if (!/^(?:http|mqtt|ws)s?:\/\//.test(hostname)) {
hostname = "mqtt://" + hostname;
}
const timeoutID = setTimeout(() => {
log.debug("mqtt", "MQTT timeout triggered");
client.end();
reject(new Error("Timeout, Message not received"));
}, interval * 1000 * 0.8);
const mqttUrl = `${hostname}:${port}`;
log.debug("mqtt", `MQTT connecting to ${mqttUrl}`);
let client = mqtt.connect(mqttUrl, {
username,
password
});
client.on("connect", () => {
log.debug("mqtt", "MQTT connected");
try {
client.subscribe(topic, () => {
log.debug("mqtt", "MQTT subscribed to topic");
});
} catch (e) {
client.end();
clearTimeout(timeoutID);
reject(new Error("Cannot subscribe topic"));
}
});
client.on("error", (error) => {
client.end();
clearTimeout(timeoutID);
reject(error);
});
client.on("message", (messageTopic, message) => {
if (messageTopic === topic) {
client.end();
clearTimeout(timeoutID);
resolve(message.toString("utf8"));
}
});
});
}
}
module.exports = {
MqttMonitorType,
};

@ -8,6 +8,7 @@ const path = require("path");
const Database = require("../database"); const Database = require("../database");
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const config = require("../config"); const config = require("../config");
const { RemoteBrowser } = require("../remote-browser");
let browser = null; let browser = null;
@ -43,6 +44,7 @@ if (process.platform === "win32") {
"/usr/bin/chromium", "/usr/bin/chromium",
"/usr/bin/chromium-browser", "/usr/bin/chromium-browser",
"/usr/bin/google-chrome", "/usr/bin/google-chrome",
"/snap/bin/chromium", // Ubuntu
]; ];
} else if (process.platform === "darwin") { } else if (process.platform === "darwin") {
allowedList = [ allowedList = [
@ -85,6 +87,19 @@ async function getBrowser() {
return browser; return browser;
} }
/**
* Get the current instance of the browser. If there isn't one, create it
* @param {integer} remoteBrowserID Path to executable
* @param {integer} userId User ID
* @returns {Promise<Browser>} The browser
*/
async function getRemoteBrowser(remoteBrowserID, userId) {
let remoteBrowser = await RemoteBrowser.get(remoteBrowserID, userId);
log.debug("MONITOR", `Using remote browser: ${remoteBrowser.name} (${remoteBrowser.id})`);
browser = chromium.connect(remoteBrowser.url);
return browser;
}
/** /**
* Prepare the chrome executable path * Prepare the chrome executable path
* @param {string} executablePath Path to chrome executable * @param {string} executablePath Path to chrome executable
@ -191,11 +206,21 @@ async function testChrome(executablePath) {
throw new Error(e.message); throw new Error(e.message);
} }
} }
// test remote browser
/** /**
* TODO: connect remote browser? https://playwright.dev/docs/api/class-browsertype#browser-type-connect * @param {string} remoteBrowserURL Remote Browser URL
* * @returns {Promise<boolean>} Returns if connection worked
*/ */
async function testRemoteBrowser(remoteBrowserURL) {
try {
const browser = await chromium.connect(remoteBrowserURL);
browser.version();
await browser.close();
return true;
} catch (e) {
throw new Error(e.message);
}
}
class RealBrowserMonitorType extends MonitorType { class RealBrowserMonitorType extends MonitorType {
name = "real-browser"; name = "real-browser";
@ -204,7 +229,7 @@ class RealBrowserMonitorType extends MonitorType {
* @inheritdoc * @inheritdoc
*/ */
async check(monitor, heartbeat, server) { async check(monitor, heartbeat, server) {
const browser = await getBrowser(); const browser = monitor.remote_browser ? await getRemoteBrowser(monitor.remote_browser, monitor.user_id) : await getBrowser();
const context = await browser.newContext(); const context = await browser.newContext();
const page = await context.newPage(); const page = await context.newPage();
@ -237,4 +262,5 @@ module.exports = {
RealBrowserMonitorType, RealBrowserMonitorType,
testChrome, testChrome,
resetChrome, resetChrome,
testRemoteBrowser,
}; };

@ -1,6 +1,6 @@
const { MonitorType } = require("./monitor-type"); const { MonitorType } = require("./monitor-type");
const { UP, log } = require("../../src/util"); const { UP } = require("../../src/util");
const exec = require("child_process").exec; const childProcessAsync = require("promisify-child-process");
/** /**
* A TailscalePing class extends the MonitorType. * A TailscalePing class extends the MonitorType.
@ -23,7 +23,6 @@ class TailscalePing extends MonitorType {
let tailscaleOutput = await this.runTailscalePing(monitor.hostname, monitor.interval); let tailscaleOutput = await this.runTailscalePing(monitor.hostname, monitor.interval);
this.parseTailscaleOutput(tailscaleOutput, heartbeat); this.parseTailscaleOutput(tailscaleOutput, heartbeat);
} catch (err) { } catch (err) {
log.debug("Tailscale", err);
// trigger log function somewhere to display a notification or alert to the user (but how?) // trigger log function somewhere to display a notification or alert to the user (but how?)
throw new Error(`Error checking Tailscale ping: ${err}`); throw new Error(`Error checking Tailscale ping: ${err}`);
} }
@ -37,26 +36,18 @@ class TailscalePing extends MonitorType {
* @throws Will throw an error if the command execution encounters any error. * @throws Will throw an error if the command execution encounters any error.
*/ */
async runTailscalePing(hostname, interval) { async runTailscalePing(hostname, interval) {
let cmd = `tailscale ping ${hostname}`; let timeout = interval * 1000 * 0.8;
let res = await childProcessAsync.spawn("tailscale", [ "ping", "--c", "1", hostname ], {
log.debug("Tailscale", cmd); timeout: timeout
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);
});
}); });
if (res.stderr && res.stderr.toString()) {
throw new Error(`Error in output: ${res.stderr.toString()}`);
}
if (res.stdout && res.stdout.toString()) {
return res.stdout.toString();
} else {
throw new Error("No output from Tailscale ping");
}
} }
/** /**
@ -74,7 +65,7 @@ class TailscalePing extends MonitorType {
heartbeat.status = UP; heartbeat.status = UP;
let time = line.split(" in ")[1].split(" ")[0]; let time = line.split(" in ")[1].split(" ")[0];
heartbeat.ping = parseInt(time); heartbeat.ping = parseInt(time);
heartbeat.msg = line; heartbeat.msg = "OK";
break; break;
} else if (line.includes("timed out")) { } else if (line.includes("timed out")) {
throw new Error(`Ping timed out: "${line}"`); throw new Error(`Ping timed out: "${line}"`);

@ -1,5 +1,5 @@
const NotificationProvider = require("./notification-provider"); const NotificationProvider = require("./notification-provider");
const childProcess = require("child_process"); const childProcessAsync = require("promisify-child-process");
class Apprise extends NotificationProvider { class Apprise extends NotificationProvider {
@ -14,7 +14,7 @@ class Apprise extends NotificationProvider {
args.push("-t"); args.push("-t");
args.push(notification.title); args.push(notification.title);
} }
const s = childProcess.spawnSync("apprise", args); const s = await childProcessAsync.spawn("apprise", args);
const output = (s.stdout) ? s.stdout.toString() : "ERROR: maybe apprise not found"; const output = (s.stdout) ? s.stdout.toString() : "ERROR: maybe apprise not found";

@ -78,12 +78,12 @@ class Mattermost extends NotificationProvider {
{ {
fallback: fallback:
"Your " + "Your " +
monitorJSON.name + monitorJSON.pathName +
" service went " + " service went " +
statusText, statusText,
color: color, color: color,
title: title:
monitorJSON.name + monitorJSON.pathName +
" service went " + " service went " +
statusText, statusText,
title_link: monitorJSON.url, title_link: monitorJSON.url,

@ -4,6 +4,8 @@ const HttpsProxyAgent = require("https-proxy-agent");
const SocksProxyAgent = require("socks-proxy-agent"); const SocksProxyAgent = require("socks-proxy-agent");
const { debug } = require("../src/util"); const { debug } = require("../src/util");
const { UptimeKumaServer } = require("./uptime-kuma-server"); const { UptimeKumaServer } = require("./uptime-kuma-server");
const { CookieJar } = require("tough-cookie");
const { createCookieAgent } = require("http-cookie-agent/http");
class Proxy { class Proxy {
@ -95,10 +97,13 @@ class Proxy {
let httpAgent; let httpAgent;
let httpsAgent; let httpsAgent;
let jar = new CookieJar();
const proxyOptions = { const proxyOptions = {
protocol: proxy.protocol, protocol: proxy.protocol,
host: proxy.host, host: proxy.host,
port: proxy.port, port: proxy.port,
cookies: { jar },
}; };
if (proxy.auth) { if (proxy.auth) {
@ -112,12 +117,17 @@ class Proxy {
switch (proxy.protocol) { switch (proxy.protocol) {
case "http": case "http":
case "https": case "https":
httpAgent = new HttpProxyAgent({ // eslint-disable-next-line no-case-declarations
const HttpCookieProxyAgent = createCookieAgent(HttpProxyAgent);
// eslint-disable-next-line no-case-declarations
const HttpsCookieProxyAgent = createCookieAgent(HttpsProxyAgent);
httpAgent = new HttpCookieProxyAgent({
...httpAgentOptions || {}, ...httpAgentOptions || {},
...proxyOptions ...proxyOptions,
}); });
httpsAgent = new HttpsProxyAgent({ httpsAgent = new HttpsCookieProxyAgent({
...httpsAgentOptions || {}, ...httpsAgentOptions || {},
...proxyOptions, ...proxyOptions,
}); });
@ -126,7 +136,9 @@ class Proxy {
case "socks5": case "socks5":
case "socks5h": case "socks5h":
case "socks4": case "socks4":
agent = new SocksProxyAgent({ // eslint-disable-next-line no-case-declarations
const SocksCookieProxyAgent = createCookieAgent(SocksProxyAgent);
agent = new SocksCookieProxyAgent({
...httpAgentOptions, ...httpAgentOptions,
...httpsAgentOptions, ...httpsAgentOptions,
...proxyOptions, ...proxyOptions,

@ -0,0 +1,74 @@
const { R } = require("redbean-node");
class RemoteBrowser {
/**
* Gets remote browser from ID
* @param {number} remoteBrowserID ID of the remote browser
* @param {number} userID ID of the user who created the remote browser
* @returns {Promise<Bean>} Remote Browser
*/
static async get(remoteBrowserID, userID) {
let bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]);
if (!bean) {
throw new Error("Remote browser not found");
}
return bean;
}
/**
* Save a Remote Browser
* @param {object} remoteBrowser Remote Browser to save
* @param {?number} remoteBrowserID ID of the Remote Browser to update
* @param {number} userID ID of the user who adds the Remote Browser
* @returns {Promise<Bean>} Updated Remote Browser
*/
static async save(remoteBrowser, remoteBrowserID, userID) {
let bean;
if (remoteBrowserID) {
bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]);
if (!bean) {
throw new Error("Remote browser not found");
}
} else {
bean = R.dispense("remote_browser");
}
bean.user_id = userID;
bean.name = remoteBrowser.name;
bean.url = remoteBrowser.url;
await R.store(bean);
return bean;
}
/**
* Delete a Remote Browser
* @param {number} remoteBrowserID ID of the Remote Browser to delete
* @param {number} userID ID of the user who created the Remote Browser
* @returns {Promise<void>}
*/
static async delete(remoteBrowserID, userID) {
let bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]);
if (!bean) {
throw new Error("Remote Browser not found");
}
// Delete removed remote browser from monitors if exists
await R.exec("UPDATE monitor SET remote_browser = null WHERE remote_browser = ?", [ remoteBrowserID ]);
await R.trash(bean);
}
}
module.exports = {
RemoteBrowser,
};

@ -11,11 +11,10 @@ const { R } = require("redbean-node");
const apicache = require("../modules/apicache"); const apicache = require("../modules/apicache");
const Monitor = require("../model/monitor"); const Monitor = require("../model/monitor");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const { UP, MAINTENANCE, DOWN, PENDING, flipStatus, log } = require("../../src/util"); const { UP, MAINTENANCE, DOWN, PENDING, flipStatus, log, badgeConstants } = require("../../src/util");
const StatusPage = require("../model/status_page"); const StatusPage = require("../model/status_page");
const { UptimeKumaServer } = require("../uptime-kuma-server"); const { UptimeKumaServer } = require("../uptime-kuma-server");
const { makeBadge } = require("badge-maker"); const { makeBadge } = require("badge-maker");
const { badgeConstants } = require("../config");
const { Prometheus } = require("../prometheus"); const { Prometheus } = require("../prometheus");
const Database = require("../database"); const Database = require("../database");
const { UptimeCalculator } = require("../uptime-calculator"); const { UptimeCalculator } = require("../uptime-calculator");
@ -50,7 +49,7 @@ router.get("/api/push/:pushToken", async (request, response) => {
let pushToken = request.params.pushToken; let pushToken = request.params.pushToken;
let msg = request.query.msg || "OK"; let msg = request.query.msg || "OK";
let ping = parseInt(request.query.ping) || null; let ping = parseFloat(request.query.ping) || null;
let statusString = request.query.status || "up"; let statusString = request.query.status || "up";
let status = (statusString === "up") ? UP : DOWN; let status = (statusString === "up") ? UP : DOWN;
@ -64,38 +63,57 @@ router.get("/api/push/:pushToken", async (request, response) => {
const previousHeartbeat = await Monitor.getPreviousHeartbeat(monitor.id); const previousHeartbeat = await Monitor.getPreviousHeartbeat(monitor.id);
if (monitor.isUpsideDown()) {
status = flipStatus(status);
}
let isFirstBeat = true; let isFirstBeat = true;
let previousStatus = status;
let duration = 0;
let bean = R.dispense("heartbeat"); let bean = R.dispense("heartbeat");
bean.time = R.isoDateTimeMillis(dayjs.utc()); bean.time = R.isoDateTimeMillis(dayjs.utc());
bean.monitor_id = monitor.id;
bean.ping = ping;
bean.msg = msg;
bean.downCount = previousHeartbeat?.downCount || 0;
if (previousHeartbeat) { if (previousHeartbeat) {
isFirstBeat = false; isFirstBeat = false;
previousStatus = previousHeartbeat.status; bean.duration = dayjs(bean.time).diff(dayjs(previousHeartbeat.time), "second");
duration = dayjs(bean.time).diff(dayjs(previousHeartbeat.time), "second");
} }
if (await Monitor.isUnderMaintenance(monitor.id)) { if (await Monitor.isUnderMaintenance(monitor.id)) {
msg = "Monitor under maintenance"; msg = "Monitor under maintenance";
status = MAINTENANCE; bean.status = MAINTENANCE;
} else {
determineStatus(status, previousHeartbeat, monitor.maxretries, monitor.isUpsideDown(), bean);
} }
// Calculate uptime
let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitor.id);
let endTimeDayjs = await uptimeCalculator.update(bean.status, parseFloat(bean.ping));
bean.end_time = R.isoDateTimeMillis(endTimeDayjs);
log.debug("router", `/api/push/ called at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`); log.debug("router", `/api/push/ called at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`);
log.debug("router", "PreviousStatus: " + previousStatus); log.debug("router", "PreviousStatus: " + previousHeartbeat?.status);
log.debug("router", "Current Status: " + status); log.debug("router", "Current Status: " + bean.status);
bean.important = Monitor.isImportantBeat(isFirstBeat, previousStatus, status); bean.important = Monitor.isImportantBeat(isFirstBeat, previousHeartbeat?.status, status);
bean.monitor_id = monitor.id;
bean.status = status; if (Monitor.isImportantForNotification(isFirstBeat, previousHeartbeat?.status, status)) {
bean.msg = msg; // Reset down count
bean.ping = ping; bean.downCount = 0;
bean.duration = duration;
log.debug("monitor", `[${this.name}] sendNotification`);
await Monitor.sendNotification(isFirstBeat, monitor, bean);
} else {
if (bean.status === DOWN && this.resendInterval > 0) {
++bean.downCount;
if (bean.downCount >= this.resendInterval) {
// Send notification again, because we are still DOWN
log.debug("monitor", `[${this.name}] sendNotification again: Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`);
await Monitor.sendNotification(isFirstBeat, this, bean);
// Reset down count
bean.downCount = 0;
}
}
}
await R.store(bean); await R.store(bean);
@ -107,11 +125,6 @@ router.get("/api/push/:pushToken", async (request, response) => {
response.json({ response.json({
ok: true, ok: true,
}); });
if (Monitor.isImportantForNotification(isFirstBeat, previousStatus, status)) {
await Monitor.sendNotification(isFirstBeat, monitor, bean);
}
} catch (e) { } catch (e) {
response.status(404).json({ response.status(404).json({
ok: false, ok: false,
@ -562,4 +575,58 @@ router.get("/api/badge/:id/response", cache("5 minutes"), async (request, respon
} }
}); });
/**
* Determines the status of the next beat in the push route handling.
* @param {string} status - The reported new status.
* @param {object} previousHeartbeat - The previous heartbeat object.
* @param {number} maxretries - The maximum number of retries allowed.
* @param {boolean} isUpsideDown - Indicates if the monitor is upside down.
* @param {object} bean - The new heartbeat object.
* @returns {void}
*/
function determineStatus(status, previousHeartbeat, maxretries, isUpsideDown, bean) {
if (isUpsideDown) {
status = flipStatus(status);
}
if (previousHeartbeat) {
if (previousHeartbeat.status === UP && status === DOWN) {
// Going Down
if ((maxretries > 0) && (previousHeartbeat.retries < maxretries)) {
// Retries available
bean.retries = previousHeartbeat.retries + 1;
bean.status = PENDING;
} else {
// No more retries
bean.retries = 0;
bean.status = DOWN;
}
} else if (previousHeartbeat.status === PENDING && status === DOWN && previousHeartbeat.retries < maxretries) {
// Retries available
bean.retries = previousHeartbeat.retries + 1;
bean.status = PENDING;
} else {
// No more retries or not pending
if (status === DOWN) {
bean.retries = previousHeartbeat.retries + 1;
bean.status = status;
} else {
bean.retries = 0;
bean.status = status;
}
}
} else {
// First beat?
if (status === DOWN && maxretries > 0) {
// Retries available
bean.retries = 1;
bean.status = PENDING;
} else {
// Retires not enabled
bean.retries = 0;
bean.status = status;
}
}
}
module.exports = router; module.exports = router;

@ -4,7 +4,7 @@ const { UptimeKumaServer } = require("../uptime-kuma-server");
const StatusPage = require("../model/status_page"); const StatusPage = require("../model/status_page");
const { allowDevAllOrigin, sendHttpError } = require("../util-server"); const { allowDevAllOrigin, sendHttpError } = require("../util-server");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { badgeConstants } = require("../config"); const { badgeConstants } = require("../../src/util");
const { makeBadge } = require("badge-maker"); const { makeBadge } = require("badge-maker");
const { UptimeCalculator } = require("../uptime-calculator"); const { UptimeCalculator } = require("../uptime-calculator");

@ -38,6 +38,7 @@ if (!semver.satisfies(nodeVersion, requiredNodeVersions)) {
const args = require("args-parser")(process.argv); const args = require("args-parser")(process.argv);
const { sleep, log, getRandomInt, genSecret, isDev } = require("../src/util"); const { sleep, log, getRandomInt, genSecret, isDev } = require("../src/util");
const config = require("./config");
log.debug("server", "Arguments"); log.debug("server", "Arguments");
log.debug("server", args); log.debug("server", args);
@ -46,8 +47,13 @@ if (! process.env.NODE_ENV) {
process.env.NODE_ENV = "production"; process.env.NODE_ENV = "production";
} }
if (!process.env.UPTIME_KUMA_WS_ORIGIN_CHECK) {
process.env.UPTIME_KUMA_WS_ORIGIN_CHECK = "cors-like";
}
log.info("server", "Env: " + process.env.NODE_ENV); log.info("server", "Env: " + process.env.NODE_ENV);
log.debug("server", "Inside Container: " + (process.env.UPTIME_KUMA_IS_CONTAINER === "1")); log.debug("server", "Inside Container: " + (process.env.UPTIME_KUMA_IS_CONTAINER === "1"));
log.info("server", "WebSocket Origin Check: " + process.env.UPTIME_KUMA_WS_ORIGIN_CHECK);
const checkVersion = require("./check-version"); const checkVersion = require("./check-version");
log.info("server", "Uptime Kuma Version: " + checkVersion.version); log.info("server", "Uptime Kuma Version: " + checkVersion.version);
@ -72,8 +78,7 @@ const notp = require("notp");
const base32 = require("thirty-two"); const base32 = require("thirty-two");
const { UptimeKumaServer } = require("./uptime-kuma-server"); const { UptimeKumaServer } = require("./uptime-kuma-server");
const server = UptimeKumaServer.getInstance();
const server = UptimeKumaServer.getInstance(args);
const io = module.exports.io = server.io; const io = module.exports.io = server.io;
const app = server.app; const app = server.app;
@ -82,7 +87,7 @@ const Monitor = require("./model/monitor");
const User = require("./model/user"); const User = require("./model/user");
log.debug("server", "Importing Settings"); log.debug("server", "Importing Settings");
const { getSettings, setSettings, setting, initJWTSecret, checkLogin, FBSD, doubleCheckPassword, startE2eTests, shake256, SHAKE256_LENGTH, allowDevAllOrigin, const { getSettings, setSettings, setting, initJWTSecret, checkLogin, doubleCheckPassword, startE2eTests, shake256, SHAKE256_LENGTH, allowDevAllOrigin,
} = require("./util-server"); } = require("./util-server");
log.debug("server", "Importing Notification"); log.debug("server", "Importing Notification");
@ -100,19 +105,13 @@ const { apiAuth } = require("./auth");
const { login } = require("./auth"); const { login } = require("./auth");
const passwordHash = require("./password-hash"); const passwordHash = require("./password-hash");
// If host is omitted, the server will accept connections on the unspecified IPv6 address (::) when IPv6 is available and the unspecified IPv4 address (0.0.0.0) otherwise. const hostname = config.hostname;
// Dual-stack support for (::)
// Also read HOST if not FreeBSD, as HOST is a system environment variable in FreeBSD
let hostEnv = FBSD ? null : process.env.HOST;
let hostname = args.host || process.env.UPTIME_KUMA_HOST || hostEnv;
if (hostname) { if (hostname) {
log.info("server", "Custom hostname: " + hostname); log.info("server", "Custom hostname: " + hostname);
} }
const port = [ args.port, process.env.UPTIME_KUMA_PORT, process.env.PORT, 3001 ] const port = config.port;
.map(portValue => parseInt(portValue))
.find(portValue => !isNaN(portValue));
const disableFrameSameOrigin = !!process.env.UPTIME_KUMA_DISABLE_FRAME_SAMEORIGIN || args["disable-frame-sameorigin"] || false; const disableFrameSameOrigin = !!process.env.UPTIME_KUMA_DISABLE_FRAME_SAMEORIGIN || args["disable-frame-sameorigin"] || false;
const cloudflaredToken = args["cloudflared-token"] || process.env.UPTIME_KUMA_CLOUDFLARED_TOKEN || undefined; const cloudflaredToken = args["cloudflared-token"] || process.env.UPTIME_KUMA_CLOUDFLARED_TOKEN || undefined;
@ -131,9 +130,10 @@ const testMode = !!args["test"] || false;
const e2eTestMode = !!args["e2e"] || false; const e2eTestMode = !!args["e2e"] || false;
// Must be after io instantiation // Must be after io instantiation
const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList } = require("./client"); const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList, sendRemoteBrowserList } = require("./client");
const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler"); const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler");
const databaseSocketHandler = require("./socket-handlers/database-socket-handler"); const databaseSocketHandler = require("./socket-handlers/database-socket-handler");
const { remoteBrowserSocketHandler } = require("./socket-handlers/remote-browser-socket-handler");
const TwoFA = require("./2fa"); const TwoFA = require("./2fa");
const StatusPage = require("./model/status_page"); const StatusPage = require("./model/status_page");
const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart, stop: cloudflaredStop } = require("./socket-handlers/cloudflared-socket-handler"); const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart, stop: cloudflaredStop } = require("./socket-handlers/cloudflared-socket-handler");
@ -143,7 +143,6 @@ const { maintenanceSocketHandler } = require("./socket-handlers/maintenance-sock
const { apiKeySocketHandler } = require("./socket-handlers/api-key-socket-handler"); const { apiKeySocketHandler } = require("./socket-handlers/api-key-socket-handler");
const { generalSocketHandler } = require("./socket-handlers/general-socket-handler"); const { generalSocketHandler } = require("./socket-handlers/general-socket-handler");
const { Settings } = require("./settings"); const { Settings } = require("./settings");
const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
const apicache = require("./modules/apicache"); const apicache = require("./modules/apicache");
const { resetChrome } = require("./monitor-types/real-browser-monitor-type"); const { resetChrome } = require("./monitor-types/real-browser-monitor-type");
const { EmbeddedMariaDB } = require("./embedded-mariadb"); const { EmbeddedMariaDB } = require("./embedded-mariadb");
@ -798,6 +797,7 @@ let needSetup = false;
bean.mqttPassword = monitor.mqttPassword; bean.mqttPassword = monitor.mqttPassword;
bean.mqttTopic = monitor.mqttTopic; bean.mqttTopic = monitor.mqttTopic;
bean.mqttSuccessMessage = monitor.mqttSuccessMessage; bean.mqttSuccessMessage = monitor.mqttSuccessMessage;
bean.mqttCheckType = monitor.mqttCheckType;
bean.databaseConnectionString = monitor.databaseConnectionString; bean.databaseConnectionString = monitor.databaseConnectionString;
bean.databaseQuery = monitor.databaseQuery; bean.databaseQuery = monitor.databaseQuery;
bean.authMethod = monitor.authMethod; bean.authMethod = monitor.authMethod;
@ -823,7 +823,11 @@ let needSetup = false;
bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation; bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation;
bean.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions); bean.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions);
bean.kafkaProducerMessage = monitor.kafkaProducerMessage; bean.kafkaProducerMessage = monitor.kafkaProducerMessage;
bean.kafkaProducerSsl = monitor.kafkaProducerSsl;
bean.kafkaProducerAllowAutoTopicCreation =
monitor.kafkaProducerAllowAutoTopicCreation;
bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly; bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly;
bean.remote_browser = monitor.remote_browser;
bean.validate(); bean.validate();
@ -1260,6 +1264,8 @@ let needSetup = false;
let user = await doubleCheckPassword(socket, password.currentPassword); let user = await doubleCheckPassword(socket, password.currentPassword);
await user.resetPassword(password.newPassword); await user.resetPassword(password.newPassword);
server.disconnectAllSocketClient(user.id, socket.id);
callback({ callback({
ok: true, ok: true,
msg: "successAuthChangePassword", msg: "successAuthChangePassword",
@ -1316,8 +1322,6 @@ let needSetup = false;
await setSettings("general", data); await setSettings("general", data);
server.entryPage = data.entryPage; server.entryPage = data.entryPage;
await CacheableDnsHttpAgent.update();
// Also need to apply timezone globally // Also need to apply timezone globally
if (data.serverTimezone) { if (data.serverTimezone) {
await server.setTimezone(data.serverTimezone); await server.setTimezone(data.serverTimezone);
@ -1332,9 +1336,9 @@ let needSetup = false;
// Update nscd status // Update nscd status
if (previousNSCDStatus !== data.nscd) { if (previousNSCDStatus !== data.nscd) {
if (data.nscd) { if (data.nscd) {
server.startNSCDServices(); await server.startNSCDServices();
} else { } else {
server.stopNSCDServices(); await server.stopNSCDServices();
} }
} }
@ -1505,6 +1509,7 @@ let needSetup = false;
dockerSocketHandler(socket); dockerSocketHandler(socket);
maintenanceSocketHandler(socket); maintenanceSocketHandler(socket);
apiKeySocketHandler(socket); apiKeySocketHandler(socket);
remoteBrowserSocketHandler(socket);
generalSocketHandler(socket, server); generalSocketHandler(socket, server);
log.debug("server", "added all socket handlers"); log.debug("server", "added all socket handlers");
@ -1613,6 +1618,7 @@ async function afterLogin(socket, user) {
sendProxyList(socket); sendProxyList(socket);
sendDockerHostList(socket); sendDockerHostList(socket);
sendAPIKeyList(socket); sendAPIKeyList(socket);
sendRemoteBrowserList(socket);
await sleep(500); await sleep(500);
@ -1725,6 +1731,7 @@ async function pauseMonitor(userID, monitorID) {
if (monitorID in server.monitorList) { if (monitorID in server.monitorList) {
server.monitorList[monitorID].stop(); server.monitorList[monitorID].stop();
server.monitorList[monitorID].active = 0;
} }
} }
@ -1793,8 +1800,10 @@ gracefulShutdown(server.httpServer, {
}); });
// Catch unexpected errors here // Catch unexpected errors here
process.addListener("unhandledRejection", (error, promise) => { let unexpectedErrorHandler = (error, promise) => {
console.trace(error); console.trace(error);
UptimeKumaServer.errorLog(error, false); UptimeKumaServer.errorLog(error, false);
console.error("If you keep encountering errors, please report to https://github.com/louislam/uptime-kuma/issues"); console.error("If you keep encountering errors, please report to https://github.com/louislam/uptime-kuma/issues");
}); };
process.addListener("unhandledRejection", unexpectedErrorHandler);
process.addListener("uncaughtException", unexpectedErrorHandler);

@ -74,7 +74,7 @@ class SetupDatabase {
dbConfig.type = process.env.UPTIME_KUMA_DB_TYPE; dbConfig.type = process.env.UPTIME_KUMA_DB_TYPE;
dbConfig.hostname = process.env.UPTIME_KUMA_DB_HOSTNAME; dbConfig.hostname = process.env.UPTIME_KUMA_DB_HOSTNAME;
dbConfig.port = process.env.UPTIME_KUMA_DB_PORT; dbConfig.port = process.env.UPTIME_KUMA_DB_PORT;
dbConfig.database = process.env.UPTIME_KUMA_DB_NAME; dbConfig.dbName = process.env.UPTIME_KUMA_DB_NAME;
dbConfig.username = process.env.UPTIME_KUMA_DB_USERNAME; dbConfig.username = process.env.UPTIME_KUMA_DB_USERNAME;
dbConfig.password = process.env.UPTIME_KUMA_DB_PASSWORD; dbConfig.password = process.env.UPTIME_KUMA_DB_PASSWORD;
Database.writeDBConfig(dbConfig); Database.writeDBConfig(dbConfig);

@ -44,29 +44,45 @@ module.exports.generalSocketHandler = (socket, server) => {
}); });
socket.on("getGameList", async (callback) => { socket.on("getGameList", async (callback) => {
callback({ try {
ok: true, checkLogin(socket);
gameList: getGameList(), callback({
}); ok: true,
gameList: getGameList(),
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
}); });
socket.on("testChrome", (executable, callback) => { socket.on("testChrome", (executable, callback) => {
// Just noticed that await call could block the whole socket.io server!!! Use pure promise instead. try {
testChrome(executable).then((version) => { checkLogin(socket);
callback({ // Just noticed that await call could block the whole socket.io server!!! Use pure promise instead.
ok: true, testChrome(executable).then((version) => {
msg: { callback({
key: "foundChromiumVersion", ok: true,
values: [ version ], msg: {
}, key: "foundChromiumVersion",
msgi18n: true, values: [ version ],
},
msgi18n: true,
});
}).catch((e) => {
callback({
ok: false,
msg: e.message,
});
}); });
}).catch((e) => { } catch (e) {
callback({ callback({
ok: false, ok: false,
msg: e.message, msg: e.message,
}); });
}); }
}); });
socket.on("getPushExample", (language, callback) => { socket.on("getPushExample", (language, callback) => {
@ -93,4 +109,14 @@ module.exports.generalSocketHandler = (socket, server) => {
msg: "Not found", msg: "Not found",
}); });
}); });
// Disconnect all other socket clients of the user
socket.on("disconnectOtherSocketClients", async () => {
try {
checkLogin(socket);
server.disconnectAllSocketClients(socket.userID, socket.id);
} catch (e) {
log.warn("disconnectAllSocketClients", e.message);
}
});
}; };

@ -0,0 +1,82 @@
const { sendRemoteBrowserList } = require("../client");
const { checkLogin } = require("../util-server");
const { RemoteBrowser } = require("../remote-browser");
const { log } = require("../../src/util");
const { testRemoteBrowser } = require("../monitor-types/real-browser-monitor-type");
/**
* Handlers for docker hosts
* @param {Socket} socket Socket.io instance
* @returns {void}
*/
module.exports.remoteBrowserSocketHandler = (socket) => {
socket.on("addRemoteBrowser", async (remoteBrowser, remoteBrowserID, callback) => {
try {
checkLogin(socket);
let remoteBrowserBean = await RemoteBrowser.save(remoteBrowser, remoteBrowserID, socket.userID);
await sendRemoteBrowserList(socket);
callback({
ok: true,
msg: "Saved.",
msgi18n: true,
id: remoteBrowserBean.id,
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("deleteRemoteBrowser", async (dockerHostID, callback) => {
try {
checkLogin(socket);
await RemoteBrowser.delete(dockerHostID, socket.userID);
await sendRemoteBrowserList(socket);
callback({
ok: true,
msg: "successDeleted",
msgi18n: true,
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("testRemoteBrowser", async (remoteBrowser, callback) => {
try {
checkLogin(socket);
let check = await testRemoteBrowser(remoteBrowser.url);
log.info("remoteBrowser", "Tested remote browser: " + check);
let msg;
if (check) {
msg = "Connected Successfully.";
}
callback({
ok: true,
msg,
});
} catch (e) {
log.error("remoteBrowser", e);
callback({
ok: false,
msg: e.message,
});
}
});
};

@ -4,15 +4,15 @@ const fs = require("fs");
const http = require("http"); const http = require("http");
const { Server } = require("socket.io"); const { Server } = require("socket.io");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { log } = require("../src/util"); const { log, isDev } = require("../src/util");
const Database = require("./database"); const Database = require("./database");
const util = require("util"); const util = require("util");
const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
const { Settings } = require("./settings"); const { Settings } = require("./settings");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const childProcess = require("child_process"); const childProcessAsync = require("promisify-child-process");
const path = require("path"); const path = require("path");
const axios = require("axios"); const axios = require("axios");
const { isSSL, sslKey, sslCert, sslKeyPassphrase } = require("./config");
// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead. // DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead.
/** /**
@ -65,31 +65,28 @@ class UptimeKumaServer {
/** /**
* Get the current instance of the server if it exists, otherwise * Get the current instance of the server if it exists, otherwise
* create a new instance. * create a new instance.
* @param {object} args Arguments to pass to instance constructor
* @returns {UptimeKumaServer} Server instance * @returns {UptimeKumaServer} Server instance
*/ */
static getInstance(args) { static getInstance() {
if (UptimeKumaServer.instance == null) { if (UptimeKumaServer.instance == null) {
UptimeKumaServer.instance = new UptimeKumaServer(args); UptimeKumaServer.instance = new UptimeKumaServer();
} }
return UptimeKumaServer.instance; return UptimeKumaServer.instance;
} }
/** /**
* @param {object} args Arguments to initialise server with *
*/ */
constructor(args) { constructor() {
// SSL
const sslKey = args["ssl-key"] || process.env.UPTIME_KUMA_SSL_KEY || process.env.SSL_KEY || undefined;
const sslCert = args["ssl-cert"] || process.env.UPTIME_KUMA_SSL_CERT || process.env.SSL_CERT || undefined;
const sslKeyPassphrase = args["ssl-key-passphrase"] || process.env.UPTIME_KUMA_SSL_KEY_PASSPHRASE || process.env.SSL_KEY_PASSPHRASE || undefined;
// Set axios default user-agent to Uptime-Kuma/version // Set axios default user-agent to Uptime-Kuma/version
axios.defaults.headers.common["User-Agent"] = this.getUserAgent(); axios.defaults.headers.common["User-Agent"] = this.getUserAgent();
log.debug("server", "Creating express and socket.io instance"); // Set default axios timeout to 5 minutes instead of infinity
axios.defaults.timeout = 300 * 1000;
log.info("server", "Creating express and socket.io instance");
this.app = express(); this.app = express();
if (sslKey && sslCert) { if (isSSL) {
log.info("server", "Server Type: HTTPS"); log.info("server", "Server Type: HTTPS");
this.httpServer = https.createServer({ this.httpServer = https.createServer({
key: fs.readFileSync(sslKey), key: fs.readFileSync(sslKey),
@ -115,8 +112,43 @@ class UptimeKumaServer {
UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType(); UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType();
UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing(); UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing();
UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType(); UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType();
UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType();
this.io = new Server(this.httpServer);
this.io = new Server(this.httpServer, {
allowRequest: (req, callback) => {
let isOriginValid = true;
const bypass = isDev || process.env.UPTIME_KUMA_WS_ORIGIN_CHECK === "bypass";
if (!bypass) {
let host = req.headers.host;
// If this is set, it means the request is from the browser
let origin = req.headers.origin;
// If this is from the browser, check if the origin is allowed
if (origin) {
try {
let originURL = new URL(origin);
if (host !== originURL.host) {
isOriginValid = false;
log.error("auth", `Origin (${origin}) does not match host (${host}), IP: ${req.socket.remoteAddress}`);
}
} catch (e) {
// Invalid origin url, probably not from browser
isOriginValid = false;
log.error("auth", `Invalid origin url (${origin}), IP: ${req.socket.remoteAddress}`);
}
} else {
log.info("auth", `Origin is not set, IP: ${req.socket.remoteAddress}`);
}
} else {
log.debug("auth", "Origin check is bypassed");
}
callback(null, isOriginValid);
}
});
} }
/** /**
@ -127,8 +159,6 @@ class UptimeKumaServer {
// Static // Static
this.app.use("/screenshots", express.static(Database.screenshotDir)); this.app.use("/screenshots", express.static(Database.screenshotDir));
await CacheableDnsHttpAgent.update();
process.env.TZ = await this.getTimezone(); process.env.TZ = await this.getTimezone();
dayjs.tz.setDefault(process.env.TZ); dayjs.tz.setDefault(process.env.TZ);
log.debug("DEBUG", "Timezone: " + process.env.TZ); log.debug("DEBUG", "Timezone: " + process.env.TZ);
@ -369,7 +399,7 @@ class UptimeKumaServer {
let enable = await Settings.get("nscd"); let enable = await Settings.get("nscd");
if (enable || enable === null) { if (enable || enable === null) {
this.startNSCDServices(); await this.startNSCDServices();
} }
} }
@ -381,7 +411,7 @@ class UptimeKumaServer {
let enable = await Settings.get("nscd"); let enable = await Settings.get("nscd");
if (enable || enable === null) { if (enable || enable === null) {
this.stopNSCDServices(); await this.stopNSCDServices();
} }
} }
@ -390,11 +420,11 @@ class UptimeKumaServer {
* For now, only used in Docker * For now, only used in Docker
* @returns {void} * @returns {void}
*/ */
startNSCDServices() { async startNSCDServices() {
if (process.env.UPTIME_KUMA_IS_CONTAINER) { if (process.env.UPTIME_KUMA_IS_CONTAINER) {
try { try {
log.info("services", "Starting nscd"); log.info("services", "Starting nscd");
childProcess.execSync("sudo service nscd start", { stdio: "pipe" }); await childProcessAsync.exec("sudo service nscd start");
} catch (e) { } catch (e) {
log.info("services", "Failed to start nscd"); log.info("services", "Failed to start nscd");
} }
@ -405,11 +435,11 @@ class UptimeKumaServer {
* Stop all system services * Stop all system services
* @returns {void} * @returns {void}
*/ */
stopNSCDServices() { async stopNSCDServices() {
if (process.env.UPTIME_KUMA_IS_CONTAINER) { if (process.env.UPTIME_KUMA_IS_CONTAINER) {
try { try {
log.info("services", "Stopping nscd"); log.info("services", "Stopping nscd");
childProcess.execSync("sudo service nscd stop"); await childProcessAsync.exec("sudo service nscd stop");
} catch (e) { } catch (e) {
log.info("services", "Failed to stop nscd"); log.info("services", "Failed to stop nscd");
} }
@ -423,6 +453,26 @@ class UptimeKumaServer {
getUserAgent() { getUserAgent() {
return "Uptime-Kuma/" + require("../package.json").version; return "Uptime-Kuma/" + require("../package.json").version;
} }
/**
* Force connected sockets of a user to refresh and disconnect.
* Used for resetting password.
* @param {string} userID User ID
* @param {string?} currentSocketID Current socket ID
* @returns {void}
*/
disconnectAllSocketClients(userID, currentSocketID = undefined) {
for (const socket of this.io.sockets.sockets.values()) {
if (socket.userID === userID && socket.id !== currentSocketID) {
try {
socket.emit("refresh");
socket.disconnect();
} catch (e) {
}
}
}
}
} }
module.exports = { module.exports = {
@ -433,3 +483,4 @@ module.exports = {
const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type"); const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type");
const { TailscalePing } = require("./monitor-types/tailscale-ping"); const { TailscalePing } = require("./monitor-types/tailscale-ping");
const { DnsMonitorType } = require("./monitor-types/dns"); const { DnsMonitorType } = require("./monitor-types/dns");
const { MqttMonitorType } = require("./monitor-types/mqtt");

@ -1,15 +1,13 @@
const tcpp = require("tcp-ping"); const tcpp = require("tcp-ping");
const ping = require("@louislam/ping"); const ping = require("@louislam/ping");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { log, genSecret } = require("../src/util"); const { log, genSecret, badgeConstants } = require("../src/util");
const passwordHash = require("./password-hash"); const passwordHash = require("./password-hash");
const { Resolver } = require("dns"); const { Resolver } = require("dns");
const childProcess = require("child_process"); const childProcess = require("child_process");
const iconv = require("iconv-lite"); const iconv = require("iconv-lite");
const chardet = require("chardet"); const chardet = require("chardet");
const mqtt = require("mqtt");
const chroma = require("chroma-js"); const chroma = require("chroma-js");
const { badgeConstants } = require("./config");
const mssql = require("mssql"); const mssql = require("mssql");
const { Client } = require("pg"); const { Client } = require("pg");
const postgresConParse = require("pg-connection-string").parse; const postgresConParse = require("pg-connection-string").parse;
@ -22,6 +20,7 @@ const protojs = require("protobufjs");
const radiusClient = require("node-radius-client"); const radiusClient = require("node-radius-client");
const redis = require("redis"); const redis = require("redis");
const oidc = require("openid-client"); const oidc = require("openid-client");
const tls = require("tls");
const { const {
dictionaries: { dictionaries: {
@ -172,73 +171,6 @@ exports.pingAsync = function (hostname, ipv6 = false, size = 56) {
}); });
}; };
/**
* MQTT Monitor
* @param {string} hostname Hostname / address of machine to test
* @param {string} topic MQTT topic
* @param {string} okMessage Expected result
* @param {object} options MQTT options. Contains port, username,
* password and interval (interval defaults to 20)
* @returns {Promise<string>} Received MQTT message
*/
exports.mqttAsync = function (hostname, topic, okMessage, options = {}) {
return new Promise((resolve, reject) => {
const { port, username, password, interval = 20 } = options;
// Adds MQTT protocol to the hostname if not already present
if (!/^(?:http|mqtt|ws)s?:\/\//.test(hostname)) {
hostname = "mqtt://" + hostname;
}
const timeoutID = setTimeout(() => {
log.debug("mqtt", "MQTT timeout triggered");
client.end();
reject(new Error("Timeout"));
}, interval * 1000 * 0.8);
const mqttUrl = `${hostname}:${port}`;
log.debug("mqtt", `MQTT connecting to ${mqttUrl}`);
let client = mqtt.connect(mqttUrl, {
username,
password
});
client.on("connect", () => {
log.debug("mqtt", "MQTT connected");
try {
log.debug("mqtt", "MQTT subscribe topic");
client.subscribe(topic);
} catch (e) {
client.end();
clearTimeout(timeoutID);
reject(new Error("Cannot subscribe topic"));
}
});
client.on("error", (error) => {
client.end();
clearTimeout(timeoutID);
reject(error);
});
client.on("message", (messageTopic, message) => {
if (messageTopic === topic) {
client.end();
clearTimeout(timeoutID);
if (okMessage != null && okMessage !== "" && message.toString() !== okMessage) {
reject(new Error(`Message Mismatch - Topic: ${messageTopic}; Message: ${message.toString()}`));
} else {
resolve(`Topic: ${messageTopic}; Message: ${message.toString()}`);
}
}
});
});
};
/** /**
* Monitor Kafka using Producer * Monitor Kafka using Producer
* @param {string[]} brokers List of kafka brokers to connect, host and * @param {string[]} brokers List of kafka brokers to connect, host and
@ -397,6 +329,9 @@ exports.mssqlQuery = async function (connectionString, query) {
try { try {
pool = new mssql.ConnectionPool(connectionString); pool = new mssql.ConnectionPool(connectionString);
await pool.connect(); await pool.connect();
if (!query) {
query = "SELECT 1";
}
await pool.request().query(query); await pool.request().query(query);
pool.close(); pool.close();
} catch (e) { } catch (e) {
@ -418,12 +353,22 @@ exports.postgresQuery = function (connectionString, query) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const config = postgresConParse(connectionString); const config = postgresConParse(connectionString);
// Fix #3868, which true/false is not parsed to boolean
if (typeof config.ssl === "string") {
config.ssl = config.ssl === "true";
}
if (config.password === "") { if (config.password === "") {
// See https://github.com/brianc/node-postgres/issues/1927 // See https://github.com/brianc/node-postgres/issues/1927
return reject(new Error("Password is undefined.")); reject(new Error("Password is undefined."));
return;
} }
const client = new Client(config);
const client = new Client({ connectionString }); client.on("error", (error) => {
log.debug("postgres", "Error caught in the error event handler.");
reject(error);
});
client.connect((err) => { client.connect((err) => {
if (err) { if (err) {
@ -447,6 +392,7 @@ exports.postgresQuery = function (connectionString, query) {
}); });
} catch (e) { } catch (e) {
reject(e); reject(e);
client.end();
} }
} }
}); });
@ -458,11 +404,15 @@ exports.postgresQuery = function (connectionString, query) {
* Run a query on MySQL/MariaDB * Run a query on MySQL/MariaDB
* @param {string} connectionString The database connection string * @param {string} connectionString The database connection string
* @param {string} query The query to validate the database with * @param {string} query The query to validate the database with
* @param {?string} password The password to use
* @returns {Promise<(string)>} Response from server * @returns {Promise<(string)>} Response from server
*/ */
exports.mysqlQuery = function (connectionString, query) { exports.mysqlQuery = function (connectionString, query, password = undefined) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const connection = mysql.createConnection(connectionString); const connection = mysql.createConnection({
uri: connectionString,
password
});
connection.on("error", (err) => { connection.on("error", (err) => {
reject(err); reject(err);
@ -1064,6 +1014,30 @@ module.exports.grpcQuery = async (options) => {
}); });
}; };
/**
* Returns an array of SHA256 fingerprints for all known root certificates.
* @returns {Set} A set of SHA256 fingerprints.
*/
module.exports.rootCertificatesFingerprints = () => {
let fingerprints = tls.rootCertificates.map(cert => {
let certLines = cert.split("\n");
certLines.shift();
certLines.pop();
let certBody = certLines.join("");
let buf = Buffer.from(certBody, "base64");
const shasum = crypto.createHash("sha256");
shasum.update(buf);
return shasum.digest("hex").toUpperCase().replace(/(.{2})(?!$)/g, "$1:");
});
fingerprints.push("6D:99:FB:26:5E:B1:C5:B3:74:47:65:FC:BC:64:8F:3C:D8:E1:BF:FA:FD:C4:C2:F9:9B:9D:47:CF:7F:F1:C2:4F"); // ISRG X1 cross-signed with DST X3
fingerprints.push("8B:05:B6:8C:C6:59:E5:ED:0F:CB:38:F2:C9:42:FB:FD:20:0E:6F:2F:F9:F8:5D:63:C6:99:4E:F5:E0:B0:27:01"); // ISRG X2 cross-signed with ISRG X1
return new Set(fingerprints);
};
module.exports.SHAKE256_LENGTH = 16; module.exports.SHAKE256_LENGTH = 16;
/** /**
@ -1107,13 +1081,16 @@ if (process.env.TEST_BACKEND) {
*/ */
module.exports.axiosAbortSignal = (timeoutMs) => { module.exports.axiosAbortSignal = (timeoutMs) => {
try { try {
// Just in case, as 0 timeout here will cause the request to be aborted immediately
if (!timeoutMs || timeoutMs <= 0) {
timeoutMs = 5000;
}
return AbortSignal.timeout(timeoutMs); return AbortSignal.timeout(timeoutMs);
} catch (_) { } catch (_) {
// v16-: AbortSignal.timeout is not supported // v16-: AbortSignal.timeout is not supported
try { try {
const abortController = new AbortController(); const abortController = new AbortController();
setTimeout(() => abortController.abort(), timeoutMs);
setTimeout(() => abortController.abort(), timeoutMs || 0);
return abortController.signal; return abortController.signal;
} catch (_) { } catch (_) {

@ -635,6 +635,10 @@ $shadow-box-padding: 20px;
} }
} }
.zoom-cursor {
cursor: zoom-in;
}
// Localization // Localization
@import "localization.scss"; @import "localization.scss";

@ -8,9 +8,9 @@
:placeholder="placeholder" :placeholder="placeholder"
:disabled="!enabled" :disabled="!enabled"
> >
<a class="btn btn-outline-primary" @click="action()"> <button type="button" class="btn btn-outline-primary" :aria-label="actionAriaLabel" @click="action()">
<font-awesome-icon :icon="icon" /> <font-awesome-icon :icon="icon" />
</a> </button>
</div> </div>
</template> </template>
@ -66,6 +66,13 @@ export default {
action: { action: {
type: Function, type: Function,
default: () => {}, default: () => {},
},
/**
* The aria-label of the action button
*/
actionAriaLabel: {
type: String,
required: true,
} }
}, },
emits: [ "update:modelValue" ], emits: [ "update:modelValue" ],

@ -1,11 +1,11 @@
<template> <template>
<div class="input-group mb-3"> <div class="input-group mb-3">
<select ref="select" v-model="model" class="form-select" :disabled="disabled" :required="required"> <select :id="id" ref="select" v-model="model" class="form-select" :disabled="disabled" :required="required">
<option v-for="option in options" :key="option" :value="option.value" :disabled="option.disabled">{{ option.label }}</option> <option v-for="option in options" :key="option" :value="option.value" :disabled="option.disabled">{{ option.label }}</option>
</select> </select>
<a class="btn btn-outline-primary" :class="{ disabled: actionDisabled }" @click="action()"> <button type="button" class="btn btn-outline-primary" :class="{ disabled: actionDisabled }" :aria-label="actionAriaLabel" @click="action()">
<font-awesome-icon :icon="icon" /> <font-awesome-icon :icon="icon" aria-hidden="true" />
</a> </button>
</div> </div>
</template> </template>
@ -20,6 +20,13 @@ export default {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
/**
* The id of the form which will be targeted by a <label for=..
*/
id: {
type: String,
required: true,
},
/** /**
* The value of the select field. * The value of the select field.
*/ */
@ -51,6 +58,13 @@ export default {
type: Function, type: Function,
default: () => {}, default: () => {},
}, },
/**
* The aria-label of the action button
*/
actionAriaLabel: {
type: String,
required: true,
},
/** /**
* Whether the action button is disabled. * Whether the action button is disabled.
* @example true * @example true

@ -135,7 +135,7 @@
<script lang="ts"> <script lang="ts">
import { Modal } from "bootstrap"; import { Modal } from "bootstrap";
import CopyableInput from "./CopyableInput.vue"; import CopyableInput from "./CopyableInput.vue";
import { default as serverConfig } from "../../server/config.js"; import { badgeConstants } from "../util.ts";
export default { export default {
components: { components: {
@ -230,7 +230,7 @@ export default {
"labelColor", "labelColor",
], ],
}, },
badgeConstants: serverConfig.badgeConstants, badgeConstants,
}; };
}, },

@ -29,10 +29,10 @@ export default {
}, },
computed: { computed: {
startDateTime() { startDateTime() {
return dayjs(this.maintenance.timeslotList[0].startDate).tz(this.maintenance.timezone).format(SQL_DATETIME_FORMAT_WITHOUT_SECOND); return dayjs(this.maintenance.timeslotList[0].startDate).tz(this.maintenance.timezone, true).format(SQL_DATETIME_FORMAT_WITHOUT_SECOND);
}, },
endDateTime() { endDateTime() {
return dayjs(this.maintenance.timeslotList[0].endDate).tz(this.maintenance.timezone).format(SQL_DATETIME_FORMAT_WITHOUT_SECOND); return dayjs(this.maintenance.timeslotList[0].endDate).tz(this.maintenance.timezone, true).format(SQL_DATETIME_FORMAT_WITHOUT_SECOND);
} }
}, },
}; };

@ -16,7 +16,10 @@
</a> </a>
<form> <form>
<input <input
v-model="searchText" class="form-control search-input" :placeholder="$t('Search...')" v-model="searchText"
class="form-control search-input"
:placeholder="$t('Search...')"
:aria-label="$t('Search monitored sites')"
autocomplete="off" autocomplete="off"
/> />
</form> </form>
@ -51,11 +54,12 @@
v-for="(item, index) in sortedMonitorList" v-for="(item, index) in sortedMonitorList"
:key="index" :key="index"
:monitor="item" :monitor="item"
:showPathName="filtersActive"
:isSelectMode="selectMode" :isSelectMode="selectMode"
:isSelected="isSelected" :isSelected="isSelected"
:select="select" :select="select"
:deselect="deselect" :deselect="deselect"
:filter-func="filterFunc"
:sort-func="sortFunc"
/> />
</div> </div>
</div> </div>
@ -126,75 +130,16 @@ export default {
let result = Object.values(this.$root.monitorList); let result = Object.values(this.$root.monitorList);
result = result.filter(monitor => { result = result.filter(monitor => {
// filter by search text // The root list does not show children
// finds monitor name, tag name or tag value if (monitor.parent !== null) {
let searchTextMatch = true; return false;
if (this.searchText !== "") {
const loweredSearchText = this.searchText.toLowerCase();
searchTextMatch =
monitor.name.toLowerCase().includes(loweredSearchText)
|| monitor.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
|| tag.value?.toLowerCase().includes(loweredSearchText));
} }
return true;
// filter by status
let statusMatch = true;
if (this.filterState.status != null && this.filterState.status.length > 0) {
if (monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[monitor.id]) {
monitor.status = this.$root.lastHeartbeatList[monitor.id].status;
}
statusMatch = this.filterState.status.includes(monitor.status);
}
// filter by active
let activeMatch = true;
if (this.filterState.active != null && this.filterState.active.length > 0) {
activeMatch = this.filterState.active.includes(monitor.active);
}
// filter by tags
let tagsMatch = true;
if (this.filterState.tags != null && this.filterState.tags.length > 0) {
tagsMatch = 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;
}
// Hide children if not filtering
let showChild = true;
if (this.filterState.status == null && this.filterState.active == null && this.filterState.tags == null && this.searchText === "") {
if (monitor.parent !== null) {
showChild = false;
}
}
return searchTextMatch && statusMatch && activeMatch && tagsMatch && showChild;
}); });
// Filter result by active state, weight and alphabetical result = result.filter(this.filterFunc);
result.sort((m1, m2) => {
if (m1.active !== m2.active) {
if (m1.active === false) {
return 1;
}
if (m2.active === false) { result.sort(this.sortFunc);
return -1;
}
}
if (m1.weight !== m2.weight) {
if (m1.weight > m2.weight) {
return -1;
}
if (m1.weight < m2.weight) {
return 1;
}
}
return m1.name.localeCompare(m2.name);
});
return result; return result;
}, },
@ -361,6 +306,85 @@ export default {
this.cancelSelectMode(); this.cancelSelectMode();
}, },
/**
* Whether a monitor should be displayed based on the filters
* @param {object} monitor Monitor to check
* @returns {boolean} Should the monitor be displayed
*/
filterFunc(monitor) {
// Group monitors bypass filter if at least 1 of children matched
if (monitor.type === "group") {
const children = Object.values(this.$root.monitorList).filter(m => m.parent === monitor.id);
if (children.some((child, index, children) => this.filterFunc(child))) {
return true;
}
}
// filter by search text
// finds monitor name, tag name or tag value
let searchTextMatch = true;
if (this.searchText !== "") {
const loweredSearchText = this.searchText.toLowerCase();
searchTextMatch =
monitor.name.toLowerCase().includes(loweredSearchText)
|| monitor.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
|| tag.value?.toLowerCase().includes(loweredSearchText));
}
// filter by status
let statusMatch = true;
if (this.filterState.status != null && this.filterState.status.length > 0) {
if (monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[monitor.id]) {
monitor.status = this.$root.lastHeartbeatList[monitor.id].status;
}
statusMatch = this.filterState.status.includes(monitor.status);
}
// filter by active
let activeMatch = true;
if (this.filterState.active != null && this.filterState.active.length > 0) {
activeMatch = this.filterState.active.includes(monitor.active);
}
// filter by tags
let tagsMatch = true;
if (this.filterState.tags != null && this.filterState.tags.length > 0) {
tagsMatch = 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 searchTextMatch && statusMatch && activeMatch && tagsMatch;
},
/**
* Function used in Array.sort to order monitors in a list.
* @param {*} m1 monitor 1
* @param {*} m2 monitor 2
* @returns {number} -1, 0 or 1
*/
sortFunc(m1, m2) {
if (m1.active !== m2.active) {
if (m1.active === false) {
return 1;
}
if (m2.active === false) {
return -1;
}
}
if (m1.weight !== m2.weight) {
if (m1.weight > m2.weight) {
return -1;
}
if (m1.weight < m2.weight) {
return 1;
}
}
return m1.name.localeCompare(m2.name);
}
}, },
}; };
</script> </script>
@ -458,5 +482,4 @@ export default {
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
</style> </style>

@ -20,7 +20,7 @@
<span v-if="hasChildren" class="collapse-padding" @click.prevent="changeCollapsed"> <span v-if="hasChildren" class="collapse-padding" @click.prevent="changeCollapsed">
<font-awesome-icon icon="chevron-down" class="animated" :class="{ collapsed: isCollapsed}" /> <font-awesome-icon icon="chevron-down" class="animated" :class="{ collapsed: isCollapsed}" />
</span> </span>
{{ monitorName }} {{ monitor.name }}
</div> </div>
<div v-if="monitor.tags.length > 0" class="tags"> <div v-if="monitor.tags.length > 0" class="tags">
<Tag v-for="tag in monitor.tags" :key="tag" :item="tag" :size="'sm'" /> <Tag v-for="tag in monitor.tags" :key="tag" :item="tag" :size="'sm'" />
@ -44,7 +44,6 @@
<MonitorListItem <MonitorListItem
v-for="(item, index) in sortedChildMonitorList" v-for="(item, index) in sortedChildMonitorList"
:key="index" :monitor="item" :key="index" :monitor="item"
:showPathName="showPathName"
:isSelectMode="isSelectMode" :isSelectMode="isSelectMode"
:isSelected="isSelected" :isSelected="isSelected"
:select="select" :select="select"
@ -75,11 +74,6 @@ export default {
type: Object, type: Object,
default: null, default: null,
}, },
/** Should the monitor name show it's parent */
showPathName: {
type: Boolean,
default: false,
},
/** If the user is in select mode */ /** If the user is in select mode */
isSelectMode: { isSelectMode: {
type: Boolean, type: Boolean,
@ -105,6 +99,16 @@ export default {
type: Function, type: Function,
default: () => {} default: () => {}
}, },
/** Function to filter child monitors */
filterFunc: {
type: Function,
default: () => {}
},
/** Function to sort child monitors */
sortFunc: {
type: Function,
default: () => {},
}
}, },
data() { data() {
return { return {
@ -115,32 +119,13 @@ export default {
sortedChildMonitorList() { sortedChildMonitorList() {
let result = Object.values(this.$root.monitorList); let result = Object.values(this.$root.monitorList);
// Get children
result = result.filter(childMonitor => childMonitor.parent === this.monitor.id); result = result.filter(childMonitor => childMonitor.parent === this.monitor.id);
result.sort((m1, m2) => { // Run filter on children
result = result.filter(this.filterFunc);
if (m1.active !== m2.active) { result.sort(this.sortFunc);
if (m1.active === 0) {
return 1;
}
if (m2.active === 0) {
return -1;
}
}
if (m1.weight !== m2.weight) {
if (m1.weight > m2.weight) {
return -1;
}
if (m1.weight < m2.weight) {
return 1;
}
}
return m1.name.localeCompare(m2.name);
});
return result; return result;
}, },
@ -152,13 +137,6 @@ export default {
marginLeft: `${31 * this.depth}px`, marginLeft: `${31 * this.depth}px`,
}; };
}, },
monitorName() {
if (this.showPathName) {
return this.monitor.pathName;
} else {
return this.monitor.name;
}
}
}, },
watch: { watch: {
isSelectMode() { isSelectMode() {

@ -163,7 +163,7 @@ export default {
if (this.$parent.editMode && ignoreSendUrl && Object.keys(this.$root.monitorList).length) { if (this.$parent.editMode && ignoreSendUrl && Object.keys(this.$root.monitorList).length) {
return this.$root.monitorList[monitor.element.id].type === "http" || this.$root.monitorList[monitor.element.id].type === "keyword" || this.$root.monitorList[monitor.element.id].type === "json-query"; return this.$root.monitorList[monitor.element.id].type === "http" || this.$root.monitorList[monitor.element.id].type === "keyword" || this.$root.monitorList[monitor.element.id].type === "json-query";
} }
return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://" && !this.editMode; return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://";
}, },
/** /**

@ -0,0 +1,185 @@
<template>
<form @submit.prevent="submit">
<div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 id="exampleModalLabel" class="modal-title">
{{ $t("Add a Remote Browser") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div class="modal-body">
<div class="mb-3">
<label for="remote-browser-name" class="form-label">{{ $t("Friendly Name") }}</label>
<input id="remote-browser-name" v-model="remoteBrowser.name" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="remote-browser-url" class="form-label">{{ $t("URL") }}</label>
<input id="remote-browser-url" v-model="remoteBrowser.url" type="text" class="form-control" required>
<div class="form-text mt-3">
{{ $t("Examples") }}:
<ul>
<li>ws://chrome.browserless.io/playwright?token=YOUR-API-TOKEN</li>
</ul>
</div>
</div>
</div>
<div class="modal-footer">
<button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
{{ $t("Delete") }}
</button>
<button type="button" class="btn btn-warning" :disabled="processing" @click="test">
{{ $t("Test") }}
</button>
<button type="submit" class="btn btn-primary" :disabled="processing">
<div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
{{ $t("Save") }}
</button>
</div>
</div>
</div>
</div>
</form>
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteDockerHost">
{{ $t("deleteRemoteBrowserMessage") }}
</Confirm>
</template>
<script>
import { Modal } from "bootstrap";
import Confirm from "./Confirm.vue";
export default {
components: {
Confirm,
},
props: {},
emits: [ "added" ],
data() {
return {
modal: null,
processing: false,
id: null,
remoteBrowser: {
name: "",
url: "",
// Do not set default value here, please scroll to show()
}
};
},
mounted() {
this.modal = new Modal(this.$refs.modal);
},
methods: {
/**
* Confirm deletion of docker host
* @returns {void}
*/
deleteConfirm() {
this.modal.hide();
this.$refs.confirmDelete.show();
},
/**
* Show specified docker host
* @param {number} remoteBrowserID ID of host to show
* @returns {void}
*/
show(remoteBrowserID) {
if (remoteBrowserID) {
let found = false;
this.id = remoteBrowserID;
for (let n of this.$root.remoteBrowserList) {
if (n.id === remoteBrowserID) {
this.remoteBrowser = n;
found = true;
break;
}
}
if (!found) {
this.$root.toastError(this.$t("Remote Browser not found!"));
}
} else {
this.id = null;
this.remoteBrowser = {
name: "",
url: "",
};
}
this.modal.show();
},
/**
* Add docker host
* @returns {void}
*/
submit() {
this.processing = true;
this.$root.getSocket().emit("addRemoteBrowser", this.remoteBrowser, this.id, (res) => {
this.$root.toastRes(res);
this.processing = false;
if (res.ok) {
this.modal.hide();
// Emit added event, doesn't emit edit.
if (! this.id) {
this.$emit("added", res.id);
}
}
});
},
/**
* Test the docker host
* @returns {void}
*/
test() {
this.processing = true;
this.$root.getSocket().emit("testRemoteBrowser", this.remoteBrowser, (res) => {
this.$root.toastRes(res);
this.processing = false;
});
},
/**
* Delete this docker host
* @returns {void}
*/
deleteDockerHost() {
this.processing = true;
this.$root.getSocket().emit("deleteRemoteBrowser", this.id, (res) => {
this.$root.toastRes(res);
this.processing = false;
if (res.ok) {
this.modal.hide();
}
});
},
},
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.dark {
.modal-dialog .form-text, .modal-dialog p {
color: $dark-font-color;
}
}
</style>

@ -0,0 +1,52 @@
<template>
<div ref="modal" class="modal fade" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ $t("Browser Screenshot") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div class="modal-body"></div>
<img :src="imageURL" alt="screenshot of the website">
</div>
</div>
</div>
</template>
<script lang="ts">
import { Modal } from "bootstrap";
export default {
props: {
imageURL: {
type: String,
required: true,
},
},
data() {
return {
modal: null,
};
},
mounted() {
this.modal = new Modal(this.$refs.modal);
},
methods: {
show() {
this.modal.show();
},
},
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.dark {
.modal-dialog .form-text, .modal-dialog p {
color: $dark-font-color;
}
}
</style>

@ -13,6 +13,15 @@
<div class="mb-3"> <div class="mb-3">
<label for="ntfy-priority" class="form-label">{{ $t("Priority") }}</label> <label for="ntfy-priority" class="form-label">{{ $t("Priority") }}</label>
<input id="ntfy-priority" v-model="$parent.notification.ntfyPriority" type="number" class="form-control" required min="1" max="5" step="1"> <input id="ntfy-priority" v-model="$parent.notification.ntfyPriority" type="number" class="form-control" required min="1" max="5" step="1">
<div class="form-text">
<p v-if="$parent.notification.ntfyPriority >= 5">
{{ $t("ntfyPriorityHelptextAllEvents") }}
</p>
<i18n-t v-else tag="p" keypath="ntfyPriorityHelptextAllExceptDown">
<code>DOWN</code>
<code>{{ $parent.notification.ntfyPriority + 1 }}</code>
</i18n-t>
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="authentication-method" class="form-label">{{ $t("ntfyAuthenticationMethod") }}</label> <label for="authentication-method" class="form-label">{{ $t("ntfyAuthenticationMethod") }}</label>

@ -187,46 +187,6 @@
</div> </div>
</div> </div>
<!-- DNS Cache -->
<div class="mb-4">
<label class="form-label">
{{ $t("Enable DNS Cache") }}
<div class="form-text">
{{ $t("dnsCacheDescription") }}
</div>
</label>
<div class="form-check">
<input
id="dnsCacheEnable"
v-model="settings.dnsCache"
class="form-check-input"
type="radio"
name="dnsCache"
:value="true"
required
/>
<label class="form-check-label" for="dnsCacheEnable">
{{ $t("Enable") }}
</label>
</div>
<div class="form-check">
<input
id="dnsCacheDisable"
v-model="settings.dnsCache"
class="form-check-input"
type="radio"
name="dnsCache"
:value="false"
required
/>
<label class="form-check-label" for="dnsCacheDisable">
{{ $t("Disable") }}
</label>
</div>
</div>
<!-- Chrome Executable --> <!-- Chrome Executable -->
<div class="mb-4"> <div class="mb-4">
<label class="form-label" for="primaryBaseURL"> <label class="form-label" for="primaryBaseURL">

@ -60,13 +60,13 @@
<div class="mt-1 mb-3 ps-2 cert-exp-days col-12 col-xl-6"> <div class="mt-1 mb-3 ps-2 cert-exp-days col-12 col-xl-6">
<div v-for="day in settings.tlsExpiryNotifyDays" :key="day" class="d-flex align-items-center justify-content-between cert-exp-day-row py-2"> <div v-for="day in settings.tlsExpiryNotifyDays" :key="day" class="d-flex align-items-center justify-content-between cert-exp-day-row py-2">
<span>{{ day }} {{ $tc("day", day) }}</span> <span>{{ day }} {{ $tc("day", day) }}</span>
<button type="button" class="btn-rm-expiry btn btn-outline-danger ms-2 py-1" @click="removeExpiryNotifDay(day)"> <button type="button" class="btn-rm-expiry btn btn-outline-danger ms-2 py-1" :aria-label="$t('Remove the expiry notification')" @click="removeExpiryNotifDay(day)">
<font-awesome-icon class="" icon="times" /> <font-awesome-icon icon="times" />
</button> </button>
</div> </div>
</div> </div>
<div class="col-12 col-xl-6"> <div class="col-12 col-xl-6">
<ActionInput v-model="expiryNotifInput" :type="'number'" :placeholder="$t('day')" :icon="'plus'" :action="() => addExpiryNotifDay(expiryNotifInput)" /> <ActionInput v-model="expiryNotifInput" :type="'number'" :placeholder="$t('day')" :icon="'plus'" :action="() => addExpiryNotifDay(expiryNotifInput)" :action-aria-label="$t('Add a new expiry notification day')" />
</div> </div>
<div> <div>
<button class="btn btn-primary" type="button" @click="saveSettings()"> <button class="btn btn-primary" type="button" @click="saveSettings()">

@ -0,0 +1,53 @@
<template>
<div>
<div class="dockerHost-list my-4">
<p v-if="$root.remoteBrowserList.length === 0">
{{ $t("Not available, please setup.") }}
</p>
<ul class="list-group mb-3" style="border-radius: 1rem;">
<li v-for="(remoteBrowser, index) in $root.remoteBrowserList" :key="index" class="list-group-item">
{{ remoteBrowser.name }}<br>
<a href="#" @click="$refs.remoteBrowserDialog.show(remoteBrowser.id)">{{ $t("Edit") }}</a>
</li>
</ul>
<button class="btn btn-primary me-2" type="button" @click="$refs.remoteBrowserDialog.show()">
<font-awesome-icon icon="plus" /> {{ $t("Add Remote Browser") }}
</button>
</div>
<div class="my-4 pt-4">
<h5 class="my-4 settings-subheading">{{ $t("What is a Remote Browser?") }}</h5>
<p>{{ $t("remoteBrowsersDescription") }} <a href="https://hub.docker.com/r/browserless/chrome">{{ $t("self-hosted container") }}</a></p>
</div>
<RemoteBrowserDialog ref="remoteBrowserDialog" />
</div>
</template>
<script>
import RemoteBrowserDialog from "../../components/RemoteBrowserDialog.vue";
export default {
components: {
RemoteBrowserDialog,
},
data() {
return {};
},
computed: {
settings() {
return this.$parent.$parent.$parent.settings;
},
saveSettings() {
return this.$parent.$parent.$parent.saveSettings;
},
settingsLoaded() {
return this.$parent.$parent.$parent.settingsLoaded;
},
}
};
</script>

@ -4,6 +4,7 @@
"setupDatabaseEmbeddedMariaDB": "You don't need to set anything. This docker image have embedded and configured a MariaDB for you automatically. Uptime Kuma will connect to this database via unix socket.", "setupDatabaseEmbeddedMariaDB": "You don't need to set anything. This docker image have embedded and configured a MariaDB for you automatically. Uptime Kuma will connect to this database via unix socket.",
"setupDatabaseMariaDB": "Connect to an external MariaDB database. You need to set the database connection information.", "setupDatabaseMariaDB": "Connect to an external MariaDB database. You need to set the database connection information.",
"setupDatabaseSQLite": "A simple database file, recommended for small-scale deployments. Prior to v2.0.0, Uptime Kuma used SQLite as the default database.", "setupDatabaseSQLite": "A simple database file, recommended for small-scale deployments. Prior to v2.0.0, Uptime Kuma used SQLite as the default database.",
"settingUpDatabaseMSG": "Setting up the database. It may take a while, please be patient.",
"dbName": "Database Name", "dbName": "Database Name",
"Settings": "Settings", "Settings": "Settings",
"Dashboard": "Dashboard", "Dashboard": "Dashboard",
@ -192,6 +193,7 @@
"Pink": "Pink", "Pink": "Pink",
"Custom": "Custom", "Custom": "Custom",
"Search...": "Search…", "Search...": "Search…",
"Search monitored sites": "Search monitored sites",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
"Entry Page": "Entry Page", "Entry Page": "Entry Page",
@ -247,8 +249,8 @@
"Current User": "Current User", "Current User": "Current User",
"topic": "Topic", "topic": "Topic",
"topicExplanation": "MQTT topic to monitor", "topicExplanation": "MQTT topic to monitor",
"successMessage": "Success Message", "successKeyword": "Success Keyword",
"successMessageExplanation": "MQTT message that will be considered as success", "successKeywordExplanation": "MQTT Keyword that will be considered as success",
"recent": "Recent", "recent": "Recent",
"Reset Token": "Reset Token", "Reset Token": "Reset Token",
"Done": "Done", "Done": "Done",
@ -322,6 +324,7 @@
"Accept characters:": "Accept characters:", "Accept characters:": "Accept characters:",
"startOrEndWithOnly": "Start or end with {0} only", "startOrEndWithOnly": "Start or end with {0} only",
"No consecutive dashes": "No consecutive dashes", "No consecutive dashes": "No consecutive dashes",
"statusPageSpecialSlugDesc": "Special slug {0}: this page will be shown when no slug is provided",
"Next": "Next", "Next": "Next",
"The slug is already taken. Please choose another slug.": "The slug is already taken. Please choose another slug.", "The slug is already taken. Please choose another slug.": "The slug is already taken. Please choose another slug.",
"No Proxy": "No Proxy", "No Proxy": "No Proxy",
@ -349,6 +352,8 @@
"Fingerprint:": "Fingerprint:", "Fingerprint:": "Fingerprint:",
"No status pages": "No status pages", "No status pages": "No status pages",
"Domain Name Expiry Notification": "Domain Name Expiry Notification", "Domain Name Expiry Notification": "Domain Name Expiry Notification",
"Add a new expiry notification day": "Add a new expiry notification day",
"Remove the expiry notification": "Remove the expiry notification day",
"Proxy": "Proxy", "Proxy": "Proxy",
"Date Created": "Date Created", "Date Created": "Date Created",
"Footer Text": "Footer Text", "Footer Text": "Footer Text",
@ -684,6 +689,10 @@
"Notify Channel": "Notify Channel", "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.", "aboutNotifyChannel": "Notify channel will trigger a desktop or mobile notification for all members of the channel, whether their availability is set to active or away.",
"Uptime Kuma URL": "Uptime Kuma URL", "Uptime Kuma URL": "Uptime Kuma URL",
"setup a new monitor group": "setup a new monitor group",
"openModalTo": "open modal to {0}",
"Add a domain": "Add a domain",
"Remove domain": "Remove domain '{0}'",
"Icon Emoji": "Icon Emoji", "Icon Emoji": "Icon Emoji",
"signalImportant": "IMPORTANT: You cannot mix groups and numbers in recipients!", "signalImportant": "IMPORTANT: You cannot mix groups and numbers in recipients!",
"aboutWebhooks": "More info about Webhooks on: {0}", "aboutWebhooks": "More info about Webhooks on: {0}",
@ -777,6 +786,8 @@
"lunaseaDeviceID": "Device ID", "lunaseaDeviceID": "Device ID",
"lunaseaUserID": "User ID", "lunaseaUserID": "User ID",
"ntfyAuthenticationMethod": "Authentication Method", "ntfyAuthenticationMethod": "Authentication Method",
"ntfyPriorityHelptextAllEvents": "All events are send with the maximum priority",
"ntfyPriorityHelptextAllExceptDown": "All events are send with this priority, except {0}-events, which have a priority of {1}",
"ntfyUsernameAndPassword": "Username and Password", "ntfyUsernameAndPassword": "Username and Password",
"twilioAccountSID": "Account SID", "twilioAccountSID": "Account SID",
"twilioApiKey": "Api Key (optional)", "twilioApiKey": "Api Key (optional)",
@ -860,5 +871,15 @@
"successEnabled": "Enabled Successfully.", "successEnabled": "Enabled Successfully.",
"tagNotFound": "Tag not found.", "tagNotFound": "Tag not found.",
"foundChromiumVersion": "Found Chromium/Chrome. Version: {0}", "foundChromiumVersion": "Found Chromium/Chrome. Version: {0}",
"GrafanaOncallUrl": "Grafana Oncall URL" "Remote Browsers": "Remote Browsers",
"Remote Browser": "Remote Browser",
"Add a Remote Browser": "Add a Remote Browser",
"Remote Browser not found!": "Remote Browser not found!",
"remoteBrowsersDescription": "Remote Browsers are an alternative to running Chromium locally. Setup with a service like browserless.io or connect to your own",
"self-hosted container": "self-hosted container",
"remoteBrowserToggle": "By default Chromium runs inside the Uptime Kuma container. You can use a remote browser by toggling this switch.",
"useRemoteBrowser": "Use a Remote Browser",
"deleteRemoteBrowserMessage": "Are you sure want to delete this Remote Browser for all monitors?",
"GrafanaOncallUrl": "Grafana Oncall URL",
"Browser Screenshot": "Browser Screenshot"
} }

@ -46,6 +46,7 @@ export default {
tlsInfoList: {}, tlsInfoList: {},
notificationList: [], notificationList: [],
dockerHostList: [], dockerHostList: [],
remoteBrowserList: [],
statusPageListLoaded: false, statusPageListLoaded: false,
statusPageList: [], statusPageList: [],
proxyList: [], proxyList: [],
@ -174,6 +175,10 @@ export default {
this.dockerHostList = data; this.dockerHostList = data;
}); });
socket.on("remoteBrowserList", (data) => {
this.remoteBrowserList = data;
});
socket.on("heartbeat", (data) => { socket.on("heartbeat", (data) => {
if (! (data.monitorID in this.heartbeatList)) { if (! (data.monitorID in this.heartbeatList)) {
this.heartbeatList[data.monitorID] = []; this.heartbeatList[data.monitorID] = [];
@ -283,6 +288,10 @@ export default {
socket.on("initServerTimezone", () => { socket.on("initServerTimezone", () => {
socket.emit("initServerTimezone", dayjs.tz.guess()); socket.emit("initServerTimezone", dayjs.tz.guess());
}); });
socket.on("refresh", () => {
location.reload();
});
}, },
/** /**

@ -25,6 +25,9 @@
<mark>a-z</mark> <mark>0-9</mark> <mark>a-z</mark> <mark>0-9</mark>
</i18n-t> </i18n-t>
<li>{{ $t("No consecutive dashes") }} <mark>--</mark></li> <li>{{ $t("No consecutive dashes") }} <mark>--</mark></li>
<i18n-t tag="li" keypath="statusPageSpecialSlugDesc">
<mark class="me-1">default</mark>
</i18n-t>
</ul> </ul>
</div> </div>
</div> </div>

@ -184,9 +184,10 @@
<!-- Screenshot --> <!-- Screenshot -->
<div v-if="monitor.type === 'real-browser'" class="shadow-box"> <div v-if="monitor.type === 'real-browser'" class="shadow-box">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6 zoom-cursor">
<img :src="screenshotURL" alt style="width: 100%;"> <img :src="screenshotURL" style="width: 100%;" alt="screenshot of the website" @click="showScreenshotDialog">
</div> </div>
<ScreenshotDialog ref="screenshotDialog" :imageURL="screenshotURL" />
</div> </div>
</div> </div>
@ -283,6 +284,7 @@ import "prismjs/components/prism-javascript";
import "prismjs/components/prism-css"; import "prismjs/components/prism-css";
import { PrismEditor } from "vue-prism-editor"; import { PrismEditor } from "vue-prism-editor";
import "vue-prism-editor/dist/prismeditor.min.css"; import "vue-prism-editor/dist/prismeditor.min.css";
import ScreenshotDialog from "../components/ScreenshotDialog.vue";
export default { export default {
components: { components: {
@ -297,6 +299,7 @@ export default {
Tag, Tag,
CertificateInfo, CertificateInfo,
PrismEditor, PrismEditor,
ScreenshotDialog
}, },
data() { data() {
return { return {
@ -476,6 +479,14 @@ export default {
this.$refs.confirmDelete.show(); this.$refs.confirmDelete.show();
}, },
/**
* Show Screenshot Dialog
* @returns {void}
*/
showScreenshotDialog() {
this.$refs.screenshotDialog.show();
},
/** /**
* Show dialog to confirm clearing events * Show dialog to confirm clearing events
* @returns {void} * @returns {void}

@ -144,6 +144,30 @@
</div> </div>
</div> </div>
<!-- Remote Browser -->
<div v-if="monitor.type === 'real-browser'" class="my-3">
<!-- Toggle -->
<div class="my-3 form-check">
<input id="toggle" v-model="remoteBrowsersToggle" class="form-check-input" type="checkbox">
<label class="form-check-label" for="toggle">
{{ $t("useRemoteBrowser") }}
</label>
<div class="form-text">
{{ $t("remoteBrowserToggle") }}
</div>
</div>
<div v-if="remoteBrowsersToggle">
<label for="remote-browser" class="form-label">{{ $t("Remote Browser") }}</label>
<ActionSelect
v-model="monitor.remote_browser"
:options="remoteBrowsersOptions"
icon="plus"
:action="() => $refs.remoteBrowserDialog.show()"
/>
</div>
</div>
<!-- Json Query --> <!-- Json Query -->
<div v-if="monitor.type === 'json-query'" class="my-3"> <div v-if="monitor.type === 'json-query'" class="my-3">
<label for="jsonPath" class="form-label">{{ $t("Json Query") }}</label> <label for="jsonPath" class="form-label">{{ $t("Json Query") }}</label>
@ -291,7 +315,9 @@
<div class="mb-3"> <div class="mb-3">
<label for="docker-host" class="form-label">{{ $t("Docker Host") }}</label> <label for="docker-host" class="form-label">{{ $t("Docker Host") }}</label>
<ActionSelect <ActionSelect
id="docker-host"
v-model="monitor.docker_host" v-model="monitor.docker_host"
:action-aria-label="$t('openModalTo', $t('Setup Docker Host'))"
:options="dockerHostOptionsList" :options="dockerHostOptionsList"
:disabled="$root.dockerHostList == null || $root.dockerHostList.length === 0" :disabled="$root.dockerHostList == null || $root.dockerHostList.length === 0"
:icon="'plus'" :icon="'plus'"
@ -323,11 +349,33 @@
</div> </div>
<div class="my-3"> <div class="my-3">
<label for="mqttSuccessMessage" class="form-label">MQTT {{ $t("successMessage") }}</label> <label for="mqttCheckType" class="form-label">MQTT {{ $t("Check Type") }}</label>
<input id="mqttSuccessMessage" v-model="monitor.mqttSuccessMessage" type="text" class="form-control"> <select id="mqttCheckType" v-model="monitor.mqttCheckType" class="form-select" required>
<option value="keyword">{{ $t("Keyword") }}</option>
<option value="json-query">{{ $t("Json Query") }}</option>
</select>
</div>
<div v-if="monitor.mqttCheckType === 'keyword'" class="my-3">
<label for="mqttSuccessKeyword" class="form-label">MQTT {{ $t("successKeyword") }}</label>
<input id="mqttSuccessKeyword" v-model="monitor.mqttSuccessMessage" type="text" class="form-control">
<div class="form-text"> <div class="form-text">
{{ $t("successMessageExplanation") }} {{ $t("successKeywordExplanation") }}
</div>
</div>
<!-- Json Query -->
<div v-if="monitor.mqttCheckType === '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> </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> </div>
</template> </template>
@ -368,11 +416,20 @@
<input id="connectionString" v-model="monitor.databaseConnectionString" type="text" class="form-control" required> <input id="connectionString" v-model="monitor.databaseConnectionString" type="text" class="form-control" required>
</div> </div>
</template> </template>
<template v-if="monitor.type === 'mysql'">
<div class="my-3">
<label for="mysql-password" class="form-label">{{ $t("Password") }}</label>
<!-- TODO: Rename monitor.radiusPassword to monitor.password for general use -->
<HiddenInput id="mysql-password" v-model="monitor.radiusPassword" autocomplete="false"></HiddenInput>
</div>
</template>
<!-- SQL Server / PostgreSQL / MySQL --> <!-- SQL Server / PostgreSQL / MySQL -->
<template v-if="monitor.type === 'sqlserver' || monitor.type === 'postgres' || monitor.type === 'mysql'"> <template v-if="monitor.type === 'sqlserver' || monitor.type === 'postgres' || monitor.type === 'mysql'">
<div class="my-3"> <div class="my-3">
<label for="sqlQuery" class="form-label">{{ $t("Query") }}</label> <label for="sqlQuery" class="form-label">{{ $t("Query") }}</label>
<textarea id="sqlQuery" v-model="monitor.databaseQuery" class="form-control" :placeholder="$t('Example:', [ 'select getdate()' ])" required></textarea> <textarea id="sqlQuery" v-model="monitor.databaseQuery" class="form-control" :placeholder="$t('Example:', [ 'SELECT 1' ])"></textarea>
</div> </div>
</template> </template>
@ -492,9 +549,11 @@
<!-- Parent Monitor --> <!-- Parent Monitor -->
<div class="my-3"> <div class="my-3">
<label for="parent" class="form-label">{{ $t("Monitor Group") }}</label> <label for="monitorGroupSelector" class="form-label">{{ $t("Monitor Group") }}</label>
<ActionSelect <ActionSelect
id="monitorGroupSelector"
v-model="monitor.parent" v-model="monitor.parent"
:action-aria-label="$t('openModalTo', 'setup a new monitor group')"
:options="parentMonitorOptionsList" :options="parentMonitorOptionsList"
:disabled="sortedGroupMonitorList.length === 0 && draftGroupName == null" :disabled="sortedGroupMonitorList.length === 0 && draftGroupName == null"
:icon="'plus'" :icon="'plus'"
@ -825,6 +884,7 @@
<DockerHostDialog ref="dockerHostDialog" @added="addedDockerHost" /> <DockerHostDialog ref="dockerHostDialog" @added="addedDockerHost" />
<ProxyDialog ref="proxyDialog" @added="addedProxy" /> <ProxyDialog ref="proxyDialog" @added="addedProxy" />
<CreateGroupDialog ref="createGroupDialog" @added="addedDraftGroup" /> <CreateGroupDialog ref="createGroupDialog" @added="addedDraftGroup" />
<RemoteBrowserDialog ref="remoteBrowserDialog" @added="addedRemoteBrowser" />
</div> </div>
</transition> </transition>
</template> </template>
@ -837,11 +897,12 @@ import CopyableInput from "../components/CopyableInput.vue";
import CreateGroupDialog from "../components/CreateGroupDialog.vue"; import CreateGroupDialog from "../components/CreateGroupDialog.vue";
import NotificationDialog from "../components/NotificationDialog.vue"; import NotificationDialog from "../components/NotificationDialog.vue";
import DockerHostDialog from "../components/DockerHostDialog.vue"; import DockerHostDialog from "../components/DockerHostDialog.vue";
import RemoteBrowserDialog from "../components/RemoteBrowserDialog.vue";
import ProxyDialog from "../components/ProxyDialog.vue"; import ProxyDialog from "../components/ProxyDialog.vue";
import TagsManager from "../components/TagsManager.vue"; import TagsManager from "../components/TagsManager.vue";
import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND } from "../util.ts"; import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, sleep } from "../util.ts";
import { hostNameRegexPattern } from "../util-frontend"; import { hostNameRegexPattern } from "../util-frontend";
import { sleep } from "../util"; import HiddenInput from "../components/HiddenInput.vue";
const toast = useToast; const toast = useToast;
@ -856,7 +917,7 @@ const monitorDefaults = {
interval: 60, interval: 60,
retryInterval: 60, retryInterval: 60,
resendInterval: 0, resendInterval: 0,
maxretries: 1, maxretries: 0,
timeout: 48, timeout: 48,
notificationIDList: {}, notificationIDList: {},
ignoreTls: false, ignoreTls: false,
@ -874,6 +935,7 @@ const monitorDefaults = {
mqttPassword: "", mqttPassword: "",
mqttTopic: "", mqttTopic: "",
mqttSuccessMessage: "", mqttSuccessMessage: "",
mqttCheckType: "keyword",
authMethod: null, authMethod: null,
oauth_auth_method: "client_secret_basic", oauth_auth_method: "client_secret_basic",
httpBodyEncoding: "json", httpBodyEncoding: "json",
@ -882,17 +944,21 @@ const monitorDefaults = {
mechanism: "None", mechanism: "None",
}, },
kafkaProducerSsl: false, kafkaProducerSsl: false,
kafkaProducerAllowAutoTopicCreation: false,
gamedigGivenPortOnly: true, gamedigGivenPortOnly: true,
remote_browser: null
}; };
export default { export default {
components: { components: {
HiddenInput,
ActionSelect, ActionSelect,
ProxyDialog, ProxyDialog,
CopyableInput, CopyableInput,
CreateGroupDialog, CreateGroupDialog,
NotificationDialog, NotificationDialog,
DockerHostDialog, DockerHostDialog,
RemoteBrowserDialog,
TagsManager, TagsManager,
VueMultiselect, VueMultiselect,
}, },
@ -920,6 +986,7 @@ export default {
"mongodb": "mongodb://username:password@host:port/database", "mongodb": "mongodb://username:password@host:port/database",
}, },
draftGroupName: null, draftGroupName: null,
remoteBrowsersEnabled: false,
}; };
}, },
@ -943,7 +1010,31 @@ export default {
} }
return this.$t(name); return this.$t(name);
}, },
remoteBrowsersOptions() {
return this.$root.remoteBrowserList.map(browser => {
return {
label: browser.name,
value: browser.id,
};
});
},
remoteBrowsersToggle: {
get() {
return this.remoteBrowsersEnabled || this.monitor.remote_browser != null;
},
set(value) {
if (value) {
this.remoteBrowsersEnabled = true;
if (this.monitor.remote_browser == null && this.$root.remoteBrowserList.length > 0) {
// set a default remote browser if there is one. Otherwise, the user will have to select one manually.
this.monitor.remote_browser = this.$root.remoteBrowserList[0].id;
}
} else {
this.remoteBrowsersEnabled = false;
this.monitor.remote_browser = null;
}
}
},
isAdd() { isAdd() {
return this.$route.path === "/add"; return this.$route.path === "/add";
}, },

@ -33,7 +33,7 @@ export default {
} else if (res.type === "entryPage") { // Dev only. For production, the logic is in the server side } else if (res.type === "entryPage") { // Dev only. For production, the logic is in the server side
const entryPage = res.entryPage; const entryPage = res.entryPage;
if (entryPage.startsWith("statusPage-")) { if (entryPage?.startsWith("statusPage-")) {
this.$router.push("/status/" + entryPage.replace("statusPage-", "")); this.$router.push("/status/" + entryPage.replace("statusPage-", ""));
} else { } else {
// should the old setting style still exist here? // should the old setting style still exist here?

@ -104,6 +104,9 @@ export default {
"docker-hosts": { "docker-hosts": {
title: this.$t("Docker Hosts"), title: this.$t("Docker Hosts"),
}, },
"remote-browsers": {
title: this.$t("Remote Browsers"),
},
security: { security: {
title: this.$t("Security"), title: this.$t("Security"),
}, },
@ -168,10 +171,6 @@ export default {
this.settings.nscd = true; this.settings.nscd = true;
} }
if (this.settings.dnsCache === undefined) {
this.settings.dnsCache = false;
}
if (this.settings.keepDataPeriodDays === undefined) { if (this.settings.keepDataPeriodDays === undefined) {
this.settings.keepDataPeriodDays = 180; this.settings.keepDataPeriodDays = 180;
} }

@ -11,7 +11,7 @@
<div v-if="info.runningSetup" class="mt-5"> <div v-if="info.runningSetup" class="mt-5">
<div class="alert alert-success mx-3 px-4" role="alert"> <div class="alert alert-success mx-3 px-4" role="alert">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<strong>Setting up the database. It may take a while, please be patient.</strong> <strong>{{ $t("settingUpDatabaseMSG") }}</strong>
<div class="ms-3 pt-1"> <div class="ms-3 pt-1">
<div class="spinner-border" role="status" aria-hidden="true"></div> <div class="spinner-border" role="status" aria-hidden="true"></div>
</div> </div>

@ -69,13 +69,17 @@
<div class="my-3"> <div class="my-3">
<label class="form-label"> <label class="form-label">
{{ $t("Domain Names") }} {{ $t("Domain Names") }}
<font-awesome-icon icon="plus-circle" class="btn-add-domain action text-primary" @click="addDomainField" /> <button class="p-0 bg-transparent border-0" :aria-label="$t('Add a domain')" @click="addDomainField">
<font-awesome-icon icon="plus-circle" class="action text-primary" />
</button>
</label> </label>
<ul class="list-group domain-name-list"> <ul class="list-group domain-name-list">
<li v-for="(domain, index) in config.domainNameList" :key="index" class="list-group-item"> <li v-for="(domain, index) in config.domainNameList" :key="index" class="list-group-item">
<input v-model="config.domainNameList[index]" type="text" class="no-bg domain-input" placeholder="example.com" /> <input v-model="config.domainNameList[index]" type="text" class="no-bg domain-input" placeholder="example.com" />
<font-awesome-icon icon="times" class="action remove ms-2 me-3 text-danger" @click="removeDomain(index)" /> <button class="p-0 bg-transparent border-0" :aria-label="$t('Remove domain', [ domain ])" @click="removeDomain(index)">
<font-awesome-icon icon="times" class="action remove ms-2 me-3 text-danger" />
</button>
</li> </li>
</ul> </ul>
</div> </div>

@ -31,6 +31,7 @@ import MonitorHistory from "./components/settings/MonitorHistory.vue";
const Security = () => import("./components/settings/Security.vue"); const Security = () => import("./components/settings/Security.vue");
import Proxies from "./components/settings/Proxies.vue"; import Proxies from "./components/settings/Proxies.vue";
import About from "./components/settings/About.vue"; import About from "./components/settings/About.vue";
import RemoteBrowsers from "./components/settings/RemoteBrowsers.vue";
const routes = [ const routes = [
{ {
@ -113,6 +114,10 @@ const routes = [
path: "docker-hosts", path: "docker-hosts",
component: DockerHosts, component: DockerHosts,
}, },
{
path: "remote-browsers",
component: RemoteBrowsers,
},
{ {
path: "security", path: "security",
component: Security, component: Security,

@ -8,11 +8,13 @@
// Backend uses the compiled file util.js // Backend uses the compiled file util.js
// Frontend uses util.ts // Frontend uses util.ts
*/ */
var _a;
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = exports.CONSOLE_STYLE_FgViolet = exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isDev = void 0; exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = exports.CONSOLE_STYLE_FgViolet = exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0;
exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = void 0; exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = void 0;
const dayjs = require("dayjs"); const dayjs = require("dayjs");
exports.isDev = process.env.NODE_ENV === "development"; exports.isDev = process.env.NODE_ENV === "development";
exports.isNode = typeof process !== "undefined" && ((_a = process === null || process === void 0 ? void 0 : process.versions) === null || _a === void 0 ? void 0 : _a.node);
exports.appName = "Uptime Kuma"; exports.appName = "Uptime Kuma";
exports.DOWN = 0; exports.DOWN = 0;
exports.UP = 1; exports.UP = 1;
@ -76,6 +78,24 @@ const consoleLevelColors = {
"ERROR": exports.CONSOLE_STYLE_FgRed, "ERROR": exports.CONSOLE_STYLE_FgRed,
"DEBUG": exports.CONSOLE_STYLE_FgGray, "DEBUG": exports.CONSOLE_STYLE_FgGray,
}; };
exports.badgeConstants = {
naColor: "#999",
defaultUpColor: "#66c20a",
defaultWarnColor: "#eed202",
defaultDownColor: "#c2290a",
defaultPendingColor: "#f8a306",
defaultMaintenanceColor: "#1747f5",
defaultPingColor: "blue",
defaultStyle: "flat",
defaultPingValueSuffix: "ms",
defaultPingLabelSuffix: "h",
defaultUptimeValueSuffix: "%",
defaultUptimeLabelSuffix: "h",
defaultCertExpValueSuffix: " days",
defaultCertExpLabelSuffix: "h",
defaultCertExpireWarnDays: "14",
defaultCertExpireDownDays: "7"
};
function flipStatus(s) { function flipStatus(s) {
if (s === exports.UP) { if (s === exports.UP) {
return exports.DOWN; return exports.DOWN;
@ -123,6 +143,9 @@ class Logger {
} }
} }
log(module, msg, level) { log(module, msg, level) {
if (level === "DEBUG" && !exports.isDev) {
return;
}
if (this.hideLog[level] && this.hideLog[level].includes(module.toLowerCase())) { if (this.hideLog[level] && this.hideLog[level].includes(module.toLowerCase())) {
return; return;
} }
@ -137,40 +160,67 @@ class Logger {
} }
const levelColor = consoleLevelColors[level]; const levelColor = consoleLevelColors[level];
const moduleColor = consoleModuleColors[intHash(module, consoleModuleColors.length)]; const moduleColor = consoleModuleColors[intHash(module, consoleModuleColors.length)];
let timePart = exports.CONSOLE_STYLE_FgCyan + now + exports.CONSOLE_STYLE_Reset; let timePart;
let modulePart = "[" + moduleColor + module + exports.CONSOLE_STYLE_Reset + "]"; let modulePart;
let levelPart = levelColor + `${level}:` + exports.CONSOLE_STYLE_Reset; let levelPart;
if (level === "INFO") { let msgPart;
console.info(timePart, modulePart, levelPart, msg); if (exports.isNode) {
} switch (level) {
else if (level === "WARN") { case "DEBUG":
console.warn(timePart, modulePart, levelPart, msg); timePart = exports.CONSOLE_STYLE_FgGray + now + exports.CONSOLE_STYLE_Reset;
} break;
else if (level === "ERROR") { default:
let msgPart; timePart = exports.CONSOLE_STYLE_FgCyan + now + exports.CONSOLE_STYLE_Reset;
if (typeof msg === "string") { break;
msgPart = exports.CONSOLE_STYLE_FgRed + msg + exports.CONSOLE_STYLE_Reset;
}
else {
msgPart = msg;
} }
console.error(timePart, modulePart, levelPart, msgPart); modulePart = "[" + moduleColor + module + exports.CONSOLE_STYLE_Reset + "]";
} levelPart = levelColor + `${level}:` + exports.CONSOLE_STYLE_Reset;
else if (level === "DEBUG") { switch (level) {
if (exports.isDev) { case "ERROR":
timePart = exports.CONSOLE_STYLE_FgGray + now + exports.CONSOLE_STYLE_Reset; if (typeof msg === "string") {
let msgPart; msgPart = exports.CONSOLE_STYLE_FgRed + msg + exports.CONSOLE_STYLE_Reset;
if (typeof msg === "string") { }
msgPart = exports.CONSOLE_STYLE_FgGray + msg + exports.CONSOLE_STYLE_Reset; else {
} msgPart = msg;
else { }
break;
case "DEBUG":
if (typeof msg === "string") {
msgPart = exports.CONSOLE_STYLE_FgGray + msg + exports.CONSOLE_STYLE_Reset;
}
else {
msgPart = msg;
}
break;
default:
msgPart = msg; msgPart = msg;
} break;
console.debug(timePart, modulePart, levelPart, msgPart);
} }
} }
else { else {
console.log(timePart, modulePart, msg); timePart = now;
modulePart = `[${module}]`;
levelPart = `${level}:`;
msgPart = msg;
}
switch (level) {
case "ERROR":
console.error(timePart, modulePart, levelPart, msgPart);
break;
case "WARN":
console.warn(timePart, modulePart, levelPart, msgPart);
break;
case "INFO":
console.info(timePart, modulePart, levelPart, msgPart);
break;
case "DEBUG":
if (exports.isDev) {
console.debug(timePart, modulePart, levelPart, msgPart);
}
break;
default:
console.log(timePart, modulePart, levelPart, msgPart);
break;
} }
} }
info(module, msg) { info(module, msg) {

@ -1,3 +1,4 @@
/* eslint-disable camelcase */
/*! /*!
// Common Util for frontend and backend // Common Util for frontend and backend
// //
@ -17,6 +18,7 @@ import * as timezone from "dayjs/plugin/timezone";
import * as utc from "dayjs/plugin/utc"; import * as utc from "dayjs/plugin/utc";
export const isDev = process.env.NODE_ENV === "development"; export const isDev = process.env.NODE_ENV === "development";
export const isNode = typeof process !== "undefined" && process?.versions?.node;
export const appName = "Uptime Kuma"; export const appName = "Uptime Kuma";
export const DOWN = 0; export const DOWN = 0;
export const UP = 1; export const UP = 1;
@ -93,8 +95,30 @@ const consoleLevelColors : Record<string, string> = {
/** /**
* Flip the status of s * Flip the status of s
* @param s * @param s input status: UP or DOWN
* @returns {number} UP or DOWN
*/ */
export const badgeConstants = {
naColor: "#999",
defaultUpColor: "#66c20a",
defaultWarnColor: "#eed202",
defaultDownColor: "#c2290a",
defaultPendingColor: "#f8a306",
defaultMaintenanceColor: "#1747f5",
defaultPingColor: "blue", // as defined by badge-maker / shields.io
defaultStyle: "flat",
defaultPingValueSuffix: "ms",
defaultPingLabelSuffix: "h",
defaultUptimeValueSuffix: "%",
defaultUptimeLabelSuffix: "h",
defaultCertExpValueSuffix: " days",
defaultCertExpLabelSuffix: "h",
// Values Come From Default Notification Times
defaultCertExpireWarnDays: "14",
defaultCertExpireDownDays: "7"
};
/** Flip the status of s */
export function flipStatus(s: number) { export function flipStatus(s: number) {
if (s === UP) { if (s === UP) {
return DOWN; return DOWN;
@ -110,6 +134,7 @@ export function flipStatus(s: number) {
/** /**
* Delays for specified number of seconds * Delays for specified number of seconds
* @param ms Number of milliseconds to sleep for * @param ms Number of milliseconds to sleep for
* @returns {Promise<void>} Promise that resolves after ms
*/ */
export function sleep(ms: number) { export function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
@ -117,7 +142,8 @@ export function sleep(ms: number) {
/** /**
* PHP's ucfirst * PHP's ucfirst
* @param str * @param str string input
* @returns {string} string with first letter capitalized
*/ */
export function ucfirst(str: string) { export function ucfirst(str: string) {
if (!str) { if (!str) {
@ -130,7 +156,8 @@ export function ucfirst(str: string) {
/** /**
* @deprecated Use log.debug (https://github.com/louislam/uptime-kuma/pull/910) * @deprecated Use log.debug (https://github.com/louislam/uptime-kuma/pull/910)
* @param msg * @param msg Message to write
* @returns {void}
*/ */
export function debug(msg: unknown) { export function debug(msg: unknown) {
log.log("", msg, "debug"); log.log("", msg, "debug");
@ -180,8 +207,13 @@ class Logger {
* @param module The module the log comes from * @param module The module the log comes from
* @param msg Message to write * @param msg Message to write
* @param level Log level. One of INFO, WARN, ERROR, DEBUG or can be customized. * @param level Log level. One of INFO, WARN, ERROR, DEBUG or can be customized.
* @returns {void}
*/ */
log(module: string, msg: any, level: string) { log(module: string, msg: any, level: string) {
if (level === "DEBUG" && !isDev) {
return;
}
if (this.hideLog[level] && this.hideLog[level].includes(module.toLowerCase())) { if (this.hideLog[level] && this.hideLog[level].includes(module.toLowerCase())) {
return; return;
} }
@ -199,35 +231,72 @@ class Logger {
const levelColor = consoleLevelColors[level]; const levelColor = consoleLevelColors[level];
const moduleColor = consoleModuleColors[intHash(module, consoleModuleColors.length)]; const moduleColor = consoleModuleColors[intHash(module, consoleModuleColors.length)];
let timePart = CONSOLE_STYLE_FgCyan + now + CONSOLE_STYLE_Reset; let timePart: string;
let modulePart = "[" + moduleColor + module + CONSOLE_STYLE_Reset + "]"; let modulePart: string;
let levelPart = levelColor + `${level}:` + CONSOLE_STYLE_Reset; let levelPart: string;
let msgPart: string;
if (level === "INFO") {
console.info(timePart, modulePart, levelPart, msg); if (isNode) {
} else if (level === "WARN") { // Add console colors
console.warn(timePart, modulePart, levelPart, msg); switch (level) {
} else if (level === "ERROR") { case "DEBUG":
let msgPart :string; timePart = CONSOLE_STYLE_FgGray + now + CONSOLE_STYLE_Reset;
if (typeof msg === "string") { break;
msgPart = CONSOLE_STYLE_FgRed + msg + CONSOLE_STYLE_Reset; default:
} else { timePart = CONSOLE_STYLE_FgCyan + now + CONSOLE_STYLE_Reset;
msgPart = msg; break;
} }
console.error(timePart, modulePart, levelPart, msgPart);
} else if (level === "DEBUG") { modulePart = "[" + moduleColor + module + CONSOLE_STYLE_Reset + "]";
if (isDev) {
timePart = CONSOLE_STYLE_FgGray + now + CONSOLE_STYLE_Reset; levelPart = levelColor + `${level}:` + CONSOLE_STYLE_Reset;
let msgPart :string;
if (typeof msg === "string") { switch (level) {
msgPart = CONSOLE_STYLE_FgGray + msg + CONSOLE_STYLE_Reset; case "ERROR":
} else { if (typeof msg === "string") {
msgPart = CONSOLE_STYLE_FgRed + msg + CONSOLE_STYLE_Reset;
} else {
msgPart = msg;
}
break;
case "DEBUG":
if (typeof msg === "string") {
msgPart = CONSOLE_STYLE_FgGray + msg + CONSOLE_STYLE_Reset;
} else {
msgPart = msg;
}
break;
default:
msgPart = msg; msgPart = msg;
} break;
console.debug(timePart, modulePart, levelPart, msgPart);
} }
} else { } else {
console.log(timePart, modulePart, msg); // No console colors
timePart = now;
modulePart = `[${module}]`;
levelPart = `${level}:`;
msgPart = msg;
}
// Write to console
switch (level) {
case "ERROR":
console.error(timePart, modulePart, levelPart, msgPart);
break;
case "WARN":
console.warn(timePart, modulePart, levelPart, msgPart);
break;
case "INFO":
console.info(timePart, modulePart, levelPart, msgPart);
break;
case "DEBUG":
if (isDev) {
console.debug(timePart, modulePart, levelPart, msgPart);
}
break;
default:
console.log(timePart, modulePart, levelPart, msgPart);
break;
} }
} }
@ -235,6 +304,7 @@ class Logger {
* Log an INFO message * Log an INFO message
* @param module Module log comes from * @param module Module log comes from
* @param msg Message to write * @param msg Message to write
* @returns {void}
*/ */
info(module: string, msg: unknown) { info(module: string, msg: unknown) {
this.log(module, msg, "info"); this.log(module, msg, "info");
@ -244,6 +314,7 @@ class Logger {
* Log a WARN message * Log a WARN message
* @param module Module log comes from * @param module Module log comes from
* @param msg Message to write * @param msg Message to write
* @returns {void}
*/ */
warn(module: string, msg: unknown) { warn(module: string, msg: unknown) {
this.log(module, msg, "warn"); this.log(module, msg, "warn");
@ -253,6 +324,7 @@ class Logger {
* Log an ERROR message * Log an ERROR message
* @param module Module log comes from * @param module Module log comes from
* @param msg Message to write * @param msg Message to write
* @returns {void}
*/ */
error(module: string, msg: unknown) { error(module: string, msg: unknown) {
this.log(module, msg, "error"); this.log(module, msg, "error");
@ -262,6 +334,7 @@ class Logger {
* Log a DEBUG message * Log a DEBUG message
* @param module Module log comes from * @param module Module log comes from
* @param msg Message to write * @param msg Message to write
* @returns {void}
*/ */
debug(module: string, msg: unknown) { debug(module: string, msg: unknown) {
this.log(module, msg, "debug"); this.log(module, msg, "debug");
@ -272,6 +345,7 @@ class Logger {
* @param module Module log comes from * @param module Module log comes from
* @param exception The exception to include * @param exception The exception to include
* @param msg The message to write * @param msg The message to write
* @returns {void}
*/ */
exception(module: string, exception: unknown, msg: unknown) { exception(module: string, exception: unknown, msg: unknown) {
let finalMessage = exception; let finalMessage = exception;
@ -293,6 +367,7 @@ declare global { interface String { replaceAll(str: string, newStr: string): str
* https://gomakethings.com/how-to-replace-a-section-of-a-string-with-another-one-with-vanilla-js/ * https://gomakethings.com/how-to-replace-a-section-of-a-string-with-another-one-with-vanilla-js/
* @author Chris Ferdinandi * @author Chris Ferdinandi
* @license MIT * @license MIT
* @returns {void}
*/ */
export function polyfill() { export function polyfill() {
if (!String.prototype.replaceAll) { if (!String.prototype.replaceAll) {
@ -321,6 +396,7 @@ export class TimeLogger {
/** /**
* Output time since start of monitor * Output time since start of monitor
* @param name Name of monitor * @param name Name of monitor
* @returns {void}
*/ */
print(name: string) { print(name: string) {
if (isDev && process.env.TIMELOGGER === "1") { if (isDev && process.env.TIMELOGGER === "1") {
@ -331,8 +407,9 @@ export class TimeLogger {
/** /**
* Returns a random number between min (inclusive) and max (exclusive) * Returns a random number between min (inclusive) and max (exclusive)
* @param min * @param min minumim value, inclusive
* @param max * @param max maximum value, exclusive
* @returns {number} Random number
*/ */
export function getRandomArbitrary(min: number, max: number) { export function getRandomArbitrary(min: number, max: number) {
return Math.random() * (max - min) + min; return Math.random() * (max - min) + min;
@ -346,8 +423,9 @@ export function getRandomArbitrary(min: number, max: number) {
* if min isn't an integer) and no greater than max (or the next integer * if min isn't an integer) and no greater than max (or the next integer
* lower than max if max isn't an integer). * lower than max if max isn't an integer).
* Using Math.round() will give you a non-uniform distribution! * Using Math.round() will give you a non-uniform distribution!
* @param min * @param min minumim value, inclusive
* @param max * @param max maximum value, exclusive
* @returns {number} Random number
*/ */
export function getRandomInt(min: number, max: number) { export function getRandomInt(min: number, max: number) {
min = Math.ceil(min); min = Math.ceil(min);
@ -358,6 +436,7 @@ export function getRandomInt(min: number, max: number) {
/** /**
* Returns either the NodeJS crypto.randomBytes() function or its * Returns either the NodeJS crypto.randomBytes() function or its
* browser equivalent implemented via window.crypto.getRandomValues() * browser equivalent implemented via window.crypto.getRandomValues()
* @returns {Uint8Array} Random bytes
*/ */
const getRandomBytes = ( const getRandomBytes = (
(typeof window !== "undefined" && window.crypto) (typeof window !== "undefined" && window.crypto)
@ -463,6 +542,7 @@ export function getMaintenanceRelativeURL(id: string) {
* Parse to Time Object that used in VueDatePicker * Parse to Time Object that used in VueDatePicker
* @param {string} time E.g. 12:00 * @param {string} time E.g. 12:00
* @returns object * @returns object
* @throws {Error} if time string is invalid
*/ */
export function parseTimeObject(time: string) { export function parseTimeObject(time: string) {
if (!time) { if (!time) {
@ -490,8 +570,9 @@ export function parseTimeObject(time: string) {
} }
/** /**
* @param obj * Parse time to string from object {hours: number, minutes: number, seconds?: number}
* @returns string e.g. 12:00 * @param obj object to parse
* @returns {string} e.g. 12:00
*/ */
export function parseTimeFromTimeObject(obj : any) { export function parseTimeFromTimeObject(obj : any) {
if (!obj) { if (!obj) {
@ -519,7 +600,8 @@ export function isoToUTCDateTime(input : string) {
} }
/** /**
* @param input * @param input valid datetime string
* @returns {string} ISO DateTime string
*/ */
export function utcToISODateTime(input : string) { export function utcToISODateTime(input : string) {
return dayjs.utc(input).toISOString(); return dayjs.utc(input).toISOString();
@ -527,8 +609,8 @@ export function utcToISODateTime(input : string) {
/** /**
* For SQL_DATETIME_FORMAT * For SQL_DATETIME_FORMAT
* @param input * @param input valid datetime string
* @param format * @param format Format to return
* @returns A string date of SQL_DATETIME_FORMAT * @returns A string date of SQL_DATETIME_FORMAT
*/ */
export function utcToLocal(input : string, format = SQL_DATETIME_FORMAT) : string { export function utcToLocal(input : string, format = SQL_DATETIME_FORMAT) : string {
@ -549,6 +631,7 @@ export function localToUTC(input : string, format = SQL_DATETIME_FORMAT) {
* Generate a decimal integer number from a string * Generate a decimal integer number from a string
* @param str Input * @param str Input
* @param length Default is 10 which means 0 - 9 * @param length Default is 10 which means 0 - 9
* @returns {number} output number
*/ */
export function intHash(str : string, length = 10) : number { export function intHash(str : string, length = 10) : number {
// A simple hashing function (you can use more complex hash functions if needed) // A simple hashing function (you can use more complex hash functions if needed)

Loading…
Cancel
Save