Merge pull request #512 from getmaxun/develop

chore: release v0.0.13
This commit is contained in:
Karishma Shukla
2025-04-24 20:29:08 +05:30
committed by GitHub
45 changed files with 1058 additions and 451 deletions

View File

@@ -4,7 +4,6 @@ WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
COPY maxun-core ./maxun-core
# Install dependencies # Install dependencies
RUN npm install --legacy-peer-deps RUN npm install --legacy-peer-deps

View File

@@ -15,11 +15,11 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web
<p align="center"> <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://docs.maxun.dev/?ref=ghread"><b>Documentation</b></a> |
<a href="https://www.maxun.dev/?ref=ghread"><b>Website</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://discord.gg/5GbPjBUkws"><b>Discord</b></a> |
<a href="https://x.com/maxun_io?ref=ghread"><b>Twitter</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> <a href="https://www.youtube.com/@MaxunOSS?ref=ghread"><b>Watch Tutorials</b></a>
<br /> <br />
<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" /> <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') 1. Create a root folder for your project (e.g. 'maxun')
2. Create a file named `.env` in the root folder of the project 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. 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 # get back to the root directory
cd .. cd ..
# make sure playwright is properly initialized # install chromium and its dependencies
npx playwright install npx playwright install --with-deps chromium
npx playwright install-deps
# get back to the root directory # get back to the root directory
cd .. 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. | | `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_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. | | `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. | | `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 - ✨ Run Robots On A Specific Schedule
- ✨ Turn Websites to APIs - ✨ Turn Websites to APIs
- ✨ Turn Websites to Spreadsheets - ✨ Turn Websites to Spreadsheets
- ✨ Adapt To Website Layout Changes (coming soon) - ✨ Adapt To Website Layout Changes
- ✨ Extract Behind Login, With Two-Factor Authentication Support (coming soon) - ✨ Extract Behind Login,
-Integrations (currently Google Sheet) -Bypass Two-Factor Authentication For Extract Behind Login (coming soon)
- +++ A lot of amazing things soon! - ✨ Integrations
- +++ A lot of amazing things!
# 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.
# Screenshots # Screenshots
![Maxun PH Launch (1)-1-1](https://github.com/user-attachments/assets/d7c75fa2-2bbc-47bb-a5f6-0ee6c162f391) ![Maxun PH Launch (1)-1-1](https://github.com/user-attachments/assets/d7c75fa2-2bbc-47bb-a5f6-0ee6c162f391)
@@ -152,7 +150,7 @@ We offer a managed cloud version to run Maxun without having to manage the infra
![Maxun PH Launch (1)-9-1](https://github.com/user-attachments/assets/160f46fa-0357-4c1b-ba50-b4fe64453bb7) ![Maxun PH Launch (1)-9-1](https://github.com/user-attachments/assets/160f46fa-0357-4c1b-ba50-b4fe64453bb7)
# Note # 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 # License
<p> <p>

View File

@@ -17,16 +17,6 @@ services:
timeout: 5s timeout: 5s
retries: 5 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: minio:
image: minio/minio image: minio/minio
environment: environment:
@@ -61,7 +51,6 @@ services:
mem_limit: 2g # Set a 2GB memory limit mem_limit: 2g # Set a 2GB memory limit
depends_on: depends_on:
- postgres - postgres
- redis
- minio - minio
volumes: volumes:
- /var/run/dbus:/var/run/dbus - /var/run/dbus:/var/run/dbus
@@ -82,5 +71,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
minio_data: minio_data:
redis_data:

View File

@@ -1,6 +1,6 @@
{ {
"name": "maxun-core", "name": "maxun-core",
"version": "0.0.14", "version": "0.0.15",
"description": "Core package for Maxun, responsible for data extraction", "description": "Core package for Maxun, responsible for data extraction",
"main": "build/index.js", "main": "build/index.js",
"typings": "build/index.d.ts", "typings": "build/index.d.ts",

View File

@@ -523,49 +523,62 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3,
// Enhanced value extraction with context awareness // Enhanced value extraction with context awareness
function extractValue(element, attribute) { function extractValue(element, attribute) {
if (!element) return null; if (!element) return null;
// Get context-aware base URL
const baseURL = element.ownerDocument?.location?.href || window.location.origin;
// Check shadow root first // Get context-aware base URL
if (element.shadowRoot) { const baseURL = element.ownerDocument?.location?.href || window.location.origin;
const shadowContent = element.shadowRoot.textContent;
if (shadowContent?.trim()) { // Check shadow root first
return shadowContent.trim(); 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 // Enhanced table ancestor finding with context support
function findTableAncestor(element) { function findTableAncestor(element) {

View File

@@ -572,6 +572,7 @@ export default class Interpreter extends EventEmitter {
let visitedUrls: Set<string> = new Set<string>(); let visitedUrls: Set<string> = new Set<string>();
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1 second delay between retries const RETRY_DELAY = 1000; // 1 second delay between retries
const MAX_UNCHANGED_RESULTS = 5;
const debugLog = (message: string, ...args: any[]) => { const debugLog = (message: string, ...args: any[]) => {
console.log(`[Page ${visitedUrls.size}] [URL: ${page.url()}] ${message}`, ...args); 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 availableSelectors = config.pagination.selector.split(',');
let unchangedResultCounter = 0;
try { try {
while (true) { while (true) {
// Reduced timeout for faster performance
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
switch (config.pagination.type) { switch (config.pagination.type) {
case 'scrollDown': { case 'scrollDown': {
let previousResultCount = allResults.length;
await scrapeCurrentPage();
if (checkLimit()) {
return allResults;
}
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
const currentHeight = await page.evaluate(() => document.body.scrollHeight); 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) { if (currentHeight === previousHeight) {
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
allResults = allResults.concat(finalResults);
return allResults; return allResults;
} }
@@ -684,13 +700,30 @@ export default class Interpreter extends EventEmitter {
} }
case 'scrollUp': { case 'scrollUp': {
let previousResultCount = allResults.length;
await scrapeCurrentPage();
if (checkLimit()) {
return allResults;
}
await page.evaluate(() => window.scrollTo(0, 0)); await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
const currentTopHeight = await page.evaluate(() => document.documentElement.scrollTop); 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) { if (currentTopHeight === 0) {
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
allResults = allResults.concat(finalResults);
return allResults; return allResults;
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "maxun", "name": "maxun",
"version": "0.0.12", "version": "0.0.13",
"author": "Maxun", "author": "Maxun",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
@@ -27,9 +27,7 @@
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"bullmq": "^5.12.15",
"connect-pg-simple": "^10.0.0", "connect-pg-simple": "^10.0.0",
"connect-redis": "^8.0.1",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron-parser": "^4.9.0", "cron-parser": "^4.9.0",
@@ -52,7 +50,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"loglevel": "^1.8.0", "loglevel": "^1.8.0",
"loglevel-plugin-remote": "^0.6.8", "loglevel-plugin-remote": "^0.6.8",
"maxun-core": "^0.0.14", "maxun-core": "^0.0.15",
"minio": "^8.0.1", "minio": "^8.0.1",
"moment-timezone": "^0.5.45", "moment-timezone": "^0.5.45",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
@@ -72,7 +70,6 @@
"react-router-dom": "^6.26.1", "react-router-dom": "^6.26.1",
"react-simple-code-editor": "^0.11.2", "react-simple-code-editor": "^0.11.2",
"react-transition-group": "^4.4.2", "react-transition-group": "^4.4.2",
"redis": "^4.7.0",
"sequelize": "^6.37.3", "sequelize": "^6.37.3",
"sequelize-typescript": "^2.1.6", "sequelize-typescript": "^2.1.6",
"sharp": "^0.33.5", "sharp": "^0.33.5",
@@ -121,7 +118,6 @@
"@types/prismjs": "^1.26.0", "@types/prismjs": "^1.26.0",
"@types/react-highlight": "^0.12.5", "@types/react-highlight": "^0.12.5",
"@types/react-transition-group": "^4.4.4", "@types/react-transition-group": "^4.4.4",
"@types/redis": "^4.0.11",
"@types/styled-components": "^5.1.23", "@types/styled-components": "^5.1.23",
"@types/swagger-jsdoc": "^6.0.4", "@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.6", "@types/swagger-ui-express": "^4.1.6",

View File

@@ -54,6 +54,7 @@
"label": "URL", "label": "URL",
"button": "Aufnahme starten" "button": "Aufnahme starten"
}, },
"retrain": "Neu trainieren",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"delete": "Löschen", "delete": "Löschen",
"duplicate": "Duplizieren", "duplicate": "Duplizieren",
@@ -237,7 +238,9 @@
"confirm": "Bestätigen" "confirm": "Bestätigen"
}, },
"notifications": { "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": { "errors": {
"user_not_logged": "Benutzer nicht angemeldet. Aufnahme kann nicht gespeichert werden.", "user_not_logged": "Benutzer nicht angemeldet. Aufnahme kann nicht gespeichert werden.",
@@ -513,7 +516,8 @@
"running": "Läuft", "running": "Läuft",
"scheduled": "Geplant", "scheduled": "Geplant",
"queued": "In Warteschlange", "queued": "In Warteschlange",
"failed": "Fehlgeschlagen" "failed": "Fehlgeschlagen",
"aborted": "Abgebrochen"
}, },
"run_settings_modal": { "run_settings_modal": {
"title": "Ausführungseinstellungen", "title": "Ausführungseinstellungen",

View File

@@ -60,6 +60,7 @@
"discard_and_create":"Discard & Create New", "discard_and_create":"Discard & Create New",
"cancel":"Cancel" "cancel":"Cancel"
}, },
"retrain": "Retrain",
"edit":"Edit", "edit":"Edit",
"delete":"Delete", "delete":"Delete",
"duplicate":"Duplicate", "duplicate":"Duplicate",
@@ -245,7 +246,9 @@
"confirm": "Confirm" "confirm": "Confirm"
}, },
"notifications": { "notifications": {
"save_success": "Robot saved successfully" "save_success": "Robot saved successfully",
"retrain_success": "Robot retrained successfully",
"save_error": "Error saving robot"
}, },
"errors": { "errors": {
"user_not_logged": "User not logged in. Cannot save recording.", "user_not_logged": "User not logged in. Cannot save recording.",
@@ -521,7 +524,8 @@
"running": "Running", "running": "Running",
"scheduled": "Scheduled", "scheduled": "Scheduled",
"queued": "Queued", "queued": "Queued",
"failed": "Failed" "failed": "Failed",
"aborted": "Aborted"
}, },
"run_settings_modal": { "run_settings_modal": {
"title": "Run Settings", "title": "Run Settings",

View File

@@ -54,6 +54,7 @@
"label": "URL", "label": "URL",
"button": "Comenzar grabación" "button": "Comenzar grabación"
}, },
"retrain": "Reentrenar",
"edit": "Editar", "edit": "Editar",
"delete": "Eliminar", "delete": "Eliminar",
"duplicate": "Duplicar", "duplicate": "Duplicar",
@@ -238,7 +239,9 @@
"confirm": "Confirmar" "confirm": "Confirmar"
}, },
"notifications": { "notifications": {
"save_success": "Robot guardado exitosamente" "save_success": "Robot guardado correctamente",
"retrain_success": "Robot reentrenado correctamente",
"save_error": "Error al guardar el robot"
}, },
"errors": { "errors": {
"user_not_logged": "Usuario no conectado. No se puede guardar la grabación.", "user_not_logged": "Usuario no conectado. No se puede guardar la grabación.",
@@ -514,7 +517,8 @@
"running": "Ejecutando", "running": "Ejecutando",
"scheduled": "Programado", "scheduled": "Programado",
"queued": "En cola", "queued": "En cola",
"failed": "Fallido" "failed": "Fallido",
"aborted": "Abortado"
}, },
"run_settings_modal": { "run_settings_modal": {
"title": "Configuración de Ejecución", "title": "Configuración de Ejecución",

View File

@@ -54,6 +54,7 @@
"label": "URL", "label": "URL",
"button": "録画を開始" "button": "録画を開始"
}, },
"retrain": "再学習",
"edit": "編集", "edit": "編集",
"delete": "削除", "delete": "削除",
"duplicate": "複製", "duplicate": "複製",
@@ -238,7 +239,9 @@
"confirm": "確認" "confirm": "確認"
}, },
"notifications": { "notifications": {
"save_success": "ロボットが正常に保存されました" "save_success": "ロボットの保存に成功しました",
"retrain_success": "ロボットの再トレーニングに成功しました",
"save_error": "ロボットの保存中にエラーが発生しました"
}, },
"errors": { "errors": {
"user_not_logged": "ユーザーがログインしていません。録画を保存できません。", "user_not_logged": "ユーザーがログインしていません。録画を保存できません。",
@@ -514,7 +517,8 @@
"running": "実行中", "running": "実行中",
"scheduled": "スケジュール済み", "scheduled": "スケジュール済み",
"queued": "キューに入れました", "queued": "キューに入れました",
"failed": "失敗" "failed": "失敗",
"aborted": "中止されました"
}, },
"run_settings_modal": { "run_settings_modal": {
"title": "実行設定", "title": "実行設定",

View File

@@ -54,6 +54,7 @@
"label": "URL", "label": "URL",
"button": "开始录制" "button": "开始录制"
}, },
"retrain": "重新训练",
"edit": "编辑", "edit": "编辑",
"delete": "删除", "delete": "删除",
"duplicate": "复制", "duplicate": "复制",
@@ -238,7 +239,9 @@
"confirm": "确认" "confirm": "确认"
}, },
"notifications": { "notifications": {
"save_success": "机器人保存成功" "save_success": "机器人保存成功",
"retrain_success": "机器人重新训练成功",
"save_error": "保存机器人时出错"
}, },
"errors": { "errors": {
"user_not_logged": "用户未登录。无法保存录制。", "user_not_logged": "用户未登录。无法保存录制。",
@@ -514,7 +517,8 @@
"running": "运行中", "running": "运行中",
"scheduled": "已计划", "scheduled": "已计划",
"queued": "排队", "queued": "排队",
"failed": "失败" "failed": "失败",
"aborted": "已中止"
}, },
"run_settings_modal": { "run_settings_modal": {
"title": "运行设置", "title": "运行设置",

View File

@@ -5,7 +5,6 @@ WORKDIR /app
# Install node dependencies # Install node dependencies
COPY package*.json ./ COPY package*.json ./
COPY maxun-core ./maxun-core
COPY src ./src COPY src ./src
COPY public ./public COPY public ./public
COPY server ./server COPY server ./server

View File

@@ -9,11 +9,9 @@ import { chromium } from 'playwright-extra';
import stealthPlugin from 'puppeteer-extra-plugin-stealth'; import stealthPlugin from 'puppeteer-extra-plugin-stealth';
import { PlaywrightBlocker } from '@cliqz/adblocker-playwright'; import { PlaywrightBlocker } from '@cliqz/adblocker-playwright';
import fetch from 'cross-fetch'; import fetch from 'cross-fetch';
import { throttle } from 'lodash';
import sharp from 'sharp'; import sharp from 'sharp';
import logger from '../../logger'; import logger from '../../logger';
import { InterpreterSettings, RemoteBrowserOptions } from "../../types"; import { InterpreterSettings } from "../../types";
import { WorkflowGenerator } from "../../workflow-management/classes/Generator"; import { WorkflowGenerator } from "../../workflow-management/classes/Generator";
import { WorkflowInterpreter } from "../../workflow-management/classes/Interpreter"; import { WorkflowInterpreter } from "../../workflow-management/classes/Interpreter";
import { getDecryptedProxyConfig } from '../../routes/proxy'; 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 * @param socket socket.io socket instance used to communicate with the client side
* @constructor * @constructor
*/ */
public constructor(socket: Socket, userId: string) { public constructor(socket: Socket, userId: string, poolId: string) {
this.socket = socket; this.socket = socket;
this.userId = userId; this.userId = userId;
this.interpreter = new WorkflowInterpreter(socket); this.interpreter = new WorkflowInterpreter(socket);
this.generator = new WorkflowGenerator(socket); this.generator = new WorkflowGenerator(socket, poolId);
} }
private initializeMemoryManagement(): void { private initializeMemoryManagement(): void {
@@ -320,7 +318,6 @@ export class RemoteBrowser {
isMobile: false, isMobile: false,
hasTouch: false, hasTouch: false,
userAgent: this.getUserAgent(), userAgent: this.getUserAgent(),
deviceScaleFactor: 2,
}; };
if (proxyOptions.server) { if (proxyOptions.server) {
@@ -414,7 +411,7 @@ export class RemoteBrowser {
} }
} }
this.initializeMemoryManagement(); // this.initializeMemoryManagement();
}; };
public updateViewportInfo = async (): Promise<void> => { public updateViewportInfo = async (): Promise<void> => {

View File

@@ -5,7 +5,7 @@
import { Socket } from "socket.io"; import { Socket } from "socket.io";
import { uuid } from 'uuidv4'; 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 { io, browserPool } from "../server";
import { RemoteBrowser } from "./classes/RemoteBrowser"; import { RemoteBrowser } from "./classes/RemoteBrowser";
import { RemoteBrowserOptions } from "../types"; import { RemoteBrowserOptions } from "../types";
@@ -32,7 +32,7 @@ export const initializeRemoteBrowserForRecording = (userId: string): string => {
remoteBrowser?.updateSocket(socket); remoteBrowser?.updateSocket(socket);
await remoteBrowser?.makeAndEmitScreenshot(); await remoteBrowser?.makeAndEmitScreenshot();
} else { } else {
const browserSession = new RemoteBrowser(socket, userId); const browserSession = new RemoteBrowser(socket, userId, id);
browserSession.interpreter.subscribeToPausing(); browserSession.interpreter.subscribeToPausing();
await browserSession.initialize(userId); await browserSession.initialize(userId);
await browserSession.registerEditorEvents(); await browserSession.registerEditorEvents();
@@ -48,19 +48,27 @@ export const initializeRemoteBrowserForRecording = (userId: string): string => {
* Starts and initializes a {@link RemoteBrowser} instance for interpretation. * Starts and initializes a {@link RemoteBrowser} instance for interpretation.
* Creates a new {@link Socket} connection over a dedicated namespace. * Creates a new {@link Socket} connection over a dedicated namespace.
* Returns the new remote browser's generated id. * Returns the new remote browser's generated id.
* @param options {@link RemoteBrowserOptions} to be used when launching the browser * @param userId User ID for browser ownership
* @returns string * @returns string Browser ID
* @category BrowserManagement-Controller * @category BrowserManagement-Controller
*/ */
export const createRemoteBrowserForRun = (userId: string): string => { export const createRemoteBrowserForRun = (userId: string): string => {
const id = uuid(); const id = uuid();
registerBrowserUserContext(id, userId);
logger.log('debug', `Created new browser for run: ${id} for user: ${userId}`);
createSocketConnectionForRun( createSocketConnectionForRun(
io.of(id), io.of(`/${id}`),
async (socket: Socket) => { async (socket: Socket) => {
const browserSession = new RemoteBrowser(socket, userId); try {
await browserSession.initialize(userId); const browserSession = new RemoteBrowser(socket, userId, id);
browserPool.addRemoteBrowser(id, browserSession, userId, false, "run"); await browserSession.initialize(userId);
socket.emit('ready-for-run'); browserPool.addRemoteBrowser(id, browserSession, userId, false, "run");
socket.emit('ready-for-run');
} catch (error: any) {
logger.error(`Error initializing browser: ${error.message}`);
}
}); });
return id; return id;
}; };
@@ -73,13 +81,39 @@ export const createRemoteBrowserForRun = (userId: string): string => {
* @category BrowserManagement-Controller * @category BrowserManagement-Controller
*/ */
export const destroyRemoteBrowser = async (id: string, userId: string): Promise<boolean> => { export const destroyRemoteBrowser = async (id: string, userId: string): Promise<boolean> => {
const browserSession = browserPool.getRemoteBrowser(id); try {
if (browserSession) { 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}`); 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);
}; };
/** /**

View File

@@ -185,8 +185,19 @@ const onWheel = async (socket: AuthenticatedSocket, scrollDeltas: ScrollDeltas)
* @category BrowserManagement * @category BrowserManagement
*/ */
const handleWheel = async (generator: WorkflowGenerator, page: Page, { deltaX, deltaY }: ScrollDeltas) => { const handleWheel = async (generator: WorkflowGenerator, page: Page, { deltaX, deltaY }: ScrollDeltas) => {
await page.mouse.wheel(deltaX, deltaY); try {
logger.log('debug', `Scrolled horizontally ${deltaX} pixels and vertically ${deltaY} pixels`); 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}`);
}
}; };
/** /**

View File

@@ -1,4 +1,4 @@
import { Request, Response } from "express"; import { Response } from "express";
import User from "../models/User"; import User from "../models/User";
import { AuthenticatedRequest } from "../routes/record" import { AuthenticatedRequest } from "../routes/record"

View File

@@ -1,6 +1,6 @@
import { Model, DataTypes, Optional } from 'sequelize'; import { Model, DataTypes, Optional } from 'sequelize';
import sequelize from '../storage/db'; import sequelize from '../storage/db';
import { WorkflowFile, Where, What, WhereWhatPair } from 'maxun-core'; import { WhereWhatPair } from 'maxun-core';
interface RobotMeta { interface RobotMeta {
name: string; name: string;
@@ -143,9 +143,4 @@ Robot.init(
} }
); );
// Robot.hasMany(Run, {
// foreignKey: 'robotId',
// as: 'runs', // Alias for the relation
// });
export default Robot; export default Robot;

View File

@@ -1,6 +1,5 @@
import { DataTypes, Model, Optional } from 'sequelize'; import { DataTypes, Model, Optional } from 'sequelize';
import sequelize from '../storage/db'; import sequelize from '../storage/db';
import Robot from './Robot';
interface UserAttributes { interface UserAttributes {
id: number; id: number;
@@ -61,13 +60,6 @@ User.init(
proxy_username: { proxy_username: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true, 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: { proxy_password: {
type: DataTypes.STRING, type: DataTypes.STRING,
@@ -80,9 +72,4 @@ User.init(
} }
); );
// User.hasMany(Robot, {
// foreignKey: 'userId',
// as: 'robots', // Alias for the relation
// });
export default User; export default User;

View File

@@ -8,7 +8,6 @@ import {
destroyRemoteBrowser, destroyRemoteBrowser,
interpretWholeWorkflow, interpretWholeWorkflow,
stopRunningInterpretation, stopRunningInterpretation,
createRemoteBrowserForRun
} from './browser-management/controller'; } from './browser-management/controller';
import { WorkflowFile } from 'maxun-core'; import { WorkflowFile } from 'maxun-core';
import Run from './models/Run'; import Run from './models/Run';
@@ -22,7 +21,11 @@ import { airtableUpdateTasks, processAirtableUpdates } from './workflow-manageme
import { RemoteBrowser } from './browser-management/classes/RemoteBrowser'; import { RemoteBrowser } from './browser-management/classes/RemoteBrowser';
import { io as serverIo } from "./server"; 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 { interface InitializeBrowserData {
userId: string; userId: string;
@@ -47,6 +50,11 @@ interface ExecuteRunData {
browserId: string; browserId: string;
} }
interface AbortRunData {
userId: string;
runId: string;
}
const pgBoss = new PgBoss({connectionString: pgBossConnectionString }); const pgBoss = new PgBoss({connectionString: pgBossConnectionString });
/** /**
@@ -176,6 +184,11 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
return { success: false }; 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(); const plainRun = run.toJSON();
// Find the recording // Find the recording
@@ -183,12 +196,14 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
if (!recording) { if (!recording) {
logger.log('error', `Recording for run ${data.runId} not found`); logger.log('error', `Recording for run ${data.runId} not found`);
// Update run status to failed const currentRun = await Run.findOne({ where: { runId: data.runId } });
await run.update({ if (currentRun && (currentRun.status !== 'aborted' && currentRun.status !== 'aborting')) {
status: 'failed', await run.update({
finishedAt: new Date().toLocaleString(), status: 'failed',
log: 'Failed: Recording not found', finishedAt: new Date().toLocaleString(),
}); log: 'Failed: Recording not found',
});
}
// Check for queued runs even if this one failed // Check for queued runs even if this one failed
await checkAndProcessQueuedRun(data.userId, data.browserId); await checkAndProcessQueuedRun(data.userId, data.browserId);
@@ -203,8 +218,6 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
if (!browser || !currentPage) { if (!browser || !currentPage) {
logger.log('error', `Browser or page not available for run ${data.runId}`); 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 // Even if this run failed, check for queued runs
await checkAndProcessQueuedRun(data.userId, data.browserId); await checkAndProcessQueuedRun(data.userId, data.browserId);
@@ -215,6 +228,11 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
// Reset the browser state before executing this run // Reset the browser state before executing this run
await resetBrowserState(browser); 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 // Execute the workflow
const workflow = AddGeneratedFlags(recording.recording); const workflow = AddGeneratedFlags(recording.recording);
const interpretationInfo = await browser.interpreter.InterpretRecording( const interpretationInfo = await browser.interpreter.InterpretRecording(
@@ -224,10 +242,28 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
plainRun.interpreterSettings 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 // Process the results
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); 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 // Update the run record with results
await run.update({ await run.update({
...run, ...run,
@@ -318,11 +354,28 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
} catch (executionError: any) { } catch (executionError: any) {
logger.log('error', `Run execution failed for run ${data.runId}: ${executionError.message}`); logger.log('error', `Run execution failed for run ${data.runId}: ${executionError.message}`);
await run.update({ const currentRun = await Run.findOne({ where: { runId: data.runId } });
status: 'failed', if (currentRun && (currentRun.status !== 'aborted' && currentRun.status !== 'aborting')) {
finishedAt: new Date().toLocaleString(), await run.update({
log: `Failed: ${executionError.message}`, 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 // Check for queued runs before destroying the browser
const queuedRunProcessed = await checkAndProcessQueuedRun(data.userId, plainRun.browserId); 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}`); 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 }; 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() { async function registerRunExecutionWorker() {
try { 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 * Initialize PgBoss and register all workers
@@ -495,6 +710,9 @@ async function startWorkers() {
// Register the run execution worker // Register the run execution worker
await registerRunExecutionWorker(); await registerRunExecutionWorker();
// Register the abort run worker
await registerAbortRunWorker();
logger.log('info', 'All recording workers registered successfully'); logger.log('info', 'All recording workers registered successfully');
} catch (error: unknown) { } catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
@@ -506,6 +724,10 @@ async function startWorkers() {
// Start all workers // Start all workers
startWorkers(); startWorkers();
pgBoss.on('error', (error) => {
logger.log('error', `PgBoss error: ${error.message}`);
});
// Handle graceful shutdown // Handle graceful shutdown
process.on('SIGTERM', async () => { process.on('SIGTERM', async () => {
logger.log('info', 'SIGTERM received, shutting down PgBoss...'); logger.log('info', 'SIGTERM received, shutting down PgBoss...');
@@ -520,4 +742,4 @@ process.on('SIGINT', async () => {
}); });
// For use in other files // For use in other files
export { pgBoss }; export { pgBoss };

View File

@@ -1,5 +1,4 @@
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import User from "../models/User"; import User from "../models/User";
import Robot from "../models/Robot"; import Robot from "../models/Robot";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
@@ -10,7 +9,6 @@ import { google } from "googleapis";
import { capture } from "../utils/analytics"; import { capture } from "../utils/analytics";
import crypto from 'crypto'; import crypto from 'crypto';
declare module "express-session" { declare module "express-session" {
interface SessionData { interface SessionData {
code_verifier: string; code_verifier: string;

View File

@@ -5,8 +5,6 @@ import { Router, Request, Response } from 'express';
import { import {
initializeRemoteBrowserForRecording, initializeRemoteBrowserForRecording,
destroyRemoteBrowser,
getActiveBrowserId,
interpretWholeWorkflow, interpretWholeWorkflow,
stopRunningInterpretation, stopRunningInterpretation,
getRemoteBrowserCurrentUrl, getRemoteBrowserCurrentUrl,
@@ -16,7 +14,6 @@ import {
import { chromium } from 'playwright-extra'; import { chromium } from 'playwright-extra';
import stealthPlugin from 'puppeteer-extra-plugin-stealth'; import stealthPlugin from 'puppeteer-extra-plugin-stealth';
import logger from "../logger"; import logger from "../logger";
import { getDecryptedProxyConfig } from './proxy';
import { requireSignIn } from '../middlewares/auth'; import { requireSignIn } from '../middlewares/auth';
import { pgBoss } from '../pgboss-worker'; 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...`); logger.log('info', `Queued interpret workflow job: ${jobId}, waiting for completion...`);
try { try {
const result = await waitForJobCompletion(jobId, 'interpret-workflow', 15000); const result = await waitForJobCompletion(jobId, 'interpret-workflow', 1000000);
if (result) { if (result) {
return res.send('interpretation done'); return res.send('interpretation done');

View File

@@ -1,27 +1,22 @@
import { Router } from 'express'; import { Router } from 'express';
import logger from "../logger"; 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 { chromium } from 'playwright-extra';
import stealthPlugin from 'puppeteer-extra-plugin-stealth'; import stealthPlugin from 'puppeteer-extra-plugin-stealth';
import { browserPool } from "../server"; import { browserPool } from "../server";
import { uuid } from "uuidv4"; import { uuid } from "uuidv4";
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import cron from 'node-cron'; import cron from 'node-cron';
import { googleSheetUpdateTasks, processGoogleSheetUpdates } from '../workflow-management/integrations/gsheet';
import { getDecryptedProxyConfig } from './proxy'; import { getDecryptedProxyConfig } from './proxy';
import { requireSignIn } from '../middlewares/auth'; import { requireSignIn } from '../middlewares/auth';
import Robot from '../models/Robot'; import Robot from '../models/Robot';
import Run from '../models/Run'; import Run from '../models/Run';
import { BinaryOutputService } from '../storage/mino';
import { workflowQueue } from '../worker';
import { AuthenticatedRequest } from './record'; import { AuthenticatedRequest } from './record';
import { computeNextRun } from '../utils/schedule'; import { computeNextRun } from '../utils/schedule';
import { capture } from "../utils/analytics"; import { capture } from "../utils/analytics";
import { tryCatch } from 'bullmq';
import { encrypt, decrypt } from '../utils/auth'; import { encrypt, decrypt } from '../utils/auth';
import { WorkflowFile } from 'maxun-core'; import { WorkflowFile } from 'maxun-core';
import { Page } from 'playwright'; import { cancelScheduledWorkflow, scheduleWorkflow } from '../schedule-worker';
import { airtableUpdateTasks, processAirtableUpdates } from '../workflow-management/integrations/airtable';
import { pgBoss } from '../pgboss-worker'; import { pgBoss } from '../pgboss-worker';
chromium.use(stealthPlugin()); chromium.use(stealthPlugin());
@@ -761,7 +756,7 @@ router.put('/schedule/:id/', requireSignIn, async (req: AuthenticatedRequest, re
switch (runEveryUnit) { switch (runEveryUnit) {
case 'MINUTES': case 'MINUTES':
cronExpression = `${startMinutes} */${runEvery} * * *`; cronExpression = `*/${runEvery} * * * *`;
break; break;
case 'HOURS': case 'HOURS':
cronExpression = `${startMinutes} */${runEvery} * * *`; cronExpression = `${startMinutes} */${runEvery} * * *`;
@@ -774,7 +769,7 @@ router.put('/schedule/:id/', requireSignIn, async (req: AuthenticatedRequest, re
break; break;
case 'MONTHS': case 'MONTHS':
// todo: handle leap year // todo: handle leap year
cronExpression = `0 ${atTimeStart} ${dayOfMonth} * *`; cronExpression = `${startMinutes} ${startHours} ${dayOfMonth} */${runEvery} *`;
if (startFrom !== 'SUNDAY') { if (startFrom !== 'SUNDAY') {
cronExpression += ` ${dayIndex}`; cronExpression += ` ${dayIndex}`;
} }
@@ -792,17 +787,13 @@ router.put('/schedule/:id/', requireSignIn, async (req: AuthenticatedRequest, re
return res.status(401).json({ error: 'Unauthorized' }); return res.status(401).json({ error: 'Unauthorized' });
} }
// Create the job in the queue with the cron expression try {
const job = await workflowQueue.add( await cancelScheduledWorkflow(id);
'run workflow', } catch (cancelError) {
{ id, runId: uuid(), userId: req.user.id }, logger.log('warn', `Failed to cancel existing schedule for robot ${id}: ${cancelError}`);
{ }
repeat: {
pattern: cronExpression, const jobId = await scheduleWorkflow(id, req.user.id, cronExpression, timezone);
tz: timezone,
},
}
);
const nextRunAt = computeNextRun(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' }); return res.status(404).json({ error: 'Robot not found' });
} }
// Remove existing job from queue if it exists // Cancel the scheduled job in PgBoss
const existingJobs = await workflowQueue.getJobs(['delayed', 'waiting']); try {
for (const job of existingJobs) { await cancelScheduledWorkflow(id);
if (job.data.id === id) { } catch (error) {
await job.remove(); 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 // 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) => { router.post('/runs/abort/:id', requireSignIn, async (req: AuthenticatedRequest, res) => {
try { try {
if (!req.user) { return res.status(401).send({ error: 'Unauthorized' }); } 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, runId: req.params.id,
runByUserId: req.user.id, runByUserId: req.user.id,
} }); } });
if (!run) { if (!run) {
return res.status(404).send(false); return res.status(404).send(false);
} }
const plainRun = run.toJSON();
const userQueueName = `abort-run-user-${req.user.id}`;
const browser = browserPool.getRemoteBrowser(plainRun.browserId); await pgBoss.createQueue(userQueueName);
const currentLog = browser?.interpreter.debugMessages.join('/n');
const serializableOutput = browser?.interpreter.serializableData.reduce((reducedObject, item, index) => { await pgBoss.send(userQueueName, {
return { userId: req.user.id,
[`item-${index}`]: item, runId: req.params.id
...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,
}); });
await run.update({
status: 'aborting'
});
return res.send(true); return res.send(true);
} catch (e) { } catch (e) {
const { message } = e as Error; 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); return res.send(false);
} }
}); });

View 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);
});

View File

@@ -8,9 +8,7 @@ import { record, workflow, storage, auth, integration, proxy } from './routes';
import { BrowserPool } from "./browser-management/classes/BrowserPool"; import { BrowserPool } from "./browser-management/classes/BrowserPool";
import logger from './logger'; import logger from './logger';
import { connectDB, syncDB } from './storage/db' import { connectDB, syncDB } from './storage/db'
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import csrf from 'csurf';
import { SERVER_PORT } from "./constants/config"; import { SERVER_PORT } from "./constants/config";
import { Server } from "socket.io"; import { Server } from "socket.io";
import { readdirSync } from "fs" import { readdirSync } from "fs"
@@ -20,9 +18,7 @@ import swaggerUi from 'swagger-ui-express';
import swaggerSpec from './swagger/config'; import swaggerSpec from './swagger/config';
import connectPgSimple from 'connect-pg-simple'; import connectPgSimple from 'connect-pg-simple';
import pg from 'pg'; import pg from 'pg';
import session from 'express-session'; import session from 'express-session';
import Run from './models/Run'; import Run from './models/Run';
const app = express(); const app = express();
@@ -97,7 +93,7 @@ readdirSync(path.join(__dirname, 'api')).forEach((r) => {
}); });
const isProduction = process.env.NODE_ENV === 'production'; 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'); const recordingWorkerPath = path.resolve(__dirname, isProduction ? './pgboss-worker.js' : './pgboss-worker.ts');
let workerProcess: any; let workerProcess: any;

View File

@@ -12,48 +12,85 @@ interface AuthenticatedSocket extends Socket {
request: AuthenticatedIncomingMessage; 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 * Socket.io middleware for authentication
* This is a socket.io specific auth handler that doesn't rely on Express middleware * This is a socket.io specific auth handler that doesn't rely on Express middleware
*/ */
const socketAuthMiddleware = (socket: Socket, next: (err?: Error) => void) => { const socketAuthMiddleware = (socket: Socket, next: (err?: Error) => void) => {
const cookies = socket.handshake.headers.cookie; // Extract browserId from namespace
if (!cookies) { const namespace = socket.nsp.name;
return next(new Error('Authentication required')); 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=')); // Normalize payload key
if (!tokenMatch) { if (user.userId && !user.id) {
return next(new Error('Authentication required')); user.id = user.userId;
delete user.userId;
} }
const token = tokenMatch.split('=')[1]; // Attach user to socket request
if (!token) { const authSocket = socket as AuthenticatedSocket;
return next(new Error('Authentication required')); authSocket.request.user = user;
} next();
});
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();
});
}; };
/** /**

View File

@@ -1,10 +1,13 @@
import { Sequelize } from 'sequelize'; import { Sequelize } from 'sequelize';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import setupAssociations from '../models/associations';
dotenv.config(); 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 // Extract the hostname using the URL constructor
const host = new URL(databaseUrl).hostname; const host = new URL(databaseUrl).hostname;
@@ -43,4 +46,4 @@ export const syncDB = async () => {
}; };
export default sequelize; export default sequelize;

View File

@@ -13,13 +13,8 @@ import {
selectorAlreadyInWorkflow selectorAlreadyInWorkflow
} from "../selector"; } from "../selector";
import { CustomActions } from "../../../../src/shared/types"; import { CustomActions } from "../../../../src/shared/types";
import { workflow } from "../../routes";
import Robot from "../../models/Robot"; import Robot from "../../models/Robot";
import Run from "../../models/Run";
import { saveFile } from "../storage";
import fs from "fs";
import { getBestSelectorForAction } from "../utils"; import { getBestSelectorForAction } from "../utils";
import { browserPool } from "../../server";
import { uuid } from "uuidv4"; import { uuid } from "uuidv4";
import { capture } from "../../utils/analytics" import { capture } from "../../utils/analytics"
import { decrypt, encrypt } from "../../utils/auth"; import { decrypt, encrypt } from "../../utils/auth";
@@ -74,14 +69,17 @@ export class WorkflowGenerator {
private paginationMode: boolean = false; private paginationMode: boolean = false;
private poolId: string | null = null;
/** /**
* The public constructor of the WorkflowGenerator. * The public constructor of the WorkflowGenerator.
* Takes socket for communication as a parameter and registers some important events on it. * Takes socket for communication as a parameter and registers some important events on it.
* @param socket The socket used to communicate with the client. * @param socket The socket used to communicate with the client.
* @constructor * @constructor
*/ */
public constructor(socket: Socket) { public constructor(socket: Socket, poolId: string) {
this.socket = socket; this.socket = socket;
this.poolId = poolId;
this.registerEventHandlers(socket); this.registerEventHandlers(socket);
this.initializeSocketListeners(); this.initializeSocketListeners();
} }
@@ -143,17 +141,18 @@ export class WorkflowGenerator {
*/ */
private registerEventHandlers = (socket: Socket) => { private registerEventHandlers = (socket: Socket) => {
socket.on('save', (data) => { 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}`); 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 = { socket.on('new-recording', (data) => {
workflow: [], this.workflowRecord = {
workflow: [],
};
}); });
socket.on('activeIndex', (data) => this.generatedData.lastIndex = parseInt(data)); socket.on('activeIndex', (data) => this.generatedData.lastIndex = parseInt(data));
socket.on('decision', async ({ pair, actionType, decision, userId }) => { socket.on('decision', async ({ pair, actionType, decision, userId }) => {
const id = browserPool.getActiveBrowserId(userId, "recording"); if (this.poolId) {
if (id) {
// const activeBrowser = browserPool.getRemoteBrowser(id); // const activeBrowser = browserPool.getRemoteBrowser(id);
// const currentPage = activeBrowser?.getCurrentPage(); // const currentPage = activeBrowser?.getCurrentPage();
if (!decision) { if (!decision) {
@@ -768,38 +767,62 @@ export class WorkflowGenerator {
* @param fileName The name of the file. * @param fileName The name of the file.
* @returns {Promise<void>} * @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); const recording = this.optimizeWorkflow(this.workflowRecord);
let actionType = 'saved';
try { try {
this.recordingMeta = { if (robotId) {
name: fileName, const robot = await Robot.findOne({ where: { 'recording_meta.id': robotId }});
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,
}
)
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) { catch (e) {
const { message } = e as Error; const { message } = e as Error;
logger.log('warn', `Cannot save the file to the local file system ${e}`) logger.log('warn', `Cannot save the file to the local file system ${e}`)
actionType = 'error';
} }
this.socket.emit('fileSaved');
this.socket.emit('fileSaved', { actionType });
} }
/** /**

View File

@@ -114,7 +114,7 @@ async function executeRun(id: string, userId: string) {
plainRun.status = 'running'; plainRun.status = 'running';
const browser = browserPool.getRemoteBrowser(userId); const browser = browserPool.getRemoteBrowser(plainRun.browserId);
if (!browser) { if (!browser) {
throw new Error('Could not access browser'); throw new Error('Could not access browser');
} }

View File

@@ -308,53 +308,29 @@ export const getElementInformation = async (
let elements = document.elementsFromPoint(x, y) as HTMLElement[]; let elements = document.elementsFromPoint(x, y) as HTMLElement[];
if (!elements.length) return null; 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) return null;
if (elements.length === 1) return elements[0]; if (elements.length === 1) return elements[0];
let deepestElement = elements[0]; for (let i = 0; i < elements.length; i++) {
let maxDepth = 0; const element = elements[i];
const rect = element.getBoundingClientRect();
for (const element of elements) {
let depth = 0;
let current = element;
while (current) { if (rect.width >= 30 && rect.height >= 30) {
depth++; const hasChildrenInList = elements.some((otherElement, j) =>
if (current.parentElement) { i !== j && element.contains(otherElement)
current = current.parentElement; );
} else {
break; if (hasChildrenInList) {
} return element;
} }
if (depth > maxDepth) {
maxDepth = depth;
deepestElement = element;
} }
} }
return deepestElement; return elements[0];
}; };
// Logic to get list container element
let targetElement = null;
for (const element of elements) { let deepestElement = findContainerElement(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);
if (!deepestElement) return null; if (!deepestElement) return null;
const traverseShadowDOM = (element: HTMLElement): HTMLElement => { 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[]; let elements = document.elementsFromPoint(x, y) as HTMLElement[];
if (!elements.length) return null; 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) return null;
if (elements.length === 1) return elements[0]; if (elements.length === 1) return elements[0];
let deepestElement = elements[0]; for (let i = 0; i < elements.length; i++) {
let maxDepth = 0; const element = elements[i];
const rect = element.getBoundingClientRect();
for (const element of elements) {
let depth = 0;
let current = element;
while (current) { if (rect.width >= 30 && rect.height >= 30) {
depth++; const hasChildrenInList = elements.some((otherElement, j) =>
if (current.parentElement) { i !== j && element.contains(otherElement)
current = current.parentElement; );
} else {
break; if (hasChildrenInList) {
} return element;
} }
if (depth > maxDepth) {
maxDepth = depth;
deepestElement = element;
} }
} }
return deepestElement; return elements[0];
}; };
// Logic to get list container element
let targetElement = null;
for (const element of elements) { let deepestElement = findContainerElement(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);
if (!deepestElement) return null; if (!deepestElement) return null;
const traverseShadowDOM = (element: HTMLElement): HTMLElement => { 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[]; let elements = document.elementsFromPoint(x, y) as HTMLElement[];
if (!elements.length) return null; 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) return null;
if (elements.length === 1) return elements[0]; if (elements.length === 1) return elements[0];
let deepestElement = elements[0]; for (let i = 0; i < elements.length; i++) {
let maxDepth = 0; const element = elements[i];
const rect = element.getBoundingClientRect();
for (const element of elements) {
let depth = 0;
let current = element;
while (current) { if (rect.width >= 30 && rect.height >= 30) {
depth++; const hasChildrenInList = elements.some((otherElement, j) =>
if (current.parentElement) { i !== j && element.contains(otherElement)
current = current.parentElement; );
} else {
break; if (hasChildrenInList) {
} return element;
} }
if (depth > maxDepth) {
maxDepth = depth;
deepestElement = element;
} }
} }
return deepestElement; return elements[0];
}; };
// Logic to get list container element
let targetElement = null;
for (const element of elements) { let deepestElement = findContainerElement(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);
if (!deepestElement) return null; if (!deepestElement) return null;
const traverseShadowDOM = (element: HTMLElement): HTMLElement => { const traverseShadowDOM = (element: HTMLElement): HTMLElement => {

View File

@@ -102,7 +102,7 @@ const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => {
sx={{ sx={{
color: isDarkMode ? 'white' : 'default', color: isDarkMode ? 'white' : 'default',
'&.Mui-checked': { '&.Mui-checked': {
color: isDarkMode ? '#90caf9' : '#1976d2', color: '#ff33cc',
}, },
}} }}
/> />

View File

@@ -5,7 +5,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import { NavBarButton } from '../ui/buttons/buttons'; import { NavBarButton } from '../ui/buttons/buttons';
import { UrlForm } from './UrlForm'; import { UrlForm } from './UrlForm';
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect } from "react";
import { useSocketStore } from "../../context/socket"; import { useSocketStore } from "../../context/socket";
import { getCurrentUrl } from "../../api/recording"; import { getCurrentUrl } from "../../api/recording";
import { useGlobalInfoStore } from '../../context/globalInfo'; import { useGlobalInfoStore } from '../../context/globalInfo';

View File

@@ -55,6 +55,11 @@ const BrowserRecordingSave = () => {
type: 'recording-notification', type: 'recording-notification',
notification: notificationData notification: notificationData
}, '*'); }, '*');
window.opener.postMessage({
type: 'session-data-clear',
timestamp: Date.now()
}, '*');
} }
setBrowserId(null); setBrowserId(null);

View File

@@ -4,7 +4,7 @@ import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Paper, Button, useTheme } from "@mui/material"; 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 { apiUrl } from "../../apiConfig";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import i18n from '../../i18n'; 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 />}> <Button href={`${apiUrl}/api-docs/`} target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<Code />}>
{t('mainmenu.apidocs')} {t('mainmenu.apidocs')}
</Button> </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')} {t('mainmenu.feedback')}
</Button> </Button>
</Box> </Box>

View File

@@ -19,26 +19,32 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [openModal, setOpenModal] = useState<boolean>(false); const [openModal, setOpenModal] = useState<boolean>(false);
const [needConfirm, setNeedConfirm] = 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 [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 { socket } = useSocketStore();
const { state, dispatch } = useContext(AuthContext); const { state, dispatch } = useContext(AuthContext);
const { user } = state; const { user } = state;
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
if (recordingName) {
setSaveRecordingName(recordingName);
}
}, [recordingName]);
const handleChangeOfTitle = (event: React.ChangeEvent<HTMLInputElement>) => { const handleChangeOfTitle = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target; const { value } = event.target;
if (needConfirm) { if (needConfirm) {
setNeedConfirm(false); setNeedConfirm(false);
} }
setRecordingName(value); setSaveRecordingName(value);
} }
const handleSaveRecording = async (event: React.SyntheticEvent) => { const handleSaveRecording = async (event: React.SyntheticEvent) => {
event.preventDefault(); event.preventDefault();
if (recordings.includes(recordingName)) { if (recordings.includes(saveRecordingName)) {
if (needConfirm) { return; } if (needConfirm) { return; }
setNeedConfirm(true); setNeedConfirm(true);
} else { } 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 = { const notificationData = {
type: 'success', type: 'success',
message: t('save_recording.notifications.save_success'), message: successMessage,
timestamp: Date.now() timestamp: Date.now()
}; };
window.sessionStorage.setItem('pendingNotification', JSON.stringify(notificationData));
if (window.opener) { if (window.opener) {
window.opener.postMessage({ window.opener.postMessage({
type: 'recording-notification', type: 'recording-notification',
notification: notificationData notification: notificationData
}, '*'); }, '*');
window.opener.postMessage({
type: 'session-data-clear',
timestamp: Date.now()
}, '*');
} }
if (browserId) { if (browserId) {
@@ -67,16 +97,21 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
setBrowserId(null); setBrowserId(null);
window.close(); window.close();
}, [setBrowserId, browserId]); }, [setBrowserId, browserId, t]);
// notifies backed to save the recording in progress, // notifies backed to save the recording in progress,
// releases resources and changes the view for main page by clearing the global browserId // releases resources and changes the view for main page by clearing the global browserId
const saveRecording = async () => { const saveRecording = async () => {
if (user) { 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); socket?.emit('save', payload);
setWaitingForSave(true); 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 { } else {
console.error(t('save_recording.notifications.user_not_logged')); console.error(t('save_recording.notifications.user_not_logged'));
} }
@@ -92,7 +127,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
return ( return (
<div> <div>
<Button <Button
onClick={() => setOpenModal(true)} onClick={handleFinishClick}
variant="outlined" variant="outlined"
color="success" color="success"
sx={{ sx={{
@@ -116,7 +151,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
id="title" id="title"
label={t('save_recording.robot_name')} label={t('save_recording.robot_name')}
variant="outlined" variant="outlined"
defaultValue={recordingName ? recordingName : null} value={saveRecordingName}
/> />
{needConfirm {needConfirm
? ?

View File

@@ -35,7 +35,8 @@ import {
Settings, Settings,
Power, Power,
ContentCopy, ContentCopy,
MoreHoriz MoreHoriz,
Refresh
} from "@mui/icons-material"; } from "@mui/icons-material";
import { useGlobalInfoStore } from "../../context/globalInfo"; import { useGlobalInfoStore } from "../../context/globalInfo";
import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage"; import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage";
@@ -117,6 +118,7 @@ const TableRowMemoized = memo(({ row, columns, handlers }: any) => {
return ( return (
<MemoizedTableCell key={column.id} align={column.align}> <MemoizedTableCell key={column.id} align={column.align}>
<MemoizedOptionsButton <MemoizedOptionsButton
handleRetrain={() =>handlers.handleRetrainRobot(row.id, row.name)}
handleEdit={() => handlers.handleEditRobot(row.id, row.name, row.params || [])} handleEdit={() => handlers.handleEditRobot(row.id, row.name, row.params || [])}
handleDuplicate={() => handlers.handleDuplicateRobot(row.id, row.name, row.params || [])} handleDuplicate={() => handlers.handleDuplicateRobot(row.id, row.name, row.params || [])}
handleDelete={() => handlers.handleDelete(row.id)} handleDelete={() => handlers.handleDelete(row.id)}
@@ -185,7 +187,7 @@ export const RecordingsTable = ({
useEffect(() => { useEffect(() => {
const handleMessage = (event: any) => { 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; const notificationData = event.data.notification;
if (notificationData) { if (notificationData) {
notify(notificationData.type, notificationData.message); 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); window.addEventListener('message', handleMessage);
@@ -303,6 +316,60 @@ export const RecordingsTable = ({
setModalOpen(true); 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 = () => { const startRecording = () => {
setModalOpen(false); setModalOpen(false);
@@ -381,6 +448,7 @@ export const RecordingsTable = ({
handleSettingsRecording, handleSettingsRecording,
handleEditRobot, handleEditRobot,
handleDuplicateRobot, handleDuplicateRobot,
handleRetrainRobot,
handleDelete: async (id: string) => { handleDelete: async (id: string) => {
const hasRuns = await checkRunsForRecording(id); const hasRuns = await checkRunsForRecording(id);
if (hasRuns) { if (hasRuns) {
@@ -395,7 +463,7 @@ export const RecordingsTable = ({
fetchRecordings(); fetchRecordings();
} }
} }
}), [handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot, notify, t]); }), [handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot, handleRetrainRobot, notify, t]);
return ( return (
<React.Fragment> <React.Fragment>
@@ -597,12 +665,13 @@ const SettingsButton = ({ handleSettings }: SettingsButtonProps) => {
} }
interface OptionsButtonProps { interface OptionsButtonProps {
handleRetrain: () => void;
handleEdit: () => void; handleEdit: () => void;
handleDelete: () => void; handleDelete: () => void;
handleDuplicate: () => 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 [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleClick = (event: React.MouseEvent<HTMLElement>) => { const handleClick = (event: React.MouseEvent<HTMLElement>) => {
@@ -629,6 +698,13 @@ const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsBut
open={Boolean(anchorEl)} open={Boolean(anchorEl)}
onClose={handleClose} onClose={handleClose}
> >
<MenuItem onClick={() => { handleRetrain(); handleClose(); }}>
<ListItemIcon>
<Refresh fontSize="small" />
</ListItemIcon>
<ListItemText>{t('recordingtable.retrain')}</ListItemText>
</MenuItem>
<MenuItem onClick={() => { handleEdit(); handleClose(); }}> <MenuItem onClick={() => { handleEdit(); handleClose(); }}>
<ListItemIcon> <ListItemIcon>
<Edit fontSize="small" /> <Edit fontSize="small" />

View File

@@ -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 === '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 === '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 === '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> </TableCell>
) )
case 'delete': case 'delete':

View File

@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import SwipeableDrawer from '@mui/material/SwipeableDrawer'; import SwipeableDrawer from '@mui/material/SwipeableDrawer';
import Typography from '@mui/material/Typography'; 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 { useCallback, useEffect, useRef, useState } from "react";
import { useSocketStore } from "../../context/socket"; import { useSocketStore } from "../../context/socket";
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React from 'react';
import { Grid } from "@mui/material"; import { Grid } from "@mui/material";
import { RunsTable } from "./RunsTable"; import { RunsTable } from "./RunsTable";

View File

@@ -9,7 +9,7 @@ import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead'; import TableHead from '@mui/material/TableHead';
import TablePagination from '@mui/material/TablePagination'; import TablePagination from '@mui/material/TablePagination';
import TableRow from '@mui/material/TableRow'; 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 ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
@@ -390,7 +390,7 @@ export const RunsTable: React.FC<RunsTableProps> = ({
TransitionProps={{ unmountOnExit: true }} // Optimize accordion rendering TransitionProps={{ unmountOnExit: true }} // Optimize accordion rendering
> >
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">{data[data.length - 1].name}</Typography> <Typography variant="h6">{data[0].name}</Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
<Table stickyHeader aria-label="sticky table"> <Table stickyHeader aria-label="sticky table">

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Box, Button, IconButton, Stack, Typography } from "@mui/material"; import { Box, Button, Typography } from "@mui/material";
interface ConfirmationBoxProps { interface ConfirmationBoxProps {
selector: string; selector: string;

View File

@@ -60,6 +60,8 @@ interface GlobalInfo {
setRecordingLength: (recordingLength: number) => void; setRecordingLength: (recordingLength: number) => void;
recordingId: string | null; recordingId: string | null;
setRecordingId: (newId: string | null) => void; setRecordingId: (newId: string | null) => void;
retrainRobotId: string | null;
setRetrainRobotId: (newId: string | null) => void;
recordingName: string; recordingName: string;
setRecordingName: (recordingName: string) => void; setRecordingName: (recordingName: string) => void;
initialUrl: string; initialUrl: string;
@@ -90,6 +92,7 @@ class GlobalInfoStore implements Partial<GlobalInfo> {
isOpen: false, isOpen: false,
}; };
recordingId = null; recordingId = null;
retrainRobotId = null;
recordings: string[] = []; recordings: string[] = [];
rerenderRuns = false; rerenderRuns = false;
rerenderRobots = false; rerenderRobots = false;
@@ -119,6 +122,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
const [rerenderRobots, setRerenderRobots] = useState<boolean>(globalInfoStore.rerenderRobots); const [rerenderRobots, setRerenderRobots] = useState<boolean>(globalInfoStore.rerenderRobots);
const [recordingLength, setRecordingLength] = useState<number>(globalInfoStore.recordingLength); const [recordingLength, setRecordingLength] = useState<number>(globalInfoStore.recordingLength);
const [recordingId, setRecordingId] = useState<string | null>(globalInfoStore.recordingId); const [recordingId, setRecordingId] = useState<string | null>(globalInfoStore.recordingId);
const [retrainRobotId, setRetrainRobotId] = useState<string | null>(globalInfoStore.retrainRobotId);
const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName); const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName);
const [isLogin, setIsLogin] = useState<boolean>(globalInfoStore.isLogin); const [isLogin, setIsLogin] = useState<boolean>(globalInfoStore.isLogin);
const [initialUrl, setInitialUrl] = useState<string>(globalInfoStore.initialUrl); const [initialUrl, setInitialUrl] = useState<string>(globalInfoStore.initialUrl);
@@ -169,6 +173,8 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
setRecordingLength, setRecordingLength,
recordingId, recordingId,
setRecordingId, setRecordingId,
retrainRobotId,
setRetrainRobotId,
recordingName, recordingName,
setRecordingName, setRecordingName,
initialUrl, initialUrl,

View File

@@ -12,8 +12,6 @@ import { io, Socket } from "socket.io-client";
import { stopRecording } from "../api/recording"; import { stopRecording } from "../api/recording";
import { RunSettings } from "../components/run/RunSettings"; import { RunSettings } from "../components/run/RunSettings";
import { ScheduleSettings } from "../components/robot/ScheduleSettings"; import { ScheduleSettings } from "../components/robot/ScheduleSettings";
import { IntegrationSettings } from "../components/integration/IntegrationSettings";
import { RobotSettings } from "../components/robot/RobotSettings";
import { apiUrl } from "../apiConfig"; import { apiUrl } from "../apiConfig";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -73,7 +71,7 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
interpretStoredRecording(runId).then(async (interpretation: boolean) => { interpretStoredRecording(runId).then(async (interpretation: boolean) => {
if (!aborted) { if (!aborted) {
if (interpretation) { if (interpretation) {
notify('success', t('main_page.notifications.interpretation_success', { name: runningRecordingName })); // notify('success', t('main_page.notifications.interpretation_success', { name: runningRecordingName }));
} else { } else {
notify('success', t('main_page.notifications.interpretation_failed', { name: runningRecordingName })); notify('success', t('main_page.notifications.interpretation_failed', { name: runningRecordingName }));
// destroy the created browser // destroy the created browser
@@ -114,6 +112,14 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
notify('error', t('main_page.notifications.interpretation_failed', { name: robotName })); 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'); setContent('runs');
if (browserId) { if (browserId) {
notify('info', t('main_page.notifications.run_started', { name: runningRecordingName })); notify('info', t('main_page.notifications.run_started', { name: runningRecordingName }));

View File

@@ -6,7 +6,6 @@ import { AuthProvider } from '../context/auth';
import { RecordingPage } from "./RecordingPage"; import { RecordingPage } from "./RecordingPage";
import { MainPage } from "./MainPage"; import { MainPage } from "./MainPage";
import { useGlobalInfoStore } from "../context/globalInfo"; import { useGlobalInfoStore } from "../context/globalInfo";
import { getActiveBrowserId } from "../api/recording";
import { AlertSnackbar } from "../components/ui/AlertSnackbar"; import { AlertSnackbar } from "../components/ui/AlertSnackbar";
import Login from './Login'; import Login from './Login';
import Register from './Register'; import Register from './Register';

View File

@@ -3,7 +3,6 @@ import { Grid } from '@mui/material';
import { BrowserContent } from "../components/browser/BrowserContent"; import { BrowserContent } from "../components/browser/BrowserContent";
import { InterpretationLog } from "../components/run/InterpretationLog"; import { InterpretationLog } from "../components/run/InterpretationLog";
import { startRecording, getActiveBrowserId } from "../api/recording"; import { startRecording, getActiveBrowserId } from "../api/recording";
import { LeftSidePanel } from "../components/recorder/LeftSidePanel";
import { RightSidePanel } from "../components/recorder/RightSidePanel"; import { RightSidePanel } from "../components/recorder/RightSidePanel";
import { Loader } from "../components/ui/Loader"; import { Loader } from "../components/ui/Loader";
import { useSocketStore } from "../context/socket"; import { useSocketStore } from "../context/socket";
@@ -44,7 +43,7 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
const { setId, socket } = useSocketStore(); const { setId, socket } = useSocketStore();
const { setWidth } = useBrowserDimensionsStore(); const { setWidth } = useBrowserDimensionsStore();
const { browserId, setBrowserId, recordingId, recordingUrl, setRecordingUrl } = useGlobalInfoStore(); const { browserId, setBrowserId, recordingId, recordingUrl, setRecordingUrl, setRecordingName, setRetrainRobotId } = useGlobalInfoStore();
const handleShowOutputData = useCallback(() => { const handleShowOutputData = useCallback(() => {
setShowOutputData(true); setShowOutputData(true);
@@ -81,6 +80,19 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
const storedUrl = window.sessionStorage.getItem('recordingUrl'); const storedUrl = window.sessionStorage.getItem('recordingUrl');
if (storedUrl && !recordingUrl) { if (storedUrl && !recordingUrl) {
setRecordingUrl(storedUrl); 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(); const id = await getActiveBrowserId();
@@ -102,7 +114,7 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
return () => { return () => {
isCancelled = true; isCancelled = true;
} }
}, [setId, recordingUrl, setRecordingUrl]); }, [setId, recordingUrl, setRecordingUrl, setRecordingName, setRetrainRobotId]);
const changeBrowserDimensions = useCallback(() => { const changeBrowserDimensions = useCallback(() => {
if (browserContentRef.current) { if (browserContentRef.current) {
@@ -127,7 +139,7 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
} }
setIsLoaded(true); setIsLoaded(true);
} }
}, [socket, browserId, recordingName, recordingId, isLoaded]) }, [socket, browserId, recordingName, recordingId, isLoaded]);
useEffect(() => { useEffect(() => {
socket?.on('loaded', handleLoaded); socket?.on('loaded', handleLoaded);