commit
ba4a4089eb
Binary file not shown.
@ -0,0 +1,10 @@
|
||||
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE user
|
||||
ADD twofa_secret VARCHAR(64);
|
||||
|
||||
ALTER TABLE user
|
||||
ADD twofa_status BOOLEAN default 0 NOT NULL;
|
||||
|
||||
COMMIT;
|
@ -0,0 +1,7 @@
|
||||
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE monitor
|
||||
ADD retry_interval INTEGER default 0 not null;
|
||||
|
||||
COMMIT;
|
@ -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;
|
@ -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;
|
@ -0,0 +1,22 @@
|
||||
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- Generated by Intellij IDEA
|
||||
create table setting_dg_tmp
|
||||
(
|
||||
id INTEGER
|
||||
primary key autoincrement,
|
||||
key VARCHAR(200) not null
|
||||
unique,
|
||||
value TEXT,
|
||||
type VARCHAR(20)
|
||||
);
|
||||
|
||||
insert into setting_dg_tmp(id, key, value, type) select id, key, value, type from setting;
|
||||
|
||||
drop table setting;
|
||||
|
||||
alter table setting_dg_tmp rename to setting;
|
||||
|
||||
|
||||
COMMIT;
|
@ -0,0 +1,19 @@
|
||||
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
|
||||
CREATE TABLE tag (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
color VARCHAR(255) NOT NULL,
|
||||
created_date DATETIME DEFAULT (DATETIME('now')) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE monitor_tag (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
monitor_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
value TEXT,
|
||||
CONSTRAINT FK_tag FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT FK_monitor FOREIGN KEY (monitor_id) REFERENCES monitor(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX monitor_tag_monitor_id_index ON monitor_tag (monitor_id);
|
||||
CREATE INDEX monitor_tag_tag_id_index ON monitor_tag (tag_id);
|
@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# set -e Exit the script if an error happens
|
||||
set -e
|
||||
PUID=${PUID=1000}
|
||||
PGID=${PGID=1000}
|
||||
|
||||
files_ownership () {
|
||||
# -h Changes the ownership of an encountered symbolic link and not that of the file or directory pointed to by the symbolic link.
|
||||
# -R Recursively descends the specified directories
|
||||
# -c Like verbose but report only when a change is made
|
||||
chown -hRc "$PUID":"$PGID" /app/data
|
||||
}
|
||||
|
||||
echo "==> Performing startup jobs and maintenance tasks"
|
||||
files_ownership
|
||||
|
||||
echo "==> Starting application with user $PUID group $PGID"
|
||||
|
||||
# --clear-groups Clear supplementary groups.
|
||||
exec setpriv --reuid "$PUID" --regid "$PGID" --clear-groups "$@"
|
@ -1,28 +1,32 @@
|
||||
# Uptime-Kuma K8s Deployment
|
||||
|
||||
⚠ Warning: K8s deployment is provided by contributors. I have no experience with K8s and I can't fix error in the future. I only test Docker and Node.js. Use at your own risk.
|
||||
|
||||
## How does it work?
|
||||
|
||||
Kustomize is a tool which builds a complete deployment file for all config elements.
|
||||
You can edit the files in the ```uptime-kuma``` folder except the ```kustomization.yml``` until you know what you're doing.
|
||||
If you want to choose another namespace you can edit the ```kustomization.yml``` in the ```kubernetes```-Folder and change the ```namespace: uptime-kuma``` to something you like.
|
||||
|
||||
It creates a certificate with the specified Issuer and creates the Ingress for the Uptime-Kuma ClusterIP-Service
|
||||
It creates a certificate with the specified Issuer and creates the Ingress for the Uptime-Kuma ClusterIP-Service.
|
||||
|
||||
## What do I have to edit?
|
||||
|
||||
## What do i have to edit?
|
||||
You have to edit the ```ingressroute.yml``` to your needs.
|
||||
This ingressroute.yml is for the [nginx-ingress-controller](https://kubernetes.github.io/ingress-nginx/) in combination with the [cert-manager](https://cert-manager.io/).
|
||||
|
||||
- host
|
||||
- secrets and secret names
|
||||
- Host
|
||||
- Secrets and secret names
|
||||
- (Cluster)Issuer (optional)
|
||||
- the Version in the Deployment-File
|
||||
- update:
|
||||
- change to newer version and run the above commands, it will update the pods one after another
|
||||
- The Version in the Deployment-File
|
||||
- Update:
|
||||
- Change to newer version and run the above commands, it will update the pods one after another
|
||||
|
||||
## How To use:
|
||||
## How To use
|
||||
|
||||
- install [kustomize](https://kubectl.docs.kubernetes.io/installation/kustomize/)
|
||||
- Install [kustomize](https://kubectl.docs.kubernetes.io/installation/kustomize/)
|
||||
- Edit files mentioned above to your needs
|
||||
- run ```kustomize build > apply.yml```
|
||||
- run ```kubectl apply -f apply.yml```
|
||||
- Run ```kustomize build > apply.yml```
|
||||
- Run ```kubectl apply -f apply.yml```
|
||||
|
||||
Now you should see some k8s magic and Uptime-Kuma should be available at the specified address.
|
||||
Now you should see some k8s magic and Uptime-Kuma should be available at the specified address.
|
||||
|
File diff suppressed because it is too large
Load Diff
After Width: | Height: | Size: 2.6 KiB |
After Width: | Height: | Size: 9.5 KiB |
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Uptime Kuma",
|
||||
"short_name": "Uptime Kuma",
|
||||
"start_url": "/",
|
||||
"background_color": "#fff",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -0,0 +1,13 @@
|
||||
const { BeanModel } = require("redbean-node/dist/bean-model");
|
||||
|
||||
class Tag extends BeanModel {
|
||||
toJSON() {
|
||||
return {
|
||||
id: this._id,
|
||||
name: this._name,
|
||||
color: this._color,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Tag;
|
@ -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();
|
@ -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;
|
@ -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;
|
@ -0,0 +1,124 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
const { DOWN, UP } = require("../../src/util");
|
||||
|
||||
class Teams extends NotificationProvider {
|
||||
name = "teams";
|
||||
|
||||
_statusMessageFactory = (status, monitorName) => {
|
||||
if (status === DOWN) {
|
||||
return `🔴 Application [${monitorName}] went down`;
|
||||
} else if (status === UP) {
|
||||
return `✅ Application [${monitorName}] is back online`;
|
||||
}
|
||||
return "Notification";
|
||||
};
|
||||
|
||||
_getThemeColor = (status) => {
|
||||
if (status === DOWN) {
|
||||
return "ff0000";
|
||||
}
|
||||
if (status === UP) {
|
||||
return "00e804";
|
||||
}
|
||||
return "008cff";
|
||||
};
|
||||
|
||||
_notificationPayloadFactory = ({
|
||||
status,
|
||||
monitorMessage,
|
||||
monitorName,
|
||||
monitorUrl,
|
||||
}) => {
|
||||
const notificationMessage = this._statusMessageFactory(
|
||||
status,
|
||||
monitorName
|
||||
);
|
||||
|
||||
const facts = [];
|
||||
|
||||
if (monitorName) {
|
||||
facts.push({
|
||||
name: "Monitor",
|
||||
value: monitorName,
|
||||
});
|
||||
}
|
||||
|
||||
if (monitorUrl) {
|
||||
facts.push({
|
||||
name: "URL",
|
||||
value: monitorUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
"@context": "https://schema.org/extensions",
|
||||
"@type": "MessageCard",
|
||||
themeColor: this._getThemeColor(status),
|
||||
summary: notificationMessage,
|
||||
sections: [
|
||||
{
|
||||
activityImage:
|
||||
"https://raw.githubusercontent.com/louislam/uptime-kuma/master/public/icon.png",
|
||||
activityTitle: "**Uptime Kuma**",
|
||||
},
|
||||
{
|
||||
activityTitle: notificationMessage,
|
||||
},
|
||||
{
|
||||
activityTitle: "**Description**",
|
||||
text: monitorMessage,
|
||||
facts,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
_sendNotification = async (webhookUrl, payload) => {
|
||||
await axios.post(webhookUrl, payload);
|
||||
};
|
||||
|
||||
_handleGeneralNotification = (webhookUrl, msg) => {
|
||||
const payload = this._notificationPayloadFactory({
|
||||
monitorMessage: msg
|
||||
});
|
||||
|
||||
return this._sendNotification(webhookUrl, payload);
|
||||
};
|
||||
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
let okMsg = "Sent Successfully. ";
|
||||
|
||||
try {
|
||||
if (heartbeatJSON == null) {
|
||||
await this._handleGeneralNotification(notification.webhookUrl, msg);
|
||||
return okMsg;
|
||||
}
|
||||
|
||||
let url;
|
||||
|
||||
if (monitorJSON["type"] === "port") {
|
||||
url = monitorJSON["hostname"];
|
||||
if (monitorJSON["port"]) {
|
||||
url += ":" + monitorJSON["port"];
|
||||
}
|
||||
} else {
|
||||
url = monitorJSON["url"];
|
||||
}
|
||||
|
||||
const payload = this._notificationPayloadFactory({
|
||||
monitorMessage: heartbeatJSON.msg,
|
||||
monitorName: monitorJSON.name,
|
||||
monitorUrl: url,
|
||||
status: heartbeatJSON.status,
|
||||
});
|
||||
|
||||
await this._sendNotification(notification.webhookUrl, payload);
|
||||
return okMsg;
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Teams;
|
@ -0,0 +1,151 @@
|
||||
let express = require("express");
|
||||
const { allowDevAllOrigin, getSettings, setting } = require("../util-server");
|
||||
const { R } = require("redbean-node");
|
||||
const server = require("../server");
|
||||
const apicache = require("../modules/apicache");
|
||||
const Monitor = require("../model/monitor");
|
||||
let router = express.Router();
|
||||
|
||||
let cache = apicache.middleware;
|
||||
|
||||
router.get("/api/entry-page", async (_, response) => {
|
||||
allowDevAllOrigin(response);
|
||||
response.json(server.entryPage);
|
||||
});
|
||||
|
||||
// Status Page Config
|
||||
router.get("/api/status-page/config", async (_request, response) => {
|
||||
allowDevAllOrigin(response);
|
||||
|
||||
let config = await getSettings("statusPage");
|
||||
|
||||
if (! config.statusPageTheme) {
|
||||
config.statusPageTheme = "light";
|
||||
}
|
||||
|
||||
if (! config.statusPagePublished) {
|
||||
config.statusPagePublished = true;
|
||||
}
|
||||
|
||||
if (! config.title) {
|
||||
config.title = "Uptime Kuma";
|
||||
}
|
||||
|
||||
response.json(config);
|
||||
});
|
||||
|
||||
// Status Page - Get the current Incident
|
||||
// Can fetch only if published
|
||||
router.get("/api/status-page/incident", async (_, response) => {
|
||||
allowDevAllOrigin(response);
|
||||
|
||||
try {
|
||||
await checkPublished();
|
||||
|
||||
let incident = await R.findOne("incident", " pin = 1 AND active = 1");
|
||||
|
||||
if (incident) {
|
||||
incident = incident.toPublicJSON();
|
||||
}
|
||||
|
||||
response.json({
|
||||
ok: true,
|
||||
incident,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
send403(response, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Status Page - Monitor List
|
||||
// Can fetch only if published
|
||||
router.get("/api/status-page/monitor-list", cache("5 minutes"), async (_request, response) => {
|
||||
allowDevAllOrigin(response);
|
||||
|
||||
try {
|
||||
await checkPublished();
|
||||
const publicGroupList = [];
|
||||
let list = await R.find("group", " public = 1 ORDER BY weight ");
|
||||
|
||||
for (let groupBean of list) {
|
||||
publicGroupList.push(await groupBean.toPublicJSON());
|
||||
}
|
||||
|
||||
response.json(publicGroupList);
|
||||
|
||||
} catch (error) {
|
||||
send403(response, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Status Page Polling Data
|
||||
// Can fetch only if published
|
||||
router.get("/api/status-page/heartbeat", cache("5 minutes"), async (_request, response) => {
|
||||
allowDevAllOrigin(response);
|
||||
|
||||
try {
|
||||
await checkPublished();
|
||||
|
||||
let heartbeatList = {};
|
||||
let uptimeList = {};
|
||||
|
||||
let monitorIDList = await R.getCol(`
|
||||
SELECT monitor_group.monitor_id FROM monitor_group, \`group\`
|
||||
WHERE monitor_group.group_id = \`group\`.id
|
||||
AND public = 1
|
||||
`);
|
||||
|
||||
for (let monitorID of monitorIDList) {
|
||||
let list = await R.getAll(`
|
||||
SELECT * FROM heartbeat
|
||||
WHERE monitor_id = ?
|
||||
ORDER BY time DESC
|
||||
LIMIT 50
|
||||
`, [
|
||||
monitorID,
|
||||
]);
|
||||
|
||||
list = R.convertToBeans("heartbeat", list);
|
||||
heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());
|
||||
|
||||
const type = 24;
|
||||
uptimeList[`${monitorID}_${type}`] = await Monitor.calcUptime(type, monitorID);
|
||||
}
|
||||
|
||||
response.json({
|
||||
heartbeatList,
|
||||
uptimeList
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
send403(response, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function checkPublished() {
|
||||
if (! await isPublished()) {
|
||||
throw new Error("The status page is not published");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default is published
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function isPublished() {
|
||||
const value = await setting("statusPagePublished");
|
||||
if (value === null) {
|
||||
return true;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function send403(res, msg = "") {
|
||||
res.status(403).json({
|
||||
"status": "fail",
|
||||
"msg": msg,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = router;
|
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
};
|
@ -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>
|
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="tag-wrapper rounded d-inline-flex"
|
||||
:class="{ 'px-3': size == 'normal',
|
||||
'py-1': size == 'normal',
|
||||
'm-2': size == 'normal',
|
||||
'px-2': size == 'sm',
|
||||
'py-0': size == 'sm',
|
||||
'm-1': size == 'sm',
|
||||
}"
|
||||
:style="{ backgroundColor: item.color, fontSize: size == 'sm' ? '0.7em' : '1em' }"
|
||||
>
|
||||
<span class="tag-text">{{ displayText }}</span>
|
||||
<span v-if="remove != null" class="ps-1 btn-remove" @click="remove(item)">
|
||||
<font-awesome-icon icon="times" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
remove: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "normal",
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
displayText() {
|
||||
if (this.item.value == "") {
|
||||
return this.item.name;
|
||||
} else {
|
||||
return `${this.item.name}: ${this.item.value}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tag-wrapper {
|
||||
color: white;
|
||||
opacity: 0.85;
|
||||
|
||||
.dark & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
padding-bottom: 1px !important;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
font-size: 0.9em;
|
||||
line-height: 24px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<div>
|
||||
<h4 class="mb-3">{{ $t("Tags") }}</h4>
|
||||
<div class="mb-3 p-1">
|
||||
<tag
|
||||
v-for="item in selectedTags"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:remove="deleteTag"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary btn-add"
|
||||
:disabled="processing"
|
||||
@click.stop="showAddDialog"
|
||||
>
|
||||
<font-awesome-icon class="me-1" icon="plus" /> {{ $t("Add") }}
|
||||
</button>
|
||||
</div>
|
||||
<div ref="modal" class="modal fade" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body">
|
||||
<vue-multiselect
|
||||
v-model="newDraftTag.select"
|
||||
class="mb-2"
|
||||
:options="tagOptions"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
:placeholder="$t('Add New below or Select...')"
|
||||
track-by="id"
|
||||
label="name"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="mx-2 py-1 px-3 rounded d-inline-flex"
|
||||
style="margin-top: -5px; margin-bottom: -5px; height: 24px;"
|
||||
:style="{ color: textColor(option), backgroundColor: option.color + ' !important' }"
|
||||
>
|
||||
<span>
|
||||
{{ option.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #singleLabel="{ option }">
|
||||
<div class="py-1 px-3 rounded d-inline-flex"
|
||||
style="height: 24px;"
|
||||
:style="{ color: textColor(option), backgroundColor: option.color + ' !important' }"
|
||||
>
|
||||
<span>{{ option.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</vue-multiselect>
|
||||
<div v-if="newDraftTag.select?.name == null" class="d-flex mb-2">
|
||||
<div class="w-50 pe-2">
|
||||
<input v-model="newDraftTag.name" class="form-control"
|
||||
:class="{'is-invalid': validateDraftTag.nameInvalid}"
|
||||
:placeholder="$t('Name')"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
/>
|
||||
<div class="invalid-feedback">
|
||||
{{ $t("Tag with this name already exist.") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-50 ps-2">
|
||||
<vue-multiselect
|
||||
v-model="newDraftTag.color"
|
||||
:options="colorOptions"
|
||||
:multiple="false"
|
||||
:searchable="true"
|
||||
:placeholder="$t('color')"
|
||||
track-by="color"
|
||||
label="name"
|
||||
select-label=""
|
||||
deselect-label=""
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="mx-2 py-1 px-3 rounded d-inline-flex"
|
||||
style="height: 24px; color: white;"
|
||||
:style="{ backgroundColor: option.color + ' !important' }"
|
||||
>
|
||||
<span>{{ option.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #singleLabel="{ option }">
|
||||
<div class="py-1 px-3 rounded d-inline-flex"
|
||||
style="height: 24px; color: white;"
|
||||
:style="{ backgroundColor: option.color + ' !important' }"
|
||||
>
|
||||
<span>{{ option.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</vue-multiselect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input v-model="newDraftTag.value" class="form-control"
|
||||
:class="{'is-invalid': validateDraftTag.valueInvalid}"
|
||||
:placeholder="$t('value (optional)')"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
/>
|
||||
<div class="invalid-feedback">
|
||||
{{ $t("Tag with this value already exist.") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary float-end"
|
||||
:disabled="processing || validateDraftTag.invalid"
|
||||
@click.stop="addDraftTag"
|
||||
>
|
||||
{{ $t("Add") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Modal } from "bootstrap";
|
||||
import VueMultiselect from "vue-multiselect";
|
||||
import Tag from "../components/Tag.vue";
|
||||
import { useToast } from "vue-toastification"
|
||||
const toast = useToast()
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Tag,
|
||||
VueMultiselect,
|
||||
},
|
||||
props: {
|
||||
preSelectedTags: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
modal: null,
|
||||
existingTags: [],
|
||||
processing: false,
|
||||
newTags: [],
|
||||
deleteTags: [],
|
||||
newDraftTag: {
|
||||
name: null,
|
||||
select: null,
|
||||
color: null,
|
||||
value: "",
|
||||
invalid: true,
|
||||
nameInvalid: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
tagOptions() {
|
||||
const tagOptions = this.existingTags;
|
||||
for (const tag of this.newTags) {
|
||||
if (!tagOptions.find(t => t.name == tag.name && t.color == tag.color)) {
|
||||
tagOptions.push(tag);
|
||||
}
|
||||
}
|
||||
return tagOptions;
|
||||
},
|
||||
selectedTags() {
|
||||
return this.preSelectedTags.concat(this.newTags).filter(tag => !this.deleteTags.find(monitorTag => monitorTag.id == tag.id));
|
||||
},
|
||||
colorOptions() {
|
||||
return [
|
||||
{ name: this.$t("Gray"),
|
||||
color: "#4B5563" },
|
||||
{ name: this.$t("Red"),
|
||||
color: "#DC2626" },
|
||||
{ name: this.$t("Orange"),
|
||||
color: "#D97706" },
|
||||
{ name: this.$t("Green"),
|
||||
color: "#059669" },
|
||||
{ name: this.$t("Blue"),
|
||||
color: "#2563EB" },
|
||||
{ name: this.$t("Indigo"),
|
||||
color: "#4F46E5" },
|
||||
{ name: this.$t("Purple"),
|
||||
color: "#7C3AED" },
|
||||
{ name: this.$t("Pink"),
|
||||
color: "#DB2777" },
|
||||
]
|
||||
},
|
||||
validateDraftTag() {
|
||||
let nameInvalid = false;
|
||||
let valueInvalid = false;
|
||||
let invalid = true;
|
||||
if (this.deleteTags.find(tag => tag.name == this.newDraftTag.select?.name && tag.value == this.newDraftTag.value)) {
|
||||
// Undo removing a Tag
|
||||
nameInvalid = false;
|
||||
valueInvalid = false;
|
||||
invalid = false;
|
||||
} else if (this.existingTags.filter(tag => tag.name === this.newDraftTag.name).length > 0) {
|
||||
// Try to create new tag with existing name
|
||||
nameInvalid = true;
|
||||
invalid = true;
|
||||
} else if (this.newTags.concat(this.preSelectedTags).filter(tag => (
|
||||
tag.name == this.newDraftTag.select?.name && tag.value == this.newDraftTag.value
|
||||
) || (
|
||||
tag.name == this.newDraftTag.name && tag.value == this.newDraftTag.value
|
||||
)).length > 0) {
|
||||
// Try to add a tag with existing name and value
|
||||
valueInvalid = true;
|
||||
invalid = true;
|
||||
} else if (this.newDraftTag.select != null) {
|
||||
// Select an existing tag, no need to validate
|
||||
invalid = false;
|
||||
valueInvalid = false;
|
||||
} else if (this.newDraftTag.color == null || this.newDraftTag.name === "") {
|
||||
// Missing form inputs
|
||||
nameInvalid = false;
|
||||
invalid = true;
|
||||
} else {
|
||||
// Looks valid
|
||||
invalid = false;
|
||||
nameInvalid = false;
|
||||
valueInvalid = false;
|
||||
}
|
||||
return {
|
||||
invalid,
|
||||
nameInvalid,
|
||||
valueInvalid,
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.modal = new Modal(this.$refs.modal);
|
||||
this.getExistingTags();
|
||||
},
|
||||
methods: {
|
||||
showAddDialog() {
|
||||
this.modal.show();
|
||||
},
|
||||
getExistingTags() {
|
||||
this.$root.getSocket().emit("getTags", (res) => {
|
||||
if (res.ok) {
|
||||
this.existingTags = res.tags;
|
||||
} else {
|
||||
toast.error(res.msg)
|
||||
}
|
||||
});
|
||||
},
|
||||
deleteTag(item) {
|
||||
if (item.new) {
|
||||
// Undo Adding a new Tag
|
||||
this.newTags = this.newTags.filter(tag => !(tag.name == item.name && tag.value == item.value));
|
||||
} else {
|
||||
// Remove an Existing Tag
|
||||
this.deleteTags.push(item);
|
||||
}
|
||||
},
|
||||
textColor(option) {
|
||||
if (option.color) {
|
||||
return "white";
|
||||
} else {
|
||||
return this.$root.theme === "light" ? "var(--bs-body-color)" : "inherit";
|
||||
}
|
||||
},
|
||||
addDraftTag() {
|
||||
console.log("Adding Draft Tag: ", this.newDraftTag);
|
||||
if (this.newDraftTag.select != null) {
|
||||
if (this.deleteTags.find(tag => tag.name == this.newDraftTag.select.name && tag.value == this.newDraftTag.value)) {
|
||||
// Undo removing a tag
|
||||
this.deleteTags = this.deleteTags.filter(tag => !(tag.name == this.newDraftTag.select.name && tag.value == this.newDraftTag.value));
|
||||
} else {
|
||||
// Add an existing Tag
|
||||
this.newTags.push({
|
||||
id: this.newDraftTag.select.id,
|
||||
color: this.newDraftTag.select.color,
|
||||
name: this.newDraftTag.select.name,
|
||||
value: this.newDraftTag.value,
|
||||
new: true,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Add new Tag
|
||||
this.newTags.push({
|
||||
color: this.newDraftTag.color.color,
|
||||
name: this.newDraftTag.name.trim(),
|
||||
value: this.newDraftTag.value,
|
||||
new: true,
|
||||
})
|
||||
}
|
||||
this.clearDraftTag();
|
||||
},
|
||||
clearDraftTag() {
|
||||
this.newDraftTag = {
|
||||
name: null,
|
||||
select: null,
|
||||
color: null,
|
||||
value: "",
|
||||
invalid: true,
|
||||
nameInvalid: false,
|
||||
};
|
||||
this.modal.hide();
|
||||
},
|
||||
addTagAsync(newTag) {
|
||||
return new Promise((resolve) => {
|
||||
this.$root.getSocket().emit("addTag", newTag, resolve);
|
||||
});
|
||||
},
|
||||
addMonitorTagAsync(tagId, monitorId, value) {
|
||||
return new Promise((resolve) => {
|
||||
this.$root.getSocket().emit("addMonitorTag", tagId, monitorId, value, resolve);
|
||||
});
|
||||
},
|
||||
deleteMonitorTagAsync(tagId, monitorId, value) {
|
||||
return new Promise((resolve) => {
|
||||
this.$root.getSocket().emit("deleteMonitorTag", tagId, monitorId, value, resolve);
|
||||
});
|
||||
},
|
||||
onEnter() {
|
||||
if (!this.validateDraftTag.invalid) {
|
||||
this.addDraftTag();
|
||||
}
|
||||
},
|
||||
async submit(monitorId) {
|
||||
console.log(`Submitting tag changes for monitor ${monitorId}...`);
|
||||
this.processing = true;
|
||||
|
||||
for (const newTag of this.newTags) {
|
||||
let tagId;
|
||||
if (newTag.id == null) {
|
||||
// Create a New Tag
|
||||
let newTagResult;
|
||||
await this.addTagAsync(newTag).then((res) => {
|
||||
if (!res.ok) {
|
||||
toast.error(res.msg);
|
||||
newTagResult = false;
|
||||
}
|
||||
newTagResult = res.tag;
|
||||
});
|
||||
if (!newTagResult) {
|
||||
// abort
|
||||
this.processing = false;
|
||||
return;
|
||||
}
|
||||
tagId = newTagResult.id;
|
||||
// Assign the new ID to the tags of the same name & color
|
||||
this.newTags.map(tag => {
|
||||
if (tag.name == newTag.name && tag.color == newTag.color) {
|
||||
tag.id = newTagResult.id;
|
||||
}
|
||||
})
|
||||
} else {
|
||||
tagId = newTag.id;
|
||||
}
|
||||
|
||||
let newMonitorTagResult;
|
||||
// Assign tag to monitor
|
||||
await this.addMonitorTagAsync(tagId, monitorId, newTag.value).then((res) => {
|
||||
if (!res.ok) {
|
||||
toast.error(res.msg);
|
||||
newMonitorTagResult = false;
|
||||
}
|
||||
newMonitorTagResult = true;
|
||||
});
|
||||
if (!newMonitorTagResult) {
|
||||
// abort
|
||||
this.processing = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const deleteTag of this.deleteTags) {
|
||||
let deleteMonitorTagResult;
|
||||
await this.deleteMonitorTagAsync(deleteTag.tag_id, deleteTag.monitor_id, deleteTag.value).then((res) => {
|
||||
if (!res.ok) {
|
||||
toast.error(res.msg);
|
||||
deleteMonitorTagResult = false;
|
||||
}
|
||||
deleteMonitorTagResult = true;
|
||||
});
|
||||
if (!deleteMonitorTagResult) {
|
||||
// abort
|
||||
this.processing = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.getExistingTags();
|
||||
this.newTags = [];
|
||||
this.deleteTags = [];
|
||||
this.processing = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btn-add {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
{{ $t("Setup 2FA") }}
|
||||
<span v-if="twoFAStatus == true" class="badge bg-primary">{{ $t("Active") }}</span>
|
||||
<span v-if="twoFAStatus == false" class="badge bg-primary">{{ $t("Inactive") }}</span>
|
||||
</h5>
|
||||
<button :disabled="processing" type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<div v-if="uri && twoFAStatus == false" class="mx-auto text-center" style="width: 210px;">
|
||||
<vue-qrcode :key="uri" :value="uri" type="image/png" :quality="1" :color="{ light: '#ffffffff' }" />
|
||||
<button v-show="!showURI" type="button" class="btn btn-outline-primary btn-sm mt-2" @click="showURI = true">{{ $t("Show URI") }}</button>
|
||||
</div>
|
||||
<p v-if="showURI && twoFAStatus == false" class="text-break mt-2">{{ uri }}</p>
|
||||
|
||||
<button v-if="uri == null && twoFAStatus == false" class="btn btn-primary" type="button" @click="prepare2FA()">
|
||||
{{ $t("Enable 2FA") }}
|
||||
</button>
|
||||
|
||||
<button v-if="twoFAStatus == true" class="btn btn-danger" type="button" :disabled="processing" @click="confirmDisableTwoFA()">
|
||||
{{ $t("Disable 2FA") }}
|
||||
</button>
|
||||
|
||||
<div v-if="uri && twoFAStatus == false" class="mt-3">
|
||||
<label for="basic-url" class="form-label">{{ $t("twoFAVerifyLabel") }}</label>
|
||||
<div class="input-group">
|
||||
<input v-model="token" type="text" maxlength="6" class="form-control">
|
||||
<button class="btn btn-outline-primary" type="button" @click="verifyToken()">{{ $t("Verify Token") }}</button>
|
||||
</div>
|
||||
<p v-show="tokenValid" class="mt-2" style="color: green">{{ $t("tokenValidSettingsMsg") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="uri && twoFAStatus == false" class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary" :disabled="processing || tokenValid == false" @click="confirmEnableTwoFA()">
|
||||
<div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
|
||||
{{ $t("Save") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Confirm ref="confirmEnableTwoFA" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="save2FA">
|
||||
{{ $t("confirmEnableTwoFAMsg") }}
|
||||
</Confirm>
|
||||
|
||||
<Confirm ref="confirmDisableTwoFA" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="disable2FA">
|
||||
{{ $t("confirmDisableTwoFAMsg") }}
|
||||
</Confirm>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Modal } from "bootstrap"
|
||||
import Confirm from "./Confirm.vue";
|
||||
import VueQrcode from "vue-qrcode"
|
||||
import { useToast } from "vue-toastification"
|
||||
const toast = useToast()
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Confirm,
|
||||
VueQrcode,
|
||||
},
|
||||
props: {},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
uri: null,
|
||||
tokenValid: false,
|
||||
twoFAStatus: null,
|
||||
token: null,
|
||||
showURI: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.modal = new Modal(this.$refs.modal)
|
||||
this.getStatus();
|
||||
},
|
||||
methods: {
|
||||
show() {
|
||||
this.modal.show()
|
||||
},
|
||||
|
||||
confirmEnableTwoFA() {
|
||||
this.$refs.confirmEnableTwoFA.show()
|
||||
},
|
||||
|
||||
confirmDisableTwoFA() {
|
||||
this.$refs.confirmDisableTwoFA.show()
|
||||
},
|
||||
|
||||
prepare2FA() {
|
||||
this.processing = true;
|
||||
|
||||
this.$root.getSocket().emit("prepare2FA", (res) => {
|
||||
this.processing = false;
|
||||
|
||||
if (res.ok) {
|
||||
this.uri = res.uri;
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
save2FA() {
|
||||
this.processing = true;
|
||||
|
||||
this.$root.getSocket().emit("save2FA", (res) => {
|
||||
this.processing = false;
|
||||
|
||||
if (res.ok) {
|
||||
this.$root.toastRes(res)
|
||||
this.getStatus();
|
||||
this.modal.hide();
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
disable2FA() {
|
||||
this.processing = true;
|
||||
|
||||
this.$root.getSocket().emit("disable2FA", (res) => {
|
||||
this.processing = false;
|
||||
|
||||
if (res.ok) {
|
||||
this.$root.toastRes(res)
|
||||
this.getStatus();
|
||||
this.modal.hide();
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
verifyToken() {
|
||||
this.$root.getSocket().emit("verifyToken", this.token, (res) => {
|
||||
if (res.ok) {
|
||||
this.tokenValid = res.valid;
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
getStatus() {
|
||||
this.$root.getSocket().emit("twoFAStatus", (res) => {
|
||||
if (res.ok) {
|
||||
this.twoFAStatus = res.status;
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../assets/vars.scss";
|
||||
|
||||
.dark {
|
||||
.modal-dialog .form-text, .modal-dialog p {
|
||||
color: $dark-font-color;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label for="teams-webhookurl" class="form-label">Webhook URL</label>
|
||||
<input
|
||||
id="teams-webhookurl"
|
||||
v-model="$parent.notification.webhookUrl"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
<div class="form-text">
|
||||
You can learn how to create a webhook url
|
||||
<a
|
||||
href="https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook"
|
||||
target="_blank"
|
||||
>here</a>.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
name: "teams",
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
@ -0,0 +1,50 @@
|
||||
import { createI18n } from "vue-i18n";
|
||||
import daDK from "./languages/da-DK";
|
||||
import deDE from "./languages/de-DE";
|
||||
import en from "./languages/en";
|
||||
import esEs from "./languages/es-ES";
|
||||
import ptBR from "./languages/pt-BR";
|
||||
import etEE from "./languages/et-EE";
|
||||
import frFR from "./languages/fr-FR";
|
||||
import itIT from "./languages/it-IT";
|
||||
import ja from "./languages/ja";
|
||||
import koKR from "./languages/ko-KR";
|
||||
import nlNL from "./languages/nl-NL";
|
||||
import pl from "./languages/pl";
|
||||
import ruRU from "./languages/ru-RU";
|
||||
import sr from "./languages/sr";
|
||||
import srLatn from "./languages/sr-latn";
|
||||
import trTR from "./languages/tr-TR";
|
||||
import svSE from "./languages/sv-SE";
|
||||
import zhCN from "./languages/zh-CN";
|
||||
import zhHK from "./languages/zh-HK";
|
||||
|
||||
const languageList = {
|
||||
en,
|
||||
"zh-HK": zhHK,
|
||||
"de-DE": deDE,
|
||||
"nl-NL": nlNL,
|
||||
"es-ES": esEs,
|
||||
"pt-BR": ptBR,
|
||||
"fr-FR": frFR,
|
||||
"it-IT": itIT,
|
||||
"ja": ja,
|
||||
"da-DK": daDK,
|
||||
"sr": sr,
|
||||
"sr-latn": srLatn,
|
||||
"sv-SE": svSE,
|
||||
"tr-TR": trTR,
|
||||
"ko-KR": koKR,
|
||||
"ru-RU": ruRU,
|
||||
"zh-CN": zhCN,
|
||||
"pl": pl,
|
||||
"et-EE": etEE,
|
||||
};
|
||||
|
||||
export const i18n = createI18n({
|
||||
locale: localStorage.locale || "en",
|
||||
fallbackLocale: "en",
|
||||
silentFallbackWarn: true,
|
||||
silentTranslationWarn: true,
|
||||
messages: languageList,
|
||||
});
|
@ -0,0 +1,182 @@
|
||||
export default {
|
||||
languageName: "Português (Brasileiro)",
|
||||
checkEverySecond: "Verificar cada {0} segundos.",
|
||||
retryCheckEverySecond: "Tentar novamente a cada {0} segundos.",
|
||||
retriesDescription: "Máximo de tentativas antes que o serviço seja marcado como inativo e uma notificação seja enviada",
|
||||
ignoreTLSError: "Ignorar erros TLS/SSL para sites HTTPS",
|
||||
upsideDownModeDescription: "Inverta o status de cabeça para baixo. Se o serviço estiver acessível, ele está OFFLINE.",
|
||||
maxRedirectDescription: "Número máximo de redirecionamentos a seguir. Defina como 0 para desativar redirecionamentos.",
|
||||
acceptedStatusCodesDescription: "Selecione os códigos de status que são considerados uma resposta bem-sucedida.",
|
||||
passwordNotMatchMsg: "A senha repetida não corresponde.",
|
||||
notificationDescription: "Atribua uma notificação ao (s) monitor (es) para que funcione.",
|
||||
keywordDescription: "Pesquise a palavra-chave em html simples ou resposta JSON e diferencia maiúsculas de minúsculas",
|
||||
pauseDashboardHome: "Pausar",
|
||||
deleteMonitorMsg: "Tem certeza de que deseja excluir este monitor?",
|
||||
deleteNotificationMsg: "Tem certeza de que deseja excluir esta notificação para todos os monitores?",
|
||||
resoverserverDescription: "Cloudflare é o servidor padrão, você pode alterar o servidor resolvedor a qualquer momento.",
|
||||
rrtypeDescription: "Selecione o RR-Type que você deseja monitorar",
|
||||
pauseMonitorMsg: "Tem certeza que deseja fazer uma pausa?",
|
||||
enableDefaultNotificationDescription: "Para cada novo monitor, esta notificação será habilitada por padrão. Você ainda pode desativar a notificação separadamente para cada monitor.",
|
||||
clearEventsMsg: "Tem certeza de que deseja excluir todos os eventos deste monitor?",
|
||||
clearHeartbeatsMsg: "Tem certeza de que deseja excluir todos os heartbeats deste monitor?",
|
||||
confirmClearStatisticsMsg: "Tem certeza que deseja excluir TODAS as estatísticas?",
|
||||
importHandleDescription: "Escolha 'Ignorar existente' se quiser ignorar todos os monitores ou notificações com o mesmo nome. 'Substituir' excluirá todos os monitores e notificações existentes.",
|
||||
confirmImportMsg: "Tem certeza que deseja importar o backup? Certifique-se de que selecionou a opção de importação correta.",
|
||||
twoFAVerifyLabel: "Digite seu token para verificar se 2FA está funcionando",
|
||||
tokenValidSettingsMsg: "O token é válido! Agora você pode salvar as configurações 2FA.",
|
||||
confirmEnableTwoFAMsg: "Tem certeza de que deseja habilitar 2FA?",
|
||||
confirmDisableTwoFAMsg: "Tem certeza de que deseja desativar 2FA?",
|
||||
Settings: "Configurações",
|
||||
Dashboard: "Dashboard",
|
||||
"New Update": "Nova Atualização",
|
||||
Language: "Linguagem",
|
||||
Appearance: "Aparência",
|
||||
Theme: "Tema",
|
||||
General: "Geral",
|
||||
Version: "Versão",
|
||||
"Check Update On GitHub": "Verificar atualização no Github",
|
||||
List: "Lista",
|
||||
Add: "Adicionar",
|
||||
"Add New Monitor": "Adicionar novo monitor",
|
||||
"Quick Stats": "Estatísticas rápidas",
|
||||
Up: "On",
|
||||
Down: "Off",
|
||||
Pending: "Pendente",
|
||||
Unknown: "Desconhecido",
|
||||
Pause: "Pausar",
|
||||
Name: "Nome",
|
||||
Status: "Status",
|
||||
DateTime: "Data hora",
|
||||
Message: "Mensagem",
|
||||
"No important events": "Nenhum evento importante",
|
||||
Resume: "Resumo",
|
||||
Edit: "Editar",
|
||||
Delete: "Deletar",
|
||||
Current: "Atual",
|
||||
Uptime: "Tempo de atividade",
|
||||
"Cert Exp.": "Cert Exp.",
|
||||
days: "dias",
|
||||
day: "dia",
|
||||
"-day": "-dia",
|
||||
hour: "hora",
|
||||
"-hour": "-hora",
|
||||
Response: "Resposta",
|
||||
Ping: "Ping",
|
||||
"Monitor Type": "Tipo de Monitor",
|
||||
Keyword: "Palavra-Chave",
|
||||
"Friendly Name": "Nome Amigável",
|
||||
URL: "URL",
|
||||
Hostname: "Hostname",
|
||||
Port: "Porta",
|
||||
"Heartbeat Interval": "Intervalo de Heartbeat",
|
||||
Retries: "Novas tentativas",
|
||||
"Heartbeat Retry Interval": "Intervalo de repetição de Heartbeat",
|
||||
Advanced: "Avançado",
|
||||
"Upside Down Mode": "Modo de cabeça para baixo",
|
||||
"Max. Redirects": "Redirecionamento Máx.",
|
||||
"Accepted Status Codes": "Status Code Aceitáveis",
|
||||
Save: "Salvar",
|
||||
Notifications: "Notificações",
|
||||
"Not available, please setup.": "Não disponível, por favor configure.",
|
||||
"Setup Notification": "Configurar Notificação",
|
||||
Light: "Claro",
|
||||
Dark: "Escuro",
|
||||
Auto: "Auto",
|
||||
"Theme - Heartbeat Bar": "Tema - Barra de Heartbeat",
|
||||
Normal: "Normal",
|
||||
Bottom: "Inferior",
|
||||
None: "Nenhum",
|
||||
Timezone: "Fuso horário",
|
||||
"Search Engine Visibility": "Visibilidade do mecanismo de pesquisa",
|
||||
"Allow indexing": "Permitir Indexação",
|
||||
"Discourage search engines from indexing site": "Desencoraje os motores de busca de indexar o site",
|
||||
"Change Password": "Mudar senha",
|
||||
"Current Password": "Senha atual",
|
||||
"New Password": "Nova Senha",
|
||||
"Repeat New Password": "Repetir Nova Senha",
|
||||
"Update Password": "Atualizar Senha",
|
||||
"Disable Auth": "Desativar Autenticação",
|
||||
"Enable Auth": "Ativar Autenticação",
|
||||
Logout: "Deslogar",
|
||||
Leave: "Sair",
|
||||
"I understand, please disable": "Eu entendo, por favor desative.",
|
||||
Confirm: "Confirmar",
|
||||
Yes: "Sim",
|
||||
No: "Não",
|
||||
Username: "Usuário",
|
||||
Password: "Senha",
|
||||
"Remember me": "Lembre-me",
|
||||
Login: "Autenticar",
|
||||
"No Monitors, please": "Nenhum monitor, por favor",
|
||||
"add one": "adicionar um",
|
||||
"Notification Type": "Tipo de Notificação",
|
||||
Email: "Email",
|
||||
Test: "Testar",
|
||||
"Certificate Info": "Info. do Certificado ",
|
||||
"Resolver Server": "Resolver Servidor",
|
||||
"Resource Record Type": "Tipo de registro de aplicação",
|
||||
"Last Result": "Último resultado",
|
||||
"Create your admin account": "Crie sua conta de admin",
|
||||
"Repeat Password": "Repita a senha",
|
||||
"Import Backup": "Importar Backup",
|
||||
"Export Backup": "Exportar Backup",
|
||||
Export: "Exportar",
|
||||
Import: "Importar",
|
||||
respTime: "Tempo de Resp. (ms)",
|
||||
notAvailableShort: "N/A",
|
||||
"Default enabled": "Padrão habilitado",
|
||||
"Apply on all existing monitors": "Aplicar em todos os monitores existentes",
|
||||
Create: "Criar",
|
||||
"Clear Data": "Limpar Dados",
|
||||
Events: "Eventos",
|
||||
Heartbeats: "Heartbeats",
|
||||
"Auto Get": "Obter Automático",
|
||||
backupDescription: "Você pode fazer backup de todos os monitores e todas as notificações em um arquivo JSON.",
|
||||
backupDescription2: "OBS: Os dados do histórico e do evento não estão incluídos.",
|
||||
backupDescription3: "Dados confidenciais, como tokens de notificação, estão incluídos no arquivo de exportação, mantenha-o com cuidado.",
|
||||
alertNoFile: "Selecione um arquivo para importar.",
|
||||
alertWrongFileType: "Selecione um arquivo JSON.",
|
||||
"Clear all statistics": "Limpar todas as estatísticas",
|
||||
"Skip existing": "Pular existente",
|
||||
Overwrite: "Sobrescrever",
|
||||
Options: "Opções",
|
||||
"Keep both": "Manter os dois",
|
||||
"Verify Token": "Verificar Token",
|
||||
"Setup 2FA": "Configurar 2FA",
|
||||
"Enable 2FA": "Ativar 2FA",
|
||||
"Disable 2FA": "Desativar 2FA",
|
||||
"2FA Settings": "Configurações do 2FA ",
|
||||
"Two Factor Authentication": "Autenticação e Dois Fatores",
|
||||
Active: "Ativo",
|
||||
Inactive: "Inativo",
|
||||
Token: "Token",
|
||||
"Show URI": "Mostrar URI",
|
||||
Tags: "Tag",
|
||||
"Add New below or Select...": "Adicionar Novo abaixo ou Selecionar ...",
|
||||
"Tag with this name already exist.": "Já existe uma etiqueta com este nome.",
|
||||
"Tag with this value already exist.": "Já existe uma etiqueta com este valor.",
|
||||
color: "cor",
|
||||
"value (optional)": "valor (opcional)",
|
||||
Gray: "Cinza",
|
||||
Red: "Vermelho",
|
||||
Orange: "Laranja",
|
||||
Green: "Verde",
|
||||
Blue: "Azul",
|
||||
Indigo: "Índigo",
|
||||
Purple: "Roxo",
|
||||
Pink: "Rosa",
|
||||
"Search...": "Buscar...",
|
||||
"Avg. Ping": "Ping Médio.",
|
||||
"Avg. Response": "Resposta Média. ",
|
||||
"Status Page": "Página de Status",
|
||||
"Entry Page": "Página de entrada",
|
||||
"statusPageNothing": "Nada aqui, por favor, adicione um grupo ou monitor.",
|
||||
"No Services": "Nenhum Serviço",
|
||||
"All Systems Operational": "Todos os Serviços Operacionais",
|
||||
"Partially Degraded Service": "Serviço parcialmente degradado",
|
||||
"Degraded Service": "Serviço Degradado",
|
||||
"Add Group": "Adicionar Grupo",
|
||||
"Add a monitor": "Adicionar um monitor",
|
||||
"Edit Status Page": "Editar Página de Status",
|
||||
"Go to Dashboard": "Ir para a dashboard",
|
||||
};
|
@ -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;
|
||||
},
|
||||
}
|
||||
};
|
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
|
||||
export default {
|
||||
async mounted() {
|
||||
let entryPage = (await axios.get("/api/entry-page")).data;
|
||||
|
||||
if (entryPage === "statusPage") {
|
||||
this.$router.push("/status");
|
||||
} else {
|
||||
this.$router.push("/dashboard");
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
</script>
|
@ -0,0 +1,653 @@
|
||||
<template>
|
||||
<div v-if="loadedTheme" class="container mt-3">
|
||||
<!-- Logo & Title -->
|
||||
<h1 class="mb-4">
|
||||
<!-- Logo -->
|
||||
<span class="logo-wrapper" @click="showImageCropUploadMethod">
|
||||
<img :src="logoURL" alt class="logo me-2" :class="logoClass" />
|
||||
<font-awesome-icon v-if="enableEditMode" class="icon-upload" icon="upload" />
|
||||
</span>
|
||||
|
||||
<!-- Uploader -->
|
||||
<!-- url="/api/status-page/upload-logo" -->
|
||||
<ImageCropUpload v-model="showImageCropUpload"
|
||||
field="img"
|
||||
:width="128"
|
||||
:height="128"
|
||||
:langType="$i18n.locale"
|
||||
img-format="png"
|
||||
:noCircle="true"
|
||||
:noSquare="false"
|
||||
@crop-success="cropSuccess"
|
||||
/>
|
||||
|
||||
<!-- Title -->
|
||||
<Editable v-model="config.title" tag="span" :contenteditable="editMode" :noNL="true" />
|
||||
</h1>
|
||||
|
||||
<!-- Admin functions -->
|
||||
<div v-if="hasToken" class="mb-4">
|
||||
<div v-if="!enableEditMode">
|
||||
<button class="btn btn-info me-2" @click="edit">
|
||||
<font-awesome-icon icon="edit" />
|
||||
{{ $t("Edit Status Page") }}
|
||||
</button>
|
||||
|
||||
<a href="/dashboard" class="btn btn-info">
|
||||
<font-awesome-icon icon="tachometer-alt" />
|
||||
{{ $t("Go to Dashboard") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<button class="btn btn-success me-2" @click="save">
|
||||
<font-awesome-icon icon="save" />
|
||||
{{ $t("Save") }}
|
||||
</button>
|
||||
|
||||
<button class="btn btn-danger me-2" @click="discard">
|
||||
<font-awesome-icon icon="save" />
|
||||
{{ $t("Discard") }}
|
||||
</button>
|
||||
|
||||
<button class="btn btn-primary btn-add-group me-2" @click="createIncident">
|
||||
<font-awesome-icon icon="bullhorn" />
|
||||
{{ $t("Create Incident") }}
|
||||
</button>
|
||||
|
||||
<!--
|
||||
<button v-if="isPublished" class="btn btn-light me-2" @click="">
|
||||
<font-awesome-icon icon="save" />
|
||||
{{ $t("Unpublish") }}
|
||||
</button>
|
||||
|
||||
<button v-if="!isPublished" class="btn btn-info me-2" @click="">
|
||||
<font-awesome-icon icon="save" />
|
||||
{{ $t("Publish") }}
|
||||
</button>-->
|
||||
|
||||
<!-- Set Default Language -->
|
||||
<!-- Set theme -->
|
||||
<button v-if="theme == 'dark'" class="btn btn-light me-2" @click="changeTheme('light')">
|
||||
<font-awesome-icon icon="save" />
|
||||
{{ $t("Switch to Light Theme") }}
|
||||
</button>
|
||||
|
||||
<button v-if="theme == 'light'" class="btn btn-dark me-2" @click="changeTheme('dark')">
|
||||
<font-awesome-icon icon="save" />
|
||||
{{ $t("Switch to Dark Theme") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incident -->
|
||||
<div v-if="incident !== null" class="shadow-box alert mb-4 p-4 incident" role="alert" :class="incidentClass">
|
||||
<strong v-if="editIncidentMode">{{ $t("Title") }}:</strong>
|
||||
<Editable v-model="incident.title" tag="h4" :contenteditable="editIncidentMode" :noNL="true" class="alert-heading" />
|
||||
|
||||
<strong v-if="editIncidentMode">{{ $t("Content") }}:</strong>
|
||||
<Editable v-model="incident.content" tag="div" :contenteditable="editIncidentMode" class="content" />
|
||||
|
||||
<!-- Incident Date -->
|
||||
<div class="date mt-3">
|
||||
Created: {{ incident.createdDate }} ({{ createdDateFromNow }})<br />
|
||||
<span v-if="incident.lastUpdatedDate">
|
||||
Last Updated: {{ incident.lastUpdatedDate }} ({{ lastUpdatedDateFromNow }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="editMode" class="mt-3">
|
||||
<button v-if="editIncidentMode" class="btn btn-light me-2" @click="postIncident">
|
||||
<font-awesome-icon icon="bullhorn" />
|
||||
{{ $t("Post") }}
|
||||
</button>
|
||||
|
||||
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="editIncident">
|
||||
<font-awesome-icon icon="edit" />
|
||||
{{ $t("Edit") }}
|
||||
</button>
|
||||
|
||||
<button v-if="editIncidentMode" class="btn btn-light me-2" @click="cancelIncident">
|
||||
<font-awesome-icon icon="times" />
|
||||
{{ $t("Cancel") }}
|
||||
</button>
|
||||
|
||||
<div v-if="editIncidentMode" class="dropdown d-inline-block me-2">
|
||||
<button id="dropdownMenuButton1" class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Style: {{ incident.style }}
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
|
||||
<li><a class="dropdown-item" href="#" @click="incident.style = 'info'">info</a></li>
|
||||
<li><a class="dropdown-item" href="#" @click="incident.style = 'warning'">warning</a></li>
|
||||
<li><a class="dropdown-item" href="#" @click="incident.style = 'danger'">danger</a></li>
|
||||
<li><a class="dropdown-item" href="#" @click="incident.style = 'primary'">primary</a></li>
|
||||
<li><a class="dropdown-item" href="#" @click="incident.style = 'light'">light</a></li>
|
||||
<li><a class="dropdown-item" href="#" @click="incident.style = 'dark'">dark</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button v-if="!editIncidentMode && incident.id" class="btn btn-light me-2" @click="unpinIncident">
|
||||
<font-awesome-icon icon="unlink" />
|
||||
{{ $t("Unpin") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overall Status -->
|
||||
<div class="shadow-box list p-4 overall-status mb-4">
|
||||
<div v-if="Object.keys($root.publicMonitorList).length === 0 && loadedData">
|
||||
<font-awesome-icon icon="question-circle" class="ok" />
|
||||
{{ $t("No Services") }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="allUp">
|
||||
<font-awesome-icon icon="check-circle" class="ok" />
|
||||
{{ $t("All Systems Operational") }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="partialDown">
|
||||
<font-awesome-icon icon="exclamation-circle" class="warning" />
|
||||
{{ $t("Partially Degraded Service") }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="allDown">
|
||||
<font-awesome-icon icon="times-circle" class="danger" />
|
||||
{{ $t("Degraded Service") }}
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<font-awesome-icon icon="question-circle" style="color: #efefef" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<strong v-if="editMode">{{ $t("Description") }}:</strong>
|
||||
<Editable v-model="config.description" :contenteditable="editMode" tag="div" class="mb-4 description" />
|
||||
|
||||
<div v-if="editMode" class="mb-4">
|
||||
<div>
|
||||
<button class="btn btn-primary btn-add-group me-2" @click="addGroup">
|
||||
<font-awesome-icon icon="plus" />
|
||||
{{ $t("Add Group") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div v-if="allMonitorList.length > 0 && loadedData">
|
||||
<label>{{ $t("Add a monitor") }}:</label>
|
||||
<select v-model="selectedMonitor" class="form-control">
|
||||
<option v-for="monitor in allMonitorList" :key="monitor.id" :value="monitor">{{ monitor.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-else class="text-center">
|
||||
{{ $t("No monitors available.") }} <router-link to="/add">{{ $t("Add one") }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div v-if="$root.publicGroupList.length === 0 && loadedData" class="text-center">
|
||||
<!-- 👀 Nothing here, please add a group or a monitor. -->
|
||||
👀 {{ $t("statusPageNothing") }}
|
||||
</div>
|
||||
|
||||
<PublicGroupList :edit-mode="enableEditMode" />
|
||||
</div>
|
||||
|
||||
<footer class="mt-5 mb-4">
|
||||
Powered by <a target="_blank" href="https://github.com/louislam/uptime-kuma">Uptime Kuma</a>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import PublicGroupList from "../components/PublicGroupList.vue";
|
||||
import ImageCropUpload from "vue-image-crop-upload";
|
||||
import { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_PARTIAL_DOWN, UP } from "../util.ts";
|
||||
import { useToast } from "vue-toastification";
|
||||
import dayjs from "dayjs";
|
||||
const toast = useToast();
|
||||
|
||||
const leavePageMsg = "Do you really want to leave? you have unsaved changes!";
|
||||
|
||||
let feedInterval;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PublicGroupList,
|
||||
ImageCropUpload
|
||||
},
|
||||
|
||||
// Leave Page for vue route change
|
||||
beforeRouteLeave(to, from, next) {
|
||||
if (this.editMode) {
|
||||
const answer = window.confirm(leavePageMsg);
|
||||
if (answer) {
|
||||
next();
|
||||
} else {
|
||||
next(false);
|
||||
}
|
||||
}
|
||||
next();
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
enableEditMode: false,
|
||||
enableEditIncidentMode: false,
|
||||
hasToken: false,
|
||||
config: {},
|
||||
selectedMonitor: null,
|
||||
incident: null,
|
||||
previousIncident: null,
|
||||
showImageCropUpload: false,
|
||||
imgDataUrl: "/icon.svg",
|
||||
loadedTheme: false,
|
||||
loadedData: false,
|
||||
baseURL: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
||||
logoURL() {
|
||||
if (this.imgDataUrl.startsWith("data:")) {
|
||||
return this.imgDataUrl;
|
||||
} else {
|
||||
return this.baseURL + this.imgDataUrl;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* If the monitor is added to public list, which will not be in this list.
|
||||
*/
|
||||
allMonitorList() {
|
||||
let result = [];
|
||||
|
||||
for (let id in this.$root.monitorList) {
|
||||
if (this.$root.monitorList[id] && ! (id in this.$root.publicMonitorList)) {
|
||||
let monitor = this.$root.monitorList[id];
|
||||
result.push(monitor);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
editMode() {
|
||||
return this.enableEditMode && this.$root.socket.connected;
|
||||
},
|
||||
|
||||
editIncidentMode() {
|
||||
return this.enableEditIncidentMode;
|
||||
},
|
||||
|
||||
isPublished() {
|
||||
return this.config.statusPagePublished;
|
||||
},
|
||||
|
||||
theme() {
|
||||
return this.config.statusPageTheme;
|
||||
},
|
||||
|
||||
logoClass() {
|
||||
if (this.editMode) {
|
||||
return {
|
||||
"edit-mode": true,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
incidentClass() {
|
||||
return "bg-" + this.incident.style;
|
||||
},
|
||||
|
||||
overallStatus() {
|
||||
|
||||
if (Object.keys(this.$root.publicLastHeartbeatList).length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
let status = STATUS_PAGE_ALL_UP;
|
||||
let hasUp = false;
|
||||
|
||||
for (let id in this.$root.publicLastHeartbeatList) {
|
||||
let beat = this.$root.publicLastHeartbeatList[id];
|
||||
|
||||
if (beat.status === UP) {
|
||||
hasUp = true;
|
||||
} else {
|
||||
status = STATUS_PAGE_PARTIAL_DOWN;
|
||||
}
|
||||
}
|
||||
|
||||
if (! hasUp) {
|
||||
status = STATUS_PAGE_ALL_DOWN;
|
||||
}
|
||||
|
||||
return status;
|
||||
},
|
||||
|
||||
allUp() {
|
||||
return this.overallStatus === STATUS_PAGE_ALL_UP;
|
||||
},
|
||||
|
||||
partialDown() {
|
||||
return this.overallStatus === STATUS_PAGE_PARTIAL_DOWN;
|
||||
},
|
||||
|
||||
allDown() {
|
||||
return this.overallStatus === STATUS_PAGE_ALL_DOWN;
|
||||
},
|
||||
|
||||
createdDateFromNow() {
|
||||
return dayjs.utc(this.incident.createdDate).fromNow();
|
||||
},
|
||||
|
||||
lastUpdatedDateFromNow() {
|
||||
return dayjs.utc(this.incident. lastUpdatedDate).fromNow();
|
||||
}
|
||||
|
||||
},
|
||||
watch: {
|
||||
|
||||
/**
|
||||
* Selected a monitor and add to the list.
|
||||
*/
|
||||
selectedMonitor(monitor) {
|
||||
if (monitor) {
|
||||
if (this.$root.publicGroupList.length === 0) {
|
||||
this.addGroup();
|
||||
}
|
||||
|
||||
const firstGroup = this.$root.publicGroupList[0];
|
||||
|
||||
firstGroup.monitorList.push(monitor);
|
||||
this.selectedMonitor = null;
|
||||
}
|
||||
},
|
||||
|
||||
// Set Theme
|
||||
"config.statusPageTheme"() {
|
||||
this.$root.statusPageTheme = this.config.statusPageTheme;
|
||||
this.loadedTheme = true;
|
||||
},
|
||||
|
||||
"config.title"(title) {
|
||||
document.title = title;
|
||||
}
|
||||
|
||||
},
|
||||
async created() {
|
||||
this.hasToken = ("token" in this.$root.storage());
|
||||
|
||||
// Browser change page
|
||||
// https://stackoverflow.com/questions/7317273/warn-user-before-leaving-web-page-with-unsaved-changes
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (this.editMode) {
|
||||
(e || window.event).returnValue = leavePageMsg;
|
||||
return leavePageMsg;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Special handle for dev
|
||||
const env = process.env.NODE_ENV;
|
||||
if (env === "development" || localStorage.dev === "dev") {
|
||||
this.baseURL = location.protocol + "//" + location.hostname + ":3001";
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
axios.get("/api/status-page/config").then((res) => {
|
||||
this.config = res.data;
|
||||
|
||||
if (this.config.logo) {
|
||||
this.imgDataUrl = this.config.logo;
|
||||
}
|
||||
});
|
||||
|
||||
axios.get("/api/status-page/incident").then((res) => {
|
||||
if (res.data.ok) {
|
||||
this.incident = res.data.incident;
|
||||
}
|
||||
});
|
||||
|
||||
axios.get("/api/status-page/monitor-list").then((res) => {
|
||||
this.$root.publicGroupList = res.data;
|
||||
});
|
||||
|
||||
// 5mins a loop
|
||||
this.updateHeartbeatList();
|
||||
feedInterval = setInterval(() => {
|
||||
this.updateHeartbeatList();
|
||||
}, (300 + 10) * 1000);
|
||||
},
|
||||
methods: {
|
||||
|
||||
updateHeartbeatList() {
|
||||
// If editMode, it will use the data from websocket.
|
||||
if (! this.editMode) {
|
||||
axios.get("/api/status-page/heartbeat").then((res) => {
|
||||
this.$root.heartbeatList = res.data.heartbeatList;
|
||||
this.$root.uptimeList = res.data.uptimeList;
|
||||
this.loadedData = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
edit() {
|
||||
this.$root.initSocketIO(true);
|
||||
this.enableEditMode = true;
|
||||
},
|
||||
|
||||
save() {
|
||||
this.$root.getSocket().emit("saveStatusPage", this.config, this.imgDataUrl, this.$root.publicGroupList, (res) => {
|
||||
if (res.ok) {
|
||||
this.enableEditMode = false;
|
||||
this.$root.publicGroupList = res.publicGroupList;
|
||||
location.reload();
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
monitorSelectorLabel(monitor) {
|
||||
return `${monitor.name}`;
|
||||
},
|
||||
|
||||
addGroup() {
|
||||
let groupName = "Untitled Group";
|
||||
|
||||
if (this.$root.publicGroupList.length === 0) {
|
||||
groupName = "Services";
|
||||
}
|
||||
|
||||
this.$root.publicGroupList.push({
|
||||
name: groupName,
|
||||
monitorList: [],
|
||||
});
|
||||
},
|
||||
|
||||
discard() {
|
||||
location.reload();
|
||||
},
|
||||
|
||||
changeTheme(name) {
|
||||
this.config.statusPageTheme = name;
|
||||
},
|
||||
|
||||
/**
|
||||
* Crop Success
|
||||
*/
|
||||
cropSuccess(imgDataUrl) {
|
||||
this.imgDataUrl = imgDataUrl;
|
||||
},
|
||||
|
||||
showImageCropUploadMethod() {
|
||||
if (this.editMode) {
|
||||
this.showImageCropUpload = true;
|
||||
}
|
||||
},
|
||||
|
||||
createIncident() {
|
||||
this.enableEditIncidentMode = true;
|
||||
|
||||
if (this.incident) {
|
||||
this.previousIncident = this.incident;
|
||||
}
|
||||
|
||||
this.incident = {
|
||||
title: "",
|
||||
content: "",
|
||||
style: "primary",
|
||||
};
|
||||
},
|
||||
|
||||
postIncident() {
|
||||
if (this.incident.title == "" || this.incident.content == "") {
|
||||
toast.error("Please input title and content.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.$root.getSocket().emit("postIncident", this.incident, (res) => {
|
||||
|
||||
if (res.ok) {
|
||||
this.enableEditIncidentMode = false;
|
||||
this.incident = res.incident;
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Click Edit Button
|
||||
*/
|
||||
editIncident() {
|
||||
this.enableEditIncidentMode = true;
|
||||
this.previousIncident = Object.assign({}, this.incident);
|
||||
},
|
||||
|
||||
cancelIncident() {
|
||||
this.enableEditIncidentMode = false;
|
||||
|
||||
if (this.previousIncident) {
|
||||
this.incident = this.previousIncident;
|
||||
this.previousIncident = null;
|
||||
}
|
||||
},
|
||||
|
||||
unpinIncident() {
|
||||
this.$root.getSocket().emit("unpinIncident", () => {
|
||||
this.incident = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../assets/vars.scss";
|
||||
|
||||
.overall-status {
|
||||
font-weight: bold;
|
||||
font-size: 25px;
|
||||
|
||||
.ok {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 30px;
|
||||
|
||||
img {
|
||||
vertical-align: middle;
|
||||
height: 60px;
|
||||
width: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.description span {
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
.icon-upload {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-upload {
|
||||
transition: all $easing-in 0.2s;
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
font-size: 20px;
|
||||
left: -14px;
|
||||
background-color: white;
|
||||
padding: 5px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
transition: all $easing-in 0.2s;
|
||||
|
||||
&.edit-mode {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.incident {
|
||||
.content {
|
||||
&[contenteditable=true] {
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.overall-status {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
@ -0,0 +1,85 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import EmptyLayout from "./layouts/EmptyLayout.vue";
|
||||
import Layout from "./layouts/Layout.vue";
|
||||
import Dashboard from "./pages/Dashboard.vue";
|
||||
import DashboardHome from "./pages/DashboardHome.vue";
|
||||
import Details from "./pages/Details.vue";
|
||||
import EditMonitor from "./pages/EditMonitor.vue";
|
||||
import List from "./pages/List.vue";
|
||||
import Settings from "./pages/Settings.vue";
|
||||
import Setup from "./pages/Setup.vue";
|
||||
import StatusPage from "./pages/StatusPage.vue";
|
||||
import Entry from "./pages/Entry.vue";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/",
|
||||
component: Entry,
|
||||
},
|
||||
{
|
||||
// If it is "/dashboard", the active link is not working
|
||||
// If it is "", it overrides the "/" unexpectedly
|
||||
// Give a random name to solve the problem.
|
||||
path: "/empty",
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: Dashboard,
|
||||
children: [
|
||||
{
|
||||
name: "DashboardHome",
|
||||
path: "/dashboard",
|
||||
component: DashboardHome,
|
||||
children: [
|
||||
{
|
||||
path: "/dashboard/:id",
|
||||
component: EmptyLayout,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: Details,
|
||||
},
|
||||
{
|
||||
path: "/edit/:id",
|
||||
component: EditMonitor,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/add",
|
||||
component: EditMonitor,
|
||||
},
|
||||
{
|
||||
path: "/list",
|
||||
component: List,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
component: Settings,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/setup",
|
||||
component: Setup,
|
||||
},
|
||||
{
|
||||
path: "/status-page",
|
||||
component: StatusPage,
|
||||
},
|
||||
{
|
||||
path: "/status",
|
||||
component: StatusPage,
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
linkActiveClass: "active",
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
@ -1,70 +1,104 @@
|
||||
"use strict";
|
||||
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;
|
||||
const _dayjs = require("dayjs");
|
||||
const dayjs = _dayjs;
|
||||
exports.isDev = process.env.NODE_ENV === "development";
|
||||
exports.appName = "Uptime Kuma";
|
||||
exports.DOWN = 0;
|
||||
exports.UP = 1;
|
||||
exports.PENDING = 2;
|
||||
function flipStatus(s) {
|
||||
if (s === exports.UP) {
|
||||
return exports.DOWN;
|
||||
}
|
||||
if (s === exports.DOWN) {
|
||||
return exports.UP;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
exports.flipStatus = flipStatus;
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
exports.sleep = sleep;
|
||||
function ucfirst(str) {
|
||||
if (!str) {
|
||||
return str;
|
||||
}
|
||||
const firstLetter = str.substr(0, 1);
|
||||
return firstLetter.toUpperCase() + str.substr(1);
|
||||
}
|
||||
exports.ucfirst = ucfirst;
|
||||
function debug(msg) {
|
||||
if (exports.isDev) {
|
||||
console.log(msg);
|
||||
}
|
||||
}
|
||||
exports.debug = debug;
|
||||
function polyfill() {
|
||||
if (!String.prototype.replaceAll) {
|
||||
String.prototype.replaceAll = function (str, newStr) {
|
||||
if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") {
|
||||
return this.replace(str, newStr);
|
||||
}
|
||||
return this.replace(new RegExp(str, "g"), newStr);
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.polyfill = polyfill;
|
||||
class TimeLogger {
|
||||
constructor() {
|
||||
this.startTime = dayjs().valueOf();
|
||||
}
|
||||
print(name) {
|
||||
if (exports.isDev) {
|
||||
console.log(name + ": " + (dayjs().valueOf() - this.startTime) + "ms");
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.TimeLogger = TimeLogger;
|
||||
function getRandomArbitrary(min, max) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
exports.getRandomArbitrary = getRandomArbitrary;
|
||||
function getRandomInt(min, max) {
|
||||
min = Math.ceil(min);
|
||||
max = Math.floor(max);
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
exports.getRandomInt = getRandomInt;
|
||||
"use strict";
|
||||
// Common Util for frontend and backend
|
||||
//
|
||||
// DOT NOT MODIFY util.js!
|
||||
// Need to run "tsc" to compile if there are any changes.
|
||||
//
|
||||
// Backend uses the compiled file util.js
|
||||
// Frontend uses util.ts
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
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 = _dayjs;
|
||||
exports.isDev = process.env.NODE_ENV === "development";
|
||||
exports.appName = "Uptime Kuma";
|
||||
exports.DOWN = 0;
|
||||
exports.UP = 1;
|
||||
exports.PENDING = 2;
|
||||
exports.STATUS_PAGE_ALL_DOWN = 0;
|
||||
exports.STATUS_PAGE_ALL_UP = 1;
|
||||
exports.STATUS_PAGE_PARTIAL_DOWN = 2;
|
||||
function flipStatus(s) {
|
||||
if (s === exports.UP) {
|
||||
return exports.DOWN;
|
||||
}
|
||||
if (s === exports.DOWN) {
|
||||
return exports.UP;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
exports.flipStatus = flipStatus;
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
exports.sleep = sleep;
|
||||
/**
|
||||
* PHP's ucfirst
|
||||
* @param str
|
||||
*/
|
||||
function ucfirst(str) {
|
||||
if (!str) {
|
||||
return str;
|
||||
}
|
||||
const firstLetter = str.substr(0, 1);
|
||||
return firstLetter.toUpperCase() + str.substr(1);
|
||||
}
|
||||
exports.ucfirst = ucfirst;
|
||||
function debug(msg) {
|
||||
if (exports.isDev) {
|
||||
console.log(msg);
|
||||
}
|
||||
}
|
||||
exports.debug = debug;
|
||||
function polyfill() {
|
||||
/**
|
||||
* String.prototype.replaceAll() polyfill
|
||||
* https://gomakethings.com/how-to-replace-a-section-of-a-string-with-another-one-with-vanilla-js/
|
||||
* @author Chris Ferdinandi
|
||||
* @license MIT
|
||||
*/
|
||||
if (!String.prototype.replaceAll) {
|
||||
String.prototype.replaceAll = function (str, newStr) {
|
||||
// If a regex pattern
|
||||
if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") {
|
||||
return this.replace(str, newStr);
|
||||
}
|
||||
// If a string
|
||||
return this.replace(new RegExp(str, "g"), newStr);
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.polyfill = polyfill;
|
||||
class TimeLogger {
|
||||
constructor() {
|
||||
this.startTime = dayjs().valueOf();
|
||||
}
|
||||
print(name) {
|
||||
if (exports.isDev) {
|
||||
console.log(name + ": " + (dayjs().valueOf() - this.startTime) + "ms");
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.TimeLogger = TimeLogger;
|
||||
/**
|
||||
* Returns a random number between min (inclusive) and max (exclusive)
|
||||
*/
|
||||
function getRandomArbitrary(min, max) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
exports.getRandomArbitrary = getRandomArbitrary;
|
||||
/**
|
||||
* From: https://stackoverflow.com/questions/1527803/generating-random-whole-numbers-in-javascript-in-a-specific-range
|
||||
*
|
||||
* Returns a random integer between min (inclusive) and max (inclusive).
|
||||
* The value is no lower than min (or the next integer greater than min
|
||||
* if min isn't an integer) and no greater than max (or the next integer
|
||||
* lower than max if max isn't an integer).
|
||||
* Using Math.round() will give you a non-uniform distribution!
|
||||
*/
|
||||
function getRandomInt(min, max) {
|
||||
min = Math.ceil(min);
|
||||
max = Math.floor(max);
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
exports.getRandomInt = getRandomInt;
|
||||
|
@ -0,0 +1,10 @@
|
||||
FROM ubuntu
|
||||
WORKDIR /app
|
||||
RUN apt update && apt --yes install git curl
|
||||
RUN curl -sL https://deb.nodesource.com/setup_16.x | bash -
|
||||
RUN apt --yes install nodejs
|
||||
RUN git clone https://github.com/louislam/uptime-kuma.git .
|
||||
RUN npm run setup
|
||||
|
||||
# Option 1. Try it
|
||||
RUN node server/server.js
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue