Merge Conflict Resolved

Farsi Lang Updated
Some hardcoded words has been replaced with translations
This commit is contained in:
Soroosh 2021-09-26 19:56:25 +03:30
commit 04b7a4a423
68 changed files with 4180 additions and 1297 deletions

View file

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

View file

@ -17,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
@ -33,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,
}], }],
@ -85,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

@ -82,12 +82,10 @@ npm install --legacy-peer-deps --dev
# Backend Dev # Backend Dev
(2021-09-23 Update)
```bash ```bash
npm run start-server npm run start-server-dev
# Or
node server/server.js
``` ```
It binds to `0.0.0.0:3001` by default. It binds to `0.0.0.0:3001` by default.

View file

@ -44,6 +44,9 @@ Browse to http://localhost:3001 after started.
Required Tools: Node.js >= 14, git and pm2. Required Tools: Node.js >= 14, git and pm2.
```bash ```bash
# Update your npm to the latest version
npm install npm -g
git clone https://github.com/louislam/uptime-kuma.git git clone https://github.com/louislam/uptime-kuma.git
cd uptime-kuma cd uptime-kuma
npm run setup npm run setup

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

@ -1,14 +1,8 @@
# DON'T UPDATE TO node:14-bullseye-slim, see #372. # DON'T UPDATE TO node:14-bullseye-slim, see #372.
# If the image changed, the second stage image should be changed too
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 && \ RUN npm install --legacy-peer-deps && \
npm run build && \ npm run build && \
@ -16,7 +10,7 @@ RUN npm install --legacy-peer-deps && \
chmod +x /app/extra/entrypoint.sh 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, add sqlite3 cli for debugging in the future, iputils-ping for ping, util-linux for setpriv # Install Apprise, add sqlite3 cli for debugging in the future, iputils-ping for ping, util-linux for setpriv

View file

@ -2,13 +2,6 @@
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 && \ RUN npm install --legacy-peer-deps && \
npm run build && \ npm run build && \

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 {

1073
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "uptime-kuma", "name": "uptime-kuma",
"version": "1.6.0", "version": "1.7.0",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
@ -10,22 +10,25 @@
"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",
"dev": "vite --host", "dev": "vite --host",
"start": "npm run start-server", "start": "npm run start-server",
"start-server": "node server/server.js", "start-server": "node server/server.js",
"start-server-dev": "cross-env NODE_ENV=development 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.7.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.7.0 -t louislam/uptime-kuma:debian -t louislam/uptime-kuma:1-debian -t louislam/uptime-kuma:1.7.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 -f dockerfile-alpine --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.7.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",
"mark-as-nightly": "node extra/mark-as-nightly.js", "mark-as-nightly": "node extra/mark-as-nightly.js",
"reset-password": "node extra/reset-password.js", "reset-password": "node extra/reset-password.js",
@ -36,7 +39,7 @@
"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 .", "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_old": "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" "update-language-files": "cd extra/update-language-files && node index.js && eslint ../../src/languages/**.js --fix"
}, },
"dependencies": { "dependencies": {
@ -44,6 +47,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.6",
"@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",
@ -59,7 +63,7 @@
"form-data": "^4.0.0", "form-data": "^4.0.0",
"http-graceful-shutdown": "^3.1.4", "http-graceful-shutdown": "^3.1.4",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"nodemailer": "^6.6.3", "nodemailer": "^6.6.5",
"notp": "^2.0.3", "notp": "^2.0.3",
"password-hash": "^1.2.2", "password-hash": "^1.2.2",
"postcss-rtlcss": "^3.4.1", "postcss-rtlcss": "^3.4.1",
@ -70,34 +74,37 @@
"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.7",
"@types/bootstrap": "^5.1.4", "@types/bootstrap": "^5.1.6",
"@vitejs/plugin-legacy": "^1.5.3", "@vitejs/plugin-legacy": "^1.5.3",
"@vitejs/plugin-vue": "^1.6.2", "@vitejs/plugin-vue": "^1.9.1",
"@vue/compiler-sfc": "^3.2.11", "@vue/compiler-sfc": "^3.2.16",
"core-js": "^3.17.3", "core-js": "^3.18.0",
"cross-env": "^7.0.3",
"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.18.0",
"sass": "^1.39.2", "sass": "^1.42.1",
"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",
"vite": "^2.5.7" "vite": "^2.5.10"
} }
} }

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,151 @@
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;

File diff suppressed because it is too large Load diff

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;
}
}
} }
/* /*
@ -289,6 +313,121 @@ h2 {
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;
}
// Localization // Localization

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

@ -4,6 +4,7 @@ import deDE from "./languages/de-DE";
import en from "./languages/en"; import en from "./languages/en";
import fa from "./languages/fa"; import fa from "./languages/fa";
import esEs from "./languages/es-ES"; import esEs from "./languages/es-ES";
import ptBR from "./languages/pt-BR";
import etEE from "./languages/et-EE"; import etEE from "./languages/et-EE";
import frFR from "./languages/fr-FR"; import frFR from "./languages/fr-FR";
import itIT from "./languages/it-IT"; import itIT from "./languages/it-IT";
@ -26,6 +27,7 @@ const languageList = {
"nl-NL": nlNL, "nl-NL": nlNL,
"es-ES": esEs, "es-ES": esEs,
"fa": fa, "fa": fa,
"pt-BR": ptBR,
"fr-FR": frFR, "fr-FR": frFR,
"it-IT": itIT, "it-IT": itIT,
"ja": ja, "ja": ja,
@ -52,6 +54,6 @@ export const i18n = createI18n({
locale: currentLocale(), locale: currentLocale(),
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

@ -169,4 +169,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -166,6 +166,16 @@ export default {
retryCheckEverySecond: "Versuche alle {0} Sekunden", retryCheckEverySecond: "Versuche alle {0} Sekunden",
"Import Backup": "Import Backup", "Import Backup": "Import Backup",
"Export Backup": "Export Backup", "Export Backup": "Export Backup",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Durchsch. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Durchsch. Antwort",
} "Entry Page": "Einstiegsseite",
"statusPageNothing": "Nichts ist hier, bitte füge eine Gruppe oder Monitor hinzu.",
"No Services": "Keine Dienste",
"All Systems Operational": "Alle Systeme Betriebsbereit",
"Partially Degraded Service": "Teilweise beeinträchtigter Dienst",
"Degraded Service": "Eingeschränkter Dienst",
"Add Group": "Gruppe hinzufügen",
"Add a monitor": "Monitor hinzufügen",
"Edit Status Page": "Bearbeite Statusseite",
"Go to Dashboard": "Gehe zum Dashboard",
};

View file

@ -168,4 +168,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -169,4 +169,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -169,4 +169,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -168,10 +168,23 @@ export default {
"Search...": "جستجو...", "Search...": "جستجو...",
"Avg. Ping": "متوسط پینگ", "Avg. Ping": "متوسط پینگ",
"Avg. Response": "متوسط زمان پاسخ", "Avg. Response": "متوسط زمان پاسخ",
"Entry Page": "صفحه ورودی",
"statusPageNothing": "چیزی اینجا نیست، لطفا یک گروه و یا یک مانیتور اضافه کنید!",
"No Services": "هیچ سرویسی موجود نیست",
"All Systems Operational": "تمامی سیستم‌ها عملیاتی هستند!",
"Partially Degraded Service": "افت نسبی کیفیت سرویس",
"Degraded Service": "افت کامل کیفیت سرویس",
"Add Group": "اضافه کردن گروه",
"Add a monitor": "اضافه کردن مانیتور",
"Edit Status Page": "ویرایش صفحه وضعیت",
"Status Page":"صفحه وضعیت",
"Go to Dashboard": "رفتن به پیشخوان",
"Uptime Kuma": "آپتایم کوما", "Uptime Kuma": "آپتایم کوما",
"records":"مورد", "records":"مورد",
"One record":"یک مورد", "One record":"یک مورد",
"Showing {from} to {to} of {count} records":"نمایش از {from} تا {to} از {count} مورد", "Showing {from} to {to} of {count} records":"نمایش از {from} تا {to} از {count} مورد",
'First': 'اولین', 'First': 'اولین',
'Last': 'آخرین' 'Last': 'آخرین',
'Info':'اطلاعات',
'Powered By':'نیرو گرفته از'
} }

View file

@ -169,4 +169,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -168,4 +168,14 @@ export default {
"Search...": "Cerca...", "Search...": "Cerca...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -169,4 +169,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -169,4 +169,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -169,4 +169,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -169,4 +169,14 @@ export default {
"Search...": "Szukaj...", "Search...": "Szukaj...",
"Avg. Ping": "Średni ping", "Avg. Ping": "Średni ping",
"Avg. Response": "Średnia odpowiedź", "Avg. Response": "Średnia odpowiedź",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

182
src/languages/pt-BR.js Normal file
View file

@ -0,0 +1,182 @@
export default {
languageName: "Português (Brasileiro)",
checkEverySecond: "Verificar cada {0} segundos.",
retryCheckEverySecond: "Tentar novamente a cada {0} segundos.",
retriesDescription: "Máximo de tentativas antes que o serviço seja marcado como inativo e uma notificação seja enviada",
ignoreTLSError: "Ignorar erros TLS/SSL para sites HTTPS",
upsideDownModeDescription: "Inverta o status de cabeça para baixo. Se o serviço estiver acessível, ele está OFFLINE.",
maxRedirectDescription: "Número máximo de redirecionamentos a seguir. Defina como 0 para desativar redirecionamentos.",
acceptedStatusCodesDescription: "Selecione os códigos de status que são considerados uma resposta bem-sucedida.",
passwordNotMatchMsg: "A senha repetida não corresponde.",
notificationDescription: "Atribua uma notificação ao (s) monitor (es) para que funcione.",
keywordDescription: "Pesquise a palavra-chave em html simples ou resposta JSON e diferencia maiúsculas de minúsculas",
pauseDashboardHome: "Pausar",
deleteMonitorMsg: "Tem certeza de que deseja excluir este monitor?",
deleteNotificationMsg: "Tem certeza de que deseja excluir esta notificação para todos os monitores?",
resoverserverDescription: "Cloudflare é o servidor padrão, você pode alterar o servidor resolvedor a qualquer momento.",
rrtypeDescription: "Selecione o RR-Type que você deseja monitorar",
pauseMonitorMsg: "Tem certeza que deseja fazer uma pausa?",
enableDefaultNotificationDescription: "Para cada novo monitor, esta notificação será habilitada por padrão. Você ainda pode desativar a notificação separadamente para cada monitor.",
clearEventsMsg: "Tem certeza de que deseja excluir todos os eventos deste monitor?",
clearHeartbeatsMsg: "Tem certeza de que deseja excluir todos os heartbeats deste monitor?",
confirmClearStatisticsMsg: "Tem certeza que deseja excluir TODAS as estatísticas?",
importHandleDescription: "Escolha 'Ignorar existente' se quiser ignorar todos os monitores ou notificações com o mesmo nome. 'Substituir' excluirá todos os monitores e notificações existentes.",
confirmImportMsg: "Tem certeza que deseja importar o backup? Certifique-se de que selecionou a opção de importação correta.",
twoFAVerifyLabel: "Digite seu token para verificar se 2FA está funcionando",
tokenValidSettingsMsg: "O token é válido! Agora você pode salvar as configurações 2FA.",
confirmEnableTwoFAMsg: "Tem certeza de que deseja habilitar 2FA?",
confirmDisableTwoFAMsg: "Tem certeza de que deseja desativar 2FA?",
Settings: "Configurações",
Dashboard: "Dashboard",
"New Update": "Nova Atualização",
Language: "Linguagem",
Appearance: "Aparência",
Theme: "Tema",
General: "Geral",
Version: "Versão",
"Check Update On GitHub": "Verificar atualização no Github",
List: "Lista",
Add: "Adicionar",
"Add New Monitor": "Adicionar novo monitor",
"Quick Stats": "Estatísticas rápidas",
Up: "On",
Down: "Off",
Pending: "Pendente",
Unknown: "Desconhecido",
Pause: "Pausar",
Name: "Nome",
Status: "Status",
DateTime: "Data hora",
Message: "Mensagem",
"No important events": "Nenhum evento importante",
Resume: "Resumo",
Edit: "Editar",
Delete: "Deletar",
Current: "Atual",
Uptime: "Tempo de atividade",
"Cert Exp.": "Cert Exp.",
days: "dias",
day: "dia",
"-day": "-dia",
hour: "hora",
"-hour": "-hora",
Response: "Resposta",
Ping: "Ping",
"Monitor Type": "Tipo de Monitor",
Keyword: "Palavra-Chave",
"Friendly Name": "Nome Amigável",
URL: "URL",
Hostname: "Hostname",
Port: "Porta",
"Heartbeat Interval": "Intervalo de Heartbeat",
Retries: "Novas tentativas",
"Heartbeat Retry Interval": "Intervalo de repetição de Heartbeat",
Advanced: "Avançado",
"Upside Down Mode": "Modo de cabeça para baixo",
"Max. Redirects": "Redirecionamento Máx.",
"Accepted Status Codes": "Status Code Aceitáveis",
Save: "Salvar",
Notifications: "Notificações",
"Not available, please setup.": "Não disponível, por favor configure.",
"Setup Notification": "Configurar Notificação",
Light: "Claro",
Dark: "Escuro",
Auto: "Auto",
"Theme - Heartbeat Bar": "Tema - Barra de Heartbeat",
Normal: "Normal",
Bottom: "Inferior",
None: "Nenhum",
Timezone: "Fuso horário",
"Search Engine Visibility": "Visibilidade do mecanismo de pesquisa",
"Allow indexing": "Permitir Indexação",
"Discourage search engines from indexing site": "Desencoraje os motores de busca de indexar o site",
"Change Password": "Mudar senha",
"Current Password": "Senha atual",
"New Password": "Nova Senha",
"Repeat New Password": "Repetir Nova Senha",
"Update Password": "Atualizar Senha",
"Disable Auth": "Desativar Autenticação",
"Enable Auth": "Ativar Autenticação",
Logout: "Deslogar",
Leave: "Sair",
"I understand, please disable": "Eu entendo, por favor desative.",
Confirm: "Confirmar",
Yes: "Sim",
No: "Não",
Username: "Usuário",
Password: "Senha",
"Remember me": "Lembre-me",
Login: "Autenticar",
"No Monitors, please": "Nenhum monitor, por favor",
"add one": "adicionar um",
"Notification Type": "Tipo de Notificação",
Email: "Email",
Test: "Testar",
"Certificate Info": "Info. do Certificado ",
"Resolver Server": "Resolver Servidor",
"Resource Record Type": "Tipo de registro de aplicação",
"Last Result": "Último resultado",
"Create your admin account": "Crie sua conta de admin",
"Repeat Password": "Repita a senha",
"Import Backup": "Importar Backup",
"Export Backup": "Exportar Backup",
Export: "Exportar",
Import: "Importar",
respTime: "Tempo de Resp. (ms)",
notAvailableShort: "N/A",
"Default enabled": "Padrão habilitado",
"Apply on all existing monitors": "Aplicar em todos os monitores existentes",
Create: "Criar",
"Clear Data": "Limpar Dados",
Events: "Eventos",
Heartbeats: "Heartbeats",
"Auto Get": "Obter Automático",
backupDescription: "Você pode fazer backup de todos os monitores e todas as notificações em um arquivo JSON.",
backupDescription2: "OBS: Os dados do histórico e do evento não estão incluídos.",
backupDescription3: "Dados confidenciais, como tokens de notificação, estão incluídos no arquivo de exportação, mantenha-o com cuidado.",
alertNoFile: "Selecione um arquivo para importar.",
alertWrongFileType: "Selecione um arquivo JSON.",
"Clear all statistics": "Limpar todas as estatísticas",
"Skip existing": "Pular existente",
Overwrite: "Sobrescrever",
Options: "Opções",
"Keep both": "Manter os dois",
"Verify Token": "Verificar Token",
"Setup 2FA": "Configurar 2FA",
"Enable 2FA": "Ativar 2FA",
"Disable 2FA": "Desativar 2FA",
"2FA Settings": "Configurações do 2FA ",
"Two Factor Authentication": "Autenticação e Dois Fatores",
Active: "Ativo",
Inactive: "Inativo",
Token: "Token",
"Show URI": "Mostrar URI",
Tags: "Tag",
"Add New below or Select...": "Adicionar Novo abaixo ou Selecionar ...",
"Tag with this name already exist.": "Já existe uma etiqueta com este nome.",
"Tag with this value already exist.": "Já existe uma etiqueta com este valor.",
color: "cor",
"value (optional)": "valor (opcional)",
Gray: "Cinza",
Red: "Vermelho",
Orange: "Laranja",
Green: "Verde",
Blue: "Azul",
Indigo: "Índigo",
Purple: "Roxo",
Pink: "Rosa",
"Search...": "Buscar...",
"Avg. Ping": "Ping Médio.",
"Avg. Response": "Resposta Média. ",
"Status Page": "Página de Status",
"Entry Page": "Página de entrada",
"statusPageNothing": "Nada aqui, por favor, adicione um grupo ou monitor.",
"No Services": "Nenhum Serviço",
"All Systems Operational": "Todos os Serviços Operacionais",
"Partially Degraded Service": "Serviço parcialmente degradado",
"Degraded Service": "Serviço Degradado",
"Add Group": "Adicionar Grupo",
"Add a monitor": "Adicionar um monitor",
"Edit Status Page": "Editar Página de Status",
"Go to Dashboard": "Ir para a dashboard",
};

View file

@ -169,4 +169,14 @@ export default {
"Search...": "Поиск...", "Search...": "Поиск...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -169,4 +169,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -169,4 +169,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -169,4 +169,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -168,4 +168,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -169,4 +169,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -169,4 +169,14 @@ export default {
"Search...": "Search...", "Search...": "Search...",
"Avg. Ping": "Avg. Ping", "Avg. Ping": "Avg. Ping",
"Avg. Response": "Avg. Response", "Avg. Response": "Avg. Response",
} "Entry Page": "Entry Page",
"statusPageNothing": "Nothing here, please add a group or a monitor.",
"No Services": "No Services",
"All Systems Operational": "All Systems Operational",
"Partially Degraded Service": "Partially Degraded Service",
"Degraded Service": "Degraded Service",
"Add Group": "Add Group",
"Add a monitor": "Add a monitor",
"Edit Status Page": "Edit Status Page",
"Go to Dashboard": "Go to Dashboard",
};

View file

@ -18,7 +18,12 @@
</a> </a>
<ul class="nav nav-pills"> <ul class="nav nav-pills">
<li class="nav-item"> <li class="nav-item me-2">
<a href="/status" class="nav-link status-page">
<font-awesome-icon icon="stream" /> {{ $t("Status Page") }}
</a>
</li>
<li class="nav-item me-2">
<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") }}
</router-link> </router-link>
@ -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,15 @@
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",
"/status",
"/"
];
export default { export default {
data() { data() {
@ -14,6 +20,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 +33,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 +236,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 +245,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 +257,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 +269,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 +341,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 +382,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" || this.path === "/status") {
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

@ -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" :pattern="ipRegexPattern || hostnameRegexPattern" 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,11 +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 // Source: https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address
// eslint-disable-next-line 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])$"
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])$"
} }
}, },
@ -333,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");
} 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" />
@ -229,6 +244,12 @@
<p>Por favor usar con cuidado.</p> <p>Por favor usar con cuidado.</p>
</template> </template>
<template v-else-if="$i18n.locale === 'pt-BR' ">
<p>Você tem certeza que deseja <strong>desativar a autenticação</strong>?</p>
<p>Isso é para <strong>alguém que tem autenticação de terceiros</strong> na frente do 'UpTime Kuma' como o Cloudflare Access.</p>
<p>Por favor, utilize isso com cautela.</p>
</template>
<template v-else-if="$i18n.locale === 'zh-HK' "> <template v-else-if="$i18n.locale === 'zh-HK' ">
<p>你是否確認<strong>取消登入認証</strong></p> <p>你是否確認<strong>取消登入認証</strong></p>
<p>這個功能是設計給已有<strong>第三方認証</strong>的用家例如 Cloudflare Access</p> <p>這個功能是設計給已有<strong>第三方認証</strong>的用家例如 Cloudflare Access</p>
@ -261,8 +282,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' ">
@ -295,6 +316,12 @@
<p>Пожалуйста, используйте с осторожностью.</p> <p>Пожалуйста, используйте с осторожностью.</p>
</template> </template>
<template v-else-if="$i18n.locale === 'fa' ">
<p>آیا مطمئن هستید که میخواهید <strong>احراز هویت را غیر فعال کنید</strong>?</p>
<p>این ویژگی برای کسانی است که <strong> لایه امنیتی شخص ثالث دیگر بر روی این آدرس فعال کردهاند</strong>، مانند 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>
@ -316,16 +343,16 @@
<script> <script>
import Confirm from "../components/Confirm.vue"; import Confirm from "../components/Confirm.vue";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc" import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone" import timezone from "dayjs/plugin/timezone";
import NotificationDialog from "../components/NotificationDialog.vue"; import NotificationDialog from "../components/NotificationDialog.vue";
import TwoFADialog from "../components/TwoFADialog.vue"; import TwoFADialog from "../components/TwoFADialog.vue";
dayjs.extend(utc) dayjs.extend(utc);
dayjs.extend(timezone) dayjs.extend(timezone);
import { timezoneList } from "../util-frontend"; import { timezoneList } from "../util-frontend";
import { useToast } from "vue-toastification" import { useToast } from "vue-toastification";
const toast = useToast() const toast = useToast();
export default { export default {
components: { components: {
@ -351,7 +378,7 @@ export default {
importAlert: null, importAlert: null,
importHandle: "skip", importHandle: "skip",
processing: false, processing: false,
} };
}, },
watch: { watch: {
"password.repeatNewPassword"() { "password.repeatNewPassword"() {
@ -379,13 +406,13 @@ export default {
this.invalidPassword = true; this.invalidPassword = true;
} else { } else {
this.$root.getSocket().emit("changePassword", this.password, (res) => { this.$root.getSocket().emit("changePassword", this.password, (res) => {
this.$root.toastRes(res) this.$root.toastRes(res);
if (res.ok) { if (res.ok) {
this.password.currentPassword = "" this.password.currentPassword = "";
this.password.newPassword = "" this.password.newPassword = "";
this.password.repeatNewPassword = "" this.password.repeatNewPassword = "";
} }
}) });
} }
}, },
@ -397,15 +424,19 @@ 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;
}) });
}, },
saveSettings() { saveSettings() {
this.$root.getSocket().emit("setSettings", this.settings, (res) => { this.$root.getSocket().emit("setSettings", this.settings, (res) => {
this.$root.toastRes(res); this.$root.toastRes(res);
this.loadSettings(); this.loadSettings();
}) });
}, },
confirmDisableAuth() { confirmDisableAuth() {
@ -439,7 +470,7 @@ export default {
version: this.$root.info.version, version: this.$root.info.version,
notificationList: this.$root.notificationList, notificationList: this.$root.notificationList,
monitorList: monitorList, monitorList: monitorList,
} };
exportData = JSON.stringify(exportData, null, 4); exportData = JSON.stringify(exportData, null, 4);
let downloadItem = document.createElement("a"); let downloadItem = document.createElement("a");
downloadItem.setAttribute("href", "data:application/json;charset=utf-8," + encodeURIComponent(exportData)); downloadItem.setAttribute("href", "data:application/json;charset=utf-8," + encodeURIComponent(exportData));
@ -453,12 +484,12 @@ export default {
if (uploadItem.length <= 0) { if (uploadItem.length <= 0) {
this.processing = false; this.processing = false;
return this.importAlert = this.$t("alertNoFile") return this.importAlert = this.$t("alertNoFile");
} }
if (uploadItem.item(0).type !== "application/json") { if (uploadItem.item(0).type !== "application/json") {
this.processing = false; this.processing = false;
return this.importAlert = this.$t("alertWrongFileType") return this.importAlert = this.$t("alertWrongFileType");
} }
let fileReader = new FileReader(); let fileReader = new FileReader();
@ -473,8 +504,8 @@ export default {
} else { } else {
toast.error(res.msg); toast.error(res.msg);
} }
}) });
} };
}, },
clearStatistics() { clearStatistics() {
@ -484,10 +515,10 @@ export default {
} else { } else {
toast.error(res.msg); toast.error(res.msg);
} }
}) });
}, },
}, },
} };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

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

@ -0,0 +1,653 @@
<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" />
{{ $t("Edit Status Page") }}
</button>
<a href="/dashboard" class="btn btn-info">
<font-awesome-icon icon="tachometer-alt" />
{{ $t("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" />
{{ $t("No Services") }}
</div>
<template v-else>
<div v-if="allUp">
<font-awesome-icon icon="check-circle" class="ok" />
{{ $t("All Systems Operational") }}
</div>
<div v-else-if="partialDown">
<font-awesome-icon icon="exclamation-circle" class="warning" />
{{ $t("Partially Degraded Service") }}
</div>
<div v-else-if="allDown">
<font-awesome-icon icon="times-circle" class="danger" />
{{ $t("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" />
{{ $t("Add Group") }}
</button>
</div>
<div class="mt-3">
<div v-if="allMonitorList.length > 0 && loadedData">
<label>{{ $t("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 v-else class="text-center">
{{ $t("No monitors available.") }} <router-link to="/add">{{ $t("Add one") }}</router-link>
</div>
</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. -->
👀 {{ $t("statusPageNothing") }}
</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 this.$root.storage());
// 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,14 +8,22 @@ 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,
},
{
// If it is "/dashboard", the active link is not working
// If it is "", it overrides the "/" unexpectedly
// Give a random name to solve the problem.
path: "/empty",
component: Layout, component: Layout,
children: [ children: [
{ {
name: "root",
path: "", path: "",
component: Dashboard, component: Dashboard,
children: [ children: [
@ -54,15 +62,21 @@ const routes = [
}, },
], ],
}, },
], ],
}, },
{ {
path: "/setup", path: "/setup",
component: Setup, component: Setup,
}, },
] {
path: "/status-page",
component: StatusPage,
},
{
path: "/status",
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,6 +1,13 @@
"use strict"; "use strict";
// 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
// Frontend uses util.ts
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
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; 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;
const _dayjs = require("dayjs"); const _dayjs = require("dayjs");
const dayjs = _dayjs; const dayjs = _dayjs;
exports.isDev = process.env.NODE_ENV === "development"; exports.isDev = process.env.NODE_ENV === "development";
@ -8,6 +15,9 @@ exports.appName = "Uptime Kuma";
exports.DOWN = 0; exports.DOWN = 0;
exports.UP = 1; exports.UP = 1;
exports.PENDING = 2; exports.PENDING = 2;
exports.STATUS_PAGE_ALL_DOWN = 0;
exports.STATUS_PAGE_ALL_UP = 1;
exports.STATUS_PAGE_PARTIAL_DOWN = 2;
function flipStatus(s) { function flipStatus(s) {
if (s === exports.UP) { if (s === exports.UP) {
return exports.DOWN; return exports.DOWN;
@ -22,6 +32,10 @@ function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
exports.sleep = sleep; exports.sleep = sleep;
/**
* PHP's ucfirst
* @param str
*/
function ucfirst(str) { function ucfirst(str) {
if (!str) { if (!str) {
return str; return str;
@ -37,11 +51,19 @@ function debug(msg) {
} }
exports.debug = debug; exports.debug = debug;
function polyfill() { function polyfill() {
/**
* String.prototype.replaceAll() polyfill
* https://gomakethings.com/how-to-replace-a-section-of-a-string-with-another-one-with-vanilla-js/
* @author Chris Ferdinandi
* @license MIT
*/
if (!String.prototype.replaceAll) { if (!String.prototype.replaceAll) {
String.prototype.replaceAll = function (str, newStr) { String.prototype.replaceAll = function (str, newStr) {
// 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);
} }
// If a string
return this.replace(new RegExp(str, "g"), newStr); return this.replace(new RegExp(str, "g"), newStr);
}; };
} }
@ -58,10 +80,22 @@ class TimeLogger {
} }
} }
exports.TimeLogger = TimeLogger; exports.TimeLogger = TimeLogger;
/**
* Returns a random number between min (inclusive) and max (exclusive)
*/
function getRandomArbitrary(min, max) { function getRandomArbitrary(min, max) {
return Math.random() * (max - min) + min; return Math.random() * (max - min) + min;
} }
exports.getRandomArbitrary = getRandomArbitrary; 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) { function getRandomInt(min, max) {
min = Math.ceil(min); min = Math.ceil(min);
max = Math.floor(max); max = Math.floor(max);

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

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