diff --git a/README.md b/README.md index 10908e9a5..05e0424ba 100644 --- a/README.md +++ b/README.md @@ -123,10 +123,6 @@ Maxun lets you create custom robots which emulate user actions and extract data. 2. Capture Text: Useful to extract individual text content from the website. 3. Capture Screenshot: Get fullpage or visible section screenshots of the website. -## 2. BYOP -BYOP (Bring Your Own Proxy) lets you connect external proxies to bypass anti-bot protection. Currently, the proxies are per user. Soon you'll be able to configure proxy per robot. - - # Features - ✨ Extract Data With No-Code - ✨ Handle Pagination & Scrolling @@ -136,9 +132,11 @@ BYOP (Bring Your Own Proxy) lets you connect external proxies to bypass anti-bot - ✨ Adapt To Website Layout Changes - ✨ Extract Behind Login - ✨ Integrations -- ✨ MCP Server -- ✨ Bypass 2FA & MFA For Extract Behind Login (coming soon) -- +++ A lot of amazing things! +- ✨ MCP + +# Use Cases +Maxun can be used for various use-cases, including lead generation, market research, content aggregation and more. +View use-cases in detail here: https://www.maxun.dev/#usecases # Screenshots ![Maxun PH Launch (1)-1-1](https://github.com/user-attachments/assets/d7c75fa2-2bbc-47bb-a5f6-0ee6c162f391) diff --git a/maxun-core/package.json b/maxun-core/package.json index a9eba3ba7..b0aaf4c0a 100644 --- a/maxun-core/package.json +++ b/maxun-core/package.json @@ -1,6 +1,6 @@ { "name": "maxun-core", - "version": "0.0.23", + "version": "0.0.24", "description": "Core package for Maxun, responsible for data extraction", "main": "build/index.js", "typings": "build/index.d.ts", diff --git a/maxun-core/src/interpret.ts b/maxun-core/src/interpret.ts index ae224b9e0..2937c40b6 100644 --- a/maxun-core/src/interpret.ts +++ b/maxun-core/src/interpret.ts @@ -123,6 +123,13 @@ export default class Interpreter extends EventEmitter { this.isAborted = true; } + /** + * Returns the current abort status + */ + public getIsAborted(): boolean { + return this.isAborted; + } + private async applyAdBlocker(page: Page): Promise { if (this.blocker) { try { @@ -610,6 +617,13 @@ export default class Interpreter extends EventEmitter { if (methodName === 'waitForLoadState') { try { + let args = step.args; + + if (Array.isArray(args) && args.length === 1) { + args = [args[0], { timeout: 30000 }]; + } else if (!Array.isArray(args)) { + args = [args, { timeout: 30000 }]; + } await executeAction(invokee, methodName, step.args); } catch (error) { await executeAction(invokee, methodName, 'domcontentloaded'); @@ -670,7 +684,19 @@ export default class Interpreter extends EventEmitter { return; } - const results = await page.evaluate((cfg) => window.scrapeList(cfg), config); + const evaluationPromise = page.evaluate((cfg) => window.scrapeList(cfg), config); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Page evaluation timeout')), 10000) + ); + + let results; + try { + results = await Promise.race([evaluationPromise, timeoutPromise]); + } catch (error) { + debugLog(`Page evaluation failed: ${error.message}`); + return; + } + const newResults = results.filter(item => { const uniqueKey = JSON.stringify(item); if (scrapedItems.has(uniqueKey)) return false; @@ -691,43 +717,94 @@ export default class Interpreter extends EventEmitter { return false; }; + // Helper function to detect if a selector is XPath + const isXPathSelector = (selector: string): boolean => { + return selector.startsWith('//') || + selector.startsWith('/') || + selector.startsWith('./') || + selector.includes('contains(@') || + selector.includes('[count(') || + selector.includes('@class=') || + selector.includes('@id=') || + selector.includes(' and ') || + selector.includes(' or '); + }; + + // Helper function to wait for selector (CSS or XPath) + const waitForSelectorUniversal = async (selector: string, options: any = {}): Promise => { + try { + if (isXPathSelector(selector)) { + // Use XPath locator + const locator = page.locator(`xpath=${selector}`); + await locator.waitFor({ + state: 'attached', + timeout: options.timeout || 10000 + }); + return await locator.elementHandle(); + } else { + // Use CSS selector + return await page.waitForSelector(selector, { + state: 'attached', + timeout: options.timeout || 10000 + }); + } + } catch (error) { + return null; + } + }; + // Enhanced button finder with retry mechanism - const findWorkingButton = async (selectors: string[]): Promise<{ - button: ElementHandle | null, + const findWorkingButton = async (selectors: string[]): Promise<{ + button: ElementHandle | null, workingSelector: string | null, updatedSelectors: string[] }> => { - let updatedSelectors = [...selectors]; - + const startTime = Date.now(); + const MAX_BUTTON_SEARCH_TIME = 15000; + let updatedSelectors = [...selectors]; + for (let i = 0; i < selectors.length; i++) { + if (Date.now() - startTime > MAX_BUTTON_SEARCH_TIME) { + debugLog(`Button search timeout reached (${MAX_BUTTON_SEARCH_TIME}ms), aborting`); + break; + } const selector = selectors[i]; let retryCount = 0; let selectorSuccess = false; while (retryCount < MAX_RETRIES && !selectorSuccess) { try { - const button = await page.waitForSelector(selector, { - state: 'attached', - timeout: 10000 - }); - + const button = await waitForSelectorUniversal(selector, { timeout: 2000 }); + if (button) { debugLog('Found working selector:', selector); - return { - button, + return { + button, workingSelector: selector, - updatedSelectors + updatedSelectors }; + } else { + retryCount++; + debugLog(`Selector "${selector}" not found: attempt ${retryCount}/${MAX_RETRIES}`); + + if (retryCount < MAX_RETRIES) { + await page.waitForTimeout(RETRY_DELAY); + } else { + debugLog(`Removing failed selector "${selector}" after ${MAX_RETRIES} attempts`); + updatedSelectors = updatedSelectors.filter(s => s !== selector); + selectorSuccess = true; + } } } catch (error) { retryCount++; - debugLog(`Selector "${selector}" failed: attempt ${retryCount}/${MAX_RETRIES}`); - + debugLog(`Selector "${selector}" error: attempt ${retryCount}/${MAX_RETRIES} - ${error.message}`); + if (retryCount < MAX_RETRIES) { await page.waitForTimeout(RETRY_DELAY); } else { debugLog(`Removing failed selector "${selector}" after ${MAX_RETRIES} attempts`); updatedSelectors = updatedSelectors.filter(s => s !== selector); + selectorSuccess = true; } } } @@ -1347,9 +1424,35 @@ export default class Interpreter extends EventEmitter { } private async ensureScriptsLoaded(page: Page) { - const isScriptLoaded = await page.evaluate(() => typeof window.scrape === 'function' && typeof window.scrapeSchema === 'function' && typeof window.scrapeList === 'function' && typeof window.scrapeListAuto === 'function' && typeof window.scrollDown === 'function' && typeof window.scrollUp === 'function'); - if (!isScriptLoaded) { - await page.addInitScript({ path: path.join(__dirname, 'browserSide', 'scraper.js') }); + try { + const evaluationPromise = page.evaluate(() => + typeof window.scrape === 'function' && + typeof window.scrapeSchema === 'function' && + typeof window.scrapeList === 'function' && + typeof window.scrapeListAuto === 'function' && + typeof window.scrollDown === 'function' && + typeof window.scrollUp === 'function' + ); + + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Script check timeout')), 3000) + ); + + const isScriptLoaded = await Promise.race([ + evaluationPromise, + timeoutPromise + ]); + + if (!isScriptLoaded) { + await page.addInitScript({ path: path.join(__dirname, 'browserSide', 'scraper.js') }); + } + } catch (error) { + this.log(`Script check failed, adding script anyway: ${error.message}`, Level.WARN); + try { + await page.addInitScript({ path: path.join(__dirname, 'browserSide', 'scraper.js') }); + } catch (scriptError) { + this.log(`Failed to add script: ${scriptError.message}`, Level.ERROR); + } } } diff --git a/package.json b/package.json index 439370c42..536836c0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maxun", - "version": "0.0.23", + "version": "0.0.24", "author": "Maxun", "license": "AGPL-3.0-or-later", "dependencies": { @@ -11,6 +11,7 @@ "@mui/lab": "^5.0.0-alpha.80", "@mui/material": "^5.6.2", "@react-oauth/google": "^0.12.1", + "@tanstack/react-query": "^5.90.2", "@testing-library/react": "^13.1.1", "@testing-library/user-event": "^13.5.0", "@types/bcrypt": "^5.0.2", @@ -50,7 +51,7 @@ "lodash": "^4.17.21", "loglevel": "^1.8.0", "loglevel-plugin-remote": "^0.6.8", - "maxun-core": "^0.0.23", + "maxun-core": "^0.0.24", "minio": "^8.0.1", "moment-timezone": "^0.5.45", "node-cron": "^3.0.3", @@ -129,6 +130,7 @@ "ajv": "^8.8.2", "concurrently": "^7.0.0", "cross-env": "^7.0.3", + "esbuild": "^0.25.10", "js-cookie": "^3.0.5", "nodemon": "^2.0.15", "sequelize-cli": "^6.6.2", diff --git a/server/src/api/record.ts b/server/src/api/record.ts index cdaf89f93..5d9a68cd6 100644 --- a/server/src/api/record.ts +++ b/server/src/api/record.ts @@ -710,8 +710,8 @@ async function executeRun(id: string, userId: string) { retries: 5, }; - processAirtableUpdates(); - processGoogleSheetUpdates(); + processAirtableUpdates().catch(err => logger.log('error', `Airtable update error: ${err.message}`)); + processGoogleSheetUpdates().catch(err => logger.log('error', `Google Sheets update error: ${err.message}`)); } catch (err: any) { logger.log('error', `Failed to update Google Sheet for run: ${plainRun.runId}: ${err.message}`); } diff --git a/server/src/browser-management/classes/RemoteBrowser.ts b/server/src/browser-management/classes/RemoteBrowser.ts index 7e5839c85..a3796cbeb 100644 --- a/server/src/browser-management/classes/RemoteBrowser.ts +++ b/server/src/browser-management/classes/RemoteBrowser.ts @@ -380,6 +380,7 @@ export class RemoteBrowser { ); await this.currentPage.mouse.wheel(data.deltaX, data.deltaY); + await this.currentPage.waitForLoadState("networkidle", { timeout: 5000 }); const scrollInfo = await this.currentPage.evaluate(() => ({ x: window.scrollX, @@ -1590,7 +1591,7 @@ export class RemoteBrowser { } return window.rrwebSnapshot.snapshot(document, { - inlineImages: true, + inlineImages: false, collectFonts: true, }); }); diff --git a/server/src/browser-management/controller.ts b/server/src/browser-management/controller.ts index 1c6ecb5cc..a6db615e4 100644 --- a/server/src/browser-management/controller.ts +++ b/server/src/browser-management/controller.ts @@ -77,7 +77,11 @@ export const createRemoteBrowserForRun = (userId: string): string => { logger.log('info', `createRemoteBrowserForRun: Reserved slot ${id} for user ${userId}`); - initializeBrowserAsync(id, userId); + initializeBrowserAsync(id, userId) + .catch((error: any) => { + logger.log('error', `Unhandled error in initializeBrowserAsync for browser ${id}: ${error.message}`); + browserPool.failBrowserSlot(id); + }); return id; }; @@ -110,7 +114,16 @@ export const destroyRemoteBrowser = async (id: string, userId: string): Promise< } catch (switchOffError) { logger.log('warn', `Error switching off browser ${id}: ${switchOffError}`); } - + + try { + const namespace = io.of(id); + namespace.removeAllListeners(); + namespace.disconnectSockets(true); + logger.log('debug', `Cleaned up socket namespace for browser ${id}`); + } catch (namespaceCleanupError: any) { + logger.log('warn', `Error cleaning up socket namespace for browser ${id}: ${namespaceCleanupError.message}`); + } + return browserPool.deleteRemoteBrowser(id); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -273,11 +286,27 @@ const initializeBrowserAsync = async (id: string, userId: string) => { } logger.log('debug', `Starting browser initialization for ${id}`); - await browserSession.initialize(userId); - logger.log('debug', `Browser initialization completed for ${id}`); - + + try { + await browserSession.initialize(userId); + logger.log('debug', `Browser initialization completed for ${id}`); + } catch (initError: any) { + try { + await browserSession.switchOff(); + logger.log('info', `Cleaned up failed browser initialization for ${id}`); + } catch (cleanupError: any) { + logger.log('error', `Failed to cleanup browser ${id}: ${cleanupError.message}`); + } + throw initError; + } + const upgraded = browserPool.upgradeBrowserSlot(id, browserSession); if (!upgraded) { + try { + await browserSession.switchOff(); + } catch (cleanupError: any) { + logger.log('error', `Failed to cleanup browser after slot upgrade failure: ${cleanupError.message}`); + } throw new Error('Failed to upgrade reserved browser slot'); } diff --git a/server/src/pgboss-worker.ts b/server/src/pgboss-worker.ts index ccc931316..c1511b2ff 100644 --- a/server/src/pgboss-worker.ts +++ b/server/src/pgboss-worker.ts @@ -102,8 +102,8 @@ async function triggerIntegrationUpdates(runId: string, robotMetaId: string): Pr retries: 5, }; - processAirtableUpdates(); - processGoogleSheetUpdates(); + processAirtableUpdates().catch(err => logger.log('error', `Airtable update error: ${err.message}`)); + processGoogleSheetUpdates().catch(err => logger.log('error', `Google Sheets update error: ${err.message}`)); } catch (err: any) { logger.log('error', `Failed to update integrations for run: ${runId}: ${err.message}`); } @@ -333,6 +333,12 @@ async function processRunExecution(job: Job) { // Schedule updates for Google Sheets and Airtable await triggerIntegrationUpdates(plainRun.runId, plainRun.robotMetaId); + // Flush any remaining persistence buffer before emitting socket event + if (browser && browser.interpreter) { + await browser.interpreter.flushPersistenceBuffer(); + logger.log('debug', `Flushed persistence buffer before emitting run-completed for run ${data.runId}`); + } + const completionData = { runId: data.runId, robotMetaId: plainRun.robotMetaId, diff --git a/server/src/server.ts b/server/src/server.ts index c49b367b1..5aa8efeda 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -37,6 +37,12 @@ const pool = new Pool({ database: process.env.DB_NAME, password: process.env.DB_PASSWORD, port: process.env.DB_PORT ? parseInt(process.env.DB_PORT, 10) : undefined, + max: 50, + min: 5, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 10000, + maxUses: 7500, + allowExitOnIdle: true }); const PgSession = connectPgSimple(session); @@ -215,6 +221,22 @@ if (require.main === module) { }); } +process.on('unhandledRejection', (reason, promise) => { + logger.log('error', `Unhandled promise rejection at: ${promise}, reason: ${reason}`); + console.error('Unhandled promise rejection:', reason); +}); + +process.on('uncaughtException', (error) => { + logger.log('error', `Uncaught exception: ${error.message}`, { stack: error.stack }); + console.error('Uncaught exception:', error); + + if (process.env.NODE_ENV === 'production') { + setTimeout(() => { + process.exit(1); + }, 5000); + } +}); + if (require.main === module) { process.on('SIGINT', async () => { console.log('Main app shutting down...'); diff --git a/server/src/workflow-management/classes/Interpreter.ts b/server/src/workflow-management/classes/Interpreter.ts index 0ed19f19d..5e843a80e 100644 --- a/server/src/workflow-management/classes/Interpreter.ts +++ b/server/src/workflow-management/classes/Interpreter.ts @@ -118,6 +118,23 @@ export class WorkflowInterpreter { */ private currentRunId: string | null = null; + /** + * Batched persistence system for performance optimization + */ + private persistenceBuffer: Array<{ + actionType: string; + data: any; + listIndex?: number; + timestamp: number; + creditValidated: boolean; + }> = []; + + private persistenceTimer: NodeJS.Timeout | null = null; + private readonly BATCH_SIZE = 5; + private readonly BATCH_TIMEOUT = 3000; + private persistenceInProgress = false; + private persistenceRetryCount = 0; + /** * An array of id's of the pairs from the workflow that are about to be paused. * As "breakpoints". @@ -283,6 +300,10 @@ export class WorkflowInterpreter { this.socket.emit('log', `----- The interpretation finished with status: ${status} -----`, false); logger.log('debug', `Interpretation finished`); + + // Flush any remaining data in persistence buffer before completing + await this.flushPersistenceBuffer(); + this.interpreter = null; this.socket.emit('activePairId', -1); this.interpretationIsPaused = false; @@ -303,13 +324,39 @@ export class WorkflowInterpreter { await this.interpreter.stop(); this.socket.emit('log', '----- The interpretation has been stopped -----', false); - this.clearState(); + await this.clearState(); } else { logger.log('error', 'Cannot stop: No active interpretation.'); } }; - private clearState = () => { + public clearState = async (): Promise => { + if (this.persistenceBuffer.length > 0) { + try { + await this.flushPersistenceBuffer(); + logger.log('debug', 'Successfully flushed final persistence buffer during cleanup'); + } catch (error: any) { + logger.log('error', `Failed to flush final persistence buffer: ${error.message}`); + } + } + + if (this.persistenceTimer) { + clearTimeout(this.persistenceTimer); + this.persistenceTimer = null; + } + + if (this.interpreter) { + try { + if (!this.interpreter.getIsAborted()) { + this.interpreter.abort(); + } + await this.interpreter.stop(); + logger.log('debug', 'mx-cloud interpreter properly stopped during cleanup'); + } catch (error: any) { + logger.log('warn', `Error stopping mx-cloud interpreter during cleanup: ${error.message}`); + } + } + this.debugMessages = []; this.interpretationIsPaused = false; this.activeId = null; @@ -324,6 +371,9 @@ export class WorkflowInterpreter { this.binaryData = []; this.currentScrapeListIndex = 0; this.currentRunId = null; + this.persistenceBuffer = []; + this.persistenceInProgress = false; + this.persistenceRetryCount = 0; } /** @@ -336,61 +386,22 @@ export class WorkflowInterpreter { }; /** - * Persists data to database in real-time during interpretation + * Persists extracted data to database with intelligent batching for performance + * Falls back to immediate persistence for critical operations * @private */ private persistDataToDatabase = async (actionType: string, data: any, listIndex?: number): Promise => { if (!this.currentRunId) { - logger.log('debug', 'No run ID available for real-time persistence'); + logger.log('debug', 'No run ID available for persistence'); return; } - try { - const run = await Run.findOne({ where: { runId: this.currentRunId } }); - - if (!run) { - logger.log('warn', `Run not found for real-time persistence: ${this.currentRunId}`); - return; - } - - const currentSerializableOutput = run.serializableOutput ? - JSON.parse(JSON.stringify(run.serializableOutput)) : - { scrapeSchema: [], scrapeList: [] }; - - if (actionType === 'scrapeSchema') { - const newSchemaData = Array.isArray(data) ? data : [data]; - const updatedOutput = { - ...currentSerializableOutput, - scrapeSchema: newSchemaData - }; - - await run.update({ - serializableOutput: updatedOutput - }); - - logger.log('debug', `Persisted scrapeSchema data for run ${this.currentRunId}: ${newSchemaData.length} items`); - - } else if (actionType === 'scrapeList' && typeof listIndex === 'number') { - if (!Array.isArray(currentSerializableOutput.scrapeList)) { - currentSerializableOutput.scrapeList = []; - } - - const updatedList = [...currentSerializableOutput.scrapeList]; - updatedList[listIndex] = data; - - const updatedOutput = { - ...currentSerializableOutput, - scrapeList: updatedList - }; + this.addToPersistenceBatch(actionType, data, listIndex, true); - await run.update({ - serializableOutput: updatedOutput - }); - - logger.log('debug', `Persisted scrapeList data for run ${this.currentRunId} at index ${listIndex}: ${Array.isArray(data) ? data.length : 'N/A'} items`); - } - } catch (error: any) { - logger.log('error', `Failed to persist data in real-time for run ${this.currentRunId}: ${error.message}`); + if (actionType === 'scrapeSchema' || this.persistenceBuffer.length >= this.BATCH_SIZE) { + await this.flushPersistenceBuffer(); + } else { + this.scheduleBatchFlush(); } }; @@ -548,7 +559,6 @@ export class WorkflowInterpreter { } logger.log('debug', `Interpretation finished`); - this.clearState(); return result; } @@ -569,4 +579,124 @@ export class WorkflowInterpreter { this.socket = socket; this.subscribeToPausing(); }; + + /** + * Adds data to persistence buffer for batched processing + * @private + */ + private addToPersistenceBatch(actionType: string, data: any, listIndex?: number, creditValidated: boolean = false): void { + this.persistenceBuffer.push({ + actionType, + data, + listIndex, + timestamp: Date.now(), + creditValidated + }); + + logger.log('debug', `Added ${actionType} to persistence buffer (${this.persistenceBuffer.length} items)`); + } + + /** + * Schedules a batched flush if not already scheduled + * @private + */ + private scheduleBatchFlush(): void { + if (!this.persistenceTimer && !this.persistenceInProgress) { + this.persistenceTimer = setTimeout(async () => { + await this.flushPersistenceBuffer(); + }, this.BATCH_TIMEOUT); + } + } + + /** + * Flushes persistence buffer to database in a single transaction + * @public - Made public to allow external flush before socket emission + */ + public async flushPersistenceBuffer(): Promise { + if (this.persistenceBuffer.length === 0 || this.persistenceInProgress || !this.currentRunId) { + return; + } + + if (this.persistenceTimer) { + clearTimeout(this.persistenceTimer); + this.persistenceTimer = null; + } + + this.persistenceInProgress = true; + const batchToProcess = [...this.persistenceBuffer]; + this.persistenceBuffer = []; + + try { + const sequelize = require('../../storage/db').default; + await sequelize.transaction(async (transaction: any) => { + const run = await Run.findOne({ + where: { runId: this.currentRunId! }, + transaction + }); + + if (!run) { + logger.log('warn', `Run not found for batched persistence: ${this.currentRunId}`); + return; + } + + const currentSerializableOutput = run.serializableOutput ? + JSON.parse(JSON.stringify(run.serializableOutput)) : + { scrapeSchema: [], scrapeList: [] }; + + let hasUpdates = false; + + for (const item of batchToProcess) { + if (item.actionType === 'scrapeSchema') { + const newSchemaData = Array.isArray(item.data) ? item.data : [item.data]; + currentSerializableOutput.scrapeSchema = newSchemaData; + hasUpdates = true; + } else if (item.actionType === 'scrapeList' && typeof item.listIndex === 'number') { + if (!Array.isArray(currentSerializableOutput.scrapeList)) { + currentSerializableOutput.scrapeList = []; + } + currentSerializableOutput.scrapeList[item.listIndex] = item.data; + hasUpdates = true; + } + } + + if (hasUpdates) { + await run.update({ + serializableOutput: currentSerializableOutput + }, { transaction }); + + logger.log('debug', `Batched persistence: Updated run ${this.currentRunId} with ${batchToProcess.length} items`); + } + }); + + this.persistenceRetryCount = 0; + + } catch (error: any) { + logger.log('error', `Failed to flush persistence buffer for run ${this.currentRunId}: ${error.message}`); + + if (!this.persistenceRetryCount) { + this.persistenceRetryCount = 0; + } + + if (this.persistenceRetryCount < 3) { + this.persistenceBuffer.unshift(...batchToProcess); + this.persistenceRetryCount++; + + const backoffDelay = Math.min(5000 * Math.pow(2, this.persistenceRetryCount), 30000); + setTimeout(async () => { + await this.flushPersistenceBuffer(); + }, backoffDelay); + + logger.log('warn', `Scheduling persistence retry ${this.persistenceRetryCount}/3 in ${backoffDelay}ms`); + } else { + logger.log('error', `Max persistence retries exceeded for run ${this.currentRunId}, dropping ${batchToProcess.length} items`); + this.persistenceRetryCount = 0; + } + } finally { + this.persistenceInProgress = false; + + if (this.persistenceBuffer.length > 0 && !this.persistenceTimer) { + this.scheduleBatchFlush(); + } + } + }; } diff --git a/server/src/workflow-management/integrations/airtable.ts b/server/src/workflow-management/integrations/airtable.ts index 5f72c8363..e1f27264d 100644 --- a/server/src/workflow-management/integrations/airtable.ts +++ b/server/src/workflow-management/integrations/airtable.ts @@ -454,33 +454,36 @@ function isValidUrl(str: string): boolean { } export const processAirtableUpdates = async () => { - while (true) { + const maxProcessingTime = 60000; + const startTime = Date.now(); + + while (Date.now() - startTime < maxProcessingTime) { let hasPendingTasks = false; - + for (const runId in airtableUpdateTasks) { const task = airtableUpdateTasks[runId]; - + if (task.status === 'pending') { hasPendingTasks = true; console.log(`Processing Airtable update for run: ${runId}`); - + try { await updateAirtable(task.robotId, task.runId); console.log(`Successfully updated Airtable for runId: ${runId}`); - airtableUpdateTasks[runId].status = 'completed'; - delete airtableUpdateTasks[runId]; + delete airtableUpdateTasks[runId]; } catch (error: any) { console.error(`Failed to update Airtable for run ${task.runId}:`, error); - + if (task.retries < MAX_RETRIES) { airtableUpdateTasks[runId].retries += 1; console.log(`Retrying task for runId: ${runId}, attempt: ${task.retries + 1}`); } else { - airtableUpdateTasks[runId].status = 'failed'; - console.log(`Max retries reached for runId: ${runId}. Marking task as failed.`); - logger.log('error', `Permanent failure for run ${runId}: ${error.message}`); + console.log(`Max retries reached for runId: ${runId}. Removing task.`); + delete airtableUpdateTasks[runId]; } } + } else if (task.status === 'completed' || task.status === 'failed') { + delete airtableUpdateTasks[runId]; } } @@ -488,8 +491,10 @@ export const processAirtableUpdates = async () => { console.log('No pending Airtable update tasks, exiting processor'); break; } - + console.log('Waiting for 5 seconds before checking again...'); await new Promise(resolve => setTimeout(resolve, 5000)); } + + console.log('Airtable processing completed or timed out'); }; \ No newline at end of file diff --git a/server/src/workflow-management/integrations/gsheet.ts b/server/src/workflow-management/integrations/gsheet.ts index 2a29bdcc2..fcf9b95c8 100644 --- a/server/src/workflow-management/integrations/gsheet.ts +++ b/server/src/workflow-management/integrations/gsheet.ts @@ -286,8 +286,12 @@ export async function writeDataToSheet( } export const processGoogleSheetUpdates = async () => { - while (true) { + const maxProcessingTime = 60000; + const startTime = Date.now(); + + while (Date.now() - startTime < maxProcessingTime) { let hasPendingTasks = false; + for (const runId in googleSheetUpdateTasks) { const task = googleSheetUpdateTasks[runId]; console.log(`Processing task for runId: ${runId}, status: ${task.status}`); @@ -297,7 +301,6 @@ export const processGoogleSheetUpdates = async () => { try { await updateGoogleSheet(task.robotId, task.runId); console.log(`Successfully updated Google Sheet for runId: ${runId}`); - googleSheetUpdateTasks[runId].status = 'completed'; delete googleSheetUpdateTasks[runId]; } catch (error: any) { console.error(`Failed to update Google Sheets for run ${task.runId}:`, error); @@ -305,10 +308,12 @@ export const processGoogleSheetUpdates = async () => { googleSheetUpdateTasks[runId].retries += 1; console.log(`Retrying task for runId: ${runId}, attempt: ${task.retries}`); } else { - googleSheetUpdateTasks[runId].status = 'failed'; - console.log(`Max retries reached for runId: ${runId}. Marking task as failed.`); + console.log(`Max retries reached for runId: ${runId}. Removing task.`); + delete googleSheetUpdateTasks[runId]; } } + } else if (task.status === 'completed' || task.status === 'failed') { + delete googleSheetUpdateTasks[runId]; } } @@ -320,4 +325,6 @@ export const processGoogleSheetUpdates = async () => { console.log('Waiting for 5 seconds before checking again...'); await new Promise(resolve => setTimeout(resolve, 5000)); } + + console.log('Google Sheets processing completed or timed out'); }; \ No newline at end of file diff --git a/server/src/workflow-management/scheduler/index.ts b/server/src/workflow-management/scheduler/index.ts index 195e1888d..ce272689a 100644 --- a/server/src/workflow-management/scheduler/index.ts +++ b/server/src/workflow-management/scheduler/index.ts @@ -277,8 +277,8 @@ async function executeRun(id: string, userId: string) { retries: 5, }; - processAirtableUpdates(); - processGoogleSheetUpdates(); + processAirtableUpdates().catch(err => logger.log('error', `Airtable update error: ${err.message}`)); + processGoogleSheetUpdates().catch(err => logger.log('error', `Google Sheets update error: ${err.message}`)); } catch (err: any) { logger.log('error', `Failed to update Google Sheet for run: ${plainRun.runId}: ${err.message}`); } diff --git a/src/components/browser/BrowserRecordingSave.tsx b/src/components/browser/BrowserRecordingSave.tsx index 2adfcd79d..32d0fabad 100644 --- a/src/components/browser/BrowserRecordingSave.tsx +++ b/src/components/browser/BrowserRecordingSave.tsx @@ -41,8 +41,6 @@ const BrowserRecordingSave = () => { const goToMainMenu = async () => { if (browserId) { - await stopRecording(browserId); - const notificationData = { type: 'warning', message: t('browser_recording.notifications.terminated'), @@ -65,6 +63,10 @@ const BrowserRecordingSave = () => { setBrowserId(null); window.close(); + + stopRecording(browserId).catch((error) => { + console.warn('Background cleanup failed:', error); + }); } }; @@ -242,4 +244,4 @@ const modalStyle = { height: 'fit-content', display: 'block', padding: '20px', -}; \ No newline at end of file +}; diff --git a/src/components/dashboard/MainMenu.tsx b/src/components/dashboard/MainMenu.tsx index 9df2a6d29..05846b758 100644 --- a/src/components/dashboard/MainMenu.tsx +++ b/src/components/dashboard/MainMenu.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; import Box from '@mui/material/Box'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { Paper, Button, useTheme, Modal, Typography, Stack, TextField, InputAdornment, IconButton } from "@mui/material"; import { AutoAwesome, FormatListBulleted, VpnKey, Usb, CloudQueue, Description, Favorite, ContentCopy, SlowMotionVideo } from "@mui/icons-material"; import { useTranslation } from 'react-i18next'; @@ -17,10 +17,11 @@ export const MainMenu = ({ value = 'robots', handleChangeContent }: MainMenuProp const theme = useTheme(); const { t } = useTranslation(); const navigate = useNavigate(); + const location = useLocation(); const { notify } = useGlobalInfoStore(); - const [cloudModalOpen, setCloudModalOpen] = useState(false); const [sponsorModalOpen, setSponsorModalOpen] = useState(false); + const [docModalOpen, setDocModalOpen] = useState(false); const ossDiscountCode = "MAXUNOSS8"; @@ -29,6 +30,13 @@ export const MainMenu = ({ value = 'robots', handleChangeContent }: MainMenuProp handleChangeContent(newValue); }; + const handleRobotsClick = () => { + if (location.pathname !== '/robots') { + navigate('/robots'); + handleChangeContent('robots'); + } + }; + const copyDiscountCode = () => { navigator.clipboard.writeText(ossDiscountCode).then(() => { notify("success", "Discount code copied to clipboard!"); @@ -81,20 +89,51 @@ export const MainMenu = ({ value = 'robots', handleChangeContent }: MainMenuProp orientation="vertical" sx={{ alignItems: 'flex-start' }} > - } iconPosition="start" sx={{ justifyContent: 'flex-start', textAlign: 'left', fontSize: 'medium' }} /> + } iconPosition="start" sx={{ justifyContent: 'flex-start', textAlign: 'left', fontSize: 'medium' }} onClick={handleRobotsClick} /> } iconPosition="start" sx={{ justifyContent: 'flex-start', textAlign: 'left', fontSize: 'medium' }} /> } iconPosition="start" sx={{ justifyContent: 'flex-start', textAlign: 'left', fontSize: 'medium' }} /> } iconPosition="start" sx={{ justifyContent: 'flex-start', textAlign: 'left', fontSize: 'medium' }} />
- - - + + + + + - - - setSponsorModalOpen(false)}> diff --git a/src/components/recorder/DOMBrowserRenderer.tsx b/src/components/recorder/DOMBrowserRenderer.tsx index 7e9098542..7fcafdeb4 100644 --- a/src/components/recorder/DOMBrowserRenderer.tsx +++ b/src/components/recorder/DOMBrowserRenderer.tsx @@ -167,7 +167,6 @@ export const DOMBrowserRenderer: React.FC = ({ const containerRef = useRef(null); const iframeRef = useRef(null); const [isRendered, setIsRendered] = useState(false); - const [renderError, setRenderError] = useState(null); const [lastMousePosition, setLastMousePosition] = useState({ x: 0, y: 0 }); const [currentHighlight, setCurrentHighlight] = useState<{ element: Element; @@ -342,7 +341,10 @@ export const DOMBrowserRenderer: React.FC = ({ const existingHandlers = (iframeDoc as any)._domRendererHandlers; if (existingHandlers) { Object.entries(existingHandlers).forEach(([event, handler]) => { - iframeDoc.removeEventListener(event, handler as EventListener, false); // Changed to false + const options: boolean | AddEventListenerOptions = ['wheel', 'touchstart', 'touchmove'].includes(event) + ? { passive: false } + : false; + iframeDoc.removeEventListener(event, handler as EventListener, options); }); } @@ -700,7 +702,11 @@ export const DOMBrowserRenderer: React.FC = ({ return; } - e.preventDefault(); + if (isInCaptureMode) { + e.preventDefault(); + e.stopPropagation(); + return; + } if (!isInCaptureMode) { const wheelEvent = e as WheelEvent; @@ -752,7 +758,10 @@ export const DOMBrowserRenderer: React.FC = ({ handlers.beforeunload = preventDefaults; Object.entries(handlers).forEach(([event, handler]) => { - iframeDoc.addEventListener(event, handler, false); + const options: boolean | AddEventListenerOptions = ['wheel', 'touchstart', 'touchmove'].includes(event) + ? { passive: false } + : false; + iframeDoc.addEventListener(event, handler, options); }); // Store handlers for cleanup @@ -795,11 +804,39 @@ export const DOMBrowserRenderer: React.FC = ({ } try { - setRenderError(null); setIsRendered(false); const iframe = iframeRef.current!; - const iframeDoc = iframe.contentDocument!; + let iframeDoc: Document; + + try { + iframeDoc = iframe.contentDocument!; + if (!iframeDoc) { + throw new Error("Cannot access iframe document"); + } + } catch (crossOriginError) { + console.warn("Cross-origin iframe access blocked, recreating iframe"); + + const newIframe = document.createElement('iframe'); + newIframe.style.cssText = iframe.style.cssText; + newIframe.sandbox = iframe.sandbox.value; + newIframe.title = iframe.title; + newIframe.tabIndex = iframe.tabIndex; + newIframe.id = iframe.id; + + iframe.parentNode?.replaceChild(newIframe, iframe); + Object.defineProperty(iframeRef, 'current', { + value: newIframe, + writable: false, + enumerable: true, + configurable: true + }); + + iframeDoc = newIframe.contentDocument!; + if (!iframeDoc) { + throw new Error("Cannot access new iframe document"); + } + } const styleTags = Array.from( document.querySelectorAll('link[rel="stylesheet"], style') @@ -897,8 +934,6 @@ export const DOMBrowserRenderer: React.FC = ({ setupIframeInteractions(iframeDoc); } catch (error) { console.error("Error rendering rrweb snapshot:", error); - setRenderError(error instanceof Error ? error.message : String(error)); - showErrorInIframe(error); } }, [setupIframeInteractions, isInCaptureMode, isCachingChildSelectors] @@ -919,89 +954,6 @@ export const DOMBrowserRenderer: React.FC = ({ } }, [getText, getList, listSelector, isRendered, setupIframeInteractions]); - /** - * Show error message in iframe - */ - const showErrorInIframe = (error: any) => { - if (!iframeRef.current) return; - - const iframe = iframeRef.current; - const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; - - if (iframeDoc) { - try { - iframeDoc.open(); - iframeDoc.write(` - - - - - -
-

Error Loading DOM Content

-

Failed to render the page in DOM mode.

-

Common causes:

-
    -
  • Page is still loading or navigating
  • -
  • Resource proxy timeouts or failures
  • -
  • Network connectivity issues
  • -
  • Invalid HTML structure
  • -
-

Solutions:

-
    -
  • Try switching back to Screenshot mode
  • -
  • Wait for the page to fully load and try again
  • -
  • Check your network connection
  • -
  • Refresh the browser page
  • -
- -
- Technical details -
${error.toString()}
-
-
- - - `); - iframeDoc.close(); - - window.addEventListener("message", (event) => { - if (event.data === "retry-dom-mode") { - if (socket) { - socket.emit("enable-dom-streaming"); - } - } - }); - } catch (e) { - console.error("Failed to write error message to iframe:", e); - } - } - }; - useEffect(() => { return () => { if (iframeRef.current) { @@ -1010,10 +962,13 @@ export const DOMBrowserRenderer: React.FC = ({ const handlers = (iframeDoc as any)._domRendererHandlers; if (handlers) { Object.entries(handlers).forEach(([event, handler]) => { + const options: boolean | AddEventListenerOptions = ['wheel', 'touchstart', 'touchmove'].includes(event) + ? { passive: false } + : false; iframeDoc.removeEventListener( event, handler as EventListener, - true + options ); }); } @@ -1051,7 +1006,7 @@ export const DOMBrowserRenderer: React.FC = ({ /> {/* Loading indicator */} - {!isRendered && !renderError && ( + {!isRendered && (
= ({
)} - {/* Error indicator */} - {renderError && ( -
- RENDER ERROR -
- )} - {/* Capture mode overlay */} {isInCaptureMode && (
= ({ onFinishCapture {showLimitOptions && ( - - - {t('right_panel.limit.title')} - - + + {t('right_panel.limit.title')} + updateLimitType(e.target.value as LimitType)} @@ -1191,4 +1189,4 @@ export const RightSidePanel: React.FC = ({ onFinishCapture ); -}; \ No newline at end of file +}; diff --git a/src/components/robot/Recordings.tsx b/src/components/robot/Recordings.tsx index 6f463ba46..44dc4df6a 100644 --- a/src/components/robot/Recordings.tsx +++ b/src/components/robot/Recordings.tsx @@ -79,7 +79,7 @@ export const Recordings = ({ } else if (authStatus === "success" && robotId) { console.log("Google Auth Status:", authStatus); notify(authStatus, t("recordingtable.notifications.auth_success")); - handleNavigate(`/robots/${robotId}/integrate/google`, robotId, "", []); + handleNavigate(`/robots/${robotId}/integrate/googleSheets`, robotId, "", []); } }, []); diff --git a/src/components/robot/RecordingsTable.tsx b/src/components/robot/RecordingsTable.tsx index 42c5971b7..a1f0daaa6 100644 --- a/src/components/robot/RecordingsTable.tsx +++ b/src/components/robot/RecordingsTable.tsx @@ -36,8 +36,8 @@ import { MoreHoriz, Refresh } from "@mui/icons-material"; -import { useGlobalInfoStore } from "../../context/globalInfo"; -import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage"; +import { useGlobalInfoStore, useCachedRecordings } from "../../context/globalInfo"; +import { checkRunsForRecording, deleteRecordingFromStorage } from "../../api/storage"; import { Add } from "@mui/icons-material"; import { useNavigate } from 'react-router-dom'; import { canCreateBrowserInState, getActiveBrowserId, stopRecording } from "../../api/recording"; @@ -150,12 +150,11 @@ export const RecordingsTable = ({ const { t } = useTranslation(); const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); - const [rows, setRows] = React.useState([]); + const { data: recordingsData = [], isLoading: isFetching, error, refetch } = useCachedRecordings(); const [isModalOpen, setModalOpen] = React.useState(false); const [searchTerm, setSearchTerm] = React.useState(''); const [isWarningModalOpen, setWarningModalOpen] = React.useState(false); const [activeBrowserId, setActiveBrowserId] = React.useState(''); - const [isFetching, setIsFetching] = React.useState(true); const columns = useMemo(() => [ { id: 'interpret', label: t('recordingtable.run'), minWidth: 80 }, @@ -238,60 +237,46 @@ export const RecordingsTable = ({ if (dateStr.includes('PM') || dateStr.includes('AM')) { return new Date(dateStr); } - + return new Date(dateStr.replace(/(\d+)\/(\d+)\//, '$2/$1/')) } catch { return new Date(0); } }; - const fetchRecordings = useCallback(async () => { - try { - const recordings = await getStoredRecordings(); - if (recordings) { - const parsedRows = recordings - .map((recording: any, index: number) => { - if (recording?.recording_meta) { - const parsedDate = parseDateString(recording.recording_meta.updatedAt); - - return { - id: index, - ...recording.recording_meta, - content: recording.recording, - parsedDate - }; - } - return null; - }) - .filter(Boolean) - .sort((a, b) => b.parsedDate.getTime() - a.parsedDate.getTime()); - - setRecordings(parsedRows.map((recording) => recording.name)); - setRows(parsedRows); - } - } catch (error) { - console.error('Error fetching recordings:', error); - notify('error', t('recordingtable.notifications.fetch_error')); - } finally { - setIsFetching(false); - } - }, [setRecordings, notify, t]); + const rows = useMemo(() => { + if (!recordingsData) return []; - const handleNewRecording = useCallback(async () => { - const canCreateRecording = await canCreateBrowserInState("recording"); - - if (!canCreateRecording) { - const activeBrowserId = await getActiveBrowserId(); - if (activeBrowserId) { - setActiveBrowserId(activeBrowserId); - setWarningModalOpen(true); - } else { - notify('warning', t('recordingtable.notifications.browser_limit_warning')); + const parsedRows = recordingsData + .map((recording: any, index: number) => { + if (recording?.recording_meta) { + const parsedDate = parseDateString(recording.recording_meta.updatedAt); + + return { + id: index, + ...recording.recording_meta, + content: recording.recording, + parsedDate + }; } - } else { - setModalOpen(true); + return null; + }) + .filter(Boolean) + .sort((a, b) => b.parsedDate.getTime() - a.parsedDate.getTime()); + + return parsedRows; + }, [recordingsData]); + + useEffect(() => { + if (rows.length > 0) { + setRecordings(rows.map((recording) => recording.name)); } - }, []); + }, [rows, setRecordings]); + + + const handleNewRecording = useCallback(async () => { + navigate('/robots/create'); + }, [navigate]); const notifyRecordingTabsToClose = (browserId: string) => { const closeMessage = { @@ -331,7 +316,7 @@ export const RecordingsTable = ({ if (lastPair?.what) { if (Array.isArray(lastPair.what)) { - const gotoAction = lastPair.what.find(action => + const gotoAction = lastPair.what.find((action: any) => action && typeof action === 'object' && 'action' in action && action.action === "goto" ) as any; @@ -408,17 +393,12 @@ export const RecordingsTable = ({ window.sessionStorage.setItem('initialUrl', event.target.value); } - useEffect(() => { - fetchRecordings(); - }, [fetchRecordings]); - useEffect(() => { if (rerenderRobots) { - fetchRecordings().then(() => { - setRerenderRobots(false); - }); + refetch(); + setRerenderRobots(false); } - }, [rerenderRobots, fetchRecordings, setRerenderRobots]); + }, [rerenderRobots, setRerenderRobots, refetch]); function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = React.useState(value); @@ -468,12 +448,11 @@ export const RecordingsTable = ({ const success = await deleteRecordingFromStorage(id); if (success) { - setRows([]); notify('success', t('recordingtable.notifications.delete_success')); - fetchRecordings(); + refetch(); } } - }), [handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot, handleRetrainRobot, notify, t]); + }), [handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot, handleRetrainRobot, notify, t, refetch]); return ( diff --git a/src/components/robot/pages/RobotConfigPage.tsx b/src/components/robot/pages/RobotConfigPage.tsx index 902518eb0..53ae90abe 100644 --- a/src/components/robot/pages/RobotConfigPage.tsx +++ b/src/components/robot/pages/RobotConfigPage.tsx @@ -107,9 +107,8 @@ export const RobotConfigPage: React.FC = ({ )} = ({ )} ); -}; \ No newline at end of file +}; diff --git a/src/components/robot/pages/RobotCreate.tsx b/src/components/robot/pages/RobotCreate.tsx new file mode 100644 index 000000000..70058642c --- /dev/null +++ b/src/components/robot/pages/RobotCreate.tsx @@ -0,0 +1,349 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + Box, + Typography, + TextField, + Button, + FormControlLabel, + Checkbox, + IconButton, + Grid, + Card, + CircularProgress, + Container, + CardContent +} from '@mui/material'; +import { ArrowBack, PlayCircleOutline, Article } from '@mui/icons-material'; +import { useGlobalInfoStore } from '../../../context/globalInfo'; +import { canCreateBrowserInState, getActiveBrowserId, stopRecording } from '../../../api/recording'; +import { AuthContext } from '../../../context/auth'; +import { GenericModal } from '../../ui/GenericModal'; + + +const RobotCreate: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { setBrowserId, setRecordingUrl, notify, setRecordingId } = useGlobalInfoStore(); + + const [url, setUrl] = useState(''); + const [needsLogin, setNeedsLogin] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isWarningModalOpen, setWarningModalOpen] = useState(false); + const [activeBrowserId, setActiveBrowserId] = useState(''); + + const { state } = React.useContext(AuthContext); + const { user } = state; + + + const handleStartRecording = async () => { + if (!url.trim()) { + notify('error', 'Please enter a valid URL'); + return; + } + + setIsLoading(true); + + try { + const canCreateRecording = await canCreateBrowserInState("recording"); + + if (!canCreateRecording) { + const activeBrowser = await getActiveBrowserId(); + if (activeBrowser) { + setActiveBrowserId(activeBrowser); + setWarningModalOpen(true); + } else { + notify('warning', t('recordingtable.notifications.browser_limit_warning')); + } + setIsLoading(false); + return; + } + + setBrowserId('new-recording'); + setRecordingUrl(url); + + window.sessionStorage.setItem('browserId', 'new-recording'); + window.sessionStorage.setItem('recordingUrl', url); + window.sessionStorage.setItem('initialUrl', url); + window.sessionStorage.setItem('needsLogin', needsLogin.toString()); + + const sessionId = Date.now().toString(); + window.sessionStorage.setItem('recordingSessionId', sessionId); + + window.open(`/recording-setup?session=${sessionId}`, '_blank'); + window.sessionStorage.setItem('nextTabIsRecording', 'true'); + + // Reset loading state immediately after opening new tab + setIsLoading(false); + navigate('/robots'); + } catch (error) { + console.error('Error starting recording:', error); + notify('error', 'Failed to start recording. Please try again.'); + setIsLoading(false); + } + }; + + const handleDiscardAndCreate = async () => { + if (activeBrowserId) { + await stopRecording(activeBrowserId); + notify('warning', t('browser_recording.notifications.terminated')); + } + + setWarningModalOpen(false); + setIsLoading(false); + + // Continue with the original Recording logic + setBrowserId('new-recording'); + setRecordingUrl(url); + + window.sessionStorage.setItem('browserId', 'new-recording'); + window.sessionStorage.setItem('recordingUrl', url); + window.sessionStorage.setItem('initialUrl', url); + window.sessionStorage.setItem('needsLogin', needsLogin.toString()); + + const sessionId = Date.now().toString(); + window.sessionStorage.setItem('recordingSessionId', sessionId); + + window.open(`/recording-setup?session=${sessionId}`, '_blank'); + window.sessionStorage.setItem('nextTabIsRecording', 'true'); + + navigate('/robots'); + }; + + + + + + + return ( + + + + navigate('/robots')} + sx={{ + ml: -1, + mr: 1, + color: theme => theme.palette.text.primary, + backgroundColor: 'transparent !important', + '&:hover': { + backgroundColor: 'transparent !important', + }, + '&:active': { + backgroundColor: 'transparent !important', + }, + '&:focus': { + backgroundColor: 'transparent !important', + }, + '&:focus-visible': { + backgroundColor: 'transparent !important', + }, + }} + disableRipple + aria-label="Go back" + > + + + + New Data Extraction Robot + + + + + + {/* Logo (kept as original) */} + Maxun Logo + + {/* Origin URL Input */} + + setUrl(e.target.value)} + /> + + + {/* Checkbox */} + + setNeedsLogin(e.target.checked)} + color="primary" + /> + } + label="This website needs logging in." + /> + + + {/* Button */} + + + + + + + + + First time creating a robot? + + + Get help and learn how to use Maxun effectively. + + + + + {/* YouTube Tutorials */} + + window.open("https://www.youtube.com/@MaxunOSS/videos", "_blank")} + > + + theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.54)' : '', + }} + > + + + + + Video Tutorials + + + Watch step-by-step guides + + + + + + + {/* Documentation */} + + window.open("https://docs.maxun.dev", "_blank")} + > + + theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.54)' : '', + }} + > +
+ + + + Documentation + + + Explore detailed guides + + + + + + + + + + + { + setWarningModalOpen(false); + setIsLoading(false); + }} modalStyle={modalStyle}> +
+ {t('recordingtable.warning_modal.title')} + + {t('recordingtable.warning_modal.message')} + + + + + + +
+
+ + + + ); +}; + +export default RobotCreate; + +const modalStyle = { + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: '30%', + backgroundColor: 'background.paper', + p: 4, + height: 'fit-content', + display: 'block', + padding: '20px', +}; \ No newline at end of file diff --git a/src/components/robot/pages/RobotIntegrationPage.tsx b/src/components/robot/pages/RobotIntegrationPage.tsx index 9b941a3d4..3c8425901 100644 --- a/src/components/robot/pages/RobotIntegrationPage.tsx +++ b/src/components/robot/pages/RobotIntegrationPage.tsx @@ -694,11 +694,11 @@ export const RobotIntegrationPage = ({ return ( diff --git a/src/components/run/RunsTable.tsx b/src/components/run/RunsTable.tsx index cfcb05d49..3729b1caf 100644 --- a/src/components/run/RunsTable.tsx +++ b/src/components/run/RunsTable.tsx @@ -13,8 +13,7 @@ import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextFie import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import SearchIcon from '@mui/icons-material/Search'; import { useLocation, useNavigate } from 'react-router-dom'; -import { useGlobalInfoStore } from "../../context/globalInfo"; -import { getStoredRuns } from "../../api/storage"; +import { useGlobalInfoStore, useCachedRuns } from "../../context/globalInfo"; import { RunSettings } from "./RunSettings"; import { CollapsibleRow } from "./ColapsibleRow"; import { ArrowDownward, ArrowUpward, UnfoldMore } from '@mui/icons-material'; @@ -132,16 +131,14 @@ export const RunsTable: React.FC = ({ [t] ); - const [rows, setRows] = useState([]); - const [searchTerm, setSearchTerm] = useState(''); - const [isFetching, setIsFetching] = useState(true); + const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore(); + const { data: rows = [], isLoading: isFetching, error, refetch } = useCachedRuns(); + const [searchTerm, setSearchTerm] = useState(''); const [paginationStates, setPaginationStates] = useState({}); const [expandedRows, setExpandedRows] = useState>(new Set()); const [expandedAccordions, setExpandedAccordions] = useState>(new Set()); - const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore(); - const handleAccordionChange = useCallback((robotMetaId: string, isExpanded: boolean) => { setExpandedAccordions(prev => { const newSet = new Set(prev); @@ -278,47 +275,20 @@ export const RunsTable: React.FC = ({ debouncedSetSearch(event.target.value); }, [debouncedSearch]); - const fetchRuns = useCallback(async () => { - try { - const runs = await getStoredRuns(); - if (runs) { - const parsedRows: Data[] = runs.map((run: any, index: number) => ({ - id: index, - ...run, - })); - setRows(parsedRows); - } else { - notify('error', t('runstable.notifications.no_runs')); - } - } catch (error) { - notify('error', t('runstable.notifications.fetch_error')); - } finally { - setIsFetching(false); - } - }, [notify, t]); + // Handle rerender requests using cache invalidation useEffect(() => { - let mounted = true; - - if (rows.length === 0 || rerenderRuns) { - setIsFetching(true); - fetchRuns().then(() => { - if (mounted) { - setRerenderRuns(false); - } - }); + if (rerenderRuns) { + // Invalidate cache to force refetch + refetch(); + setRerenderRuns(false); } - - return () => { - mounted = false; - }; - }, [rerenderRuns, rows.length, setRerenderRuns, fetchRuns]); + }, [rerenderRuns, refetch, setRerenderRuns]); const handleDelete = useCallback(() => { - setRows([]); notify('success', t('runstable.notifications.delete_success')); - fetchRuns(); - }, [notify, t, fetchRuns]); + refetch(); + }, [notify, t, refetch]); // Filter rows based on search term const filteredRows = useMemo(() => { @@ -350,15 +320,15 @@ export const RunsTable: React.FC = ({ }, {} as Record); Object.keys(groupedData).forEach(robotId => { - groupedData[robotId].sort((a, b) => + groupedData[robotId].sort((a: any, b: any) => parseDateString(b.startedAt).getTime() - parseDateString(a.startedAt).getTime() ); }); const robotEntries = Object.entries(groupedData).map(([robotId, runs]) => ({ robotId, - runs, - latestRunDate: parseDateString(runs[0].startedAt).getTime() + runs: runs as Data[], + latestRunDate: parseDateString((runs as Data[])[0].startedAt).getTime() })); robotEntries.sort((a, b) => b.latestRunDate - a.latestRunDate); @@ -405,7 +375,7 @@ export const RunsTable: React.FC = ({ urlRunId={urlRunId} /> )); - }, [getPaginationState, accordionSortConfigs, expandedRows, handleRowExpand, handleDelete, currentInterpretationLog, abortRunHandler, runningRecordingName, urlRunId]); + }, [paginationStates, runId, runningRecordingName, currentInterpretationLog, abortRunHandler, handleDelete, accordionSortConfigs]); const renderSortIcon = useCallback((column: Column, robotMetaId: string) => { const sortConfig = accordionSortConfigs[robotMetaId]; diff --git a/src/context/globalInfo.tsx b/src/context/globalInfo.tsx index 6f3cf8cd6..a9b20bfa7 100644 --- a/src/context/globalInfo.tsx +++ b/src/context/globalInfo.tsx @@ -1,6 +1,24 @@ import React, { createContext, useContext, useState } from "react"; import { AlertSnackbarProps } from "../components/ui/AlertSnackbar"; import { WhereWhatPair } from "maxun-core"; +import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from '@tanstack/react-query'; +import { getStoredRuns, getStoredRecordings } from "../api/storage"; + +const createDataCacheClient = () => new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + retry: 2, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + } + } +}); + +const dataCacheKeys = { + runs: ['cached-runs'] as const, + recordings: ['cached-recordings'] as const, +} as const; interface RobotMeta { name: string; @@ -164,6 +182,65 @@ const globalInfoContext = createContext(globalInfoStore as GlobalInf export const useGlobalInfoStore = () => useContext(globalInfoContext); +export const useCachedRuns = () => { + return useQuery({ + queryKey: dataCacheKeys.runs, + queryFn: async () => { + const runs = await getStoredRuns(); + if (!runs) throw new Error('Failed to fetch runs data'); + return runs.map((run: any, index: number) => ({ id: index, ...run })); + }, + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + retry: 2, + }); +}; + +export const useCacheInvalidation = () => { + const queryClient = useQueryClient(); + + const invalidateRuns = () => { + queryClient.invalidateQueries({ queryKey: dataCacheKeys.runs }); + }; + + const invalidateRecordings = () => { + queryClient.invalidateQueries({ queryKey: dataCacheKeys.recordings }); + }; + + const addOptimisticRun = (newRun: any) => { + queryClient.setQueryData(dataCacheKeys.runs, (oldData: any) => { + if (!oldData) return [{ id: 0, ...newRun }]; + return [{ id: oldData.length, ...newRun }, ...oldData]; + }); + }; + + const invalidateAllCache = () => { + invalidateRuns(); + invalidateRecordings(); + }; + + return { + invalidateRuns, + invalidateRecordings, + addOptimisticRun, + invalidateAllCache + }; +}; + +export const useCachedRecordings = () => { + return useQuery({ + queryKey: dataCacheKeys.recordings, + queryFn: async () => { + const recordings = await getStoredRecordings(); + if (!recordings) throw new Error('Failed to fetch recordings data'); + return recordings; + }, + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + retry: 2, + }); +}; + export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { const [browserId, setBrowserId] = useState(globalInfoStore.browserId); const [lastAction, setLastAction] = useState(globalInfoStore.lastAction); @@ -172,7 +249,29 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { const [rerenderRuns, setRerenderRuns] = useState(globalInfoStore.rerenderRuns); const [rerenderRobots, setRerenderRobots] = useState(globalInfoStore.rerenderRobots); const [recordingLength, setRecordingLength] = useState(globalInfoStore.recordingLength); - const [recordingId, setRecordingId] = useState(globalInfoStore.recordingId); + // const [recordingId, setRecordingId] = useState(globalInfoStore.recordingId); + const [recordingId, setRecordingId] = useState(() => { + try { + const stored = sessionStorage.getItem('recordingId'); + return stored ? JSON.parse(stored) : globalInfoStore.recordingId; + } catch { + return globalInfoStore.recordingId; + } + }); + + // Create a wrapped setter that persists to sessionStorage + const setPersistedRecordingId = (newRecordingId: string | null) => { + setRecordingId(newRecordingId); + try { + if (newRecordingId) { + sessionStorage.setItem('recordingId', JSON.stringify(newRecordingId)); + } else { + sessionStorage.removeItem('recordingId'); + } + } catch (error) { + console.warn('Failed to persist recordingId to sessionStorage:', error); + } + }; const [retrainRobotId, setRetrainRobotId] = useState(globalInfoStore.retrainRobotId); const [recordingName, setRecordingName] = useState(globalInfoStore.recordingName); const [isLogin, setIsLogin] = useState(globalInfoStore.isLogin); @@ -221,9 +320,12 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { } } + const [dataCacheClient] = useState(() => createDataCacheClient()); + return ( - + { recordingLength, setRecordingLength, recordingId, - setRecordingId, + setRecordingId: setPersistedRecordingId, retrainRobotId, setRetrainRobotId, recordingName, @@ -266,9 +368,10 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { currentSnapshot, setCurrentSnapshot, updateDOMMode, - }} - > - {children} - + }} + > + {children} + + ); }; diff --git a/src/context/socket.tsx b/src/context/socket.tsx index 05a8c989c..ea5b049af 100644 --- a/src/context/socket.tsx +++ b/src/context/socket.tsx @@ -9,7 +9,7 @@ interface SocketState { queueSocket: Socket | null; id: string; setId: (id: string) => void; - connectToQueueSocket: (userId: string, onRunCompleted?: (data: any) => void) => void; + connectToQueueSocket: (userId: string, onRunCompleted?: (data: any) => void, onRunStarted?: (data: any) => void, onRunRecovered?: (data: any) => void, onRunScheduled?: (data: any) => void) => void; disconnectQueueSocket: () => void; }; @@ -29,6 +29,9 @@ export const SocketProvider = ({ children }: { children: JSX.Element }) => { const [queueSocket, setQueueSocket] = useState(socketStore.queueSocket); const [id, setActiveId] = useState(socketStore.id); const runCompletedCallbackRef = useRef<((data: any) => void) | null>(null); + const runStartedCallbackRef = useRef<((data: any) => void) | null>(null); + const runRecoveredCallbackRef = useRef<((data: any) => void) | null>(null); + const runScheduledCallbackRef = useRef<((data: any) => void) | null>(null); const setId = useCallback((id: string) => { // the socket client connection is recomputed whenever id changes -> the new browser has been initialized @@ -45,8 +48,11 @@ export const SocketProvider = ({ children }: { children: JSX.Element }) => { setActiveId(id); }, [setSocket]); - const connectToQueueSocket = useCallback((userId: string, onRunCompleted?: (data: any) => void) => { + const connectToQueueSocket = useCallback((userId: string, onRunCompleted?: (data: any) => void, onRunStarted?: (data: any) => void, onRunRecovered?: (data: any) => void, onRunScheduled?: (data: any) => void) => { runCompletedCallbackRef.current = onRunCompleted || null; + runStartedCallbackRef.current = onRunStarted || null; + runRecoveredCallbackRef.current = onRunRecovered || null; + runScheduledCallbackRef.current = onRunScheduled || null; const newQueueSocket = io(`${SERVER_ENDPOINT}/queued-run`, { transports: ["websocket"], @@ -69,6 +75,27 @@ export const SocketProvider = ({ children }: { children: JSX.Element }) => { } }); + newQueueSocket.on('run-started', (startedData) => { + console.log('Run started event received:', startedData); + if (runStartedCallbackRef.current) { + runStartedCallbackRef.current(startedData); + } + }); + + newQueueSocket.on('run-recovered', (recoveredData) => { + console.log('Run recovered event received:', recoveredData); + if (runRecoveredCallbackRef.current) { + runRecoveredCallbackRef.current(recoveredData); + } + }); + + newQueueSocket.on('run-scheduled', (scheduledData) => { + console.log('Run scheduled event received:', scheduledData); + if (runScheduledCallbackRef.current) { + runScheduledCallbackRef.current(scheduledData); + } + }); + setQueueSocket(currentSocket => { if (currentSocket) { currentSocket.disconnect(); @@ -88,7 +115,10 @@ export const SocketProvider = ({ children }: { children: JSX.Element }) => { }); socketStore.queueSocket = null; + runStartedCallbackRef.current = null; runCompletedCallbackRef.current = null; + runRecoveredCallbackRef.current = null; + runScheduledCallbackRef.current = null; }, []); // Cleanup on unmount diff --git a/src/helpers/clientSelectorGenerator.ts b/src/helpers/clientSelectorGenerator.ts index 184a199b8..385aa8ecb 100644 --- a/src/helpers/clientSelectorGenerator.ts +++ b/src/helpers/clientSelectorGenerator.ts @@ -413,7 +413,7 @@ class ClientSelectorGenerator { // Only switch to dialog-focused analysis if dialogs have substantial content if (dialogContentElements.length > 5) { - allElements = dialogContentElements; + allElements = [...dialogContentElements, ...allElements]; } } } @@ -957,21 +957,19 @@ class ClientSelectorGenerator { ); if (groupedElementsAtPoint.length > 0) { - const hasAnchorTag = groupedElementsAtPoint.some( - (el) => el.tagName === "A" + let filteredElements = this.filterParentChildGroupedElements( + groupedElementsAtPoint ); - let filteredElements = groupedElementsAtPoint; - - if (hasAnchorTag) { - // Apply parent-child filtering when anchor tags are present - filteredElements = this.filterParentChildGroupedElements( - groupedElementsAtPoint - ); - } - // Sort by DOM depth (deeper elements first for more specificity) filteredElements.sort((a, b) => { + const aDialog = this.isDialogElement(a) ? 1 : 0; + const bDialog = this.isDialogElement(b) ? 1 : 0; + + if (aDialog !== bDialog) { + return bDialog - aDialog; + } + const aDepth = this.getElementDepth(a); const bDepth = this.getElementDepth(b); return bDepth - aDepth; @@ -4046,69 +4044,10 @@ class ClientSelectorGenerator { } /** - * Find dialog element in the elements array + * Check if an element is a dialog */ - private findDialogElement(elements: HTMLElement[]): HTMLElement | null { - let dialogElement = elements.find((el) => el.getAttribute("role") === "dialog"); - - if (!dialogElement) { - dialogElement = elements.find((el) => el.tagName.toLowerCase() === "dialog"); - } - - if (!dialogElement) { - dialogElement = elements.find((el) => { - const classList = el.classList.toString().toLowerCase(); - const id = (el.id || "").toLowerCase(); - - return ( - classList.includes("modal") || - classList.includes("dialog") || - classList.includes("popup") || - classList.includes("overlay") || - id.includes("modal") || - id.includes("dialog") || - id.includes("popup") - ); - }); - } - - return dialogElement || null; - } - - /** - * Find the deepest element within a dialog - */ - private findDeepestInDialog( - dialogElements: HTMLElement[], - dialogElement: HTMLElement - ): HTMLElement | null { - if (!dialogElements.length) return null; - if (dialogElements.length === 1) return dialogElements[0]; - - let deepestElement = dialogElements[0]; - let maxDepth = 0; - - for (const element of dialogElements) { - let depth = 0; - let current = element; - - // Calculate depth within the dialog context - while ( - current && - current.parentElement && - current !== dialogElement.parentElement - ) { - depth++; - current = current.parentElement; - } - - if (depth > maxDepth) { - maxDepth = depth; - deepestElement = element; - } - } - - return deepestElement; + private isDialogElement(el: HTMLElement): boolean { + return !!el.closest('dialog, [role="dialog"]'); } /** @@ -4119,14 +4058,8 @@ class ClientSelectorGenerator { const allElements = Array.from(doc.querySelectorAll("*")) as HTMLElement[]; for (const element of allElements) { - if (element.getAttribute("role") === "dialog") { + if (this.isDialogElement(element)) { dialogElements.push(element); - continue; - } - - if (element.tagName.toLowerCase() === "dialog") { - dialogElements.push(element); - continue; } } diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index c71478b32..861781172 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -6,7 +6,7 @@ import { Recordings } from "../components/robot/Recordings"; import { Runs } from "../components/run/Runs"; import ProxyForm from '../components/proxy/ProxyForm'; import ApiKey from '../components/api/ApiKey'; -import { useGlobalInfoStore } from "../context/globalInfo"; +import { useGlobalInfoStore, useCacheInvalidation } from "../context/globalInfo"; import { createAndRunRecording, createRunForStoredRecording, CreateRunResponseWithQueue, interpretStoredRecording, notifyAboutAbort, scheduleStoredRecording } from "../api/storage"; import { io, Socket } from "socket.io-client"; import { stopRecording } from "../api/recording"; @@ -50,6 +50,7 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) let aborted = false; const { notify, setRerenderRuns, setRecordingId } = useGlobalInfoStore(); + const { invalidateRuns, addOptimisticRun } = useCacheInvalidation(); const navigate = useNavigate(); const { state } = useContext(AuthContext); @@ -66,12 +67,14 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) if (!response.success) { notify('error', t('main_page.notifications.abort_failed', { name: robotName })); setRerenderRuns(true); + invalidateRuns(); return; } if (response.isQueued) { setRerenderRuns(true); - + invalidateRuns(); + notify('success', t('main_page.notifications.abort_success', { name: robotName })); setQueuedRuns(prev => { @@ -92,6 +95,7 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) if (abortData.runId === runId) { notify('success', t('main_page.notifications.abort_success', { name: abortData.robotName || robotName })); setRerenderRuns(true); + invalidateRuns(); abortSocket.disconnect(); } }); @@ -100,6 +104,7 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) console.log('Abort socket connection error:', error); notify('error', t('main_page.notifications.abort_failed', { name: robotName })); setRerenderRuns(true); + invalidateRuns(); abortSocket.disconnect(); }); }); @@ -125,6 +130,7 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) setRunningRecordingName(''); setCurrentInterpretationLog(''); setRerenderRuns(true); + invalidateRuns(); }) }, [runningRecordingName, aborted, currentInterpretationLog, notify, setRerenderRuns]); @@ -134,9 +140,25 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) }, [currentInterpretationLog]) const handleRunRecording = useCallback((settings: RunSettings) => { + // Add optimistic run to cache immediately + const optimisticRun = { + id: runningRecordingId, + runId: `temp-${Date.now()}`, // Temporary ID until we get the real one + status: 'running', + name: runningRecordingName, + startedAt: new Date().toISOString(), + finishedAt: '', + robotMetaId: runningRecordingId, + log: 'Starting...', + isOptimistic: true + }; + + addOptimisticRun(optimisticRun); + createAndRunRecording(runningRecordingId, settings).then((response: CreateRunResponseWithQueue) => { + invalidateRuns(); const { browserId, runId, robotMetaId, queued } = response; - + setIds({ browserId, runId, robotMetaId }); navigate(`/runs/${robotMetaId}/run/${runId}`); @@ -153,9 +175,8 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) socket.on('debugMessage', debugMessageHandler); socket.on('run-completed', (data) => { - setRunningRecordingName(''); - setCurrentInterpretationLog(''); setRerenderRuns(true); + invalidateRuns(); const robotName = data.robotName; @@ -193,7 +214,13 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) socket.off('connect_error'); socket.off('disconnect'); } - }, [runningRecordingName, sockets, ids, debugMessageHandler, user?.id, t, notify, setRerenderRuns, setQueuedRuns, navigate, setContent, setIds]); + }, [runningRecordingName, sockets, ids, debugMessageHandler, user?.id, t, notify, setRerenderRuns, setQueuedRuns, navigate, setContent, setIds, invalidateRuns, addOptimisticRun, runningRecordingId]); + + useEffect(() => { + return () => { + queuedRuns.clear(); + }; + }, []); const handleScheduleRecording = async (settings: ScheduleSettings) => { const { message, runId }: ScheduleRunResponse = await scheduleStoredRecording(runningRecordingId, settings); @@ -207,8 +234,17 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) useEffect(() => { if (user?.id) { + const handleRunStarted = (startedData: any) => { + setRerenderRuns(true); + invalidateRuns(); + + const robotName = startedData.robotName || 'Unknown Robot'; + notify('info', t('main_page.notifications.run_started', { name: robotName })); + }; + const handleRunCompleted = (completionData: any) => { setRerenderRuns(true); + invalidateRuns(); // Invalidate cache to show completed run status if (queuedRuns.has(completionData.runId)) { setQueuedRuns(prev => { @@ -226,10 +262,32 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) notify('error', t('main_page.notifications.interpretation_failed', { name: robotName })); } }; + + const handleRunRecovered = (recoveredData: any) => { + setRerenderRuns(true); + invalidateRuns(); + + if (queuedRuns.has(recoveredData.runId)) { + setQueuedRuns(prev => { + const newSet = new Set(prev); + newSet.delete(recoveredData.runId); + return newSet; + }); + } + + const robotName = recoveredData.robotName || 'Unknown Robot'; + notify('error', t('main_page.notifications.interpretation_failed', { name: robotName })); + }; + + const handleRunScheduled = (scheduledData: any) => { + setRerenderRuns(true); + invalidateRuns(); + }; - connectToQueueSocket(user.id, handleRunCompleted); + connectToQueueSocket(user.id, handleRunCompleted, handleRunStarted, handleRunRecovered, handleRunScheduled); return () => { + console.log('Disconnecting persistent queue socket for user:', user.id); disconnectQueueSocket(); }; } diff --git a/src/pages/PageWrapper.tsx b/src/pages/PageWrapper.tsx index e7361b2f5..e257367bf 100644 --- a/src/pages/PageWrapper.tsx +++ b/src/pages/PageWrapper.tsx @@ -12,6 +12,7 @@ import Register from './Register'; import UserRoute from '../routes/userRoute'; import { Routes, Route, useNavigate, Navigate } from 'react-router-dom'; import { NotFoundPage } from '../components/dashboard/NotFound'; +import RobotCreate from '../components/robot/pages/RobotCreate'; export const PageWrapper = () => { const [open, setOpen] = useState(false); @@ -94,6 +95,7 @@ export const PageWrapper = () => { }> } /> + } /> } /> } /> } />