Browse Source

Merge remote-tracking branch 'upstream/master' into feature/release-management

# Conflicts:
#	package-lock.json
#	package.json
philippdormann/feature/release-management
Philipp Dormann 3 years ago
parent
commit
64064a63e9
No known key found for this signature in database GPG Key ID: 3BB9ADD52DCA4314
  1. 35
      .dockerignore
  2. 3
      .editorconfig
  3. 73
      .eslintrc.js
  4. 10
      .github/ISSUE_TEMPLATE/ask-for-help.md
  5. 34
      .github/ISSUE_TEMPLATE/bug_report.md
  6. 20
      .github/ISSUE_TEMPLATE/feature_request.md
  7. 1
      .gitignore
  8. 3
      .stylelintrc
  9. 128
      CODE_OF_CONDUCT.md
  10. 104
      CONTRIBUTING.md
  11. 56
      README.md
  12. 7
      app.json
  13. BIN
      db/kuma.db
  14. 37
      db/patch1.sql
  15. 9
      db/patch2.sql
  16. 37
      db/patch3.sql
  17. 40
      db/patch4.sql
  18. 35
      dockerfile
  19. 19
      extra/healthcheck.js
  20. 40
      extra/mark-as-nightly.js
  21. 3
      index.html
  22. 20599
      package-lock.json
  23. 82
      package.json
  24. BIN
      public/apple-touch-icon.png
  25. 3
      public/robots.txt
  26. 51
      server/auth.js
  27. 120
      server/database.js
  28. 10
      server/model/heartbeat.js
  29. 218
      server/model/monitor.js
  30. 323
      server/notification.js
  31. 8
      server/password-hash.js
  32. 84
      server/ping-lite.js
  33. 59
      server/prometheus.js
  34. 320
      server/server.js
  35. 114
      server/util-server.js
  36. 20
      server/util.js
  37. 8
      src/App.vue
  38. 3
      src/assets/vars.scss
  39. 44
      src/components/Confirm.vue
  40. 32
      src/components/CountUp.vue
  41. 27
      src/components/Datetime.vue
  42. 84
      src/components/HeartbeatBar.vue
  43. 25
      src/components/Login.vue
  44. 377
      src/components/NotificationDialog.vue
  45. 30
      src/components/Status.vue
  46. 38
      src/components/Uptime.vue
  47. 10
      src/icon.js
  48. 8
      src/layouts/EmptyLayout.vue
  49. 84
      src/layouts/Layout.vue
  50. 55
      src/main.js
  51. 113
      src/mixins/socket.js
  52. 34
      src/pages/Dashboard.vue
  53. 96
      src/pages/DashboardHome.vue
  54. 161
      src/pages/Details.vue
  55. 145
      src/pages/EditMonitor.vue
  56. 167
      src/pages/Settings.vue
  57. 28
      src/pages/Setup.vue
  58. 718
      src/util-frontend.js
  59. 34
      src/util.js
  60. 43
      src/util.ts
  61. 14
      tsconfig.json

35
.dockerignore

@ -1,4 +1,37 @@
/.idea
/dist
/node_modules
/data/kuma.db
/data
/.do
**/.dockerignore
**/.git
**/.gitignore
**/docker-compose*
**/[Dd]ockerfile*
LICENSE
README.md
.editorconfig
.vscode
.eslint*
.stylelint*
/.github
package-lock.json
yarn.lock
app.json
CODE_OF_CONDUCT.md
CONTRIBUTING.md
### .gitignore content (commented rules are duplicated)
#node_modules
.DS_Store
#dist
dist-ssr
*.local
#.idea
#/data
#!/data/.gitkeep
#.vscode
### End of .gitignore content

3
.editorconfig

@ -16,3 +16,6 @@ indent_size = 2
[*.yml]
indent_size = 2
[*.vue]
trim_trailing_whitespace = false

73
.eslintrc.js

@ -0,0 +1,73 @@
module.exports = {
env: {
browser: true,
commonjs: true,
es2020: true,
node: true,
},
extends: [
"eslint:recommended",
"plugin:vue/vue3-recommended",
],
parser: "vue-eslint-parser",
parserOptions: {
parser: "@babel/eslint-parser",
sourceType: "module",
requireConfigFile: false,
},
rules: {
// override/add rules settings here, such as:
// 'vue/no-unused-vars': 'error'
"no-unused-vars": "warn",
indent: [
"error",
4,
{
ignoredNodes: ["TemplateLiteral"],
SwitchCase: 1,
},
],
quotes: ["warn", "double"],
//semi: ['off', 'never'],
"vue/html-indent": ["warn", 4], // default: 2
"vue/max-attributes-per-line": "off",
"vue/singleline-html-element-content-newline": "off",
"vue/html-self-closing": "off",
"no-multi-spaces": ["error", {
ignoreEOLComments: true,
}],
"curly": "error",
"object-curly-spacing": ["error", "always"],
"object-curly-newline": "off",
"object-property-newline": "error",
"comma-spacing": "error",
"brace-style": "error",
"no-var": "error",
"key-spacing": "warn",
"keyword-spacing": "warn",
"space-infix-ops": "warn",
"arrow-spacing": "warn",
"no-trailing-spaces": "warn",
"no-constant-condition": ["error", {
"checkLoops": false,
}],
"space-before-blocks": "warn",
//'no-console': 'warn',
"no-extra-boolean-cast": "off",
"no-multiple-empty-lines": ["warn", {
"max": 1,
"maxBOF": 0,
}],
"lines-between-class-members": ["warn", "always", {
exceptAfterSingleLine: true,
}],
"no-unneeded-ternary": "error",
"no-else-return": ["error", {
"allowElseIf": false,
}],
"array-bracket-newline": ["error", "consistent"],
"eol-last": ["error", "always"],
//'prefer-template': 'error',
"comma-dangle": ["warn", "only-multiline"],
},
}

10
.github/ISSUE_TEMPLATE/ask-for-help.md

@ -0,0 +1,10 @@
---
name: Ask for help
about: You can ask any question related to Uptime Kuma.
title: ''
labels: help
assignees: ''
---

34
.github/ISSUE_TEMPLATE/bug_report.md

@ -0,0 +1,34 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- Uptime Kuma Version:
- Using Docker?: Yes/No
- OS:
- Browser:
**Additional context**
Add any other context about the problem here.

20
.github/ISSUE_TEMPLATE/feature_request.md

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

1
.gitignore

@ -7,3 +7,4 @@ dist-ssr
/data
!/data/.gitkeep
.vscode

3
.stylelintrc

@ -0,0 +1,3 @@
{
"extends": "stylelint-config-recommended",
}

128
CODE_OF_CONDUCT.md

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
louis@uptimekuma.louislam.net.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

104
CONTRIBUTING.md

@ -0,0 +1,104 @@
# 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 structed and commented so well, lol. Sorry about that.
The project was created with vite.js (vue3). Then I created a sub-directory called "server" for server part. Both frontend and backend share the same package.json.
The frontend code build into "dist" directory. The server uses "dist" as root. This is how production is working.
Your IDE should follow the config in ".editorconfig". The most special thing is I set it to 4 spaces indentation. I know 2 spaces indentation became a kind of standard nowadays for js, but my eyes is not so comfortable for this. In my opinion, there is no callback-hell nowadays, it is good to go back 4 spaces world again.
# 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.
For example, recently, because I am not a python expert, I spent a 2 hours to resolve all problems in order to install and use the Apprise cli. Apprise requires so many hidden requirements, I have to figure out myself how to solve the problems by Google search for my OS. That is painful. I do not want Uptime Kuma to be like this way, so:
- 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
- Single container for Docker users, no very complex docker-composer file. Just map the volume and expose the port, then good to go
- All settings in frontend.
- Easy to use
# Tools
- Node.js >= 14
- Git
- IDE that supports .editorconfig (I am using Intellji Idea)
- A SQLite tool (I am using SQLite Expert Personal)
# Prepare the dev
```bash
npm install
```
# Backend Dev
```bash
npm run start-server
# Or
node server/server.js
```
It binds to 0.0.0.0:3001 by default.
## Backend Details
It is mainly a socket.io app + express.js.
express.js is just used for serving the frontend built files (index.html, .js and .css etc.)
# Frontend Dev
Start frontend dev server. Hot-reload enabled in this way. It binds to 0.0.0.0:3000.
```bash
npm run dev
```
PS: You can ignore those scss warnings, those warnings are from Bootstrap that I cannot fix.
You can use Vue Devtool Chrome extension for debugging.
After the frontend server started. It cannot connect to the websocket server even you have started the server. You need to tell the frontend that is a dev env by running this in DevTool console and refresh:
```javascript
localStorage.dev = "dev";
```
So that the frontend will try to connect websocket server in 3001.
Alternately, you can specific NODE_ENV to "development".
## Build the frontend
```bash
npm run build
```
## Frontend Details
Uptime Kuma Frontend is a single page application (SPA). Most paths are handled by Vue Router.
The router in "src/main.js"
As you can see, most data in frontend is stored in root level, even though you changed the current router to any other pages.
The data and socket logic in "src/mixins/socket.js"
# Database Migration
TODO
# Unit Test
Yes, no unit test for now. I know it is very important, but at the same time my spare time is very limited. I want to implement my ideas first. I will go back to this in some points.

56
README.md

@ -1,5 +1,8 @@
# 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>
<div align="center" width="100%">
<img src="./public/icon.svg" width="128" alt="" />
</div>
@ -12,18 +15,19 @@ It is a self-hosted monitoring tool like "Uptime Robot".
* Monitoring uptime for HTTP(s) / TCP / Ping.
* Fancy, Reactive, Fast UI/UX.
* Notifications via Webhook, Telegram, Discord and email (SMTP).
* Notifications via Webhook, Telegram, Discord, Gotify, Slack, Pushover, Email (SMTP) and more by Apprise.
* 20 seconds interval.
# How to Use
### Docker
## Docker
```bash
# Create a volume
docker volume create uptime-kuma
# Start the container
docker run -d --restart=always -p 3001:3001 -v uptime-kuma:/app/data --name uptime-kuma louislam/uptime-kuma
docker run -d --restart=always -p 3001:3001 -v uptime-kuma:/app/data --name uptime-kuma louislam/uptime-kuma:1
```
Browse to http://localhost:3001 after started.
@ -31,12 +35,12 @@ Browse to http://localhost:3001 after started.
Change Port and Volume
```bash
docker run -d --restart=always -p <YOUR_PORT>:3001 -v <YOUR_DIR OR VOLUME>:/app/data --name uptime-kuma louislam/uptime-kuma
docker run -d --restart=always -p <YOUR_PORT>:3001 -v <YOUR_DIR OR VOLUME>:/app/data --name uptime-kuma louislam/uptime-kuma:1
```
### Without Docker
## Without Docker
Required Tools: Node.js >= 14, git and pm2.
Required Tools: Node.js >= 14, git and pm2.
```bash
git clone https://github.com/louislam/uptime-kuma.git
@ -58,11 +62,24 @@ pm2 start npm --name uptime-kuma -- run start-server -- --port=80 --hostname=0.0
Browse to http://localhost:3001 after started.
### One-click Deploy to DigitalOcean
[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/louislam/uptime-kuma/tree/master&refcode=e2c7eb658434)
## (Optional) One more step for Reverse Proxy
This is optional for someone who want to do reverse proxy.
Unlikely other web apps, Uptime Kuma is based on WebSocket. You need two more headers **"Upgrade"** and **"Connection"** in order to reverse proxy WebSocket.
Please read wiki for more info:
https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy
## One-click Deploy
Choose Cheapest Plan is enough. (US$ 5)
<!---
Abort. Heroku instance killed the server.js if idle, stupid.
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/louislam/uptime-kuma/tree/1.0.8)
-->
[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/louislam/uptime-kuma/tree/master&refcode=e2c7eb658434)
# How to Update
@ -70,16 +87,23 @@ Choose Cheapest Plan is enough. (US$ 5)
Re-pull the latest docker image and create another container with the same volume.
PS: For every new release, it takes some time to build the docker image, please be patient if it is not available yet.
### Without Docker
```bash
git fetch --all
git checkout 1.0.3 --force
git checkout 1.0.8 --force
npm install
npm run build
pm2 restart uptime-kuma
```
# What's Next?
I will mark requests/issues to the next milestone.
https://github.com/louislam/uptime-kuma/milestones
# More Screenshots
Settings Page:
@ -93,13 +117,21 @@ Telegram Notification Sample:
# Motivation
* I was looking for a self-hosted monitoring tool like "Uptime Robot", but it is hard to find a suitable one. One of the close one is statping. Unfortunately, it is not stable and unmaintained.
* I was looking for a self-hosted monitoring tool like "Uptime Robot", but it is hard to find a suitable one. One of the close one is statping. Unfortunately, it is not stable and unmaintained.
* Want to build a fancy UI.
* Learn Vue 3 and vite.js.
* Show the power of Bootstrap 5.
* Show the power of Bootstrap 5.
* Try to use WebSocket with SPA instead of REST API.
* Deploy my first Docker image to Docker Hub.
If you love this project, please consider giving me a ⭐.
# Contribute
If you want to report a bug or request a new feature. Free feel to open a new issue.
If you want to modify Uptime Kuma, this guideline maybe useful for you: https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md
English proofreading is needed too, because my grammar is not that great sadly. Feel free to correct my grammar in this Readme, source code or wiki.

7
app.json

@ -0,0 +1,7 @@
{
"name": "Uptime Kuma",
"description": "A fancy self-hosted monitoring tool",
"repository": "https://github.com/louislam/uptime-kuma",
"logo": "https://raw.githubusercontent.com/louislam/uptime-kuma/master/public/icon.png",
"keywords": ["node", "express", "socket-io", "uptime-kuma", "uptime"]
}

BIN
db/kuma.db

Binary file not shown.

37
db/patch1.sql

@ -0,0 +1,37 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
-- Change Monitor.created_date from "TIMESTAMP" to "DATETIME"
-- SQL Generated by Intellij Idea
PRAGMA foreign_keys=off;
BEGIN TRANSACTION;
create table monitor_dg_tmp
(
id INTEGER not null
primary key autoincrement,
name VARCHAR(150),
active BOOLEAN default 1 not null,
user_id INTEGER
references user
on update cascade on delete set null,
interval INTEGER default 20 not null,
url TEXT,
type VARCHAR(20),
weight INTEGER default 2000,
hostname VARCHAR(255),
port INTEGER,
created_date DATETIME,
keyword VARCHAR(255)
);
insert into monitor_dg_tmp(id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword) select id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword from monitor;
drop table monitor;
alter table monitor_dg_tmp rename to monitor;
create index user_id on monitor (user_id);
COMMIT;
PRAGMA foreign_keys=on;

9
db/patch2.sql

@ -0,0 +1,9 @@
BEGIN TRANSACTION;
CREATE TABLE monitor_tls_info (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
monitor_id INTEGER NOT NULL,
info_json TEXT
);
COMMIT;

37
db/patch3.sql

@ -0,0 +1,37 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
-- Add maxretries column to monitor
PRAGMA foreign_keys=off;
BEGIN TRANSACTION;
create table monitor_dg_tmp
(
id INTEGER not null
primary key autoincrement,
name VARCHAR(150),
active BOOLEAN default 1 not null,
user_id INTEGER
references user
on update cascade on delete set null,
interval INTEGER default 20 not null,
url TEXT,
type VARCHAR(20),
weight INTEGER default 2000,
hostname VARCHAR(255),
port INTEGER,
created_date DATETIME,
keyword VARCHAR(255),
maxretries INTEGER NOT NULL DEFAULT 0
);
insert into monitor_dg_tmp(id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword) select id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword from monitor;
drop table monitor;
alter table monitor_dg_tmp rename to monitor;
create index user_id on monitor (user_id);
COMMIT;
PRAGMA foreign_keys=on;

40
db/patch4.sql

@ -0,0 +1,40 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
-- OK.... serious wrong, missing maxretries column
-- Developers should patch it manually if you have missing the maxretries column
PRAGMA foreign_keys=off;
BEGIN TRANSACTION;
create table monitor_dg_tmp
(
id INTEGER not null
primary key autoincrement,
name VARCHAR(150),
active BOOLEAN default 1 not null,
user_id INTEGER
references user
on update cascade on delete set null,
interval INTEGER default 20 not null,
url TEXT,
type VARCHAR(20),
weight INTEGER default 2000,
hostname VARCHAR(255),
port INTEGER,
created_date DATETIME,
keyword VARCHAR(255),
maxretries INTEGER NOT NULL DEFAULT 0,
ignore_tls BOOLEAN default 0 not null,
upside_down BOOLEAN default 0 not null
);
insert into monitor_dg_tmp(id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword, maxretries) select id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword, maxretries from monitor;
drop table monitor;
alter table monitor_dg_tmp rename to monitor;
create index user_id on monitor (user_id);
COMMIT;
PRAGMA foreign_keys=on;

35
dockerfile

@ -1,18 +1,31 @@
FROM node:14-alpine3.14
# DON'T UPDATE TO alpine3.13, 1.14, see #41.
FROM node:14-alpine3.12 AS release
WORKDIR /app
# sqlite have to build on arm
# TODO: use prebuilt sqlite for arm, because it is very very slow.
RUN apk add --no-cache make g++ python3
RUN ln -s /usr/bin/python3 /usr/bin/python
# split the sqlite install here, so that it can caches the arm prebuilt
RUN apk add --no-cache --virtual .build-deps make g++ python3 python3-dev && \
ln -s /usr/bin/python3 /usr/bin/python && \
npm install sqlite3@5.0.2 bcrypt@5.0.1 && \
apk del .build-deps
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
# Touching above code may causes sqlite3 re-compile again, painful slow.
# Install apprise
RUN apk add --no-cache python3 py3-cryptography py3-pip py3-six py3-yaml py3-click py3-markdown py3-requests py3-requests-oauthlib
RUN pip3 --no-cache-dir install apprise && \
rm -rf /root/.cache
# Remove built tools
RUN apk del make g++
# New things add here
COPY . .
RUN npm install && \
npm run build && \
npm prune
EXPOSE 3001
VOLUME ["/app/data"]
HEALTHCHECK --interval=60s --timeout=30s --start-period=300s CMD node extra/healthcheck.js
CMD ["npm", "run", "start-server"]
FROM release AS nightly
RUN npm run mark-as-nightly

19
extra/healthcheck.js

@ -0,0 +1,19 @@
var http = require("http");
var options = {
host: "localhost",
port: "3001",
timeout: 2000,
};
var request = http.request(options, (res) => {
console.log(`STATUS: ${res.statusCode}`);
if (res.statusCode == 200) {
process.exit(0);
} else {
process.exit(1);
}
});
request.on("error", function (err) {
console.log("ERROR");
process.exit(1);
});
request.end();

40
extra/mark-as-nightly.js

@ -0,0 +1,40 @@
/**
* String.prototype.replaceAll() polyfill
* https://gomakethings.com/how-to-replace-a-section-of-a-string-with-another-one-with-vanilla-js/
* @author Chris Ferdinandi
* @license MIT
*/
if (!String.prototype.replaceAll) {
String.prototype.replaceAll = function(str, newStr){
// If a regex pattern
if (Object.prototype.toString.call(str).toLowerCase() === '[object regexp]') {
return this.replace(str, newStr);
}
// If a string
return this.replace(new RegExp(str, 'g'), newStr);
};
}
const pkg = require('../package.json');
const fs = require("fs");
const oldVersion = pkg.version
const newVersion = oldVersion + "-nightly"
console.log("Old Version: " + oldVersion)
console.log("New Version: " + newVersion)
if (newVersion) {
// Process package.json
pkg.version = newVersion
pkg.scripts.setup = pkg.scripts.setup.replaceAll(oldVersion, newVersion)
pkg.scripts["build-docker"] = pkg.scripts["build-docker"].replaceAll(oldVersion, newVersion)
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n")
// Process README.md
if (fs.existsSync("README.md")) {
fs.writeFileSync("README.md", fs.readFileSync("README.md", 'utf8').replaceAll(oldVersion, newVersion))
}
}

3
index.html

@ -3,7 +3,10 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#5cdd8b" />
<meta name="description" content="Uptime Kuma monitoring tool" />
<title>Uptime Kuma</title>
</head>
<body>

20599
package-lock.json

File diff suppressed because it is too large

82
package.json

@ -1,66 +1,74 @@
{
"name": "uptime-kuma",
"version": "1.0.4",
"version": "1.0.8",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/louislam/uptime-kuma.git"
},
"engines": {
"node": "14.*"
},
"scripts": {
"dev": "vite --host",
"release": "release-it",
"start": "npm run start-server",
"start-server": "node server/server.js",
"update": "",
"build": "vite build",
"vite-preview-dist": "vite preview --host",
"build-docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:1.0.3 . --push",
"build-docker-nightly": "docker buildx build --platform linux/amd64 -t louislam/uptime-kuma:nightly . --push",
"setup": "git checkout 1.0.3 && npm install && npm run build",
"version-global-replace": "node extra/version-global-replace.js"
"build-docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:1.0.8 --target release . --push",
"build-docker-nightly": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly --target nightly . --push",
"build-docker-nightly-amd64": "docker buildx build --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push",
"setup": "git checkout 1.0.8 && npm install && npm run build",
"version-global-replace": "node extra/version-global-replace.js",
"mark-as-nightly": "node extra/mark-as-nightly.js"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.35",
"@fortawesome/free-regular-svg-icons": "^5.15.3",
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/vue-fontawesome": "^3.0.0-4",
"@popperjs/core": "^2.9.2",
"args-parser": "^1.3.0",
"axios": "^0.21.1",
"bcrypt": "^5.0.1",
"bootstrap": "^5.0.0",
"dayjs": "^1.10.4",
"bootstrap": "^5.0.2",
"command-exists": "^1.2.9",
"dayjs": "^1.10.6",
"express": "^4.17.1",
"express-basic-auth": "^1.2.0",
"form-data": "^4.0.0",
"http-graceful-shutdown": "^3.1.2",
"jsonwebtoken": "^8.5.1",
"nodemailer": "^6.6.2",
"nodemailer": "^6.6.3",
"password-hash": "^1.2.2",
"prom-client": "^13.1.0",
"prometheus-api-metrics": "^3.2.0",
"redbean-node": "0.0.20",
"socket.io": "^4.0.2",
"socket.io-client": "^4.1.2",
"sqlite3": "^5.0.0",
"socket.io": "^4.1.3",
"socket.io-client": "^4.1.3",
"sqlite3": "^5.0.2",
"tcp-ping": "^0.1.1",
"v-pagination-3": "^0.1.6",
"vue": "^3.0.5",
"vue-confirm-dialog": "^1.0.2",
"vue-router": "^4.0.10",
"vue-toastification": "^2.0.0-rc.1"
},
"devDependencies": {
"@vitejs/plugin-legacy": "^1.4.3",
"@vitejs/plugin-vue": "^1.2.3",
"@vue/compiler-sfc": "^3.0.5",
"@babel/eslint-parser": "^7.13.10",
"@types/bootstrap": "^5.0.17",
"@vitejs/plugin-legacy": "^1.5.0",
"@vitejs/plugin-vue": "^1.3.0",
"@vue/compiler-sfc": "^3.1.5",
"core-js": "^3.15.2",
"sass": "^1.35.1",
"vite": "^2.3.7",
"auto-changelog": "2.3.0",
"release-it": "14.10.0"
},
"release-it": {
"git": {
"commit": true,
"requireUpstream": false,
"requireCleanWorkingDir": false,
"commitMessage": "🚀RELEASE v${version}",
"push": false,
"tag": true,
"tagName": "v${version}",
"tagAnnotation": "v${version}"
},
"npm": {
"publish": false
},
"hooks": {
"after:bump": "auto-changelog --commit-limit false -p -u --hide-credit && git add CHANGELOG.md"
}
"eslint": "^7.31.0",
"eslint-plugin-vue": "^7.14.0",
"sass": "^1.36.0",
"stylelint": "^13.13.1",
"stylelint-config-recommended": "^5.0.0",
"stylelint-config-standard": "^22.0.0",
"typescript": "^4.3.5",
"vite": "^2.4.4"
}
}

BIN
public/apple-touch-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

3
public/robots.txt

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

51
server/auth.js

@ -0,0 +1,51 @@
const basicAuth = require("express-basic-auth")
const passwordHash = require("./password-hash");
const { R } = require("redbean-node");
const { setting } = require("./util-server");
const { debug } = require("../src/util");
/**
*
* @param username : string
* @param password : string
* @returns {Promise<Bean|null>}
*/
exports.login = async function (username, password) {
let user = await R.findOne("user", " username = ? AND active = 1 ", [
username,
])
if (user && passwordHash.verify(password, user.password)) {
// Upgrade the hash to bcrypt
if (passwordHash.needRehash(user.password)) {
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
passwordHash.generate(password),
user.id,
]);
}
return user;
}
return null;
}
function myAuthorizer(username, password, callback) {
setting("disableAuth").then((result) => {
if (result) {
callback(null, true)
} else {
exports.login(username, password).then((user) => {
callback(null, user != null)
})
}
})
}
exports.basicAuth = basicAuth({
authorizer: myAuthorizer,
authorizeAsync: true,
challenge: true,
});

120
server/database.js

@ -0,0 +1,120 @@
const fs = require("fs");
const { sleep } = require("../src/util");
const { R } = require("redbean-node");
const {
setSetting, setting,
} = require("./util-server");
class Database {
static templatePath = "./db/kuma.db"
static path = "./data/kuma.db";
static latestVersion = 4;
static noReject = true;
static async patch() {
let version = parseInt(await setting("database_version"));
if (! version) {
version = 0;
}
console.info("Your database version: " + version);
console.info("Latest database version: " + this.latestVersion);
if (version === this.latestVersion) {
console.info("Database no need to patch");
} else {
console.info("Database patch is needed")
console.info("Backup the db")
const backupPath = "./data/kuma.db.bak" + version;
fs.copyFileSync(Database.path, backupPath);
// Try catch anything here, if gone wrong, restore the backup
try {
for (let i = version + 1; i <= this.latestVersion; i++) {
const sqlFile = `./db/patch${i}.sql`;
console.info(`Patching ${sqlFile}`);
await Database.importSQLFile(sqlFile);
console.info(`Patched ${sqlFile}`);
await setSetting("database_version", i);
}
console.log("Database Patched Successfully");
} catch (ex) {
await Database.close();
console.error("Patch db failed!!! Restoring the backup")
fs.copyFileSync(backupPath, Database.path);
console.error(ex)
console.error("Start Uptime-Kuma failed due to patch db failed")
console.error("Please submit the bug report if you still encounter the problem after restart: https://github.com/louislam/uptime-kuma/issues")
process.exit(1);
}
}
}
/**
* Sadly, multi sql statements is not supported by many sqlite libraries, I have to implement it myself
* @param filename
* @returns {Promise<void>}
*/
static async importSQLFile(filename) {
await R.getCell("SELECT 1");
let text = fs.readFileSync(filename).toString();
// Remove all comments (--)
let lines = text.split("\n");
lines = lines.filter((line) => {
return ! line.startsWith("--")
});
// Split statements by semicolon
// Filter out empty line
text = lines.join("\n")
let statements = text.split(";")
.map((statement) => {
return statement.trim();
})
.filter((statement) => {
return statement !== "";
})
for (let statement of statements) {
await R.exec(statement);
}
}
/**
* Special handle, because tarn.js throw a promise reject that cannot be caught
* @returns {Promise<void>}
*/
static async close() {
const listener = (reason, p) => {
Database.noReject = false;
};
process.addListener("unhandledRejection", listener);
console.log("Closing DB")
while (true) {
Database.noReject = true;
await R.close()
await sleep(2000)
if (Database.noReject) {
break;
} else {
console.log("Waiting to close the db")
}
}
console.log("SQLite closed")
process.removeListener("unhandledRejection", listener);
}
}
module.exports = Database;

10
server/model/heartbeat.js

@ -1,17 +1,15 @@
const dayjs = require("dayjs");
const utc = require('dayjs/plugin/utc')
var timezone = require('dayjs/plugin/timezone')
const utc = require("dayjs/plugin/utc")
let timezone = require("dayjs/plugin/timezone")
dayjs.extend(utc)
dayjs.extend(timezone)
const axios = require("axios");
const {R} = require("redbean-node");
const {BeanModel} = require("redbean-node/dist/bean-model");
const { BeanModel } = require("redbean-node/dist/bean-model");
/**
* status:
* 0 = DOWN
* 1 = UP
* 2 = PENDING
*/
class Heartbeat extends BeanModel {

218
server/model/monitor.js

@ -1,28 +1,30 @@
const https = require("https");
const dayjs = require("dayjs");
const utc = require('dayjs/plugin/utc')
var timezone = require('dayjs/plugin/timezone')
const utc = require("dayjs/plugin/utc")
let timezone = require("dayjs/plugin/timezone")
dayjs.extend(utc)
dayjs.extend(timezone)
const axios = require("axios");
const {tcping, ping} = require("../util-server");
const {R} = require("redbean-node");
const {BeanModel} = require("redbean-node/dist/bean-model");
const {Notification} = require("../notification")
const { Prometheus } = require("../prometheus");
const { debug, UP, DOWN, PENDING, flipStatus } = require("../../src/util");
const { tcping, ping, checkCertificate } = require("../util-server");
const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model");
const { Notification } = require("../notification")
/**
* status:
* 0 = DOWN
* 1 = UP
* 2 = PENDING
*/
class Monitor extends BeanModel {
async toJSON() {
let notificationIDList = {};
let list = await R.find("monitor_notification", " monitor_id = ? ", [
this.id
this.id,
])
for (let bean of list) {
@ -35,35 +37,62 @@ class Monitor extends BeanModel {
url: this.url,
hostname: this.hostname,
port: this.port,
maxretries: this.maxretries,
weight: this.weight,
active: this.active,
type: this.type,
interval: this.interval,
keyword: this.keyword,
notificationIDList
ignoreTls: this.getIgnoreTls(),
upsideDown: this.isUpsideDown(),
notificationIDList,
};
}
/**
* Parse to boolean
* @returns {boolean}
*/
getIgnoreTls() {
return Boolean(this.ignoreTls)
}
/**
* Parse to boolean
* @returns {boolean}
*/
isUpsideDown() {
return Boolean(this.upsideDown);
}
start(io) {
let previousBeat = null;
let retries = 0;
let prometheus = new Prometheus(this);
const beat = async () => {
console.log(`Monitor ${this.id}: Heartbeat`)
if (! previousBeat) {
previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [
this.id
this.id,
])
}
const isFirstBeat = !previousBeat;
let bean = R.dispense("heartbeat")
bean.monitor_id = this.id;
bean.time = R.isoDateTime(dayjs.utc());
bean.status = 0;
bean.status = DOWN;
if (this.isUpsideDown()) {
bean.status = flipStatus(bean.status);
}
// Duration
if (previousBeat) {
bean.duration = dayjs(bean.time).diff(dayjs(previousBeat.time), 'second');
if (! isFirstBeat) {
bean.duration = dayjs(bean.time).diff(dayjs(previousBeat.time), "second");
} else {
bean.duration = 0;
}
@ -71,14 +100,36 @@ class Monitor extends BeanModel {
try {
if (this.type === "http" || this.type === "keyword") {
let startTime = dayjs().valueOf();
// Use Custom agent to disable session reuse
// https://github.com/nodejs/node/issues/3940
let res = await axios.get(this.url, {
headers: { 'User-Agent':'Uptime-Kuma' }
})
headers: {
"User-Agent": "Uptime-Kuma",
},
httpsAgent: new https.Agent({
maxCachedSessions: 0,
rejectUnauthorized: ! this.getIgnoreTls(),
}),
});
bean.msg = `${res.status} - ${res.statusText}`
bean.ping = dayjs().valueOf() - startTime;
// Check certificate if https is used
let certInfoStartTime = dayjs().valueOf();
if (this.getUrl()?.protocol === "https:") {
try {
await this.updateTlsInfo(checkCertificate(res));
} catch (e) {
console.error(e.message)
}
}
debug("Cert Info Query Time: " + (dayjs().valueOf() - certInfoStartTime) + "ms")
if (this.type === "http") {
bean.status = 1;
bean.status = UP;
} else {
let data = res.data;
@ -90,43 +141,77 @@ class Monitor extends BeanModel {
if (data.includes(this.keyword)) {
bean.msg += ", keyword is found"
bean.status = 1;
bean.status = UP;
} else {
throw new Error(bean.msg + ", but keyword is not found")
}
}
} else if (this.type === "port") {
bean.ping = await tcping(this.hostname, this.port);
bean.msg = ""
bean.status = 1;
bean.status = UP;
} else if (this.type === "ping") {
bean.ping = await ping(this.hostname);
bean.msg = ""
bean.status = 1;
bean.status = UP;
}
if (this.isUpsideDown()) {
bean.status = flipStatus(bean.status);
if (bean.status === DOWN) {
throw new Error("Flip UP to DOWN");
}
}
retries = 0;
} catch (error) {
bean.msg = error.message;
// If UP come in here, it must be upside down mode
// Just reset the retries
if (this.isUpsideDown() && bean.status === UP) {
retries = 0;
} else if ((this.maxretries > 0) && (retries < this.maxretries)) {
retries++;
bean.status = PENDING;
}
}
// Mark as important if status changed
if (! previousBeat || previousBeat.status !== bean.status) {
// * ? -> ANY STATUS = important [isFirstBeat]
// UP -> PENDING = not important
// * UP -> DOWN = important
// UP -> UP = not important
// PENDING -> PENDING = not important
// * PENDING -> DOWN = important
// PENDING -> UP = not important
// DOWN -> PENDING = this case not exists
// DOWN -> DOWN = not important
// * DOWN -> UP = important
let isImportant = isFirstBeat ||
(previousBeat.status === UP && bean.status === DOWN) ||
(previousBeat.status === DOWN && bean.status === UP) ||
(previousBeat.status === PENDING && bean.status === DOWN);
// Mark as important if status changed, ignore pending pings,
// Don't notify if disrupted changes to up
if (isImportant) {
bean.important = true;
// Do not send if first beat is UP
if (previousBeat || bean.status !== 1) {
let notificationList = await R.getAll(`SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id `, [
this.id
// Send only if the first beat is DOWN
if (!isFirstBeat || bean.status === DOWN) {
let notificationList = await R.getAll("SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id ", [
this.id,
])
let promiseList = [];
let text;
if (bean.status === 1) {
if (bean.status === UP) {
text = "✅ Up"
} else {
text = "🔴 Down"
@ -134,17 +219,29 @@ class Monitor extends BeanModel {
let msg = `[${this.name}] [${text}] ${bean.msg}`;
for(let notification of notificationList) {
promiseList.push(Notification.send(JSON.parse(notification.config), msg, await this.toJSON(), bean.toJSON()));
for (let notification of notificationList) {
try {
await Notification.send(JSON.parse(notification.config), msg, await this.toJSON(), bean.toJSON())
} catch (e) {
console.error("Cannot send notification to " + notification.name)
}
}
await Promise.all(promiseList);
}
} else {
bean.important = false;
}
if (bean.status === UP) {
console.info(`Monitor #${this.id} '${this.name}': Successful Response: ${bean.ping} ms | Interval: ${this.interval} seconds | Type: ${this.type}`)
} else if (bean.status === PENDING) {
console.warn(`Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Max retries: ${this.maxretries} | Type: ${this.type}`)
} else {
console.warn(`Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Type: ${this.type}`)
}
prometheus.update(bean)
io.to(this.user_id).emit("heartbeat", bean.toJSON());
await R.store(bean)
@ -161,10 +258,42 @@ class Monitor extends BeanModel {
clearInterval(this.heartbeatInterval)
}
/**
* Helper Method:
* returns URL object for further usage
* returns null if url is invalid
* @returns {null|URL}
*/
getUrl() {
try {
return new URL(this.url);
} catch (_) {
return null;
}
}
/**
* Store TLS info to database
* @param checkCertificateResult
* @returns {Promise<void>}
*/
async updateTlsInfo(checkCertificateResult) {
let tls_info_bean = await R.findOne("monitor_tls_info", "monitor_id = ?", [
this.id,
]);
if (tls_info_bean == null) {
tls_info_bean = R.dispense("monitor_tls_info");
tls_info_bean.monitor_id = this.id;
}
tls_info_bean.info_json = JSON.stringify(checkCertificateResult);
await R.store(tls_info_bean);
}
static async sendStats(io, monitorID, userID) {
Monitor.sendAvgPing(24, io, monitorID, userID);
Monitor.sendUptime(24, io, monitorID, userID);
Monitor.sendUptime(24 * 30, io, monitorID, userID);
Monitor.sendCertInfo(io, monitorID, userID);
}
/**
@ -179,12 +308,21 @@ class Monitor extends BeanModel {
AND ping IS NOT NULL
AND monitor_id = ? `, [
-duration,
monitorID
monitorID,
]));
io.to(userID).emit("avgPing", monitorID, avgPing);
}
static async sendCertInfo(io, monitorID, userID) {
let tls_info = await R.findOne("monitor_tls_info", "monitor_id = ?", [
monitorID,
]);
if (tls_info != null) {
io.to(userID).emit("certInfo", monitorID, tls_info.info_json);
}
}
/**
* Uptime with calculation
* Calculation based on:
@ -200,7 +338,7 @@ class Monitor extends BeanModel {
WHERE time > DATETIME('now', ? || ' hours')
AND monitor_id = ? `, [
-duration,
monitorID
monitorID,
]);
let downtime = 0;
@ -224,7 +362,7 @@ class Monitor extends BeanModel {
// Handle if heartbeat duration longer than the target duration
// e.g. Heartbeat duration = 28hrs, but target duration = 24hrs
if (value > sec) {
let trim = dayjs.utc().diff(dayjs(time), 'second');
let trim = dayjs.utc().diff(dayjs(time), "second");
value = sec - trim;
if (value < 0) {
@ -233,7 +371,7 @@ class Monitor extends BeanModel {
}
total += value;
if (row.status === 0) {
if (row.status === 0 || row.status === 2) {
downtime += value;
}
}
@ -245,8 +383,6 @@ class Monitor extends BeanModel {
}
}
io.to(userID).emit("uptime", monitorID, duration, uptime);
}
}

323
server/notification.js

@ -1,27 +1,57 @@
const axios = require("axios");
const {R} = require("redbean-node");
const FormData = require('form-data');
const { R } = require("redbean-node");
const FormData = require("form-data");
const nodemailer = require("nodemailer");
const child_process = require("child_process");
class Notification {
/**
*
* @param notification
* @param msg
* @param monitorJSON
* @param heartbeatJSON
* @returns {Promise<string>} Successful msg
* Throw Error with fail msg
*/
static async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
let okMsg = "Sent Successfully. ";
if (notification.type === "telegram") {
try {
await axios.get(`https://api.telegram.org/bot${notification.telegramBotToken}/sendMessage`, {
params: {
chat_id: notification.telegramChatID,
text: msg,
}
},
})
return true;
return okMsg;
} catch (error) {
console.log(error)
return false;
let msg = (error.response.data.description) ? error.response.data.description : "Error without description"
throw new Error(msg)
}
} else if (notification.type === "webhook") {
} else if (notification.type === "gotify") {
try {
if (notification.gotifyserverurl && notification.gotifyserverurl.endsWith("/")) {
notification.gotifyserverurl = notification.gotifyserverurl.slice(0, -1);
}
await axios.post(`${notification.gotifyserverurl}/message?token=${notification.gotifyapplicationToken}`, {
"message": msg,
"priority": notification.gotifyPriority || 8,
"title": "Uptime-Kuma",
})
return okMsg;
} catch (error) {
throwGeneralAxiosError(error)
}
} else if (notification.type === "webhook") {
try {
let data = {
heartbeat: heartbeatJSON,
monitor: monitorJSON,
@ -32,21 +62,21 @@ class Notification {
if (notification.webhookContentType === "form-data") {
finalData = new FormData();
finalData.append('data', JSON.stringify(data));
finalData.append("data", JSON.stringify(data));
config = {
headers: finalData.getHeaders()
headers: finalData.getHeaders(),
}
} else {
finalData = data;
}
let res = await axios.post(notification.webhookURL, finalData, config)
return true;
await axios.post(notification.webhookURL, finalData, config)
return okMsg;
} catch (error) {
console.log(error)
return false;
throwGeneralAxiosError(error)
}
} else if (notification.type === "smtp") {
@ -54,60 +84,191 @@ class Notification {
} else if (notification.type === "discord") {
try {
// If heartbeatJSON is null, assume we're testing.
if(heartbeatJSON == null) {
// If heartbeatJSON is null, assume we're testing.
if (heartbeatJSON == null) {
let data = {
username: "Uptime-Kuma",
content: msg,
}
await axios.post(notification.discordWebhookUrl, data)
return okMsg;
}
// If heartbeatJSON is not null, we go into the normal alerting loop.
if (heartbeatJSON["status"] == 0) {
var alertColor = "16711680";
} else if (heartbeatJSON["status"] == 1) {
var alertColor = "65280";
}
let data = {
username: 'Uptime-Kuma',
content: msg
username: "Uptime-Kuma",
embeds: [{
title: "Uptime-Kuma Alert",
color: alertColor,
fields: [
{
name: "Time (UTC)",
value: heartbeatJSON["time"],
},
{
name: "Message",
value: msg,
},
],
}],
}
let res = await axios.post(notification.discordWebhookUrl, data)
return true;
}
// If heartbeatJSON is not null, we go into the normal alerting loop.
if(heartbeatJSON['status'] == 0) {
var alertColor = "16711680";
} else if(heartbeatJSON['status'] == 1) {
var alertColor = "65280";
}
let data = {
username: 'Uptime-Kuma',
embeds: [{
title: "Uptime-Kuma Alert",
color: alertColor,
fields: [
await axios.post(notification.discordWebhookUrl, data)
return okMsg;
} catch (error) {
throwGeneralAxiosError(error)
}
} else if (notification.type === "signal") {
try {
let data = {
"message": msg,
"number": notification.signalNumber,
"recipients": notification.signalRecipients.replace(/\s/g, "").split(","),
};
let config = {};
await axios.post(notification.signalURL, data, config)
return okMsg;
} catch (error) {
throwGeneralAxiosError(error)
}
} else if (notification.type === "slack") {
try {
if (heartbeatJSON == null) {
let data = {
"text": "Uptime Kuma Slack testing successful.",
"channel": notification.slackchannel,
"username": notification.slackusername,
"icon_emoji": notification.slackiconemo,
}
await axios.post(notification.slackwebhookURL, data)
return okMsg;
}
const time = heartbeatJSON["time"];
let data = {
"text": "Uptime Kuma Alert",
"channel": notification.slackchannel,
"username": notification.slackusername,
"icon_emoji": notification.slackiconemo,
"blocks": [{
"type": "header",
"text": {
"type": "plain_text",
"text": "Uptime Kuma Alert",
},
},
{
name: "Time (UTC)",
value: heartbeatJSON["time"]
"type": "section",
"fields": [{
"type": "mrkdwn",
"text": "*Message*\n" + msg,
},
{
"type": "mrkdwn",
"text": "*Time (UTC)*\n" + time,
}],
},
{
name: "Message",
value: msg
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "Visit Uptime Kuma",
},
"value": "Uptime-Kuma",
"url": notification.slackbutton || "https://github.com/louislam/uptime-kuma",
},
],
}],
}
await axios.post(notification.slackwebhookURL, data)
return okMsg;
} catch (error) {
throwGeneralAxiosError(error)
}
} else if (notification.type === "pushover") {
let pushoverlink = "https://api.pushover.net/1/messages.json"
try {
if (heartbeatJSON == null) {
let data = {
"message": "<b>Uptime Kuma Pushover testing successful.</b>",
"user": notification.pushoveruserkey,
"token": notification.pushoverapptoken,
"sound": notification.pushoversounds,
"priority": notification.pushoverpriority,
"title": notification.pushovertitle,
"retry": "30",
"expire": "3600",
"html": 1,
}
]
}]
}
let res = await axios.post(notification.discordWebhookUrl, data)
return true;
} catch(error) {
console.log(error)
return false;
await axios.post(pushoverlink, data)
return okMsg;
}
let data = {
"message": "<b>Uptime Kuma Alert</b>\n\n<b>Message</b>:" + msg + "\n<b>Time (UTC)</b>:" + heartbeatJSON["time"],
"user": notification.pushoveruserkey,
"token": notification.pushoverapptoken,
"sound": notification.pushoversounds,
"priority": notification.pushoverpriority,
"title": notification.pushovertitle,
"retry": "30",
"expire": "3600",
"html": 1,
}
await axios.post(pushoverlink, data)
return okMsg;
} catch (error) {
throwGeneralAxiosError(error)
}
} else if (notification.type === "signal") {
try {
let data = {
"message": msg,
"number": notification.signalNumber,
"recipients": notification.signalRecipients.replace(/\s/g, '').split(",")
};
let config = {};
let res = await axios.post(notification.signalURL, data, config)
return true;
} catch (error) {
console.log(error)
return false;
}
} else if (notification.type === "apprise") {
return Notification.apprise(notification, msg)
} else if (notification.type === "lunasea") {
let lunaseadevice = "https://notify.lunasea.app/v1/custom/device/" + notification.lunaseaDevice
try {
if (heartbeatJSON == null) {
let testdata = {
"title": "Uptime Kuma Alert",
"body": "Testing Successful.",
}
await axios.post(lunaseadevice, testdata)
return okMsg;
}
if (heartbeatJSON["status"] == 0) {
let downdata = {
"title": "UptimeKuma Alert:" + monitorJSON["name"],
"body": "[🔴 Down]" + heartbeatJSON["msg"] + "\nTime (UTC):" + heartbeatJSON["time"],
}
await axios.post(lunaseadevice, downdata)
return okMsg;
}
if (heartbeatJSON["status"] == 1) {
let updata = {
"title": "UptimeKuma Alert:" + monitorJSON["name"],
"body": "[✅ Up]" + heartbeatJSON["msg"] + "\nTime (UTC):" + heartbeatJSON["time"],
}
await axios.post(lunaseadevice, updata)
return okMsg;
}
} catch (error) {
throwGeneralAxiosError(error)
}
} else {
throw new Error("Notification type is not supported")
@ -163,27 +324,53 @@ class Notification {
});
// send mail with defined transport object
let info = await transporter.sendMail({
await transporter.sendMail({
from: `"Uptime Kuma" <${notification.smtpFrom}>`,
to: notification.smtpTo,
subject: msg,
text: msg,
});
return true;
return "Sent Successfully.";
}
static async discord(notification, msg) {
const client = new Discord.Client();
await client.login(notification.discordToken)
static async apprise(notification, msg) {
let s = child_process.spawnSync("apprise", [ "-vv", "-b", msg, notification.appriseURL])
const channel = await client.channels.fetch(notification.discordChannelID);
await channel.send(msg);
let output = (s.stdout) ? s.stdout.toString() : "ERROR: maybe apprise not found";
client.destroy()
if (output) {
return true;
if (! output.includes("ERROR")) {
return "Sent Successfully";
}
throw new Error(output)
} else {
return ""
}
}
static checkApprise() {
let commandExistsSync = require("command-exists").sync;
let exists = commandExistsSync("apprise");
return exists;
}
}
function throwGeneralAxiosError(error) {
let msg = "Error: " + error + " ";
if (error.response && error.response.data) {
if (typeof error.response.data === "string") {
msg += error.response.data;
} else {
msg += JSON.stringify(error.response.data)
}
}
throw new Error(msg)
}
module.exports = {

8
server/password-hash.js

@ -1,5 +1,5 @@
const passwordHashOld = require('password-hash');
const bcrypt = require('bcrypt');
const passwordHashOld = require("password-hash");
const bcrypt = require("bcrypt");
const saltRounds = 10;
exports.generate = function (password) {
@ -9,9 +9,9 @@ exports.generate = function (password) {
exports.verify = function (password, hash) {
if (isSHA1(hash)) {
return passwordHashOld.verify(password, hash)
} else {
return bcrypt.compareSync(password, hash);
}
return bcrypt.compareSync(password, hash);
}
function isSHA1(hash) {

84
server/ping-lite.js

@ -1,9 +1,9 @@
// https://github.com/ben-bradley/ping-lite/blob/master/ping-lite.js
// Fixed on Windows
var spawn = require('child_process').spawn,
events = require('events'),
fs = require('fs'),
let spawn = require("child_process").spawn,
events = require("events"),
fs = require("fs"),
WIN = /^win/.test(process.platform),
LIN = /^linux/.test(process.platform),
MAC = /^darwin/.test(process.platform);
@ -11,8 +11,9 @@ var spawn = require('child_process').spawn,
module.exports = Ping;
function Ping(host, options) {
if (!host)
throw new Error('You must specify a host to ping!');
if (!host) {
throw new Error("You must specify a host to ping!");
}
this._host = host;
this._options = options = (options || {});
@ -20,26 +21,24 @@ function Ping(host, options) {
events.EventEmitter.call(this);
if (WIN) {
this._bin = 'c:/windows/system32/ping.exe';
this._args = (options.args) ? options.args : [ '-n', '1', '-w', '5000', host ];
this._bin = "c:/windows/system32/ping.exe";
this._args = (options.args) ? options.args : [ "-n", "1", "-w", "5000", host ];
this._regmatch = /[><=]([0-9.]+?)ms/;
}
else if (LIN) {
this._bin = '/bin/ping';
this._args = (options.args) ? options.args : [ '-n', '-w', '2', '-c', '1', host ];
} else if (LIN) {
this._bin = "/bin/ping";
this._args = (options.args) ? options.args : [ "-n", "-w", "2", "-c", "1", host ];
this._regmatch = /=([0-9.]+?) ms/; // need to verify this
}
else if (MAC) {
this._bin = '/sbin/ping';
this._args = (options.args) ? options.args : [ '-n', '-t', '2', '-c', '1', host ];
} else if (MAC) {
this._bin = "/sbin/ping";
this._args = (options.args) ? options.args : [ "-n", "-t", "2", "-c", "1", host ];
this._regmatch = /=([0-9.]+?) ms/;
}
else {
throw new Error('Could not detect your ping binary.');
} 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');
if (!fs.existsSync(this._bin)) {
throw new Error("Could not detect " + this._bin + " on your system");
}
this._i = 0;
@ -51,48 +50,57 @@ Ping.prototype.__proto__ = events.EventEmitter.prototype;
// SEND A PING
// ===========
Ping.prototype.send = function(callback) {
var self = this;
let self = this;
callback = callback || function(err, ms) {
if (err) return self.emit('error', err);
else return self.emit('result', ms);
if (err) {
return self.emit("error", err);
}
return self.emit("result", ms);
};
var _ended, _exited, _errored;
let _ended, _exited, _errored;
this._ping = spawn(this._bin, this._args); // spawn the binary
this._ping.on('error', function(err) { // handle binary errors
this._ping.on("error", function(err) { // handle binary errors
_errored = true;
callback(err);
});
this._ping.stdout.on('data', function(data) { // log stdout
this._stdout = (this._stdout || '') + data;
this._ping.stdout.on("data", function(data) { // log stdout
this._stdout = (this._stdout || "") + data;
});
this._ping.stdout.on('end', function() {
this._ping.stdout.on("end", function() {
_ended = true;
if (_exited && !_errored) onEnd.call(self._ping);
if (_exited && !_errored) {
onEnd.call(self._ping);
}
});
this._ping.stderr.on('data', function(data) { // log stderr
this._stderr = (this._stderr || '') + data;
this._ping.stderr.on("data", function(data) { // log stderr
this._stderr = (this._stderr || "") + data;
});
this._ping.on('exit', function(code) { // handle complete
this._ping.on("exit", function(code) { // handle complete
_exited = true;
if (_ended && !_errored) onEnd.call(self._ping);
if (_ended && !_errored) {
onEnd.call(self._ping);
}
});
function onEnd() {
var stdout = this.stdout._stdout,
let stdout = this.stdout._stdout,
stderr = this.stderr._stderr,
ms;
if (stderr)
if (stderr) {
return callback(new Error(stderr));
else if (!stdout)
return callback(new Error('No stdout detected'));
}
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;
@ -104,7 +112,7 @@ Ping.prototype.send = function(callback) {
// CALL Ping#send(callback) ON A TIMER
// ===================================
Ping.prototype.start = function(callback) {
var self = this;
let self = this;
this._i = setInterval(function() {
self.send(callback);
}, (self._options.interval || 5000));

59
server/prometheus.js

@ -0,0 +1,59 @@
const PrometheusClient = require('prom-client');
const commonLabels = [
'monitor_name',
'monitor_type',
'monitor_url',
'monitor_hostname',
'monitor_port',
]
const monitor_response_time = new PrometheusClient.Gauge({
name: 'monitor_response_time',
help: 'Monitor Response Time (ms)',
labelNames: commonLabels
});
const monitor_status = new PrometheusClient.Gauge({
name: 'monitor_status',
help: 'Monitor Status (1 = UP, 0= DOWN)',
labelNames: commonLabels
});
class Prometheus {
monitorLabelValues = {}
constructor(monitor) {
this.monitorLabelValues = {
monitor_name: monitor.name,
monitor_type: monitor.type,
monitor_url: monitor.url,
monitor_hostname: monitor.hostname,
monitor_port: monitor.port
}
}
update(heartbeat) {
try {
monitor_status.set(this.monitorLabelValues, heartbeat.status)
} catch (e) {
console.error(e)
}
try {
if (typeof heartbeat.ping === 'number') {
monitor_response_time.set(this.monitorLabelValues, heartbeat.ping)
} else {
// Is it good?
monitor_response_time.set(this.monitorLabelValues, -1)
}
} catch (e) {
console.error(e)
}
}
}
module.exports = {
Prometheus
}

320
server/server.js

@ -1,46 +1,112 @@
const express = require('express');
const app = express();
const http = require('http');
const server = http.createServer(app);
console.log("Welcome to Uptime Kuma")
const { sleep, debug } = require("../src/util");
console.log("Importing Node libraries")
const fs = require("fs");
const http = require("http");
console.log("Importing 3rd-party libraries")
debug("Importing express");
const express = require("express");
debug("Importing socket.io");
const { Server } = require("socket.io");
const io = new Server(server);
debug("Importing dayjs");
const dayjs = require("dayjs");
const {R} = require("redbean-node");
const passwordHash = require('./password-hash');
const jwt = require('jsonwebtoken');
debug("Importing redbean-node");
const { R } = require("redbean-node");
debug("Importing jsonwebtoken");
const jwt = require("jsonwebtoken");
debug("Importing http-graceful-shutdown");
const gracefulShutdown = require("http-graceful-shutdown");
debug("Importing prometheus-api-metrics");
const prometheusAPIMetrics = require("prometheus-api-metrics");
console.log("Importing this project modules");
debug("Importing Monitor");
const Monitor = require("./model/monitor");
const fs = require("fs");
const {getSettings} = require("./util-server");
const {Notification} = require("./notification")
const args = require('args-parser')(process.argv);
debug("Importing Settings");
const { getSettings, setSettings, setting } = require("./util-server");
debug("Importing Notification");
const { Notification } = require("./notification");
debug("Importing Database");
const Database = require("./database");
const { basicAuth } = require("./auth");
const { login } = require("./auth");
const passwordHash = require("./password-hash");
const args = require("args-parser")(process.argv);
const version = require("../package.json").version;
const hostname = process.env.HOST || args.host || "0.0.0.0"
const port = parseInt(process.env.PORT || args.port || 3001);
const version = require('../package.json').version;
const hostname = args.host || "0.0.0.0"
const port = args.port || 3001
console.info("Version: " + version)
console.log("Creating express and socket.io instance")
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(express.json())
/**
* Total WebSocket client connected to server currently, no actual use
* @type {number}
*/
let totalClient = 0;
/**
* Use for decode the auth object
* @type {null}
*/
let jwtSecret = null;
/**
* Main monitor list
* @type {{}}
*/
let monitorList = {};
/**
* Show Setup Page
* @type {boolean}
*/
let needSetup = false;
/**
* Cache Index HTML
* @type {string}
*/
let indexHTML = fs.readFileSync("./dist/index.html").toString();
(async () => {
await initDatabase();
app.use('/', express.static("dist"));
console.log("Adding route")
app.get('*', function(request, response, next) {
response.sendFile(process.cwd() + '/dist/index.html');
// Normal Router here
app.use("/", express.static("dist"));
// Basic Auth Router here
// Prometheus API metrics /metrics
// With Basic Auth using the first user's username/password
app.get("/metrics", basicAuth, prometheusAPIMetrics())
// Universal Route Handler, must be at the end
app.get("*", function(request, response, next) {
response.end(indexHTML)
});
io.on('connection', async (socket) => {
console.log("Adding socket handler")
io.on("connection", async (socket) => {
socket.emit("info", {
version,
})
console.log('a user connected');
totalClient++;
if (needSetup) {
@ -48,12 +114,18 @@ let needSetup = false;
socket.emit("setup")
}
socket.on('disconnect', () => {
console.log('user disconnected');
if (await setting("disableAuth")) {
console.log("Disabled Auth: auto login to admin")
await afterLogin(socket, await R.findOne("user", " username = 'admin' "))
}
socket.on("disconnect", () => {
totalClient--;
});
// ***************************
// Public API
// ***************************
socket.on("loginByToken", async (token, callback) => {
@ -63,7 +135,7 @@ let needSetup = false;
console.log("Username from JWT: " + decoded.username)
let user = await R.findOne("user", " username = ? AND active = 1 ", [
decoded.username
decoded.username,
])
if (user) {
@ -75,13 +147,13 @@ let needSetup = false;
} else {
callback({
ok: false,
msg: "The user is inactive or deleted."
msg: "The user is inactive or deleted.",
})
}
} catch (error) {
callback({
ok: false,
msg: "Invalid token."
msg: "Invalid token.",
})
}
@ -90,32 +162,21 @@ let needSetup = false;
socket.on("login", async (data, callback) => {
console.log("Login")
let user = await R.findOne("user", " username = ? AND active = 1 ", [
data.username
])
if (user && passwordHash.verify(data.password, user.password)) {
// Upgrade the hash to bcrypt
if (passwordHash.needRehash(user.password)) {
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
passwordHash.generate(data.password),
user.id
]);
}
let user = await login(data.username, data.password)
if (user) {
await afterLogin(socket, user)
callback({
ok: true,
token: jwt.sign({
username: data.username
}, jwtSecret)
username: data.username,
}, jwtSecret),
})
} else {
callback({
ok: false,
msg: "Incorrect username or password."
msg: "Incorrect username or password.",
})
}
@ -146,23 +207,22 @@ let needSetup = false;
callback({
ok: true,
msg: "Added Successfully."
msg: "Added Successfully.",
});
} catch (e) {
callback({
ok: false,
msg: e.message
msg: e.message,
});
}
});
// ***************************
// Auth Only API
// ***************************
// Add a new monitor
socket.on("add", async (monitor, callback) => {
try {
checkLogin(socket)
@ -183,17 +243,18 @@ let needSetup = false;
callback({
ok: true,
msg: "Added Successfully.",
monitorID: bean.id
monitorID: bean.id,
});
} catch (e) {
callback({
ok: false,
msg: e.message
msg: e.message,
});
}
});
// Edit a monitor
socket.on("editMonitor", async (monitor, callback) => {
try {
checkLogin(socket)
@ -209,8 +270,11 @@ let needSetup = false;
bean.url = monitor.url
bean.interval = monitor.interval
bean.hostname = monitor.hostname;
bean.maxretries = monitor.maxretries;
bean.port = monitor.port;
bean.keyword = monitor.keyword;
bean.ignoreTls = monitor.ignoreTls;
bean.upsideDown = monitor.upsideDown;
await R.store(bean)
@ -225,14 +289,14 @@ let needSetup = false;
callback({
ok: true,
msg: "Saved.",
monitorID: bean.id
monitorID: bean.id,
});
} catch (e) {
console.log(e)
console.error(e)
callback({
ok: false,
msg: e.message
msg: e.message,
});
}
});
@ -256,7 +320,7 @@ let needSetup = false;
} catch (e) {
callback({
ok: false,
msg: e.message
msg: e.message,
});
}
});
@ -270,13 +334,13 @@ let needSetup = false;
callback({
ok: true,
msg: "Resumed Successfully."
msg: "Resumed Successfully.",
});
} catch (e) {
callback({
ok: false,
msg: e.message
msg: e.message,
});
}
});
@ -289,14 +353,13 @@ let needSetup = false;
callback({
ok: true,
msg: "Paused Successfully."
msg: "Paused Successfully.",
});
} catch (e) {
callback({
ok: false,
msg: e.message
msg: e.message,
});
}
});
@ -314,12 +377,12 @@ let needSetup = false;
await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [
monitorID,
socket.userID
socket.userID,
]);
callback({
ok: true,
msg: "Deleted Successfully."
msg: "Deleted Successfully.",
});
await sendMonitorList(socket);
@ -327,7 +390,7 @@ let needSetup = false;
} catch (e) {
callback({
ok: false,
msg: e.message
msg: e.message,
});
}
});
@ -341,19 +404,19 @@ let needSetup = false;
}
let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID
socket.userID,
])
if (user && passwordHash.verify(password.currentPassword, user.password)) {
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
passwordHash.generate(password.newPassword),
socket.userID
socket.userID,
]);
callback({
ok: true,
msg: "Password has been updated successfully."
msg: "Password has been updated successfully.",
})
} else {
throw new Error("Incorrect current password")
@ -362,25 +425,43 @@ let needSetup = false;
} catch (e) {
callback({
ok: false,
msg: e.message
msg: e.message,
});
}
});
socket.on("getSettings", async (callback) => {
try {
checkLogin(socket)
callback({
ok: true,
data: await getSettings("general"),
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("getSettings", async (type, callback) => {
socket.on("setSettings", async (data, callback) => {
try {
checkLogin(socket)
await setSettings("general", data)
callback({
ok: true,
data: await getSettings(type),
msg: "Saved"
});
} catch (e) {
callback({
ok: false,
msg: e.message
msg: e.message,
});
}
});
@ -401,7 +482,7 @@ let needSetup = false;
} catch (e) {
callback({
ok: false,
msg: e.message
msg: e.message,
});
}
});
@ -421,7 +502,7 @@ let needSetup = false;
} catch (e) {
callback({
ok: false,
msg: e.message
msg: e.message,
});
}
});
@ -430,25 +511,36 @@ let needSetup = false;
try {
checkLogin(socket)
await Notification.send(notification, notification.name + " Testing")
let msg = await Notification.send(notification, notification.name + " Testing")
callback({
ok: true,
msg: "Sent Successfully"
msg,
});
} catch (e) {
console.error(e)
callback({
ok: false,
msg: e.message
msg: e.message,
});
}
});
socket.on("checkApprise", async (callback) => {
try {
checkLogin(socket)
callback(Notification.checkApprise());
} catch (e) {
callback(false);
}
});
});
console.log("Init")
server.listen(port, hostname, () => {
console.log(`Listening on ${hostname}:${port}`);
startMonitors();
});
@ -456,7 +548,7 @@ let needSetup = false;
async function updateMonitorNotification(monitorID, notificationIDList) {
R.exec("DELETE FROM monitor_notification WHERE monitor_id = ? ", [
monitorID
monitorID,
])
for (let notificationID in notificationIDList) {
@ -489,7 +581,7 @@ async function sendMonitorList(socket) {
async function sendNotificationList(socket) {
let result = [];
let list = await R.find("notification", " user_id = ? ", [
socket.userID
socket.userID,
]);
for (let bean of list) {
@ -507,19 +599,21 @@ async function afterLogin(socket, user) {
let monitorList = await sendMonitorList(socket)
for (let monitorID in monitorList) {
await sendHeartbeatList(socket, monitorID);
await sendImportantHeartbeatList(socket, monitorID);
await Monitor.sendStats(io, monitorID, user.id)
sendHeartbeatList(socket, monitorID);
sendImportantHeartbeatList(socket, monitorID);
Monitor.sendStats(io, monitorID, user.id)
}
await sendNotificationList(socket)
sendNotificationList(socket)
socket.emit("autoLogin")
}
async function getMonitorJSONList(userID) {
let result = {};
let monitorList = await R.find("monitor", " user_id = ? ", [
userID
userID,
])
for (let monitor of monitorList) {
@ -536,23 +630,26 @@ function checkLogin(socket) {
}
async function initDatabase() {
const path = './data/kuma.db';
if (! fs.existsSync(path)) {
console.log("Copy Database")
fs.copyFileSync("./db/kuma.db", path);
if (! fs.existsSync(Database.path)) {
console.log("Copying Database")
fs.copyFileSync(Database.templatePath, Database.path);
}
console.log("Connect to Database")
R.setup('sqlite', {
filename: path
console.log("Connecting to Database")
R.setup("sqlite", {
filename: Database.path,
});
console.log("Connected")
// Patch the database
await Database.patch()
// Auto map the model to a bean object
R.freeze(true)
await R.autoloadModels("./server/model");
let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
"jwtSecret"
"jwtSecret",
]);
if (! jwtSecretBean) {
@ -562,10 +659,12 @@ async function initDatabase() {
jwtSecretBean.value = passwordHash.generate(dayjs() + "")
await R.store(jwtSecretBean)
console.log("Stored JWT secret into database")
} else {
console.log("Load JWT secret from database.")
}
// If there is no record in user table, it is a new Uptime Kuma instance, need to setup
if ((await R.count("user")) === 0) {
console.log("No user, need setup")
needSetup = true;
@ -581,11 +680,11 @@ async function startMonitor(userID, monitorID) {
await R.exec("UPDATE monitor SET active = 1 WHERE id = ? AND user_id = ? ", [
monitorID,
userID
userID,
]);
let monitor = await R.findOne("monitor", " id = ? ", [
monitorID
monitorID,
])
if (monitor.id in monitorList) {
@ -607,7 +706,7 @@ async function pauseMonitor(userID, monitorID) {
await R.exec("UPDATE monitor SET active = 0 WHERE id = ? AND user_id = ? ", [
monitorID,
userID
userID,
]);
if (monitorID in monitorList) {
@ -636,13 +735,13 @@ async function sendHeartbeatList(socket, monitorID) {
ORDER BY time DESC
LIMIT 100
`, [
monitorID
monitorID,
])
let result = [];
for (let bean of list) {
result.unshift(bean.toJSON())
result.unshift(bean.toJSON())
}
socket.emit("heartbeatList", monitorID, result)
@ -655,8 +754,35 @@ async function sendImportantHeartbeatList(socket, monitorID) {
ORDER BY time DESC
LIMIT 500
`, [
monitorID
monitorID,
])
socket.emit("importantHeartbeatList", monitorID, list)
}
async function shutdownFunction(signal) {
console.log("Shutdown requested");
console.log("Called signal: " + signal);
console.log("Stopping all monitors")
for (let id in monitorList) {
let monitor = monitorList[id]
monitor.stop()
}
await sleep(2000);
await Database.close();
console.log("Stopped DB")
}
function finalFunction() {
console.log("Graceful Shutdown Done")
}
gracefulShutdown(server, {
signals: "SIGINT SIGTERM",
timeout: 30000, // timeout: 30 secs
development: false, // not in dev mode
forceExit: true, // triggers process.exit() at the end of shutdown process
onShutdown: shutdownFunction, // shutdown function (async) - e.g. for cleanup DB, ...
finally: finalFunction, // finally function (sync) - e.g. for logging
});

114
server/util-server.js

@ -1,6 +1,7 @@
const tcpp = require('tcp-ping');
const tcpp = require("tcp-ping");
const Ping = require("./ping-lite");
const {R} = require("redbean-node");
const { R } = require("redbean-node");
const { debug } = require("../src/util");
exports.tcping = function (hostname, port) {
return new Promise((resolve, reject) => {
@ -40,21 +41,120 @@ exports.ping = function (hostname) {
}
exports.setting = async function (key) {
return await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [
key
let value = await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [
key,
]);
try {
const v = JSON.parse(value);
debug(`Get Setting: ${key}: ${v}`)
return v;
} catch (e) {
return value;
}
}
exports.setSetting = async function (key, value) {
let bean = await R.findOne("setting", " `key` = ? ", [
key,
])
if (! bean) {
bean = R.dispense("setting")
bean.key = key;
}
bean.value = JSON.stringify(value);
await R.store(bean)
}
exports.getSettings = async function (type) {
let list = await R.getAll("SELECT * FROM setting WHERE `type` = ? ", [
type
let list = await R.getAll("SELECT `key`, `value` FROM setting WHERE `type` = ? ", [
type,
])
let result = {};
for (let row of list) {
result[row.key] = row.value;
try {
result[row.key] = JSON.parse(row.value);
} catch (e) {
result[row.key] = row.value;
}
}
return result;
}
exports.setSettings = async function (type, data) {
let keyList = Object.keys(data);
let promiseList = [];
for (let key of keyList) {
let bean = await R.findOne("setting", " `key` = ? ", [
key
]);
if (bean == null) {
bean = R.dispense("setting");
bean.type = type;
bean.key = key;
}
if (bean.type === type) {
bean.value = JSON.stringify(data[key]);
promiseList.push(R.store(bean))
}
}
await Promise.all(promiseList);
}
// ssl-checker by @dyaa
// param: res - response object from axios
// return an object containing the certificate information
const getDaysBetween = (validFrom, validTo) =>
Math.round(Math.abs(+validFrom - +validTo) / 8.64e7);
const getDaysRemaining = (validFrom, validTo) => {
const daysRemaining = getDaysBetween(validFrom, validTo);
if (new Date(validTo).getTime() < new Date().getTime()) {
return -daysRemaining;
}
return daysRemaining;
};
exports.checkCertificate = function (res) {
const {
valid_from,
valid_to,
subjectaltname,
issuer,
fingerprint,
} = res.request.res.socket.getPeerCertificate(false);
if (!valid_from || !valid_to || !subjectaltname) {
throw {
message: "No TLS certificate in response",
};
}
const valid = res.request.res.socket.authorized || false;
const validTo = new Date(valid_to);
const validFor = subjectaltname
.replace(/DNS:|IP Address:/g, "")
.split(", ");
const daysRemaining = getDaysRemaining(new Date(), validTo);
return {
valid,
validFor,
validTo,
daysRemaining,
issuer,
fingerprint,
};
}

20
server/util.js

@ -1,20 +0,0 @@
/*
* Common functions - can be used in frontend or backend
*/
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function ucfirst(str) {
if (! str) {
return str;
}
const firstLetter = str.substr(0, 1);
return firstLetter.toUpperCase() + str.substr(1);
}

8
src/App.vue

@ -3,11 +3,5 @@
</template>
<script>
export default {
}
export default {}
</script>
<style lang="scss">
</style>

3
src/assets/vars.scss

@ -1,7 +1,8 @@
$primary: #5CDD8B;
$danger: #DC3545;
$warning: #f8a306;
$link-color: #111;
$border-radius: 50rem;
$highlight: #7ce8a4;
$highlight-white: #e7faec;
$highlight-white: #e7faec;

44
src/components/Confirm.vue

@ -1,17 +1,23 @@
<template>
<div class="modal fade" tabindex="-1" ref="modal">
<div ref="modal" class="modal fade" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">Confirm</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<h5 id="exampleModalLabel" class="modal-title">
Confirm
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div class="modal-body">
<slot></slot>
<slot />
</div>
<div class="modal-footer">
<button type="button" class="btn" :class="btnStyle" @click="yes" data-bs-dismiss="modal">Yes</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">No</button>
<button type="button" class="btn" :class="btnStyle" data-bs-dismiss="modal" @click="yes">
{{ yesText }}
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{{ noText }}
</button>
</div>
</div>
</div>
@ -19,17 +25,25 @@
</template>
<script>
import { Modal } from 'bootstrap'
import { Modal } from "bootstrap"
export default {
props: {
btnStyle: {
type: String,
default: "btn-primary"
}
default: "btn-primary",
},
yesText: {
type: String,
default: "Yes",
},
noText: {
type: String,
default: "No",
},
},
data: () => ({
modal: null
modal: null,
}),
mounted() {
this.modal = new Modal(this.$refs.modal)
@ -39,12 +53,8 @@ export default {
this.modal.show()
},
yes() {
this.$emit('yes');
}
}
this.$emit("yes");
},
},
}
</script>
<style scoped>
</style>

32
src/components/CountUp.vue

@ -3,26 +3,22 @@
<span v-else>{{ value }}</span>
</template>
<script>
<script lang="ts">
import {sleep} from "../../server/util";
import { sleep } from "../util.ts"
export default {
props: {
value: [String, Number],
time: {
Number,
type: Number,
default: 0.3,
},
unit: {
String,
type: String,
default: "ms",
}
},
mounted() {
this.output = this.value;
},
},
data() {
@ -32,14 +28,10 @@ export default {
}
},
methods: {
},
computed: {
isNum() {
return typeof this.value === 'number'
}
return typeof this.value === "number"
},
},
watch: {
@ -61,9 +53,11 @@ export default {
},
},
}
</script>
mounted() {
this.output = this.value;
},
<style scoped>
methods: {},
</style>
}
</script>

27
src/components/Datetime.vue

@ -4,9 +4,9 @@
<script>
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone' // dependent on utc plugin
import relativeTime from "dayjs/plugin/relativeTime"
import utc from "dayjs/plugin/utc"
import timezone from "dayjs/plugin/timezone" // dependent on utc plugin
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(relativeTime)
@ -14,17 +14,24 @@ dayjs.extend(relativeTime)
export default {
props: {
value: String,
dateOnly: {
type: Boolean,
default: false,
},
},
computed: {
displayText() {
let format = "YYYY-MM-DD HH:mm:ss";
return dayjs.utc(this.value).tz(this.$root.timezone).format(format)
if (this.value !== undefined && this.value !== "") {
let format = "YYYY-MM-DD HH:mm:ss";
if (this.dateOnly) {
format = "YYYY-MM-DD";
}
return dayjs.utc(this.value).tz(this.$root.timezone).format(format);
}
return "";
},
}
},
}
</script>
<style scoped>
</style>

84
src/components/HeartbeatBar.vue

@ -1,28 +1,27 @@
<template>
<div class="wrap" :style="wrapStyle" ref="wrap">
<div ref="wrap" class="wrap" :style="wrapStyle">
<div class="hp-bar-big" :style="barStyle">
<div
class="beat"
:class="{ 'empty' : (beat === 0), 'down' : (beat.status === 0) }"
:style="beatStyle"
v-for="(beat, index) in shortBeatList"
:key="index"
:title="beat.msg">
</div>
class="beat"
:class="{ 'empty' : (beat === 0), 'down' : (beat.status === 0), 'pending' : (beat.status === 2) }"
:style="beatStyle"
:title="beat.msg"
/>
</div>
</div>
</template>
<script>
export default {
props: {
size: {
type: String,
default: "big"
default: "big",
},
monitorId: Number
monitorId: Number,
},
data() {
return {
@ -34,26 +33,6 @@ export default {
maxBeat: -1,
}
},
unmounted() {
window.removeEventListener("resize", this.resize);
},
mounted() {
if (this.size === "small") {
this.beatWidth = 5.6;
this.beatMargin = 2.4;
this.beatHeight = 16
}
window.addEventListener("resize", this.resize);
this.resize();
},
methods: {
resize() {
if (this.$refs.wrap) {
this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatMargin * 2))
}
}
},
computed: {
beatList() {
@ -80,8 +59,6 @@ export default {
start = 0;
}
return placeholders.concat(this.beatList.slice(start))
},
@ -89,16 +66,9 @@ export default {
let topBottom = (((this.beatHeight * this.hoverScale) - this.beatHeight) / 2);
let leftRight = (((this.beatWidth * this.hoverScale) - this.beatWidth) / 2);
let width
if (this.maxBeat > 0) {
width = (this.beatWidth + this.beatMargin * 2) * this.maxBeat + (leftRight * 2) + "px"
} {
width = "100%"
}
return {
padding: `${topBottom}px ${leftRight}px`,
width: width
width: "100%",
}
},
@ -111,11 +81,11 @@ export default {
transform: `translateX(${width}px)`,
}
} else {
return {
transform: `translateX(0)`,
}
}
return {
transform: "translateX(0)",
}
},
beatStyle() {
@ -125,7 +95,7 @@ export default {
margin: this.beatMargin + "px",
"--hover-scale": this.hoverScale,
}
}
},
},
watch: {
@ -138,8 +108,28 @@ export default {
}, 300)
},
deep: true,
},
},
unmounted() {
window.removeEventListener("resize", this.resize);
},
mounted() {
if (this.size === "small") {
this.beatWidth = 5.6;
this.beatMargin = 2.4;
this.beatHeight = 16
}
}
window.addEventListener("resize", this.resize);
this.resize();
},
methods: {
resize() {
if (this.$refs.wrap) {
this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatMargin * 2))
}
},
},
}
</script>
@ -166,6 +156,10 @@ export default {
background-color: $danger;
}
&.pending {
background-color: $warning;
}
&:not(.empty):hover {
transition: all ease-in-out 0.15s;
opacity: 0.8;

25
src/components/Login.vue

@ -2,31 +2,32 @@
<div class="form-container">
<div class="form">
<form @submit.prevent="submit">
<h1 class="h3 mb-3 fw-normal"></h1>
<h1 class="h3 mb-3 fw-normal" />
<div class="form-floating">
<input type="text" class="form-control" id="floatingInput" placeholder="Username" v-model="username">
<input id="floatingInput" v-model="username" type="text" class="form-control" placeholder="Username">
<label for="floatingInput">Username</label>
</div>
<div class="form-floating mt-3">
<input type="password" class="form-control" id="floatingPassword" placeholder="Password" v-model="password">
<input id="floatingPassword" v-model="password" type="password" class="form-control" placeholder="Password">
<label for="floatingPassword">Password</label>
</div>
<div class="form-check mb-3 mt-3" >
<label>
<input type="checkbox" value="remember-me" class="form-check-input" id="remember" v-model="$root.remember">
<div class="form-check mb-3 mt-3 d-flex justify-content-center pe-4">
<div class="form-check">
<input id="remember" v-model="$root.remember" type="checkbox" value="remember-me" class="form-check-input">
<label class="form-check-label" for="remember">
Remember me
</label>
</label>
</div>
</div>
<button class="w-100 btn btn-primary" type="submit" :disabled="processing">Login</button>
<button class="w-100 btn btn-primary" type="submit" :disabled="processing">
Login
</button>
<div class="alert alert-danger mt-3" role="alert" v-if="res && !res.ok">
<div v-if="res && !res.ok" class="alert alert-danger mt-3" role="alert">
{{ res.msg }}
</div>
</form>
@ -52,8 +53,8 @@ export default {
this.processing = false;
this.res = res;
})
}
}
},
},
}
</script>

377
src/components/NotificationDialog.vue

@ -1,79 +1,90 @@
<template>
<form @submit.prevent="submit">
<div class="modal fade" tabindex="-1" ref="modal" data-bs-backdrop="static">
<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 class="modal-title" id="exampleModalLabel">Setup Notification</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<h5 id="exampleModalLabel" class="modal-title">
Setup Notification
</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="type" class="form-label">Notification Type</label>
<select id="type" v-model="notification.type" class="form-select">
<option value="telegram">Telegram</option>
<option value="webhook">Webhook</option>
<option value="smtp">Email (SMTP)</option>
<option value="discord">Discord</option>
<option value="signal">Signal</option>
<option value="gotify">Gotify</option>
<option value="slack">Slack</option>
<option value="pushover">Pushover</option>
<option value="lunasea">LunaSea</option>
<option value="apprise">Apprise (Support 50+ Notification services)</option>
</select>
</div>
<div class="mb-3">
<label for="name" class="form-label">Friendly Name</label>
<input id="name" v-model="notification.name" type="text" class="form-control" required>
</div>
<template v-if="notification.type === 'telegram'">
<div class="mb-3">
<label for="type" class="form-label">Notification Type</label>
<select class="form-select" id="type" v-model="notification.type">
<option value="telegram">Telegram</option>
<option value="webhook">Webhook</option>
<option value="smtp">Email (SMTP)</option>
<option value="discord">Discord</option>
<option value="signal">Signal</option>
</select>
<label for="telegram-bot-token" class="form-label">Bot Token</label>
<input id="telegram-bot-token" v-model="notification.telegramBotToken" type="text" class="form-control" required>
<div class="form-text">
You can get a token from <a href="https://t.me/BotFather" target="_blank">https://t.me/BotFather</a>.
</div>
</div>
<div class="mb-3">
<label for="name" class="form-label">Friendly Name</label>
<input type="text" class="form-control" id="name" required v-model="notification.name">
</div>
<label for="telegram-chat-id" class="form-label">Chat ID</label>
<template v-if="notification.type === 'telegram'">
<div class="mb-3">
<label for="telegram-bot-token" class="form-label">Bot Token</label>
<input type="text" class="form-control" id="telegram-bot-token" required v-model="notification.telegramBotToken">
<div class="form-text">You can get a token from <a href="https://t.me/BotFather" target="_blank">https://t.me/BotFather</a>.</div>
<div class="input-group mb-3">
<input id="telegram-chat-id" v-model="notification.telegramChatID" type="text" class="form-control" required>
<button v-if="notification.telegramBotToken" class="btn btn-outline-secondary" type="button" @click="autoGetTelegramChatID">
Auto Get
</button>
</div>
<div class="mb-3">
<label for="telegram-chat-id" class="form-label">Chat ID</label>
<div class="input-group mb-3">
<input type="text" class="form-control" id="telegram-chat-id" required v-model="notification.telegramChatID">
<button class="btn btn-outline-secondary" type="button" @click="autoGetTelegramChatID" v-if="notification.telegramBotToken">Auto Get</button>
</div>
<div class="form-text">
Support Direct Chat / Group / Channel's Chat ID
<p style="margin-top: 8px;">
You can get your chat id by sending message to the bot and go to this url to view the chat_id:
</p>
<div class="form-text">
Support Direct Chat / Group / Channel's Chat ID
<p style="margin-top: 8px;">
<p style="margin-top: 8px;">
You can get your chat id by sending message to the bot and go to this url to view the chat_id:
</p>
<template v-if="notification.telegramBotToken">
<a :href="telegramGetUpdatesURL" target="_blank">{{ telegramGetUpdatesURL }}</a>
</template>
<p style="margin-top: 8px;">
<template v-if="notification.telegramBotToken">
<a :href="telegramGetUpdatesURL" target="_blank" style="word-break: break-word;">{{ telegramGetUpdatesURL }}</a>
</template>
<template v-else>
{{ telegramGetUpdatesURL }}
</template>
</p>
</div>
<template v-else>
{{ telegramGetUpdatesURL }}
</template>
</p>
</div>
</template>
</div>
</template>
<template v-if="notification.type === 'webhook'">
<div class="mb-3">
<label for="webhook-url" class="form-label">Post URL</label>
<input type="url" pattern="https?://.+" class="form-control" id="webhook-url" required v-model="notification.webhookURL">
<input id="webhook-url" v-model="notification.webhookURL" type="url" pattern="https?://.+" class="form-control" required>
</div>
<div class="mb-3">
<label for="webhook-content-type" class="form-label">Content Type</label>
<select class="form-select" id="webhook-content-type" v-model="notification.webhookContentType" required>
<option value="json">application/json</option>
<option value="form-data">multipart/form-data</option>
<select id="webhook-content-type" v-model="notification.webhookContentType" class="form-select" required>
<option value="json">
application/json
</option>
<option value="form-data">
multipart/form-data
</option>
</select>
<div class="form-text">
@ -86,70 +97,71 @@
<template v-if="notification.type === 'smtp'">
<div class="mb-3">
<label for="hostname" class="form-label">Hostname</label>
<input type="text" class="form-control" id="hostname" required v-model="notification.smtpHost">
<input id="hostname" v-model="notification.smtpHost" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="port" class="form-label">Port</label>
<input type="number" class="form-control" id="port" v-model="notification.smtpPort" required min="0" max="65535" step="1">
<input id="port" v-model="notification.smtpPort" type="number" class="form-control" required min="0" max="65535" step="1">
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="secure" v-model="notification.smtpSecure">
<input id="secure" v-model="notification.smtpSecure" class="form-check-input" type="checkbox" value="">
<label class="form-check-label" for="secure">
Secure
</label>
</div>
<div class="form-text">Generally, true for 465, false for other ports.</div>
<div class="form-text">
Generally, true for 465, false for other ports.
</div>
</div>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" v-model="notification.smtpUsername" autocomplete="false">
<input id="username" v-model="notification.smtpUsername" type="text" class="form-control" autocomplete="false">
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" v-model="notification.smtpPassword" autocomplete="false">
<input id="password" v-model="notification.smtpPassword" type="password" class="form-control" autocomplete="false">
</div>
<div class="mb-3">
<label for="from-email" class="form-label">From Email</label>
<input type="email" class="form-control" id="from-email" required v-model="notification.smtpFrom" autocomplete="false">
<input id="from-email" v-model="notification.smtpFrom" type="email" class="form-control" required autocomplete="false">
</div>
<div class="mb-3">
<label for="to-email" class="form-label">To Email</label>
<input type="email" class="form-control" id="to-email" required v-model="notification.smtpTo" autocomplete="false">
<input id="to-email" v-model="notification.smtpTo" type="email" class="form-control" required autocomplete="false">
</div>
</template>
<template v-if="notification.type === 'discord'">
<div class="mb-3">
<label for="discord-webhook-url" class="form-label">Discord Webhook URL</label>
<input type="text" class="form-control" id="discord-webhook-url" required v-model="notification.discordWebhookUrl" autocomplete="false">
<div class="form-text">You can get this by going to Server Settings -> Integrations -> Create Webhook</div>
<input id="discord-webhook-url" v-model="notification.discordWebhookUrl" type="text" class="form-control" required autocomplete="false">
<div class="form-text">
You can get this by going to Server Settings -> Integrations -> Create Webhook
</div>
</div>
</template>
<template v-if="notification.type === 'signal'">
<div class="mb-3">
<label for="signal-url" class="form-label">Post URL</label>
<input type="url" pattern="https?://.+" class="form-control" id="signal-url" required v-model="notification.signalURL">
<input id="signal-url" v-model="notification.signalURL" type="url" pattern="https?://.+" class="form-control" required>
</div>
<div class="mb-3">
<label for="signal-number" class="form-label">Number</label>
<input type="text" class="form-control" id="signal-number" required v-model="notification.signalNumber">
<input id="signal-number" v-model="notification.signalNumber" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="signal-recipients" class="form-label">Recipients</label>
<input type="text" class="form-control" id="signal-recipients" required v-model="notification.signalRecipients">
<input id="signal-recipients" v-model="notification.signalRecipients" type="text" class="form-control" required>
<div class="form-text">
You need to have a signal client with REST API.
@ -169,34 +181,177 @@
</div>
</template>
<template v-if="notification.type === 'gotify'">
<div class="mb-3">
<label for="gotify-application-token" class="form-label">Application Token</label>
<input id="gotify-application-token" v-model="notification.gotifyapplicationToken" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="gotify-server-url" class="form-label">Server URL</label>
<div class="input-group mb-3">
<input id="gotify-server-url" v-model="notification.gotifyserverurl" type="text" class="form-control" required>
</div>
</div>
<div class="mb-3">
<label for="gotify-priority" class="form-label">Priority</label>
<input id="gotify-priority" v-model="notification.gotifyPriority" type="number" class="form-control" required min="0" max="10" step="1">
</div>
</template>
<template v-if="notification.type === 'slack'">
<div class="mb-3">
<label for="slack-webhook-url" class="form-label">Webhook URL<span style="color:red;"><sup>*</sup></span></label>
<input id="slack-webhook-url" v-model="notification.slackwebhookURL" type="text" class="form-control" required>
<label for="slack-username" class="form-label">Username</label>
<input id="slack-username" v-model="notification.slackusername" type="text" class="form-control">
<label for="slack-iconemo" class="form-label">Icon Emoji</label>
<input id="slack-iconemo" v-model="notification.slackiconemo" type="text" class="form-control">
<label for="slack-channel" class="form-label">Channel Name</label>
<input id="slack-channel-name" v-model="notification.slackchannel" type="text" class="form-control">
<label for="slack-button-url" class="form-label">Uptime Kuma URL</label>
<input id="slack-button" v-model="notification.slackbutton" type="text" class="form-control">
<div class="form-text">
<span style="color:red;"><sup>*</sup></span>Required
<p style="margin-top: 8px;">
More info about webhooks on: <a href="https://api.slack.com/messaging/webhooks" target="_blank">https://api.slack.com/messaging/webhooks</a>
</p>
<p style="margin-top: 8px;">
Enter the channel name on Slack Channel Name field if you want to bypass the webhook channel. Ex: #other-channel
</p>
<p style="margin-top: 8px;">
If you leave the Uptime Kuma URL field blank, it will default to the Project Github page.
</p>
<p style="margin-top: 8px;">
Emoji cheat sheet: <a href="https://www.webfx.com/tools/emoji-cheat-sheet/" target="_blank">https://www.webfx.com/tools/emoji-cheat-sheet/</a>
</p>
</div>
</div>
</template>
<template v-if="notification.type === 'pushover'">
<div class="mb-3">
<label for="pushover-user" class="form-label">User Key<span style="color:red;"><sup>*</sup></span></label>
<input id="pushover-user" v-model="notification.pushoveruserkey" type="text" class="form-control" required>
<label for="pushover-app-token" class="form-label">Application Token<span style="color:red;"><sup>*</sup></span></label>
<input id="pushover-app-token" v-model="notification.pushoverapptoken" type="text" class="form-control" required>
<label for="pushover-device" class="form-label">Device</label>
<input id="pushover-device" v-model="notification.pushoverdevice" type="text" class="form-control">
<label for="pushover-device" class="form-label">Message Title</label>
<input id="pushover-title" v-model="notification.pushovertitle" type="text" class="form-control">
<label for="pushover-priority" class="form-label">Priority</label>
<select id="pushover-priority" v-model="notification.pushoverpriority" class="form-select">
<option>-2</option>
<option>-1</option>
<option>0</option>
<option>1</option>
<option>2</option>
</select>
<label for="pushover-sound" class="form-label">Notification Sound</label>
<select id="pushover-sound" v-model="notification.pushoversounds" class="form-select">
<option>pushover</option>
<option>bike</option>
<option>bugle</option>
<option>cashregister</option>
<option>classical</option>
<option>cosmic</option>
<option>falling</option>
<option>gamelan</option>
<option>incoming</option>
<option>intermission</option>
<option>mechanical</option>
<option>pianobar</option>
<option>siren</option>
<option>spacealarm</option>
<option>tugboat</option>
<option>alien</option>
<option>climb</option>
<option>persistent</option>
<option>echo</option>
<option>updown</option>
<option>vibrate</option>
<option>none</option>
</select>
<div class="form-text">
<span style="color:red;"><sup>*</sup></span>Required
<p style="margin-top: 8px;">
More info on: <a href="https://pushover.net/api" target="_blank">https://pushover.net/api</a>
</p>
<p style="margin-top: 8px;">
Emergency priority (2) has default 30 second timeout between retries and will expire after 1 hour.
</p>
<p style="margin-top: 8px;">
If you want to send notifications to different devices, fill out Device field.
</p>
</div>
</div>
</template>
<template v-if="notification.type === 'apprise'">
<div class="mb-3">
<label for="apprise-url" class="form-label">Apprise URL</label>
<input id="apprise-url" v-model="notification.appriseURL" type="text" class="form-control" required>
<div class="form-text">
<p>Example: twilio://AccountSid:AuthToken@FromPhoneNo</p>
<p>
Read more: <a href="https://github.com/caronc/apprise/wiki#notification-services" target="_blank">https://github.com/caronc/apprise/wiki#notification-services</a>
</p>
</div>
</div>
<div class="mb-3">
<p>
Status:
<span v-if="appriseInstalled" class="text-primary">Apprise is installed</span>
<span v-else class="text-danger">Apprise is not installed. <a href="https://github.com/caronc/apprise">Read more</a></span>
</p>
</div>
</template>
<template v-if="notification.type === 'lunasea'">
<div class="mb-3">
<label for="lunasea-device" class="form-label">LunaSea Device ID<span style="color:red;"><sup>*</sup></span></label>
<input id="lunasea-device" v-model="notification.lunaseaDevice" type="text" class="form-control" required>
<div class="form-text">
<p><span style="color:red;"><sup>*</sup></span>Required</p>
</div>
</div>
</template>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" @click="deleteConfirm" :disabled="processing" v-if="id">Delete</button>
<button type="button" class="btn btn-warning" @click="test" :disabled="processing">Test</button>
<button type="submit" class="btn btn-primary" :disabled="processing">Save</button>
<button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
Delete
</button>
<button type="button" class="btn btn-warning" :disabled="processing" @click="test">
Test
</button>
<button type="submit" class="btn btn-primary" :disabled="processing">
Save
</button>
</div>
</div>
</div>
</div>
</form>
<Confirm ref="confirmDelete" @yes="deleteNotification" btn-style="btn-danger">Are you sure want to delete this notification for all monitors?</Confirm>
<Confirm ref="confirmDelete" btn-style="btn-danger" @yes="deleteNotification">
Are you sure want to delete this notification for all monitors?
</Confirm>
</template>
<script>
import { Modal } from 'bootstrap'
import { ucfirst } from "../../server/util";
<script lang="ts">
import { Modal } from "bootstrap"
import { ucfirst } from "../util.ts"
import axios from "axios";
import { useToast } from 'vue-toastification'
import { useToast } from "vue-toastification"
import Confirm from "./Confirm.vue";
const toast = useToast()
export default {
components: {Confirm},
props: {
components: {
Confirm,
},
props: {},
data() {
return {
model: null,
@ -205,18 +360,43 @@ export default {
notification: {
name: "",
type: null,
gotifyPriority: 8,
},
appriseInstalled: false,
}
},
computed: {
telegramGetUpdatesURL() {
let token = "<YOUR BOT TOKEN HERE>"
if (this.notification.telegramBotToken) {
token = this.notification.telegramBotToken;
}
return `https://api.telegram.org/bot${token}/getUpdates`;
},
},
watch: {
"notification.type"(to, from) {
let oldName;
if (from) {
oldName = `My ${ucfirst(from)} Alert (1)`;
} else {
oldName = "";
}
if (! this.notification.name || this.notification.name === oldName) {
this.notification.name = `My ${ucfirst(to)} Alert (1)`
}
},
},
mounted() {
this.modal = new Modal(this.$refs.modal)
// TODO: for edit
this.$root.getSocket().emit("getSettings", "notification", (data) => {
// this.notification = data
this.$root.getSocket().emit("checkApprise", (installed) => {
this.appriseInstalled = installed;
})
},
methods: {
@ -244,6 +424,7 @@ export default {
// Default set to Telegram
this.notification.type = "telegram"
this.notification.gotifyPriority = 8
}
this.modal.show()
@ -307,35 +488,5 @@ export default {
},
},
computed: {
telegramGetUpdatesURL() {
let token = "<YOUR BOT TOKEN HERE>"
if (this.notification.telegramBotToken) {
token = this.notification.telegramBotToken;
}
return `https://api.telegram.org/bot${token}/getUpdates`;
},
},
watch: {
"notification.type"(to, from) {
let oldName;
if (from) {
oldName = `My ${ucfirst(from)} Alert (1)`;
} else {
oldName = "";
}
if (! this.notification.name || this.notification.name === oldName) {
this.notification.name = `My ${ucfirst(to)} Alert (1)`
}
}
}
}
</script>
<style scoped>
</style>

30
src/components/Status.vue

@ -5,35 +5,47 @@
<script>
export default {
props: {
status: Number
status: Number,
},
computed: {
color() {
if (this.status === 0) {
return "danger"
} else if (this.status === 1) {
}
if (this.status === 1) {
return "primary"
} else {
return "secondary"
}
if (this.status === 2) {
return "warning"
}
return "secondary"
},
text() {
if (this.status === 0) {
return "Down"
} else if (this.status === 1) {
}
if (this.status === 1) {
return "Up"
} else {
return "Unknown"
}
if (this.status === 2) {
return "Pending"
}
return "Unknown"
},
}
},
}
</script>
<style scoped>
span {
width: 45px;
width: 64px;
}
</style>

38
src/components/Uptime.vue

@ -5,10 +5,10 @@
<script>
export default {
props: {
monitor : Object,
monitor: Object,
type: String,
pill: {
Boolean,
type: Boolean,
default: false,
},
},
@ -20,42 +20,50 @@ export default {
if (this.$root.uptimeList[key] !== undefined) {
return Math.round(this.$root.uptimeList[key] * 10000) / 100 + "%";
} else {
return "N/A"
}
return "N/A"
},
color() {
if (this.lastHeartBeat.status === 0) {
return "danger"
} else if (this.lastHeartBeat.status === 1) {
}
if (this.lastHeartBeat.status === 1) {
return "primary"
} else {
return "secondary"
}
if (this.lastHeartBeat.status === 2) {
return "warning"
}
return "secondary"
},
lastHeartBeat() {
if (this.monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[this.monitor.id]) {
return this.$root.lastHeartbeatList[this.monitor.id]
} else {
return { status: -1 }
}
return {
status: -1,
}
},
className() {
if (this.pill) {
return `badge rounded-pill bg-${this.color}`;
} else {
return "";
}
},
return "";
},
},
}
</script>
<style scoped>
<style>
.badge {
min-width: 62px;
}
</style>

10
src/icon.js

@ -0,0 +1,10 @@
import { library } from "@fortawesome/fontawesome-svg-core"
import { faCog, faEdit, faList, faPause, faPlay, faPlus, faTachometerAlt, faTrash } from "@fortawesome/free-solid-svg-icons"
//import { fa } from '@fortawesome/free-regular-svg-icons'
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"
// Add Free Font Awesome Icons here
// https://fontawesome.com/v5.15/icons?d=gallery&p=2&s=solid&m=free
library.add(faCog, faTachometerAlt, faEdit, faPlus, faPause, faPlay, faTrash, faList)
export { FontAwesomeIcon }

8
src/layouts/EmptyLayout.vue

@ -3,11 +3,5 @@
</template>
<script>
export default {
}
export default {}
</script>
<style scoped>
</style>

84
src/layouts/Layout.vue

@ -1,28 +1,35 @@
<template>
<div class="lost-connection" v-if="! $root.socket.connected && ! $root.socket.firstConnect">
<div v-if="! $root.socket.connected && ! $root.socket.firstConnect" class="lost-connection">
<div class="container-fluid">
Lost connection to the socket server. Reconnecting...
{{ $root.connectionErrorMsg }}
</div>
</div>
<!-- Desktop header -->
<header class="d-flex flex-wrap justify-content-center py-3 mb-3 border-bottom" v-if="! $root.isMobile">
<header v-if="! $root.isMobile" class="d-flex flex-wrap justify-content-center py-3 mb-3 border-bottom">
<router-link to="/dashboard" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
<object class="bi me-2 ms-4" width="40" height="40" data="/icon.svg"></object>
<object class="bi me-2 ms-4" width="40" height="40" data="/icon.svg" alt="Logo" />
<span class="fs-4 title">Uptime Kuma</span>
</router-link>
<ul class="nav nav-pills" >
<li class="nav-item"><router-link to="/dashboard" class="nav-link">📊 Dashboard</router-link></li>
<li class="nav-item"><router-link to="/settings" class="nav-link">🔧 Settings</router-link></li>
<ul class="nav nav-pills">
<li class="nav-item">
<router-link to="/dashboard" class="nav-link">
<font-awesome-icon icon="tachometer-alt" /> Dashboard
</router-link>
</li>
<li class="nav-item">
<router-link to="/settings" class="nav-link">
<font-awesome-icon icon="cog" /> Settings
</router-link>
</li>
</ul>
</header>
<!-- Mobile header -->
<header class="d-flex flex-wrap justify-content-center mt-3 mb-3" v-else>
<header v-else class="d-flex flex-wrap justify-content-center mt-3 mb-3">
<router-link to="/dashboard" class="d-flex align-items-center text-dark text-decoration-none">
<object class="bi" width="40" height="40" data="/icon.svg"></object>
<object class="bi" width="40" height="40" data="/icon.svg" />
<span class="fs-4 title ms-2">Uptime Kuma</span>
</router-link>
</header>
@ -37,17 +44,32 @@
<div class="container-fluid">
Uptime Kuma -
Version: {{ $root.info.version }} -
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank">Check Update On GitHub</a>
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">Check Update On GitHub</a>
</div>
</footer>
<!-- Mobile Only -->
<div style="width: 100%;height: 60px;" v-if="$root.isMobile"></div>
<nav class="bottom-nav" v-if="$root.isMobile">
<router-link to="/dashboard" class="nav-link" @click="$root.cancelActiveList"><div>📊</div>Dashboard</router-link>
<a href="#" :class=" { 'router-link-exact-active' : $root.showListMobile } " @click="$root.showListMobile = ! $root.showListMobile"><div>📃</div>List</a>
<router-link to="/add" class="nav-link" @click="$root.cancelActiveList"><div></div>Add</router-link>
<router-link to="/settings" class="nav-link" @click="$root.cancelActiveList"><div>🔧</div>Settings</router-link>
<div v-if="$root.isMobile" style="width: 100%;height: 60px;" />
<nav v-if="$root.isMobile" class="bottom-nav">
<router-link to="/dashboard" class="nav-link" @click="$root.cancelActiveList">
<div><font-awesome-icon icon="tachometer-alt" /></div>
Dashboard
</router-link>
<a href="#" :class=" { 'router-link-exact-active' : $root.showListMobile } " @click="$root.showListMobile = ! $root.showListMobile">
<div><font-awesome-icon icon="list" /></div>
List
</a>
<router-link to="/add" class="nav-link" @click="$root.cancelActiveList">
<div><font-awesome-icon icon="plus" /></div>
Add
</router-link>
<router-link to="/settings" class="nav-link" @click="$root.cancelActiveList">
<div><font-awesome-icon icon="cog" /></div>
Settings
</router-link>
</nav>
</template>
@ -56,23 +78,19 @@ import Login from "../components/Login.vue";
export default {
components: {
Login
Login,
},
data() {
return {
}
},
computed: {
},
mounted() {
this.init();
return {}
},
computed: {},
watch: {
$route (to, from) {
this.init();
}
},
},
mounted() {
this.init();
},
methods: {
init() {
@ -81,7 +99,7 @@ export default {
}
},
}
},
}
</script>
@ -99,7 +117,7 @@ export default {
box-shadow: 0 15px 47px 0 rgba(0, 0, 0, 0.05), 0 5px 14px 0 rgba(0, 0, 0, 0.05);
text-align: center;
white-space: nowrap;
padding: 0 35px;
padding: 0 10px;
a {
text-align: center;
@ -137,15 +155,13 @@ export default {
color: white;
}
main {
}
footer {
color: #AAA;
font-size: 13px;
margin-top: 10px;
margin-bottom: 30px;
margin-left: 10px;
text-align: center;
}
</style>

55
src/main.js

@ -1,58 +1,58 @@
import {createApp, h} from "vue";
import {createRouter, createWebHistory} from 'vue-router'
import App from './App.vue'
import Layout from './layouts/Layout.vue'
import EmptyLayout from './layouts/EmptyLayout.vue'
import Settings from "./pages/Settings.vue";
import "bootstrap";
import { createApp, h } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import Toast from "vue-toastification";
import "vue-toastification/dist/index.css";
import App from "./App.vue";
import "./assets/app.scss";
import { FontAwesomeIcon } from "./icon.js";
import EmptyLayout from "./layouts/EmptyLayout.vue";
import Layout from "./layouts/Layout.vue";
import socket from "./mixins/socket";
import Dashboard from "./pages/Dashboard.vue";
import DashboardHome from "./pages/DashboardHome.vue";
import Details from "./pages/Details.vue";
import socket from "./mixins/socket"
import "./assets/app.scss"
import EditMonitor from "./pages/EditMonitor.vue";
import Toast from "vue-toastification";
import "vue-toastification/dist/index.css";
import "bootstrap"
import Settings from "./pages/Settings.vue";
import Setup from "./pages/Setup.vue";
const routes = [
{
path: '/',
path: "/",
component: Layout,
children: [
{
name: "root",
path: '',
path: "",
component: Dashboard,
children: [
{
name: "DashboardHome",
path: '/dashboard',
path: "/dashboard",
component: DashboardHome,
children: [
{
path: '/dashboard/:id',
path: "/dashboard/:id",
component: EmptyLayout,
children: [
{
path: '',
path: "",
component: Details,
},
{
path: '/edit/:id',
path: "/edit/:id",
component: EditMonitor,
},
]
],
},
{
path: '/add',
path: "/add",
component: EditMonitor,
},
]
],
},
{
path: '/settings',
path: "/settings",
component: Settings,
},
],
@ -62,13 +62,13 @@ const routes = [
},
{
path: '/setup',
path: "/setup",
component: Setup,
},
]
const router = createRouter({
linkActiveClass: 'active',
linkActiveClass: "active",
history: createWebHistory(),
routes,
})
@ -77,16 +77,17 @@ const app = createApp({
mixins: [
socket,
],
render: ()=>h(App)
render: () => h(App),
})
app.use(router)
const options = {
position: "bottom-right"
position: "bottom-right",
};
app.use(Toast, options);
app.mount('#app')
app.component("FontAwesomeIcon", FontAwesomeIcon)
app.mount("#app")

113
src/mixins/socket.js

@ -1,6 +1,6 @@
import {io} from "socket.io-client";
import { useToast } from 'vue-toastification'
import dayjs from "dayjs";
import { io } from "socket.io-client";
import { useToast } from "vue-toastification";
const toast = useToast()
let socket;
@ -25,43 +25,62 @@ export default {
importantHeartbeatList: { },
avgPingList: { },
uptimeList: { },
certInfoList: {},
notificationList: [],
windowWidth: window.innerWidth,
showListMobile: false,
connectionErrorMsg: "Cannot connect to the socket server. Reconnecting..."
}
},
created() {
window.addEventListener('resize', this.onResize);
window.addEventListener("resize", this.onResize);
let wsHost;
if (localStorage.dev === "dev") {
const env = process.env.NODE_ENV || "production";
if (env === "development" || localStorage.dev === "dev") {
wsHost = ":3001"
} else {
wsHost = ""
}
socket = io(wsHost, {
transports: ['websocket']
transports: ["websocket"],
});
socket.on('info', (info) => {
socket.on("info", (info) => {
this.info = info;
});
socket.on('setup', (monitorID, data) => {
socket.on("setup", (monitorID, data) => {
this.$router.push("/setup")
});
socket.on('monitorList', (data) => {
socket.on("autoLogin", (monitorID, data) => {
this.loggedIn = true;
this.storage().token = "autoLogin";
this.allowLoginDialog = false;
});
socket.on("monitorList", (data) => {
// Add Helper function
Object.entries(data).forEach(([monitorID, monitor]) => {
monitor.getUrl = () => {
try {
return new URL(monitor.url);
} catch (_) {
return null;
}
};
});
this.monitorList = data;
});
socket.on('notificationList', (data) => {
socket.on("notificationList", (data) => {
this.notificationList = data;
});
socket.on('heartbeat', (data) => {
socket.on("heartbeat", (data) => {
if (! (data.monitorID in this.heartbeatList)) {
this.heartbeatList[data.monitorID] = [];
}
@ -84,7 +103,6 @@ export default {
toast(`[${this.monitorList[data.monitorID].name}] ${data.msg}`);
}
if (! (data.monitorID in this.importantHeartbeatList)) {
this.importantHeartbeatList[data.monitorID] = [];
}
@ -93,7 +111,7 @@ export default {
}
});
socket.on('heartbeatList', (monitorID, data) => {
socket.on("heartbeatList", (monitorID, data) => {
if (! (monitorID in this.heartbeatList)) {
this.heartbeatList[monitorID] = data;
} else {
@ -101,15 +119,19 @@ export default {
}
});
socket.on('avgPing', (monitorID, data) => {
socket.on("avgPing", (monitorID, data) => {
this.avgPingList[monitorID] = data
});
socket.on('uptime', (monitorID, type, data) => {
socket.on("uptime", (monitorID, type, data) => {
this.uptimeList[`${monitorID}_${type}`] = data
});
socket.on('importantHeartbeatList', (monitorID, data) => {
socket.on("certInfo", (monitorID, data) => {
this.certInfoList[monitorID] = JSON.parse(data)
});
socket.on("importantHeartbeatList", (monitorID, data) => {
if (! (monitorID in this.importantHeartbeatList)) {
this.importantHeartbeatList[monitorID] = data;
} else {
@ -117,12 +139,20 @@ export default {
}
});
socket.on('disconnect', () => {
socket.on("connect_error", (err) => {
console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`);
this.connectionErrorMsg = `Cannot connect to the socket server. [${err}] Reconnecting...`;
this.socket.connected = false;
this.socket.firstConnect = false;
});
socket.on("disconnect", () => {
console.log("disconnect")
this.connectionErrorMsg = "Lost connection to the socket server. Reconnecting...";
this.socket.connected = false;
});
socket.on('connect', () => {
socket.on("connect", () => {
console.log("connect")
this.socket.connectCount++;
this.socket.connected = true;
@ -132,8 +162,22 @@ export default {
this.clearData()
}
if (this.storage().token) {
this.loginByToken(this.storage().token)
let token = this.storage().token;
if (token) {
if (token !== "autoLogin") {
this.loginByToken(token)
} else {
// Timeout if it is not actually auto login
setTimeout(() => {
if (! this.loggedIn) {
this.allowLoginDialog = true;
this.$root.storage().removeItem("token");
}
}, 5000);
}
} else {
this.allowLoginDialog = true;
}
@ -158,7 +202,7 @@ export default {
},
getSocket() {
return socket;
return socket;
},
toastRes(res) {
@ -181,7 +225,7 @@ export default {
this.loggedIn = true;
// Trigger Chrome Save Password
history.pushState({}, '')
history.pushState({}, "")
}
callback(res)
@ -234,10 +278,9 @@ export default {
if (this.userTimezone === "auto") {
return dayjs.tz.guess()
} else {
return this.userTimezone
}
return this.userTimezone
},
lastHeartbeatList() {
@ -256,7 +299,7 @@ export default {
let unknown = {
text: "Unknown",
color: "secondary"
color: "secondary",
}
for (let monitorID in this.lastHeartbeatList) {
@ -267,12 +310,17 @@ export default {
} else if (lastHeartBeat.status === 1) {
result[monitorID] = {
text: "Up",
color: "primary"
color: "primary",
};
} else if (lastHeartBeat.status === 0) {
result[monitorID] = {
text: "Down",
color: "danger"
color: "danger",
};
} else if (lastHeartBeat.status === 2) {
result[monitorID] = {
text: "Pending",
color: "warning",
};
} else {
result[monitorID] = unknown;
@ -280,23 +328,22 @@ export default {
}
return result;
}
},
},
watch: {
// Reload the SPA if the server version is changed.
"info.version"(to, from) {
if (from && from !== to) {
window.location.reload()
}
if (from && from !== to) {
window.location.reload()
}
},
remember() {
localStorage.remember = (this.remember) ? "1" : "0"
}
},
}
},
}

34
src/pages/Dashboard.vue

@ -1,36 +1,29 @@
<template>
<div class="container-fluid">
<div class="row">
<div class="col-12 col-md-5 col-xl-4">
<div v-if="! $root.isMobile">
<router-link to="/add" class="btn btn-primary">Add New Monitor</router-link>
<router-link to="/add" class="btn btn-primary"><font-awesome-icon icon="plus" /> Add New Monitor</router-link>
</div>
<div class="shadow-box list mb-4" v-if="showList">
<div class="text-center mt-3" v-if="Object.keys($root.monitorList).length === 0">
<div v-if="showList" class="shadow-box list mb-4">
<div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3">
No Monitors, please <router-link to="/add">add one</router-link>.
</div>
<router-link :to="monitorURL(item.id)" class="item" :class="{ 'disabled': ! item.active }" v-for="item in sortedMonitorList" @click="$root.cancelActiveList">
<router-link v-for="(item, index) in sortedMonitorList" :key="index" :to="monitorURL(item.id)" class="item" :class="{ 'disabled': ! item.active }" @click="$root.cancelActiveList">
<div class="row">
<div class="col-6 col-md-8 small-padding">
<div class="col-6 col-md-8 small-padding">
<div class="info">
<Uptime :monitor="item" type="24" :pill="true" />
{{ item.name }}
</div>
</div>
<div class="col-6 col-md-4">
<div class="col-6 col-md-4">
<HeartbeatBar size="small" :monitor-id="item.id" />
</div>
</div>
</router-link>
</div>
</div>
<div class="col-12 col-md-7 col-xl-8">
@ -38,7 +31,6 @@
</div>
</div>
</div>
</template>
<script>
@ -49,12 +41,10 @@ import Uptime from "../components/Uptime.vue";
export default {
components: {
Uptime,
HeartbeatBar
HeartbeatBar,
},
data() {
return {
}
return {}
},
computed: {
sortedMonitorList() {
@ -94,8 +84,8 @@ export default {
methods: {
monitorURL(id) {
return "/dashboard/" + id;
}
}
},
},
}
</script>
@ -138,10 +128,6 @@ export default {
}
}
.badge {
min-width: 58px;
}
.small-padding {
padding-left: 5px !important;
padding-right: 5px !important;

96
src/pages/DashboardHome.vue

@ -1,15 +1,16 @@
<template>
<div v-if="$route.name === 'DashboardHome'">
<h1 class="mb-3">Quick Stats</h1>
<h1 class="mb-3">
Quick Stats
</h1>
<div class="shadow-box big-padding text-center">
<div class="row">
<div class="col">
<div class="col">
<h3>Up</h3>
<span class="num">{{ stats.up }}</span>
</div>
<div class="col">
<div class="col">
<h3>Down</h3>
<span class="num text-danger">{{ stats.down }}</span>
</div>
@ -22,16 +23,16 @@
<span class="num text-secondary">{{ stats.pause }}</span>
</div>
</div>
<div class="row" v-if="false">
<div v-if="false" class="row">
<div class="col-3">
<h3>Uptime</h3>
<p>(24-hour)</p>
<span class="num"></span>
<span class="num" />
</div>
<div class="col-3">
<h3>Uptime</h3>
<p>(30-day)</p>
<span class="num"></span>
<span class="num" />
</div>
</div>
</div>
@ -39,26 +40,36 @@
<div class="shadow-box" style="margin-top: 25px;">
<table class="table table-borderless table-hover">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>DateTime</th>
<th>Message</th>
</tr>
<tr>
<th>Name</th>
<th>Status</th>
<th>DateTime</th>
<th>Message</th>
</tr>
</thead>
<tbody>
<tr v-for="beat in importantHeartBeatList">
<td>{{ beat.name }}</td>
<td><Status :status="beat.status" /></td>
<td><Datetime :value="beat.time" /></td>
<td>{{ beat.msg }}</td>
</tr>
<tr v-if="importantHeartBeatList.length === 0">
<td colspan="4">No important events</td>
</tr>
<tr v-for="(beat, index) in displayedRecords" :key="index">
<td>{{ beat.name }}</td>
<td><Status :status="beat.status" /></td>
<td><Datetime :value="beat.time" /></td>
<td>{{ beat.msg }}</td>
</tr>
<tr v-if="importantHeartBeatList.length === 0">
<td colspan="4">
No important events
</td>
</tr>
</tbody>
</table>
<div class="d-flex justify-content-center kuma_pagination">
<pagination
v-model="page"
:records="importantHeartBeatList.length"
:per-page="perPage"
/>
</div>
</div>
</div>
@ -68,8 +79,21 @@
<script>
import Status from "../components/Status.vue";
import Datetime from "../components/Datetime.vue";
import Pagination from "v-pagination-3";
export default {
components: {Datetime, Status},
components: {
Datetime,
Status,
Pagination,
},
data() {
return {
page: 1,
perPage: 25,
heartBeatList: [],
}
},
computed: {
stats() {
let result = {
@ -90,6 +114,8 @@ export default {
result.up++;
} else if (beat.status === 0) {
result.down++;
} else if (beat.status === 2) {
result.up++;
} else {
result.unknown++;
}
@ -105,7 +131,7 @@ export default {
let result = [];
for (let monitorID in this.$root.importantHeartbeatList) {
let list = this.$root.importantHeartbeatList[monitorID]
let list = this.$root.importantHeartbeatList[monitorID]
result = result.concat(list);
}
@ -120,16 +146,26 @@ export default {
result.sort((a, b) => {
if (a.time > b.time) {
return -1;
} else if (a.time < b.time) {
}
if (a.time < b.time) {
return 1;
} else {
return 0;
}
return 0;
});
this.heartBeatList = result;
return result;
}
}
},
displayedRecords() {
const startIndex = this.perPage * (this.page - 1);
const endIndex = startIndex + this.perPage;
return this.heartBeatList.slice(startIndex, endIndex);
},
},
}
</script>

161
src/pages/Details.vue

@ -1,20 +1,28 @@
<template>
<h1> {{ monitor.name }}</h1>
<p class="url">
<a :href="monitor.url" target="_blank" v-if="monitor.type === 'http' || monitor.type === 'keyword' ">{{ monitor.url }}</a>
<a v-if="monitor.type === 'http' || monitor.type === 'keyword' " :href="monitor.url" target="_blank">{{ monitor.url }}</a>
<span v-if="monitor.type === 'port'">TCP Ping {{ monitor.hostname }}:{{ monitor.port }}</span>
<span v-if="monitor.type === 'ping'">Ping: {{ monitor.hostname }}</span>
<span v-if="monitor.type === 'keyword'">
<br />
<br>
<span>Keyword:</span> <span style="color: black">{{ monitor.keyword }}</span>
</span>
</p>
<div class="functions">
<button class="btn btn-light" @click="pauseDialog" v-if="monitor.active">Pause</button>
<button class="btn btn-primary" @click="resumeMonitor" v-if="! monitor.active">Resume</button>
<router-link :to=" '/edit/' + monitor.id " class="btn btn-secondary">Edit</router-link>
<button class="btn btn-danger" @click="deleteDialog">Delete</button>
<button v-if="monitor.active" class="btn btn-light" @click="pauseDialog">
<font-awesome-icon icon="pause" /> Pause
</button>
<button v-if="! monitor.active" class="btn btn-primary" @click="resumeMonitor">
<font-awesome-icon icon="play" /> Resume
</button>
<router-link :to=" '/edit/' + monitor.id " class="btn btn-secondary">
<font-awesome-icon icon="edit" /> Edit
</router-link>
<button class="btn btn-danger" @click="deleteDialog">
<font-awesome-icon icon="trash" /> Delete
</button>
</div>
<div class="shadow-box">
@ -37,7 +45,7 @@
<span class="num"><CountUp :value="ping" /></span>
</div>
<div class="col">
<h4>Avg.{{ pingTitle }}</h4>
<h4>Avg. {{ pingTitle }}</h4>
<p>(24-hour)</p>
<span class="num"><CountUp :value="avgPing" /></span>
</div>
@ -51,6 +59,56 @@
<p>(30-day)</p>
<span class="num"><Uptime :monitor="monitor" type="720" /></span>
</div>
<div v-if="certInfo" class="col">
<h4>Cert Exp.</h4>
<p>(<Datetime :value="certInfo.validTo" date-only />)</p>
<span class="num">
<a href="#" @click.prevent="toggleCertInfoBox = !toggleCertInfoBox">{{ certInfo.daysRemaining }} days</a>
</span>
</div>
</div>
</div>
<div v-if="showCertInfoBox" class="shadow-box big-padding text-center">
<div class="row">
<div class="col">
<h4>Certificate Info</h4>
<table class="text-start">
<tbody>
<tr class="my-3">
<td class="px-3">
Valid:
</td>
<td>{{ certInfo.valid }}</td>
</tr>
<tr class="my-3">
<td class="px-3">
Valid To:
</td>
<td><Datetime :value="certInfo.validTo" /></td>
</tr>
<tr class="my-3">
<td class="px-3">
Days Remaining:
</td>
<td>{{ certInfo.daysRemaining }}</td>
</tr>
<tr class="my-3">
<td class="px-3">
Issuer:
</td>
<td>{{ certInfo.issuer }}</td>
</tr>
<tr class="my-3">
<td class="px-3">
Fingerprint:
</td>
<td>{{ certInfo.fingerprint }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@ -64,30 +122,40 @@
</tr>
</thead>
<tbody>
<tr v-for="beat in importantHeartBeatList">
<tr v-for="(beat, index) in displayedRecords" :key="index">
<td><Status :status="beat.status" /></td>
<td><Datetime :value="beat.time" /></td>
<td>{{ beat.msg }}</td>
</tr>
<tr v-if="importantHeartBeatList.length === 0">
<td colspan="3">No important events</td>
<td colspan="3">
No important events
</td>
</tr>
</tbody>
</table>
<div class="d-flex justify-content-center kuma_pagination">
<pagination
v-model="page"
:records="importantHeartBeatList.length"
:per-page="perPage"
/>
</div>
</div>
<Confirm ref="confirmPause" @yes="pauseMonitor">
Are you sure want to pause?
</Confirm>
<Confirm ref="confirmDelete" btnStyle="btn-danger" @yes="deleteMonitor">
<Confirm ref="confirmDelete" btn-style="btn-danger" @yes="deleteMonitor">
Are you sure want to delete this monitor?
</Confirm>
</template>
<script>
import { useToast } from 'vue-toastification'
import { useToast } from "vue-toastification"
const toast = useToast()
import Confirm from "../components/Confirm.vue";
import HeartbeatBar from "../components/HeartbeatBar.vue";
@ -95,6 +163,7 @@ import Status from "../components/Status.vue";
import Datetime from "../components/Datetime.vue";
import CountUp from "../components/CountUp.vue";
import Uptime from "../components/Uptime.vue";
import Pagination from "v-pagination-3";
export default {
components: {
@ -104,13 +173,14 @@ export default {
HeartbeatBar,
Confirm,
Status,
},
mounted() {
Pagination,
},
data() {
return {
page: 1,
perPage: 25,
heartBeatList: [],
toggleCertInfoBox: false,
}
},
computed: {
@ -118,9 +188,9 @@ export default {
pingTitle() {
if (this.monitor.type === "http") {
return "Response"
} else {
return "Ping"
}
return "Ping"
},
monitor() {
@ -131,42 +201,65 @@ export default {
lastHeartBeat() {
if (this.monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[this.monitor.id]) {
return this.$root.lastHeartbeatList[this.monitor.id]
} else {
return { status: -1 }
}
return {
status: -1,
}
},
ping() {
if (this.lastHeartBeat.ping || this.lastHeartBeat.ping === 0) {
return this.lastHeartBeat.ping;
} else {
return "N/A"
}
return "N/A"
},
avgPing() {
if (this.$root.avgPingList[this.monitor.id] || this.$root.avgPingList[this.monitor.id] === 0) {
return this.$root.avgPingList[this.monitor.id];
} else {
return "N/A"
}
return "N/A"
},
importantHeartBeatList() {
if (this.$root.importantHeartbeatList[this.monitor.id]) {
this.heartBeatList = this.$root.importantHeartbeatList[this.monitor.id];
return this.$root.importantHeartbeatList[this.monitor.id]
} else {
return [];
}
return [];
},
status() {
if (this.$root.statusList[this.monitor.id]) {
return this.$root.statusList[this.monitor.id]
} else {
return { }
}
}
return { }
},
certInfo() {
if (this.$root.certInfoList[this.monitor.id]) {
return this.$root.certInfoList[this.monitor.id]
}
return null
},
showCertInfoBox() {
return this.certInfo != null && this.toggleCertInfoBox;
},
displayedRecords() {
const startIndex = this.perPage * (this.page - 1);
const endIndex = startIndex + this.perPage;
return this.heartBeatList.slice(startIndex, endIndex);
},
},
mounted() {
},
methods: {
@ -204,9 +297,9 @@ export default {
toast.error(res.msg);
}
})
}
},
}
},
}
</script>
@ -251,4 +344,12 @@ table {
font-size: 13px;
color: #AAA;
}
.stats {
padding: 10px;
.col {
margin: 20px 0;
}
}
</style>

145
src/pages/EditMonitor.vue

@ -1,79 +1,121 @@
<template>
<h1 class="mb-3">{{ pageName }}</h1>
<h1 class="mb-3">
{{ pageName }}
</h1>
<form @submit.prevent="submit">
<div class="shadow-box">
<div class="row">
<div class="col-md-6">
<h2>General</h2>
<div class="shadow-box">
<div class="row">
<div class="col-md-6">
<h2>General</h2>
<div class="mb-3">
<label for="type" class="form-label">Monitor Type</label>
<select class="form-select" aria-label="Default select example" id="type" v-model="monitor.type">
<option value="http">HTTP(s)</option>
<option value="port">TCP Port</option>
<option value="ping">Ping</option>
<option value="keyword">HTTP(s) - Keyword</option>
<select id="type" v-model="monitor.type" class="form-select" aria-label="Default select example">
<option value="http">
HTTP(s)
</option>
<option value="port">
TCP Port
</option>
<option value="ping">
Ping
</option>
<option value="keyword">
HTTP(s) - Keyword
</option>
</select>
</div>
<div class="mb-3">
<label for="name" class="form-label">Friendly Name</label>
<input type="text" class="form-control" id="name" v-model="monitor.name" required>
<input id="name" v-model="monitor.name" type="text" class="form-control" required>
</div>
<div class="mb-3" v-if="monitor.type === 'http' || monitor.type === 'keyword' ">
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="mb-3">
<label for="url" class="form-label">URL</label>
<input type="url" class="form-control" id="url" v-model="monitor.url" pattern="https?://.+" required>
<input id="url" v-model="monitor.url" type="url" class="form-control" pattern="https?://.+" required>
</div>
<div class="mb-3" v-if="monitor.type === 'keyword' ">
<div v-if="monitor.type === 'keyword' " class="mb-3">
<label for="keyword" class="form-label">Keyword</label>
<input type="text" class="form-control" id="keyword" v-model="monitor.keyword" required>
<div class="form-text">Search keyword in plain html or JSON response and it is case-sensitive</div>
<input id="keyword" v-model="monitor.keyword" type="text" class="form-control" required>
<div class="form-text">
Search keyword in plain html or JSON response and it is case-sensitive
</div>
</div>
<div class="mb-3" v-if="monitor.type === 'port' || monitor.type === 'ping' ">
<div v-if="monitor.type === 'port' || monitor.type === 'ping' " class="mb-3">
<label for="hostname" class="form-label">Hostname</label>
<input type="text" class="form-control" id="hostname" v-model="monitor.hostname" required>
<input id="hostname" v-model="monitor.hostname" type="text" class="form-control" required>
</div>
<div class="mb-3" v-if="monitor.type === 'port' ">
<div v-if="monitor.type === 'port' " class="mb-3">
<label for="port" class="form-label">Port</label>
<input type="number" class="form-control" id="port" v-model="monitor.port" required min="0" max="65535" step="1">
<input id="port" v-model="monitor.port" type="number" class="form-control" required min="0" max="65535" step="1">
</div>
<div class="mb-3">
<label for="interval" class="form-label">Heartbeat Interval (Every {{ monitor.interval }} seconds)</label>
<input type="number" class="form-control" id="interval" v-model="monitor.interval" required min="20" step="1">
<input id="interval" v-model="monitor.interval" type="number" class="form-control" required min="20" step="1">
</div>
<div>
<button class="btn btn-primary" type="submit" :disabled="processing">Save</button>
<div class="mb-3">
<label for="maxRetries" class="form-label">Retries</label>
<input id="maxRetries" v-model="monitor.maxretries" type="number" class="form-control" required min="0" step="1">
<div class="form-text">
Maximum retries before the service is marked as down and a notification is sent
</div>
</div>
</div>
<h2>Advanced</h2>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="mb-3 form-check">
<input id="ignore-tls" v-model="monitor.ignoreTls" class="form-check-input" type="checkbox" value="">
<label class="form-check-label" for="ignore-tls">
Ignore TLS/SSL error for HTTPS websites
</label>
</div>
<div class="mb-3 form-check">
<input id="upside-down" v-model="monitor.upsideDown" class="form-check-input" type="checkbox">
<label class="form-check-label" for="upside-down">
Upside Down Mode
</label>
<div class="form-text">
Flip the status upside down. If the service is reachable, it is DOWN.
</div>
</div>
<div class="col-md-6">
<div>
<button class="btn btn-primary" type="submit" :disabled="processing">
Save
</button>
</div>
</div>
<div class="mt-3" v-if="$root.isMobile"></div>
<div class="col-md-6">
<div v-if="$root.isMobile" class="mt-3" />
<h2>Notifications</h2>
<p v-if="$root.notificationList.length === 0">Not available, please setup.</p>
<h2>Notifications</h2>
<p v-if="$root.notificationList.length === 0">
Not available, please setup.
</p>
<div class="form-check form-switch mb-3" v-for="notification in $root.notificationList">
<input class="form-check-input" type="checkbox" :id=" 'notification' + notification.id" v-model="monitor.notificationIDList[notification.id]">
<div v-for="notification in $root.notificationList" :key="notification.id" class="form-check form-switch mb-3">
<input :id=" 'notification' + notification.id" v-model="monitor.notificationIDList[notification.id]" class="form-check-input" type="checkbox">
<label class="form-check-label" :for=" 'notification' + notification.id">
{{ notification.name }}
<a href="#" @click="$refs.notificationDialog.show(notification.id)">Edit</a>
</label>
</div>
<label class="form-check-label" :for=" 'notification' + notification.id">
{{ notification.name }}
<a href="#" @click="$refs.notificationDialog.show(notification.id)">Edit</a>
</label>
</div>
<button class="btn btn-primary me-2" @click="$refs.notificationDialog.show()" type="button">Setup Notification</button>
<button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()">
Setup Notification
</button>
</div>
</div>
</div>
</div>
</form>
<NotificationDialog ref="notificationDialog" />
@ -81,15 +123,12 @@
<script>
import NotificationDialog from "../components/NotificationDialog.vue";
import { useToast } from 'vue-toastification'
import { useToast } from "vue-toastification"
const toast = useToast()
export default {
components: {
NotificationDialog
},
mounted() {
this.init();
NotificationDialog,
},
data() {
return {
@ -108,7 +147,15 @@ export default {
},
isEdit() {
return this.$route.path.startsWith("/edit");
}
},
},
watch: {
"$route.fullPath" () {
this.init();
},
},
mounted() {
this.init();
},
methods: {
init() {
@ -119,7 +166,10 @@ export default {
name: "",
url: "https://",
interval: 60,
maxretries: 0,
notificationIDList: {},
ignoreTls: false,
upsideDown: false,
}
} else if (this.isEdit) {
this.$root.getSocket().emit("getMonitor", this.$route.params.id, (res) => {
@ -154,12 +204,7 @@ export default {
this.$root.toastRes(res)
})
}
}
},
watch: {
'$route.fullPath' () {
this.init();
}
},
},
}
</script>

167
src/pages/Settings.vue

@ -1,94 +1,123 @@
<template>
<h1 class="mb-3">Settings</h1>
<h1 class="mb-3">
Settings
</h1>
<div class="shadow-box">
<div class="row">
<div class="col-md-6">
<h2>General</h2>
<form class="mb-3" @submit.prevent="saveGeneral">
<div class="mb-3">
<label for="timezone" class="form-label">Timezone</label>
<select class="form-select" id="timezone" v-model="$root.userTimezone">
<option value="auto">Auto: {{ guessTimezone }}</option>
<option v-for="timezone in timezoneList" :value="timezone.value">{{ timezone.name }}</option>
<select id="timezone" v-model="$root.userTimezone" class="form-select">
<option value="auto">
Auto: {{ guessTimezone }}
</option>
<option v-for="(timezone, index) in timezoneList" :key="index" :value="timezone.value">
{{ timezone.name }}
</option>
</select>
</div>
<div>
<button class="btn btn-primary" type="submit">Save</button>
<button class="btn btn-primary" type="submit">
Save
</button>
</div>
</form>
<h2>Change Password</h2>
<form class="mb-3" @submit.prevent="savePassword">
<div class="mb-3">
<label for="current-password" class="form-label">Current Password</label>
<input type="password" class="form-control" id="current-password" required v-model="password.currentPassword">
</div>
<div class="mb-3">
<label for="new-password" class="form-label">New Password</label>
<input type="password" class="form-control" id="new-password" required v-model="password.newPassword">
</div>
<template v-if="loaded">
<template v-if="! settings.disableAuth">
<h2>Change Password</h2>
<form class="mb-3" @submit.prevent="savePassword">
<div class="mb-3">
<label for="current-password" class="form-label">Current Password</label>
<input id="current-password" v-model="password.currentPassword" type="password" class="form-control" required>
</div>
<div class="mb-3">
<label for="new-password" class="form-label">New Password</label>
<input id="new-password" v-model="password.newPassword" type="password" class="form-control" required>
</div>
<div class="mb-3">
<label for="repeat-new-password" class="form-label">Repeat New Password</label>
<input id="repeat-new-password" v-model="password.repeatNewPassword" type="password" class="form-control" :class="{ 'is-invalid' : invalidPassword }" required>
<div class="invalid-feedback">
The repeat password does not match.
</div>
</div>
<div>
<button class="btn btn-primary" type="submit">
Update Password
</button>
</div>
</form>
</template>
<h2>Advanced</h2>
<div class="mb-3">
<label for="repeat-new-password" class="form-label">Repeat New Password</label>
<input type="password" class="form-control" :class="{ 'is-invalid' : invalidPassword }" id="repeat-new-password" required v-model="password.repeatNewPassword">
<div class="invalid-feedback">
The repeat password is not match.
</div>
<button v-if="settings.disableAuth" class="btn btn-outline-primary me-1" @click="enableAuth">Enable Auth</button>
<button v-if="! settings.disableAuth" class="btn btn-primary me-1" @click="confirmDisableAuth">Disable Auth</button>
<button v-if="! settings.disableAuth" class="btn btn-danger me-1" @click="$root.logout">Logout</button>
</div>
<div>
<button class="btn btn-primary" type="submit">Update Password</button>
</div>
</form>
<div>
<button class="btn btn-danger" @click="$root.logout">Logout</button>
</div>
</template>
</div>
<div class="col-md-6">
<div class="mt-3" v-if="$root.isMobile"></div>
<div v-if="$root.isMobile" class="mt-3" />
<h2>Notifications</h2>
<p v-if="$root.notificationList.length === 0">Not available, please setup.</p>
<p v-else>Please assign the notification to monitor(s) to get it works.</p>
<p v-if="$root.notificationList.length === 0">
Not available, please setup.
</p>
<p v-else>
Please assign a notification to monitor(s) to get it to work.
</p>
<ul class="list-group mb-3" style="border-radius: 1rem;">
<li class="list-group-item" v-for="notification in $root.notificationList">
{{ notification.name }}<br />
<li v-for="(notification, index) in $root.notificationList" :key="index" class="list-group-item">
{{ notification.name }}<br>
<a href="#" @click="$refs.notificationDialog.show(notification.id)">Edit</a>
</li>
</ul>
<button class="btn btn-primary me-2" @click="$refs.notificationDialog.show()" type="button">Setup Notification</button>
<button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()">
Setup Notification
</button>
</div>
</div>
</div>
<NotificationDialog ref="notificationDialog" />
<Confirm ref="confirmDisableAuth" btn-style="btn-danger" yes-text="I understand, please disable" no-text="Leave" @yes="disableAuth">
<p>Are you sure want to <strong>disable auth</strong>?</p>
<p>It is for <strong>someone who have 3rd-party auth</strong> in front of Uptime Kuma such as Cloudflare Access.</p>
<p>Please use it carefully.</p>
</Confirm>
</template>
<script>
import Confirm from "../components/Confirm.vue";
import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import utc from "dayjs/plugin/utc"
import timezone from "dayjs/plugin/timezone"
import NotificationDialog from "../components/NotificationDialog.vue";
dayjs.extend(utc)
dayjs.extend(timezone)
import {timezoneList} from "../util-frontend";
import { useToast } from 'vue-toastification'
import { timezoneList } from "../util-frontend";
import { useToast } from "vue-toastification"
const toast = useToast()
export default {
components: {
NotificationDialog
NotificationDialog,
Confirm,
},
data() {
return {
@ -100,12 +129,21 @@ export default {
currentPassword: "",
newPassword: "",
repeatNewPassword: "",
}
},
settings: {
},
loaded: false,
}
},
watch: {
"password.repeatNewPassword"() {
this.invalidPassword = false;
},
},
mounted() {
this.loadSettings();
},
methods: {
@ -129,12 +167,37 @@ export default {
})
}
},
loadSettings() {
this.$root.getSocket().emit("getSettings", (res) => {
this.settings = res.data;
this.loaded = true;
})
},
saveSettings() {
this.$root.getSocket().emit("setSettings", this.settings, (res) => {
this.$root.toastRes(res);
this.loadSettings();
})
},
confirmDisableAuth() {
this.$refs.confirmDisableAuth.show();
},
disableAuth() {
this.settings.disableAuth = true;
this.saveSettings();
},
enableAuth() {
this.settings.disableAuth = false;
this.saveSettings();
this.$root.storage().removeItem("token");
},
},
watch: {
"password.repeatNewPassword"() {
this.invalidPassword = false;
}
}
}
</script>

28
src/pages/Setup.vue

@ -2,38 +2,42 @@
<div class="form-container">
<div class="form">
<form @submit.prevent="submit">
<div>
<object width="64" height="64" data="/icon.svg"></object>
<div style="font-size: 28px; font-weight: bold; margin-top: 5px;">Uptime Kuma</div>
<object width="64" height="64" data="/icon.svg" />
<div style="font-size: 28px; font-weight: bold; margin-top: 5px;">
Uptime Kuma
</div>
</div>
<p class="mt-3">Create your admin account</p>
<p class="mt-3">
Create your admin account
</p>
<div class="form-floating">
<input type="text" class="form-control" id="floatingInput" placeholder="Username" v-model="username" required>
<input id="floatingInput" v-model="username" type="text" class="form-control" placeholder="Username" required>
<label for="floatingInput">Username</label>
</div>
<div class="form-floating mt-3">
<input type="password" class="form-control" id="floatingPassword" placeholder="Password" v-model="password" required>
<input id="floatingPassword" v-model="password" type="password" class="form-control" placeholder="Password" required>
<label for="floatingPassword">Password</label>
</div>
<div class="form-floating mt-3">
<input type="password" class="form-control" id="repeat" placeholder="Repeat Password" v-model="repeatPassword" required>
<input id="repeat" v-model="repeatPassword" type="password" class="form-control" placeholder="Repeat Password" required>
<label for="repeat">Repeat Password</label>
</div>
<button class="w-100 btn btn-primary mt-3" type="submit" :disabled="processing">Create</button>
<button class="w-100 btn btn-primary mt-3" type="submit" :disabled="processing">
Create
</button>
</form>
</div>
</div>
</template>
<script>
import { useToast } from 'vue-toastification'
import { useToast } from "vue-toastification"
const toast = useToast()
export default {
@ -70,8 +74,8 @@ export default {
this.$router.push("/")
}
})
}
}
},
},
}
</script>

718
src/util-frontend.js

@ -1,371 +1,372 @@
import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc)
dayjs.extend(timezone)
function getTimezoneOffset(timeZone) {
const now = new Date();
const tzString = now.toLocaleString('en-US', { timeZone });
const localString = now.toLocaleString('en-US');
const tzString = now.toLocaleString("en-US", {
timeZone,
});
const localString = now.toLocaleString("en-US");
const diff = (Date.parse(localString) - Date.parse(tzString)) / 3600000;
const offset = diff + now.getTimezoneOffset() / 60;
return -offset;
}
// From: https://stackoverflow.com/questions/38399465/how-to-get-list-of-all-timezones-in-javascript
// TODO: Move to separate file
const aryIannaTimeZones = [
'Europe/Andorra',
'Asia/Dubai',
'Asia/Kabul',
'Europe/Tirane',
'Asia/Yerevan',
'Antarctica/Casey',
'Antarctica/Davis',
'Antarctica/Mawson',
'Antarctica/Palmer',
'Antarctica/Rothera',
'Antarctica/Syowa',
'Antarctica/Troll',
'Antarctica/Vostok',
'America/Argentina/Buenos_Aires',
'America/Argentina/Cordoba',
'America/Argentina/Salta',
'America/Argentina/Jujuy',
'America/Argentina/Tucuman',
'America/Argentina/Catamarca',
'America/Argentina/La_Rioja',
'America/Argentina/San_Juan',
'America/Argentina/Mendoza',
'America/Argentina/San_Luis',
'America/Argentina/Rio_Gallegos',
'America/Argentina/Ushuaia',
'Pacific/Pago_Pago',
'Europe/Vienna',
'Australia/Lord_Howe',
'Antarctica/Macquarie',
'Australia/Hobart',
'Australia/Currie',
'Australia/Melbourne',
'Australia/Sydney',
'Australia/Broken_Hill',
'Australia/Brisbane',
'Australia/Lindeman',
'Australia/Adelaide',
'Australia/Darwin',
'Australia/Perth',
'Australia/Eucla',
'Asia/Baku',
'America/Barbados',
'Asia/Dhaka',
'Europe/Brussels',
'Europe/Sofia',
'Atlantic/Bermuda',
'Asia/Brunei',
'America/La_Paz',
'America/Noronha',
'America/Belem',
'America/Fortaleza',
'America/Recife',
'America/Araguaina',
'America/Maceio',
'America/Bahia',
'America/Sao_Paulo',
'America/Campo_Grande',
'America/Cuiaba',
'America/Santarem',
'America/Porto_Velho',
'America/Boa_Vista',
'America/Manaus',
'America/Eirunepe',
'America/Rio_Branco',
'America/Nassau',
'Asia/Thimphu',
'Europe/Minsk',
'America/Belize',
'America/St_Johns',
'America/Halifax',
'America/Glace_Bay',
'America/Moncton',
'America/Goose_Bay',
'America/Blanc-Sablon',
'America/Toronto',
'America/Nipigon',
'America/Thunder_Bay',
'America/Iqaluit',
'America/Pangnirtung',
'America/Atikokan',
'America/Winnipeg',
'America/Rainy_River',
'America/Resolute',
'America/Rankin_Inlet',
'America/Regina',
'America/Swift_Current',
'America/Edmonton',
'America/Cambridge_Bay',
'America/Yellowknife',
'America/Inuvik',
'America/Creston',
'America/Dawson_Creek',
'America/Fort_Nelson',
'America/Vancouver',
'America/Whitehorse',
'America/Dawson',
'Indian/Cocos',
'Europe/Zurich',
'Africa/Abidjan',
'Pacific/Rarotonga',
'America/Santiago',
'America/Punta_Arenas',
'Pacific/Easter',
'Asia/Shanghai',
'Asia/Urumqi',
'America/Bogota',
'America/Costa_Rica',
'America/Havana',
'Atlantic/Cape_Verde',
'America/Curacao',
'Indian/Christmas',
'Asia/Nicosia',
'Asia/Famagusta',
'Europe/Prague',
'Europe/Berlin',
'Europe/Copenhagen',
'America/Santo_Domingo',
'Africa/Algiers',
'America/Guayaquil',
'Pacific/Galapagos',
'Europe/Tallinn',
'Africa/Cairo',
'Africa/El_Aaiun',
'Europe/Madrid',
'Africa/Ceuta',
'Atlantic/Canary',
'Europe/Helsinki',
'Pacific/Fiji',
'Atlantic/Stanley',
'Pacific/Chuuk',
'Pacific/Pohnpei',
'Pacific/Kosrae',
'Atlantic/Faroe',
'Europe/Paris',
'Europe/London',
'Asia/Tbilisi',
'America/Cayenne',
'Africa/Accra',
'Europe/Gibraltar',
'America/Godthab',
'America/Danmarkshavn',
'America/Scoresbysund',
'America/Thule',
'Europe/Athens',
'Atlantic/South_Georgia',
'America/Guatemala',
'Pacific/Guam',
'Africa/Bissau',
'America/Guyana',
'Asia/Hong_Kong',
'America/Tegucigalpa',
'America/Port-au-Prince',
'Europe/Budapest',
'Asia/Jakarta',
'Asia/Pontianak',
'Asia/Makassar',
'Asia/Jayapura',
'Europe/Dublin',
'Asia/Jerusalem',
'Asia/Kolkata',
'Indian/Chagos',
'Asia/Baghdad',
'Asia/Tehran',
'Atlantic/Reykjavik',
'Europe/Rome',
'America/Jamaica',
'Asia/Amman',
'Asia/Tokyo',
'Africa/Nairobi',
'Asia/Bishkek',
'Pacific/Tarawa',
'Pacific/Enderbury',
'Pacific/Kiritimati',
'Asia/Pyongyang',
'Asia/Seoul',
'Asia/Almaty',
'Asia/Qyzylorda',
'Asia/Aqtobe',
'Asia/Aqtau',
'Asia/Atyrau',
'Asia/Oral',
'Asia/Beirut',
'Asia/Colombo',
'Africa/Monrovia',
'Europe/Vilnius',
'Europe/Luxembourg',
'Europe/Riga',
'Africa/Tripoli',
'Africa/Casablanca',
'Europe/Monaco',
'Europe/Chisinau',
'Pacific/Majuro',
'Pacific/Kwajalein',
'Asia/Yangon',
'Asia/Ulaanbaatar',
'Asia/Hovd',
'Asia/Choibalsan',
'Asia/Macau',
'America/Martinique',
'Europe/Malta',
'Indian/Mauritius',
'Indian/Maldives',
'America/Mexico_City',
'America/Cancun',
'America/Merida',
'America/Monterrey',
'America/Matamoros',
'America/Mazatlan',
'America/Chihuahua',
'America/Ojinaga',
'America/Hermosillo',
'America/Tijuana',
'America/Bahia_Banderas',
'Asia/Kuala_Lumpur',
'Asia/Kuching',
'Africa/Maputo',
'Africa/Windhoek',
'Pacific/Noumea',
'Pacific/Norfolk',
'Africa/Lagos',
'America/Managua',
'Europe/Amsterdam',
'Europe/Oslo',
'Asia/Kathmandu',
'Pacific/Nauru',
'Pacific/Niue',
'Pacific/Auckland',
'Pacific/Chatham',
'America/Panama',
'America/Lima',
'Pacific/Tahiti',
'Pacific/Marquesas',
'Pacific/Gambier',
'Pacific/Port_Moresby',
'Pacific/Bougainville',
'Asia/Manila',
'Asia/Karachi',
'Europe/Warsaw',
'America/Miquelon',
'Pacific/Pitcairn',
'America/Puerto_Rico',
'Asia/Gaza',
'Asia/Hebron',
'Europe/Lisbon',
'Atlantic/Madeira',
'Atlantic/Azores',
'Pacific/Palau',
'America/Asuncion',
'Asia/Qatar',
'Indian/Reunion',
'Europe/Bucharest',
'Europe/Belgrade',
'Europe/Kaliningrad',
'Europe/Moscow',
'Europe/Simferopol',
'Europe/Kirov',
'Europe/Astrakhan',
'Europe/Volgograd',
'Europe/Saratov',
'Europe/Ulyanovsk',
'Europe/Samara',
'Asia/Yekaterinburg',
'Asia/Omsk',
'Asia/Novosibirsk',
'Asia/Barnaul',
'Asia/Tomsk',
'Asia/Novokuznetsk',
'Asia/Krasnoyarsk',
'Asia/Irkutsk',
'Asia/Chita',
'Asia/Yakutsk',
'Asia/Khandyga',
'Asia/Vladivostok',
'Asia/Ust-Nera',
'Asia/Magadan',
'Asia/Sakhalin',
'Asia/Srednekolymsk',
'Asia/Kamchatka',
'Asia/Anadyr',
'Asia/Riyadh',
'Pacific/Guadalcanal',
'Indian/Mahe',
'Africa/Khartoum',
'Europe/Stockholm',
'Asia/Singapore',
'America/Paramaribo',
'Africa/Juba',
'Africa/Sao_Tome',
'America/El_Salvador',
'Asia/Damascus',
'America/Grand_Turk',
'Africa/Ndjamena',
'Indian/Kerguelen',
'Asia/Bangkok',
'Asia/Dushanbe',
'Pacific/Fakaofo',
'Asia/Dili',
'Asia/Ashgabat',
'Africa/Tunis',
'Pacific/Tongatapu',
'Europe/Istanbul',
'America/Port_of_Spain',
'Pacific/Funafuti',
'Asia/Taipei',
'Europe/Kiev',
'Europe/Uzhgorod',
'Europe/Zaporozhye',
'Pacific/Wake',
'America/New_York',
'America/Detroit',
'America/Kentucky/Louisville',
'America/Kentucky/Monticello',
'America/Indiana/Indianapolis',
'America/Indiana/Vincennes',
'America/Indiana/Winamac',
'America/Indiana/Marengo',
'America/Indiana/Petersburg',
'America/Indiana/Vevay',
'America/Chicago',
'America/Indiana/Tell_City',
'America/Indiana/Knox',
'America/Menominee',
'America/North_Dakota/Center',
'America/North_Dakota/New_Salem',
'America/North_Dakota/Beulah',
'America/Denver',
'America/Boise',
'America/Phoenix',
'America/Los_Angeles',
'America/Anchorage',
'America/Juneau',
'America/Sitka',
'America/Metlakatla',
'America/Yakutat',
'America/Nome',
'America/Adak',
'Pacific/Honolulu',
'America/Montevideo',
'Asia/Samarkand',
'Asia/Tashkent',
'America/Caracas',
'Asia/Ho_Chi_Minh',
'Pacific/Efate',
'Pacific/Wallis',
'Pacific/Apia',
'Africa/Johannesburg',
"Europe/Andorra",
"Asia/Dubai",
"Asia/Kabul",
"Europe/Tirane",
"Asia/Yerevan",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/Mawson",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"America/Argentina/Buenos_Aires",
"America/Argentina/Cordoba",
"America/Argentina/Salta",
"America/Argentina/Jujuy",
"America/Argentina/Tucuman",
"America/Argentina/Catamarca",
"America/Argentina/La_Rioja",
"America/Argentina/San_Juan",
"America/Argentina/Mendoza",
"America/Argentina/San_Luis",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Ushuaia",
"Pacific/Pago_Pago",
"Europe/Vienna",
"Australia/Lord_Howe",
"Antarctica/Macquarie",
"Australia/Hobart",
"Australia/Currie",
"Australia/Melbourne",
"Australia/Sydney",
"Australia/Broken_Hill",
"Australia/Brisbane",
"Australia/Lindeman",
"Australia/Adelaide",
"Australia/Darwin",
"Australia/Perth",
"Australia/Eucla",
"Asia/Baku",
"America/Barbados",
"Asia/Dhaka",
"Europe/Brussels",
"Europe/Sofia",
"Atlantic/Bermuda",
"Asia/Brunei",
"America/La_Paz",
"America/Noronha",
"America/Belem",
"America/Fortaleza",
"America/Recife",
"America/Araguaina",
"America/Maceio",
"America/Bahia",
"America/Sao_Paulo",
"America/Campo_Grande",
"America/Cuiaba",
"America/Santarem",
"America/Porto_Velho",
"America/Boa_Vista",
"America/Manaus",
"America/Eirunepe",
"America/Rio_Branco",
"America/Nassau",
"Asia/Thimphu",
"Europe/Minsk",
"America/Belize",
"America/St_Johns",
"America/Halifax",
"America/Glace_Bay",
"America/Moncton",
"America/Goose_Bay",
"America/Blanc-Sablon",
"America/Toronto",
"America/Nipigon",
"America/Thunder_Bay",
"America/Iqaluit",
"America/Pangnirtung",
"America/Atikokan",
"America/Winnipeg",
"America/Rainy_River",
"America/Resolute",
"America/Rankin_Inlet",
"America/Regina",
"America/Swift_Current",
"America/Edmonton",
"America/Cambridge_Bay",
"America/Yellowknife",
"America/Inuvik",
"America/Creston",
"America/Dawson_Creek",
"America/Fort_Nelson",
"America/Vancouver",
"America/Whitehorse",
"America/Dawson",
"Indian/Cocos",
"Europe/Zurich",
"Africa/Abidjan",
"Pacific/Rarotonga",
"America/Santiago",
"America/Punta_Arenas",
"Pacific/Easter",
"Asia/Shanghai",
"Asia/Urumqi",
"America/Bogota",
"America/Costa_Rica",
"America/Havana",
"Atlantic/Cape_Verde",
"America/Curacao",
"Indian/Christmas",
"Asia/Nicosia",
"Asia/Famagusta",
"Europe/Prague",
"Europe/Berlin",
"Europe/Copenhagen",
"America/Santo_Domingo",
"Africa/Algiers",
"America/Guayaquil",
"Pacific/Galapagos",
"Europe/Tallinn",
"Africa/Cairo",
"Africa/El_Aaiun",
"Europe/Madrid",
"Africa/Ceuta",
"Atlantic/Canary",
"Europe/Helsinki",
"Pacific/Fiji",
"Atlantic/Stanley",
"Pacific/Chuuk",
"Pacific/Pohnpei",
"Pacific/Kosrae",
"Atlantic/Faroe",
"Europe/Paris",
"Europe/London",
"Asia/Tbilisi",
"America/Cayenne",
"Africa/Accra",
"Europe/Gibraltar",
"America/Godthab",
"America/Danmarkshavn",
"America/Scoresbysund",
"America/Thule",
"Europe/Athens",
"Atlantic/South_Georgia",
"America/Guatemala",
"Pacific/Guam",
"Africa/Bissau",
"America/Guyana",
"Asia/Hong_Kong",
"America/Tegucigalpa",
"America/Port-au-Prince",
"Europe/Budapest",
"Asia/Jakarta",
"Asia/Pontianak",
"Asia/Makassar",
"Asia/Jayapura",
"Europe/Dublin",
"Asia/Jerusalem",
"Asia/Kolkata",
"Indian/Chagos",
"Asia/Baghdad",
"Asia/Tehran",
"Atlantic/Reykjavik",
"Europe/Rome",
"America/Jamaica",
"Asia/Amman",
"Asia/Tokyo",
"Africa/Nairobi",
"Asia/Bishkek",
"Pacific/Tarawa",
"Pacific/Enderbury",
"Pacific/Kiritimati",
"Asia/Pyongyang",
"Asia/Seoul",
"Asia/Almaty",
"Asia/Qyzylorda",
"Asia/Aqtobe",
"Asia/Aqtau",
"Asia/Atyrau",
"Asia/Oral",
"Asia/Beirut",
"Asia/Colombo",
"Africa/Monrovia",
"Europe/Vilnius",
"Europe/Luxembourg",
"Europe/Riga",
"Africa/Tripoli",
"Africa/Casablanca",
"Europe/Monaco",
"Europe/Chisinau",
"Pacific/Majuro",
"Pacific/Kwajalein",
"Asia/Yangon",
"Asia/Ulaanbaatar",
"Asia/Hovd",
"Asia/Choibalsan",
"Asia/Macau",
"America/Martinique",
"Europe/Malta",
"Indian/Mauritius",
"Indian/Maldives",
"America/Mexico_City",
"America/Cancun",
"America/Merida",
"America/Monterrey",
"America/Matamoros",
"America/Mazatlan",
"America/Chihuahua",
"America/Ojinaga",
"America/Hermosillo",
"America/Tijuana",
"America/Bahia_Banderas",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
"Africa/Maputo",
"Africa/Windhoek",
"Pacific/Noumea",
"Pacific/Norfolk",
"Africa/Lagos",
"America/Managua",
"Europe/Amsterdam",
"Europe/Oslo",
"Asia/Kathmandu",
"Pacific/Nauru",
"Pacific/Niue",
"Pacific/Auckland",
"Pacific/Chatham",
"America/Panama",
"America/Lima",
"Pacific/Tahiti",
"Pacific/Marquesas",
"Pacific/Gambier",
"Pacific/Port_Moresby",
"Pacific/Bougainville",
"Asia/Manila",
"Asia/Karachi",
"Europe/Warsaw",
"America/Miquelon",
"Pacific/Pitcairn",
"America/Puerto_Rico",
"Asia/Gaza",
"Asia/Hebron",
"Europe/Lisbon",
"Atlantic/Madeira",
"Atlantic/Azores",
"Pacific/Palau",
"America/Asuncion",
"Asia/Qatar",
"Indian/Reunion",
"Europe/Bucharest",
"Europe/Belgrade",
"Europe/Kaliningrad",
"Europe/Moscow",
"Europe/Simferopol",
"Europe/Kirov",
"Europe/Astrakhan",
"Europe/Volgograd",
"Europe/Saratov",
"Europe/Ulyanovsk",
"Europe/Samara",
"Asia/Yekaterinburg",
"Asia/Omsk",
"Asia/Novosibirsk",
"Asia/Barnaul",
"Asia/Tomsk",
"Asia/Novokuznetsk",
"Asia/Krasnoyarsk",
"Asia/Irkutsk",
"Asia/Chita",
"Asia/Yakutsk",
"Asia/Khandyga",
"Asia/Vladivostok",
"Asia/Ust-Nera",
"Asia/Magadan",
"Asia/Sakhalin",
"Asia/Srednekolymsk",
"Asia/Kamchatka",
"Asia/Anadyr",
"Asia/Riyadh",
"Pacific/Guadalcanal",
"Indian/Mahe",
"Africa/Khartoum",
"Europe/Stockholm",
"Asia/Singapore",
"America/Paramaribo",
"Africa/Juba",
"Africa/Sao_Tome",
"America/El_Salvador",
"Asia/Damascus",
"America/Grand_Turk",
"Africa/Ndjamena",
"Indian/Kerguelen",
"Asia/Bangkok",
"Asia/Dushanbe",
"Pacific/Fakaofo",
"Asia/Dili",
"Asia/Ashgabat",
"Africa/Tunis",
"Pacific/Tongatapu",
"Europe/Istanbul",
"America/Port_of_Spain",
"Pacific/Funafuti",
"Asia/Taipei",
"Europe/Kiev",
"Europe/Uzhgorod",
"Europe/Zaporozhye",
"Pacific/Wake",
"America/New_York",
"America/Detroit",
"America/Kentucky/Louisville",
"America/Kentucky/Monticello",
"America/Indiana/Indianapolis",
"America/Indiana/Vincennes",
"America/Indiana/Winamac",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
"America/Indiana/Vevay",
"America/Chicago",
"America/Indiana/Tell_City",
"America/Indiana/Knox",
"America/Menominee",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/North_Dakota/Beulah",
"America/Denver",
"America/Boise",
"America/Phoenix",
"America/Los_Angeles",
"America/Anchorage",
"America/Juneau",
"America/Sitka",
"America/Metlakatla",
"America/Yakutat",
"America/Nome",
"America/Adak",
"Pacific/Honolulu",
"America/Montevideo",
"Asia/Samarkand",
"Asia/Tashkent",
"America/Caracas",
"Asia/Ho_Chi_Minh",
"Pacific/Efate",
"Pacific/Wallis",
"Pacific/Apia",
"Africa/Johannesburg",
];
export function timezoneList() {
let result = [];
@ -381,7 +382,7 @@ export function timezoneList() {
time: getTimezoneOffset(timezone),
})
} catch (e) {
console.log(e.message);
console.error(e.message);
console.log("Skip this timezone")
}
@ -390,13 +391,14 @@ export function timezoneList() {
result.sort((a, b) => {
if (a.time > b.time) {
return 1;
} else if (b.time > a.time) {
}
if (b.time > a.time) {
return -1;
} else {
return 0;
}
return 0;
})
return result;
};
}

34
src/util.js

@ -0,0 +1,34 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.PENDING = exports.UP = exports.DOWN = void 0;
exports.DOWN = 0;
exports.UP = 1;
exports.PENDING = 2;
function flipStatus(s) {
if (s === exports.UP) {
return exports.DOWN;
}
if (s === exports.DOWN) {
return exports.UP;
}
return s;
}
exports.flipStatus = flipStatus;
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
exports.sleep = sleep;
function ucfirst(str) {
if (!str) {
return str;
}
const firstLetter = str.substr(0, 1);
return firstLetter.toUpperCase() + str.substr(1);
}
exports.ucfirst = ucfirst;
function debug(msg) {
if (process.env.NODE_ENV === "development") {
console.log(msg);
}
}
exports.debug = debug;

43
src/util.ts

@ -0,0 +1,43 @@
// Common Util for frontend and backend
// Backend uses the compiled file util.js
// Frontend uses util.ts
// Need to run "tsc" to compile if there are any changes.
export const DOWN = 0;
export const UP = 1;
export const PENDING = 2;
export function flipStatus(s) {
if (s === UP) {
return DOWN;
}
if (s === DOWN) {
return UP;
}
return s;
}
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* PHP's ucfirst
* @param str
*/
export function ucfirst(str) {
if (! str) {
return str;
}
const firstLetter = str.substr(0, 1);
return firstLetter.toUpperCase() + str.substr(1);
}
export function debug(msg) {
if (process.env.NODE_ENV === "development") {
console.log(msg)
}
}

14
tsconfig.json

@ -0,0 +1,14 @@
{
"compileOnSave": true,
"compilerOptions": {
"target": "ES2018",
"module": "commonjs",
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": false,
"files.insertFinalNewline": true
},
"files": [
"./server/util.ts"
]
}
Loading…
Cancel
Save