Merge branch 'louislam:master' into fix-metrics-push

pull/2491/head
Ruben van Dijk 2 years ago committed by GitHub
commit 896e33815d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -33,7 +33,7 @@ tsconfig.json
/ecosystem.config.js /ecosystem.config.js
/extra/healthcheck.exe /extra/healthcheck.exe
/extra/healthcheck /extra/healthcheck
extra/exe-builder
### .gitignore content (commented rules are duplicated) ### .gitignore content (commented rules are duplicated)
@ -48,6 +48,4 @@ dist-ssr
#!/data/.gitkeep #!/data/.gitkeep
#.vscode #.vscode
### End of .gitignore content ### End of .gitignore content

@ -0,0 +1,19 @@
---
name: "Security Issue"
about: "Just for alerting @louislam, do not provide any details here"
title: "Security Issue"
ref: "main"
labels:
- security
---
DO NOT PROVIDE ANY DETAILS HERE. Please privately report to https://github.com/louislam/uptime-kuma/security/advisories/new.
Why need this issue? It is because GitHub Advisory do not send a notification to @louislam, it is a workaround to do so.
Your GitHub Advisory URL:

@ -16,7 +16,6 @@ Please delete any options that are not relevant.
- User interface (UI) - User interface (UI)
- New feature (non-breaking change which adds functionality) - New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to not work as expected) - Breaking change (fix or feature that would cause existing functionality to not work as expected)
- Translation update
- Other - Other
- This change requires a documentation update - This change requires a documentation update

@ -6,8 +6,12 @@ name: Auto Test
on: on:
push: push:
branches: [ master ] branches: [ master ]
paths-ignore:
- '*.md'
pull_request: pull_request:
branches: [ master ] branches: [ master ]
paths-ignore:
- '*.md'
jobs: jobs:
auto-test: auto-test:
@ -18,7 +22,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [macos-latest, ubuntu-latest, windows-latest] os: [macos-latest, ubuntu-latest, windows-latest]
node: [ 14, 16, 17, 18 ] node: [ 14, 16, 18, 19 ]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/ # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps: steps:
@ -36,6 +40,7 @@ jobs:
env: env:
HEADLESS_TEST: 1 HEADLESS_TEST: 1
JUST_FOR_TEST: ${{ secrets.JUST_FOR_TEST }} JUST_FOR_TEST: ${{ secrets.JUST_FOR_TEST }}
check-linters: check-linters:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -66,3 +71,19 @@ jobs:
- run: npm install - run: npm install
- run: npm run build - run: npm run build
- run: npm run cy:test - run: npm run cy:test
frontend-unit-tests:
needs: [ check-linters ]
runs-on: ubuntu-latest
steps:
- run: git config --global core.autocrlf false # Mainly for Windows
- uses: actions/checkout@v3
- name: Use Node.js 14
uses: actions/setup-node@v3
with:
node-version: 14
cache: 'npm'
- run: npm install
- run: npm run build
- run: npm run cy:run:unit

@ -1,4 +1,3 @@
name: Close Incorrect Issue name: Close Incorrect Issue
on: on:
@ -12,13 +11,13 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest] os: [ubuntu-latest]
node-version: [16.x] node-version: [16]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2 uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'npm' cache: 'npm'

@ -9,7 +9,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v5 - uses: actions/stale@v7
with: with:
stale-issue-message: 'We are clearing up our old issues and your ticket has been open for 3 months with no activity. Remove stale label or comment or this will be closed in 2 days.' stale-issue-message: 'We are clearing up our old issues and your ticket has been open for 3 months with no activity. Remove stale label or comment or this will be closed in 2 days.'
close-issue-message: 'This issue was closed because it has been stalled for 2 days with no activity.' close-issue-message: 'This issue was closed because it has been stalled for 2 days with no activity.'

3
.gitignore vendored

@ -20,3 +20,6 @@ cypress/screenshots
/extra/healthcheck.exe /extra/healthcheck.exe
/extra/healthcheck /extra/healthcheck
/extra/healthcheck-armv7 /extra/healthcheck-armv7
extra/exe-builder/bin
extra/exe-builder/obj

@ -1,6 +1,6 @@
# Project Info # Project Info
First of all, thank you everyone who made pull requests for Uptime Kuma, I never thought GitHub Community can be that nice! And also because of this, I also never thought other people actually read my code and edit my code. It is not structured and commented so well, lol. Sorry about that. First of all, I want to thank everyone who made pull requests for Uptime Kuma. I never thought the GitHub Community would be so nice! Because of this, I also never thought that other people would actually read and edit my code. It is not very well structured or commented, sorry about that.
The project was created with vite.js (vue3). Then I created a subdirectory called "server" for server part. Both frontend and backend share the same package.json. The project was created with vite.js (vue3). Then I created a subdirectory called "server" for server part. Both frontend and backend share the same package.json.
@ -17,8 +17,11 @@ The frontend code build into "dist" directory. The server (express.js) exposes t
## Directories ## Directories
- config (dev config files)
- data (App data) - data (App data)
- db (Base database and migration scripts)
- dist (Frontend build) - dist (Frontend build)
- docker (Dockerfiles)
- extra (Extra useful scripts) - extra (Extra useful scripts)
- public (Frontend resources for dev only) - public (Frontend resources for dev only)
- server (Server source code) - server (Server source code)
@ -27,20 +30,23 @@ The frontend code build into "dist" directory. The server (express.js) exposes t
## Can I create a pull request for Uptime Kuma? ## Can I create a pull request for Uptime Kuma?
Yes or no, it depends on what you will try to do. Since I don't want to waste your time, be sure to **create an empty draft pull request or open an issue, so we can discuss first**. Especially for a large pull request or you don't know it will be merged or not. Yes or no, it depends on what you will try to do. Since I don't want to waste your time, be sure to **create an empty draft pull request or open an issue, so we can have a discussion first**. Especially for a large pull request or you don't know it will be merged or not.
Here are some references: Here are some references:
✅ Usually Accept: ✅ Usually Accept:
- Bug/Security fix - Bug fix
- Translations - Security fix
- Adding notification providers - Adding notification providers
- Adding new language files (You should go to https://weblate.kuma.pet for existing languages)
- Adding new language keys: `$t("...")`
⚠️ Discussion First ⚠️ Discussion First
- Large pull requests - Large pull requests
- New features - New features
❌ Won't Merge ❌ Won't Merge
- A dedicated pr for translating existing languages (You can now translate on https://weblate.kuma.pet)
- Do not pass auto test - Do not pass auto test
- Any breaking changes - Any breaking changes
- Duplicated pull request - Duplicated pull request
@ -48,8 +54,13 @@ Here are some references:
- UI/UX is not close to Uptime Kuma - UI/UX is not close to Uptime Kuma
- Existing logic is completely modified or deleted for no reason - Existing logic is completely modified or deleted for no reason
- A function that is completely out of scope - A function that is completely out of scope
- Convert existing code into other programming languages
- Unnecessary large code changes (Hard to review, causes code conflicts to other pull requests) - Unnecessary large code changes (Hard to review, causes code conflicts to other pull requests)
The above cases cannot cover all situations.
I (@louislam) have the final say. If your pull request does not meet my expectations, I will reject it, no matter how much time you spend on it. Therefore, it is essential to have a discussion beforehand.
I will mark your pull request in the [milestones](https://github.com/louislam/uptime-kuma/milestones), if I am plan to review and merge it. I will mark your pull request in the [milestones](https://github.com/louislam/uptime-kuma/milestones), if I am plan to review and merge it.
Also, please don't rush or ask for ETA, because I have to understand the pull request, make sure it is no breaking changes and stick to my vision of this project, especially for large pull requests. Also, please don't rush or ask for ETA, because I have to understand the pull request, make sure it is no breaking changes and stick to my vision of this project, especially for large pull requests.
@ -72,13 +83,13 @@ Before deep into coding, discussion first is preferred. Creating an empty pull r
## Project Styles ## Project Styles
I personally do not like something need to learn so much and need to config so much before you can finally start the app. I personally do not like something that requires so many configurations before you can finally start the app. I hope Uptime Kuma installation could be as easy as like installing a mobile app.
- Easy to install for non-Docker users, no native build dependency is needed (at least for x86_64), no extra config, no extra effort to get it run - Easy to install for non-Docker users, no native build dependency is needed (for x86_64/armv7/arm64), no extra config, no extra effort required to get it running
- Single container for Docker users, no very complex docker-compose file. Just map the volume and expose the port, then good to go - Single container for Docker users, no very complex docker-compose file. Just map the volume and expose the port, then good to go
- Settings should be configurable in the frontend. Environment variable is not encouraged, unless it is related to startup such as `DATA_DIR`. - Settings should be configurable in the frontend. Environment variable is not encouraged, unless it is related to startup such as `DATA_DIR`
- Easy to use - Easy to use
- The web UI styling should be consistent and nice. - The web UI styling should be consistent and nice
## Coding Styles ## Coding Styles
@ -87,7 +98,7 @@ I personally do not like something need to learn so much and need to config so m
- Follow ESLint - Follow ESLint
- Methods and functions should be documented with JSDoc - Methods and functions should be documented with JSDoc
## Name convention ## Name Conventions
- Javascript/Typescript: camelCaseType - Javascript/Typescript: camelCaseType
- SQLite: snake_case (Underscore) - SQLite: snake_case (Underscore)
@ -101,7 +112,7 @@ I personally do not like something need to learn so much and need to config so m
- IDE that supports ESLint and EditorConfig (I am using IntelliJ IDEA) - IDE that supports ESLint and EditorConfig (I am using IntelliJ IDEA)
- A SQLite GUI tool (SQLite Expert Personal is suggested) - A SQLite GUI tool (SQLite Expert Personal is suggested)
## Install dependencies ## Install Dependencies for Development
```bash ```bash
npm ci npm ci
@ -119,6 +130,12 @@ Port `3000` and port `3001` will be used.
npm run dev npm run dev
``` ```
But sometimes, you would like to keep restart the server, but not the frontend, you can run these command in two terminals:
```
npm run start-frontend-dev
npm run start-server-dev
```
## Backend Server ## Backend Server
It binds to `0.0.0.0:3001` by default. It binds to `0.0.0.0:3001` by default.
@ -134,12 +151,15 @@ express.js is used for:
### Structure in /server/ ### Structure in /server/
- jobs/ (Jobs that are running in another process)
- model/ (Object model, auto mapping to the database table name) - model/ (Object model, auto mapping to the database table name)
- modules/ (Modified 3rd-party modules) - modules/ (Modified 3rd-party modules)
- monitor_types (Monitor Types)
- notification-providers/ (individual notification logic) - notification-providers/ (individual notification logic)
- routers/ (Express Routers) - routers/ (Express Routers)
- socket-handler (Socket.io Handlers) - socket-handler (Socket.io Handlers)
- server.js (Server entry point and main logic) - server.js (Server entry point)
- uptime-kuma-server.js (UptimeKumaServer class, main logic should be here, but some still in `server.js`)
## Frontend Dev Server ## Frontend Dev Server
@ -172,15 +192,11 @@ The data and socket logic are in `src/mixins/socket.js`.
## Unit Test ## Unit Test
It is an end-to-end testing. It is using Jest and Puppeteer.
```bash ```bash
npm run build npm run build
npm test npm test
``` ```
By default, the Chromium window will be shown up during the test. Specifying `HEADLESS_TEST=1` for terminal environments.
## Dependencies ## Dependencies
Both frontend and backend share the same package.json. However, the frontend dependencies are eventually not used in the production environment, because it is usually also baked into dist files. So: Both frontend and backend share the same package.json. However, the frontend dependencies are eventually not used in the production environment, because it is usually also baked into dist files. So:
@ -194,18 +210,12 @@ Both frontend and backend share the same package.json. However, the frontend dep
### Update Dependencies ### Update Dependencies
Install `ncu`
https://github.com/raineorshine/npm-check-updates
```bash
ncu -u -t patch
npm install
```
Since previously updating Vite 2.5.10 to 2.6.0 broke the application completely, from now on, it should update patch release version only. Since previously updating Vite 2.5.10 to 2.6.0 broke the application completely, from now on, it should update patch release version only.
Patch release = the third digit ([Semantic Versioning](https://semver.org/)) Patch release = the third digit ([Semantic Versioning](https://semver.org/))
If for maybe security reasons, a library must be updated. Then you must need to check if there are any breaking changes.
## Translations ## Translations
Please read: https://github.com/louislam/uptime-kuma/tree/master/src/languages Please read: https://github.com/louislam/uptime-kuma/tree/master/src/languages
@ -225,12 +235,13 @@ https://github.com/louislam/uptime-kuma/issues?q=sort%3Aupdated-desc
1. Draft a release note 1. Draft a release note
2. Make sure the repo is cleared 2. Make sure the repo is cleared
3. If the healthcheck is updated, remember to re-compile it: `npm run build-docker-builder-go`
3. `npm run release-final with env vars: `VERSION` and `GITHUB_TOKEN` 3. `npm run release-final with env vars: `VERSION` and `GITHUB_TOKEN`
4. Wait until the `Press any key to continue` 4. Wait until the `Press any key to continue`
5. `git push` 5. `git push`
6. Publish the release note as 1.X.X 6. Publish the release note as 1.X.X
7. Press any key to continue 7. Press any key to continue
8. SSH to demo site server and update to 1.X.X 8. Deploy to the demo server: `npm run deploy-demo-server`
Checking: Checking:

@ -1,38 +1,39 @@
# Uptime Kuma # Uptime Kuma
<a target="_blank" href="https://github.com/louislam/uptime-kuma"><img src="https://img.shields.io/github/stars/louislam/uptime-kuma" /></a> <a target="_blank" href="https://hub.docker.com/r/louislam/uptime-kuma"><img src="https://img.shields.io/docker/pulls/louislam/uptime-kuma" /></a> <a target="_blank" href="https://hub.docker.com/r/louislam/uptime-kuma"><img src="https://img.shields.io/docker/v/louislam/uptime-kuma/latest?label=docker%20image%20ver." /></a> <a target="_blank" href="https://github.com/louislam/uptime-kuma"><img src="https://img.shields.io/github/last-commit/louislam/uptime-kuma" /></a> <a target="_blank" href="https://opencollective.com/uptime-kuma"><img src="https://opencollective.com/uptime-kuma/total/badge.svg?label=Open%20Collective%20Backers&color=brightgreen" /></a> <a target="_blank" href="https://github.com/louislam/uptime-kuma"><img src="https://img.shields.io/github/stars/louislam/uptime-kuma" /></a> <a target="_blank" href="https://hub.docker.com/r/louislam/uptime-kuma"><img src="https://img.shields.io/docker/pulls/louislam/uptime-kuma" /></a> <a target="_blank" href="https://hub.docker.com/r/louislam/uptime-kuma"><img src="https://img.shields.io/docker/v/louislam/uptime-kuma/latest?label=docker%20image%20ver." /></a> <a target="_blank" href="https://github.com/louislam/uptime-kuma"><img src="https://img.shields.io/github/last-commit/louislam/uptime-kuma" /></a> <a target="_blank" href="https://opencollective.com/uptime-kuma"><img src="https://opencollective.com/uptime-kuma/total/badge.svg?label=Open%20Collective%20Backers&color=brightgreen" /></a>
[![GitHub Sponsors](https://img.shields.io/github/sponsors/louislam?label=GitHub%20Sponsors)](https://github.com/sponsors/louislam) [![GitHub Sponsors](https://img.shields.io/github/sponsors/louislam?label=GitHub%20Sponsors)](https://github.com/sponsors/louislam) <a href="https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/">
<img src="https://weblate.kuma.pet/widgets/uptime-kuma/-/svg-badge.svg" alt="Translation status" />
</a>
<div align="center" width="100%"> <div align="center" width="100%">
<img src="./public/icon.svg" width="128" alt="" /> <img src="./public/icon.svg" width="128" alt="" />
</div> </div>
It is a self-hosted monitoring tool like "Uptime Robot". Uptime Kuma is an easy-to-use self-hosted monitoring tool.
<img src="https://uptime.kuma.pet/img/dark.jpg" width="700" alt="" /> <img src="https://user-images.githubusercontent.com/1336778/212262296-e6205815-ad62-488c-83ec-a5b0d0689f7c.jpg" width="700" alt="" />
## 🥔 Live Demo ## 🥔 Live Demo
Try it! Try it!
- Tokyo Demo Server: https://demo.uptime.kuma.pet (Sponsored by [Uptime Kuma Sponsors](https://github.com/louislam/uptime-kuma#%EF%B8%8F-sponsors)) - Tokyo Demo Server: https://demo.uptime.kuma.pet (Sponsored by [Uptime Kuma Sponsors](https://github.com/louislam/uptime-kuma#%EF%B8%8F-sponsors))
- Europe Demo Server: https://demo.uptime-kuma.karimi.dev:27000 (Provided by [@mhkarimi1383](https://github.com/mhkarimi1383))
It is a temporary live demo, all data will be deleted after 10 minutes. Use the one that is closer to you, but I suggest that you should install and try it out for the best demo experience. It is a temporary live demo, all data will be deleted after 10 minutes. Use the one that is closer to you, but I suggest that you should install and try it out for the best demo experience.
## ⭐ Features ## ⭐ Features
* Monitoring uptime for HTTP(s) / TCP / HTTP(s) Keyword / Ping / DNS Record / Push / Steam Game Server / Docker Containers. * Monitoring uptime for HTTP(s) / TCP / HTTP(s) Keyword / Ping / DNS Record / Push / Steam Game Server / Docker Containers
* Fancy, Reactive, Fast UI/UX. * Fancy, Reactive, Fast UI/UX
* Notifications via Telegram, Discord, Gotify, Slack, Pushover, Email (SMTP), and [90+ notification services, click here for the full list](https://github.com/louislam/uptime-kuma/tree/master/src/components/notifications). * Notifications via Telegram, Discord, Gotify, Slack, Pushover, Email (SMTP), and [90+ notification services, click here for the full list](https://github.com/louislam/uptime-kuma/tree/master/src/components/notifications)
* 20 second intervals. * 20 second intervals
* [Multi Languages](https://github.com/louislam/uptime-kuma/tree/master/src/languages) * [Multi Languages](https://github.com/louislam/uptime-kuma/tree/master/src/lang)
* Multiple Status Pages * Multiple status pages
* Map Status Page to Domain * Map status pages to specific domains
* Ping Chart * Ping chart
* Certificate Info * Certificate info
* Proxy Support * Proxy support
* 2FA available * 2FA support
## 🔧 How to Install ## 🔧 How to Install
@ -44,14 +45,15 @@ docker run -d --restart=always -p 3001:3001 -v uptime-kuma:/app/data --name upti
⚠️ Please use a **local volume** only. Other types such as NFS are not supported. ⚠️ Please use a **local volume** only. Other types such as NFS are not supported.
Browse to http://localhost:3001 after starting. Uptime Kuma is now running on http://localhost:3001
### 💪🏻 Non-Docker ### 💪🏻 Non-Docker
Required Tools: Required Tools:
- [Node.js](https://nodejs.org/en/download/) >= 14 - [Node.js](https://nodejs.org/en/download/) >= 14
- [npm](https://docs.npmjs.com/cli/) >= 7
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
- [pm2](https://pm2.keymetrics.io/) - For run in background - [pm2](https://pm2.keymetrics.io/) - For running Uptime Kuma in the background
```bash ```bash
# Update your npm to the latest version # Update your npm to the latest version
@ -73,7 +75,7 @@ pm2 start server/server.js --name uptime-kuma
``` ```
Browse to http://localhost:3001 after starting. Uptime Kuma is now running on http://localhost:3001
More useful PM2 Commands More useful PM2 Commands
@ -171,7 +173,7 @@ Check out the latest beta release here: https://github.com/louislam/uptime-kuma/
If you want to report a bug or request a new feature, feel free to open a [new issue](https://github.com/louislam/uptime-kuma/issues). If you want to report a bug or request a new feature, feel free to open a [new issue](https://github.com/louislam/uptime-kuma/issues).
### Translations ### Translations
If you want to translate Uptime Kuma into your language, please read: https://github.com/louislam/uptime-kuma/tree/master/src/languages If you want to translate Uptime Kuma into your language, please visit [Weblate Readme](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md).
Feel free to correct my grammar in this README, source code, or wiki, as my mother language is not English and my grammar is not that great. Feel free to correct my grammar in this README, source code, or wiki, as my mother language is not English and my grammar is not that great.

@ -2,9 +2,14 @@
## Reporting a Vulnerability ## Reporting a Vulnerability
Please report security issues to uptime@kuma.pet. 1. Please report security issues to https://github.com/louislam/uptime-kuma/security/advisories/new.
1. Please also create a empty security issues for alerting me, as GitHub Advisory do not send a notification, I probably will miss without this. https://github.com/louislam/uptime-kuma/issues/new?assignees=&labels=help&template=security.md
Do not use the issue tracker or discuss it in the public as it will cause more damage. Do not use the public issue tracker or discuss it in the public as it will cause more damage.
## Do you accept other 3rd-party bug bounty platforms?
At this moment, I DO NOT accept other bug bounty platforms, because I am not familiar with these platforms and someone have tried to send a phishing link to me by this already. To minimize my own risk, please report through GitHub Advisories only. I will ignore all 3rd-party bug bounty platforms emails.
## Supported Versions ## Supported Versions

@ -0,0 +1,10 @@
const { defineConfig } = require("cypress");
module.exports = defineConfig({
e2e: {
supportFile: false,
specPattern: [
"test/cypress/unit/**/*.js"
],
}
});

@ -0,0 +1,5 @@
BEGIN TRANSACTION;
ALTER TABLE monitor
ADD game VARCHAR(255);
COMMIT

@ -0,0 +1,4 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
ALTER TABLE status_page ADD google_analytics_tag_id VARCHAR;
COMMIT;

@ -0,0 +1,5 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
ALTER TABLE monitor
ADD packet_size INTEGER DEFAULT 56 NOT NULL;
COMMIT;

@ -3,6 +3,6 @@ FROM node:16-alpine3.12
WORKDIR /app WORKDIR /app
# Install apprise, iputils for non-root ping, setpriv # Install apprise, iputils for non-root ping, setpriv
RUN apk add --no-cache iputils setpriv dumb-init python3 py3-cryptography py3-pip py3-six py3-yaml py3-click py3-markdown py3-requests py3-requests-oauthlib && \ RUN apk add --no-cache iputils setpriv dumb-init python3 py3-cryptography py3-pip py3-six py3-yaml py3-click py3-markdown py3-requests py3-requests-oauthlib git && \
pip3 --no-cache-dir install apprise==1.2.0 && \ pip3 --no-cache-dir install apprise==1.2.1 && \
rm -rf /root/.cache rm -rf /root/.cache

@ -0,0 +1,16 @@
############################################
# Build in Golang
# Run npm run build-healthcheck-armv7 in the host first, another it will be super slow where it is building the armv7 healthcheck
############################################
FROM golang:1.19-buster
WORKDIR /app
ARG TARGETPLATFORM
COPY ./extra/ ./extra/
# Compile healthcheck.go
RUN apt update && \
apt --yes --no-install-recommends install curl && \
curl -sL https://deb.nodesource.com/setup_18.x | bash && \
apt --yes --no-install-recommends install nodejs && \
node ./extra/build-healthcheck.js $TARGETPLATFORM && \
apt --yes remove nodejs

@ -10,8 +10,8 @@ WORKDIR /app
# Stupid python3 and python3-pip actually install a lot of useless things into Debian, specify --no-install-recommends to skip them, make the base even smaller than alpine! # Stupid python3 and python3-pip actually install a lot of useless things into Debian, specify --no-install-recommends to skip them, make the base even smaller than alpine!
RUN apt update && \ RUN apt update && \
apt --yes --no-install-recommends install python3 python3-pip python3-cryptography python3-six python3-yaml python3-click python3-markdown python3-requests python3-requests-oauthlib \ apt --yes --no-install-recommends install python3 python3-pip python3-cryptography python3-six python3-yaml python3-click python3-markdown python3-requests python3-requests-oauthlib \
sqlite3 iputils-ping util-linux dumb-init && \ sqlite3 iputils-ping util-linux dumb-init git && \
pip3 --no-cache-dir install apprise==1.2.0 && \ pip3 --no-cache-dir install apprise==1.2.1 && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
apt --yes autoremove apt --yes autoremove

@ -1,19 +1,9 @@
############################################ ############################################
# Build in Golang # Build in Golang
# Run npm run build-healthcheck-armv7 in the host first, another it will be super slow where it is building the armv7 healthcheck # Run npm run build-healthcheck-armv7 in the host first, another it will be super slow where it is building the armv7 healthcheck
# Check file: builder-go.dockerfile
############################################ ############################################
FROM golang:1.19.4-buster AS build_healthcheck FROM louislam/uptime-kuma:builder-go AS build_healthcheck
WORKDIR /app
ARG TARGETPLATFORM
COPY ./extra/ ./extra/
# Compile healthcheck.go
RUN apt update
RUN apt --yes --no-install-recommends install curl
RUN curl -sL https://deb.nodesource.com/setup_18.x | bash
RUN apt --yes --no-install-recommends install nodejs
RUN node -v
RUN node ./extra/build-healthcheck.js $TARGETPLATFORM
############################################ ############################################
# Build in Node.js # Build in Node.js
@ -22,10 +12,13 @@ FROM louislam/uptime-kuma:base-debian AS build
WORKDIR /app WORKDIR /app
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
COPY .npmrc .npmrc
COPY package.json package.json
COPY package-lock.json package-lock.json
RUN npm ci --omit=dev
COPY . . COPY . .
COPY --from=build_healthcheck /app/extra/healthcheck /app/extra/healthcheck COPY --from=build_healthcheck /app/extra/healthcheck /app/extra/healthcheck
RUN npm ci --production && \ RUN chmod +x /app/extra/entrypoint.sh
chmod +x /app/extra/entrypoint.sh
############################################ ############################################
# ⭐ Main Image # ⭐ Main Image
@ -78,7 +71,7 @@ RUN npm ci
EXPOSE 3000 3001 EXPOSE 3000 3001
VOLUME ["/app/data"] VOLUME ["/app/data"]
HEALTHCHECK --interval=60s --timeout=30s --start-period=180s --retries=5 CMD node extra/healthcheck.js HEALTHCHECK --interval=60s --timeout=30s --start-period=180s --retries=5 CMD extra/healthcheck
CMD ["npm", "run", "start-pr-test"] CMD ["npm", "run", "start-pr-test"]
############################################ ############################################

@ -3,10 +3,12 @@ WORKDIR /app
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
COPY .npmrc .npmrc
COPY package.json package.json
COPY package-lock.json package-lock.json
RUN npm ci --omit=dev
COPY . . COPY . .
RUN npm ci --production && \ RUN chmod +x /app/extra/entrypoint.sh
chmod +x /app/extra/entrypoint.sh
FROM louislam/uptime-kuma:base-alpine AS release FROM louislam/uptime-kuma:base-alpine AS release
WORKDIR /app WORKDIR /app

@ -22,7 +22,8 @@ if (! exists) {
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n"); fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n");
// Also update package-lock.json // Also update package-lock.json
childProcess.spawnSync("npm", [ "install" ]); const npm = /^win/.test(process.platform) ? "npm.cmd" : "npm";
childProcess.spawnSync(npm, [ "install" ]);
commit(version); commit(version);
tag(version); tag(version);
@ -32,6 +33,10 @@ if (! exists) {
process.exit(1); process.exit(1);
} }
/**
* Commit updated files
* @param {string} version Version to update to
*/
function commit(version) { function commit(version) {
let msg = "Update to " + version; let msg = "Update to " + version;
@ -47,6 +52,10 @@ function commit(version) {
console.log(res.stdout.toString().trim()); console.log(res.stdout.toString().trim());
} }
/**
* Create a tag with the specified version
* @param {string} version Tag to create
*/
function tag(version) { function tag(version) {
let res = childProcess.spawnSync("git", [ "tag", version ]); let res = childProcess.spawnSync("git", [ "tag", version ]);
console.log(res.stdout.toString().trim()); console.log(res.stdout.toString().trim());
@ -55,6 +64,11 @@ function tag(version) {
console.log(res.stdout.toString().trim()); console.log(res.stdout.toString().trim());
} }
/**
* Check if a tag exists for the specified version
* @param {string} version Version to check
* @returns {boolean} Does the tag already exist
*/
function tagExists(version) { function tagExists(version) {
if (! version) { if (! version) {
throw new Error("invalid version"); throw new Error("invalid version");

@ -0,0 +1,59 @@
require("dotenv").config();
const { NodeSSH } = require("node-ssh");
const readline = require("readline");
const rl = readline.createInterface({ input: process.stdin,
output: process.stdout });
const prompt = (query) => new Promise((resolve) => rl.question(query, resolve));
(async () => {
try {
console.log("SSH to demo server");
const ssh = new NodeSSH();
await ssh.connect({
host: process.env.UPTIME_KUMA_DEMO_HOST,
port: process.env.UPTIME_KUMA_DEMO_PORT,
username: process.env.UPTIME_KUMA_DEMO_USERNAME,
privateKeyPath: process.env.UPTIME_KUMA_DEMO_PRIVATE_KEY_PATH
});
let cwd = process.env.UPTIME_KUMA_DEMO_CWD;
let result;
const version = await prompt("Enter Version: ");
result = await ssh.execCommand("git fetch --all", {
cwd,
});
console.log(result.stdout + result.stderr);
await prompt("Press any key to continue...");
result = await ssh.execCommand(`git checkout ${version} --force`, {
cwd,
});
console.log(result.stdout + result.stderr);
result = await ssh.execCommand("npm run download-dist", {
cwd,
});
console.log(result.stdout + result.stderr);
result = await ssh.execCommand("npm install --production", {
cwd,
});
console.log(result.stdout + result.stderr);
result = await ssh.execCommand("pm2 restart 1", {
cwd,
});
console.log(result.stdout + result.stderr);
} catch (e) {
console.log(e);
} finally {
rl.close();
}
})();
// When done reading prompt, exit program
rl.on("close", () => process.exit(0));

@ -25,6 +25,10 @@ if (platform === "linux/amd64") {
const file = fs.createWriteStream("cloudflared.deb"); const file = fs.createWriteStream("cloudflared.deb");
get("https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-" + arch + ".deb"); get("https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-" + arch + ".deb");
/**
* Download specified file
* @param {string} url URL to request
*/
function get(url) { function get(url) {
http.get(url, function (res) { http.get(url, function (res) {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {

@ -47,6 +47,7 @@ function download(url) {
}); });
} }
console.log("Done"); console.log("Done");
process.exit(0);
}); });
tarStream.on("error", () => { tarStream.on("error", () => {

@ -0,0 +1 @@
packages/

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Diagnostics.DiagnosticSource" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.0.1.0" newVersion="4.0.1.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Diagnostics.Tracing" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.1.1.0" newVersion="4.1.1.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Reflection" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.1.1.0" newVersion="4.1.1.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Runtime" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.1.1.1" newVersion="4.1.1.1" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Runtime.InteropServices" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.1.1.0" newVersion="4.1.1.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

@ -0,0 +1,84 @@
using System.ComponentModel;
namespace UptimeKuma {
partial class DownloadForm {
/// <summary>
/// Required designer variable.
/// </summary>
private IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing) {
if (disposing && (components != null)) {
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent() {
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(DownloadForm));
this.progressBar = new System.Windows.Forms.ProgressBar();
this.label = new System.Windows.Forms.Label();
this.labelData = new System.Windows.Forms.Label();
this.SuspendLayout();
//
// progressBar
//
this.progressBar.Location = new System.Drawing.Point(12, 12);
this.progressBar.Name = "progressBar";
this.progressBar.Size = new System.Drawing.Size(472, 41);
this.progressBar.TabIndex = 0;
//
// label
//
this.label.Location = new System.Drawing.Point(12, 59);
this.label.Name = "label";
this.label.Size = new System.Drawing.Size(472, 23);
this.label.TabIndex = 1;
this.label.Text = "Preparing...";
//
// labelData
//
this.labelData.Location = new System.Drawing.Point(12, 82);
this.labelData.Name = "labelData";
this.labelData.Size = new System.Drawing.Size(472, 23);
this.labelData.TabIndex = 2;
//
// DownloadForm
//
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(496, 117);
this.Controls.Add(this.labelData);
this.Controls.Add(this.label);
this.Controls.Add(this.progressBar);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));
this.MaximizeBox = false;
this.Name = "DownloadForm";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
this.Text = "Uptime Kuma";
this.Load += new System.EventHandler(this.DownloadForm_Load);
this.ResumeLayout(false);
}
private System.Windows.Forms.Label labelData;
private System.Windows.Forms.Label label;
private System.Windows.Forms.ProgressBar progressBar;
#endregion
}
}

@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using Newtonsoft.Json;
namespace UptimeKuma {
public partial class DownloadForm : Form {
private readonly Queue<DownloadItem> downloadQueue = new();
private readonly WebClient webClient = new();
private DownloadItem currentDownloadItem;
public DownloadForm() {
InitializeComponent();
}
private void DownloadForm_Load(object sender, EventArgs e) {
webClient.DownloadProgressChanged += DownloadProgressChanged;
webClient.DownloadFileCompleted += DownloadFileCompleted;
label.Text = "Reading latest version...";
// Read json from https://uptime.kuma.pet/version
var versionJson = new WebClient().DownloadString("https://uptime.kuma.pet/version");
var versionObj = JsonConvert.DeserializeObject<Version>(versionJson);
var nodeVersion = versionObj.nodejs;
var uptimeKumaVersion = versionObj.latest;
var hasUpdateFile = File.Exists("update");
if (!Directory.Exists("node")) {
downloadQueue.Enqueue(new DownloadItem {
URL = $"https://nodejs.org/dist/v{nodeVersion}/node-v{nodeVersion}-win-x64.zip",
Filename = "node.zip",
TargetFolder = "node"
});
}
if (!Directory.Exists("core") || hasUpdateFile) {
// It is update, rename the core folder to core.old
if (Directory.Exists("core")) {
// Remove the old core.old folder
if (Directory.Exists("core.old")) {
Directory.Delete("core.old", true);
}
Directory.Move("core", "core.old");
}
downloadQueue.Enqueue(new DownloadItem {
URL = $"https://github.com/louislam/uptime-kuma/archive/refs/tags/{uptimeKumaVersion}.zip",
Filename = "core.zip",
TargetFolder = "core"
});
File.WriteAllText("version.json", versionJson);
// Delete the update file
if (hasUpdateFile) {
File.Delete("update");
}
}
DownloadNextFile();
}
void DownloadNextFile() {
if (downloadQueue.Count > 0) {
var item = downloadQueue.Dequeue();
currentDownloadItem = item;
// Download if the zip file is not existing
if (!File.Exists(item.Filename)) {
label.Text = item.URL;
webClient.DownloadFileAsync(new Uri(item.URL), item.Filename);
} else {
progressBar.Value = 100;
label.Text = "Use local " + item.Filename;
DownloadFileCompleted(null, null);
}
} else {
npmSetup();
}
}
void npmSetup() {
labelData.Text = "";
var npm = "..\\node\\npm.cmd";
var cmd = $"{npm} ci --production & {npm} run download-dist & exit";
var startInfo = new ProcessStartInfo {
FileName = "cmd.exe",
Arguments = $"/k \"{cmd}\"",
RedirectStandardOutput = false,
RedirectStandardError = false,
RedirectStandardInput = true,
UseShellExecute = false,
CreateNoWindow = false,
WorkingDirectory = "core"
};
var process = new Process();
process.StartInfo = startInfo;
process.EnableRaisingEvents = true;
process.Exited += (_, e) => {
progressBar.Value = 100;
if (process.ExitCode == 0) {
Task.Delay(2000).ContinueWith(_ => {
Application.Restart();
});
label.Text = "Done";
} else {
label.Text = "Failed, exit code: " + process.ExitCode;
}
};
process.Start();
label.Text = "Installing dependencies and download dist files";
progressBar.Value = 50;
process.WaitForExit();
}
void DownloadProgressChanged(object sender, DownloadProgressChangedEventArgs e) {
progressBar.Value = e.ProgressPercentage;
var total = e.TotalBytesToReceive / 1024;
var current = e.BytesReceived / 1024;
if (total > 0) {
labelData.Text = $"{current}KB/{total}KB";
}
}
void DownloadFileCompleted(object sender, AsyncCompletedEventArgs e) {
Extract(currentDownloadItem);
DownloadNextFile();
}
void Extract(DownloadItem item) {
if (Directory.Exists(item.TargetFolder)) {
var dir = new DirectoryInfo(item.TargetFolder);
dir.Delete(true);
}
if (Directory.Exists("temp")) {
var dir = new DirectoryInfo("temp");
dir.Delete(true);
}
labelData.Text = $"Extracting {item.Filename}...";
ZipFile.ExtractToDirectory(item.Filename, "temp");
string[] dirList;
// Move to the correct level
dirList = Directory.GetDirectories("temp");
if (dirList.Length > 0) {
var dir = dirList[0];
// As sometime ExtractToDirectory is still locking the directory, loop until ok
while (true) {
try {
Directory.Move(dir, item.TargetFolder);
break;
} catch (Exception exception) {
Thread.Sleep(1000);
}
}
} else {
MessageBox.Show("Unexcepted Error: Cannot move extracted files, folder not found.");
}
labelData.Text = $"Extracted";
if (Directory.Exists("temp")) {
var dir = new DirectoryInfo("temp");
dir.Delete(true);
}
File.Delete(item.Filename);
}
}
public class DownloadItem {
public string URL { get; set; }
public string Filename { get; set; }
public string TargetFolder { get; set; }
}
}

@ -0,0 +1,377 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
<data name="$this.Icon" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
AAABAAMAMDAAAAEAIACoJQAANgAAACAgAAABACAAqBAAAN4lAAAQEAAAAQAgAGgEAACGNgAAKAAAADAA
AABgAAAAAQAgAAAAAAAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAA////BPT09Bfu7u4e8fHxJPPz8yv19fUy9fX1M/Pz8yvx8fEk9vb2HPPz8xXMzMwFAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//
/wHv7+8f7u7uPPPz81Tx8fFs8fHxgPHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGB8fHxcfHx8V3x8fFI9PT0MOvr6w0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AADy8vIU8fHxS/Dw8Hbx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fFr9PT0R/Dw8CIAAAABAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAA8vLyFPHx8Vnx8fGB8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fFs9fX1Mb+/vwQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAICAgALy8vI88fHxfvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvLy8nby8vI8gICAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAzMzMBfHx8Vrx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8vLyYf///wwAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMwF8vLyYPHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8W/z8/MWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADv7+9R8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLw8PB26urqDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPLy8ijx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgu7w7Ifj79ud2u7PtNLrw83P677dzeu85c3r
u+rM67rwzOu68c7rverQ68Dj0uvD3NbuyM3b7c+64u7apujv5ZPx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxXgAAAAEAAAAAAAAAAAAAAAAAAAAA4+PjCfDw
8Hfx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLd7tSmzeu92MbqsvvG6bH/xumy/8fq
s//H6rP/yOq0/8jqtf/J6rb/yeq2/8rrt//K67j/y+u4/8vruf/M67r/zOu7/83ru//Q7MDx1u7Kz9/t
163s8OuJ8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgu/v7y8AAAAAAAAAAAAA
AAAAAAAA7u7uPfHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC5PDdl8jqtuTE6a7/xOmv/8Xp
sP/G6bH/xumx/8bpsv/H6rP/x+qz/8jqtP/I6rX/yeq2/8nqtv/K67f/yuu4/8vruP/L67n/zOu6/8zr
u//N67v/zey8/87svf/P67742e3Mx+jv5ZLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvDw
8HWAgIACAAAAAAAAAACqqqoD8vLyc/Hx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLf7degxOiu+cPo
rf/D6a7/xOmu/8Xpr//F6bD/xumx/8bpsf/G6bL/x+qz/8fqs//I6rT/yOq1/8nqtv/J6rb/yuu3/8rr
uP/L67j/y+u5/8zruv/M67v/zeu7/83svP/O7L3/zuy9/87svfzc7tK28fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fEkAAAAAAAAAADz8/Mq8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgunv
5o3D6a/0wuis/8Lorf/D6K3/xOmu/8Tprv/F6a//xemw/8bpsf/G6bH/xumy/8fqs//H6rP/yOq0/8jq
tf/J6rb/yeq2/8rrt//K67j/y+u4/8vruf/M67r/zOu7/83ru//N7Lz/zuy9/87svf/O7L3/3e/TtPHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLy8vJNAAAAAAAAAADy8vJM8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgszqutDB6Kv/weir/8LorP/D6K3/w+it/8Tprv/E6a7/xemv/8XpsP/G6bH/xumx/8bp
sv/H6rP/x+qz/8jqtP/I6rX/yeq2/8nqtv/K67f/yuu4/8vruP/L67n/zOu6/8zru//N67v/zey8/87s
vf/O7L3/zuy++u3w6Yzx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLy8vJ1AAAAAAAAAADx8fFr8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC6O/kjsDoqvzA6Kr/weir/8Loq//C6Kz/w+it/8Porf/E6a7/xOmu/8Xp
r//F6bD/xumx/8bpsf/G6bL/x+qz/8fqtP/I6rT/yOq1/8nqtv/J6rb/yuu3/8rruP/L67n/y+u5/8zr
uv/M67v/zeu7/83svP/O7L3/zuy9/93u07Xx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC////Bv//
/wfx8fGB8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC1ezJsr/nqf/A56n/weiq/8Hoq//C6Kv/wuis/8Po
rf/D6K3/xOmu/8Pprv+856T/uOed/7bmmv+05Zf/teWZ/7jnnf+86KP/wOio/8fqs//J6rb/yeq2/8rr
t//K67j/y+u5/8vruf/M67r/zOu7/83ru//N7Lz/zuy9/9buyNLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8vLyE/Ly8hPx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGCy+q6zr/nqP/A56n/wOep/8Ho
qv/B6Kv/wuir/8LorP+u5Y//neF2/5bgav+V4Gr/luBr/5fhbP+Y4W7/meFv/5rhcf+b4nL/nOJ0/53i
dv+j5H//reaM/7nnnf/E6q//y+y4/8vruf/L67n/zOu6/8zru//N67v/zey8/9Lsxd/x8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC7+/vIPb29hzx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGCx+m03L/n
qP+/56j/wOep/8Dnqf/B6Kr/weir/7nmn/+R32T/kt9l/5PfZ/+U4Gj/leBq/5bga/+X4W3/mOFu/5nh
b/+a4XH/m+Jy/5zidP+d4nX/nuN3/5/jeP+f4nn/weqq/8rruP/L67n/y+u5/8zruv/M67v/zeu7/9Ls
w+Lx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8PDwI/Hx8SXx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGCxeix5L/nqP+/56j/v+eo/8Dnqf/A56n/weiq/7Pllv+Q3mP/kd9k/5LfZf+T32f/lOBo/5Xg
av+W4Gv/l+Ft/5jhbv+Z4W//muFx/5vicv+c4nT/neJ1/57jd/+f43j/xOmu/8rrt//K67j/y+u5/8vr
uf/M67r/zOu7/9Tsxtfx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC9PT0GO/v7yDx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGCx+m037/nqP+/56j/v+eo/7/nqP/A56n/wOip/7TmmP+P3mH/kN5j/5Hf
ZP+S32b/k99n/5TgaP+V4Gr/luBr/5fhbf+Y4W7/meFw/5rhcf+b4nL/nOJ0/53idf+h5Hz/yuu2/8nq
t//K67f/yuu4/8vruf/L67n/zOu6/9ftysrx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC7e3tDvT0
9Bfx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGCyOq117/nqP+/56j/v+eo/7/nqP+/56j/wOep/7vn
of+O3mD/j95h/5DeY/+R32T/kt9m/5PfZ/+U4Gj/leBq/5bga/+X4W3/mOFu/5nhcP+a4nH/m+Jy/5zi
dP+r5Yr/yOq1/8nqtv/J6rf/yuu3/8rruP/L67n/y+u5/9zu1LHx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLz8/OA////A+7u7g/x8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGCz+q+xb/nqP+/56j/v+eo/7/n
qP+/56j/v+eo/8Dnqf+S4Gb/jt5g/4/eYf+Q3mP/kd9k/5LfZv+T32f/lOBo/5Xgav+W4Gv/l+Ft/5jh
bv+Z4XD/muJx/5vic/+4553/yOq0/8jqtf/J6rb/yeq3/8rrt//K67j/y+u5/+bw4Zfx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fFrAAAAAP///wHz8/N88fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC1+zMrr/n
qP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+f4Xn/jd5f/47eYP+P3mH/kN5j/5HfZP+S32b/k99n/5Tg
af+V4Gr/luBr/5fhbf+Y4W7/meFw/5vic//F6rD/x+q0/8jqtP/I6rX/yeq2/8nqt//K67f/zOu88u/x
74Px8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLv7+9QAAAAAAAAAADw8PBm8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC5e7gk7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+u5I//jN1d/43eX/+O3mD/j95h/5De
Y/+R32T/kt9m/5PfZ/+U4Gn/leBq/5bga/+X4W3/mOFu/6rliP/G6rL/x+qz/8fqtP/I6rT/yOq1/8nq
tv/J6rf/1OzGy/Hx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YL19fUzAAAAAAAAAADy8vJO8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgsPoru2/56j/v+eo/7/nqP+/56j/v+eo/7/nqP++6Kf/j95i/4zd
Xf+N3l//jt5g/4/eYv+Q3mP/kd9k/5LfZv+T32f/lOBp/5Xgav+W4Gz/l+Ft/7voov/G6bL/xuqy/8fq
s//H6rT/yOq1/8jqtf/J6rb/4e/Zo/Hx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLw8PARAAAAAAAA
AADu7u4u8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgszpvMm/56j/v+eo/7/nqP+/56j/v+eo/7/n
qP+/56j/q+SL/4vdXP+M3V3/jd5f/47eYP+P3mL/kN9j/5HfZP+S32b/k99n/5Tgaf+V4Gr/qOOH/8Xp
sP/G6bH/xumy/8bqsv/H6rP/x+q0/8jqtf/K67jy8PHwhPHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8WoAAAAAAAAAAAAAAADo6OgL8fHxgfHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxguDv2J2/56j/v+eo/7/n
qP+/56j/v+eo/7/nqP+/56j/v+eo/6Xjgv+L3Vz/jN1d/43eX/+O3mD/j95i/5DfY/+R32T/kt9m/5Pf
Z/+k44D/xOmu/8XpsP/F6bD/xumx/8bpsv/G6rL/x+qz/8fqtP/W7cnB8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvPz80AAAAAAAAAAAAAAAAAAAAAA8PDwZ/Hx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLD6K/rv+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+u5I//kt5n/4zdXf+N3l//jt5g/4/e
Yv+Q32P/luFs/67kj//D6K3/xOmu/8Tpr//F6bD/xemw/8bpsf/G6bL/xuqy/8fqtP7o7+WR8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvPz8xYAAAAAAAAAAAAAAAAAAAAA8vLyPPHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLV7ci0v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/wOio/7Xl
mv+u5I7/rOSM/67kj/+35pz/wumr/8Lorf/D6K3/w+it/8Tprv/E6a//xemw/8XpsP/G6bH/xumy/9Ds
wNPx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8vLyZQAAAAAAAAAAAAAAAAAAAAAAAAAA////DPHx
8YDx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGCx+m03L/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/n
qP+/56j/v+eo/7/nqP+/56j/wOep/8Doqv/B6Kr/weir/8LorP/C6K3/w+it/8Porv/E6a7/xOmv/8Xp
sP/F6bD/yOq18uvw6Yvx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC7+/vMQAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAPHx8Vzx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC6O/ij8LorPG/56j/v+eo/7/n
qP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/8Dnqf/A6Kr/weiq/8Hoq//C6Kz/wuit/8Po
rf/D6K7/xOmu/8Tpr//F6bH74u/anvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLw8PB6////BQAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAPPz8yrx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxguHu
2pnB56v2v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP/A56n/wOiq/8Ho
q//B6Kv/wuis/8Lorf/D6K3/w+mu/8Tprv3b7dKq8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fFJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHy8vJf8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLi7tyXwumt8L/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/n
qP+/56j/wOep/8Doqv/B6Kv/weir/8LorP/C6K3/xOiv+d7u1aTx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvLy8nb///8KAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADv7+8Q8/Pze/Hx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC6/Dpiszqu82/56j/v+eo/7/nqP+/56j/v+eo/7/n
qP+/56j/v+eo/7/nqP+/56j/v+eo/8Dnqf/A6Kr/weir/8Hoq//H6bTj5e7elfHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvPz8yoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAA9fX1MvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLe7tShx+mz3r/n
qP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP/A56n/xumy5drtz6rv8e+D8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8vLyTgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAPHx8Unx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgubv45DU68e2y+q6z8XoseTD6a7uweir9MPpru7F6bHly+q50tLsxLrl796U8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLy8vJh////AwAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wHx8fFZ8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8Wzf398IAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8D8/PzVfHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8PDwZujo
6AsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAA////AfHx8Ujx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fFa////BQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADz8/Mp8vLydvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8/PzfPHx8TcAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////CvLy8lDz8/N/8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvPz84Hx8fFa8PDwEQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AADw8PAR8vLyTvHx8X3x8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fF/8/PzVvT09BgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wXz8/Mq8/PzU/Hx8XDx8fGB8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLy8vJz8fHxWO/v7y////8IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8G7e3tHfLy
8ifu7u4u8PDwNPT09C/y8vIo7+/vH+Pj4wkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAP///////wAA////////AAD///////8AAP//gAf//wAA//gAAD//AAD/wAAAB/8AAP+A
AAAB/wAA/gAAAAB/AAD8AAAAAD8AAPgAAAAAHwAA8AAAAAAPAADwAAAAAAcAAOAAAAAABwAA4AAAAAAD
AADAAAAAAAMAAMAAAAAAAwAAwAAAAAABAACAAAAAAAEAAIAAAAAAAQAAgAAAAAABAACAAAAAAAEAAIAA
AAAAAQAAgAAAAAABAACAAAAAAAMAAMAAAAAAAwAAwAAAAAADAADAAAAAAAMAAMAAAAAABwAAwAAAAAAH
AADgAAAAAAcAAOAAAAAADwAA4AAAAAAPAADwAAAAAB8AAPAAAAAAHwAA+AAAAAA/AAD8AAAAAD8AAPwA
AAAAfwAA/gAAAAD/AAD/AAAAAf8AAP+AAAAD/wAA/8AAAAf/AAD/8AAAH/8AAP/8AAA//wAA//8AAf//
AAD//+AP//8AAP///////wAA////////AAD///////8AACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAgICAAu/v7xD09PQX7u7uHvDw8CP29vYb8vLyFOrq6gwAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAICA
gALy8vIm7+/vT/Pz82fz8/N98fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvDw8Hrw8PBm7+/vUPT0
9C3o6OgLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOPj
4wnz8/NC8vLydPHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YHy8vJj8/PzKoCAgAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AADx8fEl8vLydfHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxcfHx8SUAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAA9PT0LfHx8YDx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8/PzgPLy8j0AAAABAAAAAAAA
AAAAAAAAAAAAAO3t7Rzx8fGA8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLr8OmM5O7emeTv
3Z7h79mj5fDem+nv45Tu8u6H8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvLy
8joAAAAAAAAAAAAAAAD///8E8fHxbvHx8YLx8fGC8fHxgvHx8YLx8fGC7vDshtns0K7N67zayeq288fq
s//I6rT/yOq1/8nqtv/K67f/y+u4/8vruf/P7L7w0+zF29vv0Lrn8OKX8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8/PzfvPz8xUAAAAAAAAAAPX19TLx8fGC8fHxgvHx8YLx8fGC8fHxgt3u1KXF6rHzxOmv/8Xp
sP/G6bH/xumy/8fqs//I6rT/yOq1/8nqtv/K67f/y+u4/8vruf/M67v/zey8/87svf/S7MPj4u7Zp/Hx
8YLx8fGC8fHxgvHx8YLx8fGC8/PzVQAAAAAAAAAA8fHxavHx8YLx8fGC8fHxgvHx8YLf7defwuis/cPo
rf/E6a7/xOmv/8XpsP/G6bH/xumy/8fqs//I6rT/yOq1/8nqtv/K67f/y+u4/8vruv/M67v/zey8/87s
vf/N67z/3e7SufHx8YLx8fGC8fHxgvHx8YLz8/N8////Bf///w3x8fGC8fHxgvHx8YLx8fGC8fHxgsXp
sOnB6Kv/wuis/8Porf/E6a7/xOmv/8XpsP/G6bH/xumy/8fqs//I6rT/yOq1/8nqtv/K67f/y+u4/8vr
uv/M67v/zey8/87svf/O67z96/Hoj/Hx8YLx8fGC8fHxgvHx8YLy8vIm8/PzK/Hx8YLx8fGC8fHxgvHx
8YLg79icwOep/8Hoqv/B6Kv/wuis/8Porf/E6a7/wuit/73opP+76KL/u+eh/77opv/D6a3/yeu1/8nq
tv/K67f/y+u5/8zruv/M67v/zey8/87svf/d7tSz8fHxgvHx8YLx8fGC8fHxgvHx8Tby8vI68fHxgvHx
8YLx8fGC8fHxgtTrxre/56j/wOep/8Hoqv/B6Kv/uOad/53idv+V4Gn/leBq/5fhbP+Y4W//muFx/5vi
c/+e4Xb/puWD/7PmlP/D6a3/y+u5/8zruv/M67v/zey8/9rtzsHx8fGC8fHxgvHx8YLx8fGC8/PzQfPz
80Lx8fGC8fHxgvHx8YLx8fGC0OvAwr/nqP+/56j/wOep/8Hoqv+o44b/kd9k/5LfZv+U4Gj/leBq/5fh
bf+Y4W//muFx/5vic/+d4nX/n+N3/7fnm//K67j/y+u5/8zruv/M67v/2u3QvPHx8YLx8fGC8fHxgvHx
8YLy8vI98/PzP/Hx8YLx8fGC8fHxgvHx8YLQ6sK/v+eo/7/nqP+/56j/wOep/6jjhv+P3mL/kd9k/5Lf
Zv+U4Gj/leBr/5fhbf+Y4W//muFx/5zic/+d4nX/v+mm/8nqt//K67j/y+u5/8zruv/f79au8fHxgvHx
8YLx8fGC8fHxgvX19TLx8fE38fHxgvHx8YLx8fGC8fHxgtTrybO/56j/v+eo/7/nqP+/56j/sOSS/47e
YP+P3mL/kd9k/5LfZv+U4Gj/leBr/5fhbf+Z4W//muJx/5/jd//H6bP/yeq2/8nqt//K67j/y+u5/+nv
45Tx8fGC8fHxgvHx8YLx8fGC7+/vIPHx8SXx8fGC8fHxgvHx8YLx8fGC4e/Zm7/nqP+/56j/v+eo/7/n
qP+956X/jt5h/47eYP+P3mL/kd9k/5LfZv+U4Gn/luBr/5fhbf+Z4W//q+aK/8fqs//I6rT/yeq2/8nq
t//N7Lvw8fHxgvHx8YLx8fGC8fHxgvPz84D///8G6+vrDfHx8YLx8fGC8fHxgvHx8YLv8e+Dweis87/n
qP+/56j/v+eo/7/nqP+d4XX/jN1e/47eYP+P3mL/kd9k/5PfZ/+U4Gn/luBr/5fhbf+86KP/xuqy/8fq
s//I6rX/yeq2/9Tsx8nx8fGC8fHxgvHx8YLx8fGC8PDwaAAAAAAAAAAA8fHxbPHx8YLx8fGC8fHxgvHx
8YLM6rrMv+eo/7/nqP+/56j/v+eo/7blmv+N3V//jN1e/47eYP+Q3mL/kd9k/5PfZ/+U4Gn/qeSH/8Xp
sP/G6bH/xuqy/8fqs//I6rX/5fDem/Hx8YLx8fGC8fHxgvHx8YLz8/M/AAAAAAAAAADz8/NB8fHxgvHx
8YLx8fGC8fHxgt3s06O/56j/v+eo/7/nqP+/56j/v+eo/7Xmmf+U32n/jN1e/47eYP+Q3mL/k99o/6zk
i//D6a7/xemv/8XpsP/G6bH/xuqy/8vqu+jx8fGC8fHxgvHx8YLx8fGC8fHxgvPz8xUAAAAAAAAAAPT0
9Bfx8fGC8fHxgvHx8YLx8fGC8fHvg8Tpsee/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+35pz/suWV/7Xm
mf/A6Kj/wuit/8Porf/E6a7/xemv/8XpsP/G6bH/3e3UqvHx8YLx8fGC8fHxgvHx8YLw8PBmAAAAAAAA
AAAAAAAAAAAAAPHx8W7x8fGC8fHxgvHx8YLx8fGC4u7cmMHnqvm/56j/v+eo/7/nqP+/56j/v+eo/7/n
qP+/56j/wOep/8Hoqv/C6Kz/wuit/8Porf/E6a7/xemv/9Hrwszx8fGC8fHxgvHx8YLx8fGC8fHxgvX1
9TEAAAAAAAAAAAAAAAAAAAAA7u7uO/Hx8YLx8fGC8fHxgvHx8YLx8fGC3e7SpMHoqfq/56j/v+eo/7/n
qP+/56j/v+eo/7/nqP+/56j/wOip/8Hoq//C6Kz/wuit/8Porf/O67zV8PHwhPHx8YLx8fGC8fHxgvHx
8YLy8vJ2////BQAAAAAAAAAAAAAAAAAAAACqqqoD8PDwafHx8YLx8fGC8fHxgvHx8YLx8fGC4O/YnMTo
ruy/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/wOip/8Hoq//C6Kz90uvEwe/x74Px8fGC8fHxgvHx
8YLx8fGC8fHxgvPz8ykAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADz8/MW8fHxfPHx8YLx8fGC8fHxgvHx
8YLx8fGC8PLuhdXtyLXF6bHlv+eo/7/nqP+/56j/v+eo/7/nqP/B6Kv0zeq8zOXv4JTx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLy8vJNAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADy8vIm8fHxgPHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLs8OmJ4e/Zm93u06Pf7def5+/hkvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxXf///wIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AADy8vIo8/PzffHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8VnMzMwFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAD29vYb8fHxbvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvPz83/v7+9BgICAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMwF8/PzQPLy8nnx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvPz84Hx8fFc9PT0GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////B/X19TLx8fFc8PDwevHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgPHx8Wv09PRE9PT0FwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAA7+/vEPb29hvw8PAj7+/vH/T09Be/v78EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////////////8B///wAA//wAAD/wAAAP4AAAB+AA
AAfAAAADwAAAA4AAAAGAAAABgAAAAYAAAAGAAAABgAAAAYAAAAGAAAADwAAAA8AAAAPAAAAH4AAAB+AA
AA/wAAAP+AAAH/gAAD/+AAB//wAB///AA///+B////////////8oAAAAEAAAACAAAAABACAAAAAAAAAE
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////CfDw8BH///8GAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgICAAu7u7i7x8fFe8PDwevHx8YLx8fGC8fHxgvDw
8Hvx8fFs7+/vT/Dw8CMAAAABAAAAAAAAAAAAAAAA5ubmCvLy8l/x8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8/PzZu7u7g8AAAAAAAAAAPHx8V3x8fGC8fHxgunv5o7Z7c200+vFytTs
xc7W7cnH2+7QueLu2qbu8OyH8fHxgvHx8YLx8fFu////BfHx8STx8fGC8fHxgtrtzq3D6a/8xemw/8bp
sv/I6rT/yeq2/8vruP/M67v/z+u++Nzu0bjx8fGC8fHxgu/v7zDx8fFI8fHxguzw6ojC56z3wuis/8Tp
rv/E6q3/weiq/8fqsv/J6rb/y+u5/8zru//N67z/6/HpjfHx8YLy8vJN8fHxXPHx8YLg79icv+eo/8Ho
qv+k4n//lOBo/5fhbf+a4XH/n+J5/7Pmlv/L67n/zOu7/+Xw353x8fGC8fHxXvHx8Vrx8fGC4O3Zm7/n
qP+/56j/nuF3/5HfZP+U4Gj/l+Ft/5ricf+x5pL/yeq3/8vruf/r8emN8fHxgu/v70/x8fFK8fHxguzw
6ojA6Kn8v+eo/6njiP+O3mD/kd9k/5Tgaf+X4W3/vuim/8jqtP/N67zr8fHxgvHx8YLy8vI68/PzK/Hx
8YLx8fGCx+m03L/nqP++6Kb/meBw/47eYP+S32X/q+SL/8XpsP/G6rL/1+zLvvHx8YLz8/OB8PDwEdXV
1Qbx8fF98fHxgt/t1Z/A56j9v+eo/7/nqP+656H/vuim/8Lorf/E6a7/yOq18Ovw6Yvx8fGC8vLyYwAA
AAAAAAAA8fHxR/Hx8YLx8fGC2O3NrMDnqfq/56j/v+eo/7/nqP/B6Kv/xumy7OTu3Zfx8fGC8/PzgfLy
8icAAAAAAAAAAP///wPz8/Nm8fHxgvHx8YLo7+SO0+zFuczquszM6bzJ1+zMru7w7Ibx8fGC8fHxgvHx
8UcAAAAAAAAAAAAAAAAAAAAA4+PjCfHx8Vzx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgfPz
80D///8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8/PzK/Ly8mDz8/N+8fHxgvHx8YLy8vJ68vLyUezs
7BsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAevr6w3j4+MJAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD8fwAA4AcAAMADAACAAQAAgAEAAIABAACAAQAAgAEAAIAB
AADAAwAAwAMAAOAHAADwDwAA/n8AAP//AAA=
</value>
</data>
</root>

@ -0,0 +1,65 @@
using System.IO;
using System.Reflection;
namespace UptimeKuma {
/**
* Current Directory using App location
*/
public class Directory {
private static string baseDir;
public static string FullPath(string path) {
return Path.Combine(GetBaseDir(), path);
}
public static string GetBaseDir() {
if (baseDir == null) {
baseDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
}
return baseDir;
}
public static bool Exists(string path) {
return System.IO.Directory.Exists(FullPath(path));
}
public static void Delete(string path, bool recursive) {
System.IO.Directory.Delete(FullPath(path), recursive);
}
public static void Move(string src, string dest) {
System.IO.Directory.Move(FullPath(src), FullPath(dest));
}
public static string[] GetDirectories(string path) {
return System.IO.Directory.GetDirectories(FullPath(path));
}
}
public class File {
private static string FullPath(string path) {
return Directory.FullPath(path);
}
public static bool Exists(string path) {
return System.IO.File.Exists(FullPath(path));
}
public static FileStream Create(string path) {
return System.IO.File.Create(FullPath(path));
}
public static string ReadAllText(string path) {
return System.IO.File.ReadAllText(FullPath(path));
}
public static void Delete(string path) {
System.IO.File.Delete(FullPath(path));
}
public static void WriteAllText(string path, string content) {
System.IO.File.WriteAllText(FullPath(path), content);
}
}
}

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

@ -0,0 +1,141 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
<xs:element name="Weavers">
<xs:complexType>
<xs:all>
<xs:element name="Costura" minOccurs="0" maxOccurs="1">
<xs:complexType>
<xs:all>
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="IncludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="IncludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged32Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged 32 bit assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged 64 bit assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="PreloadOrder" type="xs:string">
<xs:annotation>
<xs:documentation>The order of preloaded assemblies, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
</xs:all>
<xs:attribute name="CreateTemporaryAssemblies" type="xs:boolean">
<xs:annotation>
<xs:documentation>This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeDebugSymbols" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls if .pdbs for reference assemblies are also embedded.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeRuntimeReferences" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls if runtime assemblies are also embedded.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UseRuntimeReferencePaths" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls whether the runtime assemblies are embedded with their full path or only with their assembly name.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableCompression" type="xs:boolean">
<xs:annotation>
<xs:documentation>Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableCleanup" type="xs:boolean">
<xs:annotation>
<xs:documentation>As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="LoadAtModuleInit" type="xs:boolean">
<xs:annotation>
<xs:documentation>Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IgnoreSatelliteAssemblies" type="xs:boolean">
<xs:annotation>
<xs:documentation>Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="ExcludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="ExcludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="Unmanaged32Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged 32 bit assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="Unmanaged64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged 64 bit assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="PreloadOrder" type="xs:string">
<xs:annotation>
<xs:documentation>The order of preloaded assemblies, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation>
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation>
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="GenerateXsd" type="xs:boolean">
<xs:annotation>
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:schema>

@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Net;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Windows.Forms;
using Microsoft.Win32;
using Newtonsoft.Json;
using UptimeKuma.Properties;
namespace UptimeKuma {
static class Program {
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main(string[] args) {
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new UptimeKumaApplicationContext());
}
}
public class UptimeKumaApplicationContext : ApplicationContext
{
const string appName = "Uptime Kuma";
private NotifyIcon trayIcon;
private Process process;
private MenuItem runWhenStarts;
private RegistryKey registryKey = Registry.CurrentUser.OpenSubKey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", true);
public UptimeKumaApplicationContext()
{
trayIcon = new NotifyIcon();
runWhenStarts = new MenuItem("Run when system starts", RunWhenStarts);
runWhenStarts.Checked = registryKey.GetValue(appName) != null;
trayIcon.Icon = Icon.ExtractAssociatedIcon(Assembly.GetExecutingAssembly().Location);
trayIcon.ContextMenu = new ContextMenu(new MenuItem[] {
new("Open", Open),
//new("Debug Console", DebugConsole),
runWhenStarts,
new("Check for Update...", CheckForUpdate),
new("Visit GitHub...", VisitGitHub),
new("About", About),
new("Exit", Exit),
});
trayIcon.MouseDoubleClick += new MouseEventHandler(Open);
trayIcon.Visible = true;
var hasUpdateFile = File.Exists("update");
if (!hasUpdateFile && Directory.Exists("core") && Directory.Exists("node") && Directory.Exists("core/node_modules") && Directory.Exists("core/dist")) {
// Go go go
StartProcess();
} else {
DownloadFiles();
}
}
void DownloadFiles() {
var form = new DownloadForm();
form.Closed += Exit;
form.Show();
}
private void RunWhenStarts(object sender, EventArgs e) {
if (registryKey == null) {
MessageBox.Show("Error: Unable to set startup registry key.");
return;
}
if (runWhenStarts.Checked) {
registryKey.DeleteValue(appName, false);
runWhenStarts.Checked = false;
} else {
registryKey.SetValue(appName, Application.ExecutablePath);
runWhenStarts.Checked = true;
}
}
void StartProcess() {
var startInfo = new ProcessStartInfo {
FileName = "node/node.exe",
Arguments = "server/server.js --data-dir=\"../data/\"",
RedirectStandardOutput = false,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = "core"
};
process = new Process();
process.StartInfo = startInfo;
process.EnableRaisingEvents = true;
process.Exited += ProcessExited;
try {
process.Start();
//Open(null, null);
} catch (Exception e) {
MessageBox.Show("Startup failed: " + e.Message, "Uptime Kuma Error");
}
}
void StopProcess() {
process?.Kill();
}
void Open(object sender, EventArgs e) {
Process.Start("http://localhost:3001");
}
void DebugConsole(object sender, EventArgs e) {
}
void CheckForUpdate(object sender, EventArgs e) {
var needUpdate = false;
// Check version.json exists
if (File.Exists("version.json")) {
// Load version.json and compare with the latest version from GitHub
var currentVersionObj = JsonConvert.DeserializeObject<Version>(File.ReadAllText("version.json"));
var versionJson = new WebClient().DownloadString("https://uptime.kuma.pet/version");
var latestVersionObj = JsonConvert.DeserializeObject<Version>(versionJson);
// Compare version, if the latest version is newer, then update
if (new System.Version(latestVersionObj.latest).CompareTo(new System.Version(currentVersionObj.latest)) > 0) {
var result = MessageBox.Show("A new version is available. Do you want to update?", "Update", MessageBoxButtons.YesNo);
if (result == DialogResult.Yes) {
// Create a empty file `update`, so the app will download the core files again at startup
File.Create("update").Close();
trayIcon.Visible = false;
process?.Kill();
// Restart the app, it will download the core files again at startup
Application.Restart();
}
} else {
MessageBox.Show("You are using the latest version.");
}
}
}
void VisitGitHub(object sender, EventArgs e)
{
Process.Start("https://github.com/louislam/uptime-kuma");
}
void About(object sender, EventArgs e)
{
MessageBox.Show("Uptime Kuma Windows Runtime v1.0.0" + Environment.NewLine + "© 2023 Louis Lam", "Info");
}
void Exit(object sender, EventArgs e)
{
// Hide tray icon, otherwise it will remain shown until user mouses over it
trayIcon.Visible = false;
process?.Kill();
Application.Exit();
}
void ProcessExited(object sender, EventArgs e) {
if (process.ExitCode != 0) {
var line = "";
while (!process.StandardOutput.EndOfStream)
{
line += process.StandardOutput.ReadLine();
}
MessageBox.Show("Uptime Kuma exited unexpectedly. Exit code: " + process.ExitCode + " " + line);
}
trayIcon.Visible = false;
Application.Exit();
}
}
}

@ -0,0 +1,36 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("Uptime Kuma")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("Uptime Kuma")]
[assembly: AssemblyCopyright("Copyright © 2022 Louis Lam")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("2DB53988-1D93-4AC0-90C4-96ADEAAC5C04")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

@ -0,0 +1,62 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace UptimeKuma.Properties {
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder",
"4.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance",
"CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState
.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if ((resourceMan == null)) {
global::System.Resources.ResourceManager temp =
new global::System.Resources.ResourceManager("UptimeKuma.Properties.Resources",
typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState
.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get { return resourceCulture; }
set { resourceCulture = value; }
}
}
}

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

@ -0,0 +1,23 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace UptimeKuma.Properties {
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute(
"Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")]
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
private static Settings defaultInstance =
((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
public static Settings Default {
get { return defaultInstance; }
}
}
}

@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)">
<Profiles>
<Profile Name="(Default)" />
</Profiles>
<Settings />
</SettingsFile>

@ -0,0 +1,213 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="packages\Costura.Fody.5.7.0\build\Costura.Fody.props" Condition="Exists('packages\Costura.Fody.5.7.0\build\Costura.Fody.props')" />
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{2DB53988-1D93-4AC0-90C4-96ADEAAC5C04}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>UptimeKuma</RootNamespace>
<AssemblyName>uptime-kuma</AssemblyName>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
<ApplicationIcon>..\..\public\favicon.ico</ApplicationIcon>
<LangVersion>9</LangVersion>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<PropertyGroup>
<PostBuildEvent>COPY "$(SolutionDir)bin\Debug\uptime-kuma.exe" "%UserProfile%\Desktop\uptime-kuma-win64\"</PostBuildEvent>
</PropertyGroup>
<ItemGroup>
<Reference Include="Costura, Version=5.7.0.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>packages\Costura.Fody.5.7.0\lib\netstandard1.0\Costura.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Win32.Primitives, Version=4.0.2.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\Microsoft.Win32.Primitives.4.3.0\lib\net46\Microsoft.Win32.Primitives.dll</HintPath>
</Reference>
<Reference Include="mscorlib" />
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>packages\Newtonsoft.Json.13.0.2\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.AppContext, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.AppContext.4.3.0\lib\net463\System.AppContext.dll</HintPath>
</Reference>
<Reference Include="System.Buffers, Version=4.0.3.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
<HintPath>packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll</HintPath>
</Reference>
<Reference Include="System.ComponentModel.Composition" />
<Reference Include="System.Console, Version=4.0.1.1, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Console.4.3.1\lib\net46\System.Console.dll</HintPath>
</Reference>
<Reference Include="System.Core" />
<Reference Include="System.Diagnostics.DiagnosticSource, Version=7.0.0.1, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
<HintPath>packages\System.Diagnostics.DiagnosticSource.7.0.1\lib\net462\System.Diagnostics.DiagnosticSource.dll</HintPath>
</Reference>
<Reference Include="System.Diagnostics.Tracing, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Diagnostics.Tracing.4.3.0\lib\net462\System.Diagnostics.Tracing.dll</HintPath>
</Reference>
<Reference Include="System.Globalization.Calendars, Version=4.0.2.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Globalization.Calendars.4.3.0\lib\net46\System.Globalization.Calendars.dll</HintPath>
</Reference>
<Reference Include="System.IO, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.IO.4.3.0\lib\net462\System.IO.dll</HintPath>
</Reference>
<Reference Include="System.IO.Compression, Version=4.1.2.0, Culture=neutral, PublicKeyToken=b77a5c561934e089, processorArchitecture=MSIL">
<HintPath>packages\System.IO.Compression.4.3.0\lib\net46\System.IO.Compression.dll</HintPath>
</Reference>
<Reference Include="System.IO.Compression.FileSystem" />
<Reference Include="System.IO.Compression.ZipFile, Version=4.0.2.0, Culture=neutral, PublicKeyToken=b77a5c561934e089, processorArchitecture=MSIL">
<HintPath>packages\System.IO.Compression.ZipFile.4.3.0\lib\net46\System.IO.Compression.ZipFile.dll</HintPath>
</Reference>
<Reference Include="System.IO.FileSystem, Version=4.0.2.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.IO.FileSystem.4.3.0\lib\net46\System.IO.FileSystem.dll</HintPath>
</Reference>
<Reference Include="System.IO.FileSystem.Primitives, Version=4.0.2.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.IO.FileSystem.Primitives.4.3.0\lib\net46\System.IO.FileSystem.Primitives.dll</HintPath>
</Reference>
<Reference Include="System.Linq, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Linq.4.3.0\lib\net463\System.Linq.dll</HintPath>
</Reference>
<Reference Include="System.Linq.Expressions, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Linq.Expressions.4.3.0\lib\net463\System.Linq.Expressions.dll</HintPath>
</Reference>
<Reference Include="System.Memory, Version=4.0.1.2, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
<HintPath>packages\System.Memory.4.5.5\lib\net461\System.Memory.dll</HintPath>
</Reference>
<Reference Include="System.Net.Http, Version=4.1.1.3, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Net.Http.4.3.4\lib\net46\System.Net.Http.dll</HintPath>
</Reference>
<Reference Include="System.Net.Sockets, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Net.Sockets.4.3.0\lib\net46\System.Net.Sockets.dll</HintPath>
</Reference>
<Reference Include="System.Numerics" />
<Reference Include="System.Numerics.Vectors, Version=4.1.4.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll</HintPath>
</Reference>
<Reference Include="System.Reflection, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Reflection.4.3.0\lib\net462\System.Reflection.dll</HintPath>
</Reference>
<Reference Include="System.Runtime, Version=4.1.1.1, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Runtime.4.3.1\lib\net462\System.Runtime.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.CompilerServices.Unsafe, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Runtime.CompilerServices.Unsafe.6.0.0\lib\net461\System.Runtime.CompilerServices.Unsafe.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.Extensions, Version=4.1.1.1, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Runtime.Extensions.4.3.1\lib\net462\System.Runtime.Extensions.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.InteropServices, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Runtime.InteropServices.4.3.0\lib\net463\System.Runtime.InteropServices.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.InteropServices.RuntimeInformation, Version=4.0.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Runtime.InteropServices.RuntimeInformation.4.3.0\lib\net45\System.Runtime.InteropServices.RuntimeInformation.dll</HintPath>
</Reference>
<Reference Include="System.Security.Cryptography.Algorithms, Version=4.2.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Security.Cryptography.Algorithms.4.3.1\lib\net463\System.Security.Cryptography.Algorithms.dll</HintPath>
</Reference>
<Reference Include="System.Security.Cryptography.Encoding, Version=4.0.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Security.Cryptography.Encoding.4.3.0\lib\net46\System.Security.Cryptography.Encoding.dll</HintPath>
</Reference>
<Reference Include="System.Security.Cryptography.Primitives, Version=4.0.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Security.Cryptography.Primitives.4.3.0\lib\net46\System.Security.Cryptography.Primitives.dll</HintPath>
</Reference>
<Reference Include="System.Security.Cryptography.X509Certificates, Version=4.1.1.2, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Security.Cryptography.X509Certificates.4.3.2\lib\net461\System.Security.Cryptography.X509Certificates.dll</HintPath>
</Reference>
<Reference Include="System.Text.RegularExpressions, Version=4.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Text.RegularExpressions.4.3.1\lib\net463\System.Text.RegularExpressions.dll</HintPath>
</Reference>
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Deployment" />
<Reference Include="System.Drawing" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Xml" />
<Reference Include="System.Xml.ReaderWriter, Version=4.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Xml.ReaderWriter.4.3.1\lib\net46\System.Xml.ReaderWriter.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="DownloadForm.cs">
<SubType>Form</SubType>
</Compile>
<Compile Include="DownloadForm.Designer.cs">
<DependentUpon>DownloadForm.cs</DependentUpon>
</Compile>
<Compile Include="FS.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Version.cs" />
<EmbeddedResource Include="DownloadForm.resx">
<DependentUpon>DownloadForm.cs</DependentUpon>
</EmbeddedResource>
<EmbeddedResource Include="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
<SubType>Designer</SubType>
</EmbeddedResource>
<Compile Include="Properties\Resources.Designer.cs">
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<None Include="..\..\public\favicon.ico">
<Link>favicon.ico</Link>
</None>
<None Include="packages.config" />
<None Include="Properties\Settings.settings">
<Generator>SettingsSingleFileGenerator</Generator>
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
</None>
<Compile Include="Properties\Settings.Designer.cs">
<AutoGen>True</AutoGen>
<DependentUpon>Settings.settings</DependentUpon>
<DesignTimeSharedInput>True</DesignTimeSharedInput>
</Compile>
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
</ItemGroup>
<ItemGroup>
<Content Include=".gitignore" />
<Content Include="app.manifest" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
<PropertyGroup>
<ErrorText>This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105.The missing file is {0}.</ErrorText>
</PropertyGroup>
<Error Condition="!Exists('packages\Costura.Fody.5.7.0\build\Costura.Fody.props')" Text="$([System.String]::Format('$(ErrorText)', 'packages\Costura.Fody.5.7.0\build\Costura.Fody.props'))" />
<Error Condition="!Exists('packages\Costura.Fody.5.7.0\build\Costura.Fody.targets')" Text="$([System.String]::Format('$(ErrorText)', 'packages\Costura.Fody.5.7.0\build\Costura.Fody.targets'))" />
<Error Condition="!Exists('packages\Fody.6.6.4\build\Fody.targets')" Text="$([System.String]::Format('$(ErrorText)', 'packages\Fody.6.6.4\build\Fody.targets'))" />
<Error Condition="!Exists('packages\NETStandard.Library.2.0.3\build\netstandard2.0\NETStandard.Library.targets')" Text="$([System.String]::Format('$(ErrorText)', 'packages\NETStandard.Library.2.0.3\build\netstandard2.0\NETStandard.Library.targets'))" />
</Target>
<Import Project="packages\Costura.Fody.5.7.0\build\Costura.Fody.targets" Condition="Exists('packages\Costura.Fody.5.7.0\build\Costura.Fody.targets')" />
<Import Project="packages\Fody.6.6.4\build\Fody.targets" Condition="Exists('packages\Fody.6.6.4\build\Fody.targets')" />
<Import Project="packages\NETStandard.Library.2.0.3\build\netstandard2.0\NETStandard.Library.targets" Condition="Exists('packages\NETStandard.Library.2.0.3\build\netstandard2.0\NETStandard.Library.targets')" />
</Project>

@ -0,0 +1,16 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UptimeKuma", "UptimeKuma.csproj", "{2DB53988-1D93-4AC0-90C4-96ADEAAC5C04}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2DB53988-1D93-4AC0-90C4-96ADEAAC5C04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2DB53988-1D93-4AC0-90C4-96ADEAAC5C04}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2DB53988-1D93-4AC0-90C4-96ADEAAC5C04}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2DB53988-1D93-4AC0-90C4-96ADEAAC5C04}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

@ -0,0 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=UptimeKuma_002FProperties_002FResources/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/ResxEditorPersonal/Initialized/@EntryValue">True</s:Boolean></wpf:ResourceDictionary>

@ -0,0 +1,9 @@
namespace UptimeKuma {
public class Version {
public string latest { get; set; }
public string slow { get; set; }
public string beta { get; set; }
public string nodejs { get; set; }
public string exe { get; set; }
}
}

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- UAC Manifest Options
If you want to change the Windows User Account Control level replace the
requestedExecutionLevel node with one of the following.
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
Specifying requestedExecutionLevel element will disable file and registry virtualization.
Remove this element if your application requires this virtualization for backwards
compatibility.
-->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Costura.Fody" version="5.7.0" targetFramework="net472" developmentDependency="true" />
<package id="Fody" version="6.6.4" targetFramework="net472" developmentDependency="true" />
<package id="Microsoft.NETCore.Platforms" version="7.0.0" targetFramework="net472" />
<package id="Microsoft.Win32.Primitives" version="4.3.0" targetFramework="net472" />
<package id="NETStandard.Library" version="2.0.3" targetFramework="net472" />
<package id="Newtonsoft.Json" version="13.0.2" targetFramework="net472" />
<package id="System.AppContext" version="4.3.0" targetFramework="net472" />
<package id="System.Console" version="4.3.1" targetFramework="net472" />
<package id="System.Diagnostics.DiagnosticSource" version="7.0.1" targetFramework="net472" />
<package id="System.Net.Http" version="4.3.4" targetFramework="net472" />
<package id="System.Runtime.Extensions" version="4.3.1" targetFramework="net472" />
<package id="System.Security.Cryptography.Algorithms" version="4.3.1" targetFramework="net472" />
<package id="System.Security.Cryptography.X509Certificates" version="4.3.2" targetFramework="net472" />
<package id="System.Text.RegularExpressions" version="4.3.1" targetFramework="net472" />
<package id="System.Xml.ReaderWriter" version="4.3.1" targetFramework="net472" />
<package id="System.Memory" version="4.5.5" targetFramework="net472" />
<package id="System.Net.Primitives" version="4.3.1" targetFramework="net472" />
<package id="System.Runtime" version="4.3.1" targetFramework="net472" />
<package id="System.Buffers" version="4.5.1" targetFramework="net472" />
<package id="System.Collections" version="4.3.0" targetFramework="net472" />
<package id="System.Collections.Concurrent" version="4.3.0" targetFramework="net472" />
<package id="System.Diagnostics.Debug" version="4.3.0" targetFramework="net472" />
<package id="System.Diagnostics.Tools" version="4.3.0" targetFramework="net472" />
<package id="System.Diagnostics.Tracing" version="4.3.0" targetFramework="net472" />
<package id="System.Globalization" version="4.3.0" targetFramework="net472" />
<package id="System.Globalization.Calendars" version="4.3.0" targetFramework="net472" />
<package id="System.IO" version="4.3.0" targetFramework="net472" />
<package id="System.IO.Compression" version="4.3.0" targetFramework="net472" />
<package id="System.IO.Compression.ZipFile" version="4.3.0" targetFramework="net472" />
<package id="System.IO.FileSystem" version="4.3.0" targetFramework="net472" />
<package id="System.IO.FileSystem.Primitives" version="4.3.0" targetFramework="net472" />
<package id="System.Linq" version="4.3.0" targetFramework="net472" />
<package id="System.Linq.Expressions" version="4.3.0" targetFramework="net472" />
<package id="System.Net.Sockets" version="4.3.0" targetFramework="net472" />
<package id="System.Numerics.Vectors" version="4.5.0" targetFramework="net472" />
<package id="System.ObjectModel" version="4.3.0" targetFramework="net472" />
<package id="System.Reflection" version="4.3.0" targetFramework="net472" />
<package id="System.Reflection.Extensions" version="4.3.0" targetFramework="net472" />
<package id="System.Reflection.Primitives" version="4.3.0" targetFramework="net472" />
<package id="System.Resources.ResourceManager" version="4.3.0" targetFramework="net472" />
<package id="System.Runtime.CompilerServices.Unsafe" version="6.0.0" targetFramework="net472" />
<package id="System.Runtime.Handles" version="4.3.0" targetFramework="net472" />
<package id="System.Runtime.InteropServices" version="4.3.0" targetFramework="net472" />
<package id="System.Runtime.InteropServices.RuntimeInformation" version="4.3.0" targetFramework="net472" />
<package id="System.Runtime.Numerics" version="4.3.0" targetFramework="net472" />
<package id="System.Security.Cryptography.Encoding" version="4.3.0" targetFramework="net472" />
<package id="System.Security.Cryptography.Primitives" version="4.3.0" targetFramework="net472" />
<package id="System.Text.Encoding" version="4.3.0" targetFramework="net472" />
<package id="System.Text.Encoding.Extensions" version="4.3.0" targetFramework="net472" />
<package id="System.Threading" version="4.3.0" targetFramework="net472" />
<package id="System.Threading.Tasks" version="4.3.0" targetFramework="net472" />
<package id="System.Threading.Timer" version="4.3.0" targetFramework="net472" />
<package id="System.Xml.XDocument" version="4.3.0" targetFramework="net472" />
</packages>

@ -1,3 +1,7 @@
/*
* If changed, have to run `npm run build-docker-builder-go`.
* This script should be run after a period of time (180s), because the server may need some time to prepare.
*/
package main package main
import ( import (
@ -7,12 +11,17 @@ import (
"net/http" "net/http"
"os" "os"
"runtime" "runtime"
"strings"
"time" "time"
) )
func main() { func main() {
isFreeBSD := runtime.GOOS == "freebsd" isFreeBSD := runtime.GOOS == "freebsd"
// Is K8S + uptime-kuma as the container name
// See #2083
isK8s := strings.HasPrefix(os.Getenv("UPTIME_KUMA_PORT"), "tcp://")
// process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{ http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{
InsecureSkipVerify: true, InsecureSkipVerify: true,
@ -40,7 +49,11 @@ func main() {
hostname = "127.0.0.1" hostname = "127.0.0.1"
} }
port := os.Getenv("UPTIME_KUMA_PORT") port := ""
// UPTIME_KUMA_PORT is override by K8S unexpectedly,
if !isK8s {
port = os.Getenv("UPTIME_KUMA_PORT")
}
if len(port) == 0 { if len(port) == 0 {
port = os.Getenv("PORT") port = os.Getenv("PORT")
} }

@ -1,4 +1,8 @@
/* /*
* Due to the weird issue in Portainer that the healthcheck script is still pointing to this script for unknown reason.
* IT CANNOT BE DROPPED, even though it looks like it is not used.
* See more: https://github.com/louislam/uptime-kuma/issues/2774#issuecomment-1429092359
*
* Deprecated: Changed to healthcheck.go, it will be deleted in the future. * Deprecated: Changed to healthcheck.go, it will be deleted in the future.
* This script should be run after a period of time (180s), because the server may need some time to prepare. * This script should be run after a period of time (180s), because the server may need some time to prepare.
*/ */

@ -1,11 +1,12 @@
const pkg = require("../package.json"); const pkg = require("../package.json");
const fs = require("fs"); const fs = require("fs");
const util = require("../src/util"); const util = require("../src/util");
const dayjs = require("dayjs");
util.polyfill(); util.polyfill();
const oldVersion = pkg.version; const oldVersion = pkg.version;
const newVersion = oldVersion + "-nightly-" + util.genSecret(8); const newVersion = oldVersion + "-nightly-" + dayjs().format("YYYYMMDDHHmmss");
console.log("Old Version: " + oldVersion); console.log("Old Version: " + oldVersion);
console.log("New Version: " + newVersion); console.log("New Version: " + newVersion);

@ -43,6 +43,11 @@ const main = async () => {
console.log("Finished."); console.log("Finished.");
}; };
/**
* Ask question of user
* @param {string} question Question to ask
* @returns {Promise<string>} Users response
*/
function question(question) { function question(question) {
return new Promise((resolve) => { return new Promise((resolve) => {
rl.question(question, (answer) => { rl.question(question, (answer) => {

@ -53,6 +53,11 @@ const main = async () => {
console.log("Finished."); console.log("Finished.");
}; };
/**
* Ask question of user
* @param {string} question Question to ask
* @returns {Promise<string>} Users response
*/
function question(question) { function question(question) {
return new Promise((resolve) => { return new Promise((resolve) => {
rl.question(question, (answer) => { rl.question(question, (answer) => {

@ -135,6 +135,11 @@ server.listen({
udp: 5300 udp: 5300
}); });
/**
* Get human readable request type from request code
* @param {number} code Request code to translate
* @returns {string} Human readable request type
*/
function type(code) { function type(code) {
for (let name in Packet.TYPE) { for (let name in Packet.TYPE) {
if (Packet.TYPE[name] === code) { if (Packet.TYPE[name] === code) {

@ -11,6 +11,7 @@ class SimpleMqttServer {
this.port = port; this.port = port;
} }
/** Start the MQTT server */
start() { start() {
this.server.listen(this.port, () => { this.server.listen(this.port, () => {
console.log("server started and listening on port ", this.port); console.log("server started and listening on port ", this.port);

@ -26,7 +26,8 @@ if (! exists) {
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n"); fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n");
// Also update package-lock.json // Also update package-lock.json
childProcess.spawnSync("npm", [ "install" ]); const npm = /^win/.test(process.platform) ? "npm.cmd" : "npm";
childProcess.spawnSync(npm, [ "install" ]);
commit(newVersion); commit(newVersion);
tag(newVersion); tag(newVersion);
@ -36,10 +37,8 @@ if (! exists) {
} }
/** /**
* Updates the version number in package.json and commits it to git. * Commit updated files
* @param {string} version - The new version number * @param {string} version Version to update to
*
* Generated by Trelent
*/ */
function commit(version) { function commit(version) {
let msg = "Update to " + version; let msg = "Update to " + version;
@ -53,16 +52,19 @@ function commit(version) {
} }
} }
/**
* Create a tag with the specified version
* @param {string} version Tag to create
*/
function tag(version) { function tag(version) {
let res = childProcess.spawnSync("git", [ "tag", version ]); let res = childProcess.spawnSync("git", [ "tag", version ]);
console.log(res.stdout.toString().trim()); console.log(res.stdout.toString().trim());
} }
/** /**
* Checks if a given version is already tagged in the git repository. * Check if a tag exists for the specified version
* @param {string} version - The version to check for. * @param {string} version Version to check
* * @returns {boolean} Does the tag already exist
* Generated by Trelent
*/ */
function tagExists(version) { function tagExists(version) {
if (! version) { if (! version) {

@ -10,6 +10,10 @@ if (!newVersion) {
updateWiki(newVersion); updateWiki(newVersion);
/**
* Update the wiki with new version number
* @param {string} newVersion Version to update to
*/
function updateWiki(newVersion) { function updateWiki(newVersion) {
const wikiDir = "./tmp/wiki"; const wikiDir = "./tmp/wiki";
const howToUpdateFilename = "./tmp/wiki/🆙-How-to-Update.md"; const howToUpdateFilename = "./tmp/wiki/🆙-How-to-Update.md";
@ -39,6 +43,10 @@ function updateWiki(newVersion) {
safeDelete(wikiDir); safeDelete(wikiDir);
} }
/**
* Check if a directory exists and then delete it
* @param {string} dir Directory to delete
*/
function safeDelete(dir) { function safeDelete(dir) {
if (fs.existsSync(dir)) { if (fs.existsSync(dir)) {
fs.rm(dir, { fs.rm(dir, {

28169
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
{ {
"name": "uptime-kuma", "name": "uptime-kuma",
"version": "1.19.2", "version": "1.20.1",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
@ -31,6 +31,7 @@
"build-docker": "npm run build && npm run build-docker-debian && npm run build-docker-alpine", "build-docker": "npm run build && npm run build-docker-debian && npm run build-docker-alpine",
"build-docker-alpine-base": "docker buildx build -f docker/alpine-base.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:base-alpine . --push", "build-docker-alpine-base": "docker buildx build -f docker/alpine-base.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:base-alpine . --push",
"build-docker-debian-base": "docker buildx build -f docker/debian-base.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:base-debian . --push", "build-docker-debian-base": "docker buildx build -f docker/debian-base.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:base-debian . --push",
"build-docker-builder-go": "docker buildx build -f docker/builder-go.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:builder-go . --push",
"build-docker-alpine": "node ./extra/env2arg.js docker buildx build -f docker/dockerfile-alpine --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:alpine -t louislam/uptime-kuma:1-alpine -t louislam/uptime-kuma:$VERSION-alpine --target release . --push", "build-docker-alpine": "node ./extra/env2arg.js docker buildx build -f docker/dockerfile-alpine --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:alpine -t louislam/uptime-kuma:1-alpine -t louislam/uptime-kuma:$VERSION-alpine --target release . --push",
"build-docker-debian": "node ./extra/env2arg.js docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:$VERSION -t louislam/uptime-kuma:debian -t louislam/uptime-kuma:1-debian -t louislam/uptime-kuma:$VERSION-debian --target release . --push", "build-docker-debian": "node ./extra/env2arg.js docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:$VERSION -t louislam/uptime-kuma:debian -t louislam/uptime-kuma:1-debian -t louislam/uptime-kuma:$VERSION-debian --target release . --push",
"build-docker-nightly": "npm run build && docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly --target nightly . --push", "build-docker-nightly": "npm run build && docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly --target nightly . --push",
@ -38,7 +39,7 @@
"build-docker-nightly-amd64": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain", "build-docker-nightly-amd64": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain",
"build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test --target pr-test . --push", "build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test --target pr-test . --push",
"upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain", "upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain",
"setup": "git checkout 1.19.2 && npm ci --production && npm run download-dist", "setup": "git checkout 1.20.1 && npm ci --production && npm run download-dist",
"download-dist": "node extra/download-dist.js", "download-dist": "node extra/download-dist.js",
"mark-as-nightly": "node extra/mark-as-nightly.js", "mark-as-nightly": "node extra/mark-as-nightly.js",
"reset-password": "node extra/reset-password.js", "reset-password": "node extra/reset-password.js",
@ -60,11 +61,14 @@
"start-pr-test": "node extra/checkout-pr.js && npm install && npm run dev", "start-pr-test": "node extra/checkout-pr.js && npm install && npm run dev",
"cy:test": "node test/prepare-test-server.js && node server/server.js --port=3002 --data-dir=./data/test/ --e2e", "cy:test": "node test/prepare-test-server.js && node server/server.js --port=3002 --data-dir=./data/test/ --e2e",
"cy:run": "npx cypress run --browser chrome --headless --config-file ./config/cypress.config.js", "cy:run": "npx cypress run --browser chrome --headless --config-file ./config/cypress.config.js",
"cy:run:unit": "npx cypress run --browser chrome --headless --config-file ./config/cypress.frontend.config.js",
"cypress-open": "concurrently -k -r \"node test/prepare-test-server.js && node server/server.js --port=3002 --data-dir=./data/test/\" \"cypress open --config-file ./config/cypress.config.js\"", "cypress-open": "concurrently -k -r \"node test/prepare-test-server.js && node server/server.js --port=3002 --data-dir=./data/test/\" \"cypress open --config-file ./config/cypress.config.js\"",
"build-healthcheck-armv7": "cross-env GOOS=linux GOARCH=arm GOARM=7 go build -x -o ./extra/healthcheck-armv7 ./extra/healthcheck.go" "build-healthcheck-armv7": "cross-env GOOS=linux GOARCH=arm GOARM=7 go build -x -o ./extra/healthcheck-armv7 ./extra/healthcheck.go",
"depoly-demo-server": "node extra/deploy-demo-server.js"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "~1.7.3", "@grpc/grpc-js": "~1.7.3",
"@louislam/ping": "~0.4.2-mod.1",
"@louislam/sqlite3": "15.1.2", "@louislam/sqlite3": "15.1.2",
"args-parser": "~1.3.0", "args-parser": "~1.3.0",
"axios": "~0.27.0", "axios": "~0.27.0",
@ -81,18 +85,21 @@
"compare-versions": "~3.6.0", "compare-versions": "~3.6.0",
"compression": "~1.7.4", "compression": "~1.7.4",
"dayjs": "~1.11.5", "dayjs": "~1.11.5",
"dotenv": "~16.0.3",
"express": "~4.17.3", "express": "~4.17.3",
"express-basic-auth": "~1.2.1", "express-basic-auth": "~1.2.1",
"express-static-gzip": "~2.1.7", "express-static-gzip": "~2.1.7",
"form-data": "~4.0.0", "form-data": "~4.0.0",
"gamedig": "^4.0.5",
"http-graceful-shutdown": "~3.1.7", "http-graceful-shutdown": "~3.1.7",
"http-proxy-agent": "~5.0.0", "http-proxy-agent": "~5.0.0",
"https-proxy-agent": "~5.0.1", "https-proxy-agent": "~5.0.1",
"iconv-lite": "~0.6.3", "iconv-lite": "~0.6.3",
"jsesc": "~3.0.2", "jsesc": "~3.0.2",
"jsonwebtoken": "~8.5.1", "jsonwebtoken": "~9.0.0",
"jwt-decode": "~3.1.2", "jwt-decode": "~3.1.2",
"limiter": "~2.1.0", "limiter": "~2.1.0",
"mongodb": "~4.13.0",
"mqtt": "~4.3.7", "mqtt": "~4.3.7",
"mssql": "~8.1.4", "mssql": "~8.1.4",
"mysql2": "~2.3.3", "mysql2": "~2.3.3",
@ -106,7 +113,9 @@
"prom-client": "~13.2.0", "prom-client": "~13.2.0",
"prometheus-api-metrics": "~3.2.1", "prometheus-api-metrics": "~3.2.1",
"protobufjs": "~7.1.1", "protobufjs": "~7.1.1",
"redbean-node": "0.1.4", "qs": "~6.10.4",
"redbean-node": "~0.2.0",
"redis": "~4.5.1",
"socket.io": "~4.5.3", "socket.io": "~4.5.3",
"socket.io-client": "~4.5.3", "socket.io-client": "~4.5.3",
"socks-proxy-agent": "6.1.1", "socks-proxy-agent": "6.1.1",
@ -139,10 +148,13 @@
"cypress": "^10.1.0", "cypress": "^10.1.0",
"delay": "^5.0.0", "delay": "^5.0.0",
"dns2": "~2.0.1", "dns2": "~2.0.1",
"dompurify": "~2.4.3",
"eslint": "~8.14.0", "eslint": "~8.14.0",
"eslint-plugin-vue": "~8.7.1", "eslint-plugin-vue": "~8.7.1",
"favico.js": "~0.3.10", "favico.js": "~0.3.10",
"jest": "~27.2.5", "jest": "~27.2.5",
"marked": "~4.2.5",
"node-ssh": "~13.0.1",
"postcss-html": "~1.5.0", "postcss-html": "~1.5.0",
"postcss-rtlcss": "~3.7.2", "postcss-rtlcss": "~3.7.2",
"postcss-scss": "~4.0.4", "postcss-scss": "~4.0.4",

@ -63,6 +63,12 @@ function myAuthorizer(username, password, callback) {
}); });
} }
/**
* Use basic auth if auth is not disabled
* @param {express.Request} req Express request object
* @param {express.Response} res Express response object
* @param {express.NextFunction} next
*/
exports.basicAuth = async function (req, res, next) { exports.basicAuth = async function (req, res, next) {
const middleware = basicAuth({ const middleware = basicAuth({
authorizer: myAuthorizer, authorizer: myAuthorizer,

@ -37,6 +37,10 @@ class CacheableDnsHttpAgent {
this.enable = isEnable; this.enable = isEnable;
} }
/**
* Attach cacheable to HTTP agent
* @param {http.Agent} agent Agent to install
*/
static install(agent) { static install(agent) {
this.cacheable.install(agent); this.cacheable.install(agent);
} }

@ -4,13 +4,21 @@ const demoMode = args["demo"] || false;
const badgeConstants = { const badgeConstants = {
naColor: "#999", naColor: "#999",
defaultUpColor: "#66c20a", defaultUpColor: "#66c20a",
defaultWarnColor: "#eed202",
defaultDownColor: "#c2290a", defaultDownColor: "#c2290a",
defaultPendingColor: "#f8a306",
defaultMaintenanceColor: "#1747f5",
defaultPingColor: "blue", // as defined by badge-maker / shields.io defaultPingColor: "blue", // as defined by badge-maker / shields.io
defaultStyle: "flat", defaultStyle: "flat",
defaultPingValueSuffix: "ms", defaultPingValueSuffix: "ms",
defaultPingLabelSuffix: "h", defaultPingLabelSuffix: "h",
defaultUptimeValueSuffix: "%", defaultUptimeValueSuffix: "%",
defaultUptimeLabelSuffix: "h", defaultUptimeLabelSuffix: "h",
defaultCertExpValueSuffix: " days",
defaultCertExpLabelSuffix: "h",
// Values Come From Default Notification Times
defaultCertExpireWarnDays: "14",
defaultCertExpireDownDays: "7"
}; };
module.exports = { module.exports = {

@ -4,6 +4,7 @@ const { setSetting, setting } = require("./util-server");
const { log, sleep } = require("../src/util"); const { log, sleep } = require("../src/util");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const knex = require("knex"); const knex = require("knex");
const { PluginsManager } = require("./plugins-manager");
/** /**
* Database & App Data Folder * Database & App Data Folder
@ -65,7 +66,10 @@ class Database {
"patch-grpc-monitor.sql": true, "patch-grpc-monitor.sql": true,
"patch-add-radius-monitor.sql": true, "patch-add-radius-monitor.sql": true,
"patch-monitor-add-resend-interval.sql": true, "patch-monitor-add-resend-interval.sql": true,
"patch-ping-packet-size.sql": true,
"patch-maintenance-table2.sql": true, "patch-maintenance-table2.sql": true,
"patch-add-gamedig-monitor.sql": true,
"patch-add-google-analytics-status-page-tag.sql": true,
}; };
/** /**
@ -83,6 +87,13 @@ class Database {
static init(args) { static init(args) {
// Data Directory (must be end with "/") // Data Directory (must be end with "/")
Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/"; Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/";
// Plugin feature is working only if the dataDir = "./data";
if (Database.dataDir !== "./data/") {
log.warn("PLUGIN", "Warning: In order to enable plugin feature, you need to use the default data directory: ./data/");
PluginsManager.disable = true;
}
Database.path = Database.dataDir + "kuma.db"; Database.path = Database.dataDir + "kuma.db";
if (! fs.existsSync(Database.dataDir)) { if (! fs.existsSync(Database.dataDir)) {
fs.mkdirSync(Database.dataDir, { recursive: true }); fs.mkdirSync(Database.dataDir, { recursive: true });
@ -485,6 +496,16 @@ class Database {
const shmPath = Database.path + "-shm"; const shmPath = Database.path + "-shm";
const walPath = Database.path + "-wal"; const walPath = Database.path + "-wal";
// Make sure we have a backup to restore before deleting old db
if (
!fs.existsSync(this.backupPath)
&& !fs.existsSync(shmPath)
&& !fs.existsSync(walPath)
) {
log.error("db", "Backup file not found! Leaving database in failed state.");
process.exit(1);
}
// Delete patch failed db // Delete patch failed db
try { try {
if (fs.existsSync(Database.path)) { if (fs.existsSync(Database.path)) {

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

@ -0,0 +1,24 @@
const jsesc = require("jsesc");
/**
* Returns a string that represents the javascript that is required to insert the Google Analytics scripts
* into a webpage.
* @param tagId Google UA/G/AW/DC Property ID to use with the Google Analytics script.
* @returns {string}
*/
function getGoogleAnalyticsScript(tagId) {
let escapedTagId = jsesc(tagId, { isScriptContext: true });
if (escapedTagId) {
escapedTagId = escapedTagId.trim();
}
return `
<script async src="https://www.googletagmanager.com/gtag/js?id=${escapedTagId}"></script>
<script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date());gtag('config', '${escapedTagId}'); </script>
`;
}
module.exports = {
getGoogleAnalyticsScript,
};

@ -32,6 +32,7 @@ const initBackgroundJobs = function (args) {
return bree; return bree;
}; };
/** Stop all background jobs if running */
const stopBackgroundJobs = function () { const stopBackgroundJobs = function () {
if (bree) { if (bree) {
bree.stop(); bree.stop();

@ -25,6 +25,10 @@ const DEFAULT_KEEP_PERIOD = 180;
parsedPeriod = DEFAULT_KEEP_PERIOD; parsedPeriod = DEFAULT_KEEP_PERIOD;
} }
if (parsedPeriod < 1) {
log(`Data deletion has been disabled as period is less than 1. Period is ${parsedPeriod} days.`);
} else {
log(`Clearing Data older than ${parsedPeriod} days...`); log(`Clearing Data older than ${parsedPeriod} days...`);
try { try {
@ -35,6 +39,7 @@ const DEFAULT_KEEP_PERIOD = 180;
} catch (e) { } catch (e) {
log(`Failed to clear old data: ${e.message}`); log(`Failed to clear old data: ${e.message}`);
} }
}
exit(); exit();
})(); })();

@ -112,6 +112,11 @@ class Maintenance extends BeanModel {
return this.toPublicJSON(timezone); return this.toPublicJSON(timezone);
} }
/**
* Get a list of weekdays that the maintenance is active for
* Monday=1, Tuesday=2 etc.
* @returns {number[]} Array of active weekdays
*/
getDayOfWeekList() { getDayOfWeekList() {
log.debug("timeslot", "List: " + this.weekdays); log.debug("timeslot", "List: " + this.weekdays);
return JSON.parse(this.weekdays).sort(function (a, b) { return JSON.parse(this.weekdays).sort(function (a, b) {
@ -119,12 +124,20 @@ class Maintenance extends BeanModel {
}); });
} }
/**
* Get a list of days in month that maintenance is active for
* @returns {number[]} Array of active days in month
*/
getDayOfMonthList() { getDayOfMonthList() {
return JSON.parse(this.days_of_month).sort(function (a, b) { return JSON.parse(this.days_of_month).sort(function (a, b) {
return a - b; return a - b;
}); });
} }
/**
* Get the start date and time for maintenance
* @returns {dayjs.Dayjs} Start date and time
*/
getStartDateTime() { getStartDateTime() {
let startOfTheDay = dayjs.utc(this.start_date).format("HH:mm"); let startOfTheDay = dayjs.utc(this.start_date).format("HH:mm");
log.debug("timeslot", "startOfTheDay: " + startOfTheDay); log.debug("timeslot", "startOfTheDay: " + startOfTheDay);
@ -137,6 +150,10 @@ class Maintenance extends BeanModel {
return dayjs.utc(this.start_date).add(startTimeSecond, "second"); return dayjs.utc(this.start_date).add(startTimeSecond, "second");
} }
/**
* Get the duraction of maintenance in seconds
* @returns {number} Duration of maintenance
*/
getDuration() { getDuration() {
let duration = dayjs.utc(this.end_time, "HH:mm").diff(dayjs.utc(this.start_time, "HH:mm"), "second"); let duration = dayjs.utc(this.end_time, "HH:mm").diff(dayjs.utc(this.start_time, "HH:mm"), "second");
// Add 24hours if it is across day // Add 24hours if it is across day
@ -146,6 +163,12 @@ class Maintenance extends BeanModel {
return duration; return duration;
} }
/**
* Convert data from socket to bean
* @param {Bean} bean Bean to fill in
* @param {Object} obj Data to fill bean with
* @returns {Bean} Filled bean
*/
static jsonToBean(bean, obj) { static jsonToBean(bean, obj) {
if (obj.id) { if (obj.id) {
bean.id = obj.id; bean.id = obj.id;

@ -6,6 +6,11 @@ const { UptimeKumaServer } = require("../uptime-kuma-server");
class MaintenanceTimeslot extends BeanModel { class MaintenanceTimeslot extends BeanModel {
/**
* Return an object that ready to parse to JSON for public
* Only show necessary data to public
* @returns {Object}
*/
async toPublicJSON() { async toPublicJSON() {
const serverTimezoneOffset = UptimeKumaServer.getInstance().getTimezoneOffset(); const serverTimezoneOffset = UptimeKumaServer.getInstance().getTimezoneOffset();
@ -21,6 +26,10 @@ class MaintenanceTimeslot extends BeanModel {
return obj; return obj;
} }
/**
* Return an object that ready to parse to JSON
* @returns {Object}
*/
async toJSON() { async toJSON() {
return await this.toPublicJSON(); return await this.toPublicJSON();
} }

@ -3,7 +3,9 @@ const dayjs = require("dayjs");
const axios = require("axios"); const axios = require("axios");
const { Prometheus } = require("../prometheus"); const { Prometheus } = require("../prometheus");
const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND } = require("../../src/util"); const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND } = require("../../src/util");
const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery } = require("../util-server"); const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery,
redisPingAsync, mongodbPing,
} = require("../util-server");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model"); const { BeanModel } = require("redbean-node/dist/bean-model");
const { Notification } = require("../notification"); const { Notification } = require("../notification");
@ -16,6 +18,7 @@ const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent");
const { DockerHost } = require("../docker"); const { DockerHost } = require("../docker");
const Maintenance = require("./maintenance"); const Maintenance = require("./maintenance");
const { UptimeCacheList } = require("../uptime-cache-list"); const { UptimeCacheList } = require("../uptime-cache-list");
const Gamedig = require("gamedig");
/** /**
* status: * status:
@ -36,7 +39,6 @@ class Monitor extends BeanModel {
id: this.id, id: this.id,
name: this.name, name: this.name,
sendUrl: this.sendUrl, sendUrl: this.sendUrl,
maintenance: await Monitor.isUnderMaintenance(this.id),
}; };
if (this.sendUrl) { if (this.sendUrl) {
@ -85,6 +87,7 @@ class Monitor extends BeanModel {
expiryNotification: this.isEnabledExpiryNotification(), expiryNotification: this.isEnabledExpiryNotification(),
ignoreTls: this.getIgnoreTls(), ignoreTls: this.getIgnoreTls(),
upsideDown: this.isUpsideDown(), upsideDown: this.isUpsideDown(),
packetSize: this.packetSize,
maxredirects: this.maxredirects, maxredirects: this.maxredirects,
accepted_statuscodes: this.getAcceptedStatuscodes(), accepted_statuscodes: this.getAcceptedStatuscodes(),
dns_resolve_type: this.dns_resolve_type, dns_resolve_type: this.dns_resolve_type,
@ -107,6 +110,7 @@ class Monitor extends BeanModel {
grpcEnableTls: this.getGrpcEnableTls(), grpcEnableTls: this.getGrpcEnableTls(),
radiusCalledStationId: this.radiusCalledStationId, radiusCalledStationId: this.radiusCalledStationId,
radiusCallingStationId: this.radiusCallingStationId, radiusCallingStationId: this.radiusCallingStationId,
game: this.game,
}; };
if (includeSensitiveData) { if (includeSensitiveData) {
@ -139,7 +143,7 @@ class Monitor extends BeanModel {
* @returns {Promise<LooseObject<any>[]>} * @returns {Promise<LooseObject<any>[]>}
*/ */
async getTags() { async getTags() {
return await R.getAll("SELECT mt.*, tag.name, tag.color FROM monitor_tag mt JOIN tag ON mt.tag_id = tag.id WHERE mt.monitor_id = ?", [ this.id ]); return await R.getAll("SELECT mt.*, tag.name, tag.color FROM monitor_tag mt JOIN tag ON mt.tag_id = tag.id WHERE mt.monitor_id = ? ORDER BY tag.name", [ this.id ]);
} }
/** /**
@ -199,7 +203,7 @@ class Monitor extends BeanModel {
let previousBeat = null; let previousBeat = null;
let retries = 0; let retries = 0;
let prometheus = new Prometheus(this); this.prometheus = new Prometheus(this);
const beat = async () => { const beat = async () => {
@ -372,7 +376,7 @@ class Monitor extends BeanModel {
bean.status = UP; bean.status = UP;
} else if (this.type === "ping") { } else if (this.type === "ping") {
bean.ping = await ping(this.hostname); bean.ping = await ping(this.hostname, this.packetSize);
bean.msg = ""; bean.msg = "";
bean.status = UP; bean.status = UP;
} else if (this.type === "dns") { } else if (this.type === "dns") {
@ -482,25 +486,44 @@ class Monitor extends BeanModel {
bean.msg = res.data.response.servers[0].name; bean.msg = res.data.response.servers[0].name;
try { try {
bean.ping = await ping(this.hostname); bean.ping = await ping(this.hostname, this.packetSize);
} catch (_) { } } catch (_) { }
} else { } else {
throw new Error("Server not found on Steam"); throw new Error("Server not found on Steam");
} }
} else if (this.type === "gamedig") {
try {
const state = await Gamedig.query({
type: this.game,
host: this.hostname,
port: this.port,
givenPortOnly: true,
});
bean.msg = state.name;
bean.status = UP;
bean.ping = state.ping;
} catch (e) {
throw new Error(e.message);
}
} else if (this.type === "docker") { } else if (this.type === "docker") {
log.debug(`[${this.name}] Prepare Options for Axios`); log.debug("monitor", `[${this.name}] Prepare Options for Axios`);
const dockerHost = await R.load("docker_host", this.docker_host); const dockerHost = await R.load("docker_host", this.docker_host);
const options = { const options = {
url: `/containers/${this.docker_container}/json`, url: `/containers/${this.docker_container}/json`,
timeout: this.interval * 1000 * 0.8,
headers: { headers: {
"Accept": "*/*", "Accept": "*/*",
"User-Agent": "Uptime-Kuma/" + version, "User-Agent": "Uptime-Kuma/" + version,
}, },
httpsAgent: new https.Agent({ httpsAgent: CacheableDnsHttpAgent.getHttpsAgent({
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
rejectUnauthorized: ! this.getIgnoreTls(), rejectUnauthorized: !this.getIgnoreTls(),
}),
httpAgent: CacheableDnsHttpAgent.getHttpAgent({
maxCachedSessions: 0,
}), }),
}; };
@ -510,11 +533,13 @@ class Monitor extends BeanModel {
options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon); options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon);
} }
log.debug(`[${this.name}] Axios Request`); log.debug("monitor", `[${this.name}] Axios Request`);
let res = await axios.request(options); let res = await axios.request(options);
if (res.data.State.Running) { if (res.data.State.Running) {
bean.status = UP; bean.status = UP;
bean.msg = ""; bean.msg = res.data.State.Status;
} else {
throw Error("Container State is " + res.data.State.Status);
} }
} else if (this.type === "mqtt") { } else if (this.type === "mqtt") {
bean.msg = await mqttAsync(this.hostname, this.mqttTopic, this.mqttSuccessMessage, { bean.msg = await mqttAsync(this.hostname, this.mqttTopic, this.mqttSuccessMessage, {
@ -548,7 +573,7 @@ class Monitor extends BeanModel {
log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`); log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`);
let responseData = response.data; let responseData = response.data;
if (responseData.length > 50) { if (responseData.length > 50) {
responseData = response.substring(0, 47) + "..."; responseData = responseData.toString().substring(0, 47) + "...";
} }
if (response.code !== 1) { if (response.code !== 1) {
bean.status = DOWN; bean.status = DOWN;
@ -579,6 +604,15 @@ class Monitor extends BeanModel {
bean.msg = ""; bean.msg = "";
bean.status = UP; bean.status = UP;
bean.ping = dayjs().valueOf() - startTime; bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "mongodb") {
let startTime = dayjs().valueOf();
await mongodbPing(this.databaseConnectionString);
bean.msg = "";
bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "radius") { } else if (this.type === "radius") {
let startTime = dayjs().valueOf(); let startTime = dayjs().valueOf();
@ -615,9 +649,23 @@ class Monitor extends BeanModel {
} }
} }
bean.ping = dayjs().valueOf() - startTime; bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "redis") {
let startTime = dayjs().valueOf();
bean.msg = await redisPingAsync(this.databaseConnectionString);
bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
} else if (this.type in UptimeKumaServer.monitorTypeList) {
let startTime = dayjs().valueOf();
const monitorType = UptimeKumaServer.monitorTypeList[this.type];
await monitorType.check(this, bean);
if (!bean.ping) {
bean.ping = dayjs().valueOf() - startTime;
}
} else { } else {
bean.msg = "Unknown Monitor Type"; throw new Error("Unknown Monitor Type");
bean.status = PENDING;
} }
if (this.isUpsideDown()) { if (this.isUpsideDown()) {
@ -707,7 +755,7 @@ class Monitor extends BeanModel {
await R.store(bean); await R.store(bean);
log.debug("monitor", `[${this.name}] prometheus.update`); log.debug("monitor", `[${this.name}] prometheus.update`);
prometheus.update(bean, tlsInfo); this.prometheus.update(bean, tlsInfo);
previousBeat = bean; previousBeat = bean;
@ -746,6 +794,13 @@ class Monitor extends BeanModel {
} }
} }
/**
* Make a request using axios
* @param {Object} options Options for Axios
* @param {boolean} finalCall Should this be the final call i.e
* don't retry on faliure
* @returns {Object} Axios response
*/
async makeAxiosRequest(options, finalCall = false) { async makeAxiosRequest(options, finalCall = false) {
try { try {
let res; let res;
@ -785,15 +840,15 @@ class Monitor extends BeanModel {
clearTimeout(this.heartbeatInterval); clearTimeout(this.heartbeatInterval);
this.isStop = true; this.isStop = true;
this.prometheus().remove(); this.prometheus.remove();
} }
/** /**
* Get a new prometheus instance * Get prometheus instance
* @returns {Prometheus} * @returns {Prometheus|undefined}
*/ */
prometheus() { getPrometheus() {
return new Prometheus(this); return this.prometheus;
} }
/** /**
@ -1090,7 +1145,7 @@ class Monitor extends BeanModel {
// Prevent if the msg is undefined, notifications such as Discord cannot send out. // Prevent if the msg is undefined, notifications such as Discord cannot send out.
const heartbeatJSON = bean.toJSON(); const heartbeatJSON = bean.toJSON();
if (!heartbeatJSON["msg"]) { if (!heartbeatJSON["msg"]) {
heartbeatJSON["msg"] = ""; heartbeatJSON["msg"] = "N/A";
} }
await Notification.send(JSON.parse(notification.config), msg, await monitor.toJSON(false), heartbeatJSON); await Notification.send(JSON.parse(notification.config), msg, await monitor.toJSON(false), heartbeatJSON);
@ -1200,7 +1255,7 @@ class Monitor extends BeanModel {
*/ */
static async getPreviousHeartbeat(monitorID) { static async getPreviousHeartbeat(monitorID) {
return await R.getRow(` return await R.getRow(`
SELECT status, time FROM heartbeat SELECT ping, status, time FROM heartbeat
WHERE id = (select MAX(id) from heartbeat where monitor_id = ?) WHERE id = (select MAX(id) from heartbeat where monitor_id = ?)
`, [ `, [
monitorID monitorID
@ -1227,6 +1282,7 @@ class Monitor extends BeanModel {
return maintenance.count !== 0; return maintenance.count !== 0;
} }
/** Make sure monitor interval is between bounds */
validate() { validate() {
if (this.interval > MAX_INTERVAL_SECOND) { if (this.interval > MAX_INTERVAL_SECOND) {
throw new Error(`Interval cannot be more than ${MAX_INTERVAL_SECOND} seconds`); throw new Error(`Interval cannot be more than ${MAX_INTERVAL_SECOND} seconds`);

@ -4,6 +4,7 @@ const cheerio = require("cheerio");
const { UptimeKumaServer } = require("../uptime-kuma-server"); const { UptimeKumaServer } = require("../uptime-kuma-server");
const jsesc = require("jsesc"); const jsesc = require("jsesc");
const Maintenance = require("./maintenance"); const Maintenance = require("./maintenance");
const googleAnalytics = require("../google-analytics");
class StatusPage extends BeanModel { class StatusPage extends BeanModel {
@ -53,9 +54,17 @@ class StatusPage extends BeanModel {
const head = $("head"); const head = $("head");
if (statusPage.googleAnalyticsTagId) {
let escapedGoogleAnalyticsScript = googleAnalytics.getGoogleAnalyticsScript(statusPage.googleAnalyticsTagId);
head.append($(escapedGoogleAnalyticsScript));
}
// OG Meta Tags // OG Meta Tags
head.append(`<meta property="og:title" content="${statusPage.title}" />`); let ogTitle = $("<meta property=\"og:title\" content=\"\" />").attr("content", statusPage.title);
head.append(`<meta property="og:description" content="${description155}" />`); head.append(ogTitle);
let ogDescription = $("<meta property=\"og:description\" content=\"\" />").attr("content", description155);
head.append(ogDescription);
// Preload data // Preload data
// Add jsesc, fix https://github.com/louislam/uptime-kuma/issues/2186 // Add jsesc, fix https://github.com/louislam/uptime-kuma/issues/2186
@ -225,6 +234,7 @@ class StatusPage extends BeanModel {
customCSS: this.custom_css, customCSS: this.custom_css,
footerText: this.footer_text, footerText: this.footer_text,
showPoweredBy: !!this.show_powered_by, showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id,
}; };
} }
@ -245,6 +255,7 @@ class StatusPage extends BeanModel {
customCSS: this.custom_css, customCSS: this.custom_css,
footerText: this.footer_text, footerText: this.footer_text,
showPoweredBy: !!this.show_powered_by, showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id,
}; };
} }
@ -281,7 +292,7 @@ class StatusPage extends BeanModel {
let activeCondition = Maintenance.getActiveMaintenanceSQLCondition(); let activeCondition = Maintenance.getActiveMaintenanceSQLCondition();
let maintenanceBeanList = R.convertToBeans("maintenance", await R.getAll(` let maintenanceBeanList = R.convertToBeans("maintenance", await R.getAll(`
SELECT maintenance.* SELECT DISTINCT maintenance.*
FROM maintenance FROM maintenance
JOIN maintenance_status_page JOIN maintenance_status_page
ON maintenance_status_page.maintenance_id = maintenance.id ON maintenance_status_page.maintenance_id = maintenance.id

@ -0,0 +1,19 @@
class MonitorType {
name = undefined;
/**
*
* @param {Monitor} monitor
* @param {Heartbeat} heartbeat
* @returns {Promise<void>}
*/
async check(monitor, heartbeat) {
throw new Error("You need to override check()");
}
}
module.exports = {
MonitorType,
};

@ -8,7 +8,6 @@ class ClickSendSMS extends NotificationProvider {
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
let okMsg = "Sent Successfully."; let okMsg = "Sent Successfully.";
try { try {
console.log({ notification });
let config = { let config = {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

@ -91,7 +91,7 @@ class Discord extends NotificationProvider {
}, },
{ {
name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL", name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
value: monitorJSON["type"] === "push" ? "Heartbeat" : address.startsWith("http") ? "[Visit Service](" + address + ")" : address, value: monitorJSON["type"] === "push" ? "Heartbeat" : address,
}, },
{ {
name: "Time (UTC)", name: "Time (UTC)",

@ -0,0 +1,31 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class Kook extends NotificationProvider {
name = "Kook";
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
let okMsg = "Sent Successfully.";
let url = "https://www.kookapp.cn/api/v3/message/create";
let data = {
target_id: notification.kookGuildID,
content: msg,
};
let config = {
headers: {
"Authorization": "Bot " + notification.kookBotToken,
"Content-Type": "application/json",
},
};
try {
await axios.post(url, data, config);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = Kook;

@ -8,6 +8,14 @@ class PromoSMS extends NotificationProvider {
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
let okMsg = "Sent Successfully."; let okMsg = "Sent Successfully.";
if (notification.promosmsAllowLongSMS === undefined) {
notification.promosmsAllowLongSMS = false;
}
//TODO: Add option for enabling special characters. It will decrese message max length from 160 to 70 chars.
//Lets remove non ascii char
let cleanMsg = msg.replace(/[^\x00-\x7F]/g, "");
try { try {
let config = { let config = {
headers: { headers: {
@ -18,8 +26,9 @@ class PromoSMS extends NotificationProvider {
}; };
let data = { let data = {
"recipients": [ notification.promosmsPhoneNumber ], "recipients": [ notification.promosmsPhoneNumber ],
//Lets remove non ascii char //Trim message to maximum length of 1 SMS or 4 if we allowed long messages
"text": msg.replace(/[^\x00-\x7F]/g, ""), "text": notification.promosmsAllowLongSMS ? cleanMsg.substring(0, 639) : cleanMsg.substring(0, 159),
"long-sms": notification.promosmsAllowLongSMS,
"type": Number(notification.promosmsSMSType), "type": Number(notification.promosmsSMSType),
"sender": notification.promosmsSenderName "sender": notification.promosmsSenderName
}; };

@ -10,7 +10,7 @@ class Pushover extends NotificationProvider {
let pushoverlink = "https://api.pushover.net/1/messages.json"; let pushoverlink = "https://api.pushover.net/1/messages.json";
let data = { let data = {
"message": "<b>Uptime Kuma Alert</b>\n\n<b>Message</b>:" + msg, "message": msg,
"user": notification.pushoveruserkey, "user": notification.pushoveruserkey,
"token": notification.pushoverapptoken, "token": notification.pushoverapptoken,
"sound": notification.pushoversounds, "sound": notification.pushoversounds,

@ -21,6 +21,12 @@ class ServerChan extends NotificationProvider {
} }
} }
/**
* Get the formatted title for message
* @param {?Object} monitorJSON Monitor details (For Up/Down only)
* @param {?Object} heartbeatJSON Heartbeat details (For Up/Down only)
* @returns {string} Formatted title
*/
checkStatus(heartbeatJSON, monitorJSON) { checkStatus(heartbeatJSON, monitorJSON) {
let title = "UptimeKuma Message"; let title = "UptimeKuma Message";
if (heartbeatJSON != null && heartbeatJSON["status"] === UP) { if (heartbeatJSON != null && heartbeatJSON["status"] === UP) {

@ -1,7 +1,7 @@
const NotificationProvider = require("./notification-provider"); const NotificationProvider = require("./notification-provider");
const axios = require("axios"); const axios = require("axios");
const { setSettings, setting } = require("../util-server"); const { setSettings, setting } = require("../util-server");
const { getMonitorRelativeURL } = require("../../src/util"); const { getMonitorRelativeURL, UP } = require("../../src/util");
class Slack extends NotificationProvider { class Slack extends NotificationProvider {
@ -46,7 +46,11 @@ class Slack extends NotificationProvider {
"channel": notification.slackchannel, "channel": notification.slackchannel,
"username": notification.slackusername, "username": notification.slackusername,
"icon_emoji": notification.slackiconemo, "icon_emoji": notification.slackiconemo,
"blocks": [{ "attachments": [
{
"color": (heartbeatJSON["status"] === UP) ? "#2eb886" : "#e01e5a",
"blocks": [
{
"type": "header", "type": "header",
"text": { "text": {
"type": "plain_text", "type": "plain_text",
@ -63,7 +67,10 @@ class Slack extends NotificationProvider {
"type": "mrkdwn", "type": "mrkdwn",
"text": "*Time (UTC)*\n" + time, "text": "*Time (UTC)*\n" + time,
}], }],
}], }
],
}
]
}; };
if (notification.slackbutton) { if (notification.slackbutton) {
@ -74,7 +81,8 @@ class Slack extends NotificationProvider {
// Button // Button
if (baseURL) { if (baseURL) {
data.blocks.push({ data.attachments.forEach(element => {
element.blocks.push({
"type": "actions", "type": "actions",
"elements": [{ "elements": [{
"type": "button", "type": "button",
@ -86,6 +94,7 @@ class Slack extends NotificationProvider {
"url": baseURL + getMonitorRelativeURL(monitorJSON.id), "url": baseURL + getMonitorRelativeURL(monitorJSON.id),
}], }],
}); });
});
} }
await axios.post(notification.slackwebhookURL, data); await axios.post(notification.slackwebhookURL, data);

@ -0,0 +1,113 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util");
const { setting } = require("../util-server");
let successMessage = "Sent Successfully.";
class Splunk extends NotificationProvider {
name = "Splunk";
/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
try {
if (heartbeatJSON == null) {
const title = "Uptime Kuma Alert";
const monitor = {
type: "ping",
url: "Uptime Kuma Test Button",
};
return this.postNotification(notification, title, msg, monitor, "trigger");
}
if (heartbeatJSON.status === UP) {
const title = "Uptime Kuma Monitor ✅ Up";
return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, "recovery");
}
if (heartbeatJSON.status === DOWN) {
const title = "Uptime Kuma Monitor 🔴 Down";
return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, "trigger");
}
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
/**
* Check if result is successful, result code should be in range 2xx
* @param {Object} result Axios response object
* @throws {Error} The status code is not in range 2xx
*/
checkResult(result) {
if (result.status == null) {
throw new Error("Splunk notification failed with invalid response!");
}
if (result.status < 200 || result.status >= 300) {
throw new Error("Splunk notification failed with status code " + result.status);
}
}
/**
* Send the message
* @param {BeanModel} notification Message title
* @param {string} title Message title
* @param {string} body Message
* @param {Object} monitorInfo Monitor details (For Up/Down only)
* @param {?string} eventAction Action event for PagerDuty (trigger, acknowledge, resolve)
* @returns {string}
*/
async postNotification(notification, title, body, monitorInfo, eventAction = "trigger") {
let monitorUrl;
if (monitorInfo.type === "port") {
monitorUrl = monitorInfo.hostname;
if (monitorInfo.port) {
monitorUrl += ":" + monitorInfo.port;
}
} else if (monitorInfo.hostname != null) {
monitorUrl = monitorInfo.hostname;
} else {
monitorUrl = monitorInfo.url;
}
if (eventAction === "recovery") {
if (notification.splunkAutoResolve === "0") {
return "No action required";
}
eventAction = notification.splunkAutoResolve;
} else {
eventAction = notification.splunkSeverity;
}
const options = {
method: "POST",
url: notification.splunkRestURL,
headers: { "Content-Type": "application/json" },
data: {
message_type: eventAction,
state_message: `[${title}] [${monitorUrl}] ${body}`,
entity_display_name: "Uptime Kuma Alert: " + monitorInfo.name,
routing_key: notification.pagerdutyIntegrationKey,
entity_id: "Uptime Kuma/" + monitorInfo.id,
}
};
const baseURL = await setting("primaryBaseURL");
if (baseURL && monitorInfo) {
options.client = "Uptime Kuma";
options.client_url = baseURL + getMonitorRelativeURL(monitorInfo.id);
}
let result = await axios.request(options);
this.checkResult(result);
if (result.statusText != null) {
return "Splunk notification succeed: " + result.statusText;
}
return successMessage;
}
}
module.exports = Splunk;

@ -9,11 +9,16 @@ class Telegram extends NotificationProvider {
let okMsg = "Sent Successfully."; let okMsg = "Sent Successfully.";
try { try {
await axios.get(`https://api.telegram.org/bot${notification.telegramBotToken}/sendMessage`, { let params = {
params: {
chat_id: notification.telegramChatID, chat_id: notification.telegramChatID,
text: msg, text: msg,
}, };
if (notification.telegramMessageThreadID) {
params.message_thread_id = notification.telegramMessageThreadID;
}
await axios.get(`https://api.telegram.org/bot${notification.telegramBotToken}/sendMessage`, {
params: params,
}); });
return okMsg; return okMsg;

@ -0,0 +1,116 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
const { DOWN, UP } = require("../../src/util");
class ZohoCliq extends NotificationProvider {
name = "ZohoCliq";
/**
* Generate the message to send
* @param {const} status The status constant
* @param {string} monitorName Name of monitor
* @returns {string}
*/
_statusMessageFactory = (status, monitorName) => {
if (status === DOWN) {
return `🔴 Application [${monitorName}] went down\n`;
} else if (status === UP) {
return `✅ Application [${monitorName}] is back online\n`;
}
return "Notification\n";
};
/**
* Send the notification
* @param {string} webhookUrl URL to send the request to
* @param {Array} payload Payload generated by _notificationPayloadFactory
*/
_sendNotification = async (webhookUrl, payload) => {
await axios.post(webhookUrl, { text: payload.join("\n") });
};
/**
* Generate payload for notification
* @param {const} status The status of the monitor
* @param {string} monitorMessage Message to send
* @param {string} monitorName Name of monitor affected
* @param {string} monitorUrl URL of monitor affected
* @returns {Array}
*/
_notificationPayloadFactory = ({
status,
monitorMessage,
monitorName,
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})`);
}
return payload;
};
/**
* Send a general notification
* @param {string} webhookUrl URL to send request to
* @param {string} msg Message to send
* @returns {Promise<void>}
*/
_handleGeneralNotification = (webhookUrl, msg) => {
const payload = this._notificationPayloadFactory({
monitorMessage: msg
});
return this._sendNotification(webhookUrl, payload);
};
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
let okMsg = "Sent Successfully.";
try {
if (heartbeatJSON == null) {
await this._handleGeneralNotification(notification.webhookUrl, msg);
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,
status: heartbeatJSON.status
});
await this._sendNotification(notification.webhookUrl, payload);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = ZohoCliq;

@ -14,6 +14,7 @@ const GoogleChat = require("./notification-providers/google-chat");
const Gorush = require("./notification-providers/gorush"); const Gorush = require("./notification-providers/gorush");
const Gotify = require("./notification-providers/gotify"); const Gotify = require("./notification-providers/gotify");
const HomeAssistant = require("./notification-providers/home-assistant"); const HomeAssistant = require("./notification-providers/home-assistant");
const Kook = require("./notification-providers/kook");
const Line = require("./notification-providers/line"); const Line = require("./notification-providers/line");
const LineNotify = require("./notification-providers/linenotify"); const LineNotify = require("./notification-providers/linenotify");
const LunaSea = require("./notification-providers/lunasea"); const LunaSea = require("./notification-providers/lunasea");
@ -39,11 +40,13 @@ const Stackfield = require("./notification-providers/stackfield");
const Teams = require("./notification-providers/teams"); const Teams = require("./notification-providers/teams");
const TechulusPush = require("./notification-providers/techulus-push"); const TechulusPush = require("./notification-providers/techulus-push");
const Telegram = require("./notification-providers/telegram"); const Telegram = require("./notification-providers/telegram");
const Splunk = require("./notification-providers/splunk");
const Webhook = require("./notification-providers/webhook"); const Webhook = require("./notification-providers/webhook");
const WeCom = require("./notification-providers/wecom"); const WeCom = require("./notification-providers/wecom");
const GoAlert = require("./notification-providers/goalert"); const GoAlert = require("./notification-providers/goalert");
const SMSManager = require("./notification-providers/smsmanager"); const SMSManager = require("./notification-providers/smsmanager");
const ServerChan = require("./notification-providers/serverchan"); const ServerChan = require("./notification-providers/serverchan");
const ZohoCliq = require("./notification-providers/zoho-cliq");
class Notification { class Notification {
@ -70,6 +73,7 @@ class Notification {
new Gorush(), new Gorush(),
new Gotify(), new Gotify(),
new HomeAssistant(), new HomeAssistant(),
new Kook(),
new Line(), new Line(),
new LineNotify(), new LineNotify(),
new LunaSea(), new LunaSea(),
@ -97,9 +101,11 @@ class Notification {
new Teams(), new Teams(),
new TechulusPush(), new TechulusPush(),
new Telegram(), new Telegram(),
new Splunk(),
new Webhook(), new Webhook(),
new WeCom(), new WeCom(),
new GoAlert(), new GoAlert(),
new ZohoCliq()
]; ];
for (let item of list) { for (let item of list) {

@ -1,199 +0,0 @@
// https://github.com/ben-bradley/ping-lite/blob/master/ping-lite.js
// Fixed on Windows
const net = require("net");
const spawn = require("child_process").spawn;
const events = require("events");
const fs = require("fs");
const util = require("./util-server");
module.exports = Ping;
/**
* Constructor for ping class
* @param {string} host Host to ping
* @param {object} [options] Options for the ping command
* @param {array|string} [options.args] - Arguments to pass to the ping command
*/
function Ping(host, options) {
if (!host) {
throw new Error("You must specify a host to ping!");
}
this._host = host;
this._options = options = (options || {});
events.EventEmitter.call(this);
const timeout = 10;
if (util.WIN) {
this._bin = "c:/windows/system32/ping.exe";
this._args = (options.args) ? options.args : [ "-n", "1", "-w", timeout * 1000, host ];
this._regmatch = /[><=]([0-9.]+?)ms/;
} else if (util.LIN) {
this._bin = "/bin/ping";
const defaultArgs = [ "-n", "-w", timeout, "-c", "1", host ];
if (net.isIPv6(host) || options.ipv6) {
defaultArgs.unshift("-6");
}
this._args = (options.args) ? options.args : defaultArgs;
this._regmatch = /=([0-9.]+?) ms/;
} else if (util.MAC) {
if (net.isIPv6(host) || options.ipv6) {
this._bin = "/sbin/ping6";
} else {
this._bin = "/sbin/ping";
}
this._args = (options.args) ? options.args : [ "-n", "-t", timeout, "-c", "1", host ];
this._regmatch = /=([0-9.]+?) ms/;
} else if (util.BSD) {
this._bin = "/sbin/ping";
const defaultArgs = [ "-n", "-t", timeout, "-c", "1", host ];
if (net.isIPv6(host) || options.ipv6) {
defaultArgs.unshift("-6");
}
this._args = (options.args) ? options.args : defaultArgs;
this._regmatch = /=([0-9.]+?) ms/;
} else {
throw new Error("Could not detect your ping binary.");
}
if (!fs.existsSync(this._bin)) {
throw new Error("Could not detect " + this._bin + " on your system");
}
this._i = 0;
return this;
}
Ping.prototype.__proto__ = events.EventEmitter.prototype;
/**
* Callback for send
* @callback pingCB
* @param {any} err Any error encountered
* @param {number} ms Ping time in ms
*/
/**
* Send a ping
* @param {pingCB} callback Callback to call with results
*/
Ping.prototype.send = function (callback) {
let self = this;
callback = callback || function (err, ms) {
if (err) {
return self.emit("error", err);
}
return self.emit("result", ms);
};
let _ended;
let _exited;
let _errored;
this._ping = spawn(this._bin, this._args, { windowsHide: true }); // spawn the binary
this._ping.on("error", function (err) { // handle binary errors
_errored = true;
callback(err);
});
this._ping.stdout.on("data", function (data) { // log stdout
if (util.WIN) {
data = convertOutput(data);
}
this._stdout = (this._stdout || "") + data;
});
this._ping.stdout.on("end", function () {
_ended = true;
if (_exited && !_errored) {
onEnd.call(self._ping);
}
});
this._ping.stderr.on("data", function (data) { // log stderr
if (util.WIN) {
data = convertOutput(data);
}
this._stderr = (this._stderr || "") + data;
});
this._ping.on("exit", function (code) { // handle complete
_exited = true;
if (_ended && !_errored) {
onEnd.call(self._ping);
}
});
/**
* @param {Function} callback
*
* Generated by Trelent
*/
function onEnd() {
let stdout = this.stdout._stdout;
let stderr = this.stderr._stderr;
let ms;
if (stderr) {
return callback(new Error(stderr));
}
if (!stdout) {
return callback(new Error("No stdout detected"));
}
ms = stdout.match(self._regmatch); // parse out the ##ms response
ms = (ms && ms[1]) ? Number(ms[1]) : ms;
callback(null, ms, stdout);
}
};
/**
* Ping every interval
* @param {pingCB} callback Callback to call with results
*/
Ping.prototype.start = function (callback) {
let self = this;
this._i = setInterval(function () {
self.send(callback);
}, (self._options.interval || 5000));
self.send(callback);
};
/** Stop sending pings */
Ping.prototype.stop = function () {
clearInterval(this._i);
};
/**
* Try to convert to UTF-8 for Windows, as the ping's output on Windows is not UTF-8 and could be in other languages
* Thank @pemassi
* https://github.com/louislam/uptime-kuma/issues/570#issuecomment-941984094
* @param {any} data
* @returns {string}
*/
function convertOutput(data) {
if (util.WIN) {
if (data) {
return util.convertToUTF8(data);
}
}
return data;
}

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

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

@ -99,6 +99,7 @@ class Prometheus {
} }
} }
/** Remove monitor from prometheus */
remove() { remove() {
try { try {
monitorCertDaysRemaining.remove(this.monitorLabelValues); monitorCertDaysRemaining.remove(this.monitorLabelValues);

@ -1,10 +1,10 @@
let express = require("express"); let express = require("express");
const { allowDevAllOrigin, allowAllOrigin, percentageToColor, filterAndJoin, send403 } = require("../util-server"); const { allowDevAllOrigin, allowAllOrigin, percentageToColor, filterAndJoin, sendHttpError } = require("../util-server");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const apicache = require("../modules/apicache"); const apicache = require("../modules/apicache");
const Monitor = require("../model/monitor"); const Monitor = require("../model/monitor");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const { UP, MAINTENANCE, DOWN, flipStatus, log } = require("../../src/util"); const { UP, MAINTENANCE, DOWN, PENDING, flipStatus, log } = require("../../src/util");
const StatusPage = require("../model/status_page"); const StatusPage = require("../model/status_page");
const { UptimeKumaServer } = require("../uptime-kuma-server"); const { UptimeKumaServer } = require("../uptime-kuma-server");
const { makeBadge } = require("badge-maker"); const { makeBadge } = require("badge-maker");
@ -113,8 +113,12 @@ router.get("/api/badge/:id/status", cache("5 minutes"), async (request, response
label, label,
upLabel = "Up", upLabel = "Up",
downLabel = "Down", downLabel = "Down",
pendingLabel = "Pending",
maintenanceLabel = "Maintenance",
upColor = badgeConstants.defaultUpColor, upColor = badgeConstants.defaultUpColor,
downColor = badgeConstants.defaultDownColor, downColor = badgeConstants.defaultDownColor,
pendingColor = badgeConstants.defaultPendingColor,
maintenanceColor = badgeConstants.defaultMaintenanceColor,
style = badgeConstants.defaultStyle, style = badgeConstants.defaultStyle,
value, // for demo purpose only value, // for demo purpose only
} = request.query; } = request.query;
@ -141,11 +145,30 @@ router.get("/api/badge/:id/status", cache("5 minutes"), async (request, response
badgeValues.color = badgeConstants.naColor; badgeValues.color = badgeConstants.naColor;
} else { } else {
const heartbeat = await Monitor.getPreviousHeartbeat(requestedMonitorId); const heartbeat = await Monitor.getPreviousHeartbeat(requestedMonitorId);
const state = overrideValue !== undefined ? overrideValue : heartbeat.status === 1; const state = overrideValue !== undefined ? overrideValue : heartbeat.status;
badgeValues.label = label ? label : ""; badgeValues.label = label ?? "Status";
badgeValues.color = state ? upColor : downColor; switch (state) {
badgeValues.message = label ?? state ? upLabel : downLabel; case DOWN:
badgeValues.color = downColor;
badgeValues.message = downLabel;
break;
case UP:
badgeValues.color = upColor;
badgeValues.message = upLabel;
break;
case PENDING:
badgeValues.color = pendingColor;
badgeValues.message = pendingLabel;
break;
case MAINTENANCE:
badgeValues.color = maintenanceColor;
badgeValues.message = maintenanceLabel;
break;
default:
badgeValues.color = badgeConstants.naColor;
badgeValues.message = "N/A";
}
} }
// build the svg based on given values // build the svg based on given values
@ -154,7 +177,7 @@ router.get("/api/badge/:id/status", cache("5 minutes"), async (request, response
response.type("image/svg+xml"); response.type("image/svg+xml");
response.send(svg); response.send(svg);
} catch (error) { } catch (error) {
send403(response, error.message); sendHttpError(response, error.message);
} }
}); });
@ -191,7 +214,7 @@ router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (reques
const badgeValues = { style }; const badgeValues = { style };
if (!publicMonitor) { if (!publicMonitor) {
// return a "N/A" badge in naColor (grey), if monitor is not public / not available / non exsitant // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non existent
badgeValues.message = "N/A"; badgeValues.message = "N/A";
badgeValues.color = badgeConstants.naColor; badgeValues.color = badgeConstants.naColor;
} else { } else {
@ -207,8 +230,11 @@ router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (reques
badgeValues.color = color ?? percentageToColor(uptime); badgeValues.color = color ?? percentageToColor(uptime);
// use a given, custom labelColor or use the default badge label color (defined by badge-maker) // use a given, custom labelColor or use the default badge label color (defined by badge-maker)
badgeValues.labelColor = labelColor ?? ""; badgeValues.labelColor = labelColor ?? "";
// build a lable string. If a custom label is given, override the default one (requestedDuration) // build a label string. If a custom label is given, override the default one (requestedDuration)
badgeValues.label = filterAndJoin([ labelPrefix, label ?? requestedDuration, labelSuffix ]); badgeValues.label = filterAndJoin([
labelPrefix,
label ?? `Uptime (${requestedDuration}${labelSuffix})`,
]);
badgeValues.message = filterAndJoin([ prefix, `${cleanUptime * 100}`, suffix ]); badgeValues.message = filterAndJoin([ prefix, `${cleanUptime * 100}`, suffix ]);
} }
@ -218,7 +244,7 @@ router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (reques
response.type("image/svg+xml"); response.type("image/svg+xml");
response.send(svg); response.send(svg);
} catch (error) { } catch (error) {
send403(response, error.message); sendHttpError(response, error.message);
} }
}); });
@ -269,7 +295,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) // use a given, custom labelColor or use the default badge label color (defined by badge-maker)
badgeValues.labelColor = labelColor ?? ""; badgeValues.labelColor = labelColor ?? "";
// build a lable string. If a custom label is given, override the default one (requestedDuration) // build a lable string. If a custom label is given, override the default one (requestedDuration)
badgeValues.label = filterAndJoin([ labelPrefix, label ?? requestedDuration, labelSuffix ]); badgeValues.label = filterAndJoin([ labelPrefix, label ?? `Avg. Ping (${requestedDuration}${labelSuffix})` ]);
badgeValues.message = filterAndJoin([ prefix, avgPing, suffix ]); badgeValues.message = filterAndJoin([ prefix, avgPing, suffix ]);
} }
@ -279,7 +305,240 @@ router.get("/api/badge/:id/ping/:duration?", cache("5 minutes"), async (request,
response.type("image/svg+xml"); response.type("image/svg+xml");
response.send(svg); response.send(svg);
} catch (error) { } catch (error) {
send403(response, error.message); sendHttpError(response, error.message);
}
});
router.get("/api/badge/:id/avg-response/:duration?", cache("5 minutes"), async (request, response) => {
allowAllOrigin(response);
const {
label,
labelPrefix,
labelSuffix,
prefix,
suffix = badgeConstants.defaultPingValueSuffix,
color = badgeConstants.defaultPingColor,
labelColor,
style = badgeConstants.defaultStyle,
value, // for demo purpose only
} = request.query;
try {
const requestedMonitorId = parseInt(request.params.id, 10);
// Default duration is 24 (h) if not defined in queryParam, limited to 720h (30d)
const requestedDuration = Math.min(
request.params.duration
? parseInt(request.params.duration, 10)
: 24,
720
);
const overrideValue = value && parseFloat(value);
const publicAvgPing = parseInt(await R.getCell(`
SELECT AVG(ping) FROM monitor_group, \`group\`, heartbeat
WHERE monitor_group.group_id = \`group\`.id
AND heartbeat.time > DATETIME('now', ? || ' hours')
AND heartbeat.ping IS NOT NULL
AND public = 1
AND heartbeat.monitor_id = ?
`,
[ -requestedDuration, requestedMonitorId ]
));
const badgeValues = { style };
if (!publicAvgPing) {
// return a "N/A" badge in naColor (grey), if monitor is not public / not available / non existent
badgeValues.message = "N/A";
badgeValues.color = badgeConstants.naColor;
} else {
const avgPing = parseInt(overrideValue ?? publicAvgPing);
badgeValues.color = color;
// use a given, custom labelColor or use the default badge label color (defined by badge-maker)
badgeValues.labelColor = labelColor ?? "";
// build a label string. If a custom label is given, override the default one (requestedDuration)
badgeValues.label = filterAndJoin([
labelPrefix,
label ?? `Avg. Response (${requestedDuration}h)`,
labelSuffix,
]);
badgeValues.message = filterAndJoin([ prefix, avgPing, suffix ]);
}
// build the SVG based on given values
const svg = makeBadge(badgeValues);
response.type("image/svg+xml");
response.send(svg);
} catch (error) {
sendHttpError(response, error.message);
}
});
router.get("/api/badge/:id/cert-exp", cache("5 minutes"), async (request, response) => {
allowAllOrigin(response);
const date = request.query.date;
const {
label,
labelPrefix,
labelSuffix,
prefix,
suffix = date ? "" : badgeConstants.defaultCertExpValueSuffix,
upColor = badgeConstants.defaultUpColor,
warnColor = badgeConstants.defaultWarnColor,
downColor = badgeConstants.defaultDownColor,
warnDays = badgeConstants.defaultCertExpireWarnDays,
downDays = badgeConstants.defaultCertExpireDownDays,
labelColor,
style = badgeConstants.defaultStyle,
value, // for demo purpose only
} = request.query;
try {
const requestedMonitorId = parseInt(request.params.id, 10);
const overrideValue = value && parseFloat(value);
let publicMonitor = await R.getRow(`
SELECT monitor_group.monitor_id FROM monitor_group, \`group\`
WHERE monitor_group.group_id = \`group\`.id
AND monitor_group.monitor_id = ?
AND public = 1
`,
[ requestedMonitorId ]
);
const badgeValues = { style };
if (!publicMonitor) {
// return a "N/A" badge in naColor (grey), if monitor is not public / not available / non existent
badgeValues.message = "N/A";
badgeValues.color = badgeConstants.naColor;
} else {
const tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [
requestedMonitorId,
]);
if (!tlsInfoBean) {
// return a "No/Bad Cert" badge in naColor (grey), if no cert saved (does not save bad certs?)
badgeValues.message = "No/Bad Cert";
badgeValues.color = badgeConstants.naColor;
} else {
const tlsInfo = JSON.parse(tlsInfoBean.info_json);
if (!tlsInfo.valid) {
// return a "Bad Cert" badge in naColor (grey), when cert is not valid
badgeValues.message = "Bad Cert";
badgeValues.color = badgeConstants.downColor;
} else {
const daysRemaining = parseInt(overrideValue ?? tlsInfo.certInfo.daysRemaining);
if (daysRemaining > warnDays) {
badgeValues.color = upColor;
} else if (daysRemaining > downDays) {
badgeValues.color = warnColor;
} else {
badgeValues.color = downColor;
}
// use a given, custom labelColor or use the default badge label color (defined by badge-maker)
badgeValues.labelColor = labelColor ?? "";
// build a label string. If a custom label is given, override the default one
badgeValues.label = filterAndJoin([
labelPrefix,
label ?? "Cert Exp.",
labelSuffix,
]);
badgeValues.message = filterAndJoin([ prefix, date ? tlsInfo.certInfo.validTo : daysRemaining, suffix ]);
}
}
}
// build the SVG based on given values
const svg = makeBadge(badgeValues);
response.type("image/svg+xml");
response.send(svg);
} catch (error) {
sendHttpError(response, error.message);
}
});
router.get("/api/badge/:id/response", cache("5 minutes"), async (request, response) => {
allowAllOrigin(response);
const {
label,
labelPrefix,
labelSuffix,
prefix,
suffix = badgeConstants.defaultPingValueSuffix,
color = badgeConstants.defaultPingColor,
labelColor,
style = badgeConstants.defaultStyle,
value, // for demo purpose only
} = request.query;
try {
const requestedMonitorId = parseInt(request.params.id, 10);
const overrideValue = value && parseFloat(value);
let publicMonitor = await R.getRow(`
SELECT monitor_group.monitor_id FROM monitor_group, \`group\`
WHERE monitor_group.group_id = \`group\`.id
AND monitor_group.monitor_id = ?
AND public = 1
`,
[ requestedMonitorId ]
);
const badgeValues = { style };
if (!publicMonitor) {
// return a "N/A" badge in naColor (grey), if monitor is not public / not available / non existent
badgeValues.message = "N/A";
badgeValues.color = badgeConstants.naColor;
} else {
const heartbeat = await Monitor.getPreviousHeartbeat(
requestedMonitorId
);
if (!heartbeat.ping) {
// return a "N/A" badge in naColor (grey), if previous heartbeat has no ping
badgeValues.message = "N/A";
badgeValues.color = badgeConstants.naColor;
} else {
const ping = parseInt(overrideValue ?? heartbeat.ping);
badgeValues.color = color;
// use a given, custom labelColor or use the default badge label color (defined by badge-maker)
badgeValues.labelColor = labelColor ?? "";
// build a label string. If a custom label is given, override the default one
badgeValues.label = filterAndJoin([
labelPrefix,
label ?? "Response",
labelSuffix,
]);
badgeValues.message = filterAndJoin([ prefix, ping, suffix ]);
}
}
// build the SVG based on given values
const svg = makeBadge(badgeValues);
response.type("image/svg+xml");
response.send(svg);
} catch (error) {
sendHttpError(response, error.message);
} }
}); });

@ -2,7 +2,7 @@ let express = require("express");
const apicache = require("../modules/apicache"); const apicache = require("../modules/apicache");
const { UptimeKumaServer } = require("../uptime-kuma-server"); const { UptimeKumaServer } = require("../uptime-kuma-server");
const StatusPage = require("../model/status_page"); const StatusPage = require("../model/status_page");
const { allowDevAllOrigin, send403 } = require("../util-server"); const { allowDevAllOrigin, sendHttpError } = require("../util-server");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const Monitor = require("../model/monitor"); const Monitor = require("../model/monitor");
@ -44,10 +44,7 @@ router.get("/api/status-page/:slug", cache("5 minutes"), async (request, respons
let statusPageData = await StatusPage.getStatusPageData(statusPage); let statusPageData = await StatusPage.getStatusPageData(statusPage);
if (!statusPageData) { if (!statusPageData) {
response.statusCode = 404; sendHttpError(response, "Not Found");
response.json({
msg: "Not Found"
});
return; return;
} }
@ -55,7 +52,7 @@ router.get("/api/status-page/:slug", cache("5 minutes"), async (request, respons
response.json(statusPageData); response.json(statusPageData);
} catch (error) { } catch (error) {
send403(response, error.message); sendHttpError(response, error.message);
} }
}); });
@ -103,7 +100,7 @@ router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (reques
}); });
} catch (error) { } catch (error) {
send403(response, error.message); sendHttpError(response, error.message);
} }
}); });
@ -119,10 +116,7 @@ router.get("/api/status-page/:slug/manifest.json", cache("1440 minutes"), async
]); ]);
if (!statusPage) { if (!statusPage) {
response.statusCode = 404; sendHttpError(response, "Not Found");
response.json({
msg: "Not Found"
});
return; return;
} }
@ -141,7 +135,7 @@ router.get("/api/status-page/:slug/manifest.json", cache("1440 minutes"), async
}); });
} catch (error) { } catch (error) {
send403(response, error.message); sendHttpError(response, error.message);
} }
}); });

@ -11,6 +11,9 @@ dayjs.extend(require("dayjs/plugin/utc"));
dayjs.extend(require("./modules/dayjs/plugin/timezone")); dayjs.extend(require("./modules/dayjs/plugin/timezone"));
dayjs.extend(require("dayjs/plugin/customParseFormat")); dayjs.extend(require("dayjs/plugin/customParseFormat"));
// Load environment variables from `.env`
require("dotenv").config();
// Check Node.js Version // Check Node.js Version
const nodeVersion = parseInt(process.versions.node.split(".")[0]); const nodeVersion = parseInt(process.versions.node.split(".")[0]);
const requiredVersion = 14; const requiredVersion = 14;
@ -138,6 +141,7 @@ const { maintenanceSocketHandler } = require("./socket-handlers/maintenance-sock
const { generalSocketHandler } = require("./socket-handlers/general-socket-handler"); const { generalSocketHandler } = require("./socket-handlers/general-socket-handler");
const { Settings } = require("./settings"); const { Settings } = require("./settings");
const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
const { pluginsHandler } = require("./socket-handlers/plugins-handler");
app.use(express.json()); app.use(express.json());
@ -166,7 +170,7 @@ let needSetup = false;
Database.init(args); Database.init(args);
await initDatabase(testMode); await initDatabase(testMode);
await server.initAfterDatabaseReady(); await server.initAfterDatabaseReady();
server.loadPlugins();
server.entryPage = await Settings.get("entryPage"); server.entryPage = await Settings.get("entryPage");
await StatusPage.loadDomainMappingList(); await StatusPage.loadDomainMappingList();
@ -574,7 +578,6 @@ let needSetup = false;
}); });
} }
} catch (error) { } catch (error) {
console.log(error);
callback({ callback({
ok: false, ok: false,
msg: error.message, msg: error.message,
@ -674,9 +677,6 @@ let needSetup = false;
throw new Error("Permission denied."); throw new Error("Permission denied.");
} }
// Reset Prometheus labels
server.monitorList[monitor.id]?.prometheus()?.remove();
bean.name = monitor.name; bean.name = monitor.name;
bean.type = monitor.type; bean.type = monitor.type;
bean.url = monitor.url; bean.url = monitor.url;
@ -689,12 +689,14 @@ let needSetup = false;
bean.retryInterval = monitor.retryInterval; bean.retryInterval = monitor.retryInterval;
bean.resendInterval = monitor.resendInterval; bean.resendInterval = monitor.resendInterval;
bean.hostname = monitor.hostname; bean.hostname = monitor.hostname;
bean.game = monitor.game;
bean.maxretries = monitor.maxretries; bean.maxretries = monitor.maxretries;
bean.port = parseInt(monitor.port); bean.port = parseInt(monitor.port);
bean.keyword = monitor.keyword; bean.keyword = monitor.keyword;
bean.ignoreTls = monitor.ignoreTls; bean.ignoreTls = monitor.ignoreTls;
bean.expiryNotification = monitor.expiryNotification; bean.expiryNotification = monitor.expiryNotification;
bean.upsideDown = monitor.upsideDown; bean.upsideDown = monitor.upsideDown;
bean.packetSize = monitor.packetSize;
bean.maxredirects = monitor.maxredirects; bean.maxredirects = monitor.maxredirects;
bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes); bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
bean.dns_resolve_type = monitor.dns_resolve_type; bean.dns_resolve_type = monitor.dns_resolve_type;
@ -714,6 +716,7 @@ let needSetup = false;
bean.authDomain = monitor.authDomain; bean.authDomain = monitor.authDomain;
bean.grpcUrl = monitor.grpcUrl; bean.grpcUrl = monitor.grpcUrl;
bean.grpcProtobuf = monitor.grpcProtobuf; bean.grpcProtobuf = monitor.grpcProtobuf;
bean.grpcServiceName = monitor.grpcServiceName;
bean.grpcMethod = monitor.grpcMethod; bean.grpcMethod = monitor.grpcMethod;
bean.grpcBody = monitor.grpcBody; bean.grpcBody = monitor.grpcBody;
bean.grpcMetadata = monitor.grpcMetadata; bean.grpcMetadata = monitor.grpcMetadata;
@ -940,13 +943,21 @@ let needSetup = false;
try { try {
checkLogin(socket); checkLogin(socket);
let bean = await R.findOne("monitor", " id = ? ", [ tag.id ]); let bean = await R.findOne("tag", " id = ? ", [ tag.id ]);
if (bean == null) {
callback({
ok: false,
msg: "Tag not found",
});
return;
}
bean.name = tag.name; bean.name = tag.name;
bean.color = tag.color; bean.color = tag.color;
await R.store(bean); await R.store(bean);
callback({ callback({
ok: true, ok: true,
msg: "Saved",
tag: await bean.toJSON(), tag: await bean.toJSON(),
}); });
@ -1490,6 +1501,7 @@ let needSetup = false;
dockerSocketHandler(socket); dockerSocketHandler(socket);
maintenanceSocketHandler(socket); maintenanceSocketHandler(socket);
generalSocketHandler(socket, server); generalSocketHandler(socket, server);
pluginsHandler(socket, server);
log.debug("server", "added all socket handlers"); log.debug("server", "added all socket handlers");

@ -2,6 +2,30 @@ const { log } = require("../../src/util");
const { Settings } = require("../settings"); const { Settings } = require("../settings");
const { sendInfo } = require("../client"); const { sendInfo } = require("../client");
const { checkLogin } = require("../util-server"); const { checkLogin } = require("../util-server");
const GameResolver = require("gamedig/lib/GameResolver");
let gameResolver = new GameResolver();
let gameList = null;
/**
* Get a game list via GameDig
* @returns {any[]}
*/
function getGameList() {
if (!gameList) {
gameList = gameResolver._readGames().games.sort((a, b) => {
if ( a.pretty < b.pretty ) {
return -1;
}
if ( a.pretty > b.pretty ) {
return 1;
}
return 0;
});
} else {
return gameList;
}
}
module.exports.generalSocketHandler = (socket, server) => { module.exports.generalSocketHandler = (socket, server) => {
@ -17,4 +41,11 @@ module.exports.generalSocketHandler = (socket, server) => {
} }
}); });
socket.on("getGameList", async (callback) => {
callback({
ok: true,
gameList: getGameList(),
});
});
}; };

@ -244,6 +244,8 @@ module.exports.maintenanceSocketHandler = (socket) => {
socket.userID, socket.userID,
]); ]);
apicache.clear();
callback({ callback({
ok: true, ok: true,
msg: "Deleted Successfully.", msg: "Deleted Successfully.",
@ -269,6 +271,8 @@ module.exports.maintenanceSocketHandler = (socket) => {
maintenanceID, maintenanceID,
]); ]);
apicache.clear();
callback({ callback({
ok: true, ok: true,
msg: "Paused Successfully.", msg: "Paused Successfully.",
@ -294,6 +298,8 @@ module.exports.maintenanceSocketHandler = (socket) => {
maintenanceID, maintenanceID,
]); ]);
apicache.clear();
callback({ callback({
ok: true, ok: true,
msg: "Resume Successfully", msg: "Resume Successfully",

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

@ -163,6 +163,7 @@ module.exports.statusPageSocketHandler = (socket) => {
statusPage.custom_css = config.customCSS; statusPage.custom_css = config.customCSS;
statusPage.show_powered_by = config.showPoweredBy; statusPage.show_powered_by = config.showPoweredBy;
statusPage.modified_date = R.isoDateTime(); statusPage.modified_date = R.isoDateTime();
statusPage.google_analytics_tag_id = config.googleAnalyticsId;
await R.store(statusPage); await R.store(statusPage);

@ -6,10 +6,10 @@ class UptimeCacheList {
static list = {}; static list = {};
/** /**
* * Get the uptime for a specific period
* @param monitorID * @param {number} monitorID
* @param duration * @param {number} duration
* @return number * @return {number}
*/ */
static getUptime(monitorID, duration) { static getUptime(monitorID, duration) {
if (UptimeCacheList.list[monitorID] && UptimeCacheList.list[monitorID][duration]) { if (UptimeCacheList.list[monitorID] && UptimeCacheList.list[monitorID][duration]) {
@ -20,6 +20,12 @@ class UptimeCacheList {
} }
} }
/**
* Add uptime for specified monitor
* @param {number} monitorID
* @param {number} duration
* @param {number} uptime Uptime to add
*/
static addUptime(monitorID, duration, uptime) { static addUptime(monitorID, duration, uptime) {
log.debug("UptimeCacheList", "addUptime: " + monitorID + " " + duration); log.debug("UptimeCacheList", "addUptime: " + monitorID + " " + duration);
if (!UptimeCacheList.list[monitorID]) { if (!UptimeCacheList.list[monitorID]) {
@ -28,6 +34,10 @@ class UptimeCacheList {
UptimeCacheList.list[monitorID][duration] = uptime; UptimeCacheList.list[monitorID][duration] = uptime;
} }
/**
* Clear cache for specified monitor
* @param {number} monitorID
*/
static clearCache(monitorID) { static clearCache(monitorID) {
log.debug("UptimeCacheList", "clearCache: " + monitorID); log.debug("UptimeCacheList", "clearCache: " + monitorID);
delete UptimeCacheList.list[monitorID]; delete UptimeCacheList.list[monitorID];

@ -10,6 +10,7 @@ const util = require("util");
const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
const { Settings } = require("./settings"); const { Settings } = require("./settings");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const { PluginsManager } = require("./plugins-manager");
// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()` // DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`
/** /**
@ -48,6 +49,20 @@ class UptimeKumaServer {
generateMaintenanceTimeslotsInterval = undefined; generateMaintenanceTimeslotsInterval = undefined;
/**
* Plugins Manager
* @type {PluginsManager}
*/
pluginsManager = null;
/**
*
* @type {{}}
*/
static monitorTypeList = {
};
static getInstance(args) { static getInstance(args) {
if (UptimeKumaServer.instance == null) { if (UptimeKumaServer.instance == null) {
UptimeKumaServer.instance = new UptimeKumaServer(args); UptimeKumaServer.instance = new UptimeKumaServer(args);
@ -86,6 +101,7 @@ class UptimeKumaServer {
this.io = new Server(this.httpServer); this.io = new Server(this.httpServer);
} }
/** Initialise app after the database has been set up */
async initAfterDatabaseReady() { async initAfterDatabaseReady() {
await CacheableDnsHttpAgent.update(); await CacheableDnsHttpAgent.update();
@ -98,6 +114,11 @@ class UptimeKumaServer {
this.generateMaintenanceTimeslotsInterval = setInterval(this.generateMaintenanceTimeslots, 60 * 1000); this.generateMaintenanceTimeslotsInterval = setInterval(this.generateMaintenanceTimeslots, 60 * 1000);
} }
/**
* Send list of monitors to client
* @param {Socket} socket
* @returns {Object} List of monitors
*/
async sendMonitorList(socket) { async sendMonitorList(socket) {
let list = await this.getMonitorJSONList(socket.userID); let list = await this.getMonitorJSONList(socket.userID);
this.io.to(socket.userID).emit("monitorList", list); this.io.to(socket.userID).emit("monitorList", list);
@ -134,6 +155,11 @@ class UptimeKumaServer {
return await this.sendMaintenanceListByUserID(socket.userID); return await this.sendMaintenanceListByUserID(socket.userID);
} }
/**
* Send list of maintenances to user
* @param {number} userID
* @returns {Object}
*/
async sendMaintenanceListByUserID(userID) { async sendMaintenanceListByUserID(userID) {
let list = await this.getMaintenanceJSONList(userID); let list = await this.getMaintenanceJSONList(userID);
this.io.to(userID).emit("maintenanceList", list); this.io.to(userID).emit("maintenanceList", list);
@ -185,6 +211,11 @@ class UptimeKumaServer {
errorLogStream.end(); errorLogStream.end();
} }
/**
* Get the IP of the client connected to the socket
* @param {Socket} socket
* @returns {string}
*/
async getClientIP(socket) { async getClientIP(socket) {
let clientIP = socket.client.conn.remoteAddress; let clientIP = socket.client.conn.remoteAddress;
@ -203,6 +234,12 @@ class UptimeKumaServer {
} }
} }
/**
* Attempt to get the current server timezone
* If this fails, fall back to environment variables and then make a
* guess.
* @returns {string}
*/
async getTimezone() { async getTimezone() {
let timezone = await Settings.get("serverTimezone"); let timezone = await Settings.get("serverTimezone");
if (timezone) { if (timezone) {
@ -214,16 +251,25 @@ class UptimeKumaServer {
} }
} }
/**
* Get the current offset
* @returns {string}
*/
getTimezoneOffset() { getTimezoneOffset() {
return dayjs().format("Z"); return dayjs().format("Z");
} }
/**
* Set the current server timezone and environment variables
* @param {string} timezone
*/
async setTimezone(timezone) { async setTimezone(timezone) {
await Settings.set("serverTimezone", timezone, "general"); await Settings.set("serverTimezone", timezone, "general");
process.env.TZ = timezone; process.env.TZ = timezone;
dayjs.tz.setDefault(timezone); dayjs.tz.setDefault(timezone);
} }
/** Load the timeslots for maintenance */
async generateMaintenanceTimeslots() { async generateMaintenanceTimeslots() {
let list = await R.find("maintenance_timeslot", " generated_next = 0 AND start_date <= DATETIME('now') "); let list = await R.find("maintenance_timeslot", " generated_next = 0 AND start_date <= DATETIME('now') ");
@ -237,9 +283,50 @@ class UptimeKumaServer {
} }
/** Stop the server */
async stop() { async stop() {
clearTimeout(this.generateMaintenanceTimeslotsInterval); clearTimeout(this.generateMaintenanceTimeslotsInterval);
} }
loadPlugins() {
this.pluginsManager = new PluginsManager(this);
}
/**
*
* @returns {PluginsManager}
*/
getPluginManager() {
return this.pluginsManager;
}
/**
*
* @param {MonitorType} monitorType
*/
addMonitorType(monitorType) {
if (monitorType instanceof MonitorType && monitorType.name) {
if (monitorType.name in UptimeKumaServer.monitorTypeList) {
log.error("", "Conflict Monitor Type name");
}
UptimeKumaServer.monitorTypeList[monitorType.name] = monitorType;
} else {
log.error("", "Invalid Monitor Type: " + monitorType.name);
}
}
/**
*
* @param {MonitorType} monitorType
*/
removeMonitorType(monitorType) {
if (UptimeKumaServer.monitorTypeList[monitorType.name] === monitorType) {
delete UptimeKumaServer.monitorTypeList[monitorType.name];
} else {
log.error("", "Remove MonitorType failed: " + monitorType.name);
}
}
} }
module.exports = { module.exports = {
@ -248,3 +335,4 @@ module.exports = {
// Must be at the end // Must be at the end
const MaintenanceTimeslot = require("./model/maintenance_timeslot"); const MaintenanceTimeslot = require("./model/maintenance_timeslot");
const { MonitorType } = require("./monitor-types/monitor-type");

@ -1,5 +1,5 @@
const tcpp = require("tcp-ping"); const tcpp = require("tcp-ping");
const Ping = require("./ping-lite"); const ping = require("@louislam/ping");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { log, genSecret } = require("../src/util"); const { log, genSecret } = require("../src/util");
const passwordHash = require("./password-hash"); const passwordHash = require("./password-hash");
@ -14,11 +14,13 @@ const mssql = require("mssql");
const { Client } = require("pg"); const { Client } = require("pg");
const postgresConParse = require("pg-connection-string").parse; const postgresConParse = require("pg-connection-string").parse;
const mysql = require("mysql2"); const mysql = require("mysql2");
const { MongoClient } = require("mongodb");
const { NtlmClient } = require("axios-ntlm"); const { NtlmClient } = require("axios-ntlm");
const { Settings } = require("./settings"); const { Settings } = require("./settings");
const grpc = require("@grpc/grpc-js"); const grpc = require("@grpc/grpc-js");
const protojs = require("protobufjs"); const protojs = require("protobufjs");
const radiusClient = require("node-radius-client"); const radiusClient = require("node-radius-client");
const redis = require("redis");
const { const {
dictionaries: { dictionaries: {
rfc2865: { file, attributes }, rfc2865: { file, attributes },
@ -26,12 +28,7 @@ const {
} = require("node-radius-utils"); } = require("node-radius-utils");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
// From ping-lite const isWindows = process.platform === /^win/.test(process.platform);
exports.WIN = /^win/.test(process.platform);
exports.LIN = /^linux/.test(process.platform);
exports.MAC = /^darwin/.test(process.platform);
exports.FBSD = /^freebsd/.test(process.platform);
exports.BSD = /bsd$/.test(process.platform);
/** /**
* Init or reset JWT secret * Init or reset JWT secret
@ -82,15 +79,16 @@ exports.tcping = function (hostname, port) {
/** /**
* Ping the specified machine * Ping the specified machine
* @param {string} hostname Hostname / address of machine * @param {string} hostname Hostname / address of machine
* @param {number} [size=56] Size of packet to send
* @returns {Promise<number>} Time for ping in ms rounded to nearest integer * @returns {Promise<number>} Time for ping in ms rounded to nearest integer
*/ */
exports.ping = async (hostname) => { exports.ping = async (hostname, size = 56) => {
try { try {
return await exports.pingAsync(hostname); return await exports.pingAsync(hostname, false, size);
} catch (e) { } catch (e) {
// If the host cannot be resolved, try again with ipv6 // If the host cannot be resolved, try again with ipv6
if (e.message.includes("service not known")) { if (e.message.includes("service not known")) {
return await exports.pingAsync(hostname, true); return await exports.pingAsync(hostname, true, size);
} else { } else {
throw e; throw e;
} }
@ -101,22 +99,29 @@ exports.ping = async (hostname) => {
* Ping the specified machine * Ping the specified machine
* @param {string} hostname Hostname / address of machine to ping * @param {string} hostname Hostname / address of machine to ping
* @param {boolean} ipv6 Should IPv6 be used? * @param {boolean} ipv6 Should IPv6 be used?
* @param {number} [size = 56] Size of ping packet to send
* @returns {Promise<number>} Time for ping in ms rounded to nearest integer * @returns {Promise<number>} Time for ping in ms rounded to nearest integer
*/ */
exports.pingAsync = function (hostname, ipv6 = false) { exports.pingAsync = function (hostname, ipv6 = false, size = 56) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ping = new Ping(hostname, { ping.promise.probe(hostname, {
ipv6 v6: ipv6,
}); min_reply: 1,
deadline: 10,
ping.send(function (err, ms, stdout) { packetSize: size,
if (err) { }).then((res) => {
reject(err); // If ping failed, it will set field to unknown
} else if (ms === null) { if (res.alive) {
reject(new Error(stdout)); resolve(res.time);
} else {
if (isWindows) {
reject(new Error(exports.convertToUTF8(res.output)));
} else { } else {
resolve(Math.round(ms)); reject(new Error(res.output));
} }
}
}).catch((err) => {
reject(err);
}); });
}); });
}; };
@ -135,7 +140,7 @@ exports.mqttAsync = function (hostname, topic, okMessage, options = {}) {
const { port, username, password, interval = 20 } = options; const { port, username, password, interval = 20 } = options;
// Adds MQTT protocol to the hostname if not already present // Adds MQTT protocol to the hostname if not already present
if (!/^(?:http|mqtt)s?:\/\//.test(hostname)) { if (!/^(?:http|mqtt|ws)s?:\/\//.test(hostname)) {
hostname = "mqtt://" + hostname; hostname = "mqtt://" + hostname;
} }
@ -145,10 +150,11 @@ exports.mqttAsync = function (hostname, topic, okMessage, options = {}) {
reject(new Error("Timeout")); reject(new Error("Timeout"));
}, interval * 1000 * 0.8); }, interval * 1000 * 0.8);
log.debug("mqtt", "MQTT connecting"); const mqttUrl = `${hostname}:${port}`;
log.debug("mqtt", `MQTT connecting to ${mqttUrl}`);
let client = mqtt.connect(hostname, { let client = mqtt.connect(mqttUrl, {
port,
username, username,
password password
}); });
@ -248,19 +254,19 @@ exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype) {
* @param {string} query The query to validate the database with * @param {string} query The query to validate the database with
* @returns {Promise<(string[]|Object[]|Object)>} * @returns {Promise<(string[]|Object[]|Object)>}
*/ */
exports.mssqlQuery = function (connectionString, query) { exports.mssqlQuery = async function (connectionString, query) {
return new Promise((resolve, reject) => { let pool;
mssql.connect(connectionString).then(pool => { try {
return pool.request() pool = new mssql.ConnectionPool(connectionString);
.query(query); await pool.connect();
}).then(result => { await pool.request().query(query);
resolve(result); pool.close();
}).catch(err => { } catch (e) {
reject(err); if (pool) {
}).finally(() => { pool.close();
mssql.close(); }
}); throw e;
}); }
}; };
/** /**
@ -280,18 +286,32 @@ exports.postgresQuery = function (connectionString, query) {
const client = new Client({ connectionString }); const client = new Client({ connectionString });
client.connect(); client.connect((err) => {
if (err) {
reject(err);
client.end();
} else {
// Connected here
try {
// No query provided by user, use SELECT 1
if (!query || (typeof query === "string" && query.trim() === "")) {
query = "SELECT 1";
}
return client.query(query) client.query(query, (err, res) => {
.then(res => { if (err) {
resolve(res);
})
.catch(err => {
reject(err); reject(err);
}) } else {
.finally(() => { resolve(res);
}
client.end(); client.end();
}); });
} catch (e) {
reject(e);
}
}
});
}); });
}; };
@ -312,11 +332,28 @@ exports.mysqlQuery = function (connectionString, query) {
reject(err); reject(err);
}) })
.finally(() => { .finally(() => {
connection.end(); connection.destroy();
}); });
}); });
}; };
/**
* Connect to and Ping a MongoDB database
* @param {string} connectionString The database connection string
* @returns {Promise<(string[]|Object[]|Object)>}
*/
exports.mongodbPing = async function (connectionString) {
let client = await MongoClient.connect(connectionString);
let dbPing = await client.db().command({ ping: 1 });
await client.close();
if (dbPing["ok"] === 1) {
return "UP";
} else {
throw Error("failed");
}
};
/** /**
* Query radius server * Query radius server
* @param {string} hostname Hostname of radius server * @param {string} hostname Hostname of radius server
@ -354,6 +391,30 @@ exports.radius = function (
}); });
}; };
/**
* Redis server ping
* @param {string} dsn The redis connection string
*/
exports.redisPingAsync = function (dsn) {
return new Promise((resolve, reject) => {
const client = redis.createClient({
url: dsn,
});
client.on("error", (err) => {
reject(err);
});
client.connect().then(() => {
client.ping().then((res, err) => {
if (err) {
reject(err);
} else {
resolve(res);
}
});
});
});
};
/** /**
* Retrieve value of setting based on key * Retrieve value of setting based on key
* @param {string} key Key of setting to retrieve * @param {string} key Key of setting to retrieve
@ -678,15 +739,27 @@ exports.filterAndJoin = (parts, connector = "") => {
}; };
/** /**
* Send a 403 response * Send an Error response
* @param {Object} res Express response object * @param {Object} res Express response object
* @param {string} [msg=""] Message to send * @param {string} [msg=""] Message to send
*/ */
module.exports.send403 = (res, msg = "") => { module.exports.sendHttpError = (res, msg = "") => {
if (msg.includes("SQLITE_BUSY") || msg.includes("SQLITE_LOCKED")) {
res.status(503).json({
"status": "fail",
"msg": msg,
});
} else if (msg.toLowerCase().includes("not found")) {
res.status(404).json({
"status": "fail",
"msg": msg,
});
} else {
res.status(403).json({ res.status(403).json({
"status": "fail", "status": "fail",
"msg": msg, "msg": msg,
}); });
}
}; };
function timeObjectConvertTimezone(obj, timezone, timeObjectToUTC = true) { function timeObjectConvertTimezone(obj, timezone, timeObjectToUTC = true) {
@ -778,6 +851,7 @@ module.exports.grpcQuery = async (options) => {
cb); cb);
}, false, false); }, false, false);
return new Promise((resolve, _) => { return new Promise((resolve, _) => {
try {
return grpcService[`${grpcMethod}`](JSON.parse(grpcBody), function (err, response) { return grpcService[`${grpcMethod}`](JSON.parse(grpcBody), function (err, response) {
const responseData = JSON.stringify(response); const responseData = JSON.stringify(response);
if (err) { if (err) {
@ -787,7 +861,7 @@ module.exports.grpcQuery = async (options) => {
data: "" data: ""
}); });
} else { } else {
log.debug("monitor:", `gRPC response: ${response}`); log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`);
return resolve({ return resolve({
code: 1, code: 1,
errorMessage: "", errorMessage: "",
@ -795,5 +869,13 @@ module.exports.grpcQuery = async (options) => {
}); });
} }
}); });
} 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: ""
});
}
}); });
}; };

@ -35,6 +35,11 @@ textarea.form-control {
color: $maintenance !important; color: $maintenance !important;
} }
.incident a,
.bg-maintenance a {
color: inherit;
}
.list-group { .list-group {
border-radius: 0.75rem; border-radius: 0.75rem;
@ -248,6 +253,11 @@ optgroup {
} }
} }
.incident a,
.bg-maintenance a {
color: inherit;
}
.form-control, .form-control,
.form-control:focus, .form-control:focus,
.form-select, .form-select,

@ -3,3 +3,7 @@ html[lang='fa'] {
font-family: 'IRANSans', 'Iranian Sans','B Nazanin', 'Tahoma', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, segoe ui, Roboto, helvetica neue, Arial, noto sans, sans-serif, apple color emoji, segoe ui emoji, segoe ui symbol, noto color emoji; font-family: 'IRANSans', 'Iranian Sans','B Nazanin', 'Tahoma', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, segoe ui, Roboto, helvetica neue, Arial, noto sans, sans-serif, apple color emoji, segoe ui emoji, segoe ui symbol, noto color emoji;
} }
} }
ul.multiselect__content {
padding-left: 0 !important;
}

@ -73,7 +73,7 @@ export default {
emits: [ "added" ], emits: [ "added" ],
data() { data() {
return { return {
model: null, modal: null,
processing: false, processing: false,
id: null, id: null,
connectionTypes: [ "socket", "tcp" ], connectionTypes: [ "socket", "tcp" ],
@ -91,11 +91,16 @@ export default {
}, },
methods: { methods: {
/** Confirm deletion of docker host */
deleteConfirm() { deleteConfirm() {
this.modal.hide(); this.modal.hide();
this.$refs.confirmDelete.show(); this.$refs.confirmDelete.show();
}, },
/**
* Show specified docker host
* @param {number} dockerHostID
*/
show(dockerHostID) { show(dockerHostID) {
if (dockerHostID) { if (dockerHostID) {
let found = false; let found = false;
@ -126,6 +131,7 @@ export default {
this.modal.show(); this.modal.show();
}, },
/** Add docker host */
submit() { submit() {
this.processing = true; this.processing = true;
this.$root.getSocket().emit("addDockerHost", this.dockerHost, this.id, (res) => { this.$root.getSocket().emit("addDockerHost", this.dockerHost, this.id, (res) => {
@ -144,6 +150,7 @@ export default {
}); });
}, },
/** Test the docker host */
test() { test() {
this.processing = true; this.processing = true;
this.$root.getSocket().emit("testDockerHost", this.dockerHost, (res) => { this.$root.getSocket().emit("testDockerHost", this.dockerHost, (res) => {
@ -152,6 +159,7 @@ export default {
}); });
}, },
/** Delete this docker host */
deleteDockerHost() { deleteDockerHost() {
this.processing = true; this.processing = true;
this.$root.getSocket().emit("deleteDockerHost", this.id, (res) => { this.$root.getSocket().emit("deleteDockerHost", this.id, (res) => {

@ -13,7 +13,10 @@
<div class="mb-3"> <div class="mb-3">
<label for="notification-type" class="form-label">{{ $t("Notification Type") }}</label> <label for="notification-type" class="form-label">{{ $t("Notification Type") }}</label>
<select id="notification-type" v-model="notification.type" class="form-select"> <select id="notification-type" v-model="notification.type" class="form-select">
<option v-for="type in notificationTypes" :key="type" :value="type">{{ $t(type) }}</option> <option v-for="(name, type) in notificationNameList.regularList" :key="type" :value="type">{{ name }}</option>
<optgroup :label="$t('notificationRegional')">
<option v-for="(name, type) in notificationNameList.regionalList" :key="type" :value="type">{{ name }}</option>
</optgroup>
</select> </select>
</div> </div>
@ -67,7 +70,7 @@
</Confirm> </Confirm>
</template> </template>
<script lang="ts"> <script>
import { Modal } from "bootstrap"; import { Modal } from "bootstrap";
import Confirm from "./Confirm.vue"; import Confirm from "./Confirm.vue";
@ -103,7 +106,91 @@ export default {
return null; return null;
} }
return NotificationFormList[this.notification.type]; return NotificationFormList[this.notification.type];
},
notificationNameList() {
let regularList = {
"alerta": "Alerta",
"AlertNow": "AlertNow",
"apprise": this.$t("apprise"),
"Bark": "Bark",
"clicksendsms": "ClickSend SMS",
"discord": "Discord",
"GoogleChat": "Google Chat (Google Workspace)",
"gorush": "Gorush",
"gotify": "Gotify",
"HomeAssistant": "Home Assistant",
"Kook": "Kook",
"line": "LINE Messenger",
"LineNotify": "LINE Notify",
"lunasea": "LunaSea",
"matrix": "Matrix",
"mattermost": "Mattermost",
"ntfy": "Ntfy",
"octopush": "Octopush",
"OneBot": "OneBot",
"PagerDuty": "PagerDuty",
"pushbullet": "Pushbullet",
"PushByTechulus": "Push by Techulus",
"pushover": "Pushover",
"pushy": "Pushy",
"rocket.chat": "Rocket.Chat",
"signal": "Signal",
"slack": "Slack",
"squadcast": "SquadCast",
"SMSEagle": "SMSEagle",
"smtp": this.$t("smtp"),
"stackfield": "Stackfield",
"teams": "Microsoft Teams",
"telegram": "Telegram",
"Splunk": "Splunk",
"webhook": "Webhook",
"GoAlert": "GoAlert",
"ZohoCliq": "ZohoCliq"
};
// Put notifications here if it's not supported in most regions or its documentation is not in English
let regionalList = {
"AliyunSMS": "AliyunSMS (阿里云短信服务)",
"DingDing": "DingDing (钉钉自定义机器人)",
"Feishu": "Feishu (飞书)",
"FreeMobile": "FreeMobile (mobile.free.fr)",
"PushDeer": "PushDeer",
"promosms": "PromoSMS",
"serwersms": "SerwerSMS.pl",
"SMSManager": "SmsManager (smsmanager.cz)",
"WeCom": "WeCom (企业微信群机器人)",
"ServerChan": "ServerChan (Server酱)",
};
// Sort by notification name
// No idea how, but it works
// https://stackoverflow.com/questions/1069666/sorting-object-property-by-values
let sort = (list2) => {
return Object.entries(list2)
.sort(([ , a ], [ , b ]) => a.localeCompare(b))
.reduce((r, [ k, v ]) => ({
...r,
[k]: v
}), {});
};
return {
regularList: sort(regularList),
regionalList: sort(regionalList),
};
},
notificationFullNameList() {
let list = {};
for (let [ key, value ] of Object.entries(this.notificationNameList.regularList)) {
list[key] = value;
} }
for (let [ key, value ] of Object.entries(this.notificationNameList.regionalList)) {
list[key] = value;
}
return list;
},
}, },
watch: { watch: {
@ -203,11 +290,12 @@ export default {
* @return {string} * @return {string}
*/ */
getUniqueDefaultName(notificationKey) { getUniqueDefaultName(notificationKey) {
let index = 1; let index = 1;
let name = ""; let name = "";
do { do {
name = this.$t("defaultNotificationName", { name = this.$t("defaultNotificationName", {
notification: this.$t(notificationKey).replace(/\(.+\)/, "").trim(), notification: this.notificationFullNameList[notificationKey].replace(/\(.+\)/, "").trim(),
number: index++ number: index++
}); });
} while (this.$root.notificationList.find(it => it.name === name)); } while (this.$root.notificationList.find(it => it.name === name));

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

@ -41,7 +41,7 @@ export default {
}, },
computed: { computed: {
displayText() { displayText() {
if (this.item.value === "") { if (this.item.value === "" || this.item.value === undefined) {
return this.item.name; return this.item.name;
} else { } else {
return `${this.item.name}: ${this.item.value}`; return `${this.item.name}: ${this.item.value}`;

@ -0,0 +1,376 @@
<template>
<form @submit.prevent="submit">
<div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 id="exampleModalLabel" class="modal-title">
{{ $t("Edit Tag") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div class="modal-body">
<div class="mb-3">
<label for="tag-name" class="form-label">{{ $t("Name") }}</label>
<input id="tag-name" v-model="tag.name" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="tag-color" class="form-label">{{ $t("color") }}</label>
<div class="d-flex">
<div class="col-8 pe-1">
<vue-multiselect
v-model="selectedColor"
:options="colorOptions"
:multiple="false"
:searchable="true"
:placeholder="$t('color')"
track-by="color"
label="name"
select-label=""
deselect-label=""
>
<template #option="{ option }">
<div
class="mx-2 py-1 px-3 rounded d-inline-flex"
style="height: 24px; color: white;"
:style="{ backgroundColor: option.color + ' !important' }"
>
<span>{{ option.name }}</span>
</div>
</template>
<template #singleLabel="{ option }">
<div
class="py-1 px-3 rounded d-inline-flex"
style="height: 24px; color: white;"
:style="{ backgroundColor: option.color + ' !important' }"
>
<span>{{ option.name }}</span>
</div>
</template>
</vue-multiselect>
</div>
<div class="col-4 ps-1">
<input id="tag-color-hex" v-model="tag.color" type="text" class="form-control">
</div>
</div>
</div>
<div class="mb-3">
<label for="tag-monitors" class="form-label">{{ $tc("Monitor", selectedMonitors.length) }}</label>
<div class="tag-monitors-list">
<router-link v-for="monitor in selectedMonitors" :key="monitor.id" class="d-flex align-items-center justify-content-between text-decoration-none tag-monitors-list-row py-2 px-3" :to="monitorURL(monitor.id)" @click="modal.hide()">
<span>{{ monitor.name }}</span>
<button type="button" class="btn-rm-monitor btn btn-outline-danger ms-2 py-1" @click.stop.prevent="removeMonitor(monitor.id)">
<font-awesome-icon class="" icon="times" />
</button>
</router-link>
</div>
<div v-if="allMonitorList.length > 0" class="pt-3 px-3">
<label class="form-label">{{ $t("Add a monitor") }}:</label>
<select v-model="selectedAddMonitor" class="form-control">
<option v-for="monitor in allMonitorList" :key="monitor.id" :value="monitor">{{ monitor.name }}</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button v-if="tag" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
{{ $t("Delete") }}
</button>
<button type="submit" class="btn btn-primary" :disabled="processing">
<div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
{{ $t("Save") }}
</button>
</div>
</div>
</div>
</div>
</form>
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteTag">
{{ $t("confirmDeleteTagMsg") }}
</Confirm>
</template>
<script>
import { Modal } from "bootstrap";
import Confirm from "./Confirm.vue";
import VueMultiselect from "vue-multiselect";
import { colorOptions } from "../util-frontend";
import { useToast } from "vue-toastification";
import { getMonitorRelativeURL } from "../util.ts";
const toast = useToast();
export default {
components: {
VueMultiselect,
Confirm,
},
props: {
updated: {
type: Function,
default: () => {},
}
},
data() {
return {
modal: null,
processing: false,
selectedColor: {
name: null,
color: null,
},
tag: {
id: null,
name: "",
color: "",
// Do not set default value here, please scroll to show()
},
monitors: [],
removingMonitor: [],
addingMonitor: [],
selectedAddMonitor: null,
};
},
computed: {
colorOptions() {
if (!colorOptions(this).find(option => option.color === this.tag.color)) {
return colorOptions(this).concat(
{
name: "custom",
color: this.tag.color
});
} else {
return colorOptions(this);
}
},
selectedMonitors() {
return this.monitors
.concat(Object.values(this.$root.monitorList).filter(monitor => this.addingMonitor.includes(monitor.id)))
.filter(monitor => !this.removingMonitor.includes(monitor.id));
},
allMonitorList() {
return Object.values(this.$root.monitorList).filter(monitor => !this.selectedMonitors.includes(monitor));
},
},
watch: {
// Set color option to "Custom" when a unknown color is entered
"tag.color"(to, from) {
if (colorOptions(this).find(x => x.color === to) == null) {
this.selectedColor.name = this.$t("Custom");
this.selectedColor.color = to;
}
},
selectedColor(to, from) {
if (to != null) {
this.tag.color = to.color;
}
},
/**
* Selected a monitor and add to the list.
*/
selectedAddMonitor(monitor) {
if (monitor) {
if (this.removingMonitor.includes(monitor.id)) {
this.removingMonitor = this.removingMonitor.filter(id => id !== monitor.id);
} else {
this.addingMonitor.push(monitor.id);
}
this.selectedAddMonitor = null;
}
},
},
mounted() {
this.modal = new Modal(this.$refs.modal);
},
methods: {
/**
* Show confirmation for deleting a tag
*/
deleteConfirm() {
this.$refs.confirmDelete.show();
},
/**
* Load tag information for display in the edit dialog
* @param {Object} tag tag object to edit
* @returns {void}
*/
show(tag) {
if (tag) {
this.selectedColor = this.colorOptions.find(x => x.color === tag.color) ?? {
name: this.$t("Custom"),
color: tag.color
};
this.tag.id = tag.id;
this.tag.name = tag.name;
this.tag.color = tag.color;
this.monitors = this.monitorsByTag(tag.id);
this.removingMonitor = [];
this.addingMonitor = [];
this.selectedAddMonitor = null;
}
this.modal.show();
},
/**
* Submit tag and monitorTag changes to server
* @returns {void}
*/
async submit() {
this.processing = true;
let editResult = true;
for (let addId of this.addingMonitor) {
await this.addMonitorTagAsync(this.tag.id, addId, "").then((res) => {
if (!res.ok) {
toast.error(res.msg);
editResult = false;
}
});
}
for (let removeId of this.removingMonitor) {
this.monitors.find(monitor => monitor.id === removeId)?.tags.forEach(async (monitorTag) => {
await this.deleteMonitorTagAsync(this.tag.id, removeId, monitorTag.value).then((res) => {
if (!res.ok) {
toast.error(res.msg);
editResult = false;
}
});
});
}
this.$root.getSocket().emit("editTag", this.tag, (res) => {
this.$root.toastRes(res);
this.processing = false;
if (res.ok && editResult) {
this.updated();
this.modal.hide();
}
});
},
/**
* Delete the editing tag from server
* @returns {void}
*/
deleteTag() {
this.processing = true;
this.$root.getSocket().emit("deleteTag", this.tag.id, (res) => {
this.$root.toastRes(res);
this.processing = false;
if (res.ok) {
this.updated();
this.modal.hide();
}
});
},
/**
* Remove a monitor from the monitors list locally
* @param {number} id id of the tag to remove
* @returns {void}
*/
removeMonitor(id) {
if (this.addingMonitor.includes(id)) {
this.addingMonitor = this.addingMonitor.filter(x => x !== id);
} else {
this.removingMonitor.push(id);
}
},
/**
* Get monitors which has a specific tag locally
* @param {number} tagId id of the tag to filter
* @returns {Object[]} list of monitors which has a specific tag
*/
monitorsByTag(tagId) {
return Object.values(this.$root.monitorList).filter((monitor) => {
return monitor.tags.find(monitorTag => monitorTag.tag_id === tagId);
});
},
/**
* Get URL of monitor
* @param {number} id ID of monitor
* @returns {string} Relative URL of monitor
*/
monitorURL(id) {
return getMonitorRelativeURL(id);
},
/**
* Add a tag to a monitor asynchronously
* @param {number} tagId ID of tag to add
* @param {number} monitorId ID of monitor to add tag to
* @param {string} value Value of tag
* @returns {Promise<void>}
*/
addMonitorTagAsync(tagId, monitorId, value) {
return new Promise((resolve) => {
this.$root.getSocket().emit("addMonitorTag", tagId, monitorId, value, resolve);
});
},
/**
* Delete a tag from a monitor asynchronously
* @param {number} tagId ID of tag to remove
* @param {number} monitorId ID of monitor to remove tag from
* @param {string} value Value of tag
* @returns {Promise<void>}
*/
deleteMonitorTagAsync(tagId, monitorId, value) {
return new Promise((resolve) => {
this.$root.getSocket().emit("deleteMonitorTag", tagId, monitorId, value, resolve);
});
},
},
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.dark {
.modal-dialog .form-text, .modal-dialog p {
color: $dark-font-color;
}
}
.btn-rm-monitor {
padding-left: 11px;
padding-right: 11px;
}
.tag-monitors-list {
max-height: 40vh;
overflow-y: scroll;
}
.tag-monitors-list .tag-monitors-list-row {
cursor: pointer;
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
.dark & {
border-bottom: 1px solid $dark-border-color;
}
&:hover {
background-color: $highlight-white;
}
.dark &:hover {
background-color: $dark-bg2;
}
}
</style>

@ -130,6 +130,7 @@
import { Modal } from "bootstrap"; import { Modal } from "bootstrap";
import VueMultiselect from "vue-multiselect"; import VueMultiselect from "vue-multiselect";
import { useToast } from "vue-toastification"; import { useToast } from "vue-toastification";
import { colorOptions } from "../util-frontend";
import Tag from "../components/Tag.vue"; import Tag from "../components/Tag.vue";
const toast = useToast(); const toast = useToast();
@ -176,24 +177,7 @@ export default {
return this.preSelectedTags.concat(this.newTags).filter(tag => !this.deleteTags.find(monitorTag => monitorTag.id === tag.id)); return this.preSelectedTags.concat(this.newTags).filter(tag => !this.deleteTags.find(monitorTag => monitorTag.id === tag.id));
}, },
colorOptions() { colorOptions() {
return [ return colorOptions(this);
{ name: this.$t("Gray"),
color: "#4B5563" },
{ name: this.$t("Red"),
color: "#DC2626" },
{ name: this.$t("Orange"),
color: "#D97706" },
{ name: this.$t("Green"),
color: "#059669" },
{ name: this.$t("Blue"),
color: "#2563EB" },
{ name: this.$t("Indigo"),
color: "#4F46E5" },
{ name: this.$t("Purple"),
color: "#7C3AED" },
{ name: this.$t("Pink"),
color: "#DB2777" },
];
}, },
validateDraftTag() { validateDraftTag() {
let nameInvalid = false; let nameInvalid = false;
@ -204,7 +188,7 @@ export default {
nameInvalid = false; nameInvalid = false;
valueInvalid = false; valueInvalid = false;
invalid = false; invalid = false;
} else if (this.existingTags.filter(tag => tag.name === this.newDraftTag.name).length > 0) { } else if (this.existingTags.filter(tag => tag.name === this.newDraftTag.name).length > 0 && this.newDraftTag.select == null) {
// Try to create new tag with existing name // Try to create new tag with existing name
nameInvalid = true; nameInvalid = true;
invalid = true; invalid = true;

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

Loading…
Cancel
Save