Merge pull request #124 from chakflying/public-dashboard

Implement Public Dashboard
This commit is contained in:
Louis Lam 2021-09-23 16:54:07 +08:00 committed by GitHub
commit a9bb8ae6a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 3468 additions and 918 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

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;

623
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,7 @@
"start": "npm run start-server", "start": "npm run start-server",
"start-server": "node server/server.js", "start-server": "node server/server.js",
"build": "vite build", "build": "vite build",
"tsc": "tsc",
"vite-preview-dist": "vite preview --host", "vite-preview-dist": "vite preview --host",
"build-docker": "npm run build-docker-debian && npm run build-docker-alpine", "build-docker": "npm run build-docker-debian && npm run build-docker-alpine",
"build-docker-alpine": "docker buildx build -f dockerfile-alpine --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:alpine -t louislam/uptime-kuma:1-alpine -t louislam/uptime-kuma:1.6.0-alpine --target release . --push", "build-docker-alpine": "docker buildx build -f dockerfile-alpine --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:alpine -t louislam/uptime-kuma:1-alpine -t louislam/uptime-kuma:1.6.0-alpine --target release . --push",
@ -37,7 +38,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": {
@ -71,17 +72,20 @@
"socket.io": "^4.2.0", "socket.io": "^4.2.0",
"socket.io-client": "^4.2.0", "socket.io-client": "^4.2.0",
"tcp-ping": "^0.1.1", "tcp-ping": "^0.1.1",
"timezones-list": "^3.0.1",
"thirty-two": "^1.0.2", "thirty-two": "^1.0.2",
"timezones-list": "^3.0.1",
"v-pagination-3": "^0.1.6", "v-pagination-3": "^0.1.6",
"vue": "^3.2.8", "vue": "next",
"vue-chart-3": "^0.5.8", "vue-chart-3": "^0.5.8",
"vue-confirm-dialog": "^1.0.2", "vue-confirm-dialog": "^1.0.2",
"vue-contenteditable": "^3.0.4",
"vue-i18n": "^9.1.7", "vue-i18n": "^9.1.7",
"vue-image-crop-upload": "^3.0.3",
"vue-multiselect": "^3.0.0-alpha.2", "vue-multiselect": "^3.0.0-alpha.2",
"vue-qrcode": "^1.0.0", "vue-qrcode": "^1.0.0",
"vue-router": "^4.0.11", "vue-router": "^4.0.11",
"vue-toastification": "^2.0.0-rc.1" "vue-toastification": "^2.0.0-rc.1",
"vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.15.4", "@babel/eslint-parser": "^7.15.4",
@ -93,7 +97,7 @@
"dns2": "^2.0.1", "dns2": "^2.0.1",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-plugin-vue": "^7.17.0", "eslint-plugin-vue": "^7.17.0",
"sass": "^1.39.2", "sass": "^1.41.0",
"stylelint": "^13.13.1", "stylelint": "^13.13.1",
"stylelint-config-standard": "^22.0.0", "stylelint-config-standard": "^22.0.0",
"typescript": "^4.4.3", "typescript": "^4.4.3",

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

@ -5,10 +5,23 @@ const { debug, sleep } = require("../src/util");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const knex = require("knex"); 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;
/** /**
@ -33,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,
} }
/** /**
@ -50,6 +65,13 @@ class Database {
if (! fs.existsSync(Database.dataDir)) { if (! fs.existsSync(Database.dataDir)) {
fs.mkdirSync(Database.dataDir, { recursive: true }); 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}`); console.log(`Data Dir: ${Database.dataDir}`);
} }
@ -82,7 +104,7 @@ class Database {
} }
// 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
@ -110,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);
@ -125,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);
} }
} }
@ -154,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) {
@ -163,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);
} }
@ -207,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");
} }
} }
@ -225,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) => {
@ -238,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);
@ -284,7 +309,7 @@ class Database {
*/ */
static backup(version) { static backup(version) {
if (! this.backupPath) { if (! this.backupPath) {
console.info("Backup the db") console.info("Backup the db");
this.backupPath = this.dataDir + "kuma.db.bak" + version; this.backupPath = this.dataDir + "kuma.db.bak" + version;
fs.copyFileSync(Database.path, this.backupPath); fs.copyFileSync(Database.path, this.backupPath);

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

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

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

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -1,9 +1,14 @@
import { io } from "socket.io-client"; import { io } from "socket.io-client";
import { useToast } from "vue-toastification"; import { useToast } from "vue-toastification";
const toast = useToast() const toast = useToast();
let socket; let socket;
const noSocketIOPages = [
"/status-page",
"/"
];
export default { export default {
data() { data() {
@ -14,6 +19,7 @@ export default {
firstConnect: true, firstConnect: true,
connected: false, connected: false,
connectCount: 0, connectCount: 0,
initedSocketIO: false,
}, },
remember: (localStorage.remember !== "0"), remember: (localStorage.remember !== "0"),
allowLoginDialog: false, // Allowed to show login dialog, but "loggedIn" have to be true too. This exists because prevent the login dialog show 0.1s in first before the socket server auth-ed. allowLoginDialog: false, // Allowed to show login dialog, but "loggedIn" have to be true too. This exists because prevent the login dialog show 0.1s in first before the socket server auth-ed.
@ -26,11 +32,28 @@ 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();
},
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 protocol = (location.protocol === "https:") ? "wss://" : "ws://";
@ -51,7 +74,7 @@ export default {
}); });
socket.on("setup", (monitorID, data) => { socket.on("setup", (monitorID, data) => {
this.$router.push("/setup") this.$router.push("/setup");
}); });
socket.on("autoLogin", (monitorID, data) => { socket.on("autoLogin", (monitorID, data) => {
@ -83,7 +106,11 @@ export default {
this.heartbeatList[data.monitorID] = []; this.heartbeatList[data.monitorID] = [];
} }
this.heartbeatList[data.monitorID].push(data) 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 // Add to important list if it is important
// Also toast // Also toast
@ -105,7 +132,7 @@ export default {
this.importantHeartbeatList[data.monitorID] = []; this.importantHeartbeatList[data.monitorID] = [];
} }
this.importantHeartbeatList[data.monitorID].unshift(data) this.importantHeartbeatList[data.monitorID].unshift(data);
} }
}); });
@ -113,27 +140,27 @@ export default {
if (! (monitorID in this.heartbeatList) || overwrite) { if (! (monitorID in this.heartbeatList) || overwrite) {
this.heartbeatList[monitorID] = data; this.heartbeatList[monitorID] = data;
} else { } else {
this.heartbeatList[monitorID] = data.concat(this.heartbeatList[monitorID]) this.heartbeatList[monitorID] = data.concat(this.heartbeatList[monitorID]);
} }
}); });
socket.on("avgPing", (monitorID, data) => { socket.on("avgPing", (monitorID, data) => {
this.avgPingList[monitorID] = data this.avgPingList[monitorID] = data;
}); });
socket.on("uptime", (monitorID, type, data) => { socket.on("uptime", (monitorID, type, data) => {
this.uptimeList[`${monitorID}_${type}`] = data this.uptimeList[`${monitorID}_${type}`] = data;
}); });
socket.on("certInfo", (monitorID, data) => { socket.on("certInfo", (monitorID, data) => {
this.certInfoList[monitorID] = JSON.parse(data) this.certInfoList[monitorID] = JSON.parse(data);
}); });
socket.on("importantHeartbeatList", (monitorID, data, overwrite) => { socket.on("importantHeartbeatList", (monitorID, data, overwrite) => {
if (! (monitorID in this.importantHeartbeatList) || overwrite) { if (! (monitorID in this.importantHeartbeatList) || overwrite) {
this.importantHeartbeatList[monitorID] = data; this.importantHeartbeatList[monitorID] = data;
} else { } else {
this.importantHeartbeatList[monitorID] = data.concat(this.importantHeartbeatList[monitorID]) this.importantHeartbeatList[monitorID] = data.concat(this.importantHeartbeatList[monitorID]);
} }
}); });
@ -145,26 +172,26 @@ export default {
}); });
socket.on("disconnect", () => { socket.on("disconnect", () => {
console.log("disconnect") console.log("disconnect");
this.connectionErrorMsg = "Lost connection to the socket server. Reconnecting..."; this.connectionErrorMsg = "Lost connection to the socket server. Reconnecting...";
this.socket.connected = false; this.socket.connected = false;
}); });
socket.on("connect", () => { socket.on("connect", () => {
console.log("connect") console.log("connect");
this.socket.connectCount++; this.socket.connectCount++;
this.socket.connected = true; this.socket.connected = true;
// Reset Heartbeat list if it is re-connect // Reset Heartbeat list if it is re-connect
if (this.socket.connectCount >= 2) { if (this.socket.connectCount >= 2) {
this.clearData() this.clearData();
} }
let token = this.storage().token; let token = this.storage().token;
if (token) { if (token) {
if (token !== "autoLogin") { if (token !== "autoLogin") {
this.loginByToken(token) this.loginByToken(token);
} else { } else {
// Timeout if it is not actually auto login // Timeout if it is not actually auto login
@ -185,8 +212,6 @@ export default {
}, },
methods: {
storage() { storage() {
return (this.remember) ? localStorage : sessionStorage; return (this.remember) ? localStorage : sessionStorage;
}, },
@ -210,7 +235,7 @@ export default {
token, token,
}, (res) => { }, (res) => {
if (res.tokenRequired) { if (res.tokenRequired) {
callback(res) callback(res);
} }
if (res.ok) { if (res.ok) {
@ -219,11 +244,11 @@ export default {
this.loggedIn = true; this.loggedIn = true;
// Trigger Chrome Save Password // Trigger Chrome Save Password
history.pushState({}, "") history.pushState({}, "");
} }
callback(res) callback(res);
}) });
}, },
loginByToken(token) { loginByToken(token) {
@ -231,11 +256,11 @@ export default {
this.allowLoginDialog = true; this.allowLoginDialog = true;
if (! res.ok) { if (! res.ok) {
this.logout() this.logout();
} else { } else {
this.loggedIn = true; this.loggedIn = true;
} }
}) });
}, },
logout() { logout() {
@ -243,68 +268,68 @@ export default {
this.socket.token = null; this.socket.token = null;
this.loggedIn = false; this.loggedIn = false;
this.clearData() this.clearData();
}, },
prepare2FA(callback) { prepare2FA(callback) {
socket.emit("prepare2FA", callback) socket.emit("prepare2FA", callback);
}, },
save2FA(secret, callback) { save2FA(secret, callback) {
socket.emit("save2FA", callback) socket.emit("save2FA", callback);
}, },
disable2FA(callback) { disable2FA(callback) {
socket.emit("disable2FA", callback) socket.emit("disable2FA", callback);
}, },
verifyToken(token, callback) { verifyToken(token, callback) {
socket.emit("verifyToken", token, callback) socket.emit("verifyToken", token, callback);
}, },
twoFAStatus(callback) { twoFAStatus(callback) {
socket.emit("twoFAStatus", callback) socket.emit("twoFAStatus", callback);
}, },
getMonitorList(callback) { getMonitorList(callback) {
socket.emit("getMonitorList", callback) socket.emit("getMonitorList", callback);
}, },
add(monitor, callback) { add(monitor, callback) {
socket.emit("add", monitor, callback) socket.emit("add", monitor, callback);
}, },
deleteMonitor(monitorID, callback) { deleteMonitor(monitorID, callback) {
socket.emit("deleteMonitor", monitorID, callback) socket.emit("deleteMonitor", monitorID, callback);
}, },
clearData() { clearData() {
console.log("reset heartbeat list") console.log("reset heartbeat list");
this.heartbeatList = {} this.heartbeatList = {};
this.importantHeartbeatList = {} this.importantHeartbeatList = {};
}, },
uploadBackup(uploadedJSON, importHandle, callback) { uploadBackup(uploadedJSON, importHandle, callback) {
socket.emit("uploadBackup", uploadedJSON, importHandle, callback) socket.emit("uploadBackup", uploadedJSON, importHandle, callback);
}, },
clearEvents(monitorID, callback) { clearEvents(monitorID, callback) {
socket.emit("clearEvents", monitorID, callback) socket.emit("clearEvents", monitorID, callback);
}, },
clearHeartbeats(monitorID, callback) { clearHeartbeats(monitorID, callback) {
socket.emit("clearHeartbeats", monitorID, callback) socket.emit("clearHeartbeats", monitorID, callback);
}, },
clearStatistics(callback) { clearStatistics(callback) {
socket.emit("clearStatistics", callback) socket.emit("clearStatistics", callback);
}, },
}, },
computed: { computed: {
lastHeartbeatList() { lastHeartbeatList() {
let result = {} let result = {};
for (let monitorID in this.heartbeatList) { for (let monitorID in this.heartbeatList) {
let index = this.heartbeatList[monitorID].length - 1; let index = this.heartbeatList[monitorID].length - 1;
@ -315,15 +340,15 @@ export default {
}, },
statusList() { statusList() {
let result = {} let result = {};
let unknown = { let unknown = {
text: "Unknown", text: "Unknown",
color: "secondary", color: "secondary",
} };
for (let monitorID in this.lastHeartbeatList) { for (let monitorID in this.lastHeartbeatList) {
let lastHeartBeat = this.lastHeartbeatList[monitorID] let lastHeartBeat = this.lastHeartbeatList[monitorID];
if (! lastHeartBeat) { if (! lastHeartBeat) {
result[monitorID] = unknown; result[monitorID] = unknown;
@ -356,14 +381,22 @@ export default {
// Reload the SPA if the server version is changed. // Reload the SPA if the server version is changed.
"info.version"(to, from) { "info.version"(to, from) {
if (from && from !== to) { if (from && from !== to) {
window.location.reload() window.location.reload();
} }
}, },
remember() { remember() {
localStorage.remember = (this.remember) ? "1" : "0" localStorage.remember = (this.remember) ? "1" : "0";
},
// Reconnect the socket io, if status-page to dashboard
"$route.fullPath"(newValue, oldValue) {
if (noSocketIOPages.includes(newValue)) {
return;
}
this.initSocketIO();
}, },
}, },
} };

View file

@ -5,6 +5,8 @@ export default {
system: (window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light", system: (window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light",
userTheme: localStorage.theme, userTheme: localStorage.theme,
userHeartbeatBar: localStorage.heartbeatBarTheme, userHeartbeatBar: localStorage.heartbeatBarTheme,
statusPageTheme: "light",
path: "",
}; };
}, },
@ -25,14 +27,28 @@ export default {
computed: { computed: {
theme() { theme() {
// Entry no need dark
if (this.path === "") {
return "light";
}
if (this.path === "/status-page") {
return this.statusPageTheme;
} else {
if (this.userTheme === "auto") { if (this.userTheme === "auto") {
return this.system; 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 {
} }
} }
} }
} };

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

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

View file

@ -83,6 +83,24 @@
</div> </div>
</div> </div>
<div class="mb-3">
<label class="form-label">{{ $t("Entry Page") }}</label>
<div class="form-check">
<input id="entryPageYes" v-model="settings.entryPage" class="form-check-input" type="radio" name="statusPage" value="dashboard" required>
<label class="form-check-label" for="entryPageYes">
{{ $t("Dashboard") }}
</label>
</div>
<div class="form-check">
<input id="entryPageNo" v-model="settings.entryPage" class="form-check-input" type="radio" name="statusPage" value="statusPage" required>
<label class="form-check-label" for="entryPageNo">
{{ $t("Status Page") }}
</label>
</div>
</div>
<div> <div>
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit">
{{ $t("Save") }} {{ $t("Save") }}
@ -207,17 +225,14 @@
<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>
</div>
</div>
</div>
<footer> <h2 class="mt-5">Info</h2>
<div class="container-fluid">
Uptime Kuma - {{ $t("Version") }}: {{ $root.info.version }} <br />
{{ $t("Version") }}: {{ $root.info.version }} -
<a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a> <a href="https://github.com/louislam/uptime-kuma/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a>
</div> </div>
</footer> </div>
</div>
<NotificationDialog ref="notificationDialog" /> <NotificationDialog ref="notificationDialog" />
<TwoFADialog ref="TwoFADialog" /> <TwoFADialog ref="TwoFADialog" />
@ -397,6 +412,10 @@ export default {
this.settings.searchEngineIndex = false; this.settings.searchEngineIndex = false;
} }
if (this.settings.entryPage === undefined) {
this.settings.entryPage = "dashboard";
}
this.loaded = true; this.loaded = true;
}) })
}, },

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

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

View file

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

View file

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

View file

@ -1,10 +1,13 @@
"use strict"; "use strict";
// 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.
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";
@ -12,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;

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;