Browse Source

Merge remote-tracking branch 'upstream/master'

# Conflicts:
#	.do/deploy.template.yaml
#	README.md
#	dockerfile
#	package-lock.json
#	package.json
#	server/notification.js
#	server/server.js
#	src/assets/app.scss
#	src/assets/vars.scss
#	src/components/HeartbeatBar.vue
#	src/components/NotificationDialog.vue
#	src/layouts/Layout.vue
#	src/pages/Dashboard.vue
#	src/pages/EditMonitor.vue
#	src/pages/Settings.vue
remotes/philippdormann/main
Philipp Dormann 4 years ago
parent
commit
a9cb3325cc
No known key found for this signature in database GPG Key ID: 3BB9ADD52DCA4314
  1. 8
      .do/deploy.template.yaml
  2. 2
      .dockerignore
  3. 15
      .eslintrc.js
  4. 7
      .github/ISSUE_TEMPLATE/ask-for-help.md
  5. 20
      .github/ISSUE_TEMPLATE/bug_report.md
  6. 2
      .github/ISSUE_TEMPLATE/feature_request.md
  7. 9
      .stylelintrc
  8. 54
      CONTRIBUTING.md
  9. 14
      SECURITY.md
  10. 7
      app.json
  11. 70
      db/patch5.sql
  12. 74
      db/patch6.sql
  13. 2
      extra/compile-install-script.ps1
  14. 28
      extra/healthcheck.js
  15. 245
      extra/install.batsh
  16. 26
      extra/mark-as-nightly.js
  17. 59
      extra/reset-password.js
  18. 59
      extra/update-version.js
  19. 39
      extra/version-global-replace.js
  20. 18
      index.html
  21. 203
      install.sh
  22. 28
      kubernetes/README.md
  23. 10
      kubernetes/kustomization.yml
  24. 42
      kubernetes/uptime-kuma/deployment.yml
  25. 39
      kubernetes/uptime-kuma/ingressroute.yml
  26. 5
      kubernetes/uptime-kuma/kustomization.yml
  27. 10
      kubernetes/uptime-kuma/pvc.yml
  28. 13
      kubernetes/uptime-kuma/service.yml
  29. BIN
      public/apple-touch-icon-precomposed.png
  30. BIN
      public/apple-touch-icon.png
  31. BIN
      public/favicon.ico
  32. 3
      public/robots.txt
  33. 44
      server/check-version.js
  34. 83
      server/database.js
  35. 70
      server/model/monitor.js
  36. 21
      server/model/user.js
  37. 238
      server/notification.js
  38. 62
      server/ping-lite.js
  39. 55
      server/prometheus.js
  40. 162
      server/server.js
  41. 79
      server/util-server.js
  42. 4
      src/components/Confirm.vue
  43. 12
      src/components/Datetime.vue
  44. 39
      src/components/HeartbeatBar.vue
  45. 8
      src/components/Login.vue
  46. 133
      src/components/MonitorList.vue
  47. 142
      src/components/NotificationDialog.vue
  48. 172
      src/components/PingChart.vue
  49. 8
      src/components/Status.vue
  50. 4
      src/icon.js
  51. 12
      src/languages/en.js
  52. 97
      src/languages/zh-HK.js
  53. 207
      src/layouts/Layout.vue
  54. 38
      src/main.js
  55. 57
      src/mixins/datetime.js
  56. 25
      src/mixins/mobile.js
  57. 27
      src/mixins/socket.js
  58. 66
      src/mixins/theme.js
  59. 117
      src/pages/Dashboard.vue
  60. 130
      src/pages/DashboardHome.vue
  61. 352
      src/pages/Details.vue
  62. 16
      src/pages/List.vue
  63. 5
      src/pages/Settings.vue
  64. 40
      src/util.js
  65. 80
      src/util.ts
  66. 4
      test/test_install_script/alpine3.dockerfile
  67. 4
      test/test_install_script/centos7.dockerfile
  68. 4
      test/test_install_script/centos8.dockerfile
  69. 10
      test/test_install_script/debian.dockerfile
  70. 10
      test/test_install_script/ubuntu.dockerfile
  71. 10
      test/test_install_script/ubuntu1604.dockerfile
  72. 10
      tsconfig.json
  73. 20
      vite.config.js

8
.do/deploy.template.yaml

@ -1,8 +0,0 @@
spec:
name: uptime-kuma
services:
- dockerfile_path: Dockerfile
git:
branch: main
repo_clone_url: https://github.com/philippdormann/uptime-kuma.git
name: uptime-kuma

2
.dockerignore

@ -17,6 +17,8 @@ README.md
/.github
package-lock.json
app.json
CODE_OF_CONDUCT.md
CONTRIBUTING.md
### .gitignore content (commented rules are duplicated)

15
.eslintrc.js

@ -16,6 +16,9 @@ module.exports = {
requireConfigFile: false,
},
rules: {
"camelcase": ["warn", {
"properties": "never"
}],
// override/add rules settings here, such as:
// 'vue/no-unused-vars': 'error'
"no-unused-vars": "warn",
@ -36,6 +39,11 @@ module.exports = {
"no-multi-spaces": ["error", {
ignoreEOLComments: true,
}],
"space-before-function-paren": ["error", {
"anonymous": "always",
"named": "never",
"asyncArrow": "always"
}],
"curly": "error",
"object-curly-spacing": ["error", "always"],
"object-curly-newline": "off",
@ -62,12 +70,13 @@ module.exports = {
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"],
"no-empty": ["error", {
"allowEmptyCatch": true
}],
"no-control-regex": "off"
},
}

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

@ -6,5 +6,12 @@ labels: help
assignees: ''
---
**Is it a duplicate question?**
Please search in Issues without filters: https://github.com/louislam/uptime-kuma/issues?q=
**Info**
Uptime Kuma Version:
Using Docker?: Yes/No
OS:
Browser:

20
.github/ISSUE_TEMPLATE/bug_report.md

@ -7,6 +7,9 @@ assignees: ''
---
**Is it a duplicate question?**
Please search in Issues without filters: https://github.com/louislam/uptime-kuma/issues?q=
**Describe the bug**
A clear and concise description of what the bug is.
@ -20,15 +23,16 @@ Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**Info**
- Uptime Kuma Version:
- Using Docker?: Yes/No
- OS:
- Browser:
**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:
**Error Log**
It is easier for us to find out the problem.
**Additional context**
Add any other context about the problem here.

2
.github/ISSUE_TEMPLATE/feature_request.md

@ -6,6 +6,8 @@ labels: enhancement
assignees: ''
---
**Is it a duplicate question?**
Please search in Issues without filters: https://github.com/louislam/uptime-kuma/issues?q=
**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 [...]

9
.stylelintrc

@ -1,3 +1,10 @@
{
"extends": "stylelint-config-recommended",
"extends": "stylelint-config-standard",
"rules": {
"indentation": 4,
"no-descending-specificity": null,
"selector-list-comma-newline-after": null,
"declaration-empty-line-before": null,
"no-duplicate-selectors": null
}
}

54
CONTRIBUTING.md

@ -6,7 +6,38 @@ The project was created with vite.js (vue3). Then I created a sub-directory call
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.
# Can I create a pull request for Uptime Kuma?
Generally, if the pull request is working fine and it do not affect any existing logic, workflow and perfomance, I will merge to the master branch once it is tested.
If you are not sure, feel free to create an empty pull request draft first.
## Pull Request Examples
### ✅ High - Medium Priority
- Add a new notification
- Add a chart
- Fix a bug
### *️⃣ Requires one more reviewer
I do not have such knowledge to test it
- Add k8s supports
### *️⃣ Low Priority
It chnaged my current workflow and require further studies.
- Change my release approach
### ❌ Won't Merge
- Duplicated pull request
- Buggy
- Existing logic is completely modified or deleted
- A function that is completely out of scope
# Project Styles
@ -19,16 +50,27 @@ For example, recently, because I am not a python expert, I spent a 2 hours to re
- All settings in frontend.
- Easy to use
# Coding Styles
- Follow .editorconfig
- Follow eslint
## Name convention
- Javascript/Typescript: camelCaseType
- SQLite: underscore_type
- CSS/SCSS: dash-type
# Tools
- Node.js >= 14
- Git
- IDE that supports .editorconfig (I am using Intellji Idea)
- IDE that supports .editorconfig and eslint (I am using Intellji Idea)
- A SQLite tool (I am using SQLite Expert Personal)
# Prepare the dev
# Install dependencies
```bash
npm install
npm install --dev
```
# Backend Dev
@ -39,7 +81,6 @@ npm run start-server
# Or
node server/server.js
```
It binds to 0.0.0.0:3001 by default.
@ -92,7 +133,8 @@ The data and socket logic in "src/mixins/socket.js"
# Database Migration
TODO
1. create `patch{num}.sql` in `./db/`
1. update `latestVersion` in `./server/database.js`
# Unit Test

14
SECURITY.md

@ -0,0 +1,14 @@
# Security Policy
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ------- | ------------------ |
| 1.x.x | :white_check_mark: |
## Reporting a Vulnerability
https://github.com/louislam/uptime-kuma/issues

7
app.json

@ -1,7 +0,0 @@
{
"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"]
}

70
db/patch5.sql

@ -0,0 +1,70 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
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 default (DATETIME('now')) not null,
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,
keyword,
maxretries,
ignore_tls,
upside_down
)
select
id,
name,
active,
user_id,
interval,
url,
type,
weight,
hostname,
port,
keyword,
maxretries,
ignore_tls,
upside_down
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;

74
db/patch6.sql

@ -0,0 +1,74 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
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 default (DATETIME('now')) not null,
keyword VARCHAR(255),
maxretries INTEGER NOT NULL DEFAULT 0,
ignore_tls BOOLEAN default 0 not null,
upside_down BOOLEAN default 0 not null,
maxredirects INTEGER default 10 not null,
accepted_statuscodes_json TEXT default '["200-299"]' not null
);
insert into
monitor_dg_tmp(
id,
name,
active,
user_id,
interval,
url,
type,
weight,
hostname,
port,
created_date,
keyword,
maxretries,
ignore_tls,
upside_down
)
select
id,
name,
active,
user_id,
interval,
url,
type,
weight,
hostname,
port,
created_date,
keyword,
maxretries,
ignore_tls,
upside_down
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;

2
extra/compile-install-script.ps1

@ -0,0 +1,2 @@
# Must enable File Sharing in Docker Desktop
docker run -it --rm -v ${pwd}:/app louislam/batsh /usr/bin/batsh bash --output ./install.sh ./extra/install.batsh

28
extra/healthcheck.js

@ -1,19 +1,19 @@
var http = require("http");
var options = {
host: "localhost",
port: "3001",
timeout: 2000,
let http = require("http");
let 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);
}
let 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);
console.log("ERROR");
process.exit(1);
});
request.end();

245
extra/install.batsh

@ -0,0 +1,245 @@
// install.sh is generated by ./extra/install.batsh, do not modify it directly.
// "npm run compile-install-script" to compile install.sh
// The command is working on Windows PowerShell and Docker for Windows only.
// curl -o kuma_install.sh https://raw.githubusercontent.com/louislam/uptime-kuma/master/install.sh && sudo bash kuma_install.sh
println("=====================");
println("Uptime Kuma Installer");
println("=====================");
println("Supported OS: CentOS 7/8, Ubuntu >= 16.04 and Debian");
println("---------------------------------------");
println("This script is designed for Linux and basic usage.");
println("For advanced usage, please go to https://github.com/louislam/uptime-kuma/wiki/Installation");
println("---------------------------------------");
println("");
println("Local - Install Uptime Kuma in your current machine with git, Node.js 14 and pm2");
println("Docker - Install Uptime Kuma Docker container");
println("");
if ("$1" != "") {
type = "$1";
} else {
call("read", "-p", "Which installation method do you prefer? [DOCKER/local]: ", "type");
}
defaultPort = "3001";
function checkNode() {
bash("nodeVersion=$(node -e 'console.log(process.versions.node.split(`.`)[0])')");
println("Node Version: " ++ nodeVersion);
if (nodeVersion < "12") {
println("Error: Required Node.js 14");
call("exit", "1");
}
if (nodeVersion == "12") {
println("Warning: NodeJS " ++ nodeVersion ++ " is not tested.");
}
}
function deb() {
bash("nodeCheck=$(node -v)");
bash("apt --yes update");
if (nodeCheck != "") {
checkNode();
} else {
// Old nodejs binary name is "nodejs"
bash("check=$(nodejs --version)");
if (check != "") {
println("Error: 'node' command is not found, but 'nodejs' command is found. Your NodeJS should be too old.");
bash("exit 1");
}
bash("curlCheck=$(curl --version)");
if (curlCheck == "") {
println("Installing Curl");
bash("apt --yes install curl");
}
println("Installing Node.js 14");
bash("curl -sL https://deb.nodesource.com/setup_14.x | bash - > log.txt");
bash("apt --yes install nodejs");
bash("node -v");
bash("nodeCheckAgain=$(node -v)");
if (nodeCheckAgain == "") {
println("Error during Node.js installation");
bash("exit 1");
}
}
bash("check=$(git --version)");
if (check == "") {
println("Installing Git");
bash("apt --yes install git");
}
}
if (type == "local") {
defaultInstallPath = "/opt/uptime-kuma";
if (exists("/etc/redhat-release")) {
os = call("cat", "/etc/redhat-release");
distribution = "rhel";
} else if (exists("/etc/issue")) {
bash("os=$(head -n1 /etc/issue | cut -f 1 -d ' ')");
if (os == "Ubuntu") {
distribution = "ubuntu";
}
if (os == "Debian") {
distribution = "debian";
}
}
bash("arch=$(uname -i)");
println("Your OS: " ++ os);
println("Distribution: " ++ distribution);
println("Arch: " ++ arch);
if ("$3" != "") {
port = "$3";
} else {
call("read", "-p", "Listening Port [$defaultPort]: ", "port");
if (port == "") {
port = defaultPort;
}
}
if ("$2" != "") {
installPath = "$2";
} else {
call("read", "-p", "Installation Path [$defaultInstallPath]: ", "installPath");
if (installPath == "") {
installPath = defaultInstallPath;
}
}
// CentOS
if (distribution == "rhel") {
bash("nodeCheck=$(node -v)");
if (nodeCheck != "") {
checkNode();
} else {
bash("curlCheck=$(curl --version)");
if (curlCheck == "") {
println("Installing Curl");
bash("yum -y -q install curl");
}
println("Installing Node.js 14");
bash("curl -sL https://rpm.nodesource.com/setup_14.x | bash - > log.txt");
bash("yum install -y -q nodejs");
bash("node -v");
bash("nodeCheckAgain=$(node -v)");
if (nodeCheckAgain == "") {
println("Error during Node.js installation");
bash("exit 1");
}
}
bash("check=$(git --version)");
if (check == "") {
println("Installing Git");
bash("yum -y -q install git");
}
// Ubuntu
} else if (distribution == "ubuntu") {
deb();
// Debian
} else if (distribution == "debian") {
deb();
} else {
// Unknown distribution
error = 0;
bash("check=$(git --version)");
if (check == "") {
error = 1;
println("Error: git is missing");
}
bash("check=$(node -v)");
if (check == "") {
error = 1;
println("Error: node is missing");
}
if (error > 0) {
println("Please install above missing software");
bash("exit 1");
}
}
bash("check=$(pm2 --version)");
if (check == "") {
println("Installing PM2");
bash("npm install pm2 -g");
bash("pm2 startup");
}
bash("mkdir -p $installPath");
bash("cd $installPath");
bash("git clone https://github.com/louislam/uptime-kuma.git .");
bash("npm run setup");
bash("pm2 start npm --name uptime-kuma -- run start-server -- --port=$port");
} else {
defaultVolume = "uptime-kuma";
bash("check=$(docker -v)");
if (check == "") {
println("Error: docker is not found!");
bash("exit 1");
}
bash("check=$(docker info)");
bash("if [[ \"$check\" == *\"Is the docker daemon running\"* ]]; then
echo \"Error: docker is not running\"
exit 1
fi");
if ("$3" != "") {
port = "$3";
} else {
call("read", "-p", "Expose Port [$defaultPort]: ", "port");
if (port == "") {
port = defaultPort;
}
}
if ("$2" != "") {
volume = "$2";
} else {
call("read", "-p", "Volume Name [$defaultVolume]: ", "volume");
if (volume == "") {
volume = defaultVolume;
}
}
println("Port: $port");
println("Volume: $volume");
bash("docker volume create $volume");
bash("docker run -d --restart=always -p $port:3001 -v $volume:/app/data --name uptime-kuma louislam/uptime-kuma:1");
}
println("http://localhost:$port");

26
extra/mark-as-nightly.js

@ -1,25 +1,9 @@
/**
* 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);
}
const pkg = require("../package.json");
const fs = require("fs");
const util = require("../src/util");
// If a string
return this.replace(new RegExp(str, 'g'), newStr);
util.polyfill();
};
}
const pkg = require('../package.json');
const fs = require("fs");
const oldVersion = pkg.version
const newVersion = oldVersion + "-nightly"
@ -35,6 +19,6 @@ if (newVersion) {
// Process README.md
if (fs.existsSync("README.md")) {
fs.writeFileSync("README.md", fs.readFileSync("README.md", 'utf8').replaceAll(oldVersion, newVersion))
fs.writeFileSync("README.md", fs.readFileSync("README.md", "utf8").replaceAll(oldVersion, newVersion))
}
}

59
extra/reset-password.js

@ -0,0 +1,59 @@
console.log("== Uptime Kuma Reset Password Tool ==");
console.log("Loading the database");
const Database = require("../server/database");
const { R } = require("redbean-node");
const readline = require("readline");
const { initJWTSecret } = require("../server/util-server");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
(async () => {
await Database.connect();
try {
const user = await R.findOne("user");
if (! user) {
throw new Error("user not found, have you installed?");
}
console.log("Found user: " + user.username);
while (true) {
let password = await question("New Password: ");
let confirmPassword = await question("Confirm New Password: ");
if (password === confirmPassword) {
await user.resetPassword(password);
// Reset all sessions by reset jwt secret
await initJWTSecret();
rl.close();
break;
} else {
console.log("Passwords do not match, please try again.");
}
}
console.log("Password reset successfully.");
} catch (e) {
console.error("Error: " + e.message);
}
await Database.close();
console.log("Finished. You should restart the Uptime Kuma server.")
})();
function question(question) {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer);
})
});
}

59
extra/update-version.js

@ -0,0 +1,59 @@
const pkg = require("../package.json");
const fs = require("fs");
const child_process = require("child_process");
const util = require("../src/util");
util.polyfill();
const oldVersion = pkg.version;
const newVersion = process.argv[2];
console.log("Old Version: " + oldVersion);
console.log("New Version: " + newVersion);
if (! newVersion) {
console.error("invalid version");
process.exit(1);
}
const exists = tagExists(newVersion);
if (! exists) {
// 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");
commit(newVersion);
tag(newVersion);
} else {
console.log("version exists")
}
function commit(version) {
let msg = "update to " + version;
let res = child_process.spawnSync("git", ["commit", "-m", msg, "-a"]);
let stdout = res.stdout.toString().trim();
console.log(stdout)
if (stdout.includes("no changes added to commit")) {
throw new Error("commit error")
}
}
function tag(version) {
let res = child_process.spawnSync("git", ["tag", version]);
console.log(res.stdout.toString().trim())
}
function tagExists(version) {
if (! version) {
throw new Error("invalid version");
}
let res = child_process.spawnSync("git", ["tag", "-l", version]);
return res.stdout.toString().trim() === version;
}

39
extra/version-global-replace.js

@ -1,39 +0,0 @@
/**
* 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 = process.argv[2]
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
fs.writeFileSync("README.md", fs.readFileSync("README.md", 'utf8').replaceAll(oldVersion, newVersion))
}

18
index.html

@ -1,16 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="theme-color" id="theme-color" content="" />
<meta name="description" content="Uptime Kuma monitoring tool" />
<title>Uptime Kuma</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

203
install.sh

@ -0,0 +1,203 @@
# install.sh is generated by ./extra/install.batsh, do not modify it directly.
# "npm run compile-install-script" to compile install.sh
# The command is working on Windows PowerShell and Docker for Windows only.
# curl -o kuma_install.sh https://raw.githubusercontent.com/louislam/uptime-kuma/master/install.sh && sudo bash kuma_install.sh
"echo" "-e" "====================="
"echo" "-e" "Uptime Kuma Installer"
"echo" "-e" "====================="
"echo" "-e" "Supported OS: CentOS 7/8, Ubuntu >= 16.04 and Debian"
"echo" "-e" "---------------------------------------"
"echo" "-e" "This script is designed for Linux and basic usage."
"echo" "-e" "For advanced usage, please go to https://github.com/louislam/uptime-kuma/wiki/Installation"
"echo" "-e" "---------------------------------------"
"echo" "-e" ""
"echo" "-e" "Local - Install Uptime Kuma in your current machine with git, Node.js 14 and pm2"
"echo" "-e" "Docker - Install Uptime Kuma Docker container"
"echo" "-e" ""
if [ "$1" != "" ]; then
type="$1"
else
"read" "-p" "Which installation method do you prefer? [DOCKER/local]: " "type"
fi
defaultPort="3001"
function checkNode {
local _0
nodeVersion=$(node -e 'console.log(process.versions.node.split(`.`)[0])')
"echo" "-e" "Node Version: ""$nodeVersion"
_0="12"
if [ $(($nodeVersion < $_0)) == 1 ]; then
"echo" "-e" "Error: Required Node.js 14"
"exit" "1"
fi
if [ "$nodeVersion" == "12" ]; then
"echo" "-e" "Warning: NodeJS ""$nodeVersion"" is not tested."
fi
}
function deb {
nodeCheck=$(node -v)
apt --yes update
if [ "$nodeCheck" != "" ]; then
"checkNode"
else
# Old nodejs binary name is "nodejs"
check=$(nodejs --version)
if [ "$check" != "" ]; then
"echo" "-e" "Error: 'node' command is not found, but 'nodejs' command is found. Your NodeJS should be too old."
exit 1
fi
curlCheck=$(curl --version)
if [ "$curlCheck" == "" ]; then
"echo" "-e" "Installing Curl"
apt --yes install curl
fi
"echo" "-e" "Installing Node.js 14"
curl -sL https://deb.nodesource.com/setup_14.x | bash - > log.txt
apt --yes install nodejs
node -v
nodeCheckAgain=$(node -v)
if [ "$nodeCheckAgain" == "" ]; then
"echo" "-e" "Error during Node.js installation"
exit 1
fi
fi
check=$(git --version)
if [ "$check" == "" ]; then
"echo" "-e" "Installing Git"
apt --yes install git
fi
}
if [ "$type" == "local" ]; then
defaultInstallPath="/opt/uptime-kuma"
if [ -e "/etc/redhat-release" ]; then
os=$("cat" "/etc/redhat-release")
distribution="rhel"
else
if [ -e "/etc/issue" ]; then
os=$(head -n1 /etc/issue | cut -f 1 -d ' ')
if [ "$os" == "Ubuntu" ]; then
distribution="ubuntu"
fi
if [ "$os" == "Debian" ]; then
distribution="debian"
fi
fi
fi
arch=$(uname -i)
"echo" "-e" "Your OS: ""$os"
"echo" "-e" "Distribution: ""$distribution"
"echo" "-e" "Arch: ""$arch"
if [ "$3" != "" ]; then
port="$3"
else
"read" "-p" "Listening Port [$defaultPort]: " "port"
if [ "$port" == "" ]; then
port="$defaultPort"
fi
fi
if [ "$2" != "" ]; then
installPath="$2"
else
"read" "-p" "Installation Path [$defaultInstallPath]: " "installPath"
if [ "$installPath" == "" ]; then
installPath="$defaultInstallPath"
fi
fi
# CentOS
if [ "$distribution" == "rhel" ]; then
nodeCheck=$(node -v)
if [ "$nodeCheck" != "" ]; then
"checkNode"
else
curlCheck=$(curl --version)
if [ "$curlCheck" == "" ]; then
"echo" "-e" "Installing Curl"
yum -y -q install curl
fi
"echo" "-e" "Installing Node.js 14"
curl -sL https://rpm.nodesource.com/setup_14.x | bash - > log.txt
yum install -y -q nodejs
node -v
nodeCheckAgain=$(node -v)
if [ "$nodeCheckAgain" == "" ]; then
"echo" "-e" "Error during Node.js installation"
exit 1
fi
fi
check=$(git --version)
if [ "$check" == "" ]; then
"echo" "-e" "Installing Git"
yum -y -q install git
fi
# Ubuntu
else
if [ "$distribution" == "ubuntu" ]; then
"deb"
# Debian
else
if [ "$distribution" == "debian" ]; then
"deb"
else
# Unknown distribution
error=$((0))
check=$(git --version)
if [ "$check" == "" ]; then
error=$((1))
"echo" "-e" "Error: git is missing"
fi
check=$(node -v)
if [ "$check" == "" ]; then
error=$((1))
"echo" "-e" "Error: node is missing"
fi
if [ $(($error > 0)) == 1 ]; then
"echo" "-e" "Please install above missing software"
exit 1
fi
fi
fi
fi
check=$(pm2 --version)
if [ "$check" == "" ]; then
"echo" "-e" "Installing PM2"
npm install pm2 -g
pm2 startup
fi
mkdir -p $installPath
cd $installPath
git clone https://github.com/louislam/uptime-kuma.git .
npm run setup
pm2 start npm --name uptime-kuma -- run start-server -- --port=$port
else
defaultVolume="uptime-kuma"
check=$(docker -v)
if [ "$check" == "" ]; then
"echo" "-e" "Error: docker is not found!"
exit 1
fi
check=$(docker info)
if [[ "$check" == *"Is the docker daemon running"* ]]; then
echo "Error: docker is not running"
exit 1
fi
if [ "$3" != "" ]; then
port="$3"
else
"read" "-p" "Expose Port [$defaultPort]: " "port"
if [ "$port" == "" ]; then
port="$defaultPort"
fi
fi
if [ "$2" != "" ]; then
volume="$2"
else
"read" "-p" "Volume Name [$defaultVolume]: " "volume"
if [ "$volume" == "" ]; then
volume="$defaultVolume"
fi
fi
"echo" "-e" "Port: $port"
"echo" "-e" "Volume: $volume"
docker volume create $volume
docker run -d --restart=always -p $port:3001 -v $volume:/app/data --name uptime-kuma louislam/uptime-kuma:1
fi
"echo" "-e" "http://localhost:$port"

28
kubernetes/README.md

@ -0,0 +1,28 @@
# Uptime-Kuma K8s Deployment
## How does it work?
Kustomize is a tool which builds a complete deployment file for all config elements.
You can edit the files in the ```uptime-kuma``` folder except the ```kustomization.yml``` until you know what you're doing.
If you want to choose another namespace you can edit the ```kustomization.yml``` in the ```kubernetes```-Folder and change the ```namespace: uptime-kuma``` to something you like.
It creates a certificate with the specified Issuer and creates the Ingress for the Uptime-Kuma ClusterIP-Service
## What do i have to edit?
You have to edit the ```ingressroute.yml``` to your needs.
This ingressroute.yml is for the [nginx-ingress-controller](https://kubernetes.github.io/ingress-nginx/) in combination with the [cert-manager](https://cert-manager.io/).
- host
- secrets and secret names
- (Cluster)Issuer (optional)
- the Version in the Deployment-File
- update:
- change to newer version and run the above commands, it will update the pods one after another
## How To use:
- install [kustomize](https://kubectl.docs.kubernetes.io/installation/kustomize/)
- Edit files mentioned above to your needs
- run ```kustomize build > apply.yml```
- run ```kubectl apply -f apply.yml```
Now you should see some k8s magic and Uptime-Kuma should be available at the specified address.

10
kubernetes/kustomization.yml

@ -0,0 +1,10 @@
namespace: uptime-kuma
namePrefix: uptime-kuma-
commonLabels:
app: uptime-kuma
bases:
- uptime-kuma

42
kubernetes/uptime-kuma/deployment.yml

@ -0,0 +1,42 @@
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
component: uptime-kuma
name: deployment
spec:
selector:
matchLabels:
component: uptime-kuma
replicas: 1
strategy:
type: Recreate
template:
metadata:
labels:
component: uptime-kuma
spec:
containers:
- name: app
image: louislam/uptime-kuma:1
ports:
- containerPort: 3001
volumeMounts:
- mountPath: /app/data
name: storage
livenessProbe:
exec:
command:
- node
- extra/healthcheck.js
readinessProbe:
httpGet:
path: /
port: 3001
scheme: HTTP
volumes:
- name: storage
persistentVolumeClaim:
claimName: pvc

39
kubernetes/uptime-kuma/ingressroute.yml

@ -0,0 +1,39 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/server-snippets: |
location / {
proxy_set_header Upgrade $http_upgrade;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Host $host;
proxy_set_header Connection "upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_cache_bypass $http_upgrade;
}
name: ingress
spec:
tls:
- hosts:
- example.com
secretName: example-com-tls
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: service
port:
number: 3001

5
kubernetes/uptime-kuma/kustomization.yml

@ -0,0 +1,5 @@
resources:
- deployment.yml
- service.yml
- ingressroute.yml
- pvc.yml

10
kubernetes/uptime-kuma/pvc.yml

@ -0,0 +1,10 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 4Gi

13
kubernetes/uptime-kuma/service.yml

@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: service
spec:
selector:
component: uptime-kuma
type: ClusterIP
ports:
- name: http
port: 3001
targetPort: 3001
protocol: TCP

BIN
public/apple-touch-icon-precomposed.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
public/apple-touch-icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
public/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

3
public/robots.txt

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

44
server/check-version.js

@ -0,0 +1,44 @@
const { setSetting } = require("./util-server");
const axios = require("axios");
const { isDev } = require("../src/util");
exports.version = require("../package.json").version;
exports.latestVersion = null;
let interval;
exports.startInterval = () => {
let check = async () => {
try {
const res = await axios.get("https://raw.githubusercontent.com/louislam/uptime-kuma/master/package.json");
if (typeof res.data === "string") {
res.data = JSON.parse(res.data);
}
// For debug
if (process.env.TEST_CHECK_VERSION === "1") {
res.data.version = "1000.0.0"
}
exports.latestVersion = res.data.version;
console.log("Latest Version: " + exports.latestVersion);
} catch (_) { }
};
check();
interval = setInterval(check, 3600 * 1000 * 48);
};
exports.enableCheckUpdate = async (value) => {
await setSetting("checkUpdate", value);
clearInterval(interval);
if (value) {
exports.startInterval();
}
};
exports.socket = null;

83
server/database.js

@ -1,16 +1,45 @@
const fs = require("fs");
const { sleep } = require("../src/util");
const { R } = require("redbean-node");
const {
setSetting, setting,
} = require("./util-server");
const { setSetting, setting } = require("./util-server");
class Database {
static templatePath = "./db/kuma.db"
static path = "./data/kuma.db";
static latestVersion = 4;
static latestVersion = 6;
static noReject = true;
static sqliteInstance = null;
static async connect() {
const acquireConnectionTimeout = 120 * 1000;
R.useBetterSQLite3 = true;
R.betterSQLite3Options.timeout = acquireConnectionTimeout;
R.setup("sqlite", {
filename: Database.path,
useNullAsDefault: true,
acquireConnectionTimeout: acquireConnectionTimeout,
}, {
min: 1,
max: 1,
idleTimeoutMillis: 120 * 1000,
propagateCreateError: false,
acquireTimeoutMillis: acquireConnectionTimeout,
});
if (process.env.SQL_LOG === "1") {
R.debug(true);
}
// Auto map the model to a bean object
R.freeze(true)
await R.autoloadModels("./server/model");
// Change to WAL
await R.exec("PRAGMA journal_mode = WAL");
console.log(await R.getAll("PRAGMA journal_mode"));
}
static async patch() {
let version = parseInt(await setting("database_version"));
@ -24,6 +53,8 @@ class Database {
if (version === this.latestVersion) {
console.info("Database no need to patch");
} else if (version > this.latestVersion) {
console.info("Warning: Database version is newer than expected");
} else {
console.info("Database patch is needed")
@ -31,6 +62,16 @@ class Database {
const backupPath = "./data/kuma.db.bak" + version;
fs.copyFileSync(Database.path, backupPath);
const shmPath = Database.path + "-shm";
if (fs.existsSync(shmPath)) {
fs.copyFileSync(shmPath, shmPath + ".bak" + version);
}
const walPath = Database.path + "-wal";
if (fs.existsSync(walPath)) {
fs.copyFileSync(walPath, walPath + ".bak" + version);
}
// Try catch anything here, if gone wrong, restore the backup
try {
for (let i = version + 1; i <= this.latestVersion; i++) {
@ -83,37 +124,27 @@ class Database {
return statement !== "";
})
// Use better-sqlite3 to run, prevent "This statement does not return data. Use run() instead"
const db = await this.getBetterSQLite3Database();
for (let statement of statements) {
await R.exec(statement);
db.prepare(statement).run();
}
}
static getBetterSQLite3Database() {
return R.knex.client.acquireConnection();
}
/**
* 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")
}
if (this.sqliteInstance) {
this.sqliteInstance.close();
}
console.log("SQLite closed")
process.removeListener("unhandledRejection", listener);
console.log("Stopped database");
}
}

70
server/model/monitor.js

@ -6,11 +6,12 @@ dayjs.extend(utc)
dayjs.extend(timezone)
const axios = require("axios");
const { Prometheus } = require("../prometheus");
const { debug, UP, DOWN, PENDING, flipStatus } = require("../../src/util");
const { tcping, ping, checkCertificate } = require("../util-server");
const { debug, UP, DOWN, PENDING, flipStatus, TimeLogger } = require("../../src/util");
const { tcping, ping, checkCertificate, checkStatusCode } = require("../util-server");
const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model");
const { Notification } = require("../notification")
const version = require("../../package.json").version;
/**
* status:
@ -45,6 +46,8 @@ class Monitor extends BeanModel {
keyword: this.keyword,
ignoreTls: this.getIgnoreTls(),
upsideDown: this.isUpsideDown(),
maxredirects: this.maxredirects,
accepted_statuscodes: this.getAcceptedStatuscodes(),
notificationIDList,
};
}
@ -65,6 +68,10 @@ class Monitor extends BeanModel {
return Boolean(this.upsideDown);
}
getAcceptedStatuscodes() {
return JSON.parse(this.accepted_statuscodes_json);
}
start(io) {
let previousBeat = null;
let retries = 0;
@ -73,6 +80,10 @@ class Monitor extends BeanModel {
const beat = async () => {
// Expose here for prometheus update
// undefined if not https
let tlsInfo = undefined;
if (! previousBeat) {
previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [
this.id,
@ -99,30 +110,36 @@ class Monitor extends BeanModel {
try {
if (this.type === "http" || this.type === "keyword") {
// Do not do any queries/high loading things before the "bean.ping"
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, {
timeout: this.interval * 1000 * 0.8,
headers: {
"User-Agent": "Uptime-Kuma",
"Accept": "*/*",
"User-Agent": "Uptime-Kuma/" + version,
},
httpsAgent: new https.Agent({
maxCachedSessions: 0,
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
rejectUnauthorized: ! this.getIgnoreTls(),
}),
maxRedirects: this.maxredirects,
validateStatus: (status) => {
return checkStatusCode(status, this.getAcceptedStatuscodes());
},
});
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));
tlsInfo = await this.updateTlsInfo(checkCertificate(res));
} catch (e) {
console.error(e.message)
if (e.message !== "No TLS certificate in response") {
console.error(e.message)
}
}
}
@ -223,7 +240,8 @@ class Monitor extends BeanModel {
try {
await Notification.send(JSON.parse(notification.config), msg, await this.toJSON(), bean.toJSON())
} catch (e) {
console.error("Cannot send notification to " + notification.name)
console.error("Cannot send notification to " + notification.name);
console.log(e);
}
}
}
@ -240,22 +258,22 @@ class Monitor extends BeanModel {
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)
Monitor.sendStats(io, this.id, this.user_id)
await R.store(bean);
prometheus.update(bean, tlsInfo);
previousBeat = bean;
this.heartbeatInterval = setTimeout(beat, this.interval * 1000);
}
beat();
this.heartbeatInterval = setInterval(beat, this.interval * 1000);
}
stop() {
clearInterval(this.heartbeatInterval)
clearTimeout(this.heartbeatInterval);
}
/**
@ -275,7 +293,7 @@ class Monitor extends BeanModel {
/**
* Store TLS info to database
* @param checkCertificateResult
* @returns {Promise<void>}
* @returns {Promise<object>}
*/
async updateTlsInfo(checkCertificateResult) {
let tls_info_bean = await R.findOne("monitor_tls_info", "monitor_id = ?", [
@ -287,13 +305,15 @@ class Monitor extends BeanModel {
}
tls_info_bean.info_json = JSON.stringify(checkCertificateResult);
await R.store(tls_info_bean);
return checkCertificateResult;
}
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);
await Monitor.sendAvgPing(24, io, monitorID, userID);
await Monitor.sendUptime(24, io, monitorID, userID);
await Monitor.sendUptime(24 * 30, io, monitorID, userID);
await Monitor.sendCertInfo(io, monitorID, userID);
}
/**
@ -301,6 +321,8 @@ class Monitor extends BeanModel {
* @param duration : int Hours
*/
static async sendAvgPing(duration, io, monitorID, userID) {
const timeLogger = new TimeLogger();
let avgPing = parseInt(await R.getCell(`
SELECT AVG(ping)
FROM heartbeat
@ -311,6 +333,8 @@ class Monitor extends BeanModel {
monitorID,
]));
timeLogger.print(`[Monitor: ${monitorID}] avgPing`);
io.to(userID).emit("avgPing", monitorID, avgPing);
}
@ -330,6 +354,8 @@ class Monitor extends BeanModel {
* @param duration : int Hours
*/
static async sendUptime(duration, io, monitorID, userID) {
const timeLogger = new TimeLogger();
let sec = duration * 3600;
let heartbeatList = await R.getAll(`
@ -341,6 +367,8 @@ class Monitor extends BeanModel {
monitorID,
]);
timeLogger.print(`[Monitor: ${monitorID}][${duration}] sendUptime`);
let downtime = 0;
let total = 0;
let uptime;

21
server/model/user.js

@ -0,0 +1,21 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
const passwordHash = require("../password-hash");
const { R } = require("redbean-node");
class User extends BeanModel {
/**
* Direct execute, no need R.store()
* @param newPassword
* @returns {Promise<void>}
*/
async resetPassword(newPassword) {
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
passwordHash.generate(newPassword),
this.id
]);
this.password = newPassword;
}
}
module.exports = User;

238
server/notification.js

@ -84,40 +84,78 @@ class Notification {
} else if (notification.type === "discord") {
try {
const discordDisplayName = notification.discordUsername || "Uptime Kuma";
// If heartbeatJSON is null, assume we're testing.
if (heartbeatJSON == null) {
let data = {
username: "Uptime-Kuma",
let discordtestdata = {
username: discordDisplayName,
content: msg,
}
await axios.post(notification.discordWebhookUrl, data)
await axios.post(notification.discordWebhookUrl, discordtestdata)
return okMsg;
}
// If heartbeatJSON is not null, we go into the normal alerting loop.
if (heartbeatJSON["status"] == 0) {
var alertColor = "16711680";
let discorddowndata = {
username: discordDisplayName,
embeds: [{
title: "❌ One of your services went down. ❌",
color: 16711680,
timestamp: heartbeatJSON["time"],
fields: [
{
name: "Service Name",
value: monitorJSON["name"],
},
{
name: "Service URL",
value: monitorJSON["url"],
},
{
name: "Time (UTC)",
value: heartbeatJSON["time"],
},
{
name: "Error",
value: heartbeatJSON["msg"],
},
],
}],
}
await axios.post(notification.discordWebhookUrl, discorddowndata)
return okMsg;
} else if (heartbeatJSON["status"] == 1) {
var alertColor = "65280";
}
let data = {
username: "Uptime-Kuma",
embeds: [{
title: "Uptime-Kuma Alert",
color: alertColor,
fields: [
{
name: "Time (UTC)",
value: heartbeatJSON["time"],
},
{
name: "Message",
value: msg,
},
],
}],
let discordupdata = {
username: discordDisplayName,
embeds: [{
title: "✅ Your service " + monitorJSON["name"] + " is up! ✅",
color: 65280,
timestamp: heartbeatJSON["time"],
fields: [
{
name: "Service Name",
value: monitorJSON["name"],
},
{
name: "Service URL",
value: "[Visit Service](" + monitorJSON["url"] + ")",
},
{
name: "Time (UTC)",
value: heartbeatJSON["time"],
},
{
name: "Ping",
value: heartbeatJSON["ping"] + "ms",
},
],
}],
}
await axios.post(notification.discordWebhookUrl, discordupdata)
return okMsg;
}
await axios.post(notification.discordWebhookUrl, data)
return okMsg;
} catch (error) {
throwGeneralAxiosError(error)
}
@ -136,6 +174,7 @@ class Notification {
} catch (error) {
throwGeneralAxiosError(error)
}
} else if (notification.type === "pushy") {
try {
await axios.post(`https://api.pushy.me/push?api_key=${notification.pushyAPIKey}`, {
@ -154,6 +193,34 @@ class Notification {
console.log(error)
return false;
}
} else if (notification.type === "octopush") {
try {
let config = {
headers: {
"api-key": notification.octopushAPIKey,
"api-login": notification.octopushLogin,
"cache-control": "no-cache"
}
};
let data = {
"recipients": [
{
"phone_number": notification.octopushPhoneNumber
}
],
//octopush not supporting non ascii char
"text": msg.replace(/[^\x00-\x7F]/g, ""),
"type": notification.octopushSMSType,
"purpose": "alert",
"sender": notification.octopushSenderName
};
await axios.post("https://api.octopush.com/v1/public/sms-campaign/send", data, config)
return true;
} catch (error) {
console.log(error)
return false;
}
} else if (notification.type === "slack") {
try {
if (heartbeatJSON == null) {
@ -248,10 +315,6 @@ class Notification {
throwGeneralAxiosError(error)
}
} 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
@ -287,6 +350,88 @@ class Notification {
throwGeneralAxiosError(error)
}
} else if (notification.type === "pushbullet") {
try {
let pushbulletUrl = "https://api.pushbullet.com/v2/pushes";
let config = {
headers: {
"Access-Token": notification.pushbulletAccessToken,
"Content-Type": "application/json"
}
};
if (heartbeatJSON == null) {
let testdata = {
"type": "note",
"title": "Uptime Kuma Alert",
"body": "Testing Successful.",
}
await axios.post(pushbulletUrl, testdata, config)
} else if (heartbeatJSON["status"] == 0) {
let downdata = {
"type": "note",
"title": "UptimeKuma Alert:" + monitorJSON["name"],
"body": "[🔴 Down]" + heartbeatJSON["msg"] + "\nTime (UTC):" + heartbeatJSON["time"],
}
await axios.post(pushbulletUrl, downdata, config)
} else if (heartbeatJSON["status"] == 1) {
let updata = {
"type": "note",
"title": "UptimeKuma Alert:" + monitorJSON["name"],
"body": "[✅ Up]" + heartbeatJSON["msg"] + "\nTime (UTC):" + heartbeatJSON["time"],
}
await axios.post(pushbulletUrl, updata, config)
}
return okMsg;
} catch (error) {
throwGeneralAxiosError(error)
}
} else if (notification.type === "line") {
try {
let lineAPIUrl = "https://api.line.me/v2/bot/message/push";
let config = {
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + notification.lineChannelAccessToken
}
};
if (heartbeatJSON == null) {
let testMessage = {
"to": notification.lineUserID,
"messages": [
{
"type": "text",
"text": "Test Successful!"
}
]
}
await axios.post(lineAPIUrl, testMessage, config)
} else if (heartbeatJSON["status"] == 0) {
let downMessage = {
"to": notification.lineUserID,
"messages": [
{
"type": "text",
"text": "UptimeKuma Alert: [🔴 Down]\n" + "Name: " + monitorJSON["name"] + " \n" + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"]
}
]
}
await axios.post(lineAPIUrl, downMessage, config)
} else if (heartbeatJSON["status"] == 1) {
let upMessage = {
"to": notification.lineUserID,
"messages": [
{
"type": "text",
"text": "UptimeKuma Alert: [✅ Up]\n" + "Name: " + monitorJSON["name"] + " \n" + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"]
}
]
}
await axios.post(lineAPIUrl, upMessage, config)
}
return okMsg;
} catch (error) {
throwGeneralAxiosError(error)
}
} else {
throw new Error("Notification type is not supported")
}
@ -330,15 +475,21 @@ class Notification {
static async smtp(notification, msg) {
let transporter = nodemailer.createTransport({
const config = {
host: notification.smtpHost,
port: notification.smtpPort,
secure: notification.smtpSecure,
auth: {
};
// Should fix the issue in https://github.com/louislam/uptime-kuma/issues/26#issuecomment-896373904
if (notification.smtpUsername || notification.smtpPassword) {
config.auth = {
user: notification.smtpUsername,
pass: notification.smtpPassword,
},
});
};
}
let transporter = nodemailer.createTransport(config);
// send mail with defined transport object
await transporter.sendMail({
@ -351,29 +502,6 @@ class Notification {
return "Sent Successfully.";
}
static async apprise(notification, msg) {
let s = child_process.spawnSync("apprise", [ "-vv", "-b", msg, notification.appriseURL])
let output = (s.stdout) ? s.stdout.toString() : "ERROR: maybe apprise not found";
if (output) {
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) {

62
server/ping-lite.js

@ -1,12 +1,14 @@
// https://github.com/ben-bradley/ping-lite/blob/master/ping-lite.js
// Fixed on Windows
let spawn = require("child_process").spawn,
const net = require("net");
const 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);
FBSD = /^freebsd/.test(process.platform);
const { debug } = require("../src/util");
module.exports = Ping;
@ -24,14 +26,42 @@ function Ping(host, options) {
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 ];
this._regmatch = /=([0-9.]+?) ms/; // need to verify this
const defaultArgs = [ "-n", "-w", "2", "-c", "1", host ];
if (net.isIPv6(host) || options.ipv6) {
defaultArgs.unshift("-6");
}
this._args = (options.args) ? options.args : defaultArgs;
this._regmatch = /=([0-9.]+?) ms/;
} else if (MAC) {
this._bin = "/sbin/ping";
if (net.isIPv6(host) || options.ipv6) {
this._bin = "/sbin/ping6";
} else {
this._bin = "/sbin/ping";
}
this._args = (options.args) ? options.args : [ "-n", "-t", "2", "-c", "1", host ];
this._regmatch = /=([0-9.]+?) ms/;
} else if (FBSD) {
this._bin = "/sbin/ping";
const defaultArgs = [ "-n", "-t", "2", "-c", "1", host ];
if (net.isIPv6(host) || options.ipv6) {
defaultArgs.unshift("-6");
}
this._args = (options.args) ? options.args : defaultArgs;
this._regmatch = /=([0-9.]+?) ms/;
} else {
throw new Error("Could not detect your ping binary.");
}
@ -49,9 +79,9 @@ Ping.prototype.__proto__ = events.EventEmitter.prototype;
// SEND A PING
// ===========
Ping.prototype.send = function(callback) {
Ping.prototype.send = function (callback) {
let self = this;
callback = callback || function(err, ms) {
callback = callback || function (err, ms) {
if (err) {
return self.emit("error", err);
}
@ -62,27 +92,27 @@ Ping.prototype.send = function(callback) {
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._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);
}
});
this._ping.stderr.on("data", function(data) { // log stderr
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);
@ -105,15 +135,15 @@ Ping.prototype.send = function(callback) {
ms = stdout.match(self._regmatch); // parse out the ##ms response
ms = (ms && ms[1]) ? Number(ms[1]) : ms;
callback(null, ms);
callback(null, ms, stdout);
}
};
// CALL Ping#send(callback) ON A TIMER
// ===================================
Ping.prototype.start = function(callback) {
Ping.prototype.start = function (callback) {
let self = this;
this._i = setInterval(function() {
this._i = setInterval(function () {
self.send(callback);
}, (self._options.interval || 5000));
self.send(callback);
@ -121,6 +151,6 @@ Ping.prototype.start = function(callback) {
// STOP SENDING PINGS
// ==================
Ping.prototype.stop = function() {
Ping.prototype.stop = function () {
clearInterval(this._i);
};

55
server/prometheus.js

@ -1,22 +1,33 @@
const PrometheusClient = require('prom-client');
const PrometheusClient = require("prom-client");
const commonLabels = [
'monitor_name',
'monitor_type',
'monitor_url',
'monitor_hostname',
'monitor_port',
"monitor_name",
"monitor_type",
"monitor_url",
"monitor_hostname",
"monitor_port",
]
const monitor_cert_days_remaining = new PrometheusClient.Gauge({
name: "monitor_cert_days_remaining",
help: "The number of days remaining until the certificate expires",
labelNames: commonLabels
});
const monitor_cert_is_valid = new PrometheusClient.Gauge({
name: "monitor_cert_is_valid",
help: "Is the certificate still valid? (1 = Yes, 0= No)",
labelNames: commonLabels
});
const monitor_response_time = new PrometheusClient.Gauge({
name: 'monitor_response_time',
help: 'Monitor Response Time (ms)',
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)',
name: "monitor_status",
help: "Monitor Status (1 = UP, 0= DOWN)",
labelNames: commonLabels
});
@ -33,7 +44,27 @@ class Prometheus {
}
}
update(heartbeat) {
update(heartbeat, tlsInfo) {
if (typeof tlsInfo !== "undefined") {
try {
let is_valid = 0
if (tlsInfo.valid == true) {
is_valid = 1
} else {
is_valid = 0
}
monitor_cert_is_valid.set(this.monitorLabelValues, is_valid)
} catch (e) {
console.error(e)
}
try {
monitor_cert_days_remaining.set(this.monitorLabelValues, tlsInfo.daysRemaining)
} catch (e) {
console.error(e)
}
}
try {
monitor_status.set(this.monitorLabelValues, heartbeat.status)
} catch (e) {
@ -41,7 +72,7 @@ class Prometheus {
}
try {
if (typeof heartbeat.ping === 'number') {
if (typeof heartbeat.ping === "number") {
monitor_response_time.set(this.monitorLabelValues, heartbeat.ping)
} else {
// Is it good?

162
server/server.js

@ -1,6 +1,7 @@
console.log("Welcome to Uptime Kuma")
console.log("Welcome to Uptime Kuma");
console.log("Node Env: " + process.env.NODE_ENV);
const { sleep, debug } = require("../src/util");
const { sleep, debug, TimeLogger, getRandomInt } = require("../src/util");
console.log("Importing Node libraries")
const fs = require("fs");
@ -11,8 +12,6 @@ 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");
@ -26,7 +25,7 @@ console.log("Importing this project modules");
debug("Importing Monitor");
const Monitor = require("./model/monitor");
debug("Importing Settings");
const { getSettings, setSettings, setting } = require("./util-server");
const { getSettings, setSettings, setting, initJWTSecret } = require("./util-server");
debug("Importing Notification");
const { Notification } = require("./notification");
debug("Importing Database");
@ -38,11 +37,13 @@ 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 checkVersion = require("./check-version");
console.info("Version: " + checkVersion.version);
console.info("Version: " + version)
// If host is omitted, the server will accept connections on the unspecified IPv6 address (::) when IPv6 is available and the unspecified IPv4 address (0.0.0.0) otherwise.
// Dual-stack support for (::)
const hostname = process.env.HOST || args.host;
const port = parseInt(process.env.PORT || args.port || 3001);
console.log("Creating express and socket.io instance")
const app = express();
@ -87,24 +88,35 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
// Normal Router here
app.use("/", express.static("dist"));
// Robots.txt
app.get("/robots.txt", async (_request, response) => {
let txt = "User-agent: *\nDisallow:";
if (! await setting("searchEngineIndex")) {
txt += " /";
}
response.setHeader("Content-Type", "text/plain");
response.send(txt);
});
// Basic Auth Router here
// Prometheus API metrics /metrics
// With Basic Auth using the first user's username/password
app.get("/metrics", basicAuth, prometheusAPIMetrics())
app.get("/metrics", basicAuth, prometheusAPIMetrics());
app.use("/", express.static("dist"));
// Universal Route Handler, must be at the end
app.get("*", function(request, response, next) {
response.end(indexHTML)
app.get("*", async (_request, response) => {
response.send(indexHTML);
});
console.log("Adding socket handler")
io.on("connection", async (socket) => {
socket.emit("info", {
version,
version: checkVersion.version,
latestVersion: checkVersion.latestVersion,
})
totalClient++;
@ -114,11 +126,6 @@ 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--;
});
@ -139,7 +146,11 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
])
if (user) {
await afterLogin(socket, user)
debug("afterLogin")
afterLogin(socket, user)
debug("afterLogin ok")
callback({
ok: true,
@ -165,7 +176,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
let user = await login(data.username, data.password)
if (user) {
await afterLogin(socket, user)
afterLogin(socket, user)
callback({
ok: true,
@ -231,6 +242,9 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
let notificationIDList = monitor.notificationIDList;
delete monitor.notificationIDList;
monitor.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
delete monitor.accepted_statuscodes;
bean.import(monitor)
bean.user_id = socket.userID
await R.store(bean)
@ -275,6 +289,8 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
bean.keyword = monitor.keyword;
bean.ignoreTls = monitor.ignoreTls;
bean.upsideDown = monitor.upsideDown;
bean.maxredirects = monitor.maxredirects;
bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
await R.store(bean)
@ -409,10 +425,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
if (user && passwordHash.verify(password.currentPassword, user.password)) {
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
passwordHash.generate(password.newPassword),
socket.userID,
]);
user.resetPassword(password.newPassword);
callback({
ok: true,
@ -527,18 +540,45 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
});
}
});
debug("added all socket handlers")
// ***************************
// Better do anything after added all socket handlers here
// ***************************
debug("check auto login")
if (await setting("disableAuth")) {
console.log("Disabled Auth: auto login to admin")
afterLogin(socket, await R.findOne("user"))
socket.emit("autoLogin")
} else {
debug("need auth")
}
});
console.log("Init the server")
server.once("error", async (err) => {
console.error("Cannot listen: " + err.message);
await Database.close();
});
console.log("Init")
server.listen(port, hostname, () => {
console.log(`Listening on ${hostname}:${port}`);
if (hostname) {
console.log(`Listening on ${hostname}:${port}`);
} else {
console.log(`Listening on ${port}`);
}
startMonitors();
checkVersion.startInterval();
});
})();
async function updateMonitorNotification(monitorID, notificationIDList) {
R.exec("DELETE FROM monitor_notification WHERE monitor_id = ? ", [
await R.exec("DELETE FROM monitor_notification WHERE monitor_id = ? ", [
monitorID,
])
@ -570,6 +610,8 @@ async function sendMonitorList(socket) {
}
async function sendNotificationList(socket) {
const timeLogger = new TimeLogger();
let result = [];
let list = await R.find("notification", " user_id = ? ", [
socket.userID,
@ -580,6 +622,9 @@ async function sendNotificationList(socket) {
}
io.to(socket.userID).emit("notificationList", result)
timeLogger.print("Send Notification List");
return list;
}
@ -588,22 +633,27 @@ async function afterLogin(socket, user) {
socket.join(user.id)
let monitorList = await sendMonitorList(socket)
sendNotificationList(socket)
await sleep(500);
for (let monitorID in monitorList) {
sendHeartbeatList(socket, monitorID);
sendImportantHeartbeatList(socket, monitorID);
Monitor.sendStats(io, monitorID, user.id)
await sendHeartbeatList(socket, monitorID);
}
sendNotificationList(socket)
for (let monitorID in monitorList) {
await sendImportantHeartbeatList(socket, monitorID);
}
socket.emit("autoLogin")
for (let monitorID in monitorList) {
await Monitor.sendStats(io, monitorID, user.id)
}
}
async function getMonitorJSONList(userID) {
let result = {};
let monitorList = await R.find("monitor", " user_id = ? ", [
let monitorList = await R.find("monitor", " user_id = ? ORDER BY weight DESC, name", [
userID,
])
@ -627,32 +677,22 @@ async function initDatabase() {
}
console.log("Connecting to Database")
R.setup("sqlite", {
filename: Database.path,
});
await Database.connect();
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",
]);
if (! jwtSecretBean) {
console.log("JWT secret is not found, generate one.")
jwtSecretBean = R.dispense("setting")
jwtSecretBean.key = "jwtSecret"
jwtSecretBean.value = passwordHash.generate(dayjs() + "")
await R.store(jwtSecretBean)
console.log("Stored JWT secret into database")
console.log("JWT secret is not found, generate one.");
jwtSecretBean = await initJWTSecret();
console.log("Stored JWT secret into database");
} else {
console.log("Load JWT secret from database.")
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
@ -712,15 +752,22 @@ async function startMonitors() {
let list = await R.find("monitor", " active = 1 ")
for (let monitor of list) {
monitor.start(io)
monitorList[monitor.id] = monitor;
}
for (let monitor of list) {
monitor.start(io);
// Give some delays, so all monitors won't make request at the same moment when just start the server.
await sleep(getRandomInt(300, 1000));
}
}
/**
* Send Heartbeat History list to socket
*/
async function sendHeartbeatList(socket, monitorID) {
const timeLogger = new TimeLogger();
let list = await R.find("heartbeat", `
monitor_id = ?
ORDER BY time DESC
@ -736,9 +783,13 @@ async function sendHeartbeatList(socket, monitorID) {
}
socket.emit("heartbeatList", monitorID, result)
timeLogger.print(`[Monitor: ${monitorID}] sendHeartbeatList`)
}
async function sendImportantHeartbeatList(socket, monitorID) {
const timeLogger = new TimeLogger();
let list = await R.find("heartbeat", `
monitor_id = ?
AND important = 1
@ -748,6 +799,8 @@ async function sendImportantHeartbeatList(socket, monitorID) {
monitorID,
])
timeLogger.print(`[Monitor: ${monitorID}] sendImportantHeartbeatList`);
socket.emit("importantHeartbeatList", monitorID, list)
}
@ -762,11 +815,10 @@ async function shutdownFunction(signal) {
}
await sleep(2000);
await Database.close();
console.log("Stopped DB")
}
function finalFunction() {
console.log("Graceful Shutdown Done")
console.log("Graceful shutdown successfully!");
}
gracefulShutdown(server, {
@ -777,3 +829,9 @@ gracefulShutdown(server, {
onShutdown: shutdownFunction, // shutdown function (async) - e.g. for cleanup DB, ...
finally: finalFunction, // finally function (sync) - e.g. for logging
});
// Catch unexpected errors here
process.addListener("unhandledRejection", (error, promise) => {
console.trace(error);
console.error("If you keep encountering errors, please report to https://github.com/louislam/uptime-kuma/issues");
});

79
server/util-server.js

@ -2,6 +2,27 @@ const tcpp = require("tcp-ping");
const Ping = require("./ping-lite");
const { R } = require("redbean-node");
const { debug } = require("../src/util");
const passwordHash = require("./password-hash");
const dayjs = require("dayjs");
/**
* Init or reset JWT secret
* @returns {Promise<Bean>}
*/
exports.initJWTSecret = async () => {
let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
"jwtSecret",
]);
if (! jwtSecretBean) {
jwtSecretBean = R.dispense("setting");
jwtSecretBean.key = "jwtSecret";
}
jwtSecretBean.value = passwordHash.generate(dayjs() + "");
await R.store(jwtSecretBean);
return jwtSecretBean;
}
exports.tcping = function (hostname, port) {
return new Promise((resolve, reject) => {
@ -9,7 +30,7 @@ exports.tcping = function (hostname, port) {
address: hostname,
port: port,
attempts: 1,
}, function(err, data) {
}, function (err, data) {
if (err) {
reject(err);
@ -24,15 +45,30 @@ exports.tcping = function (hostname, port) {
});
}
exports.ping = function (hostname) {
exports.ping = async (hostname) => {
try {
return await exports.pingAsync(hostname);
} catch (e) {
// If the host cannot be resolved, try again with ipv6
if (e.message.includes("service not known")) {
return await exports.pingAsync(hostname, true);
} else {
throw e;
}
}
}
exports.pingAsync = function (hostname, ipv6 = false) {
return new Promise((resolve, reject) => {
const ping = new Ping(hostname);
const ping = new Ping(hostname, {
ipv6
});
ping.send(function(err, ms) {
ping.send(function (err, ms, stdout) {
if (err) {
reject(err)
reject(err);
} else if (ms === null) {
reject(new Error("timeout"))
reject(new Error(stdout))
} else {
resolve(Math.round(ms))
}
@ -58,7 +94,7 @@ exports.setSetting = async function (key, value) {
let bean = await R.findOne("setting", " `key` = ? ", [
key,
])
if (! bean) {
if (!bean) {
bean = R.dispense("setting")
bean.key = key;
}
@ -158,3 +194,32 @@ exports.checkCertificate = function (res) {
fingerprint,
};
}
// Check if the provided status code is within the accepted ranges
// Param: status - the status code to check
// Param: accepted_codes - an array of accepted status codes
// Return: true if the status code is within the accepted ranges, false otherwise
// Will throw an error if the provided status code is not a valid range string or code string
exports.checkStatusCode = function (status, accepted_codes) {
if (accepted_codes == null || accepted_codes.length === 0) {
return false;
}
for (const code_range of accepted_codes) {
const code_range_split = code_range.split("-").map(string => parseInt(string));
if (code_range_split.length === 1) {
if (status === code_range_split[0]) {
return true;
}
} else if (code_range_split.length === 2) {
if (status >= code_range_split[0] && status <= code_range_split[1]) {
return true;
}
} else {
throw new Error("Invalid status code range");
}
}
return false;
}

4
src/components/Confirm.vue

@ -4,7 +4,7 @@
<div class="modal-content">
<div class="modal-header">
<h5 id="exampleModalLabel" class="modal-title">
Confirm
{{ $t("Confirm") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
@ -35,7 +35,7 @@ export default {
},
yesText: {
type: String,
default: "Yes",
default: "Yes", // TODO: No idea what to translate this
},
noText: {
type: String,

12
src/components/Datetime.vue

@ -22,15 +22,11 @@ export default {
computed: {
displayText() {
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);
if (this.dateOnly) {
return this.$root.date(this.value);
} else {
return this.$root.datetime(this.value);
}
return "";
},
},
}

39
src/components/HeartbeatBar.vue

@ -7,7 +7,7 @@
class="beat"
:class="{ 'empty' : (beat === 0), 'down' : (beat.status === 0), 'pending' : (beat.status === 2) }"
:style="beatStyle"
:title="beat.msg"
:title="getBeatTitle(beat)"
/>
</div>
</div>
@ -21,7 +21,10 @@ export default {
type: String,
default: "big",
},
monitorId: Number,
monitorId: {
type: Number,
required: true,
},
},
data() {
return {
@ -36,14 +39,15 @@ export default {
computed: {
beatList() {
if (! (this.monitorId in this.$root.heartbeatList)) {
this.$root.heartbeatList[this.monitorId] = [];
}
return this.$root.heartbeatList[this.monitorId]
},
shortBeatList() {
let placeholders = []
if (! this.beatList) {
return [];
}
let placeholders = [];
let start = this.beatList.length - this.maxBeat;
@ -113,6 +117,11 @@ export default {
unmounted() {
window.removeEventListener("resize", this.resize);
},
beforeMount() {
if (! (this.monitorId in this.$root.heartbeatList)) {
this.$root.heartbeatList[this.monitorId] = [];
}
},
mounted() {
if (this.size === "small") {
this.beatWidth = 5.6;
@ -129,11 +138,15 @@ export default {
this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatMargin * 2))
}
},
getBeatTitle(beat) {
return `${this.$root.datetime(beat.time)} - ${beat.msg}`;
}
},
}
</script>
<style scoped lang="scss">
<style lang="scss" scoped>
@import "../assets/vars.scss";
.wrap {
@ -149,7 +162,11 @@ export default {
border-radius: 50rem;
&.empty {
background-color: #d0d3d5;
background-color: aliceblue;
.dark & {
background-color: #d0d3d5;
}
}
&.down {
@ -168,8 +185,10 @@ export default {
}
}
.hp-bar-big .beat.empty{
background-color: #848484;
.dark {
.hp-bar-big .beat.empty {
background-color: #848484;
}
}
</style>

8
src/components/Login.vue

@ -6,12 +6,12 @@
<div class="form-floating">
<input id="floatingInput" v-model="username" type="text" class="form-control" placeholder="Username">
<label for="floatingInput">Username</label>
<label for="floatingInput">{{ $t("Username") }}</label>
</div>
<div class="form-floating mt-3">
<input id="floatingPassword" v-model="password" type="password" class="form-control" placeholder="Password">
<label for="floatingPassword">Password</label>
<label for="floatingPassword">{{ $t("Password") }}</label>
</div>
<div class="form-check mb-3 mt-3 d-flex justify-content-center pe-4">
@ -19,12 +19,12 @@
<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
{{ $t("Remember me") }}
</label>
</div>
</div>
<button class="w-100 btn btn-primary" type="submit" :disabled="processing">
Login
{{ $t("Login") }}
</button>
<div v-if="res && !res.ok" class="alert alert-danger mt-3" role="alert">

133
src/components/MonitorList.vue

@ -0,0 +1,133 @@
<template>
<div class="shadow-box list mb-4">
<div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3">
{{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link>
</div>
<router-link v-for="(item, index) in sortedMonitorList" :key="index" :to="monitorURL(item.id)" class="item" :class="{ 'disabled': ! item.active }">
<div class="row">
<div class="col-6 col-md-8 small-padding" :class="{ 'monitorItem': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none' }">
<div class="info">
<Uptime :monitor="item" type="24" :pill="true" />
{{ item.name }}
</div>
</div>
<div v-show="$root.userHeartbeatBar == 'normal'" :key="$root.userHeartbeatBar" class="col-6 col-md-4">
<HeartbeatBar size="small" :monitor-id="item.id" />
</div>
</div>
<div v-if="$root.userHeartbeatBar == 'bottom'" class="row">
<div class="col-12">
<HeartbeatBar size="small" :monitor-id="item.id" />
</div>
</div>
</router-link>
</div>
</template>
<script>
import HeartbeatBar from "../components/HeartbeatBar.vue";
import Uptime from "../components/Uptime.vue";
export default {
components: {
Uptime,
HeartbeatBar,
},
computed: {
sortedMonitorList() {
let result = Object.values(this.$root.monitorList);
result.sort((m1, m2) => {
if (m1.active !== m2.active) {
if (m1.active === 0) {
return 1;
}
if (m2.active === 0) {
return -1;
}
}
if (m1.weight !== m2.weight) {
if (m1.weight > m2.weight) {
return -1;
}
if (m1.weight < m2.weight) {
return 1;
}
}
return m1.name.localeCompare(m2.name);
})
return result;
},
},
methods: {
monitorURL(id) {
return "/dashboard/" + id;
},
},
}
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.small-padding {
padding-left: 5px !important;
padding-right: 5px !important;
}
.list {
height: auto;
min-height: calc(100vh - 240px);
.item {
display: block;
text-decoration: none;
padding: 13px 15px 10px 15px;
border-radius: 10px;
transition: all ease-in-out 0.15s;
&.disabled {
opacity: 0.3;
}
.info {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
background-color: $highlight-white;
}
&.active {
background-color: #cdf8f4;
}
}
}
.dark {
.list {
.item {
&:hover {
background-color: $dark-bg2;
}
&.active {
background-color: $dark-bg2;
}
}
}
}
.monitorItem {
width: 100%;
}
</style>

142
src/components/NotificationDialog.vue

@ -5,30 +5,32 @@
<div class="modal-content">
<div class="modal-header">
<h5 id="exampleModalLabel" class="modal-title">
Setup Notification
{{ $t("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>
<label for="type" class="form-label">{{ $t("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="smtp">{{ $t("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="pushy">Pushy</option>
<option value="octopush">Octopush</option>
<option value="lunasea">LunaSea</option>
<option value="apprise">Apprise (Support 50+ Notification services)</option>
<option value="pushbullet">Pushbullet</option>
<option value="line">Line Messenger</option>
</select>
</div>
<div class="mb-3">
<label for="name" class="form-label">Friendly Name</label>
<label for="name" class="form-label">{{ $t("Friendly Name") }}</label>
<input id="name" v-model="notification.name" type="text" class="form-control" required>
</div>
@ -147,6 +149,11 @@
You can get this by going to Server Settings -> Integrations -> Create Webhook
</div>
</div>
<div class="mb-3">
<label for="discord-username" class="form-label">Bot Display Name</label>
<input id="discord-username" v-model="notification.discordUsername" type="text" class="form-control" autocomplete="false" :placeholder="$root.appName">
</div>
</template>
<template v-if="notification.type === 'signal'">
@ -202,7 +209,7 @@
<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>
<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">
@ -213,7 +220,7 @@
<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
<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>
@ -233,25 +240,56 @@
<template v-if="notification.type === 'pushy'">
<div class="mb-3">
<label for="pushy-app-token" class="form-label">API_KEY</label>
<input type="text" class="form-control" id="pushy-app-token" required v-model="notification.pushyAPIKey">
<input id="pushy-app-token" v-model="notification.pushyAPIKey" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="pushy-user-key" class="form-label">USER_TOKEN</label>
<div class="input-group mb-3">
<input type="text" class="form-control" id="pushy-user-key" required v-model="notification.pushyToken">
<input id="pushy-user-key" v-model="notification.pushyToken" type="text" class="form-control" required>
</div>
</div>
<p style="margin-top: 8px;">
More info on: <a href="https://pushy.me/docs/api/send-notifications" target="_blank">https://pushy.me/docs/api/send-notifications</a>
</p>
</template>
<template v-if="notification.type === 'octopush'">
<div class="mb-3">
<label for="octopush-key" class="form-label">API KEY</label>
<input id="octopush-key" v-model="notification.octopushAPIKey" type="text" class="form-control" required>
<label for="octopush-login" class="form-label">API LOGIN</label>
<input id="octopush-login" v-model="notification.octopushLogin" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="octopush-type-sms" class="form-label">SMS Type</label>
<select id="octopush-type-sms" v-model="notification.octopushSMSType" class="form-select">
<option value="sms_premium">Premium (Fast - recommended for alerting)</option>
<option value="sms_low_cost">Low Cost (Slow, sometimes blocked by operator)</option>
</select>
<div class="form-text">
Check octopush prices <a href="https://octopush.com/tarifs-sms-international/" target="_blank">https://octopush.com/tarifs-sms-international/</a>.
</div>
</div>
<div class="mb-3">
<label for="octopush-phone-number" class="form-label">Phone number (intl format, eg : +33612345678) </label>
<input id="octopush-phone-number" v-model="notification.octopushPhoneNumber" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="octopush-sender-name" class="form-label">SMS Sender Name : 3-11 alphanumeric characters and space (a-zA-Z0-9)</label>
<input id="octopush-sender-name" v-model="notification.octopushSenderName" type="text" minlength="3" maxlength="11" class="form-control">
</div>
<p style="margin-top: 8px;">
More info on: <a href="https://pushy.me/docs/api/send-notifications" target="_blank">https://pushy.me/docs/api/send-notifications</a>
More info on: <a href="https://octopush.com/api-sms-documentation/envoi-de-sms/" target="_blank">https://octopush.com/api-sms-documentation/envoi-de-sms/</a>
</p>
</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>
<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>
<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">
@ -291,7 +329,7 @@
<option>none</option>
</select>
<div class="form-text">
<span style="color:red;"><sup>*</sup></span>Required
<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>
@ -305,46 +343,56 @@
</div>
</template>
<template v-if="notification.type === 'apprise'">
<template v-if="notification.type === 'lunasea'">
<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>
<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>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>
<p><span style="color: red;"><sup>*</sup></span>Required</p>
</div>
</div>
</template>
<template v-if="notification.type === 'pushbullet'">
<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>
<label for="pushbullet-access-token" class="form-label">Access Token</label>
<input id="pushbullet-access-token" v-model="notification.pushbulletAccessToken" type="text" class="form-control" required>
</div>
<p style="margin-top: 8px;">
More info on: <a href="https://docs.pushbullet.com" target="_blank">https://docs.pushbullet.com</a>
</p>
</template>
<template v-if="notification.type === 'lunasea'">
<template v-if="notification.type === 'line'">
<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>
<label for="line-channel-access-token" class="form-label">Channel access token</label>
<input id="line-channel-access-token" v-model="notification.lineChannelAccessToken" type="text" class="form-control" required>
</div>
<div class="form-text">
Line Developers Console - <b>Basic Settings</b>
</div>
<div class="mb-3" style="margin-top: 12px;">
<label for="line-user-id" class="form-label">User ID</label>
<input id="line-user-id" v-model="notification.lineUserID" type="text" class="form-control" required>
</div>
<div class="form-text">
Line Developers Console - <b>Messaging API</b>
</div>
<div class="form-text" style="margin-top: 8px;">
First access the <a href="https://developers.line.biz/console/" target="_blank">Line Developers Console</a>, create a provider and channel (Messaging API), then you can get the channel access token and user id from the above mentioned menu items.
</div>
</template>
</div>
<div class="modal-footer">
<button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
Delete
{{ $t("Delete") }}
</button>
<button type="button" class="btn btn-warning" :disabled="processing" @click="test">
Test
{{ $t("Test") }}
</button>
<button type="submit" class="btn btn-primary" :disabled="processing">
Save
{{ $t("Save") }}
</button>
</div>
</div>
@ -380,7 +428,6 @@ export default {
type: null,
gotifyPriority: 8,
},
appriseInstalled: false,
}
},
computed: {
@ -411,10 +458,6 @@ export default {
},
mounted() {
this.modal = new Modal(this.$refs.modal)
this.$root.getSocket().emit("checkApprise", (installed) => {
this.appriseInstalled = installed;
})
},
methods: {
@ -509,17 +552,12 @@ export default {
}
</script>
<style>
.modal-dialog .form-text, .modal-dialog p{
color: var(--main-font-color);
}
.modal-content{
border: 1px solid var(--main-font-color);
}
@media (prefers-color-scheme: dark) {
.btn-close{
background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23FFF'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;
opacity: 1;
<style lang="scss" scoped>
@import "../assets/vars.scss";
.dark {
.modal-dialog .form-text, .modal-dialog p {
color: $dark-font-color;
}
}
</style>
</style>

172
src/components/PingChart.vue

@ -0,0 +1,172 @@
<template>
<LineChart :chart-data="chartData" :options="chartOptions" />
</template>
<script>
import { BarController, BarElement, Chart, Filler, LinearScale, LineController, LineElement, PointElement, TimeScale, Tooltip } from "chart.js";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import "chartjs-adapter-dayjs";
import { LineChart } from "vue-chart-3";
dayjs.extend(utc);
dayjs.extend(timezone);
Chart.register(LineController, BarController, LineElement, PointElement, TimeScale, BarElement, LinearScale, Tooltip, Filler);
export default {
components: { LineChart },
props: {
monitorId: {
type: Number,
required: true,
},
},
data() {
return {
chartPeriodHrs: 6,
};
},
computed: {
chartOptions() {
return {
responsive: true,
maintainAspectRatio: false,
onResize: (chart) => {
chart.canvas.parentNode.style.position = "relative";
if (screen.width < 576) {
chart.canvas.parentNode.style.height = "275px";
} else if (screen.width < 768) {
chart.canvas.parentNode.style.height = "320px";
} else if (screen.width < 992) {
chart.canvas.parentNode.style.height = "300px";
} else {
chart.canvas.parentNode.style.height = "250px";
}
},
layout: {
padding: {
left: 10,
right: 30,
top: 30,
bottom: 10,
},
},
elements: {
point: {
radius: 0,
},
bar: {
barThickness: "flex",
}
},
scales: {
x: {
type: "time",
time: {
minUnit: "minute",
round: "second",
tooltipFormat: "YYYY-MM-DD HH:mm:ss",
displayFormats: {
minute: "HH:mm",
hour: "MM-DD HH:mm",
}
},
ticks: {
maxRotation: 0,
autoSkipPadding: 30,
},
bounds: "ticks",
grid: {
color: this.$root.theme === "light" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.1)",
},
},
y: {
title: {
display: true,
text: "Resp. Time (ms)",
},
offset: false,
grid: {
color: this.$root.theme === "light" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.1)",
},
},
y1: {
display: false,
position: "right",
grid: {
drawOnChartArea: false,
},
min: 0,
max: 1,
offset: false,
},
},
bounds: "ticks",
plugins: {
tooltip: {
mode: "nearest",
intersect: false,
padding: 10,
filter: function (tooltipItem) {
return tooltipItem.datasetIndex === 0;
},
callbacks: {
label: (context) => {
return ` ${new Intl.NumberFormat().format(context.parsed.y)} ms`
},
}
},
legend: {
display: false,
},
},
}
},
chartData() {
let ping_data = [];
let down_data = [];
if (this.monitorId in this.$root.heartbeatList) {
ping_data = this.$root.heartbeatList[this.monitorId]
.filter(
(beat) => dayjs.utc(beat.time).tz(this.$root.timezone).isAfter(dayjs().subtract(this.chartPeriodHrs, "hours")))
.map((beat) => {
return {
x: dayjs.utc(beat.time).tz(this.$root.timezone).format("YYYY-MM-DD HH:mm:ss"),
y: beat.ping,
};
});
down_data = this.$root.heartbeatList[this.monitorId]
.filter(
(beat) => dayjs.utc(beat.time).tz(this.$root.timezone).isAfter(dayjs().subtract(this.chartPeriodHrs, "hours")))
.map((beat) => {
return {
x: dayjs.utc(beat.time).tz(this.$root.timezone).format("YYYY-MM-DD HH:mm:ss"),
y: beat.status === 0 ? 1 : 0,
};
});
}
return {
datasets: [
{
data: ping_data,
fill: "origin",
tension: 0.2,
borderColor: "#5CDD8B",
backgroundColor: "#5CDD8B38",
yAxisID: "y",
},
{
type: "bar",
data: down_data,
borderColor: "#00000000",
backgroundColor: "#DC354568",
yAxisID: "y1",
},
],
};
},
},
};
</script>

8
src/components/Status.vue

@ -27,18 +27,18 @@ export default {
text() {
if (this.status === 0) {
return "Down"
return this.$t("Down");
}
if (this.status === 1) {
return "Up"
return this.$t("Up");
}
if (this.status === 2) {
return "Pending"
return this.$t("Pending");
}
return "Unknown"
return this.$t("Unknown");
},
},
}

4
src/icon.js

@ -1,10 +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 { faCog, faEdit, faPlus, faPause, faPlay, faTachometerAlt, faTrash, faList, faArrowAltCircleUp } 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)
library.add(faCog, faEdit, faPlus, faPause, faPlay, faTachometerAlt, faTrash, faList, faArrowAltCircleUp);
export { FontAwesomeIcon }

12
src/languages/en.js

@ -0,0 +1,12 @@
export default {
languageName: "English",
checkEverySecond: "Check every {0} seconds.",
"Avg.": "Avg. ",
retriesDescription: "Maximum retries before the service is marked as down and a notification is sent",
ignoreTLSError: "Ignore TLS/SSL error for HTTPS websites",
upsideDownModeDescription: "Flip the status upside down. If the service is reachable, it is DOWN.",
maxRedirectDescription: "Maximum number of redirects to follow. Set to 0 to disable redirects.",
acceptedStatusCodesDescription: "Select status codes which are considered as a successful response.",
passwordNotMatchMsg: "The repeat password does not match.",
notificationDescription: "Please assign a notification to monitor(s) to get it to work.",
}

97
src/languages/zh-HK.js

@ -0,0 +1,97 @@
export default {
languageName: "繁體中文 (香港)",
Settings: "設定",
Dashboard: "錶板",
"New Update": "有更新",
Language: "語言",
Appearance: "外觀",
Theme: "主題",
General: "一般",
Version: "版本",
"Check Update On GitHub": "到 Github 查看更新",
List: "列表",
Add: "新增",
"Add New Monitor": "新增監測器",
"Quick Stats": "綜合數據",
Up: "上線",
Down: "離線",
Pending: "待定",
Unknown: "不明",
Pause: "暫停",
Name: "名稱",
Status: "狀態",
DateTime: "日期時間",
Message: "內容",
"No important events": "沒有重要事件",
Resume: "恢復",
Edit: "編輯",
Delete: "刪除",
Current: "目前",
Uptime: "上線率",
"Cert Exp.": "証書期限",
days: "日",
day: "日",
"-day": "日",
hour: "小時",
"-hour": "小時",
checkEverySecond: "每 {0} 秒檢查一次",
"Avg.": "平均",
Response: "反應時間",
Ping: "反應時間",
"Monitor Type": "監測器類型",
Keyword: "關鍵字",
"Friendly Name": "名稱",
URL: "網址 URL",
Hostname: "Hostname",
Port: "Port",
"Heartbeat Interval": "檢查間距",
Retries: "重試數次確定為離線",
retriesDescription: "重試多少次後才判定為離線及傳送通知。如數值為 0 會即判定為離線及傳送通知。",
Advanced: "進階",
ignoreTLSError: "忽略 TLS/SSL 錯誤",
"Upside Down Mode": "反轉模式",
upsideDownModeDescription: "反轉狀態,如網址是可正常瀏覽,會被判定為 '離線/DOWN'",
"Max. Redirects": "跟隨重新導向 (Redirect) 的次數",
maxRedirectDescription: "設為 0 即不跟蹤",
"Accepted Status Codes": "接受為上線的 HTTP 狀態碼",
acceptedStatusCodesDescription: "可多選",
Save: "儲存",
Notifications: "通知",
"Not available, please setup.": "無法使用,需要設定",
"Setup Notification": "設定通知",
Light: "明亮",
Dark: "暗黑",
Auto: "自動",
"Theme - Heartbeat Bar": "監測器列表 狀態條外觀",
Normal: "一般",
Bottom: "下方",
None: "沒有",
Timezone: "時區",
"Search Engine Visibility": "是否允許搜尋器索引",
"Allow indexing": "允許索引",
"Discourage search engines from indexing site": "不建議搜尋器索引",
"Change Password": "變更密碼",
"Current Password": "目前密碼",
"New Password": "新密碼",
"Repeat New Password": "確認新密碼",
passwordNotMatchMsg: "密碼不一致",
"Update Password": "更新密碼",
"Disable Auth": "取消登入認証",
"Enable Auth": "開啟登入認証",
Logout: "登出",
notificationDescription: "新增後,你需要在監測器裡啟用。",
Leave: "離開",
"I understand, please disable": "我明白,請取消登入認証",
Confirm: "確認",
Yes: "是",
No: "否",
Username: "帳號",
Password: "密碼",
"Remember me": "記住我",
Login: "登入",
"No Monitors, please": "沒有監測器,請",
"add one": "新增",
"Notification Type": "通知類型",
"Email": "電郵",
"Test": "測試",
}

207
src/layouts/Layout.vue

@ -1,109 +1,140 @@
<template>
<div v-if="! $root.socket.connected && ! $root.socket.firstConnect" class="lost-connection">
<div class="container-fluid">
{{ $root.connectionErrorMsg }}
<div :class="classes">
<div v-if="! $root.socket.connected && ! $root.socket.firstConnect" class="lost-connection">
<div class="container-fluid">
{{ $root.connectionErrorMsg }}
</div>
</div>
</div>
<!-- Desktop header -->
<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-decoration-none">
<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">
<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 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-decoration-none">
<object class="bi" width="40" height="40" data="/icon.svg" />
<span class="fs-4 title ms-2">Uptime Kuma</span>
</router-link>
</header>
<main>
<!-- Add :key to disable vue router re-use the same component -->
<router-view v-if="$root.loggedIn" :key="$route.fullPath" />
<Login v-if="! $root.loggedIn && $root.allowLoginDialog" />
</main>
<footer>
<div class="container-fluid">
Uptime Kuma -
Version: {{ $root.info.version }} -
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">Check Update On GitHub</a>
</div>
</footer>
<!-- Mobile Only -->
<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>
<!-- Desktop header -->
<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" />
<span class="fs-4 title">Uptime Kuma</span>
</router-link>
<a v-if="hasNewVersion" target="_blank" href="https://github.com/louislam/uptime-kuma/releases" class="btn btn-info me-3">
<font-awesome-icon icon="arrow-alt-circle-up" /> {{ $t("New Update") }}
</a>
<ul class="nav nav-pills">
<li class="nav-item">
<router-link to="/dashboard" class="nav-link">
<font-awesome-icon icon="tachometer-alt" /> {{ $t("Dashboard") }}
</router-link>
</li>
<li class="nav-item">
<router-link to="/settings" class="nav-link">
<font-awesome-icon icon="cog" /> {{ $t("Settings") }}
</router-link>
</li>
</ul>
</header>
<!-- Mobile header -->
<header v-else class="d-flex flex-wrap justify-content-center pt-2 pb-2 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" />
<span class="fs-4 title ms-2">Uptime Kuma</span>
</router-link>
</header>
<main>
<!-- Add :key to disable vue router re-use the same component -->
<router-view v-if="$root.loggedIn" :key="$route.fullPath" />
<Login v-if="! $root.loggedIn && $root.allowLoginDialog" />
</main>
<footer>
<div class="container-fluid">
Uptime Kuma -
{{ $t("Version") }}: {{ $root.info.version }} -
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a>
</div>
</footer>
<!-- Mobile Only -->
<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">
<div><font-awesome-icon icon="tachometer-alt" /></div>
{{ $t("Dashboard") }}
</router-link>
<router-link to="/list" class="nav-link">
<div><font-awesome-icon icon="list" /></div>
{{ $t("List") }}
</router-link>
<router-link to="/add" class="nav-link">
<div><font-awesome-icon icon="plus" /></div>
{{ $t("Add") }}
</router-link>
<router-link to="/settings" class="nav-link">
<div><font-awesome-icon icon="cog" /></div>
{{ $t("Settings") }}
</router-link>
</nav>
</div>
</template>
<script>
import Login from "../components/Login.vue";
import compareVersions from "compare-versions";
export default {
components: {
Login,
},
data() {
return {}
},
computed: {},
computed: {
// Theme or Mobile
classes() {
const classes = {};
classes[this.$root.theme] = true;
classes["mobile"] = this.$root.isMobile;
return classes;
},
hasNewVersion() {
if (this.$root.info.latestVersion && this.$root.info.version) {
return compareVersions(this.$root.info.latestVersion, this.$root.info.version) >= 1;
} else {
return false;
}
},
},
watch: {
$route (to, from) {
$route(to, from) {
this.init();
},
},
mounted() {
this.init();
},
methods: {
init() {
if (this.$route.name === "root") {
this.$router.push("/dashboard")
}
},
},
}
</script>
<style scoped lang="scss">
<style lang="scss" scoped>
@import "../assets/vars.scss";
.bottom-nav {
@ -113,7 +144,7 @@ export default {
height: 60px;
width: 100%;
left: 0;
background-color: var(--background-navbar);
background-color: #fff;
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;
@ -141,6 +172,10 @@ export default {
}
}
main {
min-height: calc(100vh - 160px);
}
.title {
font-weight: bold;
}
@ -156,12 +191,30 @@ export default {
}
footer {
color: #AAA;
font-size: 13px;
margin-top: 10px;
margin-bottom: 30px;
padding-bottom: 30px;
margin-left: 10px;
text-align: center;
}
footer {
color: #aaa;
}
.dark {
header {
background-color: #161b22;
border-bottom-color: #161b22 !important;
span {
color: #f0f6fc;
}
}
.bottom-nav {
background-color: $dark-bg;
}
}
</style>

38
src/main.js

@ -1,5 +1,6 @@
import "bootstrap";
import { createApp, h } from "vue";
import { createI18n } from "vue-i18n"
import { createRouter, createWebHistory } from "vue-router";
import Toast from "vue-toastification";
import "vue-toastification/dist/index.css";
@ -9,12 +10,21 @@ import { FontAwesomeIcon } from "./icon.js";
import EmptyLayout from "./layouts/EmptyLayout.vue";
import Layout from "./layouts/Layout.vue";
import socket from "./mixins/socket";
import theme from "./mixins/theme";
import mobile from "./mixins/mobile";
import datetime from "./mixins/datetime";
import Dashboard from "./pages/Dashboard.vue";
import DashboardHome from "./pages/DashboardHome.vue";
import Details from "./pages/Details.vue";
import EditMonitor from "./pages/EditMonitor.vue";
import Settings from "./pages/Settings.vue";
import Setup from "./pages/Setup.vue";
import List from "./pages/List.vue";
import { appName } from "./util.ts";
import en from "./languages/en";
import zhHK from "./languages/zh-HK";
const routes = [
{
@ -49,6 +59,10 @@ const routes = [
path: "/add",
component: EditMonitor,
},
{
path: "/list",
component: List,
},
],
},
{
@ -73,14 +87,36 @@ const router = createRouter({
routes,
})
const languageList = {
en,
"zh-HK": zhHK,
};
const i18n = createI18n({
locale: localStorage.locale || "en",
fallbackLocale: "en",
silentFallbackWarn: true,
silentTranslationWarn: true,
messages: languageList
});
const app = createApp({
mixins: [
socket,
theme,
mobile,
datetime
],
data() {
return {
appName: appName
}
},
render: () => h(App),
})
app.use(router)
app.use(router);
app.use(i18n);
const options = {
position: "bottom-right",

57
src/mixins/datetime.js

@ -0,0 +1,57 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
/**
* DateTime Mixin
* Handled timezone and format
*/
export default {
data() {
return {
userTimezone: localStorage.timezone || "auto",
}
},
methods: {
datetime(value) {
return this.datetimeFormat(value, "YYYY-MM-DD HH:mm:ss");
},
date(value) {
return this.datetimeFormat(value, "YYYY-MM-DD");
},
time(value, second = true) {
let secondString;
if (second) {
secondString = ":ss";
} else {
secondString = "";
}
return this.datetimeFormat(value, "HH:mm" + secondString);
},
datetimeFormat(value, format) {
if (value !== undefined && value !== "") {
return dayjs.utc(value).tz(this.timezone).format(format);
}
return "";
}
},
computed: {
timezone() {
if (this.userTimezone === "auto") {
return dayjs.tz.guess()
}
return this.userTimezone
},
}
}

25
src/mixins/mobile.js

@ -0,0 +1,25 @@
export default {
data() {
return {
windowWidth: window.innerWidth,
}
},
created() {
window.addEventListener("resize", this.onResize);
},
methods: {
onResize() {
this.windowWidth = window.innerWidth;
},
},
computed: {
isMobile() {
return this.windowWidth <= 767.98;
},
}
}

27
src/mixins/socket.js

@ -1,4 +1,3 @@
import dayjs from "dayjs";
import { io } from "socket.io-client";
import { useToast } from "vue-toastification";
const toast = useToast()
@ -17,7 +16,6 @@ export default {
connectCount: 0,
},
remember: (localStorage.remember !== "0"),
userTimezone: localStorage.timezone || "auto",
allowLoginDialog: false, // Allowed to show login dialog, but "loggedIn" have to be true too. This exists because prevent the login dialog show 0.1s in first before the socket server auth-ed.
loggedIn: false,
monitorList: { },
@ -27,9 +25,7 @@ export default {
uptimeList: { },
certInfoList: {},
notificationList: [],
windowWidth: window.innerWidth,
showListMobile: false,
connectionErrorMsg: "Cannot connect to the socket server. Reconnecting..."
connectionErrorMsg: "Cannot connect to the socket server. Reconnecting...",
}
},
@ -189,14 +185,6 @@ export default {
methods: {
cancelActiveList() {
this.$root.showListMobile = false;
},
onResize() {
this.windowWidth = window.innerWidth;
},
storage() {
return (this.remember) ? localStorage : sessionStorage;
},
@ -270,19 +258,6 @@ export default {
computed: {
isMobile() {
return this.windowWidth <= 767.98;
},
timezone() {
if (this.userTimezone === "auto") {
return dayjs.tz.guess()
}
return this.userTimezone
},
lastHeartbeatList() {
let result = {}

66
src/mixins/theme.js

@ -0,0 +1,66 @@
export default {
data() {
return {
system: (window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light",
userTheme: localStorage.theme,
userHeartbeatBar: localStorage.heartbeatBarTheme,
};
},
mounted() {
// Default Light
if (! this.userTheme) {
this.userTheme = "light";
}
// Default Heartbeat Bar
if (!this.userHeartbeatBar) {
this.userHeartbeatBar = "normal";
}
document.body.classList.add(this.theme);
this.updateThemeColorMeta();
},
computed: {
theme() {
if (this.userTheme === "auto") {
return this.system;
}
return this.userTheme;
}
},
watch: {
userTheme(to, from) {
localStorage.theme = to;
},
theme(to, from) {
document.body.classList.remove(from);
document.body.classList.add(this.theme);
this.updateThemeColorMeta();
},
userHeartbeatBar(to, from) {
localStorage.heartbeatBarTheme = to;
},
heartbeatBarTheme(to, from) {
document.body.classList.remove(from);
document.body.classList.add(this.heartbeatBarTheme);
}
},
methods: {
updateThemeColorMeta() {
if (this.theme === "dark") {
document.querySelector("#theme-color").setAttribute("content", "#161B22");
} else {
document.querySelector("#theme-color").setAttribute("content", "#5cdd8b");
}
}
}
}

117
src/pages/Dashboard.vue

@ -1,31 +1,13 @@
<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"><font-awesome-icon icon="plus" /> Add New Monitor</router-link>
</div>
<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 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="info">
<Uptime :monitor="item" type="24" :pill="true" />
{{ item.name }}
</div>
</div>
<div class="col-6 col-md-4">
<HeartbeatBar size="small" :monitor-id="item.id" />
</div>
</div>
</router-link>
<div v-if="! $root.isMobile" class="col-12 col-md-5 col-xl-4">
<div>
<router-link to="/add" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> {{ $t("Add New Monitor") }}</router-link>
</div>
<MonitorList />
</div>
<div class="col-12 col-md-7 col-xl-8">
<router-view />
</div>
@ -35,102 +17,21 @@
<script>
import HeartbeatBar from "../components/HeartbeatBar.vue";
import Uptime from "../components/Uptime.vue";
import MonitorList from "../components/MonitorList.vue";
export default {
components: {
Uptime,
HeartbeatBar,
MonitorList,
},
data() {
return {}
},
computed: {
sortedMonitorList() {
let result = Object.values(this.$root.monitorList);
result.sort((m1, m2) => {
if (m1.active !== m2.active) {
if (m1.active === 0) {
return 1;
}
if (m2.active === 0) {
return -1;
}
}
if (m1.weight !== m2.weight) {
if (m1.weight > m2.weight) {
return -1;
}
if (m1.weight < m2.weight) {
return 1;
}
}
return m1.name.localeCompare(m2.name);
})
return result;
},
showList() {
return ! this.$root.isMobile || this.$root.showListMobile;
},
},
methods: {
monitorURL(id) {
return "/dashboard/" + id;
},
},
}
</script>
<style scoped lang="scss">
@import "../assets/vars.scss";
<style lang="scss" scoped>
.container-fluid {
width: 98%
width: 98%;
}
.list {
margin-top: 25px;
height: auto;
min-height: calc(100vh - 200px);
.item {
display: block;
text-decoration: none;
padding: 15px 15px 12px 15px;
border-radius: 10px;
transition: all ease-in-out 0.15s;
&.disabled {
opacity: 0.3;
}
.info {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
background-color: var(--background-4);
}
&.active {
background-color: var(--background-4);
}
}
}
.small-padding {
padding-left: 5px !important;
padding-right: 5px !important;
}
</style>

130
src/pages/DashboardHome.vue

@ -1,78 +1,67 @@
<template>
<div v-if="$route.name === 'DashboardHome'">
<h1 class="mb-3">
Quick Stats
</h1>
<div class="shadow-box big-padding text-center">
<div class="row">
<div class="col">
<h3>Up</h3>
<span class="num">{{ stats.up }}</span>
</div>
<div class="col">
<h3>Down</h3>
<span class="num text-danger">{{ stats.down }}</span>
</div>
<div class="col">
<h3>Unknown</h3>
<span class="num text-secondary">{{ stats.unknown }}</span>
</div>
<div class="col">
<h3>Pause</h3>
<span class="num text-secondary">{{ stats.pause }}</span>
<transition name="slide-fade" appear>
<div v-if="$route.name === 'DashboardHome'">
<h1 class="mb-3">
{{ $t("Quick Stats") }}
</h1>
<div class="shadow-box big-padding text-center">
<div class="row">
<div class="col">
<h3>{{ $t("Up") }}</h3>
<span class="num">{{ stats.up }}</span>
</div>
<div class="col">
<h3>{{ $t("Down") }}</h3>
<span class="num text-danger">{{ stats.down }}</span>
</div>
<div class="col">
<h3>{{ $t("Unknown") }}</h3>
<span class="num text-secondary">{{ stats.unknown }}</span>
</div>
<div class="col">
<h3>{{ $t("Pause") }}</h3>
<span class="num text-secondary">{{ stats.pause }}</span>
</div>
</div>
</div>
<div v-if="false" class="row">
<div class="col-3">
<h3>Uptime</h3>
<p>(24-hour)</p>
<span class="num" />
</div>
<div class="col-3">
<h3>Uptime</h3>
<p>(30-day)</p>
<span class="num" />
</div>
</div>
</div>
<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>
</thead>
<tbody>
<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 class="shadow-box table-shadow-box" style="overflow-x: scroll;">
<table class="table table-borderless table-hover">
<thead>
<tr>
<th>{{ $t("Name") }}</th>
<th>{{ $t("Status") }}</th>
<th>{{ $t("DateTime") }}</th>
<th>{{ $t("Message") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(beat, index) in displayedRecords" :key="index" :class="{ 'shadow-box': $root.windowWidth <= 550}">
<td>{{ beat.name }}</td>
<td><Status :status="beat.status" /></td>
<td :class="{ 'border-0':! beat.msg}"><Datetime :value="beat.time" /></td>
<td class="border-0">{{ beat.msg }}</td>
</tr>
<tr v-if="importantHeartBeatList.length === 0">
<td colspan="4">
{{ $t("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>
</div>
</transition>
<router-view ref="child" />
</template>
@ -169,7 +158,7 @@ export default {
}
</script>
<style scoped lang="scss">
<style lang="scss" scoped>
@import "../assets/vars";
.num {
@ -181,6 +170,7 @@ export default {
.shadow-box {
padding: 20px;
margin-top: 25px;
}
table {

352
src/pages/Details.vue

@ -1,160 +1,179 @@
<template>
<h1> {{ monitor.name }}</h1>
<p class="url">
<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>
<span>Keyword:</span> <span style="color: black">{{ monitor.keyword }}</span>
</span>
</p>
<div class="functions">
<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">
<div class="row">
<div class="col-md-8">
<HeartbeatBar :monitor-id="monitor.id" />
<span class="word">Check every {{ monitor.interval }} seconds.</span>
</div>
<div class="col-md-4 text-center">
<span class="badge rounded-pill" :class=" 'bg-' + status.color " style="font-size: 30px">{{ status.text }}</span>
</div>
</div>
</div>
<div class="shadow-box big-padding text-center stats">
<div class="row">
<div class="col">
<h4>{{ pingTitle }}</h4>
<p>(Current)</p>
<span class="num"><CountUp :value="ping" /></span>
</div>
<div class="col">
<h4>Avg. {{ pingTitle }}</h4>
<p>(24-hour)</p>
<span class="num"><CountUp :value="avgPing" /></span>
<transition name="slide-fade" appear>
<div v-if="monitor">
<h1> {{ monitor.name }}</h1>
<p class="url">
<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>
<span>Keyword:</span> <span class="keyword">{{ monitor.keyword }}</span>
</span>
</p>
<div class="functions">
<button v-if="monitor.active" class="btn btn-light" @click="pauseDialog">
<font-awesome-icon icon="pause" /> {{ $t("Pause") }}
</button>
<button v-if="! monitor.active" class="btn btn-primary" @click="resumeMonitor">
<font-awesome-icon icon="play" /> {{ $t("Resume") }}
</button>
<router-link :to=" '/edit/' + monitor.id " class="btn btn-secondary">
<font-awesome-icon icon="edit" /> {{ $t("Edit") }}
</router-link>
<button class="btn btn-danger" @click="deleteDialog">
<font-awesome-icon icon="trash" /> {{ $t("Delete") }}
</button>
</div>
<div class="col">
<h4>Uptime</h4>
<p>(24-hour)</p>
<span class="num"><Uptime :monitor="monitor" type="24" /></span>
<div class="shadow-box">
<div class="row">
<div class="col-md-8">
<HeartbeatBar :monitor-id="monitor.id" />
<span class="word">{{ $t("checkEverySecond", [ monitor.interval ]) }}</span>
</div>
<div class="col-md-4 text-center">
<span class="badge rounded-pill" :class=" 'bg-' + status.color " style="font-size: 30px;">{{ status.text }}</span>
</div>
</div>
</div>
<div class="col">
<h4>Uptime</h4>
<p>(30-day)</p>
<span class="num"><Uptime :monitor="monitor" type="720" /></span>
<div class="shadow-box big-padding text-center stats">
<div class="row">
<div class="col">
<h4>{{ pingTitle }}</h4>
<p>({{ $t("Current") }})</p>
<span class="num">
<a href="#" @click.prevent="showPingChartBox = !showPingChartBox">
<CountUp :value="ping" />
</a>
</span>
</div>
<div class="col">
<h4>{{ $t("Avg.") }}{{ pingTitle }}</h4>
<p>(24{{ $t("-hour") }})</p>
<span class="num"><CountUp :value="avgPing" /></span>
</div>
<div class="col">
<h4>{{ $t("Uptime") }}</h4>
<p>(24{{ $t("-hour") }})</p>
<span class="num"><Uptime :monitor="monitor" type="24" /></span>
</div>
<div class="col">
<h4>{{ $t("Uptime") }}</h4>
<p>(30{{ $t("-day") }})</p>
<span class="num"><Uptime :monitor="monitor" type="720" /></span>
</div>
<div v-if="certInfo" class="col">
<h4>{{ $t("Cert Exp.") }}</h4>
<p>(<Datetime :value="certInfo.validTo" date-only />)</p>
<span class="num">
<a href="#" @click.prevent="toggleCertInfoBox = !toggleCertInfoBox">{{ certInfo.daysRemaining }} {{ $t("days") }}</a>
</span>
</div>
</div>
</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>
<transition name="slide-fade" appear>
<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>
</transition>
<div v-if="showPingChartBox" class="shadow-box big-padding text-center ping-chart-wrapper">
<div class="row">
<div class="col">
<PingChart :monitor-id="monitor.id" />
</div>
</div>
</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>
<div class="shadow-box table-shadow-box">
<table class="table table-borderless table-hover">
<thead>
<tr>
<th>{{ $t("Status") }}</th>
<th>{{ $t("DateTime") }}</th>
<th>{{ $t("Message") }}</th>
</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>
</thead>
<tbody>
<tr v-for="(beat, index) in displayedRecords" :key="index" :class="{ 'shadow-box': $root.windowWidth <= 550}" style="padding: 10px;">
<td><Status :status="beat.status" /></td>
<td :class="{ 'border-0':! beat.msg}"><Datetime :value="beat.time" /></td>
<td class="border-0">{{ beat.msg }}</td>
</tr>
<tr class="my-3">
<td class="px-3">
Fingerprint:
<tr v-if="importantHeartBeatList.length === 0">
<td colspan="3">
{{ $t("No important events") }}
</td>
<td>{{ certInfo.fingerprint }}</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>
</div>
<div class="shadow-box">
<table class="table table-borderless table-hover">
<thead>
<tr>
<th>Status</th>
<th>DateTime</th>
<th>Message</th>
</tr>
</thead>
<tbody>
<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>
</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="confirmPause" @yes="pauseMonitor">
Are you sure want to pause?
</Confirm>
<Confirm ref="confirmDelete" btn-style="btn-danger" @yes="deleteMonitor">
Are you sure want to delete this monitor?
</Confirm>
<Confirm ref="confirmDelete" btn-style="btn-danger" @yes="deleteMonitor">
Are you sure want to delete this monitor?
</Confirm>
</div>
</transition>
</template>
<script>
import { defineAsyncComponent } from "vue";
import { useToast } from "vue-toastification"
const toast = useToast()
import Confirm from "../components/Confirm.vue";
@ -164,6 +183,7 @@ import Datetime from "../components/Datetime.vue";
import CountUp from "../components/CountUp.vue";
import Uptime from "../components/Uptime.vue";
import Pagination from "v-pagination-3";
const PingChart = defineAsyncComponent(() => import("../components/PingChart.vue"));
export default {
components: {
@ -174,6 +194,7 @@ export default {
Confirm,
Status,
Pagination,
PingChart,
},
data() {
return {
@ -181,16 +202,16 @@ export default {
perPage: 25,
heartBeatList: [],
toggleCertInfoBox: false,
showPingChartBox: true,
}
},
computed: {
pingTitle() {
if (this.monitor.type === "http") {
return "Response"
return this.$t("Response");
}
return "Ping"
return this.$t("Ping");
},
monitor() {
@ -306,6 +327,41 @@ export default {
<style lang="scss" scoped>
@import "../assets/vars.scss";
@media (max-width: 767px) {
.badge {
margin-top: 14px;
}
}
@media (max-width: 550px) {
.functions {
text-align: center;
}
button, a {
margin-left: 10px !important;
margin-right: 10px !important;
}
.ping-chart-wrapper {
padding: 10px !important;
}
}
@media (max-width: 400px) {
.btn {
display: inline-flex;
flex-direction: column;
align-items: center;
padding-top: 10px;
}
a.btn {
padding-left: 25px;
padding-right: 25px;
}
}
.url {
color: $primary;
margin-bottom: 20px;
@ -328,7 +384,7 @@ export default {
}
.word {
color: #AAA;
color: #aaa;
font-size: 14px;
}
@ -342,7 +398,7 @@ table {
.stats p {
font-size: 13px;
color: #AAA;
color: #aaa;
}
.stats {
@ -352,4 +408,14 @@ table {
margin: 20px 0;
}
}
.keyword {
color: black;
}
.dark {
.keyword {
color: $dark-font-color;
}
}
</style>

16
src/pages/List.vue

@ -0,0 +1,16 @@
<template>
<transition name="slide-fade" appear>
<MonitorList />
</transition>
</template>
<script>
import MonitorList from "../components/MonitorList.vue";
export default {
components: {
MonitorList,
},
}
</script>

5
src/pages/Settings.vue

@ -209,4 +209,9 @@ export default {
background-color: var(--background-4);
color: var(--main-font-color);
}
.btn-check:active + .btn-outline-primary,
.btn-check:checked + .btn-outline-primary,
.btn-check:hover + .btn-outline-primary {
color: #000;
}
</style>

40
src/util.js

@ -1,6 +1,10 @@
"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.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isDev = void 0;
const _dayjs = require("dayjs");
const dayjs = _dayjs;
exports.isDev = process.env.NODE_ENV === "development";
exports.appName = "Uptime Kuma";
exports.DOWN = 0;
exports.UP = 1;
exports.PENDING = 2;
@ -27,8 +31,40 @@ function ucfirst(str) {
}
exports.ucfirst = ucfirst;
function debug(msg) {
if (process.env.NODE_ENV === "development") {
if (exports.isDev) {
console.log(msg);
}
}
exports.debug = debug;
function polyfill() {
if (!String.prototype.replaceAll) {
String.prototype.replaceAll = function (str, newStr) {
if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") {
return this.replace(str, newStr);
}
return this.replace(new RegExp(str, "g"), newStr);
};
}
}
exports.polyfill = polyfill;
class TimeLogger {
constructor() {
this.startTime = dayjs().valueOf();
}
print(name) {
if (exports.isDev) {
console.log(name + ": " + (dayjs().valueOf() - this.startTime) + "ms");
}
}
}
exports.TimeLogger = TimeLogger;
function getRandomArbitrary(min, max) {
return Math.random() * (max - min) + min;
}
exports.getRandomArbitrary = getRandomArbitrary;
function getRandomInt(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
exports.getRandomInt = getRandomInt;

80
src/util.ts

@ -3,11 +3,16 @@
// Frontend uses util.ts
// Need to run "tsc" to compile if there are any changes.
import * as _dayjs from "dayjs";
const dayjs = _dayjs;
export const isDev = process.env.NODE_ENV === "development";
export const appName = "Uptime Kuma";
export const DOWN = 0;
export const UP = 1;
export const PENDING = 2;
export function flipStatus(s) {
export function flipStatus(s: number) {
if (s === UP) {
return DOWN;
}
@ -19,7 +24,7 @@ export function flipStatus(s) {
return s;
}
export function sleep(ms) {
export function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
@ -27,8 +32,8 @@ export function sleep(ms) {
* PHP's ucfirst
* @param str
*/
export function ucfirst(str) {
if (! str) {
export function ucfirst(str: string) {
if (!str) {
return str;
}
@ -36,8 +41,69 @@ export function ucfirst(str) {
return firstLetter.toUpperCase() + str.substr(1);
}
export function debug(msg) {
if (process.env.NODE_ENV === "development") {
console.log(msg)
export function debug(msg: any) {
if (isDev) {
console.log(msg);
}
}
declare global { interface String { replaceAll(str: string, newStr: string): string; } }
export function polyfill() {
/**
* 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: string, newStr: string) {
// 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);
};
}
}
export class TimeLogger {
startTime: number;
constructor() {
this.startTime = dayjs().valueOf();
}
print(name: string) {
if (isDev) {
console.log(name + ": " + (dayjs().valueOf() - this.startTime) + "ms")
}
}
}
/**
* Returns a random number between min (inclusive) and max (exclusive)
*/
export function getRandomArbitrary(min: number, max: number) {
return Math.random() * (max - min) + min;
}
/**
* From: https://stackoverflow.com/questions/1527803/generating-random-whole-numbers-in-javascript-in-a-specific-range
*
* Returns a random integer between min (inclusive) and max (inclusive).
* The value is no lower than min (or the next integer greater than min
* if min isn't an integer) and no greater than max (or the next integer
* lower than max if max isn't an integer).
* Using Math.round() will give you a non-uniform distribution!
*/
export function getRandomInt(min: number, max: number) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}

4
test/test_install_script/alpine3.dockerfile

@ -0,0 +1,4 @@
FROM alpine:3
RUN apk add --update nodejs npm git
COPY ./install.sh .
RUN /bin/sh install.sh local /opt/uptime-kuma 3000 0.0.0.0

4
test/test_install_script/centos7.dockerfile

@ -0,0 +1,4 @@
FROM centos:7
COPY ./install.sh .
RUN bash install.sh local /opt/uptime-kuma 3000 0.0.0.0

4
test/test_install_script/centos8.dockerfile

@ -0,0 +1,4 @@
FROM centos:8
COPY ./install.sh .
RUN bash install.sh local /opt/uptime-kuma 3000 0.0.0.0

10
test/test_install_script/debian.dockerfile

@ -0,0 +1,10 @@
FROM debian
# Test invalid node version, these commands install nodejs 10
# RUN apt-get update
# RUN apt --yes install nodejs
# RUN ln -s /usr/bin/nodejs /usr/bin/node
# RUN node -v
COPY ./install.sh .
RUN bash install.sh local /opt/uptime-kuma 3000 0.0.0.0

10
test/test_install_script/ubuntu.dockerfile

@ -0,0 +1,10 @@
FROM ubuntu
# Test invalid node version, these commands install nodejs 10
# RUN apt-get update
# RUN apt --yes install nodejs
# RUN ln -s /usr/bin/nodejs /usr/bin/node
# RUN node -v
COPY ./install.sh .
RUN bash install.sh local /opt/uptime-kuma 3000 0.0.0.0

10
test/test_install_script/ubuntu1604.dockerfile

@ -0,0 +1,10 @@
FROM ubuntu:16.04
# Test invalid node version, these commands install nodejs 10
RUN apt-get update
RUN apt --yes install nodejs
# RUN ln -s /usr/bin/nodejs /usr/bin/node
# RUN node -v
COPY ./install.sh .
RUN bash install.sh local /opt/uptime-kuma 3000 0.0.0.0

10
tsconfig.json

@ -1,14 +1,18 @@
{
"compileOnSave": true,
"compilerOptions": {
"target": "ES2018",
"target": "es2018",
"module": "commonjs",
"lib": [
"es2020",
"DOM",
],
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": false,
"files.insertFinalNewline": true
"strict": true
},
"files": [
"./server/util.ts"
"./src/util.ts"
]
}

20
vite.config.js

@ -1,14 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import legacy from '@vitejs/plugin-legacy'
import legacy from "@vitejs/plugin-legacy"
import vue from "@vitejs/plugin-vue"
import { defineConfig } from "vite"
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
legacy({
targets: ['ie > 11'],
additionalLegacyPolyfills: ['regenerator-runtime/runtime']
})
]
plugins: [
vue(),
legacy({
targets: ["ie > 11"],
additionalLegacyPolyfills: ["regenerator-runtime/runtime"]
})
]
})

Loading…
Cancel
Save