@@ -4,7 +4,6 @@ WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY maxun-core ./maxun-core
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
30
README.md
30
README.md
@@ -15,11 +15,11 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="https://app.maxun.dev/?ref=ghread"><b>Go To App</b></a> |
|
||||
<a href="https://docs.maxun.dev/?ref=ghread"><b>Documentation</b></a> |
|
||||
<a href="https://www.maxun.dev/?ref=ghread"><b>Website</b></a> |
|
||||
<a href="https://discord.gg/5GbPjBUkws"><b>Discord</b></a> |
|
||||
<a href="https://x.com/maxun_io?ref=ghread"><b>Twitter</b></a> |
|
||||
<a href="https://docs.google.com/forms/d/e/1FAIpQLSdbD2uhqC4sbg4eLZ9qrFbyrfkXZ2XsI6dQ0USRCQNZNn5pzg/viewform"><b>Join Maxun Cloud</b></a> |
|
||||
<a href="https://www.youtube.com/@MaxunOSS?ref=ghread"><b>Watch Tutorials</b></a>
|
||||
<br />
|
||||
<br />
|
||||
@@ -30,7 +30,10 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web
|
||||
|
||||
<img src="https://static.scarf.sh/a.png?x-pxid=c12a77cc-855e-4602-8a0f-614b2d0da56a" />
|
||||
|
||||
# Installation
|
||||
# Getting Started
|
||||
The simplest & fastest way to get started is to use the hosted version: https://app.maxun.dev. Maxun Cloud deals with anti-bot detection, huge proxy network with automatic proxy rotation, and CAPTCHA solving.
|
||||
|
||||
# Local Installation
|
||||
1. Create a root folder for your project (e.g. 'maxun')
|
||||
2. Create a file named `.env` in the root folder of the project
|
||||
3. Example env file can be viewed [here](https://github.com/getmaxun/maxun/blob/master/ENVEXAMPLE). Copy all content of example env to your `.env` file.
|
||||
@@ -64,9 +67,8 @@ npm install
|
||||
# get back to the root directory
|
||||
cd ..
|
||||
|
||||
# make sure playwright is properly initialized
|
||||
npx playwright install
|
||||
npx playwright install-deps
|
||||
# install chromium and its dependencies
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
# get back to the root directory
|
||||
cd ..
|
||||
@@ -105,9 +107,7 @@ You can access the frontend at http://localhost:5173/ and backend at http://loca
|
||||
| `GOOGLE_REDIRECT_URI` | No | Redirect URI for handling Google OAuth responses. | Google login will not work. |
|
||||
| `AIRTABLE_CLIENT_ID` | No | Client ID for Airtable, used for Airtable integration authentication. | Airtable login will not work. |
|
||||
| `AIRTABLE_REDIRECT_URI` | No | Redirect URI for handling Airtable OAuth responses. | Airtable login will not work. |
|
||||
| `REDIS_HOST` | Yes | Host address of the Redis server, used by BullMQ for scheduling robots. | Redis connection will fail. |
|
||||
| `REDIS_PORT` | Yes | Port number for the Redis server. | Redis connection will fail. |
|
||||
| `REDIS_PASSWORD` | No | Password for Redis Authentication. Needed to authenticate with a password-protected Redis instance; | Redis will attempt to connect without authentication. |
|
||||
|
||||
| `MAXUN_TELEMETRY` | No | Disables telemetry to stop sending anonymous usage data. Keeping it enabled helps us understand how the product is used and assess the impact of any new changes. Please keep it enabled. | Telemetry data will not be collected. |
|
||||
|
||||
|
||||
@@ -132,13 +132,11 @@ BYOP (Bring Your Own Proxy) lets you connect external proxies to bypass anti-bot
|
||||
- ✨ Run Robots On A Specific Schedule
|
||||
- ✨ Turn Websites to APIs
|
||||
- ✨ Turn Websites to Spreadsheets
|
||||
- ✨ Adapt To Website Layout Changes (coming soon)
|
||||
- ✨ Extract Behind Login, With Two-Factor Authentication Support (coming soon)
|
||||
- ✨ Integrations (currently Google Sheet)
|
||||
- +++ A lot of amazing things soon!
|
||||
|
||||
# Cloud
|
||||
We offer a managed cloud version to run Maxun without having to manage the infrastructure and extract data at scale. Maxun cloud also deals with anti-bot detection, huge proxy network with automatic proxy rotation, and CAPTCHA solving. If this interests you, [join the cloud waitlist](https://docs.google.com/forms/d/e/1FAIpQLSdbD2uhqC4sbg4eLZ9qrFbyrfkXZ2XsI6dQ0USRCQNZNn5pzg/viewform) as we launch soon.
|
||||
- ✨ Adapt To Website Layout Changes
|
||||
- ✨ Extract Behind Login,
|
||||
- ✨ Bypass Two-Factor Authentication For Extract Behind Login (coming soon)
|
||||
- ✨ Integrations
|
||||
- +++ A lot of amazing things!
|
||||
|
||||
# Screenshots
|
||||

|
||||
@@ -152,7 +150,7 @@ We offer a managed cloud version to run Maxun without having to manage the infra
|
||||

|
||||
|
||||
# Note
|
||||
This project is in early stages of development. Your feedback is very important for us - we're actively working to improve the product. <a href="https://forms.gle/E8vRMVB7bUbsSktPA">Drop anonymous feedback here.</a>
|
||||
This project is in early stages of development. Your feedback is very important for us - we're actively working to improve the product. </a>
|
||||
|
||||
# License
|
||||
<p>
|
||||
|
||||
@@ -17,16 +17,6 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:6
|
||||
environment:
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
REDIS_PORT: ${REDIS_PORT}
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:${REDIS_PORT:-6379}"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
environment:
|
||||
@@ -61,7 +51,6 @@ services:
|
||||
mem_limit: 2g # Set a 2GB memory limit
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
- minio
|
||||
volumes:
|
||||
- /var/run/dbus:/var/run/dbus
|
||||
@@ -82,5 +71,4 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
redis_data:
|
||||
minio_data:
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "maxun-core",
|
||||
"version": "0.0.14",
|
||||
"version": "0.0.15",
|
||||
"description": "Core package for Maxun, responsible for data extraction",
|
||||
"main": "build/index.js",
|
||||
"typings": "build/index.d.ts",
|
||||
|
||||
@@ -523,49 +523,62 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3,
|
||||
|
||||
// Enhanced value extraction with context awareness
|
||||
function extractValue(element, attribute) {
|
||||
if (!element) return null;
|
||||
|
||||
// Get context-aware base URL
|
||||
const baseURL = element.ownerDocument?.location?.href || window.location.origin;
|
||||
if (!element) return null;
|
||||
|
||||
// Check shadow root first
|
||||
if (element.shadowRoot) {
|
||||
const shadowContent = element.shadowRoot.textContent;
|
||||
if (shadowContent?.trim()) {
|
||||
return shadowContent.trim();
|
||||
}
|
||||
// Get context-aware base URL
|
||||
const baseURL = element.ownerDocument?.location?.href || window.location.origin;
|
||||
|
||||
// Check shadow root first
|
||||
if (element.shadowRoot) {
|
||||
const shadowContent = element.shadowRoot.textContent;
|
||||
if (shadowContent?.trim()) {
|
||||
return shadowContent.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (attribute === 'innerText') {
|
||||
return element.innerText.trim();
|
||||
} else if (attribute === 'innerHTML') {
|
||||
return element.innerHTML.trim();
|
||||
} else if (attribute === 'src' || attribute === 'href') {
|
||||
if (attribute === 'href' && element.tagName !== 'A') {
|
||||
const parentElement = element.parentElement;
|
||||
if (parentElement && parentElement.tagName === 'A') {
|
||||
const parentHref = parentElement.getAttribute('href');
|
||||
if (parentHref) {
|
||||
try {
|
||||
return new URL(parentHref, baseURL).href;
|
||||
} catch (e) {
|
||||
return parentHref;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const attrValue = element.getAttribute(attribute);
|
||||
const dataAttr = attrValue || element.getAttribute('data-' + attribute);
|
||||
|
||||
if (!dataAttr || dataAttr.trim() === '') {
|
||||
if (attribute === 'src') {
|
||||
const style = window.getComputedStyle(element);
|
||||
const bgImage = style.backgroundImage;
|
||||
if (bgImage && bgImage !== 'none') {
|
||||
const matches = bgImage.match(/url\(['"]?([^'")]+)['"]?\)/);
|
||||
return matches ? new URL(matches[1], baseURL).href : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(dataAttr, baseURL).href;
|
||||
} catch (e) {
|
||||
console.warn('Error creating URL from', dataAttr, e);
|
||||
return dataAttr; // Return the original value if URL construction fails
|
||||
}
|
||||
}
|
||||
return element.getAttribute(attribute);
|
||||
}
|
||||
|
||||
if (attribute === 'innerText') {
|
||||
return element.innerText.trim();
|
||||
} else if (attribute === 'innerHTML') {
|
||||
return element.innerHTML.trim();
|
||||
} else if (attribute === 'src' || attribute === 'href') {
|
||||
const attrValue = element.getAttribute(attribute);
|
||||
|
||||
const dataAttr = attrValue || element.getAttribute('data-' + attribute);
|
||||
|
||||
if (!dataAttr || dataAttr.trim() === '') {
|
||||
if (attribute === 'src') {
|
||||
const style = window.getComputedStyle(element);
|
||||
const bgImage = style.backgroundImage;
|
||||
if (bgImage && bgImage !== 'none') {
|
||||
const matches = bgImage.match(/url\(['"]?([^'")]+)['"]?\)/);
|
||||
return matches ? new URL(matches[1], baseURL).href : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(dataAttr, baseURL).href;
|
||||
} catch (e) {
|
||||
console.warn('Error creating URL from', dataAttr, e);
|
||||
return dataAttr; // Return the original value if URL construction fails
|
||||
}
|
||||
}
|
||||
return element.getAttribute(attribute);
|
||||
}
|
||||
|
||||
// Enhanced table ancestor finding with context support
|
||||
function findTableAncestor(element) {
|
||||
|
||||
@@ -572,6 +572,7 @@ export default class Interpreter extends EventEmitter {
|
||||
let visitedUrls: Set<string> = new Set<string>();
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000; // 1 second delay between retries
|
||||
const MAX_UNCHANGED_RESULTS = 5;
|
||||
|
||||
const debugLog = (message: string, ...args: any[]) => {
|
||||
console.log(`[Page ${visitedUrls.size}] [URL: ${page.url()}] ${message}`, ...args);
|
||||
@@ -661,21 +662,36 @@ export default class Interpreter extends EventEmitter {
|
||||
};
|
||||
|
||||
let availableSelectors = config.pagination.selector.split(',');
|
||||
let unchangedResultCounter = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
// Reduced timeout for faster performance
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
||||
|
||||
while (true) {
|
||||
switch (config.pagination.type) {
|
||||
case 'scrollDown': {
|
||||
let previousResultCount = allResults.length;
|
||||
|
||||
await scrapeCurrentPage();
|
||||
|
||||
if (checkLimit()) {
|
||||
return allResults;
|
||||
}
|
||||
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||
const currentResultCount = allResults.length;
|
||||
|
||||
if (currentResultCount === previousResultCount) {
|
||||
unchangedResultCounter++;
|
||||
if (unchangedResultCounter >= MAX_UNCHANGED_RESULTS) {
|
||||
return allResults;
|
||||
}
|
||||
} else {
|
||||
unchangedResultCounter = 0;
|
||||
}
|
||||
|
||||
if (currentHeight === previousHeight) {
|
||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||
allResults = allResults.concat(finalResults);
|
||||
return allResults;
|
||||
}
|
||||
|
||||
@@ -684,13 +700,30 @@ export default class Interpreter extends EventEmitter {
|
||||
}
|
||||
|
||||
case 'scrollUp': {
|
||||
let previousResultCount = allResults.length;
|
||||
|
||||
await scrapeCurrentPage();
|
||||
|
||||
if (checkLimit()) {
|
||||
return allResults;
|
||||
}
|
||||
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const currentTopHeight = await page.evaluate(() => document.documentElement.scrollTop);
|
||||
const currentResultCount = allResults.length;
|
||||
|
||||
if (currentResultCount === previousResultCount) {
|
||||
unchangedResultCounter++;
|
||||
if (unchangedResultCounter >= MAX_UNCHANGED_RESULTS) {
|
||||
return allResults;
|
||||
}
|
||||
} else {
|
||||
unchangedResultCounter = 0;
|
||||
}
|
||||
|
||||
if (currentTopHeight === 0) {
|
||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||
allResults = allResults.concat(finalResults);
|
||||
return allResults;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "maxun",
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.13",
|
||||
"author": "Maxun",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
@@ -27,9 +27,7 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"body-parser": "^1.20.3",
|
||||
"buffer": "^6.0.3",
|
||||
"bullmq": "^5.12.15",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"connect-redis": "^8.0.1",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"cron-parser": "^4.9.0",
|
||||
@@ -52,7 +50,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"loglevel": "^1.8.0",
|
||||
"loglevel-plugin-remote": "^0.6.8",
|
||||
"maxun-core": "^0.0.14",
|
||||
"maxun-core": "^0.0.15",
|
||||
"minio": "^8.0.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"node-cron": "^3.0.3",
|
||||
@@ -72,7 +70,6 @@
|
||||
"react-router-dom": "^6.26.1",
|
||||
"react-simple-code-editor": "^0.11.2",
|
||||
"react-transition-group": "^4.4.2",
|
||||
"redis": "^4.7.0",
|
||||
"sequelize": "^6.37.3",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"sharp": "^0.33.5",
|
||||
@@ -121,7 +118,6 @@
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"@types/react-highlight": "^0.12.5",
|
||||
"@types/react-transition-group": "^4.4.4",
|
||||
"@types/redis": "^4.0.11",
|
||||
"@types/styled-components": "^5.1.23",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/swagger-ui-express": "^4.1.6",
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"label": "URL",
|
||||
"button": "Aufnahme starten"
|
||||
},
|
||||
"retrain": "Neu trainieren",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"duplicate": "Duplizieren",
|
||||
@@ -237,7 +238,9 @@
|
||||
"confirm": "Bestätigen"
|
||||
},
|
||||
"notifications": {
|
||||
"save_success": "Roboter erfolgreich gespeichert"
|
||||
"save_success": "Roboter erfolgreich gespeichert",
|
||||
"retrain_success": "Roboter erfolgreich neu trainiert",
|
||||
"save_error": "Fehler beim Speichern des Roboters"
|
||||
},
|
||||
"errors": {
|
||||
"user_not_logged": "Benutzer nicht angemeldet. Aufnahme kann nicht gespeichert werden.",
|
||||
@@ -513,7 +516,8 @@
|
||||
"running": "Läuft",
|
||||
"scheduled": "Geplant",
|
||||
"queued": "In Warteschlange",
|
||||
"failed": "Fehlgeschlagen"
|
||||
"failed": "Fehlgeschlagen",
|
||||
"aborted": "Abgebrochen"
|
||||
},
|
||||
"run_settings_modal": {
|
||||
"title": "Ausführungseinstellungen",
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"discard_and_create":"Discard & Create New",
|
||||
"cancel":"Cancel"
|
||||
},
|
||||
"retrain": "Retrain",
|
||||
"edit":"Edit",
|
||||
"delete":"Delete",
|
||||
"duplicate":"Duplicate",
|
||||
@@ -245,7 +246,9 @@
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"notifications": {
|
||||
"save_success": "Robot saved successfully"
|
||||
"save_success": "Robot saved successfully",
|
||||
"retrain_success": "Robot retrained successfully",
|
||||
"save_error": "Error saving robot"
|
||||
},
|
||||
"errors": {
|
||||
"user_not_logged": "User not logged in. Cannot save recording.",
|
||||
@@ -521,7 +524,8 @@
|
||||
"running": "Running",
|
||||
"scheduled": "Scheduled",
|
||||
"queued": "Queued",
|
||||
"failed": "Failed"
|
||||
"failed": "Failed",
|
||||
"aborted": "Aborted"
|
||||
},
|
||||
"run_settings_modal": {
|
||||
"title": "Run Settings",
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"label": "URL",
|
||||
"button": "Comenzar grabación"
|
||||
},
|
||||
"retrain": "Reentrenar",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -238,7 +239,9 @@
|
||||
"confirm": "Confirmar"
|
||||
},
|
||||
"notifications": {
|
||||
"save_success": "Robot guardado exitosamente"
|
||||
"save_success": "Robot guardado correctamente",
|
||||
"retrain_success": "Robot reentrenado correctamente",
|
||||
"save_error": "Error al guardar el robot"
|
||||
},
|
||||
"errors": {
|
||||
"user_not_logged": "Usuario no conectado. No se puede guardar la grabación.",
|
||||
@@ -514,7 +517,8 @@
|
||||
"running": "Ejecutando",
|
||||
"scheduled": "Programado",
|
||||
"queued": "En cola",
|
||||
"failed": "Fallido"
|
||||
"failed": "Fallido",
|
||||
"aborted": "Abortado"
|
||||
},
|
||||
"run_settings_modal": {
|
||||
"title": "Configuración de Ejecución",
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"label": "URL",
|
||||
"button": "録画を開始"
|
||||
},
|
||||
"retrain": "再学習",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"duplicate": "複製",
|
||||
@@ -238,7 +239,9 @@
|
||||
"confirm": "確認"
|
||||
},
|
||||
"notifications": {
|
||||
"save_success": "ロボットが正常に保存されました"
|
||||
"save_success": "ロボットの保存に成功しました",
|
||||
"retrain_success": "ロボットの再トレーニングに成功しました",
|
||||
"save_error": "ロボットの保存中にエラーが発生しました"
|
||||
},
|
||||
"errors": {
|
||||
"user_not_logged": "ユーザーがログインしていません。録画を保存できません。",
|
||||
@@ -514,7 +517,8 @@
|
||||
"running": "実行中",
|
||||
"scheduled": "スケジュール済み",
|
||||
"queued": "キューに入れました",
|
||||
"failed": "失敗"
|
||||
"failed": "失敗",
|
||||
"aborted": "中止されました"
|
||||
},
|
||||
"run_settings_modal": {
|
||||
"title": "実行設定",
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"label": "URL",
|
||||
"button": "开始录制"
|
||||
},
|
||||
"retrain": "重新训练",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"duplicate": "复制",
|
||||
@@ -238,7 +239,9 @@
|
||||
"confirm": "确认"
|
||||
},
|
||||
"notifications": {
|
||||
"save_success": "机器人保存成功"
|
||||
"save_success": "机器人保存成功",
|
||||
"retrain_success": "机器人重新训练成功",
|
||||
"save_error": "保存机器人时出错"
|
||||
},
|
||||
"errors": {
|
||||
"user_not_logged": "用户未登录。无法保存录制。",
|
||||
@@ -514,7 +517,8 @@
|
||||
"running": "运行中",
|
||||
"scheduled": "已计划",
|
||||
"queued": "排队",
|
||||
"failed": "失败"
|
||||
"failed": "失败",
|
||||
"aborted": "已中止"
|
||||
},
|
||||
"run_settings_modal": {
|
||||
"title": "运行设置",
|
||||
|
||||
@@ -5,7 +5,6 @@ WORKDIR /app
|
||||
|
||||
# Install node dependencies
|
||||
COPY package*.json ./
|
||||
COPY maxun-core ./maxun-core
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
COPY server ./server
|
||||
|
||||
@@ -9,11 +9,9 @@ import { chromium } from 'playwright-extra';
|
||||
import stealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import { PlaywrightBlocker } from '@cliqz/adblocker-playwright';
|
||||
import fetch from 'cross-fetch';
|
||||
import { throttle } from 'lodash';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import logger from '../../logger';
|
||||
import { InterpreterSettings, RemoteBrowserOptions } from "../../types";
|
||||
import { InterpreterSettings } from "../../types";
|
||||
import { WorkflowGenerator } from "../../workflow-management/classes/Generator";
|
||||
import { WorkflowInterpreter } from "../../workflow-management/classes/Interpreter";
|
||||
import { getDecryptedProxyConfig } from '../../routes/proxy';
|
||||
@@ -120,11 +118,11 @@ export class RemoteBrowser {
|
||||
* @param socket socket.io socket instance used to communicate with the client side
|
||||
* @constructor
|
||||
*/
|
||||
public constructor(socket: Socket, userId: string) {
|
||||
public constructor(socket: Socket, userId: string, poolId: string) {
|
||||
this.socket = socket;
|
||||
this.userId = userId;
|
||||
this.interpreter = new WorkflowInterpreter(socket);
|
||||
this.generator = new WorkflowGenerator(socket);
|
||||
this.generator = new WorkflowGenerator(socket, poolId);
|
||||
}
|
||||
|
||||
private initializeMemoryManagement(): void {
|
||||
@@ -320,7 +318,6 @@ export class RemoteBrowser {
|
||||
isMobile: false,
|
||||
hasTouch: false,
|
||||
userAgent: this.getUserAgent(),
|
||||
deviceScaleFactor: 2,
|
||||
};
|
||||
|
||||
if (proxyOptions.server) {
|
||||
@@ -414,7 +411,7 @@ export class RemoteBrowser {
|
||||
}
|
||||
}
|
||||
|
||||
this.initializeMemoryManagement();
|
||||
// this.initializeMemoryManagement();
|
||||
};
|
||||
|
||||
public updateViewportInfo = async (): Promise<void> => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { Socket } from "socket.io";
|
||||
import { uuid } from 'uuidv4';
|
||||
|
||||
import { createSocketConnection, createSocketConnectionForRun } from "../socket-connection/connection";
|
||||
import { createSocketConnection, createSocketConnectionForRun, registerBrowserUserContext } from "../socket-connection/connection";
|
||||
import { io, browserPool } from "../server";
|
||||
import { RemoteBrowser } from "./classes/RemoteBrowser";
|
||||
import { RemoteBrowserOptions } from "../types";
|
||||
@@ -32,7 +32,7 @@ export const initializeRemoteBrowserForRecording = (userId: string): string => {
|
||||
remoteBrowser?.updateSocket(socket);
|
||||
await remoteBrowser?.makeAndEmitScreenshot();
|
||||
} else {
|
||||
const browserSession = new RemoteBrowser(socket, userId);
|
||||
const browserSession = new RemoteBrowser(socket, userId, id);
|
||||
browserSession.interpreter.subscribeToPausing();
|
||||
await browserSession.initialize(userId);
|
||||
await browserSession.registerEditorEvents();
|
||||
@@ -48,19 +48,27 @@ export const initializeRemoteBrowserForRecording = (userId: string): string => {
|
||||
* Starts and initializes a {@link RemoteBrowser} instance for interpretation.
|
||||
* Creates a new {@link Socket} connection over a dedicated namespace.
|
||||
* Returns the new remote browser's generated id.
|
||||
* @param options {@link RemoteBrowserOptions} to be used when launching the browser
|
||||
* @returns string
|
||||
* @param userId User ID for browser ownership
|
||||
* @returns string Browser ID
|
||||
* @category BrowserManagement-Controller
|
||||
*/
|
||||
export const createRemoteBrowserForRun = (userId: string): string => {
|
||||
const id = uuid();
|
||||
|
||||
registerBrowserUserContext(id, userId);
|
||||
logger.log('debug', `Created new browser for run: ${id} for user: ${userId}`);
|
||||
|
||||
createSocketConnectionForRun(
|
||||
io.of(id),
|
||||
io.of(`/${id}`),
|
||||
async (socket: Socket) => {
|
||||
const browserSession = new RemoteBrowser(socket, userId);
|
||||
await browserSession.initialize(userId);
|
||||
browserPool.addRemoteBrowser(id, browserSession, userId, false, "run");
|
||||
socket.emit('ready-for-run');
|
||||
try {
|
||||
const browserSession = new RemoteBrowser(socket, userId, id);
|
||||
await browserSession.initialize(userId);
|
||||
browserPool.addRemoteBrowser(id, browserSession, userId, false, "run");
|
||||
socket.emit('ready-for-run');
|
||||
} catch (error: any) {
|
||||
logger.error(`Error initializing browser: ${error.message}`);
|
||||
}
|
||||
});
|
||||
return id;
|
||||
};
|
||||
@@ -73,13 +81,39 @@ export const createRemoteBrowserForRun = (userId: string): string => {
|
||||
* @category BrowserManagement-Controller
|
||||
*/
|
||||
export const destroyRemoteBrowser = async (id: string, userId: string): Promise<boolean> => {
|
||||
const browserSession = browserPool.getRemoteBrowser(id);
|
||||
if (browserSession) {
|
||||
try {
|
||||
const browserSession = browserPool.getRemoteBrowser(id);
|
||||
if (!browserSession) {
|
||||
logger.log('info', `Browser with id: ${id} not found, may have already been destroyed`);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.log('debug', `Switching off the browser with id: ${id}`);
|
||||
await browserSession.stopCurrentInterpretation();
|
||||
await browserSession.switchOff();
|
||||
|
||||
try {
|
||||
await browserSession.stopCurrentInterpretation();
|
||||
} catch (stopError) {
|
||||
logger.log('warn', `Error stopping interpretation for browser ${id}: ${stopError}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await browserSession.switchOff();
|
||||
} catch (switchOffError) {
|
||||
logger.log('warn', `Error switching off browser ${id}: ${switchOffError}`);
|
||||
}
|
||||
|
||||
return browserPool.deleteRemoteBrowser(id);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.log('error', `Failed to destroy browser ${id}: ${errorMessage}`);
|
||||
|
||||
try {
|
||||
return browserPool.deleteRemoteBrowser(id);
|
||||
} catch (deleteError) {
|
||||
logger.log('error', `Failed to delete browser ${id} from pool: ${deleteError}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return browserPool.deleteRemoteBrowser(id);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -185,8 +185,19 @@ const onWheel = async (socket: AuthenticatedSocket, scrollDeltas: ScrollDeltas)
|
||||
* @category BrowserManagement
|
||||
*/
|
||||
const handleWheel = async (generator: WorkflowGenerator, page: Page, { deltaX, deltaY }: ScrollDeltas) => {
|
||||
await page.mouse.wheel(deltaX, deltaY);
|
||||
logger.log('debug', `Scrolled horizontally ${deltaX} pixels and vertically ${deltaY} pixels`);
|
||||
try {
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.mouse.wheel(deltaX, deltaY).catch(error => {
|
||||
logger.log('warn', `Wheel event failed: ${error.message}`);
|
||||
});
|
||||
logger.log('debug', `Scrolled horizontally ${deltaX} pixels and vertically ${deltaY} pixels`);
|
||||
} catch (e) {
|
||||
const { message } = e as Error;
|
||||
logger.log('warn', `Error handling wheel event: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Request, Response } from "express";
|
||||
import { Response } from "express";
|
||||
import User from "../models/User";
|
||||
import { AuthenticatedRequest } from "../routes/record"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Model, DataTypes, Optional } from 'sequelize';
|
||||
import sequelize from '../storage/db';
|
||||
import { WorkflowFile, Where, What, WhereWhatPair } from 'maxun-core';
|
||||
import { WhereWhatPair } from 'maxun-core';
|
||||
|
||||
interface RobotMeta {
|
||||
name: string;
|
||||
@@ -143,9 +143,4 @@ Robot.init(
|
||||
}
|
||||
);
|
||||
|
||||
// Robot.hasMany(Run, {
|
||||
// foreignKey: 'robotId',
|
||||
// as: 'runs', // Alias for the relation
|
||||
// });
|
||||
|
||||
export default Robot;
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import sequelize from '../storage/db';
|
||||
import Robot from './Robot';
|
||||
|
||||
interface UserAttributes {
|
||||
id: number;
|
||||
@@ -61,13 +60,6 @@ User.init(
|
||||
proxy_username: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
// validate: {
|
||||
// isProxyPasswordRequired(value: string | null) {
|
||||
// if (value && !this.proxy_password) {
|
||||
// throw new Error('Proxy password is required when proxy username is provided');
|
||||
// }
|
||||
// },
|
||||
// },
|
||||
},
|
||||
proxy_password: {
|
||||
type: DataTypes.STRING,
|
||||
@@ -80,9 +72,4 @@ User.init(
|
||||
}
|
||||
);
|
||||
|
||||
// User.hasMany(Robot, {
|
||||
// foreignKey: 'userId',
|
||||
// as: 'robots', // Alias for the relation
|
||||
// });
|
||||
|
||||
export default User;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
destroyRemoteBrowser,
|
||||
interpretWholeWorkflow,
|
||||
stopRunningInterpretation,
|
||||
createRemoteBrowserForRun
|
||||
} from './browser-management/controller';
|
||||
import { WorkflowFile } from 'maxun-core';
|
||||
import Run from './models/Run';
|
||||
@@ -22,7 +21,11 @@ import { airtableUpdateTasks, processAirtableUpdates } from './workflow-manageme
|
||||
import { RemoteBrowser } from './browser-management/classes/RemoteBrowser';
|
||||
import { io as serverIo } from "./server";
|
||||
|
||||
const pgBossConnectionString = `postgres://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`;
|
||||
if (!process.env.DB_USER || !process.env.DB_PASSWORD || !process.env.DB_HOST || !process.env.DB_PORT || !process.env.DB_NAME) {
|
||||
throw new Error('Failed to start pgboss worker: one or more required environment variables are missing.');
|
||||
}
|
||||
|
||||
const pgBossConnectionString = `postgresql://${process.env.DB_USER}:${encodeURIComponent(process.env.DB_PASSWORD)}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`;
|
||||
|
||||
interface InitializeBrowserData {
|
||||
userId: string;
|
||||
@@ -47,6 +50,11 @@ interface ExecuteRunData {
|
||||
browserId: string;
|
||||
}
|
||||
|
||||
interface AbortRunData {
|
||||
userId: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
const pgBoss = new PgBoss({connectionString: pgBossConnectionString });
|
||||
|
||||
/**
|
||||
@@ -176,6 +184,11 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
if (run.status === 'aborted' || run.status === 'aborting') {
|
||||
logger.log('info', `Run ${data.runId} has status ${run.status}, skipping execution`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const plainRun = run.toJSON();
|
||||
|
||||
// Find the recording
|
||||
@@ -183,12 +196,14 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
|
||||
if (!recording) {
|
||||
logger.log('error', `Recording for run ${data.runId} not found`);
|
||||
|
||||
// Update run status to failed
|
||||
await run.update({
|
||||
status: 'failed',
|
||||
finishedAt: new Date().toLocaleString(),
|
||||
log: 'Failed: Recording not found',
|
||||
});
|
||||
const currentRun = await Run.findOne({ where: { runId: data.runId } });
|
||||
if (currentRun && (currentRun.status !== 'aborted' && currentRun.status !== 'aborting')) {
|
||||
await run.update({
|
||||
status: 'failed',
|
||||
finishedAt: new Date().toLocaleString(),
|
||||
log: 'Failed: Recording not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Check for queued runs even if this one failed
|
||||
await checkAndProcessQueuedRun(data.userId, data.browserId);
|
||||
@@ -203,8 +218,6 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
|
||||
if (!browser || !currentPage) {
|
||||
logger.log('error', `Browser or page not available for run ${data.runId}`);
|
||||
|
||||
await pgBoss.fail(job.id, "Failed to get browser or page for run");
|
||||
|
||||
// Even if this run failed, check for queued runs
|
||||
await checkAndProcessQueuedRun(data.userId, data.browserId);
|
||||
|
||||
@@ -215,6 +228,11 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
|
||||
// Reset the browser state before executing this run
|
||||
await resetBrowserState(browser);
|
||||
|
||||
const isRunAborted = async (): Promise<boolean> => {
|
||||
const currentRun = await Run.findOne({ where: { runId: data.runId } });
|
||||
return currentRun ? (currentRun.status === 'aborted' || currentRun.status === 'aborting') : false;
|
||||
};
|
||||
|
||||
// Execute the workflow
|
||||
const workflow = AddGeneratedFlags(recording.recording);
|
||||
const interpretationInfo = await browser.interpreter.InterpretRecording(
|
||||
@@ -224,10 +242,28 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
|
||||
plainRun.interpreterSettings
|
||||
);
|
||||
|
||||
if (await isRunAborted()) {
|
||||
logger.log('info', `Run ${data.runId} was aborted during execution, not updating status`);
|
||||
|
||||
const queuedRunProcessed = await checkAndProcessQueuedRun(data.userId, plainRun.browserId);
|
||||
|
||||
if (!queuedRunProcessed) {
|
||||
await destroyRemoteBrowser(plainRun.browserId, data.userId);
|
||||
logger.log('info', `No queued runs found for browser ${plainRun.browserId}, browser destroyed`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Process the results
|
||||
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
|
||||
const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput);
|
||||
|
||||
if (await isRunAborted()) {
|
||||
logger.log('info', `Run ${data.runId} was aborted while processing results, not updating status`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Update the run record with results
|
||||
await run.update({
|
||||
...run,
|
||||
@@ -318,11 +354,28 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
|
||||
} catch (executionError: any) {
|
||||
logger.log('error', `Run execution failed for run ${data.runId}: ${executionError.message}`);
|
||||
|
||||
await run.update({
|
||||
status: 'failed',
|
||||
finishedAt: new Date().toLocaleString(),
|
||||
log: `Failed: ${executionError.message}`,
|
||||
});
|
||||
const currentRun = await Run.findOne({ where: { runId: data.runId } });
|
||||
if (currentRun && (currentRun.status !== 'aborted' && currentRun.status !== 'aborting')) {
|
||||
await run.update({
|
||||
status: 'failed',
|
||||
finishedAt: new Date().toLocaleString(),
|
||||
log: `Failed: ${executionError.message}`,
|
||||
});
|
||||
|
||||
// Capture failure metrics
|
||||
capture(
|
||||
'maxun-oss-run-created-manual',
|
||||
{
|
||||
runId: data.runId,
|
||||
user_id: data.userId,
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'failed',
|
||||
error_message: executionError.message,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
logger.log('info', `Run ${data.runId} was aborted, not updating status to failed`);
|
||||
}
|
||||
|
||||
// Check for queued runs before destroying the browser
|
||||
const queuedRunProcessed = await checkAndProcessQueuedRun(data.userId, plainRun.browserId);
|
||||
@@ -336,18 +389,6 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
|
||||
logger.log('warn', `Failed to clean up browser for failed run ${data.runId}: ${cleanupError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Capture failure metrics
|
||||
capture(
|
||||
'maxun-oss-run-created-manual',
|
||||
{
|
||||
runId: data.runId,
|
||||
user_id: data.userId,
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'failed',
|
||||
error_message: executionError.message,
|
||||
}
|
||||
);
|
||||
|
||||
return { success: false };
|
||||
}
|
||||
@@ -359,6 +400,134 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function abortRun(runId: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
const run = await Run.findOne({
|
||||
where: {
|
||||
runId: runId,
|
||||
runByUserId: userId
|
||||
}
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
logger.log('warn', `Run ${runId} not found or does not belong to user ${userId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await run.update({
|
||||
status: 'aborting'
|
||||
});
|
||||
|
||||
const plainRun = run.toJSON();
|
||||
|
||||
const recording = await Robot.findOne({
|
||||
where: { 'recording_meta.id': plainRun.robotMetaId },
|
||||
raw: true
|
||||
});
|
||||
|
||||
const robotName = recording?.recording_meta?.name || 'Unknown Robot';
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = browserPool.getRemoteBrowser(plainRun.browserId);
|
||||
} catch (browserError) {
|
||||
logger.log('warn', `Could not get browser for run ${runId}: ${browserError}`);
|
||||
browser = null;
|
||||
}
|
||||
|
||||
if (!browser) {
|
||||
await run.update({
|
||||
status: 'aborted',
|
||||
finishedAt: new Date().toLocaleString(),
|
||||
log: 'Aborted: Browser not found or already closed'
|
||||
});
|
||||
|
||||
try {
|
||||
serverIo.of(plainRun.browserId).emit('run-aborted', {
|
||||
runId,
|
||||
robotName: robotName,
|
||||
status: 'aborted',
|
||||
finishedAt: new Date().toLocaleString()
|
||||
});
|
||||
} catch (socketError) {
|
||||
logger.log('warn', `Failed to emit run-aborted event: ${socketError}`);
|
||||
}
|
||||
|
||||
logger.log('warn', `Browser not found for run ${runId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
let currentLog = 'Run aborted by user';
|
||||
let serializableOutput: Record<string, any> = {};
|
||||
let binaryOutput: Record<string, any> = {};
|
||||
|
||||
try {
|
||||
if (browser.interpreter) {
|
||||
if (browser.interpreter.debugMessages) {
|
||||
currentLog = browser.interpreter.debugMessages.join('\n') || currentLog;
|
||||
}
|
||||
|
||||
if (browser.interpreter.serializableData) {
|
||||
browser.interpreter.serializableData.forEach((item, index) => {
|
||||
serializableOutput[`item-${index}`] = item;
|
||||
});
|
||||
}
|
||||
|
||||
if (browser.interpreter.binaryData) {
|
||||
browser.interpreter.binaryData.forEach((item, index) => {
|
||||
binaryOutput[`item-${index}`] = item;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (interpreterError) {
|
||||
logger.log('warn', `Error collecting data from interpreter: ${interpreterError}`);
|
||||
}
|
||||
|
||||
await run.update({
|
||||
status: 'aborted',
|
||||
finishedAt: new Date().toLocaleString(),
|
||||
browserId: plainRun.browserId,
|
||||
log: currentLog,
|
||||
serializableOutput,
|
||||
binaryOutput,
|
||||
});
|
||||
|
||||
try {
|
||||
serverIo.of(plainRun.browserId).emit('run-aborted', {
|
||||
runId,
|
||||
robotName: robotName,
|
||||
status: 'aborted',
|
||||
finishedAt: new Date().toLocaleString()
|
||||
});
|
||||
} catch (socketError) {
|
||||
logger.log('warn', `Failed to emit run-aborted event: ${socketError}`);
|
||||
}
|
||||
|
||||
let queuedRunProcessed = false;
|
||||
try {
|
||||
queuedRunProcessed = await checkAndProcessQueuedRun(userId, plainRun.browserId);
|
||||
} catch (queueError) {
|
||||
logger.log('warn', `Error checking queued runs: ${queueError}`);
|
||||
}
|
||||
|
||||
if (!queuedRunProcessed) {
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
await destroyRemoteBrowser(plainRun.browserId, userId);
|
||||
logger.log('info', `Browser ${plainRun.browserId} destroyed successfully after abort`);
|
||||
} catch (cleanupError) {
|
||||
logger.log('warn', `Failed to clean up browser for aborted run ${runId}: ${cleanupError}`);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.log('error', `Failed to abort run ${runId}: ${errorMessage}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerRunExecutionWorker() {
|
||||
try {
|
||||
@@ -414,6 +583,52 @@ async function registerRunExecutionWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
async function registerAbortRunWorker() {
|
||||
try {
|
||||
const registeredAbortQueues = new Map();
|
||||
|
||||
const checkForNewAbortQueues = async () => {
|
||||
try {
|
||||
const activeQueues = await pgBoss.getQueues();
|
||||
|
||||
const abortQueues = activeQueues.filter(q => q.name.startsWith('abort-run-user-'));
|
||||
|
||||
for (const queue of abortQueues) {
|
||||
if (!registeredAbortQueues.has(queue.name)) {
|
||||
await pgBoss.work(queue.name, async (job: Job<AbortRunData> | Job<AbortRunData>[]) => {
|
||||
try {
|
||||
const data = extractJobData(job);
|
||||
const { userId, runId } = data;
|
||||
|
||||
logger.log('info', `Processing abort request for run ${runId} by user ${userId}`);
|
||||
const success = await abortRun(runId, userId);
|
||||
return { success };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.log('error', `Abort run job failed in ${queue.name}: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
registeredAbortQueues.set(queue.name, true);
|
||||
logger.log('info', `Registered abort worker for queue: ${queue.name}`);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.log('error', `Failed to check for new abort queues: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
await checkForNewAbortQueues();
|
||||
|
||||
logger.log('info', 'Abort run worker registration system initialized');
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.log('error', `Failed to initialize abort run worker system: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize PgBoss and register all workers
|
||||
@@ -495,6 +710,9 @@ async function startWorkers() {
|
||||
// Register the run execution worker
|
||||
await registerRunExecutionWorker();
|
||||
|
||||
// Register the abort run worker
|
||||
await registerAbortRunWorker();
|
||||
|
||||
logger.log('info', 'All recording workers registered successfully');
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -506,6 +724,10 @@ async function startWorkers() {
|
||||
// Start all workers
|
||||
startWorkers();
|
||||
|
||||
pgBoss.on('error', (error) => {
|
||||
logger.log('error', `PgBoss error: ${error.message}`);
|
||||
});
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.log('info', 'SIGTERM received, shutting down PgBoss...');
|
||||
@@ -520,4 +742,4 @@ process.on('SIGINT', async () => {
|
||||
});
|
||||
|
||||
// For use in other files
|
||||
export { pgBoss };
|
||||
export { pgBoss };
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
|
||||
import User from "../models/User";
|
||||
import Robot from "../models/Robot";
|
||||
import jwt from "jsonwebtoken";
|
||||
@@ -10,7 +9,6 @@ import { google } from "googleapis";
|
||||
import { capture } from "../utils/analytics";
|
||||
import crypto from 'crypto';
|
||||
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
code_verifier: string;
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Router, Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
initializeRemoteBrowserForRecording,
|
||||
destroyRemoteBrowser,
|
||||
getActiveBrowserId,
|
||||
interpretWholeWorkflow,
|
||||
stopRunningInterpretation,
|
||||
getRemoteBrowserCurrentUrl,
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
import { chromium } from 'playwright-extra';
|
||||
import stealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import logger from "../logger";
|
||||
import { getDecryptedProxyConfig } from './proxy';
|
||||
import { requireSignIn } from '../middlewares/auth';
|
||||
import { pgBoss } from '../pgboss-worker';
|
||||
|
||||
@@ -237,7 +234,7 @@ router.get('/interpret', requireSignIn, async (req: AuthenticatedRequest, res) =
|
||||
logger.log('info', `Queued interpret workflow job: ${jobId}, waiting for completion...`);
|
||||
|
||||
try {
|
||||
const result = await waitForJobCompletion(jobId, 'interpret-workflow', 15000);
|
||||
const result = await waitForJobCompletion(jobId, 'interpret-workflow', 1000000);
|
||||
|
||||
if (result) {
|
||||
return res.send('interpretation done');
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
import { Router } from 'express';
|
||||
import logger from "../logger";
|
||||
import { createRemoteBrowserForRun, destroyRemoteBrowser, getActiveBrowserIdByState } from "../browser-management/controller";
|
||||
import { createRemoteBrowserForRun, getActiveBrowserIdByState } from "../browser-management/controller";
|
||||
import { chromium } from 'playwright-extra';
|
||||
import stealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import { browserPool } from "../server";
|
||||
import { uuid } from "uuidv4";
|
||||
import moment from 'moment-timezone';
|
||||
import cron from 'node-cron';
|
||||
import { googleSheetUpdateTasks, processGoogleSheetUpdates } from '../workflow-management/integrations/gsheet';
|
||||
import { getDecryptedProxyConfig } from './proxy';
|
||||
import { requireSignIn } from '../middlewares/auth';
|
||||
import Robot from '../models/Robot';
|
||||
import Run from '../models/Run';
|
||||
import { BinaryOutputService } from '../storage/mino';
|
||||
import { workflowQueue } from '../worker';
|
||||
import { AuthenticatedRequest } from './record';
|
||||
import { computeNextRun } from '../utils/schedule';
|
||||
import { capture } from "../utils/analytics";
|
||||
import { tryCatch } from 'bullmq';
|
||||
import { encrypt, decrypt } from '../utils/auth';
|
||||
import { WorkflowFile } from 'maxun-core';
|
||||
import { Page } from 'playwright';
|
||||
import { airtableUpdateTasks, processAirtableUpdates } from '../workflow-management/integrations/airtable';
|
||||
import { cancelScheduledWorkflow, scheduleWorkflow } from '../schedule-worker';
|
||||
import { pgBoss } from '../pgboss-worker';
|
||||
chromium.use(stealthPlugin());
|
||||
|
||||
@@ -761,7 +756,7 @@ router.put('/schedule/:id/', requireSignIn, async (req: AuthenticatedRequest, re
|
||||
|
||||
switch (runEveryUnit) {
|
||||
case 'MINUTES':
|
||||
cronExpression = `${startMinutes} */${runEvery} * * *`;
|
||||
cronExpression = `*/${runEvery} * * * *`;
|
||||
break;
|
||||
case 'HOURS':
|
||||
cronExpression = `${startMinutes} */${runEvery} * * *`;
|
||||
@@ -774,7 +769,7 @@ router.put('/schedule/:id/', requireSignIn, async (req: AuthenticatedRequest, re
|
||||
break;
|
||||
case 'MONTHS':
|
||||
// todo: handle leap year
|
||||
cronExpression = `0 ${atTimeStart} ${dayOfMonth} * *`;
|
||||
cronExpression = `${startMinutes} ${startHours} ${dayOfMonth} */${runEvery} *`;
|
||||
if (startFrom !== 'SUNDAY') {
|
||||
cronExpression += ` ${dayIndex}`;
|
||||
}
|
||||
@@ -792,17 +787,13 @@ router.put('/schedule/:id/', requireSignIn, async (req: AuthenticatedRequest, re
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Create the job in the queue with the cron expression
|
||||
const job = await workflowQueue.add(
|
||||
'run workflow',
|
||||
{ id, runId: uuid(), userId: req.user.id },
|
||||
{
|
||||
repeat: {
|
||||
pattern: cronExpression,
|
||||
tz: timezone,
|
||||
},
|
||||
}
|
||||
);
|
||||
try {
|
||||
await cancelScheduledWorkflow(id);
|
||||
} catch (cancelError) {
|
||||
logger.log('warn', `Failed to cancel existing schedule for robot ${id}: ${cancelError}`);
|
||||
}
|
||||
|
||||
const jobId = await scheduleWorkflow(id, req.user.id, cronExpression, timezone);
|
||||
|
||||
const nextRunAt = computeNextRun(cronExpression, timezone);
|
||||
|
||||
@@ -877,12 +868,12 @@ router.delete('/schedule/:id', requireSignIn, async (req: AuthenticatedRequest,
|
||||
return res.status(404).json({ error: 'Robot not found' });
|
||||
}
|
||||
|
||||
// Remove existing job from queue if it exists
|
||||
const existingJobs = await workflowQueue.getJobs(['delayed', 'waiting']);
|
||||
for (const job of existingJobs) {
|
||||
if (job.data.id === id) {
|
||||
await job.remove();
|
||||
}
|
||||
// Cancel the scheduled job in PgBoss
|
||||
try {
|
||||
await cancelScheduledWorkflow(id);
|
||||
} catch (error) {
|
||||
logger.log('error', `Error cancelling scheduled job for robot ${id}: ${error}`);
|
||||
// Continue with robot update even if cancellation fails
|
||||
}
|
||||
|
||||
// Delete the schedule from the robot
|
||||
@@ -913,42 +904,32 @@ router.delete('/schedule/:id', requireSignIn, async (req: AuthenticatedRequest,
|
||||
router.post('/runs/abort/:id', requireSignIn, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
if (!req.user) { return res.status(401).send({ error: 'Unauthorized' }); }
|
||||
const run = await Run.findOne({ where: {
|
||||
|
||||
const run = await Run.findOne({ where: {
|
||||
runId: req.params.id,
|
||||
runByUserId: req.user.id,
|
||||
} });
|
||||
|
||||
if (!run) {
|
||||
return res.status(404).send(false);
|
||||
}
|
||||
const plainRun = run.toJSON();
|
||||
|
||||
const browser = browserPool.getRemoteBrowser(plainRun.browserId);
|
||||
const currentLog = browser?.interpreter.debugMessages.join('/n');
|
||||
const serializableOutput = browser?.interpreter.serializableData.reduce((reducedObject, item, index) => {
|
||||
return {
|
||||
[`item-${index}`]: item,
|
||||
...reducedObject,
|
||||
}
|
||||
}, {});
|
||||
const binaryOutput = browser?.interpreter.binaryData.reduce((reducedObject, item, index) => {
|
||||
return {
|
||||
[`item-${index}`]: item,
|
||||
...reducedObject,
|
||||
}
|
||||
}, {});
|
||||
await run.update({
|
||||
...run,
|
||||
status: 'aborted',
|
||||
finishedAt: new Date().toLocaleString(),
|
||||
browserId: plainRun.browserId,
|
||||
log: currentLog,
|
||||
serializableOutput,
|
||||
binaryOutput,
|
||||
|
||||
const userQueueName = `abort-run-user-${req.user.id}`;
|
||||
await pgBoss.createQueue(userQueueName);
|
||||
|
||||
await pgBoss.send(userQueueName, {
|
||||
userId: req.user.id,
|
||||
runId: req.params.id
|
||||
});
|
||||
|
||||
await run.update({
|
||||
status: 'aborting'
|
||||
});
|
||||
|
||||
return res.send(true);
|
||||
} catch (e) {
|
||||
const { message } = e as Error;
|
||||
logger.log('info', `Error while running a robot with name: ${req.params.fileName}_${req.params.runId}.json`);
|
||||
logger.log('info', `Error while aborting run with id: ${req.params.id} - ${message}`);
|
||||
return res.send(false);
|
||||
}
|
||||
});
|
||||
|
||||
212
server/src/schedule-worker.ts
Normal file
212
server/src/schedule-worker.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Worker process focused solely on scheduling logic
|
||||
*/
|
||||
import PgBoss, { Job } from 'pg-boss';
|
||||
import logger from './logger';
|
||||
import Robot from './models/Robot';
|
||||
import { handleRunRecording } from './workflow-management/scheduler';
|
||||
import { computeNextRun } from './utils/schedule';
|
||||
|
||||
if (!process.env.DB_USER || !process.env.DB_PASSWORD || !process.env.DB_HOST || !process.env.DB_PORT || !process.env.DB_NAME) {
|
||||
throw new Error('One or more required environment variables are missing.');
|
||||
}
|
||||
|
||||
const pgBossConnectionString = `postgresql://${process.env.DB_USER}:${encodeURIComponent(process.env.DB_PASSWORD)}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`;
|
||||
|
||||
const pgBoss = new PgBoss({connectionString: pgBossConnectionString });
|
||||
|
||||
const registeredQueues = new Set<string>();
|
||||
|
||||
interface ScheduledWorkflowData {
|
||||
id: string;
|
||||
runId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to schedule a cron job using PgBoss
|
||||
* @param id The robot ID
|
||||
* @param userId The user ID
|
||||
* @param cronExpression The cron expression for scheduling
|
||||
* @param timezone The timezone for the cron expression
|
||||
*/
|
||||
export async function scheduleWorkflow(id: string, userId: string, cronExpression: string, timezone: string): Promise<void> {
|
||||
try {
|
||||
const runId = require('uuidv4').uuid();
|
||||
|
||||
const queueName = `scheduled-workflow-${id}`;
|
||||
|
||||
logger.log('info', `Scheduling workflow ${id} with cron expression ${cronExpression} in timezone ${timezone}`);
|
||||
|
||||
await pgBoss.createQueue(queueName);
|
||||
|
||||
await pgBoss.schedule(queueName, cronExpression,
|
||||
{ id, runId, userId },
|
||||
{ tz: timezone }
|
||||
);
|
||||
|
||||
await registerWorkerForQueue(queueName);
|
||||
|
||||
logger.log('info', `Scheduled workflow job for robot ${id}`);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.log('error', `Failed to schedule workflow: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to cancel a scheduled job
|
||||
* @param robotId The robot ID
|
||||
* @returns true if successful
|
||||
*/
|
||||
export async function cancelScheduledWorkflow(robotId: string) {
|
||||
try {
|
||||
const jobs = await pgBoss.getSchedules();
|
||||
|
||||
const matchingJobs = jobs.filter((job: any) => {
|
||||
try {
|
||||
const data = job.data;
|
||||
return data && data.id === robotId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
for (const job of matchingJobs) {
|
||||
logger.log('info', `Cancelling scheduled job ${job.name} for robot ${robotId}`);
|
||||
await pgBoss.unschedule(job.name);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.log('error', `Failed to cancel scheduled workflow: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a scheduled workflow job
|
||||
*/
|
||||
async function processScheduledWorkflow(job: Job<ScheduledWorkflowData>) {
|
||||
const { id, runId, userId } = job.data;
|
||||
logger.log('info', `Processing scheduled workflow job for robotId: ${id}, runId: ${runId}, userId: ${userId}`);
|
||||
|
||||
try {
|
||||
// Execute the workflow using the existing handleRunRecording function
|
||||
const result = await handleRunRecording(id, userId);
|
||||
|
||||
// Update the robot's schedule with last run and next run times
|
||||
const robot = await Robot.findOne({ where: { 'recording_meta.id': id } });
|
||||
if (robot && robot.schedule && robot.schedule.cronExpression && robot.schedule.timezone) {
|
||||
// Update lastRunAt to the current time
|
||||
const lastRunAt = new Date();
|
||||
|
||||
// Compute the next run date
|
||||
const nextRunAt = computeNextRun(robot.schedule.cronExpression, robot.schedule.timezone) || undefined;
|
||||
|
||||
await robot.update({
|
||||
schedule: {
|
||||
...robot.schedule,
|
||||
lastRunAt,
|
||||
nextRunAt,
|
||||
},
|
||||
});
|
||||
|
||||
logger.log('info', `Updated robot ${id} schedule - next run at: ${nextRunAt}`);
|
||||
} else {
|
||||
logger.log('error', `Robot ${id} schedule, cronExpression, or timezone is missing.`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.log('error', `Scheduled workflow job failed: ${errorMessage}`);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a worker to handle scheduled workflow jobs
|
||||
*/
|
||||
async function registerScheduledWorkflowWorker() {
|
||||
try {
|
||||
const jobs = await pgBoss.getSchedules();
|
||||
for (const job of jobs) {
|
||||
await pgBoss.createQueue(job.name);
|
||||
await registerWorkerForQueue(job.name);
|
||||
}
|
||||
|
||||
logger.log('info', 'Scheduled workflow workers registered successfully');
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.log('error', `Failed to register scheduled workflow workers: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a worker for a specific queue
|
||||
*/
|
||||
async function registerWorkerForQueue(queueName: string) {
|
||||
try {
|
||||
if (registeredQueues.has(queueName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pgBoss.work(queueName, async (job: Job<ScheduledWorkflowData> | Job<ScheduledWorkflowData>[]) => {
|
||||
try {
|
||||
const singleJob = Array.isArray(job) ? job[0] : job;
|
||||
return await processScheduledWorkflow(singleJob);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.log('error', `Scheduled workflow job failed in queue ${queueName}: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
registeredQueues.add(queueName);
|
||||
logger.log('info', `Registered worker for queue: ${queueName}`);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.log('error', `Failed to register worker for queue ${queueName}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize PgBoss and register scheduling workers
|
||||
*/
|
||||
async function startScheduleWorker() {
|
||||
try {
|
||||
logger.log('info', 'Starting PgBoss scheduling worker...');
|
||||
await pgBoss.start();
|
||||
logger.log('info', 'PgBoss scheduling worker started successfully');
|
||||
|
||||
// Register the scheduled workflow worker
|
||||
await registerScheduledWorkflowWorker();
|
||||
|
||||
logger.log('info', 'Scheduling worker registered successfully');
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.log('error', `Failed to start PgBoss scheduling worker: ${errorMessage}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startScheduleWorker();
|
||||
|
||||
pgBoss.on('error', (error) => {
|
||||
logger.log('error', `PgBoss scheduler error: ${error.message}`);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.log('info', 'SIGTERM received, shutting down PgBoss scheduler...');
|
||||
await pgBoss.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
logger.log('info', 'SIGINT received, shutting down PgBoss scheduler...');
|
||||
await pgBoss.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -8,9 +8,7 @@ import { record, workflow, storage, auth, integration, proxy } from './routes';
|
||||
import { BrowserPool } from "./browser-management/classes/BrowserPool";
|
||||
import logger from './logger';
|
||||
import { connectDB, syncDB } from './storage/db'
|
||||
import bodyParser from 'body-parser';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import csrf from 'csurf';
|
||||
import { SERVER_PORT } from "./constants/config";
|
||||
import { Server } from "socket.io";
|
||||
import { readdirSync } from "fs"
|
||||
@@ -20,9 +18,7 @@ import swaggerUi from 'swagger-ui-express';
|
||||
import swaggerSpec from './swagger/config';
|
||||
import connectPgSimple from 'connect-pg-simple';
|
||||
import pg from 'pg';
|
||||
|
||||
import session from 'express-session';
|
||||
|
||||
import Run from './models/Run';
|
||||
|
||||
const app = express();
|
||||
@@ -97,7 +93,7 @@ readdirSync(path.join(__dirname, 'api')).forEach((r) => {
|
||||
});
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const workerPath = path.resolve(__dirname, isProduction ? './worker.js' : './worker.ts');
|
||||
const workerPath = path.resolve(__dirname, isProduction ? './schedule-worker.js' : './schedule-worker.ts');
|
||||
const recordingWorkerPath = path.resolve(__dirname, isProduction ? './pgboss-worker.js' : './pgboss-worker.ts');
|
||||
|
||||
let workerProcess: any;
|
||||
|
||||
@@ -12,48 +12,85 @@ interface AuthenticatedSocket extends Socket {
|
||||
request: AuthenticatedIncomingMessage;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var userContextMap: Map<string, string>;
|
||||
}
|
||||
|
||||
if (!global.userContextMap) {
|
||||
global.userContextMap = new Map<string, string>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register browser-user association in the global context map
|
||||
*/
|
||||
export function registerBrowserUserContext(browserId: string, userId: string) {
|
||||
if (!global.userContextMap) {
|
||||
global.userContextMap = new Map<string, string>();
|
||||
}
|
||||
global.userContextMap.set(browserId, userId);
|
||||
logger.log('debug', `Registered browser-user association: ${browserId} -> ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket.io middleware for authentication
|
||||
* This is a socket.io specific auth handler that doesn't rely on Express middleware
|
||||
*/
|
||||
const socketAuthMiddleware = (socket: Socket, next: (err?: Error) => void) => {
|
||||
const cookies = socket.handshake.headers.cookie;
|
||||
if (!cookies) {
|
||||
return next(new Error('Authentication required'));
|
||||
// Extract browserId from namespace
|
||||
const namespace = socket.nsp.name;
|
||||
const browserId = namespace.slice(1);
|
||||
|
||||
// Check if this browser is in our context map
|
||||
if (global.userContextMap && global.userContextMap.has(browserId)) {
|
||||
const userId = global.userContextMap.get(browserId);
|
||||
logger.log('debug', `Found browser in context map: ${browserId} -> ${userId}`);
|
||||
|
||||
const authSocket = socket as AuthenticatedSocket;
|
||||
authSocket.request.user = { id: userId };
|
||||
return next();
|
||||
}
|
||||
|
||||
const cookies = socket.handshake.headers.cookie;
|
||||
if (!cookies) {
|
||||
logger.log('debug', `No cookies found in socket handshake for ${browserId}`);
|
||||
return next(new Error('Authentication required'));
|
||||
}
|
||||
|
||||
const tokenMatch = cookies.split(';').find(c => c.trim().startsWith('token='));
|
||||
if (!tokenMatch) {
|
||||
logger.log('debug', `No token cookie found in socket handshake for ${browserId}`);
|
||||
return next(new Error('Authentication required'));
|
||||
}
|
||||
|
||||
const token = tokenMatch.split('=')[1];
|
||||
if (!token) {
|
||||
logger.log('debug', `Empty token value in cookie for ${browserId}`);
|
||||
return next(new Error('Authentication required'));
|
||||
}
|
||||
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
logger.error('JWT_SECRET environment variable is not defined');
|
||||
return next(new Error('Server configuration error'));
|
||||
}
|
||||
|
||||
verify(token, secret, (err: any, user: any) => {
|
||||
if (err) {
|
||||
logger.log('warn', `JWT verification error: ${err.message}`);
|
||||
return next(new Error('Authentication failed'));
|
||||
}
|
||||
|
||||
const tokenMatch = cookies.split(';').find(c => c.trim().startsWith('token='));
|
||||
if (!tokenMatch) {
|
||||
return next(new Error('Authentication required'));
|
||||
// Normalize payload key
|
||||
if (user.userId && !user.id) {
|
||||
user.id = user.userId;
|
||||
delete user.userId;
|
||||
}
|
||||
|
||||
const token = tokenMatch.split('=')[1];
|
||||
if (!token) {
|
||||
return next(new Error('Authentication required'));
|
||||
}
|
||||
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
return next(new Error('Server configuration error'));
|
||||
}
|
||||
|
||||
verify(token, secret, (err: any, user: any) => {
|
||||
if (err) {
|
||||
logger.log('warn', 'JWT verification error:', err);
|
||||
return next(new Error('Authentication failed'));
|
||||
}
|
||||
|
||||
// Normalize payload key
|
||||
if (user.userId && !user.id) {
|
||||
user.id = user.userId;
|
||||
delete user.userId; // temporary: del the old key for clarity
|
||||
}
|
||||
|
||||
// Attach user to socket request
|
||||
const authSocket = socket as AuthenticatedSocket;
|
||||
authSocket.request.user = user;
|
||||
next();
|
||||
});
|
||||
// Attach user to socket request
|
||||
const authSocket = socket as AuthenticatedSocket;
|
||||
authSocket.request.user = user;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Sequelize } from 'sequelize';
|
||||
import dotenv from 'dotenv';
|
||||
import setupAssociations from '../models/associations';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const databaseUrl = `postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`;
|
||||
if (!process.env.DB_USER || !process.env.DB_PASSWORD || !process.env.DB_HOST || !process.env.DB_PORT || !process.env.DB_NAME) {
|
||||
throw new Error('One or more required environment variables are missing.');
|
||||
}
|
||||
|
||||
const databaseUrl = `postgresql://${process.env.DB_USER}:${encodeURIComponent(process.env.DB_PASSWORD)}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`;
|
||||
|
||||
// Extract the hostname using the URL constructor
|
||||
const host = new URL(databaseUrl).hostname;
|
||||
@@ -43,4 +46,4 @@ export const syncDB = async () => {
|
||||
};
|
||||
|
||||
|
||||
export default sequelize;
|
||||
export default sequelize;
|
||||
|
||||
@@ -13,13 +13,8 @@ import {
|
||||
selectorAlreadyInWorkflow
|
||||
} from "../selector";
|
||||
import { CustomActions } from "../../../../src/shared/types";
|
||||
import { workflow } from "../../routes";
|
||||
import Robot from "../../models/Robot";
|
||||
import Run from "../../models/Run";
|
||||
import { saveFile } from "../storage";
|
||||
import fs from "fs";
|
||||
import { getBestSelectorForAction } from "../utils";
|
||||
import { browserPool } from "../../server";
|
||||
import { uuid } from "uuidv4";
|
||||
import { capture } from "../../utils/analytics"
|
||||
import { decrypt, encrypt } from "../../utils/auth";
|
||||
@@ -74,14 +69,17 @@ export class WorkflowGenerator {
|
||||
|
||||
private paginationMode: boolean = false;
|
||||
|
||||
private poolId: string | null = null;
|
||||
|
||||
/**
|
||||
* The public constructor of the WorkflowGenerator.
|
||||
* Takes socket for communication as a parameter and registers some important events on it.
|
||||
* @param socket The socket used to communicate with the client.
|
||||
* @constructor
|
||||
*/
|
||||
public constructor(socket: Socket) {
|
||||
public constructor(socket: Socket, poolId: string) {
|
||||
this.socket = socket;
|
||||
this.poolId = poolId;
|
||||
this.registerEventHandlers(socket);
|
||||
this.initializeSocketListeners();
|
||||
}
|
||||
@@ -143,17 +141,18 @@ export class WorkflowGenerator {
|
||||
*/
|
||||
private registerEventHandlers = (socket: Socket) => {
|
||||
socket.on('save', (data) => {
|
||||
const { fileName, userId, isLogin } = data;
|
||||
const { fileName, userId, isLogin, robotId } = data;
|
||||
logger.log('debug', `Saving workflow ${fileName} for user ID ${userId}`);
|
||||
this.saveNewWorkflow(fileName, userId, isLogin);
|
||||
this.saveNewWorkflow(fileName, userId, isLogin, robotId);
|
||||
});
|
||||
socket.on('new-recording', () => this.workflowRecord = {
|
||||
workflow: [],
|
||||
socket.on('new-recording', (data) => {
|
||||
this.workflowRecord = {
|
||||
workflow: [],
|
||||
};
|
||||
});
|
||||
socket.on('activeIndex', (data) => this.generatedData.lastIndex = parseInt(data));
|
||||
socket.on('decision', async ({ pair, actionType, decision, userId }) => {
|
||||
const id = browserPool.getActiveBrowserId(userId, "recording");
|
||||
if (id) {
|
||||
if (this.poolId) {
|
||||
// const activeBrowser = browserPool.getRemoteBrowser(id);
|
||||
// const currentPage = activeBrowser?.getCurrentPage();
|
||||
if (!decision) {
|
||||
@@ -768,38 +767,62 @@ export class WorkflowGenerator {
|
||||
* @param fileName The name of the file.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public saveNewWorkflow = async (fileName: string, userId: number, isLogin: boolean) => {
|
||||
public saveNewWorkflow = async (fileName: string, userId: number, isLogin: boolean, robotId?: string) => {
|
||||
const recording = this.optimizeWorkflow(this.workflowRecord);
|
||||
let actionType = 'saved';
|
||||
|
||||
try {
|
||||
this.recordingMeta = {
|
||||
name: fileName,
|
||||
id: uuid(),
|
||||
createdAt: this.recordingMeta.createdAt || new Date().toLocaleString(),
|
||||
pairs: recording.workflow.length,
|
||||
updatedAt: new Date().toLocaleString(),
|
||||
params: this.getParams() || [],
|
||||
isLogin: isLogin,
|
||||
}
|
||||
const robot = await Robot.create({
|
||||
userId,
|
||||
recording_meta: this.recordingMeta,
|
||||
recording: recording,
|
||||
});
|
||||
capture(
|
||||
'maxun-oss-robot-created',
|
||||
{
|
||||
robot_meta: robot.recording_meta,
|
||||
recording: robot.recording,
|
||||
}
|
||||
)
|
||||
if (robotId) {
|
||||
const robot = await Robot.findOne({ where: { 'recording_meta.id': robotId }});
|
||||
|
||||
logger.log('info', `Robot saved with id: ${robot.id}`);
|
||||
if (robot) {
|
||||
await robot.update({
|
||||
recording: recording,
|
||||
recording_meta: {
|
||||
...robot.recording_meta,
|
||||
pairs: recording.workflow.length,
|
||||
params: this.getParams() || [],
|
||||
updatedAt: new Date().toLocaleString(),
|
||||
},
|
||||
})
|
||||
|
||||
actionType = 'retrained';
|
||||
logger.log('info', `Robot retrained with id: ${robot.id}`);
|
||||
}
|
||||
} else {
|
||||
this.recordingMeta = {
|
||||
name: fileName,
|
||||
id: uuid(),
|
||||
createdAt: this.recordingMeta.createdAt || new Date().toLocaleString(),
|
||||
pairs: recording.workflow.length,
|
||||
updatedAt: new Date().toLocaleString(),
|
||||
params: this.getParams() || [],
|
||||
isLogin: isLogin,
|
||||
}
|
||||
const robot = await Robot.create({
|
||||
userId,
|
||||
recording_meta: this.recordingMeta,
|
||||
recording: recording,
|
||||
});
|
||||
capture(
|
||||
'maxun-oss-robot-created',
|
||||
{
|
||||
robot_meta: robot.recording_meta,
|
||||
recording: robot.recording,
|
||||
}
|
||||
)
|
||||
|
||||
actionType = 'saved';
|
||||
logger.log('info', `Robot saved with id: ${robot.id}`);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
const { message } = e as Error;
|
||||
logger.log('warn', `Cannot save the file to the local file system ${e}`)
|
||||
actionType = 'error';
|
||||
}
|
||||
this.socket.emit('fileSaved');
|
||||
|
||||
this.socket.emit('fileSaved', { actionType });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -114,7 +114,7 @@ async function executeRun(id: string, userId: string) {
|
||||
|
||||
plainRun.status = 'running';
|
||||
|
||||
const browser = browserPool.getRemoteBrowser(userId);
|
||||
const browser = browserPool.getRemoteBrowser(plainRun.browserId);
|
||||
if (!browser) {
|
||||
throw new Error('Could not access browser');
|
||||
}
|
||||
|
||||
@@ -308,53 +308,29 @@ export const getElementInformation = async (
|
||||
let elements = document.elementsFromPoint(x, y) as HTMLElement[];
|
||||
if (!elements.length) return null;
|
||||
|
||||
const findDeepestElement = (elements: HTMLElement[]): HTMLElement | null => {
|
||||
const findContainerElement = (elements: HTMLElement[]): HTMLElement | null => {
|
||||
if (!elements.length) return null;
|
||||
if (elements.length === 1) return elements[0];
|
||||
|
||||
let deepestElement = elements[0];
|
||||
let maxDepth = 0;
|
||||
|
||||
for (const element of elements) {
|
||||
let depth = 0;
|
||||
let current = element;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
while (current) {
|
||||
depth++;
|
||||
if (current.parentElement) {
|
||||
current = current.parentElement;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (depth > maxDepth) {
|
||||
maxDepth = depth;
|
||||
deepestElement = element;
|
||||
if (rect.width >= 30 && rect.height >= 30) {
|
||||
const hasChildrenInList = elements.some((otherElement, j) =>
|
||||
i !== j && element.contains(otherElement)
|
||||
);
|
||||
|
||||
if (hasChildrenInList) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deepestElement;
|
||||
return elements[0];
|
||||
};
|
||||
|
||||
// Logic to get list container element
|
||||
let targetElement = null;
|
||||
|
||||
for (const element of elements) {
|
||||
const deepestEl = findDeepestElement(elements);
|
||||
|
||||
if (deepestEl && element !== deepestEl) {
|
||||
if (element.contains(deepestEl) &&
|
||||
element !== deepestEl.parentElement &&
|
||||
element.tagName !== 'HTML' &&
|
||||
element.tagName !== 'BODY') {
|
||||
targetElement = element;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let deepestElement = targetElement || findDeepestElement(elements);
|
||||
let deepestElement = findContainerElement(elements);
|
||||
if (!deepestElement) return null;
|
||||
|
||||
const traverseShadowDOM = (element: HTMLElement): HTMLElement => {
|
||||
@@ -842,53 +818,29 @@ export const getRect = async (page: Page, coordinates: Coordinates, listSelector
|
||||
let elements = document.elementsFromPoint(x, y) as HTMLElement[];
|
||||
if (!elements.length) return null;
|
||||
|
||||
const findDeepestElement = (elements: HTMLElement[]): HTMLElement | null => {
|
||||
const findContainerElement = (elements: HTMLElement[]): HTMLElement | null => {
|
||||
if (!elements.length) return null;
|
||||
if (elements.length === 1) return elements[0];
|
||||
|
||||
let deepestElement = elements[0];
|
||||
let maxDepth = 0;
|
||||
|
||||
for (const element of elements) {
|
||||
let depth = 0;
|
||||
let current = element;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
while (current) {
|
||||
depth++;
|
||||
if (current.parentElement) {
|
||||
current = current.parentElement;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (depth > maxDepth) {
|
||||
maxDepth = depth;
|
||||
deepestElement = element;
|
||||
if (rect.width >= 30 && rect.height >= 30) {
|
||||
const hasChildrenInList = elements.some((otherElement, j) =>
|
||||
i !== j && element.contains(otherElement)
|
||||
);
|
||||
|
||||
if (hasChildrenInList) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deepestElement;
|
||||
return elements[0];
|
||||
};
|
||||
|
||||
// Logic to get list container element
|
||||
let targetElement = null;
|
||||
|
||||
for (const element of elements) {
|
||||
const deepestEl = findDeepestElement(elements);
|
||||
|
||||
if (deepestEl && element !== deepestEl) {
|
||||
if (element.contains(deepestEl) &&
|
||||
element !== deepestEl.parentElement &&
|
||||
element.tagName !== 'HTML' &&
|
||||
element.tagName !== 'BODY') {
|
||||
targetElement = element;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let deepestElement = targetElement || findDeepestElement(elements);
|
||||
let deepestElement = findContainerElement(elements);
|
||||
if (!deepestElement) return null;
|
||||
|
||||
const traverseShadowDOM = (element: HTMLElement): HTMLElement => {
|
||||
@@ -2041,53 +1993,29 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates
|
||||
let elements = document.elementsFromPoint(x, y) as HTMLElement[];
|
||||
if (!elements.length) return null;
|
||||
|
||||
const findDeepestElement = (elements: HTMLElement[]): HTMLElement | null => {
|
||||
const findContainerElement = (elements: HTMLElement[]): HTMLElement | null => {
|
||||
if (!elements.length) return null;
|
||||
if (elements.length === 1) return elements[0];
|
||||
|
||||
let deepestElement = elements[0];
|
||||
let maxDepth = 0;
|
||||
|
||||
for (const element of elements) {
|
||||
let depth = 0;
|
||||
let current = element;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
while (current) {
|
||||
depth++;
|
||||
if (current.parentElement) {
|
||||
current = current.parentElement;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (depth > maxDepth) {
|
||||
maxDepth = depth;
|
||||
deepestElement = element;
|
||||
if (rect.width >= 30 && rect.height >= 30) {
|
||||
const hasChildrenInList = elements.some((otherElement, j) =>
|
||||
i !== j && element.contains(otherElement)
|
||||
);
|
||||
|
||||
if (hasChildrenInList) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deepestElement;
|
||||
return elements[0];
|
||||
};
|
||||
|
||||
// Logic to get list container element
|
||||
let targetElement = null;
|
||||
|
||||
for (const element of elements) {
|
||||
const deepestEl = findDeepestElement(elements);
|
||||
|
||||
if (deepestEl && element !== deepestEl) {
|
||||
if (element.contains(deepestEl) &&
|
||||
element !== deepestEl.parentElement &&
|
||||
element.tagName !== 'HTML' &&
|
||||
element.tagName !== 'BODY') {
|
||||
targetElement = element;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let deepestElement = targetElement || findDeepestElement(elements);
|
||||
let deepestElement = findContainerElement(elements);
|
||||
if (!deepestElement) return null;
|
||||
|
||||
const traverseShadowDOM = (element: HTMLElement): HTMLElement => {
|
||||
|
||||
@@ -102,7 +102,7 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => {
|
||||
sx={{
|
||||
color: isDarkMode ? 'white' : 'default',
|
||||
'&.Mui-checked': {
|
||||
color: isDarkMode ? '#90caf9' : '#1976d2',
|
||||
color: '#ff33cc',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import { NavBarButton } from '../ui/buttons/buttons';
|
||||
import { UrlForm } from './UrlForm';
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useSocketStore } from "../../context/socket";
|
||||
import { getCurrentUrl } from "../../api/recording";
|
||||
import { useGlobalInfoStore } from '../../context/globalInfo';
|
||||
|
||||
@@ -55,6 +55,11 @@ const BrowserRecordingSave = () => {
|
||||
type: 'recording-notification',
|
||||
notification: notificationData
|
||||
}, '*');
|
||||
|
||||
window.opener.postMessage({
|
||||
type: 'session-data-clear',
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
}
|
||||
|
||||
setBrowserId(null);
|
||||
|
||||
@@ -4,7 +4,7 @@ import Tab from '@mui/material/Tab';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Paper, Button, useTheme } from "@mui/material";
|
||||
import { AutoAwesome, FormatListBulleted, VpnKey, Usb, Article, CloudQueue, Code, } from "@mui/icons-material";
|
||||
import { AutoAwesome, FormatListBulleted, VpnKey, Usb, CloudQueue, Code, } from "@mui/icons-material";
|
||||
import { apiUrl } from "../../apiConfig";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../../i18n';
|
||||
@@ -112,7 +112,7 @@ export const MainMenu = ({ value = 'robots', handleChangeContent }: MainMenuProp
|
||||
<Button href={`${apiUrl}/api-docs/`} target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<Code />}>
|
||||
{t('mainmenu.apidocs')}
|
||||
</Button>
|
||||
<Button href="https://forms.gle/hXjgqDvkEhPcaBW76" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<CloudQueue />}>
|
||||
<Button href="https://app.maxun.dev/login" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<CloudQueue />}>
|
||||
{t('mainmenu.feedback')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -19,26 +19,32 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [openModal, setOpenModal] = useState<boolean>(false);
|
||||
const [needConfirm, setNeedConfirm] = useState<boolean>(false);
|
||||
const [recordingName, setRecordingName] = useState<string>(fileName);
|
||||
const [saveRecordingName, setSaveRecordingName] = useState<string>(fileName);
|
||||
const [waitingForSave, setWaitingForSave] = useState<boolean>(false);
|
||||
|
||||
const { browserId, setBrowserId, notify, recordings, isLogin } = useGlobalInfoStore();
|
||||
const { browserId, setBrowserId, notify, recordings, isLogin, recordingName, retrainRobotId } = useGlobalInfoStore();
|
||||
const { socket } = useSocketStore();
|
||||
const { state, dispatch } = useContext(AuthContext);
|
||||
const { user } = state;
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (recordingName) {
|
||||
setSaveRecordingName(recordingName);
|
||||
}
|
||||
}, [recordingName]);
|
||||
|
||||
const handleChangeOfTitle = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
if (needConfirm) {
|
||||
setNeedConfirm(false);
|
||||
}
|
||||
setRecordingName(value);
|
||||
setSaveRecordingName(value);
|
||||
}
|
||||
|
||||
const handleSaveRecording = async (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
if (recordings.includes(recordingName)) {
|
||||
if (recordings.includes(saveRecordingName)) {
|
||||
if (needConfirm) { return; }
|
||||
setNeedConfirm(true);
|
||||
} else {
|
||||
@@ -46,19 +52,43 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const exitRecording = useCallback(async () => {
|
||||
const handleFinishClick = () => {
|
||||
if (recordingName && !recordings.includes(recordingName)) {
|
||||
saveRecording();
|
||||
} else {
|
||||
setOpenModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const exitRecording = useCallback(async (data?: { actionType: string }) => {
|
||||
let successMessage = t('save_recording.notifications.save_success');
|
||||
|
||||
if (data && data.actionType) {
|
||||
if (data.actionType === 'retrained') {
|
||||
successMessage = t('save_recording.notifications.retrain_success');
|
||||
} else if (data.actionType === 'saved') {
|
||||
successMessage = t('save_recording.notifications.save_success');
|
||||
} else if (data.actionType === 'error') {
|
||||
successMessage = t('save_recording.notifications.save_error');
|
||||
}
|
||||
}
|
||||
|
||||
const notificationData = {
|
||||
type: 'success',
|
||||
message: t('save_recording.notifications.save_success'),
|
||||
message: successMessage,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
window.sessionStorage.setItem('pendingNotification', JSON.stringify(notificationData));
|
||||
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({
|
||||
type: 'recording-notification',
|
||||
notification: notificationData
|
||||
}, '*');
|
||||
|
||||
window.opener.postMessage({
|
||||
type: 'session-data-clear',
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (browserId) {
|
||||
@@ -67,16 +97,21 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
|
||||
setBrowserId(null);
|
||||
|
||||
window.close();
|
||||
}, [setBrowserId, browserId]);
|
||||
}, [setBrowserId, browserId, t]);
|
||||
|
||||
// notifies backed to save the recording in progress,
|
||||
// releases resources and changes the view for main page by clearing the global browserId
|
||||
const saveRecording = async () => {
|
||||
if (user) {
|
||||
const payload = { fileName: recordingName, userId: user.id, isLogin: isLogin };
|
||||
const payload = {
|
||||
fileName: saveRecordingName || recordingName,
|
||||
userId: user.id,
|
||||
isLogin: isLogin,
|
||||
robotId: retrainRobotId,
|
||||
};
|
||||
socket?.emit('save', payload);
|
||||
setWaitingForSave(true);
|
||||
console.log(`Saving the recording as ${recordingName} for userId ${user.id}`);
|
||||
console.log(`Saving the recording as ${saveRecordingName || recordingName} for userId ${user.id}`);
|
||||
} else {
|
||||
console.error(t('save_recording.notifications.user_not_logged'));
|
||||
}
|
||||
@@ -92,7 +127,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => setOpenModal(true)}
|
||||
onClick={handleFinishClick}
|
||||
variant="outlined"
|
||||
color="success"
|
||||
sx={{
|
||||
@@ -116,7 +151,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
|
||||
id="title"
|
||||
label={t('save_recording.robot_name')}
|
||||
variant="outlined"
|
||||
defaultValue={recordingName ? recordingName : null}
|
||||
value={saveRecordingName}
|
||||
/>
|
||||
{needConfirm
|
||||
?
|
||||
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
Settings,
|
||||
Power,
|
||||
ContentCopy,
|
||||
MoreHoriz
|
||||
MoreHoriz,
|
||||
Refresh
|
||||
} from "@mui/icons-material";
|
||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||
import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage";
|
||||
@@ -117,6 +118,7 @@ const TableRowMemoized = memo(({ row, columns, handlers }: any) => {
|
||||
return (
|
||||
<MemoizedTableCell key={column.id} align={column.align}>
|
||||
<MemoizedOptionsButton
|
||||
handleRetrain={() =>handlers.handleRetrainRobot(row.id, row.name)}
|
||||
handleEdit={() => handlers.handleEditRobot(row.id, row.name, row.params || [])}
|
||||
handleDuplicate={() => handlers.handleDuplicateRobot(row.id, row.name, row.params || [])}
|
||||
handleDelete={() => handlers.handleDelete(row.id)}
|
||||
@@ -185,7 +187,7 @@ export const RecordingsTable = ({
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: any) => {
|
||||
if (event.data && event.data.type === 'recording-notification') {
|
||||
if (event.origin === window.location.origin && event.data && event.data.type === 'recording-notification') {
|
||||
const notificationData = event.data.notification;
|
||||
if (notificationData) {
|
||||
notify(notificationData.type, notificationData.message);
|
||||
@@ -198,6 +200,17 @@ export const RecordingsTable = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.origin === window.location.origin && event.data && event.data.type === 'session-data-clear') {
|
||||
window.sessionStorage.removeItem('browserId');
|
||||
window.sessionStorage.removeItem('robotToRetrain');
|
||||
window.sessionStorage.removeItem('robotName');
|
||||
window.sessionStorage.removeItem('recordingUrl');
|
||||
window.sessionStorage.removeItem('recordingSessionId');
|
||||
window.sessionStorage.removeItem('pendingSessionData');
|
||||
window.sessionStorage.removeItem('nextTabIsRecording');
|
||||
window.sessionStorage.removeItem('initialUrl');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
@@ -303,6 +316,60 @@ export const RecordingsTable = ({
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleRetrainRobot = useCallback(async (id: string, name: string) => {
|
||||
const activeBrowserId = await getActiveBrowserId();
|
||||
const robot = rows.find(row => row.id === id);
|
||||
let targetUrl;
|
||||
|
||||
if (robot?.content?.workflow && robot.content.workflow.length > 0) {
|
||||
const lastPair = robot.content.workflow[robot.content.workflow.length - 1];
|
||||
|
||||
if (lastPair?.what) {
|
||||
if (Array.isArray(lastPair.what)) {
|
||||
const gotoAction = lastPair.what.find(action =>
|
||||
action && typeof action === 'object' && 'action' in action && action.action === "goto"
|
||||
) as any;
|
||||
|
||||
if (gotoAction?.args?.[0]) {
|
||||
targetUrl = gotoAction.args[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetUrl) {
|
||||
setInitialUrl(targetUrl);
|
||||
setRecordingUrl(targetUrl);
|
||||
window.sessionStorage.setItem('initialUrl', targetUrl);
|
||||
}
|
||||
|
||||
if (activeBrowserId) {
|
||||
setActiveBrowserId(activeBrowserId);
|
||||
setWarningModalOpen(true);
|
||||
} else {
|
||||
startRetrainRecording(id, name, targetUrl);
|
||||
}
|
||||
}, [rows, setInitialUrl, setRecordingUrl]);
|
||||
|
||||
const startRetrainRecording = (id: string, name: string, url?: string) => {
|
||||
setBrowserId('new-recording');
|
||||
setRecordingName(name);
|
||||
setRecordingId(id);
|
||||
|
||||
window.sessionStorage.setItem('browserId', 'new-recording');
|
||||
window.sessionStorage.setItem('robotToRetrain', id);
|
||||
window.sessionStorage.setItem('robotName', name);
|
||||
|
||||
window.sessionStorage.setItem('recordingUrl', url || recordingUrl);
|
||||
|
||||
const sessionId = Date.now().toString();
|
||||
window.sessionStorage.setItem('recordingSessionId', sessionId);
|
||||
|
||||
window.openedRecordingWindow = window.open(`/recording-setup?session=${sessionId}`, '_blank');
|
||||
|
||||
window.sessionStorage.setItem('nextTabIsRecording', 'true');
|
||||
};
|
||||
|
||||
const startRecording = () => {
|
||||
setModalOpen(false);
|
||||
|
||||
@@ -381,6 +448,7 @@ export const RecordingsTable = ({
|
||||
handleSettingsRecording,
|
||||
handleEditRobot,
|
||||
handleDuplicateRobot,
|
||||
handleRetrainRobot,
|
||||
handleDelete: async (id: string) => {
|
||||
const hasRuns = await checkRunsForRecording(id);
|
||||
if (hasRuns) {
|
||||
@@ -395,7 +463,7 @@ export const RecordingsTable = ({
|
||||
fetchRecordings();
|
||||
}
|
||||
}
|
||||
}), [handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot, notify, t]);
|
||||
}), [handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot, handleRetrainRobot, notify, t]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
@@ -597,12 +665,13 @@ const SettingsButton = ({ handleSettings }: SettingsButtonProps) => {
|
||||
}
|
||||
|
||||
interface OptionsButtonProps {
|
||||
handleRetrain: () => void;
|
||||
handleEdit: () => void;
|
||||
handleDelete: () => void;
|
||||
handleDuplicate: () => void;
|
||||
}
|
||||
|
||||
const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsButtonProps) => {
|
||||
const OptionsButton = ({ handleRetrain, handleEdit, handleDelete, handleDuplicate }: OptionsButtonProps) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
@@ -629,6 +698,13 @@ const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsBut
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<MenuItem onClick={() => { handleRetrain(); handleClose(); }}>
|
||||
<ListItemIcon>
|
||||
<Refresh fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t('recordingtable.retrain')}</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={() => { handleEdit(); handleClose(); }}>
|
||||
<ListItemIcon>
|
||||
<Edit fontSize="small" />
|
||||
|
||||
@@ -125,6 +125,7 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun
|
||||
{row.status === 'scheduled' && <Chip label={t('runs_table.run_status_chips.scheduled')} variant="outlined" />}
|
||||
{row.status === 'queued' && <Chip label={t('runs_table.run_status_chips.queued')} variant="outlined" />}
|
||||
{row.status === 'failed' && <Chip label={t('runs_table.run_status_chips.failed')} color="error" variant="outlined" />}
|
||||
{row.status === 'aborted' && <Chip label={t('runs_table.run_status_chips.aborted')} color="error" variant="outlined" />}
|
||||
</TableCell>
|
||||
)
|
||||
case 'delete':
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import SwipeableDrawer from '@mui/material/SwipeableDrawer';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Button, TextField, Grid } from '@mui/material';
|
||||
import { Button, Grid } from '@mui/material';
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSocketStore } from "../../context/socket";
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { Grid } from "@mui/material";
|
||||
import { RunsTable } from "./RunsTable";
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TablePagination from '@mui/material/TablePagination';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField, CircularProgress, Tooltip } from '@mui/material';
|
||||
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField, Tooltip } from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
@@ -390,7 +390,7 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
||||
TransitionProps={{ unmountOnExit: true }} // Optimize accordion rendering
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">{data[data.length - 1].name}</Typography>
|
||||
<Typography variant="h6">{data[0].name}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Table stickyHeader aria-label="sticky table">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, IconButton, Stack, Typography } from "@mui/material";
|
||||
import { Box, Button, Typography } from "@mui/material";
|
||||
|
||||
interface ConfirmationBoxProps {
|
||||
selector: string;
|
||||
|
||||
@@ -60,6 +60,8 @@ interface GlobalInfo {
|
||||
setRecordingLength: (recordingLength: number) => void;
|
||||
recordingId: string | null;
|
||||
setRecordingId: (newId: string | null) => void;
|
||||
retrainRobotId: string | null;
|
||||
setRetrainRobotId: (newId: string | null) => void;
|
||||
recordingName: string;
|
||||
setRecordingName: (recordingName: string) => void;
|
||||
initialUrl: string;
|
||||
@@ -90,6 +92,7 @@ class GlobalInfoStore implements Partial<GlobalInfo> {
|
||||
isOpen: false,
|
||||
};
|
||||
recordingId = null;
|
||||
retrainRobotId = null;
|
||||
recordings: string[] = [];
|
||||
rerenderRuns = false;
|
||||
rerenderRobots = false;
|
||||
@@ -119,6 +122,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
|
||||
const [rerenderRobots, setRerenderRobots] = useState<boolean>(globalInfoStore.rerenderRobots);
|
||||
const [recordingLength, setRecordingLength] = useState<number>(globalInfoStore.recordingLength);
|
||||
const [recordingId, setRecordingId] = useState<string | null>(globalInfoStore.recordingId);
|
||||
const [retrainRobotId, setRetrainRobotId] = useState<string | null>(globalInfoStore.retrainRobotId);
|
||||
const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName);
|
||||
const [isLogin, setIsLogin] = useState<boolean>(globalInfoStore.isLogin);
|
||||
const [initialUrl, setInitialUrl] = useState<string>(globalInfoStore.initialUrl);
|
||||
@@ -169,6 +173,8 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
|
||||
setRecordingLength,
|
||||
recordingId,
|
||||
setRecordingId,
|
||||
retrainRobotId,
|
||||
setRetrainRobotId,
|
||||
recordingName,
|
||||
setRecordingName,
|
||||
initialUrl,
|
||||
|
||||
@@ -12,8 +12,6 @@ import { io, Socket } from "socket.io-client";
|
||||
import { stopRecording } from "../api/recording";
|
||||
import { RunSettings } from "../components/run/RunSettings";
|
||||
import { ScheduleSettings } from "../components/robot/ScheduleSettings";
|
||||
import { IntegrationSettings } from "../components/integration/IntegrationSettings";
|
||||
import { RobotSettings } from "../components/robot/RobotSettings";
|
||||
import { apiUrl } from "../apiConfig";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -73,7 +71,7 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
|
||||
interpretStoredRecording(runId).then(async (interpretation: boolean) => {
|
||||
if (!aborted) {
|
||||
if (interpretation) {
|
||||
notify('success', t('main_page.notifications.interpretation_success', { name: runningRecordingName }));
|
||||
// notify('success', t('main_page.notifications.interpretation_success', { name: runningRecordingName }));
|
||||
} else {
|
||||
notify('success', t('main_page.notifications.interpretation_failed', { name: runningRecordingName }));
|
||||
// destroy the created browser
|
||||
@@ -114,6 +112,14 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
|
||||
notify('error', t('main_page.notifications.interpretation_failed', { name: robotName }));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('run-aborted', (data) => {
|
||||
setRerenderRuns(true);
|
||||
|
||||
const abortedRobotName = data.robotName;
|
||||
notify('success', t('main_page.notifications.abort_success', { name: abortedRobotName }));
|
||||
});
|
||||
|
||||
setContent('runs');
|
||||
if (browserId) {
|
||||
notify('info', t('main_page.notifications.run_started', { name: runningRecordingName }));
|
||||
|
||||
@@ -6,7 +6,6 @@ import { AuthProvider } from '../context/auth';
|
||||
import { RecordingPage } from "./RecordingPage";
|
||||
import { MainPage } from "./MainPage";
|
||||
import { useGlobalInfoStore } from "../context/globalInfo";
|
||||
import { getActiveBrowserId } from "../api/recording";
|
||||
import { AlertSnackbar } from "../components/ui/AlertSnackbar";
|
||||
import Login from './Login';
|
||||
import Register from './Register';
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Grid } from '@mui/material';
|
||||
import { BrowserContent } from "../components/browser/BrowserContent";
|
||||
import { InterpretationLog } from "../components/run/InterpretationLog";
|
||||
import { startRecording, getActiveBrowserId } from "../api/recording";
|
||||
import { LeftSidePanel } from "../components/recorder/LeftSidePanel";
|
||||
import { RightSidePanel } from "../components/recorder/RightSidePanel";
|
||||
import { Loader } from "../components/ui/Loader";
|
||||
import { useSocketStore } from "../context/socket";
|
||||
@@ -44,7 +43,7 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
|
||||
|
||||
const { setId, socket } = useSocketStore();
|
||||
const { setWidth } = useBrowserDimensionsStore();
|
||||
const { browserId, setBrowserId, recordingId, recordingUrl, setRecordingUrl } = useGlobalInfoStore();
|
||||
const { browserId, setBrowserId, recordingId, recordingUrl, setRecordingUrl, setRecordingName, setRetrainRobotId } = useGlobalInfoStore();
|
||||
|
||||
const handleShowOutputData = useCallback(() => {
|
||||
setShowOutputData(true);
|
||||
@@ -81,6 +80,19 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
|
||||
const storedUrl = window.sessionStorage.getItem('recordingUrl');
|
||||
if (storedUrl && !recordingUrl) {
|
||||
setRecordingUrl(storedUrl);
|
||||
window.sessionStorage.removeItem('recordingUrl');
|
||||
}
|
||||
|
||||
const robotName = window.sessionStorage.getItem('robotName');
|
||||
if (robotName) {
|
||||
setRecordingName(robotName);
|
||||
window.sessionStorage.removeItem('robotName');
|
||||
}
|
||||
|
||||
const recordingId = window.sessionStorage.getItem('robotToRetrain');
|
||||
if (recordingId) {
|
||||
setRetrainRobotId(recordingId);
|
||||
window.sessionStorage.removeItem('robotToRetrain');
|
||||
}
|
||||
|
||||
const id = await getActiveBrowserId();
|
||||
@@ -102,7 +114,7 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
}
|
||||
}, [setId, recordingUrl, setRecordingUrl]);
|
||||
}, [setId, recordingUrl, setRecordingUrl, setRecordingName, setRetrainRobotId]);
|
||||
|
||||
const changeBrowserDimensions = useCallback(() => {
|
||||
if (browserContentRef.current) {
|
||||
@@ -127,7 +139,7 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
|
||||
}
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}, [socket, browserId, recordingName, recordingId, isLoaded])
|
||||
}, [socket, browserId, recordingName, recordingId, isLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
socket?.on('loaded', handleLoaded);
|
||||
|
||||
Reference in New Issue
Block a user