@@ -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
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -15,11 +15,11 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web
|
|||||||
|
|
||||||
|
|
||||||
<p align="center">
|
<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
|
||||||

|

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

|

|
||||||
|
|
||||||
# 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>
|
||||||
|
|||||||
@@ -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:
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "実行設定",
|
||||||
|
|||||||
@@ -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": "运行设置",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
212
server/src/schedule-worker.ts
Normal file
212
server/src/schedule-worker.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* Worker process focused solely on scheduling logic
|
||||||
|
*/
|
||||||
|
import PgBoss, { Job } from 'pg-boss';
|
||||||
|
import logger from './logger';
|
||||||
|
import Robot from './models/Robot';
|
||||||
|
import { handleRunRecording } from './workflow-management/scheduler';
|
||||||
|
import { computeNextRun } from './utils/schedule';
|
||||||
|
|
||||||
|
if (!process.env.DB_USER || !process.env.DB_PASSWORD || !process.env.DB_HOST || !process.env.DB_PORT || !process.env.DB_NAME) {
|
||||||
|
throw new Error('One or more required environment variables are missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pgBossConnectionString = `postgresql://${process.env.DB_USER}:${encodeURIComponent(process.env.DB_PASSWORD)}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`;
|
||||||
|
|
||||||
|
const pgBoss = new PgBoss({connectionString: pgBossConnectionString });
|
||||||
|
|
||||||
|
const registeredQueues = new Set<string>();
|
||||||
|
|
||||||
|
interface ScheduledWorkflowData {
|
||||||
|
id: string;
|
||||||
|
runId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to schedule a cron job using PgBoss
|
||||||
|
* @param id The robot ID
|
||||||
|
* @param userId The user ID
|
||||||
|
* @param cronExpression The cron expression for scheduling
|
||||||
|
* @param timezone The timezone for the cron expression
|
||||||
|
*/
|
||||||
|
export async function scheduleWorkflow(id: string, userId: string, cronExpression: string, timezone: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const runId = require('uuidv4').uuid();
|
||||||
|
|
||||||
|
const queueName = `scheduled-workflow-${id}`;
|
||||||
|
|
||||||
|
logger.log('info', `Scheduling workflow ${id} with cron expression ${cronExpression} in timezone ${timezone}`);
|
||||||
|
|
||||||
|
await pgBoss.createQueue(queueName);
|
||||||
|
|
||||||
|
await pgBoss.schedule(queueName, cronExpression,
|
||||||
|
{ id, runId, userId },
|
||||||
|
{ tz: timezone }
|
||||||
|
);
|
||||||
|
|
||||||
|
await registerWorkerForQueue(queueName);
|
||||||
|
|
||||||
|
logger.log('info', `Scheduled workflow job for robot ${id}`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.log('error', `Failed to schedule workflow: ${errorMessage}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to cancel a scheduled job
|
||||||
|
* @param robotId The robot ID
|
||||||
|
* @returns true if successful
|
||||||
|
*/
|
||||||
|
export async function cancelScheduledWorkflow(robotId: string) {
|
||||||
|
try {
|
||||||
|
const jobs = await pgBoss.getSchedules();
|
||||||
|
|
||||||
|
const matchingJobs = jobs.filter((job: any) => {
|
||||||
|
try {
|
||||||
|
const data = job.data;
|
||||||
|
return data && data.id === robotId;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const job of matchingJobs) {
|
||||||
|
logger.log('info', `Cancelling scheduled job ${job.name} for robot ${robotId}`);
|
||||||
|
await pgBoss.unschedule(job.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.log('error', `Failed to cancel scheduled workflow: ${errorMessage}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a scheduled workflow job
|
||||||
|
*/
|
||||||
|
async function processScheduledWorkflow(job: Job<ScheduledWorkflowData>) {
|
||||||
|
const { id, runId, userId } = job.data;
|
||||||
|
logger.log('info', `Processing scheduled workflow job for robotId: ${id}, runId: ${runId}, userId: ${userId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute the workflow using the existing handleRunRecording function
|
||||||
|
const result = await handleRunRecording(id, userId);
|
||||||
|
|
||||||
|
// Update the robot's schedule with last run and next run times
|
||||||
|
const robot = await Robot.findOne({ where: { 'recording_meta.id': id } });
|
||||||
|
if (robot && robot.schedule && robot.schedule.cronExpression && robot.schedule.timezone) {
|
||||||
|
// Update lastRunAt to the current time
|
||||||
|
const lastRunAt = new Date();
|
||||||
|
|
||||||
|
// Compute the next run date
|
||||||
|
const nextRunAt = computeNextRun(robot.schedule.cronExpression, robot.schedule.timezone) || undefined;
|
||||||
|
|
||||||
|
await robot.update({
|
||||||
|
schedule: {
|
||||||
|
...robot.schedule,
|
||||||
|
lastRunAt,
|
||||||
|
nextRunAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.log('info', `Updated robot ${id} schedule - next run at: ${nextRunAt}`);
|
||||||
|
} else {
|
||||||
|
logger.log('error', `Robot ${id} schedule, cronExpression, or timezone is missing.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.log('error', `Scheduled workflow job failed: ${errorMessage}`);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a worker to handle scheduled workflow jobs
|
||||||
|
*/
|
||||||
|
async function registerScheduledWorkflowWorker() {
|
||||||
|
try {
|
||||||
|
const jobs = await pgBoss.getSchedules();
|
||||||
|
for (const job of jobs) {
|
||||||
|
await pgBoss.createQueue(job.name);
|
||||||
|
await registerWorkerForQueue(job.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', 'Scheduled workflow workers registered successfully');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.log('error', `Failed to register scheduled workflow workers: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a worker for a specific queue
|
||||||
|
*/
|
||||||
|
async function registerWorkerForQueue(queueName: string) {
|
||||||
|
try {
|
||||||
|
if (registeredQueues.has(queueName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pgBoss.work(queueName, async (job: Job<ScheduledWorkflowData> | Job<ScheduledWorkflowData>[]) => {
|
||||||
|
try {
|
||||||
|
const singleJob = Array.isArray(job) ? job[0] : job;
|
||||||
|
return await processScheduledWorkflow(singleJob);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.log('error', `Scheduled workflow job failed in queue ${queueName}: ${errorMessage}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
registeredQueues.add(queueName);
|
||||||
|
logger.log('info', `Registered worker for queue: ${queueName}`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.log('error', `Failed to register worker for queue ${queueName}: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize PgBoss and register scheduling workers
|
||||||
|
*/
|
||||||
|
async function startScheduleWorker() {
|
||||||
|
try {
|
||||||
|
logger.log('info', 'Starting PgBoss scheduling worker...');
|
||||||
|
await pgBoss.start();
|
||||||
|
logger.log('info', 'PgBoss scheduling worker started successfully');
|
||||||
|
|
||||||
|
// Register the scheduled workflow worker
|
||||||
|
await registerScheduledWorkflowWorker();
|
||||||
|
|
||||||
|
logger.log('info', 'Scheduling worker registered successfully');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.log('error', `Failed to start PgBoss scheduling worker: ${errorMessage}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startScheduleWorker();
|
||||||
|
|
||||||
|
pgBoss.on('error', (error) => {
|
||||||
|
logger.log('error', `PgBoss scheduler error: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
logger.log('info', 'SIGTERM received, shutting down PgBoss scheduler...');
|
||||||
|
await pgBoss.stop();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
logger.log('info', 'SIGINT received, shutting down PgBoss scheduler...');
|
||||||
|
await pgBoss.stop();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -8,9 +8,7 @@ import { record, workflow, storage, auth, integration, proxy } from './routes';
|
|||||||
import { BrowserPool } from "./browser-management/classes/BrowserPool";
|
import { 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;
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
?
|
?
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user