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. 6
      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. 68
      server/model/monitor.js
  36. 21
      server/model/user.js
  37. 216
      server/notification.js
  38. 60
      server/ping-lite.js
  39. 55
      server/prometheus.js
  40. 160
      server/server.js
  41. 79
      server/util-server.js
  42. 4
      src/components/Confirm.vue
  43. 10
      src/components/Datetime.vue
  44. 35
      src/components/HeartbeatBar.vue
  45. 8
      src/components/Login.vue
  46. 133
      src/components/MonitorList.vue
  47. 134
      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. 103
      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. 115
      src/pages/Dashboard.vue
  60. 46
      src/pages/DashboardHome.vue
  61. 126
      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. 10
      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 /.github
package-lock.json package-lock.json
app.json app.json
CODE_OF_CONDUCT.md
CONTRIBUTING.md
### .gitignore content (commented rules are duplicated) ### .gitignore content (commented rules are duplicated)

15
.eslintrc.js

@ -16,6 +16,9 @@ module.exports = {
requireConfigFile: false, requireConfigFile: false,
}, },
rules: { rules: {
"camelcase": ["warn", {
"properties": "never"
}],
// override/add rules settings here, such as: // override/add rules settings here, such as:
// 'vue/no-unused-vars': 'error' // 'vue/no-unused-vars': 'error'
"no-unused-vars": "warn", "no-unused-vars": "warn",
@ -36,6 +39,11 @@ module.exports = {
"no-multi-spaces": ["error", { "no-multi-spaces": ["error", {
ignoreEOLComments: true, ignoreEOLComments: true,
}], }],
"space-before-function-paren": ["error", {
"anonymous": "always",
"named": "never",
"asyncArrow": "always"
}],
"curly": "error", "curly": "error",
"object-curly-spacing": ["error", "always"], "object-curly-spacing": ["error", "always"],
"object-curly-newline": "off", "object-curly-newline": "off",
@ -62,12 +70,13 @@ module.exports = {
exceptAfterSingleLine: true, exceptAfterSingleLine: true,
}], }],
"no-unneeded-ternary": "error", "no-unneeded-ternary": "error",
"no-else-return": ["error", {
"allowElseIf": false,
}],
"array-bracket-newline": ["error", "consistent"], "array-bracket-newline": ["error", "consistent"],
"eol-last": ["error", "always"], "eol-last": ["error", "always"],
//'prefer-template': 'error', //'prefer-template': 'error',
"comma-dangle": ["warn", "only-multiline"], "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: '' 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** **Describe the bug**
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
@ -20,15 +23,16 @@ Steps to reproduce the behavior:
**Expected behavior** **Expected behavior**
A clear and concise description of what you expected to happen. A clear and concise description of what you expected to happen.
**Info**
- Uptime Kuma Version:
- Using Docker?: Yes/No
- OS:
- Browser:
**Screenshots** **Screenshots**
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):** **Error Log**
- Uptime Kuma Version: It is easier for us to find out the problem.
- Using Docker?: Yes/No
- OS:
- Browser:
**Additional context**
Add any other context about the problem here.

2
.github/ISSUE_TEMPLATE/feature_request.md

@ -6,6 +6,8 @@ labels: enhancement
assignees: '' 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.** **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 [...] 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. 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 # 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. - All settings in frontend.
- Easy to use - Easy to use
# Coding Styles
- Follow .editorconfig
- Follow eslint
## Name convention
- Javascript/Typescript: camelCaseType
- SQLite: underscore_type
- CSS/SCSS: dash-type
# Tools # Tools
- Node.js >= 14 - Node.js >= 14
- Git - 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) - A SQLite tool (I am using SQLite Expert Personal)
# Prepare the dev # Install dependencies
```bash ```bash
npm install npm install --dev
``` ```
# Backend Dev # Backend Dev
@ -39,7 +81,6 @@ npm run start-server
# Or # Or
node server/server.js node server/server.js
``` ```
It binds to 0.0.0.0:3001 by default. 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 # Database Migration
TODO 1. create `patch{num}.sql` in `./db/`
1. update `latestVersion` in `./server/database.js`
# Unit Test # 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

6
extra/healthcheck.js

@ -1,10 +1,10 @@
var http = require("http"); let http = require("http");
var options = { let options = {
host: "localhost", host: "localhost",
port: "3001", port: "3001",
timeout: 2000, timeout: 2000,
}; };
var request = http.request(options, (res) => { let request = http.request(options, (res) => {
console.log(`STATUS: ${res.statusCode}`); console.log(`STATUS: ${res.statusCode}`);
if (res.statusCode == 200) { if (res.statusCode == 200) {
process.exit(0); process.exit(0);

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 @@
/** const pkg = require("../package.json");
* String.prototype.replaceAll() polyfill const fs = require("fs");
* https://gomakethings.com/how-to-replace-a-section-of-a-string-with-another-one-with-vanilla-js/ const util = require("../src/util");
* @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);
}; util.polyfill();
}
const pkg = require('../package.json');
const fs = require("fs");
const oldVersion = pkg.version const oldVersion = pkg.version
const newVersion = oldVersion + "-nightly" const newVersion = oldVersion + "-nightly"
@ -35,6 +19,6 @@ if (newVersion) {
// Process README.md // Process README.md
if (fs.existsSync("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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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="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" /> <meta name="description" content="Uptime Kuma monitoring tool" />
<title>Uptime Kuma</title> <title>Uptime Kuma</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </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 fs = require("fs");
const { sleep } = require("../src/util");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { const { setSetting, setting } = require("./util-server");
setSetting, setting,
} = require("./util-server");
class Database { class Database {
static templatePath = "./db/kuma.db" static templatePath = "./db/kuma.db"
static path = "./data/kuma.db"; static path = "./data/kuma.db";
static latestVersion = 4; static latestVersion = 6;
static noReject = true; 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() { static async patch() {
let version = parseInt(await setting("database_version")); let version = parseInt(await setting("database_version"));
@ -24,6 +53,8 @@ class Database {
if (version === this.latestVersion) { if (version === this.latestVersion) {
console.info("Database no need to patch"); console.info("Database no need to patch");
} else if (version > this.latestVersion) {
console.info("Warning: Database version is newer than expected");
} else { } else {
console.info("Database patch is needed") console.info("Database patch is needed")
@ -31,6 +62,16 @@ class Database {
const backupPath = "./data/kuma.db.bak" + version; const backupPath = "./data/kuma.db.bak" + version;
fs.copyFileSync(Database.path, backupPath); 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 catch anything here, if gone wrong, restore the backup
try { try {
for (let i = version + 1; i <= this.latestVersion; i++) { for (let i = version + 1; i <= this.latestVersion; i++) {
@ -83,9 +124,16 @@ class Database {
return statement !== ""; 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) { for (let statement of statements) {
await R.exec(statement); db.prepare(statement).run();
}
} }
static getBetterSQLite3Database() {
return R.knex.client.acquireConnection();
} }
/** /**
@ -93,27 +141,10 @@ class Database {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
static async close() { static async close() {
const listener = (reason, p) => { if (this.sqliteInstance) {
Database.noReject = false; this.sqliteInstance.close();
};
process.addListener("unhandledRejection", listener);
console.log("Closing DB")
while (true) {
Database.noReject = true;
await R.close()
await sleep(2000)
if (Database.noReject) {
break;
} else {
console.log("Waiting to close the db")
} }
} console.log("Stopped database");
console.log("SQLite closed")
process.removeListener("unhandledRejection", listener);
} }
} }

68
server/model/monitor.js

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

216
server/notification.js

@ -84,40 +84,78 @@ class Notification {
} else if (notification.type === "discord") { } else if (notification.type === "discord") {
try { try {
const discordDisplayName = notification.discordUsername || "Uptime Kuma";
// If heartbeatJSON is null, assume we're testing. // If heartbeatJSON is null, assume we're testing.
if (heartbeatJSON == null) { if (heartbeatJSON == null) {
let data = { let discordtestdata = {
username: "Uptime-Kuma", username: discordDisplayName,
content: msg, content: msg,
} }
await axios.post(notification.discordWebhookUrl, data) await axios.post(notification.discordWebhookUrl, discordtestdata)
return okMsg; return okMsg;
} }
// If heartbeatJSON is not null, we go into the normal alerting loop. // If heartbeatJSON is not null, we go into the normal alerting loop.
if (heartbeatJSON["status"] == 0) { if (heartbeatJSON["status"] == 0) {
var alertColor = "16711680"; let discorddowndata = {
} else if (heartbeatJSON["status"] == 1) { username: discordDisplayName,
var alertColor = "65280"; 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"],
},
],
}],
} }
let data = { await axios.post(notification.discordWebhookUrl, discorddowndata)
username: "Uptime-Kuma", return okMsg;
} else if (heartbeatJSON["status"] == 1) {
let discordupdata = {
username: discordDisplayName,
embeds: [{ embeds: [{
title: "Uptime-Kuma Alert", title: "✅ Your service " + monitorJSON["name"] + " is up! ✅",
color: alertColor, color: 65280,
timestamp: heartbeatJSON["time"],
fields: [ fields: [
{
name: "Service Name",
value: monitorJSON["name"],
},
{
name: "Service URL",
value: "[Visit Service](" + monitorJSON["url"] + ")",
},
{ {
name: "Time (UTC)", name: "Time (UTC)",
value: heartbeatJSON["time"], value: heartbeatJSON["time"],
}, },
{ {
name: "Message", name: "Ping",
value: msg, value: heartbeatJSON["ping"] + "ms",
}, },
], ],
}], }],
} }
await axios.post(notification.discordWebhookUrl, data) await axios.post(notification.discordWebhookUrl, discordupdata)
return okMsg; return okMsg;
}
} catch (error) { } catch (error) {
throwGeneralAxiosError(error) throwGeneralAxiosError(error)
} }
@ -136,6 +174,7 @@ class Notification {
} catch (error) { } catch (error) {
throwGeneralAxiosError(error) throwGeneralAxiosError(error)
} }
} else if (notification.type === "pushy") { } else if (notification.type === "pushy") {
try { try {
await axios.post(`https://api.pushy.me/push?api_key=${notification.pushyAPIKey}`, { await axios.post(`https://api.pushy.me/push?api_key=${notification.pushyAPIKey}`, {
@ -154,6 +193,34 @@ class Notification {
console.log(error) console.log(error)
return false; 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") { } else if (notification.type === "slack") {
try { try {
if (heartbeatJSON == null) { if (heartbeatJSON == null) {
@ -248,10 +315,6 @@ class Notification {
throwGeneralAxiosError(error) throwGeneralAxiosError(error)
} }
} else if (notification.type === "apprise") {
return Notification.apprise(notification, msg)
} else if (notification.type === "lunasea") { } else if (notification.type === "lunasea") {
let lunaseadevice = "https://notify.lunasea.app/v1/custom/device/" + notification.lunaseaDevice let lunaseadevice = "https://notify.lunasea.app/v1/custom/device/" + notification.lunaseaDevice
@ -287,6 +350,88 @@ class Notification {
throwGeneralAxiosError(error) 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 { } else {
throw new Error("Notification type is not supported") throw new Error("Notification type is not supported")
} }
@ -330,15 +475,21 @@ class Notification {
static async smtp(notification, msg) { static async smtp(notification, msg) {
let transporter = nodemailer.createTransport({ const config = {
host: notification.smtpHost, host: notification.smtpHost,
port: notification.smtpPort, port: notification.smtpPort,
secure: notification.smtpSecure, 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, user: notification.smtpUsername,
pass: notification.smtpPassword, pass: notification.smtpPassword,
}, };
}); }
let transporter = nodemailer.createTransport(config);
// send mail with defined transport object // send mail with defined transport object
await transporter.sendMail({ await transporter.sendMail({
@ -351,29 +502,6 @@ class Notification {
return "Sent Successfully."; 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) { function throwGeneralAxiosError(error) {

60
server/ping-lite.js

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

55
server/prometheus.js

@ -1,22 +1,33 @@
const PrometheusClient = require('prom-client'); const PrometheusClient = require("prom-client");
const commonLabels = [ const commonLabels = [
'monitor_name', "monitor_name",
'monitor_type', "monitor_type",
'monitor_url', "monitor_url",
'monitor_hostname', "monitor_hostname",
'monitor_port', "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({ const monitor_response_time = new PrometheusClient.Gauge({
name: 'monitor_response_time', name: "monitor_response_time",
help: 'Monitor Response Time (ms)', help: "Monitor Response Time (ms)",
labelNames: commonLabels labelNames: commonLabels
}); });
const monitor_status = new PrometheusClient.Gauge({ const monitor_status = new PrometheusClient.Gauge({
name: 'monitor_status', name: "monitor_status",
help: 'Monitor Status (1 = UP, 0= DOWN)', help: "Monitor Status (1 = UP, 0= DOWN)",
labelNames: commonLabels 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 { try {
monitor_status.set(this.monitorLabelValues, heartbeat.status) monitor_status.set(this.monitorLabelValues, heartbeat.status)
} catch (e) { } catch (e) {
@ -41,7 +72,7 @@ class Prometheus {
} }
try { try {
if (typeof heartbeat.ping === 'number') { if (typeof heartbeat.ping === "number") {
monitor_response_time.set(this.monitorLabelValues, heartbeat.ping) monitor_response_time.set(this.monitorLabelValues, heartbeat.ping)
} else { } else {
// Is it good? // Is it good?

160
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") console.log("Importing Node libraries")
const fs = require("fs"); const fs = require("fs");
@ -11,8 +12,6 @@ debug("Importing express");
const express = require("express"); const express = require("express");
debug("Importing socket.io"); debug("Importing socket.io");
const { Server } = require("socket.io"); const { Server } = require("socket.io");
debug("Importing dayjs");
const dayjs = require("dayjs");
debug("Importing redbean-node"); debug("Importing redbean-node");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
debug("Importing jsonwebtoken"); debug("Importing jsonwebtoken");
@ -26,7 +25,7 @@ console.log("Importing this project modules");
debug("Importing Monitor"); debug("Importing Monitor");
const Monitor = require("./model/monitor"); const Monitor = require("./model/monitor");
debug("Importing Settings"); debug("Importing Settings");
const { getSettings, setSettings, setting } = require("./util-server"); const { getSettings, setSettings, setting, initJWTSecret } = require("./util-server");
debug("Importing Notification"); debug("Importing Notification");
const { Notification } = require("./notification"); const { Notification } = require("./notification");
debug("Importing Database"); debug("Importing Database");
@ -38,11 +37,13 @@ const passwordHash = require("./password-hash");
const args = require("args-parser")(process.argv); const args = require("args-parser")(process.argv);
const version = require("../package.json").version; const checkVersion = require("./check-version");
const hostname = process.env.HOST || args.host || "0.0.0.0" console.info("Version: " + checkVersion.version);
const port = parseInt(process.env.PORT || args.port || 3001);
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") console.log("Creating express and socket.io instance")
const app = express(); const app = express();
@ -87,24 +88,35 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
// Normal Router here // 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 // Basic Auth Router here
// Prometheus API metrics /metrics // Prometheus API metrics /metrics
// With Basic Auth using the first user's username/password // 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 // Universal Route Handler, must be at the end
app.get("*", function(request, response, next) { app.get("*", async (_request, response) => {
response.end(indexHTML) response.send(indexHTML);
}); });
console.log("Adding socket handler") console.log("Adding socket handler")
io.on("connection", async (socket) => { io.on("connection", async (socket) => {
socket.emit("info", { socket.emit("info", {
version, version: checkVersion.version,
latestVersion: checkVersion.latestVersion,
}) })
totalClient++; totalClient++;
@ -114,11 +126,6 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.emit("setup") 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", () => { socket.on("disconnect", () => {
totalClient--; totalClient--;
}); });
@ -139,7 +146,11 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
]) ])
if (user) { if (user) {
await afterLogin(socket, user) debug("afterLogin")
afterLogin(socket, user)
debug("afterLogin ok")
callback({ callback({
ok: true, ok: true,
@ -165,7 +176,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
let user = await login(data.username, data.password) let user = await login(data.username, data.password)
if (user) { if (user) {
await afterLogin(socket, user) afterLogin(socket, user)
callback({ callback({
ok: true, ok: true,
@ -231,6 +242,9 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
let notificationIDList = monitor.notificationIDList; let notificationIDList = monitor.notificationIDList;
delete monitor.notificationIDList; delete monitor.notificationIDList;
monitor.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
delete monitor.accepted_statuscodes;
bean.import(monitor) bean.import(monitor)
bean.user_id = socket.userID bean.user_id = socket.userID
await R.store(bean) await R.store(bean)
@ -275,6 +289,8 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
bean.keyword = monitor.keyword; bean.keyword = monitor.keyword;
bean.ignoreTls = monitor.ignoreTls; bean.ignoreTls = monitor.ignoreTls;
bean.upsideDown = monitor.upsideDown; bean.upsideDown = monitor.upsideDown;
bean.maxredirects = monitor.maxredirects;
bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
await R.store(bean) await R.store(bean)
@ -409,10 +425,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
if (user && passwordHash.verify(password.currentPassword, user.password)) { if (user && passwordHash.verify(password.currentPassword, user.password)) {
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [ user.resetPassword(password.newPassword);
passwordHash.generate(password.newPassword),
socket.userID,
]);
callback({ callback({
ok: true, 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, () => { server.listen(port, hostname, () => {
if (hostname) {
console.log(`Listening on ${hostname}:${port}`); console.log(`Listening on ${hostname}:${port}`);
} else {
console.log(`Listening on ${port}`);
}
startMonitors(); startMonitors();
checkVersion.startInterval();
}); });
})(); })();
async function updateMonitorNotification(monitorID, notificationIDList) { 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, monitorID,
]) ])
@ -570,6 +610,8 @@ async function sendMonitorList(socket) {
} }
async function sendNotificationList(socket) { async function sendNotificationList(socket) {
const timeLogger = new TimeLogger();
let result = []; let result = [];
let list = await R.find("notification", " user_id = ? ", [ let list = await R.find("notification", " user_id = ? ", [
socket.userID, socket.userID,
@ -580,6 +622,9 @@ async function sendNotificationList(socket) {
} }
io.to(socket.userID).emit("notificationList", result) io.to(socket.userID).emit("notificationList", result)
timeLogger.print("Send Notification List");
return list; return list;
} }
@ -588,22 +633,27 @@ async function afterLogin(socket, user) {
socket.join(user.id) socket.join(user.id)
let monitorList = await sendMonitorList(socket) let monitorList = await sendMonitorList(socket)
sendNotificationList(socket)
await sleep(500);
for (let monitorID in monitorList) { for (let monitorID in monitorList) {
sendHeartbeatList(socket, monitorID); await sendHeartbeatList(socket, monitorID);
sendImportantHeartbeatList(socket, monitorID);
Monitor.sendStats(io, monitorID, user.id)
} }
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) { async function getMonitorJSONList(userID) {
let result = {}; let result = {};
let monitorList = await R.find("monitor", " user_id = ? ", [ let monitorList = await R.find("monitor", " user_id = ? ORDER BY weight DESC, name", [
userID, userID,
]) ])
@ -627,32 +677,22 @@ async function initDatabase() {
} }
console.log("Connecting to Database") console.log("Connecting to Database")
R.setup("sqlite", { await Database.connect();
filename: Database.path,
});
console.log("Connected") console.log("Connected")
// Patch the database // Patch the database
await Database.patch() 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` = ? ", [ let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
"jwtSecret", "jwtSecret",
]); ]);
if (! jwtSecretBean) { if (! jwtSecretBean) {
console.log("JWT secret is not found, generate one.") console.log("JWT secret is not found, generate one.");
jwtSecretBean = R.dispense("setting") jwtSecretBean = await initJWTSecret();
jwtSecretBean.key = "jwtSecret" console.log("Stored JWT secret into database");
jwtSecretBean.value = passwordHash.generate(dayjs() + "")
await R.store(jwtSecretBean)
console.log("Stored JWT secret into database")
} else { } 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 // 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 ") let list = await R.find("monitor", " active = 1 ")
for (let monitor of list) { for (let monitor of list) {
monitor.start(io)
monitorList[monitor.id] = monitor; 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 * Send Heartbeat History list to socket
*/ */
async function sendHeartbeatList(socket, monitorID) { async function sendHeartbeatList(socket, monitorID) {
const timeLogger = new TimeLogger();
let list = await R.find("heartbeat", ` let list = await R.find("heartbeat", `
monitor_id = ? monitor_id = ?
ORDER BY time DESC ORDER BY time DESC
@ -736,9 +783,13 @@ async function sendHeartbeatList(socket, monitorID) {
} }
socket.emit("heartbeatList", monitorID, result) socket.emit("heartbeatList", monitorID, result)
timeLogger.print(`[Monitor: ${monitorID}] sendHeartbeatList`)
} }
async function sendImportantHeartbeatList(socket, monitorID) { async function sendImportantHeartbeatList(socket, monitorID) {
const timeLogger = new TimeLogger();
let list = await R.find("heartbeat", ` let list = await R.find("heartbeat", `
monitor_id = ? monitor_id = ?
AND important = 1 AND important = 1
@ -748,6 +799,8 @@ async function sendImportantHeartbeatList(socket, monitorID) {
monitorID, monitorID,
]) ])
timeLogger.print(`[Monitor: ${monitorID}] sendImportantHeartbeatList`);
socket.emit("importantHeartbeatList", monitorID, list) socket.emit("importantHeartbeatList", monitorID, list)
} }
@ -762,11 +815,10 @@ async function shutdownFunction(signal) {
} }
await sleep(2000); await sleep(2000);
await Database.close(); await Database.close();
console.log("Stopped DB")
} }
function finalFunction() { function finalFunction() {
console.log("Graceful Shutdown Done") console.log("Graceful shutdown successfully!");
} }
gracefulShutdown(server, { gracefulShutdown(server, {
@ -777,3 +829,9 @@ gracefulShutdown(server, {
onShutdown: shutdownFunction, // shutdown function (async) - e.g. for cleanup DB, ... onShutdown: shutdownFunction, // shutdown function (async) - e.g. for cleanup DB, ...
finally: finalFunction, // finally function (sync) - e.g. for logging 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 Ping = require("./ping-lite");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { debug } = require("../src/util"); 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) { exports.tcping = function (hostname, port) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -9,7 +30,7 @@ exports.tcping = function (hostname, port) {
address: hostname, address: hostname,
port: port, port: port,
attempts: 1, attempts: 1,
}, function(err, data) { }, function (err, data) {
if (err) { if (err) {
reject(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) => { 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) { if (err) {
reject(err) reject(err);
} else if (ms === null) { } else if (ms === null) {
reject(new Error("timeout")) reject(new Error(stdout))
} else { } else {
resolve(Math.round(ms)) resolve(Math.round(ms))
} }
@ -58,7 +94,7 @@ exports.setSetting = async function (key, value) {
let bean = await R.findOne("setting", " `key` = ? ", [ let bean = await R.findOne("setting", " `key` = ? ", [
key, key,
]) ])
if (! bean) { if (!bean) {
bean = R.dispense("setting") bean = R.dispense("setting")
bean.key = key; bean.key = key;
} }
@ -158,3 +194,32 @@ exports.checkCertificate = function (res) {
fingerprint, 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-content">
<div class="modal-header"> <div class="modal-header">
<h5 id="exampleModalLabel" class="modal-title"> <h5 id="exampleModalLabel" class="modal-title">
Confirm {{ $t("Confirm") }}
</h5> </h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" /> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div> </div>
@ -35,7 +35,7 @@ export default {
}, },
yesText: { yesText: {
type: String, type: String,
default: "Yes", default: "Yes", // TODO: No idea what to translate this
}, },
noText: { noText: {
type: String, type: String,

10
src/components/Datetime.vue

@ -22,15 +22,11 @@ export default {
computed: { computed: {
displayText() { displayText() {
if (this.value !== undefined && this.value !== "") {
let format = "YYYY-MM-DD HH:mm:ss";
if (this.dateOnly) { if (this.dateOnly) {
format = "YYYY-MM-DD"; return this.$root.date(this.value);
} else {
return this.$root.datetime(this.value);
} }
return dayjs.utc(this.value).tz(this.$root.timezone).format(format);
}
return "";
}, },
}, },
} }

35
src/components/HeartbeatBar.vue

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

8
src/components/Login.vue

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

134
src/components/NotificationDialog.vue

@ -5,30 +5,32 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 id="exampleModalLabel" class="modal-title"> <h5 id="exampleModalLabel" class="modal-title">
Setup Notification {{ $t("Setup Notification") }}
</h5> </h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" /> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <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"> <select id="type" v-model="notification.type" class="form-select">
<option value="telegram">Telegram</option> <option value="telegram">Telegram</option>
<option value="webhook">Webhook</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="discord">Discord</option>
<option value="signal">Signal</option> <option value="signal">Signal</option>
<option value="gotify">Gotify</option> <option value="gotify">Gotify</option>
<option value="slack">Slack</option> <option value="slack">Slack</option>
<option value="pushover">Pushover</option> <option value="pushover">Pushover</option>
<option value="pushy">Pushy</option> <option value="pushy">Pushy</option>
<option value="octopush">Octopush</option>
<option value="lunasea">LunaSea</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> </select>
</div> </div>
<div class="mb-3"> <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> <input id="name" v-model="notification.name" type="text" class="form-control" required>
</div> </div>
@ -147,6 +149,11 @@
You can get this by going to Server Settings -> Integrations -> Create Webhook You can get this by going to Server Settings -> Integrations -> Create Webhook
</div> </div>
</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>
<template v-if="notification.type === 'signal'"> <template v-if="notification.type === 'signal'">
@ -202,7 +209,7 @@
<template v-if="notification.type === 'slack'"> <template v-if="notification.type === 'slack'">
<div class="mb-3"> <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> <input id="slack-webhook-url" v-model="notification.slackwebhookURL" type="text" class="form-control" required>
<label for="slack-username" class="form-label">Username</label> <label for="slack-username" class="form-label">Username</label>
<input id="slack-username" v-model="notification.slackusername" type="text" class="form-control"> <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> <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"> <input id="slack-button" v-model="notification.slackbutton" type="text" class="form-control">
<div class="form-text"> <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;"> <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> More info about webhooks on: <a href="https://api.slack.com/messaging/webhooks" target="_blank">https://api.slack.com/messaging/webhooks</a>
</p> </p>
@ -233,13 +240,13 @@
<template v-if="notification.type === 'pushy'"> <template v-if="notification.type === 'pushy'">
<div class="mb-3"> <div class="mb-3">
<label for="pushy-app-token" class="form-label">API_KEY</label> <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>
<div class="mb-3"> <div class="mb-3">
<label for="pushy-user-key" class="form-label">USER_TOKEN</label> <label for="pushy-user-key" class="form-label">USER_TOKEN</label>
<div class="input-group mb-3"> <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>
</div> </div>
<p style="margin-top: 8px;"> <p style="margin-top: 8px;">
@ -247,11 +254,42 @@
</p> </p>
</template> </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://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'"> <template v-if="notification.type === 'pushover'">
<div class="mb-3"> <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> <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> <input id="pushover-app-token" v-model="notification.pushoverapptoken" type="text" class="form-control" required>
<label for="pushover-device" class="form-label">Device</label> <label for="pushover-device" class="form-label">Device</label>
<input id="pushover-device" v-model="notification.pushoverdevice" type="text" class="form-control"> <input id="pushover-device" v-model="notification.pushoverdevice" type="text" class="form-control">
@ -291,7 +329,7 @@
<option>none</option> <option>none</option>
</select> </select>
<div class="form-text"> <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;"> <p style="margin-top: 8px;">
More info on: <a href="https://pushover.net/api" target="_blank">https://pushover.net/api</a> More info on: <a href="https://pushover.net/api" target="_blank">https://pushover.net/api</a>
</p> </p>
@ -305,46 +343,56 @@
</div> </div>
</template> </template>
<template v-if="notification.type === 'apprise'"> <template v-if="notification.type === 'lunasea'">
<div class="mb-3"> <div class="mb-3">
<label for="apprise-url" class="form-label">Apprise URL</label> <label for="lunasea-device" class="form-label">LunaSea Device ID<span style="color: red;"><sup>*</sup></span></label>
<input id="apprise-url" v-model="notification.appriseURL" type="text" class="form-control" required> <input id="lunasea-device" v-model="notification.lunaseaDevice" type="text" class="form-control" required>
<div class="form-text"> <div class="form-text">
<p>Example: twilio://AccountSid:AuthToken@FromPhoneNo</p> <p><span style="color: red;"><sup>*</sup></span>Required</p>
<p>
Read more: <a href="https://github.com/caronc/apprise/wiki#notification-services" target="_blank">https://github.com/caronc/apprise/wiki#notification-services</a>
</p>
</div> </div>
</div> </div>
</template>
<template v-if="notification.type === 'pushbullet'">
<div class="mb-3"> <div class="mb-3">
<p> <label for="pushbullet-access-token" class="form-label">Access Token</label>
Status: <input id="pushbullet-access-token" v-model="notification.pushbulletAccessToken" type="text" class="form-control" required>
<span v-if="appriseInstalled" class="text-primary">Apprise is installed</span>
<span v-else class="text-danger">Apprise is not installed. <a href="https://github.com/caronc/apprise">Read more</a></span>
</p>
</div> </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>
<template v-if="notification.type === 'lunasea'"> <template v-if="notification.type === 'line'">
<div class="mb-3"> <div class="mb-3">
<label for="lunasea-device" class="form-label">LunaSea Device ID<span style="color:red;"><sup>*</sup></span></label> <label for="line-channel-access-token" class="form-label">Channel access token</label>
<input id="lunasea-device" v-model="notification.lunaseaDevice" type="text" class="form-control" required> <input id="line-channel-access-token" v-model="notification.lineChannelAccessToken" type="text" class="form-control" required>
</div>
<div class="form-text"> <div class="form-text">
<p><span style="color:red;"><sup>*</sup></span>Required</p> 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>
<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> </div>
</template> </template>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm"> <button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
Delete {{ $t("Delete") }}
</button> </button>
<button type="button" class="btn btn-warning" :disabled="processing" @click="test"> <button type="button" class="btn btn-warning" :disabled="processing" @click="test">
Test {{ $t("Test") }}
</button> </button>
<button type="submit" class="btn btn-primary" :disabled="processing"> <button type="submit" class="btn btn-primary" :disabled="processing">
Save {{ $t("Save") }}
</button> </button>
</div> </div>
</div> </div>
@ -380,7 +428,6 @@ export default {
type: null, type: null,
gotifyPriority: 8, gotifyPriority: 8,
}, },
appriseInstalled: false,
} }
}, },
computed: { computed: {
@ -411,10 +458,6 @@ export default {
}, },
mounted() { mounted() {
this.modal = new Modal(this.$refs.modal) this.modal = new Modal(this.$refs.modal)
this.$root.getSocket().emit("checkApprise", (installed) => {
this.appriseInstalled = installed;
})
}, },
methods: { methods: {
@ -509,17 +552,12 @@ export default {
} }
</script> </script>
<style> <style lang="scss" scoped>
.modal-dialog .form-text, .modal-dialog p{ @import "../assets/vars.scss";
color: var(--main-font-color);
} .dark {
.modal-content{ .modal-dialog .form-text, .modal-dialog p {
border: 1px solid var(--main-font-color); color: $dark-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> </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() { text() {
if (this.status === 0) { if (this.status === 0) {
return "Down" return this.$t("Down");
} }
if (this.status === 1) { if (this.status === 1) {
return "Up" return this.$t("Up");
} }
if (this.status === 2) { 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 { 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 { fa } from '@fortawesome/free-regular-svg-icons'
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome" import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"
// Add Free Font Awesome Icons here // Add Free Font Awesome Icons here
// https://fontawesome.com/v5.15/icons?d=gallery&p=2&s=solid&m=free // 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 } 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": "測試",
}

103
src/layouts/Layout.vue

@ -1,4 +1,5 @@
<template> <template>
<div :class="classes">
<div v-if="! $root.socket.connected && ! $root.socket.firstConnect" class="lost-connection"> <div v-if="! $root.socket.connected && ! $root.socket.firstConnect" class="lost-connection">
<div class="container-fluid"> <div class="container-fluid">
{{ $root.connectionErrorMsg }} {{ $root.connectionErrorMsg }}
@ -7,28 +8,32 @@
<!-- Desktop header --> <!-- Desktop header -->
<header v-if="! $root.isMobile" class="d-flex flex-wrap justify-content-center py-3 mb-3 border-bottom"> <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"> <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" alt="Logo" /> <object class="bi me-2 ms-4" width="40" height="40" data="/icon.svg" />
<span class="fs-4 title">Uptime Kuma</span> <span class="fs-4 title">Uptime Kuma</span>
</router-link> </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"> <ul class="nav nav-pills">
<li class="nav-item"> <li class="nav-item">
<router-link to="/dashboard" class="nav-link"> <router-link to="/dashboard" class="nav-link">
<font-awesome-icon icon="tachometer-alt" /> Dashboard <font-awesome-icon icon="tachometer-alt" /> {{ $t("Dashboard") }}
</router-link> </router-link>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<router-link to="/settings" class="nav-link"> <router-link to="/settings" class="nav-link">
<font-awesome-icon icon="cog" /> Settings <font-awesome-icon icon="cog" /> {{ $t("Settings") }}
</router-link> </router-link>
</li> </li>
</ul> </ul>
</header> </header>
<!-- Mobile header --> <!-- Mobile header -->
<header v-else class="d-flex flex-wrap justify-content-center mt-3 mb-3"> <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-decoration-none"> <router-link to="/dashboard" class="d-flex align-items-center text-dark text-decoration-none">
<object class="bi" width="40" height="40" data="/icon.svg" /> <object class="bi" width="40" height="40" data="/icon.svg" />
<span class="fs-4 title ms-2">Uptime Kuma</span> <span class="fs-4 title ms-2">Uptime Kuma</span>
</router-link> </router-link>
@ -43,67 +48,93 @@
<footer> <footer>
<div class="container-fluid"> <div class="container-fluid">
Uptime Kuma - Uptime Kuma -
Version: {{ $root.info.version }} - {{ $t("Version") }}: {{ $root.info.version }} -
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">Check Update On GitHub</a> <a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a>
</div> </div>
</footer> </footer>
<!-- Mobile Only --> <!-- Mobile Only -->
<div v-if="$root.isMobile" style="width: 100%;height: 60px;" /> <div v-if="$root.isMobile" style="width: 100%; height: 60px;" />
<nav v-if="$root.isMobile" class="bottom-nav"> <nav v-if="$root.isMobile" class="bottom-nav">
<router-link to="/dashboard" class="nav-link" @click="$root.cancelActiveList"> <router-link to="/dashboard" class="nav-link">
<div><font-awesome-icon icon="tachometer-alt" /></div> <div><font-awesome-icon icon="tachometer-alt" /></div>
Dashboard {{ $t("Dashboard") }}
</router-link> </router-link>
<a href="#" :class=" { 'router-link-exact-active' : $root.showListMobile } " @click="$root.showListMobile = ! $root.showListMobile"> <router-link to="/list" class="nav-link">
<div><font-awesome-icon icon="list" /></div> <div><font-awesome-icon icon="list" /></div>
List {{ $t("List") }}
</a> </router-link>
<router-link to="/add" class="nav-link" @click="$root.cancelActiveList"> <router-link to="/add" class="nav-link">
<div><font-awesome-icon icon="plus" /></div> <div><font-awesome-icon icon="plus" /></div>
Add {{ $t("Add") }}
</router-link> </router-link>
<router-link to="/settings" class="nav-link" @click="$root.cancelActiveList"> <router-link to="/settings" class="nav-link">
<div><font-awesome-icon icon="cog" /></div> <div><font-awesome-icon icon="cog" /></div>
Settings {{ $t("Settings") }}
</router-link> </router-link>
</nav> </nav>
</div>
</template> </template>
<script> <script>
import Login from "../components/Login.vue"; import Login from "../components/Login.vue";
import compareVersions from "compare-versions";
export default { export default {
components: { components: {
Login, Login,
}, },
data() { data() {
return {} 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: { watch: {
$route (to, from) { $route(to, from) {
this.init(); this.init();
}, },
}, },
mounted() { mounted() {
this.init(); this.init();
}, },
methods: { methods: {
init() { init() {
if (this.$route.name === "root") { if (this.$route.name === "root") {
this.$router.push("/dashboard") this.$router.push("/dashboard")
} }
}, },
}, },
} }
</script> </script>
<style scoped lang="scss"> <style lang="scss" scoped>
@import "../assets/vars.scss"; @import "../assets/vars.scss";
.bottom-nav { .bottom-nav {
@ -113,7 +144,7 @@ export default {
height: 60px; height: 60px;
width: 100%; width: 100%;
left: 0; 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); 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; text-align: center;
white-space: nowrap; white-space: nowrap;
@ -141,6 +172,10 @@ export default {
} }
} }
main {
min-height: calc(100vh - 160px);
}
.title { .title {
font-weight: bold; font-weight: bold;
} }
@ -156,12 +191,30 @@ export default {
} }
footer { footer {
color: #AAA;
font-size: 13px; font-size: 13px;
margin-top: 10px; margin-top: 10px;
margin-bottom: 30px; padding-bottom: 30px;
margin-left: 10px; margin-left: 10px;
text-align: center; 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> </style>

38
src/main.js

@ -1,5 +1,6 @@
import "bootstrap"; import "bootstrap";
import { createApp, h } from "vue"; import { createApp, h } from "vue";
import { createI18n } from "vue-i18n"
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import Toast from "vue-toastification"; import Toast from "vue-toastification";
import "vue-toastification/dist/index.css"; import "vue-toastification/dist/index.css";
@ -9,12 +10,21 @@ import { FontAwesomeIcon } from "./icon.js";
import EmptyLayout from "./layouts/EmptyLayout.vue"; import EmptyLayout from "./layouts/EmptyLayout.vue";
import Layout from "./layouts/Layout.vue"; import Layout from "./layouts/Layout.vue";
import socket from "./mixins/socket"; 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 Dashboard from "./pages/Dashboard.vue";
import DashboardHome from "./pages/DashboardHome.vue"; import DashboardHome from "./pages/DashboardHome.vue";
import Details from "./pages/Details.vue"; import Details from "./pages/Details.vue";
import EditMonitor from "./pages/EditMonitor.vue"; import EditMonitor from "./pages/EditMonitor.vue";
import Settings from "./pages/Settings.vue"; import Settings from "./pages/Settings.vue";
import Setup from "./pages/Setup.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 = [ const routes = [
{ {
@ -49,6 +59,10 @@ const routes = [
path: "/add", path: "/add",
component: EditMonitor, component: EditMonitor,
}, },
{
path: "/list",
component: List,
},
], ],
}, },
{ {
@ -73,14 +87,36 @@ const router = createRouter({
routes, 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({ const app = createApp({
mixins: [ mixins: [
socket, socket,
theme,
mobile,
datetime
], ],
data() {
return {
appName: appName
}
},
render: () => h(App), render: () => h(App),
}) })
app.use(router) app.use(router);
app.use(i18n);
const options = { const options = {
position: "bottom-right", 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 { io } from "socket.io-client";
import { useToast } from "vue-toastification"; import { useToast } from "vue-toastification";
const toast = useToast() const toast = useToast()
@ -17,7 +16,6 @@ export default {
connectCount: 0, connectCount: 0,
}, },
remember: (localStorage.remember !== "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. 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, loggedIn: false,
monitorList: { }, monitorList: { },
@ -27,9 +25,7 @@ export default {
uptimeList: { }, uptimeList: { },
certInfoList: {}, certInfoList: {},
notificationList: [], notificationList: [],
windowWidth: window.innerWidth, connectionErrorMsg: "Cannot connect to the socket server. Reconnecting...",
showListMobile: false,
connectionErrorMsg: "Cannot connect to the socket server. Reconnecting..."
} }
}, },
@ -189,14 +185,6 @@ export default {
methods: { methods: {
cancelActiveList() {
this.$root.showListMobile = false;
},
onResize() {
this.windowWidth = window.innerWidth;
},
storage() { storage() {
return (this.remember) ? localStorage : sessionStorage; return (this.remember) ? localStorage : sessionStorage;
}, },
@ -270,19 +258,6 @@ export default {
computed: { computed: {
isMobile() {
return this.windowWidth <= 767.98;
},
timezone() {
if (this.userTimezone === "auto") {
return dayjs.tz.guess()
}
return this.userTimezone
},
lastHeartbeatList() { lastHeartbeatList() {
let result = {} 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");
}
}
}
}

115
src/pages/Dashboard.vue

@ -1,31 +1,13 @@
<template> <template>
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<div class="col-12 col-md-5 col-xl-4"> <div v-if="! $root.isMobile" class="col-12 col-md-5 col-xl-4">
<div v-if="! $root.isMobile"> <div>
<router-link to="/add" class="btn btn-primary"><font-awesome-icon icon="plus" /> Add New Monitor</router-link> <router-link to="/add" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> {{ $t("Add New Monitor") }}</router-link>
</div> </div>
<MonitorList />
<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> </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>
</div>
<div class="col-12 col-md-7 col-xl-8"> <div class="col-12 col-md-7 col-xl-8">
<router-view /> <router-view />
</div> </div>
@ -35,102 +17,21 @@
<script> <script>
import HeartbeatBar from "../components/HeartbeatBar.vue"; import MonitorList from "../components/MonitorList.vue";
import Uptime from "../components/Uptime.vue";
export default { export default {
components: { components: {
Uptime, MonitorList,
HeartbeatBar,
}, },
data() { data() {
return {} 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> </script>
<style scoped lang="scss"> <style lang="scss" scoped>
@import "../assets/vars.scss";
.container-fluid { .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> </style>

46
src/pages/DashboardHome.vue

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

126
src/pages/Details.vue

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

40
src/util.js

@ -1,6 +1,10 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); 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.DOWN = 0;
exports.UP = 1; exports.UP = 1;
exports.PENDING = 2; exports.PENDING = 2;
@ -27,8 +31,40 @@ function ucfirst(str) {
} }
exports.ucfirst = ucfirst; exports.ucfirst = ucfirst;
function debug(msg) { function debug(msg) {
if (process.env.NODE_ENV === "development") { if (exports.isDev) {
console.log(msg); console.log(msg);
} }
} }
exports.debug = debug; 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 // Frontend uses util.ts
// Need to run "tsc" to compile if there are any changes. // 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 DOWN = 0;
export const UP = 1; export const UP = 1;
export const PENDING = 2; export const PENDING = 2;
export function flipStatus(s) { export function flipStatus(s: number) {
if (s === UP) { if (s === UP) {
return DOWN; return DOWN;
} }
@ -19,7 +24,7 @@ export function flipStatus(s) {
return s; return s;
} }
export function sleep(ms) { export function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
@ -27,8 +32,8 @@ export function sleep(ms) {
* PHP's ucfirst * PHP's ucfirst
* @param str * @param str
*/ */
export function ucfirst(str) { export function ucfirst(str: string) {
if (! str) { if (!str) {
return str; return str;
} }
@ -36,8 +41,69 @@ export function ucfirst(str) {
return firstLetter.toUpperCase() + str.substr(1); return firstLetter.toUpperCase() + str.substr(1);
} }
export function debug(msg) { export function debug(msg: any) {
if (process.env.NODE_ENV === "development") { if (isDev) {
console.log(msg) 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, "compileOnSave": true,
"compilerOptions": { "compilerOptions": {
"target": "ES2018", "target": "es2018",
"module": "commonjs", "module": "commonjs",
"lib": [
"es2020",
"DOM",
],
"removeComments": true, "removeComments": true,
"preserveConstEnums": true, "preserveConstEnums": true,
"sourceMap": false, "sourceMap": false,
"files.insertFinalNewline": true "strict": true
}, },
"files": [ "files": [
"./server/util.ts" "./src/util.ts"
] ]
} }

10
vite.config.js

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

Loading…
Cancel
Save