Merge branch 'master' into reduce_docker_image_size

This commit is contained in:
Adam Stachowicz 2021-08-01 16:46:38 +02:00
commit 713bbe0014
27 changed files with 825 additions and 228 deletions

View File

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

View File

@ -2,48 +2,43 @@ module.exports = {
env: {
browser: true,
commonjs: true,
es2017: true,
es2020: true,
node: true,
},
extends: [
"eslint:recommended",
"plugin:vue/vue3-recommended",
],
parser: "vue-eslint-parser",
parserOptions: {
ecmaVersion: 2018,
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],
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": ["error", {
"ObjectExpression": {
"minProperties": 1,
},
"ObjectPattern": {
"multiline": true,
"minProperties": 2,
},
"ImportDeclaration": {
"multiline": true,
},
"ExportDeclaration": {
"multiline": true,
//'minProperties': 2,
},
}],
"object-curly-newline": "off",
"object-property-newline": "error",
"comma-spacing": "error",
"brace-style": "error",
@ -53,6 +48,9 @@ module.exports = {
"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",
@ -70,6 +68,6 @@ module.exports = {
"array-bracket-newline": ["error", "consistent"],
"eol-last": ["error", "always"],
//'prefer-template': 'error',
"comma-dangle": ["warn", "always-multiline"],
"comma-dangle": ["warn", "only-multiline"],
},
}

128
CODE_OF_CONDUCT.md Normal file
View File

@ -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 Normal file
View File

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

View File

@ -15,12 +15,12 @@ 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, Gotify, Slack, Pushover, Email (SMTP) and more by Apprise.
* 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
@ -38,9 +38,9 @@ Change Port and Volume
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
@ -62,12 +62,25 @@ 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
## (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
<!---
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)
Choose Cheapest Plan is enough. (US$ 5)
# How to Update
### Docker
@ -88,7 +101,7 @@ pm2 restart uptime-kuma
# What's Next?
I will mark requests/issues to the next milestone.
I will mark requests/issues to the next milestone.
https://github.com/louislam/uptime-kuma/milestones
# More Screenshots
@ -104,10 +117,10 @@ 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.
@ -119,6 +132,6 @@ If you love this project, please consider giving me a ⭐.
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/wiki/%5BDev%5D-Setup-Development-Environment
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.

View File

@ -1,4 +1,6 @@
-- 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;
@ -20,11 +22,12 @@ create table monitor_dg_tmp
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) select id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword from monitor;
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;

View File

@ -11,21 +11,9 @@ RUN apk add --no-cache --virtual .build-deps make g++ python3 python3-dev && \
# Touching above code may causes sqlite3 re-compile again, painful slow.
# Install apprise
# Hate pip!!! I never run pip install successfully in first run for anything in my life without Google :/
# Compilation Fail 1 => Google Search "alpine ffi.h" => Add libffi-dev
# Compilation Fail 2 => Google Search "alpine cargo" => Add cargo
# Compilation Fail 3 => Google Search "alpine opensslv.h" => Add openssl-dev
# Compilation Fail 4 => Google Search "alpine opensslv.h" again => Change to libressl-dev musl-dev
# Compilation Fail 5 => Google Search "ERROR: libressl3.3-libtls-3.3.3-r0: trying to overwrite usr/lib/libtls.so.20 owned by libretls-3.3.3-r0." again => Change back to openssl-dev with musl-dev
# Runtime Error => ModuleNotFoundError: No module named 'six' => pip3 install six
# Runtime Error 2 => ModuleNotFoundError: No module named 'six' => apk add py3-six
ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1
RUN apk add --no-cache py3-six cargo
RUN apk add --no-cache --virtual .build-deps python3 py3-pip libffi-dev musl-dev openssl-dev python3-dev && \
pip3 install apprise && \
pip3 cache purge && \
rm -rf /root/.cache && \
apk del .build-deps
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
RUN apprise --version
# New things add here
@ -33,8 +21,9 @@ RUN apprise --version
FROM release-base AS build
COPY . .
RUN npm install
RUN npm run build
RUN npm install && \
npm run build && \
npm prune
FROM release-base AS release-final

50
package-lock.json generated
View File

@ -96,6 +96,25 @@
}
}
},
"@babel/eslint-parser": {
"version": "7.14.7",
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.14.7.tgz",
"integrity": "sha512-6WPwZqO5priAGIwV6msJcdc9TsEPzYeYdS/Xuoap+/ihkgN6dzHp2bcAAwyWZ5bLzk0vvjDmKvRwkqNaiJ8BiQ==",
"dev": true,
"requires": {
"eslint-scope": "^5.1.1",
"eslint-visitor-keys": "^2.1.0",
"semver": "^6.3.0"
},
"dependencies": {
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"dev": true
}
}
},
"@babel/generator": {
"version": "7.14.8",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.8.tgz",
@ -600,6 +619,16 @@
"@types/node": "*"
}
},
"@types/bootstrap": {
"version": "5.0.17",
"resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.0.17.tgz",
"integrity": "sha512-uQQQ3p+zw10VjZLvtCuKWI6QgVCYEnK/yHnno3gyEhikfQdiZexS2XPxjWRboGmX135o470GkmCta9eAgQMVLQ==",
"dev": true,
"requires": {
"@popperjs/core": "^2.9.2",
"@types/jquery": "*"
}
},
"@types/component-emitter": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.10.tgz",
@ -676,6 +705,15 @@
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-1.8.1.tgz",
"integrity": "sha512-e+2rjEwK6KDaNOm5Aa9wNGgyS9oSZU/4pfSMMPYNOfjvFI0WVXm29+ITRFr6aKDvvKo7uU1jV68MW4ScsfDi7Q=="
},
"@types/jquery": {
"version": "3.5.6",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.6.tgz",
"integrity": "sha512-SmgCQRzGPId4MZQKDj9Hqc6kSXFNWZFHpELkyK8AQhf8Zr6HKfCzFv9ZC1Fv3FyQttJZOlap3qYb12h61iZAIg==",
"dev": true,
"requires": {
"@types/sizzle": "*"
}
},
"@types/keygrip": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz",
@ -760,6 +798,12 @@
"@types/node": "*"
}
},
"@types/sizzle": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz",
"integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==",
"dev": true
},
"@types/unist": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
@ -6673,6 +6717,12 @@
"is-typedarray": "^1.0.0"
}
},
"typescript": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
"integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==",
"dev": true
},
"unc-path-regex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz",

View File

@ -7,7 +7,7 @@
"url": "https://github.com/louislam/uptime-kuma.git"
},
"engines": {
"node": ">=14"
"node": "14.*"
},
"scripts": {
"dev": "vite --host",
@ -56,6 +56,8 @@
"vue-toastification": "^2.0.0-rc.1"
},
"devDependencies": {
"@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",
@ -66,6 +68,7 @@
"stylelint": "^13.13.1",
"stylelint-config-recommended": "^5.0.0",
"stylelint-config-standard": "^22.0.0",
"typescript": "^4.3.5",
"vite": "^2.4.4"
}
}

View File

@ -1,8 +1,9 @@
const fs = require("fs");
const {sleep} = require("./util");
const {R} = require("redbean-node");
const {setSetting, setting} = require("./util-server");
const { sleep } = require("../src/util");
const { R } = require("redbean-node");
const {
setSetting, setting,
} = require("./util-server");
class Database {
@ -95,7 +96,7 @@ class Database {
const listener = (reason, p) => {
Database.noReject = false;
};
process.addListener('unhandledRejection', listener);
process.addListener("unhandledRejection", listener);
console.log("Closing DB")
@ -112,7 +113,7 @@ class Database {
}
console.log("SQLite closed")
process.removeListener('unhandledRejection', listener);
process.removeListener("unhandledRejection", listener);
}
}

View File

@ -1,22 +1,16 @@
const https = require('https');
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 {Prometheus} = require("../prometheus");
const {debug, UP, DOWN, PENDING} = require("../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")
// Use Custom agent to disable session reuse
// https://github.com/nodejs/node/issues/3940
const customAgent = new https.Agent({
maxCachedSessions: 0
});
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:
@ -30,7 +24,7 @@ class Monitor extends BeanModel {
let notificationIDList = {};
let list = await R.find("monitor_notification", " monitor_id = ? ", [
this.id
this.id,
])
for (let bean of list) {
@ -49,10 +43,28 @@ class Monitor extends BeanModel {
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;
@ -63,7 +75,7 @@ class Monitor extends BeanModel {
if (! previousBeat) {
previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [
this.id
this.id,
])
}
@ -74,9 +86,13 @@ class Monitor extends BeanModel {
bean.time = R.isoDateTime(dayjs.utc());
bean.status = DOWN;
if (this.isUpsideDown()) {
bean.status = flipStatus(bean.status);
}
// Duration
if (! isFirstBeat) {
bean.duration = dayjs(bean.time).diff(dayjs(previousBeat.time), 'second');
bean.duration = dayjs(bean.time).diff(dayjs(previousBeat.time), "second");
} else {
bean.duration = 0;
}
@ -84,9 +100,17 @@ 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" },
httpsAgent: customAgent,
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;
@ -124,7 +148,6 @@ class Monitor extends BeanModel {
}
} else if (this.type === "port") {
bean.ping = await tcping(this.hostname, this.port);
bean.msg = ""
@ -136,14 +159,29 @@ class Monitor extends BeanModel {
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) {
if ((this.maxretries > 0) && (retries < this.maxretries)) {
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;
}
bean.msg = error.message;
}
// * ? -> ANY STATUS = important [isFirstBeat]
@ -168,8 +206,8 @@ class Monitor extends BeanModel {
// 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 notificationList = await R.getAll("SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id ", [
this.id,
])
let text;
@ -181,7 +219,7 @@ class Monitor extends BeanModel {
let msg = `[${this.name}] [${text}] ${bean.msg}`;
for(let notification of notificationList) {
for (let notification of notificationList) {
try {
await Notification.send(JSON.parse(notification.config), msg, await this.toJSON(), bean.toJSON())
} catch (e) {
@ -194,7 +232,6 @@ class Monitor extends BeanModel {
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) {
@ -221,9 +258,12 @@ class Monitor extends BeanModel {
clearInterval(this.heartbeatInterval)
}
// Helper Method:
// returns URL object for further usage
// returns null if url is invalid
/**
* Helper Method:
* returns URL object for further usage
* returns null if url is invalid
* @returns {null|URL}
*/
getUrl() {
try {
return new URL(this.url);
@ -232,10 +272,14 @@ class Monitor extends BeanModel {
}
}
// Store TLS info to database
/**
* 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
this.id,
]);
if (tls_info_bean == null) {
tls_info_bean = R.dispense("monitor_tls_info");
@ -264,15 +308,15 @@ 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
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);
@ -294,7 +338,7 @@ class Monitor extends BeanModel {
WHERE time > DATETIME('now', ? || ' hours')
AND monitor_id = ? `, [
-duration,
monitorID
monitorID,
]);
let downtime = 0;
@ -318,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) {
@ -339,8 +383,6 @@ class Monitor extends BeanModel {
}
}
io.to(userID).emit("uptime", monitorID, duration, uptime);
}
}

View File

@ -235,6 +235,41 @@ class Notification {
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")
}

View File

@ -1,26 +1,46 @@
console.log("Welcome to Uptime Kuma ")
console.log("Importing libraries")
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const dayjs = require("dayjs");
const { R } = require("redbean-node");
const jwt = require("jsonwebtoken");
const Monitor = require("./model/monitor");
console.log("Welcome to Uptime Kuma")
const { sleep, debug } = require("../src/util");
console.log("Importing Node libraries")
const fs = require("fs");
const { getSettings } = require("./util-server");
const { Notification } = require("./notification")
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");
debug("Importing dayjs");
const dayjs = require("dayjs");
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");
const Database = require("./database");
const { sleep } = require("./util");
const args = require("args-parser")(process.argv);
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");
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 = args.host || "0.0.0.0"
const port = process.env.PORT || args.port || 3001
const hostname = process.env.HOST || args.host || "0.0.0.0"
const port = parseInt(process.env.PORT || args.port || 3001);
console.info("Version: " + version)
@ -94,11 +114,18 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.emit("setup")
}
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) => {
@ -191,8 +218,11 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
}
});
// ***************************
// Auth Only API
// ***************************
// Add a new monitor
socket.on("add", async (monitor, callback) => {
try {
checkLogin(socket)
@ -224,6 +254,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
}
});
// Edit a monitor
socket.on("editMonitor", async (monitor, callback) => {
try {
checkLogin(socket)
@ -242,6 +273,8 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
bean.maxretries = monitor.maxretries;
bean.port = monitor.port;
bean.keyword = monitor.keyword;
bean.ignoreTls = monitor.ignoreTls;
bean.upsideDown = monitor.upsideDown;
await R.store(bean)
@ -397,13 +430,32 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
}
});
socket.on("getSettings", async (type, callback) => {
socket.on("getSettings", async (callback) => {
try {
checkLogin(socket)
callback({
ok: true,
data: await getSettings(type),
data: await getSettings("general"),
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("setSettings", async (data, callback) => {
try {
checkLogin(socket)
await setSettings("general", data)
callback({
ok: true,
msg: "Saved"
});
} catch (e) {
@ -553,6 +605,8 @@ async function afterLogin(socket, user) {
}
sendNotificationList(socket)
socket.emit("autoLogin")
}
async function getMonitorJSONList(userID) {

View File

@ -40,9 +40,15 @@ exports.ping = function (hostname) {
}
exports.setting = async function (key) {
return await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [
let value = await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [
key,
])
]);
try {
return JSON.parse(value);
} catch (e) {
return value;
}
}
exports.setSetting = async function (key, value) {
@ -53,24 +59,53 @@ exports.setSetting = async function (key, value) {
bean = R.dispense("setting")
bean.key = key;
}
bean.value = value;
bean.value = JSON.stringify(value);
await R.store(bean)
}
exports.getSettings = async function (type) {
let list = await R.getAll("SELECT * FROM setting WHERE `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

View File

@ -1,25 +0,0 @@
// Common JS cannot be used in frontend sadly
// sleep, ucfirst is duplicated in ../src/util-frontend.js
exports.DOWN = 0;
exports.UP = 1;
exports.PENDING = 2;
exports.sleep = function (ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
exports.ucfirst = function (str) {
if (! str) {
return str;
}
const firstLetter = str.substr(0, 1);
return firstLetter.toUpperCase() + str.substr(1);
}
exports.debug = (msg) => {
if (process.env.NODE_ENV === "development") {
console.log(msg)
}
}

View File

@ -13,10 +13,10 @@
</div>
<div class="modal-footer">
<button type="button" class="btn" :class="btnStyle" data-bs-dismiss="modal" @click="yes">
Yes
{{ yesText }}
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
No
{{ noText }}
</button>
</div>
</div>
@ -33,6 +33,14 @@ export default {
type: String,
default: "btn-primary",
},
yesText: {
type: String,
default: "Yes",
},
noText: {
type: String,
default: "No",
},
},
data: () => ({
modal: null,

View File

@ -3,9 +3,9 @@
<span v-else>{{ value }}</span>
</template>
<script>
<script lang="ts">
import { sleep } from "../util-frontend"
import { sleep } from "../util.ts"
export default {

View File

@ -13,33 +13,16 @@
<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="apprise">
Apprise (Support 50+ Notification services)
</option>
<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>
@ -323,6 +306,17 @@
</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 v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
@ -345,9 +339,9 @@
</Confirm>
</template>
<script>
<script lang="ts">
import { Modal } from "bootstrap"
import { ucfirst } from "../util-frontend"
import { ucfirst } from "../util.ts"
import axios from "axios";
import { useToast } from "vue-toastification"
import Confirm from "./Confirm.vue";

View File

@ -1,7 +1,7 @@
<template>
<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>

View File

@ -29,6 +29,7 @@ export default {
notificationList: [],
windowWidth: window.innerWidth,
showListMobile: false,
connectionErrorMsg: "Cannot connect to the socket server. Reconnecting..."
}
},
@ -47,10 +48,6 @@ export default {
transports: ["websocket"],
});
socket.on("connect_error", (err) => {
console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`);
});
socket.on("info", (info) => {
this.info = info;
});
@ -59,6 +56,11 @@ export default {
this.$router.push("/setup")
});
socket.on("autoLogin", (monitorID, data) => {
this.loggedIn = true;
this.storage().token = "autoLogin"
});
socket.on("monitorList", (data) => {
// Add Helper function
Object.entries(data).forEach(([monitorID, monitor]) => {
@ -136,8 +138,16 @@ export default {
}
});
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;
});
@ -151,8 +161,12 @@ 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 {
this.allowLoginDialog = true;
}

View File

@ -15,7 +15,7 @@
<font-awesome-icon icon="pause" /> Pause
</button>
<button v-if="! monitor.active" class="btn btn-primary" @click="resumeMonitor">
<font-awesome-icon icon="pause" /> Resume
<font-awesome-icon icon="play" /> Resume
</button>
<router-link :to=" '/edit/' + monitor.id " class="btn btn-secondary">
<font-awesome-icon icon="edit" /> Edit

View File

@ -67,6 +67,25 @@
</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>
<button class="btn btn-primary" type="submit" :disabled="processing">
Save
@ -149,6 +168,8 @@ export default {
interval: 60,
maxretries: 0,
notificationIDList: {},
ignoreTls: false,
upsideDown: false,
}
} else if (this.isEdit) {
this.$root.getSocket().emit("getMonitor", this.$route.params.id, (res) => {

View File

@ -27,38 +27,44 @@
</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 id="current-password" v-model="password.currentPassword" type="password" class="form-control" required>
</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="new-password" class="form-label">New Password</label>
<input id="new-password" v-model="password.newPassword" type="password" class="form-control" required>
<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 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>
<div>
<button class="btn btn-danger" @click="$root.logout">
Logout
</button>
</div>
</template>
</div>
<div class="col-md-6">
@ -87,15 +93,23 @@
</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 NotificationDialog from "../components/NotificationDialog.vue";
dayjs.extend(utc)
dayjs.extend(timezone)
import { timezoneList } from "../util-frontend";
import { useToast } from "vue-toastification"
const toast = useToast()
@ -103,6 +117,7 @@ const toast = useToast()
export default {
components: {
NotificationDialog,
Confirm,
},
data() {
return {
@ -115,6 +130,10 @@ export default {
newPassword: "",
repeatNewPassword: "",
},
settings: {
},
loaded: false,
}
},
watch: {
@ -124,7 +143,7 @@ export default {
},
mounted() {
this.loadSettings();
},
methods: {
@ -148,6 +167,36 @@ 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().token = null;
},
},
}
</script>

View File

@ -5,19 +5,6 @@ import utc from "dayjs/plugin/utc";
dayjs.extend(utc)
dayjs.extend(timezone)
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);
}
function getTimezoneOffset(timeZone) {
const now = new Date();
const tzString = now.toLocaleString("en-US", {

34
src/util.js Normal file
View File

@ -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 Normal file
View File

@ -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 Normal file
View File

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