@@ -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"]
|
||||
@@ -23,7 +23,7 @@ chromium.use(stealthPlugin());
|
||||
export const router = Router();
|
||||
|
||||
export const processWorkflowActions = async (workflow: any[], checkLimit: boolean = false): Promise<any[]> => {
|
||||
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<string>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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 === '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 (!action?.action || !action?.args?.[0]) {
|
||||
newWhat.push(action);
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
...step,
|
||||
what: newWhat
|
||||
};
|
||||
const selector = action.args[0];
|
||||
const credential = credentials[selector];
|
||||
|
||||
if (!credential) {
|
||||
newWhat.push(action);
|
||||
continue;
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
@@ -444,7 +444,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
||||
ref={logEndRef} />
|
||||
</div>
|
||||
</Box>
|
||||
{row.status === 'running' ? <Button
|
||||
{row.status === 'running' || row.status === 'queued' ? <Button
|
||||
color="error"
|
||||
onClick={abortRunHandler}
|
||||
>
|
||||
@@ -598,4 +598,4 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
||||
</TabContext>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
|
||||
|
||||
const abortRunHandler = (runId: string, robotName: string, browserId: string) => {
|
||||
notify('info', t('main_page.notifications.abort_initiated', { name: robotName }));
|
||||
|
||||
|
||||
aborted = true;
|
||||
|
||||
notifyAboutAbort(runId).then(async (response) => {
|
||||
|
||||
Reference in New Issue
Block a user