Merge branch 'master' into import-export

# Conflicts:
#	server/server.js
This commit is contained in:
LouisLam 2021-09-23 17:20:13 +08:00
commit 23a63213aa
75 changed files with 4048 additions and 914 deletions

View file

@ -2,8 +2,12 @@
/dist /dist
/node_modules /node_modules
/data /data
/out
/test
/kubernetes
/.do /.do
**/.dockerignore **/.dockerignore
/private
**/.git **/.git
**/.gitignore **/.gitignore
**/docker-compose* **/docker-compose*

View file

@ -1,4 +1,5 @@
module.exports = { module.exports = {
root: true,
env: { env: {
browser: true, browser: true,
commonjs: true, commonjs: true,
@ -16,6 +17,7 @@ module.exports = {
requireConfigFile: false, requireConfigFile: false,
}, },
rules: { rules: {
"linebreak-style": ["error", "unix"],
"camelcase": ["warn", { "camelcase": ["warn", {
"properties": "never", "properties": "never",
"ignoreImports": true "ignoreImports": true
@ -32,11 +34,12 @@ module.exports = {
}, },
], ],
quotes: ["warn", "double"], quotes: ["warn", "double"],
//semi: ['off', 'never'], semi: "warn",
"vue/html-indent": ["warn", 4], // default: 2 "vue/html-indent": ["warn", 4], // default: 2
"vue/max-attributes-per-line": "off", "vue/max-attributes-per-line": "off",
"vue/singleline-html-element-content-newline": "off", "vue/singleline-html-element-content-newline": "off",
"vue/html-self-closing": "off", "vue/html-self-closing": "off",
"vue/attribute-hyphenation": "off", // This change noNL to "no-n-l" unexpectedly
"no-multi-spaces": ["error", { "no-multi-spaces": ["error", {
ignoreEOLComments: true, ignoreEOLComments: true,
}], }],
@ -84,10 +87,10 @@ module.exports = {
}, },
"overrides": [ "overrides": [
{ {
"files": [ "src/languages/*.js" ], "files": [ "src/languages/*.js", "src/icon.js" ],
"rules": { "rules": {
"comma-dangle": ["error", "always-multiline"], "comma-dangle": ["error", "always-multiline"],
} }
} }
] ]
} };

3
.gitignore vendored
View file

@ -8,3 +8,6 @@ dist-ssr
/data /data
!/data/.gitkeep !/data/.gitkeep
.vscode .vscode
/private
/out

View file

@ -107,11 +107,13 @@ If you love this project, please consider giving me a ⭐.
## 🗣️ Discussion ## 🗣️ Discussion
You can also discuss or ask for help in [Issues](https://github.com/louislam/uptime-kuma/issues). ### Issues Page
You can discuss or ask for help in [Issues](https://github.com/louislam/uptime-kuma/issues).
Alternatively, you can discuss in my original post on reddit: https://www.reddit.com/r/selfhosted/comments/oi7dc7/uptime_kuma_a_fancy_selfhosted_monitoring_tool_an/ ### Subreddit
My Reddit account: louislamlam
I think the real "Discussion" tab is hard to use, as it is reddit-like flow, I always missed new comments. You can mention me if you ask question on Reddit.
https://www.reddit.com/r/UptimeKuma/
## Contribute ## Contribute

Binary file not shown.

30
db/patch-group-table.sql Normal file
View file

@ -0,0 +1,30 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
create table `group`
(
id INTEGER not null
constraint group_pk
primary key autoincrement,
name VARCHAR(255) not null,
created_date DATETIME default (DATETIME('now')) not null,
public BOOLEAN default 0 not null,
active BOOLEAN default 1 not null,
weight BOOLEAN NOT NULL DEFAULT 1000
);
CREATE TABLE [monitor_group]
(
[id] INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
[monitor_id] INTEGER NOT NULL REFERENCES [monitor] ([id]) ON DELETE CASCADE ON UPDATE CASCADE,
[group_id] INTEGER NOT NULL REFERENCES [group] ([id]) ON DELETE CASCADE ON UPDATE CASCADE,
weight BOOLEAN NOT NULL DEFAULT 1000
);
CREATE INDEX [fk]
ON [monitor_group] (
[monitor_id],
[group_id]);
COMMIT;

View file

@ -0,0 +1,18 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
create table incident
(
id INTEGER not null
constraint incident_pk
primary key autoincrement,
title VARCHAR(255) not null,
content TEXT not null,
style VARCHAR(30) default 'warning' not null,
created_date DATETIME default (DATETIME('now')) not null,
last_updated_date DATETIME,
pin BOOLEAN default 1 not null,
active BOOLEAN default 1 not null
);
COMMIT;

View file

@ -2,28 +2,22 @@
FROM node:14-buster-slim AS build FROM node:14-buster-slim AS build
WORKDIR /app WORKDIR /app
# split the sqlite install here, so that it can caches the arm prebuilt
# do not modify it, since we don't want to re-compile the arm prebuilt again
RUN apt update && \
apt --yes install python3 python3-pip python3-dev git g++ make && \
ln -s /usr/bin/python3 /usr/bin/python && \
npm install mapbox/node-sqlite3#593c9d --build-from-source
COPY . . COPY . .
RUN npm install --legacy-peer-deps && npm run build && npm prune --production RUN npm install --legacy-peer-deps && \
npm run build && \
npm prune --production && \
chmod +x /app/extra/entrypoint.sh
FROM node:14-bullseye-slim AS release
FROM node:14-buster-slim AS release
WORKDIR /app WORKDIR /app
# Install Apprise, # Install Apprise, add sqlite3 cli for debugging in the future, iputils-ping for ping, util-linux for setpriv
# add sqlite3 cli for debugging in the future
# iputils-ping for ping
RUN apt update && \ RUN apt update && \
apt --yes install python3 python3-pip python3-cryptography python3-six python3-yaml python3-click python3-markdown python3-requests python3-requests-oauthlib \ apt --yes install python3 python3-pip python3-cryptography python3-six python3-yaml python3-click python3-markdown python3-requests python3-requests-oauthlib \
sqlite3 \ sqlite3 iputils-ping util-linux && \
iputils-ping && \ pip3 --no-cache-dir install apprise && \
pip3 --no-cache-dir install apprise && \ rm -rf /var/lib/apt/lists/*
rm -rf /var/lib/apt/lists/*
# Copy app files from build layer # Copy app files from build layer
COPY --from=build /app /app COPY --from=build /app /app
@ -31,6 +25,7 @@ COPY --from=build /app /app
EXPOSE 3001 EXPOSE 3001
VOLUME ["/app/data"] VOLUME ["/app/data"]
HEALTHCHECK --interval=60s --timeout=30s --start-period=180s --retries=5 CMD node extra/healthcheck.js HEALTHCHECK --interval=60s --timeout=30s --start-period=180s --retries=5 CMD node extra/healthcheck.js
ENTRYPOINT ["extra/entrypoint.sh"]
CMD ["node", "server/server.js"] CMD ["node", "server/server.js"]
FROM release AS nightly FROM release AS nightly

View file

@ -2,24 +2,20 @@
FROM node:14-alpine3.12 AS build FROM node:14-alpine3.12 AS build
WORKDIR /app WORKDIR /app
# split the sqlite install here, so that it can caches the arm prebuilt
RUN apk add --no-cache --virtual .build-deps make g++ python3 python3-dev git && \
ln -s /usr/bin/python3 /usr/bin/python && \
npm install mapbox/node-sqlite3#593c9d && \
apk del .build-deps && \
rm -f /usr/bin/python
COPY . . COPY . .
RUN npm install --legacy-peer-deps && npm run build && npm prune --production RUN npm install --legacy-peer-deps && \
npm run build && \
npm prune --production && \
chmod +x /app/extra/entrypoint.sh
FROM node:14-alpine3.12 AS release FROM node:14-alpine3.12 AS release
WORKDIR /app WORKDIR /app
# Install apprise # Install apprise, iputils for non-root ping, setpriv
RUN apk add --no-cache python3 py3-cryptography py3-pip py3-six py3-yaml py3-click py3-markdown py3-requests py3-requests-oauthlib && \ RUN apk add --no-cache iputils setpriv python3 py3-cryptography py3-pip py3-six py3-yaml py3-click py3-markdown py3-requests py3-requests-oauthlib && \
pip3 --no-cache-dir install apprise && \ pip3 --no-cache-dir install apprise && \
rm -rf /root/.cache rm -rf /root/.cache
# Copy app files from build layer # Copy app files from build layer
COPY --from=build /app /app COPY --from=build /app /app
@ -27,6 +23,7 @@ COPY --from=build /app /app
EXPOSE 3001 EXPOSE 3001
VOLUME ["/app/data"] VOLUME ["/app/data"]
HEALTHCHECK --interval=60s --timeout=30s --start-period=180s --retries=5 CMD node extra/healthcheck.js HEALTHCHECK --interval=60s --timeout=30s --start-period=180s --retries=5 CMD node extra/healthcheck.js
ENTRYPOINT ["extra/entrypoint.sh"]
CMD ["node", "server/server.js"] CMD ["node", "server/server.js"]
FROM release AS nightly FROM release AS nightly

21
extra/entrypoint.sh Normal file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env sh
# set -e Exit the script if an error happens
set -e
PUID=${PUID=1000}
PGID=${PGID=1000}
files_ownership () {
# -h Changes the ownership of an encountered symbolic link and not that of the file or directory pointed to by the symbolic link.
# -R Recursively descends the specified directories
# -c Like verbose but report only when a change is made
chown -hRc "$PUID":"$PGID" /app/data
}
echo "==> Performing startup jobs and maintenance tasks"
files_ownership
echo "==> Starting application with user $PUID group $PGID"
# --clear-groups Clear supplementary groups.
exec setpriv --reuid "$PUID" --regid "$PGID" --clear-groups "$@"

View file

@ -6,12 +6,14 @@ const Database = require("../server/database");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const readline = require("readline"); const readline = require("readline");
const { initJWTSecret } = require("../server/util-server"); const { initJWTSecret } = require("../server/util-server");
const args = require("args-parser")(process.argv);
const rl = readline.createInterface({ const rl = readline.createInterface({
input: process.stdin, input: process.stdin,
output: process.stdout output: process.stdout
}); });
(async () => { (async () => {
Database.init(args);
await Database.connect(); await Database.connect();
try { try {

View file

@ -1,4 +1,4 @@
// Need to use es6 to read language files // Need to use ES6 to read language files
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@ -14,6 +14,7 @@ const copyRecursiveSync = function (src, dest) {
let exists = fs.existsSync(src); let exists = fs.existsSync(src);
let stats = exists && fs.statSync(src); let stats = exists && fs.statSync(src);
let isDirectory = exists && stats.isDirectory(); let isDirectory = exists && stats.isDirectory();
if (isDirectory) { if (isDirectory) {
fs.mkdirSync(dest); fs.mkdirSync(dest);
fs.readdirSync(src).forEach(function (childItemName) { fs.readdirSync(src).forEach(function (childItemName) {
@ -24,8 +25,9 @@ const copyRecursiveSync = function (src, dest) {
fs.copyFileSync(src, dest); fs.copyFileSync(src, dest);
} }
}; };
console.log(process.argv)
const baseLangCode = process.argv[2] || "zh-HK"; console.log("Arguments:", process.argv)
const baseLangCode = process.argv[2] || "en";
console.log("Base Lang: " + baseLangCode); console.log("Base Lang: " + baseLangCode);
fs.rmdirSync("./languages", { recursive: true }); fs.rmdirSync("./languages", { recursive: true });
copyRecursiveSync("../../src/languages", "./languages"); copyRecursiveSync("../../src/languages", "./languages");
@ -33,46 +35,50 @@ copyRecursiveSync("../../src/languages", "./languages");
const en = (await import("./languages/en.js")).default; const en = (await import("./languages/en.js")).default;
const baseLang = (await import(`./languages/${baseLangCode}.js`)).default; const baseLang = (await import(`./languages/${baseLangCode}.js`)).default;
const files = fs.readdirSync("./languages"); const files = fs.readdirSync("./languages");
console.log(files); console.log("Files:", files);
for (const file of files) { for (const file of files) {
if (file.endsWith(".js")) { if (!file.endsWith(".js")) {
console.log("Processing " + file); console.log("Skipping " + file)
const lang = await import("./languages/" + file); continue;
}
let obj; console.log("Processing " + file);
const lang = await import("./languages/" + file);
if (lang.default) { let obj;
console.log("is js module");
obj = lang.default; if (lang.default) {
} else { obj = lang.default;
console.log("empty file"); } else {
obj = { console.log("Empty file");
languageName: "<Your Language name in your language (not in English)>" obj = {
}; languageName: "<Your Language name in your language (not in English)>"
} };
}
// En first
for (const key in en) { // En first
if (! obj[key]) { for (const key in en) {
obj[key] = en[key]; if (! obj[key]) {
} obj[key] = en[key];
} }
}
if (baseLang !== en) {
// Base second // Base second
for (const key in baseLang) { for (const key in baseLang) {
if (! obj[key]) { if (! obj[key]) {
obj[key] = key; obj[key] = key;
} }
} }
const code = "export default " + util.inspect(obj, {
depth: null,
});
fs.writeFileSync(`../../src/languages/${file}`, code);
} }
const code = "export default " + util.inspect(obj, {
depth: null,
});
fs.writeFileSync(`../../src/languages/${file}`, code);
} }
fs.rmdirSync("./languages", { recursive: true }); fs.rmdirSync("./languages", { recursive: true });
console.log("Done, fix the format by eslint now"); console.log("Done. Fixing formatting by ESLint...");

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/svg+xml" href="/icon.svg" /> <link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="manifest" href="manifest.json" />
<meta name="theme-color" id="theme-color" content="" /> <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>

323
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@fortawesome/free-regular-svg-icons": "^5.15.4", "@fortawesome/free-regular-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/vue-fontawesome": "^3.0.0-4", "@fortawesome/vue-fontawesome": "^3.0.0-4",
"@louislam/sqlite3": "^5.0.5",
"@popperjs/core": "^2.10.1", "@popperjs/core": "^2.10.1",
"args-parser": "^1.3.0", "args-parser": "^1.3.0",
"axios": "^0.21.4", "axios": "^0.21.4",
@ -37,19 +38,21 @@
"redbean-node": "0.1.2", "redbean-node": "0.1.2",
"socket.io": "^4.2.0", "socket.io": "^4.2.0",
"socket.io-client": "^4.2.0", "socket.io-client": "^4.2.0",
"sqlite3": "github:mapbox/node-sqlite3#593c9d",
"tcp-ping": "^0.1.1", "tcp-ping": "^0.1.1",
"thirty-two": "^1.0.2", "thirty-two": "^1.0.2",
"timezones-list": "^3.0.1", "timezones-list": "^3.0.1",
"v-pagination-3": "^0.1.6", "v-pagination-3": "^0.1.6",
"vue": "^3.2.8", "vue": "next",
"vue-chart-3": "^0.5.8", "vue-chart-3": "^0.5.8",
"vue-confirm-dialog": "^1.0.2", "vue-confirm-dialog": "^1.0.2",
"vue-contenteditable": "^3.0.4",
"vue-i18n": "^9.1.7", "vue-i18n": "^9.1.7",
"vue-image-crop-upload": "^3.0.3",
"vue-multiselect": "^3.0.0-alpha.2", "vue-multiselect": "^3.0.0-alpha.2",
"vue-qrcode": "^1.0.0", "vue-qrcode": "^1.0.0",
"vue-router": "^4.0.11", "vue-router": "^4.0.11",
"vue-toastification": "^2.0.0-rc.1" "vue-toastification": "^2.0.0-rc.1",
"vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.15.4", "@babel/eslint-parser": "^7.15.4",
@ -61,7 +64,7 @@
"dns2": "^2.0.1", "dns2": "^2.0.1",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-plugin-vue": "^7.17.0", "eslint-plugin-vue": "^7.17.0",
"sass": "^1.39.2", "sass": "^1.41.0",
"stylelint": "^13.13.1", "stylelint": "^13.13.1",
"stylelint-config-standard": "^22.0.0", "stylelint-config-standard": "^22.0.0",
"typescript": "^4.4.3", "typescript": "^4.4.3",
@ -737,6 +740,27 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@louislam/sqlite3": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@louislam/sqlite3/-/sqlite3-5.0.5.tgz",
"integrity": "sha512-iWVmEdoWjn8dXVEliXcE7w7rGR8P0o3N9z+ickgC7+xStUZnM+PuEqrVl7U6CQMRwz6x0/q09K8ZG063cI5DPw==",
"hasInstallScript": true,
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.0",
"node-addon-api": "^3.0.0"
},
"optionalDependencies": {
"node-gyp": "^7.1.2"
},
"peerDependencies": {
"node-gyp": "7.x"
},
"peerDependenciesMeta": {
"node-gyp": {
"optional": true
}
}
},
"node_modules/@mapbox/node-pre-gyp": { "node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz",
@ -1278,9 +1302,9 @@
} }
}, },
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -1553,6 +1577,27 @@
"resolved": "https://registry.npmjs.org/babel-plugin-add-module-exports/-/babel-plugin-add-module-exports-0.2.1.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-add-module-exports/-/babel-plugin-add-module-exports-0.2.1.tgz",
"integrity": "sha1-mumh9KjcZ/DN7E9K7aHkOl/2XiU=" "integrity": "sha1-mumh9KjcZ/DN7E9K7aHkOl/2XiU="
}, },
"node_modules/babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
"dependencies": {
"core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0"
}
},
"node_modules/babel-runtime/node_modules/core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
"deprecated": "core-js@<3.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Please, upgrade your dependencies to the actual version of core-js.",
"hasInstallScript": true
},
"node_modules/babel-runtime/node_modules/regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
},
"node_modules/backo2": { "node_modules/backo2": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
@ -2212,9 +2257,9 @@
} }
}, },
"node_modules/csstype": { "node_modules/csstype": {
"version": "2.6.17", "version": "2.6.18",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.17.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.18.tgz",
"integrity": "sha512-u1wmTI1jJGzCJzWndZo8mk4wnPTZd1eOIYTYvuEyOQGfmDl3TrabCCfKnOC86FZwW/9djqTl933UF/cS425i9A==" "integrity": "sha512-RSU6Hyeg14am3Ah4VZEmeX8H7kLwEEirXe6aU2IPfKNvhXwTflK5HQRDNI0ypQXoqmm+QPyG2IaPuQE5zMwSIQ=="
}, },
"node_modules/dashdash": { "node_modules/dashdash": {
"version": "1.14.1", "version": "1.14.1",
@ -2437,9 +2482,9 @@
"integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.3.836", "version": "1.3.840",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.836.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.840.tgz",
"integrity": "sha512-Ney3pHOJBWkG/AqYjrW0hr2AUCsao+2uvq9HUlRP8OlpSdk/zOHOUJP7eu0icDvePC9DlgffuelP4TnOJmMRUg==", "integrity": "sha512-yRoUmTLDJnkIJx23xLY7GbSvnmDCq++NSuxHDQ0jiyDJ9YZBUGJcrdUqm+ZwZFzMbCciVzfem2N2AWiHJcWlbw==",
"dev": true "dev": true
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
@ -2554,9 +2599,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.12.27", "version": "0.12.28",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.12.27.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.12.28.tgz",
"integrity": "sha512-G42siADcTdRU1qRBxhiIiVLG4gcEMyWV4CWfLBdSii+olCueZJHFRHc7EqQRnRvNkSQq88i0k1Oufw/YVueUWQ==", "integrity": "sha512-pZ0FrWZXlvQOATlp14lRSk1N9GkeJ3vLIwOcUoo3ICQn9WNR4rWoNi81pbn6sC1iYUy7QPqNzI3+AEzokwyVcA==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"bin": { "bin": {
@ -3083,9 +3128,9 @@
"dev": true "dev": true
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.14.3", "version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.3.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"integrity": "sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw==", "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@ -4558,9 +4603,9 @@
} }
}, },
"node_modules/minipass": { "node_modules/minipass": {
"version": "3.1.3", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.5.tgz",
"integrity": "sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==", "integrity": "sha512-+8NzxD82XQoNKNrl1d/FSi+X8wAEWR+sbYAfIvub4Nz0d22plFG72CEVVaufV8PNf4qSslFTD8VMOxNVhHCjTw==",
"dependencies": { "dependencies": {
"yallist": "^4.0.0" "yallist": "^4.0.0"
}, },
@ -5978,9 +6023,9 @@
} }
}, },
"node_modules/redbean-node/node_modules/@types/node": { "node_modules/redbean-node/node_modules/@types/node": {
"version": "14.17.15", "version": "14.17.16",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.15.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.16.tgz",
"integrity": "sha512-D1sdW0EcSCmNdLKBGMYb38YsHUS6JcM7yQ6sLQ9KuZ35ck7LYCKE7kYFHOO59ayFOY3zobWVZxf4KXhYHcHYFA==" "integrity": "sha512-WiFf2izl01P1CpeY8WqFAeKWwByMueBEkND38EcN8N68qb0aDG3oIS1P5MhAX5kUdr469qRyqsY/MjanLjsFbQ=="
}, },
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
@ -6234,9 +6279,9 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.39.2", "version": "1.41.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.39.2.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.41.0.tgz",
"integrity": "sha512-4/6Vn2RPc+qNwSclUSKvssh7dqK1Ih3FfHBW16I/GfH47b3scbYeOw65UIrYG7PkweFiKbpJjgkf5CV8EMmvzw==", "integrity": "sha512-wb8nT60cjo9ZZMcHzG7TzdbFtCAmHEKWrH+zAdScPb4ZxL64WQBnGdbp5nwlenW5wJPcHva1JWmVa0h6iqA5eg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"chokidar": ">=3.0.0 <4.0.0" "chokidar": ">=3.0.0 <4.0.0"
@ -6343,9 +6388,9 @@
} }
}, },
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "3.0.3", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.4.tgz",
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" "integrity": "sha512-rqYhcAnZ6d/vTPGghdrw7iumdcbXpsk1b8IG/rz+VWV51DM0p7XCtMoJ3qhPLIbp3tvyt3pKRbaaEMZYpHto8Q=="
}, },
"node_modules/slash": { "node_modules/slash": {
"version": "3.0.0", "version": "3.0.0",
@ -6436,6 +6481,11 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -6506,27 +6556,6 @@
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
"dev": true "dev": true
}, },
"node_modules/sqlite3": {
"version": "5.0.2",
"resolved": "git+ssh://git@github.com/mapbox/node-sqlite3.git#593c9d498be2510d286349134537e3bf89401c4a",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.0",
"node-addon-api": "^3.0.0"
},
"optionalDependencies": {
"node-gyp": "7.x"
},
"peerDependencies": {
"node-gyp": "7.x"
},
"peerDependenciesMeta": {
"node-gyp": {
"optional": true
}
}
},
"node_modules/sshpk": { "node_modules/sshpk": {
"version": "1.16.1", "version": "1.16.1",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
@ -7550,6 +7579,14 @@
"vue": "^2.6.10" "vue": "^2.6.10"
} }
}, },
"node_modules/vue-contenteditable": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/vue-contenteditable/-/vue-contenteditable-3.0.4.tgz",
"integrity": "sha512-CmtqT4zHQwLoJEyNVaXUjjUFPUVYlXXBHfSbRCHCUjODMqrn6L293LM1nc1ELx8epitZZvecTfIqOLlSzB3d+w==",
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-demi": { "node_modules/vue-demi": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.10.1.tgz", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.10.1.tgz",
@ -7636,6 +7673,14 @@
"vue": "^3.0.0" "vue": "^3.0.0"
} }
}, },
"node_modules/vue-image-crop-upload": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/vue-image-crop-upload/-/vue-image-crop-upload-3.0.3.tgz",
"integrity": "sha512-VeBsU0oI1hXeCvdpnu19DM/r3KTlI8SUXTxsHsU4MhDXR0ahRziiL9tf4FbILGx+gRVNZhGbl32yuM6TiaGNhA==",
"dependencies": {
"babel-runtime": "^6.11.6"
}
},
"node_modules/vue-multiselect": { "node_modules/vue-multiselect": {
"version": "3.0.0-alpha.2", "version": "3.0.0-alpha.2",
"resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-3.0.0-alpha.2.tgz", "resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-3.0.0-alpha.2.tgz",
@ -7708,6 +7753,17 @@
"vue": "^3.0.2" "vue": "^3.0.2"
} }
}, },
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"dependencies": {
"sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -8445,7 +8501,8 @@
"@fortawesome/vue-fontawesome": { "@fortawesome/vue-fontawesome": {
"version": "3.0.0-4", "version": "3.0.0-4",
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.0-4.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.0-4.tgz",
"integrity": "sha512-dQVhhMRcUPCb0aqk5ohm0KGk5OJ7wFZ9aYapLzJB3Z+xs7LhkRWLTb87reelUAG5PFDjutDAXuloT9hi6cz72A==" "integrity": "sha512-dQVhhMRcUPCb0aqk5ohm0KGk5OJ7wFZ9aYapLzJB3Z+xs7LhkRWLTb87reelUAG5PFDjutDAXuloT9hi6cz72A==",
"requires": {}
}, },
"@humanwhocodes/config-array": { "@humanwhocodes/config-array": {
"version": "0.5.0", "version": "0.5.0",
@ -8525,6 +8582,16 @@
"@intlify/shared": "9.1.7" "@intlify/shared": "9.1.7"
} }
}, },
"@louislam/sqlite3": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@louislam/sqlite3/-/sqlite3-5.0.5.tgz",
"integrity": "sha512-iWVmEdoWjn8dXVEliXcE7w7rGR8P0o3N9z+ickgC7+xStUZnM+PuEqrVl7U6CQMRwz6x0/q09K8ZG063cI5DPw==",
"requires": {
"@mapbox/node-pre-gyp": "^1.0.0",
"node-addon-api": "^3.0.0",
"node-gyp": "^7.1.2"
}
},
"@mapbox/node-pre-gyp": { "@mapbox/node-pre-gyp": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz",
@ -8839,7 +8906,8 @@
"version": "1.6.2", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-1.6.2.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-1.6.2.tgz",
"integrity": "sha512-Pf+dqkT4pWPfziPm51VtDXsPwE74CEGRiK6Vgm5EDBewHw1EgcxG7V2ZI/Yqj5gcDy5nVtjgx0AbsTL+F3gddg==", "integrity": "sha512-Pf+dqkT4pWPfziPm51VtDXsPwE74CEGRiK6Vgm5EDBewHw1EgcxG7V2ZI/Yqj5gcDy5nVtjgx0AbsTL+F3gddg==",
"dev": true "dev": true,
"requires": {}
}, },
"@vue/compiler-core": { "@vue/compiler-core": {
"version": "3.2.11", "version": "3.2.11",
@ -8972,7 +9040,8 @@
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true "dev": true,
"requires": {}
}, },
"agent-base": { "agent-base": {
"version": "6.0.2", "version": "6.0.2",
@ -9001,9 +9070,9 @@
"dev": true "dev": true
}, },
"ansi-regex": { "ansi-regex": {
"version": "5.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true "dev": true
}, },
"ansi-styles": { "ansi-styles": {
@ -9221,6 +9290,27 @@
"resolved": "https://registry.npmjs.org/babel-plugin-add-module-exports/-/babel-plugin-add-module-exports-0.2.1.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-add-module-exports/-/babel-plugin-add-module-exports-0.2.1.tgz",
"integrity": "sha1-mumh9KjcZ/DN7E9K7aHkOl/2XiU=" "integrity": "sha1-mumh9KjcZ/DN7E9K7aHkOl/2XiU="
}, },
"babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
"requires": {
"core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0"
},
"dependencies": {
"core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ=="
},
"regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
}
}
},
"backo2": { "backo2": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
@ -9332,7 +9422,8 @@
"bootstrap": { "bootstrap": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.1.1.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.1.1.tgz",
"integrity": "sha512-/jUa4sSuDZWlDLQ1gwQQR8uoYSvLJzDd8m5o6bPKh3asLAMYVZKdRCjb1joUd5WXf0WwCNzd2EjwQQhupou0dA==" "integrity": "sha512-/jUa4sSuDZWlDLQ1gwQQR8uoYSvLJzDd8m5o6bPKh3asLAMYVZKdRCjb1joUd5WXf0WwCNzd2EjwQQhupou0dA==",
"requires": {}
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
@ -9478,7 +9569,8 @@
"chartjs-adapter-dayjs": { "chartjs-adapter-dayjs": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs/-/chartjs-adapter-dayjs-1.0.0.tgz", "resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs/-/chartjs-adapter-dayjs-1.0.0.tgz",
"integrity": "sha512-EnbVqTJGFKLpg1TROLdCEufrzbmIa2oeLGx8O2Wdjw2EoMudoOo9+YFu+6CM0Z0hQ/v3yq/e/Y6efQMu22n8Jg==" "integrity": "sha512-EnbVqTJGFKLpg1TROLdCEufrzbmIa2oeLGx8O2Wdjw2EoMudoOo9+YFu+6CM0Z0hQ/v3yq/e/Y6efQMu22n8Jg==",
"requires": {}
}, },
"chokidar": { "chokidar": {
"version": "3.5.2", "version": "3.5.2",
@ -9706,9 +9798,9 @@
"dev": true "dev": true
}, },
"csstype": { "csstype": {
"version": "2.6.17", "version": "2.6.18",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.17.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.18.tgz",
"integrity": "sha512-u1wmTI1jJGzCJzWndZo8mk4wnPTZd1eOIYTYvuEyOQGfmDl3TrabCCfKnOC86FZwW/9djqTl933UF/cS425i9A==" "integrity": "sha512-RSU6Hyeg14am3Ah4VZEmeX8H7kLwEEirXe6aU2IPfKNvhXwTflK5HQRDNI0ypQXoqmm+QPyG2IaPuQE5zMwSIQ=="
}, },
"dashdash": { "dashdash": {
"version": "1.14.1", "version": "1.14.1",
@ -9888,9 +9980,9 @@
"integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
}, },
"electron-to-chromium": { "electron-to-chromium": {
"version": "1.3.836", "version": "1.3.840",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.836.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.840.tgz",
"integrity": "sha512-Ney3pHOJBWkG/AqYjrW0hr2AUCsao+2uvq9HUlRP8OlpSdk/zOHOUJP7eu0icDvePC9DlgffuelP4TnOJmMRUg==", "integrity": "sha512-yRoUmTLDJnkIJx23xLY7GbSvnmDCq++NSuxHDQ0jiyDJ9YZBUGJcrdUqm+ZwZFzMbCciVzfem2N2AWiHJcWlbw==",
"dev": true "dev": true
}, },
"emoji-regex": { "emoji-regex": {
@ -9986,9 +10078,9 @@
} }
}, },
"esbuild": { "esbuild": {
"version": "0.12.27", "version": "0.12.28",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.12.27.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.12.28.tgz",
"integrity": "sha512-G42siADcTdRU1qRBxhiIiVLG4gcEMyWV4CWfLBdSii+olCueZJHFRHc7EqQRnRvNkSQq88i0k1Oufw/YVueUWQ==", "integrity": "sha512-pZ0FrWZXlvQOATlp14lRSk1N9GkeJ3vLIwOcUoo3ICQn9WNR4rWoNi81pbn6sC1iYUy7QPqNzI3+AEzokwyVcA==",
"dev": true "dev": true
}, },
"escalade": { "escalade": {
@ -10413,9 +10505,9 @@
"dev": true "dev": true
}, },
"follow-redirects": { "follow-redirects": {
"version": "1.14.3", "version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.3.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"integrity": "sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw==" "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g=="
}, },
"forever-agent": { "forever-agent": {
"version": "0.6.1", "version": "0.6.1",
@ -10818,7 +10910,8 @@
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
"integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
"dev": true "dev": true,
"requires": {}
}, },
"ieee754": { "ieee754": {
"version": "1.2.1", "version": "1.2.1",
@ -11507,9 +11600,9 @@
} }
}, },
"minipass": { "minipass": {
"version": "3.1.3", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.5.tgz",
"integrity": "sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==", "integrity": "sha512-+8NzxD82XQoNKNrl1d/FSi+X8wAEWR+sbYAfIvub4Nz0d22plFG72CEVVaufV8PNf4qSslFTD8VMOxNVhHCjTw==",
"requires": { "requires": {
"yallist": "^4.0.0" "yallist": "^4.0.0"
}, },
@ -12032,7 +12125,8 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
"integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
"dev": true "dev": true,
"requires": {}
}, },
"postcss-modules-local-by-default": { "postcss-modules-local-by-default": {
"version": "4.0.0", "version": "4.0.0",
@ -12351,7 +12445,8 @@
"version": "0.36.2", "version": "0.36.2",
"resolved": "https://registry.npmjs.org/postcss-syntax/-/postcss-syntax-0.36.2.tgz", "resolved": "https://registry.npmjs.org/postcss-syntax/-/postcss-syntax-0.36.2.tgz",
"integrity": "sha512-nBRg/i7E3SOHWxF3PpF5WnJM/jQ1YpY9000OaVXlAQj6Zp/kIqJxEDWIZ67tAd7NLuk7zqN4yqe9nc0oNAOs1w==", "integrity": "sha512-nBRg/i7E3SOHWxF3PpF5WnJM/jQ1YpY9000OaVXlAQj6Zp/kIqJxEDWIZ67tAd7NLuk7zqN4yqe9nc0oNAOs1w==",
"dev": true "dev": true,
"requires": {}
}, },
"postcss-value-parser": { "postcss-value-parser": {
"version": "4.1.0", "version": "4.1.0",
@ -12590,9 +12685,9 @@
}, },
"dependencies": { "dependencies": {
"@types/node": { "@types/node": {
"version": "14.17.15", "version": "14.17.16",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.15.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.16.tgz",
"integrity": "sha512-D1sdW0EcSCmNdLKBGMYb38YsHUS6JcM7yQ6sLQ9KuZ35ck7LYCKE7kYFHOO59ayFOY3zobWVZxf4KXhYHcHYFA==" "integrity": "sha512-WiFf2izl01P1CpeY8WqFAeKWwByMueBEkND38EcN8N68qb0aDG3oIS1P5MhAX5kUdr469qRyqsY/MjanLjsFbQ=="
} }
} }
}, },
@ -12774,9 +12869,9 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
}, },
"sass": { "sass": {
"version": "1.39.2", "version": "1.41.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.39.2.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.41.0.tgz",
"integrity": "sha512-4/6Vn2RPc+qNwSclUSKvssh7dqK1Ih3FfHBW16I/GfH47b3scbYeOw65UIrYG7PkweFiKbpJjgkf5CV8EMmvzw==", "integrity": "sha512-wb8nT60cjo9ZZMcHzG7TzdbFtCAmHEKWrH+zAdScPb4ZxL64WQBnGdbp5nwlenW5wJPcHva1JWmVa0h6iqA5eg==",
"dev": true, "dev": true,
"requires": { "requires": {
"chokidar": ">=3.0.0 <4.0.0" "chokidar": ">=3.0.0 <4.0.0"
@ -12866,9 +12961,9 @@
"dev": true "dev": true
}, },
"signal-exit": { "signal-exit": {
"version": "3.0.3", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.4.tgz",
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" "integrity": "sha512-rqYhcAnZ6d/vTPGghdrw7iumdcbXpsk1b8IG/rz+VWV51DM0p7XCtMoJ3qhPLIbp3tvyt3pKRbaaEMZYpHto8Q=="
}, },
"slash": { "slash": {
"version": "3.0.0", "version": "3.0.0",
@ -12940,6 +13035,11 @@
"debug": "~4.3.1" "debug": "~4.3.1"
} }
}, },
"sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
},
"source-map": { "source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -13001,15 +13101,6 @@
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
"dev": true "dev": true
}, },
"sqlite3": {
"version": "git+ssh://git@github.com/mapbox/node-sqlite3.git#593c9d498be2510d286349134537e3bf89401c4a",
"from": "sqlite3@github:mapbox/node-sqlite3#593c9d",
"requires": {
"@mapbox/node-pre-gyp": "^1.0.0",
"node-addon-api": "^3.0.0",
"node-gyp": "7.x"
}
},
"sshpk": { "sshpk": {
"version": "1.16.1", "version": "1.16.1",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
@ -13284,7 +13375,8 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-5.0.0.tgz", "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-5.0.0.tgz",
"integrity": "sha512-c8aubuARSu5A3vEHLBeOSJt1udOdS+1iue7BmJDTSXoCBmfEQmmWX+59vYIj3NQdJBY6a/QRv1ozVFpaB9jaqA==", "integrity": "sha512-c8aubuARSu5A3vEHLBeOSJt1udOdS+1iue7BmJDTSXoCBmfEQmmWX+59vYIj3NQdJBY6a/QRv1ozVFpaB9jaqA==",
"dev": true "dev": true,
"requires": {}
}, },
"stylelint-config-standard": { "stylelint-config-standard": {
"version": "22.0.0", "version": "22.0.0",
@ -13808,12 +13900,20 @@
"vue-confirm-dialog": { "vue-confirm-dialog": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/vue-confirm-dialog/-/vue-confirm-dialog-1.0.2.tgz", "resolved": "https://registry.npmjs.org/vue-confirm-dialog/-/vue-confirm-dialog-1.0.2.tgz",
"integrity": "sha512-gTo1bMDWOLd/6ihmWv8VlPxhc9QaKoE5YqlsKydUOfrrQ3Q3taljF6yI+1TMtAtJLrvZ8DYrePhgBhY1VCJzbQ==" "integrity": "sha512-gTo1bMDWOLd/6ihmWv8VlPxhc9QaKoE5YqlsKydUOfrrQ3Q3taljF6yI+1TMtAtJLrvZ8DYrePhgBhY1VCJzbQ==",
"requires": {}
},
"vue-contenteditable": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/vue-contenteditable/-/vue-contenteditable-3.0.4.tgz",
"integrity": "sha512-CmtqT4zHQwLoJEyNVaXUjjUFPUVYlXXBHfSbRCHCUjODMqrn6L293LM1nc1ELx8epitZZvecTfIqOLlSzB3d+w==",
"requires": {}
}, },
"vue-demi": { "vue-demi": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.10.1.tgz", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.10.1.tgz",
"integrity": "sha512-L6Oi+BvmMv6YXvqv5rJNCFHEKSVu7llpWWJczqmAQYOdmPPw5PNYoz1KKS//Fxhi+4QP64dsPjtmvnYGo1jemA==" "integrity": "sha512-L6Oi+BvmMv6YXvqv5rJNCFHEKSVu7llpWWJczqmAQYOdmPPw5PNYoz1KKS//Fxhi+4QP64dsPjtmvnYGo1jemA==",
"requires": {}
}, },
"vue-eslint-parser": { "vue-eslint-parser": {
"version": "7.11.0", "version": "7.11.0",
@ -13860,6 +13960,14 @@
"@vue/devtools-api": "^6.0.0-beta.7" "@vue/devtools-api": "^6.0.0-beta.7"
} }
}, },
"vue-image-crop-upload": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/vue-image-crop-upload/-/vue-image-crop-upload-3.0.3.tgz",
"integrity": "sha512-VeBsU0oI1hXeCvdpnu19DM/r3KTlI8SUXTxsHsU4MhDXR0ahRziiL9tf4FbILGx+gRVNZhGbl32yuM6TiaGNhA==",
"requires": {
"babel-runtime": "^6.11.6"
}
},
"vue-multiselect": { "vue-multiselect": {
"version": "3.0.0-alpha.2", "version": "3.0.0-alpha.2",
"resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-3.0.0-alpha.2.tgz", "resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-3.0.0-alpha.2.tgz",
@ -13877,7 +13985,8 @@
"vue-demi": { "vue-demi": {
"version": "0.11.4", "version": "0.11.4",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.11.4.tgz", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.11.4.tgz",
"integrity": "sha512-/3xFwzSykLW2HiiLie43a+FFgNOcokbBJ+fzvFXd0r2T8MYohqvphUyDQ8lbAwzQ3Dlcrb1c9ykifGkhSIAk6A==" "integrity": "sha512-/3xFwzSykLW2HiiLie43a+FFgNOcokbBJ+fzvFXd0r2T8MYohqvphUyDQ8lbAwzQ3Dlcrb1c9ykifGkhSIAk6A==",
"requires": {}
} }
} }
}, },
@ -13892,7 +14001,16 @@
"vue-toastification": { "vue-toastification": {
"version": "2.0.0-rc.1", "version": "2.0.0-rc.1",
"resolved": "https://registry.npmjs.org/vue-toastification/-/vue-toastification-2.0.0-rc.1.tgz", "resolved": "https://registry.npmjs.org/vue-toastification/-/vue-toastification-2.0.0-rc.1.tgz",
"integrity": "sha512-hjauv/FyesNZdwcr5m1SCyvu1JmlB+Ts5bTptDLDmsYYlj6Oqv8NYakiElpCF+Abwkn9J/AChh6FwkTL1HOb7Q==" "integrity": "sha512-hjauv/FyesNZdwcr5m1SCyvu1JmlB+Ts5bTptDLDmsYYlj6Oqv8NYakiElpCF+Abwkn9J/AChh6FwkTL1HOb7Q==",
"requires": {}
},
"vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"requires": {
"sortablejs": "1.14.0"
}
}, },
"which": { "which": {
"version": "2.0.2", "version": "2.0.2",
@ -14003,7 +14121,8 @@
"ws": { "ws": {
"version": "7.4.6", "version": "7.4.6",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
"integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==" "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==",
"requires": {}
}, },
"xmlhttprequest-ssl": { "xmlhttprequest-ssl": {
"version": "2.0.0", "version": "2.0.0",

View file

@ -10,8 +10,8 @@
"node": "14.*" "node": "14.*"
}, },
"scripts": { "scripts": {
"install-legacy-peer-deps": "npm install --legacy-peer-deps", "install-legacy": "npm install --legacy-peer-deps",
"update-legacy-peer-deps": "npm update --legacy-peer-deps", "update-legacy": "npm update --legacy-peer-deps",
"lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .", "lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .",
"lint:style": "stylelint \"**/*.{vue,css,scss}\" --ignore-path .gitignore", "lint:style": "stylelint \"**/*.{vue,css,scss}\" --ignore-path .gitignore",
"lint": "npm run lint:js && npm run lint:style", "lint": "npm run lint:js && npm run lint:style",
@ -19,11 +19,13 @@
"start": "npm run start-server", "start": "npm run start-server",
"start-server": "node server/server.js", "start-server": "node server/server.js",
"build": "vite build", "build": "vite build",
"tsc": "tsc",
"vite-preview-dist": "vite preview --host", "vite-preview-dist": "vite preview --host",
"build-docker": "npm run build-docker-debian && npm run build-docker-alpine", "build-docker": "npm run build-docker-debian && npm run build-docker-alpine",
"build-docker-alpine": "docker buildx build -f dockerfile-alpine --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:alpine -t louislam/uptime-kuma:1-alpine -t louislam/uptime-kuma:1.6.0-alpine --target release . --push", "build-docker-alpine": "docker buildx build -f dockerfile-alpine --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:alpine -t louislam/uptime-kuma:1-alpine -t louislam/uptime-kuma:1.6.0-alpine --target release . --push",
"build-docker-debian": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:1.6.0 -t louislam/uptime-kuma:debian -t louislam/uptime-kuma:1-debian -t louislam/uptime-kuma:1.6.0-debian --target release . --push", "build-docker-debian": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:1.6.0 -t louislam/uptime-kuma:debian -t louislam/uptime-kuma:1-debian -t louislam/uptime-kuma:1.6.0-debian --target release . --push",
"build-docker-nightly": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly --target nightly . --push", "build-docker-nightly": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly --target nightly . --push",
"build-docker-nightly-alpine": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly-alpine --target nightly . --push",
"build-docker-nightly-amd64": "docker buildx build --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain", "build-docker-nightly-amd64": "docker buildx build --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain",
"setup": "git checkout 1.6.0 && npm install --legacy-peer-deps && node node_modules/esbuild/install.js && npm run build && npm prune", "setup": "git checkout 1.6.0 && npm install --legacy-peer-deps && node node_modules/esbuild/install.js && npm run build && npm prune",
"update-version": "node extra/update-version.js", "update-version": "node extra/update-version.js",
@ -34,14 +36,17 @@
"test-install-script-alpine3": "npm run compile-install-script && docker build --progress plain -f test/test_install_script/alpine3.dockerfile .", "test-install-script-alpine3": "npm run compile-install-script && docker build --progress plain -f test/test_install_script/alpine3.dockerfile .",
"test-install-script-ubuntu": "npm run compile-install-script && docker build --progress plain -f test/test_install_script/ubuntu.dockerfile .", "test-install-script-ubuntu": "npm run compile-install-script && docker build --progress plain -f test/test_install_script/ubuntu.dockerfile .",
"test-install-script-ubuntu1604": "npm run compile-install-script && docker build --progress plain -f test/test_install_script/ubuntu1604.dockerfile .", "test-install-script-ubuntu1604": "npm run compile-install-script && docker build --progress plain -f test/test_install_script/ubuntu1604.dockerfile .",
"test-nodejs16": "docker build --progress plain -f test/ubuntu-nodejs16.dockerfile .",
"simple-dns-server": "node extra/simple-dns-server.js", "simple-dns-server": "node extra/simple-dns-server.js",
"update-language-files": "cd extra/update-language-files && node index.js %npm_config_base_lang% && eslint ../../src/languages/**.js --fix" "update-language-files-with-base-lang": "cd extra/update-language-files && node index.js %npm_config_base_lang% && eslint ../../src/languages/**.js --fix",
"update-language-files": "cd extra/update-language-files && node index.js && eslint ../../src/languages/**.js --fix"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.36", "@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-regular-svg-icons": "^5.15.4", "@fortawesome/free-regular-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/vue-fontawesome": "^3.0.0-4", "@fortawesome/vue-fontawesome": "^3.0.0-4",
"@louislam/sqlite3": "^5.0.5",
"@popperjs/core": "^2.10.1", "@popperjs/core": "^2.10.1",
"args-parser": "^1.3.0", "args-parser": "^1.3.0",
"axios": "^0.21.4", "axios": "^0.21.4",
@ -66,19 +71,21 @@
"redbean-node": "0.1.2", "redbean-node": "0.1.2",
"socket.io": "^4.2.0", "socket.io": "^4.2.0",
"socket.io-client": "^4.2.0", "socket.io-client": "^4.2.0",
"sqlite3": "github:mapbox/node-sqlite3#593c9d",
"tcp-ping": "^0.1.1", "tcp-ping": "^0.1.1",
"timezones-list": "^3.0.1",
"thirty-two": "^1.0.2", "thirty-two": "^1.0.2",
"timezones-list": "^3.0.1",
"v-pagination-3": "^0.1.6", "v-pagination-3": "^0.1.6",
"vue": "^3.2.8", "vue": "next",
"vue-chart-3": "^0.5.8", "vue-chart-3": "^0.5.8",
"vue-confirm-dialog": "^1.0.2", "vue-confirm-dialog": "^1.0.2",
"vue-contenteditable": "^3.0.4",
"vue-i18n": "^9.1.7", "vue-i18n": "^9.1.7",
"vue-image-crop-upload": "^3.0.3",
"vue-multiselect": "^3.0.0-alpha.2", "vue-multiselect": "^3.0.0-alpha.2",
"vue-qrcode": "^1.0.0", "vue-qrcode": "^1.0.0",
"vue-router": "^4.0.11", "vue-router": "^4.0.11",
"vue-toastification": "^2.0.0-rc.1" "vue-toastification": "^2.0.0-rc.1",
"vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.15.4", "@babel/eslint-parser": "^7.15.4",
@ -90,7 +97,7 @@
"dns2": "^2.0.1", "dns2": "^2.0.1",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-plugin-vue": "^7.17.0", "eslint-plugin-vue": "^7.17.0",
"sass": "^1.39.2", "sass": "^1.41.0",
"stylelint": "^13.13.1", "stylelint": "^13.13.1",
"stylelint-config-standard": "^22.0.0", "stylelint-config-standard": "^22.0.0",
"typescript": "^4.4.3", "typescript": "^4.4.3",

BIN
public/icon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/icon-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

19
public/manifest.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "Uptime Kuma",
"short_name": "Uptime Kuma",
"start_url": "/",
"background_color": "#fff",
"display": "standalone",
"icons": [
{
"src": "icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View file

@ -18,7 +18,7 @@ exports.startInterval = () => {
// For debug // For debug
if (process.env.TEST_CHECK_VERSION === "1") { if (process.env.TEST_CHECK_VERSION === "1") {
res.data.version = "1000.0.0" res.data.version = "1000.0.0";
} }
exports.latestVersion = res.data.version; exports.latestVersion = res.data.version;

View file

@ -3,11 +3,25 @@ const { R } = require("redbean-node");
const { setSetting, setting } = require("./util-server"); const { setSetting, setting } = require("./util-server");
const { debug, sleep } = require("../src/util"); const { debug, sleep } = require("../src/util");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const knex = require("knex");
/**
* Database & App Data Folder
*/
class Database { class Database {
static templatePath = "./db/kuma.db"; static templatePath = "./db/kuma.db";
/**
* Data Dir (Default: ./data)
*/
static dataDir; static dataDir;
/**
* User Upload Dir (Default: ./data/upload)
*/
static uploadDir;
static path; static path;
/** /**
@ -32,6 +46,8 @@ class Database {
"patch-improve-performance.sql": true, "patch-improve-performance.sql": true,
"patch-2fa.sql": true, "patch-2fa.sql": true,
"patch-add-retry-interval-monitor.sql": true, "patch-add-retry-interval-monitor.sql": true,
"patch-incident-table.sql": true,
"patch-group-table.sql": true,
} }
/** /**
@ -42,27 +58,53 @@ class Database {
static noReject = true; static noReject = true;
static init(args) {
// Data Directory (must be end with "/")
Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/";
Database.path = Database.dataDir + "kuma.db";
if (! fs.existsSync(Database.dataDir)) {
fs.mkdirSync(Database.dataDir, { recursive: true });
}
Database.uploadDir = Database.dataDir + "upload/";
if (! fs.existsSync(Database.uploadDir)) {
fs.mkdirSync(Database.uploadDir, { recursive: true });
}
console.log(`Data Dir: ${Database.dataDir}`);
}
static async connect() { static async connect() {
const acquireConnectionTimeout = 120 * 1000; const acquireConnectionTimeout = 120 * 1000;
R.setup("sqlite", { const Dialect = require("knex/lib/dialects/sqlite3/index.js");
filename: Database.path, Dialect.prototype._driver = () => require("@louislam/sqlite3");
const knexInstance = knex({
client: Dialect,
connection: {
filename: Database.path,
acquireConnectionTimeout: acquireConnectionTimeout,
},
useNullAsDefault: true, useNullAsDefault: true,
acquireConnectionTimeout: acquireConnectionTimeout, pool: {
}, { min: 1,
min: 1, max: 1,
max: 1, idleTimeoutMillis: 120 * 1000,
idleTimeoutMillis: 120 * 1000, propagateCreateError: false,
propagateCreateError: false, acquireTimeoutMillis: acquireConnectionTimeout,
acquireTimeoutMillis: acquireConnectionTimeout, }
}); });
R.setup(knexInstance);
if (process.env.SQL_LOG === "1") { if (process.env.SQL_LOG === "1") {
R.debug(true); R.debug(true);
} }
// Auto map the model to a bean object // Auto map the model to a bean object
R.freeze(true) R.freeze(true);
await R.autoloadModels("./server/model"); await R.autoloadModels("./server/model");
// Change to WAL // Change to WAL
@ -72,6 +114,7 @@ class Database {
console.log("SQLite config:"); console.log("SQLite config:");
console.log(await R.getAll("PRAGMA journal_mode")); console.log(await R.getAll("PRAGMA journal_mode"));
console.log(await R.getAll("PRAGMA cache_size")); console.log(await R.getAll("PRAGMA cache_size"));
console.log("SQLite Version: " + await R.getCell("SELECT sqlite_version()"));
} }
static async patch() { static async patch() {
@ -89,7 +132,7 @@ class Database {
} else if (version > this.latestVersion) { } else if (version > this.latestVersion) {
console.info("Warning: Database version is newer than expected"); console.info("Warning: Database version is newer than expected");
} else { } else {
console.info("Database patch is needed") console.info("Database patch is needed");
this.backup(version); this.backup(version);
@ -104,11 +147,12 @@ class Database {
} }
} catch (ex) { } catch (ex) {
await Database.close(); await Database.close();
this.restore();
console.error(ex) console.error(ex);
console.error("Start Uptime-Kuma failed due to patch db failed") console.error("Start Uptime-Kuma failed due to patch db failed");
console.error("Please submit the bug report if you still encounter the problem after restart: https://github.com/louislam/uptime-kuma/issues") console.error("Please submit the bug report if you still encounter the problem after restart: https://github.com/louislam/uptime-kuma/issues");
this.restore();
process.exit(1); process.exit(1);
} }
} }
@ -133,7 +177,7 @@ class Database {
try { try {
for (let sqlFilename in this.patchList) { for (let sqlFilename in this.patchList) {
await this.patch2Recursion(sqlFilename, databasePatchedFiles) await this.patch2Recursion(sqlFilename, databasePatchedFiles);
} }
if (this.patched) { if (this.patched) {
@ -142,11 +186,13 @@ class Database {
} catch (ex) { } catch (ex) {
await Database.close(); await Database.close();
this.restore();
console.error(ex) console.error(ex);
console.error("Start Uptime-Kuma failed due to patch db failed"); console.error("Start Uptime-Kuma failed due to patch db failed");
console.error("Please submit the bug report if you still encounter the problem after restart: https://github.com/louislam/uptime-kuma/issues"); console.error("Please submit the bug report if you still encounter the problem after restart: https://github.com/louislam/uptime-kuma/issues");
this.restore();
process.exit(1); process.exit(1);
} }
@ -186,7 +232,7 @@ class Database {
console.log(sqlFilename + " is patched successfully"); console.log(sqlFilename + " is patched successfully");
} else { } else {
console.log(sqlFilename + " is already patched, skip"); debug(sqlFilename + " is already patched, skip");
} }
} }
@ -204,12 +250,12 @@ class Database {
// Remove all comments (--) // Remove all comments (--)
let lines = text.split("\n"); let lines = text.split("\n");
lines = lines.filter((line) => { lines = lines.filter((line) => {
return ! line.startsWith("--") return ! line.startsWith("--");
}); });
// Split statements by semicolon // Split statements by semicolon
// Filter out empty line // Filter out empty line
text = lines.join("\n") text = lines.join("\n");
let statements = text.split(";") let statements = text.split(";")
.map((statement) => { .map((statement) => {
@ -217,7 +263,7 @@ class Database {
}) })
.filter((statement) => { .filter((statement) => {
return statement !== ""; return statement !== "";
}) });
for (let statement of statements) { for (let statement of statements) {
await R.exec(statement); await R.exec(statement);
@ -263,7 +309,7 @@ class Database {
*/ */
static backup(version) { static backup(version) {
if (! this.backupPath) { if (! this.backupPath) {
console.info("Backup the db") console.info("Backup the db");
this.backupPath = this.dataDir + "kuma.db.bak" + version; this.backupPath = this.dataDir + "kuma.db.bak" + version;
fs.copyFileSync(Database.path, this.backupPath); fs.copyFileSync(Database.path, this.backupPath);

57
server/image-data-uri.js Normal file
View file

@ -0,0 +1,57 @@
/*
From https://github.com/DiegoZoracKy/image-data-uri/blob/master/lib/image-data-uri.js
Modified with 0 dependencies
*/
let fs = require("fs");
let ImageDataURI = (() => {
function decode(dataURI) {
if (!/data:image\//.test(dataURI)) {
console.log("ImageDataURI :: Error :: It seems that it is not an Image Data URI. Couldn't match \"data:image/\"");
return null;
}
let regExMatches = dataURI.match("data:(image/.*);base64,(.*)");
return {
imageType: regExMatches[1],
dataBase64: regExMatches[2],
dataBuffer: new Buffer(regExMatches[2], "base64")
};
}
function encode(data, mediaType) {
if (!data || !mediaType) {
console.log("ImageDataURI :: Error :: Missing some of the required params: data, mediaType ");
return null;
}
mediaType = (/\//.test(mediaType)) ? mediaType : "image/" + mediaType;
let dataBase64 = (Buffer.isBuffer(data)) ? data.toString("base64") : new Buffer(data).toString("base64");
let dataImgBase64 = "data:" + mediaType + ";base64," + dataBase64;
return dataImgBase64;
}
function outputFile(dataURI, filePath) {
filePath = filePath || "./";
return new Promise((resolve, reject) => {
let imageDecoded = decode(dataURI);
fs.writeFile(filePath, imageDecoded.dataBuffer, err => {
if (err) {
return reject("ImageDataURI :: Error :: " + JSON.stringify(err, null, 4));
}
resolve(filePath);
});
});
}
return {
decode: decode,
encode: encode,
outputFile: outputFile,
};
})();
module.exports = ImageDataURI;

34
server/model/group.js Normal file
View file

@ -0,0 +1,34 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
const { R } = require("redbean-node");
class Group extends BeanModel {
async toPublicJSON() {
let monitorBeanList = await this.getMonitorList();
let monitorList = [];
for (let bean of monitorBeanList) {
monitorList.push(await bean.toPublicJSON());
}
return {
id: this.id,
name: this.name,
weight: this.weight,
monitorList,
};
}
async getMonitorList() {
return R.convertToBeans("monitor", await R.getAll(`
SELECT monitor.* FROM monitor, monitor_group
WHERE monitor.id = monitor_group.monitor_id
AND group_id = ?
ORDER BY monitor_group.weight
`, [
this.id,
]));
}
}
module.exports = Group;

View file

@ -1,8 +1,8 @@
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const utc = require("dayjs/plugin/utc") const utc = require("dayjs/plugin/utc");
let timezone = require("dayjs/plugin/timezone") let timezone = require("dayjs/plugin/timezone");
dayjs.extend(utc) dayjs.extend(utc);
dayjs.extend(timezone) dayjs.extend(timezone);
const { BeanModel } = require("redbean-node/dist/bean-model"); const { BeanModel } = require("redbean-node/dist/bean-model");
/** /**
@ -13,6 +13,15 @@ const { BeanModel } = require("redbean-node/dist/bean-model");
*/ */
class Heartbeat extends BeanModel { class Heartbeat extends BeanModel {
toPublicJSON() {
return {
status: this.status,
time: this.time,
msg: "", // Hide for public
ping: this.ping,
};
}
toJSON() { toJSON() {
return { return {
monitorID: this.monitor_id, monitorID: this.monitor_id,

18
server/model/incident.js Normal file
View file

@ -0,0 +1,18 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
class Incident extends BeanModel {
toPublicJSON() {
return {
id: this.id,
style: this.style,
title: this.title,
content: this.content,
pin: this.pin,
createdDate: this.createdDate,
lastUpdatedDate: this.lastUpdatedDate,
};
}
}
module.exports = Incident;

View file

@ -1,16 +1,16 @@
const https = require("https"); const https = require("https");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const utc = require("dayjs/plugin/utc") const utc = require("dayjs/plugin/utc");
let timezone = require("dayjs/plugin/timezone") let timezone = require("dayjs/plugin/timezone");
dayjs.extend(utc) 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, TimeLogger } = require("../../src/util"); const { debug, UP, DOWN, PENDING, flipStatus, TimeLogger } = require("../../src/util");
const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom } = require("../util-server"); const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom } = 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; const version = require("../../package.json").version;
/** /**
@ -20,13 +20,28 @@ const version = require("../../package.json").version;
* 2 = PENDING * 2 = PENDING
*/ */
class Monitor extends BeanModel { class Monitor extends BeanModel {
/**
* Return a object that ready to parse to JSON for public
* Only show necessary data to public
*/
async toPublicJSON() {
return {
id: this.id,
name: this.name,
};
}
/**
* Return a object that ready to parse to JSON
*/
async toJSON() { async toJSON() {
let notificationIDList = {}; let notificationIDList = {};
let list = await R.find("monitor_notification", " monitor_id = ? ", [ let list = await R.find("monitor_notification", " monitor_id = ? ", [
this.id, this.id,
]) ]);
for (let bean of list) { for (let bean of list) {
notificationIDList[bean.notification_id] = true; notificationIDList[bean.notification_id] = true;
@ -64,7 +79,7 @@ class Monitor extends BeanModel {
* @returns {boolean} * @returns {boolean}
*/ */
getIgnoreTls() { getIgnoreTls() {
return Boolean(this.ignoreTls) return Boolean(this.ignoreTls);
} }
/** /**
@ -94,12 +109,12 @@ class Monitor extends BeanModel {
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,
]) ]);
} }
const isFirstBeat = !previousBeat; const isFirstBeat = !previousBeat;
let bean = R.dispense("heartbeat") let bean = R.dispense("heartbeat");
bean.monitor_id = this.id; bean.monitor_id = this.id;
bean.time = R.isoDateTime(dayjs.utc()); bean.time = R.isoDateTime(dayjs.utc());
bean.status = DOWN; bean.status = DOWN;
@ -135,7 +150,7 @@ class Monitor extends BeanModel {
return checkStatusCode(status, this.getAcceptedStatuscodes()); 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
@ -145,12 +160,12 @@ class Monitor extends BeanModel {
tlsInfo = await this.updateTlsInfo(checkCertificate(res)); tlsInfo = await this.updateTlsInfo(checkCertificate(res));
} catch (e) { } catch (e) {
if (e.message !== "No TLS certificate in response") { 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");
if (this.type === "http") { if (this.type === "http") {
bean.status = UP; bean.status = UP;
@ -160,26 +175,26 @@ class Monitor extends BeanModel {
// Convert to string for object/array // Convert to string for object/array
if (typeof data !== "string") { if (typeof data !== "string") {
data = JSON.stringify(data) data = JSON.stringify(data);
} }
if (data.includes(this.keyword)) { if (data.includes(this.keyword)) {
bean.msg += ", keyword is found" bean.msg += ", keyword is found";
bean.status = UP; bean.status = UP;
} else { } else {
throw new Error(bean.msg + ", but keyword is not found") throw new Error(bean.msg + ", but keyword is not found");
} }
} }
} else if (this.type === "port") { } else if (this.type === "port") {
bean.ping = await tcping(this.hostname, this.port); bean.ping = await tcping(this.hostname, this.port);
bean.msg = "" bean.msg = "";
bean.status = UP; bean.status = UP;
} else if (this.type === "ping") { } else if (this.type === "ping") {
bean.ping = await ping(this.hostname); bean.ping = await ping(this.hostname);
bean.msg = "" bean.msg = "";
bean.status = UP; bean.status = UP;
} else if (this.type === "dns") { } else if (this.type === "dns") {
let startTime = dayjs().valueOf(); let startTime = dayjs().valueOf();
@ -199,7 +214,7 @@ class Monitor extends BeanModel {
dnsRes.forEach(record => { dnsRes.forEach(record => {
dnsMessage += `Hostname: ${record.exchange} - Priority: ${record.priority} | `; dnsMessage += `Hostname: ${record.exchange} - Priority: ${record.priority} | `;
}); });
dnsMessage = dnsMessage.slice(0, -2) dnsMessage = dnsMessage.slice(0, -2);
} else if (this.dns_resolve_type == "NS") { } else if (this.dns_resolve_type == "NS") {
dnsMessage += "Servers: "; dnsMessage += "Servers: ";
dnsMessage += dnsRes.join(" | "); dnsMessage += dnsRes.join(" | ");
@ -209,7 +224,7 @@ class Monitor extends BeanModel {
dnsRes.forEach(record => { dnsRes.forEach(record => {
dnsMessage += `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight} | `; dnsMessage += `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight} | `;
}); });
dnsMessage = dnsMessage.slice(0, -2) dnsMessage = dnsMessage.slice(0, -2);
} }
if (this.dnsLastResult !== dnsMessage) { if (this.dnsLastResult !== dnsMessage) {
@ -272,20 +287,20 @@ class Monitor extends BeanModel {
if (!isFirstBeat || bean.status === DOWN) { if (!isFirstBeat || bean.status === DOWN) {
let notificationList = await R.getAll("SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id ", [ let notificationList = await R.getAll("SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id ", [
this.id, this.id,
]) ]);
let text; let text;
if (bean.status === UP) { if (bean.status === UP) {
text = "✅ Up" text = "✅ Up";
} else { } else {
text = "🔴 Down" text = "🔴 Down";
} }
let msg = `[${this.name}] [${text}] ${bean.msg}`; let msg = `[${this.name}] [${text}] ${bean.msg}`;
for (let notification of notificationList) { for (let notification of notificationList) {
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); console.log(e);
@ -300,18 +315,18 @@ class Monitor extends BeanModel {
let beatInterval = this.interval; let beatInterval = this.interval;
if (bean.status === UP) { if (bean.status === UP) {
console.info(`Monitor #${this.id} '${this.name}': Successful Response: ${bean.ping} ms | Interval: ${beatInterval} seconds | Type: ${this.type}`) console.info(`Monitor #${this.id} '${this.name}': Successful Response: ${bean.ping} ms | Interval: ${beatInterval} seconds | Type: ${this.type}`);
} else if (bean.status === PENDING) { } else if (bean.status === PENDING) {
if (this.retryInterval !== this.interval) { if (this.retryInterval !== this.interval) {
beatInterval = this.retryInterval; beatInterval = this.retryInterval;
} }
console.warn(`Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Max retries: ${this.maxretries} | Retry: ${retries} | Retry Interval: ${beatInterval} seconds | Type: ${this.type}`) console.warn(`Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Max retries: ${this.maxretries} | Retry: ${retries} | Retry Interval: ${beatInterval} seconds | Type: ${this.type}`);
} else { } else {
console.warn(`Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type}`) console.warn(`Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type}`);
} }
io.to(this.user_id).emit("heartbeat", bean.toJSON()); io.to(this.user_id).emit("heartbeat", bean.toJSON());
Monitor.sendStats(io, this.id, this.user_id) Monitor.sendStats(io, this.id, this.user_id);
await R.store(bean); await R.store(bean);
prometheus.update(bean, tlsInfo); prometheus.update(bean, tlsInfo);
@ -322,7 +337,7 @@ class Monitor extends BeanModel {
this.heartbeatInterval = setTimeout(beat, beatInterval * 1000); this.heartbeatInterval = setTimeout(beat, beatInterval * 1000);
} }
} };
beat(); beat();
} }
@ -415,7 +430,7 @@ class Monitor extends BeanModel {
* https://www.uptrends.com/support/kb/reporting/calculation-of-uptime-and-downtime * https://www.uptrends.com/support/kb/reporting/calculation-of-uptime-and-downtime
* @param duration : int Hours * @param duration : int Hours
*/ */
static async sendUptime(duration, io, monitorID, userID) { static async calcUptime(duration, monitorID) {
const timeLogger = new TimeLogger(); const timeLogger = new TimeLogger();
const startTime = R.isoDateTime(dayjs.utc().subtract(duration, "hour")); const startTime = R.isoDateTime(dayjs.utc().subtract(duration, "hour"));
@ -468,12 +483,21 @@ class Monitor extends BeanModel {
} else { } else {
// Handle new monitor with only one beat, because the beat's duration = 0 // Handle new monitor with only one beat, because the beat's duration = 0
let status = parseInt(await R.getCell("SELECT `status` FROM heartbeat WHERE monitor_id = ?", [ monitorID ])); let status = parseInt(await R.getCell("SELECT `status` FROM heartbeat WHERE monitor_id = ?", [ monitorID ]));
console.log("here???" + status);
if (status === UP) { if (status === UP) {
uptime = 1; uptime = 1;
} }
} }
return uptime;
}
/**
* Send Uptime
* @param duration : int Hours
*/
static async sendUptime(duration, io, monitorID, userID) {
const uptime = await this.calcUptime(duration, monitorID);
io.to(userID).emit("uptime", monitorID, duration, uptime); io.to(userID).emit("uptime", monitorID, duration, uptime);
} }
} }

View file

@ -0,0 +1,749 @@
let url = require("url");
let MemoryCache = require("./memory-cache");
let t = {
ms: 1,
second: 1000,
minute: 60000,
hour: 3600000,
day: 3600000 * 24,
week: 3600000 * 24 * 7,
month: 3600000 * 24 * 30,
};
let instances = [];
let matches = function (a) {
return function (b) {
return a === b;
};
};
let doesntMatch = function (a) {
return function (b) {
return !matches(a)(b);
};
};
let logDuration = function (d, prefix) {
let str = d > 1000 ? (d / 1000).toFixed(2) + "sec" : d + "ms";
return "\x1b[33m- " + (prefix ? prefix + " " : "") + str + "\x1b[0m";
};
function getSafeHeaders(res) {
return res.getHeaders ? res.getHeaders() : res._headers;
}
function ApiCache() {
let memCache = new MemoryCache();
let globalOptions = {
debug: false,
defaultDuration: 3600000,
enabled: true,
appendKey: [],
jsonp: false,
redisClient: false,
headerBlacklist: [],
statusCodes: {
include: [],
exclude: [],
},
events: {
expire: undefined,
},
headers: {
// 'cache-control': 'no-cache' // example of header overwrite
},
trackPerformance: false,
respectCacheControl: false,
};
let middlewareOptions = [];
let instance = this;
let index = null;
let timers = {};
let performanceArray = []; // for tracking cache hit rate
instances.push(this);
this.id = instances.length;
function debug(a, b, c, d) {
let arr = ["\x1b[36m[apicache]\x1b[0m", a, b, c, d].filter(function (arg) {
return arg !== undefined;
});
let debugEnv = process.env.DEBUG && process.env.DEBUG.split(",").indexOf("apicache") !== -1;
return (globalOptions.debug || debugEnv) && console.log.apply(null, arr);
}
function shouldCacheResponse(request, response, toggle) {
let opt = globalOptions;
let codes = opt.statusCodes;
if (!response) {
return false;
}
if (toggle && !toggle(request, response)) {
return false;
}
if (codes.exclude && codes.exclude.length && codes.exclude.indexOf(response.statusCode) !== -1) {
return false;
}
if (codes.include && codes.include.length && codes.include.indexOf(response.statusCode) === -1) {
return false;
}
return true;
}
function addIndexEntries(key, req) {
let groupName = req.apicacheGroup;
if (groupName) {
debug("group detected \"" + groupName + "\"");
let group = (index.groups[groupName] = index.groups[groupName] || []);
group.unshift(key);
}
index.all.unshift(key);
}
function filterBlacklistedHeaders(headers) {
return Object.keys(headers)
.filter(function (key) {
return globalOptions.headerBlacklist.indexOf(key) === -1;
})
.reduce(function (acc, header) {
acc[header] = headers[header];
return acc;
}, {});
}
function createCacheObject(status, headers, data, encoding) {
return {
status: status,
headers: filterBlacklistedHeaders(headers),
data: data,
encoding: encoding,
timestamp: new Date().getTime() / 1000, // seconds since epoch. This is used to properly decrement max-age headers in cached responses.
};
}
function cacheResponse(key, value, duration) {
let redis = globalOptions.redisClient;
let expireCallback = globalOptions.events.expire;
if (redis && redis.connected) {
try {
redis.hset(key, "response", JSON.stringify(value));
redis.hset(key, "duration", duration);
redis.expire(key, duration / 1000, expireCallback || function () {});
} catch (err) {
debug("[apicache] error in redis.hset()");
}
} else {
memCache.add(key, value, duration, expireCallback);
}
// add automatic cache clearing from duration, includes max limit on setTimeout
timers[key] = setTimeout(function () {
instance.clear(key, true);
}, Math.min(duration, 2147483647));
}
function accumulateContent(res, content) {
if (content) {
if (typeof content == "string") {
res._apicache.content = (res._apicache.content || "") + content;
} else if (Buffer.isBuffer(content)) {
let oldContent = res._apicache.content;
if (typeof oldContent === "string") {
oldContent = !Buffer.from ? new Buffer(oldContent) : Buffer.from(oldContent);
}
if (!oldContent) {
oldContent = !Buffer.alloc ? new Buffer(0) : Buffer.alloc(0);
}
res._apicache.content = Buffer.concat(
[oldContent, content],
oldContent.length + content.length
);
} else {
res._apicache.content = content;
}
}
}
function makeResponseCacheable(req, res, next, key, duration, strDuration, toggle) {
// monkeypatch res.end to create cache object
res._apicache = {
write: res.write,
writeHead: res.writeHead,
end: res.end,
cacheable: true,
content: undefined,
};
// append header overwrites if applicable
Object.keys(globalOptions.headers).forEach(function (name) {
res.setHeader(name, globalOptions.headers[name]);
});
res.writeHead = function () {
// add cache control headers
if (!globalOptions.headers["cache-control"]) {
if (shouldCacheResponse(req, res, toggle)) {
res.setHeader("cache-control", "max-age=" + (duration / 1000).toFixed(0));
} else {
res.setHeader("cache-control", "no-cache, no-store, must-revalidate");
}
}
res._apicache.headers = Object.assign({}, getSafeHeaders(res));
return res._apicache.writeHead.apply(this, arguments);
};
// patch res.write
res.write = function (content) {
accumulateContent(res, content);
return res._apicache.write.apply(this, arguments);
};
// patch res.end
res.end = function (content, encoding) {
if (shouldCacheResponse(req, res, toggle)) {
accumulateContent(res, content);
if (res._apicache.cacheable && res._apicache.content) {
addIndexEntries(key, req);
let headers = res._apicache.headers || getSafeHeaders(res);
let cacheObject = createCacheObject(
res.statusCode,
headers,
res._apicache.content,
encoding
);
cacheResponse(key, cacheObject, duration);
// display log entry
let elapsed = new Date() - req.apicacheTimer;
debug("adding cache entry for \"" + key + "\" @ " + strDuration, logDuration(elapsed));
debug("_apicache.headers: ", res._apicache.headers);
debug("res.getHeaders(): ", getSafeHeaders(res));
debug("cacheObject: ", cacheObject);
}
}
return res._apicache.end.apply(this, arguments);
};
next();
}
function sendCachedResponse(request, response, cacheObject, toggle, next, duration) {
if (toggle && !toggle(request, response)) {
return next();
}
let headers = getSafeHeaders(response);
// Modified by @louislam, removed Cache-control, since I don't need client side cache!
// Original Source: https://github.com/kwhitley/apicache/blob/0d5686cc21fad353c6dddee646288c2fca3e4f50/src/apicache.js#L254
Object.assign(headers, filterBlacklistedHeaders(cacheObject.headers || {}));
// only embed apicache headers when not in production environment
if (process.env.NODE_ENV !== "production") {
Object.assign(headers, {
"apicache-store": globalOptions.redisClient ? "redis" : "memory",
"apicache-version": "1.6.2-modified",
});
}
// unstringify buffers
let data = cacheObject.data;
if (data && data.type === "Buffer") {
data =
typeof data.data === "number" ? new Buffer.alloc(data.data) : new Buffer.from(data.data);
}
// test Etag against If-None-Match for 304
let cachedEtag = cacheObject.headers.etag;
let requestEtag = request.headers["if-none-match"];
if (requestEtag && cachedEtag === requestEtag) {
response.writeHead(304, headers);
return response.end();
}
response.writeHead(cacheObject.status || 200, headers);
return response.end(data, cacheObject.encoding);
}
function syncOptions() {
for (let i in middlewareOptions) {
Object.assign(middlewareOptions[i].options, globalOptions, middlewareOptions[i].localOptions);
}
}
this.clear = function (target, isAutomatic) {
let group = index.groups[target];
let redis = globalOptions.redisClient;
if (group) {
debug("clearing group \"" + target + "\"");
group.forEach(function (key) {
debug("clearing cached entry for \"" + key + "\"");
clearTimeout(timers[key]);
delete timers[key];
if (!globalOptions.redisClient) {
memCache.delete(key);
} else {
try {
redis.del(key);
} catch (err) {
console.log("[apicache] error in redis.del(\"" + key + "\")");
}
}
index.all = index.all.filter(doesntMatch(key));
});
delete index.groups[target];
} else if (target) {
debug("clearing " + (isAutomatic ? "expired" : "cached") + " entry for \"" + target + "\"");
clearTimeout(timers[target]);
delete timers[target];
// clear actual cached entry
if (!redis) {
memCache.delete(target);
} else {
try {
redis.del(target);
} catch (err) {
console.log("[apicache] error in redis.del(\"" + target + "\")");
}
}
// remove from global index
index.all = index.all.filter(doesntMatch(target));
// remove target from each group that it may exist in
Object.keys(index.groups).forEach(function (groupName) {
index.groups[groupName] = index.groups[groupName].filter(doesntMatch(target));
// delete group if now empty
if (!index.groups[groupName].length) {
delete index.groups[groupName];
}
});
} else {
debug("clearing entire index");
if (!redis) {
memCache.clear();
} else {
// clear redis keys one by one from internal index to prevent clearing non-apicache entries
index.all.forEach(function (key) {
clearTimeout(timers[key]);
delete timers[key];
try {
redis.del(key);
} catch (err) {
console.log("[apicache] error in redis.del(\"" + key + "\")");
}
});
}
this.resetIndex();
}
return this.getIndex();
};
function parseDuration(duration, defaultDuration) {
if (typeof duration === "number") {
return duration;
}
if (typeof duration === "string") {
let split = duration.match(/^([\d\.,]+)\s?(\w+)$/);
if (split.length === 3) {
let len = parseFloat(split[1]);
let unit = split[2].replace(/s$/i, "").toLowerCase();
if (unit === "m") {
unit = "ms";
}
return (len || 1) * (t[unit] || 0);
}
}
return defaultDuration;
}
this.getDuration = function (duration) {
return parseDuration(duration, globalOptions.defaultDuration);
};
/**
* Return cache performance statistics (hit rate). Suitable for putting into a route:
* <code>
* app.get('/api/cache/performance', (req, res) => {
* res.json(apicache.getPerformance())
* })
* </code>
*/
this.getPerformance = function () {
return performanceArray.map(function (p) {
return p.report();
});
};
this.getIndex = function (group) {
if (group) {
return index.groups[group];
} else {
return index;
}
};
this.middleware = function cache(strDuration, middlewareToggle, localOptions) {
let duration = instance.getDuration(strDuration);
let opt = {};
middlewareOptions.push({
options: opt,
});
let options = function (localOptions) {
if (localOptions) {
middlewareOptions.find(function (middleware) {
return middleware.options === opt;
}).localOptions = localOptions;
}
syncOptions();
return opt;
};
options(localOptions);
/**
* A Function for non tracking performance
*/
function NOOPCachePerformance() {
this.report = this.hit = this.miss = function () {}; // noop;
}
/**
* A function for tracking and reporting hit rate. These statistics are returned by the getPerformance() call above.
*/
function CachePerformance() {
/**
* Tracks the hit rate for the last 100 requests.
* If there have been fewer than 100 requests, the hit rate just considers the requests that have happened.
*/
this.hitsLast100 = new Uint8Array(100 / 4); // each hit is 2 bits
/**
* Tracks the hit rate for the last 1000 requests.
* If there have been fewer than 1000 requests, the hit rate just considers the requests that have happened.
*/
this.hitsLast1000 = new Uint8Array(1000 / 4); // each hit is 2 bits
/**
* Tracks the hit rate for the last 10000 requests.
* If there have been fewer than 10000 requests, the hit rate just considers the requests that have happened.
*/
this.hitsLast10000 = new Uint8Array(10000 / 4); // each hit is 2 bits
/**
* Tracks the hit rate for the last 100000 requests.
* If there have been fewer than 100000 requests, the hit rate just considers the requests that have happened.
*/
this.hitsLast100000 = new Uint8Array(100000 / 4); // each hit is 2 bits
/**
* The number of calls that have passed through the middleware since the server started.
*/
this.callCount = 0;
/**
* The total number of hits since the server started
*/
this.hitCount = 0;
/**
* The key from the last cache hit. This is useful in identifying which route these statistics apply to.
*/
this.lastCacheHit = null;
/**
* The key from the last cache miss. This is useful in identifying which route these statistics apply to.
*/
this.lastCacheMiss = null;
/**
* Return performance statistics
*/
this.report = function () {
return {
lastCacheHit: this.lastCacheHit,
lastCacheMiss: this.lastCacheMiss,
callCount: this.callCount,
hitCount: this.hitCount,
missCount: this.callCount - this.hitCount,
hitRate: this.callCount == 0 ? null : this.hitCount / this.callCount,
hitRateLast100: this.hitRate(this.hitsLast100),
hitRateLast1000: this.hitRate(this.hitsLast1000),
hitRateLast10000: this.hitRate(this.hitsLast10000),
hitRateLast100000: this.hitRate(this.hitsLast100000),
};
};
/**
* Computes a cache hit rate from an array of hits and misses.
* @param {Uint8Array} array An array representing hits and misses.
* @returns a number between 0 and 1, or null if the array has no hits or misses
*/
this.hitRate = function (array) {
let hits = 0;
let misses = 0;
for (let i = 0; i < array.length; i++) {
let n8 = array[i];
for (let j = 0; j < 4; j++) {
switch (n8 & 3) {
case 1:
hits++;
break;
case 2:
misses++;
break;
}
n8 >>= 2;
}
}
let total = hits + misses;
if (total == 0) {
return null;
}
return hits / total;
};
/**
* Record a hit or miss in the given array. It will be recorded at a position determined
* by the current value of the callCount variable.
* @param {Uint8Array} array An array representing hits and misses.
* @param {boolean} hit true for a hit, false for a miss
* Each element in the array is 8 bits, and encodes 4 hit/miss records.
* Each hit or miss is encoded as to bits as follows:
* 00 means no hit or miss has been recorded in these bits
* 01 encodes a hit
* 10 encodes a miss
*/
this.recordHitInArray = function (array, hit) {
let arrayIndex = ~~(this.callCount / 4) % array.length;
let bitOffset = (this.callCount % 4) * 2; // 2 bits per record, 4 records per uint8 array element
let clearMask = ~(3 << bitOffset);
let record = (hit ? 1 : 2) << bitOffset;
array[arrayIndex] = (array[arrayIndex] & clearMask) | record;
};
/**
* Records the hit or miss in the tracking arrays and increments the call count.
* @param {boolean} hit true records a hit, false records a miss
*/
this.recordHit = function (hit) {
this.recordHitInArray(this.hitsLast100, hit);
this.recordHitInArray(this.hitsLast1000, hit);
this.recordHitInArray(this.hitsLast10000, hit);
this.recordHitInArray(this.hitsLast100000, hit);
if (hit) {
this.hitCount++;
}
this.callCount++;
};
/**
* Records a hit event, setting lastCacheMiss to the given key
* @param {string} key The key that had the cache hit
*/
this.hit = function (key) {
this.recordHit(true);
this.lastCacheHit = key;
};
/**
* Records a miss event, setting lastCacheMiss to the given key
* @param {string} key The key that had the cache miss
*/
this.miss = function (key) {
this.recordHit(false);
this.lastCacheMiss = key;
};
}
let perf = globalOptions.trackPerformance ? new CachePerformance() : new NOOPCachePerformance();
performanceArray.push(perf);
let cache = function (req, res, next) {
function bypass() {
debug("bypass detected, skipping cache.");
return next();
}
// initial bypass chances
if (!opt.enabled) {
return bypass();
}
if (
req.headers["x-apicache-bypass"] ||
req.headers["x-apicache-force-fetch"] ||
(opt.respectCacheControl && req.headers["cache-control"] == "no-cache")
) {
return bypass();
}
// REMOVED IN 0.11.1 TO CORRECT MIDDLEWARE TOGGLE EXECUTE ORDER
// if (typeof middlewareToggle === 'function') {
// if (!middlewareToggle(req, res)) return bypass()
// } else if (middlewareToggle !== undefined && !middlewareToggle) {
// return bypass()
// }
// embed timer
req.apicacheTimer = new Date();
// In Express 4.x the url is ambigious based on where a router is mounted. originalUrl will give the full Url
let key = req.originalUrl || req.url;
// Remove querystring from key if jsonp option is enabled
if (opt.jsonp) {
key = url.parse(key).pathname;
}
// add appendKey (either custom function or response path)
if (typeof opt.appendKey === "function") {
key += "$$appendKey=" + opt.appendKey(req, res);
} else if (opt.appendKey.length > 0) {
let appendKey = req;
for (let i = 0; i < opt.appendKey.length; i++) {
appendKey = appendKey[opt.appendKey[i]];
}
key += "$$appendKey=" + appendKey;
}
// attempt cache hit
let redis = opt.redisClient;
let cached = !redis ? memCache.getValue(key) : null;
// send if cache hit from memory-cache
if (cached) {
let elapsed = new Date() - req.apicacheTimer;
debug("sending cached (memory-cache) version of", key, logDuration(elapsed));
perf.hit(key);
return sendCachedResponse(req, res, cached, middlewareToggle, next, duration);
}
// send if cache hit from redis
if (redis && redis.connected) {
try {
redis.hgetall(key, function (err, obj) {
if (!err && obj && obj.response) {
let elapsed = new Date() - req.apicacheTimer;
debug("sending cached (redis) version of", key, logDuration(elapsed));
perf.hit(key);
return sendCachedResponse(
req,
res,
JSON.parse(obj.response),
middlewareToggle,
next,
duration
);
} else {
perf.miss(key);
return makeResponseCacheable(
req,
res,
next,
key,
duration,
strDuration,
middlewareToggle
);
}
});
} catch (err) {
// bypass redis on error
perf.miss(key);
return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle);
}
} else {
perf.miss(key);
return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle);
}
};
cache.options = options;
return cache;
};
this.options = function (options) {
if (options) {
Object.assign(globalOptions, options);
syncOptions();
if ("defaultDuration" in options) {
// Convert the default duration to a number in milliseconds (if needed)
globalOptions.defaultDuration = parseDuration(globalOptions.defaultDuration, 3600000);
}
if (globalOptions.trackPerformance) {
debug("WARNING: using trackPerformance flag can cause high memory usage!");
}
return this;
} else {
return globalOptions;
}
};
this.resetIndex = function () {
index = {
all: [],
groups: {},
};
};
this.newInstance = function (config) {
let instance = new ApiCache();
if (config) {
instance.options(config);
}
return instance;
};
this.clone = function () {
return this.newInstance(this.options());
};
// initialize index
this.resetIndex();
}
module.exports = new ApiCache();

View file

@ -0,0 +1,14 @@
const apicache = require("./apicache");
apicache.options({
headerBlacklist: [
"cache-control"
],
headers: {
// Disable client side cache, only server side cache.
// BUG! Not working for the second request
"cache-control": "no-cache",
},
});
module.exports = apicache;

View file

@ -0,0 +1,59 @@
function MemoryCache() {
this.cache = {};
this.size = 0;
}
MemoryCache.prototype.add = function (key, value, time, timeoutCallback) {
let old = this.cache[key];
let instance = this;
let entry = {
value: value,
expire: time + Date.now(),
timeout: setTimeout(function () {
instance.delete(key);
return timeoutCallback && typeof timeoutCallback === "function" && timeoutCallback(value, key);
}, time)
};
this.cache[key] = entry;
this.size = Object.keys(this.cache).length;
return entry;
};
MemoryCache.prototype.delete = function (key) {
let entry = this.cache[key];
if (entry) {
clearTimeout(entry.timeout);
}
delete this.cache[key];
this.size = Object.keys(this.cache).length;
return null;
};
MemoryCache.prototype.get = function (key) {
let entry = this.cache[key];
return entry;
};
MemoryCache.prototype.getValue = function (key) {
let entry = this.get(key);
return entry && entry.value;
};
MemoryCache.prototype.clear = function () {
Object.keys(this.cache).forEach(function (key) {
this.delete(key);
}, this);
return true;
};
module.exports = MemoryCache;

View file

@ -0,0 +1,150 @@
let express = require("express");
const { allowDevAllOrigin, getSettings, setting } = require("../util-server");
const { R } = require("redbean-node");
const server = require("../server");
const apicache = require("../modules/apicache");
const Monitor = require("../model/monitor");
let router = express.Router();
let cache = apicache.middleware;
router.get("/api/entry-page", async (_, response) => {
allowDevAllOrigin(response);
response.json(server.entryPage);
});
// Status Page Config
router.get("/api/status-page/config", async (_request, response) => {
allowDevAllOrigin(response);
let config = await getSettings("statusPage");
if (! config.statusPageTheme) {
config.statusPageTheme = "light";
}
if (! config.statusPagePublished) {
config.statusPagePublished = true;
}
if (! config.title) {
config.title = "Uptime Kuma";
}
response.json(config);
});
// Status Page - Get the current Incident
// Can fetch only if published
router.get("/api/status-page/incident", async (_, response) => {
allowDevAllOrigin(response);
try {
await checkPublished();
let incident = await R.findOne("incident", " pin = 1 AND active = 1");
if (incident) {
incident = incident.toPublicJSON();
}
response.json({
ok: true,
incident,
});
} catch (error) {
send403(response, error.message);
}
});
// Status Page - Monitor List
// Can fetch only if published
router.get("/api/status-page/monitor-list", cache("5 minutes"), async (_request, response) => {
allowDevAllOrigin(response);
try {
await checkPublished();
const publicGroupList = [];
let list = await R.find("group", " public = 1 ORDER BY weight ");
for (let groupBean of list) {
publicGroupList.push(await groupBean.toPublicJSON());
}
response.json(publicGroupList);
} catch (error) {
send403(response, error.message);
}
});
// Status Page Polling Data
// Can fetch only if published
router.get("/api/status-page/heartbeat", cache("5 minutes"), async (_request, response) => {
allowDevAllOrigin(response);
try {
await checkPublished();
let heartbeatList = {};
let uptimeList = {};
let monitorIDList = await R.getCol(`
SELECT monitor_group.monitor_id FROM monitor_group, \`group\`
WHERE monitor_group.group_id = \`group\`.id
AND public = 1
`);
for (let monitorID of monitorIDList) {
let list = await R.getAll(`
SELECT * FROM heartbeat
WHERE monitor_id = ?
ORDER BY time DESC
LIMIT 50
`, [
monitorID,
]);
list = R.convertToBeans("heartbeat", list);
heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());
const type = 24;
uptimeList[`${monitorID}_${type}`] = await Monitor.calcUptime(type, monitorID);
}
response.json({
heartbeatList,
uptimeList
});
} catch (error) {
send403(response, error.message);
}
});
async function checkPublished() {
if (! await isPublished()) {
throw new Error("The status page is not published");
}
}
/**
* Default is published
* @returns {Promise<boolean>}
*/
async function isPublished() {
const value = await setting("statusPagePublished");
if (value === null) {
return true;
}
return value;
}
function send403(res, msg = "") {
res.status(403).json({
"status": "fail",
"msg": msg,
});
}
module.exports = router;

View file

@ -8,12 +8,12 @@ console.log("Node Env: " + process.env.NODE_ENV);
const { sleep, debug, TimeLogger, getRandomInt } = 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");
const http = require("http"); const http = require("http");
const https = require("https"); const https = require("https");
console.log("Importing 3rd-party libraries") console.log("Importing 3rd-party libraries");
debug("Importing express"); debug("Importing express");
const express = require("express"); const express = require("express");
debug("Importing socket.io"); debug("Importing socket.io");
@ -35,7 +35,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, initJWTSecret, genSecret } = require("./util-server"); const { getSettings, setSettings, setting, initJWTSecret, genSecret, allowDevAllOrigin, checkLogin } = require("./util-server");
debug("Importing Notification"); debug("Importing Notification");
const { Notification } = require("./notification"); const { Notification } = require("./notification");
@ -62,13 +62,6 @@ const port = parseInt(process.env.PORT || args.port || 3001);
const sslKey = process.env.SSL_KEY || args["ssl-key"] || undefined; const sslKey = process.env.SSL_KEY || args["ssl-key"] || undefined;
const sslCert = process.env.SSL_CERT || args["ssl-cert"] || undefined; const sslCert = process.env.SSL_CERT || args["ssl-cert"] || undefined;
// Demo Mode?
const demoMode = args["demo"] || false;
if (demoMode) {
console.log("==== Demo Mode ====");
}
// Data Directory (must be end with "/") // Data Directory (must be end with "/")
Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/"; Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/";
Database.path = Database.dataDir + "kuma.db"; Database.path = Database.dataDir + "kuma.db";
@ -77,7 +70,7 @@ if (! fs.existsSync(Database.dataDir)) {
} }
console.log(`Data Dir: ${Database.dataDir}`); console.log(`Data Dir: ${Database.dataDir}`);
console.log("Creating express and socket.io instance") console.log("Creating express and socket.io instance");
const app = express(); const app = express();
let server; let server;
@ -98,6 +91,7 @@ module.exports.io = io;
// Must be after io instantiation // Must be after io instantiation
const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList } = require("./client"); const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList } = require("./client");
const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler");
app.use(express.json()); app.use(express.json());
@ -131,12 +125,19 @@ let needSetup = false;
*/ */
let indexHTML = fs.readFileSync("./dist/index.html").toString(); let indexHTML = fs.readFileSync("./dist/index.html").toString();
exports.entryPage = "dashboard";
(async () => { (async () => {
Database.init(args);
await initDatabase(); await initDatabase();
console.log("Adding route") exports.entryPage = await setting("entryPage");
console.log("Adding route");
// ***************************
// Normal Router here // Normal Router here
// ***************************
// Robots.txt // Robots.txt
app.get("/robots.txt", async (_request, response) => { app.get("/robots.txt", async (_request, response) => {
@ -156,28 +157,39 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
app.use("/", express.static("dist")); app.use("/", express.static("dist"));
// ./data/upload
app.use("/upload", express.static(Database.uploadDir));
app.get("/.well-known/change-password", async (_, response) => { app.get("/.well-known/change-password", async (_, response) => {
response.redirect("https://github.com/louislam/uptime-kuma/wiki/Reset-Password-via-CLI"); response.redirect("https://github.com/louislam/uptime-kuma/wiki/Reset-Password-via-CLI");
}); });
// Universal Route Handler, must be at the end // API Router
const apiRouter = require("./routers/api-router");
app.use(apiRouter);
// Universal Route Handler, must be at the end of all express route.
app.get("*", async (_request, response) => { app.get("*", async (_request, response) => {
response.send(indexHTML); if (_request.originalUrl.startsWith("/upload/")) {
response.status(404).send("File not found.");
} else {
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: checkVersion.version, version: checkVersion.version,
latestVersion: checkVersion.latestVersion, latestVersion: checkVersion.latestVersion,
}) });
totalClient++; totalClient++;
if (needSetup) { if (needSetup) {
console.log("Redirect to setup page") console.log("Redirect to setup page");
socket.emit("setup") socket.emit("setup");
} }
socket.on("disconnect", () => { socket.on("disconnect", () => {
@ -185,7 +197,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
}); });
// *************************** // ***************************
// Public API // Public Socket API
// *************************** // ***************************
socket.on("loginByToken", async (token, callback) => { socket.on("loginByToken", async (token, callback) => {
@ -193,44 +205,44 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
try { try {
let decoded = jwt.verify(token, jwtSecret); let decoded = jwt.verify(token, jwtSecret);
console.log("Username from JWT: " + decoded.username) console.log("Username from JWT: " + decoded.username);
let user = await R.findOne("user", " username = ? AND active = 1 ", [ let user = await R.findOne("user", " username = ? AND active = 1 ", [
decoded.username, decoded.username,
]) ]);
if (user) { if (user) {
debug("afterLogin") debug("afterLogin");
afterLogin(socket, user) afterLogin(socket, user);
debug("afterLogin ok") debug("afterLogin ok");
callback({ callback({
ok: true, ok: true,
}) });
} else { } else {
callback({ callback({
ok: false, ok: false,
msg: "The user is inactive or deleted.", msg: "The user is inactive or deleted.",
}) });
} }
} catch (error) { } catch (error) {
callback({ callback({
ok: false, ok: false,
msg: "Invalid token.", msg: "Invalid token.",
}) });
} }
}); });
socket.on("login", async (data, callback) => { socket.on("login", async (data, callback) => {
console.log("Login") console.log("Login");
let user = await login(data.username, data.password) let user = await login(data.username, data.password);
if (user) { if (user) {
afterLogin(socket, user) afterLogin(socket, user);
if (user.twofaStatus == 0) { if (user.twofaStatus == 0) {
callback({ callback({
@ -238,13 +250,13 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
token: jwt.sign({ token: jwt.sign({
username: data.username, username: data.username,
}, jwtSecret), }, jwtSecret),
}) });
} }
if (user.twofaStatus == 1 && !data.token) { if (user.twofaStatus == 1 && !data.token) {
callback({ callback({
tokenRequired: true, tokenRequired: true,
}) });
} }
if (data.token) { if (data.token) {
@ -256,39 +268,39 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
token: jwt.sign({ token: jwt.sign({
username: data.username, username: data.username,
}, jwtSecret), }, jwtSecret),
}) });
} else { } else {
callback({ callback({
ok: false, ok: false,
msg: "Invalid Token!", msg: "Invalid Token!",
}) });
} }
} }
} else { } else {
callback({ callback({
ok: false, ok: false,
msg: "Incorrect username or password.", msg: "Incorrect username or password.",
}) });
} }
}); });
socket.on("logout", async (callback) => { socket.on("logout", async (callback) => {
socket.leave(socket.userID) socket.leave(socket.userID);
socket.userID = null; socket.userID = null;
callback(); callback();
}); });
socket.on("prepare2FA", async (callback) => { socket.on("prepare2FA", async (callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
let user = await R.findOne("user", " id = ? AND active = 1 ", [ let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID, socket.userID,
]) ]);
if (user.twofa_status == 0) { if (user.twofa_status == 0) {
let newSecret = await genSecret() let newSecret = await genSecret();
let encodedSecret = base32.encode(newSecret); let encodedSecret = base32.encode(newSecret);
let uri = `otpauth://totp/Uptime%20Kuma:${user.username}?secret=${encodedSecret}`; let uri = `otpauth://totp/Uptime%20Kuma:${user.username}?secret=${encodedSecret}`;
@ -300,24 +312,24 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
callback({ callback({
ok: true, ok: true,
uri: uri, uri: uri,
}) });
} else { } else {
callback({ callback({
ok: false, ok: false,
msg: "2FA is already enabled.", msg: "2FA is already enabled.",
}) });
} }
} catch (error) { } catch (error) {
callback({ callback({
ok: false, ok: false,
msg: "Error while trying to prepare 2FA.", msg: "Error while trying to prepare 2FA.",
}) });
} }
}); });
socket.on("save2FA", async (callback) => { socket.on("save2FA", async (callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
await R.exec("UPDATE `user` SET twofa_status = 1 WHERE id = ? ", [ await R.exec("UPDATE `user` SET twofa_status = 1 WHERE id = ? ", [
socket.userID, socket.userID,
@ -326,18 +338,18 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
callback({ callback({
ok: true, ok: true,
msg: "2FA Enabled.", msg: "2FA Enabled.",
}) });
} catch (error) { } catch (error) {
callback({ callback({
ok: false, ok: false,
msg: "Error while trying to change 2FA.", msg: "Error while trying to change 2FA.",
}) });
} }
}); });
socket.on("disable2FA", async (callback) => { socket.on("disable2FA", async (callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
await R.exec("UPDATE `user` SET twofa_status = 0 WHERE id = ? ", [ await R.exec("UPDATE `user` SET twofa_status = 0 WHERE id = ? ", [
socket.userID, socket.userID,
@ -346,19 +358,19 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
callback({ callback({
ok: true, ok: true,
msg: "2FA Disabled.", msg: "2FA Disabled.",
}) });
} catch (error) { } catch (error) {
callback({ callback({
ok: false, ok: false,
msg: "Error while trying to change 2FA.", msg: "Error while trying to change 2FA.",
}) });
} }
}); });
socket.on("verifyToken", async (token, callback) => { socket.on("verifyToken", async (token, callback) => {
let user = await R.findOne("user", " id = ? AND active = 1 ", [ let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID, socket.userID,
]) ]);
let verify = notp.totp.verify(token, user.twofa_secret); let verify = notp.totp.verify(token, user.twofa_secret);
@ -366,40 +378,40 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
callback({ callback({
ok: true, ok: true,
valid: true, valid: true,
}) });
} else { } else {
callback({ callback({
ok: false, ok: false,
msg: "Invalid Token.", msg: "Invalid Token.",
valid: false, valid: false,
}) });
} }
}); });
socket.on("twoFAStatus", async (callback) => { socket.on("twoFAStatus", async (callback) => {
checkLogin(socket) checkLogin(socket);
try { try {
let user = await R.findOne("user", " id = ? AND active = 1 ", [ let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID, socket.userID,
]) ]);
if (user.twofa_status == 1) { if (user.twofa_status == 1) {
callback({ callback({
ok: true, ok: true,
status: true, status: true,
}) });
} else { } else {
callback({ callback({
ok: true, ok: true,
status: false, status: false,
}) });
} }
} catch (error) { } catch (error) {
callback({ callback({
ok: false, ok: false,
msg: "Error while trying to get 2FA status.", msg: "Error while trying to get 2FA status.",
}) });
} }
}); });
@ -410,13 +422,13 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("setup", async (username, password, callback) => { socket.on("setup", async (username, password, callback) => {
try { try {
if ((await R.count("user")) !== 0) { if ((await R.count("user")) !== 0) {
throw new Error("Uptime Kuma has been setup. If you want to setup again, please delete the database.") throw new Error("Uptime Kuma has been setup. If you want to setup again, please delete the database.");
} }
let user = R.dispense("user") let user = R.dispense("user");
user.username = username; user.username = username;
user.password = passwordHash.generate(password) user.password = passwordHash.generate(password);
await R.store(user) await R.store(user);
needSetup = false; needSetup = false;
@ -440,8 +452,8 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
// Add a new monitor // Add a new monitor
socket.on("add", async (monitor, callback) => { socket.on("add", async (monitor, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
let bean = R.dispense("monitor") let bean = R.dispense("monitor");
let notificationIDList = monitor.notificationIDList; let notificationIDList = monitor.notificationIDList;
delete monitor.notificationIDList; delete monitor.notificationIDList;
@ -449,11 +461,11 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
monitor.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes); monitor.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
delete 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);
await updateMonitorNotification(bean.id, notificationIDList) await updateMonitorNotification(bean.id, notificationIDList);
await startMonitor(socket.userID, bean.id); await startMonitor(socket.userID, bean.id);
await sendMonitorList(socket); await sendMonitorList(socket);
@ -475,18 +487,18 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
// Edit a monitor // Edit a monitor
socket.on("editMonitor", async (monitor, callback) => { socket.on("editMonitor", async (monitor, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
let bean = await R.findOne("monitor", " id = ? ", [ monitor.id ]) let bean = await R.findOne("monitor", " id = ? ", [ monitor.id ]);
if (bean.user_id !== socket.userID) { if (bean.user_id !== socket.userID) {
throw new Error("Permission denied.") throw new Error("Permission denied.");
} }
bean.name = monitor.name bean.name = monitor.name;
bean.type = monitor.type bean.type = monitor.type;
bean.url = monitor.url bean.url = monitor.url;
bean.interval = monitor.interval bean.interval = monitor.interval;
bean.retryInterval = monitor.retryInterval; bean.retryInterval = monitor.retryInterval;
bean.hostname = monitor.hostname; bean.hostname = monitor.hostname;
bean.maxretries = monitor.maxretries; bean.maxretries = monitor.maxretries;
@ -499,12 +511,12 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
bean.dns_resolve_type = monitor.dns_resolve_type; bean.dns_resolve_type = monitor.dns_resolve_type;
bean.dns_resolve_server = monitor.dns_resolve_server; bean.dns_resolve_server = monitor.dns_resolve_server;
await R.store(bean) await R.store(bean);
await updateMonitorNotification(bean.id, monitor.notificationIDList) await updateMonitorNotification(bean.id, monitor.notificationIDList);
if (bean.active) { if (bean.active) {
await restartMonitor(socket.userID, bean.id) await restartMonitor(socket.userID, bean.id);
} }
await sendMonitorList(socket); await sendMonitorList(socket);
@ -516,7 +528,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
}); });
} catch (e) { } catch (e) {
console.error(e) console.error(e);
callback({ callback({
ok: false, ok: false,
msg: e.message, msg: e.message,
@ -526,13 +538,13 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("getMonitorList", async (callback) => { socket.on("getMonitorList", async (callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
await sendMonitorList(socket); await sendMonitorList(socket);
callback({ callback({
ok: true, ok: true,
}); });
} catch (e) { } catch (e) {
console.error(e) console.error(e);
callback({ callback({
ok: false, ok: false,
msg: e.message, msg: e.message,
@ -542,14 +554,14 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("getMonitor", async (monitorID, callback) => { socket.on("getMonitor", async (monitorID, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
console.log(`Get Monitor: ${monitorID} User ID: ${socket.userID}`) console.log(`Get Monitor: ${monitorID} User ID: ${socket.userID}`);
let bean = await R.findOne("monitor", " id = ? AND user_id = ? ", [ let bean = await R.findOne("monitor", " id = ? AND user_id = ? ", [
monitorID, monitorID,
socket.userID, socket.userID,
]) ]);
callback({ callback({
ok: true, ok: true,
@ -567,7 +579,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
// Start or Resume the monitor // Start or Resume the monitor
socket.on("resumeMonitor", async (monitorID, callback) => { socket.on("resumeMonitor", async (monitorID, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
await startMonitor(socket.userID, monitorID); await startMonitor(socket.userID, monitorID);
await sendMonitorList(socket); await sendMonitorList(socket);
@ -586,8 +598,8 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("pauseMonitor", async (monitorID, callback) => { socket.on("pauseMonitor", async (monitorID, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
await pauseMonitor(socket.userID, monitorID) await pauseMonitor(socket.userID, monitorID);
await sendMonitorList(socket); await sendMonitorList(socket);
callback({ callback({
@ -605,13 +617,13 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("deleteMonitor", async (monitorID, callback) => { socket.on("deleteMonitor", async (monitorID, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
console.log(`Delete Monitor: ${monitorID} User ID: ${socket.userID}`) console.log(`Delete Monitor: ${monitorID} User ID: ${socket.userID}`);
if (monitorID in monitorList) { if (monitorID in monitorList) {
monitorList[monitorID].stop(); monitorList[monitorID].stop();
delete monitorList[monitorID] delete monitorList[monitorID];
} }
await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [ await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [
@ -636,9 +648,9 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("getTags", async (callback) => { socket.on("getTags", async (callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
const list = await R.findAll("tag") const list = await R.findAll("tag");
callback({ callback({
ok: true, ok: true,
@ -655,12 +667,12 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("addTag", async (tag, callback) => { socket.on("addTag", async (tag, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
let bean = R.dispense("tag") let bean = R.dispense("tag");
bean.name = tag.name bean.name = tag.name;
bean.color = tag.color bean.color = tag.color;
await R.store(bean) await R.store(bean);
callback({ callback({
ok: true, ok: true,
@ -677,12 +689,12 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("editTag", async (tag, callback) => { socket.on("editTag", async (tag, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
let bean = await R.findOne("monitor", " id = ? ", [ tag.id ]) let bean = await R.findOne("monitor", " id = ? ", [ tag.id ]);
bean.name = tag.name bean.name = tag.name;
bean.color = tag.color bean.color = tag.color;
await R.store(bean) await R.store(bean);
callback({ callback({
ok: true, ok: true,
@ -699,9 +711,9 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("deleteTag", async (tagID, callback) => { socket.on("deleteTag", async (tagID, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
await R.exec("DELETE FROM tag WHERE id = ? ", [ tagID ]) await R.exec("DELETE FROM tag WHERE id = ? ", [ tagID ]);
callback({ callback({
ok: true, ok: true,
@ -718,13 +730,13 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("addMonitorTag", async (tagID, monitorID, value, callback) => { socket.on("addMonitorTag", async (tagID, monitorID, value, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
await R.exec("INSERT INTO monitor_tag (tag_id, monitor_id, value) VALUES (?, ?, ?)", [ await R.exec("INSERT INTO monitor_tag (tag_id, monitor_id, value) VALUES (?, ?, ?)", [
tagID, tagID,
monitorID, monitorID,
value, value,
]) ]);
callback({ callback({
ok: true, ok: true,
@ -741,13 +753,13 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("editMonitorTag", async (tagID, monitorID, value, callback) => { socket.on("editMonitorTag", async (tagID, monitorID, value, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
await R.exec("UPDATE monitor_tag SET value = ? WHERE tag_id = ? AND monitor_id = ?", [ await R.exec("UPDATE monitor_tag SET value = ? WHERE tag_id = ? AND monitor_id = ?", [
value, value,
tagID, tagID,
monitorID, monitorID,
]) ]);
callback({ callback({
ok: true, ok: true,
@ -764,13 +776,13 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("deleteMonitorTag", async (tagID, monitorID, value, callback) => { socket.on("deleteMonitorTag", async (tagID, monitorID, value, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
await R.exec("DELETE FROM monitor_tag WHERE tag_id = ? AND monitor_id = ? AND value = ?", [ await R.exec("DELETE FROM monitor_tag WHERE tag_id = ? AND monitor_id = ? AND value = ?", [
tagID, tagID,
monitorID, monitorID,
value, value,
]) ]);
// Cleanup unused Tags // Cleanup unused Tags
await R.exec("delete from tag where ( select count(*) from monitor_tag mt where tag.id = mt.tag_id ) = 0"); await R.exec("delete from tag where ( select count(*) from monitor_tag mt where tag.id = mt.tag_id ) = 0");
@ -790,15 +802,15 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("changePassword", async (password, callback) => { socket.on("changePassword", async (password, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
if (! password.currentPassword) { if (! password.currentPassword) {
throw new Error("Invalid new password") throw new Error("Invalid new password");
} }
let user = await R.findOne("user", " id = ? AND active = 1 ", [ let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID, socket.userID,
]) ]);
if (user && passwordHash.verify(password.currentPassword, user.password)) { if (user && passwordHash.verify(password.currentPassword, user.password)) {
@ -807,9 +819,9 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
callback({ callback({
ok: true, ok: true,
msg: "Password has been updated successfully.", msg: "Password has been updated successfully.",
}) });
} else { } else {
throw new Error("Incorrect current password") throw new Error("Incorrect current password");
} }
} catch (e) { } catch (e) {
@ -822,7 +834,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("getSettings", async (callback) => { socket.on("getSettings", async (callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
callback({ callback({
ok: true, ok: true,
@ -839,9 +851,10 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("setSettings", async (data, callback) => { socket.on("setSettings", async (data, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
await setSettings("general", data) await setSettings("general", data);
exports.entryPage = data.entryPage;
callback({ callback({
ok: true, ok: true,
@ -859,10 +872,10 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
// Add or Edit // Add or Edit
socket.on("addNotification", async (notification, notificationID, callback) => { socket.on("addNotification", async (notification, notificationID, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
let notificationBean = await Notification.save(notification, notificationID, socket.userID) let notificationBean = await Notification.save(notification, notificationID, socket.userID);
await sendNotificationList(socket) await sendNotificationList(socket);
callback({ callback({
ok: true, ok: true,
@ -880,10 +893,10 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("deleteNotification", async (notificationID, callback) => { socket.on("deleteNotification", async (notificationID, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
await Notification.delete(notificationID, socket.userID) await Notification.delete(notificationID, socket.userID);
await sendNotificationList(socket) await sendNotificationList(socket);
callback({ callback({
ok: true, ok: true,
@ -900,9 +913,9 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("testNotification", async (notification, callback) => { socket.on("testNotification", async (notification, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
let msg = await Notification.send(notification, notification.name + " Testing") let msg = await Notification.send(notification, notification.name + " Testing");
callback({ callback({
ok: true, ok: true,
@ -910,7 +923,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
}); });
} catch (e) { } catch (e) {
console.error(e) console.error(e);
callback({ callback({
ok: false, ok: false,
@ -921,7 +934,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("checkApprise", async (callback) => { socket.on("checkApprise", async (callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
callback(Notification.checkApprise()); callback(Notification.checkApprise());
} catch (e) { } catch (e) {
callback(false); callback(false);
@ -945,8 +958,8 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
if (importHandle == "overwrite") { if (importHandle == "overwrite") {
// Stops every monitor first, so it doesn't execute any heartbeat while importing // Stops every monitor first, so it doesn't execute any heartbeat while importing
for (let id in monitorList) { for (let id in monitorList) {
let monitor = monitorList[id] let monitor = monitorList[id];
await monitor.stop() await monitor.stop();
} }
await R.exec("DELETE FROM heartbeat"); await R.exec("DELETE FROM heartbeat");
await R.exec("DELETE FROM monitor_notification"); await R.exec("DELETE FROM monitor_notification");
@ -968,7 +981,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
if ((importHandle == "skip" && notificationNameListString.includes(notificationListData[i].name) == false) || importHandle == "keep" || importHandle == "overwrite") { if ((importHandle == "skip" && notificationNameListString.includes(notificationListData[i].name) == false) || importHandle == "keep" || importHandle == "overwrite") {
let notification = JSON.parse(notificationListData[i].config); let notification = JSON.parse(notificationListData[i].config);
await Notification.save(notification, null, socket.userID) await Notification.save(notification, null, socket.userID);
} }
} }
@ -1018,9 +1031,9 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
dns_resolve_type: monitorListData[i].dns_resolve_type, dns_resolve_type: monitorListData[i].dns_resolve_type,
dns_resolve_server: monitorListData[i].dns_resolve_server, dns_resolve_server: monitorListData[i].dns_resolve_server,
notificationIDList: {}, notificationIDList: {},
} };
let bean = R.dispense("monitor") let bean = R.dispense("monitor");
let notificationIDList = monitor.notificationIDList; let notificationIDList = monitor.notificationIDList;
delete monitor.notificationIDList; delete monitor.notificationIDList;
@ -1028,9 +1041,9 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
monitor.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes); monitor.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
delete 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);
// Only for backup files with the version 1.7.0 or higher, since there was the tag feature implemented // Only for backup files with the version 1.7.0 or higher, since there was the tag feature implemented
if (version >= 170) { if (version >= 170) {
@ -1078,7 +1091,7 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
} }
} }
await sendNotificationList(socket) await sendNotificationList(socket);
await sendMonitorList(socket); await sendMonitorList(socket);
} }
@ -1097,9 +1110,9 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("clearEvents", async (monitorID, callback) => { socket.on("clearEvents", async (monitorID, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
console.log(`Clear Events Monitor: ${monitorID} User ID: ${socket.userID}`) console.log(`Clear Events Monitor: ${monitorID} User ID: ${socket.userID}`);
await R.exec("UPDATE heartbeat SET msg = ?, important = ? WHERE monitor_id = ? ", [ await R.exec("UPDATE heartbeat SET msg = ?, important = ? WHERE monitor_id = ? ", [
"", "",
@ -1123,9 +1136,9 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("clearHeartbeats", async (monitorID, callback) => { socket.on("clearHeartbeats", async (monitorID, callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
console.log(`Clear Heartbeats Monitor: ${monitorID} User ID: ${socket.userID}`) console.log(`Clear Heartbeats Monitor: ${monitorID} User ID: ${socket.userID}`);
await R.exec("DELETE FROM heartbeat WHERE monitor_id = ?", [ await R.exec("DELETE FROM heartbeat WHERE monitor_id = ?", [
monitorID monitorID
@ -1147,9 +1160,9 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
socket.on("clearStatistics", async (callback) => { socket.on("clearStatistics", async (callback) => {
try { try {
checkLogin(socket) checkLogin(socket);
console.log(`Clear Statistics User ID: ${socket.userID}`) console.log(`Clear Statistics User ID: ${socket.userID}`);
await R.exec("DELETE FROM heartbeat"); await R.exec("DELETE FROM heartbeat");
@ -1165,24 +1178,27 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
} }
}); });
debug("added all socket handlers") // Status Page Socket Handler for admin only
statusPageSocketHandler(socket);
debug("added all socket handlers");
// *************************** // ***************************
// Better do anything after added all socket handlers here // Better do anything after added all socket handlers here
// *************************** // ***************************
debug("check auto login") debug("check auto login");
if (await setting("disableAuth")) { if (await setting("disableAuth")) {
console.log("Disabled Auth: auto login to admin") console.log("Disabled Auth: auto login to admin");
afterLogin(socket, await R.findOne("user")) afterLogin(socket, await R.findOne("user"));
socket.emit("autoLogin") socket.emit("autoLogin");
} else { } else {
debug("need auth") debug("need auth");
} }
}); });
console.log("Init the server") console.log("Init the server");
server.once("error", async (err) => { server.once("error", async (err) => {
console.error("Cannot listen: " + err.message); console.error("Cannot listen: " + err.message);
@ -1204,14 +1220,14 @@ let indexHTML = fs.readFileSync("./dist/index.html").toString();
async function updateMonitorNotification(monitorID, notificationIDList) { async function updateMonitorNotification(monitorID, notificationIDList) {
await R.exec("DELETE FROM monitor_notification WHERE monitor_id = ? ", [ await R.exec("DELETE FROM monitor_notification WHERE monitor_id = ? ", [
monitorID, monitorID,
]) ]);
for (let notificationID in notificationIDList) { for (let notificationID in notificationIDList) {
if (notificationIDList[notificationID]) { if (notificationIDList[notificationID]) {
let relation = R.dispense("monitor_notification"); let relation = R.dispense("monitor_notification");
relation.monitor_id = monitorID; relation.monitor_id = monitorID;
relation.notification_id = notificationID; relation.notification_id = notificationID;
await R.store(relation) await R.store(relation);
} }
} }
} }
@ -1220,7 +1236,7 @@ async function checkOwner(userID, monitorID) {
let row = await R.getRow("SELECT id FROM monitor WHERE id = ? AND user_id = ? ", [ let row = await R.getRow("SELECT id FROM monitor WHERE id = ? AND user_id = ? ", [
monitorID, monitorID,
userID, userID,
]) ]);
if (! row) { if (! row) {
throw new Error("You do not own this monitor."); throw new Error("You do not own this monitor.");
@ -1229,16 +1245,16 @@ async function checkOwner(userID, monitorID) {
async function sendMonitorList(socket) { async function sendMonitorList(socket) {
let list = await getMonitorJSONList(socket.userID); let list = await getMonitorJSONList(socket.userID);
io.to(socket.userID).emit("monitorList", list) io.to(socket.userID).emit("monitorList", list);
return list; return list;
} }
async function afterLogin(socket, user) { async function afterLogin(socket, user) {
socket.userID = user.id; socket.userID = user.id;
socket.join(user.id) socket.join(user.id);
let monitorList = await sendMonitorList(socket) let monitorList = await sendMonitorList(socket);
sendNotificationList(socket) sendNotificationList(socket);
await sleep(500); await sleep(500);
@ -1251,7 +1267,7 @@ async function afterLogin(socket, user) {
} }
for (let monitorID in monitorList) { for (let monitorID in monitorList) {
await Monitor.sendStats(io, monitorID, user.id) await Monitor.sendStats(io, monitorID, user.id);
} }
} }
@ -1260,7 +1276,7 @@ async function getMonitorJSONList(userID) {
let monitorList = await R.find("monitor", " user_id = ? ORDER BY weight DESC, name", [ let monitorList = await R.find("monitor", " user_id = ? ORDER BY weight DESC, name", [
userID, userID,
]) ]);
for (let monitor of monitorList) { for (let monitor of monitorList) {
result[monitor.id] = await monitor.toJSON(); result[monitor.id] = await monitor.toJSON();
@ -1269,24 +1285,18 @@ async function getMonitorJSONList(userID) {
return result; return result;
} }
function checkLogin(socket) {
if (! socket.userID) {
throw new Error("You are not logged in.");
}
}
async function initDatabase() { async function initDatabase() {
if (! fs.existsSync(Database.path)) { if (! fs.existsSync(Database.path)) {
console.log("Copying Database") console.log("Copying Database");
fs.copyFileSync(Database.templatePath, Database.path); fs.copyFileSync(Database.templatePath, Database.path);
} }
console.log("Connecting to Database") console.log("Connecting to Database");
await Database.connect(); await Database.connect();
console.log("Connected") console.log("Connected");
// Patch the database // Patch the database
await Database.patch() await Database.patch();
let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [ let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
"jwtSecret", "jwtSecret",
@ -1302,7 +1312,7 @@ async function initDatabase() {
// 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
if ((await R.count("user")) === 0) { if ((await R.count("user")) === 0) {
console.log("No user, need setup") console.log("No user, need setup");
needSetup = true; needSetup = true;
} }
@ -1310,9 +1320,9 @@ async function initDatabase() {
} }
async function startMonitor(userID, monitorID) { async function startMonitor(userID, monitorID) {
await checkOwner(userID, monitorID) await checkOwner(userID, monitorID);
console.log(`Resume Monitor: ${monitorID} User ID: ${userID}`) console.log(`Resume Monitor: ${monitorID} User ID: ${userID}`);
await R.exec("UPDATE monitor SET active = 1 WHERE id = ? AND user_id = ? ", [ await R.exec("UPDATE monitor SET active = 1 WHERE id = ? AND user_id = ? ", [
monitorID, monitorID,
@ -1321,24 +1331,24 @@ async function startMonitor(userID, monitorID) {
let monitor = await R.findOne("monitor", " id = ? ", [ let monitor = await R.findOne("monitor", " id = ? ", [
monitorID, monitorID,
]) ]);
if (monitor.id in monitorList) { if (monitor.id in monitorList) {
monitorList[monitor.id].stop(); monitorList[monitor.id].stop();
} }
monitorList[monitor.id] = monitor; monitorList[monitor.id] = monitor;
monitor.start(io) monitor.start(io);
} }
async function restartMonitor(userID, monitorID) { async function restartMonitor(userID, monitorID) {
return await startMonitor(userID, monitorID) return await startMonitor(userID, monitorID);
} }
async function pauseMonitor(userID, monitorID) { async function pauseMonitor(userID, monitorID) {
await checkOwner(userID, monitorID) await checkOwner(userID, monitorID);
console.log(`Pause Monitor: ${monitorID} User ID: ${userID}`) console.log(`Pause Monitor: ${monitorID} User ID: ${userID}`);
await R.exec("UPDATE monitor SET active = 0 WHERE id = ? AND user_id = ? ", [ await R.exec("UPDATE monitor SET active = 0 WHERE id = ? AND user_id = ? ", [
monitorID, monitorID,
@ -1354,7 +1364,7 @@ async function pauseMonitor(userID, monitorID) {
* Resume active monitors * Resume active monitors
*/ */
async function startMonitors() { 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) {
monitorList[monitor.id] = monitor; monitorList[monitor.id] = monitor;
@ -1371,10 +1381,10 @@ async function shutdownFunction(signal) {
console.log("Shutdown requested"); console.log("Shutdown requested");
console.log("Called signal: " + signal); console.log("Called signal: " + signal);
console.log("Stopping all monitors") console.log("Stopping all monitors");
for (let id in monitorList) { for (let id in monitorList) {
let monitor = monitorList[id] let monitor = monitorList[id];
monitor.stop() monitor.stop();
} }
await sleep(2000); await sleep(2000);
await Database.close(); await Database.close();

View file

@ -0,0 +1,161 @@
const { R } = require("redbean-node");
const { checkLogin, setSettings } = require("../util-server");
const dayjs = require("dayjs");
const { debug } = require("../../src/util");
const ImageDataURI = require("../image-data-uri");
const Database = require("../database");
const apicache = require("../modules/apicache");
module.exports.statusPageSocketHandler = (socket) => {
// Post or edit incident
socket.on("postIncident", async (incident, callback) => {
try {
checkLogin(socket);
await R.exec("UPDATE incident SET pin = 0 ");
let incidentBean;
if (incident.id) {
incidentBean = await R.findOne("incident", " id = ?", [
incident.id
]);
}
if (incidentBean == null) {
incidentBean = R.dispense("incident");
}
incidentBean.title = incident.title;
incidentBean.content = incident.content;
incidentBean.style = incident.style;
incidentBean.pin = true;
if (incident.id) {
incidentBean.lastUpdatedDate = R.isoDateTime(dayjs.utc());
} else {
incidentBean.createdDate = R.isoDateTime(dayjs.utc());
}
await R.store(incidentBean);
callback({
ok: true,
incident: incidentBean.toPublicJSON(),
});
} catch (error) {
callback({
ok: false,
msg: error.message,
});
}
});
socket.on("unpinIncident", async (callback) => {
try {
checkLogin(socket);
await R.exec("UPDATE incident SET pin = 0 WHERE pin = 1");
callback({
ok: true,
});
} catch (error) {
callback({
ok: false,
msg: error.message,
});
}
});
// Save Status Page
// imgDataUrl Only Accept PNG!
socket.on("saveStatusPage", async (config, imgDataUrl, publicGroupList, callback) => {
try {
checkLogin(socket);
apicache.clear();
const header = "data:image/png;base64,";
// Check logo format
// If is image data url, convert to png file
// Else assume it is a url, nothing to do
if (imgDataUrl.startsWith("data:")) {
if (! imgDataUrl.startsWith(header)) {
throw new Error("Only allowed PNG logo.");
}
// Convert to file
await ImageDataURI.outputFile(imgDataUrl, Database.uploadDir + "logo.png");
config.logo = "/upload/logo.png?t=" + Date.now();
} else {
config.icon = imgDataUrl;
}
// Save Config
await setSettings("statusPage", config);
// Save Public Group List
const groupIDList = [];
let groupOrder = 1;
for (let group of publicGroupList) {
let groupBean;
if (group.id) {
groupBean = await R.findOne("group", " id = ? AND public = 1 ", [
group.id
]);
} else {
groupBean = R.dispense("group");
}
groupBean.name = group.name;
groupBean.public = true;
groupBean.weight = groupOrder++;
await R.store(groupBean);
await R.exec("DELETE FROM monitor_group WHERE group_id = ? ", [
groupBean.id
]);
let monitorOrder = 1;
console.log(group.monitorList);
for (let monitor of group.monitorList) {
let relationBean = R.dispense("monitor_group");
relationBean.weight = monitorOrder++;
relationBean.group_id = groupBean.id;
relationBean.monitor_id = monitor.id;
await R.store(relationBean);
}
groupIDList.push(groupBean.id);
group.id = groupBean.id;
}
// Delete groups that not in the list
debug("Delete groups that not in the list");
const slots = groupIDList.map(() => "?").join(",");
await R.exec(`DELETE FROM \`group\` WHERE id NOT IN (${slots})`, groupIDList);
callback({
ok: true,
publicGroupList,
});
} catch (error) {
console.log(error);
callback({
ok: false,
msg: error.message,
});
}
});
};

View file

@ -23,7 +23,7 @@ exports.initJWTSecret = async () => {
jwtSecretBean.value = passwordHash.generate(dayjs() + ""); jwtSecretBean.value = passwordHash.generate(dayjs() + "");
await R.store(jwtSecretBean); await R.store(jwtSecretBean);
return jwtSecretBean; return jwtSecretBean;
} };
exports.tcping = function (hostname, port) { exports.tcping = function (hostname, port) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -44,7 +44,7 @@ exports.tcping = function (hostname, port) {
resolve(Math.round(data.max)); resolve(Math.round(data.max));
}); });
}); });
} };
exports.ping = async (hostname) => { exports.ping = async (hostname) => {
try { try {
@ -57,7 +57,7 @@ exports.ping = async (hostname) => {
throw e; throw e;
} }
} }
} };
exports.pingAsync = function (hostname, ipv6 = false) { exports.pingAsync = function (hostname, ipv6 = false) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -69,13 +69,13 @@ exports.pingAsync = function (hostname, ipv6 = false) {
if (err) { if (err) {
reject(err); reject(err);
} else if (ms === null) { } else if (ms === null) {
reject(new Error(stdout)) reject(new Error(stdout));
} else { } else {
resolve(Math.round(ms)) resolve(Math.round(ms));
} }
}); });
}); });
} };
exports.dnsResolve = function (hostname, resolver_server, rrtype) { exports.dnsResolve = function (hostname, resolver_server, rrtype) {
const resolver = new Resolver(); const resolver = new Resolver();
@ -98,8 +98,8 @@ exports.dnsResolve = function (hostname, resolver_server, rrtype) {
} }
}); });
} }
}) });
} };
exports.setting = async function (key) { exports.setting = async function (key) {
let value = await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [ let value = await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [
@ -108,29 +108,29 @@ exports.setting = async function (key) {
try { try {
const v = JSON.parse(value); const v = JSON.parse(value);
debug(`Get Setting: ${key}: ${v}`) debug(`Get Setting: ${key}: ${v}`);
return v; return v;
} catch (e) { } catch (e) {
return value; return value;
} }
} };
exports.setSetting = async function (key, value) { 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;
} }
bean.value = JSON.stringify(value); bean.value = JSON.stringify(value);
await R.store(bean) await R.store(bean);
} };
exports.getSettings = async function (type) { exports.getSettings = async function (type) {
let list = await R.getAll("SELECT `key`, `value` FROM setting WHERE `type` = ? ", [ let list = await R.getAll("SELECT `key`, `value` FROM setting WHERE `type` = ? ", [
type, type,
]) ]);
let result = {}; let result = {};
@ -143,7 +143,7 @@ exports.getSettings = async function (type) {
} }
return result; return result;
} };
exports.setSettings = async function (type, data) { exports.setSettings = async function (type, data) {
let keyList = Object.keys(data); let keyList = Object.keys(data);
@ -163,12 +163,12 @@ exports.setSettings = async function (type, data) {
if (bean.type === type) { if (bean.type === type) {
bean.value = JSON.stringify(data[key]); bean.value = JSON.stringify(data[key]);
promiseList.push(R.store(bean)) promiseList.push(R.store(bean));
} }
} }
await Promise.all(promiseList); await Promise.all(promiseList);
} };
// ssl-checker by @dyaa // ssl-checker by @dyaa
// param: res - response object from axios // param: res - response object from axios
@ -218,7 +218,7 @@ exports.checkCertificate = function (res) {
issuer, issuer,
fingerprint, fingerprint,
}; };
} };
// Check if the provided status code is within the accepted ranges // Check if the provided status code is within the accepted ranges
// Param: status - the status code to check // Param: status - the status code to check
@ -247,7 +247,7 @@ exports.checkStatusCode = function (status, accepted_codes) {
} }
return false; return false;
} };
exports.getTotalClientInRoom = (io, roomName) => { exports.getTotalClientInRoom = (io, roomName) => {
@ -270,7 +270,7 @@ exports.getTotalClientInRoom = (io, roomName) => {
} else { } else {
return 0; return 0;
} }
} };
exports.genSecret = () => { exports.genSecret = () => {
let secret = ""; let secret = "";
@ -280,4 +280,21 @@ exports.genSecret = () => {
secret += chars.charAt(Math.floor(Math.random() * charsLength)); secret += chars.charAt(Math.floor(Math.random() * charsLength));
} }
return secret; return secret;
} };
exports.allowDevAllOrigin = (res) => {
if (process.env.NODE_ENV === "development") {
exports.allowAllOrigin(res);
}
};
exports.allowAllOrigin = (res) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
};
exports.checkLogin = (socket) => {
if (! socket.userID) {
throw new Error("You are not logged in.");
}
};

View file

@ -144,7 +144,9 @@ h2 {
} }
.shadow-box { .shadow-box {
background-color: $dark-bg; &:not(.alert) {
background-color: $dark-bg;
}
} }
.form-check-input { .form-check-input {
@ -255,6 +257,18 @@ h2 {
background-color: $dark-bg; background-color: $dark-bg;
} }
.monitor-list {
.item {
&:hover {
background-color: $dark-bg2;
}
&.active {
background-color: $dark-bg2;
}
}
}
@media (max-width: 550px) { @media (max-width: 550px) {
.table-shadow-box { .table-shadow-box {
tbody { tbody {
@ -268,6 +282,16 @@ h2 {
} }
} }
} }
.alert {
&.bg-info,
&.bg-warning,
&.bg-danger,
&.bg-light {
color: $dark-font-color2;
}
}
} }
/* /*
@ -288,3 +312,119 @@ h2 {
transform: translateY(50px); transform: translateY(50px);
opacity: 0; opacity: 0;
} }
.slide-fade-right-enter-active {
transition: all 0.2s $easing-in;
}
.slide-fade-right-leave-active {
transition: all 0.2s $easing-in;
}
.slide-fade-right-enter-from,
.slide-fade-right-leave-to {
transform: translateX(50px);
opacity: 0;
}
.monitor-list {
&.scrollbar {
min-height: calc(100vh - 240px);
max-height: calc(100vh - 30px);
overflow-y: auto;
position: sticky;
top: 10px;
}
.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;
}
}
}
.alert-success {
color: #122f21;
background-color: $primary;
border-color: $primary;
}
.alert-info {
color: #055160;
background-color: #cff4fc;
border-color: #cff4fc;
}
.alert-danger {
color: #842029;
background-color: #f8d7da;
border-color: #f8d7da;
}
.btn-success {
color: #fff;
background-color: #4caf50;
border-color: #4caf50;
}
[contenteditable=true] {
transition: all $easing-in 0.2s;
background-color: rgba(239, 239, 239, 0.7);
border-radius: 8px;
&:focus {
outline: 0 solid #eee;
background-color: rgba(245, 245, 245, 0.9);
}
&:hover {
background-color: rgba(239, 239, 239, 0.8);
}
.dark & {
background-color: rgba(239, 239, 239, 0.2);
}
/*
&::after {
margin-left: 5px;
content: "🖊️";
font-size: 13px;
color: #eee;
}
*/
}
.action {
transition: all $easing-in 0.2s;
&:hover {
cursor: pointer;
transform: scale(1.2);
}
}
.vue-image-crop-upload .vicp-wrap {
border-radius: 10px !important;
}

View file

@ -25,6 +25,10 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
heartbeatList: {
type: Array,
default: null,
}
}, },
data() { data() {
return { return {
@ -38,8 +42,15 @@ export default {
}, },
computed: { computed: {
/**
* If heartbeatList is null, get it from $root.heartbeatList
*/
beatList() { beatList() {
return this.$root.heartbeatList[this.monitorId] if (this.heartbeatList === null) {
return this.$root.heartbeatList[this.monitorId];
} else {
return this.heartbeatList;
}
}, },
shortBeatList() { shortBeatList() {
@ -118,8 +129,10 @@ export default {
window.removeEventListener("resize", this.resize); window.removeEventListener("resize", this.resize);
}, },
beforeMount() { beforeMount() {
if (! (this.monitorId in this.$root.heartbeatList)) { if (this.heartbeatList === null) {
this.$root.heartbeatList[this.monitorId] = []; if (! (this.monitorId in this.$root.heartbeatList)) {
this.$root.heartbeatList[this.monitorId] = [];
}
} }
}, },

View file

@ -12,7 +12,7 @@
<input v-model="searchText" class="form-control search-input" :placeholder="$t('Search...')" /> <input v-model="searchText" class="form-control search-input" :placeholder="$t('Search...')" />
</div> </div>
</div> </div>
<div class="list" :class="{ scrollbar: scrollbar }"> <div class="monitor-list" :class="{ scrollbar: scrollbar }">
<div v-if="Object.keys($root.monitorList).length === 0" class="text-center mt-3"> <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> {{ $t("No Monitors, please") }} <router-link to="/add">{{ $t("add one") }}</router-link>
</div> </div>
@ -163,56 +163,6 @@ export default {
max-width: 15em; max-width: 15em;
} }
.list {
&.scrollbar {
min-height: calc(100vh - 240px);
max-height: calc(100vh - 30px);
overflow-y: auto;
position: sticky;
top: 10px;
}
.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 { .monitorItem {
width: 100%; width: 100%;
} }

View file

@ -0,0 +1,144 @@
<template>
<!-- Group List -->
<Draggable
v-model="$root.publicGroupList"
:disabled="!editMode"
item-key="id"
:animation="100"
>
<template #item="group">
<div class="mb-5 ">
<!-- Group Title -->
<h2 class="group-title">
<font-awesome-icon v-if="editMode && showGroupDrag" icon="arrows-alt-v" class="action drag me-3" />
<font-awesome-icon v-if="editMode" icon="times" class="action remove me-3" @click="removeGroup(group.index)" />
<Editable v-model="group.element.name" :contenteditable="editMode" tag="span" />
</h2>
<div class="shadow-box monitor-list mt-4 position-relative">
<div v-if="group.element.monitorList.length === 0" class="text-center no-monitor-msg">
{{ $t("No Monitors") }}
</div>
<!-- Monitor List -->
<!-- animation is not working, no idea why -->
<Draggable
v-model="group.element.monitorList"
class="monitor-list"
group="same-group"
:disabled="!editMode"
:animation="100"
item-key="id"
>
<template #item="monitor">
<div class="item">
<div class="row">
<div class="col-9 col-md-8 small-padding">
<div class="info">
<font-awesome-icon v-if="editMode" icon="arrows-alt-v" class="action drag me-3" />
<font-awesome-icon v-if="editMode" icon="times" class="action remove me-3" @click="removeMonitor(group.index, monitor.index)" />
<Uptime :monitor="monitor.element" type="24" :pill="true" />
{{ monitor.element.name }}
</div>
</div>
<div :key="$root.userHeartbeatBar" class="col-3 col-md-4">
<HeartbeatBar size="small" :monitor-id="monitor.element.id" />
</div>
</div>
</div>
</template>
</Draggable>
</div>
</div>
</template>
</Draggable>
</template>
<script>
import Draggable from "vuedraggable";
import HeartbeatBar from "./HeartbeatBar.vue";
import Uptime from "./Uptime.vue";
export default {
components: {
Draggable,
HeartbeatBar,
Uptime,
},
props: {
editMode: {
type: Boolean,
required: true,
},
},
data() {
return {
};
},
computed: {
showGroupDrag() {
return (this.$root.publicGroupList.length >= 2);
}
},
created() {
},
methods: {
removeGroup(index) {
this.$root.publicGroupList.splice(index, 1);
},
removeMonitor(groupIndex, index) {
this.$root.publicGroupList[groupIndex].monitorList.splice(index, 1);
},
}
};
</script>
<style lang="scss" scoped>
@import "../assets/vars";
.no-monitor-msg {
position: absolute;
width: 100%;
top: 20px;
left: 0;
}
.monitor-list {
min-height: 46px;
}
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.drag {
color: #bbb;
cursor: grab;
}
.remove {
color: $danger;
}
.group-title {
span {
display: inline-block;
min-width: 15px;
}
}
.mobile {
.item {
padding: 13px 0 10px 0;
}
}
</style>

View file

@ -55,7 +55,7 @@
<div class="w-50 pe-2"> <div class="w-50 pe-2">
<input v-model="newDraftTag.name" class="form-control" <input v-model="newDraftTag.name" class="form-control"
:class="{'is-invalid': validateDraftTag.nameInvalid}" :class="{'is-invalid': validateDraftTag.nameInvalid}"
:placeholder="$t('name')" :placeholder="$t('Name')"
@keydown.enter.prevent="onEnter" @keydown.enter.prevent="onEnter"
/> />
<div class="invalid-feedback"> <div class="invalid-feedback">

View file

@ -43,6 +43,6 @@ export const i18n = createI18n({
locale: localStorage.locale || "en", locale: localStorage.locale || "en",
fallbackLocale: "en", fallbackLocale: "en",
silentFallbackWarn: true, silentFallbackWarn: true,
silentTranslationWarn: false, silentTranslationWarn: true,
messages: languageList, messages: languageList,
}); });

View file

@ -1,4 +1,8 @@
import { library } from "@fortawesome/fontawesome-svg-core"; import { library } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
// Add Free Font Awesome Icons
// https://fontawesome.com/v5.15/icons?d=gallery&p=2&s=solid&m=free
import { import {
faArrowAltCircleUp, faArrowAltCircleUp,
faCog, faCog,
@ -12,13 +16,19 @@ import {
faSearch, faSearch,
faTachometerAlt, faTachometerAlt,
faTimes, faTimes,
faTrash faTimesCircle,
faTrash,
faCheckCircle,
faStream,
faSave,
faExclamationCircle,
faBullhorn,
faArrowsAltV,
faUnlink,
faQuestionCircle,
faImages, faUpload,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
//import { fa } from '@fortawesome/free-regular-svg-icons'
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
// Add Free Font Awesome Icons here
// https://fontawesome.com/v5.15/icons?d=gallery&p=2&s=solid&m=free
library.add( library.add(
faArrowAltCircleUp, faArrowAltCircleUp,
faCog, faCog,
@ -32,7 +42,18 @@ library.add(
faSearch, faSearch,
faTachometerAlt, faTachometerAlt,
faTimes, faTimes,
faTimesCircle,
faTrash, faTrash,
faCheckCircle,
faStream,
faSave,
faExclamationCircle,
faBullhorn,
faArrowsAltV,
faUnlink,
faQuestionCircle,
faImages,
faUpload,
); );
export { FontAwesomeIcon }; export { FontAwesomeIcon };

View file

@ -1,8 +1,8 @@
# How to translate # How to translate
1. Fork this repo. 1. Fork this repo.
2. Create a language file. (e.g. `zh-TW.js`) The filename must be ISO language code: http://www.lingoes.net/en/translator/langcode.htm 2. Create a language file (e.g. `zh-TW.js`). The filename must be ISO language code: http://www.lingoes.net/en/translator/langcode.htm
3. `npm run update-language-files --base-lang=de-DE` 3. Run `npm run update-language-files`. You can also use this command to check if there are new strings to translate for your language.
4. Your language file should be filled in. You can translate now. 4. Your language file should be filled in. You can translate now.
5. Translate `src/pages/Settings.vue` (search for a `Confirm` component with `rel="confirmDisableAuth"`). 5. Translate `src/pages/Settings.vue` (search for a `Confirm` component with `rel="confirmDisableAuth"`).
6. Import your language file in `src/i18n.js` and add it to `languageList` constant. 6. Import your language file in `src/i18n.js` and add it to `languageList` constant.

View file

@ -36,7 +36,6 @@ export default {
hour: "Timer", hour: "Timer",
"-hour": "-Timer", "-hour": "-Timer",
checkEverySecond: "Tjek hvert {0} sekund", checkEverySecond: "Tjek hvert {0} sekund",
"Avg.": "Gns.",
Response: "Respons", Response: "Respons",
Ping: "Ping", Ping: "Ping",
"Monitor Type": "Overvåger Type", "Monitor Type": "Overvåger Type",
@ -143,4 +142,31 @@ export default {
Token: "Token", Token: "Token",
"Show URI": "Show URI", "Show URI": "Show URI",
"Clear all statistics": "Clear all Statistics", "Clear all statistics": "Clear all Statistics",
retryCheckEverySecond: "Retry every {0} seconds.",
importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.",
confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Skip existing": "Skip existing",
Overwrite: "Overwrite",
Options: "Options",
"Keep both": "Keep both",
Tags: "Tags",
"Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.",
"Tag with this value already exist.": "Tag with this value already exist.",
color: "color",
"value (optional)": "value (optional)",
Gray: "Gray",
Red: "Red",
Orange: "Orange",
Green: "Green",
Blue: "Blue",
Indigo: "Indigo",
Purple: "Purple",
Pink: "Pink",
"Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -36,7 +36,6 @@ export default {
hour: "Stunde", hour: "Stunde",
"-hour": "-Stunden", "-hour": "-Stunden",
checkEverySecond: "Überprüfe alle {0} Sekunden", checkEverySecond: "Überprüfe alle {0} Sekunden",
"Avg.": "Durchschn.",
Response: "Antwortzeit", Response: "Antwortzeit",
Ping: "Ping", Ping: "Ping",
"Monitor Type": "Monitor Typ", "Monitor Type": "Monitor Typ",
@ -113,9 +112,6 @@ export default {
"Create your admin account": "Erstelle dein Admin Konto", "Create your admin account": "Erstelle dein Admin Konto",
"Repeat Password": "Wiederhole das Passwort", "Repeat Password": "Wiederhole das Passwort",
"Resource Record Type": "Resource Record Type", "Resource Record Type": "Resource Record Type",
"Export": "Export",
"Import": "Import",
"Import/Export Backup": "Import/Export Backup",
Export: "Export", Export: "Export",
Import: "Import", Import: "Import",
respTime: "Antw. Zeit (ms)", respTime: "Antw. Zeit (ms)",
@ -133,8 +129,8 @@ export default {
"Clear all statistics": "Lösche alle Statistiken", "Clear all statistics": "Lösche alle Statistiken",
importHandleDescription: "Wähle 'Vorhandene überspringen' aus, wenn jeder Monitor oder Benachrichtigung mit demselben Namen übersprungen werden soll. 'Überschreiben' löscht jeden vorhandenen Monitor sowie Benachrichtigungen.", importHandleDescription: "Wähle 'Vorhandene überspringen' aus, wenn jeder Monitor oder Benachrichtigung mit demselben Namen übersprungen werden soll. 'Überschreiben' löscht jeden vorhandenen Monitor sowie Benachrichtigungen.",
"Skip existing": "Vorhandene überspringen", "Skip existing": "Vorhandene überspringen",
"Overwrite": "Überschreiben", Overwrite: "Überschreiben",
"Options": "Optionen", Options: "Optionen",
confirmImportMsg: "Möchtest du das Backup wirklich importieren? Bitte stelle sicher, dass die richtige Import Option ausgewählt ist.", confirmImportMsg: "Möchtest du das Backup wirklich importieren? Bitte stelle sicher, dass die richtige Import Option ausgewählt ist.",
"Keep both": "Beide behalten", "Keep both": "Beide behalten",
twoFAVerifyLabel: "Bitte trage deinen Token ein um zu verifizieren das 2FA funktioniert", twoFAVerifyLabel: "Bitte trage deinen Token ein um zu verifizieren das 2FA funktioniert",
@ -151,7 +147,6 @@ export default {
Inactive: "Inaktiv", Inactive: "Inaktiv",
Token: "Token", Token: "Token",
"Show URI": "URI Anzeigen", "Show URI": "URI Anzeigen",
"Clear all statistics": "Lösche alle Statistiken",
Tags: "Tags", Tags: "Tags",
"Add New below or Select...": "Füge neuen hinzu oder wähle aus...", "Add New below or Select...": "Füge neuen hinzu oder wähle aus...",
"Tag with this name already exist.": "Ein Tag mit dem Namen existiert bereits.", "Tag with this name already exist.": "Ein Tag mit dem Namen existiert bereits.",
@ -167,4 +162,10 @@ export default {
Purple: "Lila", Purple: "Lila",
Pink: "Pink", Pink: "Pink",
"Search...": "Suchen...", "Search...": "Suchen...",
"Heartbeat Retry Interval": "Takt-Wiederholungsintervall",
retryCheckEverySecond: "Versuche alle {0} Sekunden",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -2,7 +2,6 @@ export default {
languageName: "English", languageName: "English",
checkEverySecond: "Check every {0} seconds.", checkEverySecond: "Check every {0} seconds.",
retryCheckEverySecond: "Retry every {0} seconds.", retryCheckEverySecond: "Retry every {0} seconds.",
"Avg.": "Avg.",
retriesDescription: "Maximum retries before the service is marked as down and a notification is sent", retriesDescription: "Maximum retries before the service is marked as down and a notification is sent",
ignoreTLSError: "Ignore TLS/SSL error for HTTPS websites", ignoreTLSError: "Ignore TLS/SSL error for HTTPS websites",
upsideDownModeDescription: "Flip the status upside down. If the service is reachable, it is DOWN.", upsideDownModeDescription: "Flip the status upside down. If the service is reachable, it is DOWN.",
@ -139,8 +138,8 @@ export default {
alertWrongFileType: "Please select a JSON file.", alertWrongFileType: "Please select a JSON file.",
"Clear all statistics": "Clear all Statistics", "Clear all statistics": "Clear all Statistics",
"Skip existing": "Skip existing", "Skip existing": "Skip existing",
"Overwrite": "Overwrite", Overwrite: "Overwrite",
"Options": "Options", Options: "Options",
"Keep both": "Keep both", "Keep both": "Keep both",
"Verify Token": "Verify Token", "Verify Token": "Verify Token",
"Setup 2FA": "Setup 2FA", "Setup 2FA": "Setup 2FA",
@ -152,7 +151,6 @@ export default {
Inactive: "Inactive", Inactive: "Inactive",
Token: "Token", Token: "Token",
"Show URI": "Show URI", "Show URI": "Show URI",
"Clear all statistics": "Clear all Statistics",
Tags: "Tags", Tags: "Tags",
"Add New below or Select...": "Add New below or Select...", "Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.", "Tag with this name already exist.": "Tag with this name already exist.",
@ -168,4 +166,6 @@ export default {
Purple: "Purple", Purple: "Purple",
Pink: "Pink", Pink: "Pink",
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -1,7 +1,6 @@
export default { export default {
languageName: "Español", languageName: "Español",
checkEverySecond: "Comprobar cada {0} segundos.", checkEverySecond: "Comprobar cada {0} segundos.",
"Avg.": "Media.",
retriesDescription: "Número máximo de intentos antes de que el servicio se marque como CAÍDO y una notificación sea enviada.", retriesDescription: "Número máximo de intentos antes de que el servicio se marque como CAÍDO y una notificación sea enviada.",
ignoreTLSError: "Ignorar error TLS/SSL para sitios web HTTPS", ignoreTLSError: "Ignorar error TLS/SSL para sitios web HTTPS",
upsideDownModeDescription: "Invertir el estado. Si el servicio es alcanzable, está CAÍDO.", upsideDownModeDescription: "Invertir el estado. Si el servicio es alcanzable, está CAÍDO.",
@ -32,7 +31,7 @@ export default {
Up: "Funcional", Up: "Funcional",
Down: "Caído", Down: "Caído",
Pending: "Pendiente", Pending: "Pendiente",
Unknown: "Desconociso", Unknown: "Desconocido",
Pause: "Pausa", Pause: "Pausa",
Name: "Nombre", Name: "Nombre",
Status: "Estado", Status: "Estado",
@ -143,4 +142,31 @@ export default {
Token: "Token", Token: "Token",
"Show URI": "Show URI", "Show URI": "Show URI",
"Clear all statistics": "Clear all Statistics", "Clear all statistics": "Clear all Statistics",
retryCheckEverySecond: "Retry every {0} seconds.",
importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.",
confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Skip existing": "Skip existing",
Overwrite: "Overwrite",
Options: "Options",
"Keep both": "Keep both",
Tags: "Tags",
"Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.",
"Tag with this value already exist.": "Tag with this value already exist.",
color: "color",
"value (optional)": "value (optional)",
Gray: "Gray",
Red: "Red",
Orange: "Orange",
Green: "Green",
Blue: "Blue",
Indigo: "Indigo",
Purple: "Purple",
Pink: "Pink",
"Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -1,7 +1,6 @@
export default { export default {
languageName: "eesti", languageName: "eesti",
checkEverySecond: "Kontrolli {0} sekundilise vahega.", checkEverySecond: "Kontrolli {0} sekundilise vahega.",
"Avg.": "≈",
retriesDescription: "Mitu korda tuleb kontrollida, mille järel märkida 'maas' ja saata välja teavitus.", retriesDescription: "Mitu korda tuleb kontrollida, mille järel märkida 'maas' ja saata välja teavitus.",
ignoreTLSError: "Eira TLS/SSL viga HTTPS veebisaitidel.", ignoreTLSError: "Eira TLS/SSL viga HTTPS veebisaitidel.",
upsideDownModeDescription: "Käitle teenuse saadavust rikkena, teenuse kättesaamatust töötavaks.", upsideDownModeDescription: "Käitle teenuse saadavust rikkena, teenuse kättesaamatust töötavaks.",
@ -143,4 +142,31 @@ export default {
Token: "Token", Token: "Token",
"Show URI": "Show URI", "Show URI": "Show URI",
"Clear all statistics": "Clear all Statistics", "Clear all statistics": "Clear all Statistics",
retryCheckEverySecond: "Retry every {0} seconds.",
importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.",
confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Skip existing": "Skip existing",
Overwrite: "Overwrite",
Options: "Options",
"Keep both": "Keep both",
Tags: "Tags",
"Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.",
"Tag with this value already exist.": "Tag with this value already exist.",
color: "color",
"value (optional)": "value (optional)",
Gray: "Gray",
Red: "Red",
Orange: "Orange",
Green: "Green",
Blue: "Blue",
Indigo: "Indigo",
Purple: "Purple",
Pink: "Pink",
"Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -36,7 +36,6 @@ export default {
hour: "Heure", hour: "Heure",
"-hour": "Heures", "-hour": "Heures",
checkEverySecond: "Vérifier toutes les {0} secondes", checkEverySecond: "Vérifier toutes les {0} secondes",
"Avg.": "Moyen",
Response: "Temps de réponse", Response: "Temps de réponse",
Ping: "Ping", Ping: "Ping",
"Monitor Type": "Type de Sonde", "Monitor Type": "Type de Sonde",
@ -143,4 +142,31 @@ export default {
Token: "Token", Token: "Token",
"Show URI": "Show URI", "Show URI": "Show URI",
"Clear all statistics": "Clear all Statistics", "Clear all statistics": "Clear all Statistics",
retryCheckEverySecond: "Retry every {0} seconds.",
importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.",
confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Skip existing": "Skip existing",
Overwrite: "Overwrite",
Options: "Options",
"Keep both": "Keep both",
Tags: "Tags",
"Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.",
"Tag with this value already exist.": "Tag with this value already exist.",
color: "color",
"value (optional)": "value (optional)",
Gray: "Gray",
Red: "Red",
Orange: "Orange",
Green: "Green",
Blue: "Blue",
Indigo: "Indigo",
Purple: "Purple",
Pink: "Pink",
"Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -1,7 +1,7 @@
export default { export default {
languageName: "Italiano (Italian)", languageName: "Italiano (Italian)",
checkEverySecond: "controlla ogni {0} secondi", checkEverySecond: "controlla ogni {0} secondi",
"Avg.": "Media", retryCheckEverySecond: "Riprova ogni {0} secondi.",
retriesDescription: "Tentativi da fare prima che il servizio venga marcato come \"giù\" e che una notifica venga inviata.", retriesDescription: "Tentativi da fare prima che il servizio venga marcato come \"giù\" e che una notifica venga inviata.",
ignoreTLSError: "Ignora gli errori TLS/SSL per i siti in HTTPS.", ignoreTLSError: "Ignora gli errori TLS/SSL per i siti in HTTPS.",
upsideDownModeDescription: "Capovolgi lo stato. Se il servizio è raggiungibile viene marcato come \"GIÙ\".", upsideDownModeDescription: "Capovolgi lo stato. Se il servizio è raggiungibile viene marcato come \"GIÙ\".",
@ -20,6 +20,8 @@ export default {
clearEventsMsg: "Si è certi di voler eliminare tutti gli eventi per questo servizio?", clearEventsMsg: "Si è certi di voler eliminare tutti gli eventi per questo servizio?",
clearHeartbeatsMsg: "Si è certi di voler eliminare tutti gli intervalli di controllo per questo servizio?", clearHeartbeatsMsg: "Si è certi di voler eliminare tutti gli intervalli di controllo per questo servizio?",
confirmClearStatisticsMsg: "Si è certi di voler eliminare TUTTE le statistiche?", confirmClearStatisticsMsg: "Si è certi di voler eliminare TUTTE le statistiche?",
importHandleDescription: "Selezionare 'Ignora gli esistenti' si vuole ignorare l'importazione dei monitoraggi o delle notifiche con lo stesso nome. 'Sovrascrivi' eliminerà ogni monitoraggio e notifica esistente.",
confirmImportMsg: "Si è certi di voler importare il backup? Essere certi di aver selezionato l'opzione corretta di importazione.",
twoFAVerifyLabel: "Scrivi il token per verificare che l'autenticazione a due fattori funzioni", twoFAVerifyLabel: "Scrivi il token per verificare che l'autenticazione a due fattori funzioni",
tokenValidSettingsMsg: "Il token è valido! È ora possibile salvare le impostazioni.", tokenValidSettingsMsg: "Il token è valido! È ora possibile salvare le impostazioni.",
confirmEnableTwoFAMsg: "Si è certi di voler abilitare l'autenticazione a due fattori?", confirmEnableTwoFAMsg: "Si è certi di voler abilitare l'autenticazione a due fattori?",
@ -68,6 +70,7 @@ export default {
Port: "Porta", Port: "Porta",
"Heartbeat Interval": "Intervallo di controllo", "Heartbeat Interval": "Intervallo di controllo",
Retries: "Tentativi", Retries: "Tentativi",
"Heartbeat Retry Interval": "Intervallo tra un tentativo di controllo e l'altro",
Advanced: "Avanzate", Advanced: "Avanzate",
"Upside Down Mode": "Modalità capovolta", "Upside Down Mode": "Modalità capovolta",
"Max. Redirects": "Redirezionamenti massimi", "Max. Redirects": "Redirezionamenti massimi",
@ -115,7 +118,8 @@ export default {
"Last Result": "Ultimo risultato", "Last Result": "Ultimo risultato",
"Create your admin account": "Crea l'account amministratore", "Create your admin account": "Crea l'account amministratore",
"Repeat Password": "Ripeti Password", "Repeat Password": "Ripeti Password",
"Import/Export Backup": "Importa/Esporta Backup", "Import Backup": "Importa Backup",
"Export Backup": "Esporta Backup",
Export: "Esporta", Export: "Esporta",
Import: "Importa", Import: "Importa",
respTime: "Tempo di Risposta (ms)", respTime: "Tempo di Risposta (ms)",
@ -127,12 +131,16 @@ export default {
Events: "Eventi", Events: "Eventi",
Heartbeats: "Controlli", Heartbeats: "Controlli",
"Auto Get": "Auto Get", "Auto Get": "Auto Get",
"Also apply to existing monitors": "Also apply to existing monitors",
backupDescription: "È possibile fare il backup di tutti i monitoraggi e di tutte le notifiche in un file JSON.", backupDescription: "È possibile fare il backup di tutti i monitoraggi e di tutte le notifiche in un file JSON.",
backupDescription2: "P.S.: lo storico e i dati relativi agli eventi non saranno inclusi.", backupDescription2: "P.S.: lo storico e i dati relativi agli eventi non saranno inclusi.",
backupDescription3: "Dati sensibili come i token di autenticazione saranno inclusi nel backup, tenere quindi in un luogo sicuro.", backupDescription3: "Dati sensibili come i token di autenticazione saranno inclusi nel backup, tenere quindi in un luogo sicuro.",
alertNoFile: "Selezionare il file da importare.", alertNoFile: "Selezionare il file da importare.",
alertWrongFileType: "Selezionare un file JSON.", alertWrongFileType: "Selezionare un file JSON.",
"Clear all statistics": "Pulisci tutte le statistiche",
"Skip existing": "Ignora gli esistenti",
Overwrite: "Sovrascrivi",
Options: "Opzioni",
"Keep both": "Mantieni entrambi",
"Verify Token": "Verifica Token", "Verify Token": "Verifica Token",
"Setup 2FA": "Imposta l'autenticazione a due fattori", "Setup 2FA": "Imposta l'autenticazione a due fattori",
"Enable 2FA": "Abilita l'autenticazione a due fattori", "Enable 2FA": "Abilita l'autenticazione a due fattori",
@ -143,7 +151,6 @@ export default {
Inactive: "Disattivata", Inactive: "Disattivata",
Token: "Token", Token: "Token",
"Show URI": "Mostra URI", "Show URI": "Mostra URI",
"Clear all statistics": "Pulisci tutte le statistiche",
Tags: "Etichette", Tags: "Etichette",
"Add New below or Select...": "Aggiungine una oppure scegli...", "Add New below or Select...": "Aggiungine una oppure scegli...",
"Tag with this name already exist.": "Un'etichetta con questo nome già esiste.", "Tag with this name already exist.": "Un'etichetta con questo nome già esiste.",
@ -159,4 +166,6 @@ export default {
Purple: "Viola", Purple: "Viola",
Pink: "Rosa", Pink: "Rosa",
"Search...": "Cerca...", "Search...": "Cerca...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -1,7 +1,6 @@
export default { export default {
languageName: "日本語", languageName: "日本語",
checkEverySecond: "{0}秒ごとにチェックします。", checkEverySecond: "{0}秒ごとにチェックします。",
"Avg.": "平均",
retriesDescription: "サービスがダウンとしてマークされ、通知が送信されるまでの最大リトライ数", retriesDescription: "サービスがダウンとしてマークされ、通知が送信されるまでの最大リトライ数",
ignoreTLSError: "HTTPS ウェブサイトの TLS/SSL エラーを無視する", ignoreTLSError: "HTTPS ウェブサイトの TLS/SSL エラーを無視する",
upsideDownModeDescription: "ステータスの扱いを逆にします。サービスに到達可能な場合は、DOWNとなる。", upsideDownModeDescription: "ステータスの扱いを逆にします。サービスに到達可能な場合は、DOWNとなる。",
@ -143,4 +142,31 @@ export default {
Token: "Token", Token: "Token",
"Show URI": "Show URI", "Show URI": "Show URI",
"Clear all statistics": "Clear all Statistics", "Clear all statistics": "Clear all Statistics",
retryCheckEverySecond: "Retry every {0} seconds.",
importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.",
confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Skip existing": "Skip existing",
Overwrite: "Overwrite",
Options: "Options",
"Keep both": "Keep both",
Tags: "Tags",
"Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.",
"Tag with this value already exist.": "Tag with this value already exist.",
color: "color",
"value (optional)": "value (optional)",
Gray: "Gray",
Red: "Red",
Orange: "Orange",
Green: "Green",
Blue: "Blue",
Indigo: "Indigo",
Purple: "Purple",
Pink: "Pink",
"Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -1,7 +1,6 @@
export default { export default {
languageName: "한국어", languageName: "한국어",
checkEverySecond: "{0} 초마다 체크해요.", checkEverySecond: "{0} 초마다 체크해요.",
"Avg.": "평균",
retriesDescription: "서비스가 중단된 후 알림을 보내기 전 최대 재시도 횟수", retriesDescription: "서비스가 중단된 후 알림을 보내기 전 최대 재시도 횟수",
ignoreTLSError: "HTTPS 웹사이트에서 TLS/SSL 에러 무시하기", ignoreTLSError: "HTTPS 웹사이트에서 TLS/SSL 에러 무시하기",
upsideDownModeDescription: "서버 상태를 반대로 표시해요. 서버가 작동하면 오프라인으로 표시할 거에요.", upsideDownModeDescription: "서버 상태를 반대로 표시해요. 서버가 작동하면 오프라인으로 표시할 거에요.",
@ -143,4 +142,31 @@ export default {
Token: "Token", Token: "Token",
"Show URI": "Show URI", "Show URI": "Show URI",
"Clear all statistics": "Clear all Statistics", "Clear all statistics": "Clear all Statistics",
retryCheckEverySecond: "Retry every {0} seconds.",
importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.",
confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Skip existing": "Skip existing",
Overwrite: "Overwrite",
Options: "Options",
"Keep both": "Keep both",
Tags: "Tags",
"Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.",
"Tag with this value already exist.": "Tag with this value already exist.",
color: "color",
"value (optional)": "value (optional)",
Gray: "Gray",
Red: "Red",
Orange: "Orange",
Green: "Green",
Blue: "Blue",
Indigo: "Indigo",
Purple: "Purple",
Pink: "Pink",
"Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -1,7 +1,6 @@
export default { export default {
languageName: "Nederlands", languageName: "Nederlands",
checkEverySecond: "Controleer elke {0} seconden.", checkEverySecond: "Controleer elke {0} seconden.",
"Avg.": "Gem.",
retriesDescription: "Maximum aantal nieuwe pogingen voordat de service wordt gemarkeerd als niet beschikbaar en er een melding wordt verzonden", retriesDescription: "Maximum aantal nieuwe pogingen voordat de service wordt gemarkeerd als niet beschikbaar en er een melding wordt verzonden",
ignoreTLSError: "Negeer TLS/SSL-fout voor HTTPS-websites", ignoreTLSError: "Negeer TLS/SSL-fout voor HTTPS-websites",
upsideDownModeDescription: "Draai de status om. Als de service bereikbaar is, is deze OFFLINE.", upsideDownModeDescription: "Draai de status om. Als de service bereikbaar is, is deze OFFLINE.",
@ -115,7 +114,6 @@ export default {
"Last Result": "Laatste resultaat", "Last Result": "Laatste resultaat",
"Create your admin account": "Maak uw beheerdersaccount aan", "Create your admin account": "Maak uw beheerdersaccount aan",
"Repeat Password": "Herhaal wachtwoord", "Repeat Password": "Herhaal wachtwoord",
"Import/Export Backup": "Backup importeren/exporteren",
Export: "Exporteren", Export: "Exporteren",
Import: "Importeren", Import: "Importeren",
respTime: "resp. tijd (ms)", respTime: "resp. tijd (ms)",
@ -144,4 +142,31 @@ export default {
Token: "Token", Token: "Token",
"Show URI": "Toon URI", "Show URI": "Toon URI",
"Clear all statistics": "Wis alle statistieken", "Clear all statistics": "Wis alle statistieken",
retryCheckEverySecond: "Retry every {0} seconds.",
importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.",
confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Skip existing": "Skip existing",
Overwrite: "Overwrite",
Options: "Options",
"Keep both": "Keep both",
Tags: "Tags",
"Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.",
"Tag with this value already exist.": "Tag with this value already exist.",
color: "color",
"value (optional)": "value (optional)",
Gray: "Gray",
Red: "Red",
Orange: "Orange",
Green: "Green",
Blue: "Blue",
Indigo: "Indigo",
Purple: "Purple",
Pink: "Pink",
"Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -1,7 +1,6 @@
export default { export default {
languageName: "Polski", languageName: "Polski",
checkEverySecond: "Sprawdzaj co {0} sekund.", checkEverySecond: "Sprawdzaj co {0} sekund.",
"Avg.": "Średnia",
retriesDescription: "Maksymalna liczba powtórzeń, zanim usługa zostanie oznaczona jako wyłączona i zostanie wysłane powiadomienie", retriesDescription: "Maksymalna liczba powtórzeń, zanim usługa zostanie oznaczona jako wyłączona i zostanie wysłane powiadomienie",
ignoreTLSError: "Ignoruj błąd TLS/SSL dla stron HTTPS", ignoreTLSError: "Ignoruj błąd TLS/SSL dla stron HTTPS",
upsideDownModeDescription: "Odwróć status do góry nogami. Jeśli usługa jest osiągalna, to jest oznaczona jako niedostępna.", upsideDownModeDescription: "Odwróć status do góry nogami. Jeśli usługa jest osiągalna, to jest oznaczona jako niedostępna.",
@ -110,37 +109,64 @@ export default {
respTime: "Czas odp. (ms)", respTime: "Czas odp. (ms)",
notAvailableShort: "N/A", notAvailableShort: "N/A",
Create: "Stwórz", Create: "Stwórz",
clearEventsMsg: "Are you sure want to delete all events for this monitor?", clearEventsMsg: "Jesteś pewien, że chcesz usunąć wszystkie monitory dla tej strony?",
clearHeartbeatsMsg: "Are you sure want to delete all heartbeats for this monitor?", clearHeartbeatsMsg: "Jesteś pewien, że chcesz usunąć wszystkie bicia serca dla tego monitora?",
confirmClearStatisticsMsg: "Are you sure want to delete ALL statistics?", confirmClearStatisticsMsg: "Jesteś pewien, że chcesz usunąć WSZYSTKIE statystyki?",
"Clear Data": "Clear Data", "Clear Data": "Usuń dane",
Events: "Events", Events: "Wydarzenia",
Heartbeats: "Heartbeats", Heartbeats: "Bicia serca",
"Auto Get": "Auto Get", "Auto Get": "Pobierz automatycznie",
enableDefaultNotificationDescription: "For every new monitor this notification will be enabled by default. You can still disable the notification separately for each monitor.", enableDefaultNotificationDescription: "Dla każdego nowego monitora to powiadomienie będzie domyślnie włączone. Nadal możesz wyłączyć powiadomienia osobno dla każdego monitora.",
"Default enabled": "Default enabled", "Default enabled": "Domyślnie włączone",
"Also apply to existing monitors": "Also apply to existing monitors", "Also apply to existing monitors": "Również zastosuj do obecnych monitorów",
Export: "Export", Export: "Eksportuj",
Import: "Import", Import: "Importuj",
backupDescription: "You can backup all monitors and all notifications into a JSON file.", backupDescription: "Możesz wykonać kopię zapasową wszystkich monitorów i wszystkich powiadomień do pliku JSON.",
backupDescription2: "PS: History and event data is not included.", backupDescription2: "PS: Historia i dane zdarzeń nie są uwzględniane.",
backupDescription3: "Sensitive data such as notification tokens is included in the export file, please keep it carefully.", backupDescription3: "Poufne dane, takie jak tokeny powiadomień, są zawarte w pliku eksportu, prosimy o ostrożne przechowywanie.",
alertNoFile: "Please select a file to import.", alertNoFile: "Proszę wybrać plik do importu.",
alertWrongFileType: "Please select a JSON file.", alertWrongFileType: "Proszę wybrać plik JSON.",
twoFAVerifyLabel: "Please type in your token to verify that 2FA is working", twoFAVerifyLabel: "Proszę podaj swój token 2FA, aby sprawdzić czy 2FA działa",
tokenValidSettingsMsg: "Token is valid! You can now save the 2FA settings.", tokenValidSettingsMsg: "Token jest poprawny! Możesz teraz zapisać ustawienia 2FA.",
confirmEnableTwoFAMsg: "Are you sure you want to enable 2FA?", confirmEnableTwoFAMsg: "Jesteś pewien że chcesz włączyć 2FA?",
confirmDisableTwoFAMsg: "Are you sure you want to disable 2FA?", confirmDisableTwoFAMsg: "Jesteś pewien że chcesz wyłączyć 2FA?",
"Apply on all existing monitors": "Apply on all existing monitors", "Apply on all existing monitors": "Zastosuj do wszystki obecnych monitorów",
"Verify Token": "Verify Token", "Verify Token": "Weryfikuj token",
"Setup 2FA": "Setup 2FA", "Setup 2FA": "Konfiguracja 2FA",
"Enable 2FA": "Enable 2FA", "Enable 2FA": "Włącz 2FA",
"Disable 2FA": "Disable 2FA", "Disable 2FA": "Wyłącz 2FA",
"2FA Settings": "2FA Settings", "2FA Settings": "Ustawienia 2FA",
"Two Factor Authentication": "Two Factor Authentication", "Two Factor Authentication": "Uwierzytelnienie dwuskładnikowe",
Active: "Active", Active: "Włączone",
Inactive: "Inactive", Inactive: "Wyłączone",
Token: "Token", Token: "Token",
"Show URI": "Show URI", "Show URI": "Pokaż URI",
"Clear all statistics": "Clear all Statistics", "Clear all statistics": "Wyczyść wszystkie statystyki",
retryCheckEverySecond: "Ponawiaj co {0} sekund.",
importHandleDescription: "Wybierz 'Pomiń istniejące', jeśli chcesz pominąć każdy monitor lub powiadomienie o tej samej nazwie. 'Nadpisz' spowoduje usunięcie każdego istniejącego monitora i powiadomienia.",
confirmImportMsg: "Czy na pewno chcesz zaimportować kopię zapasową? Upewnij się, że wybrałeś właściwą opcję importu.",
"Heartbeat Retry Interval": "Częstotliwość ponawiania bicia serca",
"Import Backup": "Importuj kopię zapasową",
"Export Backup": "Eksportuj kopię zapasową",
"Skip existing": "Pomiń istniejące",
Overwrite: "Nadpisz",
Options: "Opcje",
"Keep both": "Zachowaj oba",
Tags: "Tagi",
"Add New below or Select...": "Dodaj nowy poniżej lub wybierz...",
"Tag with this name already exist.": "Tag o tej nazwie już istnieje.",
"Tag with this value already exist.": "Tag o tej wartości już istnieje.",
color: "kolor",
"value (optional)": "wartość (opcjonalnie)",
Gray: "Szary",
Red: "Czerwony",
Orange: "Pomarańczowy",
Green: "Zielony",
Blue: "Niebieski",
Indigo: "Indygo",
Purple: "Fioletowy",
Pink: "Różowy",
"Search...": "Szukaj...",
"Avg. Ping": "Średni ping",
"Avg. Response": "Średnia odpowiedź",
} }

View file

@ -1,7 +1,6 @@
export default { export default {
languageName: "Русский", languageName: "Русский",
checkEverySecond: "Проверять каждые {0} секунд.", checkEverySecond: "Проверять каждые {0} секунд.",
"Avg.": "Средн.",
retriesDescription: "Максимальное количество попыток перед пометкой сервиса как недоступного и отправкой уведомления", retriesDescription: "Максимальное количество попыток перед пометкой сервиса как недоступного и отправкой уведомления",
ignoreTLSError: "Игнорировать ошибку TLS/SSL для HTTPS сайтов", ignoreTLSError: "Игнорировать ошибку TLS/SSL для HTTPS сайтов",
upsideDownModeDescription: "Реверс статуса сервиса. Если сервис доступен, то он помечается как НЕДОСТУПНЫЙ.", upsideDownModeDescription: "Реверс статуса сервиса. Если сервис доступен, то он помечается как НЕДОСТУПНЫЙ.",
@ -107,40 +106,67 @@ export default {
"Last Result": "Последний результат", "Last Result": "Последний результат",
"Create your admin account": "Создайте аккаунт администратора", "Create your admin account": "Создайте аккаунт администратора",
"Repeat Password": "Повторите пароль", "Repeat Password": "Повторите пароль",
respTime: "Resp. Time (ms)", respTime: "Время ответа (мс)",
notAvailableShort: "N/A", notAvailableShort: "Н/Д",
Create: "Create", Create: "Создать",
clearEventsMsg: "Are you sure want to delete all events for this monitor?", clearEventsMsg: "Вы действительно хотите удалить всю статистику событий данного монитора?",
clearHeartbeatsMsg: "Are you sure want to delete all heartbeats for this monitor?", clearHeartbeatsMsg: "Вы действительно хотите удалить всю статистику опросов данного монитора?",
confirmClearStatisticsMsg: "Are you sure want to delete ALL statistics?", confirmClearStatisticsMsg: "Вы действительно хотите удалить ВСЮ статистику?",
"Clear Data": "Clear Data", "Clear Data": "Очистить статистику",
Events: "Events", Events: "События",
Heartbeats: "Heartbeats", Heartbeats: "Опросы",
"Auto Get": "Auto Get", "Auto Get": "Авто-получение",
enableDefaultNotificationDescription: "For every new monitor this notification will be enabled by default. You can still disable the notification separately for each monitor.", enableDefaultNotificationDescription: "Для каждого нового монитора это уведомление будет включено по умолчанию. Вы всё ещё можете отключить уведомления в каждом мониторе отдельно.",
"Default enabled": "Default enabled", "Default enabled": "Использовать по умолчанию",
"Also apply to existing monitors": "Also apply to existing monitors", "Also apply to existing monitors": "Применить к существующим мониторам",
Export: "Export", Export: "Экспорт",
Import: "Import", Import: "Импорт",
backupDescription: "You can backup all monitors and all notifications into a JSON file.", backupDescription: "Вы можете сохранить резервную копию всех мониторов и уведомлений в виде JSON-файла",
backupDescription2: "PS: History and event data is not included.", backupDescription2: "P.S.: История и события сохранены не будут.",
backupDescription3: "Sensitive data such as notification tokens is included in the export file, please keep it carefully.", backupDescription3: "Важные данные, такие как токены уведомлений, добавляются при экспорте, поэтому храните файлы в безопасном месте.",
alertNoFile: "Please select a file to import.", alertNoFile: "Выберите файл для импорта.",
alertWrongFileType: "Please select a JSON file.", alertWrongFileType: "Выберите JSON-файл.",
twoFAVerifyLabel: "Please type in your token to verify that 2FA is working", twoFAVerifyLabel: "Пожалуйста, введите свой токен, чтобы проверить работу 2FA",
tokenValidSettingsMsg: "Token is valid! You can now save the 2FA settings.", tokenValidSettingsMsg: "Токен действителен! Теперь вы можете сохранить настройки 2FA.",
confirmEnableTwoFAMsg: "Are you sure you want to enable 2FA?", confirmEnableTwoFAMsg: "Вы действительно хотите включить 2FA?",
confirmDisableTwoFAMsg: "Are you sure you want to disable 2FA?", confirmDisableTwoFAMsg: "Вы действительно хотите выключить 2FA?",
"Apply on all existing monitors": "Apply on all existing monitors", "Apply on all existing monitors": "Применить ко всем существующим мониторам",
"Verify Token": "Verify Token", "Verify Token": "Проверить токен",
"Setup 2FA": "Setup 2FA", "Setup 2FA": "Настройка 2FA",
"Enable 2FA": "Enable 2FA", "Enable 2FA": "Включить 2FA",
"Disable 2FA": "Disable 2FA", "Disable 2FA": "Выключить 2FA",
"2FA Settings": "2FA Settings", "2FA Settings": "Настройки 2FA",
"Two Factor Authentication": "Two Factor Authentication", "Two Factor Authentication": "Двухфакторная аутентификация",
Active: "Active", Active: "Активно",
Inactive: "Inactive", Inactive: "Неактивно",
Token: "Token", Token: "Токен",
"Show URI": "Show URI", "Show URI": "Показать URI",
"Clear all statistics": "Clear all Statistics", "Clear all statistics": "Очистить всю статистику",
retryCheckEverySecond: "Повторять каждые {0} секунд.",
importHandleDescription: "Выберите 'Пропустить существующие' если вы хотите пропустить каждый монитор или уведомление с таким же именем. 'Перезаписать' удалит каждый существующий монитор или уведомление.",
confirmImportMsg: "Вы действительно хотите восстановить резервную копию? Убедитесь, что вы выбрали подходящий вариант импорта.",
"Heartbeat Retry Interval": "Интервал повтора опроса",
"Import Backup": "Импорт резервной копии",
"Export Backup": "Экспорт резервной копии",
"Skip existing": "Пропустить существующие",
Overwrite: "Перезаписать",
Options: "Опции",
"Keep both": "Оставить оба",
Tags: "Теги",
"Add New below or Select...": "Добавить новое ниже или выбрать...",
"Tag with this name already exist.": "Такой тег уже существует.",
"Tag with this value already exist.": "Тег с таким значением уже существует.",
color: "цвет",
"value (optional)": "значение (опционально)",
Gray: "Серый",
Red: "Красный",
Orange: "Оранжевый",
Green: "Зелёный",
Blue: "Синий",
Indigo: "Индиго",
Purple: "Пурпурный",
Pink: "Розовый",
"Search...": "Поиск...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -1,7 +1,6 @@
export default { export default {
languageName: "Srpski", languageName: "Srpski",
checkEverySecond: "Proveri svakih {0} sekundi.", checkEverySecond: "Proveri svakih {0} sekundi.",
"Avg.": "Prosečni",
retriesDescription: "Maksimum pokušaja pre nego što se servis obeleži kao neaktivan i pošalje se obaveštenje.", retriesDescription: "Maksimum pokušaja pre nego što se servis obeleži kao neaktivan i pošalje se obaveštenje.",
ignoreTLSError: "Ignoriši TLS/SSL greške za HTTPS veb stranice.", ignoreTLSError: "Ignoriši TLS/SSL greške za HTTPS veb stranice.",
upsideDownModeDescription: "Obrnite status. Ako je servis dostupan, onda je obeležen kao neaktivan.", upsideDownModeDescription: "Obrnite status. Ako je servis dostupan, onda je obeležen kao neaktivan.",
@ -143,4 +142,31 @@ export default {
Token: "Token", Token: "Token",
"Show URI": "Show URI", "Show URI": "Show URI",
"Clear all statistics": "Clear all Statistics", "Clear all statistics": "Clear all Statistics",
retryCheckEverySecond: "Retry every {0} seconds.",
importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.",
confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Skip existing": "Skip existing",
Overwrite: "Overwrite",
Options: "Options",
"Keep both": "Keep both",
Tags: "Tags",
"Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.",
"Tag with this value already exist.": "Tag with this value already exist.",
color: "color",
"value (optional)": "value (optional)",
Gray: "Gray",
Red: "Red",
Orange: "Orange",
Green: "Green",
Blue: "Blue",
Indigo: "Indigo",
Purple: "Purple",
Pink: "Pink",
"Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -1,7 +1,6 @@
export default { export default {
languageName: "Српски", languageName: "Српски",
checkEverySecond: "Провери сваких {0} секунди.", checkEverySecond: "Провери сваких {0} секунди.",
"Avg.": "Просечни",
retriesDescription: "Максимум покушаја пре него што се сервис обележи као неактиван и пошаље се обавештење.", retriesDescription: "Максимум покушаја пре него што се сервис обележи као неактиван и пошаље се обавештење.",
ignoreTLSError: "Игнориши TLS/SSL грешке за HTTPS веб странице.", ignoreTLSError: "Игнориши TLS/SSL грешке за HTTPS веб странице.",
upsideDownModeDescription: "Обрните статус. Ако је сервис доступан, онда је обележен као неактиван.", upsideDownModeDescription: "Обрните статус. Ако је сервис доступан, онда је обележен као неактиван.",
@ -143,4 +142,31 @@ export default {
Token: "Token", Token: "Token",
"Show URI": "Show URI", "Show URI": "Show URI",
"Clear all statistics": "Clear all Statistics", "Clear all statistics": "Clear all Statistics",
retryCheckEverySecond: "Retry every {0} seconds.",
importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.",
confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Skip existing": "Skip existing",
Overwrite: "Overwrite",
Options: "Options",
"Keep both": "Keep both",
Tags: "Tags",
"Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.",
"Tag with this value already exist.": "Tag with this value already exist.",
color: "color",
"value (optional)": "value (optional)",
Gray: "Gray",
Red: "Red",
Orange: "Orange",
Green: "Green",
Blue: "Blue",
Indigo: "Indigo",
Purple: "Purple",
Pink: "Pink",
"Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -1,7 +1,6 @@
export default { export default {
languageName: "Svenska", languageName: "Svenska",
checkEverySecond: "Uppdatera var {0} sekund.", checkEverySecond: "Uppdatera var {0} sekund.",
"Avg.": "Genomsnittligt",
retriesDescription: "Max antal försök innan tjänsten markeras som nere och en notis skickas", retriesDescription: "Max antal försök innan tjänsten markeras som nere och en notis skickas",
ignoreTLSError: "Ignorera TLS/SSL-fel för webbsidor med HTTPS", ignoreTLSError: "Ignorera TLS/SSL-fel för webbsidor med HTTPS",
upsideDownModeDescription: "Vänd upp och ner på statusen. Om tjänsten är nåbar visas den som NERE.", upsideDownModeDescription: "Vänd upp och ner på statusen. Om tjänsten är nåbar visas den som NERE.",
@ -143,4 +142,31 @@ export default {
Token: "Token", Token: "Token",
"Show URI": "Show URI", "Show URI": "Show URI",
"Clear all statistics": "Clear all Statistics", "Clear all statistics": "Clear all Statistics",
retryCheckEverySecond: "Retry every {0} seconds.",
importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.",
confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Skip existing": "Skip existing",
Overwrite: "Overwrite",
Options: "Options",
"Keep both": "Keep both",
Tags: "Tags",
"Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.",
"Tag with this value already exist.": "Tag with this value already exist.",
color: "color",
"value (optional)": "value (optional)",
Gray: "Gray",
Red: "Red",
Orange: "Orange",
Green: "Green",
Blue: "Blue",
Indigo: "Indigo",
Purple: "Purple",
Pink: "Pink",
"Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -1,7 +1,6 @@
export default { export default {
languageName: "Türkçe", languageName: "Türkçe",
checkEverySecond: "{0} Saniyede bir kontrol et.", checkEverySecond: "{0} Saniyede bir kontrol et.",
"Avg.": "Ortalama",
retriesDescription: "Servisin kapalı olarak işaretlenmeden ve bir bildirim gönderilmeden önce maksimum yeniden deneme sayısı", retriesDescription: "Servisin kapalı olarak işaretlenmeden ve bir bildirim gönderilmeden önce maksimum yeniden deneme sayısı",
ignoreTLSError: "HTTPS web siteleri için TLS/SSL hatasını yoksay", ignoreTLSError: "HTTPS web siteleri için TLS/SSL hatasını yoksay",
upsideDownModeDescription: "Servisin durumunu tersine çevirir. Servis çalışıyorsa kapalı olarak işaretler.", upsideDownModeDescription: "Servisin durumunu tersine çevirir. Servis çalışıyorsa kapalı olarak işaretler.",
@ -116,5 +115,57 @@ export default {
"Clear Data": "Verileri Temizle", "Clear Data": "Verileri Temizle",
Events: "Olaylar", Events: "Olaylar",
Heartbeats: "Sağlık Durumları", Heartbeats: "Sağlık Durumları",
"Auto Get": "Otomatik Al" "Auto Get": "Otomatik Al",
retryCheckEverySecond: "Retry every {0} seconds.",
enableDefaultNotificationDescription: "For every new monitor this notification will be enabled by default. You can still disable the notification separately for each monitor.",
importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.",
confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.",
twoFAVerifyLabel: "Please type in your token to verify that 2FA is working",
tokenValidSettingsMsg: "Token is valid! You can now save the 2FA settings.",
confirmEnableTwoFAMsg: "Are you sure you want to enable 2FA?",
confirmDisableTwoFAMsg: "Are you sure you want to disable 2FA?",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
Export: "Export",
Import: "Import",
"Default enabled": "Default enabled",
"Apply on all existing monitors": "Apply on all existing monitors",
backupDescription: "You can backup all monitors and all notifications into a JSON file.",
backupDescription2: "PS: History and event data is not included.",
backupDescription3: "Sensitive data such as notification tokens is included in the export file, please keep it carefully.",
alertNoFile: "Please select a file to import.",
alertWrongFileType: "Please select a JSON file.",
"Clear all statistics": "Clear all Statistics",
"Skip existing": "Skip existing",
Overwrite: "Overwrite",
Options: "Options",
"Keep both": "Keep both",
"Verify Token": "Verify Token",
"Setup 2FA": "Setup 2FA",
"Enable 2FA": "Enable 2FA",
"Disable 2FA": "Disable 2FA",
"2FA Settings": "2FA Settings",
"Two Factor Authentication": "Two Factor Authentication",
Active: "Active",
Inactive: "Inactive",
Token: "Token",
"Show URI": "Show URI",
Tags: "Tags",
"Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.",
"Tag with this value already exist.": "Tag with this value already exist.",
color: "color",
"value (optional)": "value (optional)",
Gray: "Gray",
Red: "Red",
Orange: "Orange",
Green: "Green",
Blue: "Blue",
Indigo: "Indigo",
Purple: "Purple",
Pink: "Pink",
"Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -1,7 +1,6 @@
export default { export default {
languageName: "简体中文", languageName: "简体中文",
checkEverySecond: "检测频率 {0} 秒", checkEverySecond: "检测频率 {0} 秒",
"Avg.": "平均",
retriesDescription: "最大重试失败次数", retriesDescription: "最大重试失败次数",
ignoreTLSError: "忽略HTTPS站点的证书错误", ignoreTLSError: "忽略HTTPS站点的证书错误",
upsideDownModeDescription: "反向状态监控(状态码范围外为有效状态,反之为无效)", upsideDownModeDescription: "反向状态监控(状态码范围外为有效状态,反之为无效)",
@ -120,7 +119,6 @@ export default {
enableDefaultNotificationDescription: "新的监控项将默认启用,你也可以在每个监控项中分别设置", enableDefaultNotificationDescription: "新的监控项将默认启用,你也可以在每个监控项中分别设置",
"Default enabled": "默认开启", "Default enabled": "默认开启",
"Also apply to existing monitors": "应用到所有监控项", "Also apply to existing monitors": "应用到所有监控项",
"Import/Export Backup": "导入/导出备份",
Export: "导出", Export: "导出",
Import: "导入", Import: "导入",
backupDescription: "你可以将所有的监控项和消息通知备份到一个 JSON 文件中", backupDescription: "你可以将所有的监控项和消息通知备份到一个 JSON 文件中",
@ -144,4 +142,31 @@ export default {
Token: "Token", Token: "Token",
"Show URI": "Show URI", "Show URI": "Show URI",
"Clear all statistics": "Clear all Statistics", "Clear all statistics": "Clear all Statistics",
retryCheckEverySecond: "Retry every {0} seconds.",
importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.",
confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Skip existing": "Skip existing",
Overwrite: "Overwrite",
Options: "Options",
"Keep both": "Keep both",
Tags: "Tags",
"Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.",
"Tag with this value already exist.": "Tag with this value already exist.",
color: "color",
"value (optional)": "value (optional)",
Gray: "Gray",
Red: "Red",
Orange: "Orange",
Green: "Green",
Blue: "Blue",
Indigo: "Indigo",
Purple: "Purple",
Pink: "Pink",
"Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -36,7 +36,6 @@ export default {
hour: "小時", hour: "小時",
"-hour": "小時", "-hour": "小時",
checkEverySecond: "每 {0} 秒檢查一次", checkEverySecond: "每 {0} 秒檢查一次",
"Avg.": "平均",
Response: "反應時間", Response: "反應時間",
Ping: "反應時間", Ping: "反應時間",
"Monitor Type": "監測器類型", "Monitor Type": "監測器類型",
@ -120,7 +119,6 @@ export default {
enableDefaultNotificationDescription: "新增監測器時這個通知會預設啟用,當然每個監測器亦可分別控制開關。", enableDefaultNotificationDescription: "新增監測器時這個通知會預設啟用,當然每個監測器亦可分別控制開關。",
"Default enabled": "預設通知", "Default enabled": "預設通知",
"Also apply to existing monitors": "同時取用至目前所有監測器", "Also apply to existing monitors": "同時取用至目前所有監測器",
"Import/Export Backup": "匯入/匯出 備份",
Export: "匯出", Export: "匯出",
Import: "匯入", Import: "匯入",
backupDescription: "您可以備份所有監測器及所有通知。", backupDescription: "您可以備份所有監測器及所有通知。",
@ -144,4 +142,31 @@ export default {
Token: "Token", Token: "Token",
"Show URI": "顯示 URI", "Show URI": "顯示 URI",
"Clear all statistics": "清除所有歷史記錄", "Clear all statistics": "清除所有歷史記錄",
retryCheckEverySecond: "Retry every {0} seconds.",
importHandleDescription: "Choose 'Skip existing' if you want to skip every monitor or notification with the same name. 'Overwrite' will delete every existing monitor and notification.",
confirmImportMsg: "Are you sure to import the backup? Please make sure you've selected the right import option.",
"Heartbeat Retry Interval": "Heartbeat Retry Interval",
"Import Backup": "Import Backup",
"Export Backup": "Export Backup",
"Skip existing": "Skip existing",
Overwrite: "Overwrite",
Options: "Options",
"Keep both": "Keep both",
Tags: "Tags",
"Add New below or Select...": "Add New below or Select...",
"Tag with this name already exist.": "Tag with this name already exist.",
"Tag with this value already exist.": "Tag with this value already exist.",
color: "color",
"value (optional)": "value (optional)",
Gray: "Gray",
Red: "Red",
Orange: "Orange",
Green: "Green",
Blue: "Blue",
Indigo: "Indigo",
Purple: "Purple",
Pink: "Pink",
"Search...": "Search...",
"Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response",
} }

View file

@ -18,6 +18,11 @@
</a> </a>
<ul class="nav nav-pills"> <ul class="nav nav-pills">
<li class="nav-item me-2">
<a href="/status-page" class="nav-link status-page">
<font-awesome-icon icon="stream" /> {{ $t("Status Page") }}
</a>
</li>
<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" /> {{ $t("Dashboard") }} <font-awesome-icon icon="tachometer-alt" /> {{ $t("Dashboard") }}
@ -81,7 +86,7 @@ export default {
}, },
data() { data() {
return {} return {};
}, },
computed: { computed: {
@ -105,29 +110,29 @@ export default {
}, },
watch: { watch: {
$route(to, from) {
this.init();
},
}, },
mounted() { mounted() {
this.init();
}, },
methods: { methods: {
init() {
if (this.$route.name === "root") {
this.$router.push("/dashboard")
}
},
}, },
} };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "../assets/vars.scss"; @import "../assets/vars.scss";
.nav-link {
&.status-page {
background-color: rgba(255, 255, 255, 0.1);
}
}
.bottom-nav { .bottom-nav {
z-index: 1000; z-index: 1000;
position: fixed; position: fixed;

View file

@ -1,6 +1,7 @@
import "bootstrap"; import "bootstrap";
import { createApp, h } from "vue"; import { createApp, h } from "vue";
import Toast from "vue-toastification"; import Toast from "vue-toastification";
import contenteditable from "vue-contenteditable"
import "vue-toastification/dist/index.css"; import "vue-toastification/dist/index.css";
import App from "./App.vue"; import App from "./App.vue";
import "./assets/app.scss"; import "./assets/app.scss";
@ -10,6 +11,8 @@ import datetime from "./mixins/datetime";
import mobile from "./mixins/mobile"; import mobile from "./mixins/mobile";
import socket from "./mixins/socket"; import socket from "./mixins/socket";
import theme from "./mixins/theme"; import theme from "./mixins/theme";
import publicMixin from "./mixins/public";
import { router } from "./router"; import { router } from "./router";
import { appName } from "./util.ts"; import { appName } from "./util.ts";
@ -18,7 +21,8 @@ const app = createApp({
socket, socket,
theme, theme,
mobile, mobile,
datetime datetime,
publicMixin,
], ],
data() { data() {
return { return {
@ -36,7 +40,7 @@ const options = {
}; };
app.use(Toast, options); app.use(Toast, options);
app.component("Editable", contenteditable);
app.component("FontAwesomeIcon", FontAwesomeIcon);
app.component("FontAwesomeIcon", FontAwesomeIcon) app.mount("#app");
app.mount("#app")

View file

@ -3,23 +3,34 @@ export default {
data() { data() {
return { return {
windowWidth: window.innerWidth, windowWidth: window.innerWidth,
} };
}, },
created() { created() {
window.addEventListener("resize", this.onResize); window.addEventListener("resize", this.onResize);
this.updateBody();
}, },
methods: { methods: {
onResize() { onResize() {
this.windowWidth = window.innerWidth; this.windowWidth = window.innerWidth;
this.updateBody();
}, },
updateBody() {
if (this.isMobile) {
document.body.classList.add("mobile");
} else {
document.body.classList.remove("mobile");
}
}
}, },
computed: { computed: {
isMobile() { isMobile() {
return this.windowWidth <= 767.98; return this.windowWidth <= 767.98;
}, },
} },
} };

40
src/mixins/public.js Normal file
View file

@ -0,0 +1,40 @@
import axios from "axios";
const env = process.env.NODE_ENV || "production";
// change the axios base url for development
if (env === "development" || localStorage.dev === "dev") {
axios.defaults.baseURL = location.protocol + "//" + location.hostname + ":3001";
}
export default {
data() {
return {
publicGroupList: [],
};
},
computed: {
publicMonitorList() {
let result = {};
for (let group of this.publicGroupList) {
for (let monitor of group.monitorList) {
result[monitor.id] = monitor;
}
}
return result;
},
publicLastHeartbeatList() {
let result = {};
for (let monitorID in this.publicMonitorList) {
if (this.lastHeartbeatList[monitorID]) {
result[monitorID] = this.lastHeartbeatList[monitorID];
}
}
return result;
},
}
};

View file

@ -1,9 +1,14 @@
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();
let socket; let socket;
const noSocketIOPages = [
"/status-page",
"/"
];
export default { export default {
data() { data() {
@ -14,6 +19,7 @@ export default {
firstConnect: true, firstConnect: true,
connected: false, connected: false,
connectCount: 0, connectCount: 0,
initedSocketIO: false,
}, },
remember: (localStorage.remember !== "0"), remember: (localStorage.remember !== "0"),
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.
@ -26,167 +32,186 @@ export default {
certInfoList: {}, certInfoList: {},
notificationList: [], notificationList: [],
connectionErrorMsg: "Cannot connect to the socket server. Reconnecting...", connectionErrorMsg: "Cannot connect to the socket server. Reconnecting...",
} };
}, },
created() { created() {
window.addEventListener("resize", this.onResize); window.addEventListener("resize", this.onResize);
this.initSocketIO();
let protocol = (location.protocol === "https:") ? "wss://" : "ws://";
let wsHost;
const env = process.env.NODE_ENV || "production";
if (env === "development" || localStorage.dev === "dev") {
wsHost = protocol + location.hostname + ":3001";
} else {
wsHost = protocol + location.host;
}
socket = io(wsHost, {
transports: ["websocket"],
});
socket.on("info", (info) => {
this.info = info;
});
socket.on("setup", (monitorID, data) => {
this.$router.push("/setup")
});
socket.on("autoLogin", (monitorID, data) => {
this.loggedIn = true;
this.storage().token = "autoLogin";
this.allowLoginDialog = false;
});
socket.on("monitorList", (data) => {
// Add Helper function
Object.entries(data).forEach(([monitorID, monitor]) => {
monitor.getUrl = () => {
try {
return new URL(monitor.url);
} catch (_) {
return null;
}
};
});
this.monitorList = data;
});
socket.on("notificationList", (data) => {
this.notificationList = data;
});
socket.on("heartbeat", (data) => {
if (! (data.monitorID in this.heartbeatList)) {
this.heartbeatList[data.monitorID] = [];
}
this.heartbeatList[data.monitorID].push(data)
// Add to important list if it is important
// Also toast
if (data.important) {
if (data.status === 0) {
toast.error(`[${this.monitorList[data.monitorID].name}] [DOWN] ${data.msg}`, {
timeout: false,
});
} else if (data.status === 1) {
toast.success(`[${this.monitorList[data.monitorID].name}] [Up] ${data.msg}`, {
timeout: 20000,
});
} else {
toast(`[${this.monitorList[data.monitorID].name}] ${data.msg}`);
}
if (! (data.monitorID in this.importantHeartbeatList)) {
this.importantHeartbeatList[data.monitorID] = [];
}
this.importantHeartbeatList[data.monitorID].unshift(data)
}
});
socket.on("heartbeatList", (monitorID, data, overwrite = false) => {
if (! (monitorID in this.heartbeatList) || overwrite) {
this.heartbeatList[monitorID] = data;
} else {
this.heartbeatList[monitorID] = data.concat(this.heartbeatList[monitorID])
}
});
socket.on("avgPing", (monitorID, data) => {
this.avgPingList[monitorID] = data
});
socket.on("uptime", (monitorID, type, data) => {
this.uptimeList[`${monitorID}_${type}`] = data
});
socket.on("certInfo", (monitorID, data) => {
this.certInfoList[monitorID] = JSON.parse(data)
});
socket.on("importantHeartbeatList", (monitorID, data, overwrite) => {
if (! (monitorID in this.importantHeartbeatList) || overwrite) {
this.importantHeartbeatList[monitorID] = data;
} else {
this.importantHeartbeatList[monitorID] = data.concat(this.importantHeartbeatList[monitorID])
}
});
socket.on("connect_error", (err) => {
console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`);
this.connectionErrorMsg = `Cannot connect to the socket server. [${err}] Reconnecting...`;
this.socket.connected = false;
this.socket.firstConnect = false;
});
socket.on("disconnect", () => {
console.log("disconnect")
this.connectionErrorMsg = "Lost connection to the socket server. Reconnecting...";
this.socket.connected = false;
});
socket.on("connect", () => {
console.log("connect")
this.socket.connectCount++;
this.socket.connected = true;
// Reset Heartbeat list if it is re-connect
if (this.socket.connectCount >= 2) {
this.clearData()
}
let token = this.storage().token;
if (token) {
if (token !== "autoLogin") {
this.loginByToken(token)
} else {
// Timeout if it is not actually auto login
setTimeout(() => {
if (! this.loggedIn) {
this.allowLoginDialog = true;
this.$root.storage().removeItem("token");
}
}, 5000);
}
} else {
this.allowLoginDialog = true;
}
this.socket.firstConnect = false;
});
}, },
methods: { methods: {
initSocketIO(bypass = false) {
// No need to re-init
if (this.socket.initedSocketIO) {
return;
}
// No need to connect to the socket.io for status page
if (! bypass && noSocketIOPages.includes(location.pathname)) {
return;
}
this.socket.initedSocketIO = true;
let protocol = (location.protocol === "https:") ? "wss://" : "ws://";
let wsHost;
const env = process.env.NODE_ENV || "production";
if (env === "development" || localStorage.dev === "dev") {
wsHost = protocol + location.hostname + ":3001";
} else {
wsHost = protocol + location.host;
}
socket = io(wsHost, {
transports: ["websocket"],
});
socket.on("info", (info) => {
this.info = info;
});
socket.on("setup", (monitorID, data) => {
this.$router.push("/setup");
});
socket.on("autoLogin", (monitorID, data) => {
this.loggedIn = true;
this.storage().token = "autoLogin";
this.allowLoginDialog = false;
});
socket.on("monitorList", (data) => {
// Add Helper function
Object.entries(data).forEach(([monitorID, monitor]) => {
monitor.getUrl = () => {
try {
return new URL(monitor.url);
} catch (_) {
return null;
}
};
});
this.monitorList = data;
});
socket.on("notificationList", (data) => {
this.notificationList = data;
});
socket.on("heartbeat", (data) => {
if (! (data.monitorID in this.heartbeatList)) {
this.heartbeatList[data.monitorID] = [];
}
this.heartbeatList[data.monitorID].push(data);
if (this.heartbeatList[data.monitorID].length >= 150) {
this.heartbeatList[data.monitorID].shift();
}
// Add to important list if it is important
// Also toast
if (data.important) {
if (data.status === 0) {
toast.error(`[${this.monitorList[data.monitorID].name}] [DOWN] ${data.msg}`, {
timeout: false,
});
} else if (data.status === 1) {
toast.success(`[${this.monitorList[data.monitorID].name}] [Up] ${data.msg}`, {
timeout: 20000,
});
} else {
toast(`[${this.monitorList[data.monitorID].name}] ${data.msg}`);
}
if (! (data.monitorID in this.importantHeartbeatList)) {
this.importantHeartbeatList[data.monitorID] = [];
}
this.importantHeartbeatList[data.monitorID].unshift(data);
}
});
socket.on("heartbeatList", (monitorID, data, overwrite = false) => {
if (! (monitorID in this.heartbeatList) || overwrite) {
this.heartbeatList[monitorID] = data;
} else {
this.heartbeatList[monitorID] = data.concat(this.heartbeatList[monitorID]);
}
});
socket.on("avgPing", (monitorID, data) => {
this.avgPingList[monitorID] = data;
});
socket.on("uptime", (monitorID, type, data) => {
this.uptimeList[`${monitorID}_${type}`] = data;
});
socket.on("certInfo", (monitorID, data) => {
this.certInfoList[monitorID] = JSON.parse(data);
});
socket.on("importantHeartbeatList", (monitorID, data, overwrite) => {
if (! (monitorID in this.importantHeartbeatList) || overwrite) {
this.importantHeartbeatList[monitorID] = data;
} else {
this.importantHeartbeatList[monitorID] = data.concat(this.importantHeartbeatList[monitorID]);
}
});
socket.on("connect_error", (err) => {
console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`);
this.connectionErrorMsg = `Cannot connect to the socket server. [${err}] Reconnecting...`;
this.socket.connected = false;
this.socket.firstConnect = false;
});
socket.on("disconnect", () => {
console.log("disconnect");
this.connectionErrorMsg = "Lost connection to the socket server. Reconnecting...";
this.socket.connected = false;
});
socket.on("connect", () => {
console.log("connect");
this.socket.connectCount++;
this.socket.connected = true;
// Reset Heartbeat list if it is re-connect
if (this.socket.connectCount >= 2) {
this.clearData();
}
let token = this.storage().token;
if (token) {
if (token !== "autoLogin") {
this.loginByToken(token);
} else {
// Timeout if it is not actually auto login
setTimeout(() => {
if (! this.loggedIn) {
this.allowLoginDialog = true;
this.$root.storage().removeItem("token");
}
}, 5000);
}
} else {
this.allowLoginDialog = true;
}
this.socket.firstConnect = false;
});
},
storage() { storage() {
return (this.remember) ? localStorage : sessionStorage; return (this.remember) ? localStorage : sessionStorage;
}, },
@ -210,7 +235,7 @@ export default {
token, token,
}, (res) => { }, (res) => {
if (res.tokenRequired) { if (res.tokenRequired) {
callback(res) callback(res);
} }
if (res.ok) { if (res.ok) {
@ -219,11 +244,11 @@ export default {
this.loggedIn = true; this.loggedIn = true;
// Trigger Chrome Save Password // Trigger Chrome Save Password
history.pushState({}, "") history.pushState({}, "");
} }
callback(res) callback(res);
}) });
}, },
loginByToken(token) { loginByToken(token) {
@ -231,11 +256,11 @@ export default {
this.allowLoginDialog = true; this.allowLoginDialog = true;
if (! res.ok) { if (! res.ok) {
this.logout() this.logout();
} else { } else {
this.loggedIn = true; this.loggedIn = true;
} }
}) });
}, },
logout() { logout() {
@ -243,68 +268,68 @@ export default {
this.socket.token = null; this.socket.token = null;
this.loggedIn = false; this.loggedIn = false;
this.clearData() this.clearData();
}, },
prepare2FA(callback) { prepare2FA(callback) {
socket.emit("prepare2FA", callback) socket.emit("prepare2FA", callback);
}, },
save2FA(secret, callback) { save2FA(secret, callback) {
socket.emit("save2FA", callback) socket.emit("save2FA", callback);
}, },
disable2FA(callback) { disable2FA(callback) {
socket.emit("disable2FA", callback) socket.emit("disable2FA", callback);
}, },
verifyToken(token, callback) { verifyToken(token, callback) {
socket.emit("verifyToken", token, callback) socket.emit("verifyToken", token, callback);
}, },
twoFAStatus(callback) { twoFAStatus(callback) {
socket.emit("twoFAStatus", callback) socket.emit("twoFAStatus", callback);
}, },
getMonitorList(callback) { getMonitorList(callback) {
socket.emit("getMonitorList", callback) socket.emit("getMonitorList", callback);
}, },
add(monitor, callback) { add(monitor, callback) {
socket.emit("add", monitor, callback) socket.emit("add", monitor, callback);
}, },
deleteMonitor(monitorID, callback) { deleteMonitor(monitorID, callback) {
socket.emit("deleteMonitor", monitorID, callback) socket.emit("deleteMonitor", monitorID, callback);
}, },
clearData() { clearData() {
console.log("reset heartbeat list") console.log("reset heartbeat list");
this.heartbeatList = {} this.heartbeatList = {};
this.importantHeartbeatList = {} this.importantHeartbeatList = {};
}, },
uploadBackup(uploadedJSON, importHandle, callback) { uploadBackup(uploadedJSON, importHandle, callback) {
socket.emit("uploadBackup", uploadedJSON, importHandle, callback) socket.emit("uploadBackup", uploadedJSON, importHandle, callback);
}, },
clearEvents(monitorID, callback) { clearEvents(monitorID, callback) {
socket.emit("clearEvents", monitorID, callback) socket.emit("clearEvents", monitorID, callback);
}, },
clearHeartbeats(monitorID, callback) { clearHeartbeats(monitorID, callback) {
socket.emit("clearHeartbeats", monitorID, callback) socket.emit("clearHeartbeats", monitorID, callback);
}, },
clearStatistics(callback) { clearStatistics(callback) {
socket.emit("clearStatistics", callback) socket.emit("clearStatistics", callback);
}, },
}, },
computed: { computed: {
lastHeartbeatList() { lastHeartbeatList() {
let result = {} let result = {};
for (let monitorID in this.heartbeatList) { for (let monitorID in this.heartbeatList) {
let index = this.heartbeatList[monitorID].length - 1; let index = this.heartbeatList[monitorID].length - 1;
@ -315,15 +340,15 @@ export default {
}, },
statusList() { statusList() {
let result = {} let result = {};
let unknown = { let unknown = {
text: "Unknown", text: "Unknown",
color: "secondary", color: "secondary",
} };
for (let monitorID in this.lastHeartbeatList) { for (let monitorID in this.lastHeartbeatList) {
let lastHeartBeat = this.lastHeartbeatList[monitorID] let lastHeartBeat = this.lastHeartbeatList[monitorID];
if (! lastHeartBeat) { if (! lastHeartBeat) {
result[monitorID] = unknown; result[monitorID] = unknown;
@ -356,14 +381,22 @@ export default {
// Reload the SPA if the server version is changed. // Reload the SPA if the server version is changed.
"info.version"(to, from) { "info.version"(to, from) {
if (from && from !== to) { if (from && from !== to) {
window.location.reload() window.location.reload();
} }
}, },
remember() { remember() {
localStorage.remember = (this.remember) ? "1" : "0" localStorage.remember = (this.remember) ? "1" : "0";
},
// Reconnect the socket io, if status-page to dashboard
"$route.fullPath"(newValue, oldValue) {
if (noSocketIOPages.includes(newValue)) {
return;
}
this.initSocketIO();
}, },
}, },
} };

View file

@ -5,6 +5,8 @@ export default {
system: (window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light", system: (window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light",
userTheme: localStorage.theme, userTheme: localStorage.theme,
userHeartbeatBar: localStorage.heartbeatBarTheme, userHeartbeatBar: localStorage.heartbeatBarTheme,
statusPageTheme: "light",
path: "",
}; };
}, },
@ -25,14 +27,28 @@ export default {
computed: { computed: {
theme() { theme() {
if (this.userTheme === "auto") {
return this.system; // Entry no need dark
if (this.path === "") {
return "light";
}
if (this.path === "/status-page") {
return this.statusPageTheme;
} else {
if (this.userTheme === "auto") {
return this.system;
}
return this.userTheme;
} }
return this.userTheme;
} }
}, },
watch: { watch: {
"$route.fullPath"(path) {
this.path = path;
},
userTheme(to, from) { userTheme(to, from) {
localStorage.theme = to; localStorage.theme = to;
}, },
@ -62,5 +78,5 @@ export default {
} }
} }
} }
} };

View file

@ -49,7 +49,7 @@
<div class="shadow-box big-padding text-center stats"> <div class="shadow-box big-padding text-center stats">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h4>{{ pingTitle }}</h4> <h4>{{ pingTitle() }}</h4>
<p>({{ $t("Current") }})</p> <p>({{ $t("Current") }})</p>
<span class="num"> <span class="num">
<a href="#" @click.prevent="showPingChartBox = !showPingChartBox"> <a href="#" @click.prevent="showPingChartBox = !showPingChartBox">
@ -58,7 +58,7 @@
</span> </span>
</div> </div>
<div class="col"> <div class="col">
<h4>{{ $t("Avg.") }} {{ pingTitle }}</h4> <h4>{{ pingTitle(true) }}</h4>
<p>(24{{ $t("-hour") }})</p> <p>(24{{ $t("-hour") }})</p>
<span class="num"><CountUp :value="avgPing" /></span> <span class="num"><CountUp :value="avgPing" /></span>
</div> </div>
@ -240,14 +240,6 @@ export default {
} }
}, },
computed: { computed: {
pingTitle() {
if (this.monitor.type === "http") {
return this.$t("Response");
}
return this.$t("Ping");
},
monitor() { monitor() {
let id = this.$route.params.id let id = this.$route.params.id
return this.$root.monitorList[id]; return this.$root.monitorList[id];
@ -378,6 +370,19 @@ export default {
} }
}) })
}, },
pingTitle(average = false) {
let translationPrefix = ""
if (average) {
translationPrefix = "Avg. "
}
if (this.monitor.type === "http") {
return this.$t(translationPrefix + "Response");
}
return this.$t(translationPrefix + "Ping");
},
}, },
} }
</script> </script>

View file

@ -50,7 +50,7 @@
<!-- TCP Port / Ping / DNS only --> <!-- TCP Port / Ping / DNS only -->
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' " class="my-3"> <div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' " class="my-3">
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label> <label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
<input id="hostname" v-model="monitor.hostname" type="text" class="form-control" required> <input id="hostname" v-model="monitor.hostname" type="text" class="form-control" :pattern="`${ipRegexPattern}|${hostnameRegexPattern}`" required>
</div> </div>
<!-- For TCP Port Type --> <!-- For TCP Port Type -->
@ -233,8 +233,9 @@ export default {
dnsresolvetypeOptions: [], dnsresolvetypeOptions: [],
// Source: https://digitalfortress.tech/tips/top-15-commonly-used-regex/ // Source: https://digitalfortress.tech/tips/top-15-commonly-used-regex/
// eslint-disable-next-line ipRegexPattern: "((^\\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\\s*$)|(^\\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))(%.+)?\\s*$))",
ipRegexPattern: "((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))", // Source: https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address
hostnameRegexPattern: "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$"
} }
}, },
@ -330,6 +331,11 @@ export default {
this.$root.getSocket().emit("getMonitor", this.$route.params.id, (res) => { this.$root.getSocket().emit("getMonitor", this.$route.params.id, (res) => {
if (res.ok) { if (res.ok) {
this.monitor = res.monitor; this.monitor = res.monitor;
// Handling for monitors that are created before 1.7.0
if (this.monitor.retryInterval === 0) {
this.monitor.retryInterval = this.monitor.interval;
}
} else { } else {
toast.error(res.msg) toast.error(res.msg)
} }

20
src/pages/Entry.vue Normal file
View file

@ -0,0 +1,20 @@
<template>
<div></div>
</template>
<script>
import axios from "axios";
export default {
async mounted() {
let entryPage = (await axios.get("/api/entry-page")).data;
if (entryPage === "statusPage") {
this.$router.push("/status-page");
} else {
this.$router.push("/dashboard");
}
},
};
</script>

View file

@ -83,6 +83,24 @@
</div> </div>
</div> </div>
<div class="mb-3">
<label class="form-label">{{ $t("Entry Page") }}</label>
<div class="form-check">
<input id="entryPageYes" v-model="settings.entryPage" class="form-check-input" type="radio" name="statusPage" value="dashboard" required>
<label class="form-check-label" for="entryPageYes">
{{ $t("Dashboard") }}
</label>
</div>
<div class="form-check">
<input id="entryPageNo" v-model="settings.entryPage" class="form-check-input" type="radio" name="statusPage" value="statusPage" required>
<label class="form-check-label" for="entryPageNo">
{{ $t("Status Page") }}
</label>
</div>
</div>
<div> <div>
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit">
{{ $t("Save") }} {{ $t("Save") }}
@ -207,18 +225,15 @@
<button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()"> <button class="btn btn-primary me-2" type="button" @click="$refs.notificationDialog.show()">
{{ $t("Setup Notification") }} {{ $t("Setup Notification") }}
</button> </button>
<h2 class="mt-5">Info</h2>
{{ $t("Version") }}: {{ $root.info.version }} <br />
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a>
</div> </div>
</div> </div>
</div> </div>
<footer>
<div class="container-fluid">
Uptime Kuma -
{{ $t("Version") }}: {{ $root.info.version }} -
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a>
</div>
</footer>
<NotificationDialog ref="notificationDialog" /> <NotificationDialog ref="notificationDialog" />
<TwoFADialog ref="TwoFADialog" /> <TwoFADialog ref="TwoFADialog" />
@ -261,8 +276,8 @@
<template v-else-if="$i18n.locale === 'tr-TR' "> <template v-else-if="$i18n.locale === 'tr-TR' ">
<p><strong>Şifreli girişi devre dışı bırakmak istediğinizden</strong>emin misiniz?</p> <p><strong>Şifreli girişi devre dışı bırakmak istediğinizden</strong>emin misiniz?</p>
<p>Bu, Uptime Kuma'nın önünde Cloudflare Access gibi <strong>üçüncü taraf yetkilendirmesi olan</strong> kişiler içindir.</p> <p>Bu, Uptime Kuma'nın önünde Cloudflare Access gibi <strong>üçüncü taraf yetkilendirmesi olan</strong> kişiler içindir.</p>
<p>Lütfen dikkatli kullanın.</p> <p>Lütfen dikkatli kullanın.</p>
</template> </template>
<template v-else-if="$i18n.locale === 'ko-KR' "> <template v-else-if="$i18n.locale === 'ko-KR' ">
@ -289,6 +304,12 @@
<p>Utilizzare con attenzione.</p> <p>Utilizzare con attenzione.</p>
</template> </template>
<template v-else-if="$i18n.locale === 'ru-RU' ">
<p>Вы уверены, что хотите <strong>отключить авторизацию</strong>?</p>
<p>Это подходит для <strong>тех, у кого стоит другая авторизация</strong> перед открытием Uptime Kuma, например Cloudflare Access.</p>
<p>Пожалуйста, используйте с осторожностью.</p>
</template>
<!-- English (en) --> <!-- English (en) -->
<template v-else> <template v-else>
<p>Are you sure want to <strong>disable auth</strong>?</p> <p>Are you sure want to <strong>disable auth</strong>?</p>
@ -391,6 +412,10 @@ export default {
this.settings.searchEngineIndex = false; this.settings.searchEngineIndex = false;
} }
if (this.settings.entryPage === undefined) {
this.settings.entryPage = "dashboard";
}
this.loaded = true; this.loaded = true;
}) })
}, },

647
src/pages/StatusPage.vue Normal file
View file

@ -0,0 +1,647 @@
<template>
<div v-if="loadedTheme" class="container mt-3">
<!-- Logo & Title -->
<h1 class="mb-4">
<!-- Logo -->
<span class="logo-wrapper" @click="showImageCropUploadMethod">
<img :src="logoURL" alt class="logo me-2" :class="logoClass" />
<font-awesome-icon v-if="enableEditMode" class="icon-upload" icon="upload" />
</span>
<!-- Uploader -->
<!-- url="/api/status-page/upload-logo" -->
<ImageCropUpload v-model="showImageCropUpload"
field="img"
:width="128"
:height="128"
:langType="$i18n.locale"
img-format="png"
:noCircle="true"
:noSquare="false"
@crop-success="cropSuccess"
/>
<!-- Title -->
<Editable v-model="config.title" tag="span" :contenteditable="editMode" :noNL="true" />
</h1>
<!-- Admin functions -->
<div v-if="hasToken" class="mb-4">
<div v-if="!enableEditMode">
<button class="btn btn-info me-2" @click="edit">
<font-awesome-icon icon="edit" />
Edit Status Page
</button>
<a href="/dashboard" class="btn btn-info">
<font-awesome-icon icon="tachometer-alt" />
Go to Dashboard
</a>
</div>
<div v-else>
<button class="btn btn-success me-2" @click="save">
<font-awesome-icon icon="save" />
{{ $t("Save") }}
</button>
<button class="btn btn-danger me-2" @click="discard">
<font-awesome-icon icon="save" />
{{ $t("Discard") }}
</button>
<button class="btn btn-primary btn-add-group me-2" @click="createIncident">
<font-awesome-icon icon="bullhorn" />
{{ $t("Create Incident") }}
</button>
<!--
<button v-if="isPublished" class="btn btn-light me-2" @click="">
<font-awesome-icon icon="save" />
{{ $t("Unpublish") }}
</button>
<button v-if="!isPublished" class="btn btn-info me-2" @click="">
<font-awesome-icon icon="save" />
{{ $t("Publish") }}
</button>-->
<!-- Set Default Language -->
<!-- Set theme -->
<button v-if="theme == 'dark'" class="btn btn-light me-2" @click="changeTheme('light')">
<font-awesome-icon icon="save" />
{{ $t("Switch to Light Theme") }}
</button>
<button v-if="theme == 'light'" class="btn btn-dark me-2" @click="changeTheme('dark')">
<font-awesome-icon icon="save" />
{{ $t("Switch to Dark Theme") }}
</button>
</div>
</div>
<!-- Incident -->
<div v-if="incident !== null" class="shadow-box alert mb-4 p-4 incident" role="alert" :class="incidentClass">
<strong v-if="editIncidentMode">{{ $t("Title") }}:</strong>
<Editable v-model="incident.title" tag="h4" :contenteditable="editIncidentMode" :noNL="true" class="alert-heading" />
<strong v-if="editIncidentMode">{{ $t("Content") }}:</strong>
<Editable v-model="incident.content" tag="div" :contenteditable="editIncidentMode" class="content" />
<!-- Incident Date -->
<div class="date mt-3">
Created: {{ incident.createdDate }} ({{ createdDateFromNow }})<br />
<span v-if="incident.lastUpdatedDate">
Last Updated: {{ incident.lastUpdatedDate }} ({{ lastUpdatedDateFromNow }})
</span>
</div>
<div v-if="editMode" class="mt-3">
<button v-if="editIncidentMode" class="btn btn-light me-2" @click="postIncident">
<font-awesome-icon icon="bullhorn" />
{{ $t("Post") }}
</button>
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="editIncident">
<font-awesome-icon icon="edit" />
{{ $t("Edit") }}
</button>
<button v-if="editIncidentMode" class="btn btn-light me-2" @click="cancelIncident">
<font-awesome-icon icon="times" />
{{ $t("Cancel") }}
</button>
<div v-if="editIncidentMode" class="dropdown d-inline-block me-2">
<button id="dropdownMenuButton1" class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Style: {{ incident.style }}
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
<li><a class="dropdown-item" href="#" @click="incident.style = 'info'">info</a></li>
<li><a class="dropdown-item" href="#" @click="incident.style = 'warning'">warning</a></li>
<li><a class="dropdown-item" href="#" @click="incident.style = 'danger'">danger</a></li>
<li><a class="dropdown-item" href="#" @click="incident.style = 'primary'">primary</a></li>
<li><a class="dropdown-item" href="#" @click="incident.style = 'light'">light</a></li>
<li><a class="dropdown-item" href="#" @click="incident.style = 'dark'">dark</a></li>
</ul>
</div>
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="unpinIncident">
<font-awesome-icon icon="unlink" />
{{ $t("Unpin") }}
</button>
</div>
</div>
<!-- Overall Status -->
<div class="shadow-box list p-4 overall-status mb-4">
<div v-if="Object.keys($root.publicMonitorList).length === 0 && loadedData">
<font-awesome-icon icon="question-circle" class="ok" />
No Services
</div>
<template v-else>
<div v-if="allUp">
<font-awesome-icon icon="check-circle" class="ok" />
All Systems Operational
</div>
<div v-else-if="partialDown">
<font-awesome-icon icon="exclamation-circle" class="warning" />
Partially Degraded Service
</div>
<div v-else-if="allDown">
<font-awesome-icon icon="times-circle" class="danger" />
Degraded Service
</div>
<div v-else>
<font-awesome-icon icon="question-circle" style="color: #efefef" />
</div>
</template>
</div>
<!-- Description -->
<strong v-if="editMode">{{ $t("Description") }}:</strong>
<Editable v-model="config.description" :contenteditable="editMode" tag="div" class="mb-4 description" />
<div v-if="editMode" class="mb-4">
<div>
<button class="btn btn-primary btn-add-group me-2" @click="addGroup">
<font-awesome-icon icon="plus" />
Add Group
</button>
</div>
<div class="mt-3">
<label>Add a monitor:</label>
<select v-model="selectedMonitor" class="form-control">
<option v-for="monitor in allMonitorList" :key="monitor.id" :value="monitor">{{ monitor.name }}</option>
</select>
</div>
</div>
<div class="mb-4">
<div v-if="$root.publicGroupList.length === 0 && loadedData" class="text-center">
👀 Nothing here, please add a group or a monitor.
</div>
<PublicGroupList :edit-mode="enableEditMode" />
</div>
<footer class="mt-5 mb-4">
Powered by <a target="_blank" href="https://github.com/louislam/uptime-kuma">Uptime Kuma</a>
</footer>
</div>
</template>
<script>
import axios from "axios";
import PublicGroupList from "../components/PublicGroupList.vue";
import ImageCropUpload from "vue-image-crop-upload";
import { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_PARTIAL_DOWN, UP } from "../util.ts";
import { useToast } from "vue-toastification";
import dayjs from "dayjs";
const toast = useToast();
const leavePageMsg = "Do you really want to leave? you have unsaved changes!";
let feedInterval;
export default {
components: {
PublicGroupList,
ImageCropUpload
},
// Leave Page for vue route change
beforeRouteLeave(to, from, next) {
if (this.editMode) {
const answer = window.confirm(leavePageMsg);
if (answer) {
next();
} else {
next(false);
}
}
next();
},
data() {
return {
enableEditMode: false,
enableEditIncidentMode: false,
hasToken: false,
config: {},
selectedMonitor: null,
incident: null,
previousIncident: null,
showImageCropUpload: false,
imgDataUrl: "/icon.svg",
loadedTheme: false,
loadedData: false,
baseURL: "",
};
},
computed: {
logoURL() {
if (this.imgDataUrl.startsWith("data:")) {
return this.imgDataUrl;
} else {
return this.baseURL + this.imgDataUrl;
}
},
/**
* If the monitor is added to public list, which will not be in this list.
*/
allMonitorList() {
let result = [];
for (let id in this.$root.monitorList) {
if (this.$root.monitorList[id] && ! (id in this.$root.publicMonitorList)) {
let monitor = this.$root.monitorList[id];
result.push(monitor);
}
}
return result;
},
editMode() {
return this.enableEditMode && this.$root.socket.connected;
},
editIncidentMode() {
return this.enableEditIncidentMode;
},
isPublished() {
return this.config.statusPagePublished;
},
theme() {
return this.config.statusPageTheme;
},
logoClass() {
if (this.editMode) {
return {
"edit-mode": true,
};
}
return {};
},
incidentClass() {
return "bg-" + this.incident.style;
},
overallStatus() {
if (Object.keys(this.$root.publicLastHeartbeatList).length === 0) {
return -1;
}
let status = STATUS_PAGE_ALL_UP;
let hasUp = false;
for (let id in this.$root.publicLastHeartbeatList) {
let beat = this.$root.publicLastHeartbeatList[id];
if (beat.status === UP) {
hasUp = true;
} else {
status = STATUS_PAGE_PARTIAL_DOWN;
}
}
if (! hasUp) {
status = STATUS_PAGE_ALL_DOWN;
}
return status;
},
allUp() {
return this.overallStatus === STATUS_PAGE_ALL_UP;
},
partialDown() {
return this.overallStatus === STATUS_PAGE_PARTIAL_DOWN;
},
allDown() {
return this.overallStatus === STATUS_PAGE_ALL_DOWN;
},
createdDateFromNow() {
return dayjs.utc(this.incident.createdDate).fromNow();
},
lastUpdatedDateFromNow() {
return dayjs.utc(this.incident. lastUpdatedDate).fromNow();
}
},
watch: {
/**
* Selected a monitor and add to the list.
*/
selectedMonitor(monitor) {
if (monitor) {
if (this.$root.publicGroupList.length === 0) {
this.addGroup();
}
const firstGroup = this.$root.publicGroupList[0];
firstGroup.monitorList.push(monitor);
this.selectedMonitor = null;
}
},
// Set Theme
"config.statusPageTheme"() {
this.$root.statusPageTheme = this.config.statusPageTheme;
this.loadedTheme = true;
},
"config.title"(title) {
document.title = title;
}
},
async created() {
this.hasToken = ("token" in localStorage);
// Browser change page
// https://stackoverflow.com/questions/7317273/warn-user-before-leaving-web-page-with-unsaved-changes
window.addEventListener("beforeunload", (e) => {
if (this.editMode) {
(e || window.event).returnValue = leavePageMsg;
return leavePageMsg;
} else {
return null;
}
});
// Special handle for dev
const env = process.env.NODE_ENV;
if (env === "development" || localStorage.dev === "dev") {
this.baseURL = location.protocol + "//" + location.hostname + ":3001";
}
},
async mounted() {
axios.get("/api/status-page/config").then((res) => {
this.config = res.data;
if (this.config.logo) {
this.imgDataUrl = this.config.logo;
}
});
axios.get("/api/status-page/incident").then((res) => {
if (res.data.ok) {
this.incident = res.data.incident;
}
});
axios.get("/api/status-page/monitor-list").then((res) => {
this.$root.publicGroupList = res.data;
});
// 5mins a loop
this.updateHeartbeatList();
feedInterval = setInterval(() => {
this.updateHeartbeatList();
}, (300 + 10) * 1000);
},
methods: {
updateHeartbeatList() {
// If editMode, it will use the data from websocket.
if (! this.editMode) {
axios.get("/api/status-page/heartbeat").then((res) => {
this.$root.heartbeatList = res.data.heartbeatList;
this.$root.uptimeList = res.data.uptimeList;
this.loadedData = true;
});
}
},
edit() {
this.$root.initSocketIO(true);
this.enableEditMode = true;
},
save() {
this.$root.getSocket().emit("saveStatusPage", this.config, this.imgDataUrl, this.$root.publicGroupList, (res) => {
if (res.ok) {
this.enableEditMode = false;
this.$root.publicGroupList = res.publicGroupList;
location.reload();
} else {
toast.error(res.msg);
}
});
},
monitorSelectorLabel(monitor) {
return `${monitor.name}`;
},
addGroup() {
let groupName = "Untitled Group";
if (this.$root.publicGroupList.length === 0) {
groupName = "Services";
}
this.$root.publicGroupList.push({
name: groupName,
monitorList: [],
});
},
discard() {
location.reload();
},
changeTheme(name) {
this.config.statusPageTheme = name;
},
/**
* Crop Success
*/
cropSuccess(imgDataUrl) {
this.imgDataUrl = imgDataUrl;
},
showImageCropUploadMethod() {
if (this.editMode) {
this.showImageCropUpload = true;
}
},
createIncident() {
this.enableEditIncidentMode = true;
if (this.incident) {
this.previousIncident = this.incident;
}
this.incident = {
title: "",
content: "",
style: "primary",
};
},
postIncident() {
if (this.incident.title == "" || this.incident.content == "") {
toast.error("Please input title and content.");
return;
}
this.$root.getSocket().emit("postIncident", this.incident, (res) => {
if (res.ok) {
this.enableEditIncidentMode = false;
this.incident = res.incident;
} else {
toast.error(res.msg);
}
});
},
/**
* Click Edit Button
*/
editIncident() {
this.enableEditIncidentMode = true;
this.previousIncident = Object.assign({}, this.incident);
},
cancelIncident() {
this.enableEditIncidentMode = false;
if (this.previousIncident) {
this.incident = this.previousIncident;
this.previousIncident = null;
}
},
unpinIncident() {
this.$root.getSocket().emit("unpinIncident", () => {
this.incident = null;
});
}
}
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.overall-status {
font-weight: bold;
font-size: 25px;
.ok {
color: $primary;
}
.warning {
color: $warning;
}
.danger {
color: $danger;
}
}
h1 {
font-size: 30px;
img {
vertical-align: middle;
height: 60px;
width: 60px;
}
}
footer {
text-align: center;
font-size: 14px;
}
.description span {
min-width: 50px;
}
.logo-wrapper {
display: inline-block;
position: relative;
&:hover {
.icon-upload {
transform: scale(1.2);
}
}
.icon-upload {
transition: all $easing-in 0.2s;
position: absolute;
bottom: 6px;
font-size: 20px;
left: -14px;
background-color: white;
padding: 5px;
border-radius: 10px;
cursor: pointer;
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.9);
}
}
.logo {
transition: all $easing-in 0.2s;
&.edit-mode {
cursor: pointer;
&:hover {
transform: scale(1.2);
}
}
}
.incident {
.content {
&[contenteditable=true] {
min-height: 60px;
}
}
.date {
font-size: 12px;
}
}
.mobile {
h1 {
font-size: 22px;
}
.overall-status {
font-size: 20px;
}
}
</style>

View file

@ -8,20 +8,25 @@ import EditMonitor from "./pages/EditMonitor.vue";
import List from "./pages/List.vue"; import List from "./pages/List.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 StatusPage from "./pages/StatusPage.vue";
import Entry from "./pages/Entry.vue";
const routes = [ const routes = [
{ {
path: "/", path: "/",
component: Entry,
},
{
path: "/dashboard",
component: Layout, component: Layout,
children: [ children: [
{ {
name: "root",
path: "", path: "",
component: Dashboard, component: Dashboard,
children: [ children: [
{ {
name: "DashboardHome", name: "DashboardHome",
path: "/dashboard", path: "",
component: DashboardHome, component: DashboardHome,
children: [ children: [
{ {
@ -54,15 +59,17 @@ const routes = [
}, },
], ],
}, },
], ],
}, },
{ {
path: "/setup", path: "/setup",
component: Setup, component: Setup,
}, },
] {
path: "/status-page",
component: StatusPage,
},
];
export const router = createRouter({ export const router = createRouter({
linkActiveClass: "active", linkActiveClass: "active",

View file

@ -3,8 +3,8 @@ import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import timezones from "timezones-list"; import timezones from "timezones-list";
dayjs.extend(utc) dayjs.extend(utc);
dayjs.extend(timezone) dayjs.extend(timezone);
function getTimezoneOffset(timeZone) { function getTimezoneOffset(timeZone) {
const now = new Date(); const now = new Date();
@ -28,7 +28,7 @@ export function timezoneList() {
name: `(UTC${display}) ${timezone.tzCode}`, name: `(UTC${display}) ${timezone.tzCode}`,
value: timezone.tzCode, value: timezone.tzCode,
time: getTimezoneOffset(timezone.tzCode), time: getTimezoneOffset(timezone.tzCode),
}) });
} catch (e) { } catch (e) {
console.log("Skip Timezone: " + timezone.tzCode); console.log("Skip Timezone: " + timezone.tzCode);
} }
@ -44,7 +44,7 @@ export function timezoneList() {
} }
return 0; return 0;
}) });
return result; return result;
} }

View file

@ -1,70 +1,104 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); // Common Util for frontend and backend
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"); // DOT NOT MODIFY util.js!
const dayjs = _dayjs; // Need to run "tsc" to compile if there are any changes.
exports.isDev = process.env.NODE_ENV === "development"; //
exports.appName = "Uptime Kuma"; // Backend uses the compiled file util.js
exports.DOWN = 0; // Frontend uses util.ts
exports.UP = 1; Object.defineProperty(exports, "__esModule", { value: true });
exports.PENDING = 2; exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isDev = void 0;
function flipStatus(s) { const _dayjs = require("dayjs");
if (s === exports.UP) { const dayjs = _dayjs;
return exports.DOWN; exports.isDev = process.env.NODE_ENV === "development";
} exports.appName = "Uptime Kuma";
if (s === exports.DOWN) { exports.DOWN = 0;
return exports.UP; exports.UP = 1;
} exports.PENDING = 2;
return s; exports.STATUS_PAGE_ALL_DOWN = 0;
} exports.STATUS_PAGE_ALL_UP = 1;
exports.flipStatus = flipStatus; exports.STATUS_PAGE_PARTIAL_DOWN = 2;
function sleep(ms) { function flipStatus(s) {
return new Promise(resolve => setTimeout(resolve, ms)); if (s === exports.UP) {
} return exports.DOWN;
exports.sleep = sleep; }
function ucfirst(str) { if (s === exports.DOWN) {
if (!str) { return exports.UP;
return str; }
} return s;
const firstLetter = str.substr(0, 1); }
return firstLetter.toUpperCase() + str.substr(1); exports.flipStatus = flipStatus;
} function sleep(ms) {
exports.ucfirst = ucfirst; return new Promise(resolve => setTimeout(resolve, ms));
function debug(msg) { }
if (exports.isDev) { exports.sleep = sleep;
console.log(msg); /**
} * PHP's ucfirst
} * @param str
exports.debug = debug; */
function polyfill() { function ucfirst(str) {
if (!String.prototype.replaceAll) { if (!str) {
String.prototype.replaceAll = function (str, newStr) { return str;
if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") { }
return this.replace(str, newStr); const firstLetter = str.substr(0, 1);
} return firstLetter.toUpperCase() + str.substr(1);
return this.replace(new RegExp(str, "g"), newStr); }
}; exports.ucfirst = ucfirst;
} function debug(msg) {
} if (exports.isDev) {
exports.polyfill = polyfill; console.log(msg);
class TimeLogger { }
constructor() { }
this.startTime = dayjs().valueOf(); exports.debug = debug;
} function polyfill() {
print(name) { /**
if (exports.isDev) { * String.prototype.replaceAll() polyfill
console.log(name + ": " + (dayjs().valueOf() - this.startTime) + "ms"); * https://gomakethings.com/how-to-replace-a-section-of-a-string-with-another-one-with-vanilla-js/
} * @author Chris Ferdinandi
} * @license MIT
} */
exports.TimeLogger = TimeLogger; if (!String.prototype.replaceAll) {
function getRandomArbitrary(min, max) { String.prototype.replaceAll = function (str, newStr) {
return Math.random() * (max - min) + min; // If a regex pattern
} if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") {
exports.getRandomArbitrary = getRandomArbitrary; return this.replace(str, newStr);
function getRandomInt(min, max) { }
min = Math.ceil(min); // If a string
max = Math.floor(max); return this.replace(new RegExp(str, "g"), newStr);
return Math.floor(Math.random() * (max - min + 1)) + min; };
} }
exports.getRandomInt = getRandomInt; }
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;
/**
* Returns a random number between min (inclusive) and max (exclusive)
*/
function getRandomArbitrary(min, max) {
return Math.random() * (max - min) + min;
}
exports.getRandomArbitrary = getRandomArbitrary;
/**
* 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!
*/
function getRandomInt(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
exports.getRandomInt = getRandomInt;

View file

@ -1,7 +1,10 @@
// Common Util for frontend and backend // Common Util for frontend and backend
//
// DOT NOT MODIFY util.js!
// Need to run "tsc" to compile if there are any changes.
//
// Backend uses the compiled file util.js // Backend uses the compiled file util.js
// Frontend uses util.ts // Frontend uses util.ts
// Need to run "tsc" to compile if there are any changes.
import * as _dayjs from "dayjs"; import * as _dayjs from "dayjs";
const dayjs = _dayjs; const dayjs = _dayjs;
@ -12,6 +15,11 @@ export const DOWN = 0;
export const UP = 1; export const UP = 1;
export const PENDING = 2; export const PENDING = 2;
export const STATUS_PAGE_ALL_DOWN = 0;
export const STATUS_PAGE_ALL_UP = 1;
export const STATUS_PAGE_PARTIAL_DOWN = 2;
export function flipStatus(s: number) { export function flipStatus(s: number) {
if (s === UP) { if (s === UP) {
return DOWN; return DOWN;
@ -59,7 +67,6 @@ export function polyfill() {
*/ */
if (!String.prototype.replaceAll) { if (!String.prototype.replaceAll) {
String.prototype.replaceAll = function (str: string, newStr: string) { String.prototype.replaceAll = function (str: string, newStr: string) {
// If a regex pattern // If a regex pattern
if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") { if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") {
return this.replace(str, newStr); return this.replace(str, newStr);
@ -67,7 +74,6 @@ export function polyfill() {
// If a string // If a string
return this.replace(new RegExp(str, "g"), newStr); return this.replace(new RegExp(str, "g"), newStr);
}; };
} }
} }

View file

@ -0,0 +1,10 @@
FROM ubuntu
WORKDIR /app
RUN apt update && apt --yes install git curl
RUN curl -sL https://deb.nodesource.com/setup_16.x | bash -
RUN apt --yes install nodejs
RUN git clone https://github.com/louislam/uptime-kuma.git .
RUN npm run setup
# Option 1. Try it
RUN node server/server.js

View file

@ -7,7 +7,7 @@
"es2020", "es2020",
"DOM", "DOM",
], ],
"removeComments": true, "removeComments": false,
"preserveConstEnums": true, "preserveConstEnums": true,
"sourceMap": false, "sourceMap": false,
"strict": true "strict": true