Merge branch 'master' into 1.23.X-merge-to-2.X.X-2

# Conflicts:
#	package.json
This commit is contained in:
Louis Lam 2023-12-02 18:43:06 +08:00
commit c5d9c54a04
25 changed files with 803 additions and 74 deletions

View file

@ -157,7 +157,12 @@ I recommend using Google, GitHub Issues, or Uptime Kuma's subreddit for finding
My Reddit account: [u/louislamlam](https://reddit.com/u/louislamlam)
You can mention me if you ask a question on the subreddit.
## Contribute
## Contributions
### Create Pull Requests
We DO NOT accept all types of pull requests and do not want to waste your time. Please be sure that you have read and follow pull request rules:
[CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma)
### Test Pull Requests
@ -183,6 +188,4 @@ If you want to translate Uptime Kuma into your language, please visit [Weblate R
Feel free to correct the grammar in the documentation or code.
My mother language is not English and my grammar is not that great.
### Create Pull Requests
If you want to modify Uptime Kuma, please read this guide and follow the rules here: https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md

View file

@ -0,0 +1,15 @@
exports.up = function (knex) {
// Add new column heartbeat.retries
return knex.schema
.alterTable("heartbeat", function (table) {
table.integer("retries").notNullable().defaultTo(0);
});
};
exports.down = function (knex) {
return knex.schema
.alterTable("heartbeat", function (table) {
table.dropColumn("retries");
});
};

View file

@ -0,0 +1,21 @@
exports.up = function (knex) {
return knex.schema
.createTable("remote_browser", function (table) {
table.increments("id");
table.string("name", 255).notNullable();
table.string("url", 255).notNullable();
table.integer("user_id").unsigned();
}).alterTable("monitor", function (table) {
// Add new column monitor.remote_browser
table.integer("remote_browser").nullable().defaultTo(null).unsigned()
.index()
.references("id")
.inTable("remote_browser");
});
};
exports.down = function (knex) {
return knex.schema.dropTable("remote_browser").alterTable("monitor", function (table) {
table.dropColumn("remote_browser");
});
};

View file

@ -5,7 +5,7 @@ const fs = require("fs");
* or the `recursive` property removing completely in the future Node.js version.
* See the link below.
* @todo Once we drop the support for Node.js v14 (or at least versions before v14.14.0), we can safely replace this function with `fs.rmSync`, since `fs.rmSync` was add in Node.js v14.14.0 and currently we supports all the Node.js v14 versions that include the versions before the v14.14.0, and this function have almost the same signature with `fs.rmSync`.
* @link https://nodejs.org/docs/latest-v16.x/api/deprecations.html#dep0147-fsrmdirpath--recursive-true- the deprecation infomation of `fs.rmdirSync`
* @link https://nodejs.org/docs/latest-v16.x/api/deprecations.html#dep0147-fsrmdirpath--recursive-true- the deprecation information of `fs.rmdirSync`
* @link https://nodejs.org/docs/latest-v16.x/api/fs.html#fsrmsyncpath-options the document of `fs.rmSync`
* @param {fs.PathLike} path Valid types for path values in "fs".
* @param {fs.RmDirOptions} options options for `fs.rmdirSync`, if `fs.rmSync` is available and property `recursive` is true, it will automatically have property `force` with value `true`.

52
package-lock.json generated
View file

@ -34,6 +34,7 @@
"form-data": "~4.0.0",
"gamedig": "^4.2.0",
"html-escaper": "^3.0.3",
"http-cookie-agent": "~5.0.4",
"http-graceful-shutdown": "~3.1.7",
"http-proxy-agent": "~5.0.0",
"https-proxy-agent": "~5.0.1",
@ -77,6 +78,7 @@
"tar": "~6.1.11",
"tcp-ping": "~0.1.1",
"thirty-two": "~1.0.2",
"tough-cookie": "~4.1.3",
"ws": "^8.13.0"
},
"devDependencies": {
@ -9078,6 +9080,44 @@
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
"integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="
},
"node_modules/http-cookie-agent": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-5.0.4.tgz",
"integrity": "sha512-OtvikW69RvfyP6Lsequ0fN5R49S+8QcS9zwd58k6VSr6r57T8G29BkPdyrBcSwLq6ExLs9V+rBlfxu7gDstJag==",
"dependencies": {
"agent-base": "^7.1.0"
},
"engines": {
"node": ">=14.18.0 <15.0.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/3846masa"
},
"peerDependencies": {
"deasync": "^0.1.26",
"tough-cookie": "^4.0.0",
"undici": "^5.11.0"
},
"peerDependenciesMeta": {
"deasync": {
"optional": true
},
"undici": {
"optional": true
}
}
},
"node_modules/http-cookie-agent/node_modules/agent-base": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
"integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==",
"dependencies": {
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/http-errors": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz",
@ -13009,8 +13049,7 @@
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
"dev": true
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag=="
},
"node_modules/pump": {
"version": "3.0.0",
@ -13164,8 +13203,7 @@
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"dev": true
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"node_modules/queue-microtask": {
"version": "1.2.3",
@ -13649,8 +13687,7 @@
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"node_modules/resolve": {
"version": "1.22.8",
@ -15329,7 +15366,6 @@
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
"integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==",
"dev": true,
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
@ -15344,7 +15380,6 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"dev": true,
"engines": {
"node": ">= 4.0.0"
}
@ -15679,7 +15714,6 @@
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dev": true,
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"

View file

@ -97,6 +97,7 @@
"express-static-gzip": "~2.1.7",
"form-data": "~4.0.0",
"gamedig": "^4.2.0",
"http-cookie-agent": "~5.0.4",
"html-escaper": "^3.0.3",
"http-graceful-shutdown": "~3.1.7",
"http-proxy-agent": "~5.0.0",
@ -141,6 +142,7 @@
"tar": "~6.1.11",
"tcp-ping": "~0.1.1",
"thirty-two": "~1.0.2",
"tough-cookie": "~4.1.3",
"ws": "^8.13.0"
},
"devDependencies": {

View file

@ -185,6 +185,30 @@ async function sendDockerHostList(socket) {
return list;
}
/**
* Send list of docker hosts to client
* @param {Socket} socket Socket.io socket instance
* @returns {Promise<Bean[]>} List of docker hosts
*/
async function sendRemoteBrowserList(socket) {
const timeLogger = new TimeLogger();
let result = [];
let list = await R.find("remote_browser", " user_id = ? ", [
socket.userID,
]);
for (let bean of list) {
result.push(bean.toJSON());
}
io.to(socket.userID).emit("remoteBrowserList", result);
timeLogger.print("Send Remote Browser List");
return list;
}
module.exports = {
sendNotificationList,
sendImportantHeartbeatList,
@ -192,5 +216,6 @@ module.exports = {
sendProxyList,
sendAPIKeyList,
sendInfo,
sendDockerHostList
sendDockerHostList,
sendRemoteBrowserList,
};

View file

@ -1,10 +1,10 @@
const axios = require("axios");
const { R } = require("redbean-node");
const version = require("../package.json").version;
const https = require("https");
const fs = require("fs");
const path = require("path");
const Database = require("./database");
const { axiosAbortSignal } = require("./util-server");
class DockerHost {
@ -70,9 +70,11 @@ class DockerHost {
static async testDockerHost(dockerHost) {
const options = {
url: "/containers/json?all=true",
timeout: 5000,
headers: {
"Accept": "*/*",
},
signal: axiosAbortSignal(6000),
};
if (dockerHost.dockerType === "socket") {
@ -82,26 +84,33 @@ class DockerHost {
options.httpsAgent = new https.Agent(DockerHost.getHttpsAgentOptions(dockerHost.dockerType, options.baseURL));
}
let res = await axios.request(options);
try {
let res = await axios.request(options);
if (Array.isArray(res.data)) {
if (Array.isArray(res.data)) {
if (res.data.length > 1) {
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?");
}
if ("ImageID" in res.data[0]) {
return res.data.length;
} else {
throw new Error("Invalid Docker response, is it Docker really a daemon?");
return res.data.length;
}
} else {
return res.data.length;
throw new Error("Invalid Docker response, is it Docker really a daemon?");
}
} catch (e) {
if (e.code === "ECONNABORTED" || e.name === "CanceledError") {
throw new Error("Connection to Docker daemon timed out.");
} else {
throw e;
}
} else {
throw new Error("Invalid Docker response, is it Docker really a daemon?");
}
}
/**

View file

@ -29,13 +29,14 @@ class Heartbeat extends BeanModel {
*/
toJSON() {
return {
monitorID: this.monitor_id,
status: this.status,
time: this.time,
msg: this.msg,
ping: this.ping,
important: this.important,
duration: this.duration,
monitorID: this._monitorId,
status: this._status,
time: this._time,
msg: this._msg,
ping: this._ping,
important: this._important,
duration: this._duration,
retries: this._retries,
};
}

View file

@ -1,4 +1,3 @@
const https = require("https");
const dayjs = require("dayjs");
const axios = require("axios");
const { Prometheus } = require("../prometheus");
@ -23,6 +22,8 @@ const jsonata = require("jsonata");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const { UptimeCalculator } = require("../uptime-calculator");
const { CookieJar } = require("tough-cookie");
const { HttpsCookieAgent } = require("http-cookie-agent/http");
const rootCertificates = rootCertificatesFingerprints();
@ -153,6 +154,7 @@ class Monitor extends BeanModel {
kafkaProducerAllowAutoTopicCreation: this.getKafkaProducerAllowAutoTopicCreation(),
kafkaProducerMessage: this.kafkaProducerMessage,
screenshot,
remote_browser: this.remote_browser,
};
if (includeSensitiveData) {
@ -351,6 +353,9 @@ class Monitor extends BeanModel {
previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [
this.id,
]);
if (previousBeat) {
retries = previousBeat.retries;
}
}
const isFirstBeat = !previousBeat;
@ -504,7 +509,12 @@ class Monitor extends BeanModel {
}
if (!options.httpsAgent) {
options.httpsAgent = new https.Agent(httpsAgentOptions);
let jar = new CookieJar();
let httpsCookieAgentOptions = {
...httpsAgentOptions,
cookies: { jar }
};
options.httpsAgent = new HttpsCookieAgent(httpsCookieAgentOptions);
}
if (this.auth_method === "mtls") {
@ -622,6 +632,7 @@ class Monitor extends BeanModel {
// If the previous beat was down or pending we use the regular
// beatInterval/retryInterval in the setTimeout further below
if (previousBeat.status !== (this.isUpsideDown() ? DOWN : UP) || msSinceLastBeat > beatInterval * 1000 + bufferTime) {
bean.duration = Math.round(msSinceLastBeat / 1000);
throw new Error("No heartbeat in the time window");
} else {
let timeout = beatInterval * 1000 - msSinceLastBeat;
@ -637,6 +648,7 @@ class Monitor extends BeanModel {
return;
}
} else {
bean.duration = beatInterval;
throw new Error("No heartbeat in the time window");
}
@ -909,9 +921,14 @@ class Monitor extends BeanModel {
} else if ((this.maxretries > 0) && (retries < this.maxretries)) {
retries++;
bean.status = PENDING;
} else {
// Continue counting retries during DOWN
retries++;
}
}
bean.retries = retries;
log.debug("monitor", `[${this.name}] Check isImportant`);
let isImportant = Monitor.isImportantBeat(isFirstBeat, previousBeat?.status, bean.status);
@ -1425,10 +1442,7 @@ class Monitor extends BeanModel {
* @returns {Promise<LooseObject<any>>} Previous heartbeat
*/
static async getPreviousHeartbeat(monitorID) {
return await R.getRow(`
SELECT ping, status, time FROM heartbeat
WHERE id = (select MAX(id) from heartbeat where monitor_id = ?)
`, [
return await R.findOne("heartbeat", " id = (select MAX(id) from heartbeat where monitor_id = ?)", [
monitorID
]);
}

View file

@ -0,0 +1,17 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
class RemoteBrowser extends BeanModel {
/**
* Returns an object that ready to parse to JSON
* @returns {object} Object ready to parse
*/
toJSON() {
return {
id: this.id,
url: this.url,
name: this.name,
};
}
}
module.exports = RemoteBrowser;

View file

@ -8,6 +8,7 @@ const path = require("path");
const Database = require("../database");
const jwt = require("jsonwebtoken");
const config = require("../config");
const { RemoteBrowser } = require("../remote-browser");
let browser = null;
@ -86,6 +87,19 @@ async function getBrowser() {
return browser;
}
/**
* Get the current instance of the browser. If there isn't one, create it
* @param {integer} remoteBrowserID Path to executable
* @param {integer} userId User ID
* @returns {Promise<Browser>} The browser
*/
async function getRemoteBrowser(remoteBrowserID, userId) {
let remoteBrowser = await RemoteBrowser.get(remoteBrowserID, userId);
log.debug("MONITOR", `Using remote browser: ${remoteBrowser.name} (${remoteBrowser.id})`);
browser = chromium.connect(remoteBrowser.url);
return browser;
}
/**
* Prepare the chrome executable path
* @param {string} executablePath Path to chrome executable
@ -192,11 +206,21 @@ async function testChrome(executablePath) {
throw new Error(e.message);
}
}
// test remote browser
/**
* TODO: connect remote browser? https://playwright.dev/docs/api/class-browsertype#browser-type-connect
*
* @param {string} remoteBrowserURL Remote Browser URL
* @returns {Promise<boolean>} Returns if connection worked
*/
async function testRemoteBrowser(remoteBrowserURL) {
try {
const browser = await chromium.connect(remoteBrowserURL);
browser.version();
await browser.close();
return true;
} catch (e) {
throw new Error(e.message);
}
}
class RealBrowserMonitorType extends MonitorType {
name = "real-browser";
@ -205,7 +229,7 @@ class RealBrowserMonitorType extends MonitorType {
* @inheritdoc
*/
async check(monitor, heartbeat, server) {
const browser = await getBrowser();
const browser = monitor.remote_browser ? await getRemoteBrowser(monitor.remote_browser, monitor.user_id) : await getBrowser();
const context = await browser.newContext();
const page = await context.newPage();
@ -238,4 +262,5 @@ module.exports = {
RealBrowserMonitorType,
testChrome,
resetChrome,
testRemoteBrowser,
};

View file

@ -78,12 +78,12 @@ class Mattermost extends NotificationProvider {
{
fallback:
"Your " +
monitorJSON.name +
monitorJSON.pathName +
" service went " +
statusText,
color: color,
title:
monitorJSON.name +
monitorJSON.pathName +
" service went " +
statusText,
title_link: monitorJSON.url,

View file

@ -4,6 +4,8 @@ const HttpsProxyAgent = require("https-proxy-agent");
const SocksProxyAgent = require("socks-proxy-agent");
const { debug } = require("../src/util");
const { UptimeKumaServer } = require("./uptime-kuma-server");
const { CookieJar } = require("tough-cookie");
const { createCookieAgent } = require("http-cookie-agent/http");
class Proxy {
@ -95,10 +97,13 @@ class Proxy {
let httpAgent;
let httpsAgent;
let jar = new CookieJar();
const proxyOptions = {
protocol: proxy.protocol,
host: proxy.host,
port: proxy.port,
cookies: { jar },
};
if (proxy.auth) {
@ -112,12 +117,17 @@ class Proxy {
switch (proxy.protocol) {
case "http":
case "https":
httpAgent = new HttpProxyAgent({
// eslint-disable-next-line no-case-declarations
const HttpCookieProxyAgent = createCookieAgent(HttpProxyAgent);
// eslint-disable-next-line no-case-declarations
const HttpsCookieProxyAgent = createCookieAgent(HttpsProxyAgent);
httpAgent = new HttpCookieProxyAgent({
...httpAgentOptions || {},
...proxyOptions
...proxyOptions,
});
httpsAgent = new HttpsProxyAgent({
httpsAgent = new HttpsCookieProxyAgent({
...httpsAgentOptions || {},
...proxyOptions,
});
@ -126,7 +136,9 @@ class Proxy {
case "socks5":
case "socks5h":
case "socks4":
agent = new SocksProxyAgent({
// eslint-disable-next-line no-case-declarations
const SocksCookieProxyAgent = createCookieAgent(SocksProxyAgent);
agent = new SocksCookieProxyAgent({
...httpAgentOptions,
...httpsAgentOptions,
...proxyOptions,

84
server/remote-browser.js Normal file
View file

@ -0,0 +1,84 @@
const { R } = require("redbean-node");
const { testRemoteBrowser } = require("./monitor-types/real-browser-monitor-type.js");
class RemoteBrowser {
/**
* Gets remote browser from ID
* @param {number} remoteBrowserID ID of the remote browser
* @param {number} userID ID of the user who created the remote browser
* @returns {Promise<Bean>} Remote Browser
*/
static async get(remoteBrowserID, userID) {
let bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]);
if (!bean) {
throw new Error("Remote browser not found");
}
return bean;
}
/**
* Save a Remote Browser
* @param {object} remoteBrowser Remote Browser to save
* @param {?number} remoteBrowserID ID of the Remote Browser to update
* @param {number} userID ID of the user who adds the Remote Browser
* @returns {Promise<Bean>} Updated Remote Browser
*/
static async save(remoteBrowser, remoteBrowserID, userID) {
let bean;
if (remoteBrowserID) {
bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]);
if (!bean) {
throw new Error("Remote browser not found");
}
} else {
bean = R.dispense("remote_browser");
}
bean.user_id = userID;
bean.name = remoteBrowser.name;
bean.url = remoteBrowser.url;
await R.store(bean);
return bean;
}
/**
* Delete a Remote Browser
* @param {number} remoteBrowserID ID of the Remote Browser to delete
* @param {number} userID ID of the user who created the Remote Browser
* @returns {Promise<void>}
*/
static async delete(remoteBrowserID, userID) {
let bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]);
if (!bean) {
throw new Error("Remote Browser not found");
}
// Delete removed remote browser from monitors if exists
await R.exec("UPDATE monitor SET remote_browser = null WHERE remote_browser = ?", [ remoteBrowserID ]);
await R.trash(bean);
}
/**
* Tests the connection to Remote Browser
* @param {object} remoteBrowser Docker host to check for
* @returns {boolean} Returns if connection worked
*/
static async test(remoteBrowser) {
const testResult = await testRemoteBrowser(remoteBrowser.id, remoteBrowser.user_id);
return testResult;
}
}
module.exports = {
RemoteBrowser,
};

View file

@ -64,38 +64,57 @@ router.get("/api/push/:pushToken", async (request, response) => {
const previousHeartbeat = await Monitor.getPreviousHeartbeat(monitor.id);
if (monitor.isUpsideDown()) {
status = flipStatus(status);
}
let isFirstBeat = true;
let previousStatus = status;
let duration = 0;
let bean = R.dispense("heartbeat");
bean.time = R.isoDateTimeMillis(dayjs.utc());
bean.monitor_id = monitor.id;
bean.ping = ping;
bean.msg = msg;
bean.downCount = previousHeartbeat?.downCount || 0;
if (previousHeartbeat) {
isFirstBeat = false;
previousStatus = previousHeartbeat.status;
duration = dayjs(bean.time).diff(dayjs(previousHeartbeat.time), "second");
bean.duration = dayjs(bean.time).diff(dayjs(previousHeartbeat.time), "second");
}
if (await Monitor.isUnderMaintenance(monitor.id)) {
msg = "Monitor under maintenance";
status = MAINTENANCE;
bean.status = MAINTENANCE;
} else {
determineStatus(status, previousHeartbeat, monitor.maxretries, monitor.isUpsideDown(), bean);
}
log.debug("router", `/api/push/ called at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`);
log.debug("router", "PreviousStatus: " + previousStatus);
log.debug("router", "Current Status: " + status);
// Calculate uptime
let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitor.id);
let endTimeDayjs = await uptimeCalculator.update(bean.status, parseFloat(bean.ping));
bean.end_time = R.isoDateTimeMillis(endTimeDayjs);
bean.important = Monitor.isImportantBeat(isFirstBeat, previousStatus, status);
bean.monitor_id = monitor.id;
bean.status = status;
bean.msg = msg;
bean.ping = ping;
bean.duration = duration;
log.debug("router", `/api/push/ called at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`);
log.debug("router", "PreviousStatus: " + previousHeartbeat?.status);
log.debug("router", "Current Status: " + bean.status);
bean.important = Monitor.isImportantBeat(isFirstBeat, previousHeartbeat?.status, status);
if (Monitor.isImportantForNotification(isFirstBeat, previousHeartbeat?.status, status)) {
// Reset down count
bean.downCount = 0;
log.debug("monitor", `[${this.name}] sendNotification`);
await Monitor.sendNotification(isFirstBeat, monitor, bean);
} else {
if (bean.status === DOWN && this.resendInterval > 0) {
++bean.downCount;
if (bean.downCount >= this.resendInterval) {
// Send notification again, because we are still DOWN
log.debug("monitor", `[${this.name}] sendNotification again: Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`);
await Monitor.sendNotification(isFirstBeat, this, bean);
// Reset down count
bean.downCount = 0;
}
}
}
await R.store(bean);
@ -107,11 +126,6 @@ router.get("/api/push/:pushToken", async (request, response) => {
response.json({
ok: true,
});
if (Monitor.isImportantForNotification(isFirstBeat, previousStatus, status)) {
await Monitor.sendNotification(isFirstBeat, monitor, bean);
}
} catch (e) {
response.status(404).json({
ok: false,
@ -562,4 +576,58 @@ router.get("/api/badge/:id/response", cache("5 minutes"), async (request, respon
}
});
/**
* Determines the status of the next beat in the push route handling.
* @param {string} status - The reported new status.
* @param {object} previousHeartbeat - The previous heartbeat object.
* @param {number} maxretries - The maximum number of retries allowed.
* @param {boolean} isUpsideDown - Indicates if the monitor is upside down.
* @param {object} bean - The new heartbeat object.
* @returns {void}
*/
function determineStatus(status, previousHeartbeat, maxretries, isUpsideDown, bean) {
if (isUpsideDown) {
status = flipStatus(status);
}
if (previousHeartbeat) {
if (previousHeartbeat.status === UP && status === DOWN) {
// Going Down
if ((maxretries > 0) && (previousHeartbeat.retries < maxretries)) {
// Retries available
bean.retries = previousHeartbeat.retries + 1;
bean.status = PENDING;
} else {
// No more retries
bean.retries = 0;
bean.status = DOWN;
}
} else if (previousHeartbeat.status === PENDING && status === DOWN && previousHeartbeat.retries < maxretries) {
// Retries available
bean.retries = previousHeartbeat.retries + 1;
bean.status = PENDING;
} else {
// No more retries or not pending
if (status === DOWN) {
bean.retries = previousHeartbeat.retries + 1;
bean.status = status;
} else {
bean.retries = 0;
bean.status = status;
}
}
} else {
// First beat?
if (status === DOWN && maxretries > 0) {
// Retries available
bean.retries = 1;
bean.status = PENDING;
} else {
// Retires not enabled
bean.retries = 0;
bean.status = status;
}
}
}
module.exports = router;

View file

@ -131,9 +131,10 @@ const testMode = !!args["test"] || false;
const e2eTestMode = !!args["e2e"] || false;
// Must be after io instantiation
const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList } = require("./client");
const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList, sendRemoteBrowserList } = require("./client");
const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler");
const databaseSocketHandler = require("./socket-handlers/database-socket-handler");
const { remoteBrowserSocketHandler } = require("./socket-handlers/remote-browser-socket-handler");
const TwoFA = require("./2fa");
const StatusPage = require("./model/status_page");
const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart, stop: cloudflaredStop } = require("./socket-handlers/cloudflared-socket-handler");
@ -827,6 +828,7 @@ let needSetup = false;
bean.kafkaProducerAllowAutoTopicCreation =
monitor.kafkaProducerAllowAutoTopicCreation;
bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly;
bean.remote_browser = monitor.remote_browser;
bean.validate();
@ -1508,6 +1510,7 @@ let needSetup = false;
dockerSocketHandler(socket);
maintenanceSocketHandler(socket);
apiKeySocketHandler(socket);
remoteBrowserSocketHandler(socket);
generalSocketHandler(socket, server);
log.debug("server", "added all socket handlers");
@ -1616,6 +1619,7 @@ async function afterLogin(socket, user) {
sendProxyList(socket);
sendDockerHostList(socket);
sendAPIKeyList(socket);
sendRemoteBrowserList(socket);
await sleep(500);

View file

@ -0,0 +1,82 @@
const { sendRemoteBrowserList } = require("../client");
const { checkLogin } = require("../util-server");
const { RemoteBrowser } = require("../remote-browser");
const { log } = require("../../src/util");
const { testRemoteBrowser } = require("../monitor-types/real-browser-monitor-type");
/**
* Handlers for docker hosts
* @param {Socket} socket Socket.io instance
* @returns {void}
*/
module.exports.remoteBrowserSocketHandler = (socket) => {
socket.on("addRemoteBrowser", async (remoteBrowser, remoteBrowserID, callback) => {
try {
checkLogin(socket);
let remoteBrowserBean = await RemoteBrowser.save(remoteBrowser, remoteBrowserID, socket.userID);
await sendRemoteBrowserList(socket);
callback({
ok: true,
msg: "Saved.",
msgi18n: true,
id: remoteBrowserBean.id,
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("deleteRemoteBrowser", async (dockerHostID, callback) => {
try {
checkLogin(socket);
await RemoteBrowser.delete(dockerHostID, socket.userID);
await sendRemoteBrowserList(socket);
callback({
ok: true,
msg: "successDeleted",
msgi18n: true,
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("testRemoteBrowser", async (remoteBrowser, callback) => {
try {
checkLogin(socket);
let check = await testRemoteBrowser(remoteBrowser.url);
log.info("remoteBrowser", "Tested remote browser: " + check);
let msg;
if (check) {
msg = "Connected Successfully.";
}
callback({
ok: true,
msg,
});
} catch (e) {
log.error("remoteBrowser", e);
callback({
ok: false,
msg: e.message,
});
}
});
};

View file

@ -0,0 +1,185 @@
<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("Add a Remote Browser") }}
</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="remote-browser-name" class="form-label">{{ $t("Friendly Name") }}</label>
<input id="remote-browser-name" v-model="remoteBrowser.name" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="remote-browser-url" class="form-label">{{ $t("URL") }}</label>
<input id="remote-browser-url" v-model="remoteBrowser.url" type="text" class="form-control" required>
<div class="form-text mt-3">
{{ $t("Examples") }}:
<ul>
<li>ws://chrome.browserless.io/playwright?token=YOUR-API-TOKEN</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("deleteRemoteBrowserMessage") }}
</Confirm>
</template>
<script>
import { Modal } from "bootstrap";
import Confirm from "./Confirm.vue";
export default {
components: {
Confirm,
},
props: {},
emits: [ "added" ],
data() {
return {
modal: null,
processing: false,
id: null,
remoteBrowser: {
name: "",
url: "",
// Do not set default value here, please scroll to show()
}
};
},
mounted() {
this.modal = new Modal(this.$refs.modal);
},
methods: {
/**
* Confirm deletion of docker host
* @returns {void}
*/
deleteConfirm() {
this.modal.hide();
this.$refs.confirmDelete.show();
},
/**
* Show specified docker host
* @param {number} remoteBrowserID ID of host to show
* @returns {void}
*/
show(remoteBrowserID) {
if (remoteBrowserID) {
let found = false;
this.id = remoteBrowserID;
for (let n of this.$root.remoteBrowserList) {
if (n.id === remoteBrowserID) {
this.remoteBrowser = n;
found = true;
break;
}
}
if (!found) {
this.$root.toastError(this.$t("Remote Browser not found!"));
}
} else {
this.id = null;
this.remoteBrowser = {
name: "",
url: "",
};
}
this.modal.show();
},
/**
* Add docker host
* @returns {void}
*/
submit() {
this.processing = true;
this.$root.getSocket().emit("addRemoteBrowser", this.remoteBrowser, 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 the docker host
* @returns {void}
*/
test() {
this.processing = true;
this.$root.getSocket().emit("testRemoteBrowser", this.remoteBrowser, (res) => {
this.$root.toastRes(res);
this.processing = false;
});
},
/**
* Delete this docker host
* @returns {void}
*/
deleteDockerHost() {
this.processing = true;
this.$root.getSocket().emit("deleteRemoteBrowser", 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>

View file

@ -0,0 +1,53 @@
<template>
<div>
<div class="dockerHost-list my-4">
<p v-if="$root.remoteBrowserList.length === 0">
{{ $t("Not available, please setup.") }}
</p>
<ul class="list-group mb-3" style="border-radius: 1rem;">
<li v-for="(remoteBrowser, index) in $root.remoteBrowserList" :key="index" class="list-group-item">
{{ remoteBrowser.name }}<br>
<a href="#" @click="$refs.remoteBrowserDialog.show(remoteBrowser.id)">{{ $t("Edit") }}</a>
</li>
</ul>
<button class="btn btn-primary me-2" type="button" @click="$refs.remoteBrowserDialog.show()">
<font-awesome-icon icon="plus" /> {{ $t("Add Remote Browser") }}
</button>
</div>
<div class="my-4 pt-4">
<h5 class="my-4 settings-subheading">{{ $t("What is a Remote Browser?") }}</h5>
<p>{{ $t("remoteBrowsersDescription") }} <a href="https://hub.docker.com/r/browserless/chrome">{{ $t("self-hosted container") }}</a></p>
</div>
<RemoteBrowserDialog ref="remoteBrowserDialog" />
</div>
</template>
<script>
import RemoteBrowserDialog from "../../components/RemoteBrowserDialog.vue";
export default {
components: {
RemoteBrowserDialog,
},
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>

View file

@ -865,6 +865,15 @@
"successEnabled": "Enabled Successfully.",
"tagNotFound": "Tag not found.",
"foundChromiumVersion": "Found Chromium/Chrome. Version: {0}",
"Remote Browsers": "Remote Browsers",
"Remote Browser": "Remote Browser",
"Add a Remote Browser": "Add a Remote Browser",
"Remote Browser not found!": "Remote Browser not found!",
"remoteBrowsersDescription": "Remote Browsers are an alternative to running Chromium locally. Setup with a service like browserless.io or connect to your own",
"self-hosted container": "self-hosted container",
"remoteBrowserToggle": "By default Chromium runs inside the Uptime Kuma container. You can use a remote browser by toggling this switch.",
"useRemoteBrowser": "Use a Remote Browser",
"deleteRemoteBrowserMessage": "Are you sure want to delete this Remote Browser for all monitors?",
"GrafanaOncallUrl": "Grafana Oncall URL",
"Browser Screenshot": "Browser Screenshot"
}

View file

@ -46,6 +46,7 @@ export default {
tlsInfoList: {},
notificationList: [],
dockerHostList: [],
remoteBrowserList: [],
statusPageListLoaded: false,
statusPageList: [],
proxyList: [],
@ -174,6 +175,10 @@ export default {
this.dockerHostList = data;
});
socket.on("remoteBrowserList", (data) => {
this.remoteBrowserList = data;
});
socket.on("heartbeat", (data) => {
if (! (data.monitorID in this.heartbeatList)) {
this.heartbeatList[data.monitorID] = [];

View file

@ -144,6 +144,30 @@
</div>
</div>
<!-- Remote Browser -->
<div v-if="monitor.type === 'real-browser'" class="my-3">
<!-- Toggle -->
<div class="my-3 form-check">
<input id="toggle" v-model="remoteBrowsersToggle" class="form-check-input" type="checkbox">
<label class="form-check-label" for="toggle">
{{ $t("useRemoteBrowser") }}
</label>
<div class="form-text">
{{ $t("remoteBrowserToggle") }}
</div>
</div>
<div v-if="remoteBrowsersToggle">
<label for="remote-browser" class="form-label">{{ $t("Remote Browser") }}</label>
<ActionSelect
v-model="monitor.remote_browser"
:options="remoteBrowsersOptions"
icon="plus"
:action="() => $refs.remoteBrowserDialog.show()"
/>
</div>
</div>
<!-- Json Query -->
<div v-if="monitor.type === 'json-query'" class="my-3">
<label for="jsonPath" class="form-label">{{ $t("Json Query") }}</label>
@ -838,6 +862,7 @@
<DockerHostDialog ref="dockerHostDialog" @added="addedDockerHost" />
<ProxyDialog ref="proxyDialog" @added="addedProxy" />
<CreateGroupDialog ref="createGroupDialog" @added="addedDraftGroup" />
<RemoteBrowserDialog ref="remoteBrowserDialog" @added="addedRemoteBrowser" />
</div>
</transition>
</template>
@ -850,6 +875,7 @@ import CopyableInput from "../components/CopyableInput.vue";
import CreateGroupDialog from "../components/CreateGroupDialog.vue";
import NotificationDialog from "../components/NotificationDialog.vue";
import DockerHostDialog from "../components/DockerHostDialog.vue";
import RemoteBrowserDialog from "../components/RemoteBrowserDialog.vue";
import ProxyDialog from "../components/ProxyDialog.vue";
import TagsManager from "../components/TagsManager.vue";
import { genSecret, isDev, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND } from "../util.ts";
@ -898,6 +924,7 @@ const monitorDefaults = {
kafkaProducerSsl: false,
kafkaProducerAllowAutoTopicCreation: false,
gamedigGivenPortOnly: true,
remote_browser: null
};
export default {
@ -909,6 +936,7 @@ export default {
CreateGroupDialog,
NotificationDialog,
DockerHostDialog,
RemoteBrowserDialog,
TagsManager,
VueMultiselect,
},
@ -936,6 +964,7 @@ export default {
"mongodb": "mongodb://username:password@host:port/database",
},
draftGroupName: null,
remoteBrowsersEnabled: false,
};
},
@ -959,7 +988,31 @@ export default {
}
return this.$t(name);
},
remoteBrowsersOptions() {
return this.$root.remoteBrowserList.map(browser => {
return {
label: browser.name,
value: browser.id,
};
});
},
remoteBrowsersToggle: {
get() {
return this.remoteBrowsersEnabled || this.monitor.remote_browser != null;
},
set(value) {
if (value) {
this.remoteBrowsersEnabled = true;
if (this.monitor.remote_browser == null && this.$root.remoteBrowserList.length > 0) {
// set a default remote browser if there is one. Otherwise, the user will have to select one manually.
this.monitor.remote_browser = this.$root.remoteBrowserList[0].id;
}
} else {
this.remoteBrowsersEnabled = false;
this.monitor.remote_browser = null;
}
}
},
isAdd() {
return this.$route.path === "/add";
},

View file

@ -104,6 +104,9 @@ export default {
"docker-hosts": {
title: this.$t("Docker Hosts"),
},
"remote-browsers": {
title: this.$t("Remote Browsers"),
},
security: {
title: this.$t("Security"),
},

View file

@ -31,6 +31,7 @@ import MonitorHistory from "./components/settings/MonitorHistory.vue";
const Security = () => import("./components/settings/Security.vue");
import Proxies from "./components/settings/Proxies.vue";
import About from "./components/settings/About.vue";
import RemoteBrowsers from "./components/settings/RemoteBrowsers.vue";
const routes = [
{
@ -113,6 +114,10 @@ const routes = [
path: "docker-hosts",
component: DockerHosts,
},
{
path: "remote-browsers",
component: RemoteBrowsers,
},
{
path: "security",
component: Security,