mirror of
https://github.com/louislam/uptime-kuma.git
synced 2025-09-11 13:36:55 +08:00
Compare commits
308 Commits
extracted-
...
fix-check-
Author | SHA1 | Date | |
---|---|---|---|
|
cde6db7444 | ||
|
6efec6f0c9 | ||
|
dccaf9dac3 | ||
|
277d6fe0ce | ||
|
46d8744fa4 | ||
|
7d8dc55dbe | ||
|
459fb138f2 | ||
|
8f950a5145 | ||
|
4d779cfc69 | ||
|
2470451f6d | ||
|
79a26180af | ||
|
7a82ae039c | ||
|
d2f71d11d6 | ||
|
c01494ec33 | ||
|
a7e9bdd43e | ||
|
d7ffa33950 | ||
|
a20a43b8aa | ||
|
ed6087e233 | ||
|
42e77798e5 | ||
|
85dfe1f5d1 | ||
|
b719d11500 | ||
|
b7d2cedf2e | ||
|
f3ee9c2cad | ||
|
582fb6c5ad | ||
|
dda40610c7 | ||
|
bafca6bd37 | ||
|
bbc75b840b | ||
|
019702f8e5 | ||
|
365aa8d814 | ||
|
7e37dacb9a | ||
|
e8c650797c | ||
|
eca90a2b00 | ||
|
4829ad8c5d | ||
|
59e70cb763 | ||
|
6476e58907 | ||
|
e773e454e9 | ||
|
958f96f06d | ||
|
d0067a0a12 | ||
|
130d8d0177 | ||
|
f791d4a9bf | ||
|
9905ca574c | ||
|
fc429108ac | ||
|
10ffde2595 | ||
|
a5faa4b225 | ||
|
da168fc220 | ||
|
b9b48e1b2d | ||
|
a760898281 | ||
|
289b72d07d | ||
|
9257a7a19e | ||
|
8084c7e61c | ||
|
1cf88f4fea | ||
|
0f6cb15561 | ||
|
14199dc2cb | ||
|
e8e83808d3 | ||
|
324d879aad | ||
|
d2a44487b3 | ||
|
35668219ec | ||
|
895f6d2ff1 | ||
|
3088cc6141 | ||
|
98415bd419 | ||
|
087d20b775 | ||
|
ec7923f4fd | ||
|
86b3ff6bfd | ||
|
e6159d9ab4 | ||
|
e781325633 | ||
|
72478090e7 | ||
|
50ec9fec05 | ||
|
62c55f0e25 | ||
|
3ab35c38fc | ||
|
1d86fa2b5c | ||
|
cef072cae9 | ||
|
abcc98c836 | ||
|
aa38344c3d | ||
|
9ff0ae67df | ||
|
a14d05daab | ||
|
93c5ab0bd8 | ||
|
d27a9e7d7f | ||
|
c120c37030 | ||
|
acd1e7211a | ||
|
e40ce59e66 | ||
|
a309cf0e2c | ||
|
0071775525 | ||
|
46d90a6a99 | ||
|
3479992302 | ||
|
030bb1c0b8 | ||
|
7da401662f | ||
|
243726b03c | ||
|
936665aac3 | ||
|
b287a25de7 | ||
|
dd75890364 | ||
|
3c23a34fff | ||
|
935194bca3 | ||
|
11f108b501 | ||
|
c567e8eb8e | ||
|
ba46945ea9 | ||
|
362a890bc3 | ||
|
36f8be040d | ||
|
3346111090 | ||
|
032ac161f7 | ||
|
562de6abb4 | ||
|
716832b9f3 | ||
|
3d9bbe1a62 | ||
|
01210ce88d | ||
|
f8d34f22e3 | ||
|
b059c19069 | ||
|
dc3ad88fe1 | ||
|
4cb264afca | ||
|
3e88772e5d | ||
|
9486fa22ee | ||
|
0ddf35e7b5 | ||
|
4b68a86524 | ||
|
bde3d0e5ef | ||
|
643d28cebc | ||
|
fe5035d9b3 | ||
|
2a820ab16d | ||
|
8e4b9dd9c1 | ||
|
bab771df05 | ||
|
5fcc8cf34f | ||
|
895bf7c550 | ||
|
d6e254dfd6 | ||
|
3a2385bc2b | ||
|
92e982a910 | ||
|
13f67462ad | ||
|
f5f86e5524 | ||
|
cd5644d6d2 | ||
|
6dddf0319a | ||
|
6e8d4e8d76 | ||
|
74bb44e96e | ||
|
e8c74457df | ||
|
48539c4cce | ||
|
8138316b7d | ||
|
8b096a5754 | ||
|
36e7266fa4 | ||
|
50ec9e568c | ||
|
b690376750 | ||
|
58971335d0 | ||
|
26ff2c3802 | ||
|
da805ce097 | ||
|
5ae45902e0 | ||
|
ff37c68c1f | ||
|
2c31f3a2ff | ||
|
64073eaf71 | ||
|
ba448c765a | ||
|
1822b55846 | ||
|
e791c70c47 | ||
|
76deb6e0fa | ||
|
30f7b48987 | ||
|
4836f72cb8 | ||
|
4aef6780ed | ||
|
c82de20c7b | ||
|
71f9384c09 | ||
|
1d295441bb | ||
|
3bec59a205 | ||
|
dab26b1cc8 | ||
|
2bdd862340 | ||
|
921f1a2eb4 | ||
|
cc8c409489 | ||
|
3028d9b054 | ||
|
157e100bd0 | ||
|
6fe03585cc | ||
|
73873d4580 | ||
|
cada9aaef8 | ||
|
e08cee2dcb | ||
|
a6980694bc | ||
|
032ba4a03e | ||
|
ee54a3b843 | ||
|
54acfaed2b | ||
|
2c9782d1e8 | ||
|
71bb19dcba | ||
|
e3c4f6d9bd | ||
|
c0ff5965ee | ||
|
1cb6616f5c | ||
|
a2eee1f440 | ||
|
504e8dbd09 | ||
|
215c572988 | ||
|
5113b3f22b | ||
|
f84aaf7576 | ||
|
96e7c13bb2 | ||
|
9de3e7b803 | ||
|
7d728c23ed | ||
|
fba3eac97a | ||
|
a87b836aa9 | ||
|
a5775df2cd | ||
|
ae4dc0678a | ||
|
74f8a2474f | ||
|
f19be09aae | ||
|
239c2693ca | ||
|
633a64043c | ||
|
6f1b8b469d | ||
|
4436b0ba8e | ||
|
7f64badb06 | ||
|
4750bd1e36 | ||
|
d61688315d | ||
|
1a5a1a6e5d | ||
|
1ef61068c7 | ||
|
ae224f9e18 | ||
|
132f68a370 | ||
|
6eaf6b409c | ||
|
4b3ad53512 | ||
|
8efbe95d62 | ||
|
88ba9755a6 | ||
|
cc52ee3feb | ||
|
1185b259c2 | ||
|
6e30f71947 | ||
|
953058c6a5 | ||
|
85c67b6866 | ||
|
a19f417896 | ||
|
0325c14d42 | ||
|
83969d2112 | ||
|
dc15443716 | ||
|
2c8cefc784 | ||
|
94f75b2fbc | ||
|
1488b1f17b | ||
|
4941b17a46 | ||
|
58533e8f06 | ||
|
3f425dc160 | ||
|
f63d36478d | ||
|
7270caccae | ||
|
53c4bba387 | ||
|
e5ff86e6ac | ||
|
39c1283ba6 | ||
|
add0ef7be0 | ||
|
0960ec62b7 | ||
|
39b0c62c1d | ||
|
8d8ce23f2b | ||
|
a0374487ce | ||
|
2e5e103434 | ||
|
e237d66bfc | ||
|
a1f31f9373 | ||
|
46ecb822ba | ||
|
092688a5c8 | ||
|
6fc0cbf415 | ||
|
9820f57c64 | ||
|
fbf7b77ceb | ||
|
9f563adc1a | ||
|
c9132adfc7 | ||
|
c124f3a43e | ||
|
248aec8803 | ||
|
b5a73e5ad7 | ||
|
5dc4bb64d5 | ||
|
69c22edf23 | ||
|
bd95ccdc64 | ||
|
82fb7b2816 | ||
|
43bd09be2c | ||
|
e2e81091c3 | ||
|
82352910bf | ||
|
23f844d871 | ||
|
bc25b719db | ||
|
b6cd21c71a | ||
|
fdc145bffd | ||
|
eaa935cba0 | ||
|
10d3188dd3 | ||
|
36dc94b8f2 | ||
|
efb1642e3c | ||
|
2d2c1866df | ||
|
b2d76bc60a | ||
|
7eee5db4d2 | ||
|
d74facded6 | ||
|
a81f949f98 | ||
|
d25ee8f128 | ||
|
c4759948ec | ||
|
1c4740748c | ||
|
da8f0d1c31 | ||
|
8b4b27f359 | ||
|
2015142b00 | ||
|
e5fb726160 | ||
|
2b5d100cd3 | ||
|
f4842ead68 | ||
|
56e7fa8bd5 | ||
|
19f21a9a39 | ||
|
4ef66b3760 | ||
|
e9b52eb0e7 | ||
|
c68b1c6274 | ||
|
6037912085 | ||
|
433e317eee | ||
|
1fe1bb5864 | ||
|
997791bc78 | ||
|
0384b34007 | ||
|
86b997c664 | ||
|
0280b2ad3f | ||
|
4386d0afad | ||
|
9053b48030 | ||
|
407f7291b0 | ||
|
09fd816aae | ||
|
c87ac2f043 | ||
|
8e56a81ef1 | ||
|
f059d54349 | ||
|
d83c2b90c9 | ||
|
4699a1ccd8 | ||
|
ba84f01444 | ||
|
9ba0f68a86 | ||
|
97a9094d7c | ||
|
e944492da8 | ||
|
7459654e11 | ||
|
ba47aca51f | ||
|
b4bd003626 | ||
|
704ffd3f4b | ||
|
9848ce49f3 | ||
|
4593afbdbb | ||
|
9d28fcff1a | ||
|
9c8024c7fa | ||
|
138075a2af | ||
|
59f10d542b | ||
|
99dc4cfb46 | ||
|
4a882be6ba | ||
|
ff5890a11f | ||
|
a3cdd69995 | ||
|
d92003e172 |
@@ -1,28 +0,0 @@
|
||||
# Codespaces
|
||||
|
||||
You can modifiy Uptime Kuma in your browser without setting up a local development.
|
||||
|
||||

|
||||
|
||||
1. Click `Code` -> `Create codespace on master`
|
||||
2. Wait a few minutes until you see there are two exposed ports
|
||||
3. Go to the `3000` url, see if it is working
|
||||
|
||||

|
||||
|
||||
## Frontend
|
||||
|
||||
Since the frontend is using [Vite.js](https://vitejs.dev/), all changes in this area will be hot-reloaded.
|
||||
You don't need to restart the frontend, unless you try to add a new frontend dependency.
|
||||
|
||||
## Backend
|
||||
|
||||
The backend does not automatically hot-reload.
|
||||
You will need to restart the backend after changing something using these steps:
|
||||
|
||||
1. Click `Terminal`
|
||||
2. Click `Codespaces: server-dev` in the right panel
|
||||
3. Press `Ctrl + C` to stop the server
|
||||
4. Press `Up` to run `npm run start-server-dev`
|
||||
|
||||

|
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:dev-18-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
"updateContentCommand": "npm ci",
|
||||
"postCreateCommand": "",
|
||||
"postAttachCommand": {
|
||||
"frontend-dev": "npm run start-frontend-devcontainer",
|
||||
"server-dev": "npm run start-server-dev",
|
||||
"open-port": "gh codespace ports visibility 3001:public -c $CODESPACE_NAME"
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"GitHub.copilot-chat"
|
||||
]
|
||||
}
|
||||
},
|
||||
"forwardPorts": [3000, 3001]
|
||||
}
|
@@ -17,7 +17,6 @@ README.md
|
||||
.vscode
|
||||
.eslint*
|
||||
.stylelint*
|
||||
/.devcontainer
|
||||
/.github
|
||||
yarn.lock
|
||||
app.json
|
||||
|
@@ -1,7 +1,7 @@
|
||||
module.exports = {
|
||||
ignorePatterns: [
|
||||
"test/*.js",
|
||||
"server/modules/apicache/*",
|
||||
"server/modules/*",
|
||||
"src/util.js"
|
||||
],
|
||||
root: true,
|
||||
|
8
.github/workflows/auto-test.yml
vendored
8
.github/workflows/auto-test.yml
vendored
@@ -15,14 +15,14 @@ on:
|
||||
|
||||
jobs:
|
||||
auto-test:
|
||||
needs: [ check-linters, e2e-test ]
|
||||
needs: [ check-linters ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest, windows-latest, ARM64]
|
||||
node: [ 18, 20.5 ]
|
||||
node: [ 18, 20 ]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
# As a lot of dev dependencies are not supported on ARMv7, we have to test it separately and just test if `npm ci --production` works
|
||||
armv7-simple-test:
|
||||
needs: [ check-linters ]
|
||||
needs: [ ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
if: ${{ github.repository == 'louislam/uptime-kuma' }}
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
- run: npm run lint:prod
|
||||
|
||||
e2e-test:
|
||||
needs: [ check-linters ]
|
||||
needs: [ ]
|
||||
runs-on: ARM64
|
||||
steps:
|
||||
- run: git config --global core.autocrlf false # Mainly for Windows
|
||||
|
4
.github/workflows/stale-bot.yml
vendored
4
.github/workflows/stale-bot.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v8
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: |-
|
||||
We are clearing up our old `help`-issues and your issue has been open for 60 days with no activity.
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
exempt-issue-labels: 'News,Medium,High,discussion,bug,doc,feature-request'
|
||||
exempt-issue-assignees: 'louislam'
|
||||
operations-per-run: 200
|
||||
- uses: actions/stale@v8
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: |-
|
||||
This issue was marked as `cannot-reproduce` by a maintainer.
|
||||
|
@@ -1,4 +1,4 @@
|
||||
name: json-yaml-validate
|
||||
name: validate
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -25,3 +25,19 @@ jobs:
|
||||
with:
|
||||
comment: "true" # enable comment mode
|
||||
exclude_file: ".github/config/exclude.txt" # gitignore style file for exclusions
|
||||
|
||||
# General validations
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Validate language JSON files
|
||||
run: node ./extra/check-lang-json.js
|
||||
|
||||
- name: Validate knex migrations filename
|
||||
run: node ./extra/check-knex-filenames.mjs
|
@@ -1,6 +1,6 @@
|
||||
# Project Info
|
||||
|
||||
First of all, I want to thank everyone who have wrote issues or shared pull requests for Uptime Kuma.
|
||||
First of all, I want to thank everyone who has submitted issues or shared 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.
|
||||
Parts of the code are not very well-structured or commented, sorry about that.
|
||||
@@ -9,7 +9,7 @@ The project was created with `vite.js` and is written in `vue3`.
|
||||
Our backend lives in the `server`-directory and mostly communicates via websockets.
|
||||
Both frontend and backend share the same `package.json`.
|
||||
|
||||
For production, the frontend is build into `dist`-directory and the server (`express.js`) exposes the `dist` directory as the root of the endpoint.
|
||||
For production, the frontend is built into the `dist`-directory and the server (`express.js`) exposes the `dist` directory as the root of the endpoint.
|
||||
For development, we run vite in development mode on another port.
|
||||
|
||||
## Directories
|
||||
@@ -28,7 +28,7 @@ For development, we run vite in development mode on another port.
|
||||
## Can I create a pull request for Uptime Kuma?
|
||||
|
||||
Yes or no, it depends on what you will try to do.
|
||||
Both your and our maintainers time is precious, and we don't want to waste both time.
|
||||
Both yours and our maintainers' time is precious, and we don't want to waste either.
|
||||
|
||||
If you have any questions about any process/.. is not clear, you are likely not alone => please ask them ^^
|
||||
|
||||
@@ -49,11 +49,11 @@ Different guidelines exist for different types of pull requests (PRs):
|
||||
<p>
|
||||
|
||||
If you come across a bug and think you can solve, we appreciate your work.
|
||||
Please make sure that you follow by these rules:
|
||||
Please make sure that you follow these rules:
|
||||
- keep the PR as small as possible, fix only one thing at a time => keeping it reviewable
|
||||
- test that your code does what you came it does.
|
||||
- test that your code does what you claim it does.
|
||||
|
||||
<sub>Because maintainer time is precious junior maintainers may merge uncontroversial PRs in this area.</sub>
|
||||
<sub>Because maintainer time is precious, junior maintainers may merge uncontroversial PRs in this area.</sub>
|
||||
</p>
|
||||
</details>
|
||||
- <details><summary><b>translations / internationalisation (i18n)</b></summary>
|
||||
@@ -68,7 +68,7 @@ Different guidelines exist for different types of pull requests (PRs):
|
||||
- language keys need to be **added to `en.json`** to be visible in weblate. If this has not happened, a PR is appreciated.
|
||||
- **Adding a new language** requires a new file see [these instructions](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md)
|
||||
|
||||
<sub>Because maintainer time is precious junior maintainers may merge uncontroversial PRs in this area.</sub>
|
||||
<sub>Because maintainer time is precious, junior maintainers may merge uncontroversial PRs in this area.</sub>
|
||||
</p>
|
||||
</details>
|
||||
- <details><summary><b>new notification providers</b></summary>
|
||||
@@ -102,7 +102,7 @@ Different guidelines exist for different types of pull requests (PRs):
|
||||
Therefore, making sure that they work is also really important.
|
||||
Because testing notification providers is quite time intensive, we mostly offload this onto the person contributing a notification provider.
|
||||
|
||||
To make shure you have tested the notification provider, please include screenshots of the following events in the pull-request description:
|
||||
To make sure you have tested the notification provider, please include screenshots of the following events in the pull-request description:
|
||||
- `UP`/`DOWN`
|
||||
- Certificate Expiry via https://expired.badssl.com/
|
||||
- Testing (the test button on the notification provider setup page)
|
||||
@@ -117,7 +117,7 @@ Different guidelines exist for different types of pull requests (PRs):
|
||||
| Testing | paste-image-here | paste-image-here |
|
||||
```
|
||||
|
||||
<sub>Because maintainer time is precious junior maintainers may merge uncontroversial PRs in this area.</sub>
|
||||
<sub>Because maintainer time is precious, junior maintainers may merge uncontroversial PRs in this area.</sub>
|
||||
</p>
|
||||
</details>
|
||||
- <details><summary><b>new monitoring types</b></summary>
|
||||
@@ -127,7 +127,7 @@ Different guidelines exist for different types of pull requests (PRs):
|
||||
- `server/monitor-types/MONITORING_TYPE.js` is the core of each monitor.
|
||||
the `async check(...)`-function should:
|
||||
- throw an error for each fault that is detected with an actionable error message
|
||||
- in the happy-path, you should set `heartbeat.msg` to a successfull message and set `heartbeat.status = UP`
|
||||
- in the happy-path, you should set `heartbeat.msg` to a successful message and set `heartbeat.status = UP`
|
||||
- `server/uptime-kuma-server.js` is where the monitoring backend needs to be registered.
|
||||
*If you have an idea how we can skip this step, we would love to hear about it ^^*
|
||||
- `src/pages/EditMonitor.vue` is the shared frontend users interact with.
|
||||
@@ -138,14 +138,14 @@ Different guidelines exist for different types of pull requests (PRs):
|
||||
-
|
||||
|
||||
|
||||
<sub>Because maintainer time is precious junior maintainers may merge uncontroversial PRs in this area.</sub>
|
||||
<sub>Because maintainer time is precious, junior maintainers may merge uncontroversial PRs in this area.</sub>
|
||||
</p>
|
||||
</details>
|
||||
- <details><summary><b>new features/ major changes / breaking bugfixes</b></summary>
|
||||
<p>
|
||||
|
||||
be sure to **create an empty draft pull request or open an issue, so we can have a discussion first**.
|
||||
This is especially important for a large pull request or you don't know if it will be merged or not.
|
||||
This is especially important for a large pull request or when you don't know if it will be merged or not.
|
||||
|
||||
<sub>Because of the large impact of this work, only senior maintainers may merge PRs in this area.</sub>
|
||||
</p>
|
||||
@@ -201,7 +201,7 @@ The rationale behind this is that we can align the direction and scope of the fe
|
||||
|
||||
## Project Styles
|
||||
|
||||
I personally do not like something that requires so many configurations before you can finally start the app.
|
||||
I personally do not like something that requires a lot of configuration before you can finally start the app.
|
||||
The goal is to make the Uptime Kuma installation as easy as installing a mobile app.
|
||||
|
||||
- Easy to install for non-Docker users
|
||||
@@ -236,12 +236,6 @@ The goal is to make the Uptime Kuma installation as easy as installing a mobile
|
||||
- IDE that supports [`ESLint`](https://eslint.org/) and EditorConfig (I am using [`IntelliJ IDEA`](https://www.jetbrains.com/idea/))
|
||||
- A SQLite GUI tool (f.ex. [`SQLite Expert Personal`](https://www.sqliteexpert.com/download.html) or [`DBeaver Community`](https://dbeaver.io/download/))
|
||||
|
||||
### GitHub Codespaces
|
||||
|
||||
If you don't want to setup an local environment, you can now develop on GitHub Codespaces, read more:
|
||||
|
||||
https://github.com/louislam/uptime-kuma/tree/master/.devcontainer
|
||||
|
||||
## Git Branches
|
||||
|
||||
- `master`: 2.X.X development. If you want to add a new feature, your pull request should base on this.
|
||||
@@ -266,7 +260,7 @@ Port `3000` and port `3001` will be used.
|
||||
npm run dev
|
||||
```
|
||||
|
||||
But sometimes, you would like to restart the server, but not the frontend, you can run these commands in two terminals:
|
||||
But sometimes you may want to restart the server without restarting the frontend. In that case, you can run these commands in two terminals:
|
||||
|
||||
```bash
|
||||
npm run start-frontend-dev
|
||||
@@ -415,7 +409,7 @@ https://github.com/louislam/uptime-kuma/issues?q=sort%3Aupdated-desc
|
||||
|
||||
### What is a maintainer and what are their roles?
|
||||
|
||||
This project has multiple maintainers which specialise in different areas.
|
||||
This project has multiple maintainers who specialise in different areas.
|
||||
Currently, there are 3 maintainers:
|
||||
|
||||
| Person | Role | Main Area |
|
||||
|
@@ -1,11 +1,11 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const port = 30001;
|
||||
const url = `http://localhost:${port}`;
|
||||
export const url = `http://localhost:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
// Look for test files in the "tests" directory, relative to this configuration file.
|
||||
testDir: "../test/e2e",
|
||||
testDir: "../test/e2e/specs",
|
||||
outputDir: "../private/playwright-test-results",
|
||||
fullyParallel: false,
|
||||
locale: "en-US",
|
||||
@@ -40,9 +40,15 @@ export default defineConfig({
|
||||
// Configure projects for major browsers.
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
name: "run-once setup",
|
||||
testMatch: /setup-process\.once\.js/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "specs",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
dependencies: [ "run-once setup" ],
|
||||
},
|
||||
/*
|
||||
{
|
||||
name: "firefox",
|
||||
@@ -52,7 +58,7 @@ export default defineConfig({
|
||||
|
||||
// Run your local dev server before starting the tests.
|
||||
webServer: {
|
||||
command: `node extra/remove-playwright-test-data.js && node server/server.js --port=${port} --data-dir=./data/playwright-test`,
|
||||
command: `node extra/remove-playwright-test-data.js && cross-env NODE_ENV=development node server/server.js --port=${port} --data-dir=./data/playwright-test`,
|
||||
url,
|
||||
reuseExistingServer: false,
|
||||
cwd: "../",
|
||||
|
@@ -16,9 +16,7 @@ export default defineConfig({
|
||||
},
|
||||
define: {
|
||||
"FRONTEND_VERSION": JSON.stringify(process.env.npm_package_version),
|
||||
"DEVCONTAINER": JSON.stringify(process.env.DEVCONTAINER),
|
||||
"GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": JSON.stringify(process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN),
|
||||
"CODESPACE_NAME": JSON.stringify(process.env.CODESPACE_NAME),
|
||||
"process.env": {},
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
|
16
db/knex_migrations/2024-04-26-0000-snmp-monitor.js
Normal file
16
db/knex_migrations/2024-04-26-0000-snmp-monitor.js
Normal file
@@ -0,0 +1,16 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema
|
||||
.alterTable("monitor", function (table) {
|
||||
table.string("snmp_oid").defaultTo(null);
|
||||
table.enum("snmp_version", [ "1", "2c", "3" ]).defaultTo("2c");
|
||||
table.string("json_path_operator").defaultTo(null);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.alterTable("monitor", function (table) {
|
||||
table.dropColumn("snmp_oid");
|
||||
table.dropColumn("snmp_version");
|
||||
table.dropColumn("json_path_operator");
|
||||
});
|
||||
};
|
13
db/knex_migrations/2024-08-24-000-add-cache-bust.js
Normal file
13
db/knex_migrations/2024-08-24-000-add-cache-bust.js
Normal file
@@ -0,0 +1,13 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema
|
||||
.alterTable("monitor", function (table) {
|
||||
table.boolean("cache_bust").notNullable().defaultTo(false);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema
|
||||
.alterTable("monitor", function (table) {
|
||||
table.dropColumn("cache_bust");
|
||||
});
|
||||
};
|
12
db/knex_migrations/2024-08-24-0000-conditions.js
Normal file
12
db/knex_migrations/2024-08-24-0000-conditions.js
Normal file
@@ -0,0 +1,12 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema
|
||||
.alterTable("monitor", function (table) {
|
||||
table.text("conditions").notNullable().defaultTo("[]");
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.alterTable("monitor", function (table) {
|
||||
table.dropColumn("conditions");
|
||||
});
|
||||
};
|
17
db/knex_migrations/2024-10-1315-rabbitmq-monitor.js
Normal file
17
db/knex_migrations/2024-10-1315-rabbitmq-monitor.js
Normal file
@@ -0,0 +1,17 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.alterTable("monitor", function (table) {
|
||||
table.text("rabbitmq_nodes");
|
||||
table.string("rabbitmq_username");
|
||||
table.string("rabbitmq_password");
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.alterTable("monitor", function (table) {
|
||||
table.dropColumn("rabbitmq_nodes");
|
||||
table.dropColumn("rabbitmq_username");
|
||||
table.dropColumn("rabbitmq_password");
|
||||
});
|
||||
|
||||
};
|
@@ -0,0 +1,13 @@
|
||||
// Update info_json column to LONGTEXT mainly for MariaDB
|
||||
exports.up = function (knex) {
|
||||
return knex.schema
|
||||
.alterTable("monitor_tls_info", function (table) {
|
||||
table.text("info_json", "longtext").alter();
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.alterTable("monitor_tls_info", function (table) {
|
||||
table.text("info_json", "text").alter();
|
||||
});
|
||||
};
|
@@ -1,9 +1,18 @@
|
||||
# Download Apprise deb package
|
||||
FROM node:20-bookworm-slim AS download-apprise
|
||||
WORKDIR /app
|
||||
COPY ./extra/download-apprise.mjs ./download-apprise.mjs
|
||||
RUN apt update && \
|
||||
apt --yes --no-install-recommends install curl && \
|
||||
npm install cheerio semver && \
|
||||
node ./download-apprise.mjs
|
||||
|
||||
# Base Image (Slim)
|
||||
# If the image changed, the second stage image should be changed too
|
||||
FROM node:20-bookworm-slim AS base2-slim
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
# Specify --no-install-recommends to skip unused dependencies, make the base much smaller!
|
||||
# apprise = for notifications (From testing repo)
|
||||
# sqlite3 = for debugging
|
||||
# iputils-ping = for ping
|
||||
# util-linux = for setpriv (Should be dropped in 2.0.0?)
|
||||
@@ -12,10 +21,10 @@ ARG TARGETPLATFORM
|
||||
# ca-certificates = keep the cert up-to-date
|
||||
# sudo = for start service nscd with non-root user
|
||||
# nscd = for better DNS caching
|
||||
RUN echo "deb http://deb.debian.org/debian testing main" >> /etc/apt/sources.list && \
|
||||
apt update && \
|
||||
apt --yes --no-install-recommends -t testing install apprise sqlite3 ca-certificates && \
|
||||
apt --yes --no-install-recommends -t stable install \
|
||||
RUN apt update && \
|
||||
apt --yes --no-install-recommends install \
|
||||
sqlite3 \
|
||||
ca-certificates \
|
||||
iputils-ping \
|
||||
util-linux \
|
||||
dumb-init \
|
||||
@@ -25,6 +34,16 @@ RUN echo "deb http://deb.debian.org/debian testing main" >> /etc/apt/sources.lis
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
apt --yes autoremove
|
||||
|
||||
# apprise = for notifications (Install from the deb package, as the stable one is too old) (workaround for #4867)
|
||||
# Switching to testing repo is no longer working, as the testing repo is not bookworm anymore.
|
||||
# python3-paho-mqtt (#4859)
|
||||
# TODO: no idea how to delete the deb file after installation as it becomes a layer already
|
||||
COPY --from=download-apprise /app/apprise.deb ./apprise.deb
|
||||
RUN apt update && \
|
||||
apt --yes --no-install-recommends install ./apprise.deb python3-paho-mqtt && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
rm -f apprise.deb && \
|
||||
apt --yes autoremove
|
||||
|
||||
# Install cloudflared
|
||||
RUN curl https://pkg.cloudflare.com/cloudflare-main.gpg --output /usr/share/keyrings/cloudflare-main.gpg && \
|
||||
@@ -42,7 +61,9 @@ COPY ./docker/etc/sudoers /etc/sudoers
|
||||
|
||||
# Full Base Image
|
||||
# MariaDB, Chromium and fonts
|
||||
FROM base2-slim AS base2
|
||||
# Make sure to reuse the slim image here. Uncomment the above line if you want to build it from scratch.
|
||||
# FROM base2-slim AS base2
|
||||
FROM louislam/uptime-kuma:base2-slim AS base2
|
||||
ENV UPTIME_KUMA_ENABLE_EMBEDDED_MARIADB=1
|
||||
RUN apt update && \
|
||||
apt --yes --no-install-recommends install chromium fonts-indic fonts-noto fonts-noto-cjk mariadb-server && \
|
||||
|
@@ -27,7 +27,6 @@ RUN mkdir ./data
|
||||
# ⭐ Main Image
|
||||
############################################
|
||||
FROM $BASE_IMAGE AS release
|
||||
USER node
|
||||
WORKDIR /app
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/louislam/uptime-kuma"
|
||||
@@ -46,6 +45,7 @@ CMD ["node", "server/server.js"]
|
||||
# Rootless Image
|
||||
############################################
|
||||
FROM release AS rootless
|
||||
USER node
|
||||
|
||||
############################################
|
||||
# Mark as Nightly
|
||||
|
72
extra/check-knex-filenames.mjs
Normal file
72
extra/check-knex-filenames.mjs
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from "fs";
|
||||
const dir = "./db/knex_migrations";
|
||||
|
||||
// Get the file list (ending with .js) from the directory
|
||||
const files = fs.readdirSync(dir).filter((file) => file !== "README.md");
|
||||
|
||||
// They are wrong, but they had been merged, so allowed.
|
||||
const exceptionList = [
|
||||
"2024-08-24-000-add-cache-bust.js",
|
||||
"2024-10-1315-rabbitmq-monitor.js",
|
||||
];
|
||||
|
||||
// Correct format: YYYY-MM-DD-HHmm-description.js
|
||||
|
||||
for (const file of files) {
|
||||
if (exceptionList.includes(file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check ending with .js
|
||||
if (!file.endsWith(".js")) {
|
||||
console.error(`It should end with .js: ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const parts = file.split("-");
|
||||
|
||||
// Should be at least 5 parts
|
||||
if (parts.length < 5) {
|
||||
console.error(`Invalid format: ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// First part should be a year >= 2024
|
||||
const year = parseInt(parts[0], 10);
|
||||
if (isNaN(year) || year < 2023) {
|
||||
console.error(`Invalid year: ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Second part should be a month
|
||||
const month = parseInt(parts[1], 10);
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
console.error(`Invalid month: ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Third part should be a day
|
||||
const day = parseInt(parts[2], 10);
|
||||
if (isNaN(day) || day < 1 || day > 31) {
|
||||
console.error(`Invalid day: ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Fourth part should be HHmm
|
||||
const time = parts[3];
|
||||
|
||||
// Check length is 4
|
||||
if (time.length !== 4) {
|
||||
console.error(`Invalid time: ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hour = parseInt(time.substring(0, 2), 10);
|
||||
const minute = parseInt(time.substring(2), 10);
|
||||
if (isNaN(hour) || hour < 0 || hour > 23 || isNaN(minute) || minute < 0 || minute > 59) {
|
||||
console.error(`Invalid time: ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("All knex filenames are correct.");
|
27
extra/check-lang-json.js
Normal file
27
extra/check-lang-json.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// For #5231
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
let path = "./src/lang";
|
||||
|
||||
// list directories in the lang directory
|
||||
let jsonFileList = fs.readdirSync(path);
|
||||
|
||||
for (let jsonFile of jsonFileList) {
|
||||
if (!jsonFile.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let jsonPath = path + "/" + jsonFile;
|
||||
let originalContent = fs.readFileSync(jsonPath, "utf8");
|
||||
let langData = JSON.parse(originalContent);
|
||||
|
||||
let formattedContent = JSON.stringify(langData, null, 4) + "\n";
|
||||
|
||||
if (originalContent !== formattedContent) {
|
||||
console.error(`File ${jsonFile} is not formatted correctly.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("All lang json files are formatted correctly.");
|
57
extra/download-apprise.mjs
Normal file
57
extra/download-apprise.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
// Go to http://ftp.debian.org/debian/pool/main/a/apprise/ using fetch api, where it is a apache directory listing page
|
||||
// Use cheerio to parse the html and get the latest version of Apprise
|
||||
// call curl to download the latest version of Apprise
|
||||
// Target file: the latest version of Apprise, which the format is apprise_{VERSION}_all.deb
|
||||
|
||||
import * as cheerio from "cheerio";
|
||||
import semver from "semver";
|
||||
import * as childProcess from "child_process";
|
||||
|
||||
const baseURL = "http://ftp.debian.org/debian/pool/main/a/apprise/";
|
||||
const response = await fetch(baseURL);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch page of Apprise Debian repository.");
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Get all the links in the page
|
||||
const linkElements = $("a");
|
||||
|
||||
// Filter the links which match apprise_{VERSION}_all.deb
|
||||
const links = [];
|
||||
const pattern = /apprise_(.*?)_all.deb/;
|
||||
|
||||
for (let i = 0; i < linkElements.length; i++) {
|
||||
const link = linkElements[i];
|
||||
if (link.attribs.href.match(pattern) && !link.attribs.href.includes("~")) {
|
||||
links.push({
|
||||
filename: link.attribs.href,
|
||||
version: link.attribs.href.match(pattern)[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(links);
|
||||
|
||||
// semver compare and download
|
||||
let latestLink = {
|
||||
filename: "",
|
||||
version: "0.0.0",
|
||||
};
|
||||
|
||||
for (const link of links) {
|
||||
if (semver.gt(link.version, latestLink.version)) {
|
||||
latestLink = link;
|
||||
}
|
||||
}
|
||||
|
||||
const downloadURL = baseURL + latestLink.filename;
|
||||
console.log(`Downloading ${downloadURL}...`);
|
||||
let result = childProcess.spawnSync("curl", [ downloadURL, "--output", "apprise.deb" ]);
|
||||
console.log(result.stdout?.toString());
|
||||
console.error(result.stderr?.toString());
|
||||
process.exit(result.status !== null ? result.status : 1);
|
@@ -4,7 +4,6 @@ const tar = require("tar");
|
||||
|
||||
const packageJSON = require("../package.json");
|
||||
const fs = require("fs");
|
||||
const rmSync = require("./fs-rmSync.js");
|
||||
const version = packageJSON.version;
|
||||
|
||||
const filename = "dist.tar.gz";
|
||||
@@ -29,8 +28,9 @@ function download(url) {
|
||||
if (fs.existsSync("./dist")) {
|
||||
|
||||
if (fs.existsSync("./dist-backup")) {
|
||||
rmSync("./dist-backup", {
|
||||
recursive: true
|
||||
fs.rmSync("./dist-backup", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,8 +43,9 @@ function download(url) {
|
||||
|
||||
tarStream.on("close", () => {
|
||||
if (fs.existsSync("./dist-backup")) {
|
||||
rmSync("./dist-backup", {
|
||||
recursive: true
|
||||
fs.rmSync("./dist-backup", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
console.log("Done");
|
||||
|
@@ -1,23 +0,0 @@
|
||||
const fs = require("fs");
|
||||
/**
|
||||
* Detect if `fs.rmSync` is available
|
||||
* to avoid the runtime deprecation warning triggered for using `fs.rmdirSync` with `{ recursive: true }` in Node.js v16,
|
||||
* or the `recursive` property removing completely in the future Node.js version.
|
||||
* 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`.
|
||||
* @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`
|
||||
* @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`.
|
||||
* @returns {void}
|
||||
*/
|
||||
const rmSync = (path, options) => {
|
||||
if (typeof fs.rmSync === "function") {
|
||||
if (options.recursive) {
|
||||
options.force = true;
|
||||
}
|
||||
return fs.rmSync(path, options);
|
||||
}
|
||||
return fs.rmdirSync(path, options);
|
||||
};
|
||||
module.exports = rmSync;
|
25
extra/remove-empty-lang-keys.js
Normal file
25
extra/remove-empty-lang-keys.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// For #5231
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
let path = "../src/lang";
|
||||
|
||||
// list directories in the lang directory
|
||||
let jsonFileList = fs.readdirSync(path);
|
||||
|
||||
for (let jsonFile of jsonFileList) {
|
||||
if (!jsonFile.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let jsonPath = path + "/" + jsonFile;
|
||||
let langData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
||||
|
||||
for (let key in langData) {
|
||||
if (langData[key] === "") {
|
||||
delete langData[key];
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(langData, null, 4) + "\n");
|
||||
}
|
24
extra/reset-migrate-aggregate-table-state.js
Normal file
24
extra/reset-migrate-aggregate-table-state.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const { R } = require("redbean-node");
|
||||
const Database = require("../server/database");
|
||||
const args = require("args-parser")(process.argv);
|
||||
const { Settings } = require("../server/settings");
|
||||
|
||||
const main = async () => {
|
||||
console.log("Connecting the database");
|
||||
Database.initDataDir(args);
|
||||
await Database.connect(false, false, true);
|
||||
|
||||
console.log("Deleting all data from aggregate tables");
|
||||
await R.exec("DELETE FROM stat_minutely");
|
||||
await R.exec("DELETE FROM stat_hourly");
|
||||
await R.exec("DELETE FROM stat_daily");
|
||||
|
||||
console.log("Resetting the aggregate table state");
|
||||
await Settings.set("migrateAggregateTableState", "");
|
||||
|
||||
await Database.close();
|
||||
console.log("Done");
|
||||
};
|
||||
|
||||
main();
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
import fs from "fs";
|
||||
import util from "util";
|
||||
import rmSync from "../fs-rmSync.js";
|
||||
|
||||
/**
|
||||
* Copy across the required language files
|
||||
@@ -16,7 +15,10 @@ import rmSync from "../fs-rmSync.js";
|
||||
*/
|
||||
function copyFiles(langCode, baseLang) {
|
||||
if (fs.existsSync("./languages")) {
|
||||
rmSync("./languages", { recursive: true });
|
||||
fs.rmSync("./languages", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
fs.mkdirSync("./languages");
|
||||
|
||||
@@ -93,6 +95,9 @@ console.log("Updating: " + langCode);
|
||||
|
||||
copyFiles(langCode, baseLangCode);
|
||||
await updateLanguage(langCode, baseLangCode);
|
||||
rmSync("./languages", { recursive: true });
|
||||
fs.rmSync("./languages", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
||||
console.log("Done. Fixing formatting by ESLint...");
|
||||
|
7395
package-lock.json
generated
7395
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
@@ -27,9 +27,7 @@
|
||||
"build": "vite build --config ./config/vite.config.js",
|
||||
"test": "npm run test-backend && npm run test-e2e",
|
||||
"test-with-build": "npm run build && npm test",
|
||||
"test-backend": "node test/backend-test-entry.js",
|
||||
"test-backend:14": "cross-env TEST_BACKEND=1 NODE_OPTIONS=\"--experimental-abortcontroller --no-warnings\" node--test test/backend-test",
|
||||
"test-backend:18": "cross-env TEST_BACKEND=1 node --test test/backend-test",
|
||||
"test-backend": "cross-env TEST_BACKEND=1 node --test test/backend-test",
|
||||
"test-e2e": "playwright test --config ./config/playwright.config.js",
|
||||
"test-e2e-ui": "playwright test --config ./config/playwright.config.js --ui --ui-port=51063",
|
||||
"playwright-codegen": "playwright codegen localhost:3000 --save-storage=./private/e2e-auth.json",
|
||||
@@ -40,8 +38,8 @@
|
||||
"build-docker-base": "docker buildx build -f docker/debian-base.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:base2 --target base2 . --push",
|
||||
"build-docker-base-slim": "docker buildx build -f docker/debian-base.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:base2-slim --target base2-slim . --push",
|
||||
"build-docker-builder-go": "docker buildx build -f docker/builder-go.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:builder-go . --push",
|
||||
"build-docker-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-slim": "node ./extra/env2arg.js docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:next-slim -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:next -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-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",
|
||||
@@ -49,7 +47,7 @@
|
||||
"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",
|
||||
"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.13 && npm ci --production && npm run download-dist",
|
||||
"setup": "git checkout 1.23.14 && npm ci --production && npm run download-dist",
|
||||
"download-dist": "node extra/download-dist.js",
|
||||
"mark-as-nightly": "node extra/mark-as-nightly.js",
|
||||
"reset-password": "node extra/reset-password.js",
|
||||
@@ -71,15 +69,15 @@
|
||||
"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",
|
||||
"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"
|
||||
"reset-migrate-aggregate-table-state": "node extra/reset-migrate-aggregate-table-state.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "~1.7.3",
|
||||
"@grpc/grpc-js": "~1.8.22",
|
||||
"@louislam/ping": "~0.4.4-mod.1",
|
||||
"@louislam/sqlite3": "15.1.6",
|
||||
"@vvo/tzdb": "^6.125.0",
|
||||
"args-parser": "~1.3.0",
|
||||
"axios": "~0.28.1",
|
||||
"axios-ntlm": "1.3.0",
|
||||
"badge-maker": "~3.3.1",
|
||||
"bcryptjs": "~2.4.3",
|
||||
"chardet": "~1.4.0",
|
||||
@@ -89,12 +87,14 @@
|
||||
"command-exists": "~1.2.9",
|
||||
"compare-versions": "~3.6.0",
|
||||
"compression": "~1.7.4",
|
||||
"croner": "~6.0.5",
|
||||
"croner": "~8.1.0",
|
||||
"dayjs": "~1.11.5",
|
||||
"dev-null": "^0.1.1",
|
||||
"dotenv": "~16.0.3",
|
||||
"express": "~4.19.2",
|
||||
"express": "~4.21.0",
|
||||
"express-basic-auth": "~1.2.1",
|
||||
"express-static-gzip": "~2.1.7",
|
||||
"feed": "^4.2.2",
|
||||
"form-data": "~4.0.0",
|
||||
"gamedig": "^4.2.0",
|
||||
"html-escaper": "^3.0.3",
|
||||
@@ -112,12 +112,14 @@
|
||||
"knex": "^2.4.2",
|
||||
"limiter": "~2.1.0",
|
||||
"liquidjs": "^10.7.0",
|
||||
"marked": "^14.0.0",
|
||||
"mitt": "~3.0.1",
|
||||
"mongodb": "~4.17.1",
|
||||
"mqtt": "~4.3.7",
|
||||
"mssql": "~8.1.4",
|
||||
"mssql": "~11.0.0",
|
||||
"mysql2": "~3.9.6",
|
||||
"nanoid": "~3.3.4",
|
||||
"net-snmp": "^3.11.2",
|
||||
"node-cloudflared-tunnel": "~1.0.9",
|
||||
"node-radius-client": "~1.0.0",
|
||||
"nodemailer": "~6.9.13",
|
||||
@@ -136,10 +138,9 @@
|
||||
"redbean-node": "~0.3.0",
|
||||
"redis": "~4.5.1",
|
||||
"semver": "~7.5.4",
|
||||
"socket.io": "~4.6.1",
|
||||
"socket.io-client": "~4.6.1",
|
||||
"socket.io": "~4.8.0",
|
||||
"socket.io-client": "~4.8.0",
|
||||
"socks-proxy-agent": "6.1.1",
|
||||
"sqlite3": "~5.1.7",
|
||||
"tar": "~6.2.1",
|
||||
"tcp-ping": "~0.1.1",
|
||||
"thirty-two": "~1.0.2",
|
||||
@@ -154,6 +155,8 @@
|
||||
"@fortawesome/vue-fontawesome": "~3.0.0-5",
|
||||
"@playwright/test": "~1.39.0",
|
||||
"@popperjs/core": "~2.10.2",
|
||||
"@testcontainers/hivemq": "^10.13.1",
|
||||
"@testcontainers/rabbitmq": "^10.13.2",
|
||||
"@types/bootstrap": "~5.1.9",
|
||||
"@types/node": "^20.8.6",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.5",
|
||||
@@ -171,13 +174,12 @@
|
||||
"cross-env": "~7.0.3",
|
||||
"delay": "^5.0.0",
|
||||
"dns2": "~2.0.1",
|
||||
"dompurify": "~3.0.11",
|
||||
"dompurify": "~3.1.7",
|
||||
"eslint": "~8.14.0",
|
||||
"eslint-plugin-jsdoc": "~46.4.6",
|
||||
"eslint-plugin-vue": "~8.7.1",
|
||||
"favico.js": "~0.3.10",
|
||||
"get-port-please": "^3.1.1",
|
||||
"marked": "~4.2.5",
|
||||
"node-ssh": "~13.1.0",
|
||||
"postcss-html": "~1.5.0",
|
||||
"postcss-rtlcss": "~3.7.2",
|
||||
@@ -190,6 +192,7 @@
|
||||
"stylelint-config-standard": "~25.0.0",
|
||||
"terser": "~5.15.0",
|
||||
"test": "~3.3.0",
|
||||
"testcontainers": "^10.13.1",
|
||||
"typescript": "~4.4.4",
|
||||
"v-pagination-3": "~0.1.7",
|
||||
"vite": "~5.2.8",
|
||||
|
@@ -213,6 +213,32 @@ async function sendRemoteBrowserList(socket) {
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send list of monitor types to client
|
||||
* @param {Socket} socket Socket.io socket instance
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function sendMonitorTypeList(socket) {
|
||||
const result = Object.entries(UptimeKumaServer.monitorTypeList).map(([ key, type ]) => {
|
||||
return [ key, {
|
||||
supportsConditions: type.supportsConditions,
|
||||
conditionVariables: type.conditionVariables.map(v => {
|
||||
return {
|
||||
id: v.id,
|
||||
operators: v.operators.map(o => {
|
||||
return {
|
||||
id: o.id,
|
||||
caption: o.caption,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
}];
|
||||
});
|
||||
|
||||
io.to(socket.userID).emit("monitorTypeList", Object.fromEntries(result));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendNotificationList,
|
||||
sendImportantHeartbeatList,
|
||||
@@ -222,4 +248,5 @@ module.exports = {
|
||||
sendInfo,
|
||||
sendDockerHostList,
|
||||
sendRemoteBrowserList,
|
||||
sendMonitorTypeList,
|
||||
};
|
||||
|
@@ -6,6 +6,10 @@ const knex = require("knex");
|
||||
const path = require("path");
|
||||
const { EmbeddedMariaDB } = require("./embedded-mariadb");
|
||||
const mysql = require("mysql2/promise");
|
||||
const { Settings } = require("./settings");
|
||||
const { UptimeCalculator } = require("./uptime-calculator");
|
||||
const dayjs = require("dayjs");
|
||||
const { SimpleMigrationServer } = require("./utils/simple-migration-server");
|
||||
|
||||
/**
|
||||
* Database & App Data Folder
|
||||
@@ -223,8 +227,11 @@ class Database {
|
||||
fs.copyFileSync(Database.templatePath, Database.sqlitePath);
|
||||
}
|
||||
|
||||
const Dialect = require("knex/lib/dialects/sqlite3/index.js");
|
||||
Dialect.prototype._driver = () => require("@louislam/sqlite3");
|
||||
|
||||
config = {
|
||||
client: "sqlite3",
|
||||
client: Dialect,
|
||||
connection: {
|
||||
filename: Database.sqlitePath,
|
||||
acquireConnectionTimeout: acquireConnectionTimeout,
|
||||
@@ -376,9 +383,11 @@ class Database {
|
||||
|
||||
/**
|
||||
* Patch the database
|
||||
* @param {number} port Start the migration server for aggregate tables on this port if provided
|
||||
* @param {string} hostname Start the migration server for aggregate tables on this hostname if provided
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async patch() {
|
||||
static async patch(port = undefined, hostname = undefined) {
|
||||
// Still need to keep this for old versions of Uptime Kuma
|
||||
if (Database.dbConfig.type === "sqlite") {
|
||||
await this.patchSqlite();
|
||||
@@ -388,9 +397,23 @@ class Database {
|
||||
// https://knexjs.org/guide/migrations.html
|
||||
// https://gist.github.com/NigelEarle/70db130cc040cc2868555b29a0278261
|
||||
try {
|
||||
// Disable foreign key check for SQLite
|
||||
// Known issue of knex: https://github.com/drizzle-team/drizzle-orm/issues/1813
|
||||
if (Database.dbConfig.type === "sqlite") {
|
||||
await R.exec("PRAGMA foreign_keys = OFF");
|
||||
}
|
||||
|
||||
await R.knex.migrate.latest({
|
||||
directory: Database.knexMigrationsPath,
|
||||
});
|
||||
|
||||
// Enable foreign key check for SQLite
|
||||
if (Database.dbConfig.type === "sqlite") {
|
||||
await R.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
|
||||
await this.migrateAggregateTable(port, hostname);
|
||||
|
||||
} catch (e) {
|
||||
// Allow missing patch files for downgrade or testing pr.
|
||||
if (e.message.includes("the following files are missing:")) {
|
||||
@@ -708,6 +731,173 @@ class Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate the old data in the heartbeat table to the new format (stat_daily, stat_hourly, stat_minutely)
|
||||
* It should be run once while upgrading V1 to V2
|
||||
*
|
||||
* Normally, it should be in transaction, but UptimeCalculator wasn't designed to be in transaction before that.
|
||||
* I don't want to heavily modify the UptimeCalculator, so it is not in transaction.
|
||||
* Run `npm run reset-migrate-aggregate-table-state` to reset, in case the migration is interrupted.
|
||||
* @param {number} port Start the migration server on this port if provided
|
||||
* @param {string} hostname Start the migration server on this hostname if provided
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async migrateAggregateTable(port, hostname = undefined) {
|
||||
log.debug("db", "Enter Migrate Aggregate Table function");
|
||||
|
||||
// Add a setting for 2.0.0-dev users to skip this migration
|
||||
if (process.env.SET_MIGRATE_AGGREGATE_TABLE_TO_TRUE === "1") {
|
||||
log.warn("db", "SET_MIGRATE_AGGREGATE_TABLE_TO_TRUE is set to 1, skipping aggregate table migration forever (for 2.0.0-dev users)");
|
||||
await Settings.set("migrateAggregateTableState", "migrated");
|
||||
}
|
||||
|
||||
let migrateState = await Settings.get("migrateAggregateTableState");
|
||||
|
||||
// Skip if already migrated
|
||||
// If it is migrating, it possibly means the migration was interrupted, or the migration is in progress
|
||||
if (migrateState === "migrated") {
|
||||
log.debug("db", "Migrated aggregate table already, skip");
|
||||
return;
|
||||
} else if (migrateState === "migrating") {
|
||||
log.warn("db", "Aggregate table migration is already in progress, or it was interrupted");
|
||||
throw new Error("Aggregate table migration is already in progress");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start migration server for displaying the migration status
|
||||
* @type {SimpleMigrationServer}
|
||||
*/
|
||||
let migrationServer;
|
||||
let msg;
|
||||
|
||||
if (port) {
|
||||
migrationServer = new SimpleMigrationServer();
|
||||
await migrationServer.start(port, hostname);
|
||||
}
|
||||
|
||||
log.info("db", "Migrating Aggregate Table");
|
||||
|
||||
log.info("db", "Getting list of unique monitors");
|
||||
|
||||
// Get a list of unique monitors from the heartbeat table, using raw sql
|
||||
let monitors = await R.getAll(`
|
||||
SELECT DISTINCT monitor_id
|
||||
FROM heartbeat
|
||||
ORDER BY monitor_id ASC
|
||||
`);
|
||||
|
||||
// Stop if stat_* tables are not empty
|
||||
for (let table of [ "stat_minutely", "stat_hourly", "stat_daily" ]) {
|
||||
let countResult = await R.getRow(`SELECT COUNT(*) AS count FROM ${table}`);
|
||||
let count = countResult.count;
|
||||
if (count > 0) {
|
||||
log.warn("db", `Aggregate table ${table} is not empty, migration will not be started (Maybe you were using 2.0.0-dev?)`);
|
||||
await migrationServer?.stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await Settings.set("migrateAggregateTableState", "migrating");
|
||||
|
||||
let progressPercent = 0;
|
||||
let part = 100 / monitors.length;
|
||||
let i = 1;
|
||||
for (let monitor of monitors) {
|
||||
// Get a list of unique dates from the heartbeat table, using raw sql
|
||||
let dates = await R.getAll(`
|
||||
SELECT DISTINCT DATE(time) AS date
|
||||
FROM heartbeat
|
||||
WHERE monitor_id = ?
|
||||
ORDER BY date ASC
|
||||
`, [
|
||||
monitor.monitor_id
|
||||
]);
|
||||
|
||||
for (let date of dates) {
|
||||
// New Uptime Calculator
|
||||
let calculator = new UptimeCalculator();
|
||||
calculator.monitorID = monitor.monitor_id;
|
||||
calculator.setMigrationMode(true);
|
||||
|
||||
// Get all the heartbeats for this monitor and date
|
||||
let heartbeats = await R.getAll(`
|
||||
SELECT status, ping, time
|
||||
FROM heartbeat
|
||||
WHERE monitor_id = ?
|
||||
AND DATE(time) = ?
|
||||
ORDER BY time ASC
|
||||
`, [ monitor.monitor_id, date.date ]);
|
||||
|
||||
if (heartbeats.length > 0) {
|
||||
msg = `[DON'T STOP] Migrating monitor data ${monitor.monitor_id} - ${date.date} [${progressPercent.toFixed(2)}%][${i}/${monitors.length}]`;
|
||||
log.info("db", msg);
|
||||
migrationServer?.update(msg);
|
||||
}
|
||||
|
||||
for (let heartbeat of heartbeats) {
|
||||
await calculator.update(heartbeat.status, parseFloat(heartbeat.ping), dayjs(heartbeat.time));
|
||||
}
|
||||
|
||||
progressPercent += (Math.round(part / dates.length * 100) / 100);
|
||||
|
||||
// Lazy to fix the floating point issue, it is acceptable since it is just a progress bar
|
||||
if (progressPercent > 100) {
|
||||
progressPercent = 100;
|
||||
}
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
msg = "Clearing non-important heartbeats";
|
||||
log.info("db", msg);
|
||||
migrationServer?.update(msg);
|
||||
|
||||
await Database.clearHeartbeatData(true);
|
||||
await Settings.set("migrateAggregateTableState", "migrated");
|
||||
await migrationServer?.stop();
|
||||
|
||||
if (monitors.length > 0) {
|
||||
log.info("db", "Aggregate Table Migration Completed");
|
||||
} else {
|
||||
log.info("db", "No data to migrate");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all non-important heartbeats from heartbeat table, keep last 24-hour or {KEEP_LAST_ROWS} rows for each monitor
|
||||
* @param {boolean} detailedLog Log detailed information
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async clearHeartbeatData(detailedLog = false) {
|
||||
let monitors = await R.getAll("SELECT id FROM monitor");
|
||||
const sqlHourOffset = Database.sqlHourOffset();
|
||||
|
||||
for (let monitor of monitors) {
|
||||
if (detailedLog) {
|
||||
log.info("db", "Deleting non-important heartbeats for monitor " + monitor.id);
|
||||
}
|
||||
await R.exec(`
|
||||
DELETE FROM heartbeat
|
||||
WHERE monitor_id = ?
|
||||
AND important = 0
|
||||
AND time < ${sqlHourOffset}
|
||||
AND id NOT IN (
|
||||
SELECT id
|
||||
FROM heartbeat
|
||||
WHERE monitor_id = ?
|
||||
ORDER BY time DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`, [
|
||||
monitor.id,
|
||||
-24,
|
||||
monitor.id,
|
||||
100,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Database;
|
||||
|
@@ -1,21 +1,22 @@
|
||||
const { R } = require("redbean-node");
|
||||
const { log } = require("../../src/util");
|
||||
const { setSetting, setting } = require("../util-server");
|
||||
const Database = require("../database");
|
||||
const { Settings } = require("../settings");
|
||||
const dayjs = require("dayjs");
|
||||
|
||||
const DEFAULT_KEEP_PERIOD = 180;
|
||||
const DEFAULT_KEEP_PERIOD = 365;
|
||||
|
||||
/**
|
||||
* Clears old data from the heartbeat table of the database.
|
||||
* Clears old data from the heartbeat table and the stat_daily of the database.
|
||||
* @returns {Promise<void>} A promise that resolves when the data has been cleared.
|
||||
*/
|
||||
|
||||
const clearOldData = async () => {
|
||||
let period = await setting("keepDataPeriodDays");
|
||||
await Database.clearHeartbeatData();
|
||||
let period = await Settings.get("keepDataPeriodDays");
|
||||
|
||||
// Set Default Period
|
||||
if (period == null) {
|
||||
await setSetting("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general");
|
||||
await Settings.set("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general");
|
||||
period = DEFAULT_KEEP_PERIOD;
|
||||
}
|
||||
|
||||
@@ -25,23 +26,28 @@ const clearOldData = async () => {
|
||||
parsedPeriod = parseInt(period);
|
||||
} catch (_) {
|
||||
log.warn("clearOldData", "Failed to parse setting, resetting to default..");
|
||||
await setSetting("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general");
|
||||
await Settings.set("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general");
|
||||
parsedPeriod = DEFAULT_KEEP_PERIOD;
|
||||
}
|
||||
|
||||
if (parsedPeriod < 1) {
|
||||
log.info("clearOldData", `Data deletion has been disabled as period is less than 1. Period is ${parsedPeriod} days.`);
|
||||
} else {
|
||||
|
||||
log.debug("clearOldData", `Clearing Data older than ${parsedPeriod} days...`);
|
||||
|
||||
const sqlHourOffset = Database.sqlHourOffset();
|
||||
|
||||
try {
|
||||
await R.exec(
|
||||
"DELETE FROM heartbeat WHERE time < " + sqlHourOffset,
|
||||
[ parsedPeriod * -24 ]
|
||||
);
|
||||
// Heartbeat
|
||||
await R.exec("DELETE FROM heartbeat WHERE time < " + sqlHourOffset, [
|
||||
parsedPeriod * -24,
|
||||
]);
|
||||
|
||||
let timestamp = dayjs().subtract(parsedPeriod, "day").utc().startOf("day").unix();
|
||||
|
||||
// stat_daily
|
||||
await R.exec("DELETE FROM stat_daily WHERE timestamp < ? ", [
|
||||
timestamp,
|
||||
]);
|
||||
|
||||
if (Database.dbConfig.type === "sqlite") {
|
||||
await R.exec("PRAGMA optimize;");
|
||||
@@ -50,6 +56,8 @@ const clearOldData = async () => {
|
||||
log.error("clearOldData", `Failed to clear old data: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("clearOldData", "Data cleared.");
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
@@ -239,19 +239,7 @@ class Maintenance extends BeanModel {
|
||||
this.beanMeta.status = "under-maintenance";
|
||||
clearTimeout(this.beanMeta.durationTimeout);
|
||||
|
||||
// Check if duration is still in the window. If not, use the duration from the current time to the end of the window
|
||||
let duration;
|
||||
|
||||
if (customDuration > 0) {
|
||||
duration = customDuration;
|
||||
} else if (this.end_date) {
|
||||
let d = dayjs(this.end_date).diff(dayjs(), "second");
|
||||
if (d < this.duration) {
|
||||
duration = d * 1000;
|
||||
}
|
||||
} else {
|
||||
duration = this.duration * 1000;
|
||||
}
|
||||
let duration = this.inferDuration(customDuration);
|
||||
|
||||
UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);
|
||||
|
||||
@@ -263,9 +251,21 @@ class Maintenance extends BeanModel {
|
||||
};
|
||||
|
||||
// Create Cron
|
||||
this.beanMeta.job = new Cron(this.cron, {
|
||||
timezone: await this.getTimezone(),
|
||||
}, startEvent);
|
||||
if (this.strategy === "recurring-interval") {
|
||||
// For recurring-interval, Croner needs to have interval and startAt
|
||||
const startDate = dayjs(this.startDate);
|
||||
const [ hour, minute ] = this.startTime.split(":");
|
||||
const startDateTime = startDate.hour(hour).minute(minute);
|
||||
this.beanMeta.job = new Cron(this.cron, {
|
||||
timezone: await this.getTimezone(),
|
||||
interval: this.interval_day * 24 * 60 * 60,
|
||||
startAt: startDateTime.toISOString(),
|
||||
}, startEvent);
|
||||
} else {
|
||||
this.beanMeta.job = new Cron(this.cron, {
|
||||
timezone: await this.getTimezone(),
|
||||
}, startEvent);
|
||||
}
|
||||
|
||||
// Continue if the maintenance is still in the window
|
||||
let runningTimeslot = this.getRunningTimeslot();
|
||||
@@ -311,6 +311,24 @@ class Maintenance extends BeanModel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the maintenance duration
|
||||
* @param {number} customDuration - The custom duration in milliseconds.
|
||||
* @returns {number} The inferred duration in milliseconds.
|
||||
*/
|
||||
inferDuration(customDuration) {
|
||||
// Check if duration is still in the window. If not, use the duration from the current time to the end of the window
|
||||
if (customDuration > 0) {
|
||||
return customDuration;
|
||||
} else if (this.end_date) {
|
||||
let d = dayjs(this.end_date).diff(dayjs(), "second");
|
||||
if (d < this.duration) {
|
||||
return d * 1000;
|
||||
}
|
||||
}
|
||||
return this.duration * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the maintenance
|
||||
* @returns {void}
|
||||
@@ -395,10 +413,8 @@ class Maintenance extends BeanModel {
|
||||
} else if (!this.strategy.startsWith("recurring-")) {
|
||||
this.cron = "";
|
||||
} else if (this.strategy === "recurring-interval") {
|
||||
let array = this.start_time.split(":");
|
||||
let hour = parseInt(array[0]);
|
||||
let minute = parseInt(array[1]);
|
||||
this.cron = minute + " " + hour + " */" + this.interval_day + " * *";
|
||||
// For intervals, the pattern is calculated in the run function as the interval-option is set
|
||||
this.cron = "* * * * *";
|
||||
this.duration = this.calcDuration();
|
||||
log.debug("maintenance", "Cron: " + this.cron);
|
||||
log.debug("maintenance", "Duration: " + this.duration);
|
||||
|
@@ -2,9 +2,9 @@ const dayjs = require("dayjs");
|
||||
const axios = require("axios");
|
||||
const { Prometheus } = require("../prometheus");
|
||||
const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND,
|
||||
SQL_DATETIME_FORMAT
|
||||
SQL_DATETIME_FORMAT, evaluateJsonQuery
|
||||
} = require("../../src/util");
|
||||
const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius,
|
||||
const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery,
|
||||
redisPingAsync, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal
|
||||
} = require("../util-server");
|
||||
const { R } = require("redbean-node");
|
||||
@@ -17,7 +17,6 @@ const apicache = require("../modules/apicache");
|
||||
const { UptimeKumaServer } = require("../uptime-kuma-server");
|
||||
const { DockerHost } = require("../docker");
|
||||
const Gamedig = require("gamedig");
|
||||
const jsonata = require("jsonata");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const crypto = require("crypto");
|
||||
const { UptimeCalculator } = require("../uptime-calculator");
|
||||
@@ -72,23 +71,12 @@ class Monitor extends BeanModel {
|
||||
|
||||
/**
|
||||
* Return an object that ready to parse to JSON
|
||||
* @param {object} preloadData to prevent n+1 problems, we query the data in a batch outside of this function
|
||||
* @param {boolean} includeSensitiveData Include sensitive data in
|
||||
* JSON
|
||||
* @returns {Promise<object>} Object ready to parse
|
||||
* @returns {object} Object ready to parse
|
||||
*/
|
||||
async toJSON(includeSensitiveData = true) {
|
||||
|
||||
let notificationIDList = {};
|
||||
|
||||
let list = await R.find("monitor_notification", " monitor_id = ? ", [
|
||||
this.id,
|
||||
]);
|
||||
|
||||
for (let bean of list) {
|
||||
notificationIDList[bean.notification_id] = true;
|
||||
}
|
||||
|
||||
const tags = await this.getTags();
|
||||
toJSON(preloadData = {}, includeSensitiveData = true) {
|
||||
|
||||
let screenshot = null;
|
||||
|
||||
@@ -96,7 +84,7 @@ class Monitor extends BeanModel {
|
||||
screenshot = "/screenshots/" + jwt.sign(this.id, UptimeKumaServer.getInstance().jwtSecret) + ".png";
|
||||
}
|
||||
|
||||
const path = await this.getPath();
|
||||
const path = preloadData.paths.get(this.id) || [];
|
||||
const pathName = path.join(" / ");
|
||||
|
||||
let data = {
|
||||
@@ -106,15 +94,15 @@ class Monitor extends BeanModel {
|
||||
path,
|
||||
pathName,
|
||||
parent: this.parent,
|
||||
childrenIDs: await Monitor.getAllChildrenIDs(this.id),
|
||||
childrenIDs: preloadData.childrenIDs.get(this.id) || [],
|
||||
url: this.url,
|
||||
method: this.method,
|
||||
hostname: this.hostname,
|
||||
port: this.port,
|
||||
maxretries: this.maxretries,
|
||||
weight: this.weight,
|
||||
active: await this.isActive(),
|
||||
forceInactive: !await Monitor.isParentActive(this.id),
|
||||
active: preloadData.activeStatus.get(this.id),
|
||||
forceInactive: preloadData.forceInactive.get(this.id),
|
||||
type: this.type,
|
||||
timeout: this.timeout,
|
||||
interval: this.interval,
|
||||
@@ -134,9 +122,9 @@ class Monitor extends BeanModel {
|
||||
docker_container: this.docker_container,
|
||||
docker_host: this.docker_host,
|
||||
proxyId: this.proxy_id,
|
||||
notificationIDList,
|
||||
tags: tags,
|
||||
maintenance: await Monitor.isUnderMaintenance(this.id),
|
||||
notificationIDList: preloadData.notifications.get(this.id) || {},
|
||||
tags: preloadData.tags.get(this.id) || [],
|
||||
maintenance: preloadData.maintenanceStatus.get(this.id),
|
||||
mqttTopic: this.mqttTopic,
|
||||
mqttSuccessMessage: this.mqttSuccessMessage,
|
||||
mqttCheckType: this.mqttCheckType,
|
||||
@@ -160,7 +148,13 @@ class Monitor extends BeanModel {
|
||||
kafkaProducerAllowAutoTopicCreation: this.getKafkaProducerAllowAutoTopicCreation(),
|
||||
kafkaProducerMessage: this.kafkaProducerMessage,
|
||||
screenshot,
|
||||
cacheBust: this.getCacheBust(),
|
||||
remote_browser: this.remote_browser,
|
||||
snmpOid: this.snmpOid,
|
||||
jsonPathOperator: this.jsonPathOperator,
|
||||
snmpVersion: this.snmpVersion,
|
||||
rabbitmqNodes: JSON.parse(this.rabbitmqNodes),
|
||||
conditions: JSON.parse(this.conditions),
|
||||
};
|
||||
|
||||
if (includeSensitiveData) {
|
||||
@@ -190,6 +184,8 @@ class Monitor extends BeanModel {
|
||||
tlsCert: this.tlsCert,
|
||||
tlsKey: this.tlsKey,
|
||||
kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions),
|
||||
rabbitmqUsername: this.rabbitmqUsername,
|
||||
rabbitmqPassword: this.rabbitmqPassword,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,16 +193,6 @@ class Monitor extends BeanModel {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the monitor is active based on itself and its parents
|
||||
* @returns {Promise<boolean>} Is the monitor active?
|
||||
*/
|
||||
async isActive() {
|
||||
const parentActive = await Monitor.isParentActive(this.id);
|
||||
|
||||
return (this.active === 1) && parentActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags applied to this monitor
|
||||
* @returns {Promise<LooseObject<any>[]>} List of tags on the
|
||||
@@ -293,6 +279,14 @@ class Monitor extends BeanModel {
|
||||
return Boolean(this.grpcEnableTls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse to boolean
|
||||
* @returns {boolean} if cachebusting is enabled
|
||||
*/
|
||||
getCacheBust() {
|
||||
return Boolean(this.cacheBust);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accepted status codes
|
||||
* @returns {object} Accepted status codes
|
||||
@@ -498,6 +492,14 @@ class Monitor extends BeanModel {
|
||||
options.data = bodyValue;
|
||||
}
|
||||
|
||||
if (this.cacheBust) {
|
||||
const randomFloatString = Math.random().toString(36);
|
||||
const cacheBust = randomFloatString.substring(2);
|
||||
options.params = {
|
||||
uptime_kuma_cachebuster: cacheBust,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.proxy_id) {
|
||||
const proxy = await R.load("proxy", this.proxy_id);
|
||||
|
||||
@@ -598,25 +600,15 @@ class Monitor extends BeanModel {
|
||||
} else if (this.type === "json-query") {
|
||||
let data = res.data;
|
||||
|
||||
// convert data to object
|
||||
if (typeof data === "string" && res.headers["content-type"] !== "application/json") {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch (_) {
|
||||
// Failed to parse as JSON, just process it as a string
|
||||
}
|
||||
}
|
||||
const { status, response } = await evaluateJsonQuery(data, this.jsonPath, this.jsonPathOperator, this.expectedValue);
|
||||
|
||||
let expression = jsonata(this.jsonPath);
|
||||
|
||||
let result = await expression.evaluate(data);
|
||||
|
||||
if (result.toString() === this.expectedValue) {
|
||||
bean.msg += ", expected value is found";
|
||||
if (status) {
|
||||
bean.status = UP;
|
||||
bean.msg = `JSON query passes (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`;
|
||||
} else {
|
||||
throw new Error(bean.msg + ", but value is not equal to expected value, value was: [" + result + "]");
|
||||
throw new Error(`JSON query does not pass (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} else if (this.type === "port") {
|
||||
@@ -773,6 +765,37 @@ class Monitor extends BeanModel {
|
||||
bean.msg = "";
|
||||
bean.status = UP;
|
||||
bean.ping = dayjs().valueOf() - startTime;
|
||||
} else if (this.type === "grpc-keyword") {
|
||||
let startTime = dayjs().valueOf();
|
||||
const options = {
|
||||
grpcUrl: this.grpcUrl,
|
||||
grpcProtobufData: this.grpcProtobuf,
|
||||
grpcServiceName: this.grpcServiceName,
|
||||
grpcEnableTls: this.grpcEnableTls,
|
||||
grpcMethod: this.grpcMethod,
|
||||
grpcBody: this.grpcBody,
|
||||
};
|
||||
const response = await grpcQuery(options);
|
||||
bean.ping = dayjs().valueOf() - startTime;
|
||||
log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`);
|
||||
let responseData = response.data;
|
||||
if (responseData.length > 50) {
|
||||
responseData = responseData.toString().substring(0, 47) + "...";
|
||||
}
|
||||
if (response.code !== 1) {
|
||||
bean.status = DOWN;
|
||||
bean.msg = `Error in send gRPC ${response.code} ${response.errorMessage}`;
|
||||
} else {
|
||||
let keywordFound = response.data.toString().includes(this.keyword);
|
||||
if (keywordFound === !this.isInvertKeyword()) {
|
||||
bean.status = UP;
|
||||
bean.msg = `${responseData}, keyword [${this.keyword}] ${keywordFound ? "is" : "not"} found`;
|
||||
} else {
|
||||
log.debug("monitor:", `GRPC response [${response.data}] + ", but keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${response.data} + "]"`);
|
||||
bean.status = DOWN;
|
||||
bean.msg = `, but keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${responseData} + "]`;
|
||||
}
|
||||
}
|
||||
} else if (this.type === "postgres") {
|
||||
let startTime = dayjs().valueOf();
|
||||
|
||||
@@ -866,6 +889,7 @@ class Monitor extends BeanModel {
|
||||
retries = 0;
|
||||
|
||||
} catch (error) {
|
||||
|
||||
if (error?.name === "CanceledError") {
|
||||
bean.msg = `timeout by AbortSignal (${this.timeout}s)`;
|
||||
} else {
|
||||
@@ -938,7 +962,6 @@ class Monitor extends BeanModel {
|
||||
} else if (bean.status === MAINTENANCE) {
|
||||
log.warn("monitor", `Monitor #${this.id} '${this.name}': Under Maintenance | Type: ${this.type}`);
|
||||
} else {
|
||||
beatInterval = this.retryInterval;
|
||||
log.warn("monitor", `Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type} | Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`);
|
||||
}
|
||||
|
||||
@@ -1155,6 +1178,18 @@ class Monitor extends BeanModel {
|
||||
return checkCertificateResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the monitor is active based on itself and its parents
|
||||
* @param {number} monitorID ID of monitor to send
|
||||
* @param {boolean} active is active
|
||||
* @returns {Promise<boolean>} Is the monitor active?
|
||||
*/
|
||||
static async isActive(monitorID, active) {
|
||||
const parentActive = await Monitor.isParentActive(monitorID);
|
||||
|
||||
return (active === 1) && parentActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send statistics to clients
|
||||
* @param {Server} io Socket server instance
|
||||
@@ -1291,7 +1326,10 @@ class Monitor extends BeanModel {
|
||||
for (let notification of notificationList) {
|
||||
try {
|
||||
const heartbeatJSON = bean.toJSON();
|
||||
|
||||
const monitorData = [{ id: monitor.id,
|
||||
active: monitor.active
|
||||
}];
|
||||
const preloadData = await Monitor.preparePreloadData(monitorData);
|
||||
// Prevent if the msg is undefined, notifications such as Discord cannot send out.
|
||||
if (!heartbeatJSON["msg"]) {
|
||||
heartbeatJSON["msg"] = "N/A";
|
||||
@@ -1302,7 +1340,7 @@ class Monitor extends BeanModel {
|
||||
heartbeatJSON["timezoneOffset"] = UptimeKumaServer.getInstance().getTimezoneOffset();
|
||||
heartbeatJSON["localDateTime"] = dayjs.utc(heartbeatJSON["time"]).tz(heartbeatJSON["timezone"]).format(SQL_DATETIME_FORMAT);
|
||||
|
||||
await Notification.send(JSON.parse(notification.config), msg, await monitor.toJSON(false), heartbeatJSON);
|
||||
await Notification.send(JSON.parse(notification.config), msg, monitor.toJSON(preloadData, false), heartbeatJSON);
|
||||
} catch (e) {
|
||||
log.error("monitor", "Cannot send notification to " + notification.name);
|
||||
log.error("monitor", e);
|
||||
@@ -1464,6 +1502,108 @@ class Monitor extends BeanModel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets monitor notification of multiple monitor
|
||||
* @param {Array} monitorIDs IDs of monitor to get
|
||||
* @returns {Promise<LooseObject<any>>} object
|
||||
*/
|
||||
static async getMonitorNotification(monitorIDs) {
|
||||
return await R.getAll(`
|
||||
SELECT monitor_notification.monitor_id, monitor_notification.notification_id
|
||||
FROM monitor_notification
|
||||
WHERE monitor_notification.monitor_id IN (${monitorIDs.map((_) => "?").join(",")})
|
||||
`, monitorIDs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets monitor tags of multiple monitor
|
||||
* @param {Array} monitorIDs IDs of monitor to get
|
||||
* @returns {Promise<LooseObject<any>>} object
|
||||
*/
|
||||
static async getMonitorTag(monitorIDs) {
|
||||
return await R.getAll(`
|
||||
SELECT monitor_tag.monitor_id, monitor_tag.tag_id, tag.name, tag.color
|
||||
FROM monitor_tag
|
||||
JOIN tag ON monitor_tag.tag_id = tag.id
|
||||
WHERE monitor_tag.monitor_id IN (${monitorIDs.map((_) => "?").join(",")})
|
||||
`, monitorIDs);
|
||||
}
|
||||
|
||||
/**
|
||||
* prepare preloaded data for efficient access
|
||||
* @param {Array} monitorData IDs & active field of monitor to get
|
||||
* @returns {Promise<LooseObject<any>>} object
|
||||
*/
|
||||
static async preparePreloadData(monitorData) {
|
||||
|
||||
const notificationsMap = new Map();
|
||||
const tagsMap = new Map();
|
||||
const maintenanceStatusMap = new Map();
|
||||
const childrenIDsMap = new Map();
|
||||
const activeStatusMap = new Map();
|
||||
const forceInactiveMap = new Map();
|
||||
const pathsMap = new Map();
|
||||
|
||||
if (monitorData.length > 0) {
|
||||
const monitorIDs = monitorData.map(monitor => monitor.id);
|
||||
const notifications = await Monitor.getMonitorNotification(monitorIDs);
|
||||
const tags = await Monitor.getMonitorTag(monitorIDs);
|
||||
const maintenanceStatuses = await Promise.all(monitorData.map(monitor => Monitor.isUnderMaintenance(monitor.id)));
|
||||
const childrenIDs = await Promise.all(monitorData.map(monitor => Monitor.getAllChildrenIDs(monitor.id)));
|
||||
const activeStatuses = await Promise.all(monitorData.map(monitor => Monitor.isActive(monitor.id, monitor.active)));
|
||||
const forceInactiveStatuses = await Promise.all(monitorData.map(monitor => Monitor.isParentActive(monitor.id)));
|
||||
const paths = await Promise.all(monitorData.map(monitor => Monitor.getAllPath(monitor.id, monitor.name)));
|
||||
|
||||
notifications.forEach(row => {
|
||||
if (!notificationsMap.has(row.monitor_id)) {
|
||||
notificationsMap.set(row.monitor_id, {});
|
||||
}
|
||||
notificationsMap.get(row.monitor_id)[row.notification_id] = true;
|
||||
});
|
||||
|
||||
tags.forEach(row => {
|
||||
if (!tagsMap.has(row.monitor_id)) {
|
||||
tagsMap.set(row.monitor_id, []);
|
||||
}
|
||||
tagsMap.get(row.monitor_id).push({
|
||||
tag_id: row.tag_id,
|
||||
name: row.name,
|
||||
color: row.color
|
||||
});
|
||||
});
|
||||
|
||||
monitorData.forEach((monitor, index) => {
|
||||
maintenanceStatusMap.set(monitor.id, maintenanceStatuses[index]);
|
||||
});
|
||||
|
||||
monitorData.forEach((monitor, index) => {
|
||||
childrenIDsMap.set(monitor.id, childrenIDs[index]);
|
||||
});
|
||||
|
||||
monitorData.forEach((monitor, index) => {
|
||||
activeStatusMap.set(monitor.id, activeStatuses[index]);
|
||||
});
|
||||
|
||||
monitorData.forEach((monitor, index) => {
|
||||
forceInactiveMap.set(monitor.id, !forceInactiveStatuses[index]);
|
||||
});
|
||||
|
||||
monitorData.forEach((monitor, index) => {
|
||||
pathsMap.set(monitor.id, paths[index]);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
notifications: notificationsMap,
|
||||
tags: tagsMap,
|
||||
maintenanceStatus: maintenanceStatusMap,
|
||||
childrenIDs: childrenIDsMap,
|
||||
activeStatus: activeStatusMap,
|
||||
forceInactive: forceInactiveMap,
|
||||
paths: pathsMap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Parent of the monitor
|
||||
* @param {number} monitorID ID of monitor to get
|
||||
@@ -1496,16 +1636,18 @@ class Monitor extends BeanModel {
|
||||
|
||||
/**
|
||||
* Gets the full path
|
||||
* @param {number} monitorID ID of the monitor to get
|
||||
* @param {string} name of the monitor to get
|
||||
* @returns {Promise<string[]>} Full path (includes groups and the name) of the monitor
|
||||
*/
|
||||
async getPath() {
|
||||
const path = [ this.name ];
|
||||
static async getAllPath(monitorID, name) {
|
||||
const path = [ name ];
|
||||
|
||||
if (this.parent === null) {
|
||||
return path;
|
||||
}
|
||||
|
||||
let parent = await Monitor.getParent(this.id);
|
||||
let parent = await Monitor.getParent(monitorID);
|
||||
while (parent !== null) {
|
||||
path.unshift(parent.name);
|
||||
parent = await Monitor.getParent(parent.id);
|
||||
|
@@ -4,6 +4,11 @@ const cheerio = require("cheerio");
|
||||
const { UptimeKumaServer } = require("../uptime-kuma-server");
|
||||
const jsesc = require("jsesc");
|
||||
const googleAnalytics = require("../google-analytics");
|
||||
const { marked } = require("marked");
|
||||
const { Feed } = require("feed");
|
||||
const config = require("../config");
|
||||
|
||||
const { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE, DOWN } = require("../../src/util");
|
||||
|
||||
class StatusPage extends BeanModel {
|
||||
|
||||
@@ -13,6 +18,24 @@ class StatusPage extends BeanModel {
|
||||
*/
|
||||
static domainMappingList = { };
|
||||
|
||||
/**
|
||||
* Handle responses to RSS pages
|
||||
* @param {Response} response Response object
|
||||
* @param {string} slug Status page slug
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async handleStatusPageRSSResponse(response, slug) {
|
||||
let statusPage = await R.findOne("status_page", " slug = ? ", [
|
||||
slug
|
||||
]);
|
||||
|
||||
if (statusPage) {
|
||||
response.send(await StatusPage.renderRSS(statusPage, slug));
|
||||
} else {
|
||||
response.status(404).send(UptimeKumaServer.getInstance().indexHTML);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle responses to status page
|
||||
* @param {Response} response Response object
|
||||
@@ -38,6 +61,38 @@ class StatusPage extends BeanModel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SSR for RSS feed
|
||||
* @param {statusPage} statusPage object
|
||||
* @param {slug} slug from router
|
||||
* @returns {Promise<string>} the rendered html
|
||||
*/
|
||||
static async renderRSS(statusPage, slug) {
|
||||
const { heartbeats, statusDescription } = await StatusPage.getRSSPageData(statusPage);
|
||||
|
||||
let proto = config.isSSL ? "https" : "http";
|
||||
let host = `${proto}://${config.hostname || "localhost"}:${config.port}/status/${slug}`;
|
||||
|
||||
const feed = new Feed({
|
||||
title: "uptime kuma rss feed",
|
||||
description: `current status: ${statusDescription}`,
|
||||
link: host,
|
||||
language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
|
||||
updated: new Date(), // optional, default = today
|
||||
});
|
||||
|
||||
heartbeats.forEach(heartbeat => {
|
||||
feed.addItem({
|
||||
title: `${heartbeat.name} is down`,
|
||||
description: `${heartbeat.name} has been down since ${heartbeat.time}`,
|
||||
id: heartbeat.monitorID,
|
||||
date: new Date(heartbeat.time),
|
||||
});
|
||||
});
|
||||
|
||||
return feed.rss2();
|
||||
}
|
||||
|
||||
/**
|
||||
* SSR for status pages
|
||||
* @param {string} indexHTML HTML page to render
|
||||
@@ -46,7 +101,11 @@ class StatusPage extends BeanModel {
|
||||
*/
|
||||
static async renderHTML(indexHTML, statusPage) {
|
||||
const $ = cheerio.load(indexHTML);
|
||||
const description155 = statusPage.description?.substring(0, 155) ?? "";
|
||||
|
||||
const description155 = marked(statusPage.description ?? "")
|
||||
.replace(/<[^>]+>/gm, "")
|
||||
.trim()
|
||||
.substring(0, 155);
|
||||
|
||||
$("title").text(statusPage.title);
|
||||
$("meta[name=description]").attr("content", description155);
|
||||
@@ -93,6 +152,109 @@ class StatusPage extends BeanModel {
|
||||
return $.root().html();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {heartbeats} heartbeats from getRSSPageData
|
||||
* @returns {number} status_page constant from util.ts
|
||||
*/
|
||||
static overallStatus(heartbeats) {
|
||||
if (heartbeats.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
let status = STATUS_PAGE_ALL_UP;
|
||||
let hasUp = false;
|
||||
|
||||
for (let beat of heartbeats) {
|
||||
if (beat.status === MAINTENANCE) {
|
||||
return STATUS_PAGE_MAINTENANCE;
|
||||
} else if (beat.status === UP) {
|
||||
hasUp = true;
|
||||
} else {
|
||||
status = STATUS_PAGE_PARTIAL_DOWN;
|
||||
}
|
||||
}
|
||||
|
||||
if (! hasUp) {
|
||||
status = STATUS_PAGE_ALL_DOWN;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} status from overallStatus
|
||||
* @returns {string} description
|
||||
*/
|
||||
static getStatusDescription(status) {
|
||||
if (status === -1) {
|
||||
return "No Services";
|
||||
}
|
||||
|
||||
if (status === STATUS_PAGE_ALL_UP) {
|
||||
return "All Systems Operational";
|
||||
}
|
||||
|
||||
if (status === STATUS_PAGE_PARTIAL_DOWN) {
|
||||
return "Partially Degraded Service";
|
||||
}
|
||||
|
||||
if (status === STATUS_PAGE_ALL_DOWN) {
|
||||
return "Degraded Service";
|
||||
}
|
||||
|
||||
// TODO: show the real maintenance information: title, description, time
|
||||
if (status === MAINTENANCE) {
|
||||
return "Under maintenance";
|
||||
}
|
||||
|
||||
return "?";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all data required for RSS
|
||||
* @param {StatusPage} statusPage Status page to get data for
|
||||
* @returns {object} Status page data
|
||||
*/
|
||||
static async getRSSPageData(statusPage) {
|
||||
// get all heartbeats that correspond to this statusPage
|
||||
const config = await statusPage.toPublicJSON();
|
||||
|
||||
// Public Group List
|
||||
const showTags = !!statusPage.show_tags;
|
||||
|
||||
const list = await R.find("group", " public = 1 AND status_page_id = ? ORDER BY weight ", [
|
||||
statusPage.id
|
||||
]);
|
||||
|
||||
let heartbeats = [];
|
||||
|
||||
for (let groupBean of list) {
|
||||
let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry);
|
||||
for (const monitor of monitorGroup.monitorList) {
|
||||
const heartbeat = await R.findOne("heartbeat", "monitor_id = ? ORDER BY time DESC", [ monitor.id ]);
|
||||
if (heartbeat) {
|
||||
heartbeats.push({
|
||||
...monitor,
|
||||
status: heartbeat.status,
|
||||
time: heartbeat.time
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calculate RSS feed description
|
||||
let status = StatusPage.overallStatus(heartbeats);
|
||||
let statusDescription = StatusPage.getStatusDescription(status);
|
||||
|
||||
// keep only DOWN heartbeats in the RSS feed
|
||||
heartbeats = heartbeats.filter(heartbeat => heartbeat.status === DOWN);
|
||||
|
||||
return {
|
||||
heartbeats,
|
||||
statusDescription
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all status page data in one call
|
||||
* @param {StatusPage} statusPage Status page to get data for
|
||||
|
21
server/modules/axios-ntlm/LICENSE
Normal file
21
server/modules/axios-ntlm/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 CatButtes
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
77
server/modules/axios-ntlm/lib/flags.js
Normal file
77
server/modules/axios-ntlm/lib/flags.js
Normal file
@@ -0,0 +1,77 @@
|
||||
'use strict';
|
||||
// Original file https://raw.githubusercontent.com/elasticio/node-ntlm-client/master/lib/flags.js
|
||||
module.exports.NTLMFLAG_NEGOTIATE_UNICODE = 1 << 0;
|
||||
/* Indicates that Unicode strings are supported for use in security buffer
|
||||
data. */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_OEM = 1 << 1;
|
||||
/* Indicates that OEM strings are supported for use in security buffer data. */
|
||||
module.exports.NTLMFLAG_REQUEST_TARGET = 1 << 2;
|
||||
/* Requests that the server's authentication realm be included in the Type 2
|
||||
message. */
|
||||
/* unknown (1<<3) */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_SIGN = 1 << 4;
|
||||
/* Specifies that authenticated communication between the client and server
|
||||
should carry a digital signature (message integrity). */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_SEAL = 1 << 5;
|
||||
/* Specifies that authenticated communication between the client and server
|
||||
should be encrypted (message confidentiality). */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_DATAGRAM_STYLE = 1 << 6;
|
||||
/* Indicates that datagram authentication is being used. */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_LM_KEY = 1 << 7;
|
||||
/* Indicates that the LAN Manager session key should be used for signing and
|
||||
sealing authenticated communications. */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_NETWARE = 1 << 8;
|
||||
/* unknown purpose */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_NTLM_KEY = 1 << 9;
|
||||
/* Indicates that NTLM authentication is being used. */
|
||||
/* unknown (1<<10) */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_ANONYMOUS = 1 << 11;
|
||||
/* Sent by the client in the Type 3 message to indicate that an anonymous
|
||||
context has been established. This also affects the response fields. */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_DOMAIN_SUPPLIED = 1 << 12;
|
||||
/* Sent by the client in the Type 1 message to indicate that a desired
|
||||
authentication realm is included in the message. */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_WORKSTATION_SUPPLIED = 1 << 13;
|
||||
/* Sent by the client in the Type 1 message to indicate that the client
|
||||
workstation's name is included in the message. */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_LOCAL_CALL = 1 << 14;
|
||||
/* Sent by the server to indicate that the server and client are on the same
|
||||
machine. Implies that the client may use a pre-established local security
|
||||
context rather than responding to the challenge. */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_ALWAYS_SIGN = 1 << 15;
|
||||
/* Indicates that authenticated communication between the client and server
|
||||
should be signed with a "dummy" signature. */
|
||||
module.exports.NTLMFLAG_TARGET_TYPE_DOMAIN = 1 << 16;
|
||||
/* Sent by the server in the Type 2 message to indicate that the target
|
||||
authentication realm is a domain. */
|
||||
module.exports.NTLMFLAG_TARGET_TYPE_SERVER = 1 << 17;
|
||||
/* Sent by the server in the Type 2 message to indicate that the target
|
||||
authentication realm is a server. */
|
||||
module.exports.NTLMFLAG_TARGET_TYPE_SHARE = 1 << 18;
|
||||
/* Sent by the server in the Type 2 message to indicate that the target
|
||||
authentication realm is a share. Presumably, this is for share-level
|
||||
authentication. Usage is unclear. */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_NTLM2_KEY = 1 << 19;
|
||||
/* Indicates that the NTLM2 signing and sealing scheme should be used for
|
||||
protecting authenticated communications. */
|
||||
module.exports.NTLMFLAG_REQUEST_INIT_RESPONSE = 1 << 20;
|
||||
/* unknown purpose */
|
||||
module.exports.NTLMFLAG_REQUEST_ACCEPT_RESPONSE = 1 << 21;
|
||||
/* unknown purpose */
|
||||
module.exports.NTLMFLAG_REQUEST_NONNT_SESSION_KEY = 1 << 22;
|
||||
/* unknown purpose */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_TARGET_INFO = 1 << 23;
|
||||
/* Sent by the server in the Type 2 message to indicate that it is including a
|
||||
Target Information block in the message. */
|
||||
/* unknown (1<24) */
|
||||
/* unknown (1<25) */
|
||||
/* unknown (1<26) */
|
||||
/* unknown (1<27) */
|
||||
/* unknown (1<28) */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_128 = 1 << 29;
|
||||
/* Indicates that 128-bit encryption is supported. */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_KEY_EXCHANGE = 1 << 30;
|
||||
/* Indicates that the client will provide an encrypted master key in
|
||||
the "Session Key" field of the Type 3 message. */
|
||||
module.exports.NTLMFLAG_NEGOTIATE_56 = 1 << 31;
|
||||
//# sourceMappingURL=flags.js.map
|
122
server/modules/axios-ntlm/lib/hash.js
Normal file
122
server/modules/axios-ntlm/lib/hash.js
Normal file
@@ -0,0 +1,122 @@
|
||||
'use strict';
|
||||
// Original source at https://github.com/elasticio/node-ntlm-client/blob/master/lib/hash.js
|
||||
var crypto = require('crypto');
|
||||
function createLMResponse(challenge, lmhash) {
|
||||
var buf = new Buffer.alloc(24), pwBuffer = new Buffer.alloc(21).fill(0);
|
||||
lmhash.copy(pwBuffer);
|
||||
calculateDES(pwBuffer.slice(0, 7), challenge).copy(buf);
|
||||
calculateDES(pwBuffer.slice(7, 14), challenge).copy(buf, 8);
|
||||
calculateDES(pwBuffer.slice(14), challenge).copy(buf, 16);
|
||||
return buf;
|
||||
}
|
||||
function createLMHash(password) {
|
||||
var buf = new Buffer.alloc(16), pwBuffer = new Buffer.alloc(14), magicKey = new Buffer.from('KGS!@#$%', 'ascii');
|
||||
if (password.length > 14) {
|
||||
buf.fill(0);
|
||||
return buf;
|
||||
}
|
||||
pwBuffer.fill(0);
|
||||
pwBuffer.write(password.toUpperCase(), 0, 'ascii');
|
||||
return Buffer.concat([
|
||||
calculateDES(pwBuffer.slice(0, 7), magicKey),
|
||||
calculateDES(pwBuffer.slice(7), magicKey)
|
||||
]);
|
||||
}
|
||||
function calculateDES(key, message) {
|
||||
var desKey = new Buffer.alloc(8);
|
||||
desKey[0] = key[0] & 0xFE;
|
||||
desKey[1] = ((key[0] << 7) & 0xFF) | (key[1] >> 1);
|
||||
desKey[2] = ((key[1] << 6) & 0xFF) | (key[2] >> 2);
|
||||
desKey[3] = ((key[2] << 5) & 0xFF) | (key[3] >> 3);
|
||||
desKey[4] = ((key[3] << 4) & 0xFF) | (key[4] >> 4);
|
||||
desKey[5] = ((key[4] << 3) & 0xFF) | (key[5] >> 5);
|
||||
desKey[6] = ((key[5] << 2) & 0xFF) | (key[6] >> 6);
|
||||
desKey[7] = (key[6] << 1) & 0xFF;
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var parity = 0;
|
||||
for (var j = 1; j < 8; j++) {
|
||||
parity += (desKey[i] >> j) % 2;
|
||||
}
|
||||
desKey[i] |= (parity % 2) === 0 ? 1 : 0;
|
||||
}
|
||||
var des = crypto.createCipheriv('DES-ECB', desKey, '');
|
||||
return des.update(message);
|
||||
}
|
||||
function createNTLMResponse(challenge, ntlmhash) {
|
||||
var buf = new Buffer.alloc(24), ntlmBuffer = new Buffer.alloc(21).fill(0);
|
||||
ntlmhash.copy(ntlmBuffer);
|
||||
calculateDES(ntlmBuffer.slice(0, 7), challenge).copy(buf);
|
||||
calculateDES(ntlmBuffer.slice(7, 14), challenge).copy(buf, 8);
|
||||
calculateDES(ntlmBuffer.slice(14), challenge).copy(buf, 16);
|
||||
return buf;
|
||||
}
|
||||
function createNTLMHash(password) {
|
||||
var md4sum = crypto.createHash('md4');
|
||||
md4sum.update(new Buffer.from(password, 'ucs2'));
|
||||
return md4sum.digest();
|
||||
}
|
||||
function createNTLMv2Hash(ntlmhash, username, authTargetName) {
|
||||
var hmac = crypto.createHmac('md5', ntlmhash);
|
||||
hmac.update(new Buffer.from(username.toUpperCase() + authTargetName, 'ucs2'));
|
||||
return hmac.digest();
|
||||
}
|
||||
function createLMv2Response(type2message, username, ntlmhash, nonce, targetName) {
|
||||
var buf = new Buffer.alloc(24), ntlm2hash = createNTLMv2Hash(ntlmhash, username, targetName), hmac = crypto.createHmac('md5', ntlm2hash);
|
||||
//server challenge
|
||||
type2message.challenge.copy(buf, 8);
|
||||
//client nonce
|
||||
buf.write(nonce || createPseudoRandomValue(16), 16, 'hex');
|
||||
//create hash
|
||||
hmac.update(buf.slice(8));
|
||||
var hashedBuffer = hmac.digest();
|
||||
hashedBuffer.copy(buf);
|
||||
return buf;
|
||||
}
|
||||
function createNTLMv2Response(type2message, username, ntlmhash, nonce, targetName) {
|
||||
var buf = new Buffer.alloc(48 + type2message.targetInfo.buffer.length), ntlm2hash = createNTLMv2Hash(ntlmhash, username, targetName), hmac = crypto.createHmac('md5', ntlm2hash);
|
||||
//the first 8 bytes are spare to store the hashed value before the blob
|
||||
//server challenge
|
||||
type2message.challenge.copy(buf, 8);
|
||||
//blob signature
|
||||
buf.writeUInt32BE(0x01010000, 16);
|
||||
//reserved
|
||||
buf.writeUInt32LE(0, 20);
|
||||
//timestamp
|
||||
//TODO: we are loosing precision here since js is not able to handle those large integers
|
||||
// maybe think about a different solution here
|
||||
// 11644473600000 = diff between 1970 and 1601
|
||||
var timestamp = ((Date.now() + 11644473600000) * 10000).toString(16);
|
||||
var timestampLow = Number('0x' + timestamp.substring(Math.max(0, timestamp.length - 8)));
|
||||
var timestampHigh = Number('0x' + timestamp.substring(0, Math.max(0, timestamp.length - 8)));
|
||||
buf.writeUInt32LE(timestampLow, 24, false);
|
||||
buf.writeUInt32LE(timestampHigh, 28, false);
|
||||
//random client nonce
|
||||
buf.write(nonce || createPseudoRandomValue(16), 32, 'hex');
|
||||
//zero
|
||||
buf.writeUInt32LE(0, 40);
|
||||
//complete target information block from type 2 message
|
||||
type2message.targetInfo.buffer.copy(buf, 44);
|
||||
//zero
|
||||
buf.writeUInt32LE(0, 44 + type2message.targetInfo.buffer.length);
|
||||
hmac.update(buf.slice(8));
|
||||
var hashedBuffer = hmac.digest();
|
||||
hashedBuffer.copy(buf);
|
||||
return buf;
|
||||
}
|
||||
function createPseudoRandomValue(length) {
|
||||
var str = '';
|
||||
while (str.length < length) {
|
||||
str += Math.floor(Math.random() * 16).toString(16);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
module.exports = {
|
||||
createLMHash: createLMHash,
|
||||
createNTLMHash: createNTLMHash,
|
||||
createLMResponse: createLMResponse,
|
||||
createNTLMResponse: createNTLMResponse,
|
||||
createLMv2Response: createLMv2Response,
|
||||
createNTLMv2Response: createNTLMv2Response,
|
||||
createPseudoRandomValue: createPseudoRandomValue
|
||||
};
|
||||
//# sourceMappingURL=hash.js.map
|
220
server/modules/axios-ntlm/lib/ntlm.js
Normal file
220
server/modules/axios-ntlm/lib/ntlm.js
Normal file
@@ -0,0 +1,220 @@
|
||||
'use strict';
|
||||
// Original file https://raw.githubusercontent.com/elasticio/node-ntlm-client/master/lib/ntlm.js
|
||||
var os = require('os'), flags = require('./flags'), hash = require('./hash');
|
||||
var NTLMSIGNATURE = "NTLMSSP\0";
|
||||
function createType1Message(workstation, target) {
|
||||
var dataPos = 32, pos = 0, buf = new Buffer.alloc(1024);
|
||||
workstation = workstation === undefined ? os.hostname() : workstation;
|
||||
target = target === undefined ? '' : target;
|
||||
//signature
|
||||
buf.write(NTLMSIGNATURE, pos, NTLMSIGNATURE.length, 'ascii');
|
||||
pos += NTLMSIGNATURE.length;
|
||||
//message type
|
||||
buf.writeUInt32LE(1, pos);
|
||||
pos += 4;
|
||||
//flags
|
||||
buf.writeUInt32LE(flags.NTLMFLAG_NEGOTIATE_OEM |
|
||||
flags.NTLMFLAG_REQUEST_TARGET |
|
||||
flags.NTLMFLAG_NEGOTIATE_NTLM_KEY |
|
||||
flags.NTLMFLAG_NEGOTIATE_NTLM2_KEY |
|
||||
flags.NTLMFLAG_NEGOTIATE_ALWAYS_SIGN, pos);
|
||||
pos += 4;
|
||||
//domain security buffer
|
||||
buf.writeUInt16LE(target.length, pos);
|
||||
pos += 2;
|
||||
buf.writeUInt16LE(target.length, pos);
|
||||
pos += 2;
|
||||
buf.writeUInt32LE(target.length === 0 ? 0 : dataPos, pos);
|
||||
pos += 4;
|
||||
if (target.length > 0) {
|
||||
dataPos += buf.write(target, dataPos, 'ascii');
|
||||
}
|
||||
//workstation security buffer
|
||||
buf.writeUInt16LE(workstation.length, pos);
|
||||
pos += 2;
|
||||
buf.writeUInt16LE(workstation.length, pos);
|
||||
pos += 2;
|
||||
buf.writeUInt32LE(workstation.length === 0 ? 0 : dataPos, pos);
|
||||
pos += 4;
|
||||
if (workstation.length > 0) {
|
||||
dataPos += buf.write(workstation, dataPos, 'ascii');
|
||||
}
|
||||
return 'NTLM ' + buf.toString('base64', 0, dataPos);
|
||||
}
|
||||
function decodeType2Message(str) {
|
||||
if (str === undefined) {
|
||||
throw new Error('Invalid argument');
|
||||
}
|
||||
//convenience
|
||||
if (Object.prototype.toString.call(str) !== '[object String]') {
|
||||
if (str.hasOwnProperty('headers') && str.headers.hasOwnProperty('www-authenticate')) {
|
||||
str = str.headers['www-authenticate'];
|
||||
}
|
||||
else {
|
||||
throw new Error('Invalid argument');
|
||||
}
|
||||
}
|
||||
var ntlmMatch = /^NTLM ([^,\s]+)/.exec(str);
|
||||
if (ntlmMatch) {
|
||||
str = ntlmMatch[1];
|
||||
}
|
||||
var buf = new Buffer.from(str, 'base64'), obj = {};
|
||||
//check signature
|
||||
if (buf.toString('ascii', 0, NTLMSIGNATURE.length) !== NTLMSIGNATURE) {
|
||||
throw new Error('Invalid message signature: ' + str);
|
||||
}
|
||||
//check message type
|
||||
if (buf.readUInt32LE(NTLMSIGNATURE.length) !== 2) {
|
||||
throw new Error('Invalid message type (no type 2)');
|
||||
}
|
||||
//read flags
|
||||
obj.flags = buf.readUInt32LE(20);
|
||||
obj.encoding = (obj.flags & flags.NTLMFLAG_NEGOTIATE_OEM) ? 'ascii' : 'ucs2';
|
||||
obj.version = (obj.flags & flags.NTLMFLAG_NEGOTIATE_NTLM2_KEY) ? 2 : 1;
|
||||
obj.challenge = buf.slice(24, 32);
|
||||
//read target name
|
||||
obj.targetName = (function () {
|
||||
var length = buf.readUInt16LE(12);
|
||||
//skipping allocated space
|
||||
var offset = buf.readUInt32LE(16);
|
||||
if (length === 0) {
|
||||
return '';
|
||||
}
|
||||
if ((offset + length) > buf.length || offset < 32) {
|
||||
throw new Error('Bad type 2 message');
|
||||
}
|
||||
return buf.toString(obj.encoding, offset, offset + length);
|
||||
})();
|
||||
//read target info
|
||||
if (obj.flags & flags.NTLMFLAG_NEGOTIATE_TARGET_INFO) {
|
||||
obj.targetInfo = (function () {
|
||||
var info = {};
|
||||
var length = buf.readUInt16LE(40);
|
||||
//skipping allocated space
|
||||
var offset = buf.readUInt32LE(44);
|
||||
var targetInfoBuffer = new Buffer.alloc(length);
|
||||
buf.copy(targetInfoBuffer, 0, offset, offset + length);
|
||||
if (length === 0) {
|
||||
return info;
|
||||
}
|
||||
if ((offset + length) > buf.length || offset < 32) {
|
||||
throw new Error('Bad type 2 message');
|
||||
}
|
||||
var pos = offset;
|
||||
while (pos < (offset + length)) {
|
||||
var blockType = buf.readUInt16LE(pos);
|
||||
pos += 2;
|
||||
var blockLength = buf.readUInt16LE(pos);
|
||||
pos += 2;
|
||||
if (blockType === 0) {
|
||||
//reached the terminator subblock
|
||||
break;
|
||||
}
|
||||
var blockTypeStr = void 0;
|
||||
switch (blockType) {
|
||||
case 1:
|
||||
blockTypeStr = 'SERVER';
|
||||
break;
|
||||
case 2:
|
||||
blockTypeStr = 'DOMAIN';
|
||||
break;
|
||||
case 3:
|
||||
blockTypeStr = 'FQDN';
|
||||
break;
|
||||
case 4:
|
||||
blockTypeStr = 'DNS';
|
||||
break;
|
||||
case 5:
|
||||
blockTypeStr = 'PARENT_DNS';
|
||||
break;
|
||||
default:
|
||||
blockTypeStr = '';
|
||||
break;
|
||||
}
|
||||
if (blockTypeStr) {
|
||||
info[blockTypeStr] = buf.toString('ucs2', pos, pos + blockLength);
|
||||
}
|
||||
pos += blockLength;
|
||||
}
|
||||
return {
|
||||
parsed: info,
|
||||
buffer: targetInfoBuffer
|
||||
};
|
||||
})();
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
function createType3Message(type2Message, username, password, workstation, target) {
|
||||
var dataPos = 52, buf = new Buffer.alloc(1024);
|
||||
if (workstation === undefined) {
|
||||
workstation = os.hostname();
|
||||
}
|
||||
if (target === undefined) {
|
||||
target = type2Message.targetName;
|
||||
}
|
||||
//signature
|
||||
buf.write(NTLMSIGNATURE, 0, NTLMSIGNATURE.length, 'ascii');
|
||||
//message type
|
||||
buf.writeUInt32LE(3, 8);
|
||||
if (type2Message.version === 2) {
|
||||
dataPos = 64;
|
||||
var ntlmHash = hash.createNTLMHash(password), nonce = hash.createPseudoRandomValue(16), lmv2 = hash.createLMv2Response(type2Message, username, ntlmHash, nonce, target), ntlmv2 = hash.createNTLMv2Response(type2Message, username, ntlmHash, nonce, target);
|
||||
//lmv2 security buffer
|
||||
buf.writeUInt16LE(lmv2.length, 12);
|
||||
buf.writeUInt16LE(lmv2.length, 14);
|
||||
buf.writeUInt32LE(dataPos, 16);
|
||||
lmv2.copy(buf, dataPos);
|
||||
dataPos += lmv2.length;
|
||||
//ntlmv2 security buffer
|
||||
buf.writeUInt16LE(ntlmv2.length, 20);
|
||||
buf.writeUInt16LE(ntlmv2.length, 22);
|
||||
buf.writeUInt32LE(dataPos, 24);
|
||||
ntlmv2.copy(buf, dataPos);
|
||||
dataPos += ntlmv2.length;
|
||||
}
|
||||
else {
|
||||
var lmHash = hash.createLMHash(password), ntlmHash = hash.createNTLMHash(password), lm = hash.createLMResponse(type2Message.challenge, lmHash), ntlm = hash.createNTLMResponse(type2Message.challenge, ntlmHash);
|
||||
//lm security buffer
|
||||
buf.writeUInt16LE(lm.length, 12);
|
||||
buf.writeUInt16LE(lm.length, 14);
|
||||
buf.writeUInt32LE(dataPos, 16);
|
||||
lm.copy(buf, dataPos);
|
||||
dataPos += lm.length;
|
||||
//ntlm security buffer
|
||||
buf.writeUInt16LE(ntlm.length, 20);
|
||||
buf.writeUInt16LE(ntlm.length, 22);
|
||||
buf.writeUInt32LE(dataPos, 24);
|
||||
ntlm.copy(buf, dataPos);
|
||||
dataPos += ntlm.length;
|
||||
}
|
||||
//target name security buffer
|
||||
buf.writeUInt16LE(type2Message.encoding === 'ascii' ? target.length : target.length * 2, 28);
|
||||
buf.writeUInt16LE(type2Message.encoding === 'ascii' ? target.length : target.length * 2, 30);
|
||||
buf.writeUInt32LE(dataPos, 32);
|
||||
dataPos += buf.write(target, dataPos, type2Message.encoding);
|
||||
//user name security buffer
|
||||
buf.writeUInt16LE(type2Message.encoding === 'ascii' ? username.length : username.length * 2, 36);
|
||||
buf.writeUInt16LE(type2Message.encoding === 'ascii' ? username.length : username.length * 2, 38);
|
||||
buf.writeUInt32LE(dataPos, 40);
|
||||
dataPos += buf.write(username, dataPos, type2Message.encoding);
|
||||
//workstation name security buffer
|
||||
buf.writeUInt16LE(type2Message.encoding === 'ascii' ? workstation.length : workstation.length * 2, 44);
|
||||
buf.writeUInt16LE(type2Message.encoding === 'ascii' ? workstation.length : workstation.length * 2, 46);
|
||||
buf.writeUInt32LE(dataPos, 48);
|
||||
dataPos += buf.write(workstation, dataPos, type2Message.encoding);
|
||||
if (type2Message.version === 2) {
|
||||
//session key security buffer
|
||||
buf.writeUInt16LE(0, 52);
|
||||
buf.writeUInt16LE(0, 54);
|
||||
buf.writeUInt32LE(0, 56);
|
||||
//flags
|
||||
buf.writeUInt32LE(type2Message.flags, 60);
|
||||
}
|
||||
return 'NTLM ' + buf.toString('base64', 0, dataPos);
|
||||
}
|
||||
module.exports = {
|
||||
createType1Message: createType1Message,
|
||||
decodeType2Message: decodeType2Message,
|
||||
createType3Message: createType3Message
|
||||
};
|
||||
//# sourceMappingURL=ntlm.js.map
|
127
server/modules/axios-ntlm/lib/ntlmClient.js
Normal file
127
server/modules/axios-ntlm/lib/ntlmClient.js
Normal file
@@ -0,0 +1,127 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
|
||||
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (_) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.NtlmClient = void 0;
|
||||
var axios_1 = __importDefault(require("axios"));
|
||||
var ntlm = __importStar(require("./ntlm"));
|
||||
var https = __importStar(require("https"));
|
||||
var http = __importStar(require("http"));
|
||||
var dev_null_1 = __importDefault(require("dev-null"));
|
||||
/**
|
||||
* @param credentials An NtlmCredentials object containing the username and password
|
||||
* @param AxiosConfig The Axios config for the instance you wish to create
|
||||
*
|
||||
* @returns This function returns an axios instance configured to use the provided credentials
|
||||
*/
|
||||
function NtlmClient(credentials, AxiosConfig) {
|
||||
var _this = this;
|
||||
var config = AxiosConfig !== null && AxiosConfig !== void 0 ? AxiosConfig : {};
|
||||
if (!config.httpAgent) {
|
||||
config.httpAgent = new http.Agent({ keepAlive: true });
|
||||
}
|
||||
if (!config.httpsAgent) {
|
||||
config.httpsAgent = new https.Agent({ keepAlive: true });
|
||||
}
|
||||
var client = axios_1.default.create(config);
|
||||
client.interceptors.response.use(function (response) {
|
||||
return response;
|
||||
}, function (err) { return __awaiter(_this, void 0, void 0, function () {
|
||||
var error, t1Msg, t2Msg, t3Msg, stream_1;
|
||||
var _a;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0:
|
||||
error = err.response;
|
||||
if (!(error && error.status === 401
|
||||
&& error.headers['www-authenticate']
|
||||
&& error.headers['www-authenticate'].includes('NTLM'))) return [3 /*break*/, 3];
|
||||
// This length check is a hack because SharePoint is awkward and will
|
||||
// include the Negotiate option when responding with the T2 message
|
||||
// There is nore we could do to ensure we are processing correctly,
|
||||
// but this is the easiest option for now
|
||||
if (error.headers['www-authenticate'].length < 50) {
|
||||
t1Msg = ntlm.createType1Message(credentials.workstation, credentials.domain);
|
||||
error.config.headers["Authorization"] = t1Msg;
|
||||
}
|
||||
else {
|
||||
t2Msg = ntlm.decodeType2Message((error.headers['www-authenticate'].match(/^NTLM\s+(.+?)(,|\s+|$)/) || [])[1]);
|
||||
t3Msg = ntlm.createType3Message(t2Msg, credentials.username, credentials.password, credentials.workstation, credentials.domain);
|
||||
error.config.headers["X-retry"] = "false";
|
||||
error.config.headers["Authorization"] = t3Msg;
|
||||
}
|
||||
if (!(error.config.responseType === "stream")) return [3 /*break*/, 2];
|
||||
stream_1 = (_a = err.response) === null || _a === void 0 ? void 0 : _a.data;
|
||||
if (!(stream_1 && !stream_1.readableEnded)) return [3 /*break*/, 2];
|
||||
return [4 /*yield*/, new Promise(function (resolve) {
|
||||
stream_1.pipe((0, dev_null_1.default)());
|
||||
stream_1.once('close', resolve);
|
||||
})];
|
||||
case 1:
|
||||
_b.sent();
|
||||
_b.label = 2;
|
||||
case 2: return [2 /*return*/, client(error.config)];
|
||||
case 3: throw err;
|
||||
}
|
||||
});
|
||||
}); });
|
||||
return client;
|
||||
}
|
||||
exports.NtlmClient = NtlmClient;
|
||||
//# sourceMappingURL=ntlmClient.js.map
|
71
server/monitor-conditions/evaluator.js
Normal file
71
server/monitor-conditions/evaluator.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const { ConditionExpressionGroup, ConditionExpression, LOGICAL } = require("./expression");
|
||||
const { operatorMap } = require("./operators");
|
||||
|
||||
/**
|
||||
* @param {ConditionExpression} expression Expression to evaluate
|
||||
* @param {object} context Context to evaluate against; These are values for variables in the expression
|
||||
* @returns {boolean} Whether the expression evaluates true or false
|
||||
* @throws {Error}
|
||||
*/
|
||||
function evaluateExpression(expression, context) {
|
||||
/**
|
||||
* @type {import("./operators").ConditionOperator|null}
|
||||
*/
|
||||
const operator = operatorMap.get(expression.operator) || null;
|
||||
if (operator === null) {
|
||||
throw new Error("Unexpected expression operator ID '" + expression.operator + "'. Expected one of [" + operatorMap.keys().join(",") + "]");
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(context, expression.variable)) {
|
||||
throw new Error("Variable missing in context: " + expression.variable);
|
||||
}
|
||||
|
||||
return operator.test(context[expression.variable], expression.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ConditionExpressionGroup} group Group of expressions to evaluate
|
||||
* @param {object} context Context to evaluate against; These are values for variables in the expression
|
||||
* @returns {boolean} Whether the group evaluates true or false
|
||||
* @throws {Error}
|
||||
*/
|
||||
function evaluateExpressionGroup(group, context) {
|
||||
if (!group.children.length) {
|
||||
throw new Error("ConditionExpressionGroup must contain at least one child.");
|
||||
}
|
||||
|
||||
let result = null;
|
||||
|
||||
for (const child of group.children) {
|
||||
let childResult;
|
||||
|
||||
if (child instanceof ConditionExpression) {
|
||||
childResult = evaluateExpression(child, context);
|
||||
} else if (child instanceof ConditionExpressionGroup) {
|
||||
childResult = evaluateExpressionGroup(child, context);
|
||||
} else {
|
||||
throw new Error("Invalid child type in ConditionExpressionGroup. Expected ConditionExpression or ConditionExpressionGroup");
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
result = childResult; // Initialize result with the first child's result
|
||||
} else if (child.andOr === LOGICAL.OR) {
|
||||
result = result || childResult;
|
||||
} else if (child.andOr === LOGICAL.AND) {
|
||||
result = result && childResult;
|
||||
} else {
|
||||
throw new Error("Invalid logical operator in child of ConditionExpressionGroup. Expected 'and' or 'or'. Got '" + group.andOr + "'");
|
||||
}
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
throw new Error("ConditionExpressionGroup did not result in a boolean.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
evaluateExpression,
|
||||
evaluateExpressionGroup,
|
||||
};
|
111
server/monitor-conditions/expression.js
Normal file
111
server/monitor-conditions/expression.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
const LOGICAL = {
|
||||
AND: "and",
|
||||
OR: "or",
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively processes an array of raw condition objects and populates the given parent group with
|
||||
* corresponding ConditionExpression or ConditionExpressionGroup instances.
|
||||
* @param {Array} conditions Array of raw condition objects, where each object represents either a group or an expression.
|
||||
* @param {ConditionExpressionGroup} parentGroup The parent group to which the instantiated ConditionExpression or ConditionExpressionGroup objects will be added.
|
||||
* @returns {void}
|
||||
*/
|
||||
function processMonitorConditions(conditions, parentGroup) {
|
||||
conditions.forEach(condition => {
|
||||
const andOr = condition.andOr === LOGICAL.OR ? LOGICAL.OR : LOGICAL.AND;
|
||||
|
||||
if (condition.type === "group") {
|
||||
const group = new ConditionExpressionGroup([], andOr);
|
||||
|
||||
// Recursively process the group's children
|
||||
processMonitorConditions(condition.children, group);
|
||||
|
||||
parentGroup.children.push(group);
|
||||
} else if (condition.type === "expression") {
|
||||
const expression = new ConditionExpression(condition.variable, condition.operator, condition.value, andOr);
|
||||
parentGroup.children.push(expression);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class ConditionExpressionGroup {
|
||||
/**
|
||||
* @type {ConditionExpressionGroup[]|ConditionExpression[]} Groups and/or expressions to test
|
||||
*/
|
||||
children = [];
|
||||
|
||||
/**
|
||||
* @type {LOGICAL} Connects group result with previous group/expression results
|
||||
*/
|
||||
andOr;
|
||||
|
||||
/**
|
||||
* @param {ConditionExpressionGroup[]|ConditionExpression[]} children Groups and/or expressions to test
|
||||
* @param {LOGICAL} andOr Connects group result with previous group/expression results
|
||||
*/
|
||||
constructor(children = [], andOr = LOGICAL.AND) {
|
||||
this.children = children;
|
||||
this.andOr = andOr;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Monitor} monitor Monitor instance
|
||||
* @returns {ConditionExpressionGroup|null} A ConditionExpressionGroup with the Monitor's conditions
|
||||
*/
|
||||
static fromMonitor(monitor) {
|
||||
const conditions = JSON.parse(monitor.conditions);
|
||||
if (conditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = new ConditionExpressionGroup();
|
||||
processMonitorConditions(conditions, root);
|
||||
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
class ConditionExpression {
|
||||
/**
|
||||
* @type {string} ID of variable
|
||||
*/
|
||||
variable;
|
||||
|
||||
/**
|
||||
* @type {string} ID of operator
|
||||
*/
|
||||
operator;
|
||||
|
||||
/**
|
||||
* @type {string} Value to test with the operator
|
||||
*/
|
||||
value;
|
||||
|
||||
/**
|
||||
* @type {LOGICAL} Connects expression result with previous group/expression results
|
||||
*/
|
||||
andOr;
|
||||
|
||||
/**
|
||||
* @param {string} variable ID of variable to test against
|
||||
* @param {string} operator ID of operator to test the variable with
|
||||
* @param {string} value Value to test with the operator
|
||||
* @param {LOGICAL} andOr Connects expression result with previous group/expression results
|
||||
*/
|
||||
constructor(variable, operator, value, andOr = LOGICAL.AND) {
|
||||
this.variable = variable;
|
||||
this.operator = operator;
|
||||
this.value = value;
|
||||
this.andOr = andOr;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LOGICAL,
|
||||
ConditionExpressionGroup,
|
||||
ConditionExpression,
|
||||
};
|
318
server/monitor-conditions/operators.js
Normal file
318
server/monitor-conditions/operators.js
Normal file
@@ -0,0 +1,318 @@
|
||||
class ConditionOperator {
|
||||
id = undefined;
|
||||
caption = undefined;
|
||||
|
||||
/**
|
||||
* @type {mixed} variable
|
||||
* @type {mixed} value
|
||||
*/
|
||||
test(variable, value) {
|
||||
throw new Error("You need to override test()");
|
||||
}
|
||||
}
|
||||
|
||||
const OP_STR_EQUALS = "equals";
|
||||
|
||||
const OP_STR_NOT_EQUALS = "not_equals";
|
||||
|
||||
const OP_CONTAINS = "contains";
|
||||
|
||||
const OP_NOT_CONTAINS = "not_contains";
|
||||
|
||||
const OP_STARTS_WITH = "starts_with";
|
||||
|
||||
const OP_NOT_STARTS_WITH = "not_starts_with";
|
||||
|
||||
const OP_ENDS_WITH = "ends_with";
|
||||
|
||||
const OP_NOT_ENDS_WITH = "not_ends_with";
|
||||
|
||||
const OP_NUM_EQUALS = "num_equals";
|
||||
|
||||
const OP_NUM_NOT_EQUALS = "num_not_equals";
|
||||
|
||||
const OP_LT = "lt";
|
||||
|
||||
const OP_GT = "gt";
|
||||
|
||||
const OP_LTE = "lte";
|
||||
|
||||
const OP_GTE = "gte";
|
||||
|
||||
/**
|
||||
* Asserts a variable is equal to a value.
|
||||
*/
|
||||
class StringEqualsOperator extends ConditionOperator {
|
||||
id = OP_STR_EQUALS;
|
||||
caption = "equals";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
return variable === value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a variable is not equal to a value.
|
||||
*/
|
||||
class StringNotEqualsOperator extends ConditionOperator {
|
||||
id = OP_STR_NOT_EQUALS;
|
||||
caption = "not equals";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
return variable !== value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a variable contains a value.
|
||||
* Handles both Array and String variable types.
|
||||
*/
|
||||
class ContainsOperator extends ConditionOperator {
|
||||
id = OP_CONTAINS;
|
||||
caption = "contains";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
if (Array.isArray(variable)) {
|
||||
return variable.includes(value);
|
||||
}
|
||||
|
||||
return variable.indexOf(value) !== -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a variable does not contain a value.
|
||||
* Handles both Array and String variable types.
|
||||
*/
|
||||
class NotContainsOperator extends ConditionOperator {
|
||||
id = OP_NOT_CONTAINS;
|
||||
caption = "not contains";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
if (Array.isArray(variable)) {
|
||||
return !variable.includes(value);
|
||||
}
|
||||
|
||||
return variable.indexOf(value) === -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a variable starts with a value.
|
||||
*/
|
||||
class StartsWithOperator extends ConditionOperator {
|
||||
id = OP_STARTS_WITH;
|
||||
caption = "starts with";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
return variable.startsWith(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a variable does not start with a value.
|
||||
*/
|
||||
class NotStartsWithOperator extends ConditionOperator {
|
||||
id = OP_NOT_STARTS_WITH;
|
||||
caption = "not starts with";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
return !variable.startsWith(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a variable ends with a value.
|
||||
*/
|
||||
class EndsWithOperator extends ConditionOperator {
|
||||
id = OP_ENDS_WITH;
|
||||
caption = "ends with";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
return variable.endsWith(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a variable does not end with a value.
|
||||
*/
|
||||
class NotEndsWithOperator extends ConditionOperator {
|
||||
id = OP_NOT_ENDS_WITH;
|
||||
caption = "not ends with";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
return !variable.endsWith(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a numeric variable is equal to a value.
|
||||
*/
|
||||
class NumberEqualsOperator extends ConditionOperator {
|
||||
id = OP_NUM_EQUALS;
|
||||
caption = "equals";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
return variable === Number(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a numeric variable is not equal to a value.
|
||||
*/
|
||||
class NumberNotEqualsOperator extends ConditionOperator {
|
||||
id = OP_NUM_NOT_EQUALS;
|
||||
caption = "not equals";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
return variable !== Number(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a variable is less than a value.
|
||||
*/
|
||||
class LessThanOperator extends ConditionOperator {
|
||||
id = OP_LT;
|
||||
caption = "less than";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
return variable < Number(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a variable is greater than a value.
|
||||
*/
|
||||
class GreaterThanOperator extends ConditionOperator {
|
||||
id = OP_GT;
|
||||
caption = "greater than";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
return variable > Number(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a variable is less than or equal to a value.
|
||||
*/
|
||||
class LessThanOrEqualToOperator extends ConditionOperator {
|
||||
id = OP_LTE;
|
||||
caption = "less than or equal to";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
return variable <= Number(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a variable is greater than or equal to a value.
|
||||
*/
|
||||
class GreaterThanOrEqualToOperator extends ConditionOperator {
|
||||
id = OP_GTE;
|
||||
caption = "greater than or equal to";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
test(variable, value) {
|
||||
return variable >= Number(value);
|
||||
}
|
||||
}
|
||||
|
||||
const operatorMap = new Map([
|
||||
[ OP_STR_EQUALS, new StringEqualsOperator ],
|
||||
[ OP_STR_NOT_EQUALS, new StringNotEqualsOperator ],
|
||||
[ OP_CONTAINS, new ContainsOperator ],
|
||||
[ OP_NOT_CONTAINS, new NotContainsOperator ],
|
||||
[ OP_STARTS_WITH, new StartsWithOperator ],
|
||||
[ OP_NOT_STARTS_WITH, new NotStartsWithOperator ],
|
||||
[ OP_ENDS_WITH, new EndsWithOperator ],
|
||||
[ OP_NOT_ENDS_WITH, new NotEndsWithOperator ],
|
||||
[ OP_NUM_EQUALS, new NumberEqualsOperator ],
|
||||
[ OP_NUM_NOT_EQUALS, new NumberNotEqualsOperator ],
|
||||
[ OP_LT, new LessThanOperator ],
|
||||
[ OP_GT, new GreaterThanOperator ],
|
||||
[ OP_LTE, new LessThanOrEqualToOperator ],
|
||||
[ OP_GTE, new GreaterThanOrEqualToOperator ],
|
||||
]);
|
||||
|
||||
const defaultStringOperators = [
|
||||
operatorMap.get(OP_STR_EQUALS),
|
||||
operatorMap.get(OP_STR_NOT_EQUALS),
|
||||
operatorMap.get(OP_CONTAINS),
|
||||
operatorMap.get(OP_NOT_CONTAINS),
|
||||
operatorMap.get(OP_STARTS_WITH),
|
||||
operatorMap.get(OP_NOT_STARTS_WITH),
|
||||
operatorMap.get(OP_ENDS_WITH),
|
||||
operatorMap.get(OP_NOT_ENDS_WITH)
|
||||
];
|
||||
|
||||
const defaultNumberOperators = [
|
||||
operatorMap.get(OP_NUM_EQUALS),
|
||||
operatorMap.get(OP_NUM_NOT_EQUALS),
|
||||
operatorMap.get(OP_LT),
|
||||
operatorMap.get(OP_GT),
|
||||
operatorMap.get(OP_LTE),
|
||||
operatorMap.get(OP_GTE)
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
OP_STR_EQUALS,
|
||||
OP_STR_NOT_EQUALS,
|
||||
OP_CONTAINS,
|
||||
OP_NOT_CONTAINS,
|
||||
OP_STARTS_WITH,
|
||||
OP_NOT_STARTS_WITH,
|
||||
OP_ENDS_WITH,
|
||||
OP_NOT_ENDS_WITH,
|
||||
OP_NUM_EQUALS,
|
||||
OP_NUM_NOT_EQUALS,
|
||||
OP_LT,
|
||||
OP_GT,
|
||||
OP_LTE,
|
||||
OP_GTE,
|
||||
operatorMap,
|
||||
defaultStringOperators,
|
||||
defaultNumberOperators,
|
||||
ConditionOperator,
|
||||
};
|
31
server/monitor-conditions/variables.js
Normal file
31
server/monitor-conditions/variables.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Represents a variable used in a condition and the set of operators that can be applied to this variable.
|
||||
*
|
||||
* A `ConditionVariable` holds the ID of the variable and a list of operators that define how this variable can be evaluated
|
||||
* in conditions. For example, if the variable is a request body or a specific field in a request, the operators can include
|
||||
* operations such as equality checks, comparisons, or other custom evaluations.
|
||||
*/
|
||||
class ConditionVariable {
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
id;
|
||||
|
||||
/**
|
||||
* @type {import("./operators").ConditionOperator[]}
|
||||
*/
|
||||
operators = {};
|
||||
|
||||
/**
|
||||
* @param {string} id ID of variable
|
||||
* @param {import("./operators").ConditionOperator[]} operators Operators the condition supports
|
||||
*/
|
||||
constructor(id, operators = []) {
|
||||
this.id = id;
|
||||
this.operators = operators;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ConditionVariable,
|
||||
};
|
@@ -1,13 +1,22 @@
|
||||
const { MonitorType } = require("./monitor-type");
|
||||
const { UP } = require("../../src/util");
|
||||
const { UP, DOWN } = require("../../src/util");
|
||||
const dayjs = require("dayjs");
|
||||
const { dnsResolve } = require("../util-server");
|
||||
const { R } = require("redbean-node");
|
||||
const { ConditionVariable } = require("../monitor-conditions/variables");
|
||||
const { defaultStringOperators } = require("../monitor-conditions/operators");
|
||||
const { ConditionExpressionGroup } = require("../monitor-conditions/expression");
|
||||
const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator");
|
||||
|
||||
class DnsMonitorType extends MonitorType {
|
||||
|
||||
name = "dns";
|
||||
|
||||
supportsConditions = true;
|
||||
|
||||
conditionVariables = [
|
||||
new ConditionVariable("record", defaultStringOperators ),
|
||||
];
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
@@ -18,28 +27,48 @@ class DnsMonitorType extends MonitorType {
|
||||
let dnsRes = await dnsResolve(monitor.hostname, monitor.dns_resolve_server, monitor.port, monitor.dns_resolve_type);
|
||||
heartbeat.ping = dayjs().valueOf() - startTime;
|
||||
|
||||
if (monitor.dns_resolve_type === "A" || monitor.dns_resolve_type === "AAAA" || monitor.dns_resolve_type === "TXT" || monitor.dns_resolve_type === "PTR") {
|
||||
dnsMessage += "Records: ";
|
||||
dnsMessage += dnsRes.join(" | ");
|
||||
} else if (monitor.dns_resolve_type === "CNAME" || monitor.dns_resolve_type === "PTR") {
|
||||
dnsMessage += dnsRes[0];
|
||||
} else if (monitor.dns_resolve_type === "CAA") {
|
||||
dnsMessage += dnsRes[0].issue;
|
||||
} else if (monitor.dns_resolve_type === "MX") {
|
||||
dnsRes.forEach(record => {
|
||||
dnsMessage += `Hostname: ${record.exchange} - Priority: ${record.priority} | `;
|
||||
});
|
||||
dnsMessage = dnsMessage.slice(0, -2);
|
||||
} else if (monitor.dns_resolve_type === "NS") {
|
||||
dnsMessage += "Servers: ";
|
||||
dnsMessage += dnsRes.join(" | ");
|
||||
} else if (monitor.dns_resolve_type === "SOA") {
|
||||
dnsMessage += `NS-Name: ${dnsRes.nsname} | Hostmaster: ${dnsRes.hostmaster} | Serial: ${dnsRes.serial} | Refresh: ${dnsRes.refresh} | Retry: ${dnsRes.retry} | Expire: ${dnsRes.expire} | MinTTL: ${dnsRes.minttl}`;
|
||||
} else if (monitor.dns_resolve_type === "SRV") {
|
||||
dnsRes.forEach(record => {
|
||||
dnsMessage += `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight} | `;
|
||||
});
|
||||
dnsMessage = dnsMessage.slice(0, -2);
|
||||
const conditions = ConditionExpressionGroup.fromMonitor(monitor);
|
||||
let conditionsResult = true;
|
||||
const handleConditions = (data) => conditions ? evaluateExpressionGroup(conditions, data) : true;
|
||||
|
||||
switch (monitor.dns_resolve_type) {
|
||||
case "A":
|
||||
case "AAAA":
|
||||
case "TXT":
|
||||
case "PTR":
|
||||
dnsMessage = `Records: ${dnsRes.join(" | ")}`;
|
||||
conditionsResult = dnsRes.some(record => handleConditions({ record }));
|
||||
break;
|
||||
|
||||
case "CNAME":
|
||||
dnsMessage = dnsRes[0];
|
||||
conditionsResult = handleConditions({ record: dnsRes[0] });
|
||||
break;
|
||||
|
||||
case "CAA":
|
||||
dnsMessage = dnsRes[0].issue;
|
||||
conditionsResult = handleConditions({ record: dnsRes[0].issue });
|
||||
break;
|
||||
|
||||
case "MX":
|
||||
dnsMessage = dnsRes.map(record => `Hostname: ${record.exchange} - Priority: ${record.priority}`).join(" | ");
|
||||
conditionsResult = dnsRes.some(record => handleConditions({ record: record.exchange }));
|
||||
break;
|
||||
|
||||
case "NS":
|
||||
dnsMessage = `Servers: ${dnsRes.join(" | ")}`;
|
||||
conditionsResult = dnsRes.some(record => handleConditions({ record }));
|
||||
break;
|
||||
|
||||
case "SOA":
|
||||
dnsMessage = `NS-Name: ${dnsRes.nsname} | Hostmaster: ${dnsRes.hostmaster} | Serial: ${dnsRes.serial} | Refresh: ${dnsRes.refresh} | Retry: ${dnsRes.retry} | Expire: ${dnsRes.expire} | MinTTL: ${dnsRes.minttl}`;
|
||||
conditionsResult = handleConditions({ record: dnsRes.nsname });
|
||||
break;
|
||||
|
||||
case "SRV":
|
||||
dnsMessage = dnsRes.map(record => `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight}`).join(" | ");
|
||||
conditionsResult = dnsRes.some(record => handleConditions({ record: record.name }));
|
||||
break;
|
||||
}
|
||||
|
||||
if (monitor.dns_last_result !== dnsMessage && dnsMessage !== undefined) {
|
||||
@@ -47,7 +76,7 @@ class DnsMonitorType extends MonitorType {
|
||||
}
|
||||
|
||||
heartbeat.msg = dnsMessage;
|
||||
heartbeat.status = UP;
|
||||
heartbeat.status = conditionsResult ? UP : DOWN;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,90 +0,0 @@
|
||||
const { MonitorType } = require("./monitor-type");
|
||||
const { UP, log } = require("../../src/util");
|
||||
const dayjs = require("dayjs");
|
||||
const grpc = require("@grpc/grpc-js");
|
||||
const protojs = require("protobufjs");
|
||||
|
||||
class GrpcKeywordMonitorType extends MonitorType {
|
||||
name = "grpc-keyword";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async check(monitor, heartbeat, _server) {
|
||||
const startTime = dayjs().valueOf();
|
||||
const service = this.constructGrpcService(this.grpcUrl, this.grpcProtobuf, this.grpcServiceName, this.grpcEnableTls);
|
||||
let response = await this.grpcQuery(service, this.grpcMethod, this.grpcBody);
|
||||
heartbeat.ping = dayjs().valueOf() - startTime;
|
||||
log.debug(this.name, `gRPC response: ${response}`);
|
||||
if (response.length > 50) {
|
||||
response = response.toString().substring(0, 47) + "...";
|
||||
}
|
||||
let keywordFound = response.toString().includes(this.keyword);
|
||||
if (keywordFound !== !this.isInvertKeyword()) {
|
||||
log.debug(this.name, `GRPC response [${response}] + ", but keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${response} + "]"`);
|
||||
throw new Error(`keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${response} + "]`);
|
||||
}
|
||||
heartbeat.status = UP;
|
||||
heartbeat.msg = `${response}, keyword [${this.keyword}] ${keywordFound ? "is" : "not"} found`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create gRPC client
|
||||
* @param {string} url grpc Url
|
||||
* @param {string} protobufData grpc ProtobufData
|
||||
* @param {string} serviceName grpc ServiceName
|
||||
* @param {string} enableTls grpc EnableTls
|
||||
* @returns {grpc.Service} grpc Service
|
||||
*/
|
||||
constructGrpcService(url, protobufData, serviceName, enableTls) {
|
||||
const protocObject = protojs.parse(protobufData);
|
||||
const protoServiceObject = protocObject.root.lookupService(serviceName);
|
||||
const Client = grpc.makeGenericClientConstructor({});
|
||||
const credentials = enableTls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure();
|
||||
const client = new Client(url, credentials);
|
||||
return protoServiceObject.create((method, requestData, cb) => {
|
||||
const fullServiceName = method.fullName;
|
||||
const serviceFQDN = fullServiceName.split(".");
|
||||
const serviceMethod = serviceFQDN.pop();
|
||||
const serviceMethodClientImpl = `/${serviceFQDN.slice(1).join(".")}/${serviceMethod}`;
|
||||
log.debug(this.name, `gRPC method ${serviceMethodClientImpl}`);
|
||||
client.makeUnaryRequest(
|
||||
serviceMethodClientImpl,
|
||||
arg => arg,
|
||||
arg => arg,
|
||||
requestData,
|
||||
cb);
|
||||
}, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create gRPC client stib
|
||||
* @param {grpc.Service} service grpc Url
|
||||
* @param {string} method grpc Method
|
||||
* @param {string} body grpc Body
|
||||
* @returns {Promise<string>} Result of gRPC query
|
||||
*/
|
||||
async grpcQuery(service, method, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
service[`${method}`](JSON.parse(body), (err, response) => {
|
||||
if (err) {
|
||||
if (err.code !== 1) {
|
||||
reject(`Error in send gRPC ${err.code} ${err.details}`);
|
||||
}
|
||||
log.debug(this.name, `ignoring ${err.code} ${err.details}, as code=1 is considered OK`);
|
||||
resolve(`${err.code} is considered OK because ${err.details}`);
|
||||
}
|
||||
resolve(JSON.stringify(response));
|
||||
});
|
||||
} catch (err) {
|
||||
reject(`Error ${err}. Please review your gRPC configuration option. The service name must not include package name value, and the method name must follow camelCase format`);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GrpcKeywordMonitorType,
|
||||
};
|
@@ -4,7 +4,6 @@ const { MongoClient } = require("mongodb");
|
||||
const jsonata = require("jsonata");
|
||||
|
||||
class MongodbMonitorType extends MonitorType {
|
||||
|
||||
name = "mongodb";
|
||||
|
||||
/**
|
||||
@@ -49,8 +48,7 @@ class MongodbMonitorType extends MonitorType {
|
||||
* Connect to and run MongoDB command on a MongoDB database
|
||||
* @param {string} connectionString The database connection string
|
||||
* @param {object} command MongoDB command to run on the database
|
||||
* @returns {Promise<(string[] | object[] | object)>} Response from
|
||||
* server
|
||||
* @returns {Promise<(string[] | object[] | object)>} Response from server
|
||||
*/
|
||||
async runMongodbCommand(connectionString, command) {
|
||||
let client = await MongoClient.connect(connectionString);
|
||||
|
@@ -1,6 +1,19 @@
|
||||
class MonitorType {
|
||||
name = undefined;
|
||||
|
||||
/**
|
||||
* Whether or not this type supports monitor conditions. Controls UI visibility in monitor form.
|
||||
* @type {boolean}
|
||||
*/
|
||||
supportsConditions = false;
|
||||
|
||||
/**
|
||||
* Variables supported by this type. e.g. an HTTP type could have a "response_code" variable to test against.
|
||||
* This property controls the choices displayed in the monitor edit form.
|
||||
* @type {import("../monitor-conditions/variables").ConditionVariable[]}
|
||||
*/
|
||||
conditionVariables = [];
|
||||
|
||||
/**
|
||||
* Run the monitoring check on the given monitor
|
||||
* @param {Monitor} monitor Monitor to check
|
||||
@@ -11,7 +24,6 @@ class MonitorType {
|
||||
async check(monitor, heartbeat, server) {
|
||||
throw new Error("You need to override check()");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
@@ -4,15 +4,10 @@ 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>}
|
||||
* @inheritdoc
|
||||
*/
|
||||
async check(monitor, heartbeat, server) {
|
||||
const receivedMessage = await this.mqttAsync(monitor.hostname, monitor.mqttTopic, {
|
||||
|
67
server/monitor-types/rabbitmq.js
Normal file
67
server/monitor-types/rabbitmq.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const { MonitorType } = require("./monitor-type");
|
||||
const { log, UP, DOWN } = require("../../src/util");
|
||||
const { axiosAbortSignal } = require("../util-server");
|
||||
const axios = require("axios");
|
||||
|
||||
class RabbitMqMonitorType extends MonitorType {
|
||||
name = "rabbitmq";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async check(monitor, heartbeat, server) {
|
||||
let baseUrls = [];
|
||||
try {
|
||||
baseUrls = JSON.parse(monitor.rabbitmqNodes);
|
||||
} catch (error) {
|
||||
throw new Error("Invalid RabbitMQ Nodes");
|
||||
}
|
||||
|
||||
heartbeat.status = DOWN;
|
||||
for (let baseUrl of baseUrls) {
|
||||
try {
|
||||
// Without a trailing slash, path in baseUrl will be removed. https://example.com/api -> https://example.com
|
||||
if ( !baseUrl.endsWith("/") ) {
|
||||
baseUrl += "/";
|
||||
}
|
||||
const options = {
|
||||
// Do not start with slash, it will strip the trailing slash from baseUrl
|
||||
url: new URL("api/health/checks/alarms/", baseUrl).href,
|
||||
method: "get",
|
||||
timeout: monitor.timeout * 1000,
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Authorization": "Basic " + Buffer.from(`${monitor.rabbitmqUsername || ""}:${monitor.rabbitmqPassword || ""}`).toString("base64"),
|
||||
},
|
||||
signal: axiosAbortSignal((monitor.timeout + 10) * 1000),
|
||||
// Capture reason for 503 status
|
||||
validateStatus: (status) => status === 200 || status === 503,
|
||||
};
|
||||
log.debug("monitor", `[${monitor.name}] Axios Request: ${JSON.stringify(options)}`);
|
||||
const res = await axios.request(options);
|
||||
log.debug("monitor", `[${monitor.name}] Axios Response: status=${res.status} body=${JSON.stringify(res.data)}`);
|
||||
if (res.status === 200) {
|
||||
heartbeat.status = UP;
|
||||
heartbeat.msg = "OK";
|
||||
break;
|
||||
} else if (res.status === 503) {
|
||||
heartbeat.msg = res.data.reason;
|
||||
} else {
|
||||
heartbeat.msg = `${res.status} - ${res.statusText}`;
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isCancel(error)) {
|
||||
heartbeat.msg = "Request timed out";
|
||||
log.debug("monitor", `[${monitor.name}] Request timed out`);
|
||||
} else {
|
||||
log.debug("monitor", `[${monitor.name}] Axios Error: ${JSON.stringify(error.message)}`);
|
||||
heartbeat.msg = error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RabbitMqMonitorType,
|
||||
};
|
63
server/monitor-types/snmp.js
Normal file
63
server/monitor-types/snmp.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const { MonitorType } = require("./monitor-type");
|
||||
const { UP, log, evaluateJsonQuery } = require("../../src/util");
|
||||
const snmp = require("net-snmp");
|
||||
|
||||
class SNMPMonitorType extends MonitorType {
|
||||
name = "snmp";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async check(monitor, heartbeat, _server) {
|
||||
let session;
|
||||
try {
|
||||
const sessionOptions = {
|
||||
port: monitor.port || "161",
|
||||
retries: monitor.maxretries,
|
||||
timeout: monitor.timeout * 1000,
|
||||
version: snmp.Version[monitor.snmpVersion],
|
||||
};
|
||||
session = snmp.createSession(monitor.hostname, monitor.radiusPassword, sessionOptions);
|
||||
|
||||
// Handle errors during session creation
|
||||
session.on("error", (error) => {
|
||||
throw new Error(`Error creating SNMP session: ${error.message}`);
|
||||
});
|
||||
|
||||
const varbinds = await new Promise((resolve, reject) => {
|
||||
session.get([ monitor.snmpOid ], (error, varbinds) => {
|
||||
error ? reject(error) : resolve(varbinds);
|
||||
});
|
||||
});
|
||||
log.debug("monitor", `SNMP: Received varbinds (Type: ${snmp.ObjectType[varbinds[0].type]} Value: ${varbinds[0].value})`);
|
||||
|
||||
if (varbinds.length === 0) {
|
||||
throw new Error(`No varbinds returned from SNMP session (OID: ${monitor.snmpOid})`);
|
||||
}
|
||||
|
||||
if (varbinds[0].type === snmp.ObjectType.NoSuchInstance) {
|
||||
throw new Error(`The SNMP query returned that no instance exists for OID ${monitor.snmpOid}`);
|
||||
}
|
||||
|
||||
// We restrict querying to one OID per monitor, therefore `varbinds[0]` will always contain the value we're interested in.
|
||||
const value = varbinds[0].value;
|
||||
|
||||
const { status, response } = await evaluateJsonQuery(value, monitor.jsonPath, monitor.jsonPathOperator, monitor.expectedValue);
|
||||
|
||||
if (status) {
|
||||
heartbeat.status = UP;
|
||||
heartbeat.msg = `JSON query passes (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`;
|
||||
} else {
|
||||
throw new Error(`JSON query does not pass (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`);
|
||||
}
|
||||
} finally {
|
||||
if (session) {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SNMPMonitorType,
|
||||
};
|
@@ -2,23 +2,13 @@ const { MonitorType } = require("./monitor-type");
|
||||
const { UP } = require("../../src/util");
|
||||
const childProcessAsync = require("promisify-child-process");
|
||||
|
||||
/**
|
||||
* A TailscalePing class extends the MonitorType.
|
||||
* It runs Tailscale ping to monitor the status of a specific node.
|
||||
*/
|
||||
class TailscalePing extends MonitorType {
|
||||
|
||||
name = "tailscale-ping";
|
||||
|
||||
/**
|
||||
* Checks the ping status of the URL associated with the monitor.
|
||||
* It then parses the Tailscale ping command output to update the heatrbeat.
|
||||
* @param {object} monitor The monitor object associated with the check.
|
||||
* @param {object} heartbeat The heartbeat object to update.
|
||||
* @returns {Promise<void>}
|
||||
* @throws Error if checking Tailscale ping encounters any error
|
||||
* @inheritdoc
|
||||
*/
|
||||
async check(monitor, heartbeat) {
|
||||
async check(monitor, heartbeat, _server) {
|
||||
try {
|
||||
let tailscaleOutput = await this.runTailscalePing(monitor.hostname, monitor.interval);
|
||||
this.parseTailscaleOutput(tailscaleOutput, heartbeat);
|
||||
|
35
server/notification-providers/46elks.js
Normal file
35
server/notification-providers/46elks.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
|
||||
class Elks extends NotificationProvider {
|
||||
name = "Elks";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
const okMsg = "Sent Successfully.";
|
||||
const url = "https://api.46elks.com/a1/sms";
|
||||
|
||||
try {
|
||||
let data = new URLSearchParams();
|
||||
data.append("from", notification.elksFromNumber);
|
||||
data.append("to", notification.elksToNumber );
|
||||
data.append("message", msg);
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
"Authorization": "Basic " + Buffer.from(`${notification.elksUsername}:${notification.elksAuthToken}`).toString("base64")
|
||||
}
|
||||
};
|
||||
|
||||
await axios.post(url, data, config);
|
||||
|
||||
return okMsg;
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Elks;
|
@@ -87,7 +87,6 @@ class DingDing extends NotificationProvider {
|
||||
* @returns {string} Status
|
||||
*/
|
||||
statusToString(status) {
|
||||
// TODO: Move to notification-provider.js to avoid repetition in classes
|
||||
switch (status) {
|
||||
case DOWN:
|
||||
return "DOWN";
|
||||
|
@@ -33,26 +33,6 @@ class Discord extends NotificationProvider {
|
||||
return okMsg;
|
||||
}
|
||||
|
||||
let address;
|
||||
|
||||
switch (monitorJSON["type"]) {
|
||||
case "ping":
|
||||
address = monitorJSON["hostname"];
|
||||
break;
|
||||
case "port":
|
||||
case "dns":
|
||||
case "gamedig":
|
||||
case "steam":
|
||||
address = monitorJSON["hostname"];
|
||||
if (monitorJSON["port"]) {
|
||||
address += ":" + monitorJSON["port"];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
address = monitorJSON["url"];
|
||||
break;
|
||||
}
|
||||
|
||||
// If heartbeatJSON is not null, we go into the normal alerting loop.
|
||||
if (heartbeatJSON["status"] === DOWN) {
|
||||
let discorddowndata = {
|
||||
@@ -68,7 +48,7 @@ class Discord extends NotificationProvider {
|
||||
},
|
||||
{
|
||||
name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
|
||||
value: monitorJSON["type"] === "push" ? "Heartbeat" : address,
|
||||
value: this.extractAddress(monitorJSON),
|
||||
},
|
||||
{
|
||||
name: `Time (${heartbeatJSON["timezone"]})`,
|
||||
@@ -105,7 +85,7 @@ class Discord extends NotificationProvider {
|
||||
},
|
||||
{
|
||||
name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
|
||||
value: monitorJSON["type"] === "push" ? "Heartbeat" : address,
|
||||
value: this.extractAddress(monitorJSON),
|
||||
},
|
||||
{
|
||||
name: `Time (${heartbeatJSON["timezone"]})`,
|
||||
|
@@ -1,4 +1,3 @@
|
||||
const { log } = require("../../src/util");
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const {
|
||||
relayInit,
|
||||
@@ -12,16 +11,7 @@ const {
|
||||
// polyfills for node versions
|
||||
const semver = require("semver");
|
||||
const nodeVersion = process.version;
|
||||
if (semver.lt(nodeVersion, "16.0.0")) {
|
||||
log.warn("monitor", "Node <= 16 is unsupported for nostr, sorry :(");
|
||||
} else if (semver.lt(nodeVersion, "18.0.0")) {
|
||||
// polyfills for node 16
|
||||
global.crypto = require("crypto");
|
||||
global.WebSocket = require("isomorphic-ws");
|
||||
if (typeof crypto !== "undefined" && !crypto.subtle && crypto.webcrypto) {
|
||||
crypto.subtle = crypto.webcrypto.subtle;
|
||||
}
|
||||
} else if (semver.lt(nodeVersion, "20.0.0")) {
|
||||
if (semver.lt(nodeVersion, "20.0.0")) {
|
||||
// polyfills for node 18
|
||||
global.crypto = require("crypto");
|
||||
global.WebSocket = require("isomorphic-ws");
|
||||
|
@@ -19,6 +19,36 @@ class NotificationProvider {
|
||||
throw new Error("Have to override Notification.send(...)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the address from a monitor JSON object based on its type.
|
||||
* @param {?object} monitorJSON Monitor details (For Up/Down only)
|
||||
* @returns {string} The extracted address based on the monitor type.
|
||||
*/
|
||||
extractAddress(monitorJSON) {
|
||||
if (!monitorJSON) {
|
||||
return "";
|
||||
}
|
||||
switch (monitorJSON["type"]) {
|
||||
case "push":
|
||||
return "Heartbeat";
|
||||
case "ping":
|
||||
return monitorJSON["hostname"];
|
||||
case "port":
|
||||
case "dns":
|
||||
case "gamedig":
|
||||
case "steam":
|
||||
if (monitorJSON["port"]) {
|
||||
return monitorJSON["hostname"] + ":" + monitorJSON["port"];
|
||||
}
|
||||
return monitorJSON["hostname"];
|
||||
default:
|
||||
if (![ "https://", "http://", "" ].includes(monitorJSON["url"])) {
|
||||
return monitorJSON["url"];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an error
|
||||
* @param {any} error The error to throw
|
||||
|
47
server/notification-providers/onesender.js
Normal file
47
server/notification-providers/onesender.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
|
||||
class Onesender extends NotificationProvider {
|
||||
name = "Onesender";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
const okMsg = "Sent Successfully.";
|
||||
|
||||
try {
|
||||
let data = {
|
||||
heartbeat: heartbeatJSON,
|
||||
monitor: monitorJSON,
|
||||
msg,
|
||||
to: notification.onesenderReceiver,
|
||||
type: "text",
|
||||
recipient_type: "individual",
|
||||
text: {
|
||||
body: msg
|
||||
}
|
||||
};
|
||||
if (notification.onesenderTypeReceiver === "private") {
|
||||
data.to = notification.onesenderReceiver + "@s.whatsapp.net";
|
||||
} else {
|
||||
data.recipient_type = "group";
|
||||
data.to = notification.onesenderReceiver + "@g.us";
|
||||
}
|
||||
let config = {
|
||||
headers: {
|
||||
"Authorization": "Bearer " + notification.onesenderToken,
|
||||
}
|
||||
};
|
||||
await axios.post(notification.onesenderURL, data, config);
|
||||
return okMsg;
|
||||
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Onesender;
|
@@ -1,3 +1,6 @@
|
||||
const { getMonitorRelativeURL } = require("../../src/util");
|
||||
const { setting } = require("../util-server");
|
||||
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
|
||||
@@ -23,6 +26,12 @@ class Pushover extends NotificationProvider {
|
||||
"html": 1,
|
||||
};
|
||||
|
||||
const baseURL = await setting("primaryBaseURL");
|
||||
if (baseURL && monitorJSON) {
|
||||
data["url"] = baseURL + getMonitorRelativeURL(monitorJSON.id);
|
||||
data["url_title"] = "Link to Monitor";
|
||||
}
|
||||
|
||||
if (notification.pushoverdevice) {
|
||||
data.device = notification.pushoverdevice;
|
||||
}
|
||||
|
65
server/notification-providers/send-grid.js
Normal file
65
server/notification-providers/send-grid.js
Normal file
@@ -0,0 +1,65 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
|
||||
class SendGrid extends NotificationProvider {
|
||||
name = "SendGrid";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
const okMsg = "Sent Successfully.";
|
||||
|
||||
try {
|
||||
let config = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${notification.sendgridApiKey}`,
|
||||
},
|
||||
};
|
||||
|
||||
let personalizations = {
|
||||
to: [{ email: notification.sendgridToEmail }],
|
||||
};
|
||||
|
||||
// Add CC recipients if provided
|
||||
if (notification.sendgridCcEmail) {
|
||||
personalizations.cc = notification.sendgridCcEmail
|
||||
.split(",")
|
||||
.map((email) => ({ email: email.trim() }));
|
||||
}
|
||||
|
||||
// Add BCC recipients if provided
|
||||
if (notification.sendgridBccEmail) {
|
||||
personalizations.bcc = notification.sendgridBccEmail
|
||||
.split(",")
|
||||
.map((email) => ({ email: email.trim() }));
|
||||
}
|
||||
|
||||
let data = {
|
||||
personalizations: [ personalizations ],
|
||||
from: { email: notification.sendgridFromEmail.trim() },
|
||||
subject:
|
||||
notification.sendgridSubject ||
|
||||
"Notification from Your Uptime Kuma",
|
||||
content: [
|
||||
{
|
||||
type: "text/plain",
|
||||
value: msg,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await axios.post(
|
||||
"https://api.sendgrid.com/v3/mail/send",
|
||||
data,
|
||||
config
|
||||
);
|
||||
return okMsg;
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SendGrid;
|
@@ -11,8 +11,14 @@ class ServerChan extends NotificationProvider {
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
const okMsg = "Sent Successfully.";
|
||||
|
||||
// serverchan3 requires sending via ft07.com
|
||||
const matchResult = String(notification.serverChanSendKey).match(/^sctp(\d+)t/i);
|
||||
const url = matchResult && matchResult[1]
|
||||
? `https://${matchResult[1]}.push.ft07.com/send/${notification.serverChanSendKey}.send`
|
||||
: `https://sctapi.ftqq.com/${notification.serverChanSendKey}.send`;
|
||||
|
||||
try {
|
||||
await axios.post(`https://sctapi.ftqq.com/${notification.serverChanSendKey}.send`, {
|
||||
await axios.post(url, {
|
||||
"title": this.checkStatus(heartbeatJSON, monitorJSON),
|
||||
"desp": msg,
|
||||
});
|
||||
|
@@ -32,28 +32,7 @@ class SevenIO extends NotificationProvider {
|
||||
return okMsg;
|
||||
}
|
||||
|
||||
let address = "";
|
||||
|
||||
switch (monitorJSON["type"]) {
|
||||
case "ping":
|
||||
address = monitorJSON["hostname"];
|
||||
break;
|
||||
case "port":
|
||||
case "dns":
|
||||
case "gamedig":
|
||||
case "steam":
|
||||
address = monitorJSON["hostname"];
|
||||
if (monitorJSON["port"]) {
|
||||
address += ":" + monitorJSON["port"];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (![ "https://", "http://", "" ].includes(monitorJSON["url"])) {
|
||||
address = monitorJSON["url"];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let address = this.extractAddress(monitorJSON);
|
||||
if (address !== "") {
|
||||
address = `(${address}) `;
|
||||
}
|
||||
|
52
server/notification-providers/signl4.js
Normal file
52
server/notification-providers/signl4.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
const { UP, DOWN } = require("../../src/util");
|
||||
|
||||
class SIGNL4 extends NotificationProvider {
|
||||
name = "SIGNL4";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
const okMsg = "Sent Successfully.";
|
||||
|
||||
try {
|
||||
let data = {
|
||||
heartbeat: heartbeatJSON,
|
||||
monitor: monitorJSON,
|
||||
msg,
|
||||
// Source system
|
||||
"X-S4-SourceSystem": "UptimeKuma",
|
||||
monitorUrl: this.extractAddress(monitorJSON),
|
||||
};
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
if (heartbeatJSON == null) {
|
||||
// Test alert
|
||||
data.title = "Uptime Kuma Alert";
|
||||
data.message = msg;
|
||||
} else if (heartbeatJSON.status === UP) {
|
||||
data.title = "Uptime Kuma Monitor ✅ Up";
|
||||
data["X-S4-ExternalID"] = "UptimeKuma-" + monitorJSON.monitorID;
|
||||
data["X-S4-Status"] = "resolved";
|
||||
} else if (heartbeatJSON.status === DOWN) {
|
||||
data.title = "Uptime Kuma Monitor 🔴 Down";
|
||||
data["X-S4-ExternalID"] = "UptimeKuma-" + monitorJSON.monitorID;
|
||||
data["X-S4-Status"] = "new";
|
||||
}
|
||||
|
||||
await axios.post(notification.webhookURL, data, config);
|
||||
return okMsg;
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SIGNL4;
|
@@ -32,7 +32,7 @@ class Slack extends NotificationProvider {
|
||||
* @param {object} monitorJSON The monitor config
|
||||
* @returns {Array} The relevant action objects
|
||||
*/
|
||||
static buildActions(baseURL, monitorJSON) {
|
||||
buildActions(baseURL, monitorJSON) {
|
||||
const actions = [];
|
||||
|
||||
if (baseURL) {
|
||||
@@ -48,7 +48,8 @@ class Slack extends NotificationProvider {
|
||||
|
||||
}
|
||||
|
||||
if (monitorJSON.url) {
|
||||
const address = this.extractAddress(monitorJSON);
|
||||
if (address) {
|
||||
actions.push({
|
||||
"type": "button",
|
||||
"text": {
|
||||
@@ -56,7 +57,7 @@ class Slack extends NotificationProvider {
|
||||
"text": "Visit site",
|
||||
},
|
||||
"value": "Site",
|
||||
"url": monitorJSON.url,
|
||||
"url": address,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,7 +73,7 @@ class Slack extends NotificationProvider {
|
||||
* @param {string} msg The message body
|
||||
* @returns {Array<object>} The rich content blocks for the Slack message
|
||||
*/
|
||||
static buildBlocks(baseURL, monitorJSON, heartbeatJSON, title, msg) {
|
||||
buildBlocks(baseURL, monitorJSON, heartbeatJSON, title, msg) {
|
||||
|
||||
//create an array to dynamically add blocks
|
||||
const blocks = [];
|
||||
@@ -139,17 +140,22 @@ class Slack extends NotificationProvider {
|
||||
|
||||
const title = "Uptime Kuma Alert";
|
||||
let data = {
|
||||
"text": `${title}\n${msg}`,
|
||||
"channel": notification.slackchannel,
|
||||
"username": notification.slackusername,
|
||||
"icon_emoji": notification.slackiconemo,
|
||||
"attachments": [
|
||||
"attachments": [],
|
||||
};
|
||||
|
||||
if (notification.slackrichmessage) {
|
||||
data.attachments.push(
|
||||
{
|
||||
"color": (heartbeatJSON["status"] === UP) ? "#2eb886" : "#e01e5a",
|
||||
"blocks": Slack.buildBlocks(baseURL, monitorJSON, heartbeatJSON, title, msg),
|
||||
"blocks": this.buildBlocks(baseURL, monitorJSON, heartbeatJSON, title, msg),
|
||||
}
|
||||
]
|
||||
};
|
||||
);
|
||||
} else {
|
||||
data.text = `${title}\n${msg}`;
|
||||
}
|
||||
|
||||
if (notification.slackbutton) {
|
||||
await Slack.deprecateURL(notification.slackbutton);
|
||||
|
@@ -93,12 +93,7 @@ class SMTP extends NotificationProvider {
|
||||
|
||||
if (monitorJSON !== null) {
|
||||
monitorName = monitorJSON["name"];
|
||||
|
||||
if (monitorJSON["type"] === "http" || monitorJSON["type"] === "keyword" || monitorJSON["type"] === "json-query") {
|
||||
monitorHostnameOrURL = monitorJSON["url"];
|
||||
} else {
|
||||
monitorHostnameOrURL = monitorJSON["hostname"];
|
||||
}
|
||||
monitorHostnameOrURL = this.extractAddress(monitorJSON);
|
||||
}
|
||||
|
||||
let serviceStatus = "⚠️ Test";
|
||||
|
@@ -34,25 +34,7 @@ class Squadcast extends NotificationProvider {
|
||||
data.status = "resolve";
|
||||
}
|
||||
|
||||
let address;
|
||||
switch (monitorJSON["type"]) {
|
||||
case "ping":
|
||||
address = monitorJSON["hostname"];
|
||||
break;
|
||||
case "port":
|
||||
case "dns":
|
||||
case "steam":
|
||||
address = monitorJSON["hostname"];
|
||||
if (monitorJSON["port"]) {
|
||||
address += ":" + monitorJSON["port"];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
address = monitorJSON["url"];
|
||||
break;
|
||||
}
|
||||
|
||||
data.tags["AlertAddress"] = address;
|
||||
data.tags["AlertAddress"] = this.extractAddress(monitorJSON);
|
||||
|
||||
monitorJSON["tags"].forEach(tag => {
|
||||
data.tags[tag["name"]] = {
|
||||
|
@@ -216,21 +216,6 @@ class Teams extends NotificationProvider {
|
||||
return okMsg;
|
||||
}
|
||||
|
||||
let monitorUrl;
|
||||
|
||||
switch (monitorJSON["type"]) {
|
||||
case "http":
|
||||
case "keywork":
|
||||
monitorUrl = monitorJSON["url"];
|
||||
break;
|
||||
case "docker":
|
||||
monitorUrl = monitorJSON["docker_host"];
|
||||
break;
|
||||
default:
|
||||
monitorUrl = monitorJSON["hostname"];
|
||||
break;
|
||||
}
|
||||
|
||||
const baseURL = await setting("primaryBaseURL");
|
||||
let dashboardUrl;
|
||||
if (baseURL) {
|
||||
@@ -240,7 +225,7 @@ class Teams extends NotificationProvider {
|
||||
const payload = this._notificationPayloadFactory({
|
||||
heartbeatJSON: heartbeatJSON,
|
||||
monitorName: monitorJSON.name,
|
||||
monitorUrl: monitorUrl,
|
||||
monitorUrl: this.extractAddress(monitorJSON),
|
||||
dashboardUrl: dashboardUrl,
|
||||
});
|
||||
|
||||
|
@@ -10,11 +10,22 @@ class TechulusPush extends NotificationProvider {
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
const okMsg = "Sent Successfully.";
|
||||
|
||||
let data = {
|
||||
"title": notification?.pushTitle?.length ? notification.pushTitle : "Uptime-Kuma",
|
||||
"body": msg,
|
||||
"timeSensitive": notification.pushTimeSensitive ?? true,
|
||||
};
|
||||
|
||||
if (notification.pushChannel) {
|
||||
data.channel = notification.pushChannel;
|
||||
}
|
||||
|
||||
if (notification.pushSound) {
|
||||
data.sound = notification.pushSound;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(`https://push.techulus.com/api/v1/notify/${notification.pushAPIKey}`, {
|
||||
"title": "Uptime-Kuma",
|
||||
"body": msg,
|
||||
});
|
||||
await axios.post(`https://push.techulus.com/api/v1/notify/${notification.pushAPIKey}`, data);
|
||||
return okMsg;
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
|
77
server/notification-providers/threema.js
Normal file
77
server/notification-providers/threema.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
|
||||
class Threema extends NotificationProvider {
|
||||
name = "threema";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
const url = "https://msgapi.threema.ch/send_simple";
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
"Accept": "*/*",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
|
||||
}
|
||||
};
|
||||
|
||||
const data = {
|
||||
from: notification.threemaSenderIdentity,
|
||||
secret: notification.threemaSecret,
|
||||
text: msg
|
||||
};
|
||||
|
||||
switch (notification.threemaRecipientType) {
|
||||
case "identity":
|
||||
data.to = notification.threemaRecipient;
|
||||
break;
|
||||
case "phone":
|
||||
data.phone = notification.threemaRecipient;
|
||||
break;
|
||||
case "email":
|
||||
data.email = notification.threemaRecipient;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported recipient type: ${notification.threemaRecipientType}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(url, new URLSearchParams(data), config);
|
||||
return "Threema notification sent successfully.";
|
||||
} catch (error) {
|
||||
const errorMessage = this.handleApiError(error);
|
||||
this.throwGeneralAxiosError(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Threema API errors
|
||||
* @param {any} error The error to handle
|
||||
* @returns {string} Additional error context
|
||||
*/
|
||||
handleApiError(error) {
|
||||
if (!error.response) {
|
||||
return error.message;
|
||||
}
|
||||
switch (error.response.status) {
|
||||
case 400:
|
||||
return "Invalid recipient identity or account not set up for basic mode (400).";
|
||||
case 401:
|
||||
return "Incorrect API identity or secret (401).";
|
||||
case 402:
|
||||
return "No credits remaining (402).";
|
||||
case 404:
|
||||
return "Recipient not found (404).";
|
||||
case 413:
|
||||
return "Message is too long (413).";
|
||||
case 500:
|
||||
return "Temporary internal server error (500).";
|
||||
default:
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Threema;
|
@@ -32,20 +32,17 @@ class WeCom extends NotificationProvider {
|
||||
* @returns {object} Message
|
||||
*/
|
||||
composeMessage(heartbeatJSON, msg) {
|
||||
let title;
|
||||
let title = "UptimeKuma Message";
|
||||
if (msg != null && heartbeatJSON != null && heartbeatJSON["status"] === UP) {
|
||||
title = "UptimeKuma Monitor Up";
|
||||
}
|
||||
if (msg != null && heartbeatJSON != null && heartbeatJSON["status"] === DOWN) {
|
||||
title = "UptimeKuma Monitor Down";
|
||||
}
|
||||
if (msg != null) {
|
||||
title = "UptimeKuma Message";
|
||||
}
|
||||
return {
|
||||
msgtype: "text",
|
||||
text: {
|
||||
content: title + msg
|
||||
content: title + "\n" + msg
|
||||
}
|
||||
};
|
||||
}
|
||||
|
51
server/notification-providers/wpush.js
Normal file
51
server/notification-providers/wpush.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
const { DOWN, UP } = require("../../src/util");
|
||||
|
||||
class WPush extends NotificationProvider {
|
||||
name = "WPush";
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
const okMsg = "Sent Successfully.";
|
||||
|
||||
try {
|
||||
const context = {
|
||||
"title": this.checkStatus(heartbeatJSON, monitorJSON),
|
||||
"content": msg,
|
||||
"apikey": notification.wpushAPIkey,
|
||||
"channel": notification.wpushChannel
|
||||
};
|
||||
const result = await axios.post("https://api.wpush.cn/api/v1/send", context);
|
||||
if (result.data.code !== 0) {
|
||||
throw result.data.message;
|
||||
}
|
||||
|
||||
return okMsg;
|
||||
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the formatted title for message
|
||||
* @param {?object} heartbeatJSON Heartbeat details (For Up/Down only)
|
||||
* @param {?object} monitorJSON Monitor details (For Up/Down only)
|
||||
* @returns {string} Formatted title
|
||||
*/
|
||||
checkStatus(heartbeatJSON, monitorJSON) {
|
||||
let title = "UptimeKuma Message";
|
||||
if (heartbeatJSON != null && heartbeatJSON["status"] === UP) {
|
||||
title = "UptimeKuma Monitor Up " + monitorJSON["name"];
|
||||
}
|
||||
if (heartbeatJSON != null && heartbeatJSON["status"] === DOWN) {
|
||||
title = "UptimeKuma Monitor Down " + monitorJSON["name"];
|
||||
}
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WPush;
|
@@ -13,9 +13,9 @@ class ZohoCliq extends NotificationProvider {
|
||||
*/
|
||||
_statusMessageFactory = (status, monitorName) => {
|
||||
if (status === DOWN) {
|
||||
return `🔴 Application [${monitorName}] went down\n`;
|
||||
return `🔴 [${monitorName}] went down\n`;
|
||||
} else if (status === UP) {
|
||||
return `✅ Application [${monitorName}] is back online\n`;
|
||||
return `### ✅ [${monitorName}] is back online\n`;
|
||||
}
|
||||
return "Notification\n";
|
||||
};
|
||||
@@ -46,16 +46,11 @@ class ZohoCliq extends NotificationProvider {
|
||||
monitorUrl,
|
||||
}) => {
|
||||
const payload = [];
|
||||
payload.push("### Uptime Kuma\n");
|
||||
payload.push(this._statusMessageFactory(status, monitorName));
|
||||
payload.push(`*Description:* ${monitorMessage}`);
|
||||
|
||||
if (monitorName) {
|
||||
payload.push(`*Monitor:* ${monitorName}`);
|
||||
}
|
||||
|
||||
if (monitorUrl && monitorUrl !== "https://") {
|
||||
payload.push(`*URL:* [${monitorUrl}](${monitorUrl})`);
|
||||
payload.push(`*URL:* ${monitorUrl}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
@@ -87,24 +82,10 @@ class ZohoCliq extends NotificationProvider {
|
||||
return okMsg;
|
||||
}
|
||||
|
||||
let url;
|
||||
switch (monitorJSON["type"]) {
|
||||
case "http":
|
||||
case "keywork":
|
||||
url = monitorJSON["url"];
|
||||
break;
|
||||
case "docker":
|
||||
url = monitorJSON["docker_host"];
|
||||
break;
|
||||
default:
|
||||
url = monitorJSON["hostname"];
|
||||
break;
|
||||
}
|
||||
|
||||
const payload = this._notificationPayloadFactory({
|
||||
monitorMessage: heartbeatJSON.msg,
|
||||
monitorName: monitorJSON.name,
|
||||
monitorUrl: url,
|
||||
monitorUrl: this.extractAddress(monitorJSON),
|
||||
status: heartbeatJSON.status
|
||||
});
|
||||
|
||||
|
@@ -11,6 +11,7 @@ const CallMeBot = require("./notification-providers/call-me-bot");
|
||||
const SMSC = require("./notification-providers/smsc");
|
||||
const DingDing = require("./notification-providers/dingding");
|
||||
const Discord = require("./notification-providers/discord");
|
||||
const Elks = require("./notification-providers/46elks");
|
||||
const Feishu = require("./notification-providers/feishu");
|
||||
const FreeMobile = require("./notification-providers/freemobile");
|
||||
const GoogleChat = require("./notification-providers/google-chat");
|
||||
@@ -42,6 +43,7 @@ const Pushy = require("./notification-providers/pushy");
|
||||
const RocketChat = require("./notification-providers/rocket-chat");
|
||||
const SerwerSMS = require("./notification-providers/serwersms");
|
||||
const Signal = require("./notification-providers/signal");
|
||||
const SIGNL4 = require("./notification-providers/signl4");
|
||||
const Slack = require("./notification-providers/slack");
|
||||
const SMSPartner = require("./notification-providers/smspartner");
|
||||
const SMSEagle = require("./notification-providers/smseagle");
|
||||
@@ -51,6 +53,7 @@ const Stackfield = require("./notification-providers/stackfield");
|
||||
const Teams = require("./notification-providers/teams");
|
||||
const TechulusPush = require("./notification-providers/techulus-push");
|
||||
const Telegram = require("./notification-providers/telegram");
|
||||
const Threema = require("./notification-providers/threema");
|
||||
const Twilio = require("./notification-providers/twilio");
|
||||
const Splunk = require("./notification-providers/splunk");
|
||||
const Webhook = require("./notification-providers/webhook");
|
||||
@@ -63,6 +66,9 @@ const SevenIO = require("./notification-providers/sevenio");
|
||||
const Whapi = require("./notification-providers/whapi");
|
||||
const GtxMessaging = require("./notification-providers/gtx-messaging");
|
||||
const Cellsynt = require("./notification-providers/cellsynt");
|
||||
const Onesender = require("./notification-providers/onesender");
|
||||
const Wpush = require("./notification-providers/wpush");
|
||||
const SendGrid = require("./notification-providers/send-grid");
|
||||
|
||||
class Notification {
|
||||
|
||||
@@ -91,6 +97,7 @@ class Notification {
|
||||
new SMSC(),
|
||||
new DingDing(),
|
||||
new Discord(),
|
||||
new Elks(),
|
||||
new Feishu(),
|
||||
new FreeMobile(),
|
||||
new GoogleChat(),
|
||||
@@ -110,6 +117,7 @@ class Notification {
|
||||
new Ntfy(),
|
||||
new Octopush(),
|
||||
new OneBot(),
|
||||
new Onesender(),
|
||||
new Opsgenie(),
|
||||
new PagerDuty(),
|
||||
new FlashDuty(),
|
||||
@@ -123,6 +131,7 @@ class Notification {
|
||||
new ServerChan(),
|
||||
new SerwerSMS(),
|
||||
new Signal(),
|
||||
new SIGNL4(),
|
||||
new SMSManager(),
|
||||
new SMSPartner(),
|
||||
new Slack(),
|
||||
@@ -133,6 +142,7 @@ class Notification {
|
||||
new Teams(),
|
||||
new TechulusPush(),
|
||||
new Telegram(),
|
||||
new Threema(),
|
||||
new Twilio(),
|
||||
new Splunk(),
|
||||
new Webhook(),
|
||||
@@ -143,6 +153,8 @@ class Notification {
|
||||
new Whapi(),
|
||||
new GtxMessaging(),
|
||||
new Cellsynt(),
|
||||
new Wpush(),
|
||||
new SendGrid()
|
||||
];
|
||||
for (let item of list) {
|
||||
if (! item.name) {
|
||||
|
@@ -232,8 +232,8 @@ router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (reques
|
||||
let requestedDuration = request.params.duration !== undefined ? request.params.duration : "24h";
|
||||
const overrideValue = value && parseFloat(value);
|
||||
|
||||
if (requestedDuration === "24") {
|
||||
requestedDuration = "24h";
|
||||
if (/^[0-9]+$/.test(requestedDuration)) {
|
||||
requestedDuration = `${requestedDuration}h`;
|
||||
}
|
||||
|
||||
let publicMonitor = await R.getRow(`
|
||||
@@ -265,7 +265,7 @@ router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (reques
|
||||
// build a label string. If a custom label is given, override the default one (requestedDuration)
|
||||
badgeValues.label = filterAndJoin([
|
||||
labelPrefix,
|
||||
label ?? `Uptime (${requestedDuration}${labelSuffix})`,
|
||||
label ?? `Uptime (${requestedDuration.slice(0, -1)}${labelSuffix})`,
|
||||
]);
|
||||
badgeValues.message = filterAndJoin([ prefix, cleanUptime, suffix ]);
|
||||
}
|
||||
@@ -302,8 +302,8 @@ router.get("/api/badge/:id/ping/:duration?", cache("5 minutes"), async (request,
|
||||
let requestedDuration = request.params.duration !== undefined ? request.params.duration : "24h";
|
||||
const overrideValue = value && parseFloat(value);
|
||||
|
||||
if (requestedDuration === "24") {
|
||||
requestedDuration = "24h";
|
||||
if (/^[0-9]+$/.test(requestedDuration)) {
|
||||
requestedDuration = `${requestedDuration}h`;
|
||||
}
|
||||
|
||||
// Check if monitor is public
|
||||
@@ -325,7 +325,7 @@ router.get("/api/badge/:id/ping/:duration?", cache("5 minutes"), async (request,
|
||||
// use a given, custom labelColor or use the default badge label color (defined by badge-maker)
|
||||
badgeValues.labelColor = labelColor ?? "";
|
||||
// build a lable string. If a custom label is given, override the default one (requestedDuration)
|
||||
badgeValues.label = filterAndJoin([ labelPrefix, label ?? `Avg. Ping (${requestedDuration}${labelSuffix})` ]);
|
||||
badgeValues.label = filterAndJoin([ labelPrefix, label ?? `Avg. Ping (${requestedDuration.slice(0, -1)}${labelSuffix})` ]);
|
||||
badgeValues.message = filterAndJoin([ prefix, avgPing, suffix ]);
|
||||
}
|
||||
|
||||
|
@@ -18,6 +18,11 @@ router.get("/status/:slug", cache("5 minutes"), async (request, response) => {
|
||||
await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug);
|
||||
});
|
||||
|
||||
router.get("/status/:slug/rss", cache("5 minutes"), async (request, response) => {
|
||||
let slug = request.params.slug;
|
||||
await StatusPage.handleStatusPageRSSResponse(response, slug);
|
||||
});
|
||||
|
||||
router.get("/status", cache("5 minutes"), async (request, response) => {
|
||||
let slug = "default";
|
||||
await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug);
|
||||
|
100
server/server.js
100
server/server.js
@@ -19,7 +19,7 @@ const nodeVersion = process.versions.node;
|
||||
|
||||
// Get the required Node.js version from package.json
|
||||
const requiredNodeVersions = require("../package.json").engines.node;
|
||||
const bannedNodeVersions = " < 14 || 20.0.* || 20.1.* || 20.2.* || 20.3.* ";
|
||||
const bannedNodeVersions = " < 18 || 20.0.* || 20.1.* || 20.2.* || 20.3.* ";
|
||||
console.log(`Your Node.js version: ${nodeVersion}`);
|
||||
|
||||
const semver = require("semver");
|
||||
@@ -132,9 +132,9 @@ const twoFAVerifyOptions = {
|
||||
const testMode = !!args["test"] || false;
|
||||
|
||||
// Must be after io instantiation
|
||||
const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList, sendRemoteBrowserList } = require("./client");
|
||||
const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList, sendRemoteBrowserList, sendMonitorTypeList } = require("./client");
|
||||
const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler");
|
||||
const databaseSocketHandler = require("./socket-handlers/database-socket-handler");
|
||||
const { databaseSocketHandler } = require("./socket-handlers/database-socket-handler");
|
||||
const { remoteBrowserSocketHandler } = require("./socket-handlers/remote-browser-socket-handler");
|
||||
const TwoFA = require("./2fa");
|
||||
const StatusPage = require("./model/status_page");
|
||||
@@ -246,6 +246,36 @@ let needSetup = false;
|
||||
log.debug("test", request.body);
|
||||
response.send("OK");
|
||||
});
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
app.get("/_e2e/take-sqlite-snapshot", async (request, response) => {
|
||||
await Database.close();
|
||||
try {
|
||||
fs.cpSync(Database.sqlitePath, `${Database.sqlitePath}.e2e-snapshot`);
|
||||
} catch (err) {
|
||||
throw new Error("Unable to copy SQLite DB.");
|
||||
}
|
||||
await Database.connect();
|
||||
|
||||
response.send("Snapshot taken.");
|
||||
});
|
||||
|
||||
app.get("/_e2e/restore-sqlite-snapshot", async (request, response) => {
|
||||
if (!fs.existsSync(`${Database.sqlitePath}.e2e-snapshot`)) {
|
||||
throw new Error("Snapshot doesn't exist.");
|
||||
}
|
||||
|
||||
await Database.close();
|
||||
try {
|
||||
fs.cpSync(`${Database.sqlitePath}.e2e-snapshot`, Database.sqlitePath);
|
||||
} catch (err) {
|
||||
throw new Error("Unable to copy snapshot file.");
|
||||
}
|
||||
await Database.connect();
|
||||
|
||||
response.send("Snapshot restored.");
|
||||
});
|
||||
}
|
||||
|
||||
// Robots.txt
|
||||
@@ -686,6 +716,10 @@ let needSetup = false;
|
||||
monitor.kafkaProducerBrokers = JSON.stringify(monitor.kafkaProducerBrokers);
|
||||
monitor.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions);
|
||||
|
||||
monitor.conditions = JSON.stringify(monitor.conditions);
|
||||
|
||||
monitor.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes);
|
||||
|
||||
bean.import(monitor);
|
||||
bean.user_id = socket.userID;
|
||||
|
||||
@@ -695,13 +729,13 @@ let needSetup = false;
|
||||
|
||||
await updateMonitorNotification(bean.id, notificationIDList);
|
||||
|
||||
await server.sendMonitorList(socket);
|
||||
await server.sendUpdateMonitorIntoList(socket, bean.id);
|
||||
|
||||
if (monitor.active !== false) {
|
||||
await startMonitor(socket.userID, bean.id);
|
||||
}
|
||||
|
||||
log.info("monitor", `Added Monitor: ${monitor.id} User ID: ${socket.userID}`);
|
||||
log.info("monitor", `Added Monitor: ${bean.id} User ID: ${socket.userID}`);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
@@ -826,11 +860,20 @@ let needSetup = false;
|
||||
bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation;
|
||||
bean.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions);
|
||||
bean.kafkaProducerMessage = monitor.kafkaProducerMessage;
|
||||
bean.cacheBust = monitor.cacheBust;
|
||||
bean.kafkaProducerSsl = monitor.kafkaProducerSsl;
|
||||
bean.kafkaProducerAllowAutoTopicCreation =
|
||||
monitor.kafkaProducerAllowAutoTopicCreation;
|
||||
bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly;
|
||||
bean.remote_browser = monitor.remote_browser;
|
||||
bean.snmpVersion = monitor.snmpVersion;
|
||||
bean.snmpOid = monitor.snmpOid;
|
||||
bean.jsonPathOperator = monitor.jsonPathOperator;
|
||||
bean.timeout = monitor.timeout;
|
||||
bean.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes);
|
||||
bean.rabbitmqUsername = monitor.rabbitmqUsername;
|
||||
bean.rabbitmqPassword = monitor.rabbitmqPassword;
|
||||
bean.conditions = JSON.stringify(monitor.conditions);
|
||||
|
||||
bean.validate();
|
||||
|
||||
@@ -842,11 +885,11 @@ let needSetup = false;
|
||||
|
||||
await updateMonitorNotification(bean.id, monitor.notificationIDList);
|
||||
|
||||
if (await bean.isActive()) {
|
||||
if (await Monitor.isActive(bean.id, bean.active)) {
|
||||
await restartMonitor(socket.userID, bean.id);
|
||||
}
|
||||
|
||||
await server.sendMonitorList(socket);
|
||||
await server.sendUpdateMonitorIntoList(socket, bean.id);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
@@ -886,14 +929,17 @@ let needSetup = false;
|
||||
|
||||
log.info("monitor", `Get Monitor: ${monitorID} User ID: ${socket.userID}`);
|
||||
|
||||
let bean = await R.findOne("monitor", " id = ? AND user_id = ? ", [
|
||||
let monitor = await R.findOne("monitor", " id = ? AND user_id = ? ", [
|
||||
monitorID,
|
||||
socket.userID,
|
||||
]);
|
||||
|
||||
const monitorData = [{ id: monitor.id,
|
||||
active: monitor.active
|
||||
}];
|
||||
const preloadData = await Monitor.preparePreloadData(monitorData);
|
||||
callback({
|
||||
ok: true,
|
||||
monitor: await bean.toJSON(),
|
||||
monitor: monitor.toJSON(preloadData),
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
@@ -944,7 +990,7 @@ let needSetup = false;
|
||||
try {
|
||||
checkLogin(socket);
|
||||
await startMonitor(socket.userID, monitorID);
|
||||
await server.sendMonitorList(socket);
|
||||
await server.sendUpdateMonitorIntoList(socket, monitorID);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
@@ -964,7 +1010,7 @@ let needSetup = false;
|
||||
try {
|
||||
checkLogin(socket);
|
||||
await pauseMonitor(socket.userID, monitorID);
|
||||
await server.sendMonitorList(socket);
|
||||
await server.sendUpdateMonitorIntoList(socket, monitorID);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
@@ -1010,8 +1056,7 @@ let needSetup = false;
|
||||
msg: "successDeleted",
|
||||
msgi18n: true,
|
||||
});
|
||||
|
||||
await server.sendMonitorList(socket);
|
||||
await server.sendDeleteMonitorFromList(socket, monitorID);
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
@@ -1559,18 +1604,20 @@ let needSetup = false;
|
||||
|
||||
await server.start();
|
||||
|
||||
server.httpServer.listen(port, hostname, () => {
|
||||
server.httpServer.listen(port, hostname, async () => {
|
||||
if (hostname) {
|
||||
log.info("server", `Listening on ${hostname}:${port}`);
|
||||
} else {
|
||||
log.info("server", `Listening on ${port}`);
|
||||
}
|
||||
startMonitors();
|
||||
await startMonitors();
|
||||
|
||||
// Put this here. Start background jobs after the db and server is ready to prevent clear up during db migration.
|
||||
await initBackgroundJobs();
|
||||
|
||||
checkVersion.startInterval();
|
||||
});
|
||||
|
||||
await initBackgroundJobs();
|
||||
|
||||
// Start cloudflared at the end if configured
|
||||
await cloudflaredAutoStart(cloudflaredToken);
|
||||
|
||||
@@ -1636,17 +1683,18 @@ async function afterLogin(socket, user) {
|
||||
sendDockerHostList(socket),
|
||||
sendAPIKeyList(socket),
|
||||
sendRemoteBrowserList(socket),
|
||||
sendMonitorTypeList(socket),
|
||||
]);
|
||||
|
||||
await StatusPage.sendStatusPageList(io, socket);
|
||||
|
||||
const monitorPromises = [];
|
||||
for (let monitorID in monitorList) {
|
||||
await sendHeartbeatList(socket, monitorID);
|
||||
monitorPromises.push(sendHeartbeatList(socket, monitorID));
|
||||
monitorPromises.push(Monitor.sendStats(io, monitorID, user.id));
|
||||
}
|
||||
|
||||
for (let monitorID in monitorList) {
|
||||
await Monitor.sendStats(io, monitorID, user.id);
|
||||
}
|
||||
await Promise.all(monitorPromises);
|
||||
|
||||
// Set server timezone from client browser if not set
|
||||
// It should be run once only
|
||||
@@ -1668,7 +1716,7 @@ async function initDatabase(testMode = false) {
|
||||
log.info("server", "Connected to the database");
|
||||
|
||||
// Patch the database
|
||||
await Database.patch();
|
||||
await Database.patch(port, hostname);
|
||||
|
||||
let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
|
||||
"jwtSecret",
|
||||
@@ -1763,7 +1811,11 @@ async function startMonitors() {
|
||||
}
|
||||
|
||||
for (let monitor of list) {
|
||||
await monitor.start(io);
|
||||
try {
|
||||
await monitor.start(io);
|
||||
} catch (e) {
|
||||
log.error("monitor", e);
|
||||
}
|
||||
// Give some delays, so all monitors won't make request at the same moment when just start the server.
|
||||
await sleep(getRandomInt(300, 1000));
|
||||
}
|
||||
|
@@ -6,7 +6,7 @@ const Database = require("../database");
|
||||
* @param {Socket} socket Socket.io instance
|
||||
* @returns {void}
|
||||
*/
|
||||
module.exports = (socket) => {
|
||||
module.exports.databaseSocketHandler = (socket) => {
|
||||
|
||||
// Post or edit incident
|
||||
socket.on("getDatabaseSize", async (callback) => {
|
||||
|
@@ -29,8 +29,13 @@ function getGameList() {
|
||||
return gameList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for general events
|
||||
* @param {Socket} socket Socket.io instance
|
||||
* @param {UptimeKumaServer} server Uptime Kuma server
|
||||
* @returns {void}
|
||||
*/
|
||||
module.exports.generalSocketHandler = (socket, server) => {
|
||||
|
||||
socket.on("initServerTimezone", async (timezone) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
@@ -12,7 +12,6 @@ class UptimeCalculator {
|
||||
* @private
|
||||
* @type {{string:UptimeCalculator}}
|
||||
*/
|
||||
|
||||
static list = {};
|
||||
|
||||
/**
|
||||
@@ -55,6 +54,15 @@ class UptimeCalculator {
|
||||
lastHourlyStatBean = null;
|
||||
lastMinutelyStatBean = null;
|
||||
|
||||
/**
|
||||
* For migration purposes.
|
||||
* @type {boolean}
|
||||
*/
|
||||
migrationMode = false;
|
||||
|
||||
statMinutelyKeepHour = 24;
|
||||
statHourlyKeepDay = 30;
|
||||
|
||||
/**
|
||||
* Get the uptime calculator for a monitor
|
||||
* Initializes and returns the monitor if it does not exist
|
||||
@@ -189,16 +197,19 @@ class UptimeCalculator {
|
||||
/**
|
||||
* @param {number} status status
|
||||
* @param {number} ping Ping
|
||||
* @param {dayjs.Dayjs} date Date (Only for migration)
|
||||
* @returns {dayjs.Dayjs} date
|
||||
* @throws {Error} Invalid status
|
||||
*/
|
||||
async update(status, ping = 0) {
|
||||
let date = this.getCurrentDate();
|
||||
async update(status, ping = 0, date) {
|
||||
if (!date) {
|
||||
date = this.getCurrentDate();
|
||||
}
|
||||
|
||||
let flatStatus = this.flatStatus(status);
|
||||
|
||||
if (flatStatus === DOWN && ping > 0) {
|
||||
log.warn("uptime-calc", "The ping is not effective when the status is DOWN");
|
||||
log.debug("uptime-calc", "The ping is not effective when the status is DOWN");
|
||||
}
|
||||
|
||||
let divisionKey = this.getMinutelyKey(date);
|
||||
@@ -297,47 +308,61 @@ class UptimeCalculator {
|
||||
}
|
||||
await R.store(dailyStatBean);
|
||||
|
||||
let hourlyStatBean = await this.getHourlyStatBean(hourlyKey);
|
||||
hourlyStatBean.up = hourlyData.up;
|
||||
hourlyStatBean.down = hourlyData.down;
|
||||
hourlyStatBean.ping = hourlyData.avgPing;
|
||||
hourlyStatBean.pingMin = hourlyData.minPing;
|
||||
hourlyStatBean.pingMax = hourlyData.maxPing;
|
||||
{
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = hourlyData;
|
||||
if (Object.keys(extras).length > 0) {
|
||||
hourlyStatBean.extras = JSON.stringify(extras);
|
||||
let currentDate = this.getCurrentDate();
|
||||
|
||||
// For migration mode, we don't need to store old hourly and minutely data, but we need 30-day's hourly data
|
||||
// Run anyway for non-migration mode
|
||||
if (!this.migrationMode || date.isAfter(currentDate.subtract(this.statHourlyKeepDay, "day"))) {
|
||||
let hourlyStatBean = await this.getHourlyStatBean(hourlyKey);
|
||||
hourlyStatBean.up = hourlyData.up;
|
||||
hourlyStatBean.down = hourlyData.down;
|
||||
hourlyStatBean.ping = hourlyData.avgPing;
|
||||
hourlyStatBean.pingMin = hourlyData.minPing;
|
||||
hourlyStatBean.pingMax = hourlyData.maxPing;
|
||||
{
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = hourlyData;
|
||||
if (Object.keys(extras).length > 0) {
|
||||
hourlyStatBean.extras = JSON.stringify(extras);
|
||||
}
|
||||
}
|
||||
await R.store(hourlyStatBean);
|
||||
}
|
||||
await R.store(hourlyStatBean);
|
||||
|
||||
let minutelyStatBean = await this.getMinutelyStatBean(divisionKey);
|
||||
minutelyStatBean.up = minutelyData.up;
|
||||
minutelyStatBean.down = minutelyData.down;
|
||||
minutelyStatBean.ping = minutelyData.avgPing;
|
||||
minutelyStatBean.pingMin = minutelyData.minPing;
|
||||
minutelyStatBean.pingMax = minutelyData.maxPing;
|
||||
{
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = minutelyData;
|
||||
if (Object.keys(extras).length > 0) {
|
||||
minutelyStatBean.extras = JSON.stringify(extras);
|
||||
// For migration mode, we don't need to store old hourly and minutely data, but we need 24-hour's minutely data
|
||||
// Run anyway for non-migration mode
|
||||
if (!this.migrationMode || date.isAfter(currentDate.subtract(this.statMinutelyKeepHour, "hour"))) {
|
||||
let minutelyStatBean = await this.getMinutelyStatBean(divisionKey);
|
||||
minutelyStatBean.up = minutelyData.up;
|
||||
minutelyStatBean.down = minutelyData.down;
|
||||
minutelyStatBean.ping = minutelyData.avgPing;
|
||||
minutelyStatBean.pingMin = minutelyData.minPing;
|
||||
minutelyStatBean.pingMax = minutelyData.maxPing;
|
||||
{
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = minutelyData;
|
||||
if (Object.keys(extras).length > 0) {
|
||||
minutelyStatBean.extras = JSON.stringify(extras);
|
||||
}
|
||||
}
|
||||
await R.store(minutelyStatBean);
|
||||
}
|
||||
await R.store(minutelyStatBean);
|
||||
|
||||
// Remove the old data
|
||||
log.debug("uptime-calc", "Remove old data");
|
||||
await R.exec("DELETE FROM stat_minutely WHERE monitor_id = ? AND timestamp < ?", [
|
||||
this.monitorID,
|
||||
this.getMinutelyKey(date.subtract(24, "hour")),
|
||||
]);
|
||||
// No need to remove old data in migration mode
|
||||
if (!this.migrationMode) {
|
||||
// Remove the old data
|
||||
// TODO: Improvement: Convert it to a job?
|
||||
log.debug("uptime-calc", "Remove old data");
|
||||
await R.exec("DELETE FROM stat_minutely WHERE monitor_id = ? AND timestamp < ?", [
|
||||
this.monitorID,
|
||||
this.getMinutelyKey(currentDate.subtract(this.statMinutelyKeepHour, "hour")),
|
||||
]);
|
||||
|
||||
await R.exec("DELETE FROM stat_hourly WHERE monitor_id = ? AND timestamp < ?", [
|
||||
this.monitorID,
|
||||
this.getHourlyKey(date.subtract(30, "day")),
|
||||
]);
|
||||
await R.exec("DELETE FROM stat_hourly WHERE monitor_id = ? AND timestamp < ?", [
|
||||
this.monitorID,
|
||||
this.getHourlyKey(currentDate.subtract(this.statHourlyKeepDay, "day")),
|
||||
]);
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
@@ -543,7 +568,9 @@ class UptimeCalculator {
|
||||
if (type === "minute" && num > 24 * 60) {
|
||||
throw new Error("The maximum number of minutes is 1440");
|
||||
}
|
||||
|
||||
if (type === "day" && num > 365) {
|
||||
throw new Error("The maximum number of days is 365");
|
||||
}
|
||||
// Get the current time period key based on the type
|
||||
let key = this.getKey(this.getCurrentDate(), type);
|
||||
|
||||
@@ -741,20 +768,36 @@ class UptimeCalculator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the uptime data by duration
|
||||
* @param {'24h'|'30d'|'1y'} duration Only accept 24h, 30d, 1y
|
||||
* Get the uptime data for given duration.
|
||||
* @param {string} duration A string with a number and a unit (m,h,d,w,M,y), such as 24h, 30d, 1y.
|
||||
* @returns {UptimeDataResult} UptimeDataResult
|
||||
* @throws {Error} Invalid duration
|
||||
* @throws {Error} Invalid duration / Unsupported unit
|
||||
*/
|
||||
getDataByDuration(duration) {
|
||||
if (duration === "24h") {
|
||||
return this.get24Hour();
|
||||
} else if (duration === "30d") {
|
||||
return this.get30Day();
|
||||
} else if (duration === "1y") {
|
||||
return this.get1Year();
|
||||
} else {
|
||||
throw new Error("Invalid duration");
|
||||
const durationNumStr = duration.slice(0, -1);
|
||||
|
||||
if (!/^[0-9]+$/.test(durationNumStr)) {
|
||||
throw new Error(`Invalid duration: ${duration}`);
|
||||
}
|
||||
const num = Number(durationNumStr);
|
||||
const unit = duration.slice(-1);
|
||||
|
||||
switch (unit) {
|
||||
case "m":
|
||||
return this.getData(num, "minute");
|
||||
case "h":
|
||||
return this.getData(num, "hour");
|
||||
case "d":
|
||||
return this.getData(num, "day");
|
||||
case "w":
|
||||
return this.getData(7 * num, "day");
|
||||
case "M":
|
||||
return this.getData(30 * num, "day");
|
||||
case "y":
|
||||
return this.getData(365 * num, "day");
|
||||
default:
|
||||
throw new Error(`Unsupported unit (${unit}) for badge duration ${duration}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,6 +837,14 @@ class UptimeCalculator {
|
||||
return dayjs.utc();
|
||||
}
|
||||
|
||||
/**
|
||||
* For migration purposes.
|
||||
* @param {boolean} value Migration mode on/off
|
||||
* @returns {void}
|
||||
*/
|
||||
setMigrationMode(value) {
|
||||
this.migrationMode = value;
|
||||
}
|
||||
}
|
||||
|
||||
class UptimeDataResult {
|
||||
|
@@ -113,8 +113,9 @@ class UptimeKumaServer {
|
||||
UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing();
|
||||
UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType();
|
||||
UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType();
|
||||
UptimeKumaServer.monitorTypeList["grpc-keyword"] = new GrpcKeywordMonitorType();
|
||||
UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType();
|
||||
UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType();
|
||||
UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType();
|
||||
|
||||
// Allow all CORS origins (polling) in development
|
||||
let cors = undefined;
|
||||
@@ -205,24 +206,56 @@ class UptimeKumaServer {
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Monitor into list
|
||||
* @param {Socket} socket Socket to send list on
|
||||
* @param {number} monitorID update or deleted monitor id
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async sendUpdateMonitorIntoList(socket, monitorID) {
|
||||
let list = await this.getMonitorJSONList(socket.userID, monitorID);
|
||||
this.io.to(socket.userID).emit("updateMonitorIntoList", list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Monitor from list
|
||||
* @param {Socket} socket Socket to send list on
|
||||
* @param {number} monitorID update or deleted monitor id
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async sendDeleteMonitorFromList(socket, monitorID) {
|
||||
this.io.to(socket.userID).emit("deleteMonitorFromList", monitorID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of monitors for the given user.
|
||||
* @param {string} userID - The ID of the user to get monitors for.
|
||||
* @param {number} monitorID - The ID of monitor for.
|
||||
* @returns {Promise<object>} A promise that resolves to an object with monitor IDs as keys and monitor objects as values.
|
||||
*
|
||||
* Generated by Trelent
|
||||
*/
|
||||
async getMonitorJSONList(userID) {
|
||||
let result = {};
|
||||
async getMonitorJSONList(userID, monitorID = null) {
|
||||
|
||||
let monitorList = await R.find("monitor", " user_id = ? ORDER BY weight DESC, name", [
|
||||
userID,
|
||||
]);
|
||||
let query = " user_id = ? ";
|
||||
let queryParams = [ userID ];
|
||||
|
||||
for (let monitor of monitorList) {
|
||||
result[monitor.id] = await monitor.toJSON();
|
||||
if (monitorID) {
|
||||
query += "AND id = ? ";
|
||||
queryParams.push(monitorID);
|
||||
}
|
||||
|
||||
let monitorList = await R.find("monitor", query + "ORDER BY weight DESC, name", queryParams);
|
||||
|
||||
const monitorData = monitorList.map(monitor => ({
|
||||
id: monitor.id,
|
||||
active: monitor.active,
|
||||
name: monitor.name,
|
||||
}));
|
||||
const preloadData = await Monitor.preparePreloadData(monitorData);
|
||||
|
||||
const result = {};
|
||||
monitorList.forEach(monitor => result[monitor.id] = monitor.toJSON(preloadData));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -518,5 +551,7 @@ const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor
|
||||
const { TailscalePing } = require("./monitor-types/tailscale-ping");
|
||||
const { DnsMonitorType } = require("./monitor-types/dns");
|
||||
const { MqttMonitorType } = require("./monitor-types/mqtt");
|
||||
const { GrpcKeywordMonitorType } = require("./monitor-types/grpc");
|
||||
const { SNMPMonitorType } = require("./monitor-types/snmp");
|
||||
const { MongodbMonitorType } = require("./monitor-types/mongodb");
|
||||
const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq");
|
||||
const Monitor = require("./model/monitor");
|
||||
|
@@ -11,8 +11,10 @@ const mssql = require("mssql");
|
||||
const { Client } = require("pg");
|
||||
const postgresConParse = require("pg-connection-string").parse;
|
||||
const mysql = require("mysql2");
|
||||
const { NtlmClient } = require("axios-ntlm");
|
||||
const { NtlmClient } = require("./modules/axios-ntlm/lib/ntlmClient.js");
|
||||
const { Settings } = require("./settings");
|
||||
const grpc = require("@grpc/grpc-js");
|
||||
const protojs = require("protobufjs");
|
||||
const radiusClient = require("node-radius-client");
|
||||
const redis = require("redis");
|
||||
const oidc = require("openid-client");
|
||||
@@ -917,6 +919,64 @@ module.exports.timeObjectToLocal = (obj, timezone = undefined) => {
|
||||
return timeObjectConvertTimezone(obj, timezone, false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create gRPC client stib
|
||||
* @param {object} options from gRPC client
|
||||
* @returns {Promise<object>} Result of gRPC query
|
||||
*/
|
||||
module.exports.grpcQuery = async (options) => {
|
||||
const { grpcUrl, grpcProtobufData, grpcServiceName, grpcEnableTls, grpcMethod, grpcBody } = options;
|
||||
const protocObject = protojs.parse(grpcProtobufData);
|
||||
const protoServiceObject = protocObject.root.lookupService(grpcServiceName);
|
||||
const Client = grpc.makeGenericClientConstructor({});
|
||||
const credentials = grpcEnableTls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure();
|
||||
const client = new Client(
|
||||
grpcUrl,
|
||||
credentials
|
||||
);
|
||||
const grpcService = protoServiceObject.create(function (method, requestData, cb) {
|
||||
const fullServiceName = method.fullName;
|
||||
const serviceFQDN = fullServiceName.split(".");
|
||||
const serviceMethod = serviceFQDN.pop();
|
||||
const serviceMethodClientImpl = `/${serviceFQDN.slice(1).join(".")}/${serviceMethod}`;
|
||||
log.debug("monitor", `gRPC method ${serviceMethodClientImpl}`);
|
||||
client.makeUnaryRequest(
|
||||
serviceMethodClientImpl,
|
||||
arg => arg,
|
||||
arg => arg,
|
||||
requestData,
|
||||
cb);
|
||||
}, false, false);
|
||||
return new Promise((resolve, _) => {
|
||||
try {
|
||||
return grpcService[`${grpcMethod}`](JSON.parse(grpcBody), function (err, response) {
|
||||
const responseData = JSON.stringify(response);
|
||||
if (err) {
|
||||
return resolve({
|
||||
code: err.code,
|
||||
errorMessage: err.details,
|
||||
data: ""
|
||||
});
|
||||
} else {
|
||||
log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`);
|
||||
return resolve({
|
||||
code: 1,
|
||||
errorMessage: "",
|
||||
data: responseData
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
return resolve({
|
||||
code: -1,
|
||||
errorMessage: `Error ${err}. Please review your gRPC configuration option. The service name must not include package name value, and the method name must follow camelCase format`,
|
||||
data: ""
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an array of SHA256 fingerprints for all known root certificates.
|
||||
* @returns {Set} A set of SHA256 fingerprints.
|
||||
|
84
server/utils/simple-migration-server.js
Normal file
84
server/utils/simple-migration-server.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const express = require("express");
|
||||
const http = require("node:http");
|
||||
const { log } = require("../../src/util");
|
||||
|
||||
/**
|
||||
* SimpleMigrationServer
|
||||
* For displaying the migration status of the server
|
||||
* Also, it is used to let Docker healthcheck know the status of the server, as the main server is not started yet, healthcheck will think the server is down incorrectly.
|
||||
*/
|
||||
class SimpleMigrationServer {
|
||||
/**
|
||||
* Express app instance
|
||||
* @type {?Express}
|
||||
*/
|
||||
app;
|
||||
|
||||
/**
|
||||
* Server instance
|
||||
* @type {?Server}
|
||||
*/
|
||||
server;
|
||||
|
||||
/**
|
||||
* Response object
|
||||
* @type {?Response}
|
||||
*/
|
||||
response;
|
||||
|
||||
/**
|
||||
* Start the server
|
||||
* @param {number} port Port
|
||||
* @param {string} hostname Hostname
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
start(port, hostname) {
|
||||
this.app = express();
|
||||
this.server = http.createServer(this.app);
|
||||
|
||||
this.app.get("/", (req, res) => {
|
||||
res.set("Content-Type", "text/plain");
|
||||
res.write("Migration is in progress, listening message...\n");
|
||||
if (this.response) {
|
||||
this.response.write("Disconnected\n");
|
||||
this.response.end();
|
||||
}
|
||||
this.response = res;
|
||||
// never ending response
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.server.listen(port, hostname, () => {
|
||||
if (hostname) {
|
||||
log.info("migration", `Migration server is running on http://${hostname}:${port}`);
|
||||
} else {
|
||||
log.info("migration", `Migration server is running on http://localhost:${port}`);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the message
|
||||
* @param {string} msg Message to update
|
||||
* @returns {void}
|
||||
*/
|
||||
update(msg) {
|
||||
this.response?.write(msg + "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async stop() {
|
||||
this.response?.write("Finished, please refresh this page.\n");
|
||||
this.response?.end();
|
||||
await this.server?.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SimpleMigrationServer,
|
||||
};
|
@@ -576,6 +576,12 @@ optgroup {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.prism-editor__container {
|
||||
.important {
|
||||
font-weight: var(--bs-body-font-weight) !important;
|
||||
}
|
||||
}
|
||||
|
||||
h5.settings-subheading::after {
|
||||
content: "";
|
||||
display: block;
|
||||
|
152
src/components/EditMonitorCondition.vue
Normal file
152
src/components/EditMonitorCondition.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="monitor-condition mb-3" data-testid="condition">
|
||||
<button
|
||||
v-if="!isInGroup || !isFirst || !isLast"
|
||||
class="btn btn-outline-danger remove-button"
|
||||
type="button"
|
||||
:aria-label="$t('conditionDelete')"
|
||||
data-testid="remove-condition"
|
||||
@click="remove"
|
||||
>
|
||||
<font-awesome-icon icon="trash" />
|
||||
</button>
|
||||
|
||||
<select v-if="!isFirst" v-model="model.andOr" class="form-select and-or-select" data-testid="condition-and-or">
|
||||
<option value="and">{{ $t("and") }}</option>
|
||||
<option value="or">{{ $t("or") }}</option>
|
||||
</select>
|
||||
|
||||
<select v-model="model.variable" class="form-select" data-testid="condition-variable">
|
||||
<option
|
||||
v-for="variable in conditionVariables"
|
||||
:key="variable.id"
|
||||
:value="variable.id"
|
||||
>
|
||||
{{ $t(variable.id) }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-model="model.operator" class="form-select" data-testid="condition-operator">
|
||||
<option
|
||||
v-for="operator in getVariableOperators(model.variable)"
|
||||
:key="operator.id"
|
||||
:value="operator.id"
|
||||
>
|
||||
{{ $t(operator.caption) }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
v-model="model.value"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:aria-label="$t('conditionValuePlaceholder')"
|
||||
data-testid="condition-value"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "EditMonitorCondition",
|
||||
|
||||
props: {
|
||||
/**
|
||||
* The monitor condition
|
||||
*/
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether this is the first condition
|
||||
*/
|
||||
isFirst: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether this is the last condition
|
||||
*/
|
||||
isLast: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether this condition is in a group
|
||||
*/
|
||||
isInGroup: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* Variable choices
|
||||
*/
|
||||
conditionVariables: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: [ "update:modelValue", "remove" ],
|
||||
|
||||
computed: {
|
||||
model: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit("update:modelValue", value);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
remove() {
|
||||
this.$emit("remove", this.model);
|
||||
},
|
||||
|
||||
getVariableOperators(variableId) {
|
||||
return this.conditionVariables.find(v => v.id === variableId)?.operators ?? [];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../assets/vars.scss";
|
||||
|
||||
.monitor-condition {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
justify-self: flex-end;
|
||||
margin-bottom: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@container (min-width: 500px) {
|
||||
.monitor-condition {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
margin-bottom: 0;
|
||||
margin-left: 10px;
|
||||
order: 100;
|
||||
}
|
||||
|
||||
.and-or-select {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
189
src/components/EditMonitorConditionGroup.vue
Normal file
189
src/components/EditMonitorConditionGroup.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div class="condition-group mb-3" data-testid="condition-group">
|
||||
<div class="d-flex">
|
||||
<select v-if="!isFirst" v-model="model.andOr" class="form-select" style="width: auto;" data-testid="condition-group-and-or">
|
||||
<option value="and">{{ $t("and") }}</option>
|
||||
<option value="or">{{ $t("or") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="condition-group-inner mt-2 pa-2">
|
||||
<div class="condition-group-conditions">
|
||||
<template v-for="(child, childIndex) in model.children" :key="childIndex">
|
||||
<EditMonitorConditionGroup
|
||||
v-if="child.type === 'group'"
|
||||
v-model="model.children[childIndex]"
|
||||
:is-first="childIndex === 0"
|
||||
:get-new-group="getNewGroup"
|
||||
:get-new-condition="getNewCondition"
|
||||
:condition-variables="conditionVariables"
|
||||
@remove="removeChild"
|
||||
/>
|
||||
<EditMonitorCondition
|
||||
v-else
|
||||
v-model="model.children[childIndex]"
|
||||
:is-first="childIndex === 0"
|
||||
:is-last="childIndex === model.children.length - 1"
|
||||
:is-in-group="true"
|
||||
:condition-variables="conditionVariables"
|
||||
@remove="removeChild"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="condition-group-actions mt-3">
|
||||
<button class="btn btn-outline-secondary me-2" type="button" data-testid="add-condition-button" @click="addCondition">
|
||||
{{ $t("conditionAdd") }}
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary me-2" type="button" data-testid="add-group-button" @click="addGroup">
|
||||
{{ $t("conditionAddGroup") }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-danger"
|
||||
type="button"
|
||||
:aria-label="$t('conditionDeleteGroup')"
|
||||
data-testid="remove-condition-group"
|
||||
@click="remove"
|
||||
>
|
||||
<font-awesome-icon icon="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import EditMonitorCondition from "./EditMonitorCondition.vue";
|
||||
|
||||
export default {
|
||||
name: "EditMonitorConditionGroup",
|
||||
|
||||
components: {
|
||||
EditMonitorCondition,
|
||||
},
|
||||
|
||||
props: {
|
||||
/**
|
||||
* The condition group
|
||||
*/
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether this is the first condition
|
||||
*/
|
||||
isFirst: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Function to generate a new group model
|
||||
*/
|
||||
getNewGroup: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Function to generate a new condition model
|
||||
*/
|
||||
getNewCondition: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Variable choices
|
||||
*/
|
||||
conditionVariables: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: [ "update:modelValue", "remove" ],
|
||||
|
||||
computed: {
|
||||
model: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit("update:modelValue", value);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
addGroup() {
|
||||
const conditions = [ ...this.model.children ];
|
||||
conditions.push(this.getNewGroup());
|
||||
this.model.children = conditions;
|
||||
},
|
||||
|
||||
addCondition() {
|
||||
const conditions = [ ...this.model.children ];
|
||||
conditions.push(this.getNewCondition());
|
||||
this.model.children = conditions;
|
||||
},
|
||||
|
||||
remove() {
|
||||
this.$emit("remove", this.model);
|
||||
},
|
||||
|
||||
removeChild(child) {
|
||||
const idx = this.model.children.indexOf(child);
|
||||
if (idx !== -1) {
|
||||
this.model.children.splice(idx, 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../assets/vars.scss";
|
||||
|
||||
.condition-group-inner {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dark .condition-group-inner {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.condition-group-conditions {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.condition-group-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
// Delete button
|
||||
.condition-group-actions > :last-child {
|
||||
margin-left: auto;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@container (min-width: 400px) {
|
||||
.condition-group-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// Delete button
|
||||
.condition-group-actions > :last-child {
|
||||
margin-left: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.btn-delete-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
149
src/components/EditMonitorConditions.vue
Normal file
149
src/components/EditMonitorConditions.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="monitor-conditions">
|
||||
<label class="form-label">{{ $t("Conditions") }}</label>
|
||||
<div class="monitor-conditions-conditions">
|
||||
<template v-for="(condition, conditionIndex) in model" :key="conditionIndex">
|
||||
<EditMonitorConditionGroup
|
||||
v-if="condition.type === 'group'"
|
||||
v-model="model[conditionIndex]"
|
||||
:is-first="conditionIndex === 0"
|
||||
:get-new-group="getNewGroup"
|
||||
:get-new-condition="getNewCondition"
|
||||
:condition-variables="conditionVariables"
|
||||
@remove="removeCondition"
|
||||
/>
|
||||
<EditMonitorCondition
|
||||
v-else
|
||||
v-model="model[conditionIndex]"
|
||||
:is-first="conditionIndex === 0"
|
||||
:is-last="conditionIndex === model.length - 1"
|
||||
:condition-variables="conditionVariables"
|
||||
@remove="removeCondition"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="monitor-conditions-buttons">
|
||||
<button class="btn btn-outline-secondary me-2" type="button" data-testid="add-condition-button" @click="addCondition">
|
||||
{{ $t("conditionAdd") }}
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary me-2" type="button" data-testid="add-group-button" @click="addGroup">
|
||||
{{ $t("conditionAddGroup") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import EditMonitorConditionGroup from "./EditMonitorConditionGroup.vue";
|
||||
import EditMonitorCondition from "./EditMonitorCondition.vue";
|
||||
|
||||
export default {
|
||||
name: "EditMonitorConditions",
|
||||
|
||||
components: {
|
||||
EditMonitorConditionGroup,
|
||||
EditMonitorCondition,
|
||||
},
|
||||
|
||||
props: {
|
||||
/**
|
||||
* The monitor conditions
|
||||
*/
|
||||
modelValue: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
conditionVariables: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: [ "update:modelValue" ],
|
||||
|
||||
computed: {
|
||||
model: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit("update:modelValue", value);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.model.length === 0) {
|
||||
this.addCondition();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
getNewGroup() {
|
||||
return {
|
||||
type: "group",
|
||||
children: [ this.getNewCondition() ],
|
||||
andOr: "and",
|
||||
};
|
||||
},
|
||||
|
||||
getNewCondition() {
|
||||
const firstVariable = this.conditionVariables[0]?.id || null;
|
||||
const firstOperator = this.getVariableOperators(firstVariable)[0] || null;
|
||||
return {
|
||||
type: "expression",
|
||||
variable: firstVariable,
|
||||
operator: firstOperator?.id || null,
|
||||
value: "",
|
||||
andOr: "and",
|
||||
};
|
||||
},
|
||||
|
||||
addGroup() {
|
||||
const conditions = [ ...this.model ];
|
||||
conditions.push(this.getNewGroup());
|
||||
this.$emit("update:modelValue", conditions);
|
||||
},
|
||||
|
||||
addCondition() {
|
||||
const conditions = [ ...this.model ];
|
||||
conditions.push(this.getNewCondition());
|
||||
this.$emit("update:modelValue", conditions);
|
||||
},
|
||||
|
||||
removeCondition(condition) {
|
||||
const conditions = [ ...this.model ];
|
||||
const idx = conditions.indexOf(condition);
|
||||
if (idx !== -1) {
|
||||
conditions.splice(idx, 1);
|
||||
this.$emit("update:modelValue", conditions);
|
||||
}
|
||||
},
|
||||
|
||||
getVariableOperators(variableId) {
|
||||
return this.conditionVariables.find(v => v.id === variableId)?.operators ?? [];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../assets/vars.scss";
|
||||
|
||||
.monitor-conditions,
|
||||
.monitor-conditions-conditions {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.monitor-conditions-buttons {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@container (min-width: 400px) {
|
||||
.monitor-conditions-buttons {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -4,17 +4,23 @@
|
||||
<div
|
||||
v-for="(beat, index) in shortBeatList"
|
||||
:key="index"
|
||||
class="beat"
|
||||
:class="{ 'empty': (beat === 0), 'down': (beat.status === 0), 'pending': (beat.status === 2), 'maintenance': (beat.status === 3) }"
|
||||
:style="beatStyle"
|
||||
class="beat-hover-area"
|
||||
:class="{ 'empty': (beat === 0) }"
|
||||
:style="beatHoverAreaStyle"
|
||||
:title="getBeatTitle(beat)"
|
||||
/>
|
||||
>
|
||||
<div
|
||||
class="beat"
|
||||
:class="{ 'empty': (beat === 0), 'down': (beat.status === 0), 'pending': (beat.status === 2), 'maintenance': (beat.status === 3) }"
|
||||
:style="beatStyle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!$root.isMobile && size !== 'small' && beatList.length > 4 && $root.styleElapsedTime !== 'none'"
|
||||
class="d-flex justify-content-between align-items-center word" :style="timeStyle"
|
||||
>
|
||||
<div>{{ timeSinceFirstBeat }} ago</div>
|
||||
<div>{{ timeSinceFirstBeat }}</div>
|
||||
<div v-if="$root.styleElapsedTime === 'with-line'" class="connecting-line"></div>
|
||||
<div>{{ timeSinceLastBeat }}</div>
|
||||
</div>
|
||||
@@ -47,7 +53,7 @@ export default {
|
||||
beatWidth: 10,
|
||||
beatHeight: 30,
|
||||
hoverScale: 1.5,
|
||||
beatMargin: 4,
|
||||
beatHoverAreaPadding: 4,
|
||||
move: false,
|
||||
maxBeat: -1,
|
||||
};
|
||||
@@ -123,7 +129,7 @@ export default {
|
||||
|
||||
barStyle() {
|
||||
if (this.move && this.shortBeatList.length > this.maxBeat) {
|
||||
let width = -(this.beatWidth + this.beatMargin * 2);
|
||||
let width = -(this.beatWidth + this.beatHoverAreaPadding * 2);
|
||||
|
||||
return {
|
||||
transition: "all ease-in-out 0.25s",
|
||||
@@ -137,12 +143,17 @@ export default {
|
||||
|
||||
},
|
||||
|
||||
beatHoverAreaStyle() {
|
||||
return {
|
||||
padding: this.beatHoverAreaPadding + "px",
|
||||
"--hover-scale": this.hoverScale,
|
||||
};
|
||||
},
|
||||
|
||||
beatStyle() {
|
||||
return {
|
||||
width: this.beatWidth + "px",
|
||||
height: this.beatHeight + "px",
|
||||
margin: this.beatMargin + "px",
|
||||
"--hover-scale": this.hoverScale,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -152,7 +163,7 @@ export default {
|
||||
*/
|
||||
timeStyle() {
|
||||
return {
|
||||
"margin-left": this.numPadding * (this.beatWidth + this.beatMargin * 2) + "px",
|
||||
"margin-left": this.numPadding * (this.beatWidth + this.beatHoverAreaPadding * 2) + "px",
|
||||
};
|
||||
},
|
||||
|
||||
@@ -184,11 +195,11 @@ export default {
|
||||
}
|
||||
|
||||
if (seconds < tolerance) {
|
||||
return "now";
|
||||
return this.$t("now");
|
||||
} else if (seconds < 60 * 60) {
|
||||
return (seconds / 60).toFixed(0) + "m ago";
|
||||
return this.$t("time ago", [ (seconds / 60).toFixed(0) + "m" ]);
|
||||
} else {
|
||||
return (seconds / 60 / 60).toFixed(0) + "h ago";
|
||||
return this.$t("time ago", [ (seconds / 60 / 60).toFixed(0) + "h" ]);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -219,20 +230,20 @@ export default {
|
||||
if (this.size !== "big") {
|
||||
this.beatWidth = 5;
|
||||
this.beatHeight = 16;
|
||||
this.beatMargin = 2;
|
||||
this.beatHoverAreaPadding = 2;
|
||||
}
|
||||
|
||||
// Suddenly, have an idea how to handle it universally.
|
||||
// If the pixel * ratio != Integer, then it causes render issue, round it to solve it!!
|
||||
const actualWidth = this.beatWidth * window.devicePixelRatio;
|
||||
const actualMargin = this.beatMargin * window.devicePixelRatio;
|
||||
const actualHoverAreaPadding = this.beatHoverAreaPadding * window.devicePixelRatio;
|
||||
|
||||
if (!Number.isInteger(actualWidth)) {
|
||||
this.beatWidth = Math.round(actualWidth) / window.devicePixelRatio;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(actualMargin)) {
|
||||
this.beatMargin = Math.round(actualMargin) / window.devicePixelRatio;
|
||||
if (!Number.isInteger(actualHoverAreaPadding)) {
|
||||
this.beatHoverAreaPadding = Math.round(actualHoverAreaPadding) / window.devicePixelRatio;
|
||||
}
|
||||
|
||||
window.addEventListener("resize", this.resize);
|
||||
@@ -245,7 +256,7 @@ export default {
|
||||
*/
|
||||
resize() {
|
||||
if (this.$refs.wrap) {
|
||||
this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatMargin * 2));
|
||||
this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatHoverAreaPadding * 2));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -273,32 +284,41 @@ export default {
|
||||
}
|
||||
|
||||
.hp-bar-big {
|
||||
.beat {
|
||||
.beat-hover-area {
|
||||
display: inline-block;
|
||||
background-color: $primary;
|
||||
border-radius: $border-radius;
|
||||
|
||||
&.empty {
|
||||
background-color: aliceblue;
|
||||
}
|
||||
|
||||
&.down {
|
||||
background-color: $danger;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background-color: $warning;
|
||||
}
|
||||
|
||||
&.maintenance {
|
||||
background-color: $maintenance;
|
||||
}
|
||||
|
||||
&:not(.empty):hover {
|
||||
transition: all ease-in-out 0.15s;
|
||||
opacity: 0.8;
|
||||
transform: scale(var(--hover-scale));
|
||||
}
|
||||
|
||||
.beat {
|
||||
background-color: $primary;
|
||||
border-radius: $border-radius;
|
||||
|
||||
/*
|
||||
pointer-events needs to be changed because
|
||||
tooltip momentarily disappears when crossing between .beat-hover-area and .beat
|
||||
*/
|
||||
pointer-events: none;
|
||||
|
||||
&.empty {
|
||||
background-color: aliceblue;
|
||||
}
|
||||
|
||||
&.down {
|
||||
background-color: $danger;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background-color: $warning;
|
||||
}
|
||||
|
||||
&.maintenance {
|
||||
background-color: $maintenance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -45,7 +45,7 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="monitorList" class="monitor-list" :class="{ scrollbar: scrollbar }" :style="monitorListStyle">
|
||||
<div ref="monitorList" class="monitor-list" :class="{ scrollbar: scrollbar }" :style="monitorListStyle" data-testid="monitor-list">
|
||||
<div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3">
|
||||
{{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link>
|
||||
</div>
|
||||
|
@@ -43,12 +43,15 @@
|
||||
<div v-if="!isCollapsed" class="childs">
|
||||
<MonitorListItem
|
||||
v-for="(item, index) in sortedChildMonitorList"
|
||||
:key="index" :monitor="item"
|
||||
:key="index"
|
||||
:monitor="item"
|
||||
:isSelectMode="isSelectMode"
|
||||
:isSelected="isSelected"
|
||||
:select="select"
|
||||
:deselect="deselect"
|
||||
:depth="depth + 1"
|
||||
:filter-func="filterFunc"
|
||||
:sort-func="sortFunc"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
|
@@ -118,6 +118,7 @@ export default {
|
||||
"clicksendsms": "ClickSend SMS",
|
||||
"CallMeBot": "CallMeBot (WhatsApp, Telegram Call, Facebook Messanger)",
|
||||
"discord": "Discord",
|
||||
"Elks": "46elks",
|
||||
"GoogleChat": "Google Chat (Google Workspace)",
|
||||
"gorush": "Gorush",
|
||||
"gotify": "Gotify",
|
||||
@@ -135,6 +136,7 @@ export default {
|
||||
"ntfy": "Ntfy",
|
||||
"octopush": "Octopush",
|
||||
"OneBot": "OneBot",
|
||||
"Onesender": "Onesender",
|
||||
"Opsgenie": "Opsgenie",
|
||||
"PagerDuty": "PagerDuty",
|
||||
"PagerTree": "PagerTree",
|
||||
@@ -144,6 +146,7 @@ export default {
|
||||
"pushy": "Pushy",
|
||||
"rocket.chat": "Rocket.Chat",
|
||||
"signal": "Signal",
|
||||
"SIGNL4": "SIGNL4",
|
||||
"slack": "Slack",
|
||||
"squadcast": "SquadCast",
|
||||
"SMSEagle": "SMSEagle",
|
||||
@@ -152,6 +155,7 @@ export default {
|
||||
"stackfield": "Stackfield",
|
||||
"teams": "Microsoft Teams",
|
||||
"telegram": "Telegram",
|
||||
"threema": "Threema",
|
||||
"twilio": "Twilio",
|
||||
"Splunk": "Splunk",
|
||||
"webhook": "Webhook",
|
||||
@@ -161,6 +165,7 @@ export default {
|
||||
"whapi": "WhatsApp (Whapi)",
|
||||
"gtxmessaging": "GtxMessaging",
|
||||
"Cellsynt": "Cellsynt",
|
||||
"SendGrid": "SendGrid"
|
||||
};
|
||||
|
||||
// Put notifications here if it's not supported in most regions or its documentation is not in English
|
||||
@@ -177,6 +182,7 @@ export default {
|
||||
"WeCom": "WeCom (企业微信群机器人)",
|
||||
"ServerChan": "ServerChan (Server酱)",
|
||||
"smsc": "SMSC",
|
||||
"WPush": "WPush(wpush.cn)",
|
||||
};
|
||||
|
||||
// Sort by notification name
|
||||
|
@@ -7,12 +7,12 @@
|
||||
:animation="100"
|
||||
>
|
||||
<template #item="group">
|
||||
<div class="mb-5 ">
|
||||
<div class="mb-5" data-testid="group">
|
||||
<!-- Group Title -->
|
||||
<h2 class="group-title">
|
||||
<font-awesome-icon v-if="editMode && showGroupDrag" icon="arrows-alt-v" class="action drag me-3" />
|
||||
<font-awesome-icon v-if="editMode" icon="times" class="action remove me-3" @click="removeGroup(group.index)" />
|
||||
<Editable v-model="group.element.name" :contenteditable="editMode" tag="span" />
|
||||
<Editable v-model="group.element.name" :contenteditable="editMode" tag="span" data-testid="group-name" />
|
||||
</h2>
|
||||
|
||||
<div class="shadow-box monitor-list mt-4 position-relative">
|
||||
@@ -31,7 +31,7 @@
|
||||
item-key="id"
|
||||
>
|
||||
<template #item="monitor">
|
||||
<div class="item">
|
||||
<div class="item" data-testid="monitor">
|
||||
<div class="row">
|
||||
<div class="col-9 col-md-8 small-padding">
|
||||
<div class="info">
|
||||
@@ -45,10 +45,11 @@
|
||||
class="item-name"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="monitor-name"
|
||||
>
|
||||
{{ monitor.element.name }}
|
||||
</a>
|
||||
<p v-else class="item-name"> {{ monitor.element.name }} </p>
|
||||
<p v-else class="item-name" data-testid="monitor-name"> {{ monitor.element.name }} </p>
|
||||
|
||||
<span
|
||||
title="Setting"
|
||||
@@ -66,7 +67,7 @@
|
||||
<Tag :item="{name: $t('Cert Exp.'), value: formattedCertExpiryMessage(monitor), color: certExpiryColor(monitor)}" :size="'sm'" />
|
||||
</div>
|
||||
<div v-if="showTags">
|
||||
<Tag v-for="tag in monitor.element.tags" :key="tag" :item="tag" :size="'sm'" />
|
||||
<Tag v-for="tag in monitor.element.tags" :key="tag" :item="tag" :size="'sm'" data-testid="monitor-tag" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -14,6 +14,7 @@
|
||||
type="button"
|
||||
class="btn btn-outline-secondary btn-add"
|
||||
:disabled="processing"
|
||||
data-testid="add-tag-button"
|
||||
@click.stop="showAddDialog"
|
||||
>
|
||||
<font-awesome-icon class="me-1" icon="plus" /> {{ $t("Add") }}
|
||||
@@ -59,6 +60,7 @@
|
||||
v-model="newDraftTag.name" class="form-control"
|
||||
:class="{'is-invalid': validateDraftTag.nameInvalid}"
|
||||
:placeholder="$t('Name')"
|
||||
data-testid="tag-name-input"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
/>
|
||||
<div class="invalid-feedback">
|
||||
@@ -76,6 +78,7 @@
|
||||
label="name"
|
||||
select-label=""
|
||||
deselect-label=""
|
||||
data-testid="tag-color-select"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div
|
||||
@@ -103,6 +106,7 @@
|
||||
v-model="newDraftTag.value" class="form-control"
|
||||
:class="{'is-invalid': validateDraftTag.valueInvalid}"
|
||||
:placeholder="$t('value (optional)')"
|
||||
data-testid="tag-value-input"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
/>
|
||||
<div class="invalid-feedback">
|
||||
@@ -114,6 +118,7 @@
|
||||
type="button"
|
||||
class="btn btn-secondary float-end"
|
||||
:disabled="processing || validateDraftTag.invalid"
|
||||
data-testid="tag-submit-button"
|
||||
@click.stop="addDraftTag"
|
||||
>
|
||||
{{ $t("Add") }}
|
||||
|
48
src/components/notifications/46elks.vue
Normal file
48
src/components/notifications/46elks.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label for="ElksUsername" class="form-label">{{ $t("Username") }}</label>
|
||||
<input id="ElksUsername" v-model="$parent.notification.elksUsername" type="text" class="form-control" required>
|
||||
<label for="ElksPassword" class="form-label">{{ $t("Password") }}</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
<HiddenInput id="ElksPassword" v-model="$parent.notification.elksAuthToken" :required="true" autocomplete="new-password"></HiddenInput>
|
||||
<i18n-t tag="p" keypath="Can be found on:">
|
||||
<a href="https://46elks.com/account" target="_blank">https://46elks.com/account</a>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="Elks-from-number" class="form-label">{{ $t("From") }}</label>
|
||||
<input id="Elks-from-number" v-model="$parent.notification.elksFromNumber" type="text" class="form-control" required>
|
||||
<div class="form-text">
|
||||
{{ $t("Either a text sender ID or a phone number in E.164 format if you want to be able to receive replies.") }}
|
||||
<i18n-t tag="p" keypath="More info on:">
|
||||
<a href="https://46elks.se/kb/text-sender-id" target="_blank">https://46elks.se/kb/text-sender-id</a>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="Elks-to-number" class="form-label">{{ $t("To Number") }}</label>
|
||||
<input id="Elks-to-number" v-model="$parent.notification.elksToNumber" type="text" class="form-control" required>
|
||||
<div class="form-text">
|
||||
{{ $t("The phone number of the recipient in E.164 format.") }}
|
||||
<i18n-t tag="p" keypath="More info on:">
|
||||
<a href="https://46elks.se/kb/e164" target="_blank">https://46elks.se/kb/e164</a>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
|
||||
<a href="https://46elks.com/docs/send-sms" target="_blank">https://46elks.com/docs/send-sms</a>
|
||||
</i18n-t>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HiddenInput from "../HiddenInput.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
HiddenInput,
|
||||
},
|
||||
};
|
||||
</script>
|
81
src/components/notifications/Onesender.vue
Normal file
81
src/components/notifications/Onesender.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label for="host-onesender" class="form-label">{{ $t("Host Onesender") }}</label>
|
||||
<input
|
||||
id="host-onesender"
|
||||
v-model="$parent.notification.onesenderURL"
|
||||
type="url"
|
||||
placeholder="https://xxxxxxxxxxx.com/api/v1/messages"
|
||||
pattern="https?://.+"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="receiver-onesender" class="form-label">{{ $t("Token Onesender") }}</label>
|
||||
<HiddenInput id="receiver-onesender" v-model="$parent.notification.onesenderToken" :required="true" autocomplete="false"></HiddenInput>
|
||||
<i18n-t tag="div" keypath="wayToGetOnesenderUrlandToken" class="form-text">
|
||||
<a href="https://onesender.net/" target="_blank">{{ $t("here") }}</a>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="webhook-request-body" class="form-label">{{ $t("Recipient Type") }}</label>
|
||||
<select
|
||||
id="webhook-request-body"
|
||||
v-model="$parent.notification.onesenderTypeReceiver"
|
||||
class="form-select"
|
||||
required
|
||||
>
|
||||
<option value="private">{{ $t("Private Number") }}</option>
|
||||
<option value="group">{{ $t("Group ID") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="$parent.notification.onesenderTypeReceiver == 'private'" class="form-text">{{ $t("privateOnesenderDesc", ['"application/json"']) }}</div>
|
||||
<div v-else class="form-text">{{ $t("groupOnesenderDesc") }}</div>
|
||||
<div class="mb-3">
|
||||
<input
|
||||
id="type-receiver-onesender"
|
||||
v-model="$parent.notification.onesenderReceiver"
|
||||
type="text"
|
||||
placeholder="628123456789 or 628123456789-34534"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<input
|
||||
id="type-receiver-onesender"
|
||||
v-model="computedReceiverResult"
|
||||
type="text"
|
||||
class="form-control"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HiddenInput from "../HiddenInput.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
HiddenInput,
|
||||
},
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
computed: {
|
||||
computedReceiverResult() {
|
||||
let receiver = this.$parent.notification.onesenderReceiver;
|
||||
return this.$parent.notification.onesenderTypeReceiver === "private" ? receiver + "@s.whatsapp.net" : receiver + "@g.us";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
textarea {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
16
src/components/notifications/SIGNL4.vue
Normal file
16
src/components/notifications/SIGNL4.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label for="signl4-webhook-url" class="form-label">{{ $t("SIGNL4 Webhook URL") }}</label>
|
||||
<input
|
||||
id="signl4-webhook-url"
|
||||
v-model="$parent.notification.webhookURL"
|
||||
type="url"
|
||||
pattern="https?://.+"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
<i18n-t tag="div" keypath="signl4Docs" class="form-text">
|
||||
<a href="https://docs.signl4.com/integrations/uptime-kuma/uptime-kuma.html" target="_blank">SIGNL4 Docs</a>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</template>
|
47
src/components/notifications/SendGrid.vue
Normal file
47
src/components/notifications/SendGrid.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label for="sendgrid-api-key" class="form-label">{{ $t("SendGrid API Key") }}</label>
|
||||
<HiddenInput id="push-api-key" v-model="$parent.notification.sendgridApiKey" :required="true" autocomplete="new-password"></HiddenInput>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="sendgrid-from-email" class="form-label">{{ $t("From Email") }}</label>
|
||||
<input id="sendgrid-from-email" v-model="$parent.notification.sendgridFromEmail" type="email" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="sendgrid-to-email" class="form-label">{{ $t("To Email") }}</label>
|
||||
<input id="sendgrid-to-email" v-model="$parent.notification.sendgridToEmail" type="email" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="sendgrid-cc-email" class="form-label">{{ $t("smtpCC") }}</label>
|
||||
<input id="sendgrid-cc-email" v-model="$parent.notification.sendgridCcEmail" type="email" class="form-control">
|
||||
<div class="form-text">{{ $t("Separate multiple email addresses with commas") }}</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="sendgrid-bcc-email" class="form-label">{{ $t("smtpBCC") }}</label>
|
||||
<input id="sendgrid-bcc-email" v-model="$parent.notification.sendgridBccEmail" type="email" class="form-control">
|
||||
<small class="form-text text-muted">{{ $t("Separate multiple email addresses with commas") }}</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="sendgrid-subject" class="form-label">{{ $t("Subject:") }}</label>
|
||||
<input id="sendgrid-subject" v-model="$parent.notification.sendgridSubject" type="text" class="form-control">
|
||||
<small class="form-text text-muted">{{ $t("leave blank for default subject") }}</small>
|
||||
</div>
|
||||
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
|
||||
<a href="https://docs.sendgrid.com/api-reference/mail-send/mail-send" target="_blank">https://docs.sendgrid.com/api-reference/mail-send/mail-send</a>
|
||||
</i18n-t>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HiddenInput from "../HiddenInput.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
HiddenInput,
|
||||
},
|
||||
mounted() {
|
||||
if (typeof this.$parent.notification.sendgridSubject === "undefined") {
|
||||
this.$parent.notification.sendgridSubject = "Notification from Your Uptime Kuma";
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
@@ -9,6 +9,12 @@
|
||||
<label for="slack-channel" class="form-label">{{ $t("Channel Name") }}</label>
|
||||
<input id="slack-channel-name" v-model="$parent.notification.slackchannel" type="text" class="form-control">
|
||||
|
||||
<label class="form-label">{{ $t("Message format") }}</label>
|
||||
<div class="form-check form-switch">
|
||||
<input id="slack-text-message" v-model="$parent.notification.slackrichmessage" type="checkbox" class="form-check-input">
|
||||
<label for="slack-text-message" class="form-label">{{ $t("Send rich messages") }}</label>
|
||||
</div>
|
||||
|
||||
<div class="form-text">
|
||||
<span style="color: red;"><sup>*</sup></span>{{ $t("Required") }}
|
||||
<i18n-t tag="p" keypath="aboutWebhooks" style="margin-top: 8px;">
|
||||
|
@@ -4,6 +4,53 @@
|
||||
<HiddenInput id="push-api-key" v-model="$parent.notification.pushAPIKey" :required="true" autocomplete="new-password"></HiddenInput>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="push-api-title" class="form-label">{{ $t("Title") }}</label>
|
||||
<input id="push-api-title" v-model="$parent.notification.pushTitle" type="text" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="push-api-channel" class="form-label">{{ $t("Notification Channel") }}</label>
|
||||
<input id="push-api-channel" v-model="$parent.notification.pushChannel" type="text" class="form-control" patttern="[A-Za-z0-9-]+">
|
||||
<div class="form-text">
|
||||
{{ $t("Alphanumerical string and hyphens only") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="push-api-sound" class="form-label">{{ $t("Sound") }}</label>
|
||||
<select id="push-api-sound" v-model="$parent.notification.pushSound" class="form-select">
|
||||
<option value="default">{{ $t("Default") }}</option>
|
||||
<option value="arcade">{{ $t("Arcade") }}</option>
|
||||
<option value="correct">{{ $t("Correct") }}</option>
|
||||
<option value="fail">{{ $t("Fail") }}</option>
|
||||
<option value="harp">{{ $t("Harp") }}</option>
|
||||
<option value="reveal">{{ $t("Reveal") }}</option>
|
||||
<option value="bubble">{{ $t("Bubble") }}</option>
|
||||
<option value="doorbell">{{ $t("Doorbell") }}</option>
|
||||
<option value="flute">{{ $t("Flute") }}</option>
|
||||
<option value="money">{{ $t("Money") }}</option>
|
||||
<option value="scifi">{{ $t("Scifi") }}</option>
|
||||
<option value="clear">{{ $t("Clear") }}</option>
|
||||
<option value="elevator">{{ $t("Elevator") }}</option>
|
||||
<option value="guitar">{{ $t("Guitar") }}</option>
|
||||
<option value="pop">{{ $t("Pop") }}</option>
|
||||
</select>
|
||||
<div class="form-text">
|
||||
{{ $t("Custom sound to override default notification sound") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input v-model="$parent.notification.pushTimeSensitive" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label">{{ $t("Time Sensitive (iOS Only)") }}</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
{{ $t("Time sensitive notifications will be delivered immediately, even if the device is in do not disturb mode.") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
|
||||
<a href="https://docs.push.techulus.com" target="_blank">https://docs.push.techulus.com</a>
|
||||
</i18n-t>
|
||||
@@ -16,5 +63,19 @@ export default {
|
||||
components: {
|
||||
HiddenInput,
|
||||
},
|
||||
mounted() {
|
||||
if (typeof this.$parent.notification.pushTitle === "undefined") {
|
||||
this.$parent.notification.pushTitle = "Uptime-Kuma";
|
||||
}
|
||||
if (typeof this.$parent.notification.pushChannel === "undefined") {
|
||||
this.$parent.notification.pushChannel = "uptime-kuma";
|
||||
}
|
||||
if (typeof this.$parent.notification.pushSound === "undefined") {
|
||||
this.$parent.notification.pushSound = "default";
|
||||
}
|
||||
if (typeof this.$parent.notification.pushTimeSensitive === "undefined") {
|
||||
this.$parent.notification.pushTimeSensitive = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
87
src/components/notifications/Threema.vue
Normal file
87
src/components/notifications/Threema.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="threema-recipient">{{ $t("threemaRecipientType") }}</label>
|
||||
<select
|
||||
id="threema-recipient" v-model="$parent.notification.threemaRecipientType" required
|
||||
class="form-select"
|
||||
>
|
||||
<option value="identity">{{ $t("threemaRecipientTypeIdentity") }}</option>
|
||||
<option value="phone">{{ $t("threemaRecipientTypePhone") }}</option>
|
||||
<option value="email">{{ $t("threemaRecipientTypeEmail") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="$parent.notification.threemaRecipientType === 'identity'" class="mb-3">
|
||||
<label class="form-label" for="threema-recipient">{{ $t("threemaRecipient") }} {{ $t("threemaRecipientTypeIdentity") }}</label>
|
||||
<input
|
||||
id="threema-recipient"
|
||||
v-model="$parent.notification.threemaRecipient"
|
||||
class="form-control"
|
||||
minlength="8"
|
||||
maxlength="8"
|
||||
pattern="[A-Z0-9]{8}"
|
||||
required
|
||||
type="text"
|
||||
>
|
||||
<div class="form-text">
|
||||
<p>{{ $t("threemaRecipientTypeIdentityFormat") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="$parent.notification.threemaRecipientType === 'phone'" class="mb-3">
|
||||
<label class="form-label" for="threema-recipient">{{ $t("threemaRecipient") }} {{ $t("threemaRecipientTypePhone") }}</label>
|
||||
<input
|
||||
id="threema-recipient"
|
||||
v-model="$parent.notification.threemaRecipient"
|
||||
class="form-control"
|
||||
maxlength="15"
|
||||
pattern="\d{1,15}"
|
||||
required
|
||||
type="text"
|
||||
>
|
||||
<div class="form-text">
|
||||
<p>{{ $t("threemaRecipientTypePhoneFormat") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="$parent.notification.threemaRecipientType === 'email'" class="mb-3">
|
||||
<label class="form-label" for="threema-recipient">{{ $t("threemaRecipient") }} {{ $t("threemaRecipientTypeEmail") }}</label>
|
||||
<input
|
||||
id="threema-recipient"
|
||||
v-model="$parent.notification.threemaRecipient"
|
||||
class="form-control"
|
||||
maxlength="254"
|
||||
required
|
||||
type="email"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="threema-sender">{{ $t("threemaSenderIdentity") }}</label>
|
||||
<input
|
||||
id="threema-sender"
|
||||
v-model="$parent.notification.threemaSenderIdentity"
|
||||
class="form-control"
|
||||
minlength="8"
|
||||
maxlength="8"
|
||||
pattern="^\*[A-Z0-9]{7}$"
|
||||
required
|
||||
type="text"
|
||||
>
|
||||
<div class="form-text">
|
||||
<p>{{ $t("threemaSenderIdentityFormat") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="threema-secret">{{ $t("threemaApiAuthenticationSecret") }}</label>
|
||||
<HiddenInput
|
||||
id="threema-secret" v-model="$parent.notification.threemaSecret" required
|
||||
autocomplete="false"
|
||||
></HiddenInput>
|
||||
</div>
|
||||
<i18n-t class="form-text" keypath="wayToGetThreemaGateway" tag="div">
|
||||
<a href="https://threema.ch/en/gateway" target="_blank">{{ $t("here") }}</a>
|
||||
</i18n-t>
|
||||
<i18n-t class="form-text" keypath="threemaBasicModeInfo" tag="div">
|
||||
<a href="https://gateway.threema.ch/en/developer/api" target="_blank">{{ $t("here") }}</a>
|
||||
</i18n-t>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import HiddenInput from "../HiddenInput.vue";
|
||||
</script>
|
31
src/components/notifications/WPush.vue
Normal file
31
src/components/notifications/WPush.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label for="wpush-apikey" class="form-label">WPush {{ $t("API Key") }}</label>
|
||||
<HiddenInput id="wpush-apikey" v-model="$parent.notification.wpushAPIkey" :required="true" autocomplete="new-password" placeholder="WPushxxxxx"></HiddenInput>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="wpush-channel" class="form-label">发送通道</label>
|
||||
<select id="wpush-channel" v-model="$parent.notification.wpushChannel" class="form-select" required>
|
||||
<option value="wechat">微信</option>
|
||||
<option value="sms">短信</option>
|
||||
<option value="mail">邮件</option>
|
||||
<option value="feishu">飞书</option>
|
||||
<option value="dingtalk">钉钉</option>
|
||||
<option value="wechat_work">企业微信</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<i18n-t tag="p" keypath="More info on:">
|
||||
<a href="https://wpush.cn/" rel="noopener noreferrer" target="_blank">https://wpush.cn/</a>
|
||||
</i18n-t>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HiddenInput from "../HiddenInput.vue";
|
||||
export default {
|
||||
components: {
|
||||
HiddenInput,
|
||||
},
|
||||
};
|
||||
</script>
|
@@ -9,6 +9,7 @@ import CallMeBot from "./CallMeBot.vue";
|
||||
import SMSC from "./SMSC.vue";
|
||||
import DingDing from "./DingDing.vue";
|
||||
import Discord from "./Discord.vue";
|
||||
import Elks from "./46elks.vue";
|
||||
import Feishu from "./Feishu.vue";
|
||||
import FreeMobile from "./FreeMobile.vue";
|
||||
import GoogleChat from "./GoogleChat.vue";
|
||||
@@ -29,6 +30,7 @@ import Nostr from "./Nostr.vue";
|
||||
import Ntfy from "./Ntfy.vue";
|
||||
import Octopush from "./Octopush.vue";
|
||||
import OneBot from "./OneBot.vue";
|
||||
import Onesender from "./Onesender.vue";
|
||||
import Opsgenie from "./Opsgenie.vue";
|
||||
import PagerDuty from "./PagerDuty.vue";
|
||||
import FlashDuty from "./FlashDuty.vue";
|
||||
@@ -52,6 +54,7 @@ import STMP from "./SMTP.vue";
|
||||
import Teams from "./Teams.vue";
|
||||
import TechulusPush from "./TechulusPush.vue";
|
||||
import Telegram from "./Telegram.vue";
|
||||
import Threema from "./Threema.vue";
|
||||
import Twilio from "./Twilio.vue";
|
||||
import Webhook from "./Webhook.vue";
|
||||
import WeCom from "./WeCom.vue";
|
||||
@@ -61,6 +64,9 @@ import Splunk from "./Splunk.vue";
|
||||
import SevenIO from "./SevenIO.vue";
|
||||
import Whapi from "./Whapi.vue";
|
||||
import Cellsynt from "./Cellsynt.vue";
|
||||
import WPush from "./WPush.vue";
|
||||
import SIGNL4 from "./SIGNL4.vue";
|
||||
import SendGrid from "./SendGrid.vue";
|
||||
|
||||
/**
|
||||
* Manage all notification form.
|
||||
@@ -78,6 +84,7 @@ const NotificationFormList = {
|
||||
"smsc": SMSC,
|
||||
"DingDing": DingDing,
|
||||
"discord": Discord,
|
||||
"Elks": Elks,
|
||||
"Feishu": Feishu,
|
||||
"FreeMobile": FreeMobile,
|
||||
"GoogleChat": GoogleChat,
|
||||
@@ -97,6 +104,7 @@ const NotificationFormList = {
|
||||
"ntfy": Ntfy,
|
||||
"octopush": Octopush,
|
||||
"OneBot": OneBot,
|
||||
"Onesender": Onesender,
|
||||
"Opsgenie": Opsgenie,
|
||||
"PagerDuty": PagerDuty,
|
||||
"FlashDuty": FlashDuty,
|
||||
@@ -110,6 +118,7 @@ const NotificationFormList = {
|
||||
"rocket.chat": RocketChat,
|
||||
"serwersms": SerwerSMS,
|
||||
"signal": Signal,
|
||||
"SIGNL4": SIGNL4,
|
||||
"SMSManager": SMSManager,
|
||||
"SMSPartner": SMSPartner,
|
||||
"slack": Slack,
|
||||
@@ -119,6 +128,7 @@ const NotificationFormList = {
|
||||
"stackfield": Stackfield,
|
||||
"teams": Teams,
|
||||
"telegram": Telegram,
|
||||
"threema": Threema,
|
||||
"twilio": Twilio,
|
||||
"Splunk": Splunk,
|
||||
"webhook": Webhook,
|
||||
@@ -130,6 +140,8 @@ const NotificationFormList = {
|
||||
"whapi": Whapi,
|
||||
"gtxmessaging": GtxMessaging,
|
||||
"Cellsynt": Cellsynt,
|
||||
"WPush": WPush,
|
||||
"SendGrid": SendGrid,
|
||||
};
|
||||
|
||||
export default NotificationFormList;
|
||||
|
@@ -1,53 +1,63 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="add-btn">
|
||||
<button class="btn btn-primary me-2" type="button" @click="$refs.apiKeyDialog.show()">
|
||||
<font-awesome-icon icon="plus" /> {{ $t("Add API Key") }}
|
||||
</button>
|
||||
<div
|
||||
v-if="settings.disableAuth"
|
||||
class="mt-5 d-flex align-items-center justify-content-center my-3"
|
||||
>
|
||||
{{ $t("apiKeysDisabledMsg") }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="add-btn">
|
||||
<button class="btn btn-primary me-2" type="button" @click="$refs.apiKeyDialog.show()">
|
||||
<font-awesome-icon icon="plus" /> {{ $t("Add API Key") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span v-if="Object.keys(keyList).length === 0" class="d-flex align-items-center justify-content-center my-3">
|
||||
{{ $t("No API Keys") }}
|
||||
</span>
|
||||
<div>
|
||||
<span
|
||||
v-if="Object.keys(keyList).length === 0"
|
||||
class="d-flex align-items-center justify-content-center my-3"
|
||||
>
|
||||
{{ $t("No API Keys") }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-for="(item, index) in keyList"
|
||||
:key="index"
|
||||
class="item"
|
||||
:class="item.status"
|
||||
>
|
||||
<div class="left-part">
|
||||
<div
|
||||
class="circle"
|
||||
></div>
|
||||
<div class="info">
|
||||
<div class="title">{{ item.name }}</div>
|
||||
<div class="status">
|
||||
{{ $t("apiKey-" + item.status) }}
|
||||
</div>
|
||||
<div class="date">
|
||||
{{ $t("Created") }}: {{ item.createdDate }}
|
||||
</div>
|
||||
<div class="date">
|
||||
{{ $t("Expires") }}: {{ item.expires || $t("Never") }}
|
||||
<div
|
||||
v-for="(item, index) in keyList"
|
||||
:key="index"
|
||||
class="item"
|
||||
:class="item.status"
|
||||
>
|
||||
<div class="left-part">
|
||||
<div class="circle"></div>
|
||||
<div class="info">
|
||||
<div class="title">{{ item.name }}</div>
|
||||
<div class="status">
|
||||
{{ $t("apiKey-" + item.status) }}
|
||||
</div>
|
||||
<div class="date">
|
||||
{{ $t("Created") }}: {{ item.createdDate }}
|
||||
</div>
|
||||
<div class="date">
|
||||
{{ $t("Expires") }}:
|
||||
{{ item.expires || $t("Never") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<div class="btn-group" role="group">
|
||||
<button v-if="item.active" class="btn btn-normal" @click="disableDialog(item.id)">
|
||||
<font-awesome-icon icon="pause" /> {{ $t("Disable") }}
|
||||
</button>
|
||||
<div class="buttons">
|
||||
<div class="btn-group" role="group">
|
||||
<button v-if="item.active" class="btn btn-normal" @click="disableDialog(item.id)">
|
||||
<font-awesome-icon icon="pause" /> {{ $t("Disable") }}
|
||||
</button>
|
||||
|
||||
<button v-if="!item.active" class="btn btn-primary" @click="enableKey(item.id)">
|
||||
<font-awesome-icon icon="play" /> {{ $t("Enable") }}
|
||||
</button>
|
||||
<button v-if="!item.active" class="btn btn-primary" @click="enableKey(item.id)">
|
||||
<font-awesome-icon icon="play" /> {{ $t("Enable") }}
|
||||
</button>
|
||||
|
||||
<button class="btn btn-danger" @click="deleteDialog(item.id)">
|
||||
<font-awesome-icon icon="trash" /> {{ $t("Delete") }}
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="deleteDialog(item.id)">
|
||||
<font-awesome-icon icon="trash" /> {{ $t("Delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,6 +98,9 @@ export default {
|
||||
let result = Object.values(this.$root.apiKeyList);
|
||||
return result;
|
||||
},
|
||||
settings() {
|
||||
return this.$parent.$parent.$parent.settings;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -126,9 +139,11 @@ export default {
|
||||
* @returns {void}
|
||||
*/
|
||||
disableKey() {
|
||||
this.$root.getSocket().emit("disableAPIKey", this.selectedKeyID, (res) => {
|
||||
this.$root.toastRes(res);
|
||||
});
|
||||
this.$root
|
||||
.getSocket()
|
||||
.emit("disableAPIKey", this.selectedKeyID, (res) => {
|
||||
this.$root.toastRes(res);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -146,113 +161,113 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../assets/vars.scss";
|
||||
|
||||
.mobile {
|
||||
.item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
@import "../../assets/vars.scss";
|
||||
|
||||
.mobile {
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
border-radius: 10px;
|
||||
transition: all ease-in-out 0.15s;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
min-height: 90px;
|
||||
margin-bottom: 5px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $highlight-white;
|
||||
.add-btn {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
border-radius: 10px;
|
||||
transition: all ease-in-out 0.15s;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
min-height: 90px;
|
||||
margin-bottom: 5px;
|
||||
|
||||
&:hover {
|
||||
background-color: $highlight-white;
|
||||
}
|
||||
|
||||
&.active {
|
||||
.circle {
|
||||
background-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
.circle {
|
||||
background-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
&.inactive {
|
||||
.circle {
|
||||
background-color: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
&.expired {
|
||||
.left-part {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.circle {
|
||||
background-color: $dark-font-color;
|
||||
}
|
||||
&.inactive {
|
||||
.circle {
|
||||
background-color: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
&.expired {
|
||||
.left-part {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
|
||||
.circle {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 50rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: row-reverse;
|
||||
.circle {
|
||||
background-color: $dark-font-color;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
width: 310px;
|
||||
.left-part {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
|
||||
.circle {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 50rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 5px;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 20px;
|
||||
padding: 0 10px;
|
||||
width: fit-content;
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.dark & {
|
||||
color: white;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
.btn-group {
|
||||
width: 310px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.item {
|
||||
&:hover {
|
||||
background-color: $dark-bg2;
|
||||
}
|
||||
.date {
|
||||
margin-top: 5px;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 20px;
|
||||
padding: 0 10px;
|
||||
width: fit-content;
|
||||
|
||||
.dark & {
|
||||
color: white;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.item {
|
||||
&:hover {
|
||||
background-color: $dark-bg2;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user