Merge branch 'master' of https://github.com/louislam/uptime-kuma into feat/add-auth-header-to-webhook-notification-#1919
commit
3c5de1c889
@ -0,0 +1,24 @@
|
||||
name: 'Automatically close stale issues and PRs'
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *'
|
||||
#Run every 6 hours
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
stale-issue-message: 'We are clearing up our old issues and your ticket has been open for 3 months with no activity. Remove stale label or comment or this will be closed in 2 days.'
|
||||
stale-pr-message: 'We are clearing up our old Pull Requests and yours has been open for 3 months with no activity. Remove stale label or comment or this will be closed in 2 days.'
|
||||
close-issue-message: 'This issue was closed because it has been stalled for 2 days with no activity.'
|
||||
close-pr-message: 'This PR was closed because it has been stalled for 2 days with no activity.'
|
||||
days-before-stale: 90
|
||||
days-before-close: 2
|
||||
exempt-issue-labels: 'News,Medium,High,discussion,bug,doc,feature-request'
|
||||
exempt-pr-labels: 'awaiting-approval,work-in-progress,enhancement,feature-request'
|
||||
exempt-issue-assignees: 'louislam'
|
||||
exempt-pr-assignees: 'louislam'
|
||||
operations-per-run: 200
|
@ -0,0 +1,28 @@
|
||||
const { defineConfig } = require("cypress");
|
||||
|
||||
module.exports = defineConfig({
|
||||
projectId: "vyjuem",
|
||||
e2e: {
|
||||
experimentalStudio: true,
|
||||
setupNodeEvents(on, config) {
|
||||
|
||||
},
|
||||
fixturesFolder: "test/cypress/fixtures",
|
||||
screenshotsFolder: "test/cypress/screenshots",
|
||||
videosFolder: "test/cypress/videos",
|
||||
downloadsFolder: "test/cypress/downloads",
|
||||
supportFile: "test/cypress/support/e2e.js",
|
||||
baseUrl: "http://localhost:3002",
|
||||
defaultCommandTimeout: 10000,
|
||||
pageLoadTimeout: 60000,
|
||||
viewportWidth: 1920,
|
||||
viewportHeight: 1080,
|
||||
specPattern: [
|
||||
"test/cypress/e2e/setup.cy.js",
|
||||
"test/cypress/e2e/**/*.js"
|
||||
],
|
||||
},
|
||||
env: {
|
||||
baseUrl: "http://localhost:3002",
|
||||
},
|
||||
});
|
@ -1,33 +0,0 @@
|
||||
const PuppeteerEnvironment = require("jest-environment-puppeteer");
|
||||
const util = require("util");
|
||||
|
||||
class DebugEnv extends PuppeteerEnvironment {
|
||||
async handleTestEvent(event, state) {
|
||||
const ignoredEvents = [
|
||||
"setup",
|
||||
"add_hook",
|
||||
"start_describe_definition",
|
||||
"add_test",
|
||||
"finish_describe_definition",
|
||||
"run_start",
|
||||
"run_describe_start",
|
||||
"test_start",
|
||||
"hook_start",
|
||||
"hook_success",
|
||||
"test_fn_start",
|
||||
"test_fn_success",
|
||||
"test_done",
|
||||
"run_describe_finish",
|
||||
"run_finish",
|
||||
"teardown",
|
||||
"test_fn_failure",
|
||||
];
|
||||
if (!ignoredEvents.includes(event.name)) {
|
||||
console.log(
|
||||
new Date().toString() + ` Unhandled event [${event.name}] ` + util.inspect(event)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DebugEnv;
|
@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
"rootDir": "..",
|
||||
"testRegex": "./test/frontend.spec.js",
|
||||
};
|
||||
|
@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
"launch": {
|
||||
"dumpio": true,
|
||||
"slowMo": 500,
|
||||
"headless": process.env.HEADLESS_TEST || false,
|
||||
"userDataDir": "./data/test-chrome-profile",
|
||||
args: [
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-gpu",
|
||||
"--disable-dev-shm-usage",
|
||||
"--no-default-browser-check",
|
||||
"--no-experiments",
|
||||
"--no-first-run",
|
||||
"--no-pings",
|
||||
"--no-sandbox",
|
||||
"--no-zygote",
|
||||
"--single-process",
|
||||
],
|
||||
}
|
||||
};
|
@ -1,12 +0,0 @@
|
||||
module.exports = {
|
||||
"verbose": true,
|
||||
"preset": "jest-puppeteer",
|
||||
"globals": {
|
||||
"__DEV__": true
|
||||
},
|
||||
"testRegex": "./test/e2e.spec.js",
|
||||
"testEnvironment": "./config/jest-debug-env.js",
|
||||
"rootDir": "..",
|
||||
"testTimeout": 30000,
|
||||
};
|
||||
|
@ -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 docker_host (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INT NOT NULL,
|
||||
docker_daemon VARCHAR(255),
|
||||
docker_type VARCHAR(255),
|
||||
name VARCHAR(255)
|
||||
);
|
||||
|
||||
ALTER TABLE monitor
|
||||
ADD docker_host INTEGER REFERENCES docker_host(id);
|
||||
|
||||
ALTER TABLE monitor
|
||||
ADD docker_container VARCHAR(255);
|
||||
|
||||
COMMIT;
|
@ -0,0 +1,18 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE monitor
|
||||
ADD radius_username VARCHAR(255);
|
||||
|
||||
ALTER TABLE monitor
|
||||
ADD radius_password VARCHAR(255);
|
||||
|
||||
ALTER TABLE monitor
|
||||
ADD radius_calling_station_id VARCHAR(50);
|
||||
|
||||
ALTER TABLE monitor
|
||||
ADD radius_called_station_id VARCHAR(50);
|
||||
|
||||
ALTER TABLE monitor
|
||||
ADD radius_secret VARCHAR(255);
|
||||
|
||||
COMMIT
|
@ -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 monitor
|
||||
ADD resend_interval INTEGER default 0 not null;
|
||||
|
||||
ALTER TABLE heartbeat
|
||||
ADD down_count INTEGER default 0 not null;
|
||||
|
||||
COMMIT;
|
@ -0,0 +1,33 @@
|
||||
const childProcess = require("child_process");
|
||||
|
||||
if (!process.env.UPTIME_KUMA_GH_REPO) {
|
||||
console.error("Please set a repo to the environment variable 'UPTIME_KUMA_GH_REPO' (e.g. mhkarimi1383:goalert-notification)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let inputArray = process.env.UPTIME_KUMA_GH_REPO.split(":");
|
||||
|
||||
if (inputArray.length !== 2) {
|
||||
console.error("Invalid format. Please set a repo to the environment variable 'UPTIME_KUMA_GH_REPO' (e.g. mhkarimi1383:goalert-notification)");
|
||||
}
|
||||
|
||||
let name = inputArray[0];
|
||||
let branch = inputArray[1];
|
||||
|
||||
console.log("Checkout pr");
|
||||
|
||||
// Checkout the pr
|
||||
let result = childProcess.spawnSync("git", [ "remote", "add", name, `https://github.com/${name}/uptime-kuma` ]);
|
||||
|
||||
console.log(result.stdout.toString());
|
||||
console.error(result.stderr.toString());
|
||||
|
||||
result = childProcess.spawnSync("git", [ "fetch", name, branch ]);
|
||||
|
||||
console.log(result.stdout.toString());
|
||||
console.error(result.stderr.toString());
|
||||
|
||||
result = childProcess.spawnSync("git", [ "checkout", `${name}/${branch}`, "--force" ]);
|
||||
|
||||
console.log(result.stdout.toString());
|
||||
console.error(result.stderr.toString());
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,54 @@
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const CacheableLookup = require("cacheable-lookup");
|
||||
|
||||
class CacheableDnsHttpAgent {
|
||||
|
||||
static cacheable = new CacheableLookup();
|
||||
|
||||
static httpAgentList = {};
|
||||
static httpsAgentList = {};
|
||||
|
||||
/**
|
||||
* Register cacheable to global agents
|
||||
*/
|
||||
static registerGlobalAgent() {
|
||||
this.cacheable.install(http.globalAgent);
|
||||
this.cacheable.install(https.globalAgent);
|
||||
}
|
||||
|
||||
static install(agent) {
|
||||
this.cacheable.install(agent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @var {https.AgentOptions} agentOptions
|
||||
* @return {https.Agent}
|
||||
*/
|
||||
static getHttpsAgent(agentOptions) {
|
||||
let key = JSON.stringify(agentOptions);
|
||||
if (!(key in this.httpsAgentList)) {
|
||||
this.httpsAgentList[key] = new https.Agent(agentOptions);
|
||||
this.cacheable.install(this.httpsAgentList[key]);
|
||||
}
|
||||
return this.httpsAgentList[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* @var {http.AgentOptions} agentOptions
|
||||
* @return {https.Agents}
|
||||
*/
|
||||
static getHttpAgent(agentOptions) {
|
||||
let key = JSON.stringify(agentOptions);
|
||||
if (!(key in this.httpAgentList)) {
|
||||
this.httpAgentList[key] = new http.Agent(agentOptions);
|
||||
this.cacheable.install(this.httpAgentList[key]);
|
||||
}
|
||||
return this.httpAgentList[key];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CacheableDnsHttpAgent,
|
||||
};
|
@ -0,0 +1,118 @@
|
||||
const axios = require("axios");
|
||||
const { R } = require("redbean-node");
|
||||
const version = require("../package.json").version;
|
||||
const https = require("https");
|
||||
|
||||
class DockerHost {
|
||||
/**
|
||||
* Save a docker host
|
||||
* @param {Object} dockerHost Docker host to save
|
||||
* @param {?number} dockerHostID ID of the docker host to update
|
||||
* @param {number} userID ID of the user who adds the docker host
|
||||
* @returns {Promise<Bean>}
|
||||
*/
|
||||
static async save(dockerHost, dockerHostID, userID) {
|
||||
let bean;
|
||||
|
||||
if (dockerHostID) {
|
||||
bean = await R.findOne("docker_host", " id = ? AND user_id = ? ", [ dockerHostID, userID ]);
|
||||
|
||||
if (!bean) {
|
||||
throw new Error("docker host not found");
|
||||
}
|
||||
|
||||
} else {
|
||||
bean = R.dispense("docker_host");
|
||||
}
|
||||
|
||||
bean.user_id = userID;
|
||||
bean.docker_daemon = dockerHost.dockerDaemon;
|
||||
bean.docker_type = dockerHost.dockerType;
|
||||
bean.name = dockerHost.name;
|
||||
|
||||
await R.store(bean);
|
||||
|
||||
return bean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Docker host
|
||||
* @param {number} dockerHostID ID of the Docker host to delete
|
||||
* @param {number} userID ID of the user who created the Docker host
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async delete(dockerHostID, userID) {
|
||||
let bean = await R.findOne("docker_host", " id = ? AND user_id = ? ", [ dockerHostID, userID ]);
|
||||
|
||||
if (!bean) {
|
||||
throw new Error("docker host not found");
|
||||
}
|
||||
|
||||
// Delete removed proxy from monitors if exists
|
||||
await R.exec("UPDATE monitor SET docker_host = null WHERE docker_host = ?", [ dockerHostID ]);
|
||||
|
||||
await R.trash(bean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the amount of containers on the Docker host
|
||||
* @param {Object} dockerHost Docker host to check for
|
||||
* @returns {number} Total amount of containers on the host
|
||||
*/
|
||||
static async testDockerHost(dockerHost) {
|
||||
const options = {
|
||||
url: "/containers/json?all=true",
|
||||
headers: {
|
||||
"Accept": "*/*",
|
||||
"User-Agent": "Uptime-Kuma/" + version
|
||||
},
|
||||
httpsAgent: new https.Agent({
|
||||
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
|
||||
rejectUnauthorized: false,
|
||||
}),
|
||||
};
|
||||
|
||||
if (dockerHost.dockerType === "socket") {
|
||||
options.socketPath = dockerHost.dockerDaemon;
|
||||
} else if (dockerHost.dockerType === "tcp") {
|
||||
options.baseURL = DockerHost.patchDockerURL(dockerHost.dockerDaemon);
|
||||
}
|
||||
|
||||
let res = await axios.request(options);
|
||||
|
||||
if (Array.isArray(res.data)) {
|
||||
|
||||
if (res.data.length > 1) {
|
||||
|
||||
if ("ImageID" in res.data[0]) {
|
||||
return res.data.length;
|
||||
} else {
|
||||
throw new Error("Invalid Docker response, is it Docker really a daemon?");
|
||||
}
|
||||
|
||||
} else {
|
||||
return res.data.length;
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error("Invalid Docker response, is it Docker really a daemon?");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Since axios 0.27.X, it does not accept `tcp://` protocol.
|
||||
* Change it to `http://` on the fly in order to fix it. (https://github.com/louislam/uptime-kuma/issues/2165)
|
||||
*/
|
||||
static patchDockerURL(url) {
|
||||
if (typeof url === "string") {
|
||||
// Replace the first occurrence only with g
|
||||
return url.replace(/tcp:\/\//g, "http://");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DockerHost,
|
||||
};
|
@ -0,0 +1,19 @@
|
||||
const { BeanModel } = require("redbean-node/dist/bean-model");
|
||||
|
||||
class DockerHost extends BeanModel {
|
||||
/**
|
||||
* Returns an object that ready to parse to JSON
|
||||
* @returns {Object}
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
userID: this.user_id,
|
||||
dockerDaemon: this.docker_daemon,
|
||||
dockerType: this.docker_type,
|
||||
name: this.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DockerHost;
|
@ -0,0 +1,24 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
|
||||
class FreeMobile extends NotificationProvider {
|
||||
|
||||
name = "FreeMobile";
|
||||
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
let okMsg = "Sent Successfully.";
|
||||
try {
|
||||
await axios.post(`https://smsapi.free-mobile.fr/sendmsg?msg=${encodeURIComponent(msg.replace("🔴", "⛔️"))}`, {
|
||||
"user": notification.freemobileUser,
|
||||
"pass": notification.freemobilePass,
|
||||
});
|
||||
|
||||
return okMsg;
|
||||
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FreeMobile;
|
@ -0,0 +1,35 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
const { UP } = require("../../src/util");
|
||||
|
||||
class GoAlert extends NotificationProvider {
|
||||
|
||||
name = "GoAlert";
|
||||
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
let okMsg = "Sent Successfully.";
|
||||
try {
|
||||
let closeAction = "close";
|
||||
let data = {
|
||||
summary: msg,
|
||||
};
|
||||
if (heartbeatJSON != null && heartbeatJSON["status"] === UP) {
|
||||
data["action"] = closeAction;
|
||||
}
|
||||
let headers = {
|
||||
"Content-Type": "multipart/form-data",
|
||||
};
|
||||
let config = {
|
||||
headers: headers
|
||||
};
|
||||
await axios.post(`${notification.goAlertBaseURL}/api/v2/generic/incoming?token=${notification.goAlertToken}`, data, config);
|
||||
return okMsg;
|
||||
|
||||
} catch (error) {
|
||||
let msg = (error.response.data) ? error.response.data : "Error without response";
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GoAlert;
|
@ -0,0 +1,38 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
|
||||
const defaultNotificationService = "notify";
|
||||
|
||||
class HomeAssistant extends NotificationProvider {
|
||||
name = "HomeAssistant";
|
||||
|
||||
async send(notification, message, monitor = null, heartbeat = null) {
|
||||
const notificationService = notification?.notificationService || defaultNotificationService;
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${notification.homeAssistantUrl}/api/services/notify/${notificationService}`,
|
||||
{
|
||||
title: "Uptime Kuma",
|
||||
message,
|
||||
...(notificationService !== "persistent_notification" && { data: {
|
||||
name: monitor?.name,
|
||||
status: heartbeat?.status,
|
||||
} }),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${notification.longLivedAccessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return "Sent Successfully.";
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HomeAssistant;
|
@ -0,0 +1,43 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
const qs = require("qs");
|
||||
const { DOWN, UP } = require("../../src/util");
|
||||
|
||||
class LineNotify extends NotificationProvider {
|
||||
|
||||
name = "LineNotify";
|
||||
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
let okMsg = "Sent Successfully.";
|
||||
try {
|
||||
let lineAPIUrl = "https://notify-api.line.me/api/notify";
|
||||
let config = {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": "Bearer " + notification.lineNotifyAccessToken
|
||||
}
|
||||
};
|
||||
if (heartbeatJSON == null) {
|
||||
let testMessage = {
|
||||
"message": msg,
|
||||
};
|
||||
await axios.post(lineAPIUrl, qs.stringify(testMessage), config);
|
||||
} else if (heartbeatJSON["status"] === DOWN) {
|
||||
let downMessage = {
|
||||
"message": "\n[🔴 Down]\n" + "Name: " + monitorJSON["name"] + " \n" + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"]
|
||||
};
|
||||
await axios.post(lineAPIUrl, qs.stringify(downMessage), config);
|
||||
} else if (heartbeatJSON["status"] === UP) {
|
||||
let upMessage = {
|
||||
"message": "\n[✅ Up]\n" + "Name: " + monitorJSON["name"] + " \n" + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"]
|
||||
};
|
||||
await axios.post(lineAPIUrl, qs.stringify(upMessage), config);
|
||||
}
|
||||
return okMsg;
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LineNotify;
|
@ -0,0 +1,36 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
const { DOWN, UP } = require("../../src/util");
|
||||
|
||||
class ServerChan extends NotificationProvider {
|
||||
|
||||
name = "ServerChan";
|
||||
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
let okMsg = "Sent Successfully.";
|
||||
try {
|
||||
await axios.post(`https://sctapi.ftqq.com/${notification.serverChanSendKey}.send`, {
|
||||
"title": this.checkStatus(heartbeatJSON, monitorJSON),
|
||||
"desp": msg,
|
||||
});
|
||||
|
||||
return okMsg;
|
||||
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
}
|
||||
|
||||
checkStatus(heartbeatJSON, monitorJSON) {
|
||||
let title = "UptimeKuma Message";
|
||||
if (heartbeatJSON != null && heartbeatJSON["status"] === UP) {
|
||||
title = "UptimeKuma Monitor Up " + monitorJSON["name"];
|
||||
}
|
||||
if (heartbeatJSON != null && heartbeatJSON["status"] === DOWN) {
|
||||
title = "UptimeKuma Monitor Down " + monitorJSON["name"];
|
||||
}
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ServerChan;
|
@ -0,0 +1,25 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
|
||||
class SMSManager extends NotificationProvider {
|
||||
|
||||
name = "SMSManager";
|
||||
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
try {
|
||||
let data = {
|
||||
apikey: notification.smsmanagerApiKey,
|
||||
endpoint: "https://http-api.smsmanager.cz/Send",
|
||||
message: msg.replace(/[^\x00-\x7F]/g, ""),
|
||||
to: notification.numbers,
|
||||
messageType: notification.messageType,
|
||||
};
|
||||
await axios.get(`${data.endpoint}?apikey=${data.apikey}&message=${data.message}&number=${data.to}&gateway=${data.messageType}`);
|
||||
return "SMS sent sucessfully.";
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SMSManager;
|
@ -0,0 +1,76 @@
|
||||
const NotificationProvider = require("./notification-provider");
|
||||
const axios = require("axios");
|
||||
const { DOWN } = require("../../src/util");
|
||||
|
||||
class Squadcast extends NotificationProvider {
|
||||
|
||||
name = "squadcast";
|
||||
|
||||
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
|
||||
let okMsg = "Sent Successfully.";
|
||||
|
||||
try {
|
||||
|
||||
let config = {};
|
||||
let data = {
|
||||
message: msg,
|
||||
description: "",
|
||||
tags: {},
|
||||
heartbeat: heartbeatJSON,
|
||||
source: "uptime-kuma"
|
||||
};
|
||||
|
||||
if (heartbeatJSON !== null) {
|
||||
data.description = heartbeatJSON["msg"];
|
||||
data.event_id = heartbeatJSON["monitorID"];
|
||||
|
||||
if (heartbeatJSON["status"] === DOWN) {
|
||||
data.message = `${monitorJSON["name"]} is DOWN`;
|
||||
data.status = "trigger";
|
||||
} else {
|
||||
data.message = `${monitorJSON["name"]} is UP`;
|
||||
data.status = "resolve";
|
||||
}
|
||||
|
||||
let address;
|
||||
switch (monitorJSON["type"]) {
|
||||
case "ping":
|
||||
address = monitorJSON["hostname"];
|
||||
break;
|
||||
case "port":
|
||||
case "dns":
|
||||
case "steam":
|
||||
address = monitorJSON["hostname"];
|
||||
if (monitorJSON["port"]) {
|
||||
address += ":" + monitorJSON["port"];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
address = monitorJSON["url"];
|
||||
break;
|
||||
}
|
||||
|
||||
data.tags["AlertAddress"] = address;
|
||||
|
||||
monitorJSON["tags"].forEach(tag => {
|
||||
data.tags[tag["name"]] = {
|
||||
value: tag["value"]
|
||||
};
|
||||
if (tag["color"] !== null) {
|
||||
data.tags[tag["name"]]["color"] = tag["color"];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await axios.post(notification.squadcastWebhookURL, data, config);
|
||||
return okMsg;
|
||||
|
||||
} catch (error) {
|
||||
this.throwGeneralAxiosError(error);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Squadcast;
|
@ -0,0 +1,79 @@
|
||||
const { sendDockerHostList } = require("../client");
|
||||
const { checkLogin } = require("../util-server");
|
||||
const { DockerHost } = require("../docker");
|
||||
const { log } = require("../../src/util");
|
||||
|
||||
/**
|
||||
* Handlers for docker hosts
|
||||
* @param {Socket} socket Socket.io instance
|
||||
*/
|
||||
module.exports.dockerSocketHandler = (socket) => {
|
||||
socket.on("addDockerHost", async (dockerHost, dockerHostID, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
let dockerHostBean = await DockerHost.save(dockerHost, dockerHostID, socket.userID);
|
||||
await sendDockerHostList(socket);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Saved",
|
||||
id: dockerHostBean.id,
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("deleteDockerHost", async (dockerHostID, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
await DockerHost.delete(dockerHostID, socket.userID);
|
||||
await sendDockerHostList(socket);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Deleted",
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("testDockerHost", async (dockerHost, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
let amount = await DockerHost.testDockerHost(dockerHost);
|
||||
let msg;
|
||||
|
||||
if (amount >= 1) {
|
||||
msg = "Connected Successfully. Amount of containers: " + amount;
|
||||
} else {
|
||||
msg = "Connected Successfully, but there are no containers?";
|
||||
}
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg,
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
log.error("docker", e);
|
||||
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
@ -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 id="exampleModalLabel" class="modal-title">
|
||||
{{ $t("Setup Docker Host") }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="docker-name" class="form-label">{{ $t("Friendly Name") }}</label>
|
||||
<input id="docker-name" v-model="dockerHost.name" type="text" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="docker-type" class="form-label">{{ $t("Connection Type") }}</label>
|
||||
<select id="docker-type" v-model="dockerHost.dockerType" class="form-select">
|
||||
<option v-for="type in connectionTypes" :key="type" :value="type">{{ $t(type) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="docker-daemon" class="form-label">{{ $t("Docker Daemon") }}</label>
|
||||
<input id="docker-daemon" v-model="dockerHost.dockerDaemon" type="text" class="form-control" required>
|
||||
|
||||
<div class="form-text">
|
||||
{{ $t("Examples") }}:
|
||||
<ul>
|
||||
<li>/var/run/docker.sock</li>
|
||||
<li>http://localhost:2375</li>
|
||||
<li>https://localhost:2376 (TLS)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
|
||||
{{ $t("Delete") }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-warning" :disabled="processing" @click="test">
|
||||
{{ $t("Test") }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="processing">
|
||||
<div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
|
||||
{{ $t("Save") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteDockerHost">
|
||||
{{ $t("deleteDockerHostMsg") }}
|
||||
</Confirm>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Modal } from "bootstrap";
|
||||
import Confirm from "./Confirm.vue";
|
||||
import { useToast } from "vue-toastification";
|
||||
const toast = useToast();
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Confirm,
|
||||
},
|
||||
props: {},
|
||||
emits: [ "added" ],
|
||||
data() {
|
||||
return {
|
||||
model: null,
|
||||
processing: false,
|
||||
id: null,
|
||||
connectionTypes: [ "socket", "tcp" ],
|
||||
dockerHost: {
|
||||
name: "",
|
||||
dockerDaemon: "",
|
||||
dockerType: "",
|
||||
// Do not set default value here, please scroll to show()
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.modal = new Modal(this.$refs.modal);
|
||||
},
|
||||
methods: {
|
||||
|
||||
deleteConfirm() {
|
||||
this.modal.hide();
|
||||
this.$refs.confirmDelete.show();
|
||||
},
|
||||
|
||||
show(dockerHostID) {
|
||||
if (dockerHostID) {
|
||||
let found = false;
|
||||
|
||||
this.id = dockerHostID;
|
||||
|
||||
for (let n of this.$root.dockerHostList) {
|
||||
if (n.id === dockerHostID) {
|
||||
this.dockerHost = n;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
toast.error("Docker Host not found!");
|
||||
}
|
||||
|
||||
} else {
|
||||
this.id = null;
|
||||
this.dockerHost = {
|
||||
name: "",
|
||||
dockerType: "socket",
|
||||
dockerDaemon: "/var/run/docker.sock",
|
||||
};
|
||||
}
|
||||
|
||||
this.modal.show();
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.processing = true;
|
||||
this.$root.getSocket().emit("addDockerHost", this.dockerHost, this.id, (res) => {
|
||||
this.$root.toastRes(res);
|
||||
this.processing = false;
|
||||
|
||||
if (res.ok) {
|
||||
this.modal.hide();
|
||||
|
||||
// Emit added event, doesn't emit edit.
|
||||
if (! this.id) {
|
||||
this.$emit("added", res.id);
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
test() {
|
||||
this.processing = true;
|
||||
this.$root.getSocket().emit("testDockerHost", this.dockerHost, (res) => {
|
||||
this.$root.toastRes(res);
|
||||
this.processing = false;
|
||||
});
|
||||
},
|
||||
|
||||
deleteDockerHost() {
|
||||
this.processing = true;
|
||||
this.$root.getSocket().emit("deleteDockerHost", this.id, (res) => {
|
||||
this.$root.toastRes(res);
|
||||
this.processing = false;
|
||||
|
||||
if (res.ok) {
|
||||
this.modal.hide();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</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,12 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label for="freemobileUser" class="form-label">{{ $t("Free Mobile User Identifier") }}<span style="color: red;"><sup>*</sup></span></label>
|
||||
<input id="freemobileUser" v-model="$parent.notification.freemobileUser" type="text" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="freemobilePass" class="form-label">{{ $t("Free Mobile API Key") }}<span style="color: red;"><sup>*</sup></span></label>
|
||||
<input id="freemobilePass" v-model="$parent.notification.freemobilePass" type="text" class="form-control" required>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label for="goalert-base-url" class="form-label">{{ $t("Base URL") }}</label>
|
||||
<div class="input-group mb-3">
|
||||
<input id="goalert-base-url" v-model="$parent.notification.goAlertBaseURL" type="text" class="form-control" required>
|
||||
</div>
|
||||
<i18n-t tag="div" keypath="goAlertInfo" class="form-text">
|
||||
<a href="https://goalert.me" target="_blank">https://goalert.me</a>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="goalert-token" class="form-label">{{ $t("Token") }}</label>
|
||||
<HiddenInput id="goalert-token" v-model="$parent.notification.goAlertToken" autocomplete="one-time-code" :required="true"></HiddenInput>
|
||||
|
||||
<div class="form-text">
|
||||
{{ $t("goAlertIntegrationKeyInfo") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HiddenInput from "../HiddenInput.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
HiddenInput,
|
||||
},
|
||||
};
|
||||
</script>
|
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label for="homeAssistantUrl" class="form-label">{{ $t("Home Assistant URL") }}<span style="color: red;"><sup>*</sup></span></label>
|
||||
<input id="homeAssistantUrl" v-model="$parent.notification.homeAssistantUrl" type="url" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="longLivedAccessToken" class="form-label">{{ $t("Long-Lived Access Token") }}<span style="color: red;"><sup>*</sup></span></label>
|
||||
<input id="longLivedAccessToken" v-model="$parent.notification.longLivedAccessToken" type="text" class="form-control" required>
|
||||
|
||||
<div class="form-text">
|
||||
<p>{{ $t("Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token. ") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="notificationService" class="form-label">{{ $t("Notification Service") }}</label>
|
||||
<input id="notificationService" v-model="$parent.notification.notificationService" type="text" :placeholder="$t('default: notify all devices')" class="form-control">
|
||||
|
||||
<div class="form-text">
|
||||
<p>{{ $t("A list of Notification Services can be found in Home Assistant under \"Developer Tools > Services\" search for \"notification\" to find your device/phone name.") }}</p>
|
||||
<p>{{ $t("Automations can optionally be triggered in Home Assistant:") }}</p>
|
||||
<p>
|
||||
{{ $t("Trigger type:") }} <code>Event</code><br />
|
||||
{{ $t("Event type:") }} <code>call_service</code><br />
|
||||
{{ $t("Event data:") }}
|
||||
</p>
|
||||
<pre>domain: notify
|
||||
service: mobile_app_my_phone # change to your device name
|
||||
service_data:
|
||||
title: Uptime Kuma
|
||||
data:
|
||||
status: 0 # 0=down 1=up
|
||||
# name: Optional Uptime Kuma Monitor Name to filter by</pre>
|
||||
<p>
|
||||
{{ $t("Then choose an action, for example switch the scene to where an RGB light is red.") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label for="line-notify-access-token" class="form-label">{{ $t("Access Token") }}</label>
|
||||
<input id="line-notify-access-token" v-model="$parent.notification.lineNotifyAccessToken" type="text" class="form-control" :required="true">
|
||||
</div>
|
||||
<i18n-t tag="div" keypath="wayToGetLineNotifyToken" class="form-text" style="margin-top: 8px;">
|
||||
<a href="https://notify-bot.line.me/" target="_blank">https://notify-bot.line.me/</a>
|
||||
</i18n-t>
|
||||
</template>
|
@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label for="smsmanager-key" class="form-label">API Key</label>
|
||||
<div class="form-text">
|
||||
{{ $t("SMSManager API Docs ") }}
|
||||
<a href="https://smsmanager.cz/api/http#send" target="_blank">{{ $t("here") }}</a>
|
||||
</div>
|
||||
<input id="smsmanager-key" v-model="$parent.notification.smsmanagerApiKey" type="text" class="form-control">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="smsmanager-numbers" class="form-label"> {{ $t("Recipients") }}</label>
|
||||
<div class="form-text">
|
||||
{{ $t("You can divide numbers with") }} <b>,</b> {{ $t("or") }} <b>;</b>
|
||||
</div>
|
||||
<input id="smsmanager-numbers" v-model="$parent.notification.numbers" type="text" class="form-control">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="smsmanager-messageType" class="form-label">{{ $t("Gateway Type") }}</label>
|
||||
<select id="smsmanager-messageType" v-model="$parent.notification.messageType" class="form-select">
|
||||
<option value="economy">Economy</option>
|
||||
<option value="lowcost">Lowcost</option>
|
||||
<option value="high" selected>High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-text">
|
||||
{{ $t("checkPrice", [$t("SMSManager")]) }}
|
||||
<a href="https://smsmanager.cz/rozesilani-sms/ceny/ceska-republika/" target="_blank">{{ $t("here") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label for="serverchan-sendkey" class="form-label">{{ $t("SendKey") }}</label>
|
||||
<HiddenInput id="serverchan-sendkey" v-model="$parent.notification.serverChanSendKey" :required="true" autocomplete="one-time-code"></HiddenInput>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HiddenInput from "../HiddenInput.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
HiddenInput,
|
||||
},
|
||||
};
|
||||
</script>
|
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div class="mb-3">
|
||||
<label for="webhook-url" class="form-label">{{ $t("Post URL") }}</label>
|
||||
<input id="webhook-url" v-model="$parent.notification.squadcastWebhookURL" type="url" pattern="https?://.+" class="form-control" required>
|
||||
</div>
|
||||
</template>
|
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="dockerHost-list my-4">
|
||||
<p v-if="$root.dockerHostList.length === 0">
|
||||
{{ $t("Not available, please setup.") }}
|
||||
</p>
|
||||
|
||||
<ul class="list-group mb-3" style="border-radius: 1rem;">
|
||||
<li v-for="(dockerHost, index) in $root.dockerHostList" :key="index" class="list-group-item">
|
||||
{{ dockerHost.name }}<br>
|
||||
<a href="#" @click="$refs.dockerHostDialog.show(dockerHost.id)">{{ $t("Edit") }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button class="btn btn-primary me-2" type="button" @click="$refs.dockerHostDialog.show()">
|
||||
{{ $t("Setup Docker Host") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DockerHostDialog ref="dockerHostDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DockerHostDialog from "../../components/DockerHostDialog.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DockerHostDialog,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
|
||||
computed: {
|
||||
settings() {
|
||||
return this.$parent.$parent.$parent.settings;
|
||||
},
|
||||
saveSettings() {
|
||||
return this.$parent.$parent.$parent.saveSettings;
|
||||
},
|
||||
settingsLoaded() {
|
||||
return this.$parent.$parent.$parent.settingsLoaded;
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
@ -0,0 +1,203 @@
|
||||
export default {
|
||||
languageName: "Português (Portugal)",
|
||||
checkEverySecond: "Verificar a 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: "Inverte 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. Define como 0 para desativar redirecionamentos.",
|
||||
acceptedStatusCodesDescription: "Seleciona os códigos de status que são considerados uma resposta bem-sucedida.",
|
||||
passwordNotMatchMsg: "A senha repetida não corresponde.",
|
||||
notificationDescription: "Atribuir uma notificação ao (s) monitor (es) para que funcione.",
|
||||
keywordDescription: "Pesquisa a palavra-chave em HTML simples ou resposta JSON e diferencia maiúsculas de minúsculas",
|
||||
pauseDashboardHome: "Pausa",
|
||||
deleteMonitorMsg: "Tens a certeza de que queres excluir este monitor?",
|
||||
deleteNotificationMsg: "Tens a certeza de que queres excluir esta notificação para todos os monitores?",
|
||||
resolverserverDescription: "A Cloudflare é o servidor padrão, podes alterar o servidor 'resolvedor' a qualquer momento.",
|
||||
rrtypeDescription: "Seleciona o RR-Type que queres monitorizar",
|
||||
pauseMonitorMsg: "Tens a certeza que queres fazer uma pausa?",
|
||||
enableDefaultNotificationDescription: "Para cada monitor novo esta notificação vai estar activa por padrão. Podes também desativar a notificação separadamente para cada monitor.",
|
||||
clearEventsMsg: "Tens a certeza que queres excluir todos os eventos deste monitor?",
|
||||
clearHeartbeatsMsg: "Tens a certeza de que queres excluir todos os heartbeats deste monitor?",
|
||||
confirmClearStatisticsMsg: "Tens a certeza que queres excluir TODAS as estatísticas?",
|
||||
importHandleDescription: "Escolhe 'Ignorar existente' se quiseres ignorar todos os monitores ou notificações com o mesmo nome. 'Substituir' excluirá todos os monitores e notificações existentes.",
|
||||
confirmImportMsg: "Tens a certeza que queres importar o backup? Certifica-te que selecionaste a opção de importação correta.",
|
||||
twoFAVerifyLabel: "Insire o teu token para verificares se o 2FA está a funcionar",
|
||||
tokenValidSettingsMsg: "O token é válido! Agora podes salvar as configurações do 2FA.",
|
||||
confirmEnableTwoFAMsg: "Tens a certeza de que queres habilitar 2FA?",
|
||||
confirmDisableTwoFAMsg: "Tens a certeza de que queres 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: "Pausa",
|
||||
Name: "Nome",
|
||||
Status: "Status",
|
||||
DateTime: "Data hora",
|
||||
Message: "Mensagem",
|
||||
"No important events": "Nenhum evento importante",
|
||||
Resume: "Resumo",
|
||||
Edit: "Editar",
|
||||
Delete: "Apagar",
|
||||
Current: "Atual",
|
||||
Uptime: "Tempo de atividade",
|
||||
"Cert Exp.": "Cert Exp.",
|
||||
day: "dia | dias",
|
||||
"-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 Heartbeats",
|
||||
Retries: "Novas tentativas",
|
||||
"Heartbeat Retry Interval": "Intervalo de repetição de Heartbeats",
|
||||
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: "Guardar",
|
||||
Notifications: "Notificações",
|
||||
"Not available, please setup.": "Não disponível, por favor configura.",
|
||||
"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": "Desencorajar que motores de busca indexem 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",
|
||||
"disableauth.message1": "Tens a certeza que queres <strong>desativar a autenticação</strong>?",
|
||||
"disableauth.message2": "Isso é para <strong>alguém que tem autenticação de terceiros</strong> em frente ao 'UpTime Kuma' como o Cloudflare Access.",
|
||||
"Please use this option carefully!": "Por favor, utiliza esta opção com cuidado.",
|
||||
Logout: "Logout",
|
||||
Leave: "Sair",
|
||||
"I understand, please disable": "Eu entendo, por favor desativa.",
|
||||
Confirm: "Confirmar",
|
||||
Yes: "Sim",
|
||||
No: "Não",
|
||||
Username: "Utilizador",
|
||||
Password: "Senha",
|
||||
"Remember me": "Lembra-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": "Cria a tua conta de admin",
|
||||
"Repeat Password": "Repete 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: "Podes fazer backup de todos os monitores e todas as notificações num 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, mantem-no com cuidado.",
|
||||
alertNoFile: "Seleciona um arquivo para importar.",
|
||||
alertWrongFileType: "Seleciona um arquivo JSON.",
|
||||
"Clear all statistics": "Limpar todas as estatísticas",
|
||||
"Skip existing": "Saltar 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 de 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...": "Pesquisa...",
|
||||
"Avg. Ping": "Ping Médio.",
|
||||
"Avg. Response": "Resposta Média. ",
|
||||
"Status Page": "Página de Status",
|
||||
"Status Pages": "Página de Status",
|
||||
"Entry Page": "Página de entrada",
|
||||
statusPageNothing: "Nada aqui, por favor, adiciona um grupo ou monitor.",
|
||||
"No Services": "Nenhum Serviço",
|
||||
"All Systems Operational": "Todos os Serviços Operacionais",
|
||||
"Partially Degraded Service": "Serviço parcialmente degradados",
|
||||
"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 o dashboard",
|
||||
telegram: "Telegram",
|
||||
webhook: "Webhook",
|
||||
smtp: "Email (SMTP)",
|
||||
discord: "Discord",
|
||||
teams: "Microsoft Teams",
|
||||
signal: "Signal",
|
||||
gotify: "Gotify",
|
||||
slack: "Slack",
|
||||
"rocket.chat": "Rocket.chat",
|
||||
pushover: "Pushover",
|
||||
pushy: "Pushy",
|
||||
octopush: "Octopush",
|
||||
promosms: "PromoSMS",
|
||||
lunasea: "LunaSea",
|
||||
apprise: "Apprise (Support 50+ Notification services)",
|
||||
pushbullet: "Pushbullet",
|
||||
line: "Line Messenger",
|
||||
mattermost: "Mattermost",
|
||||
};
|
@ -0,0 +1,18 @@
|
||||
const actor = require("../support/actors/actor");
|
||||
const userData = require("../support/const/user-data");
|
||||
const dashboardPage = require("../support/pages/dashboard-page");
|
||||
const setupPage = require("../support/pages/setup-page");
|
||||
|
||||
describe("user can create a new account on setup page", () => {
|
||||
before(() => {
|
||||
cy.visit("/setup");
|
||||
});
|
||||
it("user can create new account", () => {
|
||||
cy.url().should("be.equal", setupPage.SetupPage.url);
|
||||
actor.actor.setupTask.fillAndSubmitSetupForm(userData.DEFAULT_USER_DATA.username, userData.DEFAULT_USER_DATA.password, userData.DEFAULT_USER_DATA.password);
|
||||
cy.url().should("be.equal", dashboardPage.DashboardPage.url);
|
||||
cy.get('[role="alert"]')
|
||||
.should("be.visible")
|
||||
.and("contain.text", "Added Successfully.");
|
||||
});
|
||||
});
|
@ -0,0 +1,8 @@
|
||||
const setupTask = require("../tasks/setup-task");
|
||||
class Actor {
|
||||
constructor() {
|
||||
this.setupTask = new setupTask.SetupTask();
|
||||
}
|
||||
}
|
||||
const actor = new Actor();
|
||||
exports.actor = actor;
|
@ -0,0 +1,4 @@
|
||||
exports.DEFAULT_USER_DATA = {
|
||||
username: "testuser",
|
||||
password: "testuser123",
|
||||
};
|
@ -0,0 +1 @@
|
||||
require("./commands");
|
@ -0,0 +1,3 @@
|
||||
exports.DashboardPage = {
|
||||
url: Cypress.env("baseUrl") + "/dashboard",
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
exports.SetupPage = {
|
||||
url: Cypress.env("baseUrl") + "/setup",
|
||||
usernameInput: '[data-cy="username-input"]',
|
||||
passWordInput: '[data-cy="password-input"]',
|
||||
passwordRepeatInput: '[data-cy="password-repeat-input"]',
|
||||
submitSetupForm: '[data-cy="submit-setup-form"]',
|
||||
};
|
@ -0,0 +1,11 @@
|
||||
const setupPage = require("../pages/setup-page");
|
||||
|
||||
class SetupTask {
|
||||
fillAndSubmitSetupForm(username, password, passwordRepeat) {
|
||||
cy.get(setupPage.SetupPage.usernameInput).type(username);
|
||||
cy.get(setupPage.SetupPage.passWordInput).type(password);
|
||||
cy.get(setupPage.SetupPage.passwordRepeatInput).type(passwordRepeat);
|
||||
cy.get(setupPage.SetupPage.submitSetupForm).click();
|
||||
}
|
||||
}
|
||||
exports.SetupTask = SetupTask;
|
@ -1,329 +0,0 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { Page, Browser } = require("puppeteer");
|
||||
const { sleep } = require("../src/util");
|
||||
|
||||
/**
|
||||
* Set back the correct data type for page object
|
||||
* @type {Page}
|
||||
*/
|
||||
page;
|
||||
|
||||
/**
|
||||
* @type {Browser}
|
||||
*/
|
||||
browser;
|
||||
|
||||
beforeAll(async () => {
|
||||
await page.setViewport({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
});
|
||||
|
||||
const baseURL = "http://127.0.0.1:3002";
|
||||
|
||||
describe("Init", () => {
|
||||
const title = "Uptime Kuma";
|
||||
|
||||
beforeAll(async () => {
|
||||
await page.goto(baseURL);
|
||||
});
|
||||
|
||||
it(`should be titled "${title}"`, async () => {
|
||||
await expect(page.title()).resolves.toEqual(title);
|
||||
});
|
||||
|
||||
// Setup Page
|
||||
it("Setup", async () => {
|
||||
// Create an Admin
|
||||
await page.waitForSelector("#floatingInput");
|
||||
await page.waitForSelector("#repeat");
|
||||
await page.click("#floatingInput");
|
||||
await page.type("#floatingInput", "admin");
|
||||
await page.type("#floatingPassword", "admin123");
|
||||
await page.type("#repeat", "admin123");
|
||||
await page.click(".btn-primary[type=submit]");
|
||||
await sleep(3000);
|
||||
|
||||
// Go to /setup again
|
||||
await page.goto(baseURL + "/setup");
|
||||
await sleep(3000);
|
||||
let pathname = await page.evaluate(() => location.pathname);
|
||||
expect(pathname).toEqual("/dashboard");
|
||||
|
||||
// Go to /
|
||||
await page.goto(baseURL);
|
||||
await page.waitForSelector("h1.mb-3");
|
||||
pathname = await page.evaluate(() => location.pathname);
|
||||
expect(pathname).toEqual("/dashboard");
|
||||
});
|
||||
|
||||
it("should create monitor", async () => {
|
||||
// Create monitor
|
||||
await page.goto(baseURL + "/add");
|
||||
await page.waitForSelector("#name");
|
||||
|
||||
await page.type("#name", "Myself");
|
||||
await page.waitForSelector("#url");
|
||||
await page.click("#url", { clickCount: 3 });
|
||||
await page.keyboard.type(baseURL);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const badge = document.querySelector("span.badge");
|
||||
return badge && badge.innerText == "100%";
|
||||
}, { timeout: 5000 });
|
||||
|
||||
});
|
||||
|
||||
// Settings Page
|
||||
/*
|
||||
describe("Settings", () => {
|
||||
beforeEach(async () => {
|
||||
await page.goto(baseURL + "/settings");
|
||||
});
|
||||
|
||||
it("Change Language", async () => {
|
||||
await page.goto(baseURL + "/settings/appearance");
|
||||
await page.waitForSelector("#language");
|
||||
|
||||
await page.select("#language", "zh-HK");
|
||||
let languageTitle = await page.evaluate(() => document.querySelector("[for=language]").innerText);
|
||||
expect(languageTitle).toEqual("語言");
|
||||
|
||||
await page.select("#language", "en");
|
||||
languageTitle = await page.evaluate(() => document.querySelector("[for=language]").innerText);
|
||||
expect(languageTitle).toEqual("Language");
|
||||
});
|
||||
|
||||
it("Change Theme", async () => {
|
||||
await page.goto(baseURL + "/settings/appearance");
|
||||
|
||||
// Dark
|
||||
await click(page, ".btn[for=btncheck2]");
|
||||
await page.waitForSelector("div.dark");
|
||||
|
||||
await page.waitForSelector(".btn[for=btncheck1]");
|
||||
|
||||
// Light
|
||||
await click(page, ".btn[for=btncheck1]");
|
||||
await page.waitForSelector("div.light");
|
||||
});
|
||||
|
||||
it("Change Heartbeat Bar Style", async () => {
|
||||
await page.goto(baseURL + "/settings/appearance");
|
||||
|
||||
// Bottom
|
||||
await click(page, ".btn[for=btncheck5]");
|
||||
await page.waitForSelector("div.hp-bar-big");
|
||||
|
||||
// None
|
||||
await click(page, ".btn[for=btncheck6]");
|
||||
await page.waitForSelector("div.hp-bar-big", {
|
||||
hidden: true,
|
||||
timeout: 1000
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Timezone
|
||||
|
||||
it("Search Engine Visibility", async () => {
|
||||
// Default
|
||||
let res = await axios.get(baseURL + "/robots.txt");
|
||||
expect(res.data).toContain("Disallow: /");
|
||||
|
||||
// Yes
|
||||
await click(page, "#searchEngineIndexYes");
|
||||
await click(page, "form > div > .btn[type=submit]");
|
||||
await sleep(1000);
|
||||
res = await axios.get(baseURL + "/robots.txt");
|
||||
expect(res.data).not.toContain("Disallow: /");
|
||||
|
||||
// No
|
||||
await click(page, "#searchEngineIndexNo");
|
||||
await click(page, "form > div > .btn[type=submit]");
|
||||
await sleep(1000);
|
||||
res = await axios.get(baseURL + "/robots.txt");
|
||||
expect(res.data).toContain("Disallow: /");
|
||||
});
|
||||
|
||||
it("Entry Page", async () => {
|
||||
const newPage = await browser.newPage();
|
||||
|
||||
// Default
|
||||
await newPage.goto(baseURL);
|
||||
await newPage.waitForSelector("h1.mb-3", { timeout: 3000 });
|
||||
let pathname = await newPage.evaluate(() => location.pathname);
|
||||
expect(pathname).toEqual("/dashboard");
|
||||
|
||||
// Status Page
|
||||
await click(page, "#entryPageNo");
|
||||
await click(page, "form > div > .btn[type=submit]");
|
||||
await sleep(1000);
|
||||
await newPage.goto(baseURL);
|
||||
await newPage.waitForSelector("img.logo", { timeout: 3000 });
|
||||
pathname = await newPage.evaluate(() => location.pathname);
|
||||
expect(pathname).toEqual("/status");
|
||||
|
||||
// Back to Dashboard
|
||||
await click(page, "#entryPageYes");
|
||||
await click(page, "form > div > .btn[type=submit]");
|
||||
await sleep(1000);
|
||||
await newPage.goto(baseURL);
|
||||
await newPage.waitForSelector("h1.mb-3", { timeout: 3000 });
|
||||
pathname = await newPage.evaluate(() => location.pathname);
|
||||
expect(pathname).toEqual("/dashboard");
|
||||
|
||||
await newPage.close();
|
||||
});
|
||||
|
||||
it("Change Password (wrong current password)", async () => {
|
||||
await page.goto(baseURL + "/settings/security");
|
||||
await page.waitForSelector("#current-password");
|
||||
|
||||
await page.type("#current-password", "wrong_passw$$d");
|
||||
await page.type("#new-password", "new_password123");
|
||||
await page.type("#repeat-new-password", "new_password123");
|
||||
|
||||
// Save
|
||||
await click(page, "form > div > .btn[type=submit]", 0);
|
||||
await sleep(1000);
|
||||
|
||||
await click(page, "#logout-btn");
|
||||
await login("admin", "new_password123");
|
||||
let elementCount = await page.evaluate(() => document.querySelectorAll("#floatingPassword").length);
|
||||
expect(elementCount).toEqual(1);
|
||||
|
||||
await login("admin", "admin123");
|
||||
});
|
||||
|
||||
it("Change Password (wrong repeat)", async () => {
|
||||
await page.goto(baseURL + "/settings/security");
|
||||
await page.waitForSelector("#current-password");
|
||||
|
||||
await page.type("#current-password", "admin123");
|
||||
await page.type("#new-password", "new_password123");
|
||||
await page.type("#repeat-new-password", "new_password1234567898797898");
|
||||
|
||||
await click(page, "form > div > .btn[type=submit]", 0);
|
||||
await sleep(1000);
|
||||
|
||||
await click(page, "#logout-btn");
|
||||
await login("admin", "new_password123");
|
||||
|
||||
let elementCount = await page.evaluate(() => document.querySelectorAll("#floatingPassword").length);
|
||||
expect(elementCount).toEqual(1);
|
||||
|
||||
await login("admin", "admin123");
|
||||
await page.waitForSelector("#current-password");
|
||||
let pathname = await page.evaluate(() => location.pathname);
|
||||
expect(pathname).toEqual("/settings/security");
|
||||
});
|
||||
|
||||
// TODO: 2FA
|
||||
|
||||
// TODO: Export Backup
|
||||
|
||||
// TODO: Import Backup
|
||||
|
||||
it("Should disable & enable auth", async () => {
|
||||
await page.goto(baseURL + "/settings/security");
|
||||
await click(page, "#disableAuth-btn");
|
||||
await click(page, ".btn.btn-danger[data-bs-dismiss='modal']", 2); // Not a good way to do it
|
||||
await page.waitForSelector("#enableAuth-btn", { timeout: 3000 });
|
||||
await page.waitForSelector("#logout-btn", {
|
||||
hidden: true,
|
||||
timeout: 3000
|
||||
});
|
||||
|
||||
const newPage = await browser.newPage();
|
||||
await newPage.goto(baseURL);
|
||||
await newPage.waitForSelector("span.badge", { timeout: 3000 });
|
||||
newPage.close();
|
||||
|
||||
await click(page, "#enableAuth-btn");
|
||||
await login("admin", "admin123");
|
||||
await page.waitForSelector("#disableAuth-btn", { timeout: 3000 });
|
||||
});
|
||||
|
||||
// it("Should clear all statistics", async () => {
|
||||
// await page.goto(baseURL + "/settings/monitor-history");
|
||||
// await click(page, "#clearAllStats-btn");
|
||||
// await click(page, ".btn.btn-danger");
|
||||
// await page.waitForFunction(() => {
|
||||
// const badge = document.querySelector("span.badge");
|
||||
// return badge && badge.innerText == "0%";
|
||||
// }, { timeout: 3000 });
|
||||
// });
|
||||
});
|
||||
*/
|
||||
|
||||
/*
|
||||
* TODO
|
||||
* Create Monitor - All type
|
||||
* Edit Monitor
|
||||
* Delete Monitor
|
||||
*
|
||||
* Create Notification (token problem, maybe hard to test)
|
||||
*
|
||||
*/
|
||||
|
||||
describe("Status Page", () => {
|
||||
const title = "Uptime Kuma";
|
||||
beforeAll(async () => {
|
||||
await page.goto(baseURL + "/status");
|
||||
});
|
||||
it(`should be titled "${title}"`, async () => {
|
||||
await expect(page.title()).resolves.toEqual(title);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test login
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
*/
|
||||
async function login(username, password) {
|
||||
await input(page, "#floatingInput", username);
|
||||
await input(page, "#floatingPassword", password);
|
||||
await page.click(".btn-primary[type=submit]");
|
||||
await sleep(5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on an element on the page
|
||||
* @param {Page} page Puppeteer page instance
|
||||
* @param {string} selector
|
||||
* @param {number} elementIndex
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async function click(page, selector, elementIndex = 0) {
|
||||
await page.waitForSelector(selector, {
|
||||
timeout: 5000,
|
||||
});
|
||||
return await page.evaluate((s, i) => {
|
||||
return document.querySelectorAll(s)[i].click();
|
||||
}, selector, elementIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Input text into selected field
|
||||
* @param {Page} page Puppeteer page instance
|
||||
* @param {string} selector
|
||||
* @param {string} text Text to input
|
||||
*/
|
||||
async function input(page, selector, text) {
|
||||
await page.waitForSelector(selector, {
|
||||
timeout: 5000,
|
||||
});
|
||||
const element = await page.$(selector);
|
||||
await element.click({ clickCount: 3 });
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.type(selector, text);
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
// eslint-disable-next-line no-global-assign
|
||||
global.localStorage = {};
|
||||
global.navigator = {
|
||||
language: "en"
|
||||
};
|
||||
|
||||
const { currentLocale } = require("../src/i18n");
|
||||
|
||||
describe("Test i18n.js", () => {
|
||||
|
||||
it("currentLocale()", () => {
|
||||
expect(currentLocale()).toEqual("en");
|
||||
|
||||
navigator.language = "zh-HK";
|
||||
expect(currentLocale()).toEqual("zh-HK");
|
||||
|
||||
// Note that in Safari on iOS prior to 10.2, the country code returned is lowercase: "en-us", "fr-fr" etc.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/language
|
||||
navigator.language = "zh-hk";
|
||||
expect(currentLocale()).toEqual("en");
|
||||
|
||||
navigator.language = "en-US";
|
||||
expect(currentLocale()).toEqual("en");
|
||||
|
||||
navigator.language = "ja-ZZ";
|
||||
expect(currentLocale()).toEqual("ja");
|
||||
|
||||
navigator.language = "zz";
|
||||
expect(currentLocale()).toEqual("en");
|
||||
|
||||
navigator.language = "zz-ZZ";
|
||||
expect(currentLocale()).toEqual("en");
|
||||
|
||||
localStorage.locale = "en";
|
||||
expect(currentLocale()).toEqual("en");
|
||||
|
||||
localStorage.locale = "zh-HK";
|
||||
expect(currentLocale()).toEqual("zh-HK");
|
||||
});
|
||||
|
||||
});
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue