Real browser monitor type (#3308)

This commit is contained in:
Louis Lam 2023-06-27 15:54:33 +08:00 committed by GitHub
parent dd77baabe1
commit 4f6035899d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 299 additions and 24 deletions

View file

@ -26,6 +26,8 @@ RUN chmod +x /app/extra/entrypoint.sh
FROM louislam/uptime-kuma:base-debian AS release FROM louislam/uptime-kuma:base-debian AS release
WORKDIR /app WORKDIR /app
ENV UPTIME_KUMA_IS_CONTAINER=1
# Copy app files from build layer # Copy app files from build layer
COPY --from=build /app /app COPY --from=build /app /app

12
package-lock.json generated
View file

@ -53,6 +53,7 @@
"password-hash": "~1.2.2", "password-hash": "~1.2.2",
"pg": "~8.8.0", "pg": "~8.8.0",
"pg-connection-string": "~2.5.0", "pg-connection-string": "~2.5.0",
"playwright-core": "~1.35.1",
"prom-client": "~13.2.0", "prom-client": "~13.2.0",
"prometheus-api-metrics": "~3.2.1", "prometheus-api-metrics": "~3.2.1",
"protobufjs": "~7.1.1", "protobufjs": "~7.1.1",
@ -15576,6 +15577,17 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/playwright-core": {
"version": "1.35.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.35.1.tgz",
"integrity": "sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/pngjs": { "node_modules/pngjs": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",

View file

@ -112,6 +112,7 @@
"password-hash": "~1.2.2", "password-hash": "~1.2.2",
"pg": "~8.8.0", "pg": "~8.8.0",
"pg-connection-string": "~2.5.0", "pg-connection-string": "~2.5.0",
"playwright-core": "~1.35.1",
"prom-client": "~13.2.0", "prom-client": "~13.2.0",
"prometheus-api-metrics": "~3.2.1", "prometheus-api-metrics": "~3.2.1",
"protobufjs": "~7.1.1", "protobufjs": "~7.1.1",

View file

@ -22,6 +22,8 @@ class Database {
*/ */
static uploadDir; static uploadDir;
static screenshotDir;
static path; static path;
/** /**
@ -105,6 +107,12 @@ class Database {
fs.mkdirSync(Database.uploadDir, { recursive: true }); fs.mkdirSync(Database.uploadDir, { recursive: true });
} }
// Create screenshot dir
Database.screenshotDir = Database.dataDir + "screenshots/";
if (! fs.existsSync(Database.screenshotDir)) {
fs.mkdirSync(Database.screenshotDir, { recursive: true });
}
log.info("db", `Data Dir: ${Database.dataDir}`); log.info("db", `Data Dir: ${Database.dataDir}`);
} }

View file

@ -20,6 +20,7 @@ const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent");
const { DockerHost } = require("../docker"); const { DockerHost } = require("../docker");
const { UptimeCacheList } = require("../uptime-cache-list"); const { UptimeCacheList } = require("../uptime-cache-list");
const Gamedig = require("gamedig"); const Gamedig = require("gamedig");
const jwt = require("jsonwebtoken");
/** /**
* status: * status:
@ -70,6 +71,12 @@ class Monitor extends BeanModel {
const tags = await this.getTags(); const tags = await this.getTags();
let screenshot = null;
if (this.type === "real-browser") {
screenshot = "/screenshots/" + jwt.sign(this.id, UptimeKumaServer.getInstance().jwtSecret) + ".png";
}
let data = { let data = {
id: this.id, id: this.id,
name: this.name, name: this.name,
@ -117,7 +124,8 @@ class Monitor extends BeanModel {
radiusCalledStationId: this.radiusCalledStationId, radiusCalledStationId: this.radiusCalledStationId,
radiusCallingStationId: this.radiusCallingStationId, radiusCallingStationId: this.radiusCallingStationId,
game: this.game, game: this.game,
httpBodyEncoding: this.httpBodyEncoding httpBodyEncoding: this.httpBodyEncoding,
screenshot,
}; };
if (includeSensitiveData) { if (includeSensitiveData) {
@ -740,7 +748,7 @@ class Monitor extends BeanModel {
} else if (this.type in UptimeKumaServer.monitorTypeList) { } else if (this.type in UptimeKumaServer.monitorTypeList) {
let startTime = dayjs().valueOf(); let startTime = dayjs().valueOf();
const monitorType = UptimeKumaServer.monitorTypeList[this.type]; const monitorType = UptimeKumaServer.monitorTypeList[this.type];
await monitorType.check(this, bean); await monitorType.check(this, bean, UptimeKumaServer.getInstance());
if (!bean.ping) { if (!bean.ping) {
bean.ping = dayjs().valueOf() - startTime; bean.ping = dayjs().valueOf() - startTime;
} }

View file

@ -6,9 +6,10 @@ class MonitorType {
* *
* @param {Monitor} monitor * @param {Monitor} monitor
* @param {Heartbeat} heartbeat * @param {Heartbeat} heartbeat
* @param {UptimeKumaServer} server
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async check(monitor, heartbeat) { async check(monitor, heartbeat, server) {
throw new Error("You need to override check()"); throw new Error("You need to override check()");
} }

View file

@ -0,0 +1,164 @@
const { MonitorType } = require("./monitor-type");
const { chromium, Browser } = require("playwright-core");
const { UP, log } = require("../../src/util");
const { Settings } = require("../settings");
const commandExistsSync = require("command-exists").sync;
const childProcess = require("child_process");
const path = require("path");
const Database = require("../database");
const jwt = require("jsonwebtoken");
/**
*
* @type {Browser}
*/
let browser = null;
async function getBrowser() {
if (!browser) {
let executablePath = await Settings.get("chromeExecutable");
executablePath = await prepareChromeExecutable(executablePath);
browser = await chromium.launch({
//headless: false,
executablePath,
});
}
return browser;
}
async function prepareChromeExecutable(executablePath) {
// Special code for using the playwright_chromium
if (typeof executablePath === "string" && executablePath.toLocaleLowerCase() === "#playwright_chromium") {
executablePath = undefined;
} else if (!executablePath) {
if (process.env.UPTIME_KUMA_IS_CONTAINER) {
executablePath = "/usr/bin/chromium";
// Install chromium in container via apt install
if ( !commandExistsSync(executablePath)) {
await new Promise((resolve, reject) => {
log.info("Chromium", "Installing Chromium...");
let child = childProcess.exec("apt update && apt --yes --no-install-recommends install chromium fonts-indic fonts-noto fonts-noto-cjk");
// On exit
child.on("exit", (code) => {
log.info("Chromium", "apt install chromium exited with code " + code);
if (code === 0) {
log.info("Chromium", "Installed Chromium");
let version = childProcess.execSync(executablePath + " --version").toString("utf8");
log.info("Chromium", "Chromium version: " + version);
resolve();
} else if (code === 100) {
reject(new Error("Installing Chromium, please wait..."));
} else {
reject(new Error("apt install chromium failed with code " + code));
}
});
});
}
} else if (process.platform === "win32") {
executablePath = findChrome([
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"D:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"D:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"E:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"E:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
]);
} else if (process.platform === "linux") {
executablePath = findChrome([
"chromium-browser",
"chromium",
"google-chrome",
]);
}
// TODO: Mac??
}
return executablePath;
}
function findChrome(executables) {
for (let executable of executables) {
if (commandExistsSync(executable)) {
return executable;
}
}
throw new Error("Chromium not found, please specify Chromium executable path in the settings page.");
}
async function resetChrome() {
if (browser) {
await browser.close();
browser = null;
}
}
/**
* Test if the chrome executable is valid and return the version
* @param executablePath
* @returns {Promise<string>}
*/
async function testChrome(executablePath) {
try {
executablePath = await prepareChromeExecutable(executablePath);
log.info("Chromium", "Testing Chromium executable: " + executablePath);
const browser = await chromium.launch({
executablePath,
});
const version = browser.version();
await browser.close();
return version;
} catch (e) {
throw new Error(e.message);
}
}
/**
* TODO: connect remote browser? https://playwright.dev/docs/api/class-browsertype#browser-type-connect
*
*/
class RealBrowserMonitorType extends MonitorType {
name = "real-browser";
async check(monitor, heartbeat, server) {
const browser = await getBrowser();
const context = await browser.newContext();
const page = await context.newPage();
const res = await page.goto(monitor.url, {
waitUntil: "networkidle",
timeout: monitor.interval * 1000 * 0.8,
});
let filename = jwt.sign(monitor.id, server.jwtSecret) + ".png";
await page.screenshot({
path: path.join(Database.screenshotDir, filename),
});
await context.close();
if (res.status() >= 200 && res.status() < 400) {
heartbeat.status = UP;
heartbeat.msg = res.status();
const timing = res.request().timing();
heartbeat.ping = timing.responseEnd;
} else {
throw new Error(res.status() + "");
}
}
}
module.exports = {
RealBrowserMonitorType,
testChrome,
resetChrome,
};

View file

@ -149,6 +149,7 @@ const { Settings } = require("./settings");
const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
const { pluginsHandler } = require("./socket-handlers/plugins-handler"); const { pluginsHandler } = require("./socket-handlers/plugins-handler");
const apicache = require("./modules/apicache"); const apicache = require("./modules/apicache");
const { resetChrome } = require("./monitor-types/real-browser-monitor-type");
app.use(express.json()); app.use(express.json());
@ -161,12 +162,6 @@ app.use(function (req, res, next) {
next(); next();
}); });
/**
* Use for decode the auth object
* @type {null}
*/
let jwtSecret = null;
/** /**
* Show Setup Page * Show Setup Page
* @type {boolean} * @type {boolean}
@ -286,7 +281,7 @@ let needSetup = false;
log.info("auth", `Login by token. IP=${clientIP}`); log.info("auth", `Login by token. IP=${clientIP}`);
try { try {
let decoded = jwt.verify(token, jwtSecret); let decoded = jwt.verify(token, server.jwtSecret);
log.info("auth", "Username from JWT: " + decoded.username); log.info("auth", "Username from JWT: " + decoded.username);
@ -357,7 +352,7 @@ let needSetup = false;
ok: true, ok: true,
token: jwt.sign({ token: jwt.sign({
username: data.username, username: data.username,
}, jwtSecret), }, server.jwtSecret),
}); });
} }
@ -387,7 +382,7 @@ let needSetup = false;
ok: true, ok: true,
token: jwt.sign({ token: jwt.sign({
username: data.username, username: data.username,
}, jwtSecret), }, server.jwtSecret),
}); });
} else { } else {
@ -1158,6 +1153,8 @@ let needSetup = false;
await doubleCheckPassword(socket, currentPassword); await doubleCheckPassword(socket, currentPassword);
} }
const previousChromeExecutable = await Settings.get("chromeExecutable");
await setSettings("general", data); await setSettings("general", data);
server.entryPage = data.entryPage; server.entryPage = data.entryPage;
@ -1168,6 +1165,12 @@ let needSetup = false;
await server.setTimezone(data.serverTimezone); await server.setTimezone(data.serverTimezone);
} }
// If Chrome Executable is changed, need to reset the browser
if (previousChromeExecutable !== data.chromeExecutable) {
log.info("settings", "Chrome executable is changed. Resetting Chrome...");
await resetChrome();
}
callback({ callback({
ok: true, ok: true,
msg: "Saved" msg: "Saved"
@ -1707,7 +1710,7 @@ async function initDatabase(testMode = false) {
needSetup = true; needSetup = true;
} }
jwtSecret = jwtSecretBean.value; server.jwtSecret = jwtSecretBean.value;
} }
/** /**

View file

@ -3,6 +3,7 @@ const { Settings } = require("../settings");
const { sendInfo } = require("../client"); const { sendInfo } = require("../client");
const { checkLogin } = require("../util-server"); const { checkLogin } = require("../util-server");
const GameResolver = require("gamedig/lib/GameResolver"); const GameResolver = require("gamedig/lib/GameResolver");
const { testChrome } = require("../monitor-types/real-browser-monitor-type");
let gameResolver = new GameResolver(); let gameResolver = new GameResolver();
let gameList = null; let gameList = null;
@ -47,4 +48,18 @@ module.exports.generalSocketHandler = (socket, server) => {
}); });
}); });
socket.on("testChrome", (executable, callback) => {
// Just noticed that await call could block the whole socket.io server!!! Use pure promise instead.
testChrome(executable).then((version) => {
callback({
ok: true,
msg: "Found Chromium/Chrome. Version: " + version,
});
}).catch((e) => {
callback({
ok: false,
msg: e.message,
});
});
});
}; };

View file

@ -61,6 +61,12 @@ class UptimeKumaServer {
}; };
/**
* Use for decode the auth object
* @type {null}
*/
jwtSecret = null;
static getInstance(args) { static getInstance(args) {
if (UptimeKumaServer.instance == null) { if (UptimeKumaServer.instance == null) {
UptimeKumaServer.instance = new UptimeKumaServer(args); UptimeKumaServer.instance = new UptimeKumaServer(args);
@ -98,11 +104,17 @@ class UptimeKumaServer {
} }
} }
// Set Monitor Types
UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType();
this.io = new Server(this.httpServer); this.io = new Server(this.httpServer);
} }
/** Initialise app after the database has been set up */ /** Initialise app after the database has been set up */
async initAfterDatabaseReady() { async initAfterDatabaseReady() {
// Static
this.app.use("/screenshots", express.static(Database.screenshotDir));
await CacheableDnsHttpAgent.update(); await CacheableDnsHttpAgent.update();
process.env.TZ = await this.getTimezone(); process.env.TZ = await this.getTimezone();
@ -337,3 +349,4 @@ module.exports = {
// Must be at the end // Must be at the end
const { MonitorType } = require("./monitor-types/monitor-type"); const { MonitorType } = require("./monitor-types/monitor-type");
const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type");

View file

@ -190,6 +190,30 @@
</div> </div>
</div> </div>
<!-- Chrome Executable -->
<div class="mb-4">
<label class="form-label" for="primaryBaseURL">
{{ $t("chromeExecutable") }}
</label>
<div class="input-group mb-3">
<input
id="primaryBaseURL"
v-model="settings.chromeExecutable"
class="form-control"
name="primaryBaseURL"
:placeholder="$t('chromeExecutableAutoDetect')"
/>
<button class="btn btn-outline-primary" type="button" @click="testChrome">
{{ $t("Test") }}
</button>
</div>
<div class="form-text">
{{ $t("chromeExecutableDescription") }}
</div>
</div>
<!-- Save Button --> <!-- Save Button -->
<div> <div>
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit">
@ -241,6 +265,12 @@ export default {
autoGetPrimaryBaseURL() { autoGetPrimaryBaseURL() {
this.settings.primaryBaseURL = location.protocol + "//" + location.host; this.settings.primaryBaseURL = location.protocol + "//" + location.host;
}, },
testChrome() {
this.$root.getSocket().emit("testChrome", this.settings.chromeExecutable, (res) => {
this.$root.toastRes(res);
});
},
}, },
}; };
</script> </script>

View file

@ -435,6 +435,9 @@
"Enable DNS Cache": "Enable DNS Cache", "Enable DNS Cache": "Enable DNS Cache",
"Enable": "Enable", "Enable": "Enable",
"Disable": "Disable", "Disable": "Disable",
"chromeExecutable": "Chrome/Chromium Executable",
"chromeExecutableAutoDetect": "Auto Detect",
"chromeExecutableDescription": "For Docker users, if Chromium is not yet installed, it may take a few minutes to install and display the test result. It takes 1GB of disk space.",
"dnsCacheDescription": "It may be not working in some IPv6 environments, disable it if you encounter any issues.", "dnsCacheDescription": "It may be not working in some IPv6 environments, disable it if you encounter any issues.",
"Single Maintenance Window": "Single Maintenance Window", "Single Maintenance Window": "Single Maintenance Window",
"Maintenance Time Window of a Day": "Maintenance Time Window of a Day", "Maintenance Time Window of a Day": "Maintenance Time Window of a Day",

View file

@ -68,6 +68,7 @@
</div> </div>
</div> </div>
<!-- Stats -->
<div class="shadow-box big-padding text-center stats"> <div class="shadow-box big-padding text-center stats">
<div class="row"> <div class="row">
<div v-if="monitor.type !== 'group'" class="col-12 col-sm col row d-flex align-items-center d-sm-block"> <div v-if="monitor.type !== 'group'" class="col-12 col-sm col row d-flex align-items-center d-sm-block">
@ -131,6 +132,15 @@
</div> </div>
</div> </div>
<!-- Screenshot -->
<div v-if="monitor.type === 'real-browser'" class="shadow-box">
<div class="row">
<div class="col-md-6">
<img :src="screenshotURL" alt style="width: 100%;">
</div>
</div>
</div>
<div class="shadow-box table-shadow-box"> <div class="shadow-box table-shadow-box">
<div class="dropdown dropdown-clear-data"> <div class="dropdown dropdown-clear-data">
<button class="btn btn-sm btn-outline-danger dropdown-toggle" type="button" data-bs-toggle="dropdown"> <button class="btn btn-sm btn-outline-danger dropdown-toggle" type="button" data-bs-toggle="dropdown">
@ -217,6 +227,7 @@ import Tag from "../components/Tag.vue";
import CertificateInfo from "../components/CertificateInfo.vue"; import CertificateInfo from "../components/CertificateInfo.vue";
import { getMonitorRelativeURL } from "../util.ts"; import { getMonitorRelativeURL } from "../util.ts";
import { URL } from "whatwg-url"; import { URL } from "whatwg-url";
import { getResBaseURL } from "../util-frontend";
export default { export default {
components: { components: {
@ -242,6 +253,7 @@ export default {
hideCount: true, hideCount: true,
chunksNavigation: "scroll", chunksNavigation: "scroll",
}, },
cacheTime: Date.now(),
}; };
}, },
computed: { computed: {
@ -251,6 +263,10 @@ export default {
}, },
lastHeartBeat() { lastHeartBeat() {
// Also trigger screenshot refresh here
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.cacheTime = Date.now();
if (this.monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[this.monitor.id]) { if (this.monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[this.monitor.id]) {
return this.$root.lastHeartbeatList[this.monitor.id]; return this.$root.lastHeartbeatList[this.monitor.id];
} }
@ -325,11 +341,16 @@ export default {
pushURL() { pushURL() {
return this.$root.baseURL + "/api/push/" + this.monitor.pushToken + "?status=up&msg=OK&ping="; return this.$root.baseURL + "/api/push/" + this.monitor.pushToken + "?status=up&msg=OK&ping=";
}, },
screenshotURL() {
return getResBaseURL() + this.monitor.screenshot + "?time=" + this.cacheTime;
}
}, },
mounted() { mounted() {
}, },
methods: { methods: {
getResBaseURL,
/** Request a test notification be sent for this monitor */ /** Request a test notification be sent for this monitor */
testNotification() { testNotification() {
this.$root.getSocket().emit("testNotification", this.monitor.id); this.$root.getSocket().emit("testNotification", this.monitor.id);

View file

@ -36,6 +36,10 @@
<option value="docker"> <option value="docker">
{{ $t("Docker Container") }} {{ $t("Docker Container") }}
</option> </option>
<option value="real-browser">
HTTP(s) - Browser Engine (Chrome/Chromium) (Beta)
</option>
</optgroup> </optgroup>
<optgroup :label="$t('Passive Monitor Type')"> <optgroup :label="$t('Passive Monitor Type')">
@ -73,16 +77,6 @@
Redis Redis
</option> </option>
</optgroup> </optgroup>
<!--
Hidden for now: Reason refer to Setting.vue
<optgroup :label="$t('Custom Monitor Type')">
<option value="browser">
(Beta) HTTP(s) - Browser Engine (Chrome/Firefox)
</option>
</optgroup>
</select>
-->
</select> </select>
</div> </div>
@ -103,7 +97,7 @@
</div> </div>
<!-- URL --> <!-- URL -->
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'browser' " class="my-3"> <div v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'real-browser' " class="my-3">
<label for="url" class="form-label">{{ $t("URL") }}</label> <label for="url" class="form-label">{{ $t("URL") }}</label>
<input id="url" v-model="monitor.url" type="url" class="form-control" pattern="https?://.+" required> <input id="url" v-model="monitor.url" type="url" class="form-control" pattern="https?://.+" required>
</div> </div>