diff --git a/server/Dockerfile b/server/Dockerfile deleted file mode 100644 index 003104189..000000000 --- a/server/Dockerfile +++ /dev/null @@ -1,51 +0,0 @@ -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/playwright:v1.46.0-noble - -# Set working directory -WORKDIR /app - -# Install node dependencies -COPY package*.json ./ -COPY src ./src -COPY public ./public -COPY server ./server -COPY tsconfig.json ./ -COPY server/tsconfig.json ./server/ -# COPY server/start.sh ./ - -# Install dependencies -RUN npm install --legacy-peer-deps - -# Install Playwright browsers and dependencies -RUN npx playwright install --with-deps chromium - -# Create the Chromium data directory with necessary permissions -RUN mkdir -p /tmp/chromium-data-dir && \ - chmod -R 777 /tmp/chromium-data-dir - -# Install dependencies -RUN apt-get update && apt-get install -y \ - libgbm1 \ - libnss3 \ - libatk1.0-0 \ - libatk-bridge2.0-0 \ - libdrm2 \ - libxkbcommon0 \ - libglib2.0-0 \ - libdbus-1-3 \ - libx11-xcb1 \ - libxcb1 \ - libxcomposite1 \ - libxcursor1 \ - libxdamage1 \ - libxext6 \ - libxi6 \ - libxtst6 \ - && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix - -# Expose backend port -EXPOSE ${BACKEND_PORT:-8080} - -# Run migrations & start backend using start script -#CMD ["npm", "run", "server"] -CMD ["sh", "-c", "npm run migrate && npm run server"] \ No newline at end of file diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index 9faac92a4..74bea6ffa 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -23,7 +23,7 @@ chromium.use(stealthPlugin()); export const router = Router(); export const processWorkflowActions = async (workflow: any[], checkLimit: boolean = false): Promise => { - const processedWorkflow = JSON.parse(JSON.stringify(workflow)); + const processedWorkflow = JSON.parse(JSON.stringify(workflow)); processedWorkflow.forEach((pair: any) => { pair.what.forEach((action: any) => { @@ -108,52 +108,52 @@ router.get('/recordings/:id', requireSignIn, async (req, res) => { router.get(('/recordings/:id/runs'), requireSignIn, async (req, res) => { try { const runs = await Run.findAll({ - where: { - robotMetaId: req.params.id - }, - raw: true + where: { + robotMetaId: req.params.id + }, + raw: true }); const formattedRuns = runs.map(formatRunResponse); const response = { - statusCode: 200, - messageCode: "success", - runs: { + statusCode: 200, + messageCode: "success", + runs: { totalCount: formattedRuns.length, items: formattedRuns, - }, + }, }; res.status(200).json(response); -} catch (error) { + } catch (error) { console.error("Error fetching runs:", error); res.status(500).json({ - statusCode: 500, - messageCode: "error", - message: "Failed to retrieve runs", + statusCode: 500, + messageCode: "error", + message: "Failed to retrieve runs", }); -} + } }) function formatRunResponse(run: any) { const formattedRun = { - id: run.id, - status: run.status, - name: run.name, - robotId: run.robotMetaId, // Renaming robotMetaId to robotId - startedAt: run.startedAt, - finishedAt: run.finishedAt, - runId: run.runId, - runByUserId: run.runByUserId, - runByScheduleId: run.runByScheduleId, - runByAPI: run.runByAPI, - data: {}, - screenshot: null, + id: run.id, + status: run.status, + name: run.name, + robotId: run.robotMetaId, // Renaming robotMetaId to robotId + startedAt: run.startedAt, + finishedAt: run.finishedAt, + runId: run.runId, + runByUserId: run.runByUserId, + runByScheduleId: run.runByScheduleId, + runByAPI: run.runByAPI, + data: {}, + screenshot: null, }; if (run.serializableOutput && run.serializableOutput['item-0']) { - formattedRun.data = run.serializableOutput['item-0']; + formattedRun.data = run.serializableOutput['item-0']; } else if (run.binaryOutput && run.binaryOutput['item-0']) { - formattedRun.screenshot = run.binaryOutput['item-0']; + formattedRun.screenshot = run.binaryOutput['item-0']; } return formattedRun; @@ -170,81 +170,81 @@ interface Credentials { function handleWorkflowActions(workflow: any[], credentials: Credentials) { return workflow.map(step => { - if (!step.what) return step; + if (!step.what) return step; - const newWhat: any[] = []; - const processedSelectors = new Set(); - - for (let i = 0; i < step.what.length; i++) { - const action = step.what[i]; - - if (!action?.action || !action?.args?.[0]) { - newWhat.push(action); - continue; - } + const newWhat: any[] = []; + const processedSelectors = new Set(); - const selector = action.args[0]; - const credential = credentials[selector]; + for (let i = 0; i < step.what.length; i++) { + const action = step.what[i]; - if (!credential) { - newWhat.push(action); - continue; - } + if (!action?.action || !action?.args?.[0]) { + newWhat.push(action); + continue; + } + + const selector = action.args[0]; + const credential = credentials[selector]; + + if (!credential) { + newWhat.push(action); + continue; + } + + if (action.action === 'click') { + newWhat.push(action); - if (action.action === 'click') { - newWhat.push(action); - - if (!processedSelectors.has(selector) && - i + 1 < step.what.length && - (step.what[i + 1].action === 'type' || step.what[i + 1].action === 'press')) { - - newWhat.push({ - action: 'type', - args: [selector, encrypt(credential.value), credential.type] - }); - - newWhat.push({ - action: 'waitForLoadState', - args: ['networkidle'] - }); - - processedSelectors.add(selector); - - while (i + 1 < step.what.length && - (step.what[i + 1].action === 'type' || - step.what[i + 1].action === 'press' || - step.what[i + 1].action === 'waitForLoadState')) { - i++; - } - } - } else if ((action.action === 'type' || action.action === 'press') && - !processedSelectors.has(selector)) { - newWhat.push({ - action: 'type', - args: [selector, encrypt(credential.value), credential.type] - }); - - newWhat.push({ - action: 'waitForLoadState', - args: ['networkidle'] - }); - - processedSelectors.add(selector); - - // Skip subsequent type/press/waitForLoadState actions for this selector - while (i + 1 < step.what.length && - (step.what[i + 1].action === 'type' || - step.what[i + 1].action === 'press' || - step.what[i + 1].action === 'waitForLoadState')) { - i++; - } + if (!processedSelectors.has(selector) && + i + 1 < step.what.length && + (step.what[i + 1].action === 'type' || step.what[i + 1].action === 'press')) { + + newWhat.push({ + action: 'type', + args: [selector, encrypt(credential.value), credential.type] + }); + + newWhat.push({ + action: 'waitForLoadState', + args: ['networkidle'] + }); + + processedSelectors.add(selector); + + while (i + 1 < step.what.length && + (step.what[i + 1].action === 'type' || + step.what[i + 1].action === 'press' || + step.what[i + 1].action === 'waitForLoadState')) { + i++; } + } + } else if ((action.action === 'type' || action.action === 'press') && + !processedSelectors.has(selector)) { + newWhat.push({ + action: 'type', + args: [selector, encrypt(credential.value), credential.type] + }); + + newWhat.push({ + action: 'waitForLoadState', + args: ['networkidle'] + }); + + processedSelectors.add(selector); + + // Skip subsequent type/press/waitForLoadState actions for this selector + while (i + 1 < step.what.length && + (step.what[i + 1].action === 'type' || + step.what[i + 1].action === 'press' || + step.what[i + 1].action === 'waitForLoadState')) { + i++; + } } + } - return { - ...step, - what: newWhat - }; + return { + ...step, + what: newWhat + }; }); } @@ -275,7 +275,7 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r if (targetUrl) { const updatedWorkflow = [...robot.recording.workflow]; - + for (let i = updatedWorkflow.length - 1; i >= 0; i--) { const step = updatedWorkflow[i]; for (let j = 0; j < step.what.length; j++) { @@ -286,7 +286,7 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r if (step.where?.url && step.where.url !== "about:blank") { step.where.url = targetUrl; } - + robot.set('recording', { ...robot.recording, workflow: updatedWorkflow }); robot.changed('recording', true); i = -1; @@ -307,16 +307,16 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r if (limits && Array.isArray(limits) && limits.length > 0) { for (const limitInfo of limits) { const { pairIndex, actionIndex, argIndex, limit } = limitInfo; - + const pair = workflow[pairIndex]; if (!pair || !pair.what) continue; - + const action = pair.what[actionIndex]; if (!action || !action.args) continue; - + const arg = action.args[argIndex]; if (!arg || typeof arg !== 'object') continue; - + (arg as { limit: number }).limit = limit; } } @@ -384,7 +384,7 @@ router.post('/recordings/:id/duplicate', requireSignIn, async (req: Authenticate step.what.forEach((action) => { if (action.action === "goto" && action.args?.length) { - action.args[0] = targetUrl; + action.args[0] = targetUrl; } }); @@ -394,22 +394,22 @@ router.post('/recordings/:id/duplicate', requireSignIn, async (req: Authenticate const currentTimestamp = new Date().toLocaleString(); const newRobot = await Robot.create({ - id: uuid(), - userId: originalRobot.userId, + id: uuid(), + userId: originalRobot.userId, recording_meta: { ...originalRobot.recording_meta, id: uuid(), name: `${originalRobot.recording_meta.name} (${lastWord})`, - createdAt: currentTimestamp, - updatedAt: currentTimestamp, - }, - recording: { ...originalRobot.recording, workflow }, - google_sheet_email: null, + createdAt: currentTimestamp, + updatedAt: currentTimestamp, + }, + recording: { ...originalRobot.recording, workflow }, + google_sheet_email: null, google_sheet_name: null, google_sheet_id: null, google_access_token: null, google_refresh_token: null, - schedule: null, + schedule: null, }); logger.log('info', `Robot with ID ${id} duplicated successfully as ${newRobot.id}.`); @@ -630,6 +630,43 @@ router.put('/runs/:id', requireSignIn, async (req: AuthenticatedRequest, res) => robotMetaId: recording.recording_meta.id, queued: true }); + } else { + const browserId = getActiveBrowserIdByState(req.user.id, "run") + + if (browserId) { + // User has reached the browser limit, queue the run + try { + // Create the run record with 'queued' status + await Run.create({ + status: 'queued', + name: recording.recording_meta.name, + robotId: recording.id, + robotMetaId: recording.recording_meta.id, + startedAt: new Date().toLocaleString(), + finishedAt: '', + browserId: browserId, // Random will be updated later + interpreterSettings: req.body, + log: 'Run queued - waiting for available browser slot', + runId, + runByUserId: req.user.id, + serializableOutput: {}, + binaryOutput: {}, + }); + + return res.send({ + browserId: browserId, + runId: runId, + robotMetaId: recording.recording_meta.id, + queued: true, + }); + } catch (queueError: any) { + logger.log('error', `Failed to queue run job: ${queueError.message}`); + return res.status(503).send({ error: 'Unable to queue run, please try again later' }); + } + } else { + logger.log('info', "Browser id does not exist"); + return res.send(''); + } } } catch (e) { const { message } = e as Error; @@ -690,17 +727,17 @@ router.post('/runs/run/:id', requireSignIn, async (req: AuthenticatedRequest, re // Queue the execution job await pgBoss.createQueue(userQueueName); - + const jobId = await pgBoss.send(userQueueName, { userId: req.user.id, runId: req.params.id, browserId: plainRun.browserId }); - + logger.log('info', `Queued run execution job with ID: ${jobId} for run: ${req.params.id}`); } catch (queueError: any) { logger.log('error', `Failed to queue run execution`); - + } } catch (e) { const { message } = e as Error; @@ -948,10 +985,30 @@ router.post('/runs/abort/:id', requireSignIn, async (req: AuthenticatedRequest, isQueued: true }); } - + + if (!['running', 'queued'].includes(run.status)) { + return res.status(400).send({ + error: `Cannot abort run with status: ${run.status}` + }); + } + + await run.update({ + status: 'aborting' + }); + + if (run.status === 'queued') { + await run.update({ + status: 'aborted', + finishedAt: new Date().toLocaleString(), + log: 'Run aborted while queued' + }); + + return res.send({ success: true, message: 'Queued run aborted' }); + } + const userQueueName = `abort-run-user-${req.user.id}`; await pgBoss.createQueue(userQueueName); - + const jobId = await pgBoss.send(userQueueName, { userId: req.user.id, runId: req.params.id @@ -965,6 +1022,7 @@ router.post('/runs/abort/:id', requireSignIn, async (req: AuthenticatedRequest, jobId, isQueued: false }); + } catch (e) { const { message } = e as Error; logger.log('error', `Error aborting run ${req.params.id}: ${message}`); diff --git a/src/components/run/RunContent.tsx b/src/components/run/RunContent.tsx index 5565f451c..25eb5063e 100644 --- a/src/components/run/RunContent.tsx +++ b/src/components/run/RunContent.tsx @@ -444,7 +444,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe ref={logEndRef} /> - {row.status === 'running' ?