Feature: remote browser support (#3904)

* [empty commit] pull request for remote browser support

* Remote browser: Added UI screens and DB tables.

* Remote browser working

* Fixing tests

* Fix tests

* Fix tests

* fix tests

* Test browser

* revert init_db.js

* Changed drop down to ActionSelect

* Fix translations

* added remote browsers toggle

* revert changes package-lock

* Fix bad english

* Set default remote browser

* Remote browsers Requested changes

* fixed description.
pull/4142/head
Adam Hancock 2023-12-01 07:29:10 +00:00 committed by GitHub
parent 0294118cbf
commit 62780001f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 579 additions and 7 deletions

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

@ -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

@ -154,6 +154,7 @@ class Monitor extends BeanModel {
kafkaProducerAllowAutoTopicCreation: this.getKafkaProducerAllowAutoTopicCreation(),
kafkaProducerMessage: this.kafkaProducerMessage,
screenshot,
remote_browser: this.remote_browser,
};
if (includeSensitiveData) {

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;
@ -85,6 +86,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
@ -191,11 +205,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";
@ -204,7 +228,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();
@ -237,4 +261,5 @@ module.exports = {
RealBrowserMonitorType,
testChrome,
resetChrome,
testRemoteBrowser,
};

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

@ -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

@ -859,6 +859,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>
@ -834,6 +858,7 @@
<DockerHostDialog ref="dockerHostDialog" @added="addedDockerHost" />
<ProxyDialog ref="proxyDialog" @added="addedProxy" />
<CreateGroupDialog ref="createGroupDialog" @added="addedDraftGroup" />
<RemoteBrowserDialog ref="remoteBrowserDialog" @added="addedRemoteBrowser" />
</div>
</transition>
</template>
@ -846,6 +871,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";
@ -894,6 +920,7 @@ const monitorDefaults = {
kafkaProducerSsl: false,
kafkaProducerAllowAutoTopicCreation: false,
gamedigGivenPortOnly: true,
remote_browser: null
};
export default {
@ -905,6 +932,7 @@ export default {
CreateGroupDialog,
NotificationDialog,
DockerHostDialog,
RemoteBrowserDialog,
TagsManager,
VueMultiselect,
},
@ -932,6 +960,7 @@ export default {
"mongodb": "mongodb://username:password@host:port/database",
},
draftGroupName: null,
remoteBrowsersEnabled: false,
};
},
@ -955,7 +984,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,